1. 程式人生 > 實用技巧 >狀態壓縮技巧:動態規劃的降維打擊

狀態壓縮技巧:動態規劃的降維打擊

本文由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 迴圈遍歷ij的順序為從左向右,從下向上,所以可以發現,在更新一維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 = 7s[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原創,本博文僅作為知識點學習,不會用於任何商業用途!