狀態壓縮技巧:動態規劃的降維打擊
本文由labuladong
原創,本博文僅作為知識點學習,不會用於任何商業用途!
動態規劃技巧對於演算法效率的提升非常可觀,一般來說都能把指數級和階乘級時間複雜度的演算法優化成 O(N^2),堪稱演算法界的二向箔,把各路魑魅魍魎統統打成二次元。
但是,動態規劃本身也是可以進行階段性優化的,比如說我們常聽說的「狀態壓縮」技巧,就能夠把很多動態規劃解法的空間複雜度進一步降低,由 O(N^2) 降低到 O(N),
能夠使用狀態壓縮技巧的動態規劃都是二維dp
問題,你看它的狀態轉移方程,如果計算狀態dp[i][j]
需要的都是dp[i][j]
相鄰的狀態,那麼就可以使用狀態壓縮技巧,將二維的dp
陣列轉化成一維,將空間複雜度從 O(N^2) 降低到 O(N)。
什麼叫「和dp[i][j]
相鄰的狀態」呢,比如前文 最長迴文子序列 中,最終的程式碼如下:
int longestPalindromeSubseq(string s) { int n = s.size(); // dp 陣列全部初始化為 0 vector<vector<int>> dp(n, vector<int>(n, 0)); // base case for (int i = 0; i < n; i++) dp[i][i] = 1; // 反著遍歷保證正確的狀態轉移 for (int i = n - 2; i >= 0; i--) { for (int j = i + 1; j < n; j++) { // 狀態轉移方程 if (s[i] == s[j]) dp[i][j] = dp[i + 1][j - 1] + 2; else dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]); } } // 整個 s 的最長迴文子串長度 return dp[0][n - 1]; }
PS:我們本文不探討如何推狀態轉移方程,只探討對二維 DP 問題進行狀態壓縮的技巧。技巧都是通用的,所以如果你沒看過前文,不明白這段程式碼的邏輯也無妨,完全不會阻礙你學會狀態壓縮。
你看我們對dp[i][j]
的更新,其實只依賴於dp[i+1][j-1], dp[i][j-1], dp[i+1][j]
這三個狀態:
這就叫和dp[i][j]
相鄰,反正你計算dp[i][j]
只需要這三個相鄰狀態,其實根本不需要那麼大一個二維的 dp table 對不對?
狀態壓縮的核心思路就是,將二維陣列「投影」到一維陣列:
思路很直觀,但是也有一個明顯的問題,圖中dp[i][j-1]
和dp[i+1][j-1]
dp[i][j]
時,他倆必然有一個會被另一個覆蓋掉,怎麼辦?
這就是狀態壓縮的難點,下面就來分析解決這個問題,還是拿「最長迴文子序列」問題舉例,它的狀態轉移方程主要邏輯就是如下這段程式碼:
for (int i = n - 2; i >= 0; i--) {
for (int j = i + 1; j < n; j++) {
// 狀態轉移方程
if (s[i] == s[j])
dp[i][j] = dp[i + 1][j - 1] + 2;
else
dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);
}
}
想把二維dp
陣列壓縮成一維,一般來說是把第一個維度,也就是i
這個維度去掉,只剩下j
這個維度。壓縮後的一維dp
陣列就是之前二維dp
陣列的dp[i][..]
那一行。
我們先將上述程式碼進行改造,直接無腦去掉i
這個維度,把dp
陣列變成一維:
for (int i = n - 2; i >= 0; i--) {
for (int j = i + 1; j < n; j++) {
// 在這裡,一維 dp 陣列中的數是什麼?
if (s[i] == s[j])
dp[j] = dp[j - 1] + 2;
else
dp[j] = max(dp[j], dp[j - 1]);
}
}
上述程式碼的一維dp
陣列只能表示二維dp
陣列的一行dp[i][..]
,那我怎麼才能得到dp[i+1][j-1], dp[i][j-1], dp[i+1][j]
這幾個必要的的值,進行狀態轉移呢?
在程式碼中註釋的位置,將要進行狀態轉移,更新dp[j]
,那麼我們要來思考兩個問題:
1、在對dp[j]
賦新值之前,dp[j]
對應著二維dp
陣列中的什麼位置?
2、dp[j-1]
對應著二維dp
陣列中的什麼位置?
對於問題 1,在對dp[j]
賦新值之前,dp[j]
的值就是外層 for 迴圈上一次迭代算出來的值,也就是對應二維dp
陣列中dp[i+1][j]
的位置。
對於問題 2,dp[j-1]
的值就是內層 for 迴圈上一次迭代算出來的值,也就是對應二維dp
陣列中dp[i][j-1]
的位置。
那麼問題已經解決了一大半了,只剩下二維dp
陣列中的dp[i+1][j-1]
這個狀態我們不能直接從一維dp
陣列中得到:
for (int i = n - 2; i >= 0; i--) {
for (int j = i + 1; j < n; j++) {
if (s[i] == s[j])
// dp[i][j] = dp[i+1][j-1] + 2;
dp[j] = ?? + 2;
else
// dp[i][j] = max(dp[i+1][j], dp[i][j-1]);
dp[j] = max(dp[j], dp[j - 1]);
}
}
因為 for 迴圈遍歷i
和j
的順序為從左向右,從下向上,所以可以發現,在更新一維dp
陣列的時候,dp[i+1][j-1]
會被dp[i][j-1]
覆蓋掉,圖中標出了這四個位置被遍歷到的次序:
那麼如果我們想得到dp[i+1][j-1]
,就必須在它被覆蓋之前用一個臨時變數temp
把它存起來,並把這個變數的值保留到計算dp[i][j]
的時候。為了達到這個目的,結合上圖,我們可以這樣寫程式碼:
for (int i = n - 2; i >= 0; i--) {
// 儲存 dp[i+1][j-1] 的變數
int pre = 0;
for (int j = i + 1; j < n; j++) {
int temp = dp[j];
if (s[i] == s[j])
// dp[i][j] = dp[i+1][j-1] + 2;
dp[j] = pre + 2;
else
dp[j] = max(dp[j], dp[j - 1]); // 到下一輪迴圈,pre 就是 dp[i+1][j-1] 了
pre = temp;
}
}
別小看這段程式碼,這是一維dp
最精妙的地方,會者不難,難者不會。為了清晰起見,我用具體的數值來拆解這個邏輯:
假設現在i = 5, j = 7
且s[5] == s[7]
,那麼現在會進入下面這個邏輯對吧:
if (s[5] == s[7])
// dp[5][7] = dp[i+1][j-1] + 2;
dp[7] = pre + 2;
我問你這個pre
變數是什麼?是內層 for 迴圈上一次迭代的temp
值。
那我再問你內層 for 迴圈上一次迭代的temp
值是什麼?是dp[j-1]
也就是dp[6]
,但這是外層 for 迴圈上一次迭代對應的dp[6]
,也就是二維dp
陣列中的dp[i+1][6] = dp[6][6]
。
也就是說,pre
變數就是dp[i+1][j-1] = dp[6][6]
,也就是我們想要的結果。
那麼現在我們成功對狀態轉移方程進行了降維打擊,算是最硬的的骨頭啃掉了,但注意到我們還有 base case 要處理呀:
// 二維 dp 陣列全部初始化為 0
vector<vector<int>> dp(n, vector<int>(n, 0));
// base case
for (int i = 0; i < n; i++)
dp[i][i] = 1;
如何把 base case 也打成一維呢?很簡單,記住,狀態壓縮就是投影,我們把 base case 投影到一維看看:
二維dp
陣列中的 base case 全都落入了一維dp
陣列,不存在衝突和覆蓋,所以說我們直接這樣寫程式碼就行了:
// 一維 dp 陣列全部初始化為 1
vector<int> dp(n, 1);
至此,我們把 base case 和狀態轉移方程都進行了降維,實際上已經寫出完整程式碼了:
int longestPalindromeSubseq(string s) {
int n = s.size();
// base case:一維 dp 陣列全部初始化為 1
vector<int> dp(n, 1);
for (int i = n - 2; i >= 0; i--) {
int pre = 0;
for (int j = i + 1; j < n; j++) {
int temp = dp[j];
// 狀態轉移方程
if (s[i] == s[j])
dp[j] = pre + 2;
else
dp[j] = max(dp[j], dp[j - 1]);
pre = temp;
}
}
return dp[n - 1];
}
本文就結束了,不過狀態壓縮技巧再牛逼,也是基於常規動態規劃思路之上的。
你也看到了,使用狀態壓縮技巧對二維dp
陣列進行降維打擊之後,解法程式碼的可讀性變得非常差了,如果直接看這種解法,任何人都是一臉懵逼的。
演算法的優化就是這麼一個過程,先寫出可讀性很好的暴力遞迴演算法,然後嘗試運用動態規劃技巧優化重疊子問題,最後嘗試用狀態壓縮技巧優化空間複雜度。
也就是說,你最起碼能夠熟練運用我們前文 動態規劃框架套路詳解 的套路找出狀態轉移方程,寫出一個正確的動態規劃解法,然後才有可能觀察狀態轉移的情況,分析是否可能使用狀態壓縮技巧來優化。
希望讀者能夠穩紮穩打,層層遞進,對於這種比較極限的優化,不做也罷。畢竟套路存於心,走遍天下都不怕!
本文由labuladong
原創,本博文僅作為知識點學習,不會用於任何商業用途!