Find the minimum number of cuts needed to split a string into palindromic substrings using palindrome precomputation and dynamic programming.
Problem Restatement
We are given a string s.
We need to cut the string into substrings so that every substring is a palindrome.
Return the minimum number of cuts needed.
LeetCode states the problem as partitioning s so every substring is a palindrome, then returning the minimum cuts needed for such a palindrome partitioning. The constraints are 1 <= s.length <= 2000, and s contains lowercase English letters only.
Examples
Example 1:
s = "aab"We can partition it as:
["aa", "b"]Both "aa" and "b" are palindromes.
Only one cut is needed:
aa | bOutput:
1Example 2:
s = "a"A single character is already a palindrome.
No cut is needed.
Output:
0Example 3:
s = "ab"The whole string "ab" is not a palindrome.
We need to cut it into:
["a", "b"]Output:
1Input and Output
| Item | Meaning |
|---|---|
| Input | s: str |
| Output | Minimum number of cuts |
| Rule | Every final substring must be a palindrome |
| Constraint | 1 <= len(s) <= 2000 |
Function shape:
class Solution:
def minCut(self, s: str) -> int:
...First Thought: Generate All Partitions
From LeetCode 131, we know how to generate all palindrome partitions.
For example:
s = "aab"All palindrome partitions are:
["a", "a", "b"]
["aa", "b"]Then we could count cuts in each partition and take the minimum.
For a partition with k substrings, the number of cuts is:
k - 1So:
| Partition | Substrings | Cuts |
|---|---|---|
["a", "a", "b"] | 3 | 2 |
["aa", "b"] | 2 | 1 |
This works for small strings, but it is too slow because the number of partitions can be exponential.
We need dynamic programming.
Key Insight
The problem asks for the minimum number of cuts, not the actual partitions.
So we only need the best answer for each prefix.
Let:
dp[i]mean:
minimum cuts needed for s[0:i+1]That is, dp[i] solves the prefix ending at index i.
For example, if:
s = "aab"then:
i | Prefix | dp[i] |
|---|---|---|
0 | "a" | 0 |
1 | "aa" | 0 |
2 | "aab" | 1 |
The answer is:
dp[n - 1]Palindrome Precomputation
To compute dp, we need to quickly know whether any substring s[start:end+1] is a palindrome.
We precompute a table:
is_pal[start][end]meaning:
s[start:end+1] is a palindromeA substring is a palindrome if:
- Its first and last characters are equal.
- Its inside substring is also a palindrome.
So the recurrence is:
is_pal[start][end] = (
s[start] == s[end]
and (end - start <= 2 or is_pal[start + 1][end - 1])
)The condition end - start <= 2 handles substrings of length 1, 2, and 3.
DP Transition
Suppose we want to compute dp[end].
We try every possible start index start for the last palindrome block:
s[start:end+1]If s[start:end+1] is a palindrome, then the last block can be this substring.
There are two cases.
If start == 0, the whole prefix s[0:end+1] is a palindrome.
So no cut is needed:
dp[end] = 0Otherwise, we cut before start.
The left part is:
s[0:start]Its minimum cuts are:
dp[start - 1]Then we add one cut between the left part and the last palindrome block:
dp[end] = min(dp[end], dp[start - 1] + 1)Algorithm
Let n = len(s).
First, build the palindrome table.
Then initialize:
dp = [0] * nFor each end from 0 to n - 1:
- If
s[0:end+1]is a palindrome, setdp[end] = 0. - Otherwise, try every
startfrom1toend. - If
s[start:end+1]is a palindrome, update:
dp[end] = min(dp[end], dp[start - 1] + 1)Return dp[n - 1].
Correctness
The palindrome table is correct because it follows the definition of a palindrome. A substring with matching ends is a palindrome exactly when its inner substring is a palindrome, with length 1, 2, and 3 handled directly.
For the dynamic programming part, consider an optimal palindrome partition of the prefix s[0:end+1].
Its last block must be some palindrome substring:
s[start:end+1]If start == 0, then the whole prefix is one palindrome and the answer is 0.
If start > 0, then there must be one cut before start. The part before that cut is s[0:start], whose optimal value is dp[start - 1]. Therefore, this partition uses:
dp[start - 1] + 1cuts.
The algorithm tries every possible start whose last block is a palindrome, so it considers the last block of the optimal solution. It takes the minimum over all such choices, so dp[end] equals the minimum cuts needed for s[0:end+1].
Thus, dp[n - 1] is the minimum number of cuts for the whole string.
Complexity
Let n = len(s).
| Phase | Time | Space |
|---|---|---|
| Palindrome table | O(n^2) | O(n^2) |
| Minimum cut DP | O(n^2) | O(n) |
| Total | O(n^2) | O(n^2) |
With n <= 2000, this fits the intended dynamic programming approach.
Implementation
class Solution:
def minCut(self, s: str) -> int:
n = len(s)
is_pal = [[False] * n for _ in range(n)]
for start in range(n - 1, -1, -1):
for end in range(start, n):
if s[start] == s[end] and (
end - start <= 2 or is_pal[start + 1][end - 1]
):
is_pal[start][end] = True
dp = [0] * n
for end in range(n):
if is_pal[0][end]:
dp[end] = 0
continue
best = end
for start in range(1, end + 1):
if is_pal[start][end]:
best = min(best, dp[start - 1] + 1)
dp[end] = best
return dp[n - 1]Code Explanation
We first build a table for palindrome checks:
is_pal = [[False] * n for _ in range(n)]The table is filled from right to left by start:
for start in range(n - 1, -1, -1):This order ensures that when we need:
is_pal[start + 1][end - 1]that inner substring has already been computed.
The palindrome check is:
if s[start] == s[end] and (
end - start <= 2 or is_pal[start + 1][end - 1]
):
is_pal[start][end] = TrueThen dp[end] stores the minimum cuts for the prefix ending at end.
If the whole prefix is already a palindrome:
if is_pal[0][end]:
dp[end] = 0
continueOtherwise, the worst case is cutting between every character:
best = endFor a prefix of length end + 1, the maximum needed cuts is end.
Then we try each possible starting index of the last palindrome block:
for start in range(1, end + 1):If that last block is a palindrome, we update the answer:
best = min(best, dp[start - 1] + 1)Finally:
return dp[n - 1]returns the minimum cuts for the whole string.
Testing
def run_tests():
s = Solution()
assert s.minCut("aab") == 1
assert s.minCut("a") == 0
assert s.minCut("ab") == 1
assert s.minCut("aba") == 0
assert s.minCut("aabb") == 1
assert s.minCut("banana") == 1
assert s.minCut("abcde") == 4
assert s.minCut("aaaaa") == 0
print("all tests passed")
run_tests()Test meaning:
| Test | Why |
|---|---|
"aab" | Standard example: `“aa” |
"a" | Single character needs no cut |
"ab" | Two different characters need one cut |
"aba" | Whole string is already a palindrome |
"aabb" | Best partition is `“aa” |
"banana" | Best partition is `“b” |
"abcde" | Every character must stand alone |
"aaaaa" | Whole string is palindrome |