简介
动态规划方法通常求解最优化问题,这类问题有很多可行的解,所以我们希望找到的是一个最优值,我们称这样的情况为一个最优解(an optimal solution),而不是最优解(the optimal solution),因为这个问题可能有多个解都能达到最大值。
一般按下面四个步骤来求解:
1.刻画一个最优解的结构特征。
2.递归地定义最优解的值。
3.计算最优解的值,通常采用自底向上的方法。
4.利用计算出的信息构造出一个最优解。
15.1 钢条切割
长度为n英寸的钢条有2n-1种切割方案,怎么出来的呢?可以把钢条看作n段,在i和i-1之间可以选择切开或者不切开,一共有(n-1)个可以被切开的地方,所以方案有 2n-1种不同方案。
对于最优切割方案
n = i1 + i2 + i3 + i4……+ik
有与之对应的最优收益值
n = pi1 + pi2 + pi3 + pi4……+pik
所以,对于一个已知长度的钢条,我们可以通过更短的钢条的最优切割来描述它。
即rn=max(pn , r1 + rn-1,r2 + rn-1,……,rn-1 + r1)
我们通过组合两个相关子问题的最优解,并在所有可能的两段切割方案中选取组合收益最大者,构成原问题的最优解,我们称钢条切割问题满足最优子结构(optimal substructure)性质:问题的最优解由相关子问题的最优解组合而成,而这些子问题可以独立求解。
可以得到一个更为简单的递归求解方法:
rn=max(1<=i<=n)(pi + rn-1);
代码实现(递归):
int cut_rod(int n){
if(n==0) return 0;
int res=-INF;
for(int i=1;i<=n;i++){//对左边一段切下来的长度进行枚举,之后左边的便不再进行切割 。
res=max(res,p[i]+cut_rod(n-i))//递归继续处理右边的部分。
}
return res;
}
倘若通过递归求解的话,会浪费很多重复计算的时间,因为1、2之类的底下的长度会被多次计算。
通过记忆化可以提速,代码实现:
(如果你不了解记忆化,可以看这个帖子https://blog.csdn.net/su_bao/article/details/81181975)
inputs:
10
1 5 8 9 10 17 17 20 24 30
#include<iostream>
#include<cstring>
#include<algorithm>
using namespace std;
#define INF 0x3f3f3f3f
#define MAXN 100000 //这里假设n最大为100000,视情况改变MAXN
int mem[MAXN];//递归记忆化数组
int p[MAXN];//记录i长度的钢条价格
int cut_rod(int n){
if(mem[n]) return mem[n];//对于已经处理过的值,直接返回记忆化数组的值
if(n==0) return 0;
int res=-INF;
for(int i=1;i<=n;i++){//对左边一段切下来的长度进行枚举,之后左边的便不再进行切割 。
res=max(res,p[i]+cut_rod(n-i));//递归继续处理右边的部分。
}
mem[n]=res;
//cout<<"res of "<<n<<" : "<<res<<endl;//调试
return res;
}
int main(){
int n;
cin>>n;
memset(mem,0,sizeof(mem));
for(int i=1;i<=n;i++){
cin>>p[i];//读入数据
}
cout<<cut_rod(n)<<endl;//输出长度为n的钢条切割最优值
}
通过中间的调试代码来输出中间值:
以上是递归方法求解最优钢条切割问题。
使用动态规划实现:
书上提到了两种方法:
1.带备忘的自顶向下法(发现这个就是记忆化
2.自底向上法
两种方法具有相同的渐进运行时间,但是由于自底向上没有频繁调用递归的开销,所以要更快一点。
代码:
#include<iostream>
#include<cstring>
#include<algorithm>
using namespace std;
#define INF 0x3f3f3f3f
#define MAXN 100000 //这里假设n最大为100000,视情况改变MAXN
int a[MAXN];//记录结果
int p[MAXN];//记录i长度的钢条价格
int main(){
int n;
cin>>n;
for(int i=1;i<=n;i++){
cin>>p[i];//读入数据
}
a[0]=0;
for(int i=1;i<=n;i++){//当前钢条长度
a[i]=0;
for(int j=1;j<=i;j++){//枚举分割方式
a[i]=max(a[i],p[j]+a[i-j]);//动态规划递推求解
}
//cout<<"res of "<<i<<" : "<<a[i]<<endl;//调试
}
cout<<a[n];
}
同样通过输出中间值,我们可以得到与递归同样的结果。
通过添加计时器,可以比较一下上面几种方法的时间。
(如果你不知道计时器,可以看这个帖子:https://blog.csdn.net/qq_40794973/article/details/81607896?depth_1-utm_source=distribute.pc_relevant.none-task&utm_source=distribute.pc_relevant.none-task )
子问题图
对于一个给定的子问题x,在求解它之前徐要求解邻接至它的子问题。邻接关系不一定是对称的。这种自底向上的动态规划算法是按“拓扑排序”或者“反序的拓扑排序”顺序来处理途中的顶点。
即对于一个问题,只有解决了它所依赖的所有子问题,才可以求解它。
而递归求解,则是一种类似dfs深度优先搜索的方法。
对于子问题图G=(V,E),我们该如何确定它的运行时间呢?
总时间等于每个子问题求解时间的总和(因为每个子问题之解决一次
对于通常的子问题,求解它的时间等于每个子问题顶点的出度(即有几条以它为起点的边
因此,通常情况下,动态规划的求解时间与其子问题图的顶点和边数量呈线性关系。
重构解
以刚刚的钢条切割为例,如果要返回最优的切割方案,我们该怎么做?
这时候我们就应该另外去保存对应的切割方案
即对长度j的钢条不仅计算其最大收益rj,也储存最优解的第一段钢条的长度sj
代码如下:
#include<iostream>
#include<cstring>
#include<algorithm>
using namespace std;
#define INF 0x3f3f3f3f
#define MAXN 100000 //这里假设n最大为100000,视情况改变MAXN
int a[MAXN];//记录结果
int p[MAXN];//记录i长度的钢条价格
int s[MAXN]={0};
void print_method(int x){//输出切割方案
while(x!=0){
cout<<s[x]<<' ';
x-=s[x];
}
cout<<endl;
}
int main(){
int n;
cin>>n;
for(int i=1;i<=n;i++){
cin>>p[i];//读入数据
}
a[0]=0;
for(int i=1;i<=n;i++){//当前钢条长度
a[i]=0;
for(int j=1;j<=i;j++){//枚举分割方式
if(a[i]<p[j]+a[i-j]){
s[i]=j;//记录解的分割方案
a[i]=p[j]+a[i-j];
}//动态规划递推求解
}
}
for(int i=1;i<=n;i++){
cout<<"res of "<<i<<" : ";
cout<<a[i]<<endl;
print_method(i);
}
}
样例运行结果:
这是算法导论的:
其实有点让我想到之前最短路问题Floyd法的记录路径方案:
即使用一个path数组来记录路径整体相对现在还没输出的第二个点。
在读入的时候
scanf("%d%d%d",&a,&b,&dist);
d[a][b] = dist;//储存原始边长度
path[a][b] = b;//对于原始的路径,从a到b的第二个点正好就是b
在更新d[i][j]时
if(d[i][k]!=-1&&d[k][j]!=-1&&d[i][k]+d[k][j]<d[i][j]){//松弛操作
d[i][j] = d[i][k] + d[k][j];
path[i][j] = path[i][k];
}
这步有点奇妙,两端路线合并时,相对于合并后总路径的第二个点就是前面一段路径的第二个点。
在输出时:
while(s!=e){
printf("%d ->",s);
s=path[s][e];
}
printf("%d",e);
仔细一想,当输出当前这个点s之后,现在剩余还没输出的路径就是去掉s之后剩下来的路径。即把起点s改成路径的第二个点就行了。
当然,最后也别忘了输出e