Facebook Pixel

3472. Longest Palindromic Subsequence After at Most K Operations


Problem Description

You are given a string s and an integer k.

In one operation, you can replace a character at any position with the next or previous letter in the alphabet (wrapping around so that 'a' is after 'z'). For example, replacing 'a' with the next letter results in 'b', and replacing 'a' with the previous letter results in 'z'. Similarly, replacing 'z' with the next letter results in 'a', and replacing 'z' with the previous letter results in 'y'.

Return the length of the longest palindromic subsequence of s that can be obtained after performing at most k operations.

Intuition

The task requires us to find the longest palindromic subsequence in a string s after applying at most k operations, where each operation is a character replacement to its neighboring letter in the alphabet with wrapping around.

The solution uses a dynamic programming approach to achieve this. We define a function dfs(i, j, k) that returns the length of the longest palindromic subsequence in the substring s[i..j] with at most k operations. The idea is to break down the problem for a given substring as follows:

  1. Base Cases:

    • If the start index i exceeds the end index j (i > j), the longest palindromic subsequence is 0 because there are no characters left.
    • If the substring length is 1 (i == j), the longest palindromic subsequence is 1 as a single character is always a palindrome.
  2. Recursive Cases:

    • We consider ignoring either the start (s[i]) or the end character (s[j]). This is done by calculating recursively for dfs(i + 1, j, k) or dfs(i, j - 1, k) respectively.
    • Alternatively, we can attempt to match s[i] and s[j] by performing necessary operations. We compute the difference t between the ASCII values of s[i] and s[j], considering the wrap around the z-a boundary. If t operations are feasible (t <= k), we can try forming a palindrome by considering dfs(i + 1, j - 1, k - t) + 2, where this new palindrome includes s[i] and s[j].

By considering these options, we compute the maximum between ignoring characters or forming a palindrome, and return the best value. Memoization is employed to store results of subproblems and avoid redundant calculations, thus optimizing the search process efficiently.

Learn more about Dynamic Programming patterns.

Solution Approach

The solution to the problem employs a recursive approach enhanced by memoization to find the longest palindromic subsequence within a given string s, allowing up to k character modifications. Here’s a detailed breakdown of the implementation approach:

  1. Recursive Function with Memoization:

    • We define a helper function dfs(i, j, k) where i and j are the indices defining the substring s[i..j] and k is the number of allowed operations left. This function calculates the longest palindromic subsequence within the given parameters.
    • The function is memoized using Python's @cache decorator, which helps store the results of previously computed states and improves efficiency by avoiding redundant calculations.
  2. Base and Recursive Cases:

    • Base Case 1: If i > j, return 0 as there are no characters left to form a palindrome.
    • Base Case 2: If i == j, return 1 because a single character is inherently a palindrome.
    • Recursive Case: For each substring:
      • Compute the best result by either skipping a character from the start or the end (dfs(i + 1, j, k) or dfs(i, j - 1, k) respectively).
      • Calculate the difference d = abs(s[i] - s[j]) to determine the number of operations required to make s[i] equal to s[j]. Use wrap-around logic to account for the circular nature of the alphabet: t = min(d, 26 - d).
      • If t <= k, attempt to form a palindrome by matching s[i] and s[j] and calculate dfs(i + 1, j - 1, k - t) + 2.
  3. Implementation Details:

    • Convert characters to their ASCII values using list(map(ord, s)) to facilitate the calculation of differences and modification requirements.
    • Initiate the recursive process with dfs(0, n - 1, k) where n is the length of s.
    • After computing the required result, clear the cache using dfs.cache_clear() to manage memory usage effectively.

This approach ensures that each potential subsequence is evaluated and the best palindrome is found under the constraint of k operations.

Ready to land your dream job?

Unlock your dream job with a 2-minute evaluator for a personalized learning plan!

Start Evaluator

Example Walkthrough

