代码随想录算法训练营|Day 23

Day 23

第七章 回溯算法part02

39. 组合总和

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

题目链接/文章讲解:https://programmercarl.com/0039.组合总和.html
视频讲解:https://www.bilibili.com/video/BV1KT4y1M7HJ

凑合一个版本

class Solution:
    def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]:
        #宽度 branches:候选
        #深度 路径:一个subres
        res = []
        #cur_sum 
        def backtracking(candidates, target, index, sub_res, cur_sum):
            if cur_sum == target:
                res.append(sub_res[:])
                return
            if cur_sum > target:
                return
            for i in range(index, len(candidates)):
                cur_sum += candidates[i]
                backtracking(candidates, target, i, sub_res+[candidates[i]], cur_sum)
                cur_sum -= candidates[i]

        backtracking(candidates, target, 0, [], 0)
        return res

"
本题还需要startIndex来控制for循环的起始位置,对于组合问题,什么时候需要startIndex呢?

如果是一个集合来求组合的话,就需要startIndex,例如:77.组合 (opens new window),216.组合总和III (opens new window)。

如果是多个集合取组合,各个集合之间相互不影响,那么就不用startIndex,例如:17.电话号码的字母组合(opens new window)

以上只是说求组合的情况,如果是排列问题,又是另一套分析的套路
"


剪枝优化

img

对于sum已经大于target的情况,其实是依然进入了下一层递归,只是下一层递归结束判断的时候,会判断sum > target的话就返回。

其实如果已经知道下一层的sum会大于target,就没有必要进入下一层递归了。

img

class Solution:
    def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]:
        res=[]
        candidates.sort()
        self.backtracking(candidates,0,0,target,[],res)
        return res

    def backtracking(self,candidates, start,curr_sum, target_sum,sub_res,res):
        if curr_sum == target_sum:
            res.append(sub_res[:])
            return
        for i in range(start, len(candidates)):
            if curr_sum + candidates[i] > target_sum:
                break
            self.backtracking(candidates,i,curr_sum+ candidates[i],target_sum,sub_res+[candidates[i]],res)
          

40.组合总和II

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

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

题目链接/文章讲解:https://programmercarl.com/0040.组合总和II.html
视频讲解:https://www.bilibili.com/video/BV12V4y1V73A


本题难点:

本题的难点在于区别2中:集合(数组candidates)有重复元素,但还不能有重复的组合。

一些同学可能想了:我把所有组合求出来,再用set或者map去重,这么做很容易超时!

所以要在搜索的过程中就去掉重复组合。

回看一下题目,元素在同一个组合内是可以重复的,怎么重复都没事,但两个组合不能相同。

所以我们要去重的是同一树层上的“使用过”,同一树枝上的都是一个组合里的元素,不用去重。

为了理解去重我们来举一个例子,candidates = [1, 1, 2], target = 3,(方便起见candidates已经排序了)

强调一下,树层去重的话,需要对数组排序!

img

img

img

这里直接用startIndex来去重也是可以的, 就不用used数组了。


class Solution:
    def combinationSum2(self, candidates: List[int], target: int) -> List[List[int]]:
        candidates.sort()
        res = []
        def _dfs(candidates,target,cur_sum, index, sub_res):
            if cur_sum == target:
                res.append(sub_res[:])
                return

            for i in range(index,len(candidates)):
                if cur_sum+candidates[i] >target:
                    break

                #同一树层上去重
                if i > index and candidates[i] == candidates[i-1]:
                    continue
                _dfs(candidates,target,cur_sum+candidates[i],i+1,sub_res+[candidates[i]])
        
        _dfs(candidates,target,0,0,[])
        return res

错点:

_dfs(candidates,target,cur_sum+candidates[i],i+1,sub_res+[candidates[i]])

这句把i+1写成了index+1.....

代码随想录解法

