回溯算法
回溯算法(back tracking)是一种类似尝试算法,按选优条件向前搜索,主要是在搜索尝试过程中寻找问题的解,以达到目标,当发现已不满足求解条件时,就“回溯”返回,尝试别的路径。换句话说,找到一条路往前走,能走就继续往前,不能走就算了,掉头换条路。相对于动态规划,这部分的内容相对于简单些。
回溯的处理思想,和枚举搜索有点类似,通过枚举找到所有满足期望的值。为了有规律地枚举所有的解,可以把问题拆解为多个小问题。每个小问题,我们都会面对一个岔路口,选择一条发现此路不通的时,就往回走,走到另一个岔路口。
本章节分为 2 个部分,选取了比较经典的算法题,希望可以帮助到同学们学会解决回溯相关的算法题。
括号生成、子集和电话号码的字母组合
括号生成
给出 n 代表生成括号的对数,请你写出一个函数,使其能够生成所有可能的并且有效的括号组合。
示例
例如,给出 n = 3,生成结果为:
[
"((()))",
"(()())",
"(())()",
"()(())",
"()()()"
]
方法一 回溯法(实现一)
思路
我们知道,有且仅有两种情况,生成的括号序列不合法
- 我们不停的加左括号,其实如果左括号超过 n 的时候,它肯定不是合法序列了。因为合法序列一定是 n 个左括号和 n 个右括号。
- 如果添加括号的过程中,如果右括号的总数量大于左括号的总数量了,后边不论再添加什么,它都不可能是合法序列了。
基于上边的两点,我们只要避免它们,就可以保证我们生成的括号一定是合法的了。
详解
- 采用回溯法,即把问题的解空间转化成了图或者树的结构表示,然后,使用深度优先搜索策略进行遍历,遍历的过程中记录和寻找所有可行解或者最优解。
- 如果自定义的字符串长度足够且走到这里,那么就说明这个组合是合适的,我们把它保存。
- 否则,递归执行添加左括号,添加右括号的操作。
- 递归结束条件为结果字符串长度等于左右括号的总个数(2n),则返回最终结果。
代码
const generateParenthesis = n => {
const res = [];
const gen = (str = '', l = 0, r = 0) => {
if (str.length === 2 * n) {
res.push(str);
return;
}
if (l < n) {
gen(`${str}(`, l + 1, r);
}
if (r < l) {
gen(`${str})`, l, r + 1);
}
};
gen();
return res;
};
复杂度分析
我们的复杂度分析依赖于理解 generateParenthesis(n) 中有多少个元素。这个分析需要更多的背景来解释,但事实证明这是第 n 个卡塔兰数 \dfrac{1}{n+1}\binom{2n}{n}n+11(n2n),这是由 \dfrac{4^n}{n\sqrt{n}} nn4n 渐近界定的。
- 时间复杂度:O(\dfrac{4^n}{\sqrt{n}})O(n4n),在回溯过程中,每个有效序列最多需要 n 步。
- 空间复杂度:O(\dfrac{4^n}{\sqrt{n}})O(n4n),如上所述,并使用 O(n)O(n) 的空间来存储序列。
方法二 回溯法(实现二)
思路
整体的实现思路也是回溯法,也是递归来实现该思路,唯一不同的是,递归的结束条件是左右括号都消费尽。
详解
- 采用回溯法,即把问题的解空间转化成了图或者树的结构表示,然后,使用深度优先搜索策略进行遍历,遍历的过程中记录和寻找所有可行解或者最优解。
- 首先,先从左括号开始填充。
- 然后,填充右括号,保证两类括号数目一致,平衡。
- 递归结束条件为左右括号均消费尽,则输出结果。
代码
const generateParenthesis = n => {
const res = [];
function helper (left, right, max, str) {
if (left === max && right === max) {
res.push(str);
return;
}
if (left < max) {
helper(left + 1, right, max, `${str}(`);
}
if (left > right) {
helper(left, right + 1, max, `${str})`);
}
}
helper(0, 0, n, '');
return res;
};
复杂度分析
本方法也是使用回溯法,只是具体实现方式不同,因此,复杂度也是一样的。
- 时间复杂度:O(\dfrac{4^n}{\sqrt{n}})O(n4n)
在回溯过程中,每个有效序列最多需要 nn 步。
- 空间复杂度:O(\dfrac{4^n}{\sqrt{n}})O(n4n)
如上所述,并使用 O(n)O(n) 的空间来存储序列。
子集
给定一组不含重复元素的整数数组 nums,返回该数组所有可能的子集(幂集)。
**说明:**解集不能包含重复的子集。
输入: nums = [1,2,3]
输出:
[
[3],
[1],
[2],
[1,2,3],
[1,3],
[2,3],
[1,2],
[]
]
方法一 回溯算法
思路
设置初始二维数组[[]],依次把每个数加入到数组中的每一个元素中,并保留原来的所有元素。
详解
- 初始化二维数组来存储所有子集;
- 使用双层遍历,外层遍历 nums 数组,内层遍历二维数组,将每个元素与二维数组每项合并,并保留二维数组原有的元素
- 并将得到的新子集与二维数组元素合并,最后得到所有子集;
例如:输入 nums = [1, 2, 3] 初始化二维数组存储所有子集 result = [[]] 然后遍历 nums, 1 添加到[], 结果为 [[], [1]]; 2 添加到[], [1], 结果为 [[], [1], [2], [1, 2]]; 3 添加到[], [1], [2], [1, 2], 结果为 [[], [1], [2], [1, 2], [3], [1, 3], [2, 3], [1, 2, 3]];
代码
const subsets = (nums) => {
let result = [[]];
for (let i of nums) {
const temp = [];
for (let k of result) {
temp.push(k.concat(i));
}
result = result.concat(temp);
}
return result;
}
复杂度分析
根据上述算法,每次循环 result 数组的次数为 2^{(n - 1)}2(n?1), 则计算总数为 1 + 2^1 + 2^2 + … + 2^{(n-1)}1+21+22+…+2(n?1),根据等比数列计算公式得到循环总次数为 2^n - 12n?1,所以时间复杂度为 O(2^n)O(2n)。
根据排列组合原理,子集包含一个数字的情况所耗费的存储空间为 C_n^1 * 1Cn1?1 ,包含两个数字所耗费的存储空间为 C_n^2 * 2Cn2?2 ,根据算法得出 共需要 C_n^0 + C_n^1 + 2 * C_n^2 + … + (n - 1) * C_n^{n - 1} + n * C_n^nCn0+Cn1+2?Cn2+…+(n?1)?Cnn?1+n?Cnn 个存储空间,根据排列组合公式求和可得需要 n*2^{n-1} + 1n?2n?1+1 个额外存储空间, 所以算法空间复杂度为 O(2^n)O(2n)。
方法二 二进制表示法
思路
从数学角度看,求子集其实是一个排列组合问题,比如 nums = [1, 2, 3],存在四种情况:
- 都不选,情况共有 C_3^0= 1C30=1种
- 只选 1 个数,情况共有 C_3^1 = 3C31=3种
- 选 2 个数,情况共有 C_3^2 = 3C32=3 种
- 全选,情况共有 C_3^3 = 1C33=1种
落到数组中的每个数字,都有两种可能,选与不选,我们用 1 标识选,用 0 标识不选。 则 [] 表示为 000,[1, 2, 3] 表示为 111,我们通过转化为二进制表示法,遍历 000 - 111 的所有组合,即可求出所有子集。
详解
- 根据上述分析,我们得出加入数组为 nums,则针对每位存在选与不选两种情况,那么所有组合数为 2 ^ {length}2length 个。
- 针对每一种组合情况,我们可以取出该二进制表示法中的每一位,如 110,我们分别通过向右移位并和 1 求与,判断最低为的值为 0 或者 1。
- 如果得到结果为 1,那么表示该位表示的数字在原数组中被选中,存入暂存数组,一轮遍历后即可获得该组子集的数字组合。将所有子集数字组合一起存入结果数组,即求出所有子集。
代码
const subsets = (nums) => {
const len = nums.length;
const setNums = Math.pow(2, len);
const result = [];
for (let i = 0; i < setNums; i++) {
let temp = [];
for (let j = 0; j < len; j++) {
if (1 & (i >> j)) {
temp.push(nums[j]);
}
}
result.push(temp);
}
return result;
};
复杂度分析
一共包含 2^n2n 个组合需要 n * 2^nn?2n 次计算,所以时间复杂度为 O(2^n)O(2n)。
电话号码的字母组合
给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。
给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。
示例
输入:"23"
输出:["ad", "ae", "af", "bd", "be", "bf", "cd", "ce", "cf"].
方法一 挨个遍历
思路
利用队列的先进先出的特点,把队列中的第一个元素拿出,再与后面的挨个拼接
a, b, c --> b, c, ad, ae, af --> c, ad, ae, af, bd, be, bf -> …
详解
- 先在队列中插入一个空字符
- 取出队列中的第一个元素,与后一位数字对应的字符进行挨个拼接
- 重复第二步,直到结束
代码
const letterCombinations = function (digits) {
const map = {
2: ['a', 'b', 'c'],
3: ['d', 'e', 'f'],
4: ['g', 'h', 'i'],
5: ['j', 'k', 'l'],
6: ['m', 'n', 'o'],
7: ['p', 'q', 'r', 's'],
8: ['t', 'u', 'v'],
9: ['w', 'x', 'y', 'z']
};
if (!digits) {
return [];
}
const res = [''];
for (let i = 0; i < digits.length; i++) {
const letters = map[digits[i]];
const size = res.length;
for (let j = 0; j < size; j++) {
const temp = res.shift(0);
for (let k = 0; k < letters.length; k++) {
res.push(`${temp}${letters[k]}`);
}
}
}
return res;
};
复杂度分析
-
时间复杂度: O(3^m * 4^n)O(3m?4n) 通过数组的 push 我们发现,循环的次数和最后数组的长度是一样的,然而数组的长度有和输入多个3个字母的数目、4个字母的数目有关,得出时间复杂度为 O(3^m * 4^n)O(3m?4n) 其中 mm 为3个字母的数目,nn 为4个字母的数目 -
空间复杂度: O(3^m * 4^n)O(3m?4n) 最后结果的长度和输入的数字有关,得出复杂度为 O(3^m * 4^n)O(3m?4n)
方法二 回溯
思路
这道题有点排列组合的味道,我们可以穷举所有的可能性,找到所有的可能性
详解
-
如果还有数字需要被输入,就继续遍历数字对应的字母进行组合 prefix + letter -
当发现没有数字输入时,说明已经走完了,得到结果 代码
const map = {
2: ['a', 'b', 'c'],
3: ['d', 'e', 'f'],
4: ['g', 'h', 'i'],
5: ['j', 'k', 'l'],
6: ['m', 'n', 'o'],
7: ['p', 'q', 'r', 's'],
8: ['t', 'u', 'v'],
9: ['w', 'x', 'y', 'z']
};
const letterCombinations = function (digits) {
const result = [];
function backtrack (prefix, next) {
if (next.length === 0) {
result.push(prefix);
} else {
const digit = next[0];
const letters = map[digit];
for (let i = 0; i < letters.length; i++) {
backtrack(prefix + letters[i], next.substr(1));
}
}
}
if (digits.length !== 0) {
backtrack('', digits);
}
return result;
};
复杂度分析
- 时间复杂度: O(3^m * 4^n)O(3m?4n)
- 空间复杂度: O(3^m * 4^n)O(3m?4n)
实现数组的全排列和单词搜索
实现数组的全排列
给定一个没有重复数字的序列,返回其所有可能的全排列。
示例
给定 numbs = [1,2,3];
返回
[
[1,2,3],
[1,3,2],
[2,1,3],
[2,3,1],
[3,1,2],
[3,2,1]
]
方法一 回溯法
思路
回溯法通常是构造一颗生成树,生成树排列法的核心思想为: 遍历需要全排列的数组,不断选择元素,将不同的数字生成一颗树,如果数组中待选择的结点数为 0,则完成一种全排列。
详解
从上面的思路中,我们可以抽象出全排列函数的步骤:
1. 遍历需要全排列的数组,取出不同位置的数字,创建以对应位置数字为根节点的树。
2. 遍历剩下的数组,选出一个数字,将该数字挂在生成的树上。
3. 重复第二步操作直到剩余数字数组的长度为0,表明完成了全排列,将生成的树存入排序结果数组中。
举个例子:输入数组为 [1, 2, 3],首先选择 1 为根节点,剩余数组为 [2, 3],继续选择 2 作为下一个结点,剩余数组为 [ 3 ],那么选择 3 为最后一个结点,那么 [1, 2, 3] 组成一种全排列情况。 我们回溯到第二步剩余数组,选择 3 为第二个结点,那么剩余数组为 [ 2 ],选择后完成 [1, 3, 2] 这种全排列情况。后续依次固定 2,3 为根结点,列出所有可能。
从上述内容中可以看出,生成初始树后就是一个 截取数组 -> 设置节点,继续 截取节点 -> 设置节点 的递归过程了。那么我们是否可以将第一步的操作合并到我们的递归函数中呢?
既然我们递归的操作是截取数字,并将对应的数字与目标树结合。那么我们可以将第一步看为数字和空树结合。
那么我们递归函数的参数就确定为:
递归函数的逻辑也可以收敛为:
- 遍历需要全排列的数组,将不同位置的数字与目前树结合起来
- 重复该操作直到需要全排列的数组长度为 0,即表明完成了全排列。
代码
const permute = function (numbs) {
const allSortResult = [];
function recursion (restArr, tempResult) {
for (let index = 0; index < restArr.length; index++) {
const insertNumb = restArr[index];
const nextTempResult = [...tempResult, insertNumb];
if (restArr.length > 1) {
recursion(
restArr.filter(resetNumb => resetNumb !== insertNumb),
nextTempResult
);
} else {
allSortResult.push(nextTempResult);
}
}
}
recursion(numbs, []);
return allSortResult;
};
复杂度分析
- 时间复杂度:O(n^2)O(n2)
- 空间复杂度:O(n^2)O(n2)
方法二 插值排序法
思路
插值排列法的核心思想为: 遍历需要全排列的数组,将不同位置的数字抽离出来,插入到剩余数组的不同位置,即可得到该数字与另一个数组的全排列结果。 将一个固定的数字,插入到另一个数组的全排列结果的不同位置, 遍历需要全排列的数组,将不同的数字连接到不同的树上 继续全排列剩下的数组与生成的树,当剩余数组长度为0时,表明完成了全排列。
详解
从上面的思路中,我们可以抽象出插值排序全排列方法的具体实现:
首先处理特殊情况。如果传入排列的数组长度为1,直接返回该数组。否则进行下面的全排列方法
1. 截取传入数组的第 1 位数字,将剩余数组传入获取全排列方法函数,获取剩余数字的全排列结果
2. 遍历剩余数字的全排列结果数组并取出各个结果
3. 使用循环将截取的数字插入到不同结果(数组)的不同位置,生成新的全排列结果并保存
4. 返回全排列结果数组
代码
const permute = function (numbs) {
const result = [];
if (numbs.length === 1) {
return [numbs];
} else {
const allSortList = permute(numbs.slice(1));
for (let sortIndex = 0; sortIndex < allSortList.length; sortIndex++) {
const currentSort = allSortList[sortIndex];
for (let index = 0; index <= currentSort.length; index++) {
const tempSort = [...currentSort];
tempSort.splice(index, 0, numbs[0]);
result.push(tempSort);
}
}
return result;
}
};
复杂度分析:
- 时间复杂度:O(n^3)O(n3) 时间复杂度由全排列函数 permute 提供。单个全排列函数中存在两层循环嵌套,因此单次调用的时间复杂度为 O(n^2)O(n2) 当 numbs 长度为 nn 到 2 时调用全排列函数,即调用 (n-2)(n?2)次 n^2(n-2) = n^3 - 2n^2n2(n?2)=n3?2n2; 当 nn 趋近于无限大时,nn 可以忽略,因此时间复杂度为 O(n^3)O(n3)
- 空间复杂度:O(n^3)O(n3), 在循环体内使用4个变量,空间复杂度为 O(4*n^3)O(4?n3) ,当 nn 趋近于无限大,忽略系数,即为 O(n^3)O(n3)
单词搜索
给定一个二维网格和一个单词,找出该单词是否存在于网格中。
单词必须按照字母顺序,通过相邻的单元格内的字母构成,其中“相邻”单元格是那些水平相邻或垂直相邻的单元格。同一个单元格内的字母不允许被重复使用。
示例
board =
[
['A','B','C','E'],
['S','F','C','S'],
['A','D','E','E']
]
给定 word = "ABCCED", 返回 `true`.
给定 word = "SEE", 返回 `true`.
给定 word = "ABCB", 返回 `false`.
方法 回溯算法
思路
题目给的要求水平相邻或垂直相邻的单元格,这与回溯的思想非常相似,简单来说,上下左右都去走一遍,发现不符合要求立即返回。
以题目的示例为例子,从 A 开始,放四个方向试探,如果不匹配,立即换方向
详解
- 从起点开始,枚举所有的可能性,递归搜索
- 如果当前字符串匹配,再考虑上下左右 4 个方向,当发现超出边界或者不匹配时,立即结束当前方向的搜索
const exist = function (board, word) {
const m = board.length;
const n = board[0].length;
for (let i = 0; i < m; i++) {
for (let j = 0; j < n; j++) {
if (wordSearch(i, j, 0)) {
return true;
}
}
}
function wordSearch (i, j, k) {
if (i < 0 || j < 0 || i >= m || j >= n || word[k] !== board[i][j]) {
return false;
}
if (k === word.length - 1) {
return true;
}
const temp = board[i][j];
board[i][j] = '-';
const res = wordSearch(i + 1, j, k + 1) ||
wordSearch(i - 1, j, k + 1) ||
wordSearch(i, j + 1, k + 1) ||
wordSearch(i, j - 1, k + 1);
board[i][j] = temp;
return res;
}
return false;
};
复杂度分析
-
时间复杂度: O((m*n)^2)O((m?n)2) mm 和 nn 分别是矩阵的行数和列数。 每个递归函数 wordSearch 的调用次数为 m * nm?n,并且调用了4个递归函数,复杂度为 O(4 * m * n)O(4?m?n) 在 for 循环下,时间复杂度为 O(m * n)O(m?n)。 因此总的时间复杂度为 O(4 (m * n)^2) = O((m*n)^2)O(4(m?n)2)=O((m?n)2) -
空间复杂度: O(m*n)O(m?n) 每个递归函数的递归深度为 m * nm?n*,* 空间复杂度为 O(m * n)O(m?n),一共调用了4个,因此总的复杂度为 O(4 * m * n) = O(m * n)O(4?m?n)=O(m?n)
👉练习打卡
|