1. 程式人生 > >(Java) LeetCode 139. Word Break —— 單詞拆分

(Java) LeetCode 139. Word Break —— 單詞拆分

來看 一個 list spa pub one 依次 空字符串 sum

Given a non-empty string s and a dictionary wordDict containing a list of non-empty words, determine if s can be segmented into a space-separated sequence of one or more dictionary words.

Note:

  • The same word in the dictionary may be reused multiple times in the segmentation.
  • You may assume the dictionary does not contain duplicate words.

Example 1:

Input: s = "leetcode", wordDict = ["leet", "code"]
Output: true
Explanation: Return true because "leetcode" can be segmented as "leet code".  

Example 2:

Input: s = "applepenapple", wordDict = ["apple", "pen"]
Output: true
Explanation: Return true because "applepenapple" can be segmented as "apple pen apple".
             Note that you are allowed to reuse a dictionary word.

Example 3:

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

解法一:

但凡是能把問題規模縮小的都應該想到用動態規劃求解。例如本題,如果我知道給定字符串的0到i子串可以用字典中的單詞表達,那麽我只需要知道i+1到末尾的子串能否被字典表達即可知道整個字符串能否被字典表達。所以隨著i的增大,問題規模逐漸的縮小,且之前求解過的結果可以為接下來的求解提供幫助,這就是動態規劃了。設dp[i]代表s.substring(0, i)能否被字典表達,此刻我們知道dp[0]~dp[i-1]的結果。而dp[i]的結果由兩部分組成,一部分是dp[j](j < i),已知;另一部分是j到i之間的字符串是不是在字典裏。當這兩個部分都為真的時候,dp[i]即為真。而一旦dp[i]為真,就不用繼續叠代了。測試的時候發現倒著遍歷會比正著遍歷速度稍稍快一點,大概是因為test case的字典裏長度較長的單詞要比長度較短的單詞多。

解法二(BFS)、解法三(DFS):

觀察例子2,我想知道"applepenapple"能否被字典分割,首先肯定是要從前綴開始找。碰到的第一個前綴"apple"恰好在字典裏,那麽只需要知道剩下的字符串"penapple"能不能被字典分割即可。而步驟和之前一樣,還是要從前綴開始找,碰到的第一個前綴"pen"恰好在字典裏,繼而問題規模再度縮小。到最後只要找"apple"是否能被字典分割即可。整個過程有兩個關鍵,第一個是循環,即每一次都是在做同樣的事情——找前綴;第二個是如何把剩下的字符串存起來後再拿出來。想到這裏,就不難想到可以用一個循環和一個隊列來完成這兩個關鍵。而用到循環和隊列的算法是什麽呢?廣度優先搜索!而另一種方法是不用隊列,而采用回溯尋找的方式來處理剩下的字符串,即廣度優先搜索!想到這裏就發現這道題其實和之前做過的第39題並沒有什麽區別。如果把字符串想成target,字典想成數組,那麽就是要在字典中尋找合適的組合來拼接成目標字符串。很trick的部分是到底如何模型化這個圖。首先是節點,很明顯節點就是字典中的字符串以及目標字符串。額外的,要加上一個空字符串""。對於第二個例子來說,節點就是"","apple","pen"以及"applepenapple"四個節點。確定好節點之後,再來看邊。首先本題一定是有自環的,因為可以用多個數字組成最後的結果。其次,所有的節點一定是互相聯通的,即任何節點之間一定都有邊,而且是有向邊。最後最關鍵的權值,很抽象。邊的權值是從該節點出發到達目標節點的過程中,需要在前綴位置“消耗”掉的目標節點內的字符串。之所以是消耗,是因為可以把本題想象成從節點"applepenapple"通向節點""且權值恰好依次消耗掉源節點字符串的路徑。見下圖例子(省略了自環以及目標"applepenapple"連接到""的邊)。

