羊羊刷题笔记Day30/60 | 第七章 回溯算法题型总结

回溯法理论基础

回溯是递归的副产品,只要有递归就会有回溯

解决问题

不止本章的练习,回溯法也经常和二叉树遍历,深度优先搜索混在一起,因为这两种方式都是用了递归
回溯法就是暴力搜索 ,并不是什么高效的算法,最多再剪枝一下。

回溯算法能解决如下问题:

  • 组合问题:N个数里面按一定规则找出k个数的集合
  • 排列问题:N个数按一定规则全排列,有几种排列方式
  • 切割问题:一个字符串按一定规则有几种切割方式(类似于组合问题)
  • 子集问题:一个N个数的集合里有多少符合条件的子集
  • 棋盘问题 :N皇后,解数独等等(此部分较难

在刷回溯算法也根据以上问题顺序刷,过程中回溯法确实不好理解,所以需要把在写代码前画画图,把回溯法抽象为一个图形来理解就容易多了。(在画图过程中还可以切实知道如何更好剪枝,减去没必要的步骤。干想还是比较困难的)

引用李强总理的一句话:坐在办公室碰到的都是问题,深入基层看到的全是办法。

第一章第一题77 组合中一层层递归以及for循环去画,了解程序怎么走的,对后面做题非常有帮助

回溯模板

scss 复制代码
void backtracking(参数) {
    if (终止条件) {
        存放结果;
        return;
    }

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

此模板伴随整个回溯法系列!

组合问题

组合问题

回溯算法第一题就是77 组合------组合问题。

文章举例了k层for循环例子,进而得出都是同样是暴力解法,为什么要用回溯法它用递归控制for循环嵌套的数量

本题把回溯问题抽象为树形结构,如题:

程序运转顺序:

可以直观的看出其搜索的过程:for循环横向遍历,递归纵向遍历,回溯不断调整结果集,这个理念贯穿整个回溯法系列。

另外,优化回溯算法只有剪枝 一种方法,在77 组合 - 剪枝优化中把回溯法代码做了剪枝优化,树形结构如图:

大家可以一目了然剪的究竟是哪里。
剪枝精髓是:for循环在寻找起点的时候要有一个范围,如果这个起点到集合终止之间的元素已经不够题目要求的k个元素了 ,就没有必要搜索 了。

在for循环上做剪枝操作是回溯法剪枝的常见套路!

组合总和

组合总和(一)

216 组合总和Ⅱ中,相当于 77 组合问题 多加了一个元素总和 的限制。

树形结构如图(注意红色部分):

整体思路还是一样的,本题的剪枝会好想一些,即:
已选元素总和如果已经大于n(题中要求的和)了,那么往后遍历就没有意义了,直接break剪掉 ,如图(注意蓝字):

本题另一个剪枝,就是77 组合 - 剪枝优化中提到的,对for循环选择的起始范围的剪枝

所以剪枝的代码可以在for循环加上** i <= 9 - (k - path.size()) + 1** 的限制!

组合总和(二)

相较77 组合 以及 216 组合总和Ⅱ,本题39 组合总和的特点是可重复使用元素,但是有总和的限制 。(所以间接的也是有个数的限制

看到可以重复选择,把startIndex去掉可以吗?
不可以!本题为从单个集合中取元素,因此还需要startIndex来控制for循环的起始位置!

回到正题,本题的树形结构如下:

最后还给出了本题的剪枝优化(之和大于targetSum),如下:

java 复制代码
for (int i = startIndex; i < candidates.size() && sum + candidates[i] <= target; i++)

优化后树形结构如下(注意蓝字):

组合总和(三) - 去重

去重问题来啦!40 组合总和Ⅱ中要求集合元素会有重复,但组合不能重复因此难就难在去重问题上了

都知道组合问题可以抽象为树形结构,那么**"使用过"在这个树形结构上是有两个维度的,一个维度是 同一树枝上"使用过",一个维度 是同一树层上"使用过"**。
理解这两个层面上的"使用过" 对去重问题很重要

我在图中将used的变化用橘黄色标注上,可以看出在candidates[i] == candidates[i - 1]相同的情况下:

  • used[i - 1] == true ,说明同一树枝candidates[i - 1]使用过
  • used[i - 1] == false ,说明同一树层candidates[i - 1]使用过

类似的排列和子集问题的去重也是一样的道理。

多个集合求组合

17 电话号码的字母组合中,开始用多个集合来求组合,还是熟悉的模板题目,但是有一些细节。

由于是从多个集合中取值,这里for循环不同于 77 组合216 组合总和Ⅱ 从startIndex开始遍历的。

本题每一个数字代表的是不同集合,也就是求不同集合之间的组合 ,而 77 组合216 组合总和Ⅱ 都是是求同一个集合中的组合

树形结构如下:

另外还要注意各种输入异常的情况,例如本题输入1 * #按键。

⭕对于组合问题,什么时候需要startIndex呢?

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

切割问题

131 分割回文串中,开始遇到切割问题,虽然回溯算法看起来都是一道模板题,但是正在写起来会遇到很多细节性的难点。

  • 切割问题其实类似组合问题
  • 如何模拟切割线
  • 切割问题中递归如何终止
  • 在递归循环中如何截取子串
  • 如何判断回文

切割问题其实类似组合问题 :用求解组合问题的思路来解决 切割问题本题就成功一大半了,接下来就可以对着模板照葫芦画瓢。
如何模拟切割线 :我们会发现每次循环后刚好startIndex都会往后移一位,每次递归就会在一个新的startIndex上继续循环。因此可以把startIndex作为分割线
切割问题中递归如何终止 :startIndex切到最后
如何判断回文 :双指针章节有刷过,头尾指针向中靠拢,判断是否相等。
在递归循环中如何截取字串:如果是合法字符后,使用string自带的substring方法切割(注意其下标)

除了这些难点,本题还有细节,例如:切割过的地方不能重复切割所以递归函数需要传入i + 1

树形结构如下:

子集问题

子集问题(一)

78 子集中,在树形结构中子集问题是要收集所有节点的结果,而组合问题是收集叶子节点的结果

如图(收集红线部分):

认清这个本质之后,今天的题目就是一道模板题了。
由于每个节点都需要返回,所以不需要加终止条件 ,在for循环中startIndex >= nums.size()后就结束了。

而递归中,每次传参传入i + 1,也会终止~

子集问题(二) - 去重

**去重问题来了!**在90 子集Ⅱ中,开始针对子集问题进行去重。

本题就是78 子集的基础上加上了去重,而去重在40 组合总和Ⅱ也碰过了,需要避免树层重复元素 ,一样的套路。

树形结构如下(注意看蓝字 - 树层重复与树枝重复):

递增子序列

491 递增子序列中,处处都能看到子集的身影,但处处是陷阱!

一开始以为是和之前去重一样,先排序后设定used数组的false true,这就掉坑了~

由于本题在原数组基础上求递增子序列,因此排序会打乱原数组下标,得到不同答案。

树形结构如下:

另外,子集是一定需要排序后 才去重的。而且子集没要求保留原数组的下标,也满足排序条件

如果没有排序的集合{2,1,2,2}画一个图,如下:

排列问题

排列问题(一)

排列问题46 全排列又不一样了。

排列是有序的,也就是说** [1,2] 和 [2,1] 是两个集合**,这和之前分析的子集以及组合所不同的地方。因此也不需要使用startIndex来避免元素重复的问题,只需要用used数组标记哪个数组用过,用于遍历剩余集合。

如图(注意橙色的used数组):

大家此时可以感受出排列问题的不同:

  • 每层都是从0开始搜索而不是startIndex
  • 取而代之的用used数组记录path里都放了哪些元素

排列问题(二) - 去重

去重问题又又又来了 !这次是排列问题,在47 全排列Ⅱ中又一次强调了"树层去重"和"树枝去重"。

树形结构如下:

本题used数组即是记录path里都放了哪些元素,同时也用来去重,一举两得。

去重问题

以上我都是统一使用used数组来去重的,其实使用set也可以用来去重!

但是在性能上,数组更优秀。

因为程序运行的时候对set 频繁的add,set需要做哈希映射(也就是把key通过HashCode映射为唯一的哈希值)相对费时间,而且add的时候其底层的符号表也要做相应的扩充,也是费时的。
而使用used数组在时间复杂度上几乎没有额外负担!
使用set去重,不仅时间复杂度高了,空间复杂度也高了 ,在组合,子集,排列问题的空间复杂度都是O(n),但如果使用set去重,空间复杂度就变成了O(n^2),因为每一层递归都有一个set集合,系统栈空间是n,每一个空间都有set集合。

  • 但是 用used数组也是占用O(n)的空间啊?

used数组可是全局变量,每层与每层之间公用一个used数组,所以空间复杂度是O(n + n),最终空间复杂度还是O(n)。

性能分析

以下在计算空间复杂度的时候我都把系统栈(不是数据结构里的栈)所占空间算进去。

子集问题分析:

  • 时间复杂度:O(2n),因为每一个元素的状态无外乎布尔值,取与不取,所以时间复杂度为O(2n)
  • 空间复杂度:O(n),递归深度为n,所以系统栈所用空间为O(n),每一层递归所用的空间都是常数级别,注意代码里的result和path都是全局变量,就算是放在参数里,传的也是引用,并不会新申请内存空间,最终空间复杂度为O(n)

排列问题分析:

  • 时间复杂度:O(n!),这个可以从排列的树形图中很明显发现,每一层节点为n,第二层每一个分支都延伸了n-1个分支,再往下又是n-2个分支,所以一直到叶子节点一共就是 n * n-1 * n-2 * ..... 1 = n!。
  • 空间复杂度:O(n),和子集问题同理。

组合问题分析:

  • 时间复杂度:O(2n),组合问题其实就是一种子集的问题,所以组合问题最坏的情况,也不会超过子集问题的时间复杂度。
  • 空间复杂度:O(n),和子集问题同理。

一般说道回溯算法的复杂度,都说是指数级别的时间复杂度,这也算是一个概括吧!

进阶的N皇后问题,解数独、重新安排行程见 这里👈

总结

根据前面所做的可以总结以下经验:

  • 首先写代码前,用树形结构完整画出图 ,看看应该会怎么遍历?
    • "坐在办公室碰到的都是问题,深入基层看到的全是办法。"
  • 观察树形结构
    • 终止条件是什么?
    • 各层递归后从哪里开始(0 or startIndex)
    • 需不需要去重 (树层上有没有重复元素)
      • 可以排序 - 判断used [i - 1] ?= used [i] && used[i - 1] ?= false
      • 不能排序 - 如果有限范围用used[xx] 标记 如果无限范围只能用HashSet了
      • 单纯不能重复 - (排序题)new used[nums.length] 使用了就设置为true
    • 能不能剪枝?(排序后 如果sum已经大于targetSum 后面没必要去遍历 break)

学习资料:

回溯算法总结篇

相关推荐
Lecxcy_Kastreain10 分钟前
如何快速搓出一个计算器
算法
chaofa用代码打点酱油26 分钟前
Gemini 2.5 Pro 是怎么炼成的?-- gemini 2.5 技术报告阅读笔记与思考
算法·llm
CoovallyAIHub1 小时前
从大象到老鼠,FPN如何一次搞定?多尺度检测核心解析
深度学习·算法·计算机视觉
DoraBigHead2 小时前
小哆啦解题记 · 连号危机与去重警告!
算法
Nightmare0042 小时前
决策树学习
学习·算法·决策树
商bol452 小时前
题目V^V
算法
不二青衣5 小时前
牛客网50题
数据结构·c++·算法
盛寒7 小时前
向量与向量组的线性相关性 线性代数
线性代数·算法
学不动CV了11 小时前
C语言32个关键字
c语言·开发语言·arm开发·单片机·算法
小屁孩大帅-杨一凡11 小时前
如何解决ThreadLocal内存泄漏问题?
java·开发语言·jvm·算法