今天学习了回溯算法:
- 基本知识,关键是那个模板
- 组合问题:画树状图+简单的剪枝
- 电话号码的组合问题,和经典组合问题的差别在于取不同集合中的组合,注意如何有限制的在for循环之前确定循环哪个数组,通过树状图确定for循环中i的大小。
1. 基本知识
-
回溯法也可以叫做回溯搜索法,它是一种搜索的方式。回溯是递归的副产品,只要有递归就会有回溯。
-
本质是穷举,穷举所有可能,然后选出我们想要的答案,所以效率不高。
-
解决的问题:
- 组合问题:N个数里面按一定规则找出k个数的集合
- 切割问题:一个字符串按一定规则有几种切割方式
- 子集问题:一个N个数的集合里有多少符合条件的子集
- 排列问题:N个数按一定规则全排列,有几种排列方式
- 棋盘问题:N皇后,解数独等等
-
模板:
回溯法一般是在集合中递归搜索,集合的大小构成了树的宽度,递归的深度构成的树的深度。for循环就是遍历集合区间,可以理解一个节点有多少个孩子,这个for循环就执行多少次。
void backtracking(参数) {if (终止条件) {存放结果;return;}for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {处理节点;backtracking(路径,选择列表); // 递归回溯,撤销处理结果} }
2. 77 组合
题目:给定两个整数 n 和 k,返回 1 ... n 中所有可能的 k 个数的组合。
示例: 输入: n = 4, k = 2 输出: [ [2,4], [3,4], [2,3], [1,2], [1,3], [1,4], ]
a. 如何想到回溯
直观的方法,k=2就是两层循环,k=50就是50层循环嵌套。
回溯可以解决很多层for循环嵌套问题。
回溯问题可以抽象为树形结构,如图:
- 每次从集合中选取元素,可选择的范围随着选择的进行而收缩,调整可选择的范围。
- 图中可以发现n相当于树的宽度,k相当于树的深度。
- 图中每次搜索到了叶子节点,我们就找到了一个结果。
b. 回溯三部曲
- 递归函数的返回值以及参数
在这里要定义两个全局变量,一个用来存放符合条件单一结果,一个用来存放符合条件结果的集合。
vector<vector<int>> result; // 存放符合条件结果的集合
vector<int> path; // 用来存放符合条件结果
函数里一定有两个参数,既然是集合n里面取k个数,那么n和k是两个int型的参数。
然后还需要一个参数,为int型变量startIndex
,这个参数用来记录本层递归的中,集合从哪里开始遍历(集合就是[1,...,n] ),防止出现重复的组合。
- 回溯函数终止条件
什么时候到达所谓的叶子节点了呢?path这个数组的大小如果达到k,说明我们找到了一个子集大小为k的组合了,在图中path存的就是根节点到叶子节点的路径。
- 单层搜索的过程
for循环每次从startIndex
开始遍历,然后用path保存取到的节点i。然后backtracking(递归函数)通过不断调用自己一直往深处遍历,总会遇到叶子节点,遇到了叶子节点就要返回。backtracking的下面就是回溯的操作,撤销本次处理的结果。
class Solution {
private:vector<vector<int>> result; // 存放符合条件结果的集合vector<int> path; // 用来存放符合条件结果void backtracking(int n, int k, int startIndex) {if (path.size() == k) {result.push_back(path);return;}for (int i = startIndex; i <= n; i++) {path.push_back(i); // 处理节点backtracking(n, k, i + 1); // 递归path.pop_back(); // 回溯,撤销处理的节点}}
public:vector<vector<int>> combine(int n, int k) {result.clear(); // 可以不写path.clear(); // 可以不写backtracking(n, k, 1);return result;}
};
-
时间复杂度: O(n * 2^n)
对于每一个
n
,都有两种选择:要么被选中加入到path
中,要么不被选中。因此,对于n
个元素,理论上最多有2^n
种选择方式。最多k=n,所以再乘一个n。 -
空间复杂度: O(n)
c. 剪枝优化
如果for循环选择的起始位置之后的元素个数 已经不足 我们需要的元素个数了,那么就没有必要搜索了。
优化过程如下:
- 已经选择的元素个数:
path.size()
; - 所需需要的元素个数为:
k - path.size()
; - 列表中剩余元素(
n-i) >= 所需需要的元素个数(k - path.size())
- 在集合n中至多要从该起始位置 :
i <= n - (k - path.size()) + 1
,开始遍历
为什么有个+1呢,因为包括起始位置,我们要是一个左闭的集合。举个例子,n = 4,k = 3, 目前已经选取的元素为0(path.size为0)
,n - (k - 0) + 1
即 4 - ( 3 - 0) + 1 = 2
。
从2开始搜索都是合理的,可以是组合[2, 3, 4]。
for (int i = startIndex; i <= n - (k - path.size()) + 1; i++) // i为本次搜索的起始位置
3. 316 组合Ⅲ
题目:找出所有相加之和为 n 的 k 个数的组合。组合中只允许含有 1 - 9 的正整数,并且每种组合中不存在重复的数字。
- 所有数字都是正整数。
- 解集不能包含重复的组合。
示例 1: 输入: k = 3, n = 7 输出: [[1,2,4]]
示例 2: 输入: k = 3, n = 9 输出: [[1,2,6], [1,3,5], [2,3,4]]
和上一题的组合异曲同工,需要注意的地方:不是所有叶子节点都要,只要sum=target的结果。所以这个判断在代码的if中。
class Solution {
private:vector<vector<int>> result; // 存放结果集vector<int> path; // 符合条件的结果void backtracking(int targetSum, int k, int sum, int startIndex) {if (path.size() == k) {if (sum == targetSum) result.push_back(path);return; // 如果path.size() == k 但sum != targetSum 直接返回}for (int i = startIndex; i <= 9; i++) {sum += i; // 处理path.push_back(i); // 处理backtracking(targetSum, k, sum, i + 1); // 注意i+1调整startIndexsum -= i; // 回溯path.pop_back(); // 回溯}}public:vector<vector<int>> combinationSum3(int k, int n) {result.clear(); // 可以不加path.clear(); // 可以不加backtracking(n, k, 0, 1);return result;}
};
剪枝的方法:
已选元素总和如果已经大于n(图中数值为4)了,那么往后遍历就没有意义了,直接剪掉。
class Solution {
private:vector<vector<int>> result; // 存放结果集vector<int> path; // 符合条件的结果void backtracking(int targetSum, int k, int sum, int startIndex) {if (sum > targetSum) { // 剪枝操作return; }if (path.size() == k) {if (sum == targetSum) result.push_back(path);return; // 如果path.size() == k 但sum != targetSum 直接返回}for (int i = startIndex; i <= 9 - (k - path.size()) + 1; i++) { // 剪枝sum += i; // 处理path.push_back(i); // 处理backtracking(targetSum, k, sum, i + 1); // 注意i+1调整startIndexsum -= i; // 回溯path.pop_back(); // 回溯}}public:vector<vector<int>> combinationSum3(int k, int n) {result.clear(); // 可以不加path.clear(); // 可以不加backtracking(n, k, 0, 1);return result;}
};
- 注意剪枝的两个地方:sum和k
- 有了sum,那么每次递归和回溯需要修改sum的值,不是只有path了。
4. 17.电话号码的字母组合
题目:给定一个仅包含数字 2-9
的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。
给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。
输入:digits = "23"
输出:["ad","ae","af","bd","be","bf","cd","ce","cf"]
要解决如下三个问题:
-
数字和字母如何映射
-
两个字母就两个for循环,三个字符就三个for循环,明显是多层for循环问题,可以用回溯解决,属于回溯中的组合问题,和经典组合不同的地方在于求不同集合之间的组合,所以每个for循环需要在固定的个数数字中挑一个,画出树状图:
-
输入1 * #按键等等异常情况
a. 数字和字母如何映射
映射问题,可以使用map或者定义一个二维数组,例如:string letterMap[10]
,定义一个二维数组,代码如下:
const string letterMap[10] = {"", // 0"", // 1"abc", // 2"def", // 3"ghi", // 4"jkl", // 5"mno", // 6"pqrs", // 7"tuv", // 8"wxyz", // 9
};
b. 回溯法解决n个for循环的问题
- 确定回溯函数参数
全局:字符串s来收集叶子节点的结果,然后用一个字符串数组result保存起来。
参数:参数指定是有题目中给的string digits
,然后还要有一个参数就是int型的index。
注意这个index可不是 前两道题中的startIndex
了,这个index是记录遍历第几个数字了,即用来遍历digits的(题目中给出数字字符串),同时index也表示树的深度。
- 确定终止条件
例如输入用例"23",两个数字,那么根节点往下递归两层就可以了,叶子节点就是要收集的结果集。那么终止条件就是如果index 等于 输入的数字个数(digits.size)
了(本来index就是用来遍历digits的)。然后收集结果,结束本层递归。
- 确定单层遍历逻辑
首先要取index指向的数字,并找到对应的字符集(手机键盘的字符集)。然后for循环来处理这个字符集,for循环中递归调用和回溯。
class Solution {
private:const string letterMap[10] = {"", // 0"", // 1"abc", // 2"def", // 3"ghi", // 4"jkl", // 5"mno", // 6"pqrs", // 7"tuv", // 8"wxyz", // 9};
public:vector<string> result;string s;void backtracking(const string& digits, int index) {if (index == digits.size()) {result.push_back(s);return;}int digit = digits[index] - '0'; // 将index指向的数字转为intstring letters = letterMap[digit]; // 取数字对应的字符集for (int i = 0; i < letters.size(); i++) {s.push_back(letters[i]); // 处理backtracking(digits, index + 1); // 递归,注意index+1,一下层要处理下一个数字了s.pop_back(); // 回溯}}vector<string> letterCombinations(string digits) {s.clear();result.clear();if (digits.size() == 0) {return result;}backtracking(digits, 0);return result;}
};
- 二位数组可以做到数字和字母映射
- 递归回溯问题,可以试试画树状图,从而可以看出for循环中循环多少次
- 此题中,index很重要,在for循环前,通过index确定取的是二维数组中哪个数字对应的字母
- 注意此题空间复杂度,递归问题的空间复杂度和递归层数有关系
今日古诗
八声甘州·记玉关踏雪事清游
张炎〔宋代〕
辛卯岁,沈尧道同余北归,各处杭、越。逾岁,尧道来问寂寞,语笑数日。又复别去。赋此曲,并寄赵学舟。
记玉关踏雪事清游,寒气脆貂裘。傍枯林古道,长河饮马,此意悠悠。短梦依然江表,老泪洒西州。一字无题处,落叶都愁。
载取白云归去,问谁留楚佩,弄影中洲?折芦花赠远,零落一身秋。向寻常、野桥流水,待招来,不是旧沙鸥。空怀感,有斜阳处,却怕登楼。
这首词上片以“记”字领起,气势较为开阔、笔力劲峭。写词人前年冬季赴北写经的旧事,展现了一幅冲风踏雪的北国羁旅图;旧事重提之后,中片则续写北地回归之光景;下片从眼前的离别写起,表现出零落如秋叶的心情。全词先悲后壮,先友情而后国恨,惯穿始终的,是一股荡气回肠的“词气”,词中写身世飘萍和国事之悲感哀婉动人,令人如闻断雁惊风、哀猿啼月。