2021.12.16 动态规划

1.1 什么是动态规划??动态规划的意义?

1.1.1 通过最优值算最优状态

https://www.zhihu.com/question/23995189/answer/35429905

动态规划的本质不在于是递推或是递归,也不需要纠结是不是内存换时间。

理解动态规划并不需要数学公式介入,只是完全解释清楚需要点篇幅…首先需要明白哪些问题不是动态规划可以解决的,才能明白为神马需要动态规划。不过好处时顺便也就搞明白了递推贪心搜索和动规之间有什么关系,以及帮助那些总是把动规当成搜索解的同学建立动规的思路。当然熟悉了之后可以直接根据问题的描述得到思路,如果有需要的话再补充吧。

动态规划是对于 某一类问题 的解决方法!!重点在于如何鉴定“某一类问题”是动态规划可解的而不是纠结解决方法是递归还是递推!

怎么鉴定dp可解的一类问题需要从计算机是怎么工作的说起…计算机的本质是一个状态机,内存里存储的所有数据构成了当前的状态,CPU只能利用当前的状态计算出下一个状态(不要纠结硬盘之类的外部存储,就算考虑他们也只是扩大了状态的存储容量而已,并不能改变下一个状态只能从当前状态计算出来这一条铁律)

当你企图使用计算机解决一个问题是,其实就是在思考如何将这个问题表达成状态(用哪些变量存储哪些数据)以及如何在状态中转移(怎样根据一些变量计算出另一些变量)。所以所谓的空间复杂度就是为了支持你的计算所必需存储的状态最多有多少,所谓时间复杂度就是从初始状态到达最终状态中间需要多少步!

太抽象了还是举个例子吧:

比如说我想计算第100个非波那契数,每一个非波那契数就是这个问题的一个状态,每求一个新数字只需要之前的两个状态。所以同一个时刻,最多只需要保存两个状态,空间复杂度就是常数;每计算一个新状态所需要的时间也是常数且状态是线性递增的,所以时间复杂度也是线性的。

上面这种状态计算很直接,只需要依照固定的模式从旧状态计算出新状态就行(a[i]=a[i-1]+a[i-2]),不需要考虑是不是需要更多的状态,也不需要选择哪些旧状态来计算新状态。对于这样的解法,我们叫递推。

非波那契那个例子过于简单,以至于让人忽视了阶段的概念,所谓阶段是指随着问题的解决,在同一个时刻可能会得到的不同状态的集合。非波那契数列中,每一步会计算得到一个新数字,所以每个阶段只有一个状态。想象另外一个问题情景,假如把你放在一个围棋棋盘上的某一点,你每一步只能走一格,因为你可以东南西北随便走,所以你当你同样走四步可能会处于很多个不同的位置。从头开始走了几步就是第几个阶段,走了n步可能处于的位置称为一个状态,走了这n步所有可能到达的位置的集合就是这个阶段下所有可能的状态。

现在问题来了,有了阶段之后,计算新状态可能会遇到各种奇葩的情况,针对不同的情况,就需要不同的算法,下面就分情况来说明一下:

假如问题有n个阶段,每个阶段都有多个状态,不同阶段的状态数不必相同,一个阶段的一个状态可以得到下个阶段的所有状态中的几个。那我们要计算出最终阶段的状态数自然要经历之前每个阶段的某些状态。

好消息是,有时候我们并不需要真的计算所有状态,比如这样一个弱智的棋盘问题:从棋盘的左上角到达右下角最短需要几步。答案很显然,用这样一个弱智的问题是为了帮助我们理解阶段和状态。某个阶段确实可以有多个状态,正如这个问题中走n步可以走到很多位置一样。但是同样n步中,有哪些位置可以让我们在第n+1步中走的最远呢?没错,正是第n步中走的最远的位置。换成一句熟悉话叫做“下一步最优是从当前最优得到的”。所以为了计算最终的最优值,只需要存储每一步的最优值即可,解决符合这种性质的问题的算法就叫贪心。如果只看最优状态之间的计算过程是不是和非波那契数列的计算很像?所以计算的方法是递推。