class Solution:
    def combinationSum2(self, candidates: List[int], target: int) -> List[List[int]]:
        candidates.sort()
        results = []
        self.combinationSumHelper(candidates, target, 0, [], results)
        return results

    def combinationSumHelper(self, candidates, target, index, path, results):
        if target == 0:
            results.append(path[:])
            return
        for i in range(index, len(candidates)):
            if i > index and candidates[i] == candidates[i - 1]:
                continue  
            if candidates[i] > target:
                break  
            path.append(candidates[i])
            self.combinationSumHelper(candidates, target - candidates[i], i + 1, path, results)
            path.pop()

131.分割回文串

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

https://programmercarl.com/0131.分割回文串.html
视频讲解:https://www.bilibili.com/video/BV1c54y1e7k6

img


递归用来纵向遍历,for循环用来横向遍历,切割线(就是图中的红线)切割到字符串的结尾位置,说明找到了一个切割方法。


结果都在叶子节点

startIndex是我们切割的线

回溯三部曲

  • 递归函数参数

    本题递归函数参数需要startIndex,因为切割过的地方,不能重复切割,和组合问题也是保持一致的。

  • 递归函数终止条件

    从树形结构的图中可以看出:切割线切到了字符串最后面,说明找到了一种切割方法,此时就是本层递归的终止条件。

    在处理组合问题的时候,递归参数需要传入startIndex,表示下一轮递归遍历的起始位置,这个startIndex就是切割线。

  • 单层搜索的逻辑

    在递归循环中如何截取子串呢?

    在for (int i = startIndex; i < s.size(); i++)循环中,我们 定义了起始位置startIndex,那么 [startIndex, i] 就是要截取的子串。

    首先判断这个子串是不是回文,如果是回文,就加入在path中,path用来记录切割过的回文子串。

    注意:

    切割过的位置,不能重复切割,所以,backtracking(s, i + 1); 传入下一层的起始位置为i + 1

判断回文子串

双指针法

img

class Solution:
    def partition(self, s: str) -> List[List[str]]:
        result = []
        isPalindrome = [[False] * len(s) for _ in range(len(s))]  # 初始化isPalindrome矩阵
        self.computePalindrome(s, isPalindrome)
        self.backtracking(s, 0, [], result, isPalindrome)
        return result

    def backtracking(self, s, startIndex, path, result, isPalindrome):
        if startIndex >= len(s):
            result.append(path[:])
            return

        for i in range(startIndex, len(s)):
            if isPalindrome[startIndex][i]:   # 是回文子串
                substring = s[startIndex:i + 1]
                path.append(substring)
                self.backtracking(s, i + 1, path, result, isPalindrome)  # 寻找i+1为起始位置的子串
                path.pop()           # 回溯过程,弹出本次已经添加的子串

    def computePalindrome(self, s, isPalindrome):
        for i in range(len(s) - 1, -1, -1):  # 需要倒序计算,保证在i行时,i+1行已经计算好了
            for j in range(i, len(s)):
                if j == i:
                    isPalindrome[i][j] = True
                elif j - i == 1:
                    isPalindrome[i][j] = (s[i] == s[j])
                else:
                    isPalindrome[i][j] = (s[i] == s[j] and isPalindrome[i+1][j-1])

我们一起来完整梳理一下这个 Palindrome Partitioning(分割回文子串) 解法。你写的这份代码其实是「DP 预处理回文 + 回溯枚举分割方案」的经典组合。


问题

给定一个字符串 s,要求把它切分成若干子串,使得每个子串都是回文,返回所有可能的切分。


解法分两部分

1. 预处理 DP 表:isPalindrome

目标:快速判断任意子串 s[i..j] 是否为回文。

isPalindrome = [[False] * len(s) for _ in range(len(s))]

填表逻辑:

  • 长度为 1 的子串(j == i),一定是回文。

  • 长度为 2 的子串(j - i == 1),当 s[i] == s[j] 时是回文。

  • 长度 ≥ 3 的子串,用递推公式:

    s[i..j] 是回文 ⇔ s[i] == s[j] 且 s[i+1..j-1] 是回文
    
  • 为什么从后往前计算?
    因为要用到 isPalindrome[i+1][j-1],所以要保证下一行(更大的 i)已经算好。

这样预处理一次,得到 O(n^2) 的表格,后续查询是否回文就是 O(1)


2. 回溯搜索所有切分方案

思路:从左到右扫描,每次尝试切出一个回文子串,然后递归处理剩下部分。

def backtracking(s, startIndex, path, result, isPalindrome):
    if startIndex == len(s):  # 走到末尾
        result.append(path[:])  # 保存一条完整切分
        return

    for i in range(startIndex, len(s)):
        if isPalindrome[startIndex][i]:   # s[startIndex..i] 是回文
            substring = s[startIndex:i+1]
            path.append(substring)        # 做选择
            self.backtracking(s, i+1, path, result, isPalindrome)
            path.pop()                    # 撤销选择(回溯)

关键点:

  • 路径 path:保存当前分割结果。
  • 边界条件:当起始位置 startIndex == n,说明整串已经分完。
  • 选择:尝试每一个 startIndex..i 区间,只要是回文就递归。
  • 回溯:递归回来后,把刚加入的子串移除,继续尝试其他切法。

举例

输入 s = "aab"

  1. 初始 startIndex=0

    • 尝试 "a"(回文) → path = ["a"]
      递归到 startIndex=1

      • 尝试 "a"(回文) → path = ["a","a"]
        递归到 startIndex=2

        • 尝试 "b"(回文) → path = ["a","a","b"]
          递归到末尾 → 加入结果。
      • 尝试 "ab"(不是回文) → 跳过。

    • 尝试 "aa"(回文) → path = ["aa"]
      递归到 startIndex=2

      • 尝试 "b" → path = ["aa","b"]
        递归到末尾 → 加入结果。
    • 尝试 "aab"(不是回文) → 跳过。

最终结果:

[["a","a","b"], ["aa","b"]]

复杂度

  • DP 预处理O(n^2)
  • 回溯枚举:最坏情况(字符串全是同一个字符,比如 "aaaa"),回文切分数接近 2^(n-1)
  • 所以总复杂度:O(n^2 + n * 2^n)(由输出规模主导)。

✨ 总结:

  1. DP 表 isPalindrome 解决「快速判断子串是否回文」。
  2. 回溯 path 遍历所有可能切分,保证覆盖所有情况。
  3. 回溯撤销 保证路径正确回退,继续探索其他可能。

回文部分

我就专门帮你拆开「如何判断子串是否回文」这一块,也就是你代码里 computePalindrome 的部分。


基础定义

一个子串 s[i..j] 是回文,当且仅当:

  • 首尾相等:s[i] == s[j],并且
  • 中间部分 s[i+1..j-1] 也是回文。

特殊情况:

  • 长度 1(i==j):一定回文。
  • 长度 2(j-i==1):只要两个字符相等,就回文。

为什么要用 DP 表 isPalindrome

如果你每次都临时判断子串是否回文,要扫描一遍字符串,最坏会到 O(n^3)
所以我们提前用 DP 方式,把所有 s[i..j] 的结果存起来,以后查就是 O(1)


代码逻辑(你写的版本)

def computePalindrome(self, s, isPalindrome):
    for i in range(len(s) - 1, -1, -1):  # i 从右往左
        for j in range(i, len(s)):       # j 从 i 到末尾
            if j == i:  # 子串只有一个字符
                isPalindrome[i][j] = True
            elif j - i == 1:  # 子串长度=2
                isPalindrome[i][j] = (s[i] == s[j])
            else:  # 长度>=3
                isPalindrome[i][j] = (s[i] == s[j] and isPalindrome[i+1][j-1])

为什么 i 要从右往左?

看这一句:

isPalindrome[i][j] = (s[i] == s[j] and isPalindrome[i+1][j-1])

要判断 s[i..j] 是否回文,需要先知道 里面那段 s[i+1..j-1] 是否回文。
i+1 > i,所以必须保证在计算 (i,j) 之前,已经算过 (i+1,j-1)
因此 i 要从大到小循环。


举个例子:s = "aba"

  • 长度=1:

    • isPalindrome[0][0] = True ("a")
    • isPalindrome[1][1] = True ("b")
    • isPalindrome[2][2] = True ("a")
  • 长度=2:

    • isPalindrome[0][1] = (s[0]==s[1]) = (a==b)=False
    • isPalindrome[1][2] = (s[1]==s[2]) = (b==a)=False
  • 长度=3:

    • isPalindrome[0][2] = (s[0]==s[2]) and isPalindrome[1][1]
      = (a==a) and True = True

最后表格是:

   0     1     2
0 [T,   F,    T]
1 [ ,   T,    F]
2 [ ,    ,    T]

意思是:

  • "a"(0..0) 回文
  • "b"(1..1) 回文
  • "a"(2..2) 回文
  • "aba"(0..2) 回文 ✅

我们用同一个例子 s = "abba",把 computePalindrome 里两层循环的 i、j 变化过程逐步“走表”。
外层:i 从右到左(3 → 0)
内层:ji 到末尾(i → 3

为了直观,每一步我都写出:区间、长度、规则与结果。


准备

s = a  b  b  a
    0  1  2  3   (下标)

规则速记:

  • 长度1:True
  • 长度2:s[i]==s[j]
  • 长度≥3:s[i]==s[j] and isPalindrome[i+1][j-1]

逐步填表日志(i,j 的变化)

i = 3

  1. (i=3, j=3) ⇒ 区间 s[3..3] = "a",长度1 → True
    P[3][3]=True

i = 2

  1. (2,2) ⇒ "b",长度1 → True
    P[2][2]=True
  2. (2,3) ⇒ "ba",长度2 → 比较 s[2]=='b' vs s[3]=='a' → False
    P[2][3]=False

i = 1

  1. (1,1) ⇒ "b",长度1 → True
    P[1][1]=True
  2. (1,2) ⇒ "bb",长度2 → s[1]=='b' vs s[2]=='b' → True
    P[1][2]=True
  3. (1,3) ⇒ "bba",长度3 → 首尾 b vs a 不等 → False(无需看内层)
    P[1][3]=False

i = 0

  1. (0,0) ⇒ "a",长度1 → True
    P[0][0]=True
  2. (0,1) ⇒ "ab",长度2 → a vs b → False
    P[0][1]=False
  3. (0,2) ⇒ "abb",长度3 → 首尾 a vs b 不等 → False
    P[0][2]=False
  4. (0,3) ⇒ "abba",长度4
  • 首尾相等:s[0]==s[3]a==a)✅
  • 查内层回文:P[1][2] 之前在步骤 5 已算出 True
    P[0][3] = True and True = True

关键依赖:(0,3) 需要 (1,2),而 (1,2) 是在 i=1 时已计算好——这就是为什么 i 必须从右往左。


最终 DP 表 P = isPalindrome

(行是 i,列是 j)

     j=0   j=1   j=2   j=3
i=0 [ T ,   F ,   F ,   T ]
i=1 [     , T ,   T ,   F ]
i=2 [     ,     , T ,   F ]
i=3 [     ,     ,     , T ]

表意

  • 单字符全回文;
  • "bb"(1..2) 回文;
  • "abba"(0..3) 回文;
  • 其他区间非回文。

如果你把上面的 10 个步骤照着自己的代码在纸上跑一遍,就能完全理解:

  • i 从右到左是为了解决 P[i+1][j-1] 的依赖;
  • j 从左到右保证同一行从短到长推进;
  • 三种长度规则覆盖了所有情况。
posted @ 2025-09-03 22:20  ForeverEver333  阅读(8)  评论(0)    收藏  举报