由此可見,如果想從"applepenapple"節點走向""節點,且權值恰好依次消耗完所有的"applepenapple",那麽先走到"apple",權值消耗掉目標節點的字符串"apple",變為"penapple";向右走到"pen"節點,消耗掉"pen",權值剩下"apple";之後向左走,消耗掉"apple",權值變為"";那麽最後走向""節點,恰好消耗完所有的權值。

整個過程中,必須要按照權值等於前綴的順序走,才會形成有效拼接。如果不是,比如"abcd",{"bc, "ad"}。如果先走"bc",最後還是剩下了"ad",但這不是一個有效拼接。所以拼接必須要按前綴的順序走。

理清了模型,剩下的就是BFS和DFS算法的實現了。這其中最重要的問題是,自環狀態下已訪問節點要如何標記。其實在這裏並不是標記節點本身,而是標記當前消耗掉前綴的位置。仍然拿"applepenapple"舉例,這個字符串總共有13位,也就是總共有13個位置可能產生前綴。已經訪問過的前綴是不需要再訪問的,因為我們已經知道了從那個前綴位置出的所有路徑。掃清一切障礙之後,BFS(見解法二代碼)和DFS(見解法三代碼)就都能實現了。


解法一(Java)

class Solution {
    public boolean wordBreak(String s, List<String> wordDict) {
        boolean[] dp = new boolean[s.length() + 1];
        dp[0] = true;
        for (int i = 1; i <= s.length(); i++) {
            for (int j = i - 1; j >= 0 && !dp[i]; j--) {
                String check = s.substring(j, i);
                dp[i] = dp[j] && wordDict.contains(check);
            }
        }
        return dp[s.length()];   
    }
}

解法二(Java)

class Solution {
    public boolean wordBreak(String s, List<String> wordDict) {
        Queue<Integer> q = new LinkedList<>(); //構建隊列,存儲前綴位置
        boolean[] visited = new boolean[s.length() + 1]; //總共有s.length()個位置可能產生前綴
        for (int i = 0; i < wordDict.size(); i++) //找到源節點的相鄰節點,即可以通過前綴訪問的節點
            if (s.length() >= wordDict.get(i).length() && s.indexOf(wordDict.get(i)) == 0)
                q.add(wordDict.get(i).length());
        visited[0] = true; //標記起始位置
        while (!q.isEmpty()) {
            int start = q.poll(); //取出即將訪問的前綴位置
            if (start == s.length()) return true;
            if (!visited[start]) { 
                visited[start] = true; //標記前綴位置為已訪問
                String sub = s.substring(start); //依據前綴位置更新權值
                for (int i = 0; i < wordDict.size(); i++) //根據權值,訪問具有相同前綴的下一位置
                    if (sub.length() >= wordDict.get(i).length() && sub.indexOf(wordDict.get(i)) == 0)
                        q.add(start + wordDict.get(i).length());
            }
        }
        return false;
    }
}

解法三(Java)

class Solution {
    public boolean wordBreak(String s, List<String> wordDict) {
        boolean[] visited = new boolean[s.length()+1]; //總共有s.length()個位置可能產生前綴
        return dfs(wordDict, s, s, 0, visited);
    }
    
    private boolean dfs(List<String> wordDict, String target, String sub, int start, boolean[] visited) {
        if (start == target.length()) return true; //如果前綴的位置在target末尾,證明達到目標節點
        boolean mark = false;
        for (int p = 0; p < wordDict.size(); p++) {
            String word = wordDict.get(p);
            if (word.length() > sub.length()) continue;
            if (sub.indexOf(word) == 0) { //查詢前綴
                int next = word.length(); //記錄找到的前綴的長度            
                if (!visited[next + start]) { //即將要訪問的前綴位置為當前位置start加上前綴長度next
                    visited[next + start] = true; //標記前綴位置為已訪問
                    mark = mark || dfs(wordDict, target, sub.substring(next), next + start, visited); //更新權值後,訪問下一位置
                }
            }
        }
        return mark;
    }
}

(Java) LeetCode 139. Word Break —— 單詞拆分