算法学习打卡day29|回溯算法总结

本文详细解释了回溯算法的概念,它与递归的关系,以及在解决组合、排列、切割、子集和棋盘问题中的应用。重点讨论了算法的效率,包括时间复杂度(如O(n*2^n))和空间复杂度(O(n)),并举例了几个实际问题的解题思路和优化技巧。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

理论基础

什么是回溯?
  • 回溯法也叫回溯搜索法,就是一种搜索方法,而且还是暴力搜索!另外,回溯是递归的副产品,只要有递归就会有回溯,所以回溯法也经常和二叉树遍历,深度优先搜索混在一起,因为这两种方式都是用了递归。
可以解决什么问题?
  • 组合问题:N个数里面按一定规则找出k个数的集合,这个结果集是无序的,而排列是有序的,比如1和2,2和1在组合里是算一组数据,在排列里算两组。
  • 排列问题:N个数按一定规则全排列,有几种排列方式。
  • 切割问题:一个字符串按一定规则有几种切割方式。
  • 子集问题:一个N个数的集合里有多少符合条件的子集
  • 棋盘问题 :N皇后,解数独等等。
如何理解回溯算法?
  • 把回溯算法理解为抽象的树,一般情况下集合数目n为树的宽度,递归的深度为树的深度,本质还是递归,只是要一层一层的去遍历每个子节点,递归结束条件一般是碰到叶子节点结束。
  • 效率问题:由于它属于暴力搜索,本质是穷举,所以它的效率很低,但是有些问题只能穷举,而且普通的暴力算法无法解决,只能使用回溯法,而且优化也最多就是剪枝一下。
  • 回溯解题模版
  • 回溯其搜索的过程for循环横向遍历,递归纵向遍历,回溯不断调整结果集,这个理念贯穿整个回溯法系列。
void backtracking(参数) {
    if (终止条件) {
        存放结果;
        return;
    }

    for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
        处理节点;
        backtracking(路径,选择列表); // 递归
        回溯,撤销处理结果
    }
}

组合问题

组合
  • 力扣题目链接
  • 这道题是给定两个数,一个是组合长度k,一个是数据的长度n,要求所有的长度为k的组合,直接套用回溯模版,for循环为1到n,然后递归是去找组合元素,递归深度就是k,回溯逻辑在path在递归前后的push和pop
  • 优化:剪枝操作,在for循环里,当剩余的元素不满足k个元素时,直接跳出循环,退出即可。
组合总和
  • 力扣题目链接
  • 思路:这道题和第一题的组合问题类似,只不过把ke个元素的限制改为了组合元素之和为target限制,那么递归退出条件就是组合和为target时,别的步骤都一样,注意这个可以出现重复元素,所以,下一层递归的index还是 i 而不是 i + 1。
  • 优化:还是在for循环的地方剪枝,当target - nums[i] >= 0时在进入循环,如果小于的话肯定超过target了,不符合情况。
组合总和2
  • 力扣题目链接
  • 思路:这道题是组合总和问题的变种,要求组合中每个元素只能出现一次,这就要涉及使用used数组到去重问题了。
  • 这道题是可以纵向重复,而不能横向重复的。
    • 什么是纵向重复?
      • 就是假如数组有相同元素,那么是可以出现在一个组合里的,但是同一个元素只能在一个组合出现一次,且不能有重复数组出现。
    • 什么是横向重复?
    • 就是在for循环里,相同的两个元素不能重复使用。
  • 解题思路:先对数组进行排序,这样便于去重,然后在递归函数参数里增加一个used数组,用来描述这个元素的上一个元素是否被使用过,比如1,1两个元素,第一个1被使用了,那么第二轮走到第二个1的时候就不会再去使用第二个1了,但是递归进去时,我们把第一个1的使用状态改为未使用,这样可以保证第二个1和第一个1可以被分到一个组合,递归函数退出后,再改回已使用的状态。
组合总和3:
  • 力扣题目链接
  • 这道题是组合问题的变种,把n个数中求k个组合,改为在1-9中9个数字里求和为n的k个数的组合。
  • 思路:和组合问题一样,但是增加了组合的元素和为n,其实就是在递归参数列表里增加一个参数用来保存数组和即可(也可以用减法,我就是用的减法),退出条件还是path大小为k的时候。
  • 剪枝:这里剪枝的时候需要注意for循环里还要加上i <= 9 - need + 1 && n - i >= 0,因为如果不加在,大小等于k的时候,假如不符合和为n,那么递归会继续向下走,然后就陷入递归死循环了。
