贪心算法
- 贪心算法
- 核心思想
- 常见应用场景
- 典型案例
- 案例一:找零问题
- 案例二:活动选择问题
- 案例三:货仓选址问题
- 贪心算法的应用详解
- 霍夫曼编码
- 最小生成树
- Dijkstra最短路径算法
- 总结
贪心算法
核心思想
贪心算法(Greedy Algorithm)是一种在每一步选择中都采取在当前状态下最优的选择,从而希望以这种方式能够达到全局最优解的一种算法思想。贪心算法并不总是能得到全局最优解,但在一些问题中,它能产生出足够好的解,而且相对简单高效。
贪心算法的基本思想是通过一系列局部最优的选择,希望最终达到全局最优。在每一步,贪心算法会选择当前状态下的最优解,而不考虑当前选择对以后的影响。这与动态规划的思想不同,动态规划考虑到每一步的选择对于整体解的影响,而做出相应的决策。
贪心算法通常适用于满足两个条件的问题:
- 最优子结构性质: 当一个问题的最优解包含其子问题的最优解时,称该问题具有最优子结构性质。
- 贪心选择性质: 通过局部最优选择,期望能够达到全局最优。
一旦一个问题可以通过贪心算法求解,那么贪心算法一般会比其他算法更加高效。然而,需要注意的是,并非所有问题都满足贪心选择性质和最优子结构性质。
常见应用场景
- 霍夫曼编码:用于数据压缩,为高频字符分配短编码、低频字符分配长编码,减少整体数据量。
- 活动选择问题:选择一组互不相容的活动,使得参与的活动数最大。
- 最小生成树:如Kruskal和Prim算法,用于在加权连通图中找到权值之和最小的生成树。
- Dijkstra最短路径算法:求解单源最短路径问题,从起始点开始,每次遍历到距离最近且未访问过的顶点的邻接节点。
- 货仓选址问题:在一维数轴上为多个商店选择货仓位置,使得总距离最小。
在使用贪心算法时,必须确保贪心选择的局部最优解能够推导出全局最优解,否则可能得不到最优解。
典型案例
案例一:找零问题
问题描述:假设你是柜台售货员,需要给客户找零n元钱。你拥有无限数量的1元、2元、5元、10元、20元、50元面额的硬币/纸币。如何用最少数量的硬币/纸币来找零?
贪心策略:每次尽量用面额最大的硬币来找零。
示例:
假设要找零的金额为 n = 36 元。
可用的硬币面额为 1、2、5、10、20 和 50。
- 首先,找零最大的面额,即 50 元。但由于 50 元大于 36 元,所以不能使用。
- 接下来,尽量使用次大面额的硬币,即 20 元。36 元中能用一个 20 元,剩下 16 元。
- 然后使用次次大面额的硬币,即 10 元。16 元中能用一个 10 元,剩下 6 元。
- 继续使用 5 元的硬币,6 元中能用一张 5 元,剩下 1 元。
- 最后,用 1 元的硬币找零 1 元。
这样,用 20 元、10 元、5 元和 1 元的硬币一共找零 36 元,共需要 4 枚硬币,是最少数量的硬币。
在这个案例中,贪心算法的贪心策略是每次都选择当前情况下的最优解,即选择当前可用的最大面额的硬币。这种策略在这个特定问题中得到了最优解。但需要指出的是,对于不同的面额组合和要求找零的金额,贪心算法并不总是能够得到最优解。
代码实现:
#include <stdio.h>
void findChange(int amount) {int money[] = { 50, 20, 10, 5, 2, 1 };int num = sizeof(money) / sizeof(money[0]);printf("找零 %d :\n", amount);for (int i = 0; i < num; ++i) {int current = money[i];int numNotes = amount / current;if (numNotes > 0) {printf("%d 张 %d 块\n", numNotes, current);amount = amount % current;}}
}
int main() {int amount = 46;findChange(amount);return 0;
}
复杂度分析:
- 时间复杂度:O(1),因为面额种类有限。
- 空间复杂度:O(1)。
注意:对于某些面额组合,贪心算法不一定能得到最优解。
案例二:活动选择问题
问题描述:有n个活动,每个活动有开始时间和结束时间,要求选择最多数量的互不重叠活动。
贪心策略:每次选择结束时间最早且不与已选活动冲突的活动。
伪代码:
1. 按活动结束时间从小到大排序
2. 依次选择与已选活动不冲突的下一个活动
代码实现:
#include <stdio.h>
#include <stdlib.h>// 定义活动结构体,包含开始时间和结束时间
typedef struct {int start; // 活动开始时间int end; // 活动结束时间int index; // 活动编号(可选,用于标识活动)
} Activity;// 比较函数,用于按活动结束时间排序
int cmp(const void *a, const void *b) {return ((Activity*)a)->end - ((Activity*)b)->end;
}// 贪心算法选择活动函数
void selectActivities(Activity acts[], int n) {// 按活动结束时间从小到大排序qsort(acts, n, sizeof(Activity), cmp);// 选择第一个活动(结束时间最早的活动)int count = 0; // 记录选择的活动数量int lastEnd = -1; // 上一个选择的活动的结束时间,初始为-1printf("选择的活动:\n");for (int i = 0; i < n; ++i) {// 如果当前活动的开始时间大于等于上一个选择的活动的结束时间,则选择该活动if (acts[i].start >= lastEnd) {count++;printf("活动%d: [%d, %d]\n", acts[i].index, acts[i].start, acts[i].end);lastEnd = acts[i].end; // 更新最后选择的活动的结束时间}}printf("共选择了 %d 个活动\n", count);
}// 主函数,演示活动选择问题
int main() {// 示例活动集合,每个活动有开始时间、结束时间和编号Activity activities[] = {{1, 4, 1}, // 活动1:开始时间1,结束时间4{3, 5, 2}, // 活动2:开始时间3,结束时间5{0, 6, 3}, // 活动3:开始时间0,结束时间6{5, 7, 4}, // 活动4:开始时间5,结束时间7{3, 9, 5}, // 活动5:开始时间3,结束时间9{5, 9, 6}, // 活动6:开始时间5,结束时间9{6, 10, 7}, // 活动7:开始时间6,结束时间10{8, 11, 8}, // 活动8:开始时间8,结束时间11{8, 12, 9}, // 活动9:开始时间8,结束时间12{2, 14, 10}, // 活动10:开始时间2,结束时间14{12, 16, 11} // 活动11:开始时间12,结束时间16};int n = sizeof(activities) / sizeof(activities[0]);printf("共有 %d 个活动\n\n", n);// 调用贪心算法选择活动selectActivities(activities, n);return 0;
}
复杂度分析:
- 时间复杂度:O(nlogn),主要是排序的时间复杂度。
- 空间复杂度:O(1),除了存储活动的数组外,只需要常数级的额外空间。
贪心策略正确性证明:
活动选择问题的贪心策略是选择结束时间最早的活动,这一策略的正确性可以通过反证法证明:
假设最优解集合为S,包含k个活动,按结束时间排序后为{a₁, a₂, …, aₖ}。如果a₁不是所有活动中结束时间最早的活动,那么存在活动b,其结束时间早于a₁。
我们可以用b替换S中的a₁,得到新的解集合S’ = {b, a₂, …, aₖ}。由于b的结束时间早于a₁,b不会与a₂, …, aₖ冲突,因此S’也是一个可行解,且|S’| = |S|。这说明选择结束时间最早的活动不会导致最优解的丢失。
通过归纳法,可以证明每次选择当前结束时间最早的、与已选活动不冲突的活动,最终能得到最优解。
应用场景:
活动选择问题在实际中有广泛应用,例如:
- 会议室安排:在有限的会议室中安排尽可能多的会议
- 课程表设计:在固定教室中安排尽可能多的课程
- 任务调度:在单处理器上安排尽可能多的任务
案例三:货仓选址问题
问题描述:在一条数轴上有N家商店,它们的坐标分别为A1∼AN。现在需要在数轴上建立一家货仓,每天清晨,从货仓到每家商店都要运送一车商品。为了提高效率,求把货仓建在何处,可以使得货仓到每家商店的距离之和最小。
贪心策略:将货仓建在所有商店坐标的中位数位置。
代码实现:
#include <stdio.h>
#include <stdlib.h>// 比较函数,用于排序
int compare(const void* a, const void* b) {return (*(int*)a - *(int*)b);
}// 计算距离之和的函数
int sum(int warehouse, int* shops, int n) {int totalDistance = 0;for (int i = 0; i < n; ++i) {totalDistance += abs(warehouse - shops[i]);}return totalDistance;
}// 找到货仓位置的函数
int find(int* shops, int n) {// 将商店坐标排序qsort(shops, n, sizeof(int), compare);// 中位数位置int warehousePosition = shops[n / 2];return warehousePosition;
}int main() {int n;printf("输入商店的数量:");scanf("%d", &n);int* shops = (int*)malloc(n * sizeof(int));printf("输入商店的坐标:");for (int i = 0; i < n; ++i) {scanf("%d", &shops[i]);}int pos = find(shops, n);int total = sum(pos, shops, n);printf("把货仓建在坐标 %d 处,使得货仓到每家商店的距离之和最小,为:%d\n", pos, total);free(shops);return 0;
}
复杂度分析:
- 时间复杂度:O(nlogn),主要是排序的时间复杂度。
- 空间复杂度:O(n),需要存储n个商店的坐标。
注意:这个问题的贪心策略是选择中位数位置,这是因为在一维数轴上,到各点距离之和最小的位置就是这些点的中位数。
贪心算法的应用详解
霍夫曼编码
霍夫曼编码是一种变长编码方式,它根据字符出现的频率来设计编码长度,频率高的字符使用较短的编码,频率低的字符使用较长的编码,从而达到压缩数据的目的。
贪心策略:每次选择两个频率最低的节点合并,构建一棵哈夫曼树,然后根据从根到叶子的路径确定编码。
最小生成树
最小生成树是连通无向图的一个生成树,其权值之和最小。常用的算法有:
Prim算法:
- 从图中任选一个顶点作为起始点,加入到最小生成树中
- 在所有与当前最小生成树中顶点相邻的边中,选择权值最小的边,将其连接的未访问顶点加入到最小生成树中
- 重复步骤2,直到所有顶点都加入到最小生成树中
Kruskal算法:
- 将图中所有边按权值从小到大排序
- 按照权值从小到大的顺序选择边,如果该边不会与已选择的边构成环路,则将其加入到最小生成树中
- 重复步骤2,直到选择了n-1条边(n为顶点数)
Dijkstra最短路径算法
Dijkstra算法用于求解单源最短路径问题,即从一个顶点到其余各顶点的最短路径。
贪心策略:每次选择当前未访问的距离起点最近的顶点,然后更新与该顶点相邻的其他顶点的距离。
总结
贪心算法通过每一步的局部最优选择,期望获得全局最优解。适用于满足最优子结构和贪心选择性质的问题。常见应用有找零、活动选择、最小生成树、最短路径、霍夫曼编码等。实际应用时需验证贪心策略的正确性,因为贪心算法并不总是能得到全局最优解。