293 lines
14 KiB
YAML
293 lines
14 KiB
YAML
title: Word Break II
|
|
slug: word-break-ii
|
|
difficulty: hard
|
|
leetcode_id: 140
|
|
leetcode_url: https://leetcode.com/problems/word-break-ii/
|
|
categories:
|
|
- strings
|
|
- dynamic-programming
|
|
- hash-tables
|
|
patterns:
|
|
- backtracking
|
|
- dynamic-programming
|
|
|
|
function_signature: "def word_break(s: str, word_dict: list[str]) -> list[str]:"
|
|
|
|
test_cases:
|
|
visible:
|
|
- input: { s: "catsanddog", word_dict: ["cat", "cats", "and", "sand", "dog"] }
|
|
expected: ["cat sand dog", "cats and dog"]
|
|
- input: { s: "catsandog", word_dict: ["cats", "dog", "sand", "and", "cat"] }
|
|
expected: []
|
|
hidden:
|
|
- input: { s: "pineapplepenapple", word_dict: ["apple", "pen", "applepen", "pine", "pineapple"] }
|
|
expected: ["pine apple pen apple", "pine applepen apple", "pineapple pen apple"]
|
|
- input: { s: "a", word_dict: ["a"] }
|
|
expected: ["a"]
|
|
- input: { s: "ab", word_dict: ["a", "b"] }
|
|
expected: ["a b"]
|
|
- input: { s: "aaaa", word_dict: ["a", "aa"] }
|
|
expected: ["a a a a", "a a aa", "a aa a", "aa a a", "aa aa"]
|
|
|
|
description: |
|
|
Given a string `s` and a dictionary of strings `wordDict`, add spaces in `s` to construct a sentence where each word is a valid dictionary word. Return all such possible sentences in **any order**.
|
|
|
|
**Note** that the same word in the dictionary may be reused multiple times in the segmentation.
|
|
|
|
constraints: |
|
|
- `1 <= s.length <= 20`
|
|
- `1 <= wordDict.length <= 1000`
|
|
- `1 <= wordDict[i].length <= 10`
|
|
- `s` and `wordDict[i]` consist of only lowercase English letters
|
|
- All the strings of `wordDict` are **unique**
|
|
- Input is generated in a way that the length of the answer doesn't exceed `10^5`
|
|
|
|
examples:
|
|
- input: 's = "catsanddog", wordDict = ["cat","cats","and","sand","dog"]'
|
|
output: '["cats and dog","cat sand dog"]'
|
|
explanation: "Both 'cats and dog' and 'cat sand dog' are valid segmentations using dictionary words."
|
|
- input: 's = "pineapplepenapple", wordDict = ["apple","pen","applepen","pine","pineapple"]'
|
|
output: '["pine apple pen apple","pineapple pen apple","pine applepen apple"]'
|
|
explanation: "Note that you are allowed to reuse dictionary words. The word 'apple' appears twice in one solution."
|
|
- input: 's = "catsandog", wordDict = ["cats","dog","sand","and","cat"]'
|
|
output: "[]"
|
|
explanation: "There is no way to segment the string into valid dictionary words (the 'og' at the end cannot be matched)."
|
|
|
|
explanation:
|
|
intuition: |
|
|
Imagine you're reading a string of concatenated words with no spaces, like a text message where someone forgot to add spaces. Your task is to figure out all the possible ways to insert spaces so that every segment is a real word.
|
|
|
|
Think of the string as a path you need to walk from start to end. At each position, you look ahead to see if any dictionary word starts there. If you find a match, you can "jump" forward by the length of that word and continue from the new position. Some positions might have multiple valid words starting there, creating branching paths.
|
|
|
|
The key insight is that this problem has **overlapping subproblems**: when exploring different paths, you might reach the same position multiple times. For instance, both "cat" and "cats" might lead you to positions where the remaining string is identical. Rather than recomputing all possible sentences from that position each time, we can **memoize** the results.
|
|
|
|
This naturally leads to a **backtracking with memoization** approach: explore all valid word choices at each position, cache the results for substrings you've already solved, and combine the cached results to build complete sentences.
|
|
|
|
approach: |
|
|
We solve this using **Backtracking with Memoization**:
|
|
|
|
**Step 1: Convert dictionary to a set**
|
|
|
|
- Store `wordDict` in a hash set for O(1) word lookups
|
|
- This transforms membership checking from O(n) to O(1)
|
|
|
|
|
|
|
|
**Step 2: Create a memoization cache**
|
|
|
|
- Use a dictionary mapping starting indices to lists of valid sentences
|
|
- Key: starting index in the string
|
|
- Value: list of all valid sentences that can be formed from that index to the end
|
|
|
|
|
|
|
|
**Step 3: Define the recursive backtracking function**
|
|
|
|
- Base case: if we've reached the end of the string, return a list containing an empty string (signals successful segmentation)
|
|
- If the current index is already in the memo, return the cached result
|
|
- For each possible ending position from current index:
|
|
- Extract the substring and check if it's in the dictionary
|
|
- If valid, recursively get all sentences for the remaining string
|
|
- Prepend the current word to each returned sentence
|
|
- Cache and return all valid sentences from this position
|
|
|
|
|
|
|
|
**Step 4: Build the final sentences**
|
|
|
|
- Start the recursion from index 0
|
|
- Each recursive call returns sentences for the substring from that index
|
|
- Combine words with spaces to form complete sentences
|
|
|
|
|
|
|
|
The memoization ensures we never recompute results for the same starting position, while backtracking explores all valid word combinations.
|
|
|
|
common_pitfalls:
|
|
- title: Pure Backtracking Without Memoization
|
|
description: |
|
|
A naive backtracking approach without caching will recompute the same subproblems many times.
|
|
|
|
Consider `s = "aaaaaaa"` with `wordDict = ["a", "aa", "aaa"]`. From each position, there are multiple ways to proceed, and many paths lead to the same remaining substrings. Without memoization, the time complexity becomes exponential in the worst case.
|
|
|
|
By caching results for each starting index, we ensure each substring is processed only once.
|
|
wrong_approach: "Recursively explore without caching results"
|
|
correct_approach: "Use a memo dictionary to cache results by starting index"
|
|
|
|
- title: Forgetting to Handle the Empty Result Case
|
|
description: |
|
|
When the recursive call returns an empty list, it means no valid segmentation exists from that point. You should skip adding words in this case.
|
|
|
|
But when you reach the end of the string successfully, you should return `[""]` (a list with one empty string), not `[]`. This empty string serves as a base case that allows word concatenation to work correctly.
|
|
wrong_approach: "Return [] at end of string, confusing 'no solution' with 'found solution'"
|
|
correct_approach: "Return [''] at end of string as a successful termination signal"
|
|
|
|
- title: Inefficient String Concatenation
|
|
description: |
|
|
Building sentences by repeatedly concatenating strings can be inefficient in some languages due to string immutability.
|
|
|
|
Instead of `word + " " + sentence` in a loop, consider building a list of words and joining them at the end, or using the language's efficient string building mechanisms.
|
|
wrong_approach: "Repeated string concatenation in tight loops"
|
|
correct_approach: "Build word lists and join once, or use efficient string builders"
|
|
|
|
- title: Not Using a Hash Set for Dictionary
|
|
description: |
|
|
Checking if a word exists in a list takes O(n) time per check. With potentially many substring checks during backtracking, this adds up quickly.
|
|
|
|
Converting the dictionary to a hash set at the start gives O(1) lookups, significantly improving performance for large dictionaries.
|
|
wrong_approach: "Use list and check with 'in' operator on list"
|
|
correct_approach: "Convert wordDict to a set for O(1) membership testing"
|
|
|
|
key_takeaways:
|
|
- "**Backtracking + Memoization**: When a problem requires finding all solutions and has overlapping subproblems, combine backtracking (to explore all paths) with memoization (to avoid recomputation)"
|
|
- "**Index-based caching**: For string problems, cache by starting index rather than by substring to save memory and simplify the logic"
|
|
- "**Builds on Word Break I**: This problem extends LeetCode 139 (Word Break) from a boolean 'can it be segmented?' to 'return all segmentations' - understanding the simpler version helps with this one"
|
|
- "**Watch for exponential output**: The constraint that output length doesn't exceed `10^5` is crucial - without it, there could be exponentially many valid sentences"
|
|
|
|
time_complexity: "O(n * 2^n) in the worst case, where `n` is the length of the string. Each position can be a word boundary or not, leading to 2^n possible segmentations. However, memoization and the practical constraint on output size make this much faster for typical inputs."
|
|
space_complexity: "O(n * m) where `n` is string length and `m` is the number of valid sentences. The memo stores lists of sentences for each starting index, and the recursion stack can go up to depth `n`."
|
|
|
|
solutions:
|
|
- approach_name: Backtracking with Memoization
|
|
is_optimal: true
|
|
code: |
|
|
def word_break(s: str, word_dict: list[str]) -> list[str]:
|
|
# Convert to set for O(1) lookups
|
|
word_set = set(word_dict)
|
|
# Cache: starting index -> list of valid sentences from that index
|
|
memo = {}
|
|
|
|
def backtrack(start: int) -> list[str]:
|
|
# Already computed results for this starting position
|
|
if start in memo:
|
|
return memo[start]
|
|
|
|
# Reached end of string - successful segmentation
|
|
if start == len(s):
|
|
return [""]
|
|
|
|
sentences = []
|
|
# Try all possible end positions for current word
|
|
for end in range(start + 1, len(s) + 1):
|
|
word = s[start:end]
|
|
|
|
# If this substring is a valid word
|
|
if word in word_set:
|
|
# Get all valid sentences for the remaining string
|
|
rest_sentences = backtrack(end)
|
|
|
|
# Combine current word with each sentence from remaining string
|
|
for sentence in rest_sentences:
|
|
if sentence:
|
|
# Add space between word and rest of sentence
|
|
sentences.append(word + " " + sentence)
|
|
else:
|
|
# Last word, no trailing space needed
|
|
sentences.append(word)
|
|
|
|
# Cache results for this starting position
|
|
memo[start] = sentences
|
|
return sentences
|
|
|
|
return backtrack(0)
|
|
explanation: |
|
|
**Time Complexity:** O(n * 2^n) worst case, but typically much better due to memoization and input constraints.
|
|
|
|
**Space Complexity:** O(n * m) for the memoization cache, where m is the number of valid sentences.
|
|
|
|
We use backtracking to explore all valid word combinations starting from each position. The memo dictionary ensures we never recompute sentences for the same starting index. The hash set enables O(1) word lookups. By caching at the index level, we efficiently handle overlapping subproblems.
|
|
|
|
- approach_name: Dynamic Programming (Bottom-Up)
|
|
is_optimal: false
|
|
code: |
|
|
def word_break(s: str, word_dict: list[str]) -> list[str]:
|
|
word_set = set(word_dict)
|
|
n = len(s)
|
|
|
|
# dp[i] = list of all valid sentences for s[i:]
|
|
dp = [[] for _ in range(n + 1)]
|
|
# Base case: empty string at position n
|
|
dp[n] = [""]
|
|
|
|
# Fill DP table from right to left
|
|
for start in range(n - 1, -1, -1):
|
|
sentences = []
|
|
for end in range(start + 1, n + 1):
|
|
word = s[start:end]
|
|
|
|
if word in word_set and dp[end]:
|
|
# Combine current word with sentences from dp[end]
|
|
for sentence in dp[end]:
|
|
if sentence:
|
|
sentences.append(word + " " + sentence)
|
|
else:
|
|
sentences.append(word)
|
|
|
|
dp[start] = sentences
|
|
|
|
return dp[0]
|
|
explanation: |
|
|
**Time Complexity:** O(n * 2^n) worst case, similar to the memoized approach.
|
|
|
|
**Space Complexity:** O(n * m) for the DP table storing all sentences.
|
|
|
|
This bottom-up approach builds the solution iteratively from the end of the string to the beginning. The `dp[i]` entry stores all valid sentences that can be formed from `s[i:]`. While conceptually similar to the memoized version, this explicitly shows the DP structure. The memoized version is often preferred as it only computes necessary subproblems.
|
|
|
|
- approach_name: Trie Optimization
|
|
is_optimal: false
|
|
code: |
|
|
class TrieNode:
|
|
def __init__(self):
|
|
self.children = {}
|
|
self.is_word = False
|
|
|
|
def word_break(s: str, word_dict: list[str]) -> list[str]:
|
|
# Build trie from dictionary
|
|
root = TrieNode()
|
|
for word in word_dict:
|
|
node = root
|
|
for char in word:
|
|
if char not in node.children:
|
|
node.children[char] = TrieNode()
|
|
node = node.children[char]
|
|
node.is_word = True
|
|
|
|
memo = {}
|
|
|
|
def backtrack(start: int) -> list[str]:
|
|
if start in memo:
|
|
return memo[start]
|
|
|
|
if start == len(s):
|
|
return [""]
|
|
|
|
sentences = []
|
|
node = root
|
|
|
|
# Walk the trie while scanning the string
|
|
for end in range(start, len(s)):
|
|
char = s[end]
|
|
if char not in node.children:
|
|
break # No words with this prefix
|
|
|
|
node = node.children[char]
|
|
|
|
if node.is_word:
|
|
word = s[start:end + 1]
|
|
for sentence in backtrack(end + 1):
|
|
if sentence:
|
|
sentences.append(word + " " + sentence)
|
|
else:
|
|
sentences.append(word)
|
|
|
|
memo[start] = sentences
|
|
return sentences
|
|
|
|
return backtrack(0)
|
|
explanation: |
|
|
**Time Complexity:** O(n * 2^n) worst case, but with faster prefix matching.
|
|
|
|
**Space Complexity:** O(W * L + n * m) where W is dictionary size, L is average word length.
|
|
|
|
Using a trie allows early termination when no dictionary word starts with the current prefix. This is particularly beneficial when the dictionary is large but words share common prefixes. The trie walk can quickly determine that no words exist with a given prefix, pruning the search space. For small dictionaries, the hash set approach may be simpler and sufficient.
|