回溯算法套路①子集型回溯 - 灵神视频总结

我回来了,前两天模型出了问题,忙于工作。

加上回溯有点想不清楚,查了查资料。汗颜。

这节课主要讲回溯的基本概念和回溯的基本套路。

首先各位思考一个问题:如果生成一个长度为2的字符串,该怎么操作?

我们通常的想法是用两层循环拼接就好,如果用两层循环,如果我要生成长为2或者3的,甚至长度不固定的该怎么写的呢?无能为力,单纯的递归表达能力有限。

那该如何思考呢?

之前在前面讲过递归的概念,那么原问题是构造长为n的字符串,那么子问题就是构造长为n-1的字符串。那么构造长为n的字符串,在枚举了一个字符串之后,就变成构造长为n-1的字符串。这种从原问题到子问题的过程适合用递归解决。通过递归可以达到多重递归的效果。

回溯和递归有什么关系呢?

像这种题目,比如构建长度为2的字符串,有个增量构造答案的过程,这个过程通常可以使用递归来解决。

那回忆一下递归,我们不需要想很多,只需要想明白边界条件和非边界条件逻辑即可,其他的交给数学归纳法。

这里有个模版 ,思考问题的套路

当前操作,就是枚举一个字母。dfs(i) i 不是枚举的字母i,而是第>=i 的部分。用一个全局变量来记录结果。

好,我们看一道题

17. 电话号码的字母组合

就按照刚才讲的逻辑写一下代码,如果不懂,可以看注释。

MAPPING = ["", "", "abc","def","ghi","jkl","mno","pqrs","tuv","wxyz"] # {1:"abc"}
class Solution:
    def letterCombinations(self, digits):
        # 时间复杂度O(n*4^n)
        n = len(digits)
        if n == 0 :
            return []
        ans = []
        path = ['']*n
        def dfs(i): # 从i开始,构造到n个字符串。
            if i == n: #边界条件,当等于n时,构建完了,需要结束了。
                ans.append("".join(path)) # 把结果放进去。
                return
            for c in MAPPING[int(digits[i])]: # 可以想两层,先拿第一个字母,
                path[i] = c 
                dfs(i+1) # 从i+1开始,构造到n个字符串。
        dfs(0) # 从第0个数字开始递归。
        return ans 
    
so = Solution()
print(so.letterCombinations("29"))

分析下时间复杂度,可以从循环的角度理解,循环次数最多循环4^n次方,存储答案还需要n, 时间复杂度为O(n*4^n)

回溯的套路

这里讲 子集型回溯 。

问题 : 计算[1,2]的所有子集。

每个元素都可以 选 或者不选, 就有4中选择,1 选/不选 2 选/不选

要想生成所有子集,有两种操作,区别在于当前的操作是什么?

代码 - 看注释

当前操作是 选/不选当前元素。

子问题是 -> 从大于等于i个元素开始选

下一个子问题就是已经对当前做出了选择, 然后递归 下标 i + 1 个元素。

这就是递归,不要想太多,只关注当前问题和当前问题和下个问题的逻辑条件,其他的交给数学归纳法。

要相信数学归纳法。

看代码

class Solution:
    def subsets(self, nums: List[int]) -> List[List[int]]:
        n = len(nums)
        ans = []
        path = []
        def dfs(i): # 选不选i
            if i == n:
                ans.append(path.copy()) #why?
                return 
            dfs(i+1)

            path.append(nums[i])
            dfs(i+1)
            path.pop() # 为什么要恢复现场?
        dfs(0)
        return ans 
    

针对代码再啰嗦两句:

  1. 首先它是递归,递归就是写边界条件 和 非边界条件, 其他 的交给数学归纳法。
  2. 要明白或者清楚你在递归什么。 即递归的东西是什么,里面的参数是什么,这个就是要想清楚子问题是什么。

细节: 为什么要加入path.copy() ,
如果直接将 path 添加到 ans 中,而不使用 path.copy(),那么 path 在后续的 dfs 中被修改时,已经添加到 ans 中的 path 也会被修改,这会导致答案错误。
使用 path.copy() 复制一份 path,可以确保每个子集都是独立的,不会相互影响。这样就可以正确地构建出所有的子集,避免出现错误。

为什么会要恢复现场?

在递归到某一“叶子节点”(非最后一个叶子)时,答案需要向上返回,而此时还有其他的子树(与前述节点不在同一子树)未被递归到,又由于path为全局变量。若直接返回,会将本不属于该子树的答案带上去,故需要恢复现场。
恢复现场的方式为:在递归完成后 dfs(i+1); 后,进行与“当前操作”相反的操作,“反当前操作”。

这题还有第二种解法

这里解释什么是答案的角度?就是第一个数选谁,第二个数选谁。

就固定一个遍历的顺序就行。

当前操作: 枚举下一个j>= i 的数字。

子问题就需要从下表 >=j +1 的数字中构建子集。

