算法---贪心算法(Greedy Algorithm)
一、贪心算法的核心定义与本质
贪心算法是一种在每一步选择中都采取当前状态下最优(即局部最优)的选择,以期最终获得全局最优解的启发式算法。其核心思想可概括为:“走一步看一步,每步都选最好的,不回头”。
与动态规划(DP)需要存储子问题的最优解并回溯不同,贪心算法不依赖历史决策——它通过每一步的局部最优积累,直接推导全局最优。这种“短视”的特性使其实现简单、效率极高,但也决定了它并非适用于所有问题,必须满足严格的前提条件。
二、贪心算法的适用条件
判断一个问题能否用贪心算法解决,必须同时满足以下两个核心性质,缺一不可:
1. 贪心选择性质
每一步的局部最优选择,能够导向全局最优解。即:在选择当前最优解时,不需要考虑后续的决策,其选择结果不会影响后续子问题的最优性。
- 示例:活动选择问题中,“选择最早结束的活动”这一局部最优选择,能为后续留下更多时间选择其他活动,最终导向全局最优(最多活动数)。
- 反例:0-1背包问题中,“选择价值密度最高的物品”无法保证全局最优(可能因剩余空间无法容纳其他高价值物品,导致总价值更低)。
2. 最优子结构性质
全局最优解中必然包含其子问题的最优解。即:问题的最优解可以分解为若干个子问题的最优解的组合。
- 示例:Dijkstra算法中,“从源点到节点
v的最短路径”必然包含“从源点到路径上某中间节点u的最短路径”——若存在更短的源点→u路径,替换后可得到更短的源点→v路径,与全局最优矛盾。
3. 经典反例:错误的贪心策略
以“找零钱问题”为例:
若硬币面额为[1,3,4],需找6元。直觉贪心策略(选最大面额优先)会得到4+1+1=6(3枚硬币),但最优解是3+3=6(2枚硬币)。此时“最大面额优先”的贪心策略不满足“贪心选择性质”,导致全局最优失效。
三、贪心算法的解题步骤
使用贪心算法解决问题需遵循固定流程,核心是策略设计与正确性证明:
- 问题建模:将实际问题抽象为“选择问题”,明确目标函数(如“最多活动数”“最短路径和”)和约束条件(如“活动不冲突”“边权非负”)。
- 设计贪心策略:确定每一步如何选择“局部最优”。常见策略包括:按结束时间排序、按价值密度排序、按边权排序等。
- 证明策略正确性:通过数学归纳法或反证法,验证策略满足“贪心选择性质”和“最优子结构”。
- 编码实现:根据策略选择合适的数据结构(如排序、优先队列、并查集),处理边界情况(如空输入、极端值)。
- 测试优化:验证结果正确性,优化时间复杂度(如用快排替代冒泡排序)。
四、经典问题与C++实现
贪心算法的应用场景高度集中,以下为4类核心问题的详细实现:
1. 活动选择问题(最多不冲突活动)
问题描述
给定n个活动,每个活动有开始时间start[i]和结束时间end[i],选择最多不重叠的活动集合。
贪心策略
按活动的结束时间升序排序,优先选择最早结束的活动——该选择能为后续活动预留最多时间,最大化总活动数。
C++实现
#include<iostream>#include<vector>#include<algorithm>usingnamespace std;// 定义活动结构体structActivity{int start;// 开始时间int end;// 结束时间};// 排序规则:按结束时间升序boolcompare(const Activity& a,const Activity& b){return a.end < b.end;}// 选择最多不冲突活动 vector<Activity>selectMaxActivities(vector<Activity>& activities){ vector<Activity> result;if(activities.empty())return result;// 1. 按结束时间排序sort(activities.begin(), activities.end(), compare);// 2. 选择第一个活动(最早结束) result.push_back(activities[0]);int lastEnd = activities[0].end;// 3. 遍历后续活动,选择不冲突的(开始时间>=上一个结束时间)for(int i =1; i < activities.size(); i++){if(activities[i].start >= lastEnd){ result.push_back(activities[i]); lastEnd = activities[i].end;// 更新最后一个活动的结束时间}}return result;}intmain(){ vector<Activity> activities ={{1,4},{3,5},{0,6},{5,7},{3,9},{5,9},{6,10},{8,11},{8,12},{2,14},{12,16}}; vector<Activity> selected =selectMaxActivities(activities);// 输出结果 cout <<"选择的活动(开始时间, 结束时间):"<< endl;for(auto& act : selected){ cout <<"("<< act.start <<", "<< act.end <<")"<< endl;} cout <<"最多可选择 "<< selected.size()<<" 个活动"<< endl;return0;}输出结果
选择的活动(开始时间, 结束时间): (1, 4) (5, 7) (8, 11) (12, 16) 最多可选择 4 个活动 2. 哈夫曼编码(最优前缀编码)
问题描述
给定字符的频率分布(如a:5, b:9, c:12, d:13),构造前缀编码(无编码是另一编码的前缀),使总编码长度(频率×编码长度之和)最小。
贪心策略
- 构建最小堆(优先队列),存储所有字符的频率;
- 每次取出两个频率最小的节点,合并为一个新节点(频率为两节点之和);
- 将新节点入堆,重复步骤2,直到堆中只剩一个节点(哈夫曼树的根);
- 树的左分支记为
0,右分支记为1,叶子节点的路径即为对应字符的编码。
C++实现
#include<iostream>#include<queue>#include<vector>usingnamespace std;// 计算哈夫曼编码的总长度inthuffmanCodeTotalLength(const vector<int>& frequencies){// 最小堆:priority_queue<Type, Container, Compare> priority_queue<int, vector<int>, greater<int>> minHeap;// 1. 将所有频率入堆for(int freq : frequencies){ minHeap.push(freq);}int totalLength =0;// 总编码长度// 2. 合并节点,直到堆中只剩1个节点while(minHeap.size()>1){// 取出两个最小频率int first = minHeap.top(); minHeap.pop();int second = minHeap.top(); minHeap.pop();// 合并后的新节点频率int merged = first + second; totalLength += merged;// 合并节点的频率即编码长度贡献// 新节点入堆 minHeap.push(merged);}return totalLength;}intmain(){// 字符频率:a:5, b:9, c:12, d:13, e:16, f:45 vector<int> frequencies ={5,9,12,13,16,45};int total =huffmanCodeTotalLength(frequencies); cout <<"哈夫曼编码的总长度:"<< total << endl;// 输出:224return0;}原理说明
总长度224的计算逻辑:
合并过程为5+9=14(贡献14)→12+13=25(贡献25)→14+16=30(贡献30)→25+30=55(贡献55)→45+55=100(贡献100),总和14+25+30+55+100=224。
3. Dijkstra算法(单源最短路径)
问题描述
在带非负权的无向/有向图中,找到从源点S到所有其他节点的最短路径长度。
贪心策略
- 用
dist[]数组记录源点到各节点的当前最短距离(初始为INF,dist[S]=0); - 构建最小堆,存储(当前最短距离,节点),初始将(0, S)入堆;
- 每次取出堆顶节点
u(当前距离源点最近的未确定节点),标记为“已确定”; - 遍历
u的所有邻接节点v,执行松弛操作:若dist[v] > dist[u] + 边权w,则更新dist[v],并将(dist[v], v)入堆; - 重复步骤3-4,直到堆为空。
C++实现
#include<iostream>#include<vector>#include<queue>#include<climits>#include<stdexcept>// 用于边界校验异常usingnamespace std;constint INF = INT_MAX; vector<int>dijkstra(int n,int source,const vector<vector<pair<int,int>>>& adj){// 边界校验:源点合法性if(source <0|| source >= n){throwinvalid_argument("源点超出节点范围!");} vector<int>dist(n, INF); dist[source]=0;// 最小堆:(当前距离, 节点) priority_queue<pair<int,int>, vector<pair<int,int>>, greater<pair<int,int>>> minHeap; minHeap.push({0, source});while(!minHeap.empty()){auto[currentDist, u]= minHeap.top();// C++17结构化绑定 minHeap.pop();if(currentDist > dist[u])continue;// 遍历u的所有邻接节点for(auto[v, w]: adj[u]){// 松弛操作:更新dist[v]if(dist[v]> dist[u]+ w){ dist[v]= dist[u]+ w; minHeap.push({dist[v], v});}}}return dist;}intmain(){int n =6;// 节点数(0~5) vector<vector<pair<int,int>>>adj(n);// 局部邻接表,替代全局变量// 构建图(边:u->v,权w) adj[0].emplace_back(1,2);// emplace_back比push_back更高效(直接构造对象) adj[0].emplace_back(2,4); adj[1].emplace_back(2,1); adj[1].emplace_back(3,7); adj[2].emplace_back(4,3); adj[3].emplace_back(5,1); adj[4].emplace_back(3,2); adj[4].emplace_back(5,5);int source =0;try{ vector<int> dist =dijkstra(n, source, adj);// 输出正确结果 cout <<"源点 "<< source <<" 到各节点的最短距离:"<< endl;for(int i =0; i < n;++i){if(dist[i]== INF){ cout <<"到节点 "<< i <<":不可达"<< endl;}else{ cout <<"到节点 "<< i <<":"<< dist[i]<< endl;}}}catch(const invalid_argument& e){// 捕获边界校验异常 cerr <<"错误:"<< e.what()<< endl;return1;}return0;}输出结果
源点 0 到各节点的最短距离: 到节点 0:0 到节点 1:2 到节点 2:3(0→1→2) 到节点 3:8(0→1→2→4→3) 到节点 4:6(0→1→2→4) 到节点 5:9(0→1→2→4→3→5) 4. Kruskal算法(最小生成树)
问题描述
在无向带权图中,找到一棵连接所有节点、总边权和最小的生成树(Minimum Spanning Tree, MST)。
贪心策略
- 将所有边按权值升序排序;
- 用并查集(Union-Find) 维护已选节点的连通性;
- 遍历排序后的边,若边的两个端点属于不同连通分量(不构成环),则将该边加入MST,并合并两个连通分量;
- 重复步骤3,直到MST包含
n-1条边(n为节点数)。
C++实现
#include<iostream>#include<vector>#include<algorithm>usingnamespace std;// 定义边结构体structEdge{int u;// 起点int v;// 终点int weight;// 边权};// 并查集(Union-Find):维护连通分量classUnionFind{private: vector<int> parent;// 父节点 vector<int> rank;// 秩(用于路径压缩优化)public:UnionFind(int n){ parent.resize(n); rank.resize(n,0);for(int i =0; i < n; i++){ parent[i]= i;// 初始父节点为自身}}// 查找根节点(路径压缩)intfind(int x){if(parent[x]!= x){ parent[x]=find(parent[x]);// 递归压缩路径}return parent[x];}// 合并两个连通分量(按秩合并)boolunite(int x,int y){int rootX =find(x);int rootY =find(y);if(rootX == rootY)returnfalse;// 已在同一分量// 秩小的树合并到秩大的树if(rank[rootX]< rank[rootY]){ parent[rootX]= rootY;}else{ parent[rootY]= rootX;if(rank[rootX]== rank[rootY]){ rank[rootX]++;}}returntrue;}};// Kruskal算法:返回MST的总边权intkruskal(int n, vector<Edge>& edges){// 1. 按边权升序排序sort(edges.begin(), edges.end(),[](const Edge& a,const Edge& b){return a.weight < b.weight;}); UnionFind uf(n);int mstTotal =0;// MST总边权int edgeCount =0;// 已选边数// 2. 遍历边,选择不构成环的边for(auto& edge : edges){if(uf.unite(edge.u, edge.v)){ mstTotal += edge.weight; edgeCount++;// MST需n-1条边,提前退出if(edgeCount == n -1)break;}}// 若边数不足n-1,说明图不连通return(edgeCount == n -1)? mstTotal :-1;}intmain(){int n =5;// 节点数(0~4) vector<Edge> edges ={{0,1,2},{0,3,6},{1,2,3},{1,3,8},{1,4,5},{2,4,7},{3,4,9}};int mstTotal =kruskal(n, edges);if(mstTotal ==-1){ cout <<"图不连通,无法构建MST"<< endl;}else{ cout <<"最小生成树的总边权:"<< mstTotal << endl;// 输出:16}return0;}原理说明
MST的边为(0,1,2)、(1,2,3)、(1,4,5)、(0,3,6),总权2+3+5+6=16,覆盖所有5个节点且无环。
五、贪心算法与动态规划的对比
贪心与DP均依赖“最优子结构”,但核心差异在于子问题的处理方式:
| 对比维度 | 贪心算法(Greedy) | 动态规划(DP) |
|---|---|---|
| 核心思想 | 局部最优→全局最优,不回溯 | 存储子问题最优解,回溯推导全局最优 |
| 子问题处理 | 不存储子问题解,每步直接选最优 | 存储子问题解(如dp数组),避免重复计算 |
| 适用场景 | 满足“贪心选择性质”的问题 | 不满足贪心选择性质,但有最优子结构 |
| 时间复杂度 | 低(通常O(nlogn),排序主导) | 较高(通常O(n²)或O(nm)) |
| 典型问题 | 活动选择、哈夫曼编码、Dijkstra | 0-1背包、最长公共子序列、斐波那契 |
| 最优解保证 | 需证明策略正确性,否则不保证 | 只要状态转移正确,必为全局最优 |
六、贪心算法的优缺点与应用场景
1. 优点
- 实现简单:无需复杂的状态转移或子问题存储,代码逻辑清晰;
- 效率极高:时间复杂度多为
O(nlogn)(排序)或O(MlogN)(优先队列),远低于DP; - 空间紧凑:无需存储子问题解,空间复杂度通常为
O(n)。
2. 缺点
- 适用范围窄:仅能解决满足“贪心选择性质”的问题,多数问题不适用;
- 正确性难证:需严格证明策略的有效性,直觉性策略易出错(如找零钱反例);
- 无回溯机制:一旦选择错误,无法修正,只能重新设计策略。
3. 实际应用
- 资源调度:CPU短作业优先(SJF)调度、任务优先级调度;
- 编码压缩:哈夫曼编码(用于ZIP、JPEG等格式);
- 路径规划:Dijkstra算法(导航软件核心算法之一);
- 网络优化:Kruskal/Prim算法(构建通信网络最小成本拓扑)。
贪心算法是一种“高效但挑剔”的算法:它通过局部最优的积累快速推导全局最优,但仅适用于满足“贪心选择性质”和“最优子结构”的问题。掌握贪心算法的核心在于:
- 学会判断问题是否符合贪心适用条件;
- 设计正确的贪心策略并证明其有效性;
- 熟练运用排序、优先队列、并查集等数据结构实现策略。