分割回文串图解
问题描述
给定一个字符串 s,将 s 分割成若干子串,使每个子串都是回文串。返回所有可能的分割方案。
示例
输入:"aab"
输出:
[
["a","a","b"],
["aa","b"]
]
算法思路:回溯法
核心思想
- 逐步构建:从左到右逐步构建回文子串
- 回溯尝试:对每个位置尝试所有可能的回文分割
- 剪枝优化:提前终止不可能产生回文的分支
关键步骤
- 从字符串起始位置开始
- 寻找所有以当前位置开头的回文子串
- 对每个回文子串,递归处理剩余部分
- 到达字符串末尾时保存有效分割
JavaScript实现
1 | function partition(s) { |
我将用更直观的图解方式,配合分步拆解,帮助你完全理解这个回溯过程。让我们以 s = "aab" 为例,用动画式的图解来说明。
初始状态
字符串: a a b
索引: 0 1 2
当前路径: []
待处理: 全部(0-2)
第一步:处理位置0
尝试所有以位置0开头的子串
选择1:取”a” (0-0)
- 检查回文:”a”是回文
- 更新路径:
["a"] - 递归处理剩余:”ab” (从位置1开始)
当前路径: ["a"]
剩余字符串: a b (索引1-2)
子递归:处理位置1
尝试所有以位置1开头的子串
选择1.1:取”a” (1-1)
- 检查回文:”a”是回文
- 更新路径:
["a", "a"] - 递归处理剩余:”b” (从位置2开始)
当前路径: ["a", "a"]
剩余字符串: b (索引2)
子子递归:处理位置2
尝试所有以位置2开头的子串
选择1.1.1:取”b” (2-2)
- 检查回文:”b”是回文
- 更新路径:
["a", "a", "b"] - 到达字符串末尾 → 保存结果
["a","a","b"] - 回溯:移除”b” → 路径回退到
["a","a"]
选择1.2:取”ab” (1-2)
- 检查回文:”ab”不是回文 → 跳过
子递归结束
回溯:移除”a” → 路径回退到 ["a"]
选择2:取”aa” (0-1)
- 检查回文:”aa”是回文
- 更新路径:
["aa"] - 递归处理剩余:”b” (从位置2开始)
当前路径: ["aa"]
剩余字符串: b (索引2)
子递归:处理位置2
尝试所有以位置2开头的子串
选择2.1:取”b” (2-2)
- 检查回文:”b”是回文
- 更新路径:
["aa", "b"] - 到达字符串末尾 → 保存结果
["aa","b"] - 回溯:移除”b” → 路径回退到
["aa"]
选择3:取”aab” (0-2)
- 检查回文:”aab”不是回文 → 跳过
最终结果
[
["a","a","b"],
["aa","b"]
]
关键过程图解
backTrack(0, [])
│
├─ end=0: 取 "a" (s[0..0])
│ ├─ backTrack(1, ["a"])
│ │ ├─ end=1: 取 "a" (s[1..1]) → path=["a","a"]
│ │ │ ├─ backTrack(2, ["a","a"])
│ │ │ │ ├─ end=2: 取 "b" (s[2..2]) → path=["a","a","b"] → 保存结果
│ │ │ │ └─ end++ → 循环结束
│ │ │ └─ path.pop() → ["a"]
│ │ │
│ │ └─ end=2: 检查 "ab" (s[1..2]) → 不是回文,跳过
│ └─ path.pop() → []
│
└─ end=1: 取 "aa" (s[0..1]) → path=["aa"]
└─ backTrack(2, ["aa"])
└─ end=2: 取 "b" (s[2..2]) → path=["aa","b"] → 保存结果
为什么这样能保证不遗漏?
- 系统性尝试:对每个位置,尝试所有可能的子串长度
- 回文剪枝:只有回文子串才会继续递归
- 回溯保证:每次递归后恢复状态,确保其他分支能正确执行
常见疑问解答
Q1:为什么要在path.push后立即pop?
A:这是回溯算法的核心——在递归返回后撤销选择,确保:
- 上层递归的
path不受下层影响 - 可以尝试其他分割方式
Q2:如何避免重复计算回文?
A:可以用动态规划预处理回文信息:
1 | // dp[i][j]表示s[i..j]是否是回文 |
Q3:时间复杂度为什么是O(n*2^n)?
- 最坏情况(如”aaaa”)有2^(n-1)种分割方式
- 每次判断回文需要O(n)时间
通过这种”尝试-递归-回溯”的流程,系统性地探索了所有可能的回文分割方案。关键是要理解递归树如何展开,以及回溯如何保证状态重置。
复杂度分析
- 时间复杂度:O(n * 2^n)
- 最坏情况下有 2^n 种分割方式
- 每次判断回文需要 O(n) 时间
- 空间复杂度:O(n)
- 递归栈深度最多为 n
优化方法
1. 动态规划预处理
预先计算所有子串是否为回文,存储为二维数组:
1 | function partition(s) { |
2. 记忆化搜索
缓存已计算的回文判断结果,避免重复计算。
常见问题解答
Q1:为什么需要回溯?
A:回溯法可以系统地探索所有可能的分割方式,当某条路径无法继续时回退尝试其他选择。
Q2:如何处理空字符串?
A:空字符串本身被视为有效输入,返回包含空列表的列表 [[]]。
Q3:如何避免重复计算回文?
A:使用动态规划预处理或记忆化技术存储已计算的回文判断结果。
实际应用场景
- 文本分词处理
- DNA序列分析
- 数据压缩算法
- 密码学中的字符串分解
通过这种回溯+剪枝的方法,我们可以高效地找出所有可能的回文分割方案。理解递归树的构建过程是掌握此类问题的关键。