动态规划——区间DP,计数类DP,数位统计DP
本博客部分内容参考:《算法竞赛进阶指南》
一.区间DP
划重点:
以前所学过的线性DP一般从初始状态开始,沿着阶段的扩张向某个方向递推,直至计算出目标状态。
区间DP也属于线性DP的一种,它以“区间长度”作为DP的“阶段”,使用两个坐标(区间的左、右端点)描述每个维度。在区间DP中,一个状态由若干个比它更小且包含于它的区间所代表的状态转移而来,因此区间DP的决策往往就是划分区间的方法。区间DP的初态一般就由长度为1的“元区间”构成。
下面介绍一道经典题:石子合并
题目描述:
设有N堆石子排成一排,其编号为1,2,3,…,N。 每堆石子有一定的质量,可以用一个整数来描述,现在要将这N堆石子合并成为一堆。 每次只能合并相邻的两堆,合并的代价为这两堆石子的质量之和,合并后与这两堆石子相邻的石子将和新堆相邻,合并时由于选择的顺序不同,合并的总代价也不相同。 例如有4堆石子分别为 1 3 5 2, 我们可以先合并1、2堆,代价为4,得到4 5 2, 又合并 1,2堆,代价为9,得到9 2 ,再合并得到11,总代价为4+9+11=24; 如果第二步是先合并2,3堆,则代价为7,得到4 7,最后一次合并代价为11,总代价为4+7+11=22。 问题是:找出一种合理的方法,使总的代价最小,输出最小代价。
输入格式
第一行一个数N表示石子的堆数N。
第二行N个数,表示每堆石子的质量(均不超过1000)。
输出格式
输出一个整数,表示最小代价。
数据范围
1≤N≤300,1≤N≤300
输入样例:
4 1 3 5 2
输出样例:
22
DP思路:
状态表示:我们用数组F[i,j]来描述DP状态,表示闭区间[i,j]的石子合并所需的最小代价。
对于任意时刻,任意区间[i,j]的石子,若第l堆与第r堆石子已经被合并,那么说明l~r之间任意一堆石子都已经被合并,这样才能保证l与r相邻。而要使第l堆与第r堆合并,必然存在一个整数k,使得第l~k堆石子先合并成一堆,第k+1~r堆石子合并成一堆,然后这两堆石子才能合并。于是,划分点k便是转移的决策,区间长度当然要作为DP的阶段,那么便可以得到状态转移方程。
状态计算:状态转移方程:F[l,r]=min{F[l,k]+F[k+1,r]}+ΣAi。其中k∈[l,r),i∈[l,r]。ΣAi表示区间[l,r]内每个数的和。对于求这个和,我们可以用一维的前缀和来实现:用数组sum[i]表示到以第i个数字结尾的前缀和,便可以得到ΣAi=sum[r]-sum[l-1]。
AC代码如下:
#include<iostream> #include<cstdio> #include<algorithm> #include<cstring> using namespace std; const int N = 300+10; int f[N][N],sum[N]; inline int read() { int x=0,f=1;char ch=getchar(); while(ch<'0'||ch>'9'){if(ch=='-')f=-1;ch=getchar();} while(ch<='9'&&ch>='0')x=(x<<1)+(x<<3)+(ch^48),ch=getchar(); return x*f; } int main() { int n=read(); for(int i=1;i<=n;i++) { int x=read(); sum[i]=sum[i-1]+x; } for(int m=1;m<=n-1;m++) //注意DP循环中的各个边界 for(int i=1,j=i+m;j<=n;i++,j++) { int minn=2e+9; for(int k=i;k<=j-1;k++) minn=min(minn,f[i][k]+f[k+1][j]); f[i][j]=minn+sum[j]-sum[i-1]; } printf("%d\n",f[1][n]); return 0; }
二.计数类DP
计数类DP和数位统计DP两类问题都特别强调“不重不漏”,统计对象的可能情况一般比较多,通常需要精确的划分和整体性的计算。因此,使用动态规划抽象出问题中的“子结构”和推导的“阶段”,将有助于我们准确而高效地进行求解。
下面介绍一道经典题:整数划分
这里介绍这道题的两种DP解法:
注意:类似于整数划分这种计数类DP考虑顺序(排列)与不考虑顺序(组合)是不同的算法
1.完全背包解法
状态表示:
F[i,j]表示只从1~i中选,且总和等于j的方案数。
状态转移方程:
F[i,j] = F[i - 1,j] + F[i - 1,j - i];
对于任意一种状态F[i,j]是由什么状态转移而来的问题,我们可以类似于背包问题地考虑Ai这个数的选与不选两种情况。而F[i,j]就是这两种情况的数量之和。
如果不选,那么就是从前i-1个数中选出若干个数使和为j的方案数;如果选,那么就是从前i+1个数中选出若干个数使和为j-i的方案数。
那么再类比背包问题中将二维数组优化成一维的方式,我们就可以得到一维数组的状态转移方程:F[j]=F[j]+F[j - i];
最后再注意一下边界:F[0] = 1。
代码如下:
#include <iostream> #include <algorithm> using namespace std; const int N = 1010, mod = 1e9 + 7; int n; int f[N]; int main() { cin >> n; f[0] = 1; for (int i = 1; i <= n; i ++ ) for (int j = i; j <= n; j ++ ) f[j] = (f[j] + f[j - i]) % mod; cout << f[n] << endl; return 0; }
2.
状态表示:
F[i,j]表示总和为i,总个数为j的方案数
状态转移方程:
F[i,j] = F[i - 1,j - 1] + F[i - j,j];
参考上面一种方法的DP思路,我们可以把所有状态的集合划分成两类,一类是选择了数字“1”的,另一类是不选择数字“1”的。那么F[i,j]仍然是这两类情况之和。
如果不选“1”,那么就是从j-1个数中选出总和为i-1的方案数;如果选“1”,那么就是从j个数中选出总和为i-j的方案数。
最后再将总和为n的所有情况求和即可。
代码如下:
#include <iostream> #include <algorithm> using namespace std; const int N = 1010, mod = 1e9 + 7; int n; int f[N][N]; int main() { cin >> n; f[1][1] = 1; for (int i = 2; i <= n; i ++ ) for (int j = 1; j <= i; j ++ ) f[i][j] = (f[i - 1][j - 1] + f[i - j][j]) % mod; int res = 0; for (int i = 1; i <= n; i ++ ) res = (res + f[n][i]) % mod; cout << res << endl; return 0; }
三.数位统计DP
这类问题其实与DP关系不大,大多都是小学奥数的问题,这里也不多讲,直接放经典题:计数问题
这道题由于数据范围的限制,直接枚举肯定是过不去的(否则那就是入门题了)。对于这个问题的解法,为了减少上下界的限制,我们不妨计算1~a-1和1~b中各个数字出现的次数,再作一个差,就可以得到答案了。对于统计数字的方法,我们先举一个的例子:
统计数字“1”在五位数abcde中出现的次数:
首先看“1”在第一位出现了几次,比较a与1的大小,若a=1,显然是abcde-9999;若a>1,则是10000;
再看第二位,先加上二位数ab-1乘上1000,再比较b与“1”的大小,若b=1,则再加上三位数cde,若b>1,则加上1000;
……
以此类推,便可以求出第三位、第四位与第五位的次数,最后求一个和即可。
代码实现如下: