1. 程式人生 > 實用技巧 >139. Word Break

139. Word Break

今天又是刷leetcode的一天。

今天做的是139. Word Break,說實話,我知道這道題是用dp來做,dp的兩大關鍵就是定義子問題和寫出狀態轉移方程。
其實一般來說,找到了子問題往往也就能用遞迴來做,只不過遞迴過程中有很多重複計算,所以為了避免這種重複計算,很多時候我們都是使用一種記憶性遞迴的做法。

典型的問題比如求斐波拉契數列,F(n) = F(n-1) + F(n-2), n≥2,當F(0)=F(1)=2.

這個計算過程是存在很多重複計算的,例如計算F(4) = F(3) + F(2),而計算F(3) = F(2) + F(1),可以看到這裡F(2)計算了兩次。

避免這種重複計算的辦法也很簡單,就是用一個數組把計算過的結果存下來,對於這個問題,我們可以在一開始遞迴之前新建一個長度為n+1的陣列(因為F(0)到F(n)一共n+1項),每一項初始化為-1。
然後在求某一項的時候如果它沒有被計算過,就計算它,並把它的值記錄在這個陣列中,如果計算過,那就直接從陣列讀取這個值並直接返回,避免進一步重複計算。

那麼我們發現,即便不用dp去做,而用這種記憶型遞迴方法去做的話,首要一步還是確定原問題的子問題,因為只有確定了子問題,我們才能知道遞迴結構怎麼寫。

對於今天這道題,是要把一個字串分成若干段,使得分出來的每一段都能在給定的字典中找到,換言之,就是說我們能不能從字典中選幾個單詞(每一個單詞都可以重複選擇),然後用選出來的這些單詞排列一下就能得到給定的字串。

例如用"leet" "code"兩個單詞是可以排列出"leetcode"的,題目的意思應該是比較清晰簡單的。

那麼用遞迴去做,我們先不考慮記憶性遞迴,該如何做呢?

我們先舉一個例子

Input: s = "catsandog", wordDict = ["cats", "dog", "sand", "and", "cat"]

這裡我們s串就是待分解的串,wordDict是我們的字典。

如果讓你去分解你是怎麼分解的?

我們從s串第一個字母c開始,看看字典中沒有沒它,我們發現沒有,於是我們再往後看一個字母a,此時我們要判斷字典中有沒有"ca",我們不斷這樣往後看,

我們發現,當我們看到"cat"的時候,字典中存在這個字串,於是我們再往後去試探,當我們試探到"sand"的時候,我們發現這個也能在字典中找到,我們接著往下找,

我們發現,剩下的"og"是不能在字典中找到的,問題是,這時候我們該怎麼辦?

沒錯,我們得在上一次成功的地方繼續往後試探,因為這次試探不通,說明上次不應該停在"sand"那個位置,於是我們我們讀入"sand"後面的o,發現"sando"找不到,一直到最後的"sandog"也找不到,
說明這一輪也失敗了,於是繼續回溯到上次成功的位置,那就是"cat",說明我們不應該停在這裡,應該繼續往後讀入。。。

有沒有發現這個過程貌似挺複雜的,對於不同長度的輸入串s,我們壓根就不知道應該回溯多少次,如果你只想到這裡,你大概率是還沒有發現原問題的子問題是啥,我覺得無論是遞迴還是dp,最重要的就是子問題。

子問題就是說和原問題的性質其實是一樣的,但是問題規模卻變小了一點。

例如,對於字串問題,我們是經常使用dp的,為什麼?因為字串有子串這麼一個概念,往往在子串中求解原問題對應的就是原問題的子問題。

比方說原問題是對s進行某種操作,如果是一維dp的話(也就是說我們只需要新建一個一維陣列來儲存狀態值),那麼子問題可以是對s串從0到i之間的子串進行這種操作,或者是對s串從i到最後一個位置之間的子串進行這種操作;如果是二維dp的話,一般是對s串從第i個位置到第j個位置之間的字串進行這種操作。

那麼回到這裡,我們這個問題的子問題是什麼呢?

從我們剛才的試探過程可以看出,每次我們在某個位置匹配上了,例如一開始我們試探到s中的"cat"時,我們接下來是繼續往右試探下一個可能的切割位置,如果你停在這裡想一下接下來的過程和原始問題的關係,你會發現這其實就是子問題了,原始問題是s串"catsandog"能不能拆分成字典中的詞,現在是s的子串"sandog"能不能拆分成字典中的詞,注意,因為字典中的詞是可以重複利用的,所以無論我們遞迴到哪個子問題時,字典始終都是同一個字典,如果字典中的詞不能重複使用,那麼問題就不能這麼簡單考慮了。

anyway,到現在為止我們找到了原始問題的子問題,那麼這個子問題如何定義呢?

還記得我剛才說過的嗎,子問題可以定義成原始串從第i個位置開始到最後一個位置為止之間的子串滿足某個條件。那麼這裡我們就將子問題定義為:

s串中從第i個位置到最後一個位置之間的子串是否能夠拆分成字典中的詞。

接下來我們來看看程式碼應該如何寫

class Solution {
public:
    bool wordBreak(string s, vector<string>& wordDict) {
        unordered_set<string> wordSet(wordDict.begin(), wordDict.end());
        return check(s, 0, wordSet);
    }
    
    bool check(string s, int start, unordered_set<string>& wordSet, vector<int>& memo){
        if(start == s.size()) return true;
        for(int i = start; i < s.size(); i++){
            if (wordSet.count(s.substr(start, i - start + 1)) && check(s, i+1, wordSet)) {
                return true;
            }
        }
        return false;
    }
};

首先題目給我們的時一個vector,在vector中查詢一個詞是否存在需要花費O(n)的時間,由於整個過程我們需要進行很多次查詢,所以為了加快每一次的查詢速度,我們把這些詞儲存到一個unordered_set中。

寫遞迴函式第一步不是確定遞迴結構,而是確定邊界條件,或者說遞迴到什麼時候結束,這個就對應於dp問題中的狀態初始值。

顯然,這裡是當i等於s.size()的時候結束,因為i==s.size()的時候說明子串的長度為0,一個空串當然可以用字典中的詞來表示了,這相當於從字典中選了0個詞來表示。

問題來了,接下來咋辦?也就是如何設計遞迴結構?

Input: s = "catsandog", wordDict = ["cats", "dog", "sand", "and", "cat"]

首先,我們還是根據上面的思路來思考,假設現在子串起始位置start = 0,然後我們從起始位置start開始,一直往後試探,設i為當前的試探位置

首先判斷start和i之間的子串(包括第start個位置和第i個位置)能不能在字典中找到,這個子串是什麼呢?就是s.substr(start, i - start + 1),這個意思是說s中從下標start開始,長度為i - start + 1的子串。

比如當start = 0,i = 0的時候,這個子串就是"c"。

如果這個子串能在字典中找到,那麼接下來的起始位置應該設為i+1,接下來就應該把start設為i+1去遞迴。
如果這個子串不能在字典中找到,那麼接下來應該讓i+1,去看看此時start和i之間的子串(包括第start個位置和第i個位置)能不能在字典中找到。

例如,我們上面的"c"是不能在字典中找到,於是i+1,這個時候的子串是"ca",也不行。

一直到start=0, i=2的時候可以,於是這個時候把start更新為3,去判斷"sandog"能不能被字典中的單詞表示,最後遞迴下去發現不行。

那麼什麼時候可以知道以start開頭的子串不能被字典中的單詞表示呢?

就是整個for迴圈走遍了還發現無法找到一個合適的位置,所以for迴圈最後有一個return false;

以上是普通的遞迴過程,那麼記憶型遞迴起始很簡單,就是我們要判斷一下以i開頭的子串能不能被字典表示。

我們發現這裡有兩個位置是確定了一個子串能不能被字典表示,一個是

if (wordSet.count(s.substr(start, i - start + 1)) && check(s, i+1, wordSet)) {
      return true;
}

