分割回文串
s="aab",要求把它切成若干段,每一段都必须是回文。期望输出全部合法切法:[["a","a","b"], ["aa","b"]]。
s="aab",要求把它切成若干段,每一段都必须是回文。期望输出全部合法切法:[["a","a","b"], ["aa","b"]]。
当前路径、可选范围和终止条件分别是什么?
切刀模型:维护 start 指针,枚举 end,把候选 s[start:end+1] 试着当作一段;若是回文则 path.push 并递归 end+1,回来后 path.pop 撤销,继续尝试下一种切法。start == n 时收集 path 的拷贝。
先说结论:这道题到底解决什么
怎样从“s="aab",要求把它切成若干段,每一段都必须是回文。期望输出全部合法切法:[["a","a","b"], ["aa","b"]]。”推导出 回溯 · 切割字符串 · path 栈,并证明每次状态变化都不会漏掉答案?
中心结论:切刀模型:维护 start 指针,枚举 end,把候选 s[start:end+1] 试着当作一段;若是回文则 path.push 并递归 end+1,回来后 path.pop 撤销,继续尝试下一种切法。start == n 时收集 path 的拷贝。
- 1.暴力方案在哪里重复计算,为什么仍然是正确基线?
- 2.当前路径、可选范围和终止条件分别是什么?
- 3.不变量“path 中保存的每一段子串都已经被验证为回文,向下递归时这条性质继续成立。”为什么能保证算法安全前进?
完整题目与题意拆解
给定一个字符串 s,将 s 分割成一些子串,使每个子串都是回文串。返回 s 所有可能的分割方案。
在本站主例中,s="aab",要求把它切成若干段,每一段都必须是回文。期望输出全部合法切法:[["a","a","b"], ["aa","b"]]。
算法最终需要得到或观察:对 s="aab" 收集到 [["a","a","b"], ["aa","b"]] 两种合法切法。
- • 输入:s="aab",要求把它切成若干段,每一段都必须是回文。期望输出全部合法切法:[["a","a","b"], ["aa","b"]]。
- • 机器需要维护:start(下一刀位置)、end(候选右端)、candidate(s[start:end+1])、isPalindrome、path(已经切下的回文段栈)、ans(结果集)。
- • 最终可观察结果:对 s="aab" 收集到 [["a","a","b"], ["aa","b"]] 两种合法切法。
先看清算法到底要维护什么
先建立输入、目标、输出和第一批状态,不急着进入模板。
尝试每个回文前缀,递归切剩余
第一层方案:暴力做法
先生成全部结果再过滤会探索大量无效分支;回溯在选择阶段剪枝,并在返回时恢复现场。
暴力方案的价值是确认题意并提供正确性基线。它通常会覆盖所有候选, 但没有保存已经确认的信息,因此同一状态会被重新计算。
重复工作究竟发生在哪里
把重复读取或重复搜索的区域明确标出,再决定优化必须保存什么。
尝试每个回文前缀,递归切剩余
整体地图:先做什么,再做什么
- 1建模把输入翻译成“选择树与撤销绳”,明确答案需要观察什么。
- 2状态只维护 start(下一刀位置)、end(候选右端)、candidate(s[start:end+1])、isPalindrome、path(已经切下的回文段栈)、ans(结果集)。
- 3转移每一步按照 切刀模型:维护 start 指针,枚举 end,把候选 s[start:end+1] 试着当作一段;若是回文则 path.push 并递归 end+1,回来后 path.pop 撤销,继续尝试下一种切法。start == n 时收集 path 的拷贝。
- 4收尾读取 对 s="aab" 收集到 [["a","a","b"], ["aa","b"]] 两种合法切法。,并复核边界与复杂度。
选择树与撤销绳:核心概念
路径记录已做选择,候选集合决定下一步,撤销保证兄弟分支互不污染。
这道题不是为了记住一个组件名称,而是为了让状态具有可解释的语义:start(下一刀位置)、end(候选右端)、candidate(s[start:end+1])、isPalindrome、path(已经切下的回文段栈)、ans(结果集)。
- • path 中保存的每一段子串都已经被验证为回文,向下递归时这条性质继续成立。
- • start 指针之前的字符已经被合法切完,未处理的串就是 s[start:]。
- • 每一层递归只枚举从 start 开始的所有回文前缀,保证不会漏切也不会重复切。
- • start == len(s) 时,path 是一种完整的合法分割,把它的拷贝加入 ans。
建立“选择树与撤销绳”心智模型
用主例建立核心状态,先预测下一步,再公开正确分支和理由。
尝试每个回文前缀,递归切剩余
核心机制:状态如何一步步变化
要求输出一个字符串可以被拆成回文串的所有解,DFS 递归求解即可。
切刀模型:维护 start 指针,枚举 end,把候选 s[start:end+1] 试着当作一段;若是回文则 path.push 并递归 end+1,回来后 path.pop 撤销,继续尝试下一种切法。start == n 时收集 path 的拷贝。
执行过程中持续维护:start(下一刀位置)、end(候选右端)、candidate(s[start:end+1])、isPalindrome、path(已经切下的回文段栈)、ans(结果集)。
正确性依赖以下不变量:path 中保存的每一段子串都已经被验证为回文,向下递归时这条性质继续成立。;start 指针之前的字符已经被合法切完,未处理的串就是 s[start:]。;每一层递归只枚举从 start 开始的所有回文前缀,保证不会漏切也不会重复切。;start == len(s) 时,path 是一种完整的合法分割,把它的拷贝加入 ans。
面试时可以压缩为:回溯切割字符串。维护 start 指针表示未处理前缀的起点,枚举 end,若 s[start:end+1] 是回文就把它压入 path 并递归 end+1,回溯后再弹出。start 走到末尾时把 path 拷一份加入结果。回文判断可用区间 DP 预处理降到均摊 O(1)。
落到当前题,执行机制可以压缩为:切刀模型:维护 start 指针,枚举 end,把候选 s[start:end+1] 试着当作一段;若是回文则 path.push 并递归 end+1,回来后 path.pop 撤销,继续尝试下一种切法。start == n 时收集 path 的拷贝。每一次更新都必须保持核心不变量,而不是只让样例碰巧得到正确答案。
一次状态转移为什么成立
集中观察一次选择、计算和状态写回,让变量变化与原因同时出现。
start=0,end=0
正确性证明:为什么不会漏答案
初始化:算法开始时,全部合法候选仍在状态表示范围内;path 中保存的每一段子串都已经被验证为回文,向下递归时这条性质继续成立。
保持:执行“切刀模型:维护 start 指针,枚举 end,把候选 s[start:end+1] 试着当作一段;若是回文则 path.push 并递归 end+1,回来后 path.pop 撤销,继续尝试下一种切法。start == n 时收集 path 的拷贝。”时,只删除已经能证明不可能的候选,并把新信息写回 start(下一刀位置)、end(候选右端)、candidate(s[start:end+1])、isPalindrome、path(已经切下的回文段栈)、ans(结果集)。
终止:没有待处理状态或达到命中条件时,当前可观察结果就是“对 s="aab" 收集到 [["a","a","b"], ["aa","b"]] 两种合法切法。”。
完整执行过程
- 1题目与输入s="aab",要求把它切成若干段,每一段都必须是回文。期望输出全部合法切法:[["a","a","b"], ["aa","b"]]。 因为:切刀模型:维护 start 指针,枚举 end,把候选 s[start:end+1] 试着当作一段;若是回文则 path.push 并递归 end+1,回来后 path.pop 撤销,继续尝试下一种切法。start == n 时收集 path 的拷贝。
- 2切出回文段 'a'确认前缀回文,递归剩余。 因为:非回文前缀直接剪枝。
- 3切出回文段 'a'确认前缀回文,递归剩余。 因为:非回文前缀直接剪枝。
- 4切出回文段 'b'确认前缀回文,递归剩余。 因为:非回文前缀直接剪枝。
- 5分割方案: a | a | b完整分割。 因为:覆盖全串且无重叠。
- 6切出回文段 'b'确认前缀回文,递归剩余。 因为:非回文前缀直接剪枝。
- 7分割方案: aa | b完整分割。 因为:覆盖全串且无重叠。
- 8收尾与复杂度对 s="aab" 收集到 [["a","a","b"], ["aa","b"]] 两种合法切法。 因为:时间 O(n · 2^n) 含回文判断 O(n),整体 O(n^2 · 2^n);DP 预处理回文后降到 O(n^2 + n · 2^n) · 空间 O(n) 递归栈与 path(DP 预处理回文表则需 O(n^2))。切刀模型:维护 start 指针,枚举 end,把候选 s[start:end+1] 试着当作一段;若是回文则 path.push 并递归 end+1,回来后 path.pop 撤销,继续尝试下一种切法。start == n 时收集 path 的拷贝。
从输入完整走到可观察结果
从主例第一帧运行到答案,时间轴始终显示当前状态、因果解释和下一步。
尝试每个回文前缀,递归切剩余
把动画和 Go 代码逐行对应
代码窗口不会按容易漂移的固定数字行号硬绑动画,而是根据当前语义阶段, 在完整 Go 实现中定位选择、计算、写回或返回分支。当前变量与高亮行一起变化。
让每个动作都落到 Go 分支
重新执行关键分支,只显示当前代码附近窗口,并说明这行为什么在此刻运行。
尝试每个回文前缀,递归切剩余
完整 Go 提交代码与最小测试
// 解法一
func partition(s string) [][]string {
if s == "" {
return [][]string{}
}
res, pal := [][]string{}, []string{}
findPalindrome(s, 0, "", true, pal, &res)
return res
}
func findPalindrome(str string, index int, s string, isPal bool, pal []string, res *[][]string) {
if index == len(str) {
if isPal {
tmp := make([]string, len(pal))
copy(tmp, pal)
*res = append(*res, tmp)
}
return
}
if index == 0 {
s = string(str[index])
pal = append(pal, s)
findPalindrome(str, index+1, s, isPal && isPalindrome(s), pal, res)
} else {
temp := pal[len(pal)-1]
s = pal[len(pal)-1] + string(str[index])
pal[len(pal)-1] = s
findPalindrome(str, index+1, s, isPalindrome(s), pal, res)
pal[len(pal)-1] = temp
if isPalindrome(temp) {
pal = append(pal, string(str[index]))
findPalindrome(str, index+1, temp, isPal && isPalindrome(temp), pal, res)
pal = pal[:len(pal)-1]
}
}
return
}
func isPalindrome(s string) bool {
slen := len(s)
for i, j := 0, slen-1; i < j; i, j = i+1, j-1 {
if s[i] != s[j] {
return false
}
}
return true
}
// 解法二
func partition131_1(s string) [][]string {
result := [][]string{}
size := len(s)
if size == 0 {
return result
}
current := make([]string, 0, size)
dfs(s, 0, current, &result)
return result
}
func dfs(s string, idx int, cur []string, result *[][]string) {
start, end := idx, len(s)
if start == end {
temp := make([]string, len(cur))
copy(temp, cur)
*result = append(*result, temp)
return
}
for i := start; i < end; i++ {
if isPal(s, start, i) {
dfs(s, i+1, append(cur, s[start:i+1]), result)
}
}
}
func isPal(str string, s, e int) bool {
for s < e {
if str[s] != str[e] {
return false
}
s++
e--
}
return true
}func main() {
// 1. 主例
// 输入:mode="palindrome-partition", text="aab"
// 期望:对 s="aab" 收集到 [["a","a","b"], ["aa","b"]] 两种合法切法。
//
// 2. 失败 / 未命中
// 检查:复杂度误写成 O(n):合法分割数本身可能是指数级,结果数量决定下界。
//
// 3. 边界
// 空输入、单元素、最小合法规模,以及答案恰好落在边界的情况。
//
// 4. 迁移
// LC132 分割回文串 II(同切割模型 + DP 替换回溯);LC93 复原 IP 地址(切刀 + 段长 ≤ 3 + 段值 ≤ 255 的合法性剪枝);LC139 单词拆分(切刀 + 字典判定 + 记忆化)
}Go 参考实现基于 halfrost/LeetCode-Go 的 MIT 许可代码整理,并按本站教学结构补充解释与动画映射。
正确性与复杂度
执行过程中只保留仍可能影响答案的状态。切刀模型:维护 start 指针,枚举 end,把候选 s[start:end+1] 试着当作一段;若是回文则 path.push 并递归 end+1,回来后 path.pop 撤销,继续尝试下一种切法。start == n 时收集 path 的拷贝。
额外状态主要用于维护:start(下一刀位置)、end(候选右端)、candidate(s[start:end+1])、isPalindrome、path(已经切下的回文段栈)、ans(结果集)。
- • path 中保存的每一段子串都已经被验证为回文,向下递归时这条性质继续成立。
- • start 指针之前的字符已经被合法切完,未处理的串就是 s[start:]。
- • 每一层递归只枚举从 start 开始的所有回文前缀,保证不会漏切也不会重复切。
- • start == len(s) 时,path 是一种完整的合法分割,把它的拷贝加入 ans。
最容易写错的地方
复杂度误写成 O(n):合法分割数本身可能是指数级,结果数量决定下界。
递归回来忘记 path.pop,导致 path 污染兄弟分支,结果集出现非法或重复方案。
把 path 直接 append 进 ans,而不是 append(path 的拷贝),结果集随递归一起被清空。
每次都重复 O(n) 判断回文,未用 dp[i][j] = s[i]==s[j] && dp[i+1][j-1] 预处理。
必须额外检查空输入、单元素、未命中或不可达情况,以及恰好落在边界的输入。
最后复盘:带走逻辑链
- 1题意s="aab",要求把它切成若干段,每一段都必须是回文。期望输出全部合法切法:[["a","a","b"], ["aa","b"]]。
- 2重复先生成全部结果再过滤会探索大量无效分支;回溯在选择阶段剪枝,并在返回时恢复现场。
- 3优化切刀模型:维护 start 指针,枚举 end,把候选 s[start:end+1] 试着当作一段;若是回文则 path.push 并递归 end+1,回来后 path.pop 撤销,继续尝试下一种切法。start == n 时收集 path 的拷贝。
- 4证明path 中保存的每一段子串都已经被验证为回文,向下递归时这条性质继续成立。;start 指针之前的字符已经被合法切完,未处理的串就是 s[start:]。;每一层递归只枚举从 start 开始的所有回文前缀,保证不会漏切也不会重复切。;start == len(s) 时,path 是一种完整的合法分割,把它的拷贝加入 ans。
- 5复杂度时间 O(n · 2^n) 含回文判断 O(n),整体 O(n^2 · 2^n);DP 预处理回文后降到 O(n^2 + n · 2^n),空间 O(n) 递归栈与 path(DP 预处理回文表则需 O(n^2))
- • LC132 分割回文串 II(同切割模型 + DP 替换回溯)
- • LC93 复原 IP 地址(切刀 + 段长 ≤ 3 + 段值 ≤ 255 的合法性剪枝)
- • LC139 单词拆分(切刀 + 字典判定 + 记忆化)