代码随想录算法训练营day23(python版)|第七章 回溯算法part02|·39. 组合总和·40.组合总和II·131.分割回文串

39. 组合总和

本题是 集合里元素可以用无数次,那么和组合问题的差别 其实仅在于 startIndex上的控制

题目链接/文章讲解:https://programmercarl.com/0039.%E7%BB%84%E5%90%88%E6%80%BB%E5%92%8C.html

视频讲解:https://www.bilibili.com/video/BV1KT4y1M7HJ

[]  (remain=8)
├─ 2 (→ 6)
│  ├─ 2 (→ 4)
│  │  ├─ 2 (→ 2)
│  │  │  └─ 2 (→ 0)      ✓ 收集 [2,2,2,2]
│  │  └─ (后续候选均 > 2)×   ← 遇到 3>2,直接 break
│  └─ 3 (→ 3)
│     ├─ 3 (→ 0)         ✓ 收集 [2,3,3]
│     └─ (后续候选均 > 0)×
└─ 3 (→ 5)
   ├─ 3 (→ 2)  (后续候选均 > 2)×
   └─ 5 (→ 0)              ✓ 收集 [3,5]
└─ 5 (→ 3) (后续候选均 > 3)×
  • candidates:候选数字列表(正整数、互不相同)。

  • target:目标和。

  • res:结果大盒子,装所有满足和为 target 的组合。

  • path:临时背包,装“当前已经选择的数字”(始终非降序)。

  • dfs(start, remain):回溯函数

    • start:从 candidates 的哪个下标开始枚举(只往后看,避免重复排列);

    • remain:还差多少才能凑到 targettarget - sum(path))。

    • 结束条件:remain == 0 表示刚好凑满,收集 path拷贝

    • 剪枝:如果某个候选 x > remain,因为已排序,后面更大,直接 break

class Solution:
    def combinationSum(self, candidates, target):
        """
        题意:从 candidates 中选若干数(每个数可重复使用),使其和为 target,
              返回所有“组合”(不关心顺序,通常按非降序输出)。

        参数:
        - candidates: List[int],正整数、互不相同。
        - target: int,目标和。

        返回:
        - res: List[List[int]],所有满足和为 target 的组合,每个组合内部非降序。
        """
        # 基本健壮性处理
        if not candidates or target <= 0:
            return []

        # 1) 排序:为了使用“> remain 则后续全剪掉”的剪枝
        candidates = sorted(candidates)

        res = []   # 结果集合
        path = []  # 当前构造的组合(始终非降序)

        def dfs(start, remain):
            """
            回溯函数(深度优先搜索)

            参数:
            - start: int,从 candidates 的哪个下标开始枚举(只向后看,避免重复排列)
            - remain: int,还差多少才能凑到 target(target - sum(path))

            流程:
            A. 命中:remain == 0 -> 收集 path 的副本,返回
            B. 循环枚举 i 从 start 到末尾:
               B-1. 若 candidates[i] > remain -> 后面都更大,直接 break(整段剪枝)
               B-2. 做选择:把当前数放进 path
               B-3. 递归:由于可重复使用当前数,下一层仍从 i 开始(不是 i+1)
               B-4. 回溯:把刚才放进去的数弹出,试下一个候选
            """
            # A. 命中
            if remain == 0:
                res.append(path[:])   # 记得拷贝!(否则后续回溯会修改同一个列表)
                return

            # B. 从 start 开始尝试每个候选
            for i in range(start, len(candidates)):
                x = candidates[i]

                # B-1. 剪枝:已排序,若 x 超过 remain,后面更大,整段可跳出
                if x > remain:
                    break

                # B-2. 做选择
                path.append(x)

                # B-3. 递归:因为允许重复使用 x,所以下一层仍从 i 开始
                dfs(i, remain - x)

                # B-4. 回溯:撤销选择,试下一个候选
                path.pop()

        # 入口:从下标 0 开始,剩余和为 target
        dfs(start=0, remain=target)
        return res


# =============== 自测(可直接运行) ===============
def _norm(lst):
    """组合的顺序不重要:每个组合内部先排,再整体排序,便于对比"""
    return sorted([sorted(x) for x in lst])

if __name__ == "__main__":
    s = Solution()

    # 示例:candidates = [2,3,5], target = 8
    got = s.combinationSum([2,3,5], 8)
    expect = [[2,2,2,2],[2,3,3],[3,5]]
    assert _norm(got) == _norm(expect)

    # 其他用例
    assert _norm(s.combinationSum([2,3,6,7], 7)) == _norm([[2,2,3],[7]])
    assert s.combinationSum([2], 1) == []
    assert _norm(s.combinationSum([1], 2)) == _norm([[1,1]])

    print("All tests passed!")

