动态规划DP 合辑
--------本文将梳理一下我见过的DP(动态规划)常见的类型
【目录】:
①简单递推||线性型dp
②背包
③区间dp
④状态压缩dp
⑤字符串dp
⑥数位dp
⑦树形dp
⑧概率dp
----------总概
动态规划dynamic programming (DP) 是一种经常用的算法,她满足以下两个性质:
A重叠子问题
B最优子结构
她的本质思想就是把问题划分为更小的子问题,然后把子问题的结果记录下来以后可以利用;或者说,以空间换时间。
一般有两种结构来做DP,分别为顺推(递推)和逆推,后者也叫记忆化搜索。
-----------①简单递推||线性型dp
此种dp较为简单,如斐波那契数,递归版本:
//递归 int fib(int n){ if(n==0||n==1)return 1; else return fib(n-1)+fib(n-2); }
很明显,递归版本重复计算了很多子问题,那么我们就可以用到dp的思想,开一个表,把每次计算的值存下来,然后下次要计算一个值时,先在表里面找有没有对应的值,如果有的话直接用,没有的话则计算存表.如下:
//自顶向下(记忆化搜索) int dp[N]={0};//表用来存计算过的值 int fib(int n){ if(dp[n]!=0)return dp[n]; else return dp[n]=fib(n-1)+fib(n-2); }
也可以写成更紧凑的递推形式:
int dp[N]={0}; int fib(int n){ dp[0]=d[1]=1;//初始化 for(int i=2;i<=n;i++){ dp[i]=dp[i-1]+dp[i-2]; } return dp[n]; }
另外,dp问题有时候也可以用滚动数组来优化空间复杂度,因为有些决策只用到前面几个状态,而再之前的决策不会再用到,假设当前每一个决策最多只用到前面某K个状态,那么我们开一个大小为K的表,然后在计算出当前状态i后,迭代更新存下来的表,一般用取模来或异或来解决下标.斐波那契的例子每一个状态只用到前面两个状态,那么可以开一个大小为3的表,如下:
//滚动优化 int dp[3]={0}; int fib(int n){ dp[0%3]=dp[1%3]=1; for(int i=2;i<=n;i++){ dp[i%3]=dp[(i-1)%3]+dp[(i-2)%3]; } return dp[n%3]; }
这类问题常见的还有:
eg1: 给你一个二维矩阵,让你从左上角走到左下角,每次只能走规定的方向,求走过的数总和最小(大)是多少.只要开个二维数组dp[i][j],然后每次从可以走的几个方向推过来即可.
eg2: 最大连续子串和.给你个序列,求出最大的连续字串和. 那么记dp[i]表示第i个数加入目标串时的最大字串和,那么dp[i]=max(sq[i],dp[i-1]+sq[i].类似的可以扩展到矩阵上,求最大子矩阵和
eg3:硬币组成问题.给你k种币值的硬币,用最小数目的硬币凑出价值n. 此种题有时候可以用贪心来解,不过更普遍的解法是用dp. 我们可以记dp[i]表示凑出价值为i时用到最少数目的硬币,那么dp[i]=min{dp[i-v[j]]+1|1<=j<=k}
eg4:lis,最长不降子序列问题。就是在一个序列上,找出最长的子序列(不要求连续)满足不降的大小关系。则可以记dp[i]表示以第i个数结尾的最长不降子序列,然后类似于eg3,结果就是max{dp[1..n]}. -----lis是有比较深的数学本质,我见过好几个问题的本质其实都是lis,另外lis可以利用单调队列的思想把复杂度优化到nlogn,有兴趣者自行搜索。
第一类dp问题比较简单,状态转移往往不难想。
-----②背包问题
背包问题最原始的转移方程式:dp[i][j]=max{dp[i-1][j],dp[i-1][j-v[i]]+w[i]}这里介绍最简单的01背包:有n个物品,第i个物品体积为v[i],价值w[i],问你用一个容量为V的背包,最多能装出多少价值来?-解法:记dp[i][j]表示前i件物品,背包容量为j时候,最大的价值量那么对第i件物品时,有放和不放两个决策,而我们关心的是取到最大价值量,因此方程可这样转移dp[i][j]=max{dp[i-1][j]对应第i件物品不放入背包,dp[i-1][j-v[i]]+w[i]放入背包};这样两重循环推过去则dp[n][V]就是答案。也可用滚动数组优化空间复杂度。背包问题请细看 《背包九讲》,每次看都有不同体会
-----③区间dp
此类型的dp最常见就是某个区间[i,j]的最优值得从他的子区间[i,k],[k,j]推过来,这样的话其实跟一般线性dp挺像,只是推的顺序要变化一下。因为大区间是从小区间推过来的,所以如果写成递推形式的话,那么循环推的顺序应该是:先计算小区间->大区间循环结构体一般是:for k=1 .. nfor i=1 .. n-k+1j=i+k-1dp[i][j]={ cal(dp[i][t]),cal(dp[t][j]) | i<=t<j }即最外层的k控制区间大小,然后i控制边界经典的问题有: 石子合并 最小矩阵链乘 多边形剖分 最优二叉查找树等等下面介绍其中的最小矩阵链乘问题。问题描述:给定n个矩阵构成的一个链<A1, A2, ..., An>,其中矩阵Ai的大小为pi-1 × pi ,i=1..n,找一种计算顺序,使得计算乘积A1A2...An的乘法次数最少。-解法:dp[i][j]表示计算Ai..Aj所需要的最小乘法次数,则存在某个个k(i<=k<j)是i到j这段区间最晚计算的那个矩阵,使得dp[i][j]最小。状态转移方程为dp[i][j]=min{dp[i][k]+dp[k+1][j]+p[i-1]*p[k]*p[j]| i<=k<j }写成递推形式://递推形式 #define INF 0x3f3f3f3f; int p[N],dp[N][N]; int matrix_chain(int n){ for(int i=1;i<=n;i++)dp[i][i]=0;//初始化,因为只有一个矩阵时候乘法数为0 for(int k=2;k<=n;k++){ for(int i=1;i<=n-k+1;i++){ int j=i+k-1; dp[i][j]=INF; for(int t=i;t<=j-1;t++){ dp[i][j]=min(dp[i][j],dp[i][t]+dp[t+1][j]+p[i-1]*p[t]*p[j]); } } } return dp[1][n]; }
也可以写成记忆化搜索形式,更为简洁。
------④状态压缩dp
此种dp一般用在集合上,而且集合比较小,那么我们就可以用二进制来表示集合,把对集合里每一个元素的选取与否对应到一个二进制位里,从而把状态压缩到一个整数,并写出相应的转移方程.
最经典的当属TSP旅行商问题。
TSP:给一个n个顶点的带权有向图,d[i][j]表示从i到j的权值,INF表示没有边,要求从顶点0出发,经过每一个顶点恰好一次后回到0顶点。求所经过的路径权值总和最小是多少?(n<16)
-解法:以dp[V][vi]表示访问V集合顶点各一次并且以vi为终点的最短路径和。
则有dp[0][0]=0;
dp[V][vi]=min(dp[V/vi][vj]+d[j][i] | j ∈V)
结果就是dp[S][0];
写成记忆化搜索就不用想蛋疼的递推顺序啦:
//TSP #define INF=0x3f3f3f3f; int n;//n 表示顶点个数,从0开始 int dp[1<<N][N];//存结果 int d[N][N];//距离矩阵 int dfs(int s,int v){ if(dp[s][v]!=-1)return dp[s][v]; int res=INF; for(int u=0;u<n;u++){ if(u!=v && (s>>u & 1)){ res=min(res,dfs(s^(1<<u),u)+d[u][v]); } } return dp[s][v]=res; } void TSP(){ memset(dp,-1,sizeof(dp)); dp[0][0]=0; int ans=dfs((1<<n)-1,0); }
-------⑤字符串dp
顾名思义,此种dp就是在字符串上进行,其实也没什么区别啦。
有几个经典的如 :LCS最长公共子序列 最长回文字串 最小编辑距离 等等
下面介绍其中的LCS,并介绍其优化方法
LCS:设X=<x1,x2,…,xi>和Y=<y1,y2,…,yj>为两个子序列,并设Z=<z1,z2,…,zk>为X和Y地任意一个LCS.
我们记dp[i][j]为X前i位和Y前j位构成的最长公共子序列,则有
a.xi=yj,则dp[i][j]=dp[i-1][j-1]+1
b.xi!=yj,则dp[i][j]=max(dp[i-1][j],dp[i][j-1])
然后两重循环扫过去最后dp[x_size][y_size]则为结果,代码如下:
//LCS 时间复杂度O(NM),空间复杂度O(NM)char x[N],y[M];//下标从1开始 int dp[N+1][M+1]; int LCS(){ memset(dp,0,sizeof(dp)); for(int i=1;i<=N;i++){ for(int j=1;j<=M;j++){ if(x[i]==y[j])dp[i][j]=dp[i-1][j-1]+1; else dp[i][j]=max(dp[i-1][j],dp[i][j-1]); } } return dp[N][M]; }
对空间进行优化成O(N),因为每个状态dp[i][j]对i只会用到上一层的值,因此我们可以数组大小开为dp[2][M],然后每次取模2即可。
时间复杂度进行优化成O(nlogn),可以把lcs转化成lis,方法是对x中出现的每个字符,记下所有在y中出现下标,按降序排列,然后把这个下标组合起来,现在最长公共子序列的大小就是这个下标组合的最长不降子序列的大小。为什么这样可以呢?想一想还真的可以……只能说你们太屌了,不过据说这个方法在某些情况下会退化,慎用。
其他几种求字符串各种长度的处理方法也类似。
------⑥数位dp
在数的位上进行dp,一般是比较大的数,常见情况是要求出现或不出现某些数字。
比如hdu上那道让你求[a,b]上不出现4和和62的数字有多少个。那么可以先求出[0,n].
问题:那[0,n)不出现4和62的数字个数怎么求呢?
如果n大时候,枚举可能会tle,这里就用到数位dp这个东西了。记dp[i][j]表示i位数,以j开头满足要求的数字个数。然后从高到低枚举每一位,比如n=24,那么
对于十位有 0_ 1_ _表示可以填0~9
然后对于个位,这时候十位已经确定为2了,则有20~23(这里是23,不是24,没错!!!)
并且我们可以先预处理一下对于某个位 是 _ 时候,总共有多少个满足条件的数字,那么求[0,n)满足条件的完整代码如下:
//!!![0,n)!!!满足不含4和62的数字个数 const int N=10; int dp[N][N]; void init()//预处理 { memset(dp,0,sizeof(dp)); dp[0][0] = 1; for(int i=1;i<N;i++){ for(int j=0;j<10;j++){//第i位可能出现的数 for(int k=0;k<10;k++){//第i-1位可能出现的数 if(j!=4&&!(j==6&&k==2)) dp[i][j]+=dp[i-1][k]; } } } } int solve(int n) { init(); int digit[10]; int len = 0; while(n){ digit[++len]=n%10; n/=10; } digit[len+1]=0;//必须加入,否则下面的判断可能出错 int ans = 0; for(int i=len;i>0;i--){ for(int j=0;j<digit[i];j++){ if(j!=4&&!(digit[i+1]==6&&j==2)) ans+=dp[i][j]; } if(digit[i]==4||(digit[i]==2&&digit[i+1]==6)) break; } return ans; }
知道上面为什么不包含n吗?因为我们从高位推到低位时候,对第i位,是假设第i+1位确定为digit[i+1],然后第i位从0枚举到digit[i]-1,并不包含i位,这样到第1位时候,也就不包含n了,仔细想一想吧~ 哦不,等等,但是我们要求[0,n]啊,妈蛋那你调用时候就调用solve(n+1)嘛。
---------⑦树形dp
(妈蛋,卡在树dp卡挺久了,感觉有些懂了)
树上的dp比较简单的是求树的最大独立集这些,个人感觉和普通dp没啥区别。
感觉另一类类似于分组背包的dp才计较正常的归属于树dp里面。
首先,先来了解一下分组背包(不懂分组背包?->背包九讲<-)的循环结构
// f[k][v]表示前k组物品花费费用v能取得的最大权值 f[k][v]=max{f[k-1][v],f[k-1][v-c[i]]+w[i]|物品i属于组k} 分组背包一维数组的伪代码: for 所有的组k for v=V..0 //这里用到滚动数组,所以要逆序 for 所有的i属于组k f[v]=max{f[v],f[v-c[i]]+w[i]}
然后呢,你就可以看ural 1018 了,题意是这样的:有一颗二叉苹果树,每段树枝上都有一定数量的苹果,让你从1节点开始,保留q条树枝,求出保留的最大苹果数量。把每条边的苹果数量压到节点上,这样相当于保留Q=q+1个节点,1节点苹果数为0.这样比较容易处理。
A.正常想法:以对于根节点,左子树保留k个节点,则右子树保留Q-k-1个节点。这样记忆化搜索下去就可以了。
B.转化成更一般的分组背包的模型。dp[i][j]表示以i为根节点,保留j个节点可以得到的最大数量苹果。那么对于i的每个儿子v(这里更一般情况,所以儿子数可以不止两个了),相当于一组背包,那么给这个儿子v分配k个节点(相当于分配的背包容量),剩下j-k-1个节点(剩余背包容量)。如此那就可以像分组背包那样推过去了。而树的dp我们一般写成记忆化形式,所以先对子节点各种节点数(背包容量)都求出来,然后在推过去~哦也。来段代码吧
ural 1018 apple binary tree
//ural 1018 可用于一般的树结构 #include<iostream> #include<iomanip> #include<cstring> #include<stdio.h> #include<map> #include<cmath> #include<algorithm> #include<vector> #include<stack> #include<fstream> #include<queue> #define rep(i,n) for(int i=0;i<n;i++) #define fab(i,a,b) for(int i=(a);i<=(b);i++) #define fba(i,b,a) for(int i=(b);i>=(a);i--) #define MP make_pair #define PB push_back using namespace std; const int INF=0x7ffffff; const int N=105; int n,q,u,v,w; int dp[N][N]={0}; int a[N][N]; void dfs(int u,int fa){ fab(v,1,n){ if(a[u][v]!=-1&&v!=fa){ dfs(v,u); fba(j,q,1){ fab(k,1,j-1){ dp[u][j]=max(dp[u][j],dp[v][k]+dp[u][j-k]+a[u][v]); } } } } } int main(){ ios::sync_with_stdio(false); rep(i,N)rep(j,N)a[i][j]=-1,dp[i][j]=0; cin>>n>>q; rep(i,n-1){ cin>>u>>v>>w; a[u][v]=a[v][u]=w; } q++; dfs(1,-1); cout<<dp[1][q]<<endl; return 0; }
来段复杂点的吧。。
戳poj 2486 又是苹果树。题意是这样的:一棵节点数为n的树,每个节点都放有一些苹果,从根节点1开始走,每走一条边算一步,每经过一个节点就能吃掉这个节点的苹果,问走m步最多能吃几个苹果?
因为走一步算一步,所以我们应该再开多一维来表示目前的位置。
那么可以dp[i][j][0]表示i节点走j步,最后回到i节点吃到数量最多的苹果有多少。
dp[i][j][1]表示i节点走j步,不回到i节点吃到数量最多的苹果有多少。
回到i节点的状态转移比较容易想,那么不回到i节点的呢?你自己想吧。。噗
别打我。。核心代码如下:
void dfs(int u,int fa){ vis[u]=1; rep(i,k+1)dp[u][i][0]=dp[u][i][1]=a[u]; rep(i,g[u].size()){ int v=g[u][i]; if(v!=fa&&!vis[v]){ dfs(v,u); fba(i,k,0){//逆序哦 fab(j,0,i){ if(i-j-2>=0)dp[u][i][0]=max(dp[u][i][0],dp[v][j][0]+dp[u][i-j-2][0]); if(i-j-2>=0)dp[u][i][1]=max(dp[u][i][1],dp[v][j][0]+dp[u][i-j-2][1]); if(i-j-1>=0)dp[u][i][1]=max(dp[u][i][1],dp[v][j][1]+dp[u][i-j-1][0]); } } } } }
(终于可以写最后一个类型了)
--------⑧概率dp
一般有求概率和期望两种。
其中,求概率的话,一般从前往后推,假设dp[i]表示某一点的概率值,从j=x..y个转移而来而且对于的概率分别为px…py那么跟普通的转移方程一样
dp[i]={function(dp[j]*p[i]) }具体什么的再看要求。
然后,求期望的话,一般从后往前推。可以设dp[i]表示从i到目标状态的期望,然后结果就是
dp[start],再由转移方程从目标状态推回来。
而同时,概率dp时候,如果转移方程出现环的时候,往往和高斯消元有关了,这个待撸吧。
,待续
--wanggp