構造迴文的最小插入次數

迴文串就是正着讀反着讀都一樣的字符,在筆試面試中經常出現這類問題。

labuladong 公衆號有好幾篇講解迴文問題的文章,是判斷迴文串或者尋找最長迴文串/子序列的:

判斷迴文鏈表

計算最長迴文子串

計算最長迴文子序列

本文就來研究一道構造迴文串的問題,難度 Hard 計算讓字符串成爲迴文串的最少插入次數:

輸入一個字符串 s,你可以在字符串的任意位置插入任意字符。如果要把 s 變成迴文串,請你計算最少要進行多少次插入?

函數簽名如下:

int minInsertions(string s);

比如說輸入 s = "abcea",算法返回 2,因爲可以給 s 插入 2 個字符變成迴文串 "abeceba"或者 "aebcbea"。如果輸入 s = "aba",則算法返回 0,因爲 s 已經是迴文串,不用插入任何字符。

PS:我認真寫了 100 多篇原創,手把手刷 200 道力扣題目,全部發布在 labuladong的算法小抄,持續更新。建議收藏,按照我的文章順序刷題,掌握各種算法套路後投再入題海就如魚得水了。

思路解析

首先,要找最少的插入次數,那肯定得窮舉嘍,如果我們用暴力算法窮舉出所有插入方法,時間複雜度是多少?

每次都可以在兩個字符的中間插入任意一個字符,外加判斷字符串是否爲迴文字符串,這時間複雜度肯定爆炸,是指數級。

那麼無疑,這個問題需要使用動態規劃技巧來解決。之前的文章說過,迴文問題一般都是從字符串的中間向兩端擴散,構造迴文串也是類似的。

我們定義一個二維的 dp 數組,dp[i][j] 的定義如下:對字符串 s[i..j],最少需要進行 dp[i][j] 次插入才能變成迴文串

我們想求整個 s 的最少插入次數,根據這個定義,也就是想求 dp[0][n-1] 的大小(n 爲 s的長度)。

同時,base case 也很容易想到,當 i == j 時 dp[i][j] = 0,因爲當 i == j 時 s[i..j]就是一個字符,本身就是迴文串,所以不需要進行任何插入操作。

接下來就是動態規劃的重頭戲了,利用數學歸納法思考狀態轉移方程。

PS:我認真寫了 100 多篇原創,手把手刷 200 道力扣題目,全部發布在 labuladong的算法小抄,持續更新。建議收藏,按照我的文章順序刷題,掌握各種算法套路後投再入題海就如魚得水了。

狀態轉移方程

狀態轉移就是從小規模問題的答案推導更大規模問題的答案,從 base case 向其他狀態推導嘛。如果我們現在想計算 dp[i][j] 的值,而且假設我們已經計算出了子問題 dp[i+1][j-1] 的值了,你能不能想辦法推出 dp[i][j] 的值呢

ff23b2bcb0c713e504c64d79e7f95a20.jpeg

既然已經算出 dp[i+1][j-1],即知道了 s[i+1..j-1] 成爲迴文串的最小插入次數,那麼也就可以認爲 s[i+1..j-1] 已經是一個迴文串了,所以通過 dp[i+1][j-1] 推導 dp[i][j] 的關鍵就在於 s[i] 和 s[j] 這兩個字符

ce0269a9f8e3c1e434d7c6ee07133b4c.jpeg

這個得分情況討論,如果 s[i] == s[j] 的話,我們不需要進行任何插入,只要知道如何把 s[i+1..j-1] 變成迴文串即可:

aac6bbcbb5df9a10e66863a99bcfc153.jpeg

翻譯成代碼就是這樣:

if (s[i] == s[j]) {
    dp[i][j] = dp[i + 1][j - 1];
}

如果 s[i] != s[j] 的話,就比較麻煩了,比如下面這種情況:

1a0fce4fbd3ea4696b3082d7b7a7a3d8.jpeg

最簡單的想法就是,先把 s[j] 插到 s[i] 右邊,同時把 s[i] 插到 s[j] 右邊,這樣構造出來的字符串一定是迴文串:

a189c0d04a04bdc67eb772a7642aba83.jpeg

PS:當然,把 s[j] 插到 s[i] 左邊,然後把 s[i] 插到 s[j] 左邊也是一樣的,後面會分析。

但是,這是不是就意味着代碼可以直接這樣寫呢?

if (s[i] != s[j]) {
    // 把 s[j] 插到 s[i] 右邊,把 s[i] 插到 s[j] 右邊
    dp[i][j] = dp[i + 1][j - 1] + 2;
}

不對,比如說如下這兩種情況,只需要插入一個字符即可使得 s[i..j] 變成迴文:

cf20d089469b960c336f679accbacc25.jpeg

所以說,當 s[i] != s[j] 時,無腦插入兩次肯定是可以讓 s[i..j] 變成迴文串,但是不一定是插入次數最少的,最優的插入方案應該被拆解成如下流程:

