【软考算法】软件设计师下午第四题之贪心算法:活动选择与最小生成树的“局部最优“

前面我们聊了分治法(拆、治、合)和动态规划(记住过往、避免重复)。今天聊贪心算法,它的核心就一句话:**"活在当下,每一步都选眼前最好的"**。至于这个局部最优能不能凑成全局最优,那是出题人该操心的事,我们只需要在考场上认出来、填对空。

一、贪心算法:一眼认出来的技巧

贪心算法(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 背包的"走迷宫艺术"

如果说贪心算法是"选了不回头",那回溯法就是"走不通就退回去换条路"。下一篇我们聊聊解空间树、剪枝条件、递归回溯框架,以及怎么在考场上把那条"回头路"写对。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值