《算法导论》在对动态规划讲解时,第一个问题就是钢条切割的问题。
在书中,对这个问题提供了3种思路
- 利用普通的递归来解,时间复杂度为O(2^n)
- 对普通的递归进行优化,使其带有记忆功能,减少运行时间。即自顶向下的动态规划
- 运用数组,自底向上的动态规划
现在依次讲解这三个思路,并且用代码实现它们!
首先先看题
现在给你一根钢条,你可以把它切成几个小部分(也可以不用切割),要找到一种方法,使得这根钢条可以卖出最多的价钱。
如:长度为4的钢条有三种卖法
- 不切割直接买,可以买价格9
- 切割为1,3再买,同样是价格9
- 切割为2,2再买,价格为10
可以看出,把4分为2,2来买可以达到最大收益。
普通递归方法
在上面的例子中,一根钢条可以被分为很多份,可以在被分的这些小份中继续分解,来找到最优解。这和递归的思路非常相似——将一个大问题分解子问题来求解。
首先假设一个问题的子问题的解都是最优的,于是,对于一个长度为L的钢条,若把他分为两段,L的最优解可能为 k,L-k (k=0,1,2,…,L-1)
现在遍历k的值,就可以找出最大的解(默认k,L-k为这个长度的最优解)
可以知道,虽然现在不知道k,L-k 是否为最优解,但是整个过程是递归的,在递归计算中可以知道k,L-k的最优解。
普通递归代码实现
创建两个大小为11的数组来储存这些钢条能卖出的价格,和当前钢条能买到的最大价格
int N[11]={0,1,5,8,9,10,17,17,20,24,30};
int r[11]={0};
编写递归函数
int re(int n)
{
if(n==0) //钢条长度为零的时候,返回零
return 0;
int q=N[n];
int i;
for(i=1;i<n;i++) //遍历k~L-k每一种情况,找到里面的最大值,k为零时为不切割的情况,q=N[n]已经储存,不需要考虑
q=maxx(q,r[i]+re(n-i));
r[n]=q; //钢条长度为n时最多可以买到价格q
return q;
}
完整代码
#include<stdio.h>
#include<time.h>
//int N[44]={0,1,5,8,9,10,17,17,20,24,30,0,1,5,8,9,10,17,17,20,24,30,0,1,5,8,9,10,17,17,20,24,30,0,1,5,8,9,10,17,17,20,24,30};
int N[11]={0,1,5,8,9,10,17,17,20,24,30};
int r[11]={0};
int re(int);
int maxx(int ,int);
int main()
{
int current;
puts("输入你要裁剪的钢条长度:");
scanf("%d",¤t);
clock_t start, finish;
start=clock();
int i;
printf("%d长的钢条最大收益是:%d\n",current,re(current));
printf("各个长度的收益情况:\n长度:");
for(i=0;i<=current;i++)
{
printf("%3d ",i);
}
puts("");
printf("收益:");
for(i=0;i<=current;i++)
{
printf("%3d ",r[i]);
}
puts("");
finish=clock();
double Total_time = (double)(finish-start) / CLOCKS_PER_SEC;
printf("%f 秒\n",Total_time);
scanf("%d",¤t);
}
int re(int n)
{
if(n==0)
return 0;
int q=N[n];
int i;
for(i=1;i<n;i++)
q=maxx(q,r[i]+re(n-i));
r[n]=q;
return q;
}
int maxx(int a,int b)
{
if(a>=b)
return a;
else
return b;
}
可以扩大数组N的大小,使其可以测试更多的钢条。
我测试了一下他们的时间
- n为31时运行时间为5s
- n为32时运行时间为10s
- n为33时运行时间为20s
从这里也可以看出,普通递归的时间复杂度为O(2^n),是指数的。
自顶向下的动态规划法
观察普通的递归方法可以知道,创建的数组r除了最后打印收益结果时发挥了作用,并没有发挥其他的功能。并且仔细调试普通递归方法可以发现,它重复多次的计算了许多子问题。比如当n=4时,普通递归运行的方式是
图片来源:https://zhuanlan.zhihu.com/p/70763958
重复的计算这些子问题是浪费时间的,并且随着钢条长度的增加,这些时间会指数增长。
现在,让数组r来储存这些子问题的数据,不在重复计算子问题。
修改后的递归代码
int upToDown(int n) //每次返回的值是当前长度n的最大收益
{
if(r[n]!=0)
return r[n];
if(n==0)
return 0;
int q=N[n];
int i;
for(i=1;i<n;i++)
q=maxx(q,r[i]+upToDown(n-i));
r[n]=q;
return q;
}
所添加的代码
if(r[n]!=0)
return r[n];
由于r的初始值为零,r[n]不为零的时候,就代表子问题n已经被处理过,r[n]就是钢条长度的最大收益,可以直接返回,不需要继续递归。
完整代码
#include<stdio.h>
int N[11]={0,1,5,8,9,10,17,17,20,24,30};
int upToDown(int);
int r[11]={0};
int maxx(int a,int b);
int main()
{
int current;
puts("输入你要裁剪的钢条长度:");
scanf("%d",¤t);
int i;
printf("%d长的钢条最大收益是:%d\n",current,upToDown(current));
printf("各个长度的收益情况:\n长度:");
for(i=0;i<=current;i++)
{
printf("%3d ",i);
}
puts("");
printf("收益:");
for(i=0;i<=current;i++)
{
printf("%3d ",r[i]);
}
}
int upToDown(int n) //即每次返回的值是当前长度n的最大收益
{
if(r[n]!=0)
return r[n];
if(n==0)
return 0;
int q=N[n];
int i;
for(i=1;i<n;i++)
q=maxx(q,r[i]+upToDown(n-i));
r[n]=q;
return q;
}
int maxx(int a,int b)
{
if(a>=b)
return a;
else
return b;
}
个人看法,若要求长度n的最大收益,就必须知道他的子问题的收益,如子问题收益已知就直接返回,如果不知道,就继续求解子问题的子问题。从这个思路可以看出,只有知道了子问题才可以求出当前问题,所以虽然是从n开始递归,但最终都是求出了最基本的子问题之后,再开始往上求解上一级的子问题。所以自顶向下也是有一个自底向上的过程。
自底向上的动态规划
由于最终都是要找到最底层,最基本的子问题,所以干脆直接从最底层开始自底向上的求出最终解。
同样创建两个大小为11的数组来储存这些钢条能卖出的价格,和当前钢条能买到的最大价格
int N[11]={0,1,5,8,9,10,17,17,20,24,30};
int n[11]={0};
动态规划的核心是保存已计算的子问题的值,并且是自底向上的动态规划,我们选用嵌套的两个for循环,从长度1开始依次计算各个长度的最优解。
核心代码:
puts("输入你要裁剪的钢条长度:");
scanf("%d",¤t);
for(i=1;i<=current;i++)
{
int q=N[i];
for(j=1;j<=i;j++)
{
q=maxx(q,n[j]+n[i-j]);
}
n[i]=q;
}
内层循环
for(j=1;j<=i;j++)
{
q=maxx(q,n[j]+n[i-j]);
}
每一次内层循环之后,长度为i的最优解就已知了。
在钢条长度为i时,遍历i的各种切割方法,来求出长度i的最优解
仔细思考内外层循环可知
- i=1时,内层循环运行1次
- i=2时,内层循环运行2次
- i=3时,内层循环运行3次
i为n时,内层循环运行n次。
所以总的迭代次数构成一个公差为1的等差数列,显然时间复杂度为O(n^2).
总结
利用动态规划,完成了时间复杂度从指数到常数时间的优化,足以看出动态规划的巨大威力,能用动态规划运用于求解有重复子问题的情况,动态规划不会重复计算子问题,从而大大节约了时间。
摘一段《算法导论》中的话
我通常按照4个步骤来设计一个动态规划算法
1.刻画一个最优解的特征
2.递归的定义最优值
3.计算最优值,通常采用自底向上的方法
从第2点可以看出,动态规划和递归之间有着千丝万缕的联系,动态规划可以利用递归的思路非递归的来解决问题,从而到优化时间的目的!