40.组合总和II

本题开始涉及到一个问题了:去重。

注意题目中给我们 集合是有重复元素的,那么求出来的 组合有可能重复,但题目要求不能有重复组合。

题目链接/文章讲解:https://programmercarl.com/0040.%E7%BB%84%E5%90%88%E6%80%BB%E5%92%8CII.html

视频讲解:https://www.bilibili.com/video/BV12V4y1V73A

  • candidates:候选数字列表(正整数,可能包含重复值)。

  • target:目标和。

  • res:结果大盒子,收集所有和为 target组合(组合内部非降序、结果无重复)。

  • path:临时背包,保存“当前已选择的数字”(非降序)。

  • dfs(start, remain):回溯函数

    • start:这一层从 candidates哪个下标开始枚举(只往后看,避免排列)。

    • remain:距离 target 还差多少(target - sum(path))。

    • 结束:remain == 0 → 收集 path拷贝

    • 剪枝1(和):若 nums[i] > remain,后面更大,整段 break

    • 剪枝2(去重):同层枚举中,i>start 且 nums[i]==nums[i-1] 就跳过,避免重复组合。

    • 单次使用:这题一个元素最多用一次 → 递归到下一层用 dfs(i+1, …)(而不是 i)。

class Solution:
    def combinationSum2(self, candidates, target):
        """
        题意:在 candidates(可包含重复)中选若干数,每个数最多用一次,使其和为 target,
              返回所有不重复的组合(组合内部通常非降序)。

        关键点:
          1) 排序(非常关键):便于“>remain 整段剪枝”和“同层去重”。
          2) 同层去重:i>start 且 nums[i]==nums[i-1] -> continue。
          3) 每个数最多用一次:递归下一层从 i+1 开始。
        """
        if not candidates or target <= 0:
            return []

        nums = sorted(candidates)  # 排序是去重与剪枝的基础
        res = []
        path = []

        def dfs(start, remain):
            """
            参数:
            - start: 当前层从 nums 的哪个下标开始枚举(只往后看)
            - remain: 还差多少到 target

            流程:
            A) 命中 remain==0:收集 path 的拷贝并返回
            B) i 从 start..len(nums)-1:
               - 去重:若 i>start 且 nums[i]==nums[i-1] -> continue(同层跳过相同值)
               - 和剪枝:若 nums[i] > remain -> 后续更大,break
               - 选择 nums[i] -> path.append(...)
               - 递归到下一层 dfs(i+1, remain - nums[i])  # 单次使用
               - 回溯 path.pop()
            """
            if remain == 0:
                res.append(path[:])   # 拷贝很重要
                return

            for i in range(start, len(nums)):
                # —— 去重(同一层枚举时,跳过与前一个相同的值)——
                if i > start and nums[i] == nums[i - 1]:
                    continue

                x = nums[i]
                # —— 和剪枝:排序后若 x 已经超过 remain,后面只会更大,直接整段剪掉 —— 
                if x > remain:
                    break

                # 做选择
                path.append(x)
                # 单次使用:下一层从 i+1 开始
                dfs(i + 1, remain - x)
                # 回溯
                path.pop()

        dfs(start=0, remain=target)
        return res


# =============== 自测(可直接运行) ===============
def _norm(lst):
    """组合顺序不重要:组合内部与整体都做排序以便比较"""
    return sorted([sorted(x) for x in lst])

if __name__ == "__main__":
    s = Solution()

    # 官方示例 1
    got = s.combinationSum2([10,1,2,7,6,1,5], 8)
    expect = [[1,1,6],[1,2,5],[1,7],[2,6]]
    assert _norm(got) == _norm(expect)

    # 官方示例 2
    got = s.combinationSum2([2,5,2,1,2], 5)
    expect = [[1,2,2],[5]]
    assert _norm(got) == _norm(expect)

    # 无解
    assert s.combinationSum2([4,4,4], 3) == []

    # 含重复但每个只用一次
    got = s.combinationSum2([1,1,1,2,2,3], 4)
    # 可行组合(顺序无关):[1,1,2], [1,3], [2,2]
    expect = [[1,1,2],[1,3],[2,2]]
    assert _norm(got) == _norm(expect)

    print("All tests passed!")
  • 39 题:元素可重复使用 → 递归用 dfs(i, …)

  • 40 题:每个元素最多用一次 + 去重 → 递归用 dfs(i+1, …),并在同层用
    if i>start and nums[i]==nums[i-1]: continue 跳过重复值。

131.分割回文串

本题较难,大家先看视频来理解 分割问题,明天还会有一道分割问题,先打打基础。

