Facebook Pixel

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" with k = 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:

  1. Skip character at position i or j
  2. Try to match characters at positions i and j if the cost is within budget
Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

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:

  1. Exclude the left character: Find the longest palindrome in s[i+1..j] with the same budget k
  2. Exclude the right character: Find the longest palindrome in s[i..j-1] with the same budget k
  3. Try to match both ends: If we can afford to make s[i] and s[j] the same (cost ≤ k), we gain 2 characters for our palindrome and recursively solve for s[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:

  1. 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.

  2. Main Recursive Function dfs(i, j, k):

    • Parameters:
      • i: left boundary of the current substring
      • j: right boundary of the current substring
      • k: remaining operations budget
    • Returns: length of the longest palindromic subsequence in s[i..j] using at most k operations
  3. Base Cases:

    • If i > j: The range is invalid, return 0
    • If i == j: Single character is always a palindrome, return 1
  4. 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
    • Return the maximum of all valid options
  5. 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) costs min(25, 26-25) = min(25, 1) = 1

  6. Memoization: The @cache decorator automatically memoizes the function results, preventing redundant calculations for the same (i, j, k) triplet.

  7. 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 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 Evaluator

Example 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)

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
  • 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

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

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 from 0 to n-1, j ranges from 0 to n-1, and k ranges from 0 to the initial value of k
  • Due to the constraint i <= j in the problem logic, there are approximately n²/2 valid (i, j) pairs
  • For each valid (i, j) pair, we can have up to k+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 is O(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.

Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Tailor Your Study Plan:

Which algorithm should you use to find a node that is close to the root of the tree?


Recommended Readings

Want a Structured Path to Master System Design Too? Don’t Miss This!

Load More