走出迷宫的人们,有的是认识路;有的是莽撞碰巧出来的;有的则是一路做着标记出来的;也有的是走遍了整个迷宫。
——证明了的贪心算法、没有证明的贪心算法、动态规划、暴力搜索的区别。
今天来谈谈经典的算法设计思路问题,涉及搜索(Searching),动态规划(DP, Dynamic Programming),贪心算法(GA, Greedy Algorithm)……至于什么回溯法(Backtracking)只能是搜索的方向问题。
经常听人说起:“搜索、动态规划、贪心算法这几种算法云云”,好像这些算法彼此之间并没有交集一样。
非也,它们的渊源大了!
从范畴上来看:
Greedy⊂DP⊂Searching
即,所有的贪心算法问题都能用DP求解,更可以归结为一个搜索问题,反之不成立。
不管是动态规划还是贪心本质上都是一个搜索,这一点少有人发出疑问的。
然而却时常有人争论某问题是贪心的还是动归的,一个问题是贪心的还是动归的有联系但并不对立。
看一个具体的例子:
食堂里有n个人要在一个队列里排队买饭,每个人花费的时间分别为 Ci ,求如何安排队列的顺序可以使得所有人的总用时最小,最小为多少?
比如有三个人,分别用时3,2,5,那么最佳的排序应该是2,3,5,总用时是2 + 5 + 10 = 17,对此,你可以简单地验证一下其他5种的组合来验证这个答案是正确的。
这个问题在暴力搜索算法中需要消耗O(n!)的时间来生成所有的排列并从中获得最小值。
也许我们需要优化一下……正常人略作思考便可以猜出让耗时短的人优先的贪心优化策略。
于是就有了这样一段代码(JavaScript),可以直接在现代浏览器的控制台内使用(推荐Chrome)。
var solve = C => {
C.sort();
var sum = 0;
C.forEach((e, i) => sum += e * (C.length - i));
return {
min: sum,
order: C
};
}
solve([3, 2, 5]); // Object {min: 17, order: Array[3]}
这个问题的解空间是C数组的所有排列的集合,解空间的大小显然是O(n!)的,实际上其占用的存储空间是 O(n⋅n!) 的,因为每个解都占用了O(n)的空间。然而其上的贪心算法并没有遍历解空间,因此这不是一个暴力搜索。
由于任意顺序的C的所有排列的集合是一样的,因此他们的解空间与解都是一样的,所以我们直接把问题等价地化为经 O(nlogn) 的代价升序排列后的问题,即假设C已经有序。
设前 i 个人(已升序排列)能够达成的最小总用时为 f(i)
我们考虑把第i个人插入到前i-1个人的队列中去。
等等,我们是否能证明前i -1个人的最优排列也是前i个人最优排列的子排列(最优子结构的性质是否具备)?可以。假设前i人的最优排列不包含前i-1人的最优排列,则将除第i人外的人调整成前i-1人的序列可以更优,与假设矛盾,因此前i-1人的最优排列一定是前i人最优排列的子排列。
尝试将第i人插在第j人的前面,并考虑带来的影响,便有状态转移方程:
对于
有
即证出 ϕ(j) 随 j 递减,即 minϕ(j)=ϕ(i)
代入可得
由此证明了最佳策略为将最大的第i人放在最后。
后面这个看似O(n)的求和操作也可以事先缓存,按照DP写法这个问题就变成了这样
(除了必要的排序,时空复杂度均已达到最优O(n)):
var solve = C => {
C.sort();
var dp = C.map(() => 0);
var sum = C.map(e => e);
sum.forEach((e, i) => {
sum[i] += i > 0? sum[i - 1]: 0;
});
dp.forEach((e,i) => {
dp[i] = sum[i] + (i > 0? dp[i - 1]: 0);
});
return {
min: dp[dp.length - 1],
order: C,
}
}
对上面的数学公式再进一步则是:
f(0)=C0
再按照计数原理得到 Ci 的权重为 n−i
因而最后可以直接化为两个向量的数量积:
f(n)=(C0,C1,...,Cn−1)⋅(n,n−1,...,1)
这似乎已经变为了人们口中的贪心算法,但究竟是从推导的什么地方开始变为具有贪心性质了呢?
回顾一下,当我们证明了 ϕ(i) 的单调性,对于第i个人的选择,一下子就从i个可选策略优化到了1个,这个时候所谓“贪心”的策略就真的变成了唯一正确的策略,所谓“只顾眼前利益的贪婪”其实是“看穿一切,一往无前的气魄”。贪心是优化了选择的策略,而非与动态规划背道而驰,贪心算法仅仅是动态规划的一个平凡态罢了。
与其将它称为贪婪,我觉得它更是智慧的象征。
很多时候,问题的解空间并不适合用一维来描述,当解空间在一维以上,比如……还记得经典的0-1背包问题么?那就是一个典型的二维解空间问题。
对于物品 i ,具有 Vi 的价值与 Ci 的负重(代价)。
通常我们设前 i 个物品,能塞到容量为 j 的背包里的最大价值为 f(i,j)
有状态转移方程:
这个时候我们看到解空间的第一维的策略是可贪心的:再计算 f(i,j) 的时候只用管 f(i−1,∗) 而不用去管 f(i−2,∗),f(i−3,∗),... 由此便诞生了利用滚动数组压缩一维来降低空间复杂度的优化。
这种优化本质上是贪心的,是不完全的贪心,因为它只在某些维度上进行了贪心。
从动态规划优化到贪心算法真的提高了算法效率吗?未必。
这取决于动态规划中计算所有可选的策略的代价,如果代价是常数的,那么将其贪心优化并不会带来时间复杂度的下降(本文的两个例子都是如此),该走的解空间还是要走,只是不保存其中的一些值而已,可能会带来空间复杂度的下降。
有趣的是,当计算策略的代价并非常数(如Floyd全局最短路算法)时,往往并不只有一个策略,因而不能贪心优化。
因此,贪心算法不会降低从其对应的动态规划解法的时间复杂度,如果你发现它降低了,那么一定存在更好的解空间建模,更好的动态规划算法。
贪心算法确实可能比其对应的动态规划快不少,因为它的常数可能小得多。
但是,当你试图对同一解空间的不同点进行多次查询时,你会发现贪心可能会得不偿失,在均摊时间上输给不贪心的动态规划。
贪心在于其抛弃了部分子结构的解。
如顺带要求出方案而不仅仅是最大价值的0-1背包问题,本来使用不优化空间的解法完全能够保留倒推回去的线索,如PAT 1068 Find More Coins 解题报告 所说一般。如果抛弃了动态规划带来的一些解,很有可能在其衍生的问题上得不偿失。
既然已经得出贪心是动规的优化,那么循序渐进地解问题的思路应该先从搜索开始:先对问题的解空间建模,如果得到最优子结构的性质,写出状态转移方程,再看某些维度是否有贪心的优化余地,并且考虑一下,这些优化到底是否值得。