1. 程式人生 > >[LeetCode] Minimum Window Subsequence 最小視窗序列

[LeetCode] Minimum Window Subsequence 最小視窗序列

Given strings S and T, find the minimum (contiguous) substring W of S, so that T is a subsequence of W.

If there is no such window in S that covers all characters in T, return the empty string "". If there are multiple such minimum-length windows, return the one with the left-most starting index.

Example 1:

Input: 
S = "abcdebdde", T = "bde"
Output: "bcde"
Explanation: 
"bcde" is the answer because it occurs before "bdde" which has the same length.
"deb" is not a smaller window because the elements of T in the window must occur in order.

Note:

  • All the strings in the input will only contain lowercase letters.
  • The length of S will be in the range [1, 20000].
  • The length of T will be in the range [1, 100].

這道題給了我們兩個字串S和T,讓我們找出S的一個長度最短子串W,使得T是W的子序列,如果長度相同,取起始位置靠前的。清楚子串和子序列的區別,那麼題意就不難理解,題目中給的例子也很好的解釋了題意。我們經過研究可以發現,返回的子串的起始字母和T的起始字母一定相同,這樣才能保證最短。那麼你肯定會想先試試暴力搜尋吧,以S中每個T的起始字母為起點,均開始搜尋字串T,然後維護一個子串長度的最小值。如果是這種思路,那麼還是趁早打消念頭吧,博主已經替你試過了,OJ不依。原因也不難想,假如S中有大量的連續b,並且如果T也很長的話,這種演算法實在是不高效啊。根據博主多年經驗,這種玩字串且還是Hard的題,十有八九都是要用動態規劃Dynamic Programming來做的,那麼就直接往DP上去想吧。DP的第一步就是設計dp陣列,像這種兩個字串的題,一般都是一個二維陣列,想想該怎麼定義。確定一個子串的兩個關鍵要素是起始位置和長度,那麼我們的dp值到底應該是定起始位置還是長度呢?That is a question! 仔細想一想,其實起始位置是長度的基礎,因為我們一旦知道了起始位置,那麼當前位置減去起始位置,就是長度了,所以我們dp值定為起始位置。那麼 dp[i][j] 表示範圍S中前i個字元包含範圍T中前j個字元的子串的起始位置,注意這裡的包含是子序列包含關係。然後就是確定長度了,有時候會使用字串的原長度,有時候會多加1,看個人習慣吧,這裡博主長度多加了個1。

OK,下面就是重中之重啦,求遞推式。一般來說,dp[i][j]的值是依賴於之前已經求出的dp值的,在遞迴形式的解法中,dp陣列也可以看作是記憶陣列,從而省去了大量的重複計算,這也是dp解法凌駕於暴力搜尋之上的主要原因。牛B的方法總是最難想出來的,dp的遞推式就是其中之一。在腦子一片漿糊的情況下,博主的建議是從最簡單的例子開始分析,比如 S = "b", T = "b", 那麼我們就有 dp[1][1] = 0,因為S中的起始位置為0,長度為1的子串可以包含T。如果當 S = "d", T = "b",那麼我們有 dp[1][1] = -1,因為我們的dp陣列初始化均為-1,表示未匹配或者無法匹配。下面來看一個稍稍複雜些的例子,S = "dbd", T = "bd",我們的dp陣列是:

   ∅  b  d
∅  ?  ?  ?
d  ? -1 -1
b  ?  1 -1
d  ?  1  1

這裡的問號是邊界,我們還不知道如何初給邊界賦值,我們看到,為-1的地方是對應的字母不相等的地方。我們首先要明確的是dp[i][j]中的j不能大於i,因為T的長度不能大於S的長度,所以j大於i的dp[i][j]一定都是-1的。再來看為1的幾個位置,首先是 dp[2][1] = 1,這裡表示db包含b的子串起始位置為1,make sense!然後是 dp[3][1] = 1,這裡表示dbd包含b的子串起始位置為1,沒錯!然後是 dp[3][2] = 1,這裡表示dbd包含bd的起始位置為1,all right! 那麼我們可以觀察出,當 S[i] == T[j] 的時候,實際上起始位置和 dp[i - 1][j - 1] 是一樣的,比如dbd包含bd的起始位置和db包含b的起始位置一樣,所以可以繼承過來。那麼當 S[i] != T[j] 的時候,怎麼搞?其實是和 dp[i - 1][j] 是一樣的,比如dbd包含b的起始位置和db包含b的起始位置是一樣的。

