LeetCode 2370 - Longest Ideal Subsequence
In this problem, we are given a string s containing only lowercase English letters and an integer k. We need to find the length of the longest subsequence such that the difference between every pair of adjacent characters in that subsequence is at most k.
Difficulty: 🟡 Medium
Topics: Hash Table, String, Dynamic Programming
Solution
Problem Understanding
In this problem, we are given a string s containing only lowercase English letters and an integer k. We need to find the length of the longest subsequence such that the difference between every pair of adjacent characters in that subsequence is at most k.
A subsequence does not require characters to be contiguous. We are allowed to skip characters while preserving the original order. For example, "acbd" is a subsequence of "acfgbd" because we can delete 'f' and 'g' while keeping the remaining characters in order.
The important condition is the adjacency rule inside the chosen subsequence. If two consecutive characters in the subsequence are x and y, then:
abs(ord(x) - ord(y)) <= k
This means the characters must remain close in alphabetical order. The alphabet is not cyclic, so 'a' and 'z' differ by 25, not 1.
The input constraints are large:
1 <= s.length <= 10^50 <= k <= 25
A string length of 100000 immediately tells us that exponential or quadratic subsequence generation approaches are impossible. We need something close to linear time.
There are several important edge cases to think about:
- When
k = 0, adjacent characters in the subsequence must be identical. The answer becomes the maximum frequency of any character. - When
k = 25, every lowercase character can follow every other lowercase character, so the entire string is always valid. - Strings with repeated characters require careful handling because the best subsequence ending with a character may improve multiple times.
- Very short strings such as length
1should always return1.
Approaches
Brute Force Approach
A brute force solution would generate every possible subsequence of the string and check whether each subsequence satisfies the ideal condition.
For a string of length n, there are 2^n possible subsequences. For each subsequence, we would scan adjacent characters and verify that their alphabetical difference is at most k.
This approach is correct because it explicitly checks every possible candidate and keeps the longest valid one. However, it is computationally impossible for n = 100000.
Even for n = 30, there are already more than one billion subsequences.
Key Insight for the Optimal Solution
The important observation is that we do not need to remember entire subsequences. We only need to know:
What is the best ideal subsequence ending with each character?
Since there are only 26 lowercase letters, we can maintain dynamic programming information for each letter.
Suppose we are currently processing character 'd'.
If k = 2, then 'd' can follow:
'b''c''d''e''f'
because their alphabetical distance from 'd' is at most 2.
So, to build the best subsequence ending in 'd', we only need the best subsequences ending in those compatible characters.
This transforms the problem into a compact dynamic programming problem with only 26 states.
Approach Comparison
| Approach | Time Complexity | Space Complexity | Notes |
|---|---|---|---|
| Brute Force | O(2^n × n) | O(n) | Generates every subsequence and validates each one |
| Optimal Dynamic Programming | O(n × 26) | O(26) | Tracks best subsequence length ending at each character |
Algorithm Walkthrough
- Create a DP array of size 26.
Each index represents a lowercase letter:
dp[0]represents'a'dp[1]represents'b'- ...
dp[25]represents'z'
dp[i] stores the length of the longest ideal subsequence ending with that character.
2. Process the string one character at a time.
For each character char:
- Convert it into an index:
current = ord(char) - ord('a')
- Determine which previous characters are compatible.
Any character whose index differs from current by at most k can appear immediately before the current character in an ideal subsequence.
So the valid range is:
max(0, current - k) to min(25, current + k)
- Find the best subsequence among compatible characters.
Scan the valid range and compute:
best_previous = max(dp[i])
This gives the longest ideal subsequence that can legally transition into the current character. 5. Extend that subsequence.
The new subsequence length ending at the current character becomes:
best_previous + 1
- Update the DP entry for the current character.
We take the maximum because the same character may appear many times in the string. 7. After processing the entire string, return the maximum value in the DP array.
Why it works
The DP array always maintains the longest valid ideal subsequence ending with each letter. When processing a new character, we consider all compatible previous ending letters and extend the best one. Since every valid ideal subsequence must end with some character, and every extension respects the adjacency constraint, the algorithm correctly builds the optimal answer incrementally.
Python Solution
class Solution:
def longestIdealString(self, s: str, k: int) -> int:
dp = [0] * 26
for char in s:
current_index = ord(char) - ord('a')
left = max(0, current_index - k)
right = min(25, current_index + k)
best_previous = 0
for index in range(left, right + 1):
best_previous = max(best_previous, dp[index])
dp[current_index] = max(
dp[current_index],
best_previous + 1
)
return max(dp)
The implementation begins by creating a fixed-size DP array with 26 elements. Each position stores the best ideal subsequence ending with that letter.
As we iterate through the string, we convert each character into its alphabet index. This allows constant-time mapping between characters and DP positions.
For every character, we compute the compatible alphabet range using k. We then scan only those valid characters to find the best subsequence we can extend.
The expression:
best_previous + 1
represents appending the current character onto the best compatible subsequence.
We use:
max(dp[current_index], best_previous + 1)
because the same character may already have a longer subsequence from earlier processing.
Finally, the answer is the largest value stored in the DP array.
Go Solution
func longestIdealString(s string, k int) int {
dp := make([]int, 26)
for _, ch := range s {
currentIndex := int(ch - 'a')
left := currentIndex - k
if left < 0 {
left = 0
}
right := currentIndex + k
if right > 25 {
right = 25
}
bestPrevious := 0
for i := left; i <= right; i++ {
if dp[i] > bestPrevious {
bestPrevious = dp[i]
}
}
if bestPrevious+1 > dp[currentIndex] {
dp[currentIndex] = bestPrevious + 1
}
}
answer := 0
for _, value := range dp {
if value > answer {
answer = value
}
}
return answer
}
The Go implementation follows the exact same dynamic programming logic as the Python version.
A slice of length 26 stores the DP values. Since Go does not provide built-in max for integers, comparisons are handled manually with if statements.
The iteration over the string uses Go runes, but since the input contains only lowercase English letters, rune handling is straightforward and safe.
No special overflow handling is necessary because the maximum answer is at most 100000, which easily fits inside Go's int type.
Worked Examples
Example 1
Input: s = "acfgbd", k = 2
We maintain:
dp[i] = longest ideal subsequence ending with character i
| Step | Character | Compatible Range | Best Previous | Updated Value |
|---|---|---|---|---|
| 1 | a | a-c | 0 | dp[a] = 1 |
| 2 | c | a-e | 1 | dp[c] = 2 |
| 3 | f | d-h | 0 | dp[f] = 1 |
| 4 | g | e-i | 1 | dp[g] = 2 |
| 5 | b | a-d | 2 | dp[b] = 3 |
| 6 | d | b-f | 3 | dp[d] = 4 |
Final answer:
4
The longest ideal subsequence is "acbd".
Example 2
Input: s = "abcd", k = 3
| Step | Character | Compatible Range | Best Previous | Updated Value |
|---|---|---|---|---|
| 1 | a | a-d | 0 | dp[a] = 1 |
| 2 | b | a-e | 1 | dp[b] = 2 |
| 3 | c | a-f | 2 | dp[c] = 3 |
| 4 | d | a-g | 3 | dp[d] = 4 |
Final answer:
4
The entire string is ideal.
Complexity Analysis
| Measure | Complexity | Explanation |
|---|---|---|
| Time | O(n × 26) | For each character, we scan at most 26 letters |
| Space | O(26) | Fixed-size DP array |
Although the time complexity is technically O(n × 26), the alphabet size is constant, so this simplifies to linear time, O(n).
The space usage is constant because the DP array always contains exactly 26 elements regardless of input size.
Test Cases
solution = Solution()
assert solution.longestIdealString("acfgbd", 2) == 4 # Example 1
assert solution.longestIdealString("abcd", 3) == 4 # Example 2
assert solution.longestIdealString("a", 0) == 1 # Single character
assert solution.longestIdealString("aaaaa", 0) == 5 # All identical characters
assert solution.longestIdealString("abcde", 0) == 1 # No differing adjacent chars allowed
assert solution.longestIdealString("abcde", 25) == 5 # Entire string always valid
assert solution.longestIdealString("az", 0) == 1 # Difference too large
assert solution.longestIdealString("az", 25) == 2 # Maximum allowed difference
assert solution.longestIdealString("edcba", 1) == 5 # Descending valid chain
assert solution.longestIdealString("leetcode", 2) >= 1 # General mixed case
assert solution.longestIdealString("abababab", 1) == 8 # Alternating compatible chars
assert solution.longestIdealString("xyzabc", 2) == 3 # Split compatibility groups
assert solution.longestIdealString("z", 25) == 1 # Single max-range character
assert solution.longestIdealString("bac", 1) == 2 # Must skip incompatible transition
Test Case Summary
| Test | Why |
|---|---|
"acfgbd", 2 |
Validates the main example |
"abcd", 3 |
Entire string forms ideal subsequence |
"a", 0 |
Smallest input size |
"aaaaa", 0 |
Repeated identical characters |
"abcde", 0 |
Only identical adjacent characters allowed |
"abcde", 25 |
Maximum flexibility |
"az", 0 |
Maximum alphabet gap invalid |
"az", 25 |
Maximum alphabet gap valid |
"edcba", 1 |
Descending subsequence handling |
"leetcode", 2 |
Mixed realistic input |
"abababab", 1 |
Frequent DP updates |
"xyzabc", 2 |
Disconnected compatibility regions |
"z", 25 |
Single boundary character |
"bac", 1 |
Requires skipping characters |
Edge Cases
Edge Case 1: k = 0
When k is zero, adjacent characters in the subsequence must be identical. This changes the problem into finding the most frequent character.
A naive implementation might accidentally allow transitions between different letters due to incorrect range calculations.
The implementation handles this correctly because the compatible range becomes exactly one character:
[current_index, current_index]
So only identical characters contribute to the subsequence.
Edge Case 2: k = 25
When k = 25, every lowercase letter is compatible with every other lowercase letter.
A buggy implementation might accidentally exceed array bounds when computing:
current_index + k
The solution prevents this using:
min(25, current_index + k)
This safely clamps the search range within valid alphabet indices.
Edge Case 3: Repeated Characters
Repeated characters can improve the best subsequence ending with the same letter multiple times.
For example:
s = "abababab"
The best subsequence ending with 'a' and 'b' continuously grows throughout the scan.
A common mistake is overwriting DP states incorrectly. The implementation avoids this by using:
dp[current_index] = max(
dp[current_index],
best_previous + 1
)
This ensures we never lose a previously better subsequence ending with the same character.