能否使用动态规划:分析能否把大问题分解成小问题,分解后的每个小问题也存在最优解。如果把小问题的最优解组合起来能得到整个问题的最优解,那么我们可以应用动态规划解决这个问题。
本来这次是该总结动态规划的,但在学习过程中发现动态规划和上一节的贪心算法有很大联系,而在算法设计过程中主要是对两种算法的选择,所以决定这次以对比的方式做总结,既可以更深入地了解动态规划,又可以对贪心算法有个新的认识。
背景介绍:这两种算法都是选择性算法,就是从一个候选集合中选择适当的元素加入解集合。
贪心算法的选择策略即贪心选择策略,通过对候选解按照一定的规则进行排序,然后就可以按照这个排好的顺序进行选择了,选择过程中仅需确定当前元素是否要选取,与后面的元素是什么没有关系。
动态规划的选择策略是试探性的,每一步要试探所有的可行解并将结果保存起来,最后通过回溯的方法确定最优解,其试探策略称为决策过程。
主要不同:两种算法的应用背景很相近,针对具体问题,有两个性质是与算法选择直接相关的,上一次我们也提到了,那就是最优子结构性质和贪心选择性质。
最优子结构性质是选择类最优解都具有的性质,即全优一定包含局优,上一次选择最短路线的例子已经对此作了说。
当时我们也提到了贪心选择性质,满足贪心选择性质的问题可用贪心算法解决,不满足贪心选择性质的问题只能用动态规划解决。可见能用贪心算法解决的问题理论上都可以利用动态规划解决,而一旦证明贪心选择性质,用贪心算法解决问题比动态规划具有更低的时间复杂度和空间复杂度。
零钱问题:上一次举得例子是说明了两个性质的不同点,顺便说明了什么问题可以用动态规划但不能用贪心选择,今天我们再来举一个更有针对性的例子,即找零钱问题。
大家在生活中都有找零钱的经历,假如每种面额的钱币的数量都足够多,那么如果让找出14块钱,那绝大多数人会用一种10元的两张2元(假设还有2元的纸币)的来凑足14元。或许你没有察觉到,其实在这个过程中我们已经用到了算法,即贪心算法。正如前文提到的,贪心算法就是根据人思维模式设计的算法,所以平常生活中有很多这样的例子。
先来说说找钱的具体过程。仔细琢磨一下我们的选择过程:因为14>10,所以选择一张10元的,还剩4元,正好用两个2元的凑足。这个例子比较小,可能还不够说明问题,我们这样来想,假入要找177元,该用几张1元的?快点儿,仔细想一下,到底用几张?怎么样,发现问题了吧。没人可以直接回答找177要用几张1元的,我们都是想先用一张100的,剩77再用一张50的……最后确定用几张1元的。这个过程大家已经经历了无数次了,所以已经形成条件发射了。但仔细想想,这就说明了贪心选择前的排序过程,也就是说我们的贪心选择必须从大面值的开始,而不能从小面额的开始。
再来看看我们这样做的依据。为什么我们要这么选择钱的种类呢?为什么我们可以这样选择呢?首先要明确我们的目标,为什么我们不用7张2元的来凑14元,显然我们的目的是使钱的总张数最少。那我们这样选择一定可以保证无论找多少零钱,总的张数都是最少的吗?答案仿佛是不言自明的,但这里面仍有一些细节需要了解。假如我们的纸币系统的面额不是1、2、5、10这样的数,假如是我们的是1、2、7、10,那我们要找14元,还可以用贪心选择策略来选择吗?显然就不能了,两个7元的显然是最优解,即用的张数最少的解。那问题来了,为什么1、2、5、10就可以1、2、7、10就不行呢?不行了该怎么办呢?
找钱问题的贪心选择性质。前面已经提到,找钱问题实质是贪心选择性质的应用,而现在1、2、7、10用不了了,那就说明贪心选择性质失效了。失效了就不能用贪心选择了,只能用动态规划了!先来看一下为什么会失效,也就是说,什么样的数字组合才满足贪心选择性质呢?非常抱歉,这个问题本人还没有找到答案。但我们可以肯定的是某些组合时不具有贪心选择性质的,对于这样的组合我们举个反例就可以了,就像上面的两个7可以凑足14一样。
这里再多说几句,权当题外话。一是有人认为贪心策略的失效是因为7超过了10的一半儿,所以两个7大于10而导致出现了问题,所以只要两个相邻数都相差一半儿或一半儿多久满足贪心策略,这个看法并不准确,例如1,9,20里凑27,显然不能用贪心策略,这个问题(即什么样的数字组和可以用贪心策略)比较复杂,我们不再深追究。二是为什么我们和其他很多国家都用1、2、5、10的钱币系统,这是因为首先这个数组是满足贪心选择性质的,因为数比较少,所以很容易证明。其次就是我们用的是10进制数字。而为什么我们要用10进制是因为我们有10跟手指,为什么我们有10根手指就是上帝的事情了。其实,如果抛开10进制,我们的纸币系统用1、2、4、8……是最好的选择,这样既满足贪心选择性质(容易证明)又和计算机的二进制正好对应上,要知道,用计算机表示10进制数是很复杂的,而用8进制或者16进制则会简单很多。但没有办法,我们是先有的10进制再有的计算机,所以只能这么凑合弄了。试想假如我们有16根手指,那现在计算机中那些10进制和16进制的转化都不用做了,这该多么方便啊。不过到时候人家问你多大了,你就得回答2E岁了。
动态规划解找零钱问题。好了,我们回到正题。我们已经分析了钱的面额合适的时候可以用贪心选择,而这个策略并不具有通用性,钱币面额稍作改变就不满足贪心策略了,我们的方法就不能用了。那下面我们来研究一下通用的算法,即用动态规划来找零钱。
我们设钱币的种类数为N,并且假设每种钱币的数量都足够多。
钱币的面额为ai(0≤i≤N-1),且ai为正整数,即假设不需要找1元以下的钱。同时我们假设a0=1,否则会有找不开的情况。
要找的零钱数为j(0≤j≤J),同上,j也是正整数。
我们确定决策过程T(i,j)为用前i种钱币找出j元所用的最少张数。则有,T(i,0)=0(其中,0≤i≤N-1),即如果要找的零钱为0,那肯定一张也不用。T(0,j)=j(其中,0≤j≤J),即只用1元的纸币,那找多少钱就用多少张。
T(i,j)=min{T(i-1,j),T(i,j-ai)+1}(其中,j≥ai),即当我们的钱币种类增加到第i种时,这个第i种是可以用上的,如果用了就是T(i,j-ai)+1,如果没有用就是T(i-1,j)。还有一种情况是这个钱不能用,也就是j<ai,这个钱的面额超过了我们要找的钱,自然就用不上了,此时T(i,j)=T(i-1,j)。
最后就是回溯确定钱币种类和张数的问题了,这个就不细讲了,基本是填表过程的逆过程。我们可以看一下代码
[cpp]
view plain
copy
- void dp_giveChange(const int n)
- {
- const unsigned int N = 7;//钱币的种类数
- const unsigned int a[N] = {1,2,5,10,20,50,100};//钱币的面额
- const unsigned int J = n;//要找的零钱数
- //分配内存
- int **T = new int*[N];
- for(size_t i = 0;i < N;i++){
- T[i] = new int[J+1];
- }
- //填表
- for(size_t i = 0;i < N;i++) T[i][0] = 0;
- for(size_t j = 0;j <= J;j++) T[0][j] = j;
- for(size_t i = 1;i < N;i++){
- for(size_t j = 1;j <= J;j++){
- if(j>=a[i])//能用
- T[i][j] = min(T[i-1][j],T[i][j-a[i]]+1);
- else//不能用
- T[i][j] = T[i-1][j];
- }
- }
- //输出dp表
- ofstream os;
- os.open(“./outData/动态规划.txt”);
- for(size_t i = 0;i < N;i++){
- for(size_t j = 0;j <= J;j++){
- cout<<T[i][j]<<” “;
- os<<T[i][j]<<” “;
- }
- cout<<endl;
- os<<endl;
- }
- //回溯确定找钱的种类和张数
- int m=N-1,n=J,r[N]={0};
- while(n>0 && m!=0){
- if(T[m][n]==T[m-1][n])
- r[–m]=0;
- else {
- r[m]++;
- n-=a[m];
- }
- }
- if(m==0) r[m]=a[m];
- //输出结果
- for(size_t i=0;i<N;i++){
- dp_sum += r[i];
- cout<<r[i]<<” “;
- os<<r[i]<<” “;
- }
- os<<endl;
- cout<<endl;
- os.close();
- //释放内存
- for(size_t i = 0;i < N;i++){
- delete[] T[i];
- }
- delete[] T;
- }
为了方便对比,我们同样给出贪婪算法的代码
[cpp]
view plain
copy
- void greedy_giveChange(const int n)
- {
- const unsigned int N = 7;//钱币的种类数
- const unsigned int a[N] = {1,2,5,10,20,50,100};//钱币的面额
- const unsigned int J = n;//要找的零钱数
- int i=N-1,j=J,r[N]={0};
- while(j>0){
- r[i]=j/a[i];j-=r[i]*a[i];i–;
- }
- for(size_t i=0;i<N;i++){
- cout<<r[i]<<” “;
- greedy_sum += r[i];
- }
- cout<<endl;
- }
从代码量可以明显看出两种算法的显著不同,动态规划是要先填一个二维表,然后回溯找到结果。而贪婪算法是一次历遍搞定问题(前提数据已经排好序了)。时间复杂度更显而易见了,一个O(mn),一个O(n)。而且贪心算法的空间复杂度也占绝对优势。
但正如我们前面提到的,贪心算法是受输入限制的,当我们把钱币种类中的5改为7,再输入14时贪婪算法就不灵了。而动态规划却仍可以找到问题的最优解。而两个问题的关键就是一个满足贪心选择策略一个不满足。由此我们可以看到,如果能证明一个问题满足贪心选择策略,那贪心算法无疑比动态规划更有优势,但有一部分问题是不满足这个策略的,这时候就只能用动态规划了。
背包问题:上一次我们用0-1背包问题讲解了贪婪算法的应用,其中也提到了背包问题和0-1背包的区别。而这个区别也是贪心选择策略能否适用的区别,也是该用贪心算法还是改用动态规划的区别。这里我们不再仔细分析两个问题,只定性的看一下这两个问题的关键区别。
为什么物体可以分割时能用贪婪算法,而物体不能分割时就不行呢?这里数学证明就不细讲了,本人能力有限,而且各位估计也没心情看。这里只想让大家有一个什么情况需要“回退”的概念,这个需要“回退”的概念就是贪心选择性质失败的意思。
在背包问题中,如果物体可以分割,那我们装入单位质量最贵的东西“显然”是正确的,而如果物品不可分割,那就可能出现背包装了一个单价很贵的东西但没有装满,而后面一个虽然单价比较低但体积也比较大,这样就装不进去了,如果把前面那个东西倒出来把这个大的装进去可能就会使得总价值更大。总之这个问题在于背包可能装不满,而如果有一个物体单价低但占的空间更充分的话就有可能会得到更好的解。所以这个问题就需要往回试探的过程,这个就是要使用动态规划的标识。
在这次的找零钱问题中也是这样,就因为两个7等于14,而选择10的时候不知道后面有没有4可以选择,如果后面发现没有4再回头把10拿出来就会使整个搜索过程陷入混乱,这也是贪心策略失效的标识。
最后总结:贪心选择策略是本文着重说的一点,也是两种算法的根本区别所在。要想深切理解这个性质,还是要多做一些算法实例,形成一种定性的分析,当然,如果数学功底好也可以直接给出证明。我们想做的是在不能给出完整的数学证明前提下,对算法有个比较深入的把握,最起码能判断出一些贪心策略是否可以实现。