211. Word Break
š LeetCode Problem: 139. Word Break
š Difficulty: Medium
š·ļø Topics: Dynamic Programming, String, Hash Table, Trie, Memoization, 1D DP
Problem Statement
Given a string s and a dictionary of strings wordDict, return true if s can be segmented into a space-separated sequence of one or more dictionary words.
Note: The same word in the dictionary may be reused multiple times in the segmentation.
Example 1:
Input: s = "leetcode", wordDict = ["leet","code"]
Output: true
Explanation: Return true because "leetcode" can be segmented as "leet code".
Example 2:
Input: s = "applepenapple", wordDict = ["apple","pen"]
Output: true
Explanation: Return true because "applepenapple" can be segmented as "apple pen apple".
Note that you are allowed to reuse a dictionary word.
Example 3:
Input: s = "catsandog", wordDict = ["cats","dog","sand","and","cat"]
Output: false
Constraints:
- 1 <= s.length <= 300
- 1 <= wordDict.length <= 1000
- 1 <= wordDict[i].length <= 20
- s and wordDict[i] consist of only lowercase English letters.
- All the strings of wordDict are unique.
š³ Visual Understanding - Using "applepenapple"
The Problem We're Solving:
Given: String s = "applepenapple"
Dictionary = ["apple", "pen"]
Goal: Can we build s using ONLY dictionary words?
Question: Can "applepenapple" be made from "apple" and "pen"? š¤
Finding the Solution for "applepenapple":
String: "applepenapple"
Dictionary: ["apple", "pen"]
Let's try to segment it:
Position 0-4: "apple"
ā "apple" is in dictionary!
Remaining: "penapple"
Position 5-7: "pen"
ā "pen" is in dictionary!
Remaining: "apple"
Position 8-12: "apple"
ā "apple" is in dictionary! (reusing word!)
Remaining: "" (empty - we're done!)
Segmentation: "apple" + "pen" + "apple" ā
Answer: true
Notice: We used "apple" TWICE (word reuse allowed!)
Why This Example Is Great:
"applepenapple" teaches us:
1. WORD REUSE:
- "apple" appears twice
- Dictionary words can repeat!
2. GREEDY DOESN'T WORK:
- Can't just take longest match first
- Need to try different combinations
3. MULTIPLE DECISIONS:
- At each position, multiple choices possible
- Need to explore carefully!
This is why we need DP! šÆ
Complete Decision Tree for "applepenapple":
"applepenapple"
|
+---------------------+---------------------+
| | |
"a" "apple" "applepe"
(not in dict) (in dict ā) (not in dict)
DEAD | DEAD
v
"penapple"
|
+---------------------+---------------------+
| | |
"p" "pen" "penap"
(not in dict) (in dict ā) (not in dict)
DEAD | DEAD
v
"apple"
|
+-----+-----+
| |
"apple" "applepe"
(in dict ā) (not in dict)
| DEAD
v
"" (empty)
SUCCESS! ā
Result: true - Found valid segmentation!
Segmentation: "apple" ā "pen" ā "apple"
Failed Example: "catsandog"
String: "catsandog"
Dictionary: ["cats", "dog", "sand", "and", "cat"]
Let's see why this FAILS:
Try 1: "cat" + "sandog"
ā "cat" is in dictionary
ā "sandog" is NOT in dictionary
FAILED!
Try 2: "cats" + "andog"
ā "cats" is in dictionary
ā "andog" is NOT in dictionary
FAILED!
Try 3: "cat" + "sand" + "og"
ā "cat" is in dictionary
ā "sand" is in dictionary
ā "og" is NOT in dictionary
FAILED!
Try 4: "cats" + "and" + "og"
ā "cats" is in dictionary
ā "and" is in dictionary
ā "og" is NOT in dictionary
FAILED!
No matter how we segment, we get stuck with "og"!
Answer: false
š§ The AHA Moment - Why This Problem Needs DP
What Makes This Hard?
CHALLENGE 1: Multiple ways to start
"applepenapple" could start with:
- "a" (not in dict)
- "ap" (not in dict)
- "app" (not in dict)
- "appl" (not in dict)
- "apple" (in dict!) ā
- "applep" (not in dict)
...
We must try many possibilities!
CHALLENGE 2: Same subproblems appear multiple times
If we try "cat" first: need to solve "sandog"
If we try "cats" first: need to solve "andog"
Both might later need to check "og"!
Checking "og" multiple times is wasteful! ā ļø
CHALLENGE 3: Greedy doesn't work
Can't just take the longest matching word first
Example: "aaaaaaaaaaaab" with dict = ["a", "aa", "aaaa", "aaaaa"]
Greedy (longest): Take "aaaaa" ā "aaaab" ā stuck!
Optimal: Take "a" many times ā "b" ā check if possible
Need to try different combinations! šÆ
This is a PERFECT DP problem!
The Key Insight - Think Backwards!
CLEVER THINKING:
Instead of: "Can I segment the whole string?"
Think: "Can I segment from each position to the end?"
For "applepenapple":
Position 13 (end): Empty string ā YES (base case)
Position 12: "e"
ā Can we make "e" and reach end? NO
Position 8: "apple"
ā Can we make "apple" and reach end?
ā "apple" in dict? YES
ā Can we segment from position 13? YES
ā So position 8 works! ā
Position 5: "penapple"
ā Can we make "pen" and reach position 8?
ā "pen" in dict? YES
ā Can we segment from position 8? YES (we know this!)
ā So position 5 works! ā
Position 0: "applepenapple"
ā Can we make "apple" and reach position 5?
ā "apple" in dict? YES
ā Can we segment from position 5? YES (we know this!)
ā So position 0 works! ā
If position 0 works, entire string can be segmented! šÆ
Why DP? Two Key Properties:
1. OVERLAPPING SUBPROBLEMS:
To check "applepenapple":
- If we take "apple": need to check "penapple"
- If we take "app" (if it existed): might also need "penapple"
Same suffixes checked multiple times!
Solution: Remember results! (Memoization)
2. OPTIMAL SUBSTRUCTURE:
canSegment("applepenapple") depends on:
- Can we match a word at start?
- Can we segment what remains?
Solution to big problem uses solutions to smaller problems!
These properties = Classic DP! šÆ
š“ Approach 1: Brute Force Recursion (Try Every Possibility)
š Function Definition
Function Signature:
boolean wordBreak(String s, List<String> wordDict)
boolean canSegment(String s, Set<String> wordSet, int start)
What it represents:
Input Parameter s:
- The string we're trying to segment
- Example: s = "applepenapple"
Input Parameter start:
- Current position in string
- We're checking if s[start..end] can be segmented
- Example: start=5 means "can we segment 'penapple'?"
Input Parameter wordSet:
- Dictionary converted to HashSet for O(1) lookup
- Example: {"apple", "pen"}
Return Value (boolean):
- true = Can segment s[start..end] using dictionary
- false = Cannot segment s[start..end]
Purpose: - Try every possible first word from current position - Recursively check if remaining can be segmented - Return true if ANY combination works
Key Understanding:
canSegment("applepenapple", wordSet, 0) means:
"Can we segment 'applepenapple' from position 0?"
At position 0, we try all possible first words:
- "a" (position 0-0): not in dict, skip
- "ap" (position 0-1): not in dict, skip
- "app" (position 0-2): not in dict, skip
- "appl" (position 0-3): not in dict, skip
- "apple" (position 0-4): in dict! ā
ā Check if we can segment from position 5
ā If YES, we found a solution!
Keep trying until we find a valid segmentation!
š” Intuition
The simplest idea: Try every possible way to cut the string!
Think of it like cutting a chocolate bar:
"applepenapple" - where should I make the first cut?
Option 1: Cut after "a"
ā Got "a"
ā "a" in dictionary? NO
ā Skip this option
Option 2: Cut after "ap"
ā Got "ap"
ā "ap" in dictionary? NO
ā Skip this option
Option 3: Cut after "app"
ā Got "app"
ā "app" in dictionary? NO
ā Skip this option
Option 4: Cut after "appl"
ā Got "appl"
ā "appl" in dictionary? NO
ā Skip this option
Option 5: Cut after "apple"
ā Got "apple"
ā "apple" in dictionary? YES! ā
ā Now recursively check remaining "penapple"
ā If remaining works, we're done!
For each valid first word, recursively solve what's left!
This explores ALL possible segmentations!
The Recursive Pattern:
At each position:
1. TRY: all possible word lengths from this position
2. CHECK: is this substring in dictionary?
3. RECURSE: if YES, check if remaining can be segmented
4. RETURN: true if ANY option works
5. BASE CASE: if we reach the end, return true
This naturally tries every possible combination!
š Implementation
class Solution {
public boolean wordBreak(String s, List<String> wordDict) {
// Convert to HashSet for O(1) lookup (CRITICAL!)
Set<String> wordSet = new HashSet<>(wordDict);
return canSegment(s, wordSet, 0);
}
private boolean canSegment(String s, Set<String> wordSet, int start) {
// Base case: reached end of string
if (start == s.length()) {
return true; // Successfully segmented entire string!
}
// Try all possible word lengths from current position
for (int end = start + 1; end <= s.length(); end++) {
// Extract substring from start to end
String word = s.substring(start, end);
// If this word is in dictionary
if (wordSet.contains(word)) {
// Recursively check if remaining can be segmented
if (canSegment(s, wordSet, end)) {
return true; // Found valid segmentation!
}
}
}
// No valid segmentation found from this position
return false;
}
}
š Detailed Dry Run: s = "applepenapple", wordDict = ["apple", "pen"]
wordSet = {"apple", "pen"}
canSegment("applepenapple", wordSet, 0)
āā start=0, length=13
āā Try all word lengths from position 0
ā
āā Try s[0..1] = "a": not in dict, skip
āā Try s[0..2] = "ap": not in dict, skip
āā Try s[0..3] = "app": not in dict, skip
āā Try s[0..4] = "appl": not in dict, skip
āā Try s[0..5] = "apple": in dict! ā
ā āā canSegment("applepenapple", wordSet, 5)
ā āā start=5, remaining="penapple"
ā āā Try all word lengths from position 5
ā ā
ā āā Try s[5..6] = "p": not in dict, skip
ā āā Try s[5..7] = "pe": not in dict, skip
ā āā Try s[5..8] = "pen": in dict! ā
ā ā āā canSegment("applepenapple", wordSet, 8)
ā ā āā start=8, remaining="apple"
ā ā āā Try all word lengths from position 8
ā ā ā
ā ā āā Try s[8..9] = "a": not in dict, skip
ā ā āā Try s[8..10] = "ap": not in dict, skip
ā ā āā Try s[8..11] = "app": not in dict, skip
ā ā āā Try s[8..12] = "appl": not in dict, skip
ā ā āā Try s[8..13] = "apple": in dict! ā
ā ā ā āā canSegment("applepenapple", wordSet, 13)
ā ā ā āā start=13 == length ā BASE CASE
ā ā ā return true ā
ā ā ā
ā ā āā return true (found "apple")
ā ā
ā āā return true (found "pen")
ā
āā return true (found "apple")
Result: true ā
Segmentation found: "apple" ā "pen" ā "apple"
What Happens Step-by-Step:
Position 0: "applepenapple"
ā Found "apple" (0-5)
ā Recurse to position 5
Position 5: "penapple"
ā Found "pen" (5-8)
ā Recurse to position 8
Position 8: "apple"
ā Found "apple" (8-13)
ā Recurse to position 13
Position 13: "" (empty)
ā Reached end! Success!
Backtrack with "true" all the way up!
The Problem - Exponential Time:
For "aaaaaaaaaa" with dict = ["a", "aa", "aaa", ...]:
At position 0:
Try "a" ā recurse to position 1
Try "aa" ā recurse to position 2
Try "aaa" ā recurse to position 3
...
This creates exponential branches!
Many positions checked multiple times!
For n=10: Could check same suffixes hundreds of times!
This is TOO SLOW! ā ļø
š Complexity Analysis
Time Complexity: O(2^n)
At each position, multiple choices
Each choice creates a branch
Creates exponential recursion tree
For long strings: TOO SLOW! ā
Space Complexity: O(n)
Recursion stack depth: O(n) worst case
Why This Fails:
ā Exponential time - too slow
ā Rechecks same positions many times
ā No memory of previous results
ā
But shows the recursive structure clearly!
š” Approach 2: Top-Down DP (Remember What We've Seen)
š Function Definition
Function Signature:
boolean canSegment(String s, Set<String> wordSet, int start, Boolean[] memo)
What it represents:
Input Parameter memo (Boolean[]):
- Memory array to store results
- memo[i] = can we segment s[i..end]?
- null = haven't checked yet
- true = yes, can segment
- false = no, cannot segment
Purpose: - Same recursion as Approach 1 - BUT remember results to avoid rechecking - Each position calculated at most once!
Key Understanding:
First time at position 5:
- memo[5] = null (not checked)
- Calculate: Can we segment "penapple"?
- Result: true
- Store: memo[5] = true
- Return: true
Second time at position 5 (if we get here again):
- memo[5] = true (already know!)
- Return immediately: true
- No recalculation! āØ
This saves TONS of time!
š” Intuition
Super simple idea: Remember what we've already figured out!
Imagine you're solving a maze:
WITHOUT MEMORY (Approach 1):
Every time you reach a junction you've seen before,
you explore it again from scratch.
Waste of time! ā ļø
WITH MEMORY (Approach 2):
First time at a junction: Explore it, remember result
Next time at same junction: "Oh, I've been here!"
ā Use remembered result
ā No need to explore again! āØ
For "applepenapple":
First time we check position 8:
ā Calculate: "apple" can be segmented? YES
ā Remember: memo[8] = true
If we ever need position 8 again:
ā Look up: memo[8] = true
ā Return immediately!
This is called MEMOIZATION - just fancy word for "remembering"!
š Implementation
class Solution {
public boolean wordBreak(String s, List<String> wordDict) {
Set<String> wordSet = new HashSet<>(wordDict);
// Create memory array (one extra for position n)
Boolean[] memo = new Boolean[s.length() + 1];
return canSegment(s, wordSet, 0, memo);
}
private boolean canSegment(String s, Set<String> wordSet,
int start, Boolean[] memo) {
// Base case
if (start == s.length()) {
return true;
}
// Check if we've already solved this position
if (memo[start] != null) {
return memo[start]; // Return remembered result! āØ
}
// Try all possible word lengths
for (int end = start + 1; end <= s.length(); end++) {
String word = s.substring(start, end);
if (wordSet.contains(word)) {
if (canSegment(s, wordSet, end, memo)) {
memo[start] = true; // Remember: YES!
return true;
}
}
}
// No valid segmentation found
memo[start] = false; // Remember: NO!
return false;
}
}
š Detailed Dry Run: s = "applepenapple"
memo = [null, null, null, null, null, null, null, null, null, null, null, null, null, null]
ā ā ā ā ā ā ā ā ā ā ā ā ā ā
0 1 2 3 4 5 6 7 8 9 10 11 12 13
canSegment(s, wordSet, 0, memo)
āā memo[0] = null ā not calculated yet
āā Try "apple" (0-5): in dict ā
ā āā canSegment(s, wordSet, 5, memo)
ā āā memo[5] = null ā not calculated yet
ā āā Try "pen" (5-8): in dict ā
ā ā āā canSegment(s, wordSet, 8, memo)
ā ā āā memo[8] = null ā not calculated yet
ā ā āā Try "apple" (8-13): in dict ā
ā ā ā āā canSegment(s, wordSet, 13, memo)
ā ā ā āā start == length ā return true
ā ā ā
ā ā āā Found solution!
ā ā āā memo[8] = true ā (remember!)
ā ā āā return true
ā ā
ā āā Found solution!
ā āā memo[5] = true ā (remember!)
ā āā return true
ā
āā Found solution!
āā memo[0] = true ā (remember!)
āā return true
Final memo state:
memo = [true, null, null, null, null, true, null, null, true, null, null, null, null, null]
ā ā ā
Position 0 Position 5 Position 8
"applepenapple" "penapple" "apple"
can segment! can segment! can segment!
Result: true ā
Why This Is Much Faster:
Each position checked at most ONCE!
Position 0: Check once, remember result
Position 5: Check once, remember result
Position 8: Check once, remember result
Position 13: Base case (always true)
If we had multiple paths to position 8:
First path: Calculate and store memo[8] = true
Other paths: Just read memo[8] = true immediately!
Instead of exponential, now it's polynomial! š
š Complexity Analysis
Time Complexity: O(n² à m)
n positions to check: O(n)
Each position tries up to n substrings: O(n)
Each substring check in HashSet: O(m) where m = avg word length
Total: O(n à n à m) = O(n² à m)
MUCH better than O(2^n)! š
Space Complexity: O(n)
Memo array: O(n)
Recursion stack: O(n)
Total: O(n)
š¢ Approach 3: Bottom-Up DP (Build Solution from End to Start)
š Function Definition
DP Array Definition:
boolean[] dp = new boolean[n + 1];
What dp[i] represents:
Index i:
- Position in the string
- dp[i] represents s[i..end]
Value dp[i] (boolean):
- dp[i] = true ā Can segment s[i..end] using dictionary
- dp[i] = false ā Cannot segment s[i..end]
Purpose: - Build solution table from END to START - No recursion needed! - Fill table once, use directly
Key Understanding:
For "applepenapple" (length 13):
dp[13] = true (empty string - base case)
dp[12] = ? (can segment "e"?)
dp[11] = ? (can segment "le"?)
...
dp[8] = ? (can segment "apple"?)
...
dp[5] = ? (can segment "penapple"?)
...
dp[0] = ? (can segment entire string?)
Build from right to left!
dp[0] = final answer!
š” Intuition
Think of it like climbing stairs backwards:
Instead of top-down recursion (going down),
we build bottom-up (climbing up from end)!
For "applepenapple":
Start at END (position 13):
dp[13] = true (empty string always works)
Work BACKWARDS to position 12:
Can we segment "e"?
Try all dictionary words:
- "apple" too long
- "pen" too long
Result: dp[12] = false
Work BACKWARDS to position 11:
Can we segment "le"?
Try all dictionary words:
- "apple" too long
- "pen" too long
Result: dp[11] = false
Continue...
Work BACKWARDS to position 8:
Can we segment "apple"?
Try "apple": matches! ā
Check dp[13] = true ā
Result: dp[8] = true ā
Continue...
Work BACKWARDS to position 5:
Can we segment "penapple"?
Try "pen": matches! ā
Check dp[8] = true ā (we already know this!)
Result: dp[5] = true ā
Continue...
Work BACKWARDS to position 0:
Can we segment "applepenapple"?
Try "apple": matches! ā
Check dp[5] = true ā (we already know this!)
Result: dp[0] = true ā
Answer is dp[0]! šÆ
Why Build Backwards?
KEY INSIGHT:
To know if position i works:
Need to know if positions i+1, i+2, ... work!
Example:
To know dp[5] ("penapple"):
If "pen" matches, need dp[8]
dp[8] must be calculated FIRST!
Building backwards ensures all dependencies ready!
Direction: 13 ā 12 ā 11 ā ... ā 1 ā 0
š Implementation
class Solution {
public boolean wordBreak(String s, List<String> wordDict) {
int n = s.length();
Set<String> wordSet = new HashSet<>(wordDict);
// dp[i] = can we segment s[i..end]?
boolean[] dp = new boolean[n + 1];
// Base case: empty string (position n)
dp[n] = true;
// Build backwards from end to start
for (int i = n - 1; i >= 0; i--) {
// Try all words from dictionary
for (String word : wordSet) {
int len = word.length();
// Check if word fits at position i
if (i + len <= n && s.substring(i, i + len).equals(word)) {
// If word matches AND remaining can be segmented
if (dp[i + len]) {
dp[i] = true;
break; // Found a valid word, no need to check more
}
}
}
}
// Answer: can we segment from start?
return dp[0];
}
}
š Detailed Dry Run: s = "applepenapple"
s = "applepenapple"
n = 13
wordSet = {"apple", "pen"}
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
INITIALIZATION
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Create DP table:
dp = [false, false, false, false, false, false, false, false, false, false, false, false, false, false]
ā ā ā ā ā ā ā ā ā ā ā ā ā ā
0 1 2 3 4 5 6 7 8 9 10 11 12 13
Base case:
dp[13] = true (empty string)
dp = [false, false, false, false, false, false, false, false, false, false, false, false, false, true]
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
BUILDING BACKWARDS
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Position 12: s[12..13] = "e"
āāāāāāāāāāāāāāāāāāāāāāāā
Try "apple": len=5, 12+5=17 > 13, too long
Try "pen": len=3, 12+3=15 > 13, too long
No match ā dp[12] = false
Position 11: s[11..13] = "le"
āāāāāāāāāāāāāāāāāāāāāāāāā
Try "apple": len=5, 11+5=16 > 13, too long
Try "pen": len=3, 11+3=14 > 13, too long
No match ā dp[11] = false
Position 10: s[10..13] = "ple"
āāāāāāāāāāāāāāāāāāāāāāāāāā
Try "apple": len=5, 10+5=15 > 13, too long
Try "pen": len=3, 10+3=13 ā
s[10..13] = "ple"
"ple" == "pen"? NO
Try more...
No match ā dp[10] = false
...continuing backwards...
Position 8: s[8..13] = "apple"
āāāāāāāāāāāāāāāāāāāāāāāāāāā
Try "apple": len=5, 8+5=13 ā
s[8..13] = "apple"
"apple" == "apple"? YES! ā
Check dp[13] = true ā
dp[8] = true ā
Break (found match)
dp = [false, false, false, false, false, false, false, false, true, false, false, false, false, true]
ā
"apple" works!
Position 7: s[7..13] = "papple"
āāāāāāāāāāāāāāāāāāāāāāāāāāāāā
No match ā dp[7] = false
Position 6: s[6..13] = "napple"
āāāāāāāāāāāāāāāāāāāāāāāāāāāāā
No match ā dp[6] = false
Position 5: s[5..13] = "penapple"
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Try "apple": len=5, 5+5=10 ā
s[5..10] = "penap"
"penap" == "apple"? NO
Try "pen": len=3, 5+3=8 ā
s[5..8] = "pen"
"pen" == "pen"? YES! ā
Check dp[8] = true ā
dp[5] = true ā
Break (found match)
dp = [false, false, false, false, false, true, false, false, true, false, false, false, false, true]
ā
"pen" works!
Position 4: s[4..13] = "epenapple"
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
No match ā dp[4] = false
Position 3: s[3..13] = "lepenapple"
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
No match ā dp[3] = false
Position 2: s[2..13] = "plepenapple"
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
No match ā dp[2] = false
Position 1: s[1..13] = "pplepenapple"
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
No match ā dp[1] = false
Position 0: s[0..13] = "applepenapple"
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Try "apple": len=5, 0+5=5 ā
s[0..5] = "apple"
"apple" == "apple"? YES! ā
Check dp[5] = true ā
dp[0] = true ā
Break (found match)
Final DP table:
dp = [true, false, false, false, false, true, false, false, true, false, false, false, false, true]
ā ā ā ā
Position 0 Position 5 Position 8 Position 13
"applepenapple" "penapple" "apple" (empty)
CAN segment! CAN segment! CAN segment! base case
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
RESULT: dp[0] = true ā
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Segmentation path:
dp[0] ā "apple" ā dp[5]
dp[5] ā "pen" ā dp[8]
dp[8] ā "apple" ā dp[13]
dp[13] ā empty (success!)
Segmentation: "apple" + "pen" + "apple" ā
Why This Is Beautiful:
ADVANTAGES:
ā
No recursion - just simple loop
ā
Clear building process
ā
All positions calculated exactly once
ā
Easy to debug (can print table)
ā
Optimal time complexity
The dp table shows exactly which positions work!
dp[i] = true means "can segment from i to end"
š Complexity Analysis
Time Complexity: O(n Ć k Ć m)
n positions: O(n)
For each position, try k dictionary words: O(k)
Each word comparison: O(m) where m = avg word length
Total: O(n Ć k Ć m)
Typically: O(n² à m) considering all substrings
Space Complexity: O(n)
DP table: O(n)
No recursion: O(1)
Total: O(n)
šµ Approach 4: Bottom-Up DP (Forward Direction - Alternative)
š Function Definition
DP Array Definition:
boolean[] dp = new boolean[n + 1];
What dp[i] represents (DIFFERENT!):
Index i:
- Number of characters processed
- dp[i] represents s[0..i-1] (first i characters)
Value dp[i] (boolean):
- dp[i] = true ā Can segment s[0..i-1] (first i characters)
- dp[i] = false ā Cannot segment first i characters
Purpose: - Build solution from START to END (opposite direction!) - Still no recursion - Just different perspective
Key Understanding:
For "applepenapple" (length 13):
dp[0] = true (0 characters = empty string, base case)
dp[1] = ? (can segment "a"?)
dp[2] = ? (can segment "ap"?)
...
dp[5] = ? (can segment "apple"?)
...
dp[8] = ? (can segment "applepen"?)
...
dp[13] = ? (can segment entire string?)
Build from left to right!
dp[13] = final answer!
š” Intuition
Think of it as building the string piece by piece:
Start with EMPTY string (position 0):
dp[0] = true (can segment empty string)
Add characters one by one:
Position 1: "a"
Can we make "a"?
Check if previous (dp[0]=true) AND "a" in dict
"a" not in dict ā dp[1] = false
Position 2: "ap"
Can we make "ap"?
Could be: dp[0] + "ap" (not in dict)
Could be: dp[1] + "p" (dp[1]=false, skip)
Result: dp[2] = false
...
Position 5: "apple"
Can we make "apple"?
Could be: dp[0] + "apple" (in dict!) ā
dp[0] = true ā
"apple" in dict ā
Result: dp[5] = true ā
Position 8: "applepen"
Can we make "applepen"?
Could be: dp[5] + "pen" (in dict!) ā
dp[5] = true ā
"pen" in dict ā
Result: dp[8] = true ā
Position 13: "applepenapple"
Can we make "applepenapple"?
Could be: dp[8] + "apple" (in dict!) ā
dp[8] = true ā
"apple" in dict ā
Result: dp[13] = true ā
Same answer, different direction! šÆ
š Implementation
class Solution {
public boolean wordBreak(String s, List<String> wordDict) {
int n = s.length();
Set<String> wordSet = new HashSet<>(wordDict);
// dp[i] = can we segment s[0..i-1] (first i characters)?
boolean[] dp = new boolean[n + 1];
// Base case: empty string (0 characters)
dp[0] = true;
// Build forward from start to end
for (int i = 1; i <= n; i++) {
// Try all possible previous positions
for (int j = 0; j < i; j++) {
// If first j characters can be segmented
// AND s[j..i-1] is in dictionary
if (dp[j] && wordSet.contains(s.substring(j, i))) {
dp[i] = true;
break; // Found valid segmentation
}
}
}
return dp[n];
}
}
š Complexity Analysis
Time Complexity: O(n² à m) Space Complexity: O(n)
Same as Approach 3, just different direction!
šÆ Pattern Recognition - String Segmentation
Core Pattern: Can We Build This String?
Template for segmentation problems:
Goal: Can we build string s using dictionary words?
DP Approach:
dp[i] = can we segment up to position i?
For each position i:
Try all valid segments ending at i
If segment valid AND previous position works:
ā dp[i] = true
Similar Problems:
- Palindrome Partitioning: Segment into palindromes
- Decode Ways: Segment with decoding rules
- Sentence Screen Fitting: Segment with word wrapping
When to Use This Pattern:
ā
Need to break string into pieces
ā
Each piece must satisfy some condition
ā
Need to check if entire string can be broken
ā
Order matters (left to right)
ā
Pieces can repeat
For Word Break:
ā
Break into dictionary words
ā
Words must be in dictionary
ā
Check if entire string breakable
ā
Order matters
ā
Words can repeat
ā ļø Important Edge Cases to Test
s = "a", wordDict = ["a"] // Expected: true
s = "a", wordDict = ["b"] // Expected: false
s = "applepenapple", wordDict = ["apple","pen"] // Expected: true (reuse)
s = "catsandog", wordDict = ["cats","dog","sand","and","cat"] // Expected: false
s = "aaaaaaa", wordDict = ["aaaa","aaa"] // Expected: true
s = "aaaaaaa", wordDict = ["aaaa","aa"] // Expected: false
s = "bb", wordDict = ["a","b","bbb","bbbb"] // Expected: true
s = "", wordDict = ["a"] // Expected: true (empty string)
s = "cars", wordDict = ["car","ca","rs"] // Expected: true
s = "abcd", wordDict = ["a","abc","b","cd"] // Expected: true
š Quick Revision Notes
šÆ Core Concept
Problem: Given string and dictionary, check if string can be segmented using dictionary words (reuse allowed).
Key Insight: Try each position - can we match a word AND segment the rest? Use DP to remember results.
Four Approaches: 1. Brute Force ā Try every segmentation, O(2^n) ā 2. Top-Down DP ā Remember results, O(n² Ć m) ā 3. Bottom-Up Backward ā Build from end, O(n² Ć m) ā 4. Bottom-Up Forward ā Build from start, O(n² Ć m) ā
ā” Quick Implementation
import java.util.HashSet;
import java.util.List;
public class Solution {
public boolean wordBreak(String s, List<String> wordDict) {
HashSet<String> dict = new HashSet<>(wordDict);
// // Approach 1 - brute force
// // Step 1: Can we segment string s starting from index 0 to end?
// return recursive(s, 0, dict);
// // Approach 2 - top down
// Boolean[] memo = new Boolean[s.length()];
// return topDown(s, 0, dict, memo);
// // Approach 3 - bottom up
// return bottomUpBackward(s, dict);
// Approach 4 - bottom up (forward)
return bottomUpForward(s, dict);
}
private boolean bottomUpForward(String s, HashSet<String> dict) {
int n = s.length();
boolean[] dp = new boolean[n + 1];
dp[0] = true; // an empty string can be always segmented
for (int i = 1; i <= n; i++) {
for (int j = 0; j < i; j++) {
// Try every substring from (j, i)
String word = s.substring(j, i);
// check if this substring exists in dict.
// If yes, check if string before j is segmented.
// "applepenapple" => dp[0]=T, dp[5]=T only if dp[0]=T
// Means, for dp[5], "apple" is in dict.
// Similarly, for dp[8], "pen" is in dict and dp[8]=T only
// when dp[5] is T (already segmented)
if (dict.contains(word) && dp[i - word.length()]) {
dp[i] = true;
}
}
}
return dp[n];
}
private boolean bottomUpBackward(String s, HashSet<String> dict) {
int n = s.length();
// dp[i] => string from index i to end can be segmented or not?
// For "applepenapple" (length 13):
// dp[13] = true (empty string - base case)
// dp[12] = ? (can segment "e"?)
boolean[] dp = new boolean[n + 1];
dp[n] = true; // an empty string can be always segmented
for (int i = n - 1; i >= 0; i--) {
// We need to check if s[i...end] is in dict.
// dp[12] = "e". Check if this exists in dict.
// Similarly, dp[8] = "apple" needs to be checked in dict.
// If yes, we need to check if dp[13] is also segmented (remaining)
// compare this s[i...end] with all the words in the dict.
for (String word : dict) {
int wordLen = word.length();
if (i + wordLen <= n && s.substring(i, i + wordLen).equals(word) && dp[i + wordLen]) {
dp[i] = true;
break;
}
}
}
return dp[0];
}
private boolean topDown(String s, int start, HashSet<String> dict, Boolean[] memo) {
if (start == s.length()) {
return true;
}
if (memo[start] != null) {
return memo[start];
}
for (int end = start; end < s.length(); end++) {
if (dict.contains(s.substring(start, end + 1))) {
if (topDown(s, end + 1, dict, memo)) {
return memo[start] = true;
}
}
}
return memo[start] = false;
}
private boolean recursive(String s, int start, HashSet<String> dict) {
// step 4: if reached the string end, we are done. Return true.
if (start == s.length()) {
return true;
}
// Step 2: can we segment from start to end (find this part in dict)?
// If yes, start from next index if we can segment from there.
for (int end = start; end < s.length(); end++) {
if (dict.contains(s.substring(start, end + 1))) {
// step 3: try from next index to segment
if (recursive(s, end + 1, dict)) {
return true;
}
}
}
return false;
}
public static void main(String[] args) {
Solution s = new Solution();
System.out.println(s.wordBreak("leetcode", List.of("leet", "code")) == true);
System.out.println(s.wordBreak("applepenapple", List.of("apple", "pen")) == true);
System.out.println(s.wordBreak("catsandog", List.of("cats", "dog", "sand", "and", "cat")) == false);
}
}
š Key Insights
CRITICAL: HashSet Conversion:
Set<String> wordSet = new HashSet<>(wordDict);
WHY THIS MATTERS:
List.contains(): O(k) - checks every word
Set.contains(): O(1) - instant lookup!
Without HashSet: O(n² à k à m)
With HashSet: O(n² à m)
This optimization is ESSENTIAL! š
Word Reuse Allowed:
"applepenapple" with ["apple", "pen"]
ā Use "apple" TWICE! ā
No tracking needed - just keep trying words!
This makes problem simpler than it looks!
Two Valid Directions:
BACKWARD: dp[i] = segment s[i..end]
Base: dp[n] = true
Answer: dp[0]
FORWARD: dp[i] = segment s[0..i-1]
Base: dp[0] = true
Answer: dp[n]
Both work! Forward is more intuitive.
šŖ Memory Aid
"HashSet first, then DP!"
"Words can repeat - no problem!"
"Forward or backward - your choice!"
"Try all splits, remember results!" āØ
ā ļø Common Mistakes
- ā Using List instead of HashSet (super slow!)
- ā Not allowing word reuse
- ā Off-by-one in substring indices
- ā Forgetting base case (dp[0] or dp[n])
- ā Not breaking after finding valid segmentation
ā Interview Talking Points
"This is a string segmentation DP problem.
Intuition:
At each position, ask: Can we match a dictionary word
AND successfully segment what remains?
Approach (Forward DP):
1. Convert dictionary to HashSet (O(1) lookup!)
2. Create dp array: dp[i] = can segment first i chars
3. Base case: dp[0] = true (empty string)
4. For each position i from 1 to n:
- Try all previous positions j
- If dp[j]=true AND s[j..i-1] in dictionary
- Then dp[i] = true
5. Return dp[n]
Key optimizations:
- HashSet for O(1) word lookup (CRITICAL!)
- DP avoids exponential recomputation
- Early break when segmentation found
Complexity: O(n² à m) time, O(n) space
where n=string length, m=avg word length"
š Congratulations!
You've mastered Word Break with the "applepenapple" example!
What You Learned:
ā
String Segmentation - Breaking strings with rules
ā
HashSet Optimization - O(1) lookup is critical
ā
Top-Down vs Bottom-Up - Two valid DP approaches
ā
Forward vs Backward - Different but equivalent
ā
Word Reuse - Simplified problem constraint
ā
Memoization Power - Remember to avoid recomputation
You've mastered a fundamental DP pattern! šāØ