Topics › Dynamic Programming › DP on LIS
Longest String Chain
LeetCode ↗ Java source ↗ Edit this article ↗
Jump to code:
Problem
You are given an array of lowercase words. Word A is a predecessor of word B if you can insert exactly one letter anywhere in A, without reordering the other letters, to get B. For example "abc" is a predecessor of "abac", but "cba" is not a predecessor of "bcad". A word chain is a sequence w1, w2, ..., wk where each word is a predecessor of the next. Using words from the array (each at most once, in any order), return the length of the longest possible chain. A single word is a chain of length 1.
Constraints: 1 ≤ n ≤ 1000, 1 ≤ word length ≤ 16. The Java file returns 0 for an empty array.
Example 1
words = ["a", "b", "ba", "bca", "bda", "bdca"]
answer = 4 // "a" -> "ba" -> "bda" -> "bdca"
Example 2
words = ["xbc", "pcxbcf", "xb", "cxbc", "pcxbc"]
answer = 5 // "xb" -> "xbc" -> "cxbc" -> "pcxbc" -> "pcxbcf"
Each step in a chain adds exactly one letter, so lengths go up by one at every step. That gives the natural processing order: by length. After sorting by length, a chain is a subsequence in which consecutive words satisfy the predecessor relation, which is an LIS with a custom comparison.
Approach 1: Brute force recursion
Intuition. Look at the chain from its end. The word before w in a chain must be w with one letter deleted. A word of length L has only L such deletions, so try each one, and if the shorter word is in the array, recursively find the longest chain ending there. The answer is the best over all possible final words.
Algorithm.
- Put all words in a hash set.
- For each word
w, computechainEndingAt(w): start at1; for every positioni, delete letteri, and if the result is in the set, try1 + chainEndingAt(shorter). - Return the maximum.
static int bruteForce(String[] words) {
Set<String> dict = new HashSet<>(Arrays.asList(words));
int best = 0;
for (String w : words) best = Math.max(best, chainEndingAt(w, dict));
return best;
}
static int chainEndingAt(String w, Set<String> dict) {
int best = 1;
for (int i = 0; i < w.length(); i++) {
String shorter = w.substring(0, i) + w.substring(i + 1); // delete letter i
if (dict.contains(shorter)) best = Math.max(best, 1 + chainEndingAt(shorter, dict));
}
return best;
}
Complexity. Without caching, the same shorter word is re-solved many times. A word of length L can branch up to L ways, then L - 1, and so on, so the worst case is about O(n · L!) string operations. Fine for the tests, hopeless for L = 16.
Approach 2: Better, LIS with a predecessor check
Intuition. Sort the words by length. Now every chain appears in left-to-right order, so define dp[i] = length of the longest chain ending at w[i], exactly like “LIS ending at i”:
dp[i] = 1 + max(dp[j]) over j < i where w[j] is a predecessor of w[i] (or 1)
The only new ingredient is the comparison. isPredecessor(shorter, longer) first checks that the lengths differ by exactly one. Then it walks both strings with two pointers: advance in shorter whenever the letters match, always advance in longer. If all of shorter gets matched, shorter is a subsequence of longer; with lengths differing by one, that means exactly one letter was inserted.
Sorting is essential. Without it, a predecessor might appear after the word it precedes, and the j < i loop would never see it.
Algorithm.
- Sort a copy of the words by length.
- For each
i, setdp[i] = 1, then for eachj < iwithisPredecessor(w[j], w[i]), takedp[j] + 1if larger. - Return the maximum
dp[i].
static int lisStyle(String[] words) {
String[] w = words.clone();
Arrays.sort(w, Comparator.comparingInt(String::length));
int n = w.length, best = 0;
int[] dp = new int[n]; // dp[i] = longest chain ending at w[i]
for (int i = 0; i < n; i++) {
dp[i] = 1;
for (int j = 0; j < i; j++) {
if (isPredecessor(w[j], w[i])) dp[i] = Math.max(dp[i], dp[j] + 1);
}
best = Math.max(best, dp[i]);
}
return best;
}
static boolean isPredecessor(String shorter, String longer) {
if (longer.length() != shorter.length() + 1) return false;
int i = 0; // next letter of shorter to match
for (int j = 0; j < longer.length(); j++) {
if (i < shorter.length() && shorter.charAt(i) == longer.charAt(j)) i++;
}
return i == shorter.length(); // all of shorter matched; one letter of longer skipped
}
Complexity. Time O(n log n + n^2 · L): n^2 pairs, each compared in O(L). Space O(n). For n = 1000 and L = 16 that is about 1.6 × 10^7 character steps, which passes.
Approach 3: Optimal, deletions with a hash map
Intuition. Approach 2 compares each word with every shorter word, but a word of length L has only L possible predecessors: the strings obtained by deleting one letter. Instead of searching for predecessors, generate them and look them up. Process words by increasing length and store longest[word] = longest chain ending at that word. When we reach a word, all its possible predecessors are shorter, so their values are already in the map.
This is the same recurrence as Approach 2, with the inner loop over j replaced by L hash lookups. It is also Approach 1 with memoization, computed bottom-up.
Algorithm.
- Sort a copy of the words by length.
- For each word:
cur = 1; for each positioni, formshorterby deleting letteri; iflongestcontains it,cur = max(cur, longest[shorter] + 1). Storelongest[word] = cur. - Return the largest
cur.
static int hashMapDp(String[] words) {
String[] w = words.clone();
Arrays.sort(w, Comparator.comparingInt(String::length));
Map<String, Integer> longest = new HashMap<>(); // word -> longest chain ending at it
int best = 0;
for (String word : w) {
int cur = 1;
for (int i = 0; i < word.length(); i++) {
String shorter = word.substring(0, i) + word.substring(i + 1);
Integer prev = longest.get(shorter);
if (prev != null) cur = Math.max(cur, prev + 1);
}
longest.put(word, cur);
best = Math.max(best, cur);
}
return best;
}
Dry run on ["a", "b", "ba", "bca", "bda", "bdca"] (already sorted by length):
| word | deletions found in map | longest[word] |
|---|---|---|
| a | (empty string) not present | 1 |
| b | (empty string) not present | 1 |
| ba | “a” → 1, “b” → 1 | 2 |
| bca | “ba” → 2 | 3 |
| bda | “ba” → 2 | 3 |
| bdca | “bca” → 3, “bda” → 3 | 4 |
The answer is 4.
Complexity. Time O(n log n + n · L^2): each word generates L deletions of length L - 1 and hashes them. Space O(n · L) for the map. With L ≤ 16 this is much faster than comparing all pairs.
Edge cases and pitfalls
- Insertion anywhere, order preserved.
"ba"is not a predecessor of"abc": inserting a letter cannot swapbanda. The test["ab", "ba", "abc"]expects2("ab" -> "abc"). - Same letters are not enough.
["abcd", "dbqca"]has a length difference of one, but"abcd"is not a subsequence of"dbqca", so the answer is1. - Length check first.
isPredecessormust reject equal lengths and differences of two or more before running the two pointers; otherwise"ab"and"abcd"would pass. - Repeated letters. For
"aaa", all three deletions produce"aa"; looking it up three times is harmless. - Unsorted input. Always sort by length, since the input order is arbitrary (
["bdca", "bda", "ba", "a"]must still give4). - Do not sort lexicographically. Alphabetical order does not put predecessors first (
"ab"comes before its predecessor"b"); sort by length only. - Empty input. Return
0.
Related problems
- Longest Increasing Subsequence is the template this problem specialises.
- Largest Divisible Subset is another LIS variant that needs a sort before the DP.
- Print Longest Increasing Subsequence shows how to output the chain itself with parent pointers.
- Longest common subsequence uses the same subsequence idea as the two-pointer predecessor check.