动态规划Dynamic Programming

目录

一、动态规划的实现方式

二、动态规划解决的问题

1.背包问题

01背包

完全背包

多重背包

分组背包

二维费用背包

树形背包

背包装不装满

2.计数问题

 

3.最优化问题

线性dp

区间dp

树型dp

三、动态规划的优化方式

1.空间上优化

改为滚动数组

改为一维数组

四、消除后效性


 

        分治算法会做许多不必要的工作,它会反复地求解那些公共子子问题。而动态规划算法对每个子子问题只求解一次,将其解保存在一个表格中,从而无需每次求解一个子子问题时都重新计算,避免了这种不必要的计算工作。                         ————《算法导论》

这是我数年前写的动态规划博客,可以看看:【动态规划】令你战栗的神奇算法:动态规划基础

下文将只谈重点内容

动态规划简称dp,有三大性质:

1.重叠子问题

2.最优子结构

3.无后效性

那么动态规划全部的难度在于合理的看待问题,使其具备三大性质

合理地设计状态,并写下状态转移方程,那么问题就算是解决了(因为这两步是核心步骤)

普通动态规划能够较好的满足三大性质,高等的动态规划就是学习不满足三点的情况下如何转化成具备三大性质的样子

其实平时见题看见的都完全满足不了三大性质的样子(尤其是无后效性),现在的题基本都是一眼看上去全是后效,那么动态规划的种种手段,着重在于对抗后效性

俗称“万物皆可dp”

 

一、动态规划的实现方式

就两种,记忆化搜索和数组递推

两种方式同属于动态规划,可以认为时间复杂度相同,常数不同

记忆化搜索自顶向下,符合人的思考习惯,缺点是大量调用函数,常数大容易被卡,且出问题不容易调试

数组递推自底向上,大多数记忆化搜索可以转化为数组递推,少数改成数组递推非常困难

下面展示一个看起来思路完全是记忆化搜索的题改写为数组递推的例子

例题:P1434 [SHOI2002] 滑雪https://www.luogu.com.cn/problem/P1434

这个题本身就是搜索的思路,所以第一想法用记忆化搜索

我设计的状态是 f ( i , j ) 表示以(i,j)为起点最多能走过的点的数目

dd64d23459a8491ca9009be5a4fa5851.png900ed0febdf94685972d69778a63999c.png

无需担心重复的路径(如图所示),这不可能,因为我永远走下坡路

我最开始的代码有WA掉的,如果你也是,检查一下代码能否处理数据中含有0的情况(我最开始是对拍发现处理不了0的,然后改了)

#include<iostream>
using namespace std;
int f[1001][1001];
int a[1001][1001];
int l,r;
int dfs(int x,int y)
{
	if(f[x][y]) return f[x][y];
	int m=1;
	if(a[x][y+1]<a[x][y]&&y!=r)
	{
		int k=dfs(x,y+1)+1;
		m=m>k? m:k;
	}
	if(a[x+1][y]<a[x][y]&&x!=l)
	{
		int k=dfs(x+1,y)+1;
		m=m>k? m:k;
	}
	if(a[x][y-1]<a[x][y]&&y!=1)
	{
		int k=dfs(x,y-1)+1;
		m=m>k? m:k;
	}
	if(a[x-1][y]<a[x][y]&&x!=1)
	{
		int k=dfs(x-1,y)+1;
		m=m>k? m:k;
	}
	f[x][y]=m;
	return m;
}
int main()
{
	cin>>l>>r;
	for(int i=1;i<=l;i++)
	{
		for(int j=1;j<=r;j++)
		cin>>a[i][j];
	}
	int ans=0;
	for(int i=1;i<=l;i++)
	{
		for(int j=1;j<=r;j++)
		{
			int sum=dfs(i,j);
			ans=ans>sum? ans:sum;
		}
		
	}
	cout<<ans;
	return 0;
}

整个函数是搜索,其中f[x][y]=m这句话是“记忆化”(如果这句话注释掉会远远慢于现在的程序),这句话相当于保存历史记录

由于我用的是深搜,所以每次f[x][y]刷新即已为最优结果(局部最优解),最后只需所有f[x][y]中找出最大的那一个就是最终结果(全局最优解),这同时反映着“最优子结构:全局最优解可以由局部最优解推导出来”

