我們在解決一些線性區間上的最優化問題的時候,往往也能夠利用到動態規划的思想,這種問題可以叫做線性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; }