0. 前言
最近小编在刷 leetcode , 连续好几天都刷到回溯的题目。在这篇文章中,小编就来跟大家分享一下最近刷回溯题目的一些总结和思考。废话不多说,我们切入正题。
1. 回溯模板
回溯就是递归求解。通过递归的手段,将所有的情况遍历一遍,然后在这些结果中寻找我们需要的答案。从这里可以看出来,回溯的本质是暴力求解。暴力嘛,那肯定会花费很多时间了,要想降低算法的时间复杂度,剪枝才是关键。
在分享回溯问题的解题思路之前,我们先来看一看回溯可以解决哪些问题。我觉得这个问题可以用一句话来概括:回溯可以解决所有可以通过递归暴力能够得到答案的所有问题,比如说排列组合、n皇后(这是一道经典的回溯题目)、解数独、电话号码……这些题目。
然后我们来总结一下回溯问题的解题模板:
let combination = [];
let combinations = [];
const backTrack = (idx) => {
// 满足条件添加到最终结果的终止条件
if() {
combinations.push(combination.slice());
return;
};
// 直接结束递归,但是不满足最终结果的终止条件。这里可以没有。—— 剪枝
if() {
return;
}
// 一般情况怎么办
while() {
// 遍历所有的情况,寻找满足条件的结果
// 需要注意的是,遍历有两种情况: 1. 递归; 2. 循环
if() {
// 满足条件的结果
combination.push("满足条件的某个值");
// 递归循环遍历 —— 回溯的过程
backTrack(idx + 1);
// 回退结果
combination.pop();
}
}
}
2. leetcode部分相关题目
俗话说:“纸上得来终觉浅,唯有实践出真知”。我们现在就来利用这个模板解决几道leetcode上面的题目。
2.1 电话号码的字母组合
2.1.1 题目
2.1.2 题解
/**
* @param {string} digits
* @return {string[]}
*/
var letterCombinations = function(digits) {
const phoneMap = ['', '', 'abc', 'def', 'ghi', 'jkl', 'mno', 'pqrs', 'tuv', 'wxyz']
let combination = ''
let combinations = []
if(!digits) return []
function backTrack(index) {
if(index === digits.length) {
combinations.push(combination)
} else {
let digit = digits[index]
for(let i = 0; i < phoneMap[digit].length; i++) {
combination += phoneMap[digit][i]
backTrack(index + 1)
combination = combination.substring(0, combination.length - 1)
}
}
}
backTrack(0)
return combinations
};
2.1.3 分析
首先,拿到题目的第一个想法肯定是:我去遍历输入的数字,然后在数字对应的字母中寻找可能的组合,直到遍历完所有输入的数字,我就拿到一条结果,然后放到最终的结果数组中。然后,继续遍历输入的数字,去找下一条结论。 从这段分析中,我们可以发现在查找所有的结果这个过程中,有两条路径:
- 遍历输入的数字
- 遍历数字对应的字符
对于这两条路径,我们一横一纵去遍历它。横的就用循环,即遍历数字对应的字符;纵的就采用递归,即递归遍历输入的数字。在这道题目中,我们只需要遍历就可以了,并没有需要判断是否该使用这个结果的地方。因此,这道题目是一道典型的回溯题目,而且没有特别需要剪枝的地方。
在具体的代码实现中,我们首先定义了一个用来存放数字和字符对应关系的数组;然后定义递归函数。最终调用这个函数返回结果即可。不难发现,这段代码的核心就是这个递归函数。
而这个递归的函数是符合我们前面提到的回溯模板的。在代码的开始,进行了递归终止条件的判断,即index === digits.length
。满足条件时,递归终止,将单个结果添加到最终的结果数组中。不满足条件时,进行横向的循环遍历数字对应的字符,依次向单个结果中添加字符,构成单个字母组合结果。最终,我们看到的代码就是题解中的那个样子。
2.2 括号生成
2.2.1 题目
2.2.2 题解
/**
* @param {number} n
* @return {string[]}
*/
var generateParenthesis = function(n) {
let pairs = []
let pair = ''
function backTrack(open, close) {
if(pair.length === 2*n) {
pairs.push(pair)
return
}
if(open < n) {
pair += '('
backTrack(open + 1, close)
pair = pair.substr(0, pair.length-1)
}
if(close < open) {
pair += ')'
backTrack(open, close + 1)
pair = pair.substr(0, pair.length - 1)
}
}
backTrack(0, 0)
return pairs
};
2.2.3 分析
这个括号生成的题目乍一看,似乎并不是一道典型的回溯题目。但是仔细分析的话,它是可以用回溯来解决的。首先,这道题目是一个排列组合的问题。只不过我们组合的对象不是传统的字符或者数字,而是 '('
和 ')'
.排列组合的结果,最好的解决办法就是通过回溯找到所有的排列组合结果,然后在这些结果中找到我们需要的答案。 因此,这个题目用回溯来解决是合适的。
利用回溯来解决问题,需要搞清楚两个问题:
- 递归回溯的终止条件是什么
- 递归回溯要循环遍历的对象是什么
一般来讲,回溯的终止条件就是单个组合结果的长度,回溯要遍历的对象就是需要排列组合的对象。在这个题目中,回溯的终止条件就是括号的长度,回溯遍历的对象就是两瓣括号。这个题目相对于上一个题目而言,增加了一个剪枝的过程。这个剪枝的条件就是括号必须正确的闭合。也就是说,在这个题目中,我们需要关注的问题就是括号怎样才能正确的闭合。
我们知道括号必须是一对一对的,也就是说左括号和右括号的数量必须相等,而且左括号在前面,右括号在后面。因此,在整个往单个结果中添加括号的过程中,右括号的数量总是比左括号的数量少的。而括号的数量又限制了左括号的数量。话句话说,满足我们要求的条件是用括号的数量限制左括号,用左括号的数量限制右括号。因此,我们最终的条件是:if(open < n)
添加左括号;if(close < open)
添加右括号。最终,我们看到的代码就是题解中的那个样子。
2.3 解数独
2.3.1 题目
2.3.2 题解
/**
* @param {character[][]} board
* @return {void} Do not return anything, modify board in-place instead.
*/
var solveSudoku = function(board) {
const row = new Array(9).fill(false).map(() => new Array(9).fill(false));
const column = new Array(9).fill(false).map(() => new Array(9).fill(false));
const subBoard = new Array(3).fill(false).map(() => new Array(3).fill(false).map(() => new Array(9).fill(false)));
const space = [];
let valid = false;
for(let i = 0; i < 9; ++i){
for(let j = 0; j < 9; ++j) {
if(board[i][j] === '.') {
space.push([i, j]);
} else {
const digit = board[i][j] - '0' - 1;
row[i][digit] = column[j][digit] = subBoard[Math.floor(i / 3)][Math.floor(j / 3)][digit] = true;
}
}
}
const dfs = (pos) => {
// 递归终止条件
if(pos === space.length) {
valid = true;
return;
}
const [i, j] = space[pos]
for(let digit = 0; digit < 9 && !valid; ++digit) {
if(!row[i][digit] && !column[j][digit] && !subBoard[Math.floor(i / 3)][Math.floor(j / 3)][digit]) {
row[i][digit] = column[j][digit] = subBoard[Math.floor(i / 3)][Math.floor(j / 3)][digit] = true;
board[i][j] = (digit + 1).toString();
dfs(pos + 1);
row[i][digit] = column[j][digit] = subBoard[Math.floor(i / 3)][Math.floor(j / 3)][digit] = false;
}
}
}
dfs(0);
};
2.3.3 分析
这个题目就是那种典型的一看到就知道必须用回溯才能做的。原因有二:
- 它每个结果都跟前面的有关联
- 这是一个排列组合的问题
我们知道,回溯题目的关键是剪枝。这个题目难就难在剪枝的条件判断起来很困难。但如果有前一道题目的铺垫的话,这个剪枝的条件就不难想到。
因此,我们先来解这个题目。数独成立的条件有三个:
- 每一行不能有重复
- 每一列不能有重复
- 每一个3 * 3的小格子不能有重复
因此,我们用三个数组来对每一行(或者一列或者3 * 3的格子)中某一个具体的数字填写了几次,代码如下:
const row = new Array(9).fill(0).map(() => new Array(9).fill(0));
const column = new Array(9).fill(0).map(() => new Array(9).fill(0));
const subBoard = new Array(3).fill(0).map(() => new Array(3).fill(0).map(() => new Array(9).fill(0)));
其中,const row = new Array(9).fill(0).map(() => new Array(9).fill(0));
表示数独表格中第 i 行(i 的范围是[1,9])中有多少个对应的数字;const column = new Array(9).fill(0).map(() => new Array(9).fill(0));
表示数独表格中第 j 列(j 的范围是[1,9])中有多少个对应的数字;const subBoard = new Array(3).fill(0).map(() => new Array(3).fill(0).map(() => new Array(9).fill(0)));
表示数独表格中在 (i, j) 位置上的 3 * 3 小表格(i,j 的范围是[1,9])中有多少个对应的数字。当遇到对应的数字就加1,最后判断特定位置的特定数字计数是否超过1,如果超过1,则证明是无效数独;如果所有的未超过1,则证明是有效数独。具体代码实现如下:
/**
* @param {character[][]} board
* @return {boolean}
*/
var isValidSudoku = function(board) {
const row = new Array(9).fill(0).map(() => new Array(9).fill(0));
const column = new Array(9).fill(0).map(() => new Array(9).fill(0));
const subBoard = new Array(3).fill(0).map(() => new Array(3).fill(0).map(() => new Array(9).fill(0)));
for(let i = 0; i < 9; i++){
for(let j = 0; j < 9; j++) {
if(board[i][j] !== '.') {
const num = board[i][j] - '0' - 1;
row[i][num]++;
column[j][num]++;
subBoard[Math.floor(i / 3)][Math.floor(j / 3)][num]++;
if(row[i][num] > 1 || column[j][num] > 1 || subBoard[Math.floor(i / 3)][Math.floor(j / 3)][num] > 1) {
return false;
}
}
}
}
return true;
};
从这个题目中,我们得到启发。在本题中,我们也建立类似的三个数组,只不过我们这道题中数组存储的不再是数字,而是布尔值。表示在该位置上是否填写过该数字。对这三个数组进行动态的更新。
回溯的模板 + 上一道题目提供的剪枝思路,构成了我们这个题目的最终解决方案。
2.4 n皇后
2.4.1 题目
2.4.2 题解
/**
* @param {number} n
* @return {string[][]}
*/
var solveNQueens = function(n) {
const solutions = [];
const queens = new Array(n).fill(-1);
const columns = new Set();
const diagnal1 = new Set();
const diagnal2 = new Set();
const generateBoard = () => {
const borad = [];
for(let i = 0; i < n; i++) {
let row = '';
for(let j = 0; j < n; j++) {
row = queens[i] === j ? row + 'Q' : row + '.';
}
borad.push(row);
}
return borad;
};
const dfs = row => {
if(row === n) {
const board = generateBoard();
solutions.push(board);
} else {
for(let i = 0; i < n; i++) {
if(columns.has(i)) {
continue;
}
if(diagnal1.has(row - i)) {
continue;
}
if(diagnal2.has(row + i)) {
continue;
}
queens[row] = i;
columns.add(i);
diagnal1.add(row - i);
diagnal2.add(row + i);
dfs(row + 1);
queens[row] = -1;
columns.delete(i);
diagnal1.delete(row - i);
diagnal2.delete(row + i);
}
}
};
dfs(0);
return solutions;
};
2.4.3 分析
N 皇后是经典的回溯题目,但是我们还是要看一看为什么它需要用回溯来解决。和解数独的那个题目一样它是一个排列组合的问题,并且它也是每个结果都和前面的有关联。因此,用回溯来解决。
和前面的回溯问题一样,它的难点主要在于剪枝。题目中给出了,不能添加Q
的条件:
- 不能在同一行
- 不能在同一列
- 不能在同一条斜线(同一条斜线有两个:左上到右下的斜线,我们成为左斜线;右上到左下的斜线,我们称为右斜线)
受前面一个题目的启发,我们定义数组(或者类数组)来存储某些不满足条件的结果,然后跳过。在这个题目中,我们递归遍历的就是行数,因此,一定能保证每一行只有一个皇后。除去这个条件,还需要三个数组来存放。因为我们只需要判断有没有,所以可以利用集合只能有一个元素的特性。最终,我们选择Set()
来存放皇后不能在的列。代码如下:
const columns = new Set();
const diagnal1 = new Set();
const diagnal2 = new Set();
然后只要动态的更新这三个集合即可。
回溯模板 + 剪枝条件,构成了这个题目的解。
2.5 组合总和
2.5.1 题目
2.5.2 题解
/**
* @param {number[]} candidates
* @param {number} target
* @return {number[][]}
*/
var combinationSum = function(candidates, target) {
const ans = [];
const res = [];
const dfs = (target, idx) => {
if(idx === candidates.length) {
return;
}
if(target === 0) {
ans.push(res.slice());
return;
}
dfs(target, idx + 1);
if(target - candidates[idx] >= 0) {
res.push(candidates[idx]);
dfs(target - candidates[idx], idx);
res.pop();
}
};
dfs(target, 0);
return ans;
};
2.5.3 分析
如果只是回溯模板+剪枝条件的话,这个题目是很普通的一个题目,根本没有什么困难的地方。它属于一眼就能看出来要用回溯来解决的题目,并且只需要套模板就可以做的,剪枝条件也很简单。
这个题目比较新颖的地方是先递归的遍历整个数组的元素,然后再进行条件的判断,即剪枝。
2.6 组合总和II
2.6.1 题目
2.6.2 题解
/**
* @param {number[]} candidates
* @param {number} target
* @return {number[][]}
*/
var combinationSum2 = function(candidates, target) {
const ans = [];
const list = [];
const dfs = (target, idx) => {
if(target === 0) {
ans.push(list.slice());
return;
}
if(idx === candidates.length) {
return;
}
for(let i = idx; i < candidates.length; i++) {
if(target < candidates[i]) {
break;
}
if(i > idx && candidates[i] === candidates[i - 1]) {
continue;
}
list.push(candidates[i]);
dfs(target - candidates[i], i + 1);
list.pop();
}
}
candidates.sort((a, b) => a - b);
dfs(target, 0);
return ans;
};
2.6.3 分析
这个题目更没啥好说的了,和上一个题目是同一道题。它需要注意的是去除重复的元素。
3. 总结
做了这么多道题,我们可以总结出回溯模板中的关键点:
- 终止条件:
- 需要添加结果的终止条件
- 剪枝的终止条件
- 遍历待组合的对象:
- 递归遍历
- 循环遍历
- 根据题目要求确定剪枝条件
- 回溯三步:
- 添加结果
- 递归调用
- 还原现场