这道题可否改成递推?答案是可以的,但是不太方便

因为如果想要一步到位,必须用深搜,如果想要递推,又得想办法做到无后效性想清楚从哪里推过来

为了做到无后效性,那么对于数据必须排序,但是排序会丢失相对位置,怎么办?

两种方法,排序前记录下状态转移的关系+间接排序

 

排序前记录状态转移关系就能防止后效性

其实无论怎么乱搞时间复杂度并不升高(只是常数变了),有点像《时光代理人》中的一句话:“只要关键的时间节点不发生变化,那么就不会对未来造成大的影响”

那么同样 O ( neq?%5E%7B2%7D) 的时间复杂度,记录一下状态转移关系,排序完dp就完事了

现在我设计的状态是f(i)表示,无论怎么走,到编号为i的点时结束,走过的最长路径长度是f(i)

代码如下:

#include<iostream>
#include<algorithm>
using namespace std;
int l,r;
int name[10001],a[10001];
int edge[10001][10001];
int path[10001][5];
int f[10001];
bool cmp(int i,int j)
{
	if(a[i]>a[j]) return true;
	return false;
}
int main()
{
	cin>>l>>r;
	for(int i=1;i<=l;i++)
	{
		for(int j=1;j<=r;j++)
		{
			cin>>edge[i][j];//存图
		}
	}
	int cnt=0;
	for(int i=1;i<=l;i++)
	{
		for(int j=1;j<=r;j++)
		{
			cnt++;
			a[cnt]=edge[i][j];//改成一维数组才能排序
			name[cnt]=cnt;//给每个点编号
		}
	}
	for(int i=1;i<=l;i++)
	{
		for(int j=1;j<=r;j++)
		{
			int k=0;
			if(i!=1&&edge[i-1][j]>edge[i][j]){
				path[(i-1)*r+j][++k]=((i-1)-1)*r+j;
			}
			if(i!=l&&edge[i+1][j]>edge[i][j]){
				path[(i-1)*r+j][++k]=((i-1)+1)*r+j;
			}
			if(j!=1&&edge[i][j-1]>edge[i][j]){
				path[(i-1)*r+j][++k]=(i-1)*r+j-1;
			}
			if(j!=r&&edge[i][j+1]>edge[i][j]){
				path[(i-1)*r+j][++k]=(i-1)*r+j+1;
			}
		}//path存储每个节点由谁转移过来,注意是过来不是过去
	}
	sort(name+1,name+cnt+1,cmp);//由大到小排序
	int ans=0;
	for(int i=1;i<=cnt;i++)
	{
		f[name[i]]=1;
		for(int j=1;j<=4;j++)
		{
			if(path[name[i]][j]) f[name[i]]=max(f[name[i]],f[path[name[i]][j]]+1);
		}
		ans=max(ans,f[name[i]]);
	}
	cout<<ans;
	return 0;
}

这样写的看起来长,但是很整齐、有条理,当然也可以不事先记录状态转移,把判定扔到dp那一堆里面去,如下:

#include<iostream>
#include<algorithm>
using namespace std;
int l,r;
int a[10001];
int f[10001];
int name[10001];
bool cmp(int i,int j)
{
	if(a[i]>a[j]) return true;
	return false;
}
int main()
{
	cin>>l>>r;
	int cnt=0;
	for(int i=1;i<=l;i++)
	{
		int x;
		for(int j=1;j<=r;j++)
		{
			cin>>x;
			cnt++;
			a[cnt]=x;
			name[cnt]=cnt;
		}
	}
	sort(name+1,name+cnt+1,cmp);//间接排序
	int ans=0;
	for(int i=1;i<=cnt;i++)//dp
	{
		f[name[i]]=1;
		if((name[i]-1)%r&&a[name[i]-1]>a[name[i]]) f[name[i]]=max(f[name[i]],f[name[i]-1]+1);
		if((name[i])%r&&a[name[i]+1]>a[name[i]]) f[name[i]]=max(f[name[i]],f[name[i]+1]+1);
		if(name[i]+r<=cnt&&a[name[i]+r]>a[name[i]]) f[name[i]]=max(f[name[i]],f[name[i]+r]+1);
		if(name[i]-r>=1&&a[name[i]-r]>a[name[i]]) f[name[i]]=max(f[name[i]],f[name[i]-r]+1);
		ans=max(ans,f[name[i]]);
	}
	cout<<ans;
	return 0;
}

 

二、动态规划解决的问题

一共两类,最优化问题和计数问题,最优化问题居多,其中最常见为背包问题

1.背包问题

推荐背包九讲文件:点此下载

设计一个状态,那么首先应该思考如何能将一个状态唯一确定,背包问题里面最常见的词有点什么?纠结的东西始终只有两样:容量和价值

所以背包问题的状态设计是二维的,兼顾容量和价值,还需要符合三大性质,

那么往往设计状态为:f ( i , j ) 表示选择1~i个物品且背包容量为j时能取走的最大价值

对于条件上的差异区分出了七种背包如下:

 

01背包

每个物品只有一个

设计状态如上所述,状态转移方程思路就是选还是不选比一下,初始条件是背包容量0的时候什么都不选,数组默认值0就好,不用处理

模板题:P1048 [NOIP2005 普及组] 采药https://www.luogu.com.cn/problem/P1048

如果没有任何优化,那么代码如下:

#include<iostream>
using namespace std;
int f[101][1001];
int main()
{
	int T,n;
	cin>>T>>n;
	for(int i=1;i<=n;i++)
	{
		int t,v;
		cin>>t>>v;
		for(int j=1;j<=T;j++)
		{
			if(j>=t) f[i][j]=max(f[i-1][j],f[i-1][j-t]+v);//看能不能放下,数组下标不能为负
			else f[i][j]=f[i-1][j];
		}
	}
	cout<<f[n][T];
	return 0;
}

这个二维数组利用效率,可以空间优化,滚动数组和一维数组请移步后文

还有模板题,可以用于练习:P1049 [NOIP2001 普及组] 装箱问题

 

完全背包

每个物品无限多个

但是写出来代码和01很像很像,看下面两张图吧

fa87b35f271a4b34bb3e9bb83d8c891e.pngc5fc23d079154b28ae6d3b84dbc89600.png

 你能认出来哪个是01背包,哪个是完全背包吗?

答案是第一张01背包(一维数组),第二张完全背包,区别只有一个刷新顺序是从左到右还是从右到左

从右到左是不断使用上一次的数据,是01背包

从左到右是这一轮刚刷新即调用,符合无限拿,是完全背包

完全背包是理想状态,无限取,所以和物品没多大关系,无需物品选用状态即可确定下来状态,所以设计状态是一维

01背包得看拿了什么了,所以要唯一确定下来状态,必须兼顾选了什么物品用走多大容量,所以设计状态是二维的(改写成一维数组属于是优化空间占用的小技巧,但是要明白它本来是二维的状态)

但是无论01还是完全,得到的f都是允许背包不完全充满的(这句话没什么用但是不能不想清楚)

还记得之前的“采药”吗?洛谷还有一个“疯狂的采药”

完全背包模板题:P1616 疯狂的采药

#include<iostream>
using namespace std;
long long f[10000001];
int main()
{
	int T,m;
	cin>>T>>m;
	for(int i=1;i<=m;i++)
	{
		int t;
		long long v;
		cin>>t>>v;
		for(int j=1;j<=T;j++)
		{
			if(j-t>=0)
			f[j]=max(f[j],f[j-t]+v);
		}
	}
	cout<<f[T];
	return 0;
}

由于太疯狂了,所以必须用long long了

完全背包和01背包是基础,如果有一定能力,以下背包完全可以自行推导

 

多重背包

现在既不是完全,也不是01,每个物品可以拿给定的数字Ci个,那么怎么做?

可以把Ci个物品拆分开,变成一个01背包问题,那么时间复杂度增长多少?

如果要凑出一个容量V,那么就可以一模一样的物品Ci中取ci个,这是组合数的运算,组合数的公式中含有阶乘,可想而知这是非常冗余的

有没有什么办法能够利用Ci这个重复性来使得最终程序优于01背包?

有的,要延续之前的思路,并且还要不失去关键信息

做法是把Ci分解成2的幂之和(价值翻幂的倍数),这样可以凑出0~Ci的任意数字,存进去的物品数将会是log Ci,大大优化了程序

模板题:P1776 宝物筛选

