LeetCode 730 - Count Different Palindromic Subsequences
This problem asks us to count how many distinct non-empty palindromic subsequences exist in a string. A subsequence is formed by deleting characters while preserving the relative order of the remaining characters. Unlike substrings, subsequences do not need to be contiguous.
Difficulty: 🔴 Hard
Topics: String, Dynamic Programming
Solution
Problem Understanding
This problem asks us to count how many distinct non-empty palindromic subsequences exist in a string.
A subsequence is formed by deleting characters while preserving the relative order of the remaining characters. Unlike substrings, subsequences do not need to be contiguous.
A palindrome reads the same forward and backward.
The important detail is that we are counting different palindromic subsequences, not the total number of occurrences. If the same palindrome can be formed in multiple ways, it should only be counted once.
For example, in "bccb":
"b"is a palindrome"c"is a palindrome"bb"is a palindrome"cc"is a palindrome"bcb"is a palindrome"bccb"is a palindrome
Even though "bcb" can be generated from multiple index combinations, it is counted only once.
The input size is up to 1000, which is large enough that brute force generation of all subsequences is impossible. A string of length 1000 has 2^1000 subsequences, which is astronomically large.
Another important constraint is that characters are restricted to 'a', 'b', 'c', and 'd'. This small alphabet size is a major hint that we can optimize using character-based dynamic programming.
The answer can become extremely large, so we must return it modulo:
10^9 + 7
Important edge cases include:
- A string with only one character
- A string where all characters are identical
- Strings with many duplicate palindromes
- Strings where the same palindrome can be formed in many ways
- Large strings that require efficient memoization or tabulation
The biggest challenge is avoiding duplicate counting.
Approaches
Brute Force Approach
The brute force idea is straightforward:
- Generate every possible subsequence
- Check whether each subsequence is a palindrome
- Insert valid palindromes into a set
- Return the size of the set
This works because a set naturally removes duplicates.
However, the number of subsequences of a string of length n is:
2^n
For n = 1000, this is completely infeasible.
Even if palindrome checking takes linear time, the exponential number of subsequences makes the approach unusable.
Key Insight for the Optimal Solution
The critical observation is that we do not need to explicitly construct subsequences.
Instead, we can use dynamic programming on substrings.
Define:
dp[i][j]
as the number of distinct palindromic subsequences inside substring:
s[i:j+1]
The difficult part is handling duplicates correctly.
Suppose:
s[i] == s[j]
Then every palindrome inside the middle substring can potentially be wrapped by these matching characters.
For example:
middle palindrome = "c"
wrapping with 'b' gives "bcb"
However, duplicates appear when the same character already exists inside the middle range.
To avoid overcounting, we locate:
- the first occurrence of
s[i]inside(i+1, j-1) - the last occurrence of
s[i]inside(i+1, j-1)
This creates three distinct cases:
- No matching character inside
- Exactly one matching character inside
- Multiple matching characters inside
Each case requires a different recurrence relation.
This transforms the problem into a manageable O(n^2) dynamic programming solution.
Approach Comparison
| Approach | Time Complexity | Space Complexity | Notes |
|---|---|---|---|
| Brute Force | O(2^n * n) | O(2^n) | Generates all subsequences and checks palindromes |
| Optimal Dynamic Programming | O(n^2) | O(n^2) | Counts distinct palindromes using interval DP |
Algorithm Walkthrough
Step 1: Define the DP State
We create a 2D DP table:
dp[i][j]
which represents the number of distinct non-empty palindromic subsequences in:
s[i:j+1]
Step 2: Initialize Single Characters
Every single character is itself a palindrome.
So:
dp[i][i] = 1
for all i.
Step 3: Process Substrings by Length
We process substrings from shorter to longer.
This ensures smaller subproblems are already solved before larger ones depend on them.
Step 4: Handle Different Boundary Characters
If:
s[i] != s[j]
then we combine results from:
dp[i+1][j]dp[i][j-1]
But subtract:
dp[i+1][j-1]
because it gets counted twice.
So:
dp[i][j] =
dp[i+1][j] + dp[i][j-1] - dp[i+1][j-1]
Step 5: Handle Matching Boundary Characters
If:
s[i] == s[j]
then we search for matching characters inside the interval.
Let:
leftbe first occurrence ofs[i]inside(i+1, j-1)rightbe last occurrence
Now there are three cases.
Case 1: No Matching Characters Inside
If no matching character exists:
left > right
then:
- all middle palindromes can be wrapped
- plus
"s[i]"itself - plus
"s[i]s[j]"
Formula:
dp[i][j] = 2 * dp[i+1][j-1] + 2
Case 2: Exactly One Matching Character Inside
If:
left == right
then one duplicate palindrome appears.
Formula:
dp[i][j] = 2 * dp[i+1][j-1] + 1
Case 3: Multiple Matching Characters Inside
If:
left < right
then many palindromes were already counted inside.
We subtract the duplicated region:
dp[left+1][right-1]
Formula:
dp[i][j] =
2 * dp[i+1][j-1] - dp[left+1][right-1]
Step 6: Apply Modulo
Because subtraction may produce negative values, we normalize using:
MOD = 10^9 + 7
Step 7: Return Final Answer
The result for the entire string is:
dp[0][n-1]
Why it works
The DP works because every palindromic subsequence belongs to one of two categories:
- It excludes at least one boundary character
- It includes both boundary characters
The recurrence relations partition these possibilities without missing or double-counting valid palindromes. The careful duplicate handling using left and right guarantees each distinct palindrome is counted exactly once.
Python Solution
class Solution:
def countPalindromicSubsequences(self, s: str) -> int:
MOD = 10**9 + 7
n = len(s)
dp = [[0] * n for _ in range(n)]
for i in range(n):
dp[i][i] = 1
for length in range(2, n + 1):
for i in range(n - length + 1):
j = i + length - 1
if s[i] != s[j]:
dp[i][j] = (
dp[i + 1][j]
+ dp[i][j - 1]
- dp[i + 1][j - 1]
)
else:
left = i + 1
right = j - 1
while left <= right and s[left] != s[i]:
left += 1
while left <= right and s[right] != s[i]:
right -= 1
if left > right:
dp[i][j] = 2 * dp[i + 1][j - 1] + 2
elif left == right:
dp[i][j] = 2 * dp[i + 1][j - 1] + 1
else:
dp[i][j] = (
2 * dp[i + 1][j - 1]
- dp[left + 1][right - 1]
)
dp[i][j] %= MOD
return dp[0][n - 1]
The implementation begins by creating a 2D DP table where each entry stores the number of distinct palindromic subsequences for a substring interval.
The diagonal is initialized to 1 because every single character forms a palindrome.
The outer loop processes substrings in increasing length order. This guarantees all smaller subproblems are already computed before being used.
When the boundary characters differ, the implementation applies the standard inclusion-exclusion principle. We combine counts from removing either boundary and subtract the overlap.
When the boundary characters match, the implementation searches inward to locate duplicate matching characters. These searches determine which recurrence formula should be used.
The modulo operation is applied after every computation to keep values bounded and prevent negative intermediate results from causing issues.
Finally, the algorithm returns the DP value covering the entire string.
Go Solution
func countPalindromicSubsequences(s string) int {
const MOD int = 1000000007
n := len(s)
dp := make([][]int, n)
for i := range dp {
dp[i] = make([]int, n)
dp[i][i] = 1
}
for length := 2; length <= n; length++ {
for i := 0; i <= n-length; i++ {
j := i + length - 1
if s[i] != s[j] {
dp[i][j] = dp[i+1][j] + dp[i][j-1] - dp[i+1][j-1]
} else {
left := i + 1
right := j - 1
for left <= right && s[left] != s[i] {
left++
}
for left <= right && s[right] != s[i] {
right--
}
if left > right {
dp[i][j] = 2*dp[i+1][j-1] + 2
} else if left == right {
dp[i][j] = 2*dp[i+1][j-1] + 1
} else {
dp[i][j] = 2*dp[i+1][j-1] - dp[left+1][right-1]
}
}
dp[i][j] %= MOD
if dp[i][j] < 0 {
dp[i][j] += MOD
}
}
}
return dp[0][n-1]
}
The Go implementation follows the same dynamic programming structure as the Python version.
One important Go-specific detail is handling negative modulo results. In Go, % preserves the sign of the dividend, so subtraction may leave negative values. We explicitly add MOD when necessary.
The DP table is implemented using slices of slices. Since n <= 1000, the O(n^2) memory usage is acceptable.
Worked Examples
Example 1
s = "bccb"
Initial DP Table
Single characters:
| i | j | Substring | dp[i][j] |
|---|---|---|---|
| 0 | 0 | b | 1 |
| 1 | 1 | c | 1 |
| 2 | 2 | c | 1 |
| 3 | 3 | b | 1 |
Length = 2
Substring "bc"
Characters differ.
dp[0][1] =
dp[1][1] + dp[0][0] - dp[1][0]
= 1 + 1 - 0
= 2
Palindromes:
"b", "c"
Substring "cc"
Characters match.
No matching c inside.
dp[1][2] =
2 * dp[2][1] + 2
= 0 + 2
= 2
Palindromes:
"c", "cc"
Substring "cb"
Characters differ.
dp[2][3] = 2
Length = 3
Substring "bcc"
Characters differ.
dp[0][2] =
dp[1][2] + dp[0][1] - dp[1][1]
= 2 + 2 - 1
= 3
Palindromes:
"b", "c", "cc"
Substring "ccb"
Similarly:
dp[1][3] = 3
Length = 4
Substring "bccb"
Characters match.
Search inside "cc" for 'b'.
No matching 'b' found.
dp[0][3] =
2 * dp[1][2] + 2
= 2 * 2 + 2
= 6
Final palindromes:
"b"
"c"
"bb"
"cc"
"bcb"
"bccb"
Answer:
6
Complexity Analysis
| Measure | Complexity | Explanation |
|---|---|---|
| Time | O(n^2) | There are O(n^2) DP states |
| Space | O(n^2) | The DP table stores all substring results |
Although the implementation performs inward scans to find duplicate boundary characters, the alphabet size is only four characters. This keeps the practical runtime efficient and accepted within constraints.
The DP table dominates memory usage.
Test Cases
solution = Solution()
assert solution.countPalindromicSubsequences("a") == 1 # single character
assert solution.countPalindromicSubsequences("aa") == 2 # "a", "aa"
assert solution.countPalindromicSubsequences("ab") == 2 # distinct singles
assert solution.countPalindromicSubsequences("aaa") == 3 # "a", "aa", "aaa"
assert solution.countPalindromicSubsequences("bccb") == 6 # provided example
assert solution.countPalindromicSubsequences("abcd") == 4 # no longer palindromes
assert solution.countPalindromicSubsequences("aaaa") == 4 # nested duplicates
assert solution.countPalindromicSubsequences("aba") == 4 # "a", "b", "aa", "aba"
assert solution.countPalindromicSubsequences("abba") == 6 # overlapping structures
assert solution.countPalindromicSubsequences("abcdabcd") > 0 # larger mixed case
| Test | Why |
|---|---|
"a" |
Minimum input size |
"aa" |
Simple repeated character case |
"ab" |
No multi-character palindrome |
"aaa" |
Heavy duplicate generation |
"bccb" |
Official example |
"abcd" |
All unique characters |
"aaaa" |
Duplicate handling stress case |
"aba" |
Nested palindrome structure |
"abba" |
Multiple overlapping palindromes |
"abcdabcd" |
Larger mixed-pattern input |
Edge Cases
Single Character Strings
A string like:
"a"
is the smallest valid input.
A common bug is forgetting to initialize base DP states correctly. If dp[i][i] is not initialized to 1, all larger transitions become incorrect.
The implementation explicitly initializes every diagonal cell to 1, guaranteeing single-character palindromes are counted.
Strings With All Identical Characters
Consider:
"aaaa"
Many different subsequence selections generate the same palindrome.
For example:
"a"can be formed many ways"aa"can be formed many ways
A naive counting approach would massively overcount duplicates.
The implementation avoids this by carefully locating duplicate boundary characters and subtracting already-counted regions when necessary.
Overlapping Duplicate Structures
Consider:
"abba"
The palindrome "aba" can arise through different subsequence combinations.
Without proper inclusion-exclusion logic, duplicate counting becomes inevitable.
The recurrence:
dp[i+1][j] + dp[i][j-1] - dp[i+1][j-1]
ensures overlapping regions are counted exactly once.
Negative Values During Subtraction
Some transitions subtract large overlapping ranges.
Without modulo normalization, values may temporarily become negative.
The implementation applies modulo after every update, and the Go version explicitly corrects negative results by adding MOD.