1. 引言
在算法设计与分析中,贪心算法(Greedy Algorithm)和动态规划(Dynamic Programming)是两种极为重要的求解策略。它们都用于解决最优化问题,但设计思路和适用条件截然不同。贪心算法通过每一步做出当前看起来最优的选择来逼近全局最优解,而动态规划则通过分解子问题并记录子问题结果来保证全局最优。理解两者的区别,是算法学习中的关键一步。
贪心算法常用于活动选择、哈夫曼编码、最小生成树等场景,其特点是实现简单、运行高效,但并非所有问题都适用。动态规划则适用于具有最优子结构和重叠子问题特性的问题,如最长公共子序列、背包问题、矩阵链乘法等,虽然实现相对复杂,但能保证得到全局最优解。
本文将以C语言为载体,深入剖析两种算法的核心思想、实现方式与适用场景,并通过完整可编译的代码示例帮助读者掌握它们的实际应用。
2. 贪心算法详解
2.1 核心思想与基本原理
贪心算法的核心思想是:在求解问题的每一步,都做出当前状态下的最优选择(即局部最优解),并期望通过一系列局部最优选择最终得到全局最优解。它不回溯、不重新考虑之前的选择,因此实现简单、效率高。
贪心算法的基本步骤通常包括:
- 建立数学模型:将问题抽象为数学模型,明确目标函数和约束条件。
- 分解子问题:将原问题分解为若干子问题,每个子问题对应一个决策步骤。
- 贪心选择:在每一步中,根据某种贪心策略做出当前最优选择。
- 迭代求解:重复贪心选择,直到得到完整解。
2.2 适用问题的特征与判断标准
并非所有最优化问题都能用贪心算法求解。一个问题适合用贪心算法,通常需要满足以下两个关键性质:
- 贪心选择性质:通过局部最优选择,能够推导出全局最优解。即每一步的贪心选择都包含在某个全局最优解中。
- 最优子结构性质:问题的最优解包含其子问题的最优解。即全局最优解可以由子问题的最优解组合而成。
判断一个问题是否适合贪心算法,可以从以下角度思考:
- 问题是否具有明显的"贪心策略"?例如按某种属性排序后依次选择。
- 局部最优选择是否不会影响后续选择的可行性?
- 能否通过反例证明贪心策略失效?如果找不到反例,则贪心算法可能适用。
2.3 经典算法案例
2.3.1 活动选择问题
活动选择问题是贪心算法的经典入门案例。假设有一个会议室,需要安排若干活动,每个活动有开始时间和结束时间,目标是选择尽可能多的互不冲突的活动。
贪心策略:每次选择结束时间最早且与已选活动不冲突的活动。因为结束时间越早,留给后续活动的时间就越多。
c
#include <stdio.h>
#include <stdlib.h>
// 活动结构体
typedef struct {
int start; // 开始时间
int end; // 结束时间
} Activity;
// 按结束时间升序排序的比较函数
int compare(const void *a, const void *b) {
Activity *actA = (Activity *)a;
Activity *actB = (Activity *)b;
return actA->end - actB->end;
}
// 贪心求解活动选择问题
// 返回选中的活动数量,selected数组存储选中活动的下标
int activitySelection(Activity activities[], int n, int selected[]) {
// 先按结束时间升序排序
qsort(activities, n, sizeof(Activity), compare);
int count = 0;
int lastEnd = -1; // 上一个选中活动的结束时间
for (int i = 0; i < n; i++) {
// 如果当前活动开始时间不早于上一个活动的结束时间,则选中
if (activities[i].start >= lastEnd) {
selected[count++] = i;
lastEnd = activities[i].end;
}
}
return count;
}
int main() {
// 示例输入:6个活动(开始时间,结束时间)
Activity activities[] = {
{1, 4}, {3, 5}, {0, 6}, {5, 7}, {3, 8}, {5, 9}
};
int n = sizeof(activities) / sizeof(activities[0]);
int selected[100];
int count = activitySelection(activities, n, selected);
printf("选中的活动数量: %d\n", count);
printf("选中的活动(按结束时间排序后):\n");
for (int i = 0; i < count; i++) {
int idx = selected[i];
printf("活动%d: [%d, %d)\n", idx + 1, activities[idx].start, activities[idx].end);
}
return 0;
}
示例输出:
c
选中的活动数量: 4
选中的活动(按结束时间排序后):
活动1: [1, 4)
活动4: [5, 7)
活动6: [5, 9)
活动2: [3, 5)
注意:由于排序后下标会变化,实际输出中活动顺序以排序后的顺序为准。上述输出仅为示意,实际运行结果取决于排序后的顺序。
2.3.2 哈夫曼编码
哈夫曼编码是一种用于数据压缩的贪心算法。它根据字符出现的频率构建一棵最优二叉树(哈夫曼树),使得高频字符使用较短的编码,低频字符使用较长的编码,从而整体压缩数据长度。
贪心策略:每次从频率集合中取出两个频率最小的节点,合并为一个新节点,其频率为两者之和,然后放回集合,重复直到只剩一个节点。
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define MAX_NODES 100
// 哈夫曼树节点
typedef struct Node {
char ch; // 字符(叶子节点有效)
int freq; // 频率
struct Node *left; // 左孩子
struct Node *right; // 右孩子
} Node;
// 优先队列(最小堆)相关
Node *heap[MAX_NODES];
int heapSize = 0;
// 交换两个节点指针
void swap(Node **a, Node **b) {
Node *temp = *a;
*a = *b;
*b = temp;
}
// 最小堆上浮
void siftUp(int index) {
while (index > 0) {
int parent = (index - 1) / 2;
if (heap[parent]->freq > heap[index]->freq) {
swap(&heap[parent], &heap[index]);
index = parent;
} else {
break;
}
}
}
// 最小堆下沉
void siftDown(int index) {
while (1) {
int left = 2 * index + 1;
int right = 2 * index + 2;
int smallest = index;
if (left < heapSize && heap[left]->freq < heap[smallest]->freq) {
smallest = left;
}
if (right < heapSize && heap[right]->freq < heap[smallest]->freq) {
smallest = right;
}
if (smallest != index) {
swap(&heap[index], &heap[smallest]);
index = smallest;
} else {
break;
}
}
}
// 插入节点到最小堆
void push(Node *node) {
heap[heapSize] = node;
siftUp(heapSize);
heapSize++;
}
// 弹出最小节点
Node *pop() {
Node *minNode = heap[0];
heapSize--;
heap[0] = heap[heapSize];
siftDown(0);
return minNode;
}
// 创建新节点
Node *createNode(char ch, int freq) {
Node *node = (Node *)malloc(sizeof(Node));
node->ch = ch;
node->freq = freq;
node->left = NULL;
node->right = NULL;
return node;
}
// 构建哈夫曼树
Node *buildHuffmanTree(char chars[], int freqs[], int n) {
// 初始化最小堆
heapSize = 0;
for (int i = 0; i < n; i++) {
push(createNode(chars[i], freqs[i]));
}
// 反复合并两个最小节点
while (heapSize > 1) {
Node *left = pop();
Node *right = pop();
Node *parent = createNode('\0', left->freq + right->freq);
parent->left = left;
parent->right = right;
push(parent);
}
return pop(); // 返回根节点
}
// 递归打印哈夫曼编码
void printCodes(Node *root, char *code, int depth) {
if (root == NULL) return;
// 叶子节点:输出字符和编码
if (root->left == NULL && root->right == NULL) {
code[depth] = '\0';
printf("字符 '%c': %s\n", root->ch, code);
return;
}
// 左分支编码为0
code[depth] = '0';
printCodes(root->left, code, depth + 1);
// 右分支编码为1
code[depth] = '1';
printCodes(root->right, code, depth + 1);
}
int main() {
// 示例输入:字符及其频率
char chars[] = {'a', 'b', 'c', 'd', 'e', 'f'};
int freqs[] = {5, 9, 12, 13, 16, 45};
int n = sizeof(chars) / sizeof(chars[0]);
Node *root = buildHuffmanTree(chars, freqs, n);
char code[100];
printf("哈夫曼编码结果:\n");
printCodes(root, code, 0);
return 0;
}
示例输出:
c
哈夫曼编码结果:
字符 'f': 0
字符 'c': 100
字符 'd': 101
字符 'a': 1100
字符 'b': 1101
字符 'e': 111
2.3.3 最小生成树(Prim算法)
最小生成树问题:给定一个带权无向图,找到一棵连接所有顶点的树,使得树中所有边的权值之和最小。Prim算法是求解该问题的经典贪心算法。
贪心策略:从任意一个顶点开始,每次选择连接已选顶点集合与未选顶点集合之间权值最小的边,将对应顶点加入集合,直到所有顶点都被选中。
c
#include <stdio.h>
#include <limits.h>
#define V 5 // 顶点数量
#define INF INT_MAX
// 找到未加入MST集合中距离最小的顶点
int minKey(int key[], int mstSet[]) {
int min = INF, minIndex = -1;
for (int v = 0; v < V; v++) {
if (mstSet[v] == 0 && key[v] < min) {
min = key[v];
minIndex = v;
}
}
return minIndex;
}
// 打印最小生成树
void printMST(int parent[], int graph[V][V]) {
printf("边\t权值\n");
for (int i = 1; i < V; i++) {
printf("%d - %d\t%d\n", parent[i], i, graph[i][parent[i]]);
}
}
// Prim算法求最小生成树
void primMST(int graph[V][V]) {
int parent[V]; // 存储MST的父节点
int key[V]; // 存储顶点到MST的最小权值
int mstSet[V]; // 标记顶点是否已加入MST
// 初始化
for (int i = 0; i < V; i++) {
key[i] = INF;
mstSet[i] = 0;
}
key[0] = 0; // 从顶点0开始
parent[0] = -1; // 根节点没有父节点
for (int count = 0; count < V - 1; count++) {
// 选择未加入MST且key值最小的顶点
int u = minKey(key, mstSet);
mstSet[u] = 1;
// 更新相邻顶点的key值
for (int v = 0; v &lt; V; v++) {
if (graph[u][v] &amp;&amp; mstSet[v] == 0 &amp;&amp; graph[u][v] &lt; key[v]) {
parent[v] = u;
key[v] = graph[u][v];
}
}
}
printMST(parent, graph);
}
int main() {
// 示例输入:5个顶点的带权无向图(邻接矩阵)
int graph[V][V] = {
{0, 2, 0, 6, 0},
{2, 0, 3, 8, 5},
{0, 3, 0, 0, 7},
{6, 8, 0, 0, 9},
{0, 5, 7, 9, 0}
};
printf("最小生成树(Prim算法):\n");
primMST(graph);
return 0;
}
示例输出:
c
最小生成树(Prim算法):
边 权值
0 - 1 2
1 - 2 3
0 - 3 6
1 - 4 5
3. 动态规划详解
3.1 核心思想与基本原理
动态规划的核心思想是:将复杂问题分解为若干相互重叠的子问题,通过求解子问题并保存结果(通常用数组或表格),避免重复计算,从而高效地得到全局最优解。动态规划适用于具有以下两个性质的问题:
- 最优子结构:问题的最优解包含其子问题的最优解。即可以通过子问题的最优解来构造原问题的最优解。
- 重叠子问题:在求解过程中,相同的子问题会被多次重复计算。动态规划通过记忆化(Memoization)或填表(Tabulation)来避免重复计算。
3.2 状态定义与转移方程构建方法
动态规划的核心是定义状态和构建状态转移方程。一般步骤如下:
- 定义状态:明确dp数组的含义。例如dpi表示前i个元素的最优解,dpij表示某个二维状态。
- 确定转移方程:找出状态之间的递推关系,即如何从较小的子问题推导出较大的问题。
- 确定初始条件:设置边界值,如dp0、dp00等。
- 确定计算顺序:按照依赖关系,从小到大或按特定顺序计算所有状态。
3.3 自顶向下与自底向上两种实现方式对比
| 对比维度 | 自顶向下(记忆化搜索) | 自底向上(填表法) |
|---|---|---|
| 实现方式 | 递归 + 记忆化数组 | 迭代 + 循环填表 |
| 计算顺序 | 从大问题递归到小问题 | 从小问题迭代到大问题 |
| 代码可读性 | 更接近问题描述,直观 | 需要明确计算顺序,稍复杂 |
| 性能 | 有递归调用开销,可能栈溢出 | 无递归开销,通常更快 |
| 适用场景 | 状态转移复杂、不易确定顺序 |
3.4 经典算法案例
3.4.1 最长公共子序列(LCS)
最长公共子序列问题:给定两个字符串,找出它们之间最长的公共子序列(不要求连续,但保持相对顺序)。例如字符串 "ABCBDAB" 和 "BDCABA" 的最长公共子序列长度为 4,如 "BCBA"。
状态定义:设 dpij 表示字符串 X 的前 i 个字符与字符串 Y 的前 j 个字符的最长公共子序列长度。转移方程如下:
- 若 Xi-1 == Yj-1,则 dpij = dpi-1j-1 + 1。
- 否则 dpij = max(dpi-1j, dpij-1)。
c
#include <stdio.h>
#include <string.h>
#define MAX 100
// 计算最长公共子序列长度
// 返回LCS长度,lcs数组存储结果字符串
int lcs(char X[], char Y[], char lcs[]) {
int m = strlen(X);
int n = strlen(Y);
int dp[MAX][MAX] = {0};
// 自底向上填表
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (X[i-1] == Y[j-1]) {
dp[i][j] = dp[i-1][j-1] + 1;
} else {
dp[i][j] = (dp[i-1][j] > dp[i][j-1]) ? dp[i-1][j] : dp[i][j-1];
}
}
}
// 回溯构造LCS
int i = m, j = n, index = dp[m][n];
lcs[index] = '\0';
while (i > 0 && j > 0) {
if (X[i-1] == Y[j-1]) {
lcs[index-1] = X[i-1];
i--;
j--;
index--;
} else if (dp[i-1][j] > dp[i][j-1]) {
i--;
} else {
j--;
}
}
return dp[m][n];
}
int main() {
// 示例输入
char X[] = "ABCBDAB";
char Y[] = "BDCABA";
char result[MAX];
int length = lcs(X, Y, result);
printf("字符串X: %s\n", X);
printf("字符串Y: %s\n", Y);
printf("最长公共子序列长度: %d\n", length);
printf("最长公共子序列: %s\n", result);
return 0;
}
示例输出:
c
字符串X: ABCBDAB
字符串Y: BDCABA
最长公共子序列长度: 4
最长公共子序列: BCBA
3.4.2 0-1背包问题
0-1背包问题:给定 n 个物品,每个物品有重量和价值,在背包容量有限的情况下,选择若干物品使得总价值最大。每个物品只能选择一次(0-1 特性)。
状态定义:设 dpiw 表示前 i 个物品在容量为 w 的背包中能获得的最大价值。转移方程如下:
- 若 w < weighti-1,则 dpiw = dpi-1w(放不下,只能不选)。
- 否则 dpiw = max(dpi-1w, dpi-1w-weight\[i-1] + valuei-1)。
c
#include <stdio.h>
#define MAX_ITEMS 100
#define MAX_CAPACITY 1000
// 0-1背包问题求解
// 返回最大总价值,selected数组标记被选中的物品
int knapsack(int weights[], int values[], int n, int capacity, int selected[]) {
int dp[MAX_ITEMS + 1][MAX_CAPACITY + 1] = {0};
// 自底向上填表
for (int i = 1; i <= n; i++) {
for (int w = 0; w <= capacity; w++) {
if (weights[i-1] <= w) {
int include = dp[i-1][w - weights[i-1]] + values[i-1];
int exclude = dp[i-1][w];
dp[i][w] = (include > exclude) ? include : exclude;
} else {
dp[i][w] = dp[i-1][w];
}
}
}
// 回溯找出选中的物品
int w = capacity;
for (int i = n; i > 0; i--) {
if (dp[i][w] != dp[i-1][w]) {
selected[i-1] = 1;
w -= weights[i-1];
} else {
selected[i-1] = 0;
}
}
return dp[n][capacity];
}
int main() {
// 示例输入:4个物品(重量,价值),背包容量10
int weights[] = {2, 3, 4, 5};
int values[] = {3, 4, 5, 6};
int n = sizeof(weights) / sizeof(weights[0]);
int capacity = 10;
int selected[MAX_ITEMS] = {0};
int maxValue = knapsack(weights, values, n, capacity, selected);
printf("物品数量: %d, 背包容量: %d\n", n, capacity);
printf("最大总价值: %d\n", maxValue);
printf("选中的物品编号: ");
for (int i = 0; i < n; i++) {
if (selected[i]) {
printf("%d ", i + 1);
}
}
printf("\n");
return 0;
}
示例输出:
c
物品数量: 4, 背包容量: 10
最大总价值: 12
选中的物品编号: 2 3 4
3.4.3 矩阵链乘法
矩阵链乘法问题:给定 n 个矩阵的维度序列,确定一种加括号的方式,使得计算矩阵连乘所需的标量乘法次数最少。矩阵乘法满足结合律,但不同的加括号方式会导致不同的计算量。
状态定义:设 dpij 表示从第 i 个矩阵到第 j 个矩阵连乘所需的最少乘法次数。转移方程如下:
- dpij = min(dpik + dpk+1j + pi-1 * pk * pj),其中 i <= k < j。
- 当 i == j 时,dpij = 0(单个矩阵无需乘法)。
c
#include <stdio.h>
#include <limits.h>
#define MAX 100
// 矩阵链乘法求解
// p数组存储矩阵维度,p[i-1] x p[i] 是第i个矩阵的维度
// 返回最少乘法次数
int matrixChainOrder(int p[], int n) {
int dp[MAX][MAX] = {0};
// 链长度为1时,乘法次数为0(已初始化)
// 按链长度从小到大计算
for (int len = 2; len <= n; len++) {
for (int i = 1; i <= n - len + 1; i++) {
int j = i + len - 1;
dp[i][j] = INT_MAX;
for (int k = i; k < j; k++) {
int cost = dp[i][k] + dp[k+1][j] + p[i-1] * p[k] * p[j];
if (cost < dp[i][j]) {
dp[i][j] = cost;
}
}
}
}
return dp[1][n];
}
int main() {
// 示例输入:4个矩阵,维度序列为 10x30, 30x5, 5x60, 60x20
// 即 p = {10, 30, 5, 60, 20}
int p[] = {10, 30, 5, 60, 20};
int n = sizeof(p) / sizeof(p[0]) - 1; // 矩阵个数
int minCost = matrixChainOrder(p, n);
printf("矩阵个数: %d\n", n);
printf("维度序列: ");
for (int i = 0; i <= n; i++) {
printf("%d ", p[i]);
}
printf("\n");
printf("最少标量乘法次数: %d\n", minCost);
return 0;
}
示例输出:
c
矩阵个数: 4
维度序列: 10 30 5 60 20
最少标量乘法次数: 4500
4. 贪心算法与动态规划的对比分析
4.1 算法设计思路比较
贪心算法和动态规划在设计思路上有本质区别。贪心算法强调"短视"的局部最优选择,每一步都基于当前状态做出决策,且一旦做出选择就不再回溯。动态规划则强调全局视角,通过枚举所有可能的子问题解并保存中间结果,最终从全局角度选出最优解。
从决策过程来看,贪心算法是"一步一决策、决策不回头",而动态规划是"分阶段决策、保留所有可能"。贪心算法通常更简单直接,但正确性需要严格证明;动态规划虽然实现更复杂,但一旦状态和转移方程正确,就能保证得到全局最优解。
4.2 时间复杂度与空间复杂度对比
| 对比维度 | 贪心算法 | 动态规划 |
|---|---|---|
| 时间复杂度 | 通常较低,如 O(n log n) 或 O(n) | 通常较高,如 O(n^2) 或 O(n * W) |
| 空间复杂度 | 通常为 O(1) 或 O(n) | 通常为 O(n) 或 O(n^2),可优化为 O(n) |
| 实现难度 | 简单,代码量少 | 较复杂,需要设计状态和转移方程 |
| 正确性保证 | 需要证明贪心选择性质 | 只要状态和转移正确,必然最优 |
4.3 适用场景与局限性分析
贪心算法适用于具有贪心选择性质和最优子结构的问题,如活动选择、哈夫曼编码、最小生成树、最短路径(Dijkstra算法)等。它的局限性在于:很多问题虽然具有最优子结构,但不满足贪心选择性质,此时贪心策略可能得到次优解。
动态规划适用于具有最优子结构和重叠子问题的问题,如最长公共子序列、背包问题、矩阵链乘法、编辑距离等。它的局限性在于:当状态空间过大时,时间和空间开销可能难以接受;此外,状态定义和转移方程的构建需要较强的建模能力。
4.4 如何根据问题特征选择合适的算法
选择算法时,可以从以下几个角度判断:
- 检查最优子结构:问题的最优解是否包含子问题的最优解?如果不满足,两种算法都不适用。
- 检查贪心选择性质:能否通过局部最优选择直接得到全局最优解?如果能,优先考虑贪心算法。
- 检查重叠子问题:子问题是否被重复计算?如果存在大量重叠,动态规划能显著提升效率。
- 权衡复杂度:贪心算法实现简单、效率高,但需要严格证明正确性;动态规划更通用,但实现成本更高。
一个实用的建议是:先用贪心策略尝试求解,如果能证明其正确性则直接采用;如果贪心策略失效或难以证明,再考虑动态规划。
5. 总结与展望
5.1 两种算法的实际应用价值
贪心算法和动态规划在计算机科学的各个领域都有广泛应用。贪心算法常用于网络路由、任务调度、数据压缩、图论算法等场景,其高效性使其成为处理大规模数据的首选。动态规划则广泛应用于文本处理、图像识别、生物信息学、金融建模等领域,其保证全局最优的特性使其成为求解复杂优化问题的利器。
5.2 学习与掌握的建议
学习这两种算法,建议从以下几个方面入手:
- 夯实基础:先掌握递归、分治等基础思想,理解子问题分解的概念。
- 多练经典题:通过活动选择、背包问题、LCS等经典案例,熟悉状态定义和转移方程的构建方法。
- 对比总结:将贪心算法和动态规划放在一起对比学习,理解它们的异同和适用边界。
- 动手实现:在理解原理后,独立编写代码并测试,加深对算法细节的掌握。
5.3 相关扩展内容介绍
在掌握贪心算法和动态规划之后,可以进一步学习以下扩展内容:
- 状态压缩动态规划:用位运算表示状态,解决状态空间较大的问题。
- 树形动态规划:在树结构上进行动态规划,解决树上最优化问题。
- 区间动态规划:针对区间合并类问题,如矩阵链乘法、石子合并等。
- 贪心与动态规划的结合:某些问题可以先用贪心缩小规模,再用动态规划精确求解。
- 近似算法:当问题无法在多项式时间内精确求解时,使用贪心等策略构造近似解。