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

6.6 KiB
Raw Blame History

title description date slug categories tags math
分割回文串dp 分割回文串DP问题返回的是最小分割次数而不是总方案数 2025-05-07T23:23:00+08:00 分割回文串dp
算法题
动态规划
回文串
算法
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-1j 之间进行一次切割,将 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=0s[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 计算的顺序是 i0 到 $n-1$,确保在计算 dp[i] 时,dp[j-1] (其中 j-1 < i) 的值已经被计算出来。

因此我们不能使用上文中的回文串判断方法,而是需要使用动态规划预处理出所有子串是否是回文串。我们建一个二维数组 isPalindromeisPalindrome[i][j] 表示 s[i..j] 是否是回文串。根据回文串的性质:

一个字符串是回文串,当且仅当它的首尾字符相同,并且去掉首尾,中间的子串也是回文串。

因此我们很自然地分3种情况

  • 单个字符,显然是回文串。
  • 两个字符,如果相同,则是回文串。
  • 多个字符,如果首尾字符相同,并且去掉首尾后中间的子串也是回文串,则它是回文串。

完整代码:

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];
}

这样优化后的时间复杂度是 $O(n^2)$,空间复杂度也是 $O(n^2)$,比上文中的 O(2^n) 要高效得多。