class Solution:
    def subsets(self, nums: List[int]) -> List[List[int]]:
        n = len(nums)
        if n == 0 :
            return []

        path = []
        ans = []
        def dfs(i):
            ans.append(path.copy())
            if i == n:
                return 
            for j in range(i, n):
                path.append(nums[j])
                dfs(j+1)
                path.pop()
        dfs(0)
        return ans 

枚举逗号的位置。

回文子串怎么弄? 相向双指针

看代码:

class Solution:
    def partition(self, s: str) -> List[List[str]]:
        n = len(s)
        if n == 0:
            return []

        ans = []
        path = []
        def dfs(i): # 枚举逗号的位置。
            if i == n:
                ans.append(path.copy()) #复制path
                return 
            for j in range(i, n): #枚举子串的结束位置。
                t = s[i: j+1]
                if t == t[::-1]:
                    path.append(t)
                    dfs(j+1)
                    path.pop() #恢复现场。
        dfs(0)
        return ans 
<think>好的,我现在需要回答用户关于回溯算法思想及其C++模板的问题。首先,我得回顾一下回溯的基本概念,确保自己理解正确。回溯法是一种通过探索所有可能的候解来解决问题的算法,主要用于解决组合、排列、子等问题。当候解被确认是可行解时,回溯算法会撤销之前的部分处理结果,继续尝试其他可能的路径。 接下来,我需要整理回溯的核心思想。回溯通常通过递归来实现,每次递归调用代表一个决策点,尝试同的择,并在达到终止条件时记录结果。关键点包括路径的择、撤销择以及终止条件的判断。 然后,关于解题的通用套路模板,用户可能需要一个清晰的C++代码结构。常见的模板包括一个主函数,初始化必要的变量,并调用回溯函数。回溯函数通常包含终止条件、遍历择列表、做出择、递归调用、撤销择这几个步骤。需要确保模板涵盖这些部分,并能够活适应同的问题。 还要考虑如何将思想转化为代码。例如,路径用vector保存,择列表可能根据问题同而变化,比如全排列问题需要排除已元素,而组合问题则需要按顺序择避免重复。需要说明模板中的各部分如何对应到具体实现,比如参数传递、标记已访问元素等。 可能还需要举一些例子来帮助用户理解模板的应用,比如全排列、子等问题,但用户可能只需要模板本身,所以例子可以简要提及。同时,要提醒用户在使用模板时需要注意的常见问题,比如择列表的动态生成、剪枝优化等。 最后,确保回答结构清晰,分点解释回溯思想和模板,代码部分正确使用格式,并符合LaTeX要求。检查是否有遗漏的关键点,比如剪枝的重要性,或者时间复杂度的问题。确保语言准确,避免误导用户,同时保持回答的中文表述自然流畅。</think>### 回溯算法思想介绍 回溯算法是一种通过**穷举所有可能解**并剪枝无效路径的暴力搜索算法。核心思想是“试错”: 1. **择**:在决策树的某一层做择(例如一个数加入路径) 2. **约束**:通过条件判断剪枝无效分支(例如排除重复元素) 3. **撤销**:当发现当前路径无法得到解时,回退到上一步重新择 适用于组合、排列、子、棋盘类问题(如N皇后)等场景。 --- ### 回溯算法通用模板(C++) ```cpp void backtrack(路径, 择列表, 结果) { if (满足终止条件) { 结果.push_back(路径); return; } for (择 : 择列表) { if (存在无效择) continue; // 剪枝 做择; // 将择加入路径 backtrack(更新后的路径, 新择列表, 结果); 撤销择; // 将择移出路径 } } // 示例:主函数调用 vector<vector<int>> solveProblem(vector<int>& nums) { vector<vector<int>> res; vector<int> path; backtrack(path, nums, res); return res; } ``` --- ### 关键要素解析 1. **路径**:用`vector`记录当前择序列 2. **择列表**:通常由问题决定,例如: - 全排列:未使用的元素 -:当前元素之后的元素(避免重复) 3. **终止条件**: ```cpp // 全排列终止条件 if (path.size() == nums.size()) // 组合终止条件 if (path.size() == k) ``` 4. **剪枝优化**:通过预排序和条件判断跳过无效分支 ```cpp // 剪枝重复元素示例(需先排序) if (i > 0 && nums[i] == nums[i-1] && !used[i-1]) continue; ``` --- ### 复杂度分析 - 时间复杂度:通常为$O(n \times n!)$或$O(2^n)$,具体取决于问题特征 - 空间复杂度:$O(n)$(递归栈深度) --- ### 典例题修改点 | 问题类 | 择列表变化 | 剪枝条件 | |---------|-------------|---------| | 全排列 | 排除已元素 | 无重复元素 | | 组合数 | 只后续元素 | `startIndex`控制 | | 子 | 允许所有后续元素 | 排序后跳过重复 | | N皇后 | 满足冲突的位置 | 行列对角线检查 | 实际使用时需根据具体问题调整参数和剪枝条件。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值