Let's consider a simple example where the string s is "abc" and k is 1. We want to find the length of the longest palindromic subsequence after at most 1 operation.

  1. Initial Setup:

    • We will use a recursive function, dfs(i, j, k), to calculate the longest palindromic subsequence between indices i and j, allowing up to k operations.
    • Initialize the process by calling dfs(0, 2, 1), where 0 and 2 are the starting and ending indices of the string.
  2. Base Cases:

    • For any call where i > j, return 0 since there are no characters left to form a palindrome.
    • If i == j, return 1 as a single character is always a palindromic subsequence.
  3. Recursive Cases:

    • Consider the substring "abc":
      • Call dfs(1, 2, 1) (ignoring 'a') and dfs(0, 1, 1) (ignoring 'c').
      • Compute the difference d between ASCII values of 'a' (97) and 'c' (99). The difference d = abs(97 - 99) = 2 (requires 2 operations to equalize, using wrap-around logic: t = min(2, 26 - 2) = 2).
      • Since t > k, we cannot match 'a' and 'c' with the current operation limit k = 1.
  4. Evaluation of Subproblems:

    • Evaluate dfs(1, 2, 1) for the substring "bc":
      • Call dfs(2, 2, 1) and dfs(1, 1, 1) to consider cases ignoring either side.
      • Compute d = abs(98 - 99) = 1 (requires 1 operation to match 'b' and 'c', t = 1).
      • Perform the operation as t <= k and call dfs(2, 1, 0) + 2, resulting in a subsequence of length 2.
  5. Combine and Conclude:

    • Backtrack to combine results according to maximum lengths obtained from all subproblems.
    • Finally, the length of the longest palindromic subsequence for "abc" with k=1 is 2 (e.g., forming "aba", "cbc" by replacing a single character).

By using a memoized recursive approach, the solution efficiently evaluates and returns the maximum possible length of a palindromic subsequence within the given constraints.

Solution Implementation

