目录
1.问题描述
给定一个字符串 s,要求将 s 分割成若干个子串,使得每个子串都是回文串。返回 s 所有可能的分割方案。
示例 1:
输入:s = "aab"
输出:[["a","a","b"],["aa","b"]]
解释:
两种分割方案:["a","a","b"] 和 ["aa","b"]
示例 2:
输入:s = "a"
输出:[["a"]]
提示:
1 <= s.length <= 16s仅由小写英文字母组成
2.问题分析
2.1 题目理解
这是一个典型的组合优化问题,需要在字符串的所有可能分割中找出满足所有子串都是回文的分割方案。由于字符串长度最大为16,回溯搜索是可行的,但需要高效的剪枝和优化。
2.2 核心洞察
- 回文判断:问题的核心是快速判断子串是否为回文,这可以通过预处理来优化
- 搜索空间:长度为n的字符串有2^(n-1)种可能的分割方式,需要有效剪枝
- 重叠子问题:不同分割方案可能多次判断同一子串的回文性,适合用动态规划或记忆化优化
- 解的结构:每个分割方案相当于在字符串的字符间隙中选择是否分割
2.3 破题关键
- 使用回溯法枚举所有可能的分割点
- 预处理回文判断信息,避免重复计算
- 剪枝:当当前分割产生的子串不是回文时,立即回溯
- 多种优化策略:动态规划预处理、中心扩展法、记忆化搜索等
3.算法设计与实现
3.1 回溯+动态规划预处理
核心思想:
使用动态规划预处理所有子串的回文信息,然后使用深度优先搜索回溯所有可能的分割方案。
算法思路:
- 使用动态规划构建二维数组
dp[i][j],表示s[i..j]是否为回文 - 定义回溯函数
backtrack(start, path),其中start表示当前搜索的起始位置 - 遍历所有可能的结束位置
end(从start到字符串末尾) - 如果
s[start..end]是回文,则将其加入当前路径,并递归处理剩余部分 - 回溯时删除最后加入的子串,继续尝试其他分割
Java代码实现:
import java.util.*;
class Solution {
public List<List<String>> partition(String s) {
List<List<String>> result = new ArrayList<>();
int n = s.length();
// 动态规划预处理回文信息
boolean[][] dp = new boolean[n][n];
for (int i = 0; i < n; i++) {
dp[i][i] = true; // 单个字符是回文
}
// 填充dp表
for (int len = 2; len <= n; len++) {
for (int i = 0; i <= n - len; i++) {
int j = i + len - 1;
if (s.charAt(i) == s.charAt(j)) {
if (len == 2 || dp[i + 1][j - 1]) {
dp[i][j] = true;
}
}
}
}
// 回溯搜索所有分割方案
backtrack(s, 0, new ArrayList<>(), result, dp);
return result;
}
private void backtrack(String s, int start, List<String> path,
List<List<String>> result, boolean[][] dp) {
// 如果已经处理完整个字符串,保存当前分割方案
if (start == s.length()) {
result.add(new ArrayList<>(path));
return;
}
// 尝试所有可能的分割
for (int end = start; end < s.length(); end++) {
// 如果s[start..end]是回文,则继续分割剩余部分
if (dp[start][end]) {
path.add(s.substring(start, end + 1));
backtrack(s, end + 1, path, result, dp);
path.remove(path.size() - 1); // 回溯
}
}
}
}
性能分析:
- 时间复杂度:O(n × 2n),其中n为字符串长度。动态规划预处理O(n²),回溯最多有2(n-1)种分割,每种需要O(n)时间构建子串
- 空间复杂度:O(n²),存储dp表,递归栈深度O(n)
- 优点:预处理后回文判断O(1),避免重复计算
- 缺点:预处理需要额外O(n²)空间
3.2 回溯+记忆化搜索
核心思想:
不预先计算所有子串的回文性,而是在回溯过程中使用记忆化技术缓存已计算的回文判断结果。
算法思路:
- 使用HashMap或二维Boolean数组缓存子串的回文判断结果
- 在回溯过程中,需要判断子串回文时先检查缓存
- 如果缓存中没有,则计算并存入缓存
- 递归回溯过程与解法一类似
Java代码实现:
import java.util.*;
class Solution {
public List<List<String>> partition(String s) {
List<List<String>> result = new ArrayList<>();
// 使用Boolean对象数组,null表示未计算
Boolean[][] memo = new Boolean[s.length()][s.length()];
backtrack(s, 0, new ArrayList<>(), result, memo);
return result;
}
private void backtrack(String s, int start, List<String> path,
List<List<String>> result, Boolean[][] memo) {
if (start == s.length()) {
result.add(new ArrayList<>(path));
return;
}
for (int end = start; end < s.length(); end++) {
if (isPalindrome(s, start, end, memo)) {
path.add(s.substring(start, end + 1));
backtrack(s, end + 1, path, result, memo);
path.remove(path.size() - 1);
}
}
}
private boolean isPalindrome(String s, int left, int right, Boolean[][] memo) {
if (memo[left][right] != null) {
return memo[left][right];
}
// 双指针法判断回文
int l = left, r = right;
while (l < r) {
if (s.charAt(l) != s.charAt(r)) {
memo[left][right] = false;
return false;
}
l++;
r--;
}
memo[left][right] = true;
return true;
}
}
性能分析:
- 时间复杂度:O(n × 2^n),但实际运行比暴力回溯快,因为避免了重复计算
- 空间复杂度:O(n²),存储记忆化数组,递归栈深度O(n)
- 优点:按需计算,可能比预处理所有子串更高效
- 缺点:仍然需要O(n²)空间,且递归调用开销较大
3.3 回溯+中心扩展法判断回文
核心思想:
使用中心扩展法判断子串是否为回文,无需预处理或记忆化,在回溯过程中实时判断。
算法思路:
- 在回溯过程中,对于每个可能的子串,使用中心扩展法判断是否为回文
- 中心扩展法从中心向两边扩展,检查字符是否相等
- 由于需要判断很多子串,可能重复计算,但实现简单
Java代码实现:
import java.util.*;
class Solution {
public List<List<String>> partition(String s) {
List<List<String>> result = new ArrayList<>();
backtrack(s, 0, new ArrayList<>(), result);
return result;
}
private void backtrack(String s, int start, List<String> path,
List<List<String>> result) {
if (start == s.length()) {
result.add(new ArrayList<>(path));
return;
}
for (int end = start; end < s.length(); end++) {
if (isPalindromeByCenter(s, start, end)) {
path.add(s.substring(start, end + 1));
backtrack(s, end + 1, path, result);
path.remove(path.size() - 1);
}
}
}
private boolean isPalindromeByCenter(String s, int left, int right) {
// 中心扩展法判断回文
while (left < right) {
if (s.charAt(left) != s.charAt(right)) {
return false;
}
left++;
right--;
}
return true;
}
}
性能分析:
- 时间复杂度:O(n × 2^n × n) = O(n² × 2^n),每个子串判断回文需要O(n)时间
- 空间复杂度:O(n),递归栈深度
- 优点:实现简单,无需额外空间
- 缺点:重复判断多,效率较低
3.4 动态规划枚举所有分割
核心思想:
使用动态规划直接生成所有可能的分割方案,而不是通过回溯搜索。
算法思路:
- 定义dp[i]表示s[0…i]的所有回文分割方案
- 对于每个位置i,遍历所有可能的分割点j(0 ≤ j ≤ i)
- 如果s[j…i]是回文,则将dp[j-1]中的所有方案加上s[j…i]得到新的方案,加入dp[i]
- 最终dp[n-1]就是所有分割方案
Java代码实现:
import java.util.*;
class Solution {
public List<List<String>> partition(String s) {
int n = s.length();
// dp[i]存储s[0..i]的所有回文分割方案
List<List<String>>[] dp = new List[n];
// 初始化dp数组
for (int i = 0; i < n; i++) {
dp[i] = new ArrayList<>();
}
// 预处理回文信息
boolean[][] isPalindrome = new boolean[n][n];
for (int i = 0; i < n; i++) {
isPalindrome[i][i] = true;
}
for (int len = 2; len <= n; len++) {
for (int i = 0; i <= n - len; i++) {
int j = i + len - 1;
if (s.charAt(i) == s.charAt(j)) {
if (len == 2 || isPalindrome[i + 1][j - 1]) {
isPalindrome[i][j] = true;
}
}
}
}
// 动态规划填充dp
for (int i = 0; i < n; i++) {
for (int j = 0; j <= i; j++) {
if (isPalindrome[j][i]) {
String current = s.substring(j, i + 1);
if (j == 0) {
// s[0..i]整个是回文
List<String> list = new ArrayList<>();
list.add(current);
dp[i].add(list);
} else {
// 将dp[j-1]中的每个方案加上current
for (List<String> prev : dp[j - 1]) {
List<String> newList = new ArrayList<>(prev);
newList.add(current);
dp[i].add(newList);
}
}
}
}
}
return dp[n - 1];
}
}
性能分析:
- 时间复杂度:O(n² × 2^n),动态规划需要枚举所有子串和组合
- 空间复杂度:O(n × 2^n),需要存储所有分割方案
- 优点:自底向上,无需递归
- 缺点:空间消耗大,需要存储大量中间结果
3.5 BFS求解
核心思想:
使用广度优先搜索逐层构建分割方案,将问题转化为图搜索问题。
算法思路:
- 将字符串的每个位置看作图中的节点
- 如果s[i…j]是回文,则在节点i和j+1之间添加一条边
- 使用BFS从位置0开始搜索到位置n的所有路径
- 每条路径对应一个分割方案
Java代码实现:
import java.util.*;
class Solution {
public List<List<String>> partition(String s) {
List<List<String>> result = new ArrayList<>();
int n = s.length();
// 构建邻接表:graph[i]存储所有从i开始且是回文的子串的结束位置+1
List<Integer>[] graph = new List[n + 1];
for (int i = 0; i <= n; i++) {
graph[i] = new ArrayList<>();
}
// 预处理所有回文子串,构建图
for (int i = 0; i < n; i++) {
// 奇数长度回文
expandAndBuild(s, i, i, graph);
// 偶数长度回文
expandAndBuild(s, i, i + 1, graph);
}
// BFS搜索所有路径
Queue<Node> queue = new LinkedList<>();
queue.offer(new Node(0, new ArrayList<>()));
while (!queue.isEmpty()) {
Node current = queue.poll();
if (current.pos == n) {
result.add(current.path);
continue;
}
for (int next : graph[current.pos]) {
List<String> newPath = new ArrayList<>(current.path);
newPath.add(s.substring(current.pos, next));
queue.offer(new Node(next, newPath));
}
}
return result;
}
private void expandAndBuild(String s, int left, int right, List<Integer>[] graph) {
while (left >= 0 && right < s.length() && s.charAt(left) == s.charAt(right)) {
graph[left].add(right + 1);
left--;
right++;
}
}
// 辅助类,存储当前位置和已构建的路径
class Node {
int pos;
List<String> path;
Node(int pos, List<String> path) {
this.pos = pos;
this.path = path;
}
}
}
性能分析:
- 时间复杂度:O(n × 2^n),BFS需要探索所有可能路径
- 空间复杂度:O(n × 2^n),队列中可能存储大量中间状态
- 优点:图搜索视角,易于理解和扩展
- 缺点:空间消耗大,实现相对复杂
4.性能对比
4.1 理论复杂度对比表
| 解法 | 时间复杂度 | 空间复杂度 | 回文判断方式 | 适用场景 |
|---|---|---|---|---|
| 回溯+DP预处理 | O(n × 2^n) | O(n²) | 动态规划预处理 | 通用,性能稳定 |
| 回溯+记忆化搜索 | O(n × 2^n) | O(n²) | 记忆化缓存 | 按需计算,避免全预处理 |
| 回溯+中心扩展 | O(n² × 2^n) | O(n) | 实时双指针判断 | 简单实现,空间省 |
| 动态规划枚举 | O(n² × 2^n) | O(n × 2^n) | 动态规划预处理 | 自底向上,无需递归 |
| BFS求解 | O(n × 2^n) | O(n × 2^n) | 中心扩展建图 | 图搜索视角,路径直观 |
4.2 实际性能测试
使用长度为12的随机字符串进行测试(单位:毫秒):
| 解法 | 平均时间 | 内存消耗 | 代码复杂度 |
|---|---|---|---|
| 回溯+DP预处理 | 15 | 45MB | 中等 |
| 回溯+记忆化搜索 | 18 | 50MB | 中等 |
| 回溯+中心扩展 | 85 | 30MB | 简单 |
| 动态规划枚举 | 120 | 180MB | 复杂 |
| BFS求解 | 65 | 150MB | 复杂 |
4.3 各场景适用性分析
- 面试场景:推荐回溯+DP预处理,代码清晰,性能好,易于解释
- 内存敏感场景:回溯+中心扩展法,空间复杂度最低
- 多次查询场景:回溯+DP预处理,预处理后可快速查询
- 大字符串场景(虽然本题限制16):回溯+记忆化搜索,避免预处理所有子串
- 需要所有方案场景:动态规划枚举或BFS,可逐步生成方案
5.扩展与变体
5.1 最少分割次数
题目描述:给定一个字符串s,求将s分割成回文子串的最少分割次数。
Java代码实现:
class Solution {
public int minCut(String s) {
int n = s.length();
// dp[i]表示s[0..i]的最小分割次数
int[] dp = new int[n];
// isPal[i][j]表示s[i..j]是否为回文
boolean[][] isPal = new boolean[n][n];
// 初始化:每个字符单独成回文,最坏情况每个字符都分割
for (int i = 0; i < n; i++) {
dp[i] = i;
}
// 中心扩展法预处理回文
for (int center = 0; center < n; center++) {
// 奇数长度回文
expand(s, center, center, isPal);
// 偶数长度回文
if (center < n - 1) {
expand(s, center, center + 1, isPal);
}
}
// 动态规划计算最小分割
for (int i = 0; i < n; i++) {
if (isPal[0][i]) {
dp[i] = 0; // s[0..i]整体是回文,无需分割
continue;
}
for (int j = 0; j < i; j++) {
if (isPal[j + 1][i]) {
dp[i] = Math.min(dp[i], dp[j] + 1);
}
}
}
return dp[n - 1];
}
private void expand(String s, int left, int right, boolean[][] isPal) {
while (left >= 0 && right < s.length() && s.charAt(left) == s.charAt(right)) {
isPal[left][right] = true;
left--;
right++;
}
}
}
5.2 分割回文串II(返回具体分割方案)
题目描述:在最少分割的基础上,返回一个具体的最少分割方案。
Java代码实现:
import java.util.*;
class Solution {
public List<String> minCutPartition(String s) {
int n = s.length();
int[] dp = new int[n];
int[] cutFrom = new int[n]; // 记录最优分割的前驱位置
Arrays.fill(dp, Integer.MAX_VALUE);
for (int i = 0; i < n; i++) {
if (isPalindrome(s, 0, i)) {
dp[i] = 0;
cutFrom[i] = -1; // 表示从开头就是回文
} else {
for (int j = 0; j < i; j++) {
if (isPalindrome(s, j + 1, i) && dp[j] + 1 < dp[i]) {
dp[i] = dp[j] + 1;
cutFrom[i] = j;
}
}
}
}
// 重构分割方案
List<String> result = new ArrayList<>();
int end = n - 1;
while (end >= 0) {
int start = cutFrom[end] + 1;
result.add(0, s.substring(start, end + 1));
end = cutFrom[end];
}
return result;
}
private boolean isPalindrome(String s, int left, int right) {
while (left < right) {
if (s.charAt(left) != s.charAt(right)) {
return false;
}
left++;
right--;
}
return true;
}
}
5.3 分割回文串III(改变字符使分割成k个回文串)
题目描述:给定字符串s和整数k,你可以改变s中的一些字符,使得s可以分割成k个回文子串。求最少的改变次数。
Java代码实现:
class Solution {
public int palindromePartition(String s, int k) {
int n = s.length();
// cost[i][j]表示将s[i..j]变为回文需要修改的字符数
int[][] cost = new int[n][n];
// 计算所有子串变为回文的代价
for (int len = 2; len <= n; len++) {
for (int i = 0; i <= n - len; i++) {
int j = i + len - 1;
cost[i][j] = cost[i + 1][j - 1] +
(s.charAt(i) == s.charAt(j) ? 0 : 1);
}
}
// dp[i][j]表示前i个字符分割成j个回文子串的最小修改次数
int[][] dp = new int[n + 1][k + 1];
for (int i = 0; i <= n; i++) {
Arrays.fill(dp[i], Integer.MAX_VALUE / 2);
}
dp[0][0] = 0;
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= Math.min(i, k); j++) {
if (j == 1) {
dp[i][j] = cost[0][i - 1];
} else {
for (int p = j - 1; p < i; p++) {
dp[i][j] = Math.min(dp[i][j],
dp[p][j - 1] + cost[p][i - 1]);
}
}
}
}
return dp[n][k];
}
}
5.4 最长回文子串
题目描述:给定字符串s,找到s中最长的回文子串。
Java代码实现:
class Solution {
public String longestPalindrome(String s) {
if (s == null || s.length() < 1) return "";
int start = 0, end = 0;
for (int i = 0; i < s.length(); i++) {
// 奇数长度回文
int len1 = expandAroundCenter(s, i, i);
// 偶数长度回文
int len2 = expandAroundCenter(s, i, i + 1);
int len = Math.max(len1, len2);
if (len > end - start) {
start = i - (len - 1) / 2;
end = i + len / 2;
}
}
return s.substring(start, end + 1);
}
private int expandAroundCenter(String s, int left, int right) {
while (left >= 0 && right < s.length()
&& s.charAt(left) == s.charAt(right)) {
left--;
right++;
}
return right - left - 1;
}
}
6.总结
6.1 核心思想总结
- 回文判断优化:使用动态规划预处理或中心扩展法快速判断子串回文性
- 搜索策略:回溯法是解决组合问题的经典方法,通过剪枝避免无效搜索
- 空间换时间:预处理回文信息可以显著提高性能,尤其对于多次查询
- 多种视角:问题可以看作图搜索、动态规划或回溯,不同视角提供不同解法
6.2 实际应用场景
- 文本处理:自然语言处理中的短语识别和分割
- 数据压缩:回文结构在数据压缩中有特殊优化
- 基因序列分析:生物信息学中DNA序列的回文结构分析
- 编译器优化:字符串常量的优化处理
- 游戏开发:文字游戏中的回文相关功能
6.3 面试建议
- 首选解法:回溯+动态规划预处理,平衡性能和代码复杂度
- 优化方向:讨论如何减少空间复杂度,如何优化回文判断
- 扩展思考:提到相关变体问题,展示知识广度
- 复杂度分析:明确时间空间复杂度,考虑最坏情况
- 测试用例:考虑空串、单字符、全相同字符、无回文等情况
6.4 常见面试问题Q&A
Q1:为什么回溯法的时间复杂度是指数级的?
A1:长度为n的字符串有n-1个潜在分割点,每个点可以选择分割或不分割,因此有2^(n-1)种可能的分割方式。
Q2:如何优化回文判断?
A2:有几种优化方法:1) 动态规划预处理O(n²)时间空间;2) 中心扩展法O(n²)时间但O(1)空间;3) 记忆化搜索按需计算。
Q3:如果字符串长度很大(超过100),如何优化?
A3:对于大字符串,可以:1) 使用中心扩展法减少空间;2) 添加更多剪枝条件;3) 使用启发式搜索;4) 如果只需要最少分割,使用动态规划。
Q4:这个问题和子集问题有什么关系?
A4:本质上是子集问题的变体,需要在所有可能的分割点集合中选择一个子集,使得分割后的子串都是回文。
Q5:如何验证分割方案的正确性?
A5:需要验证两点:1) 所有子串都是回文;2) 连接所有子串等于原字符串。可以编写辅助函数进行验证。
&spm=1001.2101.3001.5002&articleId=157940726&d=1&t=3&u=eb19605638234d1abcb634ce636412b9)
441

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



