251. Longest String Chain
š LeetCode Problem: 1048. Longest String Chain
š Difficulty: Medium
š·ļø Topics: Array, Hash Table, Two Pointers, String, Dynamic Programming, DP - LIS
Problem Statement
You are given an array of words where each word consists of lowercase English letters.
wordA is a predecessor of wordB if and only if we can insert exactly one letter anywhere in wordA without changing the order of the other characters to make it equal to wordB.
- For example,
"abc"is a predecessor of"abac", while"cba"is not a predecessor of"bcad".
A word chain is a sequence of words [word1, word2, ..., wordk] with k >= 1, where word1 is a predecessor of word2, word2 is a predecessor of word3, and so on. A single word is trivially a word chain with k == 1.
Return the length of the longest possible word chain with words chosen from the given list of words.
Example 1:
Input: words = ["a","b","ba","bca","bda","bdca"]
Output: 4
Explanation: One of the longest word chains is ["a","ba","bda","bdca"].
Example 2:
Input: words = ["xbc","pcxbcf","xb","cxbc","pcxbc"]
Output: 5
Explanation: All the words can be put in a word chain ["xb", "xbc", "cxbc", "pcxbc", "pcxbcf"].
Example 3:
Input: words = ["abcd","dbqca"]
Output: 1
Explanation: The trivial word chain ["abcd"] is one of the longest word chains.
["abcd","dbqca"] is not a valid word chain because letters are added more than one position.
Constraints:
- 1 <= words.length <= 1000
- 1 <= words[i].length <= 16
- words[i] only consists of lowercase English letters.
š§ Let's Build Understanding from Absolute Scratch
What Does "Predecessor" Even Mean?
Let me start with the DEFINITION:
wordA is a predecessor of wordB if:
- We can insert EXACTLY ONE letter into wordA
- Without changing order of other characters
- To make it equal to wordB
Simple examples:
Is "a" a predecessor of "ba"?
"a" ā insert 'b' at front ā "ba" ā
YES! ā
Is "a" a predecessor of "ab"?
"a" ā insert 'b' at end ā "ab" ā
YES! ā
Is "ab" a predecessor of "abc"?
"ab" ā insert 'c' at end ā "abc" ā
YES! ā
Is "ab" a predecessor of "aXb"?
"ab" ā insert 'X' in middle ā "aXb" ā
YES! ā
Is "abc" a predecessor of "abcd"?
"abc" ā insert 'd' at end ā "abcd" ā
YES! ā
Is "abc" a predecessor of "abdc"?
"abc" ā insert 'd' between b and c ā "abdc" ā
YES! ā
Is "abc" a predecessor of "bac"?
Can we get "bac" by inserting ONE letter into "abc"?
NO! The order changed! ā
NO! ā
KEY INSIGHT:
- wordB must be EXACTLY 1 character longer!
- Characters must stay in SAME order!
- We just INSERT one new character! š
Understanding "Word Chain"
What's a word chain?
It's a sequence where each word is predecessor of next!
Example: ["a", "ba", "bda", "bdca"]
Check each link:
"a" ā "ba"?
Insert 'b' at front ā
"ba" ā "bda"?
Insert 'd' in middle ā
"bda" ā "bdca"?
Insert 'c' between d and a ā
All links work! This is a valid chain of length 4! ā
Another example: ["a", "ab", "abc", "abcd"]
"a" ā "ab": insert 'b' ā
"ab" ā "abc": insert 'c' ā
"abc" ā "abcd": insert 'd' ā
Valid chain of length 4! ā
Invalid example: ["a", "ba", "abc"]
"a" ā "ba": insert 'b' ā
"ba" ā "abc"?
"ba" has length 2
"abc" has length 3
Insert one character: "ba" ā "bXa" or "Xba" or "baX"
Can we get "abc"?
"bac"? NO!
"aba"? NO!
"baa"? NO!
NO! ā
Not a valid chain! ā
The problem: Find the LONGEST chain! š
What Makes This Feel Like LIS?
Wait! This looks familiar!
LONGEST INCREASING SUBSEQUENCE:
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Array: [10, 9, 2, 5, 3, 7]
Question: Find longest chain where each > previous
Answer: [2, 3, 7] ā length 3
Condition: nums[j] < nums[i]
LONGEST STRING CHAIN:
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Words: ["a", "b", "ba", "bca", "bda", "bdca"]
Question: Find longest chain where each is predecessor of next
Answer: ["a", "ba", "bda", "bdca"] ā length 4
Condition: wordA is predecessor of wordB
SAME PATTERN!
- Looking for longest chain
- Each element extends previous
- Different condition!
LIS: nums[j] < nums[i]
This: isPredecessor(wordA, wordB)
It's LIS with a DIFFERENT comparison function! š
First Intuition - How To Check Predecessor?
Given two words, how do I check if wordA is predecessor of wordB?
Requirements:
1. wordB.length == wordA.length + 1 (exactly 1 longer)
2. Removing ONE character from wordB gives wordA
Let me think about removing a character:
wordB = "abc"
Remove index 0: "bc"
Remove index 1: "ac"
Remove index 2: "ab"
So for "abc", possible predecessors are: "bc", "ac", "ab"
If wordA is any of these, it's a predecessor! ā
Example: Is "ab" predecessor of "abc"?
wordB = "abc"
Remove last character: "ab" ā
Matches wordA! ā
YES! ā
Example: Is "ba" predecessor of "abc"?
wordB = "abc"
Possible by removing one: "bc", "ac", "ab"
"ba" in this list? NO! ā
NO! ā
Algorithm:
For each position in wordB:
Remove that character
Check if result equals wordA
If yes ā wordA is predecessor! ā
š¤ Building Intuition - The Complete Mental Model
Why Order Matters - Sorting Strategy
Unlike regular LIS with numbers where we sort numerically,
here we need to think: How should we sort STRINGS?
Key insight: If "a" ā "ab" ā "abc"
Length: 1 ā 2 ā 3
Chains ALWAYS go from SHORTER to LONGER! š
Why?
Because we INSERT characters!
Inserting makes words longer!
So: Sort by LENGTH! ā
Example:
words = ["abc", "a", "ab", "abcd"]
After sorting by length:
["a", "ab", "abc", "abcd"]
ā ā ā ā
len1 len2 len3 len4
Now we can build chains left to right! ā
Working Through Simple Example
words = ["a", "b", "ba"]
STEP 1: Sort by length
["a", "b", "ba"]
Already sorted! ā
STEP 2: Apply LIS logic
dp[i] = length of longest chain ending at word i
Initialize:
dp = [1, 1, 1] (each word alone)
Process index 0 ("a"):
No previous words
dp[0] = 1
Process index 1 ("b"):
Check index 0 ("a"):
Is "a" predecessor of "b"?
"b" has length 1, "a" has length 1
NOT exactly 1 longer! ā
Can't extend!
dp[1] = 1
Process index 2 ("ba"):
Check index 0 ("a"):
Is "a" predecessor of "ba"?
"ba" length 2, "a" length 1 ā (exactly 1 longer)
Remove from "ba": "b" or "a"
"a" matches! ā
Can extend! dp[2] = dp[0] + 1 = 2
Check index 1 ("b"):
Is "b" predecessor of "ba"?
"ba" length 2, "b" length 1 ā
Remove from "ba": "b" or "a"
"b" matches! ā
Can extend! dp[2] = max(2, dp[1] + 1) = 2
Final: dp = [1, 1, 2]
Maximum: 2
Chains of length 2:
["a", "ba"] or ["b", "ba"]
Understanding The Predecessor Check
Let me trace isPredecessor("a", "ba") step by step:
wordA = "a" (length 1)
wordB = "ba" (length 2)
Step 1: Check lengths
wordB.length == wordA.length + 1?
2 == 1 + 1? YES! ā
Step 2: Try removing each character from wordB
Remove index 0 ('b'): "ba" ā "a"
Does "a" == wordA ("a")? YES! ā
Found it! Return true! ā
So "a" IS predecessor of "ba"! ā
Let me trace isPredecessor("ab", "abc"):
wordA = "ab" (length 2)
wordB = "abc" (length 3)
Step 1: Lengths
3 == 2 + 1? YES! ā
Step 2: Remove each character
Remove index 0 ('a'): "abc" ā "bc"
"bc" == "ab"? NO!
Remove index 1 ('b'): "abc" ā "ac"
"ac" == "ab"? NO!
Remove index 2 ('c'): "abc" ā "ab"
"ab" == "ab"? YES! ā
Found it! ā
So "ab" IS predecessor of "abc"! ā
Complete Medium Example
words = ["a", "b", "ba", "bca", "bda", "bdca"]
STEP 1: Sort by length
["a", "b", "ba", "bca", "bda", "bdca"]
len1 len1 len2 len3 len3 len4
STEP 2: Build DP
Initialize:
dp = [1, 1, 1, 1, 1, 1]
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
INDEX 0: "a" (length 1)
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
No previous words.
dp[0] = 1
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
INDEX 1: "b" (length 1)
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Check j=0 ("a"):
Same length (1 == 1) ā
Can't be predecessor!
dp[1] = 1
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
INDEX 2: "ba" (length 2)
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Check j=0 ("a"):
2 == 1 + 1? YES! ā
Remove from "ba":
"b" == "a"? NO
"a" == "a"? YES! ā
Can extend!
dp[2] = dp[0] + 1 = 2
Check j=1 ("b"):
2 == 1 + 1? YES! ā
Remove from "ba":
"b" == "b"? YES! ā
Can extend!
dp[2] = max(2, dp[1] + 1) = 2
dp[2] = 2
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
INDEX 3: "bca" (length 3)
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Check j=0 ("a"): length 1, not 2 ā
Check j=1 ("b"): length 1, not 2 ā
Check j=2 ("ba"):
3 == 2 + 1? YES! ā
Remove from "bca":
"ca" == "ba"? NO
"ba" == "ba"? YES! ā
Can extend!
dp[3] = dp[2] + 1 = 3
dp[3] = 3
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
INDEX 4: "bda" (length 3)
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Check j=0 ("a"): length 1, not 2 ā
Check j=1 ("b"): length 1, not 2 ā
Check j=2 ("ba"):
3 == 2 + 1? YES! ā
Remove from "bda":
"da" == "ba"? NO
"ba" == "ba"? YES! ā
Can extend!
dp[4] = dp[2] + 1 = 3
Check j=3 ("bca"):
Same length (3 == 3) ā
dp[4] = 3
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
INDEX 5: "bdca" (length 4)
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Check j=0,1,2: lengths 1,1,2, not 3 ā
Check j=3 ("bca"):
4 == 3 + 1? YES! ā
Remove from "bdca":
"dca" == "bca"? NO
"bca" == "bca"? YES! ā
Can extend!
dp[5] = dp[3] + 1 = 4
Check j=4 ("bda"):
4 == 3 + 1? YES! ā
Remove from "bdca":
"dca" == "bda"? NO
"bca" == "bda"? NO
"bda" == "bda"? YES! ā
Can extend!
dp[5] = max(4, dp[4] + 1) = 4
dp[5] = 4
Final: dp = [1, 1, 2, 3, 3, 4]
Maximum: 4 ā
The chain: ["a", "ba", "bda", "bdca"]
or: ["a", "ba", "bca", "bdca"]
š“ Approach 1: Brute Force - Explore All Possible Chains (DFS)
The Recursive Idea
To find the longest chain, we need to try ALL possibilities:
1. Start a chain from each word
2. From current word, try extending to EVERY longer word
3. Recursively explore each valid extension
4. Track the maximum chain length found
This is NOT greedy - it explores EVERY possible path! ā
Complete DFS Implementation
class Solution {
private int maxLen = 0;
public int longestStrChain(String[] words) {
// Sort by length first
Arrays.sort(words, (a, b) -> a.length() - b.length());
// Try starting a chain from each word
for (int i = 0; i < words.length; i++) {
dfs(words, i, 1);
}
return maxLen;
}
private void dfs(String[] words, int currentIndex, int chainLength) {
// Update maximum length found
maxLen = Math.max(maxLen, chainLength);
// Try extending to each word that comes after current
for (int i = currentIndex + 1; i < words.length; i++) {
if (isPredecessor(words[currentIndex], words[i])) {
// Recursively explore this extension
dfs(words, i, chainLength + 1);
}
}
// When no more extensions possible, recursion returns
}
private boolean isPredecessor(String wordA, String wordB) {
if (wordB.length() != wordA.length() + 1) {
return false;
}
for (int i = 0; i < wordB.length(); i++) {
String removed = wordB.substring(0, i) + wordB.substring(i + 1);
if (removed.equals(wordA)) {
return true;
}
}
return false;
}
}
Trace Through Your Test Case
words = ["a", "ab", "ac", "bd", "abc", "abd", "abdd"]
After sort: same order
Starting from i=0 ("a"):
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
dfs("a", index=0, len=1)
maxLen = 1
Try extending to i=1 ("ab"):
isPredecessor("a", "ab")? YES! ā
dfs("ab", index=1, len=2)
maxLen = 2
Try extending to i=4 ("abc"):
isPredecessor("ab", "abc")? YES! ā
dfs("abc", index=4, len=3)
maxLen = 3
Try i=5,6: no extensions
Return
Try extending to i=5 ("abd"):
isPredecessor("ab", "abd")? YES! ā
dfs("abd", index=5, len=3)
maxLen = 3
Try extending to i=6 ("abdd"):
isPredecessor("abd", "abdd")? YES! ā
dfs("abdd", index=6, len=4)
maxLen = 4 āāā FOUND!
No more words to extend
Return
Return
Return
Try extending to i=2 ("ac"):
... explores this path too ...
Return
... continues from other starting points ...
Final: maxLen = 4 ā
The chain ["a", "ab", "abd", "abdd"] is discovered! ā
Understanding Why DFS Works
DFS is different from greedy!
GREEDY (wrong):
At each step, take FIRST valid extension
Example: "ab" ā sees "abc" first ā takes it
Blocked from optimal path! ā
DFS (correct):
At each step, try ALL valid extensions
Example: "ab" ā tries BOTH "abc" AND "abd"
Explores both paths:
Path 1: "a" ā "ab" ā "abc" (length 3)
Path 2: "a" ā "ab" ā "abd" ā "abdd" (length 4) ā
Finds optimal! ā
DFS explores the ENTIRE search space! š
Why This Is Too Slow
This approach CORRECTLY finds the answer! ā
BUT it's SLOW! ā
TIME COMPLEXITY:
Consider worst case: all words form valid chains
Example: ["a", "ab", "abc", "abcd", ...]
From "a", we try extending to all n-1 words
From "ab", we try extending to all remaining
From "abc", we try extending to all remaining
... and so on
This creates an exponential search tree!
For each isPredecessor call: O(L)
Total: O(2^n Ć L) worst case ā
For n = 1000:
2^1000 possibilities - IMPOSSIBLE! ā
SPACE COMPLEXITY:
Recursion stack: O(n) in worst case
(if chain includes all words)
Verdict:
ā CORRECT - explores all possibilities
ā TOO SLOW - exponential time
We need to avoid recalculating subproblems!
ā Dynamic Programming! ā
š¢ Approach 2: Dynamic Programming - Avoiding Repeated Work
The Key Insight - Overlapping Subproblems
In DFS, we recalculate the same thing multiple times!
Example:
Path 1: "a" ā "ab" ā "abd"
Need: longest chain ending at "ab"
Path 2: Start from "ab" ā "abd"
Need: longest chain ending at "ab" (SAME!)
DFS recalculates! ā
DP stores once, reuses! ā
This is why DP is faster! š
The DP Solution - Array Approach
Key insight from LIS pattern:
dp[i] = longest chain ending at word i
For each word i:
Check ALL previous words j < i
If words[j] is predecessor of words[i]:
dp[i] = max(dp[i], dp[j] + 1)
Same structure as LIS! ā
Complete Implementation
class Solution {
public int longestStrChain(String[] words) {
// STEP 1: Sort by length (shorter words first)
Arrays.sort(words, (a, b) -> a.length() - b.length());
int n = words.length;
// STEP 2: DP array
int[] dp = new int[n];
Arrays.fill(dp, 1); // Each word alone is chain of length 1
int maxChain = 1;
// STEP 3: Fill DP array
for (int i = 1; i < n; i++) {
for (int j = 0; j < i; j++) {
// Can we extend chain ending at j with word i?
if (isPredecessor(words[j], words[i])) {
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
maxChain = Math.max(maxChain, dp[i]);
}
return maxChain;
}
private boolean isPredecessor(String wordA, String wordB) {
// wordA must be exactly 1 character shorter
if (wordB.length() != wordA.length() + 1) {
return false;
}
// Try removing each character from wordB
for (int i = 0; i < wordB.length(); i++) {
// Create string with i-th character removed
String removed = wordB.substring(0, i) + wordB.substring(i + 1);
if (removed.equals(wordA)) {
return true;
}
}
return false;
}
}
Understanding isPredecessor In Detail
private boolean isPredecessor(String wordA, String wordB) {
// Example: wordA = "ab", wordB = "abc"
// Check: "abc".length() == "ab".length() + 1?
// 3 == 2 + 1? YES! ā
if (wordB.length() != wordA.length() + 1) {
return false;
}
// Try removing each position from "abc"
for (int i = 0; i < wordB.length(); i++) {
// i=0: remove 'a' ā "bc"
// "bc" == "ab"? NO
// i=1: remove 'b' ā "ac"
// "ac" == "ab"? NO
// i=2: remove 'c' ā "ab"
// "ab" == "ab"? YES! ā
// Return true!
String removed = wordB.substring(0, i) + wordB.substring(i + 1);
if (removed.equals(wordA)) {
return true;
}
}
return false;
}
Why This Works
Example: words = ["a", "ab", "abd", "abdd"]
After sort: ["a", "ab", "abd", "abdd"]
Initialize:
dp = [1, 1, 1, 1]
Process "a" (i=0):
No previous words
dp[0] = 1
Process "ab" (i=1):
Check j=0 ("a"):
isPredecessor("a", "ab")? YES! ā
dp[1] = max(1, dp[0] + 1) = 2
Process "abd" (i=2):
Check j=0 ("a"):
isPredecessor("a", "abd")? NO (length 1 vs 3)
Check j=1 ("ab"):
isPredecessor("ab", "abd")? YES! ā
dp[2] = max(1, dp[1] + 1) = 3
Process "abdd" (i=3):
Check j=0 ("a"): NO (length mismatch)
Check j=1 ("ab"): NO (length 2 vs 4)
Check j=2 ("abd"):
isPredecessor("abd", "abdd")? YES! ā
dp[3] = max(1, dp[2] + 1) = 4
Final: dp = [1, 2, 3, 4]
maxChain = 4 ā
The chain: ["a", "ab", "abd", "abdd"]
DP considers ALL possibilities! ā
š” Approach 3: Optimization - Discovering The HashMap Idea
Let's Think About The Array DP Problem
In Approach 2, for each word at position i:
We check ALL j < i to see if words[j] is a predecessor
Example: words = ["a", "b", "ba", "bca", "bda", "bdca"]
After sort: same order
When processing "bdca" (index 5):
Check "a": is "a" predecessor of "bdca"?
Length 1 vs 4 ā NO
Check "b": is "b" predecessor of "bdca"?
Length 1 vs 4 ā NO
Check "ba": is "ba" predecessor of "bdca"?
Length 2 vs 4 ā NO
Check "bca": is "bca" predecessor of "bdca"?
Length 3 vs 4 ā YES! Check if predecessor...
Check "bda": is "bda" predecessor of "bdca"?
Length 3 vs 4 ā YES! Check if predecessor...
We checked 5 words! But only 2 could possibly work! ā
Can we do better? š¤
The Key Observation - Reversing The Question
Instead of asking:
"Is words[j] a predecessor of words[i]?"
ā Check many words ā
What if we ask:
"What ARE the predecessors of words[i]?"
ā Generate just the valid ones! ā
Let me think about "bdca":
If something is a predecessor, it means:
I can insert ONE character into it to get "bdca"
Reverse thinking:
Remove ONE character from "bdca" to get predecessor!
What can I remove from "bdca"?
Remove 'b': "dca"
Remove 'd': "bca" ā (this might exist!)
Remove 'c': "bda" ā (this might exist!)
Remove 'a': "bdc"
Only 4 possibilities! Not 5+ words to check! š
Now the question becomes:
Do any of ["dca", "bca", "bda", "bdc"] exist in our word list?
If I had a FAST way to check existence... like a HashMap! ā
Building The Intuition Step By Step
Let me work through an example manually:
words = ["a", "ba", "bda"]
The OLD way (Array DP):
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Process "a":
No previous words
dp[0] = 1
Process "ba":
Check j=0: is "a" predecessor? YES!
dp[1] = dp[0] + 1 = 2
Process "bda":
Check j=0: is "a" predecessor? NO (length mismatch)
Check j=1: is "ba" predecessor? YES!
dp[2] = dp[1] + 1 = 3
Total checks: 3
The NEW way (Generate predecessors):
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Process "a":
What are predecessors of "a"?
Remove index 0: "" (empty)
Does "" exist? NO
So longest = 0
dp["a"] = 0 + 1 = 1
Process "ba":
What are predecessors of "ba"?
Remove index 0: "a"
Remove index 1: "b"
Does "a" exist? YES! dp["a"] = 1
Does "b" exist? NO
So longest = 1
dp["ba"] = 1 + 1 = 2
Process "bda":
What are predecessors of "bda"?
Remove index 0: "da"
Remove index 1: "ba"
Remove index 2: "bd"
Does "da" exist? NO
Does "ba" exist? YES! dp["ba"] = 2
Does "bd" exist? NO
So longest = 2
dp["bda"] = 2 + 1 = 3
Total checks: 6 (but against HashMap, not array!)
Same answer! ā
Why HashMap Is Better - The Math
Let's compare:
Array DP Approach:
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
For word at position i:
- Must check ALL j < i words
- In worst case: i words to check
- Average: n/2 words to check
For each check: O(L) to verify predecessor
Total per word: O(n Ć L)
Total for all: O(n² à L)
Generate Predecessors Approach:
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
For word of length L:
- Generate L predecessors (remove each position)
- Each generation: O(L) to create string
- Check existence in HashMap: O(L) to hash string
Total per word: O(L à L) = O(L²)
Total for all: O(n à L²)
COMPARISON:
When is O(n à L²) better than O(n² à L)?
Divide both by n Ć L:
O(L) vs O(n)
When L < n, second is better!
For this problem:
L ⤠16 (max word length)
n ⤠1000 (max words)
16 < 1000! ā
HashMap approach is better! š
Discovering The Algorithm Naturally
So the algorithm becomes:
1. Still need to sort by length (shorter first)
Why? To process shorter words before longer ones!
2. Use HashMap instead of array
Key: the word itself
Value: longest chain ending at this word
3. For each word:
a) Generate ALL possible predecessors
(remove each character position)
b) Check if each predecessor exists in HashMap
c) Take the MAXIMUM chain length found
d) Store current word with (max + 1)
Let me trace this completely!
Complete Trace With HashMap
words = ["a", "ba", "bda", "bdca"]
After sort: same
HashMap = {} (empty initially)
maxChain = 0
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
PROCESS: "a"
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Generate predecessors by removing each position:
Position 0: "a"[0..0] + "a"[1..] = "" + "" = ""
Check HashMap:
Does "" exist? NO
longest = 0
Store result:
HashMap["a"] = 0 + 1 = 1
maxChain = max(0, 1) = 1
State: HashMap = {"a": 1}
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
PROCESS: "ba"
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Generate predecessors:
Position 0: "ba"[0..0] + "ba"[1..] = "" + "a" = "a"
Position 1: "ba"[0..1] + "ba"[2..] = "b" + "" = "b"
Check HashMap:
Does "a" exist? YES! HashMap["a"] = 1 ā
Does "b" exist? NO
longest = max(1, 0) = 1
Store result:
HashMap["ba"] = 1 + 1 = 2
maxChain = max(1, 2) = 2
State: HashMap = {"a": 1, "ba": 2}
Why this works:
"a" is predecessor of "ba"
"a" has chain length 1
So "ba" has chain length 2! ā
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
PROCESS: "bda"
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Generate predecessors:
Position 0: "bda"[0..0] + "bda"[1..] = "" + "da" = "da"
Position 1: "bda"[0..1] + "bda"[2..] = "b" + "a" = "ba"
Position 2: "bda"[0..2] + "bda"[3..] = "bd" + "" = "bd"
Check HashMap:
Does "da" exist? NO
Does "ba" exist? YES! HashMap["ba"] = 2 ā
Does "bd" exist? NO
longest = max(0, 2, 0) = 2
Store result:
HashMap["bda"] = 2 + 1 = 3
maxChain = max(2, 3) = 3
State: HashMap = {"a": 1, "ba": 2, "bda": 3}
Why this works:
"ba" is predecessor of "bda"
"ba" has chain length 2
So "bda" has chain length 3! ā
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
PROCESS: "bdca"
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Generate predecessors:
Position 0: "" + "dca" = "dca"
Position 1: "b" + "ca" = "bca"
Position 2: "bd" + "a" = "bda"
Position 3: "bdc" + "" = "bdc"
Check HashMap:
Does "dca" exist? NO
Does "bca" exist? NO
Does "bda" exist? YES! HashMap["bda"] = 3 ā
Does "bdc" exist? NO
longest = max(0, 0, 3, 0) = 3
Store result:
HashMap["bdca"] = 3 + 1 = 4
maxChain = max(3, 4) = 4
State: HashMap = {"a": 1, "ba": 2, "bda": 3, "bdca": 4}
Why this works:
"bda" is predecessor of "bdca"
"bda" has chain length 3
So "bdca" has chain length 4! ā
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
FINAL ANSWER: maxChain = 4 ā
The chain: "a" ā "ba" ā "bda" ā "bdca"
The "Aha!" Moment
Array DP says:
"Let me check if ANY of these words is my predecessor"
ā Check many words ā
HashMap DP says:
"Let me generate MY predecessors and see if they exist"
ā Generate few, check fast! ā
It's like:
ARRAY DP (slow):
Me: "Is anyone here my parent?"
Check 1000 people one by one... ā
HASHMAP DP (fast):
Me: "My parent's name is one of these 5 names"
Look up in phone book... ā
Different question ā Different efficiency! š
Now The Code Makes Perfect Sense
public int longestStrChain(String[] words) {
// Sort by length (process shorter first)
Arrays.sort(words, (a, b) -> a.length() - b.length());
// HashMap: word ā longest chain ending at this word
Map<String, Integer> dp = new HashMap<>();
int maxChain = 1;
for (String word : words) {
int longest = 0;
// Generate ALL possible predecessors
for (int i = 0; i < word.length(); i++) {
// Remove character at position i
String predecessor = word.substring(0, i) + word.substring(i + 1);
// Check if this predecessor exists
// If yes, get its chain length
longest = Math.max(longest, dp.getOrDefault(predecessor, 0));
}
// Current word extends the best predecessor found
dp.put(word, longest + 1);
maxChain = Math.max(maxChain, longest + 1);
}
return maxChain;
}
Every line now makes sense! ā
### Understanding getOrDefault
It says: "Give me the value for this key If key doesn't exist, give me 0 instead"
Why 0? If predecessor doesn't exist in HashMap: It means we never processed it So its chain length is 0 Adding 1 gives us chain length 1 (just current word)
Example: word = "ba" predecessor = "b"
If "b" not in HashMap: dp.getOrDefault("b", 0) returns 0 longest = 0 dp["ba"] = 0 + 1 = 1 ā
If "b" exists with dp["b"] = 2: dp.getOrDefault("b", 0) returns 2 longest = 2 dp["ba"] = 2 + 1 = 3 ā
It handles both cases elegantly! ā
---
## š Complex Example
### Example: words = ["xbc","pcxbcf","xb","cxbc","pcxbc"]
STEP 2: Process with HashMap
Process "xb" (length 2): Predecessors: "b", "x" None exist in dp dp["xb"] = 1
Process "xbc" (length 3): Predecessors: "bc", "xc", "xb" dp["xb"] = 1 ā dp["xbc"] = 2
Process "cxbc" (length 4): Predecessors: "xbc", "cbc", "cxc", "cxb" dp["xbc"] = 2 ā dp["cxbc"] = 3
Process "pcxbc" (length 5): Predecessors: "cxbc", "pxbc", "pcbc", "pcxc", "pcxb" dp["cxbc"] = 3 ā dp["pcxbc"] = 4
Process "pcxbcf" (length 6): Predecessors: "cxbcf", "pxbcf", "pcbcf", "pcxcf", "pcxbf", "pcxbc" dp["pcxbc"] = 4 ā dp["pcxbcf"] = 5
Answer: 5 ā
The chain: ["xb", "xbc", "cxbc", "pcxbc", "pcxbcf"]
---
## š Complexity Analysis
### All Approaches Compared
n = number of words L = maximum word length (⤠16)
### Detailed Time Complexity
Sorting: O(n log n Ć L) DFS: Try starting from each word: n iterations At each level, try extending to remaining words Creates exponential search tree
For each isPredecessor: O(L)
Total: O(2^n Ć L) ā
For n = 50: 2^50 = way too slow! ā
APPROACH 2: Array DP āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Sorting: O(n log n Ć L) for string comparison DP: Outer loop: n iterations Inner loop: up to n iterations isPredecessor: O(L) for each check
Total: O(n log n à L) + O(n² à L) = O(n² à L)
For n=1000, L=16: 16,000,000 operations ā
APPROACH 3: HashMap DP āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Sorting: O(n log n Ć L) DP: For each word: n iterations Generate predecessors: L iterations String operations: O(L) per predecessor HashMap lookup: O(L) for string hashing
Total: O(n log n à L) + O(n à L à L) = O(n à L²)
For n=1000, L=16: 256,000 operations ā
62x faster than Approach 2! š
### Space Complexity
APPROACH 2: dp array: O(n) Total: O(n)
APPROACH 3: HashMap: O(n) entries Each entry: word (O(L)) + count (O(1)) Total: O(n Ć L)
Slightly more space but negligible! ā
---
## š» Complete Working Code
```java
class Solution {
// APPROACH 2: Array DP
public int longestStrChain_ArrayDP(String[] words) {
Arrays.sort(words, (a, b) -> a.length() - b.length());
int n = words.length;
int[] dp = new int[n];
Arrays.fill(dp, 1);
int maxChain = 1;
for (int i = 1; i < n; i++) {
for (int j = 0; j < i; j++) {
if (isPredecessor(words[j], words[i])) {
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
maxChain = Math.max(maxChain, dp[i]);
}
return maxChain;
}
private boolean isPredecessor(String wordA, String wordB) {
if (wordB.length() != wordA.length() + 1) {
return false;
}
for (int i = 0; i < wordB.length(); i++) {
String removed = wordB.substring(0, i) + wordB.substring(i + 1);
if (removed.equals(wordA)) {
return true;
}
}
return false;
}
// APPROACH 3: HashMap DP (OPTIMAL!)
public int longestStrChain(String[] words) {
Arrays.sort(words, (a, b) -> a.length() - b.length());
Map<String, Integer> dp = new HashMap<>();
int maxChain = 1;
for (String word : words) {
int longest = 0;
// Generate all possible predecessors
for (int i = 0; i < word.length(); i++) {
String prev = word.substring(0, i) + word.substring(i + 1);
longest = Math.max(longest, dp.getOrDefault(prev, 0));
}
dp.put(word, longest + 1);
maxChain = Math.max(maxChain, longest + 1);
}
return maxChain;
}
public static void main(String[] args) {
Solution sol = new Solution();
System.out.println(sol.longestStrChain(new String[] {"a", "b", "ba", "bca", "bda", "bdca"})); // 4
System.out.println(sol.longestStrChain(new String[] {"xbc", "pcxbcf", "xb", "cxbc", "pcxbc"})); // 5
System.out.println(sol.longestStrChain(new String[] {"abcd", "dbqca"})); // 1
System.out.println(sol.longestStrChain(new String[] {"a", "ab", "abd", "abdd"})); // 4
}
}
š Key Insights Summary
The Learning Journey
CRAWL (Understanding):
ā What is a predecessor? (insert exactly 1 char)
ā What is a word chain?
ā Connection to LIS pattern
ā Why sort by length?
WALK (Building):
ā How to check isPredecessor
ā Simple example traced completely
ā Medium example with all indices
ā Understanding DP state
RUN (Mastery):
ā Array DP solution
ā HashMap optimization
ā Why HashMap is faster
ā Complex example traced
ā Complete implementations
Natural baby-to-expert progression! šÆ
The Core Understanding
1. WHY sort by length?
Chains go SHORT ā LONG
Inserting makes words longer!
2. WHAT makes this LIS?
Same structure: longest chain
Different condition: predecessor vs <
3. HOW to check predecessor?
wordB exactly 1 longer
Remove each char from wordB
Check if any equals wordA
4. WHEN is HashMap better?
Generate predecessors instead of check all
O(L) predecessors vs O(n) previous words
When L << n: HashMap wins!
5. WHERE's the optimization?
Array: Check ALL previous O(n)
HashMap: Generate L predecessors, check existence O(1)
Key insight! š
Mental Model
Think of building chains like stacking blocks:
Foundation: Shortest words
"a", "b"
First level: Add 1 character
"ab", "ba"
Second level: Add 1 more
"abc", "bda"
Third level: Keep building
"abcd", "bdca"
Each level MUST come from previous level!
Sort by length ensures we build bottom-up! ā
š Quick Revision
šÆ Core Concept
LIS variant on strings
Predecessor = insert exactly 1 character
Sort by length first (SHORT ā LONG)
ā” Algorithm (HashMap - Optimal)
Sort words by length
For each word:
longest = 0
For each position i in word:
predecessor = remove char at i
longest = max(longest, dp[predecessor])
dp[word] = longest + 1
maxChain = max(maxChain, longest + 1)
Return maxChain
š Quick Implementation
import java.util.Arrays;
import java.util.HashMap;
public class Solution {
int maxLen = 0;
public int longestStrChain(String[] words) {
maxLen = 0;
// return dfs(words); // bruteforce
// This is enough. No need of hashing I feel
// return bottomUp(words);
return hashing(words);
}
private int hashing(String[] words) {
// step 0: sort the words in the ascending order of their increasing lengths
Arrays.sort(words, (a, b) -> a.length() - b.length());
int len = words.length;
// step 1: same as earlier DP LIS problems
// instead of array earlier, use hashmap now
HashMap<String, Integer> map = new HashMap<>();
int res = 1;
// step 2: same as earlier DP LIS problems except with some condition
for (int i = 0; i < len; i++) {
String curr = words[i];
int currChainLen = 0;
// step 2: remove every character from curr and check if its in hashmap.
// if its hashmap, update the chain length.
for (int j = 0; j < curr.length(); j++) {
String removed = curr.substring(0, j) + curr.substring(j + 1);
if (map.containsKey(removed)) {
currChainLen = Math.max(currChainLen, map.get(removed));
}
}
// Step 3: We stores every words[i] in hashmap with the result.
map.put(curr, currChainLen + 1);
// step 4: update res
res = Math.max(currChainLen + 1, res);
}
return res;
}
private int bottomUp(String[] words) {
// step 0: sort the words in the ascending order of their increasing lengths
Arrays.sort(words, (a, b) -> a.length() - b.length());
int len = words.length;
int[] dp = new int[len];
// step 1: same as earlier DP LIS problems
Arrays.fill(dp, 1);
int res = 1;
// step 2: same as earlier DP LIS problems except with some condition
for (int i = 1; i < len; i++) {
for (int j = 0; j < i; j++) {
if (isPredecessor(words[j], words[i])) {
dp[i] = Math.max(dp[i], 1 + dp[j]);
}
}
res = Math.max(res, dp[i]);
}
return res;
}
private int dfs(String[] words) {
// step 0: sort the words in the ascending order of their increasing lengths
Arrays.sort(words, (a, b) -> a.length() - b.length());
// Step 1: Do DFS start from every node
for (int i = 0; i < words.length; i++) {
dfsUtil(words, i, 1);
}
return maxLen;
}
private void dfsUtil(String[] words, int curr, int currChainLen) {
// step 3: update maxlen
maxLen = Math.max(currChainLen, maxLen);
// step 4: base case
if (curr == words.length) {
return;
}
// step 2: start from next node and keep on doing.
// while calling, increase the length of the current chain.
for (int i = curr + 1; i < words.length; i++) {
if (isPredecessor(words[curr], words[i])) {
dfsUtil(words, i, currChainLen + 1);
}
}
}
private boolean isPredecessor(String wordA, String wordB) {
// check if s1 is a predecessor of s2.
// Means remove every character of s2 and compare if its equal to s1.
if (wordA.length() + 1 != wordB.length()) {
return false;
}
for (int i = 0; i < wordB.length(); i++) {
String removed = wordB.substring(0, i) + wordB.substring(i + 1);
if (removed.equals(wordA)) {
return true;
}
}
return false;
}
public static void main(String[] args) {
Solution s = new Solution();
System.out.println(s.longestStrChain(new String[] { "a", "b", "ba", "bca", "bda", "bdca" }) == 4);
System.out.println(s.longestStrChain(new String[] { "xbc", "pcxbcf", "xb",
"cxbc", "pcxbc" }) == 5);
System.out.println(s.longestStrChain(new String[] { "abcd", "dbqca" }) == 1);
System.out.println(s.longestStrChain(new String[] { "a", "ab", "ac", "bd", "abc", "abd", "abdd" }) == 4);
}
}
š Key Patterns
Predecessor check:
- wordB.length == wordA.length + 1
- Remove each char from wordB
- Check if any equals wordA
HashMap optimization:
- Generate predecessors (O(L))
- Check existence (O(1))
- Faster than checking all previous (O(n))
šŖ Memory Aid
"Sort short to long"
"Remove one, check match"
"HashMap beats array when L << n"
Complexity: O(n à L²) time, O(n) space
š Congratulations!
You've mastered through baby steps: - ā CRAWL: Understanding predecessor concept - ā WALK: Building DP solution - ā RUN: HashMap optimization - ā WHY sort by length - ā HOW to check predecessor efficiently - ā Complete examples traced - ā Two complete implementations - ā True comprehensive understanding
You've learned LIS on strings with optimization using true textbook-style baby-to-expert learning! šāØ
KEY TAKEAWAY: When checking relationships between elements is expensive (O(L)), consider GENERATING candidates instead and using HashMap for O(1) lookup! This transforms O(n²ĆL) to O(nĆL²)! š