#include<iostream>
using namespace std;
int v[100001],w[100001];
int cnt=0;
int f[100001];
int main()
{
	int n,W;
	cin>>n>>W;
	for(int i=1;i<=n;i++)
	{
		int value,weight,m;
		cin>>value>>weight>>m;
		int j,sum=0;
		for(j=1;sum+j<=m;j*=2)
		{
			v[++cnt]=value*j;
			w[cnt]=weight*j;
			sum+=j;
		}
		if(sum<m){
			v[++cnt]=value*(m-sum);
			w[cnt]=weight*(m-sum);
		}
	}//重新导入物品
	for(int i=1;i<=cnt;i++)
	{
		for(int j=W;j>=w[i];j--)
		{
			f[j]=max(f[j],f[j-w[i]]+v[i]);
		}
	}//01背包
	cout<<f[W];
	return 0;
}

 

分组背包

买的东西是分组的,组内只能挑一个或者几个,是分组背包

例题:P1064 [NOIP2006 提高组] 金明的预算方案

这题有从属关系,依赖于主件,看起来是树形背包,但是其实无所谓,因为附件不会再有附件了

所以使用分组背包就行,注意好设计的状态是什么,状态怎么转移过来的就行

数据规模松松垮垮的,常数大点无所谓

#include<iostream>
using namespace std;
int v[61][3],p[61][3],size[61];
int f[61][32001];
int main()
{
	int n,m;
	cin>>n>>m;
	for(int i=1;i<=m;i++)
	{
		int x,y,q;
		cin>>v[i][0]>>p[i][0]>>q;
		if(q) 
		{
			v[q][++size[q]]=v[i][0];
			p[q][size[q]]=p[i][0];
			v[i][0]=0;
			p[i][0]=0;//注销物品
		}
	}
	
	int last=0;
	for(int i=1;i<=m;i++)
	{
		if(!v[i][0]) continue;
		for(int k=0;k<=size[i];k++)
		{
		for(int j=n;j>=1;j--)
		{
			if(k==0)
			{
				if(j-v[i][k]>=0) 
				{
					f[i][j]=max(f[last][j],f[last][j-v[i][k]]+v[i][k]*p[i][k]);
				}
				else f[i][j]=f[last][j];
			}//0节点负责继承
			else
			{
				int newv=j-v[i][0];
				int value0=v[i][0]*p[i][0];
				if(newv-v[i][k]>=0) //配件负责本行覆盖刷新
				{
					f[i][j]=max(f[i][j],f[last][newv-v[i][k]]+value0+v[i][k]*p[i][k]);
					//为什么这里的last换成i后错误?
				}
				if(size[i]==2)
				{
					if(newv-v[i][1]-v[i][2]>=0)
					f[i][j]=max(f[i][j],f[last][newv-v[i][1]-v[i][2]]+value0+v[i][1]*p[i][1]+v[i][2]*p[i][2]);
					
				}
			}	
		}
		}
		last=i;
	}
	cout<<f[last][n];
	return 0;
}


注释里面有一个问句,读者可以自行思考一下,我非要用i会怎样?

答案是一个既完全背包又01背包的尴尬状态,在更新附件的时候主件可以被重复选择(在我的代码中主件至多可能被选择2次)

所以一定要注意:

1.完全背包从本行数据转移而来,01背包从上行数据转移而来

2.01背包的实现方式当中,二维数组与i-1绑定(与刷新方向从左到右还是从右到左无关),一维数组与刷新方向从右到左绑定

 

二维费用背包

这个和之前的背包很像,只是有两个维度的费用(例如同时考虑体积和重量)

设计状态提高一个维度即可,f(i,j,k)表示选取1~i,体积j,承重k的背包所能带走的最大价值

状态转移方程:f (i,j,k) = max { f(i-1,j,k) , f(i-1,j-z(i),k-w(i)) + v(i) }

 

树形背包

当选东西存在依赖关系时,是树形背包

想法是要么选这个节点,然后可以转移去其子节点,或者不选它,转移去(先序下的)右边一棵子树

那么就采用dfs序(也是先序)来存储树,如何转移,先序的一个特点是每个子树的所有节点在一个连续区间内,原理是因为深度优先搜索,所以只要回溯时标记区间的结束位置(开始位置就是自己的位置)即可