既然问题都是可以划分成阶段和状态的。这样一来我们一下子解决了一大类问题:一个阶段的最优可以由前一个阶段的最优得到。

如果一个阶段的最优无法用前一个阶段的最优得到呢?

什么你说只需要之前两个阶段就可以得到当前最优?那跟只用之前一个阶段并没有本质区别。最麻烦的情况在于你需要之前所有的情况才行。

再来一个迷宫的例子。在计算从起点到终点的最短路线时,你不能只保存当前阶段的状态,因为题目要求你最短,所以你必须知道之前走过的所有位置。因为即便你当前再的位置不变,之前的路线不同会影响你的之后走的路线。这时你需要保存的是之前每个阶段所经历的那个状态,根据这些信息才能计算出下一个状态!

每个阶段的状态或许不多,但是每个状态都可以转移到下一阶段的多个状态,所以解的复杂度就是指数的,因此时间复杂度也是指数的。哦哦,刚刚提到的之前的路线会影响到下一步的选择,这个令人不开心的情况就叫做有后效性。

刚刚的情况实在太普遍,解决方法实在太暴力,有没有哪些情况可以避免如此的暴力呢?

契机就在于后效性。

有一类问题,看似需要之前所有的状态,其实不用。不妨也是拿最长上升子序列的例子来说明为什么他不必需要暴力搜索,进而引出动态规划的思路。

假装我们年幼无知想用搜索去寻找最长上升子序列。怎么搜索呢?需要从头到尾依次枚举是否选择当前的数字,每选定一个数字就要去看看是不是满足“上升”的性质,这里第i个阶段就是去思考是否要选择第i个数,第i个阶段有两个状态,分别是选和不选。哈哈,依稀出现了刚刚迷宫找路的影子!咦慢着,每次当我决定要选择当前数字的时候,只需要和之前选定的一个数字比较就行了!这是和之前迷宫问题的本质不同!这就可以纵容我们不需要记录之前所有的状态啊!既然我们的选择已经不受之前状态的组合的影响了,那时间复杂度自然也不是指数的了啊!虽然我们不在乎某序列之前都是什么元素,但我们还是需要这个序列的长度的。所以我们只需要记录以某个元素结尾的LIS长度就好!因此第i个阶段的最优解只是由前i-1个阶段的最优解得到的,然后就得到了[DP方程]

所以一个问题是该用递推、贪心、搜索还是动态规划,完全是由这个问题本身阶段间状态的转移方式决定的!

每个阶段只有一个状态->递推;

eleveni补充:我觉着是由固定步骤或固定特征的一些阶段转移到同一个阶段的算递推

每个阶段的最优状态都是由上一个阶段的最优状态得到的->贪心;

每个阶段的最优状态是由之前所有阶段的状态的组合得到的->搜索;

每个阶段的最优状态可以从之前某个阶段的某个或某些状态直接得到而不管之前这个状态是如何得到的->动态规划。

每个阶段的最优状态可以从之前某个阶段的某个或某些状态直接得到

这个性质叫做最优子结构

而不管之前这个状态是如何得到的

这个性质叫做无后效性

另:其实动态规划中的最优状态的说法容易产生误导,以为只需要计算最优状态就好,LIS问题确实如此,转移时只用到了每个阶段“选”的状态。但实际上有的问题往往需要对每个阶段的所有状态都算出一个最优值,然后根据这些最优值再来找最优状态。比如背包问题就需要对前i个包(阶段)容量为j时(状态)计算出最大价值。然后在最后一个阶段中的所有状态种找到最优值。

1.1.2 思考套路

https://zhuanlan.zhihu.com/p/384139873

1+4的思考套路

自己针对动态规划总结了一个自己的思考套路,我叫他1组例子4个问题,就叫1+4好了,通过这5个过程,可以站在普通人的角度(就是非acm大佬那种的角度),去理解动态规划是如何被思考出来的

  • 在超时的思路上写出一组计算过程的例子
  • 在超时例子的基础上,有哪些重复、浪费的地方?
  • 如何定义dp数组
  • 状态的变化方向是什么,是怎么变化的
  • 边界状态是什么

