文章目录
动态规划
入门篇
1.1、概念
动态规划(dynamic programming,简称dp)是一种算法技术。于20世纪50年代由一位著名的美国数学家理查德-贝尔曼发明的。动态规划是要计划、规划计算的策略。
如果问题是由交叠的子问题构成的
,我们可以使用动态规划技术来解决它。一般子问题出现在对特定问题的求解的递归关系里面
。这个递推关系包含了相同类型的更小问题解。
1.2、爬梯子例子
学例子是最容易理解问题的。如图:
爬上第 1 级只有一种方法:直接爬 1 级即可。
爬上第 2 级有两种方法:每次爬 1 级,爬两次;或者一次爬 2 级。
作为一个优秀的理科生,首先我们肯定会想到用列举的方法找规律。
N(N>0) | 方法种类 |
---|---|
1 | 1 |
2 | 2 |
3 | 3 |
4 | 5 |
5 | 8 |
可以发现规律了,这个就像斐波那契数列。
当N>3,F(N)=F(N-1)+F(N-2);F(1)=1,F(2)=2
那动态规划是如何考虑的呢?
假设我们现在处于第N格梯子,那么上一步的可能情况是爬了1格,或者爬了两格,也就是我们上次的位置只能是
- 1、处于(N-1)格
- 2、处于(N-2)格
从而我们开始用变量表示状态:F(N)表示爬到第N个格子的方法数。很明显,从我们上面的考虑出发,假设我们是第一种情况,则F(N)=F(N-1);假设是第二种情况,则F(N)=F(N-2);
所以我们一样可以得出:
当N>2,F(N)=F(N-1)+F(N-2)————我们称为递推公式
当N=2,F(N)=2;N=1,F(N)=1————我们称为边界情况
所以我们要找出问题:爬上第N格的方法有几种——转化为子问题:爬上第N-1格子和爬上第N-2格的方法分别有多少种——转化为子子问题:。。。。。
可见动态规划是需要我们能将一个问题化为求解其子问题的过程
1.2、从算法角度理解自顶向下和自底向上
上述问题的算法实现:
1、递归:
int countMethods(int n){//输入的N>0
if(n==2)
return 2;
if(n==1)
return 1;
return countMethods(n-1)+countMethods(n-2);
}
图解计算机计算的过程:
自顶向下的方法就是从问题的顶部开始向下计算
存在的问题——大量重复的计算,如图中的f(8)、f(7)、f(6)。当问题规模变得很大,如100,那么这些重复计算会耗费大量的时间!。需要改善。我们必须把已经计算过的数据保存下来!这就有了带备忘录的自顶向下
,就是把计算过的结果保存下来,给下一次利用。
带备忘录的自顶向下
修改算法:
int a[1000]={0};//备忘录,默认输入的N<=1000
int countMethods(int n){//输入的N>0
if(n==2||n==1)
{
a[n]=n;//保存于备忘录
return a[n];
}
if(0!=a[n])//说明之前已经计算过了
return a[n];
int x = countMethods(n-1);
int y = countMethods(n-2;
a[n] = x+y;//保存于备忘录
return a[n];
}
观察两种实现的速度请参考:“备忘录/Memo”优化法
2、迭代递推:
int countMethods(int n){
if(n==1||n==2)
return n;
int f[n];
f[0]=0;
f[1]=1;
f[2]=2;
for(int i=1;i<n;i++){
f[i+2]=f[i+1]+f[i];
if(i+2==n)
return f[i+2];
}
}
从代码,我们可以很容易的理解
自底向上:我们直接从最底下,最简单,问题规模最小的 f(1) 和 f(2) 开始往上推,直到推到我们想要的答案 f(N),这就是动态规划的思路,这也是为什么动态规划一般都脱离了递归,而是由循环迭代完成计算。
类型 | 描述 | 快慢 |
---|---|---|
自顶向下 | 从问题的顶部开始,依赖于子问题的解决 | 慢 |
带备忘录的自顶向下 | 同样是自定向下,但是可以将子问题的结果保存利用,避免重复计算 | 快 |
自底向上 | 直接从最底下,最简单,问题规模最小开始往上递推,直到得到想要的结果 | 最快 |
算法导论15章:
1.3、什么时候用到动态规划来设计算法
- 一般用于求最优化问题(optimization problem),这类的问题可以有很多个解,每个解都有一个值。我们期望找到最优值的那个解。当然问题的最优解可能不止一个。
- 应用的问题通常是问题可以划分为子问题重叠的情况,大的子问题划分为小的子问题
- 无后效性。当前的若干个状态值一旦确定,则此后过程的演变就只和这若干个状态的值有关,和之前是采取哪种手段或经过哪条路径演变到当前的这若干个状态没有关系。
算法设计的步骤
(我认为的):
第一步:观察问题是否可以转化为交叠子问题的情况
第二步:如果是,则设置状态,用符号来表示问题
,如上面的f(n)表示爬上第N格子的方法种类。
第三步:依据状态之间的关系,写出递推方程,如F(N)=F(N-1)+F(N-2);
第四步:明确状态的边界情况,如F(1)=1,F(2)=2;将边界情况代入递推公式,确定状态转移方程。
第五步:使用自底向上的方法设计算法或者采用递归实现再优化效率
最重要的是找出状态如何表示、状态转移方程是怎么样的!。
1.4 动态规划的分类
动态规划一般可分为线性动规,区域动规,树形动规,背包动规四类。
类型 | 举例 |
---|---|
线性动规 | 拦截导弹,合唱队形,挖地雷,建学校,剑客决斗等; |
区域动规 | 石子合并, 加分二叉树,统计单词个数,炮兵布阵等; |
树形动规 | 贪吃的九头龙,二分查找树,聚会的欢乐,数字三角形等; |
背包问题 | 01背包问题,完全背包问题,分组背包问题,二维背包,装箱问题,挤牛奶(同济ACM第1132题)等; |
应用实例:
最短路径问题 ,项目管理,网络流优化等;
1.5 小结动态规划
- 动态规划程序设计是对解最优化问题的一种途径、一种方法,而不是一种特殊算法。不像搜索或数值计算那样,具有一个标准的数学表达式和明确清晰的解题方法。
动态规划程序设计往往是针对一种最优化问题,由于各种问题的性质不同,确定最优解的条件也互不相同,因而动态规划的设计方法对不同的问题,有各具特色的解题方法,而不存在一种万能的动态规划算法,可以解决各类最优化问题。 - 动态规划算法通常用于求解具有某种最优性质的问题。在这类问题中,可能会有许多可行解。每一个解都对应于一个值,我们希望找到具有最优值的解。
- 动态规划算法与分治法类似,其基本思想也是将待求解问题分解成若干个子问题,先求解子问题,然后从这些子问题的解得到原问题的解。
与分治法不同的是,适合于用动态规划求解的问题,经分解得到子问题往往不是互相独立的 若用分治法来解这类问题,则分解得到的子问题数目太多,有些子问题被重复计算了很多次。
如果我们能够保存已解决的子问题的答案,而在需要时再找出已求得的答案,这样就可以避免大量的重复计算,节省时间。我们可以用一个表来记录所有已解的子问题的答案。
不管该子问题以后是否被用到,只要它被计算过,就将其结果填入表中。这就是动态规划法的基本思路。具体的动态规划算法多种多样,但它们具有相同的填表格式。
进阶篇(疯狂做题总结经验)
1、LeetCode05、最长回文子串(区间DP)
必做题LeetCode05、最长回文子串
必看题解:动态规划、中心扩散、Manacher 算法
收获经验:
- 动态规划更加精准思考的路线:
- 关于动态规划的本质:暴力破解、填表格、空间换时间
- 更加深刻的理解无后效性和有后效性
注:图片来源于力扣题解
2、矩阵连乘问题
1、首先以穷举的思维来看:
2、我们从动态递归的思维来看:
第一步:从数学变量角度定义问题。
我们输入的是矩阵个数n,计算n个矩阵连乘最少次数。
设A[1,n]表示n(n>=1)个矩阵相乘;其连乘的最少次数记为m[1,j]
故我们可以得出子问题
A[i,j]表示Ai…Aj的连乘。1<=i<=j<=n;其连乘最少次数记为m[i,j];第二步:构建最优解
,m[1,n]则为问题的最优解的值第三步:思考最优解的性质,求得递推方程
因为一个矩阵必然可以写为两个矩阵相乘 即必有 A[i,j] = A[i,k]A[k+1,j];i<=k<j
也就是说m[i,j] = m[i,k]+m[k+1,j]+ 两个矩阵相乘的次数
则构建最优解与递推的联系:从理解上看,存在一个k0,使得A[i,j]=A[i,k0]A[k0+1,j]的连乘次数最少。也就是说在所有的k的可能取值里边,有一个k0可以取到m[i,j],最少的连乘次数。
边界情况:当i=j,有A[i,i]就是一个矩阵,不用连乘,即m[i,i]=0。第四步:写出递推方程
第五步:我们还需要解决如何表示上述的各个量
输入:n,矩阵规模
如何处理两个矩阵相乘的次数呢?
代码实现:
#include<stdio.h>
#include<stdlib.h>
long caculatorTime(int i,int j,int *p){
if(i==j)
return 0;
long u = caculatorTime(i,i,p)+caculatorTime(i+1,j,p)+p[i-1]*p[i]*p[j];//默认最小值为k=i的时候。
for(int k=i;k<j;k++){
long m1 = caculatorTime(i,k,p)+caculatorTime(k+1,j,p)+p[i-1]*p[k]*p[j];
if(m1<u){
u=m1;
}
}
return u;
}
int main(){
int n,row,col;//矩阵的个数
printf("输入矩阵的个数:\n") ;
scanf("%d",&n);
printf("输入矩阵的规模:\n");
int p[n][2];
for(int i=0;i<n;i++){
scanf("%d %d",&row,&col);//输入矩阵规模 row*col
p[i][0]=row;
p[i][1]=col;
}
int *q;//构建数组存放用于计算两个矩阵连乘次数的值
q=(int*)malloc(sizeof(int)*(n+1));
for(int i=0;i<n;i++){
q[i]=p[i][0];
}
q[n]=p[n-1][1];
for(int j=0;j<n+1;j++){
printf("%d ",q[j]);
}
printf("\n");
printf("最小连乘次数为:%ld",caculatorTime(1,n,q));
return 0;
}
明显,我们存在大量重复计算的子问题。如图所示:
添加了备忘录的自顶向下:
#include<stdio.h>
#include<stdlib.h>
int s[10][10]={0};//备忘录。 默认最大10个矩阵连乘
long caculatorTime(int i,int j,int *p){
if(i==j)
return 0;
if(s[i][j]!=0)//不等于0,表示已经计算过了。直接返回。
return s[i][j];
long u = caculatorTime(i,i,p)+caculatorTime(i+1,j,p)+p[i-1]*p[i]*p[j];//默认最小值为k=i的时候。
for(int k=i;k<j;k++){
long m1 = caculatorTime(i,k,p)+caculatorTime(k+1,j,p)+p[i-1]*p[k]*p[j];
if(m1<u){
u=m1;
}
}
s[i][j]=u;//将计算结果存储下来。表示m[i,j]的计算结果
return u;
}
int main(){
int n,row,col;//矩阵的个数
printf("输入矩阵的个数:\n") ;
scanf("%d",&n);
printf("输入矩阵的规模:\n");
int p[n][2];
for(int i=0;i<n;i++){
scanf("%d %d",&row,&col);//输入矩阵规模 row*col
p[i][0]=row;
p[i][1]=col;
if(i>0&&p[i-1][1]!=row){//Ai-1矩阵的列不等于Ai矩阵的行无法相乘
printf("输入的矩阵无法相乘!请检查行列值!");
exit(0);
}
}
int *q;//构建数组存放用于计算两个矩阵连乘次数的值
q=(int*)malloc(sizeof(int)*(n+1));
for(int i=0;i<n;i++){
q[i]=p[i][0];
}
q[n]=p[n-1][1];
printf("矩阵相乘的计算值为:");
for(int j=0;j<n+1;j++){
printf("%d ",q[j]);
}
printf("\n");
printf("最小连乘次数为:%ld",caculatorTime(1,n,q));
return 0;
}
迭代法自底向上:
//n矩阵连乘的个数,设其最大为10,p为规模系数
long caculatorTime(int n,int *p){
long m[10][10]={0};//m[i,j]的建立
for(int l=2;l<=n;l++)//l表示连乘的矩阵个数,如m[i,i]表示连乘矩阵为1,我们需要自底向上计。
for(int i=1;i<=n-l+1;i++){//如l=1,则n-l+1=n,则【1,1】【2,2】【3,3】都是1个矩阵 不用计,所以我们应该从l=2开始。
int j = i+l-1;//m[i,j]表示所有的情况。
m[i][j] = m[i+1][j]+p[i-1]*p[i]*p[j];
for(int k=i+1;k<j;k++){
long u = m[i][k]+m[k+1][j]+p[i-1]*p[k]*p[j];
if(m[i][j]>u)
m[i][j]=u;
}
}
return m[1][n];
}
算法复杂度分析:
如果要输出怎么分割计算的:
完整代码:
#include<stdio.h>
#include<stdlib.h>
#include<iostream>
using namespace std;
int memory[10][10]={0};//备忘录。
long caculatorTime(int i,int j,int *p,int **s){
if(i==j)
return 0;
if(memory[i][j]!=0)//不等于0,表示已经计算过了。直接返回。
return memory[i][j];
long u = caculatorTime(i,i,p,s)+caculatorTime(i+1,j,p,s)+p[i-1]*p[i]*p[j];//默认最小值为k=i的时候。
s[i][j] = i;
for(int k=i+1;k<j;k++){
long m1 = caculatorTime(i,k,p,s)+caculatorTime(k+1,j,p,s)+p[i-1]*p[k]*p[j];
if(m1<u){
u=m1;
s[i][j]=k;//求m[i,j]是在k处分割得最小连乘次数。
}
}
memory[i][j]=u;//将计算结果存储下来。表示m[i,j]的计算结果
return u;
}
//n矩阵连乘的个数,设其最大为10,p为规模系数
long caculatorTime(int n,int *p,int **s){
long m[10][10]={0};//m[i,j]的建立
for(int l=2;l<=n;l++)//l表示连乘的矩阵个数,如m[i,i]表示连乘矩阵为1,我们需要自底向上计。
for(int i=1;i<=n-l+1;i++){//如l=1,则n-l+1=n,则【1,1】【2,2】【3,3】都是1个矩阵 不用计,所以我们应该从l=2开始。
int j = i+l-1;//m[i,j]表示所有的情况。
m[i][j] = m[i+1][j]+p[i-1]*p[i]*p[j]; //m[i,i]=0所以不用加。
s[i][j] = i;
for(int k=i+1;k<j;k++){
long u = m[i][k]+m[k+1][j]+p[i-1]*p[k]*p[j];
if(m[i][j]>u){
m[i][j]=u;
s[i][j]=k;//求m[i,j]是在k处分割得最小连乘次数。
}
}
}
return m[1][n];
}
void Traceback(int i,int j,int **s)
{
if(i==j) return;
Traceback(i,s[i][j],s);
Traceback(s[i][j]+1,j,s);
cout<<"Multiply A"<<i<<","<<s[i][j];
cout<<" and A"<<(s[i][j]+1)<<","<<j<<endl;
}
int main(){
int n,row,col;//矩阵的个数
printf("输入矩阵的个数:\n") ;
scanf("%d",&n);
printf("输入矩阵的规模:\n");
int p[n][2];
for(int i=0;i<n;i++){
scanf("%d %d",&row,&col);//输入矩阵规模 row*col
p[i][0]=row;
p[i][1]=col;
if(i>0&&p[i-1][1]!=row){//Ai-1矩阵的列不等于Ai矩阵的行无法相乘
printf("输入的矩阵无法相乘!请检查行列值!");
exit(0);
}
}
int *q;//构建数组存放用于计算两个矩阵连乘次数的值
q=(int*)malloc(sizeof(int)*(n+1));
for(int i=0;i<n;i++){
q[i]=p[i][0];
}
q[n]=p[n-1][1];
int **s = new int *[n+1];
for(int i=0;i<n+1;i++)
{
s[i] = new int[n+1];
}
printf("矩阵相乘的计算值为:");
for(int j=0;j<n+1;j++){
printf("%d ",q[j]);
}
printf("\n");
printf("递归法最小连乘次数为:%ld",caculatorTime(1,n,q,s));
Traceback(1,n,s);
// printf("迭代法最小连乘次数为:%ld",caculatorTime(n,q,s));
return 0;
}
填表例子:
3、最大子段和问题
暴力法:遍历所有的子段,求和最大的那个子段。
#include<iostream>
#include<cstdio>
using namespace std;
int main(){
int *a,n;
cout<<"请输入整数个数:"<<endl;
cin>>n;
cout<<"请输入"<<n<<"个整数:"<<endl;
for(int i=0;i<n;i++){
cin>>a[i];
}
int max=0,besti=0,bestj=0;
for(int i=0;i<n;i++){//以a[i]开头的子段求和,取最大值
int sub= 0;
for(int j=i;j<n;j++){//a[i]~a[j]子段
sub+=a[j];
if(sub>max){
max = sub;
besti = i+1;
bestj = j+1;//第几个数
}
}
}
cout<<"besti: "<<besti<<" bestj: "<<bestj<<" max : "<<max;
return 0;
}
时间复杂度为O(n2)
分治法
思考:
代码实现:
int MaxSubsum(int *a,int left,int right){
int sum=0;
if(left==right)
sum=a[left]>0?a[left]:0;
else{
//位于左边的一半|或者位于右边的一半
int center = (left+right)/2;
int leftsum = MaxSubsum(a,left,center);
int rightsum = MaxSubsum(a,center+1,right);
//位于中间部分
//先求左边的最大值
int s1=0,lefts=0;
for(int i=center;i>=left;i--){
s1+=a[i];
if(leftsum<s1){
lefts = s1;
}
}
//右边最大值
int s2=0,rights=0;
for(int i=center+1;i<=right;i++){
s2+=a[i];
if(rightsum<s2){
rights = s2;
}
}
//求三者中的最大值
sum = rights+lefts;
if(sum<leftsum) sum=leftsum;
if(sum<rightsum) sum = rightsum;
}
return sum;
}
复杂度分析:
动态规划法
分析:
int MaxSubsum(int *a,int n){
int b=0,sum=0;//b表示当前子段的总和。
//sum用于记录和的子段最大值
for(int i=0;i<n;i++){
if(b>0) b+=a[i];//如果大于0,则表示b有成为最大子段和的希望。
else b=a[i];//小于0,表示上一段的总和b不可能成为最大子段和了。只能计算下一段了。
if(b>sum)//记录当前子段和最大值
sum = b;
}
return sum;
}
//递推形式
int MaxSubsum(int *a,int n){
int b[n];
b[0]=a[0];
int max=b[0];
for(int i=1; i<n; i++)
{
if(b[i-1]>0)
b[i]=b[i-1]+a[i];
else
b[i]=a[i];
if(b[i]>max)
max=b[i];
}
return max;
}
4、装配线调度问题
图解问题
#include<iostream>
using namespace std;
int a[3][100]; //a[1][j]表示底盘在装配线s[1][j]所用时间
int t[3][100]; //t[1][j]表示底盘从s[1][j]移动到s[2][j+1]所用时间
int n;//装配站的数目
int e1,e2; //进入装配线1,2时间
int x1,x2;// 离开时间
int f1[100],f2[100];
int L1[100],L2[100];
//L1[j]记录第一条装配线上,最优解时第j个装配站的前一个装配站是第一条线还是第二条线上
int f,L; //最优解是f,最小花费时间,L代表最后是从哪里出来的
void fastest_way()
{
f1[1]=e1+a[1][1];
f2[1]=e2+a[2][1];
for(int j=2;j<=n;j++)
{
if((f1[j-1]+a[1][j])<(f2[j-1]+t[2][j-1]+a[1][j]))
{
f1[j]=f1[j-1]+a[1][j];
L1[j]=1;
}
else
{
f1[j]=f2[j-1]+t[2][j-1]+a[1][j];
L1[j]=2;
}
if((f2[j-1]+a[2][j])<(f1[j-1]+t[1][j-1]+a[2][j]))
{
f2[j]=f2[j-1]+a[2][j];
L2[j]=2;
}
else
{
f2[j]=f1[j-1]+t[1][j-1]+a[2][j];
L2[j]=1;
}
}
if((f1[n]+x1)<=(f2[n]+x2))
{
f=f1[n]+x1;
L=1;
}
else
{
f=f2[n]+x2;
L=2;
}
}
void print_station()
{
int i=L;
cout<<endl<<"line "<<L<<" Station "<<n<<endl;
for(int j=n;j>=2;j--)
{
if(i==1)
i=L1[j];
else
i=L2[j];
cout<<"line "<<i<<" Station "<<j-1<<endl;
}
}
int main()
{
freopen("station.txt","r",stdin); //可以有文件来输入
cout<<"请输入装配站的数目\n";
cin>>n;
cout<<"请输入进入装配线1,2所需时间";
cin>>e1>>e2;
cout<<"请输入离开装配线1,2所需时间";
cin>>x1>>x2;
cout<<"输入装配线1上各站加工时所需时间a1[j]";
for(int j=1;j<=n;j++)
cin>>a[1][j];
cout<<"输入装配线2上各站加工时所需时间a1[j]";
for(int j=1;j<=n;j++)
cin>>a[2][j];
cout<<"请输入装配线1上的站到装配线2上的站所需时间t[1][j]";
for(int j=1;j<n;j++) //j<n
cin>>t[1][j];
cout<<"请输入装配线2上的站到装配线1上的站所需时间t[2][j]";
for(int j=1;j<n;j++) //j<n
cin>>t[2][j];
fastest_way();
print_station();
return 0;
}
5、LeetCode1143、最长公共子序列问题
题目描述:
给定两个字符串 text1 和 text2,返回这两个字符串的最长公共子序列的长度。
一个字符串的 子序列 是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。
例如,“ace” 是 “abcde” 的子序列,但 “aec” 不是 “abcde” 的子序列。两个字符串的「公共子序列」是这两个字符串所共同拥有的子序列。
若这两个字符串没有公共子序列,则返回 0。
示例 1:
输入:text1 = “abcde”, text2 = “ace”
输出:3
解释:最长公共子序列是 “ace”,它的长度为 3。
示例 2:
输入:text1 = “abc”, text2 = “abc”
输出:3
解释:最长公共子序列是 “abc”,它的长度为 3。
示例 3:
输入:text1 = “abc”, text2 = “def”
输出:0
解释:两个字符串没有公共子序列,返回 0。
提示:
1 <= text1.length <= 1000
1 <= text2.length <= 1000
输入的字符串只含有小写英文字符。
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/longest-common-subsequence
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。
6、0-1背包问题
问题描述:
代码:
#include<iostream>
using namespace std;
n物品个数;V背包容量;a3维数组,存储物品编号、物品重量、物品价值
void SelectMaxValue(int n,int V,int(*a)[3],int (*f)[100]){
for(int i=1;i<=n;i++){
for(int j=1;j<=V;j++){
if(j<a[i-1][1])//装不下第i件
f[i][j]=f[i-1][j];
else{//装得下第i件,不代表一定要装才价值大
int temp1 = f[i-1][j];//不装
int temp2 = f[i-1][j-a[i-1][1]]+a[i-1][2];//装
if(temp1>temp2)
f[i][j]=temp1;
else{
f[i][j]=temp2;
}
}
}
}
cout<<"选取的编号是:";
for(int i=n,j=V;i>0;i--){
if(f[i][j]==f[i-1][j-a[i-1][1]]+a[i-1][2]){
cout<<a[i-1][0]<<" ";
j-=a[i-1][1];
}
}
// for(int i=0;i<=n;i++){
// for(int j=0;j<=V;j++)
// printf("%4d\t",f[i][j]);
// }
cout << endl << "选取的最大价值是:" << f[n][V] << endl;
}
int main(){
int n,W;
cout<<"输入物品个数、背包总容量:"<<endl;
cin>>n>>W;
int a[n][3];
int f[100][100]={0};//默认
cout<<"输入物品编号、物品重量、物品价值:"<<endl;
for(int i=0;i<n;i++){
cin>>a[i][0]>>a[i][1]>>a[i][2];
}
SelectMaxValue(n,W,a,f);
return 0;
}