前面我们聊了分治法(拆、治、合)和动态规划(记住过往、避免重复)。今天聊贪心算法,它的核心就一句话:**"活在当下,每一步都选眼前最好的"**。至于这个局部最优能不能凑成全局最优,那是出题人该操心的事,我们只需要在考场上认出来、填对空。
一、贪心算法:一眼认出来的技巧
贪心算法(Greedy Algorithm)在软考里的辨识度很高。看到题干里有这些关键词,基本就可以往贪心上靠:
-
"每一步选择当前最优"
-
"局部最优"
-
"最早结束"、"最短边"、"最小权重"
-
"活动选择"、"最小生成树"、"最短路径"
和动态规划最大的区别:贪心不回溯,选了就是选了,不会回头反悔。而动态规划是"把所有可能都记下来,最后挑一个最好的"。
软考下午第四题里,贪心算法最常考的两个场景:活动选择问题和最小生成树。下面直接上代码。
二、案例一:活动选择问题(区间调度)
这是软考真题里出现过的经典案例,也是贪心算法最直观的应用。
问题描述:有 n 个活动,每个活动有开始时间 s[i] 和结束时间 f[i]。同一资源同一时间只能被一个活动占用。求最多能安排多少个互不冲突的活动。
贪心策略:每次选结束时间最早的活动,这样能给后面留出更多时间。注意:活动必须先按结束时间递增排序。
完整代码
#include <stdio.h>
#define MAX 100
/* 活动结构体 */
typedef struct {
int start;
int end;
int id;
} Activity;
/* 按结束时间递增排序(冒泡示例) */
void sortByEnd(Activity acts[], int n) {
int i, j;
Activity temp;
for (i = 0; i < n - 1; i++) {
for (j = 0; j < n - 1 - i; j++) {
if (acts[j].end > acts[j+1].end) {
temp = acts[j];
acts[j] = acts[j+1];
acts[j+1] = temp;
}
}
}
}
/* 贪心选择:每次选结束最早的兼容活动 */
int activitySelection(Activity acts[], int n, int selected[]) {
int i, count = 0;
int lastEnd = -1;
sortByEnd(acts, n);
for (i = 0; i < n; i++) {
if (acts[i].start >= lastEnd) { /* 兼容:开始时间晚于上一个的结束时间 */
selected[count++] = acts[i].id;
lastEnd = acts[i].end; /* 更新最后结束时间 */
}
}
return count;
}
int main() {
Activity acts[] = {
{1, 4, 1}, {3, 5, 2}, {0, 6, 3},
{5, 7, 4}, {3, 8, 5}, {5, 9, 6},
{6, 10, 7}, {8, 11, 8}, {8, 12, 9},
{2, 13, 10}, {12, 14, 11}
};
int n = sizeof(acts) / sizeof(acts[0]);
int selected[MAX];
int count = activitySelection(acts, n, selected);
printf("最多可安排 %d 个活动: ", count);
for (int i = 0; i < count; i++)
printf("%d ", selected[i]);
return 0;
}
关键点解析
1. 为什么按结束时间排序? 如果两个活动兼容,选结束早的那个,留给后面的时间窗口更大。这是贪心选择性质的体现。
2. 兼容性判断 acts[i].start >= lastEnd,意思是当前活动的开始时间必须不早于上一个已选活动的结束时间。注意这里用的是 >=,表示允许"接龙"(一个结束另一个马上开始)。
3. 时间复杂度 排序 O(n log n),遍历 O(n),总复杂度 O(n log n)。空间复杂度 O(n)。
三、案例二:最小生成树(Prim 算法)
最小生成树(MST)是贪心算法的另一个经典战场,软考上午题考过策略识别(Kruskal 和 Prim 都是贪心法)。下午题如果考,通常会给 Prim 的邻接矩阵版本,让你填空。
问题描述:给定一个连通带权无向图,找一棵包含所有顶点的树,使得边权之和最小。
Prim 贪心策略:从任意一个顶点开始,每次选择连接已选顶点集合和未选顶点集合的最短边,把对应的未选顶点拉进来,直到所有顶点都入选。
完整代码(邻接矩阵版)
#include <stdio.h>
#include <limits.h>
#define V 5 /* 顶点数 */
#define INF INT_MAX /* 无穷大 */
/* 找到未选顶点中 key 值最小的顶点 */
int minKey(int key[], int mstSet[], int n) {
int min = INF, minIndex = -1;
int v;
for (v = 0; v < n; v++) {
if (mstSet[v] == 0 && key[v] < min) {
min = key[v];
minIndex = v;
}
}
return minIndex;
}
/* Prim 算法 */
void primMST(int graph[V][V], int n) {
int parent[V]; /* 记录 MST 中每个顶点的父节点 */
int key[V]; /* 记录每个顶点到 MST 的最小边权 */
int mstSet[V]; /* 标记顶点是否已入选 MST,1 表示已选 */
int i, count, u, v;
/* 初始化 */
for (i = 0; i < n; i++) {
key[i] = INF;
mstSet[i] = 0;
}
key[0] = 0; /* 从顶点 0 开始,key 为 0 */
parent[0] = -1; /* 根节点没有父节点 */
for (count = 0; count < n - 1; count++) {
u = minKey(key, mstSet, n); /* 选 key 最小的未选顶点 */
mstSet[u] = 1; /* 标记为已选 */
/* 更新 u 的邻接顶点 */
for (v = 0; v < n; v++) {
if (graph[u][v] && mstSet[v] == 0 && graph[u][v] < key[v]) {
parent[v] = u;
key[v] = graph[u][v];
}
}
}
/* 输出 MST 的边 */
printf("Edge Weight\n");
for (i = 1; i < n; i++)
printf("%d - %d %d\n", parent[i], i, graph[i][parent[i]]);
}
int main() {
/* 邻接矩阵,0 表示无直接连接 */
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}
};
primMST(graph, V);
return 0;
}
关键点解析
1. 三个数组的作用
-
key[v]:顶点 v 到当前 MST 的最小边权; -
mstSet[v]:标记 v 是否已加入 MST; -
parent[v]:记录 v 在 MST 中的父节点,用来最后输出边。
2. 贪心体现在哪里? 每次 minKey() 选的是未选顶点中 key 值最小的那个,这就是"局部最优"——当前看来最便宜的连接方式。
3. 复杂度
-
时间复杂度:O(V²),V 为顶点数。因为
minKey是 O(V),外层循环 V 次,内层更新也是 O(V)。如果用优先队列优化可以到 O(E log V),但软考一般考邻接矩阵的 O(V²) 版本。 -
空间复杂度:O(V)。
四、模拟题一(活动选择)
【说明】 活动选择问题:给定 n 个活动的开始时间和结束时间,要求选择尽可能多的互不冲突的活动。算法首先将活动按结束时间递增排序,然后每次选择结束时间最早且与已选活动兼容的活动。
【C代码】
#include <stdio.h>
#define MAX 100
typedef struct {
int start;
int end;
} Activity;
void sortByEnd(Activity acts[], int n) {
int i, j;
Activity temp;
for (i = 0; i < n - 1; i++) {
for (j = 0; j < n - 1 - i; j++) {
if (acts[j].end > acts[j+1].end) {
temp = acts[j];
acts[j] = acts[j+1];
acts[j+1] = temp;
}
}
}
}
int selectActivities(Activity acts[], int n, int result[]) {
int count = 0;
int i;
int lastEnd = (1);
sortByEnd(acts, n);
for (i = 0; i < n; i++) {
if (acts[i].start >= lastEnd) {
result[count++] = i;
(2);
}
}
return (3);
}
int main() {
Activity acts[] = {{1,4}, {3,5}, {0,6}, {5,7}, {3,8}, {5,9}, {6,10}, {8,11}};
int n = sizeof(acts) / sizeof(acts[0]);
int result[MAX];
int count = selectActivities(acts, n, result);
printf("%d\n", count);
return 0;
}
【问题1】(9分) 根据说明和 C 代码,补齐 (1)~(3) 处的空缺。
【问题2】(3分) 该算法采用了(4)算法设计策略。
【问题3】(3分) 该算法的时间复杂度为(5)(用 O 符号表示)。
五、模拟题二(Prim 最小生成树)
【说明】 Prim 算法用于求解带权连通无向图的最小生成树。算法从某一顶点开始,每次选择连接已选顶点集合与未选顶点集合的最小权边,将对应顶点加入已选集合,直至所有顶点都被选中。
【C代码】
#include <stdio.h>
#include <limits.h>
#define V 5
#define INF INT_MAX
int minKey(int key[], int mstSet[], int n) {
int min = INF, minIndex = -1;
int v;
for (v = 0; v < n; v++) {
if ((1) && key[v] < min) {
min = key[v];
minIndex = v;
}
}
return minIndex;
}
void primMST(int graph[V][V], int n) {
int parent[V], key[V], mstSet[V];
int i, count, u, v;
for (i = 0; i < n; i++) {
key[i] = INF;
mstSet[i] = 0;
}
key[0] = 0;
parent[0] = -1;
for (count = 0; count < n - 1; count++) {
u = minKey(key, mstSet, n);
(2);
for (v = 0; v < n; v++) {
if (graph[u][v] && mstSet[v] == 0 && graph[u][v] < key[v]) {
(3);
key[v] = graph[u][v];
}
}
}
}
【问题1】(9分) 根据说明和 C 代码,补齐 (1)~(3) 处的空缺。
【问题2】(3分) 该算法采用了(4)算法设计策略,其时间复杂度为(5)(用 O 符号表示)。
【问题3】(3分) 若图用邻接矩阵存储,顶点数为 n,则该算法中 minKey 函数被调用的次数为(6)次。
六、参考答案
模拟题一
【问题1】
-
(1) 0 或 -1(初始时没有活动,lastEnd 设为 0 或 -1 都可以,只要小于第一个活动的开始时间即可)
-
(2) lastEnd = acts[i].end
-
(3) count
【问题2】
-
(4) 贪心(或贪心算法 / Greedy)
【问题3】
-
(5) O(n²) 或 O(n log n)(如果认为排序占主导;若按代码中的冒泡排序算,是 O(n²))
模拟题二
【问题1】
-
(1) mstSet[v] == 0(或
!mstSet[v]) -
(2) mstSet[u] = 1
-
(3) parent[v] = u
【问题2】
-
(4) 贪心(或贪心算法 / Greedy)
-
(5) O(n²)(n 为顶点数)
【问题3】
-
(6) n - 1(外层循环
count从 0 到 n-2,共 n-1 次)
七、小结
贪心算法在软考第四题里,套路比动态规划还固定。记住这几个"贪心"的经典场景:
| 问题 | 贪心策略 | 时间复杂度 |
|---|---|---|
| 活动选择 | 选结束时间最早的 | O(n log n) |
| Prim MST | 选连接已选/未集的最短边 | O(V²) |
| Kruskal MST | 选全局最短边,不形成环 | O(E log E) |
| Dijkstra | 选距离源点最近的顶点 | O(V²) |
考场上如果看到"每次选择当前最优"、"局部最优"这类描述,策略识别填贪心基本不会错。复杂度分析也记住:Prim 和 Dijkstra 的邻接矩阵版本都是 O(V²),活动选择主要是排序的 O(n log n)。
八、下一篇预告
【软考算法】软件设计师下午第四题之回溯法:N 皇后与 0-1 背包的"走迷宫艺术"
如果说贪心算法是"选了不回头",那回溯法就是"走不通就退回去换条路"。下一篇我们聊聊解空间树、剪枝条件、递归回溯框架,以及怎么在考场上把那条"回头路"写对。

65

被折叠的 条评论
为什么被折叠?