https://programmercarl.com/0131.%E5%88%86%E5%89%B2%E5%9B%9E%E6%96%87%E4%B8%B2.html

视频讲解:https://www.bilibili.com/video/BV1c54y1e7k6

变量/参数角色

  • s:原字符串。

  • result:大盒子,收集所有“切出来每段都是回文”的方案(例如 ["aa","b"])。

  • path:临时背包,记录当前这一条搜索路径已经切出来的若干段(从左到右)。

  • start_index:下一段要从 s[start_index:] 的开头开始切。

递归框架(一层意思是一段的右端点)

  • 在一次递归里,我们固定左端点 start_index,然后把右端点 istart_index 枚举到末尾:

    1. 取出当前候选段 seg = s[start_index : i+1]

    2. 判断 seg 是否回文(seg == seg[::-1]

    3. 如果是回文:把 seg 加入 path下一段i+1 再开始递归

    4. 递归返回后,path.pop() 回溯,尝试更长/别的段

  • 终止条件start_index == len(s),说明整串都切完了,path 就是一种完整切法,result.append(path[:]) 收集。

心法:每一层就是“确定下一段的右端点在哪”;层层向右推进,直到切到字符串末尾。

为什么不会重复?

  • 一个切分方案由一组切割位置决定(比如在索引 1、2 切)。不同的切割位置就得到不同的 path;相同的切割位置只会被枚举到一次,所以天然不会生成重复方案,不需要像“组合总和 II”那样同层去重。

小例子:s = "aab"

索引:0:a 1:a 2:b


start=0, path=[]
  i=0 → seg="a"  是回文 → path=["a"] → 递归 start=1
    start=1, path=["a"]
      i=1 → seg="a"  是回文 → path=["a","a"] → 递归 start=2
        start=2, path=["a","a"]
          i=2 → seg="b"  是回文 → path=["a","a","b"] → 递归 start=3
            start==len(s):收集 ["a","a","b"]
          回溯 path=["a","a"]
      i=2 → seg="ab"  不是回文 → 跳过
    回溯 path=["a"]
  i=1 → seg="aa" 是回文 → path=["a","aa"] → 递归 start=2
    start=2, path=["a","aa"]
      i=2 → seg="b"  是回文 → path=["a","aa","b"] → 递归 start=3
        收集 ["a","aa","b"]
    回溯 path=["a","aa"] → 再回溯 path=["a"]
  i=2 → seg="aab" 不是回文 → 跳过
回溯 path=[]

关键细节与常见坑

  • seg == seg[::-1]:这是 Python 判回文的常用招,[::-1] 会返回一份倒序拷贝;比较是 O(段长)。

  • 必须拷贝 pathresult.append(path[:]),否则后续回溯会把已经加入结果的列表改掉。

  • 边界:当 s == "",第一次就命中终止条件,返回 [[]](一条“切零段”的合法方案),这和题意一致。

  • 复杂度

    • 枚举切法本身最坏有 ~2^(n-1) 种(每个间隙切/不切)。

    • 每次判回文/切片是 O(段长),所以总体接近 O(2^n · n)

    • 想加速:改用回文 DP 表双指针 is_pal(l,r)(不切片)即可。

s="aab"

(start=0) path=[]
├─ "a" = s[0:1](回文) → path=["a"] (start=1)
│  ├─ "a" = s[1:2](回文) → path=["a","a"] (start=2)
│  │  └─ "b" = s[2:3](回文) → path=["a","a","b"] (start=3)  ✓ 收集
│  └─ "ab" = s[1:3](非回文) ×
├─ "aa" = s[0:2](回文) → path=["aa"] (start=2)
│  └─ "b"  = s[2:3](回文) → path=["aa","b"] (start=3)       ✓ 收集
└─ "aab" = s[0:3](非回文) ×
from typing import List

class Solution:
    def partition(self, s: str) -> List[List[str]]:
        """
        把 s 切分为若干回文子串,返回所有切法。
        """
        result: List[List[str]] = []
        self.backtracking(s, start_index=0, path=[], result=result)
        return result

    def backtracking(self, s: str, start_index: int, path: List[str], result: List[List[str]]) -> None:
        # 终止:切到了字符串末尾,收集一份方案
        if start_index == len(s):
            result.append(path[:])  # 记得拷贝
            return

        # 枚举右端点 i,尝试把下一段切成 s[start_index : i+1]
        for i in range(start_index, len(s)):
            seg = s[start_index : i + 1]
            # 判回文:正序 == 反序
            if seg == seg[::-1]:
                path.append(seg)                          # 做选择
                self.backtracking(s, i + 1, path, result)  # 下一段从 i+1 开始
                path.pop()                                # 回溯

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

清水泛轻舟

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值