分割回文串 II 图解题解
这道题到底在问什么
- 输入
- s="aab"
- 输出
- 1("aa" + "b",切 1 刀)
- 输入
- s="a"
- 输出
- 0(本身就是回文,不用切)
先想最直接的笨办法
记住「枚举最后一段的左端 j,若 s[j..i] 回文就用 dp[j−1]+1 更新 dp[i]」,下面逐位套它。(动画第 3 步)
最优解:为什么这么做
一句话答案:LeetCode 132 分割回文串 II 求把串切成若干回文段的最少刀数。两层动态规划:先用回文表(每段回不回文先记好)O(1) 判回文,再让 dp[i] 记前 i+1 位最少切割、由更短的 dp 转移。时空 O(n²)。
分割回文串 II 到底在求什么
给字符串 s,切成若干段、每段都是回文串(正反读一样,如 "aa"、"aba"),问最少切几刀(切 k 刀得 k+1 段)、即刀数的最小值。题面 s="aab" 切 1 刀成 "aa" 和 "b";s="a" 本身回文,0 刀。
为什么把每种切法都试一遍会爆
长度 n 的串,相邻字符间每处都可切可不切,共 2ⁿ⁻¹ 种切法。n 才二十出头就几十万条,指数增长把逐条枚举压垮。
就算改成往前递归,也躲不开同一子串被反复验证是不是回文,而判一次回文最坏要扫半个串、是 O(n)。叠一起就没法用。
判一段是不是回文,怎么压成一步
先省掉反复判回文。一个串是回文靠两条:首尾字符相等,且去掉首尾的里层也回文。"abcba" 就因首尾 'a' 相等、里层 "bcb" 也回文。大回文踩着里层小回文层层搭起来,这就是区间 DP(长区间直接查短区间算好的结果、不重算)。
开回文表 pal,pal[j][i] 记「s 第 j 到第 i 位是否回文」(下标从 0 数起):s[j] 与 s[i] 相等、且里层 pal[j+1][i−1] 已为真时成立;段长不超过 3 时里层至多一字符、天然回文,首尾一等即可。里层更早填好,查一步就是 O(1),判回文从 O(n) 降下来。
dp[i] 怎么由更短的 dp 拼出来
回文能一步判就算刀数。定义 dp[i] 为「s 前 i+1 位最少切几刀」,算过的往下记、不重算。
算 dp[i] 时,最后一段右端固定在第 i 位、左端记作第 j 位。s[j..i] 回文(查表即知)时它不用切,前面「s 前 j 位」是更短子问题、答案 dp[j−1],加 1 刀得 dp[j−1]+1。让 s[j..i] 回文的 j 取最小就是 dp[i],这叫转移。
左端 j 落在第 0 位时整段前缀 s[0..i] 自己就回文、0 刀,dp[i] 直接是 0。dp[i] 起手设成 i 兜底,是「每字符各切一刀」的上界,会被更省方案压掉。
拿 aab 亲手把回文表和 dp 填一遍
串 "aab" 三字符,从左到右来。头一位 'a' 单字符回文,前缀 "a" 的 dp 记 0。第二位 'a':"aa" 首尾都 'a'、段长 2 成立,整段回文,dp 记 0。
末一位 'b':往左试,"aab"、"ab" 首尾都不等,只有 "b" 单字符回文。最后一段 "b",前面 "aa" 的 dp 记着 0,加一刀得 1。dp 末格 1 就是答案:切 1 刀成 "aa" 和 "b",对上题面。
短段特判和 dp 初值,哪个先崩
外层每个右端、内层枚举左端都是 O(n),查表判回文 O(1),时间 O(n²)。回文表 n×n 占 O(n²)、dp 只 O(n),主导 O(n²);改用「中心扩展」逐中心外扩,空间可压到 O(n)。
短段特判漏了最伤:段长不超过 3 本该首尾等就成立,漏了会去查里层 pal[j+1][i−1] 的假值、把长回文漏判。dp 初值也别写 0,得是 i 这个「全切单字符」的上界,写小了会混进假刀数。整段本就回文时答案 0、凑不出长回文时 n−1。
▶ 动画逐步走查(共 35 步)——想跟着动画一帧帧对照就展开
- 3记住「枚举最后一段的左端 j,若 s[j..i] 回文就用 dp[j−1]+1 更新 dp[i]」,下面逐位套它。
- 4演示串 banana。右端先固定在 i=0(字符 'b'),把它当作最后一段的结尾;dp[0] 初值设为最坏情况 0——单字符前缀最多切 0 刀。
- 5试左端 j=0:子串 "b" 单字符必是回文,且 j=0 说明整个前缀本身就是一段回文,dp[0] 直接置 0,一刀都不用切。
- 6右端 i=0 的所有左端都试完,定下 dp[0]=0:前缀 "b" 最少切 0 刀。
- 7右端来到 i=1(字符 'a'):dp[1] 初值设为最坏情况 1——把 "ba" 切成两个单字符要 1 刀。
- 8试左端 j=0:子串 s[0..1]="ba" 首尾 'b'≠'a',不是回文,跳过这个切法。
- 9试左端 j=1:末段 "a" 是回文 → 候选 dp[0]+1=1,不比当前 dp[1]=1 更小,保持 1。
- 10右端 i=1 试完,定下 dp[1]=1:前缀 "ba" 最少切 1 刀。
- 11右端来到 i=2(字符 'n'):dp[2] 初值设为最坏情况 2(三个单字符切 2 刀)。
- 12试左端 j=0:子串 "ban" 首尾 'b'≠'n',不是回文,跳过。
- 13试左端 j=1:子串 "an" 首尾 'a'≠'n',不是回文,跳过。
- 14试左端 j=2:末段 "n" 是回文 → 候选 dp[1]+1=2,与初值持平,dp[2] 保持 2。
- 15右端 i=2 试完,定下 dp[2]=2:前缀 "ban" 只能切成三个单字符。
- 16右端来到 i=3(字符 'a'):dp[3] 初值设为最坏情况 3。
- 17试左端 j=0:子串 "bana" 首尾 'b'≠'a',不是回文,跳过。
- 18试左端 j=1:末段 "ana" 是回文!前面只剩 "b":候选 dp[0]+1=1,比 3 小得多,更新 dp[3]=1。
- 19试左端 j=2:子串 "na" 首尾不等,不是回文,跳过。
- 20试左端 j=3:末段 "a" 是回文 → 候选 dp[2]+1=3,不如现在的 1,保持 dp[3]=1。
- 21右端 i=3 试完,定下 dp[3]=1:前缀 "bana" 最优切法是 b | ana。
- 22右端来到 i=4(字符 'n'):dp[4] 初值设为最坏情况 4。
- 23试左端 j=0:子串 "banan" 首尾 'b'≠'n',不是回文,跳过。
- 24试左端 j=1:子串 "anan" 首尾 'a'≠'n',不是回文,跳过。
- 25试左端 j=2:末段 "nan" 是回文 → 候选 dp[1]+1=2,比 4 小,更新 dp[4]=2。
- 26试左端 j=3:子串 "an" 首尾不等,不是回文,跳过。
- 27试左端 j=4:末段 "n" 是回文 → 候选 dp[3]+1=2,与当前 2 持平,保持。
- 28右端 i=4 试完,定下 dp[4]=2:前缀 "banan" 最少切 2 刀(如 b | a | nan)。
- 29右端来到最后一位 i=5(字符 'a'):dp[5] 初值设为最坏情况 5。
- 30试左端 j=0:整串 "banana" 首尾 'b'≠'a',不是回文,跳过。
- 31试左端 j=1:末段 "anana" 是回文!前面只剩 "b":候选 dp[0]+1=1,大幅变小,更新 dp[5]=1。
- 32试左端 j=2:子串 "nana" 首尾 'n'≠'a',不是回文,跳过。
- 33试左端 j=3:末段 "ana" 是回文 → 候选 dp[2]+1=3,不如 1,保持。
- 34试左端 j=4:子串 "na" 首尾不等,不是回文,跳过。
- 35试左端 j=5:末段 "a" 是回文 → 候选 dp[4]+1=3,不如 1,保持 dp[5]=1。
- 36右端 i=5 试完,定下 dp[5]=1:整串 "banana" 最少切 1 刀。
- 37全部算完,答案 = dp[5] = 1:banana 切成 b | anana("anana" 是回文),只需 1 刀。这就是「末段回文则 dp[j−1]+1」逐位递推出来的结果。
⚠️ 容易写错的地方
✗ 错:每判一次回文都重新 O(n) 扫一遍
✓ 对:用 pal 区间表 O(1) 查回文
回文判定若每次现扫会让总复杂度升到 O(n³);pal[j][i] 靠里层 pal[j+1][i−1] 递推,查一次 O(1)
✗ 错:dp[i] 初值设成 0 或无穷
✓ 对:初值设成 i(最坏每字符各切)
dp[i] 最坏是把 s[0..i] 切成 i+1 个单字符、切 i 刀;设 0 会漏更新,设无穷需额外处理整段回文的情形
✗ 错:回文判定漏了 i−j≤2 的短串
✓ 对:长度 ≤3 的首尾相等串直接判回文
长度 1、2、3 时去掉首尾后里层为空或单字符、必回文;不特判会去读越界的 pal[j+1][i−1]
完整代码(Python / C++ / Java)
Python
class Solution:
def minCut(self, s: str) -> int:
n = len(s)
pal = [[False] * n for _ in range(n)]
dp = [0] * n
for i in range(n):
best = i
for j in range(i + 1):
if s[j] == s[i] and (i - j <= 2 or pal[j + 1][i - 1]):
pal[j][i] = True
best = 0 if j == 0 else min(best, dp[j - 1] + 1)
dp[i] = best
return dp[-1]C++
#include <string>
#include <vector>
using namespace std;
class Solution {
public:
int minCut(string s) {
int n = s.size();
vector<vector<int>> pal(n, vector<int>(n));
vector<int> dp(n);
for (int i = 0; i < n; ++i) {
int best = i;
for (int j = 0; j <= i; ++j) if (s[j] == s[i] && (i - j <= 2 || pal[j+1][i-1])) {
pal[j][i] = 1;
best = j == 0 ? 0 : min(best, dp[j-1] + 1);
}
dp[i] = best;
}
return dp.back();
}
};Java
import java.util.*;
class Solution {
public int minCut(String s) {
int n = s.length();
boolean[][] pal = new boolean[n][n];
int[] dp = new int[n];
for (int i = 0; i < n; i++) {
int best = i;
for (int j = 0; j <= i; j++) if (s.charAt(j) == s.charAt(i) && (i - j <= 2 || pal[j + 1][i - 1])) {
pal[j][i] = true;
best = j == 0 ? 0 : Math.min(best, dp[j - 1] + 1);
}
dp[i] = best;
}
return dp[n - 1];
}
}复杂度
时间
O(n²)
n 是串长。外层 i、内层 j 各 O(n),回文判定借助 pal 表是 O(1),整体 O(n²)
空间
O(n²)
pal 回文表占 O(n²),dp 数组占 O(n),合计 O(n²)。也可用「中心扩展」把空间降到 O(n)
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 分割回文串 II 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
为什么非得先备一张回文表,不能边切边判回文吗?+
能边判,但会慢一档。切割的 dp 每往前一位,都要问「最后这段是不是回文」,若每次都从两端往里扫一遍,一次判定就是 O(n),套进两层循环整体升到 O(n³)。回文表把每段是否回文提前用「首尾相等 + 里层回文」递推好、存起来,dp 用时查一步就是 O(1)。多花一张 O(n²) 的表,换掉了反复扫描,是拿空间换时间。
dp[i] 为什么初值设成 i,而不是设 0 或很大的数?+
dp[i] 记前 i+1 位最少切几刀,最坏是每个字符各成一段、切 i 刀,所以 i 正是这段前缀刀数的天然上界,用它当起点既不会偏小、也省得另写一个「无穷大」。之后枚举左端时用取最小往下压,找到回文段就把刀数压得更低;若初值写成 0,等于让还没验证的方案凭空变成零刀,直接算错。
这题和「分割回文串 I」(LeetCode 131)差在哪?+
131 要列出所有的回文分割方案,是搜索题,通常用回溯把每种切法都走出来;132 只要「最少切几刀」这一个数,是最优化题,用 dp 递推。两题都靠「某段是不是回文」这个基本判断,所以回文表在两边都能用;区别在 131 关心有哪些切法、132 关心最少切几次,一个枚举方案、一个求极值。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 分割回文串 II 一步步讲透(全站已上线 948 份视频讲解,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
把这道题真正拿下
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。