一、项目背景详细介绍
1. 回文与回文分区问题概述
回文(Palindrome) 指从左到右与从右到左读取完全相同的字符串,如 "aba", "abba", "a", 乃至空串。
回文分区(Palindromic Partitioning) 问题是字符串分割与动态规划中的经典题目:
给定字符串
s,将其划分为若干子串,使得每个子串都是回文。常见目标有两类:
1)枚举型:输出所有可能的回文划分;
2)优化型:在所有回文划分中,最小化切分次数(或等价地最小化分段数)。
“最小切分次数”通常定义为:把 s 切成 k 个回文段需要 k-1 次切割。因此,最小切分次数 = 最少分段数 - 1。例如:
-
s = "aab"的回文划分有:["a","a","b"]、["aa","b"]。-
最少分段为 2(
["aa","b"]),对应 最小切分次数 = 1。
-
-
s = "abba"的最少分段为 1(整串回文),对应 最小切分次数 = 0。
2. 问题的重要性与应用
回文分区兼具搜索与动态规划特征:
-
在编译器与语法分析中,子串有效性检查经常出现布尔表预处理(如回文表);
-
在文本处理/字符串分析中,需要快速分段并满足特定“合法性”约束;
-
在算法教学中,它串联了回文判定预处理、回溯搜索、DP 优化、路径重构等核心技巧;
-
在面试中是高频题,考查代码工程化能力与复杂度权衡。
3. 常见变体
-
输出全部回文划分(回溯+剪枝);
-
计算最小切分次数(DP 或记忆化搜索);
-
在“最优值”基础上,恢复一组最优划分方案;
-
统计回文子串总数(同一张回文表可一鱼多吃);
-
在线/流式扩展(较难,常用 Manacher 或中心扩展改造)。
二、项目需求详细介绍
为便于教学与复用,本项目实现下列功能,并给出清晰 API:
-
输入/输出
-
输入:字符串
s(仅小写/大小写混合/含空格均可;本文采用通用字符处理)。 -
输出:
-
List<List<String>>:输出所有回文分区(枚举型)。 -
int:最小切分次数(优化型)。 -
List<String>:恢复一组最优分区方案(可选但实用)。
-
-
-
必备算法模块
-
回文表预处理:
isPal[i][j]表示s[i..j]是否回文,O(n²) 时间/空间; -
回溯搜索:在
isPal的支撑下枚举所有方案,显著剪枝; -
最小切分 DP:计算最小切割次数,O(n²);
-
方案重建:从 DP 或记录数组中构造一组最优分区。
-
-
工程与教学要求
-
面向对象封装,类职责清晰;
-
充分中文注释,便于课堂讲解与二次开发;
-
提供覆盖主流程的
Main测试; -
处理边界:空串、单字符、重复字符串、全不回文、全回文、超长字符串性能等。
-
三、相关技术详细介绍
1. 回文判定与预处理
最朴素的回文判定是 O(L) 双指针;在回溯中反复调用会放大到 O(n³)。
优化:预处理 isPal 二维布尔表:
-
isPal[i][i] = true -
isPal[i][i+1] = (s[i]==s[i+1]) -
一般:
isPal[i][j] = (s[i]==s[j]) && isPal[i+1][j-1]
按子串长度从小到大填表,时间 O(n²),空间 O(n²)。
有了isPal,任意子串回文判定变 O(1)。
2. 回溯(DFS)与剪枝
从起点 start=0 开始,尝试每个 end>=start,若 isPal[start][end] 为真,则选择子串 s[start..end] 进入下一层 start=end+1。
-
剪枝:非回文直接跳过;
-
路径:用
List<String>维护当前切分轨迹,到达start==n时加入答案。
复杂度取决于输出规模(指数级最坏),但 isPal 可以显著减少无效探索。
3. 最小切分次数的 DP
令 cut[i] 表示前 i 个字符(s[0..i-1])的最小切分次数。常见两种等价写法:
-
写法 A(对切割次数):
-
初始化:
cut[0] = -1(空串无需分段,令切割次数为 -1 方便转移) -
转移:对所有
j < i,若isPal[j][i-1],则cut[i] = min(cut[i], cut[j] + 1) -
结果:
cut[n]即最小切分次数
-
-
写法 B(对分段数):
-
part[i]为最少分段数,初始part[0]=0; -
若
isPal[j][i-1],则part[i] = min(part[i], part[j]+1); -
最小切分 =
part[n]-1。
-
本文采用写法 A,简洁、常见、利于方案重建。
4. 方案重建
在 DP 时额外记录最佳 prev[i]:到达 i 的最优切割来自哪个 j(使 isPal[j][i-1] 且 cut[i]=cut[j]+1)。
最终从 i=n 倒推到 0,即可恢复一条最优回文分区;再反转即可得到从左到右的方案。
5. 复杂度分析
-
预处理回文表:O(n²) 时间,O(n²) 空间;
-
回溯枚举:与答案数量相关,理论最坏指数;
-
最小切分 DP:O(n²) 时间,O(n) 或 O(n²) 空间(是否保留
isPal决定)。
在实际字符串(自然语言、随机字符)中,回文子串稀疏,回溯常能较快结束;而 DP 部分稳定在 O(n²)。
四、实现思路详细介绍
-
类设计
-
PalPartitioningSolver:核心算法类-
precomputePalindrome(s):构建isPal -
allPartitions(s):回溯枚举全部回文分区 -
minCut(s):计算最小切分次数 -
minCutPartition(s):恢复一条最优分区方案 -
minCutMemo(s)(可选):记忆化搜索计算最小切分
-
-
Main:演示与简单测试
-
-
数据结构
-
boolean[][] isPal:回文表 -
回溯用
Deque<String>或List<String>存放路径 -
DP 用
int[] cut与int[] prev
-
-
边界与鲁棒性
-
空串:所有函数应安全返回(最小切分=0,分区结果为
[]或[[]]视定义,本文输出[[]]表示“零分段”) -
单字符:天然回文
-
大小写与非字母:按字符逐一比较,天然支持
-
超长串:建议只做最小切分,不枚举全部方案
-
-
可扩展点
-
支持仅字母数字参与回文(预处理前清洗字符集)
-
支持忽略大小写(统一小写)
-
输出所有最小切分方案(需要 DAG 回溯)
-
五、完整实现代码
/*********************** 文件:PalPartitioningSolver.java ************************/
import java.util.*;
/**
* 回文分区问题(Palindromic Partitioning)综合求解器
* 功能:
* 1)预处理回文表 O(n^2)
* 2)回溯枚举所有回文分区
* 3)DP 求最小切分次数
* 4)从 DP 记录中恢复一组最优分区方案
* 5)可选:记忆化搜索求最小切分
*/
public class PalPartitioningSolver {
/**
* 预处理:构建回文判定表 isPal
* isPal[i][j] == true 表示 s[i..j](含端点)是回文子串
* 时间复杂度 O(n^2),空间 O(n^2)
*/
public boolean[][] precomputePalindrome(String s) {
int n = s.length();
boolean[][] isPal = new boolean[n][n];
// 按子串长度 len 从小到大填表
for (int len = 1; len <= n; len++) {
for (int i = 0; i + len - 1 < n; i++) {
int j = i + len - 1;
if (len == 1) {
isPal[i][j] = true; // 单字符必回文
} else if (len == 2) {
isPal[i][j] = (s.charAt(i) == s.charAt(j)); // 两字符相等才回文
} else {
isPal[i][j] = (s.charAt(i) == s.charAt(j)) && isPal[i + 1][j - 1];
}
}
}
return isPal;
}
/**
* 回溯:输出所有回文分区
* 返回:每个元素是一条完整分区(从左到右的子串列表)
* 若 s 为空串,返回列表中包含一个空列表,表示“零分段”的合法方案
*/
public List<List<String>> allPartitions(String s) {
List<List<String>> ans = new ArrayList<>();
int n = s.length();
boolean[][] isPal = precomputePalindrome(s);
// 路径使用栈/列表保存当前切分
Deque<String> path = new ArrayDeque<>();
dfsAllPartitions(s, 0, isPal, path, ans);
return ans;
}
// 辅助:基于 isPal 的回溯
private void dfsAllPartitions(String s, int start, boolean[][] isPal,
Deque<String> path, List<List<String>> ans) {
int n = s.length();
if (start == n) {
// 到达末尾,一条完整路径
ans.add(new ArrayList<>(path));
return;
}
for (int end = start; end < n; end++) {
if (isPal[start][end]) {
// 可切;选择 s[start..end]
path.addLast(s.substring(start, end + 1));
dfsAllPartitions(s, end + 1, isPal, path, ans);
// 回溯
path.removeLast();
}
}
}
/**
* 动态规划:最小切分次数(最少切割几刀,使每段为回文)
* 采用 cut[i] 表示 s[0..i-1] 的最小切分次数,cut[0] = -1
* 转移:若 isPal[j][i-1] 为真,则 cut[i] = min(cut[i], cut[j] + 1)
* 结果:cut[n]
*/
public int minCut(String s) {
int n = s.length();
if (n <= 1) return 0;
boolean[][] isPal = precomputePalindrome(s);
int[] cut = new int[n + 1];
Arrays.fill(cut, Integer.MAX_VALUE / 2);
cut[0] = -1; // 便于转移
for (int i = 1; i <= n; i++) {
// 尝试所有 j < i,使 s[j..i-1] 为回文
for (int j = 0; j < i; j++) {
if (isPal[j][i - 1]) {
cut[i] = Math.min(cut[i], cut[j] + 1);
}
}
}
return cut[n];
}
/**
* 在求最小切分次数的同时,记录前驱位置以恢复一组最优回文分区方案
* 返回值:一条最优分区(从左到右)
*/
public List<String> minCutPartition(String s) {
int n = s.length();
List<String> empty = new ArrayList<>();
if (n == 0) return empty;
boolean[][] isPal = precomputePalindrome(s);
int[] cut = new int[n + 1];
int[] prev = new int[n + 1]; // 记录到达 i 的最佳 j
Arrays.fill(cut, Integer.MAX_VALUE / 2);
Arrays.fill(prev, -1);
cut[0] = -1;
for (int i = 1; i <= n; i++) {
for (int j = 0; j < i; j++) {
if (isPal[j][i - 1] && cut[j] + 1 < cut[i]) {
cut[i] = cut[j] + 1;
prev[i] = j;
}
}
}
// 通过 prev 回溯,恢复一条最优分区
List<String> res = new ArrayList<>();
int idx = n;
while (idx > 0) {
int j = prev[idx];
if (j < 0) { // 理论上不会发生(除非 s 为空或异常)
// 兜底:整串作为一段
res.add(s.substring(0, idx));
break;
}
res.add(s.substring(j, idx));
idx = j;
}
Collections.reverse(res);
return res;
}
/**
* 记忆化搜索:自顶向下求最小切分次数
* f(i) 表示 s[i..n-1] 的最小切分次数
* 转移:f(i) = min( 1 + f(j+1) ) 对所有 isPal[i][j] 为真
* 边界:f(n) = -1(空串无需切割)
*/
public int minCutMemo(String s) {
int n = s.length();
boolean[][] isPal = precomputePalindrome(s);
int[] memo = new int[n + 1];
Arrays.fill(memo, Integer.MIN_VALUE);
return dfsMinCut(0, s, isPal, memo);
}
// 记忆化搜索的递归体
private int dfsMinCut(int i, String s, boolean[][] isPal, int[] memo) {
int n = s.length();
if (i == n) return -1; // 空串无需切割
if (memo[i] != Integer.MIN_VALUE) return memo[i];
int best = Integer.MAX_VALUE / 2;
for (int j = i; j < n; j++) {
if (isPal[i][j]) {
// s[i..j] 作为一段,后缀的切分次数 + 1
best = Math.min(best, 1 + dfsMinCut(j + 1, s, isPal, memo));
}
}
memo[i] = best;
return best;
}
}
/******************************* 文件:Main.java ********************************/
public class Main {
public static void main(String[] args) {
PalPartitioningSolver solver = new PalPartitioningSolver();
// 示例一:枚举所有回文分区
String s1 = "aab";
System.out.println("=== 所有回文分区(" + s1 + ")===");
var all1 = solver.allPartitions(s1);
for (var list : all1) {
System.out.println(list);
}
// 示例二:最小切分次数
String s2 = "aab";
int minCut = solver.minCut(s2);
System.out.println("\n=== 最小切分次数(" + s2 + ")===\n" + minCut);
// 示例三:恢复一组最优分区方案
System.out.println("\n=== 一组最优分区方案(" + s2 + ")===");
var best = solver.minCutPartition(s2);
System.out.println(best + "(切分次数=" + (best.size() - 1) + ")");
// 示例四:更长字符串的最小切分 & 记忆化搜索验证
String s3 = "abacdcaba";
System.out.println("\n=== 最小切分次数(" + s3 + ")===");
System.out.println("自底向上 DP :" + solver.minCut(s3));
System.out.println("自顶向下 Memo:" + solver.minCutMemo(s3));
// 边界用例:空串、单字符、全回文、全不回文
String sEmpty = "";
System.out.println("\n=== 边界用例 ===");
System.out.println("空串所有分区:" + solver.allPartitions(sEmpty));
System.out.println("空串最小切分次数:" + solver.minCut(sEmpty));
String sOne = "x";
System.out.println("单字符所有分区:" + solver.allPartitions(sOne));
System.out.println("单字符最小切分次数:" + solver.minCut(sOne));
System.out.println("单字符一组最优方案:" + solver.minCutPartition(sOne));
String sPal = "abba";
System.out.println("全回文最小切分次数(abba):" + solver.minCut(sPal)
+ ";方案:" + solver.minCutPartition(sPal));
String sNoPal = "abcd"; // 每个字符独立回文,需 3 次切割
System.out.println("全不回文最小切分次数(abcd):" + solver.minCut(sNoPal)
+ ";方案:" + solver.minCutPartition(sNoPal));
}
}
六、代码详细解读
-
precomputePalindrome(String s)-
作用:以 O(n²) 时间构建布尔表
isPal[i][j],用于 O(1) 查询任意子串是否回文。 -
要点:按子串长度从小到大填表,减少依赖;单字符回文,双字符相等即回文,其余
isPal[i][j] = (s[i]==s[j]) && isPal[i+1][j-1]。 -
适用:作为所有后续算法(回溯/DP/记忆化)的公共加速模块。
-
-
allPartitions(String s)-
作用:基于
isPal回溯枚举所有回文分区方案。 -
流程:从
start出发,尝试所有end,isPal[start][end]为真则选之并进入下一层;到达末尾将路径加入答案。 -
适用:教学演示、需要列出全部方案的业务(注意最坏指数复杂度)。
-
-
dfsAllPartitions(...)(私有)-
作用:
allPartitions的递归主体,维护当前路径并在终止点收集结果。 -
要点:使用
Deque<String>做入栈/出栈避免多余复制。
-
-
minCut(String s)-
作用:以 DP 计算最小切分次数,时间 O(n²)。
-
定义:
cut[i]=s[0..i-1]的最小切分次数,cut[0]=-1; -
转移:枚举所有
j<i,若isPal[j][i-1],取cut[i] = min(cut[i], cut[j]+1)。 -
适用:只需最小切割次数的场景。
-
-
minCutPartition(String s)-
作用:在求最小切分的同时记录
prev[i],最终恢复一条最优分区(从左到右)。 -
要点:DP 时若发现更优,更新
prev[i]=j;结束后自n逆推到0得到分段,再反转输出。 -
适用:既要最小切割次数,又要给出一条对应方案的业务。
-
-
minCutMemo(String s)/dfsMinCut(...)-
作用:提供一个自顶向下记忆化版本的最小切分解法,用来对照自底向上。
-
定义:
f(i)表示s[i..]的最小切分次数,f(n)=-1; -
转移:枚举
j>=i,若isPal[i][j],f(i) = min(f(i), 1 + f(j+1));使用memo缓存避免重复。 -
适用:递归思维或需要局部求解时;也可作为正确性对拍。
-
-
Main.main-
作用:样例演示与基本验证,覆盖“枚举全部”“最小切分”“方案恢复”“记忆化”“边界用例”等。
-
七、项目详细总结
-
知识要点串联:
-
用 回文表将回文判定从 O(L) 降至 O(1),为搜索与 DP 提供高效基础。
-
回溯 解决“枚举型”问题:结构清晰,易剪枝,输出规模主导复杂度。
-
DP 解决“优化型”问题:O(n²) 时间即可拿下最小切分次数;辅以前驱记录即可重建方案。
-
记忆化搜索 与 自底向上互为镜像,便于理解与对拍。
-
-
工程性:
-
单一类封装常用功能,主函数集中演示;
-
注释完整、接口清晰、边界可控;
-
可在生产中直接复用
minCut与minCutPartition两大接口。
-
-
性能权衡:
-
预处理 O(n²) 与空间 O(n²) 是多数字符串 DP 的常态;
-
当
n很大且只需最小切割次数,可考虑中心扩展在线计算回文以降空间(见扩展)。 -
枚举所有方案最坏指数,不建议对超长
s使用。
-
八、项目常见问题及解答(FAQ)
-
Q:为什么
cut[0] = -1?
A:这样当s[0..i-1]以某个回文s[0..i-1]直接构成一段时,cut[i] = cut[0] + 1 = 0,自然表示“零切割”。该初始化让转移更简洁。 -
Q:
allPartitions的时间复杂度是多少?
A:与答案数量相关,最坏指数级。因为每个切分点都有“切/不切”的选择,且需要保证子串回文。isPal使得判定降到 O(1),但整体仍由解的规模主导。 -
Q:空间能否优化到 O(n)?
A:若只求最小切分次数,可以不显式存isPal,改成中心扩展边算边转移:对每个中心向两侧扩展,实时更新cut,整体仍 O(n²) 时间、O(n) 空间(见扩展方向)。 -
Q:如何输出所有最小切分方案?
A:在最小切分 DP 后得到一张 “可行前驱” 的 DAG(所有使得cut[i]=cut[j]+1且isPal[j][i-1]的边),再从n到0回溯 DFS,收集所有路径即可。注意数量可能非常大。 -
Q:支持忽略大小写或只考虑字母数字吗?
A:可以。在precomputePalindrome前统一预处理字符串(如toLowerCase()、去除非字母数字),同时保留原索引映射用于还原分区。 -
Q:记忆化与自底向上谁更好?
A:二者复杂度相近。记忆化套路直观,便于局部求解;自底向上在工程中更稳定,迭代不受递归深度限制。
九、扩展方向与性能优化
-
空间优化的最小切分(中心扩展版,O(n) 空间)
-
不建
isPal。对每个中心(奇回文中心i、偶回文中心i,i+1)向外扩展,每扩一步(L,R)都代表s[L..R]是回文,于是可以用它更新cut[R+1] = min(cut[R+1], cut[L] + 1)。 -
这样仅需
int[] cut,空间降至 O(n)。
-
-
Manacher 算法结合 DP(理论探索)
-
Manacher 可在 O(n) 求出每个位置的最大回文半径,再配合线段覆盖/跳表结构加速更新
cut。实现复杂度高,综合实用性需权衡。
-
-
输出所有最小切分方案(多路径恢复)
-
记录所有最优前驱
prevs[i](列表),最终从n回溯 DFS,得到全部最优分区。需谨慎内存与输出规模。
-
-
长文本的工程实践
-
对超长
s,通常只求最小切分,禁用全枚举。 -
可分段处理(滑窗),或只对热点区间做枚举。
-
多线程:对不同中心或不同区间并行扩展(需小心共享数组写入同步,通常改为分桶合并)。
-
-
更多业务规则融合
-
限制分段最大/最小长度;
-
自定义“合法子串”判定(不仅是回文),只需替换
isPal的构造逻辑即可复用回溯/DP 框架。
-

被折叠的 条评论
为什么被折叠?