步驟一,做選擇,先將 s[i..j-1] 或者 s[i+1..j] 變成迴文串。怎麼做選擇呢?誰變成迴文串的插入次數少,就選誰唄。

比如圖二的情況,將 s[i+1..j] 變成迴文串的代價小,因爲它本身就是迴文串,根本不需要插入;同理,對於圖三,將 s[i..j-1] 變成迴文串的代價更小。

然而,如果 s[i+1..j] 和 s[i..j-1] 都不是迴文串,都至少需要插入一個字符才能變成迴文,所以選擇哪一個都一樣:

1d90f70a5e5dba2ef174a3cb006ce947.jpeg

那我怎麼知道 s[i+1..j] 和 s[i..j-1] 誰變成迴文串的代價更小呢?

回頭看看 dp 數組的定義是什麼,dp[i+1][j] 和 dp[i][j-1] 不就是它們變成迴文串的代價麼?

步驟二,根據步驟一的選擇,將 s[i..j] 變成迴文

如果你在步驟一中選擇把 s[i+1..j] 變成迴文串,那麼在 s[i+1..j] 右邊插入一個字符 s[i]一定可以將 s[i..j] 變成迴文;同理,如果在步驟一中選擇把 s[i..j-1] 變成迴文串,在 s[i..j-1] 左邊插入一個字符 s[j] 一定可以將 s[i..j] 變成迴文。

那麼根據剛纔對 dp 數組的定義以及以上的分析,s[i] != s[j] 時的代碼邏輯如下:

if (s[i] != s[j]) {
    // 步驟一選擇代價較小的
    // 步驟二必然要進行一次插入
    dp[i][j] = min(dp[i + 1][j], dp[i][j - 1]) + 1;
}

綜合起來,狀態轉移方程如下:

if (s[i] == s[j]) {
    dp[i][j] = dp[i + 1][j - 1];
} else {
    dp[i][j] = min(dp[i + 1][j], dp[i][j - 1]) + 1;
}

這就是動態規劃算法核心,我們可以直接寫出解法代碼了。

代碼實現

首先想想 base case 是什麼,當 i == j 時 dp[i][j] = 0,因爲這時候 s[i..j] 就是單個字符,本身就是迴文串,不需要任何插入;最終的答案是 dp[0][n-1]n 是字符串 s 的長度)。那麼 dp table 長這樣:

7cb8dd52fbfa9fd2bf945fd131754cc7.jpeg

又因爲狀態轉移方程中 dp[i][j] 和 dp[i+1][j]dp[i]-1]dp[i+1][j-1] 三個狀態有關,爲了保證每次計算 dp[i][j] 時,這三個狀態都已經被計算,我們一般選擇從下向上,從左到右遍歷 dp 數組:

9ef360ec6319b66937ac7ffb230e76d3.jpeg

PS:我認真寫了 100 多篇原創,手把手刷 200 道力扣題目,全部發布在 labuladong的算法小抄,持續更新。建議收藏,按照我的文章順序刷題,掌握各種算法套路後投再入題海就如魚得水了。

完整代碼如下:

int minInsertions(string s) {
    int n = s.size();
    // 定義:對 s[i..j],最少需要插入 dp[i][j] 次才能變成迴文
    vector<vector<int>> dp(n, vector<int>(n, 0));
    // base case:i == j 時 dp[i][j] = 0,單個字符本身就是迴文
    // dp 數組已經全部初始化爲 0,base case 已初始化

    // 從下向上遍歷
    for (int i = n - 2; i >= 0; i--) {
        // 從左向右遍歷
        for (int j = i + 1; j < n; j++) {
            // 根據 s[i] 和 s[j] 進行狀態轉移
            if (s[i] == s[j]) {
                dp[i][j] = dp[i + 1][j - 1];
            } else {
                dp[i][j] = min(dp[i + 1][j], dp[i][j - 1]) + 1;
            }
        }
    }
    // 根據 dp 數組的定義,題目要求的答案
    return dp[0][n - 1];
}

現在這道題就解決了,時間和空間複雜度都是 O(N^2)。還有一個小優化,注意到 dp 數組的狀態之和它相鄰的狀態有關,所以 dp 數組是可以壓縮成一維的:

int minInsertions(string s) {
    int n = s.size();
    vector<int> dp(n, 0);

    int temp = 0;
    for (int i = n - 2; i >= 0; i--) {
        // 記錄 dp[i+1][j-1]
        int pre = 0;
        for (int j = i + 1; j < n; j++) {
            temp = dp[j];

            if (s[i] == s[j]) {
                // dp[i][j] = dp[i+1][j-1];
                dp[j] = pre;
            } else {
                // dp[i][j] = min(dp[i+1][j], dp[i][j-1]) + 1;
                dp[j] = =min(dp[j], dp[j - 1]) + 1;
            }

            pre = temp;
        }
    }

    return dp[n - 1];
}

至於這個狀態壓縮是怎麼做的,我們前文 狀態壓縮技巧 詳細介紹過,這裏就不展開了。


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章