电话号码组合
  • 力扣题目链接
  • 思路:
    • 和上面组合问题的区别在于,这道题是不同集合之间的组合,所以每一层递归的for循环是数字对应的字母。
    • 首先定义一个数字到字母的映射,数组和map都行,直接手动定义即可
    • 还是回溯法的思路,但是这个题宽度是每个数字的代表的字母数,而深度为数字的个数
  • 注意:面试时加上其他符号的判断,只要将符号- ‘0’,得到的结果不在0-9之间就不是数字。

切割问题

  • 切割问题其实和组合问题是类似的,且看下面两道题
分割回文子串
  • 力扣题目链接
  • 就是将一个字符串分割为回文子串组合
  • 思路,步骤:
    1. 类似于组合问题,for循环横向遍历字符串,分别相当于切1、2、3…n个元素,纵向就是往组合里添加一个个子串。
    2. 子串怎么界定?
    3. 这里显然在取得子串的后,就要判断字符串是不是回文子串。
    4. 递归退出条件为下标start_index是否走到了字符串尾部。
      优化:这里不需要剪枝、剪枝操作就是判断回文子串,不是就continue,继续遍历。
复原IP地址
  • 这道题也是切割问题,类似于组合问题的做法,力扣题目链接
  • 思路,步骤:
    • 只要字符串的长度小于4或者大于12,肯定是不对的,直接return。
    • 切割字符串,这里可以在for循环里添加,每层最多切三个,切割后判断字符符合条件。
      • 主要是四点:
        1. begin要小于等于end
        2. 如果子串长度大于1,那么开头元素不能为0
        3. 有特殊符号也不行
        4. 数字在0到255之间,超出也不行。
    • 如果符合条件,就修改s在i之后添加一个 ‘.’,然后调用递归函数,注意次数应该给start_index赋值为i + 2,因为加了点,然后递归结束再将.去掉(回溯)
    • 递归退出条件为进入到第四层(每次递归都加1层,递归结束再-1)的时候,此时判断剩下的子串是否符合,符合就放入结果集,不符合就返回。

子集问题

子集
  • 力扣题目链接
  • 和组合问题的区别在于,组合问题再递归退出的时候才存入结果集,而子集问题是每一次循环(代表每一个叶子节点)都存入结果集,退出条件就是start_index等于数组大小的时候
子集ii
  • 力扣题目链接
  • 在上题的基础上要进行去重,和组合问题一样的去重逻辑,使用used数组
递增子序列
  • 力扣题目链接
  • 这道题注意是在原序列上找递增子序列,而不是在排序后的数组上找,如果给数组排序,那整体就是有序的了,没有意义了
  • 思路:
    • 同样是子集问题,我们在path大小大于1的时候放入结果集,然后在for循环里要判断下当前元素和path最后一个元素的大小,如果小,就continue。
    • 注意:这里需要去重,因为可能出现重复子序列,去重就是防止横向重复即可,因为不能对数组排序,所以used方法不能用了,但是由于只是横向去重,那么直接for循环前定义一个set进行去重即可。

排列问题

  • 排列问题和组合问题的区别是,排列问题是有序的,但组合问题无序,所以,12和21在排列里是不同的组合
全排列
  • 力扣题目链接
  • 思路:
    • 和组合问题一样,但是需要增加一个used数组作为参数,因为在一个组合里每个元素只能出现一次,但是由于每次start_index都是0,所以就需要used数组来帮我们判断这个元素在之前是否被使用过了。
    • 函数退出条件为,path大小等于数组大小的时候
全排列2

