3472. Longest Palindromic Subsequence After at Most K Operations
Problem Description
You are given a string s
and an integer k
.
You can perform operations on the string where each operation allows you to replace any character with the next or previous letter in the alphabet. The alphabet wraps around, meaning:
'a'
becomes'b'
when moved forward or'z'
when moved backward'z'
becomes'a'
when moved forward or'y'
when moved backward
Your goal is to find the length of the longest palindromic subsequence that can be obtained from s
after performing at most k
operations.
A subsequence is a sequence that can be derived from the string by deleting some or no characters without changing the order of the remaining characters. A palindromic subsequence reads the same forwards and backwards.
For example:
- If you have string
"abc"
withk = 2
, you could change'c'
to'a'
(2 operations:c→b→a
), making the string"aba"
. The longest palindromic subsequence would be"aba"
with length 3.
The key insight is that when trying to match two characters s[i]
and s[j]
to form part of a palindrome, the minimum number of operations needed is the shorter distance between them on the circular alphabet. For instance, to change 'a'
to 'z'
, you can go either forward 25 steps or backward 1 step, so the cost is min(25, 1) = 1
.
The solution uses dynamic programming with memoization, where dfs(i, j, k)
represents the maximum palindrome length achievable in substring s[i..j]
with at most k
operations remaining. At each step, you can either:
- Skip character at position
i
orj
- Try to match characters at positions
i
andj
if the cost is within budget
Intuition
The problem asks for the longest palindromic subsequence, which immediately suggests a dynamic programming approach similar to the classic longest palindromic subsequence problem. However, there's a twist - we can modify characters with a limited budget of k
operations.
The key insight is recognizing that in a palindrome, characters at symmetric positions must match. When building a palindrome, we compare characters from both ends of our current range. If they already match, great! If not, we need to decide whether it's worth spending operations to make them match.
For any two characters, the cost to make them identical is the minimum distance between them on the circular alphabet. Since the alphabet wraps around, we can go either clockwise or counterclockwise. For example, to change 'a'
to 'd'
, we can go forward 3 steps (a→b→c→d
) or backward 23 steps - so we choose the minimum: min(3, 23) = 3
.
This leads us to a recursive approach where for each substring s[i..j]
, we have three choices:
- Exclude the left character: Find the longest palindrome in
s[i+1..j]
with the same budgetk
- Exclude the right character: Find the longest palindrome in
s[i..j-1]
with the same budgetk
- Try to match both ends: If we can afford to make
s[i]
ands[j]
the same (cost ≤k
), we gain 2 characters for our palindrome and recursively solve fors[i+1..j-1]
with the remaining budget
The maximum of these three options gives us the longest palindrome for the current substring with the given budget. We use memoization to avoid recalculating the same subproblems, caching results based on the triplet (i, j, k)
.
The base cases are straightforward: an empty range returns 0, and a single character is always a palindrome of length 1.
Learn more about Dynamic Programming patterns.
Solution Approach
The solution implements a memoized recursive search (top-down dynamic programming) to find the longest palindromic subsequence.
Implementation Details:
-
Character Conversion: First, we convert the string to a list of ASCII values using
list(map(ord, s))
. This makes calculating the distance between characters easier since we can use simple arithmetic operations. -
Main Recursive Function
dfs(i, j, k)
:- Parameters:
i
: left boundary of the current substringj
: right boundary of the current substringk
: remaining operations budget
- Returns: length of the longest palindromic subsequence in
s[i..j]
using at mostk
operations
- Parameters:
-
Base Cases:
- If
i > j
: The range is invalid, return0
- If
i == j
: Single character is always a palindrome, return1
- If
-
Recursive Cases:
- Option 1: Skip the left character -
dfs(i + 1, j, k)
- Option 2: Skip the right character -
dfs(i, j - 1, k)
- Option 3: Try to match both ends if affordable:
- Calculate the distance:
d = abs(s[i] - s[j])
- Find minimum cost considering circular alphabet:
t = min(d, 26 - d)
- If
t <= k
, we can afford to match them:dfs(i + 1, j - 1, k - t) + 2
- Calculate the distance:
- Return the maximum of all valid options
- Option 1: Skip the left character -
-
Distance Calculation: The cost to change one character to another is the minimum of:
- Direct distance:
abs(s[i] - s[j])
- Wraparound distance:
26 - abs(s[i] - s[j])
For example, changing
'a'
(97) to'z'
(122) costsmin(25, 26-25) = min(25, 1) = 1
- Direct distance:
-
Memoization: The
@cache
decorator automatically memoizes the function results, preventing redundant calculations for the same(i, j, k)
triplet. -
Final Answer: Call
dfs(0, n - 1, k)
to solve for the entire string with the full budget.
The time complexity is O(n² × k)
where n
is the length of the string, as we have n²
possible substrings and k
different budget states. The space complexity is also O(n² × k)
for the memoization cache.
Ready to land your dream job?
Unlock your dream job with a 5-minute evaluator for a personalized learning plan!
Start EvaluatorExample Walkthrough
Let's walk through the solution with string s = "abcd"
and k = 3
.
Initial Setup:
- Convert string to ASCII values:
[97, 98, 99, 100]
(representing 'a', 'b', 'c', 'd') - Start with
dfs(0, 3, 3)
- checking the entire string with budget 3
Step 1: dfs(0, 3, 3)
- Can we form a palindrome from "abcd"?
- Option 1: Skip 'a' →
dfs(1, 3, 3)
- Option 2: Skip 'd' →
dfs(0, 2, 3)
- Option 3: Match 'a' and 'd'
- Distance =
|97 - 100| = 3
- Circular distance =
min(3, 26-3) = 3
- Cost is 3, exactly our budget!
- If we match them:
2 + dfs(1, 2, 0)
- Distance =
Step 2: Explore dfs(1, 2, 0)
- "bc" with budget 0
- Option 1: Skip 'b' →
dfs(2, 2, 0)
= 1 (just 'c') - Option 2: Skip 'c' →
dfs(1, 1, 0)
= 1 (just 'b') - Option 3: Match 'b' and 'c'
- Distance =
|98 - 99| = 1
- Cost is 1, but budget is 0, so we can't match
- Distance =
- Result:
max(1, 1) = 1
Step 3: Back to dfs(0, 3, 3)
Option 3:
- Matching 'a' and 'd' gives us:
2 + 1 = 3
Step 4: Explore dfs(1, 3, 3)
- "bcd" with budget 3
- Option 1: Skip 'b' →
dfs(2, 3, 3)
- Option 2: Skip 'd' →
dfs(1, 2, 3)
- Option 3: Match 'b' and 'd'
- Distance =
|98 - 100| = 2
- Cost is 2, we can afford it
- Result:
2 + dfs(2, 2, 1)
=2 + 1
= 3
- Distance =
Step 5: Explore dfs(0, 2, 3)
- "abc" with budget 3
- Option 1: Skip 'a' →
dfs(1, 2, 3)
- Option 2: Skip 'c' →
dfs(0, 1, 3)
- Option 3: Match 'a' and 'c'
- Distance =
|97 - 99| = 2
- Cost is 2, we can afford it
- Result:
2 + dfs(1, 1, 1)
=2 + 1
= 3
- Distance =
Final Result: All three main options give us a palindrome of length 3:
- Skipping 'a': "bcd" → can form "bdb" (length 3)
- Skipping 'd': "abc" → can form "aba" (length 3)
- Matching 'a' and 'd': forms "ada" with middle character (length 3)
The maximum is 3, which is our answer.
The key insight demonstrated here is that we explore all possibilities - either skipping characters to find natural palindromes in subsequences, or using our operation budget to force characters to match when beneficial.
Solution Implementation
1from functools import cache
2from typing import List
3
4class Solution:
5 def longestPalindromicSubsequence(self, s: str, k: int) -> int:
6 """
7 Find the longest palindromic subsequence where characters can be shifted
8 cyclically with a total cost limit of k.
9
10 Args:
11 s: Input string
12 k: Maximum total cost allowed for character shifts
13
14 Returns:
15 Length of the longest palindromic subsequence
16 """
17
18 @cache
19 def find_longest_palindrome(left: int, right: int, remaining_cost: int) -> int:
20 """
21 Recursively find the longest palindrome in substring s[left:right+1]
22 with remaining_cost budget for character shifts.
23
24 Args:
25 left: Left index of current substring
26 right: Right index of current substring
27 remaining_cost: Remaining budget for character shifts
28
29 Returns:
30 Length of longest palindromic subsequence
31 """
32 # Base case: invalid range
33 if left > right:
34 return 0
35
36 # Base case: single character is always a palindrome
37 if left == right:
38 return 1
39
40 # Option 1: Skip left character
41 skip_left = find_longest_palindrome(left + 1, right, remaining_cost)
42
43 # Option 2: Skip right character
44 skip_right = find_longest_palindrome(left, right - 1, remaining_cost)
45
46 # Take the maximum of skipping either character
47 result = max(skip_left, skip_right)
48
49 # Option 3: Try to match characters at both ends
50 # Calculate the circular distance between two characters
51 char_difference = abs(char_codes[left] - char_codes[right])
52 circular_distance = min(char_difference, 26 - char_difference)
53
54 # If we have enough budget to make these characters match
55 if circular_distance <= remaining_cost:
56 # Include both characters and recurse on the inner substring
57 match_both = find_longest_palindrome(
58 left + 1,
59 right - 1,
60 remaining_cost - circular_distance
61 ) + 2
62 result = max(result, match_both)
63
64 return result
65
66 # Convert string to list of ASCII values for easier manipulation
67 char_codes = [ord(char) for char in s]
68 string_length = len(s)
69
70 # Find the answer starting from the entire string
71 answer = find_longest_palindrome(0, string_length - 1, k)
72
73 # Clear the cache to free memory
74 find_longest_palindrome.cache_clear()
75
76 return answer
77
1class Solution {
2 // Character array representation of the input string
3 private char[] charArray;
4
5 // Memoization table: dp[left][right][remainingCost] stores the result
6 private Integer[][][] memo;
7
8 /**
9 * Finds the longest palindromic subsequence with at most k total transformation cost.
10 * Characters can be transformed cyclically (a->b->...->z->a) with minimum distance cost.
11 *
12 * @param s The input string
13 * @param k Maximum total transformation cost allowed
14 * @return Length of the longest palindromic subsequence
15 */
16 public int longestPalindromicSubsequence(String s, int k) {
17 this.charArray = s.toCharArray();
18 int length = s.length();
19
20 // Initialize memoization table
21 // memo[i][j][cost] represents the longest palindrome in substring [i, j] with cost budget
22 memo = new Integer[length][length][k + 1];
23
24 // Start DFS from the entire string with full budget
25 return dfs(0, length - 1, k);
26 }
27
28 /**
29 * Recursive function with memoization to find the longest palindromic subsequence.
30 *
31 * @param left Left boundary of the current substring
32 * @param right Right boundary of the current substring
33 * @param remainingBudget Remaining transformation cost budget
34 * @return Maximum length of palindromic subsequence in substring [left, right]
35 */
36 private int dfs(int left, int right, int remainingBudget) {
37 // Base case: invalid range
38 if (left > right) {
39 return 0;
40 }
41
42 // Base case: single character is always a palindrome
43 if (left == right) {
44 return 1;
45 }
46
47 // Check memoization table
48 if (memo[left][right][remainingBudget] != null) {
49 return memo[left][right][remainingBudget];
50 }
51
52 // Option 1: Skip the left character
53 // Option 2: Skip the right character
54 int maxLength = Math.max(
55 dfs(left + 1, right, remainingBudget),
56 dfs(left, right - 1, remainingBudget)
57 );
58
59 // Calculate the minimum cost to transform characters at positions left and right to match
60 int directDistance = Math.abs(charArray[left] - charArray[right]);
61 int cyclicDistance = 26 - directDistance;
62 int minTransformCost = Math.min(directDistance, cyclicDistance);
63
64 // Option 3: If we have enough budget, transform and include both characters
65 if (minTransformCost <= remainingBudget) {
66 maxLength = Math.max(
67 maxLength,
68 2 + dfs(left + 1, right - 1, remainingBudget - minTransformCost)
69 );
70 }
71
72 // Store result in memoization table
73 memo[left][right][remainingBudget] = maxLength;
74
75 return maxLength;
76 }
77}
78
1class Solution {
2public:
3 int longestPalindromicSubsequence(string s, int k) {
4 int n = s.size();
5
6 // dp[i][j][remainingBudget] = longest palindromic subsequence in s[i..j] with remainingBudget changes allowed
7 // Initialize with -1 to indicate uncomputed state
8 vector<vector<vector<int>>> dp(n, vector<vector<int>>(n, vector<int>(k + 1, -1)));
9
10 // Recursive function with memoization to find longest palindromic subsequence
11 auto dfs = [&](this auto&& dfs, int left, int right, int remainingBudget) -> int {
12 // Base case: invalid range
13 if (left > right) {
14 return 0;
15 }
16
17 // Base case: single character is always a palindrome of length 1
18 if (left == right) {
19 return 1;
20 }
21
22 // Return memoized result if already computed
23 if (dp[left][right][remainingBudget] != -1) {
24 return dp[left][right][remainingBudget];
25 }
26
27 // Option 1: Skip the left character
28 // Option 2: Skip the right character
29 int result = max(dfs(left + 1, right, remainingBudget),
30 dfs(left, right - 1, remainingBudget));
31
32 // Calculate the cost to make s[left] and s[right] match
33 // The cost is the minimum circular distance between two characters
34 int directDistance = abs(s[left] - s[right]);
35 int circularDistance = 26 - directDistance;
36 int changeCost = min(directDistance, circularDistance);
37
38 // Option 3: Include both characters if we have enough budget to make them match
39 if (changeCost <= remainingBudget) {
40 result = max(result, 2 + dfs(left + 1, right - 1, remainingBudget - changeCost));
41 }
42
43 // Memoize and return the result
44 return dp[left][right][remainingBudget] = result;
45 };
46
47 // Start the recursive search from the entire string with full budget
48 return dfs(0, n - 1, k);
49 }
50};
51
1/**
2 * Finds the length of the longest palindromic subsequence in string s
3 * where we can change at most k characters (with circular distance in alphabet)
4 * @param s - Input string containing lowercase letters
5 * @param k - Maximum total cost allowed for character changes
6 * @returns Length of the longest palindromic subsequence
7 */
8function longestPalindromicSubsequence(s: string, k: number): number {
9 const n: number = s.length;
10
11 // Convert string characters to ASCII codes for easier distance calculation
12 const charCodes: number[] = s.split('').map((char: string) => char.charCodeAt(0));
13
14 // DP memoization table: dp[i][j][remainingK] stores the result for substring s[i..j] with remainingK changes left
15 // Initialize with -1 to indicate uncomputed states
16 const dp: number[][][] = Array.from({ length: n }, () =>
17 Array.from({ length: n }, () => Array(k + 1).fill(-1))
18 );
19
20 /**
21 * Recursive helper function with memoization
22 * @param left - Left pointer of the current substring
23 * @param right - Right pointer of the current substring
24 * @param remainingChanges - Remaining allowed changes
25 * @returns Maximum palindromic subsequence length for s[left..right]
26 */
27 function dfs(left: number, right: number, remainingChanges: number): number {
28 // Base case: invalid range
29 if (left > right) {
30 return 0;
31 }
32
33 // Base case: single character is always a palindrome
34 if (left === right) {
35 return 1;
36 }
37
38 // Return memoized result if already computed
39 if (dp[left][right][remainingChanges] !== -1) {
40 return dp[left][right][remainingChanges];
41 }
42
43 // Option 1: Skip left character or skip right character
44 let maxLength: number = Math.max(
45 dfs(left + 1, right, remainingChanges),
46 dfs(left, right - 1, remainingChanges)
47 );
48
49 // Option 2: Try to match characters at left and right positions
50 // Calculate circular distance between two characters in alphabet
51 const absoluteDiff: number = Math.abs(charCodes[left] - charCodes[right]);
52 const circularDistance: number = Math.min(absoluteDiff, 26 - absoluteDiff);
53
54 // If we can afford the cost to make them match, include both characters
55 if (circularDistance <= remainingChanges) {
56 maxLength = Math.max(
57 maxLength,
58 2 + dfs(left + 1, right - 1, remainingChanges - circularDistance)
59 );
60 }
61
62 // Memoize and return the result
63 dp[left][right][remainingChanges] = maxLength;
64 return maxLength;
65 }
66
67 // Start DFS from the entire string with k changes available
68 return dfs(0, n - 1, k);
69}
70
Time and Space Complexity
The time complexity of this code is O(n² × k)
, where n
is the length of the string s
and k
is the parameter representing the maximum allowed transformation cost.
This complexity arises from the recursive function dfs(i, j, k)
with memoization:
- The function has three parameters:
i
ranges from0
ton-1
,j
ranges from0
ton-1
, andk
ranges from0
to the initial value ofk
- Due to the constraint
i <= j
in the problem logic, there are approximatelyn²/2
valid(i, j)
pairs - For each valid
(i, j)
pair, we can have up tok+1
different values for the third parameter - Each state is computed only once due to the
@cache
decorator (memoization) - Each state computation takes
O(1)
time for the operations inside the function
Therefore, the total number of unique states is O(n² × k)
, and each state is computed in O(1)
time, giving us a time complexity of O(n² × k)
.
The space complexity is also O(n² × k)
due to:
- The memoization cache stores all computed states, which is
O(n² × k)
entries - The recursion stack depth is at most
O(n)
in the worst case (when we recurse from(0, n-1)
down to base cases) - Since
O(n² × k) > O(n)
, the dominant space complexity isO(n² × k)
Learn more about how to find time and space complexity quickly.
Common Pitfalls
1. Incorrect Distance Calculation for Circular Alphabet
One of the most common mistakes is incorrectly calculating the minimum distance between two characters in a circular alphabet. Many developers forget to consider the wraparound nature of the alphabet.
Incorrect Implementation:
# Wrong: Only considers forward distance
distance = abs(ord(s[i]) - ord(s[j]))
Why it's wrong: This only calculates the direct distance. For example, changing 'a' to 'z' would cost 25, but going backward (a→z) only costs 1.
Correct Implementation:
# Correct: Considers both forward and backward distances
char_diff = abs(ord(s[i]) - ord(s[j]))
distance = min(char_diff, 26 - char_diff)
2. Confusing Subsequence with Substring
Another pitfall is treating this as a substring problem rather than a subsequence problem.
Wrong Approach:
# Trying to find contiguous palindromes only
for length in range(n, 0, -1):
for start in range(n - length + 1):
if is_palindrome_possible(s[start:start+length], k):
return length
Correct Approach: The solution correctly handles subsequences by having three choices at each step:
- Skip left character
- Skip right character
- Match both ends (if affordable)
3. Off-by-One Errors in Recursion
Be careful with index boundaries when recursing:
Common Mistake:
# Wrong: Missing the +2 when matching both ends if circular_distance <= remaining_cost: match_both = find_longest_palindrome(left + 1, right - 1, remaining_cost - circular_distance) # Forgot to add 2 for the matched characters!
Correct:
if circular_distance <= remaining_cost: match_both = find_longest_palindrome(left + 1, right - 1, remaining_cost - circular_distance) + 2
4. Not Handling Edge Cases Properly
Missing Base Cases:
def dfs(i, j, k):
# Wrong: Missing base cases
result = max(dfs(i+1, j, k), dfs(i, j-1, k))
# This will cause infinite recursion!
Correct Base Cases:
def dfs(i, j, k):
if i > j:
return 0 # Invalid range
if i == j:
return 1 # Single character
# Then handle recursive cases...
5. Memory Limit Issues with Large Inputs
For very large inputs, the memoization cache can consume significant memory. The solution correctly addresses this by clearing the cache after getting the result:
answer = find_longest_palindrome(0, string_length - 1, k) find_longest_palindrome.cache_clear() # Important for memory management return answer
Without this, repeated test cases could cause memory issues in competitive programming platforms.
Which algorithm should you use to find a node that is close to the root of the tree?
Recommended Readings
What is Dynamic Programming Prerequisite DFS problems dfs_intro Backtracking problems backtracking Memoization problems memoization_intro Pruning problems backtracking_pruning Dynamic programming is an algorithmic optimization technique that breaks down a complicated problem into smaller overlapping sub problems in a recursive manner and uses solutions to the sub problems to construct a solution
Coding Interview Patterns Your Personal Dijkstra's Algorithm to Landing Your Dream Job The goal of AlgoMonster is to help you get a job in the shortest amount of time possible in a data driven way We compiled datasets of tech interview problems and broke them down by patterns This way
Recursion Recursion is one of the most important concepts in computer science Simply speaking recursion is the process of a function calling itself Using a real life analogy imagine a scenario where you invite your friends to lunch https assets algo monster recursion jpg You first call Ben and ask
Want a Structured Path to Master System Design Too? Don’t Miss This!