140. Word Break II

Return every sentence formed by inserting spaces so each piece is a dictionary word. Backtrack from index start, trying each dictionary prefix, and memoize start → all suffix-sentences so the recursion never re-solves a position twice.

HardBacktrackingMemoizationTypeScript

PROBLEM What we're solving

Given s = "catsanddog" and wordDict = ["cat", "cats", "and", "sand", "dog"], return every way to break s into a sentence of dictionary words: ["cats and dog", "cat sand dog"].

Word Break I only asked can it be segmented (a boolean). Word Break II asks for the actual list of sentences, so we must reconstruct and join the splits, not just answer yes/no. Words may be reused, and the output can be empty if no segmentation exists.

KEY IDEA Memoize the suffix, not a boolean

Insight → let dfs(start) return all sentences that segment the suffix s[start..). For each dictionary word starting at start and ending at end, recurse on dfs(end) and prefix the word onto every sentence it returns. The base case dfs(n) returns [""] (one empty sentence) so the very last word has something to attach to. Cache start → sentences[] so each index is expanded once and its list is reused everywhere it appears.

RECIPE DFS that returns lists of sentences

  • 0 · Build a set. Put wordDict into a Set for O(1) membership tests, and a memo: Map<number, string[]>.
  • 1 · Base case. dfs(n) returns [""] — the empty suffix has exactly one segmentation, the empty sentence. (Returning [] here would wrongly kill every branch.)
  • 2 · Memo check. If start is cached, return the stored list immediately — this is what tames the exponential.
  • 3 · Try each prefix. For end from start+1 to n, take word = s[start..end). Skip it unless it's in the dictionary.
  • 4 · Join with the suffix. For every rest in dfs(end), push word if rest is empty, else word + " " + rest.
  • 5 · Cache and return. Store the collected sentences under start and return them. The answer is dfs(0).
Classic confusion → a plain boolean DP from Word Break I tells you a segmentation exists but throws away whichwords made it. You can't recover the sentences from a boolean[] table without extra work — you must store (or rebuild) the actual word listsand concatenate them. That's why the memo value here is string[], not boolean.

COST Complexity & alternatives

Naive backtracking
O(2ⁿ)
Re-explores every suffix from scratch; pathological inputs like "aaaa…" blow up exponentially.
Memoized DFS
O(n² · 2ⁿ)
Each of n start indices is expanded once; total still bounded by the output size, which can itself be exponential.

Why memoization can't make it polynomial

The memo guarantees each start is computed once, so the work to find the segmentations is polynomial. But the number of sentences can be exponential (e.g. every prefix splittable many ways), and we must list them all — so any correct solution is exponential in the worst case. Memoization removes the redundant recomputation, which is the difference between "times out" and "passes" on adversarial inputs.

Pattern transfer →"recurse on a suffix, memoize the index, join the pieces" powers Word Break I (same recursion, boolean memo), Palindrome Partitioning (split into palindromes instead of dict words), and Combination Sum (backtrack over choices, collect full result lists).

RUN IT Memoized DFS: collect every sentence for each suffix

step 0 / 42
STARTBuild dict as a Set and an empty memo. Kick off dfs(0) to segment all of "catsanddog".
1function wordBreak(s: string, wordDict: string[]): string[] {
2 const dict = new Set(wordDict);
3 // memo: start index -> every sentence that segments s[start..)
4 const memo = new Map<number, string[]>();
5
6 function dfs(start: number): string[] {
7 if (start === s.length) return ['']; // empty suffix -> one empty sentence
8 const cached = memo.get(start);
9 if (cached !== undefined) return cached;
10
11 const sentences: string[] = [];
12 for (let end = start + 1; end <= s.length; end++) {
13 const word = s.slice(start, end);
14 if (!dict.has(word)) continue; // not a dictionary prefix
15 for (const rest of dfs(end)) { // all ways to break the suffix
16 sentences.push(rest === '' ? word : word + ' ' + rest);
17 }
18 }
19
20 memo.set(start, sentences);
21 return sentences;
22 }
23
24 return dfs(0);
25}
s =c0a1t2s3a4n5d6d7o8g9
State
0
start
end
word
(empty)
path
{}
memo keys
candidate word span [start, end)remaining suffix from startpath (word chosen so far)memoized / resultno segmentation
slowfast

TYPESCRIPT The solution, annotated

wordBreak.ts
function wordBreak(s: string, wordDict: string[]): string[] {
  const dict = new Set(wordDict);
  // memo: start index -> every sentence that segments s[start..)
  const memo = new Map<number, string[]>();

  function dfs(start: number): string[] {
    if (start === s.length) return ['']; // empty suffix -> one empty sentence
    const cached = memo.get(start);
    if (cached !== undefined) return cached;

    const sentences: string[] = [];
    for (let end = start + 1; end <= s.length; end++) {
      const word = s.slice(start, end);
      if (!dict.has(word)) continue;          // not a dictionary prefix
      for (const rest of dfs(end)) {          // all ways to break the suffix
        sentences.push(rest === '' ? word : word + ' ' + rest);
      }
    }

    memo.set(start, sentences);
    return sentences;
  }

  return dfs(0);
}

Reading it block by block

Lines 2–4 — the dictionary and the memo. A Set gives O(1) has() checks. The memomaps a start index to the full list of sentences that segment the suffix beginning there — that's the cache that prevents exponential recomputation.
Line 7 — the base case. When start === s.lengthwe've consumed the whole string, so the suffix is empty. It has exactly one segmentation — the empty sentence — so we return ['']. Returning [] here would make every branch collapse to no results.
Lines 8–10 — memo hit. If we've already solved this start, return the cached list. Because each index can be reached from many earlier splits, this reuse is what keeps the algorithm from blowing up.
Lines 12–18 — try every dictionary prefix. Slide end forward; the candidate word = s.slice(start, end). Skip non-dictionary slices. For each valid word, recurse on dfs(end) to get every way the rest can be broken, and stitch word in front of each — adding a space only when restisn't the empty sentence.
Lines 20–21 — cache and return. Store the assembled sentences for this start and hand them back. The top-level call dfs(0) returns every sentence for the entire string.
Complexity → Each of the n+1 start indices is expanded at most once thanks to the memo, and at each we scan O(n) end positions with O(n) slice/join work, so finding the splits is polynomial. The dominating cost is the output: there can be exponentially many sentences (worst case O(n · 2ⁿ) characters), so the overall bound is exponential — O(n² · 2ⁿ) — driven by how many sentences exist, not by wasted recomputation.

INTERVIEWFollow-ups they'll ask

  • "Why not just reuse the boolean DP from Word Break I?" Because it only records that a split exists, not which words. To emit sentences you must store or reconstruct the actual word lists and join them.
  • "Prune impossible inputs first?"Run the O(n²) Word Break I boolean check up front; if it's false, return [] instantly instead of exploring a doomed tree. You can also only recurse into positions reachable from the start.
  • "Top-down vs bottom-up?" This is top-down memoized DFS. A bottom-up version fills sentences[i] for i from n down to 0 — same idea, same complexity.
  • "What bounds the output size?" The number of sentences is the number of valid segmentations, which can be exponential (think "aaaa" with ["a","aa","aaa"]). No algorithm can beat that lower bound since it must produce every answer.
  • "Huge dictionary, short words?" Replace the per-slice has() with a trie so you extend from start character-by-character and stop early when no word continues.

OPTIMAL Backtracking

wordBreak.ts
function wordBreak(s: string, wordDict: string[]): string[] {
  const dict = new Set(wordDict);
  // memo: start index -> every sentence that segments s[start..)
  const memo = new Map<number, string[]>();

  function dfs(start: number): string[] {
    if (start === s.length) return ['']; // empty suffix -> one empty sentence
    const cached = memo.get(start);
    if (cached !== undefined) return cached;

    const sentences: string[] = [];
    for (let end = start + 1; end <= s.length; end++) {
      const word = s.slice(start, end);
      if (!dict.has(word)) continue;          // not a dictionary prefix
      for (const rest of dfs(end)) {          // all ways to break the suffix
        sentences.push(rest === '' ? word : word + ' ' + rest);
      }
    }

    memo.set(start, sentences);
    return sentences;
  }

  return dfs(0);
}
Complexity → Each of the n+1 start indices is expanded at most once thanks to the memo, and at each we scan O(n) end positions with O(n) slice/join work, so finding the splits is polynomial. The dominating cost is the output: there can be exponentially many sentences (worst case O(n · 2ⁿ) characters), so the overall bound is exponential — O(n² · 2ⁿ) — driven by how many sentences exist, not by wasted recomputation.

ALT 1 Bottom-up DP (suffix sentence lists)