1from functools import cache
2
3class Solution:
4    def longestPalindromicSubsequence(self, s: str, k: int) -> int:
5        # Use memoization to cache results of dfs function
6        @cache
7        def dfs(i: int, j: int, remaining_k: int) -> int:
8            # Base case: if indices cross, no subsequence
9            if i > j:
10                return 0
11            # Base case: single character is a palindrome of length 1
12            if i == j:
13                return 1
14
15            # Calculate max length by skipping either i or j
16            res = max(dfs(i + 1, j, remaining_k), dfs(i, j - 1, remaining_k))
17
18            # Calculate character difference
19            diff = abs(s[i] - s[j])
20            char_change_cost = min(diff, 26 - diff)
21
22            # If the cost to make s[i] and s[j] equal is within remaining_k
23            if char_change_cost <= remaining_k:
24                # Include the pair s[i] and s[j] and deduct cost from remaining_k
25                res = max(res, dfs(i + 1, j - 1, remaining_k - char_change_cost) + 2)
26
27            return res
28
29        # Convert characters to their ordinal values
30        s = list(map(ord, s))
31        # Length of the string
32        n = len(s)
33        # Initiate recursion to compute the result
34        ans = dfs(0, n - 1, k)
35        # Clear cache after computation
36        dfs.cache_clear()
37        return ans
38
1class Solution {
2    private char[] s;   // Array to store the characters of the input string
3    private Integer[][][] memo; // Memoization table to store intermediate results
4
5    public int longestPalindromicSubsequence(String s, int k) {
6        this.s = s.toCharArray(); // Convert the input string to a character array
7        int n = s.length(); // Length of the input string
8        memo = new Integer[n][n][k + 1]; // Initialize the memoization table
9        return dfs(0, n - 1, k); // Start the recursive depth-first search
10    }
11
12    private int dfs(int i, int j, int k) {
13        if (i > j) {
14            return 0; // If the left index exceeds the right, there is no subsequence
15        }
16        if (i == j) {
17            return 1; // If both indices are the same, it forms a single-character palindrome
18        }
19        if (memo[i][j][k] != null) {
20            return memo[i][j][k]; // Return the stored result if already computed
21        }
22
23        // Case 1: Either exclude the left or right character
24        int result = Math.max(dfs(i + 1, j, k), dfs(i, j - 1, k));
25
26        // Calculate the distance between characters s[i] and s[j] in the alphabet
27        int distance = Math.abs(s[i] - s[j]);
28        int cost = Math.min(distance, 26 - distance); // Minimum cost to make characters equal
29
30        // Case 2: Include both characters if the modification cost is within the allowed limit
31        if (cost <= k) {
32            result = Math.max(result, 2 + dfs(i + 1, j - 1, k - cost));
33        }
34      
35        memo[i][j][k] = result; // Store the computed result
36        return result; // Return the longest palindromic subsequence length
37    }
38}
39
1#include <vector>
2#include <string>
3#include <cmath>
4#include <algorithm>
5
6class Solution {
7public:
8    int longestPalindromicSubsequence(std::string s, int k) {
9        int n = s.size();
10        // 3D vector to cache the results of subproblems: f[i][j][k] represents
11        // the longest palindromic subsequence in the substring s[i...j] allowing k modifications.
12        std::vector<std::vector<std::vector<int>>> cache(n, std::vector<std::vector<int>>(n, std::vector<int>(k + 1, -1)));
13      
14        // Define a recursive function (dfs) to solve the problem.
15        std::function<int(int, int, int)> dfs = [&](int i, int j, int k) -> int {
16            // Base case: no characters left.
17            if (i > j) {
18                return 0;
19            }
20            // Base case: single character is a palindrome of length 1.
21            if (i == j) {
22                return 1;
23            }
24            // Return cached result if already computed.
25            if (cache[i][j][k] != -1) {
26                return cache[i][j][k];
27            }
28
29            // Take the maximum of ignoring either of the characters at the ends.
30            int result = std::max(dfs(i + 1, j, k), dfs(i, j - 1, k));
31
32            // Calculate the difference between the character codes of s[i] and s[j].
33            int difference = std::abs(s[i] - s[j]);
34            // Calculate the minimal cost to make s[i] equal to s[j].
35            int cost = std::min(difference, 26 - difference);
36
37            // If the cost is within the allowed modifications (k),
38            // consider making s[i] and s[j] equal and include both in the subsequence count.
39            if (cost <= k) {
40                result = std::max(result, 2 + dfs(i + 1, j - 1, k - cost));
41            }
42
43            // Cache the result for the current subproblem.
44            return cache[i][j][k] = result;
45        };
46
47        // Call the dfs from the full string scope with the given number of modifications allowed.
48        return dfs(0, n - 1, k);
49    }
50};
51
1function longestPalindromicSubsequence(s: string, k: number): number {
2    // Get the length of the string
3    const n = s.length;
4    // Convert each character to its Unicode code point value
5    const sCodes = s.split('').map(char => char.charCodeAt(0));
6  
7    // Initialize the 3D memoization table with dimensions [n][n][k+1], defaulting to -1
8    const memo: number[][][] = Array.from({ length: n }, () =>
9        Array.from({ length: n }, () => Array(k + 1).fill(-1)),
10    );
11
12    // Depth-First Search with memoization
13    function dfs(i: number, j: number, remainingChanges: number): number {
14        // Base case: when pointers cross, the result is 0
15        if (i > j) {
16            return 0;
17        }
18        // Base case: when pointers are at the same position, single character is a palindrome
19        if (i === j) {
20            return 1;
21        }
22
23        // Return cached result if available
24        if (memo[i][j][remainingChanges] !== -1) {
25            return memo[i][j][remainingChanges];
26        }
27
28        // Option 1: Move the left pointer to the right
29        let result = Math.max(dfs(i + 1, j, remainingChanges), dfs(i, j - 1, remainingChanges));
30
31        // Calculate the distance between characters, consider the wrapped alphabet
32        const charDistance = Math.abs(sCodes[i] - sCodes[j]);
33        const wrappedDistance = Math.min(charDistance, 26 - charDistance);
34
35        // If distance can be covered within the limit of allowed changes
36        if (wrappedDistance <= remainingChanges) {
37            // Option 2: Consider the current pair as part of the palindromic subsequence
38            result = Math.max(result, 2 + dfs(i + 1, j - 1, remainingChanges - wrappedDistance));
39        }
40
41        // Store the result in the memoization table
42        return (memo[i][j][remainingChanges] = result);
43    }
44
45    // Initiate the depth-first search from the first to the last character with the initial available changes
46    return dfs(0, n - 1, k);
47}
48

Time and Space Complexity

The given code uses a dynamic programming approach with memoization (cache) to solve the longest palindromic subsequence problem with an additional constraint.

Time Complexity

The function dfs(i, j, k) recursively explores all possible subsequences within indices i and j with a constraint involving k.

  1. There are O(n^2) pairs of (i, j) since for each i, j can vary from i to n-1.
  2. For each pair of indices (i, j), the function evaluates different values of k, and k can vary from 0 to the initial given k.

Combining these factors, the time complexity is O(n^2 * k).

Space Complexity

  1. The space required to store intermediate results of dfs(i, j, k) in the cache is O(n^2 * k) because we store computations for each combination of (i, j, k).

  2. The recursive stack requires O(n) space in the worst case due to the depth of recursion.

Thus, the space complexity is O(n^2 * k).

Learn more about how to find time and space complexity quickly.


Discover Your Strengths and Weaknesses: Take Our 2-Minute Quiz to Tailor Your Study Plan:
Question 1 out of 10

Which data structure is used to implement recursion?


Recommended Readings

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


Load More