灵神基础算法精讲-14-学习笔记
回溯问题套路
- 子集型回溯
0-1背包问题也可以算作一种子集型回溯。
子集本质上就是看集合的每个元素是选它还是不选它。
比如集合有两个元素,第一个元素选或者不选,有两种情况,第二个元素同理,那么总共有2*2=4种情况。
要想生成所有子集,有两种思路,区别在于当前操作是什么。如果站在输入的角度思考,那就是枚举第i个数,选它还是不选它;子问题就是从下标大于等于i的数字中构造子集;下一个子问题就是从下标大于等于i+1的数字中构造子集。
17. 电话号码的字母组合
给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。
给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。

思路
首先思考一个问题,如果要生成所有长为的 2 字符串,其中第一个字母在 abc 中选一个,第二个字母在 def 中选一个,那么两两组合,总共有 3x3=9 个不同的字符串。
暴力做法:写一个二重循环,外层枚举第一个字母,内层枚举第二个字母。但是,如果需要生成长为 n 的字符串,应该怎么办。从这里可以发现,单纯的循环嵌套的表达能力是有局限的。
借用递归的思路,思考原问题和子问题。原问题:构造长为 n 的字符串——枚举一个字母——子问题:构造长为 n - 1 的字符串。这种从原问题到子问题的过程适合使用递归解决,通过递归就可以达到多重循环的效果。
回溯有一个增量构造答案的过程,这个过程通常用递归实现。把这个过程画成一棵树,第一个字母选 a,选 b,选 c,第二个字母选 d,选 e,选 f,以此类推。这个增量构造答案的过程就是回溯的特点。

- “回溯三问”
用一个 path 数组记录路径上的字母
当前操作?枚举
path[i]要填入的字母子问题?构造字符串
>= i的部分下一个子问题?构造字符串
>= i + 1的部分
首先需要枚举一个字母,比如字符串的第 i 个字母,同时,为了得到答案,还需要一个全局数组 path 来记录递归路径中枚举的字母。
这里有一个 i,递归的参数也肯定有一个 i。这里需要注意,对于递归参数中的 i,它的含义不是第 i 个,而是下标大于等于 i 的这部分还需要继续枚举,这就自然地引出了子问题。
枚举了一个字母后,下一个子问题是什么?现在已经枚举了第 i 个,那么剩下的就是下标大于等于 i + 1 的这部分了。
这里使用的递归函数名称为 dfs(Depth First Search),是因为这个过程就是在这颗树上做深度优先搜索。
做法
生成的字符串的长度和输入的字符串的长度是一样的。首先需要把数字和要枚举的字母对应起来,用一个 MAPPING 来实现,下标 2 对应着 abc,以此类推。然后获取字符串的长度 n,如果 n = 0,直接返回空数组;初始化答案为空,路径为长度为 n 的数组。接着写递归函数,首先判断边界条件,如果 i == n,说明找到答案了,将数组转换成字符串加入答案中;对于非边界条件,需要枚举第 i 个字母是什么,将它填到 path 中,然后再递归。最后递归入口,返回答案。
1 | |
时间复杂度:对于回溯问题,可以从循环的角度来理解,枚举第一个字母就是最外面的循环,第二个字母就是第二层循环,以此类推。最多需要循环 4^n 次,最后生成答案时,需要花费 O(n) 的时间,所以时间复杂度是 O(n*4^n)。
空间复杂度:O(n)。
78. 子集
给你一个整数数组 nums ,数组中的元素 互不相同 。返回该数组所有可能的子集(幂集)。
解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。
示例 1:
输入:nums = [1,2,3]
输出:[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]
思路1
要想生成所有子集,有两种思路,区别在于当前操作是什么。如果站在输入的角度思考,那就是枚举第i个数,选它还是不选它;子问题就是从下标大于等于i的数字中构造子集;下一个子问题就是从下标大于等于i+1的数字中构造子集。
做法1
首先写非边界条件,如果不选当前数,那么这个数被跳过,直接递归到i+1;如果选当前数,先把这个数加到路径中,然后递归到i+1。由于是在路径后面添加元素,所以递归之前和递归之后需要保持一致,因此在返回之前,需要把路径恢复到递归之前的样子。
写边界条件,把路径中记录的答案加到ans中,由于这个路径是一个全局变量,它是会变化的,所以此时需要把它的答案固定下来,即copy()。
1 | |
时间复杂度:每次递归只有不选和选两种情况,所以一共会递归O(2^n)次,再加上copy()的时间是O(n)的,所以时间复杂度就是O(n*2^n)。
空间复杂度:O(n)。
思路2
站在答案的视角,每次必须选一个数,枚举答案的第一个数选谁,第二个数选谁,当前操作就是枚举一个数字,同时注意{1, 2}和{2, 1}是重复的子集,也就是不考虑顺序,那么就规定一个顺序,比如严格递增,那么枚举的数字必须大于上一个枚举的数字,当前操作就是从大于等于下标i的下标j数字中选一个数;子问题是从下标大于等于i的数字中构造子集;下一个子问题是从下标大于等于j+1的数字中构造子集。由于子集的长度没有约束,所以每个长度都可以是答案,那么每次递归都需要记录一下答案。
做法2
将添加答案的代码修改为每次循环都会执行,看做递归到的每个节点都是答案。然后枚举当前要填的数字,把数字加到路径中,递归,恢复现场。
1 | |
131. 分割回文串
给你一个字符串 s,请你将 s 分割成一些 子串,使每个子串都是 回文串 。返回 s 所有可能的分割方案。
示例 1:
输入:s = "aab"
输出:[["a","a","b"],["aa","b"]]
思路1
假设每两个字符之间都有一个逗号,可以对每个逗号考虑是选它还是不选它,这样就转换成了子集问题。
做法1
从答案的视角来分析,枚举第一个逗号的位置,第二个逗号的位置。
当前操作:选择回文子串s[i..j],加入path;
子问题:从下标大于等于i的后缀中构造回文分割;
下一个子问题:从下标大于等于j+1的后缀中构造回文分割;
这里枚举的逗号位置相当于枚举回文子串结束的位置。
如何判断一个字符串是否回文:1. 相向双指针,分别指向字符串的开始和结束位置,如果相同就各往中间走一步,直到找到不同的,或者左指针大于等于右指针为止。2. 直接把这个字符串反过来,然后和原串比较是否相同。
注意,不是每次递归都添加答案,因为每个字母都需要在答案中,所以必须等到分割结束再加到答案中。
1 | |
时间复杂度:O(n*2^n)。
空间复杂度:O(n)。
思路2
输入的视角,逗号选或不选。
假设每对相邻字符之间有个逗号,那么就看每个逗号是选还是不选。
也可以理解成:是否要在 i 和 i+1 处分割,把 s[i] 作为当前子串的最后一个字符,把 s[i+1] 作为下一个子串的第一个字符。
注意 s[n−1] 一定是最后一个字符,所以在 i=n−1 的时候一定要分割。
1 | |