跳到主要內容

演算法

同一個問題,不同的解題思路

主題 · 考題:斷詞

考題:斷詞

一串字能不能切成字典裡的詞?單純回溯會一再重算同樣的後綴而變成指數時間;記憶化、由下而上的動態規劃、BFS(Breadth-First Search,廣度優先搜尋)都把它降到多項式時間。

解法
輸入
題目
給一個字串 s 和一個單字字典,s 能不能切成一個或多個字典裡的單字?同一個字可以重複用。例如 s = 「leetcode」、字典 = [leet, code] → true。
1/32
字典:[cats, dog, sand, and, cat]
s
  1. c
    0
  2. a
    1
  3. t
    2
  4. s
    3
  5. a
    4
  6. n
    5
  7. d
    6
  8. o
    7
  9. g
    8
memo[i]
  1. ·
    0
  2. ·
    1
  3. ·
    2
  4. ·
    3
  5. ·
    4
  6. ·
    5
  7. ·
    6
  8. ·
    7
  9. ·
    8
  10. ·
    9
遞迴深度:0
不是單字/目前的索引是單字確定可以切

「catsandog」(從索引 0 開始)能不能切?試每一個可能的第一個字。

字串長度 n
9
查字典次數
22
函式呼叫
5
一種切法
無
同一份輸入,所有解法
解法答案查字典次數額外記憶體Big O
回溯(暴力遞迴)false242 格O(2ⁿ)
遞迴+記憶化false2212 格O(n²)
由下而上的 DPfalse2010 格O(n²)
在索引上做 BFSfalse2212 格O(n²)
亮起來的是這一步執行的程式碼
// 1. Backtracking: try every first word, recurse on the rest. Exponential.
function wordBreakBacktrack(s: string, words: string[]): boolean {
const dict = new Set(words);
const solve = (start: number): boolean => {
if (start === s.length) return true;
for (let end = start + 1; end <= s.length; end++) {
if (dict.has(s.slice(start, end)) && solve(end)) return true;
}
return false;
};
return solve(0);
}
// 2. The same recursion, answering each start index only once. O(n²).
function wordBreakMemo(s: string, words: string[]): boolean {
const dict = new Set(words);
const memo = new Map<number, boolean>();
const solve = (start: number): boolean => {
if (memo.has(start)) return memo.get(start)!;
if (start === s.length) return true;
for (let end = start + 1; end <= s.length; end++) {
if (dict.has(s.slice(start, end)) && solve(end)) {
memo.set(start, true);
return true;
}
}
memo.set(start, false);
return false;
};
return solve(0);
}
// 3. Bottom-up: dp[i] says whether the first i characters split. O(n²).
function wordBreakDp(s: string, words: string[]): boolean {
const dict = new Set(words);
const dp = new Array(s.length + 1).fill(false);
dp[0] = true;
for (let i = 1; i <= s.length; i++) {
for (let j = 0; j < i; j++) {
if (dp[j] && dict.has(s.slice(j, i))) {
dp[i] = true;
break;
}
}
}
return dp[s.length];
}
// 4. BFS over indices: a word from i to j is an edge. O(n²).
function wordBreakBfs(s: string, words: string[]): boolean {
const dict = new Set(words);
const seen = new Array(s.length + 1).fill(false);
const queue = [0];
seen[0] = true;
while (queue.length) {
const start = queue.shift()!;
for (let end = start + 1; end <= s.length; end++) {
if (!dict.has(s.slice(start, end)) || seen[end]) continue;
if (end === s.length) return true;
seen[end] = true;
queue.push(end);
}
}
return false;
}

模型假設與範圍

  • Big O 與實測步數以頁面列出的基本操作為單位;視覺化的快照、畫圖、程式高亮和輸出成本另計,不是執行時間 benchmark。

什麼時候用

  • 面試的標準路線:先寫回溯(正確但指數時間),指出同一個起點被重算,加上記憶化,再改寫成由下而上的 DP(Dynamic Programming,動態規劃)。能順便講出 BFS(Breadth-First Search,廣度優先搜尋)的圖論觀點更加分。
  • 字典很大、單字都很短時,只試長度不超過最長單字的子字串;或把字典放進 trie,從每個起點沿著 trie 往下走,走不下去就停。

和其他主題的關係

時間與空間複雜度(Big O)

操作平均最差
回溯(暴力遞迴)
同一段尾巴被重算無數次;空間 O(n) 遞迴深度
O(2ⁿ)O(2ⁿ)
遞迴+記憶化
每個起點只算一次、每次試 n 個終點;O(n) 空間
O(n²)O(n²)
由下而上的 DP
兩層迴圈;O(n) 空間,沒有遞迴
O(n²)O(n²)
在索引上做 BFS
n+1 個點、最多 n² 條邊;O(n) 空間
O(n²)O(n²)

空間:O(n),這裡的次數是查字典的次數;每次要切出子字串並雜湊,長度最多 n,所以實際是 O(n³),把子字串長度限制在最長單字 L 內可降到 O(n·L)

Big O 實測:n 變大時步數怎麼長

數的是:查字典次數,輸入是 n 個 a 加一個 b(答案 false)

Big On = 25n = 50n = 100n = 200成長倍數:實測(理論)
遞迴+記憶化O(n²)3511,3265,15120,301×58 (×64)
由下而上的 DPO(n²)2821,1824,85719,707×70 (×64)
在索引上做 BFSO(n²)3511,3265,15120,301×58 (×64)

回溯沒有列出來:它是指數級的,上面的成本表已經量過了。

和其他做法比

n = 4n = 8n = 12n = 16
回溯:函式呼叫162243,09642,744
回溯:查字典314626,42988,820
記憶化:查字典154591153

輸入是 n 個 a 再接一個 b,字典是 [a, aa, aaa, aaaa]:答案是 false,但回溯會試遍所有把 a 切成 1–4 個一組的方法。n 每多 4,呼叫次數大約變成 14 倍;n = 16 時回溯查了 88,820 次,記憶化只查了 153 次。

真實世界裡的它

  • LeetCode 139(Word Break);變形 LeetCode 140 要列出所有切法,答案本身就可能是指數多個,只能回溯加記憶化。
  • 真實用途:中文、日文、泰文沒有空格,斷詞(把一串字切成詞)是搜尋引擎和輸入法的第一步,做法是同一個 DP 再加上每個詞的機率。

取捨與陷阱

  • DP 的定義要講清楚:dp[i] 是「前 i 個字元可以切」,所以陣列長度是 n + 1、dp[0] = true。差一的錯誤最常出在這裡。
  • 記憶化要記失敗,不只是成功:回溯慢就是因為一直重算失敗的尾巴。
  • BFS 沒有「看過」標記時,同一個索引會被放進佇列很多次,又變回指數級。

LeetCode 練習