1.1.3 动态规划与记忆化搜索

https://www.zhihu.com/question/323076638/answer/673995021

1.1.3.1
先想递归
发现重复计算
通过记忆化等方法弄掉重复计算
最后看下能不能通过利用计算顺序来做到去掉递归用“刷表”方式直接顺序计算,能搞定最好搞不定拉倒

刷表法是记忆化搜索的优化.

1.1.3.2

动态规划:子问题计算重叠

记忆化:防止重复计算子问题

1.1.3.3

https://www.zhihu.com/question/60730045

递归搜索 + 记忆缓存 = 记忆化搜索
记忆化搜索 + 缓存可有序求解 + 迭代填充记忆 = 动态规划
动态规划 + 即时废弃不需要的记忆 = 状态压缩动态规划

1.1.3.4

https://www.luogu.com.cn/blog/interestingLSY/memdfs-and-dp

记忆化搜索和动态规划从根本上来讲就是一个东西,(印象中)任何一个 dp 方程都能转为记忆化搜索 ,反之亦然(为什么?见下文“体现在”的第四条)

  • 根据记忆化搜索的参数可以直接得到dp的状态,反之亦然
  • 根据记忆化搜索的递归关系可以写出状态转移方程,这个方程可以直接写出循环式的dp,只不过是反的(想想为什么?),反之亦然
  • 大部分记忆化搜索时空复杂度与 不加优化的 dp 完全相同
  • 最重要的一点:二者思想类似!! 核心思想均为:利用对于相同参数答案相同的特性,对于相同的参数(循环式的dp体现为数组下标),记录其答案,免去重复计算,从而起到优化时间复杂度的作用。这,便是二者的精髓。

1.2 练习题

1.2.1 区间DP

https://www.luogu.com.cn/problem/P3847

#include<cstdio>
#include<iostream>
#include<algorithm>
#include<cstring>
#include<cmath>
#include<set>
#define IOS ios_base::sync_with_stdio(false);cin.tie(0);cout.tie(0);
using namespace std;

const int N=3010;
int n,a[N],f[N][N];

int main(){
	IOS;
	cin>>n;
	for(int i=1;i<=n;i++)cin>>a[i];
	for(int l=2;l<=n;l++)
	for(int i=1;i+l-1<=n;i++){
		int j=i+l-1;
		if(a[i]==a[j])f[i][j]=f[i+1][j-1];
		else f[i][j]=min(f[i+1][j-1],min(f[i][j-1],f[i+1][j]))+1;
	}
	cout<<f[1][n];
	return 0;
}

1.2.2 DP套DP

https://www.luogu.com.cn/problem/P3891

完全背包+普通背包

#include<cstdio>
#include<iostream>
#include<cstring>
#include<algorithm>
#include<queue>
#define IOS ios_base::sync_with_stdio(false);cin.tie(0);cout.tie(0);
using namespace std;

const int N=1010;
int n,m,T,f[N][N],g[N];
struct node{
	int cost,val;
}a[N];

signed main(){
	IOS;
	cin>>n>>m>>T;
	for(int i=1;i<=n;i++)cin>>a[i].cost>>a[i].val;
	if(m>=T)return puts("0"),0;
	memset(f,-1,sizeof(f));
	memset(g,-1,sizeof(g));
	//cout<<f[0][0]<<endl;//
	g[0]=0;
	for(int i=1;i<=n;i++)for(int j=a[i].cost;j<=N-10;j++)
	if(g[j-a[i].cost]!=-1)g[j]=max(g[j],g[j-a[i].cost]+a[i].val);
	//cout<<"g "<<endl;
	//for(int i=1;i<=20;i++)cout<<g[i]<<" ";cout<<endl;
	f[0][m]=0;
	for(int i=0;i<=N-10;i++){
		if(f[i][T]!=-1)return cout<<i,0;
		for(int j=0;j<=T;j++)if(f[i][j]!=-1)
		for(int k=0;k<=j;k++)if(g[k]!=-1){
			int x=j-k+g[k]+f[i][j];
			if(x>=T)return cout<<i+1,0;
			f[i+1][x]=max(f[i+1][x],f[i][j]+g[k]);
		}
	}
	return 0;
}

