灵神基础算法精讲-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 |
|