1. 算法训练营第23天任务概览
今天的三道题目都是回溯算法的经典应用场景,涵盖了组合问题和分割问题两大类型。作为代码随想录算法训练营的固定学员,我每周都会记录自己的刷题心得。这三道题在力扣上的难度评级都是中等,但实际解决过程中各有特点:
- 39. 组合总和:给定无重复元素的候选数组和目标值,找出所有和为目标值的组合(同一数字可重复使用)
- 40. 组合总和II:候选数组包含重复元素,每个数字只能使用一次的组合问题
- 131. 分割回文串:将字符串分割成所有可能的回文子串组合
这三道题看似独立,实则层层递进。组合总和问题帮助我们掌握基础回溯模板,组合总和II引入了去重逻辑,而分割回文串则将回溯应用到了字符串处理领域。
提示:建议按题目顺序练习,先掌握基础组合问题,再挑战字符串分割。我在第一次尝试时直接跳到131题,结果在双重回溯逻辑上卡了整整两小时。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 39. 组合总和详解与优化
2.1 问题重述与示例分析
给定一个无重复元素的整数数组 candidates 和一个目标整数 target,找出所有可以使数字和为目标数的组合。candidates 中的数字可以无限制重复使用。
示例:
输入:candidates = [2,3,6,7], target = 7
输出:[[2,2,3],[7]]
关键点在于:
- 同一个数字可重复使用
- 所有数字都是正整数(根据力扣测试用例)
- 解集不能包含重复组合(如[2,2,3]和[2,3,2]视为相同)
2.2 回溯解法实现
基础回溯模板需要做以下调整:
- 递归终止条件:当前路径和等于target时保存结果
- 剪枝条件:当前路径和超过target时停止向下递归
- 避免重复组合:按顺序取数,不回头查找
python复制def combinationSum(candidates, target):
res = []
def backtrack(start, path, remaining):
if remaining == 0:
res.append(path.copy())
return
for i in range(start, len(candidates)):
num = candidates[i]
if num > remaining:
continue
path.append(num)
backtrack(i, path, remaining - num) # 注意这里传入i而不是i+1
path.pop()
candidates.sort() # 提前排序方便剪枝
backtrack(0, [], target)
return res
2.3 关键点解析
-
start参数的作用:保证元素按顺序选取,避免[2,3,2]这样的重复组合。传入i而非i+1允许重复使用当前元素。
-
提前排序的意义:虽然题目没说candidates有序,但排序后可以:
- 当candidates[i] > remaining时直接break(后续更大元素无需尝试)
- 保证结果中组合也是有序的(方便判重)
-
时间复杂度分析:最坏情况下是O(n^target),实际通过剪枝会好很多。空间复杂度O(target)(递归栈深度)
我在实际实现时犯过的错误:
- 忘记copy path列表直接append,导致结果全为空列表
- 剪枝条件写成if num >= remaining,漏掉了等于的情况
- 在递归调用后写print调试语句,影响程序性能
3. 40. 组合总和II的去重策略
3.1 问题差异点对比
与39题的主要区别:
- candidates可能包含重复数字
- 每个数字在每个组合中只能使用一次
- 解集不能包含重复组合(比39题更复杂)
示例:
输入:candidates = [10,1,2,7,6,1,5], target = 8
输出:[[1,1,6],[1,2,5],[1,7],[2,6]]
3.2 树层去重与树枝去重
这是本题最核心的技巧。我们需要区分:
- 树枝去重:同一组合内不重复使用相同元素(通过start+1实现)
- 树层去重:同一层级不重复选择相同元素(需要额外判断)
python复制def combinationSum2(candidates, target):
res = []
candidates.sort()
def backtrack(start, path, remaining):
if remaining == 0:
res.append(path.copy())
return
for i in range(start, len(candidates)):
# 树层去重
if i > start and candidates[i] == candidates[i-1]:
continue
num = candidates[i]
if num > remaining:
break
path.append(num)
backtrack(i+1, path, remaining - num) # i+1保证不重复使用
path.pop()
backtrack(0, [], target)
return res
3.3 调试过程记录
我在实现时遇到了以下问题:
- 去重逻辑写成i > 0导致第一个元素被错误跳过
- 忘记排序导致去重失效
- 剪枝条件用continue代替break,错过后续更大元素的剪枝机会
验证去重是否正确的技巧:
- 打印递归树观察重复调用
- 用[1,1,2,5,6,7,10] target=8手动模拟
- 检查结果中是否出现[1,2,5]和[2,1,5]这类顺序不同但元素相同的组合
4. 131. 分割回文串的双重回溯
4.1 问题转换思路
将字符串分割为若干子串,每个子串都是回文。这实际上是一个组合问题:
- 在字符串的各个位置做切割决策
- 检查当前子串是否为回文
- 是则继续递归处理剩余部分
示例:
输入:s = "aab"
输出:[["a","a","b"],["aa","b"]]
4.2 实现方案对比
有两种实现思路:
- 先切割再验证:在每一个可能的位置切割,然后验证子串是否为回文
- 边切割边验证:在递归时实时验证当前子串
实测第二种效率更高:
python复制def partition(s):
res = []
def is_palindrome(sub):
return sub == sub[::-1]
def backtrack(start, path):
if start == len(s):
res.append(path.copy())
return
for end in range(start+1, len(s)+1):
substr = s[start:end]
if is_palindrome(substr):
path.append(substr)
backtrack(end, path)
path.pop()
backtrack(0, [])
return res
4.3 性能优化技巧
- 回文判断优化:可以用双指针代替切片比较
- 记忆化搜索:预先计算所有子串是否是回文(DP表)
- 剪枝策略:当剩余字符串长度小于当前最短回文时提前终止
我实现的DP预处理版本:
python复制def partition(s):
n = len(s)
dp = [[False]*n for _ in range(n)]
for i in range(n):
dp[i][i] = True
for length in range(2, n+1):
for i in range(n-length+1):
j = i+length-1
if s[i] == s[j]:
if length == 2 or dp[i+1][j-1]:
dp[i][j] = True
res = []
def backtrack(start, path):
if start == n:
res.append(path.copy())
return
for end in range(start, n):
if dp[start][end]:
path.append(s[start:end+1])
backtrack(end+1, path)
path.pop()
backtrack(0, [])
return res
5. 回溯问题的通用解题框架
经过这三道题的训练,我总结出回溯问题的通用思考步骤:
-
问题分析:
- 确认是组合、排列、分割哪类问题
- 是否需要去重
- 元素是否可重复使用
-
参数设计:
- path:当前路径
- start:控制选择范围的起始位置
- remaining/path_sum:记录状态
-
递归结构:
- 终止条件
- 遍历选择列表
- 做出选择
- 递归调用
- 撤销选择
-
剪枝优化:
- 排序预处理
- 提前终止无效分支
- 记忆化重复计算
对于组合类问题,我现在的标准解题流程:
- 画决策树确认递归结构
- 确定去重策略(树层or树枝)
- 编写基础回溯框架
- 添加剪枝条件
- 测试边界情况(空输入、全等元素等)
在实现分割回文串时,我发现回溯问题经常需要组合其他算法。比如本题就需要:
- 回溯框架处理所有分割方案
- 动态规划预处理回文判断
- 双指针优化回文验证
这种多算法结合的题目特别考验对各个算法本质的理解。我建议在解决这类问题时,先单独实现各个组件(如单独写回文判断函数),确保每个部分正确后再整合。
