1. LeetCode 131 分割回文串问题解析
这道题目要求我们将字符串分割成若干子串,使得每个子串都是回文串。回文串是指正读反读都相同的字符串,比如"aba"、"aa"都是回文串,而"abc"则不是。
1.1 问题理解与示例
给定一个字符串s,我们需要找到所有可能的分割方式,使得每个子串都是回文串。例如:
- 输入:"aab"
- 输出:[["a","a","b"],["aa","b"]]
这个问题的难点在于如何高效地找到所有可能的分割方式,同时避免重复计算和无效分割。
1.2 解题思路概述
这个问题可以通过回溯算法来解决。回溯算法的核心思想是尝试所有可能的选择,并在发现当前选择不满足条件时回退到上一步,尝试其他选择。具体到这个问题:
- 从字符串的起始位置开始,尝试所有可能的分割点
- 对于每个分割点,检查分割出的子串是否是回文串
- 如果是,则继续递归处理剩余的字符串
- 如果不是,则跳过这个分割点,尝试下一个
- 当处理完整个字符串时,将当前的分割方案加入结果集
2. 代码实现详解
2.1 类成员变量
java复制List<List<String>> res = new ArrayList<>();
List<String> path = new ArrayList<>();
res:存储所有合法的分割方案path:存储当前正在构建的分割方案
2.2 主方法 partition
java复制public List<List<String>> partition(String s) {
backtrack(s, 0); // 从下标 0 开始切
return res;
}
这是程序的入口方法,它初始化回溯过程,从字符串的第一个字符开始尝试分割。
2.3 回文判断辅助函数
java复制private boolean isPalindrome(String s, int left, int right) {
while (left < right) {
if (s.charAt(left++) != s.charAt(right--)) return false;
}
return true;
}
这个辅助函数用于判断子串s[left...right]是否是回文串。它使用双指针法:
- 左指针从子串开头向右移动
- 右指针从子串末尾向左移动
- 比较左右指针所指的字符是否相同
- 如果所有对应字符都相同,则是回文串
2.4 核心回溯方法
java复制private void backtrack(String s, int start) {
// 终止条件:如果 start 已经到达末尾,说明整段字符串都被合法地切分完了
if (start == s.length()) {
res.add(new ArrayList<>(path)); // 存入结果
return;
}
// 核心循环:尝试从 start 开始切,切到 i 为止
for (int i = start; i < s.length(); i++) {
// 判断 [start, i] 这一段是不是回文
if (isPalindrome(s, start, i)) {
// 如果是,切下来放入 path
path.add(s.substring(start, i + 1));
// 递归:从 i+1 的位置继续往后切剩下的字符串
backtrack(s, i + 1);
// 回溯:撤销刚才的切割动作
path.remove(path.size() - 1);
}
}
}
这是算法的核心部分,让我们详细解析:
-
终止条件:当
start指针到达字符串末尾时,说明我们已经找到了一种合法的分割方案,将其加入结果集。 -
循环尝试分割点:从
start位置开始,尝试所有可能的分割点i。 -
回文检查:对于每个分割点
i,检查子串s[start...i]是否是回文串。 -
递归处理:如果是回文串,则:
- 将这个子串加入当前路径
path - 递归处理剩余的字符串
s[i+1...] - 回溯时移除最后加入的子串,尝试其他分割方式
- 将这个子串加入当前路径
3. 算法复杂度分析
3.1 时间复杂度
最坏情况下,当字符串中所有字符都相同(如"aaaa"),每个子串都是回文串,此时会有2^(n-1)种分割方式(n为字符串长度)。对于每种分割方式,我们需要O(n)的时间来复制当前路径。因此,最坏时间复杂度为O(n*2^n)。
3.2 空间复杂度
空间复杂度主要来自递归调用栈和存储结果的res列表。递归深度最多为n,每个递归调用需要O(n)的空间存储当前路径。因此,空间复杂度为O(n^2)。
4. 优化思路
4.1 动态规划预处理
我们可以先用动态规划预处理字符串,建立一个二维数组dp,其中dp[i][j]表示s[i...j]是否是回文串。这样可以将回文判断的时间复杂度从O(n)降到O(1)。
预处理的时间复杂度是O(n^2),但可以显著提高回溯过程的效率。
4.2 代码实现优化
java复制public List<List<String>> partition(String s) {
int n = s.length();
boolean[][] dp = new boolean[n][n];
// 预处理回文信息
for (int i = 0; i < n; i++) {
for (int j = 0; j <= i; j++) {
if (s.charAt(i) == s.charAt(j) && (i - j <= 2 || dp[j+1][i-1])) {
dp[j][i] = true;
}
}
}
List<List<String>> res = new ArrayList<>();
backtrack(s, 0, new ArrayList<>(), res, dp);
return res;
}
private void backtrack(String s, int start, List<String> path,
List<List<String>> res, boolean[][] dp) {
if (start == s.length()) {
res.add(new ArrayList<>(path));
return;
}
for (int i = start; i < s.length(); i++) {
if (dp[start][i]) {
path.add(s.substring(start, i + 1));
backtrack(s, i + 1, path, res, dp);
path.remove(path.size() - 1);
}
}
}
5. 常见问题与调试技巧
5.1 为什么需要回溯?
回溯是为了尝试所有可能的分割方式。当我们选择了一个分割点并递归处理后,需要撤销这个选择,才能尝试其他可能的分割点。
5.2 如何避免重复计算?
使用动态规划预处理可以避免重复计算子串是否为回文。否则,同一个子串可能会被多次检查是否为回文。
5.3 调试技巧
-
打印中间状态:在回溯过程中打印当前的
start和path,帮助理解算法的执行流程。 -
小规模测试:先用小字符串(如"aab")测试,验证基本逻辑是否正确。
-
边界条件检查:测试空字符串、单字符字符串、全相同字符字符串等特殊情况。
6. 实际应用与扩展
6.1 类似问题
- LeetCode 132 分割回文串 II:求最少分割次数
- LeetCode 5 最长回文子串
- LeetCode 647 回文子串
6.2 实际应用场景
- 文本处理:在自然语言处理中,可能需要将文本分割成语义单元
- 数据压缩:某些压缩算法会寻找重复或对称的模式
- DNA序列分析:生物信息学中寻找特定的序列模式
7. 个人实现心得
在实际实现这个算法时,有几个关键点需要注意:
-
回溯的撤销操作:一定要记得在递归返回后撤销当前的选择(
path.remove(path.size() - 1)),这是回溯算法的核心。 -
字符串截取:Java的
substring(start, end)是前闭后开区间,所以截取s[start...i]应该是s.substring(start, i + 1)。 -
回文判断优化:对于长字符串,动态规划预处理可以显著提高性能,特别是当需要多次解决类似问题时。
-
结果复制:在将
path加入res时,一定要新建一个列表(new ArrayList<>(path)),否则后续对path的修改会影响已经存储的结果。
这个算法很好地展示了回溯法的应用场景和实现方式,理解它对于解决其他组合问题(如排列、子集等)有很大帮助。在实际编程面试中,清晰地解释回溯的过程和复杂度分析往往比写出完美代码更重要。