力扣题目链接

  • 这道题激素上道题的基础上,增加了去重逻辑,因为原数组有重复元素了
  • 去重(这里依然是横向去重):
    • 方法一:直接使用递增子序列的去重方法在普通排列的基础上增加set去横向去重即可解决。
    • 方法二:因为这里不强调在原数组上的排列,所以可以对其进行排序,因为排列本来要使用used,所以可以将去重逻辑或判断元素是否被使用过放在一起。
      • 增加一个条件判断if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == false),其实按照思维逻辑应该使用if(i > 0 && nums[i] == nums[i - 1] && used[i - 1] == true)去判断,就是说当前一个元素被用了,那么我就continue,但是由于排列问题在递归之前需要将当前元素的used置为true,递归结束后改为false,那么我们在进行去重判断的时候,其实如果取true就是在递归下一层里去判断前一个元素是否使用过,如果前一个元素是上一层的元素,那么就是成立的,如果是当前层元素那么就是不成立的。所以true的话是纵向去重,而改用false的话,因为for循环每次递归后,used数组会被改为false,所以false是在当前层去重。
      • 但是事实上这两种判断方法都是正确的,但是使用false去判断时,效率更高,因为使用false的话是在树层上去重,而使用true是在树枝上去重,在树层上去重的话,直接就不用进入递归了,而树枝上去重还得进入递归再去判断。
去重方法总结
  • used去重法和set去重法
    • 使用used数组去重效率会更高,时间复杂度和空间复杂度都比使用set来的快,而使用哈希set时,由于需要频繁的进行插入和查找工作,比较耗时,即使哈希set的效率已经很高,但是在插入的时候也还是要计算哈希值,扩充符号表等工作,时间上还是没有数组来的快,而且每次递归里都有一个set都话,空间复杂度也变为 O ( n 2 ) O(n^2) O(n2)了(递归为n,每次递归的set为n,乘起来!)。
    • 使用set去重的话,其实普通的组合和子集问题也可以使用,但是一定要排序,因为不排序的话,假如有重复元素不连在一起,可能祖先节点已经使用过了,还是会出现重复的情况,那么为什么 递增子序列 这道题可以不排序去使用set?因为这道题有约束条件‘递增’,你排序了还怎么解题啊!
    • 所以,能用used数组就用used数组去重,前提是得可排序,如果无法排序就使用set去解题吧。
    • 但是,最重要的是分析题目,是需要纵向去重还是横向去重,还是都可以,比如全排列就都可以,但是优先使用横向去重。

棋盘问题(难题)

重新安排行程
N皇后
解数独

回溯算法性能分析

  • 子集问题分析:

    • 时间复杂度: O ( n ∗ 2 n ) O(n*2^n ) O(n2n) ,因为每一个元素的状态无外乎取与不取,由于节点总数为 2 n 2^n 2n(按二叉树算了),都要遍历一次,构造每一组子集都需要填进数组(对应的代码:result.push_back(path),该操作的复杂度为 O ( n ) O(n) O(n),最终时间复杂度: O ( n × 2 n ) O(n × 2^n) O(n×2n)。所以时间复杂度为 O ( n ∗ 2 n ) O(n*2^n) O(n2n)
    • 空间复杂度: O ( n ) O(n) O(n),递归深度为n,所以系统栈所用空间为 O ( n ) O(n) O(n),每一层递归所用的空间都是常数级别,注意代码里的result和path都是全局变量,就算是放在参数里,传的也是引用,并不会新申请内存空间,最终空间复杂度为 O ( n ) O(n) O(n)
  • 排列问题分析:

    • 时间复杂度: O ( n ! ) O(n!) O(n!),这个可以从排列的树形图中很明显发现,每一层节点为 n n n,第二层每一个分支都延伸了n-1个分支,再往下又是n-2个分支,所以一直到叶子节点一共就是 n ∗ n − 1 ∗ n − 2 ∗ . . . . . 1 = n ! n * n-1 * n-2 * ..... 1 = n! nn1n2.....1=n!, 而每个叶子节点都会有一个构造全排列填进数组的操作(对应的代码:result.push_back(path),该操作的复杂度为 O ( n ) O(n) O(n)。所以,最终时间复杂度为: n ∗ n ! n * n! nn!,简化为 O ( n ! ) O(n!) O(n!)
    • 空间复杂度: O ( n ) O(n) O(n),和子集问题同理。
  • 组合问题分析:

    • 时间复杂度: O ( n ∗ 2 n ) O(n*2^n ) O(n2n),组合问题其实就是一种子集的问题,所以组合问题最坏的情况,也不会超过子集问题的时间复杂度。
    • 空间复杂度: O ( n ) O(n) O(n),和子集问题同理。
  • 切割问题和组合问题基本一样,回文子串由于要去求子串,空间复杂度为 O ( n 2 ) O(n^2) O(n2)

  • 棋盘算法问题分析

  • 一般回溯算法的复杂度,都是指数级别的时间复杂度。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值