Word Break II
Table of Contents + β
You get a long string with no spaces, like catsanddog. You also get a list of real words. The question asks you to add spaces so the string breaks into real words, and you must find every way to do it. This is like how a phone keyboard guesses where one word ends and the next begins. The hard part is that the same string can split in several valid ways, and you have to return all of them.
π― The Problem
You get a string and a dictionary, which is a list of valid words. You must split the string into sentences of dictionary words.
- Break the string so every word comes from the dictionary.
- Return every such sentence, not just one.
- A dictionary word may be used more than once.
- The order of the sentences does not matter.
- For
catsanddogwithcat,cats,and,sand,dog, there are two ways to split it.
Input: s = "catsanddog" words = ["cat", "cats", "and", "sand", "dog"]Output: ["cat sand dog", "cats and dog"]
Explanation: "cats" + "and" + "dog" and "cat" + "sand" + "dog"You return all valid sentences in any order.
This diagram shows the two valid split points near the start of the string.
π’ Approach 1: Plain Backtracking Without Memory (Brute Force)
The idea in one line: try every prefix as a word, then solve the rest the same way.
The idea:
- Backtracking means try a split, go deeper, and step back to try another.
- Start at the front of the string and try every prefix.
- A prefix is a starting chunk of the string.
How it works:
- If a prefix is a dictionary word, cut it off and solve the rest the same way.
- When you reach the end, the words you picked form one valid sentence.
Why it is weak:
- The same leftover string gets solved again and again.
- Say two prefixes both lead to the leftover
dog. It solvesdogtwice. - For long strings with many splits, the repeated work makes the time blow up.
Here is the plain backtracking code:
def word_break(s, word_dict): words = set(word_dict) result = []
def dfs(index, path): if index == len(s): result.append(" ".join(path)) return for end in range(index + 1, len(s) + 1): word = s[index:end] if word in words: dfs(end, path + [word])
dfs(0, []) return resultβ‘ Approach 2: Backtracking With Memoization (Best)
The idea in one line: solve each leftover once and remember its sentences in a cache.
The idea:
- Memoization means remember the answer for a piece of work so you never redo it.
- The piece of work here is βall sentences for the leftover starting at index iβ.
- Store that list in a cache keyed by the index.
How it works:
- Before solving a leftover, check the cache.
- If this exact leftover was already solved, return the saved list right away.
- If not, solve it once, save the list, and return it.
- Try each end position from the current index. The chunk to that end is a candidate word.
- If the chunk is in the dictionary, recurse on the rest, then glue this word in front of every sentence the rest returns.
Why it is fast:
- Each starting index is solved a single time.
- The sentences for a leftover never change, no matter how you reached it.
- So reaching the same leftover again just reads the cache.
This diagram shows how memoization reuses the answer for the leftover dog. Both paths reach dog, but we solve it only once.
Steps to Solve
- Put the dictionary words into a set for instant lookup.
- Write a function that returns all sentences for the substring starting at a given index.
- If that index is the end of the string, return a list with one empty sentence to stop the recursion.
- Check the cache for this index. If it is there, return the saved list.
- Try every end position after the index. Take the chunk from the index to that end as a candidate word.
- If the chunk is in the dictionary, solve the rest, then attach this word in front of every returned sentence.
- Save the collected sentences in the cache for this index and return them.
This Python version uses a set for the dictionary and a dictionary as the memo cache keyed by the start index.
def word_break(s, words): word_set = set(words) # instant lookup memo = {} # index -> list of sentences
def solve(start): if start in memo: # already solved this leftover return memo[start] if start == len(s): return [""] # one empty sentence to stop recursion sentences = [] for end in range(start + 1, len(s) + 1): word = s[start:end] # candidate chunk if word in word_set: for rest in solve(end): # all sentences for the leftover if rest == "": sentences.append(word) else: sentences.append(word + " " + rest) memo[start] = sentences # save for this index return sentences
return solve(0)
s = "catsanddog"words = ["cat", "cats", "and", "sand", "dog"]print(word_break(s, words))The output of the above code will be:
['cat sand dog', 'cats and dog']Let us read the Python version line by line and see why the cache matters.
The line word_set = set(words) turns the word list into a set. A set gives almost instant lookups, so checking if a chunk is a real word is fast. The line memo = {} is the cache. Its key is a start index. Its value is the full list of sentences for the substring starting there.
Inside solve, the line if start in memo is the speedup. If we already solved the leftover starting at this index, we hand back the saved list and skip all the work. This is what stops the repeated solving of the same leftover.
The line if start == len(s) is the base case. When we reach the end, we return a list holding one empty string. That empty string is a clever signal. It means βthere is exactly one way to finish from here, with nothing left to addβ. The caller will glue a word in front of it.
The loop for end in range(start + 1, len(s) + 1) tries every chunk length. The line word = s[start:end] cuts the chunk. The line if word in word_set keeps only chunks that are real words.
For a valid word, for rest in solve(end) gets every sentence for the leftover. The check if rest == "" handles the last word. When the rest is the empty string, we just keep the word with no trailing space. Otherwise we join the word and the rest with a space. The line memo[start] = sentences saves the result before returning, so the next time anyone asks for this index, the answer is ready.
β±οΈ Time and Space Complexity
Plain backtracking can repeat the same leftover many times, so its time grows fast, close to two to the power of n in the worst case, where n is the length of the string. Memoization solves each start index once, so the work per index is bounded by the string length and the number of sentences it produces. The space holds the cache, which stores a list of sentences for each index.
| Approach | Time Complexity | Space Complexity |
|---|---|---|
| Plain backtracking | O(2^n) worst case | O(n) recursion depth |
| Backtracking with memoization | O(n^2 + sentences) much faster | O(n * sentences) for the cache |
Tip
The point of memoization is simple. The sentences for a leftover never change, no matter how you reached that leftover. So solve each leftover once and store it. Reaching it again just reads the cache.
π§© Key Takeaways
- β Try every prefix as a word, and if it is in the dictionary, solve the rest.
- β At the end of the string, return a single empty sentence to stop the recursion.
- β Glue each valid word in front of every sentence the rest returns.
- β Cache the sentences per start index so you never solve the same leftover twice.
- β Put the words in a set for instant lookups.
Check Your Knowledge
Test what you learned. Pick an answer for each question, then click Check.
- 1
What does Word Break II return?
Why: Word Break II returns all valid sentences, not just whether a split exists.
- 2
Why does memoization speed up the solution?
Why: The sentences for a leftover are the same no matter how you reached it, so we cache them by index.
- 3
What does returning a list with one empty string at the end of the string mean?
Why: The empty sentence is a base case signal so the caller can attach a word in front of it.
- 4
Why store the dictionary words in a set?
Why: A set gives near constant time membership checks, so testing each chunk is fast.