Time O(n² · #sentences) · Space O(n · #sentences)

Fill dp[i] = every sentence for the suffix s[i..), iterating i from n down to 0 so each suffix is already solved before you reach it.

approach-2.ts
function wordBreak(s: string, wordDict: string[]): string[] {
  const dict = new Set(wordDict);
  const n = s.length;
  // dp[i] = every sentence that segments the suffix s[i..)
  const dp: string[][] = new Array(n + 1);
  dp[n] = ['']; // base: empty suffix has one segmentation, the empty sentence

  for (let start = n - 1; start >= 0; start--) {
    const sentences: string[] = [];
    for (let end = start + 1; end <= n; end++) {
      const word = s.slice(start, end);
      if (!dict.has(word)) continue;        // not a dictionary prefix
      for (const rest of dp[end]) {         // already-computed suffix sentences
        sentences.push(rest === '' ? word : word + ' ' + rest);
      }
    }
    dp[start] = sentences;
  }

  return dp[0];
}
Note → Same recurrence as the memoized DFS, just driven by an explicit loop instead of recursion. Because dp[end] is always filled before dp[start], the inner loop only ever reads completed lists. No memo bookkeeping and no recursion depth, at the cost of materializing every suffix's sentence list eagerly.

ALT 2 Pure backtracking (no memo)

Time O(2ⁿ · n) worst case · Space O(n) excluding output

Carry the words chosen so far down the recursion and record a finished sentence each time you reach the end — simple, but re-explores shared suffixes from scratch.

approach-3.ts
function wordBreak(s: string, wordDict: string[]): string[] {
  const dict = new Set(wordDict);
  const n = s.length;
  const result: string[] = [];
  const path: string[] = []; // dictionary words chosen so far

  function dfs(start: number): void {
    if (start === n) {            // consumed the whole string
      result.push(path.join(' '));
      return;
    }
    for (let end = start + 1; end <= n; end++) {
      const word = s.slice(start, end);
      if (!dict.has(word)) continue;
      path.push(word);            // choose
      dfs(end);                   // explore the rest
      path.pop();                 // un-choose (backtrack)
    }
  }

  dfs(0);
  return result;
}
Note → Correct, but with no cache it revisits the same suffix index along every distinct prefix that reaches it, so adversarial inputs like s = "aaaaaaaaaaaaaaab" with dict = ["a","aa","aaa"] (no valid full split) explode to O(2ⁿ) dead-end exploration and time out. The memoized solution adds a start → string[] cache to kill exactly that redundant recomputation.

MNEMONIC The one-liner

"dfs(start) hands back every sentence for the rest; glue each word onto the front and cache the index."

TRIGGERS When you see ___ → reach for ___

"return ALL ways to segment / partition"DFS returning lists, not a boolean
reconstruct sentences from a word dictionarymemo: start -> string[] of suffixes
overlapping suffixes re-solvedmemoize the start index
empty suffix must still produce a resultbase case returns ['']

SKELETON The reusable shape

skeleton.ts
function wordBreak(s: string, wordDict: string[]): string[] {
  const dict = new Set(wordDict);
  const memo = new Map<number, string[]>();

  function dfs(start: number): string[] {
    if (start === s.length) return [''];      // base: one empty sentence
    if (memo.has(start)) return memo.get(start)!;

    const out: string[] = [];
    for (let end = start + 1; end <= s.length; end++) {
      const word = s.slice(start, end);
      if (!dict.has(word)) continue;
      for (const rest of dfs(end))            // join word + suffix sentences
        out.push(rest === '' ? word : word + ' ' + rest);
    }
    memo.set(start, out);
    return out;
  }
  return dfs(0);
}

FLASHCARDS Tap to flip

What does dfs(start) return in Word Break II?
Every sentence (a string of space-joined dictionary words) that segments the suffix s[start..).
tap to flip
1 / 7
Answer to reveal explanations. No penalty for retries.Score: 0 / 9
QUESTION 01
What does dfs(start) return in the memoized Word Break II solution?
QUESTION 02
Why must the base case dfs(s.length) return [''] rather than []?
QUESTION 03
For s="catsanddog" and dict=["cat","cats","and","sand","dog"], how many sentences are returned?
QUESTION 04
What is the primary purpose of the memo (start -> string[]) in this algorithm?
QUESTION 05
Why is a plain boolean[] DP table (as in Word Break I) not enough for Word Break II?
QUESTION 06
On a pathological input like s="aaaaaaaa" with dict=["a","aa","aaa"], the number of returned sentences grows:
QUESTION 07
When stitching word in front of a suffix sentence rest, when do you omit the joining space?
QUESTION 08
#140 · Word Break IIReturn every sentence formed by inserting spaces so each piece is a dictionary word. Backtrack from each index trying dictionary prefixes, and memoize start-index to all suffix sentences to avoid exponential recompute.Which algorithmic approach does this primarily use?
QUESTION 09
#140 · Word Break IIReturn every sentence formed by inserting spaces so each piece is a dictionary word. Backtrack from each index trying dictionary prefixes, and memoize start-index to all suffix sentences to avoid exponential recompute.Which implementation correctly solves it?