很多問題往往會給出一個序列或者一個數表,讓你對其進行划分,或者選出其中的某個最優子集。這一類問題往往適合使用線性DP。
線性DP是一種非常常見的DP。它往往以狀態內的其中一個維度划分階段。接下來,我將給出幾個非常重要的轉移方程。
最長上升(下降)子序列LIS
已知一個序列\(A_i\)。現在我希望從這個序列中從左往右選出若干個元素,使得這些元素組成的子序列元素大小單調遞增。求這樣序列的最大長度。
我們嘗試設計狀態表示這個最大高度。不難發現,只要\(A_i < A_j,i<j\),\(A_j\)就可以和\(A_i\)合並。這個過程和\(A_i\)以前的元素沒有直接的關系。
於是我們嘗試設\(F(i)\)為以\(A_i\)為結尾的,從\(A_1 \sim A_i\)中選出的LIS。不難發現這樣一個轉移關系:
也就是說,我們以前\(i\)個序列划分階段,如果有\(A_j < A_i, j < i\),那么答案就可以從\(F(j)\)轉移到\(F(i)\)。
初始值\(F(1) = 1\)。
上面這個做法的時間復雜度為\(O(N^2)\),但我們可以通過以下兩種方式做到\(O(N \log N)\)。
樹狀數組維護最大值
樹狀數組不能維護區間最大值,但可以維護前綴和后綴最大值。一個狀態\(F(i)\)的決策集合為\(\{j\mid j < i, A_j < A_i\}\)。我們可以在\(A_i\)的值域上建立樹狀數組,保存結尾在\([0,A_i]\)范圍內的最長上升子序列長度。由於我們每次按輸入順序查詢,並更新之,我們自動滿足了\(i < j\)的條件。
貪心+二分
並不能算是標准做法,但是非常巧妙。它並沒有直接設狀態表示最長上升子序列的長度,而是設\(F(i)\)表示當最長上升子序列的長度為\(i\)時,這個子序列末尾的最小值。
有一個比較顯然的貪心策略:當兩個上升子序列長度一樣時,我們應該保留結尾較小者,因為它更有可能接納更長的上升組序列。
核心代碼:
inline int bs(int num)
{
int l = 0, r = maxlen + 1;
while(l < r)
{
int mid = (l + r) >> 1;
if(F[mid] > num)
r = mid;
else
l = mid + 1;
}
return l;
}
int main()
{
N = qr(1);
RP(i, 1, N) A[i] = qr(1);
RP(i, 1, N)
{
int pos = bs(A[i]);
if(pos > maxlen)
{
maxlen = pos;
F[pos] = A[i];
}
else
F[pos] = A[i];
}
printf("%d", maxlen);
return 0;
}
可以看出,盡管DP的速度相較搜索而言相當快了,但也可以通過其他算法進行更深層的優化。
最長公共子序列LCS
給定兩個序列\(A_i\),\(B_i\),現在我們要求找出一個序列\(C\),使得\(C\)既是\(A\)的子序列,又是\(B\)的子序列。請你最大化序列\(C\)長度。
從上一題來看,一個比較直觀的想法是設\(F(i,j)\)表示以\(A_i\)結尾,以\(B_j\)結尾的最長公共子序列。當\(A_i \neq B_j\)時,直接令\(F(i,j)=0\)。當\(A_i = B_j\)時,有轉移方程 \(F(i,j) = \max_{k < i, l < j, A_k = A_i, B_l = B_j}\{F(k,l)\}+1\)。
這個方程有兩個比較明顯的問題。首先,\(F(i,j)\)這個狀態是不是過度冗余?很大一部分的\(F(i,j)=0\),這樣會浪費大量的空間。其次,時間復雜度過高,達到了\(O(N^4)\)。如果用鏈表也許可以減少時間,但還是減少不了多少。
考慮這樣一種做法:設\(F(i,j)\)表示前綴序列\(A_{1\cdots i}\)和\(B_{1\cdots j}\)的最長個公共子序列之長(此時這個子序列不一定包含\(A_i,B_j\)!)。此時的\(i,j\)可以看作是兩個掃描數組的指針。
當\(i,j\)想向后擴展時,有以下情況:
- \(A_{i+1}=B_{j+1}\)。此時\(i,j\)均往后跳一位,並讓序列的長度\(+1\)。
- \(A_{i+1}\neq B_{j+1}\)。此時指針是不能同時跳的。根據DFS的思想,我們會作如下嘗試:
- 嘗試讓\(i\)往后跳一次,搜索\((i+1,j)\)這個狀態
- 嘗試讓\(j\)往后跳一次,搜索\((i,j+1)\)這個狀態
- 返回兩次嘗試的最大值
綜上,當\(A_{i+1}=B_{i+1}\)時,\(F(i,j)\)可以直接轉移到\(F(i+1,j+1)\),並增加一個貢獻。反之,\(F(i,j)\)應該轉移到\(F(i,j+1)\)或者\(F(i+1,j)\),並取其中的最大值。
把上面的每個方程反過來寫,寫成被轉移狀態關於轉移狀態的表達式,把\(i+1,i\)改成\(i,i-1\),就可以得到轉移方程:
另外,這道題的階段划分依據叫做“已經處理的前綴長度”。這里並沒有直接指明到底是哪一個前綴,因此任意選擇一個序列即可。
最長公共上升子序列LCIS
LIS和LCS的結合。求序列\(A_i,B_i\)最長的,單調遞增的LCS。
注意到第一題的狀態保證了“取結尾”,而第二題卻沒有。為了降低時間復雜度,這里我們應該采用“半保留”的設計方法。
設\(F(i,j)\)表示掃描到前綴子序列\(A_{1\cdots i}\),以\(B_j\)結尾的LCIS。
當\(i\)指針向后移一位時,前綴子串會多出來一個新的數\(A_{i+1}\)。對於這個數,我們有兩種方案:
- 不嘗試匹配這個數。此時\(j\)不動,直接把答案轉移給\(F(i+1,j)\)。
- 嘗試匹配這個數。此時\(j\)往后跳,找到一個位置\(k\),使得\(B_k = A_{i+1}, B_j < B_k\)。嘗試更新這個狀態\(F(i+1,k)\)。注意到可能有多個數匹配到這個位置\(k\),因此不能直接賦值。
把這兩種方案寫成填表法的形式,把上面的\(j\)分別替換成\(j-1\)和\(k\),就得到:
這個轉移的時間復雜度是\(O(N^3)\)的,其中這里只能以\(A_i\)的前綴子序列長度划分階段。但它還有優化的空間。
注意到在任意時刻,如果我們需要\(O(N)\)枚舉\(F(i,j)\)的決策集合,那么必然有\(A_i = B_j\)。因此,當\(A_i = B_j\)時,轉移方程還可以寫成這個樣子:
在每個階段,\(A_i\)的值都是固定的;在當前階段,對決策集合的限制條件只有\(k < j\)。因此,當前\(F(i,j)\)計算完之后,\(j < j' = j+\Delta j\),那么\(F(i,j)\)就會立刻被加到\(F(i,j')\)的決策集合中!
這就是時間復雜度過大的根源。我們花費了額外\(O(N^2)\)的時間來重復掃描這個決策集合,而這個集合實際上是“開源”的,可以供當前階段的所有狀態使用。
因此,我們只需要用一個變量\(F_m\)來維護\(\max_{B_j < A_i}\{F(i,j)\}\)。每當計算完一個狀態\(F(i,j)\),我們就可以拿它來更新\(F_m\)。這樣時間復雜度就降為了\(O(N^2)\)。