算法导论(第16章 贪心算法)
第16章 贪心算法
贪心算法(greedy algorithm)总是做出局部最优的选择,寄希望这样的选择能导致全局最优解。
贪心算法并不保证得到最优解,但对很多问题确实可以求得最优解。
后面的章节中会提出很多利用贪心策略设计的算法:
第23章 最小生成树
第24章 单源最短路径——Dijkstra算法
第35章 近似算法——Chvatal贪心启发式算法
16.1 活动选择问题
假定有一个n个活动(activity)的集合
= ,这些活动使用同一个资源,而这个资源在某个时刻只能供一个活动使用。每个活动 都有一个开始时间 和一个结束时间 ,其中 。
- 如果被选中,任务
发生在半开时间区间[ , )期间。 - 如果两个活动
和 满足[ , )和[ , )不重叠,则称它们是兼容的。也就是说,若 或 ,则 和 是兼容的。
在活动选择问题中,我们希望选出一个最大兼容活动集。
假定活动已按结束时间的单调递增顺序排序:
(稍后,我们会看到这一假设的好处)。
解决策略:
- 通过动态规划将这个问题分为两个子问题,然后将两个子问题的最优解整合成原问题的一个最优解。
- 贪心算法只需考虑一个选择,在做贪心选择时,子问题之一必是空的,只留下一个非空子问题。
我们将找到一种递归贪心算法来解决活动调度问题,并将递归算法转化为迭代算法,以完成贪心方法的过程。
活动选择问题的最优子结构
令
假定
令
如果用
表示集合 的最优解的大小,则可得递归式——
接下来我们可以设计一个带备忘机制的递归算法,或者使用自底向上法填写表项。
贪心选择
选择
由于活动已按结束时间单调递增的顺序排序,贪心选择就是活动
作出贪心选择后,只剩下一个子问题需要求解:寻找在
所以,我们可以反复选择最早结束的活动,直至不再有剩余活动。而且,因为我们总是选择最早结束的活动,所以选择的活动的结束时间必然是严格递增的。我们只需按结束时间的单调递增顺序处理所有活动,每个活动只考查一次。
此时,我们只需自顶向下进行计算。
- 定理16.1 考虑任意非空子问题
,令 是 中结束时间最早的活动,则 在 的某个最大兼容活动子集中。
递归贪心算法
设计一个直接的递归过程来实现贪心算法。
过程RECURSIVE-ACTIVITY-SELECTOR的输入为两个数组
我们假定输入的
个活动已经按结束时间的单调递增顺序排列好。如果未排好序,我们可以在 时间内对它们进行排序,结束时间相同的活动可以任意排列。
为了方便算法初始化,我们添加一个虚拟活动,其结束时间 ,这样子问题 就是完整的活动集 。求解原问题即可调用RECURSIVE-ACTIVITY-SELECTOR( )。
RECURSIVE-ACTIVITY-SELECTOR(s, f, k, n)
1 m = k + 1
2 while m ≤ n and s[m] ≥ f[k] // find the first activity to finish
3 m = m + 1
4 if m ≤ n
5 return {a_m} ∪ RECURSIVE-ACTIVITY-SELECTOR(s, f, m, n)
6 else return ∅
假定活动已经按结束时间排好序,则递归调用RECURSIVE-ACTIVITY-SELECTOR(s, f, 0, n)的运行时间为
。
在整个递归调用过程中,每个活动被且只被while循环检查一次。特别地,活动在 的最后一次调用中被检查。
迭代贪心算法
过程RECURSIVE-ACTIVITY-SELECTOR几乎就是“尾递归”:它以一个对自身的递归调用再解决一次并集操作结尾。
将一个尾递归过程改为迭代形式通常是很直接的:
GREEDY-ACTIVITY-SELECTOR(s, f)
1 n = s.length
2 A = {a_1}
3 k = 1
4 for m = 2 to n
5 if s[m] ≥ f[k]
6 A = A ∪ {a_m}
7 k = m
8 return A
与递归版本类似,在输入活动已按结束时间排序的前提下,GREEDY-ACTIVITY-SELECTOR的运行时间为
。
总结
16.1节对于活动选择问题设计贪心算法的过程如下:
- 确定问题的最优子结构。
- 设计一个递归算法。
- 证明如果我们做出一个贪心选择,则只剩下一个子问题。
- 证明贪心选择总是安全的(步骤3、4的顺序可以调换)。
- 设计一个递归算法实现贪心策略。
- 将递归算法转换为迭代算法。
16.2 贪心算法原理
更一般地,我们可以按如下步骤设计贪心算法:
- 将最优化问题转化为这样的形式:对其做出一次选择后,只剩下一个子问题需要求解。
- 证明做出贪心选择后,原问题总是存在最优解,即贪心选择总是安全的。
- 证明做出贪心选择后,剩余的子问题满足性质:其最优解与贪心选择组合即可得到原问题的最优解,这样就得到了最优子结构。
每个贪心算法都对应一个更繁琐的动态规划算法。
证明一个最优化问题中贪心算法的可行性,贪心选择性质和最优子结构是两个关键要素。
贪心选择性质(greedy-choice property)
贪心选择性质:我们可以通过做出局部最优(贪心)选择来构造全局最优解。
在动态规划中,每个步骤都要进行一次选择,但选择通常依赖于子问题的解——自底向上的方式。(也可以自顶向下求解,但需要备忘机制。当然,即使算法是自顶向下进行计算,我们仍然需要先求解子问题再进行选择。)
在贪心算法中,总是做出当时看来最佳的选择,然后求解剩下的唯一的子问题——自顶向下的方式。
当然,必须证明每个步骤做出贪心选择能生成全局最优解。(证明步骤通常首先考查某个子问题的最优解,然后用贪心选择替换某个其他选择来修改此解,从而得到一个相似但更小的子问题。)
通过对输入进行预处理或者使用适合的数据结构(通常是优先队列),我们通常可以使贪心选择更快速,从而得到更高效的算法。(比如活动选择问题中将活动按结束时间单调递增顺序排序)
最优子结构(optimal substructure)
最优子结构性质:一个问题的最优解包含其子问题的最优解。——能否应用动态规划和贪心方法的关键要素。
当应用于贪心算法时,我们通常使用更为直接的最优子结构。我们假定,通过对原问题应用贪心选择即可得到子问题。只要证明:将子问题的最优解与贪心选择组合在一起就能生成原问题的最优解。(数学归纳法)
贪心对动态规划
二者都利用了最优子结构的性质。为了说明两种方法之间的细微差别,下面研究一个经典最优化问题的两个变形。
0-1背包问题(0-1 knapsack problem)
一个正在抢劫商店的小偷发现了
分数背包问题(fractional knapsack problem)
设定与0-1背包问题是一样的,但对每个商品,小偷可以拿走其一部分,而不是只能做出二元(0-1)选择。(可以将0-1背包问题中的商品想象为金锭,分数背包问题中的商品想象为金砂)
分析
贪心策略可以求解分数背包问题,而不能求解0-1背包问题。
为了求解分数背包问题,我们首先计算每个商品的每磅价值
为了求解0-1背包问题,我们只能使用动态规划。
16.3 赫夫曼编码(Huffman Coding)
赫夫曼编码可以很有效地压缩数据:通常可以节省20%~90%的空间,具体压缩率依赖于数据的特性。我们将待压缩数据看做是字符序列。根据每个字符的出现频率,赫夫曼贪心算法构造出字符的最优二进制表示。
变长编码(variable-length code)可以达到比定长编码好得多的压缩率,其思想是赋予高频字符短码字,赋予低频字符长码字。
我们这里只考虑前缀码(prefix code)——没有任何码字是其它码字的前缀。(最优数据压缩率、简化解码过程)
我们构造一棵二叉树来表示前缀码,以便截取码字。其叶结点为给定的字符,字符的二进制码字用从根结点到该字符叶节点的简单路径表示,其中0意味着“转向左孩子”,1意味着“转向右孩子”。
文件的最优编码方案总是对应一棵满(full)二叉树,即每个非叶结点都要两个孩子结点。
给定一棵对应前缀码的树T,我们可以容易地计算出编码一个文件需要多少个二进制位。对与字母表
构造赫夫曼编码
在下面给出的伪代码中,我们假定
HUFFMAN(C)
1 n = |C|
2 Q = |C|
3 for i = 1 to n - 1
4 allocate a new node z
5 z.left = x = EXTRACT-MIN(Q)
6 z.right = y = EXTRACT-MIN(Q)
7 z.freq = x.freq + y.freq
8 INSERT(Q, z)
9 return EXTRACT-MIN(Q) //return the root of the tree
赫夫曼算法的正确性
下面的引理证明问题具有贪心选择性质。
- 引理16.2 令
为一个字母表,其中每个字符 都有一个频率 。令 和 是 中频率最低的两个字符。那么存在 的一个最优前缀码, 和 的码字长度相同,且只有最后一个二进制位不同。
下面的引理证明问题具有最优子结构性质。
-
-
-
定理16.4 过程HUFFMAN会生成一个最优前缀码。
-
证明:由引理16.2和引理16.3即可得。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步