参考来源:《算法导论》
动态规划基本概念
什么是动态规划
动态规划(dynamic programming)与分治方法相似,都是通过组合子问题的解来求原问题…动态规划应用于子问题重叠的情况,即不同的子问题具有公共的子子问题(子问题的求解是递归进行的,将其划分为更小的子子问题)
以上为《算法导论》对动态规划算法的介绍,其中有两个重要点需要关注(即加粗的字):
- 组合子问题:这是一种常见的算法思路,上文提到的分治法也是通过该思路解决问题,如最大子串和问题。
- 子问题重叠:不同的子问题具有公共的子子问题,动态规划算法与分治算法对于重复子问题的处理就有非常大的区别:
- 分治算法:反复地求解那些公共的子子问题。
- 动态规划算法:对每个重复的子子问题只求解一次。
你可能会对这些感到难以理解,不要着急,我们跟着实例一步一步来:
钢条切割问题
某公司出售一段长度为i英寸的钢条的价格为pi(i=1,2,…单位为美元)。钢条的长度均为整英寸。下图为价格表:
现给定一段长度为n英寸的钢条和一个价格表pi(i=1,2,…,n) ,求切割钢条方案,使得销售收益rn最大。注意如果长度为10英寸的钢条的价格pn足够大,最优解可能就是完全不需要切割。
接下来让我们仔细分析一下这个问题:
可以看到,在上图中,长度i=5的钢条有四个切割点,位置分别是如上图所示的1,2,3,4,在每个位置上都可以选择割或者不割,所以i=5的钢条的切割方案如下(用+代表一次切割):
- 5 = 5(不切割)
- 5 = 1 + 4
- 5 = 1 + 1 + 3
- 5 = 1 + 1 + 1 + 2
- …
- 5 = 4 + 1
i=5的钢条总共有25-1=16 种切割方案(当然其中有重复的)。现推广到一般情况当i=n ,共有2n-1种切割方案。并且我们得出了这样一个结论:
- 如果一个最优解将钢条切割成k段(1<=k<=n) ,那么最优切割方案
n = i1 + i2 + … +ik - 将钢条切割为长度分别为i1,i2,…,ik的小段,得到最大收益:
rn = pi1 + pi2 + … + pik
所以我们可以认为长度为n英寸的钢条的最优切割收益rn ( n>=1) 为:
其中pn 为直接出售长度为n英寸的钢条的切割方案。其他n-1个参数对应另外n-1种方案:对每个i=1,2,…,n-1,首先将钢条切割为长度为i和n-i的两端,接着求解这两段的最优切割收益 ri , rn-i(每种方案的最优收益为两端的最优收益之和)。下面直接上代码:
先上代码(这是我自己写的):
int p[11]={0,1,5,8,9,10,17,17,20,24,30};
#define FIN 9999999
int cut_rod(int p[10],int n) //p为价格数组p[1...n],n为钢条的长度
{
if(n == 0)
return 0;
int q;
if(n <= 10) //当n<=10时,可以不切割直接
q=p[n];
else //FIN为自己定义的无穷大
q=-FIN;
for(int i = 1;i <= n-1; i++)
q = max(q, cut_rod(p,i) + cut_rod(p,n-i));
return q;
}
然后让我们进行一些优化,上文也提到了切割方案有很多重复了(如n = 1 + n-1 和 n = n-1 + 1),所以进一步优化为(称该方案为方案A):
// 方案A
int cut_rod(int p[10],int n) //p为价格数组p[1...n],n为钢条的长度
{
if(n == 0)
return 0;
int q;
if(n <= 10) //当n<=10时,可以不切割直接
q=p[n];
else //FIN为自己定义的无穷大
q=-FIN;
for(int i = 1;i <= n/2; i++) //优化,最多左端切到一半,再继续下去就会重复
q = max(q, cut_rod(p,i) + cut_rod(p,n-i));
return q;
}
然后再介绍一下《算法导论》中提到的一种优化方案(称其为方案B):我们将钢条从左边切割下长度为i的一段,只对右边剩下的长度为n-i的一段继续切割(递归求解),对左边的一段则不再进行切割。
下面为实现代码:
// 方案B
int cut_rod(int p[10],int n) //p为价格数组p[1...n],n为钢条的长度
{
if(n == 0)
return 0;
int q;
if(n <= 10) //当n<=10时,可以不切割直接
q=p[n];
else //FIN为自己定义的无穷大
q=-FIN;
int end = min(n-1, 10); //当长度小于10时,最多切到i=n-1;当长度大于10,最多切到10
for(int i = 1;i <= end; i++)
q = max(q, p[i] + cut_rod(p,n-i));
return q;
}
然后我们可以比较一下方案A和方案B的优劣(这里跟《算法导论》有些不同,因为在我的代码里,cut_rod(1)不会再调用cut_rod(0),并且B方案在切割时,切割的两端不会出现其中一段长度为0的情况):
- 当n = 4时,方案A:
所以 T(n)=1+∑k=1n/2(T(i)+T(n−i)) T ( n ) = 1 + ∑ k = 1 n / 2 ( T ( i ) + T ( n − i ) ) - 当n = 4时,方案B:
所以 T(n)=1+∑j=0n−1T(j) T ( n ) = 1 + ∑ j = 0 n − 1 T ( j )
通过图和公式,显然可以看出方案B效率更高,但是即使是方案B,n每增加1,cutd_rod的执行量要增加一倍,当n=3时,方案B中cut_rod的执行次数为4;当n = 4时,cut_rod的执行次数为8。那么为什么效率这么差呢,原因就在于重复了太多的子子问题,如cut_rod(p,1) , cut_rod(p,0)。这时候就需要动态规划算法。
动态规划算法(两种方法)
带备忘的自顶向下法
此方法仍按自然的递归形式编写过程,但过程会保存每个子问题的解(通常保存在一个数组或散列表中)。当需要一个子问题的解时,过程首先检查是否保存过此解。如果是,则直接返回保存的值,从而节省了计算时间;否则,按通常方式计算这个子问题。我们称这个递归过程是带备忘的。
int r[1000]={0};
int cut_rod(int p[10],int n,int* r) //p为价格数组p[1...n],n为钢条的长度,
{ //r[n]代表已记录的长度为n的钢条的最优收益
if(r[n] > 0)
return r[n]; //发现之前已经计算过,所以直接返回该值
if(n == 0)
return 0;
int q;
if(n <= 10) //当n<=10时,可以不切割直接
q = p[n];
else //FIN为自己定义的无穷大
q = -FIN;
int end = min(n-1, 10); //当长度小于10时,最多切到i=n-1;当长度大于10,最多切到10
for(int i = 1;i <= end; i++)
q = max(q, p[i] + cut_rod(p,n-i,r));
r[n] = q; //将得到的q存入r[n]中,即表示长度n的最优收益已知,为r[n]
return q;
}
可以看到每当调用cut_rod方法时,先去备忘录r查看此时n所对的最大收益是否已被求过。
自底向上法(推荐)
这种方法一般需要恰当定义子问题“规模”的概念,使得任何子问题的求解都只依赖于“更小的”子问题的求解。因而我们可以将子问题按规模排列,按由小至大的顺序进行求解。
int cut_rod(int p[10],int n) //p为价格数组p[1...n],n为钢条的长度
{
if(n == 0)
return 0;
int r[1000] = {0};
r[0] = 0;
for(int j = 1; j <= n; j++)
{
int q = j <= 10 ? p[j] : -FIN; //当n<=10时,可以直接卖,所以最低收益为直接卖的价格
int time = min(j-1, 10);
for(int i = 1; i <= time; i++)
q = max(q, p[i] + r[j-i]);
r[j] = q;
}
return r[n];
}
总结
最后回顾一下适合应用动态规划方法求解的最优化问题应该具备的两个因素:
- 最优子结构:即一个问题的最优解包含其子问题的最优解。
- 子问题重复:即有公共的重复的子问题。