blog/content/post/算法题/分割回文串dp.md
2025-05-08 01:00:35 +08:00

129 lines
6.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
title: 分割回文串dp
description: 分割回文串DP问题返回的是最小分割次数而不是总方案数
date: 2025-05-07T23:23:00+08:00
slug: 分割回文串dp
# image: helena-hertz-wWZzXlDpMog-unsplash.jpg
categories:
- 算法题
tags: [
"动态规划",
"回文串",
"算法"
]
math: true
---
上文中我们寻找了分割回文串的所有方案。但是如果我们只需要知道最小分割次数,而不需要知道所有具体方案,我们可以使用动态规划来**更高效地**解决这个问题。
首先,我们定义动态规划的状态:
设 $dp[i]$ 表示将字符串 $s$ 从索引 $0$ 到索引 $i$ 的前缀子串 $s[0..i]$ 分割成若干个回文子串所需的**最小切割次数**。我们这里的索引 $i$ 的范围是从 $0$ 到 $n-1$,其中 $n$ 是字符串 $s$ 的长度。
我们的目标是计算 $dp[n-1]$。
接下来,考虑如何从已知状态计算 $dp[i]$。要计算 $s[0..i]$ 的最小切割次数,我们可以考虑最后一次切割(或不切割)。这意味着最后一块回文子串是 $s[j..i]$,其中 $j$ 是这个回文子串的起始索引,$0 \le j \le i$。
如果 $s[j..i]$ 是一个回文子串,那么将 $s[0..i]$ 分割成回文串的一种方案可以是:先将前面的子串 $s[0..j-1]$ 分割好(最优方案需要 $dp[j-1]$ 次切割),然后在索引 $j-1$ 和 $j$ 之间进行一次切割,将 $s[j..i]$ 作为最后一块。
这里需要分情况考虑 $j$ 的值:
1. 如果 $j = 0$:这意味着最后一块回文子串是整个前缀 $s[0..i]$。如果 $s[0..i]$ 本身就是回文串,那么就不需要任何切割,此时的切割次数是 $0$。
2. 如果 $j > 0$:这意味着最后一块回文子串是 $s[j..i]$。前面的子串是 $s[0..j-1]$。将 $s[0..i]$ 分割的次数就是将 $s[0..j-1]$ 分割的最小次数 $dp[j-1]$,再加上索引 $j-1$ 后面这一刀,所以是 $dp[j-1] + 1$ 次切割。
我们要求的是**最小**切割次数 $dp[i]$,所以需要考虑所有可能的起始索引 $j$ ($0 \le j \le i$),只要 $s[j..i]$ 是回文串,它就提供了一种可能的分割方案。$dp[i]$ 就是所有这些有效方案中切割次数的最小值。
因此,状态转移方程可以写为:
$$dp[i] = \min_{0 \le j \le i \atop s[j..i] \text{ 是回文串}} \{ \text{cost}(j, i) \}$$
其中,$\text{cost}(j, i)$ 是以 $s[j..i]$ 作为最后一块回文子串时的总切割次数,它是一个分段函数:
$$
\begin{cases}
0 & \text{if } j = 0, \\
dp[j-1] + 1 & \text{if } j > 0
\end{cases}
$$
将 $\text{cost}(j, i)$ 代入方程:
$$dp[i] = \min \left( \begin{cases} 0 & \text{if } 0 \le j \le i \text{ and } s[j..i] \text{ is palindrome and } j = 0, \\ dp[j-1] + 1 & \text{if } 0 \le j \le i \text{ and } s[j..i] \text{ is palindrome and } j > 0 \end{cases} \right)$$
这个分段 min 函数可以简化表达。我们是在所有满足 $s[j..i]$ 是回文串的 $j$ 值中取最小值。如果 $j=0$ 且 $s[0..i]$ 是回文,最小就是 0。否则如果存在其他 $j>0$ 使得 $s[j..i]$ 是回文,我们就取所有 $dp[j-1]+1$ 中的最小值,然后和可能存在的 0 比较。
更简洁的表达方式是:
对于 $i = 0, 1, \ldots, n-1$:
$$dp[i] = \min_{0 \le j \le i \atop s[j..i] \text{ 是回文串}} \begin{cases} 0 & \text{if } j = 0, \\ dp[j-1] + 1 & \text{if } j > 0 \end{cases}$$
**基本情况:**
$dp[0]$ 的计算:$i=0$。可能的 $j$ 只有 $0$。$s[0..0]$ 是回文串。$j=0$cost 是 $0$。
$dp[0] = \min(\{0 \mid j=0, s[0..0] \text{ 是回文} \}) = 0$。
在代码中,`dp[i] = i` 的初始化给 `dp[i]` 提供了一个初始上界(最坏情况下,$s[0..i]$ 需要 $i$ 次切割,即每次切割一个字符),然后通过内层循环不断用 `dp[j-1] + 1` 或 0 来尝试更新 `dp[i]` 取到更小值。
**需要注意:**
* 为了使这个 DP 高效,判断 "s[j..i] 是回文串" 需要是 O(1) 操作,这依赖于我们提前进行的 O(n^2) 预处理。
* DP 计算的顺序是 $i$ 从 $0$ 到 $n-1$,确保在计算 $dp[i]$ 时,$dp[j-1]$ (其中 $j-1 < i$) 的值已经被计算出来
因此我们不能使用上文中的回文串判断方法而是需要使用动态规划预处理出所有子串是否是回文串我们建一个二维数组 `isPalindrome``isPalindrome[i][j]` 表示 $s[i..j]$ 是否是回文串根据回文串的性质
> 一个字符串是回文串,当且仅当它的首尾字符相同,并且去掉首尾,中间的子串也是回文串。
因此我们很自然地分3种情况
- 单个字符显然是回文串
- 两个字符如果相同则是回文串
- 多个字符如果首尾字符相同并且去掉首尾后中间的子串也是回文串则它是回文串
完整代码
```java
public static int minCut(String s) {
int len = s.length();
// 首先构建回文子串表
boolean[][] isPalindrome = new boolean[len][len];
// 单个字符肯定是回文
for(int i=0;i<len;i++) {
isPalindrome[i][i] = true;
}
// 两个字符是回文,只需首尾相等
for(int i=0;i<len-1;i++) {
if(s.charAt(i)==s.charAt(i+1)) isPalindrome[i][i+1]=true;
}
// 处理3个及以上字符的子串
// 是回文,当且仅当首尾相等且去掉首尾仍然是回文
for(int curlen=3;curlen<=len;curlen++) {
for(int i=0;i<=len-curlen;i++) {
int right = i+curlen-1; // 计算右边界
isPalindrome[i][right] = (s.charAt(i) == s.charAt(right))
&& isPalindrome[i+1][right-1];
}
}
// 然后,动态规划来求最小分割次数
int[] dp = new int[len];
// 初始化dp数组
// 如果是0不需要分割单个字符。其他的取最坏情况都要分割i次即每个字符都要分割。
for(int i=0;i<len;i++) {
dp[i] = i;
}
for(int i=0;i<len;i++) {
for(int j=0;j<=i;j++) {
// 注意这里是j<=i因为单字符串也是回文串需要正确考虑
if(isPalindrome[j][i]) {
if(j==0) dp[i]=0;
else dp[i]=Math.min(dp[i], dp[j-1]+1);
// dp[i] 的当前值:它可能是我们为 s[0...i] 设置的初始“最坏情况”值 (i),或者是之前在内层循环中,因为某个更小的 j' 值 (j' < j) 找到了一个更好的分割方案后更新过的值。它代表了到目前为止,我们已知的将 s[0...i] 分割成回文串的最小切割次数。
}
}
}
return dp[len-1];
}
```