一. 动态规划介绍
动态规划与分治法整体思路相近:组合子问题的解求解原问题。将问题划分为互不相交的子问题,递归地求解子问题,再将它们的解组合起来,求出原问题的解。
动态规划的应用场景:子问题重叠。子问题的解决需要解决子子问题(递归),而不同子问题之间的子子问题是相同的。
动态规划的实现及特点:对于分治算法而言,它只是把问题分解为几个子问题,再分别求解这些子问题,而在这些子问题的解决中,会把它分解为子子问题,解决每一个子子问题,由于这些子子问题很多都是重复的,因而每次都重新计算会浪费计算资源。而对于动态规划而言,它对于每个子子问题只求解一次,将问题的解保存下来,在再一次遇见这些子子问题的时候,就可以直接调用这些子子问题的解,而不用重新计算。故动态规划能通过保存中间子子问题的解来降低算法复杂度,而额外的空间往往只需要一个表格/数组就可以。
动态规划应用的常见问题:最优化问题,对某个场景寻求最优解(最大/最小值)。
二.动态规划算法
步骤:
1.刻画一个最优解的结构特征
2.递归地定义最优解的值
3.计算最优解的值,
4.利用计算出的信息构造一个最优解
三.举例
1.钢条切割
问题介绍:
假定我们知道一家公司出售一段长度为i 的钢条的价格为pi(i=1,2,...),钢条的长度为整数,下图是价格表
长度i | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
价格pi | 1 | 5 | 8 | 9 | 10 | 17 | 17 | 20 | 24 | 30 |
钢条切割问题:给定一段长度为n的钢条和一个价格表pi(i=1,2,...n),求切割钢条方案,使得销售收益Rn最大,若长度为n的钢条的pn足够大,最优解可能完全不需要切割。
问题分析:
假设有n=4,有三个切割位置1-2,2-3,3-4,以1,0编码它们是否切割,得到以下切割方案,对应于0-7的二进制码,有以下8种切割方案:
A. 4 (不切割);B. 3 1;C. 2 2;D. 2 1 1;
E. 1 3 ;F. 1 2 1 ;G. 1 1 2;H. 1 1 1 1;
很显然,最有解应当是C,R=5+5=10。
对于长度为n的钢条,它的切割位置有n-1个,可以选择切割或者不切割,故它的切割方案有2^(n-1)。
动态规划算法应用分析:
第一步,刻画一个最优解的结构特征。
也就是说我们要先假设已经得到了最优解,那么针对这道题,我们已经得到了分割后的几段钢条,得到了几个最优切割位置,那么我们可以考虑选择一个最优切割位置切割,然后得到两个子钢条(每个子钢条不止一段),而每个子钢条都是经最优切割位置切割好的。而每个子钢条就相当于原钢条,最后把每个切割位置都切割后,问题就解决了。所以我们的最优解的结构特征就是:每个子钢条都是经最优切割位置切割好的。
好我们重头再来一遍,这次我们要加上具体的实现操作,我们选择一个最优切割位置切割,那么如何选择一个最优切割位置切割呢,最简单的方法,就是把每个位置都切割一遍,看看哪个最优。所以我们有公式
注:pn就表示不切割的情况,共有n-1个切割位置。
在计算中,这个递归表达式的终止条件是n=1,此时r1=p1
实际上相比于每次将一个原问题分为两个问题,还有一种更简单的方式,就是我们每次切割的时候,保证切割的其中一条子钢条只有一段,而另一条子钢条是经最优切割位置切割好的。,而不仅仅是切为两个子钢条。于是公式可以简化为
这样原问题的最优解只包含一个相关子问题,而不是两个。
直接递归 C++代码
/*p[0]保存p1值*/
cut_rod(p,n){
if(n==0)
return 0;
q=-1;
for(int i=1;i<=n;i++)
{
q=max(q,p[i-1]+cut_rod(p,n-i)); /*在i位置切割*/
}
return q;
}
但这样做是不够的,我们注意到,虽然我们是将一个原问题分解为一个子问题,但是在for循环中,每个子问题都要计算一次,长度为n的问题要调用n个长度分别为0,1,2,...,n-1的子问题,递归次数
第一项1表示自身被调用。
当i=4时,有
n4=1+n3+n2+n1+n0=1+(1+n2+n1+n0)+n2+n1+n0=2(1+n2+n1+n0)=2(1+(1+n1+n0)+n1+n0)=4(1+n1+n0)=4(1+(1+n0+n0)=8(n0+1)=16
于是总共调用次数为2^n-1,它的复杂度为O(2^n),是无法求解的。
于是,我们来思考切割钢条的子子问题:子钢条到底有几种呢?仅有n种长度的子钢条,也就是说在递归求解子问题的时候一直在重复计算这些子子问题,这才使得运算效率变得极低,事实上只需要把这n种情况全部计算一遍,就可以得到最终的解。
动态规划算法,就是计算这些子子问题的解,再组合这些子子问题的解,得到最优解。
主要有两种实现方式,第一种是自顶而下的方式,在上面所讲的递归方式中加入备忘机制,使得每个子子问题在计算了一次之后记录下来,第二次遇见的时候就可以直接调用
自顶而下 C++代码
/*p[0]保存p1值*/
memoized_cut_rod(int *p,int n){
int *r=(int*)malloc((n+1)*sizeof(int));
for(int i=0;i<n;i++)
r[i]=-1;
return cut_rod(p,n,r)
}
memoized_cut_rod_aux(int *p,int n,int *&n){
if(r[n]>=0) /*正数表示该子子问题已计算*/
return r[n]
if(n==0)
return 0;
q=-1;
for(int i=0;i<n;i++)
{
q=max(q,p[i]+memoized_cut_rod_aux(p,n-i-1));
}
r[n]=q; /*记录子子问题*/
return q;
}
将辅助r数组作为子子问题的结果存储,一旦遇见相同问题,就可以直接调用,而不用重复求解。
第二种方法是自底而上,既然原问题可以通子子问题的组合求解,那么把子子问题由简单到复杂逐步求解也能实现。
自底而上 C++代码
/*p[0]保存p1值*/
bottom_up_cut_rod(int *p,int n){
int *r=(int*)malloc((n+1)*sizeof(int));
r[0]=0;
for(int j=1;j<=n;j++)
{
q=-1;
for(int i=1;i<=j;i++)
{
q=max(q,p[i-1]+r[j-i]);
}
r[j]=q;
}
return r[n];
}
自底而上是将子子问题,即子钢条长度按从小到大依次求解,计算i规模的子问题依赖比i更小规模的子问题。
拓展:如果问题再变得复杂一点,钢条的售价不仅仅依赖于它的长度,而且还依赖于它的位置,也即p(i,j),i,j分别表示初始结束位置,切割位置依旧只能选择整数位置。
那么对于这个新问题,它的子问题依旧是切割一次后的子钢条,而它的子子问题就变成了每一种可能的子钢条情况,不再是简单的长度从1到n,而是要首尾各排列组合,也就是n(n+1)/2种情况,在这个时候,我们在使用动态规划的时候,就需要用一个二维数组来储存这n(n+1)/2个数值。
四. 参考文献
1.《算法导论》