嗯,這就是遞推式的核心了,下面再來看邊界怎麼賦值,由於j比如小於等於i,所以第一行的第二個位置往後一定都是-1,我們只需要給第一列賦值即可。通過前面的分析,我們知道了當 S[i] == T[j] 時,我們取的是左上角的dp值,表示當前字母在S中的位置,由於我們dp陣列提前加過1,所以第一列的數只要賦值為當前行數即可。最終的dp陣列如下:

   ∅  b  d
∅  0 -1 -1
d  1 -1 -1
b  2  1 -1
d  3  1  1

為了使程式碼更加簡潔,我們在遍歷完每一行,檢測如果 dp[i][n] 不為-1,說明T已經被完全包含了,且當前的位置跟起始位置都知道了,我們計算出長度來更新一個全域性最小值minLen,同時更新最小值對應的起始位置start,最後取出這個全域性最短子串,如果沒有找到返回空串即可,參見程式碼如下:

解法一:

class Solution {
public:
    string minWindow(string S, string T) {
        int m = S.size(), n = T.size(), start = -1, minLen = INT_MAX;
        vector<vector<int>> dp(m + 1, vector<int>(n + 1, -1));
        for (int i = 0; i <= m; ++i) dp[i][0] = i;
        for (int i = 1; i <= m; ++i) {
            for (int j = 1; j <= min(i, n); ++j) {
                dp[i][j] = (S[i - 1] == T[j - 1]) ? dp[i - 1][j - 1] : dp[i - 1][j];
            }
            if (dp[i][n] != -1) {
                int len = i - dp[i][n];
                if (minLen > len) {
                    minLen = len;
                    start = dp[i][n];
                }
            }
        }
        return (start != -1) ? S.substr(start, minLen) : "";
    }
};

論壇上的danzhutest大神提出了一種雙指標的解法,其實這是優化過的暴力搜尋的方法,而且居然beat了100%,給跪了好嘛?!而且這雙指標的跳躍方式猶如舞蹈般美妙絕倫,比那粗鄙的暴力搜尋雙指標不知道高到哪裡去了?!舉個栗子來說吧,比如當 S = "bbbbdde", T = "bde"時,我們知道暴力搜尋的雙指標在S和T的第一個b匹配上之後,就開始檢測S之後的字元能否包含T之後的所有字元,當匹配結束後,S的指標就會跳到第二個b開始匹配,由於有大量的重複b出現,所以每一個b都要遍歷一遍,會達到平方級的複雜度,會被OJ無情拒絕。而下面這種修改後的演算法會跳過所有重複的b,使得效率大大提升,具體是這麼做的,當第一次匹配成功後,我們的雙指標往前走,找到那個剛好包含T中字元的位置,比如開始指標 i = 0 時,指向S中的第一個b,指標 j = 0 時指向T中的第一個b,然後開始匹配T,當 i = 6, j = 2 時,此時完全包含了T。暴力搜尋解法中此時i會回到1繼續找,而這裡,我們通過向前再次匹配T,會在 i = 3,j = 0處停下,然後繼續向後找,這樣S中重複的b就會被跳過,從而達到線性的複雜度。旋轉,跳躍,我閉著眼,塵囂看不見,你沉醉了沒?博主已經沉醉在這雙指標之舞中了......

解法二:

class Solution {
public:
    string minWindow(string S, string T) {
        int m = S.size(), n = T.size(), start = -1, minLen = INT_MAX, i = 0, j = 0;
        while (i < m) {
            if (S[i] == T[j]) {
                if (++j == n) {
                    int end = i + 1;
                    while (--j >= 0) {
                        while (S[i--] != T[j]);
                    }
                    ++i; ++j;
                    if (end - i < minLen) {
                        minLen = end - i;
                        start = i;
                    }
                }
            }
            ++i;
        }
        return (start != -1) ? S.substr(start, minLen) : "";
    }
};

類似題目:

Cheapest Flights Within K Stops

Domino and Tromino Tiling

參考資料: