☰
LeetCode分割回文串实战:回溯算法与回文判断优化
2026/10/14 22:47:47 网站建设 项目流程

LeetCode热题100里的字符串题目,我最怕的不是动态规划,而是“方案枚举”。“分割回文串”就是这样一道题:回文串本身很好理解,正读倒读都一样,给你一个"abba"一眼能判断,但题目要的不是“判断”,而是“请给出全部切割方案”。我第一次写这题,老老实实地先把所有切法列出来,再逐个验证,代码将近一百行,然后跑一个中等长度的输入直接超时。

后来我把思路彻底换了一遍——把“切割”和“验证”合并到一个递归过程中,边切边验。这道题我建议每个人都动手写一遍:它是理解回溯算法的最佳案例之一,也是热题100里若干后续字符串难题的前置基础。无论你是在准备面试,还是想把回溯这一套状态搜索真正摸透,都可以从这道题入手。

1. 暴力枚举最先会被淘汰,因为切法数量是指数级的

1.1 回文串判断不是这道题的瓶颈

先解决最简单的问题:判断一个子串是不是回文。双指针从两端往中间走,任何字符不等就返回False,这是典型的O(n)扫描。网上很多初学者拿到题目第一反应就是“用双指针判断回文”,写完赶快要往枚举里套,却发现怎么套都别扭。

问题在于,一个长度为n的字符串,有n-1个可以下刀的位置。每个位置只有两种状态:切或者不切。如果不管回文约束,直接枚举全部分割方式,一共有2^(n-1)种方案。n=10时有512种,n=20时有524288种,到n=30就是536870912种,超过5亿。光是遍历这些方案就已经不可接受了,更不用说每个方案还要逐段验证回文。判断回文本身很简单,但它不是这个题目的核心困难。

1.2 真正爆炸的是切割方案数

也许你会想:先枚举再验证不行,那能不能先判断哪些子串是回文,再根据“回文段”来拼接组合?这个方向是对的,但依然要小心。因为“哪些子串是回文”是一张二维表,真正输出方案时还是要做组合枚举,仍然会回到指数级数量上。区别在于,能否通过及时的“剪枝”把大量非法分支提前切断。

举个具体的例子,s = "aaa"。长度3,有2个可切割位置,所有切割方案是:["a","a","a"]、["a","aa"]、["aa","a"]、["aaa"],共4种。因为全"a"串每个子串都是回文,这4种方案全部合法。如果直接枚举,4种还好;等长度到20,全"a"串的合法方案就有524288种。题目要求的输出量本身就是海量的,所以这道题注定不能靠“少枚举”来优化,核心在于让每一次枚举都尽量有效。

1.3 把分割问题看成决策树

把“在哪里切”看成是一个接一个的决策点:假设当前已经处理到下标start,下一步要决定的是“从start开始的这一段,在哪里结束”。end可以从start一直取到n-1,每一种取值都对应一种切割选择。这样整棵决策树的叶子,就是所有可能的完整分割方案。

这个视角非常关键。它把“分割方案”这种不好枚举的抽象概念,映射成了“从根到叶子的路径”,而路径就是回溯算法的天然工作对象。后续代码能不能写顺,很大程度上取决于有没有建立这个模型。一旦你能把“切到哪”想成“选一个end”,回溯框架就自然浮现了。

2. 回溯主框架:每一步只回答“当前段切到哪”

2.1 回溯三要素的落位

回溯本质是DFS的一种,思路是在“做选择-记录-撤销选择”的循环里遍历整个状态空间。对于分割回文串,三个要素落位非常清晰:

  • 选择列表:从当前起点start开始,所有可能的终点end,范围是[start, n-1]。
  • 约束剪枝:只有s[start:end+1]是回文,才允许进入这条分支。
  • 终止条件:start == n,说明字符串已经被完整切完,当前路径就是一个合法答案。

path里保存的是“当前已经切出来的子串”。每次进入合法分支,把子串加入path,然后递归处理剩余部分;递归返回后,必须把刚加入的子串弹出,恢复到没切这段之前的状态。这个“加进去-递归-弹出来”的三连操作,是回溯的肌肉记忆。

2.2 双指针版本代码

先用最直白的方式写一版,方便理解主流程:

class Solution: def partition(self, s: str) -> List[List[str]]: n = len(s) res = [] path = [] def is_palindrome(l: int, r: int) -> bool: while l < r: if s[l] != s[r]: return False l += 1 r -= 1 return True def dfs(start: int) -> None: if start == n: res.append(path[:]) return for end in range(start, n): if not is_palindrome(start, end): continue path.append(s[start:end + 1]) dfs(end + 1) path.pop() dfs(0) return res

这段代码的运行逻辑一句话就能说完:递归函数拿到一个起点start,尝试所有可能的终点end;只要这一段的子串是回文,就切下来,继续往后递归;当起点越界,说明字符串已经切完,把所有切出来的子串记进结果。

2.3 用"aab"完整手推一遍递归过程

输入"aab",n=3。调用dfs(0):

  • start=0时,end从0到2依次尝试:
    • end=0,子串"a"是回文,path变为["a"],进入dfs(1)
      • start=1,end从1到2:
        • end=1,子串"a"是回文,path变为["a","a"],进入dfs(2)
          • start=2,end只能取2,子串"b"是回文,path变为["a","a","b"],进入dfs(3)
            • start=3已经等于n,记录["a","a","b"]
          • 返回后弹出"b",path回到["a","a"]
        • end=2,子串"ab"不是回文,跳过
      • 返回后弹出"a",path回到["a"]
    • end=1,子串"aa"是回文,path变为["aa"],进入dfs(2)
      • start=2,end取2,子串"b"是回文,path变为["aa","b"],进入dfs(3)
        • 记录["aa","b"]
      • 返回后弹出"b"
    • 返回后弹出"aa"
    • end=2,子串"aab"不是回文,跳过

最终结果就是[["a","a","b"],["aa","b"]]。如果你自己写代码,强烈建议在一开始加上打印语句,把每次进入dfs的start和path变化打出来,整个递归过程一目了然。

2.4 为什么 path.pop() 必须紧跟递归返回

这块是新手最容易心慌的地方。如果不加path.pop(),path里会一直累积已经切出来的子串,导致两个问题:一是后续分支会在错误的状态上继续构造路径,二是即使到了终止条件,res.append(path)保存的也是同一个列表对象,后续pop会把已经保存的路径一并改掉。

更隐蔽的坑在于引用。Python里列表是引用类型,res.append(path)存的是path的内存地址,不是内容快照。因此保存结果时必须写成res.append(path[:]),或者res.append(list(path))。这一步没有商量的余地,几乎每个回溯题解里都会特别标注。我见过太多人在这个细节上栽跟头:递归过程完全正确,最后的输出却是一堆空列表。

3. 回文判断的三种姿态:朴素、DP预处理、记忆化

3.1 朴素双指针:代码最简单但重复劳动多

双指针每次判断一个子串是否回文,成本是O(len),len是子串长度。在回溯过程中,同一个子串可能被反复验证。比如上面"aab"的例子,虽然"ab"只判断了一次,但如果输入更长、递归分支更多,子串区间之间的重叠非常严重。

最坏情况下,递归过程中会对大量重叠区间进行重复扫描。虽然n不大时这问题不明显,但当输入变成16位甚至20位、且由相同字符组成时,双指针版本和预计算版本肉眼可见地拉开了差距。在LeetCode的测试数据下,双指针往往也能AC,但面试官如果追问“你怎么避免重复计算”,就暴露了优化意识不足。

3.2 DP预处理:O(n^2)铺好一张回文表

更规范的解法是先用动态规划把“任意子串是否回文”全部算好,存进二维布尔表dp,后续回溯时每次判断变成O(1)查询。递推公式很固定:

dp[i][j] 表示s[i:j+1]是否为回文。当s[i] != s[j]时,dp[i][j]肯定为False;当s[i] == s[j]时,只要长度不超过2,或者dp[i+1][j-1]为True,那么dp[i][j]就为True。写成代码:

n = len(s) dp = [[False] * n for _ in range(n)] for length in range(1, n + 1): # 枚举子串长度 for i in range(n - length + 1): # 枚举左端点 j = i + length - 1 # 右端点 if s[i] == s[j] and (length <= 2 or dp[i + 1][j - 1]): dp[i][j] = True

这里最关键的细节是遍历顺序。dp[i][j]依赖dp[i+1][j-1],也就是“更短一个长度的子串”。如果按i从小到大、j从大到小的顺序去填表,很可能还没算到需要的那个位置。正确的做法是让子串长度从短到长增长,或者让i从n-1开始递减。我在第一次写DP表时,就在这里卡了很久,填出来的表错得毫无规律。

改好之后,回溯部分只用把is_palindrome(start, end)替换成dp[start][end]即可,其他逻辑一字不改。主流程清爽很多,性能也扎实。

3.3 记忆化DFS:懒加载的判重方式

还有第三种思路:用记忆化递归来判断回文。需要判断一个区间时,先查缓存,没有则递归判断两端字符加中间区间。这样不需要一次性把整张二维表填满,只会在实际用到的区间上计算,属于“懒加载”式优化。

from functools import lru_cache class Solution: def partition(self, s: str) -> List[List[str]]: n = len(s) @lru_cache(None) def is_palindrome(l: int, r: int) -> bool: if l >= r: return True return s[l] == s[r] and is_palindrome(l + 1, r - 1) res = [] path = [] def dfs(start: int) -> None: if start == n: res.append(path[:]) return for end in range(start, n): if not is_palindrome(start, end): continue path.append(s[start:end + 1]) dfs(end + 1) path.pop() dfs(0) return res

三种做法的取舍如下表:

方案单次判断成本预处理成本适用场景
朴素双指针O(len)无快速验证思路,n很小
DP预处理O(1)O(n^2)时间+O(n^2)空间面试标准答案,n较大
记忆化递归近似O(1)仅实际访问区间不想刻意“填表”的懒人写法

我自己的习惯是:手写题解时用记忆化递归,思路清晰、代码量少;给面试官讲解时用DP预处理,因为可以顺势引出状态定义和递推公式,展示基本功。

4. 复杂度这件事:面试官会怎么追问

4.1 方案数本身是指数级的

先建立一个正确的直觉:只要要求输出“全部方案”,答案数量在最坏情况下就是2^(n-1)级别。比如字符串全部由同一个字符组成,任意长度子串都是回文,那么每一种切割方式都合法。n=20时结果就有524288组,每组还要占用一定内存,这是题目本身的下限,跟算法无关。

所以当面试官问“时间复杂度能优化到多项式级别吗?”,要斩钉截铁地说不能。因为哪怕只是打印结果,也需要O(方案数 × 方案长度)的时间。这道题不存在“又枚举又不指数”的解法。

4.2 回溯时间复杂度的正确口径

套用回溯的通用分析框架:每个合法方案都会在递归过程中被构造一次,路径的存储和复制成本是O(n),因此回溯部分的时间是O(n × 2^n)。这里要注意,2^n是方案数量级,不是递归调用次数级。因为每个分支都会有一次判断回文,如果用的是朴素双指针,判断成本还要再叠加一个O(n),总复杂度会变成O(n^2 × 2^n)级别。

用上DP预处理后,每次判断都是O(1),回溯主循环的时间就能稳定在O(n × 2^n)。面试时可以主动给出这两个口径的对比,说明自己清楚优化点在哪里。

4.3 空间复杂度的构成细节

空间开销包括三个部分:递归栈深度最多为n,因为每次递归都至少前进一步;path路径长度最多n;DP表是n×n的布尔矩阵。合在一起是O(n^2)。如果只讨论朴素回溯,不建DP表,空间就是O(n)。

另外有个容易忽略的点:Python的切片s[start:end+1]每次都会创建新字符串,这部分在结果集合里也要计入内存。因为每个方案的不同子串都各不相同,切片字符串的总量级和结果方案数是一致的,这也是为什么n=20时内存消耗非常可观。

5. 一个自然的延伸:分割回文串II的最小切割次数

5.1 从“全部方案”到“最少切割”

同一道题还有升级版:给定字符串s,求最少切几刀,能让每一段都是回文。这个问题不再要求输出方案,只需要数字,因此可以用一维动态规划瞬间把复杂度拉到O(n^2)。我在刷题时习惯把这两道题连在一起做,因为它们的回文判断预处理可以完全复用。

定义dp[i]表示前i个字符,即s[:i],最少需要切割多少次。初始化dp[0] = -1是个很妙的细节:如果整个前缀s[:i]本身就是回文,那么dp[i]应该等于0,此时它依赖的dp[0]+1恰好就是0。这样就不用单独写特判了。

5.2 两层DP如何拼装

先按前面章节的方式,把回文表pal[i][j]预先算好。然后枚举i从1到n,再枚举最后一段的起点j,只要pal[j][i-1]为True,就把dp[i]更新为dp[j]+1。这个转移的含义特别直观:前面的s[:j]已经切成回文若干段,最后一段s[j:i]是一个回文,所以再切一刀(或者直接拼接)就到了i位置。