1.2.3 概率DP

1.2.3.1 P2059 [JLOI2013]卡牌游戏

https://www.luogu.com.cn/problem/P2059

题意:

N个人坐成一圈玩游戏。一开始我们把所有玩家按顺时针从1到N编号。首先第一回合是玩家1作为庄家。每个回合庄家都会随机(即按相等的概率)从卡牌堆里选择一张卡片,假设卡片上的数字为X,则庄家首先把卡片上的数字向所有玩家展示,然后按顺时针从庄家位置数第X个人将被处决即退出游戏。然后卡片将会被放回卡牌堆里并重新洗牌。被处决的人按顺时针的下一个人将会作为下一轮的庄家。那么经过N-1轮后最后只会剩下一个人,即为本次游戏的胜者。现在你预先知道了总共有M张卡片,也知道每张卡片上的数字。现在你需要确定每个玩家胜出的概率。

这里有一个简单的例子:

例如一共有4个玩家,有四张卡片分别写着3,4,5,6.

第一回合,庄家是玩家1,假设他选择了一张写着数字5的卡片。那么按顺时针数1,2,3,4,1,最后玩家1被踢出游戏。

第二回合,庄家就是玩家1的下一个人,即玩家2.假设玩家2这次选择了一张数字6,那么2,3,4,2,3,4,玩家4被踢出游戏。

第三回合,玩家2再一次成为庄家。如果这一次玩家2再次选了6,则玩家3被踢出游戏,最后的胜者就是玩家2.

分析:

如果要写成搜索(不限时间,不限空间),我会记录:

1.目前淘汰的人是谁、目前还有几个在场,类似于bitset

2.到目前这种情况这个人的胜率是多少

我的DFS(这写不成BFS):

1.按牌的顺序选择 ,进行模拟,然后DFS下一步

2.返回这一步,清除选项,继续同次序的选择(就像根节点的不同子节点一样)

我的优化:

1.如果这种情况已经计算过了,直接返回胜率->记忆化搜索

2.如果这是最后一个人,他没得选,胜率100%->DFS是int型的

根据上面1.1.3,这种记忆化搜索可以改写成 DP。

不过由于人数太多,不能二进制储存到底有谁在场 ,根据优化2,从 \(f_{1,1}\) 入手,也就是只剩1个人的时候,第1个人的胜率,只有这种情况是确定的,这也是递归边界(DP边界)。根据优化1,如果我们还剩3个人,这三个人无论编号是什么,他们的胜率都是一定的,毕竟牌就那么几张/无奈,所以 \(f_{i,j}\) 可以表示一共还剩 \(i\) 个人时第 \(j\) 个人的胜率。因为被重复搜索的情况就是不断重复的子问题,那就试试这个状态转移方程~

代码如下:

#include<cstdio>
#include<iostream>
#include<cstring>
#include<algorithm>
#include<cmath>
#define IOS ios_base::sync_with_stdio(false);cin.tie(0);cout.tie(0);
using namespace std;

const int N=55;
int n,m,a[N];
double f[N][N];

int main(){
	cin>>n>>m;
	f[1][1]=1.0;
	for(int i=1;i<=m;i++)cin>>a[i];
	for(int i=2;i<=n;i++){
		for(int k=1;k<=m;k++){
			int aim=a[k]%i?a[k]%i:i;
			for(int j=1;j<i;j++){
				aim=aim+1>i?1:aim+1;
				f[i][aim]+=f[i-1][j]/(double)m;
			}
		}
	}
	for(int i=1;i<=n;i++)printf("%.2lf%% ",f[n][i]*100);
	return 0;
}

1.2.4 递推

1.2.4.1 P3856 [TJOI2008]公共子串

https://www.luogu.com.cn/problem/P3856

题意:

一个字符串的子串是在这个串基础上去掉0个或者若干个字符所形成的,例如abc, aa和abbc都是字符串aabbcc的子串,而aba不是。 现给你三个字符串,请问他们三个共同含有多少种子串(不算空串)?

注意: 有些相同的公共子串尽管出现在不同的位置,但仍算1种,详见样例。

分析:

重点都在代码里,请关注代码!

代码如下:

#include<cstdio>
#include<iostream>
#include<cstring>
#include<algorithm>
#include<cmath>
#define IOS ios_base::sync_with_stdio(false);cin.tie(0);cout.tie(0);
using namespace std;

#define int long long
const int N=110;
int f[N][N][N],lastia[N],lastib[N],lastic[N];
char a[N],b[N],c[N];

signed main(){
	scanf("%s",a+1);scanf("%s",b+1);scanf("%s",c+1);
	int lena=strlen(a+1),lenb=strlen(b+1),lenc=strlen(c+1);
	for(int i=1;i<=lena;i++){
		lastia[a[i]-'a'+1]=i;
		memset(lastib,0,sizeof(lastib));
		for(int j=1;j<=lenb;j++){
			lastib[b[j]-'a'+1]=j;
			memset(lastic,0,sizeof(lastic));
			for(int k=1;k<=lenc;k++){
				lastic[c[k]-'a'+1]=k;
				for(int d=1;d<=26;d++){
					int ai=lastia[d],bi=lastib[d],ci=lastic[d];
					if(!ai||!bi||!ci)continue;
					f[i][j][k]+=f[ai-1][bi-1][ci-1]+1;
					//利用容斥原理,反正就是把各种情况分别揪出来
					//一共有26种,也就是分别以26种英文字母结尾
					//而且没揪出来一种就额外多增加一种子串——原来重复的字母+1
					//并且以前被更新过的数组不会被重复更新
					//这是递推 
				}
			}
		}
	}
	cout<<f[lena][lenb][lenc];
	return 0;
}

1.2.5 二进制优化

https://www.luogu.com.cn/problem/P4095

完全背包

#include<cstdio>
#include<iostream>
#include<cstring>
#include<algorithm>
#include<cmath>
#define IOS ios_base::sync_with_stdio(false);cin.tie(0);cout.tie(0);
using namespace std;

#define int long long
const int N=1010;
int n,m,cnt,pre[N*10][N],suf[N*10][N],L[N*10],R[N*10];
struct node{
	int cost,val,id;
}a[N*N];

signed main(){
	IOS;
	cin>>n;
	for(int i=1;i<=n;i++){
		int cost,val,num;
		cin>>cost>>val>>num;
		L[i]=cnt;
		for(int j=1;j<=num;j*=2){
			++cnt;
			a[cnt].cost=cost*j;a[cnt].val=val*j;
			a[cnt].id=i;
			num-=j;
		}
		if(num)
		a[++cnt].cost=cost*num,a[cnt].val=val*num,a[cnt].id=i;
		R[i]=cnt+1;
	}
	for(int i=1;i<=cnt;i++){
		memcpy(pre[i],pre[i-1],sizeof(pre[i-1]));
		for(int j=1000;j>=a[i].cost;j--)
		pre[i][j]=max(pre[i][j],pre[i-1][j-a[i].cost]+a[i].val);
	}
	for(int i=cnt;i>=1;i--){
		memcpy(suf[i],suf[i+1],sizeof(suf[i+1]));
		for(int j=1000;j>=a[i].cost;j--)
		suf[i][j]=max(suf[i][j],suf[i+1][j-a[i].cost]+a[i].val);
	}
	cin>>m;
	for(int i=1;i<=m;i++){
		int limit,maxn;
		cin>>limit>>maxn;
		++limit;
		int ans=0;
		for(int j=0;j<=maxn;j++)
		ans=max(ans,pre[L[limit]][j]+suf[R[limit]][maxn-j]);
		cout<<ans<<endl;
	} 
	return 0;
}
 posted on 2021-12-19 19:37  eleveni  阅读(60)  评论(0)    收藏  举报