动态规划——线性dp
我们在解决一些线性区间上的最优化问题的时候,往往也能够利用到动态规划的思想,这种问题可以叫做线性dp。在这篇文章中,我们将讨论有关线性dp的一些问题。
在有关线性dp问题中,有着几个比较经典而基础的模型,例如最长上升子序列(LIS)、最长公共子序列(LCS)、最大子序列和等,那么首先我们从这几个经典的问题出发开始对线性dp的探索。
首先我们来看最长上升子序列问题。
这个问题基于这样一个背景,对于含有n个元素的集合S = {a1、a2、a3……an},对于S的一个子序列S‘ = {ai,aj,ak},若满足ai<aj<ak,则称S'是S的一个上升子序列,那么现在的问题是,在S众多的上升子序列中,含有元素最多的那个子序列的元素个数是多少呢?或者说这样上升的子序列最大长度是多少呢?
按照惯有的dp思维,我们将整个问题子问题化(这在用dp思维解决问题时非常重要,基于此各子问题之间的联系我们方能找到状态转移方程),我们设置数组dp[i]表示以ai作为上升子序列终点时最大的上升子序列长度。那么对于dp[i]和dp[i-1],它们之间存在着如下的关系。
if(ai > ai-1) dp[i] = dp[i-1] + 1
else dp[i] = 1
这就是最基本的最长上升子序列的问题,我们通过一个具体的问题来继续体会。(Problem source : hdu 1087)
#include<stdio.h> #include<string.h> #include<algorithm> using namespace std; const int maxn = 1005; const int inf = 999999999; int a[maxn] , dp[maxn]; int main() { int n , m , ans; while(scanf("%d",&n) && n) { memset(dp , 0 , sizeof(dp)); for(int i = 1;i <= n;i++) scanf("%d",&a[i]); for(int i = 1;i <= n;i++) { ans = -inf; for(int j = 0;j < i ;j++) { if(a[i]>a[j]) ans = max(ans , dp[j]); } dp[i] = ans + a[i]; } ans = -inf; for(int i = 1;i <= n;i++) ans = max(ans , dp[i]); printf("%d\n",ans); } }
我们再来看一道有关LIS加强版的问题。(Problem source : stdu 1800)
Description
给定一个长度为n的序列(n <= 1000) ,记该序列LIS(最长上升子序列)的长度为m,求该序列中有多少位置不相同的长度为m的严格上升子序列。
Input
Output
首先我们看到,此题在关于LIS的定义上加了一个严格上升的,那么我们在动态规划求解的时候稍微改动一下判断条件即可,这里主要需要解决的问题就是如何记录长度为m的位置不同的严格上升子序列个数。
其实基于对最长严格上升子序列长度的求解过程,我们只需在这个过程中设置一个记录种类数的num[i]来记录当前以第i个元素为终点的最长严格上升子序列的种类数即可,而num[]又满足怎样的递推关系呢?
我们联系记录最长上升子序列的长度的dp[]数组,在求解dp[i]的时候,我们存在着这样的状态转移方程:
dp[i] = max{dp[j] | j ∈[1,i-1]) + 1 } 那么我们可以在计算dp[i]的同时,记录下max(dp[j] | j∈[1,i-1])所对应的j1 、j2 、j3……那么此时我们容易看到num[i]存在着如下的递推关系。
num[i] = ∑num[jk](k = 1、2、3……) 需要注意的是,根据其严格子序列的定义,在计算dp[i]的时候,需要有a[i] > a[j]的限制条件,同样,在计算num[i]的时候,也需要有a[i] > a[j]的限制条件。
参考代码如下。
#include<cstdio> #include<cstring> #include<algorithm> using namespace std; int main() { int tt; int a[1005]; int dp[1005]; int num[1005]; scanf("%d",&tt); while(tt--) { memset(dp , 0 , sizeof(dp)); memset(num , 0 , sizeof(num)); int n; scanf("%d",&n); for(int i = 1;i <= n;i++) scanf("%d",&a[i]); dp[1] =1; num[1] = 1; for(int i = 2;i <= n;i++) { int Max1 = 0; for(int j = i - 1;j >= 1;j--) { if(a[i] > a[j]) Max1 = max(Max1 , dp[j]); } dp[i] = Max1 + 1; for(int j = i - 1;j >= 1;j--) { if(dp[j] == Max1 && a[i] > a[j]) num[i] += num[j]; } } int sum = 0; int Max = 0; for(int i = 1;i <= n;i++) Max = max(Max , dp[i]); for(int i = 1;i <= n;i++) if(dp[i] == Max) sum += num[i]; printf("%d\n",sum); } }
下面我们来探讨另外一个问题——最长公共子序列问题(LCS)。
LCS问题基于这样一个背景,对于集合S = {a[1]、a[2]、a[3]……a[n]},如果存在集合S' = {a[i]、a[j]、a[k]……},对于下标i、j、k……满足严格递增,那么称S'是S的一个子序列。(不难看出线性dp中的问题是基于集合元素的有序性的)那么现在给出两个序列A、B,它们最长的公共子序列的长度是多少呢?
基于对LIS问题的探讨,这里我们可以做类似的分析。
首先我们应做的是将整个问题给子问题化,采用与LIS相似的策略,我们设置二维数组dp[i][j]用于表示以A序列第i个元素为终点、以B序列第j个元素为终点的两个序列最长公共子序列的长度。
其次我们开始尝试建立状态转移方程,依旧从过程中开始分析,考察dp[i][j]和它前面相邻的几项dp[i-1][j-1]、dp[i][j-1]、dp[i-1][j]有着怎样的递推关系。
我们看到,这种递推关系显然会因a[i]与b[j]的关系而呈现出不同的关系,因此这里我们进行分段分析。
如果a[i] = b[j],显然这里我们基于dp[i-1][j-1]的最优情况,加1即可。即dp[i][j] = dp[i-1][j-1] + 1。
如果a[i] != b[j],那么我们可以看做在dp[i-1][j]记录的最优情况的基础上,给当前以A序列第i-1个元素为终点的序列A'添加A序列的第i个元素,而根据假设,这个元素a[i]并不是当前子问题下最长子序列中的一员,因此此时dp[i][j] = dp[i-1][j]。我们做同理的分析,也可得到dp[i][j] = dp[i][j-1],显然我们要给出当前子问题的最优解方能够引导出全局的最优解,因此我们不难得到如下的状态转移方程。
dp[i][j] = max(dp[i-1][j] , dp[i][j-1])。
我们将两种情况综合起来。
for i 1 to len(a)
for j 1 to len(b)
if(a[i] == b[j]) dp[i][j] = dp[i-1][j-1] + 1
else dp[i][j] = max(dp[i-1][j] , dp[i][j-1])
我们通过一个简单的题目来进一步体会用这种dp思想解决LCS的过程。(Problem source : hdu 1159)
题目大意:给出两个字符串,求解两个字符串的最长公共子序列。
基于上文对LCS的分析,这里我们只需简单的编程实现即可。
参考代码如下。
#include<stdio.h> #include<string.h> #include<algorithm> using namespace std; int const maxn = 1005; int dp[maxn][maxn]; int main() { char a[maxn] , b[maxn]; int i , j , len1 , len2; while(~scanf("%s %s",a , b)) { len1 = strlen(a); len2 = strlen(b); memset(dp , 0 , sizeof(dp)); for(i = 1;i <= len1;i++) { for(j = 1;j <= len2;j++) { if(a[i-1] == b[j-1]) dp[i][j] = dp[i-1][j-1] + 1; else dp[i][j] = max(dp[i][j-1] , dp[i-1][j]); } } printf("%d\n",dp[len1][len2]); } return 0; }
学习了基本的LIS、LCS,我们会想,能否将两者结合起来(LCIS)呢?(Problem source : hdu 1423)
题目大意:给定两个序列,让你求解两个最长公共上升子序列的长度。
数理分析:基于对简单的LCS和LIS的了解,这里将二者的结合其实并不困难。不论在LCS还是LIS中,我们都用到了一维数组dp[i]来表示以第i为为结尾的区间的最优解,而这里出现了两个区间,我们很自然的想到需要一个二维数组dp[i][j]来记录子问题的最优解。即用dp[i][j]表示序列一以第i个元素结尾和以序列二前第个元素结尾的LCIS的长度。
完成了子问题化,我们开始对求解过程进行模拟分析以求得到状态转移方程。我们定义序列一用数组a[]记录,序列二用数组b[]记录。
由于记录解的dp数组是二维的,我们显然是需要确定以为然后遍历第二维,也就是两层循环枚举出所有的情况。假设我们当前确定序列一的长度就是i,我们用参数j来遍历序列的每种长度。我们可以找到如下的状态转移方程:
if (a[i] = b[j]) dp[i][j] = max{dp[i][k] | k ∈[1,j-1]}
基于这个状态转移方程我们便可以编码实现了。
值得注意的一点是,在编程过程中维护方程中max{dp[i][k] | k ∈[1,j-1]}的时候,需要注意必须满足a[i] > b[j]的,否则会使得该公共子序列不是上升的。
参考代码如下。
#include<cstdio> #include<cstring> #include<algorithm> using namespace std; const int maxn = 505; int a[maxn] , b[maxn]; int dp[maxn]; int main() { int t , m , n; scanf("%d",&t); while(t--) { scanf("%d",&n); for(int i = 1;i <= n;i++) scanf("%d",&a[i]); scanf("%d",&m); for(int i = 1;i <= m;i++) scanf("%d",&b[i]); memset(dp , 0 , sizeof(dp)); int pos; for(int i = 1;i <= n;i++) { pos = 1; for(int j = 1;j <= m;j++) { if(a[i]>b[j] && dp[j] + 1 > dp[pos]) pos = j; if(a[i] == b[j]) dp[j] = dp[pos] + 1; } } int Max = 0; for(int i = 1;i <= m;i++) Max = max(Max , dp[i]); printf("%d\n",Max); if(t) printf("\n"); } }
下面我们讨论最大子序列和问题。
该问题依然是基于子序列的定义(上文已经给出),讨论一个整数序列S的子序列S',其中S'的所有元素之和是S的所有子序列中最大的。
而对于S'是连续子序列(即下标连续,如{a[1],a[2],a[3]}),还是可以不连续的,我们又要做出不同的分析。
下面我们首先讨论最大连续子序列和的问题。(Problem source : hdu 1231)
关于题设,我们需要注意的一点是我们在整个问题中只关注正值的大小,而对于结果是负值,我们都可以视为等同,最大值为0,这一点在下面的问题分析中埋着伏笔。
有该问题是基于子序列元素的连续性,因此我们在这里难以像上文中给出的两个例子一样对整个问题进行类似的子问题化。因此我们在这里设置一个变量sum,用来动态地记录当前以S序列的第i个元素a[i]的最优解。
下面我们开始模拟整个动态规划的过程。我们起初S第一个元素依次往后开始构造连续子序列,并计算出当前sum的值,并维护一个最大值max_sum。
对于sum的值,有如下两种情况。
sum>0,则表明之前构造的序列可以作为有价值的前缀(因为题设并不关注负值的大小,因此这里便以0作为分界点),那么此时便可以在以往构造的和为sum的连续子序列便可以继续构造当前元素a[i]。
而当sum<0的时候,显然以往构造的和为sum的连续子序列就没有存在的价值了,当前抛弃这个和为负的前缀显然是最优的选择,因此我们便开始重新构造连续子序列,起点便是这个第i个元素。
而整个过程是怎样实现对最优解的记录呢?显然,在向连续子序列添加第i个元素a[i]的时候,显然需要更新sum,那么在更新的同时完成对max_sum的维护,便完成了对最优解的记录。
而在这个具体问题中对最大和的连续子序列头尾元素的记录,也不难在更新sum和维护max_sum的值的时候完成。
可以看到,相比LCS,LIS,最大连续子序列和的的dp思想显得更加抽象和晦涩,没有显式状态转移方程,但是只要抓住dp思想的两个关键——子问题化和局部最优化,该问题也还是可以分析的。
参考代码如下。
#include<stdio.h> using namespace std; const int N = 50005; int n_num; int num[N]; int main() { while(scanf("%d",&n_num) , n_num) { for(int i = 0;i < n_num;i++) scanf("%d",&num[i]); int sum , ans , st , ed , ans_st , ans_ed; ans_st = ans_ed = st = ed = sum = ans = num[0]; for(int i = 1;i < n_num;i++) { if(sum > 0) { sum += num[i]; ed = num[i]; } else st = ed = sum = num[i]; if(ans < sum) { ans_st = st , ans_ed = ed , ans = sum; } } if(ans < 0) printf("0 %d %d\n",num[0] , num[n_num - 1]); else printf("%d %d %d\n" , ans , ans_st , ans_ed); } return 0; }
上文给出了一个关于最大连续子序列和的比较抽象化的分析(连状态转移方程)都没给出。这源于笔者从一个比较抽象的角度来理解整个动态规划的过程,其实我们这里依然可以模拟我们在LCS、LIS对整个过程的分析。我们这是数组dp[i]记录以序列S第i个元素为终点的最大和,那么我们直接考察dp[i]和dp[i-1]的关系,容易看到dp[i-1]呈现出如下两种状态。
如果dp[i-1]是负值,则当前状况下最优的决策显然是抛去先前构造的以a[i-1]为终点的子序列,从a[i]重新构造子序列。
而如果dp[i-1]是正值,则在当前情况下,构造以a[i-1]为终点的子序列中,最优的决策显然是将a[i]放在a[i-1]后面形成新的子序列。需要注意的是,这里的最优情况是所有以a[i-1]为终点的子序列,而非全局的最优情况。
概括来讲,我们可以得到这样的状态转移方程:
if(dp[i-1] < 0) dp[i] = a[i]
else dp[i] = dp[i-1] + a[i]
更加简练的一种写法如下。
dp[i] = max(dp[i-1] + a[i] , a[i])。
基于dp[1~n](n是序列S的长度),我们得到了所有子问题的解,随后找到最优解即可。
可以看到,比较对最大连续子序列和的两种分析方式,其核心的动态规划思想是本质相同的,稍有区别的是前者在动态规划的过程中已经在动态维护着最优解,而后者则是先将全局问题给子问题化然后得到各个子问题的答案,最后遍历一遍子问题的解空间然后维护出最大值。相比较而言,前者效率更高但是过程较为抽象,后者效率偏低但是很好理解。
我们结合一个问题来体会一下这种对最大连续子序列和的方法。(Problem source : hdu 1003)
基于上文的分析,我们容易找到最大的和,同时该题需要输出该子序列的首尾元素的下标,根据dp[]数组的内涵,我们在维护最大和的时候可以记录下尾元素的下标,然后通过该元素的位置往前(S序列中)依次相加判断何时得到最大和便可以得到首元素下标。根据题设的第二组数据不难看出,在最大和相同的时候,我们想让子序列尽量长,那么在编程实现小小的处理一下细节即可。
参考代码如下。
#include<cstdio> #include<string.h> using namespace std; const int maxn = 100000 + 5; int main() { int t; scanf("%d",&t); int tt = 1; while(t--) { int a[maxn] , dp[maxn]; int n; scanf("%d",&n); for(int i = 0;i < n;i++) scanf("%d",&a[i]); dp[0] = a[0]; for(int i = 1;i < n;i++) { if(dp[i-1] < 0) dp[i] = a[i]; else dp[i] = dp[i-1] + a[i]; } int Max = dp[0]; int e_index = 0; for(int i = 0;i < n;i++) { if(dp[i] > Max) Max = dp[i] , e_index = i; } int temp = 0; int s_index = 0; for(int i = e_index;i >= 0;i--) { temp += a[i]; if(temp == Max) s_index = i ; } printf("Case %d:\n%d %d %d\n",tt++,Max , s_index + 1, e_index + 1); if(t) printf("\n"); } }
讨论了线性dp几个经典的模型,下面我们便要开始对线性dp进一步的学习。
让我们再看一道线性dp问题。(Problem source : hdu 4055)
Your task is as follows: You are given a string describing the signature of many possible permutations, find out how many permutations satisfy this signature.
Note: For any positive integer n, a permutation of n elements is a sequence of length n that contains each of the integers 1 through n exactly once.
Each test case occupies exactly one single line, without leading or trailing spaces.
Proceed to the end of file. The '?' in these strings can be either 'I' or 'D'.
题目大意:给出一个长度为n-1的字符串用于表示[1,n]组成的序列的增减性。如果字符串第i位是I,表示序列中第i位大于第i-1位;如果字符串第i位是D,相反;如果是?,则没有限制。那么请你求解有多少个符合这个字符串描述的序列。
数理分析:容易看到,该题目是基于[1,n]的线性序列的,因此这里我们可以想到用区间dp中的一些思维和方法来解决问题。我们看到对于每种状态有两个维度的描述,一个是当前序列的长度,而另一个则是当前序列末尾的数字(因为字符串给出的是相邻两位的增减关系,我们应该能够想到需要记录当前序列末尾的数字以进行比较大小,另外LIS等经典线性dp也是采用类似的方法)。
那么我们就可以很好的进行子问题化了,设置dp[i][j]表示长度为i,序列末尾是数字j,并符合增减描述的序列种类数。
下面便是寻求状态转移方程。我们从中间状态分析。定义s[]表示记录序列增减性的字符串。
①s[i-1] = ? => dp[i][j] = ∑dp[i-1][k] (k∈[1,i-1])
②s[i-1] = I => dp[i][j] = ∑dp[i-1][k] (k∈[1,j-1])
③s[i-1] = D => dp[i][j] = ∑dp[i-1][k] (k∈[1,i-1]) - ∑dp[i-1][k] (k∈[1,j-1])
对于∑的形式在计算的时候显得有点繁琐,每次访问都需要扫一遍,计算时间上显得有点捉急,为了访问的简便,我们设置sum[i][j]表示长度为i,序列最后一个数字小于等于j的符合要求的序列总数,即sum[i][j] = ∑dp[i][k] (k ∈[1,j]),由此我们可以简化一下状态转移方程,并在求解过程中维护sum[i][j]的值。
①s[i-1] = ? => dp[i][j] = sum[i-1][i-1]
②s[i-1] = I => dp[i][j] = sum[i-1][j-1]
③s[i-1] = D => dp[i][j] = sum[i-1][i-1] - sum[i-1][j-1]
而对于最终解,对于长度为n的字符串,序列应有n+1个元素,而显然最后一个元素一定小于等于n+1,即sum[n+1][n+1]为最终解。
另外这道问题有一个值得注意的点,便是如果我们现在填充第i位,我们基于一个[1,i-1]的子问题,而数字i其实可以混入到这个子问题的符合要求的序列当中,此时我们若将i所在的位置换成i-1,这便是一个子问题,而这个位置现在是i,实际上并不妨碍这个序列的增减性(i和i-1都是这个序列中最大的数字),因此我们在填充第i个数的时候,考虑那种特殊情况,本质上开始考虑[1,i-1]的子问题。
基于以上的数理分析,我们不难进行编码实现。
参考代码如下。
#include<iostream> #include<cstdio> #include<cstring> using namespace std; const int maxn = 1005; const int Mod = 1000000007; int dp[maxn][maxn] , sum[maxn][maxn]; char str[maxn]; int main() { while(scanf("%s",str + 2) != EOF) { memset(dp , 0 , sizeof(dp)); memset(sum , 0 , sizeof(sum)); int len = (int)strlen(str + 2); dp[1][1] = 1 , sum[1][1] = 1; for(int i = 2;i <= len + 1;i++) { for(int j = 1;j <= i;j++) { if(str[i] == 'I') dp[i][j] = (sum[i-1][j-1])%Mod; if(str[i] == 'D') { int temp = ((sum[i-1][i-1]-sum[i-1][j-1])%Mod + Mod)%Mod; dp[i][j] = (dp[i][j] + temp)%Mod; } if(str[i] == '?') dp[i][j] = (sum[i-1][i-1]) % Mod; sum[i][j] = (dp[i][j] + sum[i][j-1])%Mod; } } printf("%d\n",sum[len+1][len+1]); } return 0; }