Loading content...
Imagine receiving the string 'leetcode' with no spaces. Can it be broken into valid dictionary words? Yes: 'leet' + 'code'. What about 'catsandog'? Can you find 'cats' + 'and' + 'og'? No—'og' isn't a word.
This is the Word Break Problem—a classic that tests your understanding of dynamic programming, string matching, and optimization. It appears in:
Tries transform the naive exponential solution into something elegant and efficient. Combined with dynamic programming and memoization, we achieve optimal performance.
By the end of this page, you will master: (1) The Word Break problem formulation and variations, (2) Brute force approach and why it fails, (3) Dynamic programming with hash set solution, (4) Trie-optimized word break, (5) Finding all possible segmentations (Word Break II), and (6) Time/space complexity analysis.
There are two main variants of the Word Break problem:
Word Break I (Decision Problem):
Given a string s and a dictionary of words wordDict, determine if s can be segmented into a space-separated sequence of dictionary words.
Word Break II (Enumeration Problem):
Given the same input, return all possible segmentations of s into dictionary words.
Examples:
Word Break I Examples:━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Example 1: s = "leetcode" wordDict = ["leet", "code"] Output: true Explanation: "leetcode" = "leet" + "code" Example 2: s = "applepenapple" wordDict = ["apple", "pen"] Output: true Explanation: "applepenapple" = "apple" + "pen" + "apple" Note: Words can be reused Example 3: s = "catsandog" wordDict = ["cats", "dog", "sand", "and", "cat"] Output: false Explanation: "og" cannot be formed from dictionary words Example 4: s = "aaaaaaa" wordDict = ["aaaa", "aaa"] Output: true Explanation: "aaaaaaa" = "aaaa" + "aaa" or "aaa" + "aaaa" Word Break II Examples:━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Example 1: s = "catsanddog" wordDict = ["cat", "cats", "and", "sand", "dog"] Output: ["cats and dog", "cat sand dog"] Example 2: s = "pineapplepennapple" wordDict = ["apple", "pen", "applepen", "pine", "pineapple"] Output: ["pine apple pen apple", "pineapple pen apple", "pine applepen apple"]Key Observations:
Subproblem Structure: If we can break s[0:i] into valid words, and s[i:j] is a valid word, then we can break s[0:j]
Overlapping Subproblems: The question "can we break s[0:i]" is asked repeatedly during different decomposition attempts
Optimal Substructure: A valid segmentation is built from valid sub-segmentations
These properties scream Dynamic Programming!
Before optimization, let's understand the naive approach and why it fails.
Brute Force Strategy:
For each position i in the string:
s[0:i] as a potential first words[i:]123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
def word_break_brute_force(s: str, word_dict: list[str]) -> bool: """ Brute force word break - DO NOT USE IN PRODUCTION. Time Complexity: O(2^n) where n = len(s) Each position is a binary choice: break here or not Space Complexity: O(n) for recursion stack Why it's slow: - Explores ALL possible segmentations - Repeats work for the same suffixes - No memoization of results """ word_set = set(word_dict) # O(1) lookup def can_break(start: int) -> bool: # Base case: reached end of string if start == len(s): return True # Try every possible first word for end in range(start + 1, len(s) + 1): prefix = s[start:end] if prefix in word_set: # If this prefix is valid, try to break the rest if can_break(end): return True return False return can_break(0) # Why is this O(2^n)?## Consider s = "aaaaaaaaaa" (10 'a's), wordDict = ["a", "aa", "aaa", ...]## At each position, we can choose to break or continue.# This creates a binary decision tree of depth n.## Recursion tree (simplified):## can_break(0)# / | \# [a]break [aa]break [aaa]break ...# | | |# can_break(1) can_break(2) can_break(3)# / | \ ... ...# ...## Without memoization, we compute can_break(5) from multiple paths:# - can_break(0) → can_break(1) → ... → can_break(5)# - can_break(0) → can_break(2) → ... → can_break(5)# - can_break(0) → can_break(3) → ... → can_break(5)## This duplication leads to exponential time!For a string of length 30 with a dictionary containing short words like 'a', the brute force approach can take billions of operations. This is because it explores up to 2^30 ≈ 1 billion possible segmentation points.
The Root Cause: Redundant Computation
The brute force approach doesn't remember past results. When we ask "can we break s[5:]?", we compute the answer fresh every time—even if we've computed it before from a different path.
This is the classic signal for memoization or dynamic programming.
The standard, interview-ready solution uses bottom-up dynamic programming with a hash set for O(1) word lookups.
DP State Definition:
dp[i] = true if s[0:i] can be segmented into dictionary words.
Transition:
dp[i] = true if there exists j where:
- dp[j] = true (s[0:j] is breakable)
- s[j:i] is in the dictionary
Base Case:
dp[0] = true (empty string is trivially breakable)
Answer:
dp[len(s)]
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586
def word_break_dp(s: str, word_dict: list[str]) -> bool: """ Dynamic Programming solution for Word Break. Time Complexity: O(n² × m) where n = len(s), m = avg word check time With hash set, m = O(1) average, so O(n²) Space Complexity: O(n) for dp array + O(k) for word set where k = total characters in dictionary """ if not s: return True if not word_dict: return False word_set = set(word_dict) # O(1) lookup n = len(s) # dp[i] = True means s[0:i] can be segmented dp = [False] * (n + 1) dp[0] = True # Empty string is breakable # Fill dp table for i in range(1, n + 1): for j in range(i): # If s[0:j] is breakable AND s[j:i] is a word if dp[j] and s[j:i] in word_set: dp[i] = True break # Found one valid segmentation, no need to continue return dp[n] # Optimized version using max word lengthdef word_break_dp_optimized(s: str, word_dict: list[str]) -> bool: """ Optimized DP that limits inner loop to max word length. Time: O(n × L²) where L = max word length Much better when L << n """ if not s: return True if not word_dict: return False word_set = set(word_dict) max_len = max(len(word) for word in word_dict) n = len(s) dp = [False] * (n + 1) dp[0] = True for i in range(1, n + 1): # Only check substrings up to max_len before i for j in range(max(0, i - max_len), i): if dp[j] and s[j:i] in word_set: dp[i] = True break return dp[n] # Trace for s = "leetcode", wordDict = ["leet", "code"]:## Initial: dp = [T, F, F, F, F, F, F, F, F]# i= 0 1 2 3 4 5 6 7 8## i=1: Try s[0:1]="l" → not in dict# dp unchanged## i=2: Try s[0:2]="le", s[1:2]="e" → neither in dict# dp unchanged## i=3: Try s[0:3]="lee", etc. → none in dict# dp unchanged## i=4: Try s[0:4]="leet" → IN DICT! AND dp[0]=True# dp[4] = True# dp = [T, F, F, F, T, F, F, F, F]## i=5,6,7: Similar checks, none succeed## i=8: Try s[4:8]="code" → IN DICT! AND dp[4]=True# dp[8] = True## Return dp[8] = TrueThe optimized version limits the inner loop to max_len iterations instead of i iterations. If the longest dictionary word is 20 characters, there's no point checking 100-character substrings. This can reduce O(n²) to O(n × L) in practice.
Why This Works:
The DP builds solutions bottom-up:
dp[0] = true)i, we check all ways to form s[0:i]s[0:j] and s[j:i] is a word, then we can break s[0:i]This eliminates the exponential redundancy of brute force.
Now we introduce the trie optimization. While the hash set approach is often sufficient, tries offer unique advantages:
Why Tries?
When Trie Wins:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
class TrieNode: """Trie node for word break optimization.""" __slots__ = ['children', 'is_word'] def __init__(self): self.children: dict[str, 'TrieNode'] = {} self.is_word: bool = False class WordBreakTrie: """ Trie-optimized Word Break solution. Build Time: O(sum of word lengths) Query Time: O(n²) worst case, often O(n × L) average Space: O(total dictionary characters) """ def __init__(self): self.root = TrieNode() def insert(self, word: str) -> None: """Insert a word into the trie.""" node = self.root for char in word: if char not in node.children: node.children[char] = TrieNode() node = node.children[char] node.is_word = True def word_break(self, s: str) -> bool: """ Check if s can be segmented using trie. Uses DP where dp[i] = True if s[0:i] is breakable. For each position, traverse trie to find all words starting there. """ if not s: return True n = len(s) dp = [False] * (n + 1) dp[0] = True for i in range(n): if not dp[i]: continue # Can't break s[0:i], skip # Traverse trie starting from s[i] node = self.root for j in range(i, n): char = s[j] if char not in node.children: break # No word has this prefix node = node.children[char] if node.is_word: # s[i:j+1] is a word, mark dp[j+1] as breakable dp[j + 1] = True return dp[n] def word_break_trie(s: str, word_dict: list[str]) -> bool: """ Main function using trie-optimized word break. """ trie = WordBreakTrie() for word in word_dict: trie.insert(word) return trie.word_break(s) # Trie traversal visualization for s = "leetcode":## Build trie with ["leet", "code"]:## (root)# / \# l c# | |# e o# | |# e d# | |# t* e*## Word break with DP:## i=0, dp[0]=True:# j=0: s[0]='l' → in trie, continue# j=1: s[1]='e' → in trie, continue# j=2: s[2]='e' → in trie, continue# j=3: s[3]='t' → in trie, is_word=True → dp[4]=True# j=4: s[4]='c' → NOT in current node's children, break## i=1,2,3: dp[i]=False, skip## i=4, dp[4]=True:# j=4: s[4]='c' → in trie (from root), continue# j=5: s[5]='o' → in trie, continue# j=6: s[6]='d' → in trie, continue# j=7: s[7]='e' → in trie, is_word=True → dp[8]=True## Return dp[8] = TrueNotice how the trie naturally implements prefix pruning. When we encounter 'c' after 'leet', the current trie node (at 't') has no 'c' child—we break immediately instead of checking if 'leetc', 'leetco', etc. are words.
Trie vs Hash Set Comparison:
| Aspect | Hash Set | Trie |
|---|---|---|
| Word lookup | O(L) hash + compare | O(L) traversal |
| Prefix pruning | No | Yes |
| String slicing | Yes (creates objects) | No |
| Memory | O(total chars) | O(total chars + nodes) |
| Build time | O(total chars) | O(total chars) |
| Best when | Simple case, small dict | Large dict, shared prefixes |
In Practice:
For interview coding, the hash set DP solution is usually preferred due to simplicity. The trie version shines in production systems with reusable dictionaries.
Word Break II is more challenging: we need to return all possible segmentations, not just whether one exists.
Challenge:
The number of segmentations can be exponential. Consider:
s = "aaa", wordDict = ["a", "aa", "aaa"]["a a a", "a aa", "aa a", "aaa"] — 4 ways"aaaa": 8 ways (Fibonacci-like growth!)We need backtracking with memoization to avoid TLE.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142
def word_break_ii(s: str, word_dict: list[str]) -> list[str]: """ Word Break II: Return all possible segmentations. Time Complexity: O(2^n × n) worst case (exponential segmentations) In practice, much faster with memoization Space Complexity: O(2^n × n) for storing all results Strategy: 1. First, check if ANY segmentation exists (Word Break I) 2. If yes, use backtracking with memoization to find all """ word_set = set(word_dict) # Step 1: Precompute which positions are reachable # This avoids exploring dead ends n = len(s) can_reach_end = [False] * (n + 1) can_reach_end[n] = True # End position is reachable for i in range(n - 1, -1, -1): for j in range(i + 1, n + 1): if s[i:j] in word_set and can_reach_end[j]: can_reach_end[i] = True break # If start position can't reach end, no solution exists if not can_reach_end[0]: return [] # Step 2: Backtracking with memoization memo: dict[int, list[list[str]]] = {} def backtrack(start: int) -> list[list[str]]: """ Returns all possible ways to segment s[start:]. Each way is a list of words. """ if start == n: return [[]] # One way: empty list (base case) if start in memo: return memo[start] result = [] for end in range(start + 1, n + 1): word = s[start:end] if word in word_set and can_reach_end[end]: # This word is valid AND we can reach the end from here suffix_ways = backtrack(end) for suffix in suffix_ways: result.append([word] + suffix) memo[start] = result return result # Get all segmentations and format as strings segmentations = backtrack(0) return [' '.join(words) for words in segmentations] # Trie-optimized versiondef word_break_ii_trie(s: str, word_dict: list[str]) -> list[str]: """ Word Break II using trie for efficient word detection. """ # Build trie root = {} END = '#' for word in word_dict: node = root for char in word: node = node.setdefault(char, {}) node[END] = word # Store word at end node # Check reachability (reverse DP) n = len(s) can_reach_end = [False] * (n + 1) can_reach_end[n] = True for i in range(n - 1, -1, -1): node = root for j in range(i, n): if s[j] not in node: break node = node[s[j]] if END in node and can_reach_end[j + 1]: can_reach_end[i] = True break if not can_reach_end[0]: return [] # Backtracking with trie result = [] path = [] def backtrack(start: int) -> None: if start == n: result.append(' '.join(path)) return node = root for j in range(start, n): if s[j] not in node: break node = node[s[j]] if END in node and can_reach_end[j + 1]: path.append(node[END]) backtrack(j + 1) path.pop() backtrack(0) return result # Example trace for s = "catsanddog":## Word dict: ["cat", "cats", "and", "sand", "dog"]## Reachability check (right to left):# can_reach_end[10] = True (end)# i=7: "dog" in dict, can_reach_end[10] → can_reach_end[7] = True# i=4: "and" in dict, can_reach_end[7] → can_reach_end[4] = True# "sand" in dict → not needed, already True# i=3: "sand" in dict, can_reach_end[7] → can_reach_end[3] = True# i=0: "cat" in dict, can_reach_end[3] → can_reach_end[0] = True# "cats" in dict, can_reach_end[4] → can_reach_end[0] = True (already)## Backtracking:# start=0: "cat" valid, go to start=3# start=3: "sand" valid, go to start=7# start=7: "dog" valid, go to start=10 → FOUND: "cat sand dog"# start=0: "cats" valid, go to start=4# start=4: "and" valid, go to start=7# start=7: "dog" valid, go to start=10 → FOUND: "cats and dog"## Result: ["cat sand dog", "cats and dog"]The can_reach_end array is crucial! Without it, we might explore paths that lead nowhere. For example, in 'catsandog', after 'cats' + 'and', we'd explore 'og' only to find it's not a word. The reachability check eliminates these dead ends upfront.
Real-world applications often involve variations of the basic word break problem:
dp[i] = min words to segment s[0:i].12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758
def min_word_break(s: str, word_dict: list[str]) -> int: """ Find minimum number of words to segment s. Returns -1 if s cannot be segmented. Time: O(n² × L) where L = avg word length Space: O(n) """ word_set = set(word_dict) n = len(s) # dp[i] = minimum words to segment s[0:i], or infinity if impossible INF = float('inf') dp = [INF] * (n + 1) dp[0] = 0 # Empty string needs 0 words for i in range(1, n + 1): for j in range(i): if dp[j] < INF and s[j:i] in word_set: dp[i] = min(dp[i], dp[j] + 1) return dp[n] if dp[n] < INF else -1 def weighted_word_break(s: str, word_scores: dict[str, int]) -> int: """ Find maximum score segmentation. word_scores: dictionary mapping words to their scores Time: O(n² × L) Space: O(n) """ n = len(s) # dp[i] = max score for s[0:i], -infinity if impossible NEG_INF = float('-inf') dp = [NEG_INF] * (n + 1) dp[0] = 0 for i in range(1, n + 1): for j in range(i): word = s[j:i] if word in word_scores and dp[j] > NEG_INF: dp[i] = max(dp[i], dp[j] + word_scores[word]) return dp[n] if dp[n] > NEG_INF else -1 # Example usage:# word_scores = {"leet": 10, "code": 20, "leetcode": 25}# weighted_word_break("leetcode", word_scores)# # dp[4] = dp[0] + 10 = 10 ("leet")# dp[8] = max(dp[0] + 25, dp[4] + 20) = max(25, 30) = 30## Best segmentation: "leet" + "code" = 30 > "leetcode" = 25Let's rigorously analyze the complexity of our solutions:
| Algorithm | Time | Space | Notes |
|---|---|---|---|
| Brute Force | O(2^n) | O(n) | Exponential, never use |
| DP + Hash Set | O(n²) | O(n + D) | D = dictionary size |
| DP + Hash (optimized) | O(n × L) | O(n + D) | L = max word length |
| DP + Trie | O(n × L) | O(n + D) | Better prefix pruning |
| Word Break II | O(2^n × n) | O(2^n × n) | Output-sensitive |
Why Word Break II is Exponential:
Consider s = "aaa...a" (n times) with wordDict = ["a"].
The number of segmentations is 1 (only "a a a ... a"). Good, tractable.
Now consider wordDict = ["a", "aa"]. The number of segmentations follows the Fibonacci sequence:
"a a", "aa")Fibonacci grows exponentially (φⁿ where φ ≈ 1.618). Since we must enumerate all solutions, Word Break II is inherently exponential in worst case.
Practical Implications:
For Word Break II, the constraint is typically n ≤ 20, with small dictionaries. For larger inputs, the problem becomes:
Word break algorithms power many real-world systems:
In production, word break often needs: (1) Multiple dictionaries (common words, proper nouns, jargon), (2) Weighted scoring to prefer common segmentations, (3) Caching of frequently queried strings, (4) Timeout handling for adversarial inputs.
We've explored the Word Break problem family in depth:
dp[i] = can s[0:i] be segmented?. Build bottom-up with O(n²) time.You now understand Word Break problems deeply—from brute force through optimized DP to trie-enhanced solutions. Next, we'll explore Spell Checkers, another powerful application of tries that combines prefix matching with edit distance algorithms.