class Solution: def minCut(self, s: str) -> int: n = len(s) pal = [[False] * n for _ in range(n)] for length in range(1, n + 1): for i in range(n - length + 1): j = i + length - 1 if s[i] == s[j] and (length <= 2 or pal[i + 1][j - 1]): pal[i][j] = True dp = [float('inf')] * (n + 1) dp[0] = -1 for i in range(1, n + 1): for j in range(i): if pal[j][i - 1]: dp[i] = min(dp[i], dp[j] + 1) return dp[n]

这题的经典错误是初始化时把dp[0]设成0,导致整个字符串都是回文时得到切割次数1,这显然是错的。把这个边界想明白,对理解动态规划的初始化意义很有帮助。

5.3 这类题的识别特征

字符串问题里有一类套路:先预处理“任意子串是否回文”的二维表,再用这张表去做回溯或一维DP。“分割回文串”“最长回文子串”“回文子串计数”“分割回文串II”,全是这个结构。

识别信号也很简单:题目里出现“子串为回文”“所有分割方式”“最少分割次数”这些关键词,大概率要用到回文判定表。我在热题100里刷下来,这类题的高频解法几乎都是这个组合拳。熟悉了之后,看到字符串+回文,第一反应就应该是“要不要先建一张dp表”。

6. 实战中的坑与调试心得

6.1 保存路径时没有深拷贝

这是回溯题里出现频率最高的bug,没有之一。res.append(path)的写法,看起来好像把路径存进了结果数组,实际上存的是同一个列表的引用。后面每次path.pop(),已经保存的结果也会跟着变。最终所有结果都变成空列表,或者变成同一个终态路径。

解决方式就是res.append(path[:])。如果你用Java或者C++,对应的是复制一份新的List/vector再加入结果。养成习惯后,这个坑基本不会再踩。

6.2 递归起点的+1写错位置

在for循环内,切完当前段s[start:end+1]之后,下一段的起点是end+1而不是end。这里写错的话,会出现两个后果:一是end被重复处理,字符重叠,结果里出现非法的子串组合;二是递归参数没有前进,导致无限递归,直接栈溢出。

我调试过的一个例子,就是写成了dfs(end),然后本地跑输入"aab"直接RecursionError报错。手推一遍递归过程能快速定位:end是当前段的结束下标,下一段必须从end的下一个字符开始。

6.3 边界情况的显式思考

空字符串s=""时,理论上有一种分割方式,就是什么都不切,返回[[]]。LeetCode的约束一般不会给空串,但本地测试时要想清楚这个边界。单个字符"a"时,结果应该是[["a"]]。两个相同字符"aa"时,结果是[["a","a"],["aa"]],两个不同字符"ab"则只有[["a","b"]],因为"ab"整体不是回文。

当你自己构造测试用例时,我建议拿“全相同字符”字符串做压力测试。比如n=15的全"a"输入,结果数量应当是2^14=16384组。如果代码的结果数量对不上这个数学期望,说明递归路径有遗漏或者有重复。这个验证方法比肉眼检查结果要高效得多。

6.4 一条实用的调试路径

我个人的做题流程是:先不优化,用朴素双指针写出能跑通示例的版本;然后加打印语句,把每次进入dfs的start、path都打出来,对比手推结果;确认回溯逻辑正确后,再替换成DP预处理。这个顺序的好处是,一旦出bug,能快速判断是“回溯框架问题”还是“回文判断问题”,不会两头都怀疑。

如果发现自己写的回溯代码总在递归中迷失,还有一个训练方法:手动模拟一个很短的状态树,比如"aab"的所有分支,然后对照代码的执行路径,看代码是否完整遍历了每一个分支。这个训练做几次之后,回溯就不再是“背模板”了,而是真的理解了“决策树到底长什么样”。

6.5 关于时间和空间的最后提醒

虽然回溯解法是指数级,但LeetCode的实际限制是n不超过16,所以这题在在线判题系统里跑起来压力不大。真正需要留意的反而是结果列表的内存占用。全相同字符的n=16输入,合法方案有32768组,每组平均长度是16个字符左右的内存开销,虽然还能撑住,但如果代码里无谓地多做一次浅拷贝或重复切片,评测时间就会明显变长。

优化的一个顺手操作是:在递归里不需要每次都s[start:end+1]切片,可以只在加入path时切片一次,其余判断都基于下标操作。这个习惯对后续写更复杂的回溯题也有帮助。

最后分享一个我个人屡试不爽的小技巧:在动手写代码之前,先把输入"aab"的完整决策树画在纸上,每条分支写上对应的path状态。画完这棵树,回溯的三要素、终止条件、剪枝位置就全部清楚了,代码几乎可以一次写对。这个习惯我保持了很长时间,对提升刷题效率帮助非常大。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询