另外一個是:

return false;

於是我們在整個遞迴之前建立一個數組memo,長度為len == s.size(),其中memo[i]表示s[i->len-1]能不能被字典中的單詞表示。
一開始memo中的每個值都設為-1,一旦在上面兩個位置確定了memo[i]的值,如果為true,就把memo[i]設為1,如果為false,memo[i]設為0。

另外,我們需要在進行該輪計算之前先判斷一下這個值是不是計算過,於是記憶型遞迴完整程式碼如下:

class Solution {
public:
    bool wordBreak(string s, vector<string>& wordDict) {
        unordered_set<string> wordSet(wordDict.begin(), wordDict.end());
        vector<int> memo(s.size(), -1);
        return check(s, 0, wordSet, memo);
    }
    
    bool check(string s, int start, unordered_set<string>& wordSet, vector<int>& memo){
        //判斷s從下標start後面開始到s最後一個字元的子串是否能分解成wordSet中的單詞
        if(start == s.size()) return true;
        if(memo[start] != -1) return memo[start];
        for(int i = start; i < s.size(); i++){
            if (wordSet.count(s.substr(start, i - start + 1)) && check(s, i+1, wordSet, memo)) {
                return memo[start] = 1;
            }
        }
        
        return memo[start] = 0;
    }
};

ok,其實一般用dp能過的題,用記憶型遞迴也能過。上面的記憶型遞迴程式碼是能AC的,不過我們還是再想想用dp怎麼做?
同樣,我們也思考一下子問題如何定義,這裡我們不妨直接取上面一樣的定義,我們新建一個數組dp,長度為s.size()+1。其中dp[i]表示s[i->len-1](其中len表示s.size())這個子串 能不能被字典中的單詞表示。

顯然我們計算順序應該是先計算dp[len-1],再計算dp[len-2]。。。一直到最後的dp[0]。dp[0]就是原始問題的解,我們return dp[0]就可以了。

dp問題還有一個初始狀態,初始狀態指的是我們一開始就能確定的狀態,在這裡是dp[len],我們初始化為true,dp陣列其他元素都初始化為false。

可能有同學覺得為什麼初始狀態不是dp[len-1],因為dp[len-1]事實上也需要計算的,當然了,如果你一開始就去單獨計算出dp[len-1]的值那你也可以把它當作初始狀態,這樣的話dp陣列就定義為len的長度就行了。

接下來我們思考dp中的狀態轉移方程是什麼?

首先當i=len-1的時候,我們知道dp[len-1]是不是為true,取決於s[len-1]在字典中能不能找到。

可是當i是中間一個一般情況下的位置時如何判斷?

例如:
i len-1
x x x x x .... x x .... x

對於這種一般情況,我們怎麼確定dp[i]是true還是false。

我們還是延用我們剛才遞迴解法的思路,就是從第i個位置往後出發,假設試探到了第j個位置,我們先看一下s[i->j]這個子串(閉區間)能不能在字典中找到,能的話再看看dp[j+1]是不是為true。
如果不是的話,j繼續往後試探,如果試探到最後面一個位置也沒有找到適合的j,那麼說明dp[i]為false。

所以整個dp程式碼如下所示:

class Solution {
public:
    bool wordBreak(string s, vector<string>& wordDict) {
        unordered_set<string> wordSet(wordDict.begin(), wordDict.end());
        int len = s.size();
        vector<bool> dp(len+1, false); dp[len] = true;
        //dp[i]定義為s[i->len-1]是否可以被字典中的單詞表示
        for(int i = len-1; i >= 0; i--){
            for(int j = i; j < len; j++){
                if(wordSet.count(s.substr(i, j-i+1)) && dp[j+1]){
                    dp[i] = true;
                    break;
                }else {
                    if (j == len - 1) dp[i] = false;
                }
            }
        }
        
        return dp[0];
    }
};

其實兩份程式碼的執行時間基本一樣,可以看到,雖然是一維dp,但是複雜度卻是O(n^2)。