现在说的是转移去的方向,是自顶向下的,那么转化为数组递推,应该改过来,从叶子节点向根节点去推,有点点像拓扑排序的感觉,先序遍历都是子树的根节点放在最前面的,所以天然就是“排好序”的,只要倒序操作即可

那么设计状态:f(i,j)表示使用物品(先序列表中的)i~n,容量j的最大价值

状态转移方程:f(i,j) = max { f(r(i)+1,j) , f(i+1,j-w(i)) + v(i) }

模板题:P2014 [CTSC1997] 选课

#include<iostream>
using namespace std;
int pre[301],s[301];
int son[301][301];int cnt_son[301];
int r[301];
int tree[301],id[301];
int cnt=-1;
int f[301][301];
void dfs(int x)//先序遍历
{
	tree[++cnt]=x;//弄进数组
	id[x]=cnt;//记录数值对应的编号
	for(int i=1;i<=cnt_son[x];i++)
	dfs(son[x][i]);
	r[id[x]]=cnt;//起始编号对应的结束编号
}
int main()
{
	int n,M;
	cin>>n>>M;
	for(int i=1;i<=n;i++)
	{
		cin>>pre[i]>>s[i];
		son[pre[i]][++cnt_son[pre[i]]]=i;//记录子节点
	}
	dfs(0);
	for(int i=n;i>=0;i--)
	{
		for(int j=M+1;j>=1;j--)
		{
			f[i][j]=max(f[r[i]+1][j],f[i+1][j-1]+s[tree[i]]);//dp
		}
	}
	cout<<f[0][M+1];
	return 0;
}

注意,因为这个题当中必修课前导为0(可以理解为一个超级根节点),但是这个0课程不是真实存在的,但是无论怎么选都要选进去这个0课程,所以就当成容量多了一个,答案是f[0][M+1]

代码可以自己看着微调,不一定这样书写

 

背包装不装满

一种处理方式是f[i]初始化不同,

如果要求必须装满的最大价值,那么就是f[0]=0,其余全部初始化为负无穷

如果要求可以不装满的最大价值,那么就是f数组全部初始化为0

为什么?可以不装满是平时使用的很好理解,对于必须装满可以这样理解,背包容量0的时候确实天然就是装满的,所以初始值0(如果这个你也设置成负无穷,想想状态转移时会出点什么问题),其他设置成负无穷意味着,我宁可不要这个背包(起始即破产),也不要装不满

另一种方式是精确的判断能不能恰好装满,比较麻烦,在本篇其他题中已有体现

 

2.计数问题

计数问题通常是比较好转移的,只要别数多了或者数少了就行(要是数多了或者少了改的相当头疼)

不过懂dp原理难度还好

例题:

P1002 [NOIP2002 普及组] 过河卒

P1164 小A点菜

过河卒没什么说的,甚至状态转移方程题上都告了你了,所以浅说一下小A点菜

我们之前说的背包都是容量可以不全部充满的,但是现在小A想把钱全花掉(不允许有剩余)

对于背包问题,f(j)表示j容量最多的价值

对于计数问题,f(j)表示j容量恰好的方案数

不同点在于

对于背包问题,我选择每次判断只要能装就进行比较塞进去还是不塞进去

对于计数问题,我选择只有恰好塞进去时塞进去(算是起始状态吧),每次塞进去只负责列式,不负责上价值

代码如下:

#include<iostream>
#include<algorithm>
using namespace std;
long long f[10001];
long long a[101];
int main()
{
	int n,m;
	cin>>n>>m;
	for(int i=1;i<=n;i++)
	cin>>a[i];
	sort(a+1,a+1+n);
	for(int i=1;i<=n;i++)
	{
		for(int j=m;j>=a[i];j--)
		{
			if(j-a[i]==0) f[j]++;
			f[j]=max(f[j],f[j-a[i]]+f[j]);//由于f默认是0,所以加不加判断无所谓
		}
	}
	cout<<f[m];
	return 0;
}

P1077 [NOIP2012 普及组] 摆花

不是所有的dp都需要max/min函数,计数问题直接加就对了

代码如下:

#include<iostream>
using namespace std;
const int mod=1000007;
int num[1000];
int f[1000][1000];
int main()
{
	int n,m;
	cin>>n>>m;
	for(int i=1;i<=n;i++)
	cin>>num[i];
	for(int i=1;i<=n;i++)
	{
		for(int j=1;j<=m;j++)
		{
			f[i][j]=f[i-1][j];//继承下来不含i的种类数
			f[i][j]%=mod;
		}
		
		for(int j=1;j<=num[i];j++)
		{
			f[i][j]+=1;//加上只用i的方案数
		}
		for(int j=1;j<=m;j++)
		{
			for(int k=1;k<=num[i];k++)
			if(j-k>=0) 
			{
				f[i][j]+=f[i-1][j-k];//加上既含i又含1,2,...,i-1的方案数
				f[i][j]%=mod;
			}
		}
	}
	cout<<f[n][m]%mod;
	return 0;
}

还有注意一点,这题不能多重背包做,试一下你就懂了~

 

3.最优化问题

线性dp

例题:P3842 [TJOI2007] 线段

从上一次的左端点或右端点转移过来(自己试试就能发现这样就是最优的,其他的最优路径可以与此等效),我的做法就没写dp用的数组

代码如下:

#include<iostream>
using namespace std;
long long l[20001],r[20001];
int main()
{
	int n;
	cin>>n;
	for(int i=1;i<=n;i++)
	cin>>l[i]>>r[i];

	long long lastx=1,lasty=1;
	long long suml=0,sumr=0;
	for(int i=1;i<=n;i++)
	{
		long long step1x=r[i]-lastx;
		step1x=step1x>0? step1x:-step1x;
		long long step1y=r[i]-lasty;
		step1y=step1y>0? step1y:-step1y;//走到右端点
		long long lastsuml=suml;//保存下来革命的火种,要不然等会儿被覆盖了

		long long choicel1=step1x+r[i]-l[i]+suml;
		long long choicel2=step1y+r[i]-l[i]+sumr;//再走到左端点
		suml=min(choicel1,choicel2);//从上次的左/右转移过来
		
		
		long long stepr1x=l[i]-lastx;
		stepr1x=stepr1x>0? stepr1x:-stepr1x;
		long long stepr1y=l[i]-lasty;
		stepr1y=stepr1y>0? stepr1y:-stepr1y;//走到左端点
		
		long long choicer1=stepr1x+r[i]-l[i]+lastsuml;
		long long choicer2=stepr1y+r[i]-l[i]+sumr;//再走到右端点
		sumr=min(choicer1,choicer2);//从上次的左/右转移过来
		
		lastx=l[i];
		lasty=r[i];

	}
	cout<<min(suml+n-lastx,sumr+n-lasty)+n-1;
	return 0;
}

区间dp

详见我n年前写的几篇博客:

1.区间动态规划详解+一本通1570能量项链题解

2.区间动态规划:1572:括号配对题解

3.区间动态规划:洛谷P4170 [CQOI2007]涂色题解

区间RMQ问题:

4.动态规划:RMQ问题

树型dp

详见我n年前写的几篇博客:

1.树型动态规划详解:二叉苹果树

2.树型动态规划&区间动态规划:加分二叉树题解

 

 

三、动态规划的优化方式

评定一个程序的标准是时空复杂度,那么对于动态规划的优化分为两个方面:时间和空间

1.空间上优化

有时数据规模大不能开二维数组,那么一个状态设计就是二维的程序能否继续存在?

如果只是频繁调用i-1,而与之前的无关,那么可以过河拆桥之前存储过的内容

拆的少一点叫做滚动数组(留下两行数据),拆的多一点叫做一维数组(一行数组反向覆盖)

改为滚动数组

 模板题:P1048 [NOIP2005 普及组] 采药https://www.luogu.com.cn/problem/P1048

无优化代码:

#include<iostream>
using namespace std;
int f[101][1001];
int main()
{
	int T,n;
	cin>>T>>n;
	for(int i=1;i<=n;i++)
	{
		int t,v;
		cin>>t>>v;
		for(int j=1;j<=T;j++)
		{
			if(j>=t) f[i][j]=max(f[i-1][j],f[i-1][j-t]+v);//看能不能放下,数组下标不能为负
			else f[i][j]=f[i-1][j];
		}
	}
	cout<<f[n][T];
	return 0;
}

注意看这个代码,始终用前一行更新下一行,那么从始至终用到两行,那么滚动使用就可以了

#include<iostream>
using namespace std;
int f[1][1001];
int main()
{
	int T,n;
	cin>>T>>n;
	int p=0,q=1;
	for(int i=1;i<=n;i++)
	{
		int t,v;
		cin>>t>>v;
		for(int j=1;j<=T;j++)
		{
			if(j>=t) f[q][j]=max(f[p][j],f[p][j-t]+v);
			else f[q][j]=f[p][j];
		}
		swap(p,q);
	}
	cout<<f[p][T];
	return 0;
}

改为一维数组

继续翻回去看无优化代码,只用到i-1,那么改用滚动数组

只用到j和j-t,那么意味着我只用到左上方的数据,那么我只要本行从右边开始更新就好了(注意是左且的数据,也就是从左到右和从右到左更新无差别),这就是一维数组

#include<iostream>
using namespace std;
int f[1001];
int main()
{
	int T,n;
	cin>>T>>n;
	for(int i=1;i<=n;i++)
	{
		int t,v;
		cin>>t>>v;
		for(int j=T;j>=1;j--)
		{
			if(j>=t) f[j]=max(f[j],f[j-t]+v);
            //原来那个else不必写了,反正是本行刷新,不更新的地方原来就有数据
		}
	}
	cout<<f[T];
	return 0;
}

当然你也可以一维数组的思想做非动态规划的题,比如输出一个杨辉三角

 

四、消除后效性

如果违背重叠子问题和最优子结构,那么是状态设计不合理,重新设计去吧

如果违背无后效性,那么就想办法消除后效性

这个没有固定的套路,只能靠经验

比如想法是把路径当成“历史记录”也存起来,作为答案的一部分

例题:P2196 [NOIP1996 提高组] 挖地雷https://www.luogu.com.cn/problem/P2196

其中要输出挖到地雷个数最多的路径,看起来是有后效性的,但是其实我可以把每个子问题的最优路径存起来,刷新的时候把新点直接放在上一个点的路径后面就行了

因为数据规模很小,而且状态设计是一维的(f(i)表示以i结束最多能挖到的雷数),所以用好多次循环简单且不必心疼,远远不及1000ms

代码如下:

#include<iostream>
using namespace std;
int num[21];
int edge[21][21];
int f[21];
int path[21][21];//第i个节点结束时的路径j1->j2->...
int cnt[21];//第i个节点结束时的路径长度
int main()
{
	int n;
	cin>>n;
	for(int i=1;i<=n;i++)
	{
		cin>>num[i];
		edge[0][i]=1;
	}
	
	for(int i=1;i<=n-1;i++)
	{
		for(int j=i+1;j<=n;j++)
		{
			int x;
			cin>>x;
			edge[i][j]=x;
		}
	}
	for(int i=1;i<=n;i++)//结束节点
	{
		for(int j=0;j<=n;j++)//更新节点
		{
			if(edge[j][i])
			{
				if(f[j]+num[i]>f[i])
				{
					f[i]=f[j]+num[i];
					for(int k=1;k<=cnt[j];k++)
					path[i][k]=path[j][k];
					path[i][cnt[j]+1]=i;
					cnt[i]=cnt[j]+1;
				}
			}
		}
	}
	int ans=0,sum=0;
	for(int i=1;i<=n;i++)
	{
		if(f[i]>sum)
		{
			sum=f[i];
			ans=i;
		}
	}
	for(int i=1;i<=cnt[ans];i++)
	cout<<path[ans][i]<<" ";
	cout<<endl;
	cout<<sum<<endl;
	return 0;
}

 

小结:

纯动态规划最大难点在于设计出无后效性的状态

处理模式最好是先会系统地处理样例数据了再去写代码

动态规划是一种思想,出题的时候可以套知识点,比如图论+动态规划,难度可以没有上限

动态规划、分治、贪心同为思想,是处理问题的思考方式

分治分解子问题,但不一定计算每一个子问题

[分解子问题][合并子问题]

动态规划计算/调用每一个子问题,调用是为了避免重复计算重叠子问题

[重叠子问题][无后效性][最优子结构]

贪心必须满足贪心选择,永远局部最优最终得到全局最优

[贪心选择][无后效性][最优子结构]

 

 

 

 

 

 

 

 

 

 

 

 

 

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值