使用场景
- 组合问题
- 切割问题:字符串有几种切割方式保证子串为回文子串
- 子集问题:列出一个数组的所有的子集
- 排列问题:与组合问题对应( 组合无顺序,排列有顺序 )
- 棋盘问题:迷宫
如何理解回溯法
抽象为一个 n 叉树,树的宽度为集合的大小,树的深度为递归的深度
- 递归一次其实就是一个 for 循环
- 当如果想要嵌套 n 层 for 循环解决问题时时,我们没法通过 for 循环实现,回溯算法可以帮我实现控制 for 循环的嵌套层数
- 任何可以用回溯法解决的问题,都能抽象成一个树形结构
回溯三部曲
- 递归函数参数以及返回值
- 确定终止条件
- 单层递归的逻辑
例题:组合
给定两个整数 n
和 k
,返回范围 [1, n]
中所有可能的 k
个数的组合。
你可以按 任何顺序 返回答案。
- 回溯法的搜索过程就是一个树型结构的遍历过程,图中,可以看出for循环用来横向遍历,递归的过程是纵向遍历。
var combine = function (n, k) {
// 存放组合
let path = []
// 最后返回的数组
let result = []
// path 组合结果之一,我需要用它的长度来判断是否跳出递归
function backtracking(n, k, startindex) {
// 递归终止条件,集合长度为k,
if (path.length === k) {
// 找到这个子集了 递归终止 防止传递引用
result.push([...path])
return
}
for (let i = startindex; i <= n; i++) {
// 这层循环我进入的是啥
path.push(i)
// 想做的事情在下一层梦境空间里做了,不把麻烦带出来
backtracking(n, k, i + 1)
// 回溯 出来时还是啥,不把麻烦带回上一层,因为我在单层循环,所以到下一轮我不能变
path.pop()
}
}
backtracking(n, k, 1)
return result
};
回溯的剪枝
以上一道组合题为例
图中打岔的分支在那一层就已经可以出局了,没必要再往下层递归了,因为不满足条件,这样能极大减少复杂度,比如上面代码单层逻辑中,可以将 n 改成 n-(k-path.length)+1
for (let i = startindex; i <= n-k+path.length+1; i++) {
path.push(i)
backtracking(n, k, i + 1)
// 回溯 出来时还是啥,不把麻烦带回上一层,因为我在单层循环,所以到下一轮我不能变
path.pop()
}
1、组合问题
例题:组合总数 III
找出所有相加之和为 n
的 k
个数的组合,且满足下列条件:
- 只使用数字1到9
- 每个数字 最多使用一次
返回 所有可能的有效组合的列表 。该列表不能包含相同的组合两次,组合可以以任何顺序返回。
示例 1:
输入: k = 3, n = 7
输出: [[1,2,4]]
解释:
1 + 2 + 4 = 7
没有其他符合的组合了。
var combinationSum3 = function (k, n) {
let result = []
let path = []
function backtracking(sum, startindex) {
// 退出递归的条件
if (sum > n || startindex + k - path.length > 10) {
return
}
// 判断叶子节点是否满足条件
if (sum === n && path.length === k) {
result.push([...path])
return
}
// 单层逻辑
for (let i = startindex; i <= 9; i++) {
// 到这里了说明 sum<n,还要继续加
path.push(i)
backtracking(sum + i,i + 1)
path.pop()
}
}
backtracking(0, 1)
return result
};
例题:电话号码的字母组合
给定一个仅包含数字
2-9
的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。
示例 1:
输入:digits = "23"
输出:["ad","ae","af","bd","be","bf","cd","ce","cf"]
示例 2:
输入:digits = ""
输出:[]
需要先对每个数字与字母进行映射
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
---|---|---|---|---|---|---|---|---|---|
‘’ | ‘’ | ‘abc’ | ‘def’ | ‘ghi’ | ‘jkl’ | ‘mno’ | ‘pqrs’ | ‘tuv’ | ‘wxyz’ |
深度由输入数字个数决定
var letterCombinations = function (digits) {
let result = []
let path = ''
// 需要用一个表映射数字与能选择字母的关系
let map = ['', '', 'abc', 'def', 'ghi', 'jkl', 'mno', 'pqrs', 'tuv', 'wxyz']
function backtracking(startindex) {
// 退出递归的条件
if (startindex === digits.length) {
// 针对出题人恶意出空字符串恶心你的情况
if (path !== '') {
result.push(path)
}
return
}
// 读取当前数字对应的所有字母情况,一个个进入再递归到下一层,再回溯
for (let i = 0; i < map[Number(digits[startindex])].length; i++) {
path += map[Number(digits[startindex])][i]
// 这个递归其实就已经包含了遍历所有的号码中的数字了
backtracking(startindex + 1)
path = path.substring(0, path.length - 1)
}
}
backtracking(0)
return result
};
2、分割问题
.
分割问题有点类似于组合问题,只不过,组合是选不同位置的元素,分割问题是选位置不同的缝插入
分割问题的树形结构,以下面的例题为例:
例题:分割回文串
给你一个字符串 s
,请你将 s
分割成一些子串,使每个子串都是 回文串 。返回 s
所有可能的分割方案。
回文串 是正着读和反着读都一样的字符串。
示例 1:
输入:s = "aab"
输出:[["a","a","b"],["aa","b"]]
示例 2:
输入:s = "a"
输出:[["a"]]
提示:
1 <= s.length <= 16
s
仅由小写英文字母组成
- 每次从头开始,切割线用传入的 startindex 作为判断,当 startindex 到最后了,说明已经将当前的情况遍历完了,如果当前的分割满足题目条件,那么加入最终结果
- 每次比较当前字符串是不是回文的,如果是,就把当前的字符串加入 path,然后把把当前字符串切割掉,用右边剩下的字符串传入下层递归;如果不是,当前末尾指针后移,继续判断是不是回文直到结束
- path 代表的就是 startindex 左边的那些已经分割完成的字符串的回文子串的分割方法(以数组保存,分隔符就是分割线)
var partition = function (s) {
// 存储最终结果
let result = []
// 存储某一种可行的分割方案
let path = []
// startindex 代表分割线
function backtracking(startindex) {
// 终止条件
if (startindex === s.length) {
// 存放结果
result.push([...path])
}
// 单层逻辑
for (let i = startindex; i < s.length; i++) {
// startindex 为选取字符串的起始位置,i为选取字符串的最后位置
// 判断现在选取的字符串是不是回文字符串,如果是,那么将这个字符串加入 path,第一次必然是回文
if (isPalindrom(s.substring(startindex, i + 1))) {
path.push(s.substring(startindex, i + 1))
} else {
// 如果不是,结束本次循环,继续往后找,直到找到回文字符串,如果后面都没有了,那么这种情况就不用进结果了
continue
}
// 没有跳出去,到这一步,证明是回文,那么继续在这里做一个分割,然后分割线后面的字符串进入下一层递归,path 已经把分割线前面的分割方法加进去了
backtracking(i + 1)
// 递归执行完毕,path 回溯到前面的状态,保持刚进入这一层循环的状态,继续执行下面的循环
path.pop()
}
}
backtracking(0)
return result
};
function isPalindrom(s) {
return s.split('').reverse().join('') === s
}
3、子集问题
子集问题与前面两种问题的一大区别就是,path(当前满足条件的状况) 进入 result(最终结果) 的时机。
前面的问题是在叶子节点里找满足条件的 path 进入 result,而子集问题在非叶子节点也要找
例题:子集
给你一个整数数组 nums
,数组中的元素 互不相同 。返回该数组所有可能的子集(幂集)。
解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。
示例 1:
输入:nums = [1,2,3]
输出:[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]
示例 2:
输入:nums = [0]
输出:[[],[0]]
var subsets = function(nums) {
// 排序
nums.sort((a,b)=>a-b)
let result=[[]]
let path=[]
function backtracking(startindex){
if(startindex===nums.length){
return
}
for(let i=startindex;i<nums.length;i++){
path.push(nums[i])
result.push([...path])
backtracking(i+1)
path.pop()
}
}
backtracking(0)
return result
};
例题:子集 II
给你一个整数数组 nums
,其中可能包含重复元素,请你返回该数组所有可能的子集(幂集)。
解集 不能 包含重复的子集。返回的解集中,子集可以按 任意顺序 排列。
示例 1:
输入:nums = [1,2,2]
输出:[[],[1],[1,2],[1,2,2],[2],[2,2]]
示例 2:
输入:nums = [0]
输出:[[],[0]]
- 跟子集那道题比起来 多了判重的步骤。
var subsetsWithDup = function (nums) {
// 不多废话,先排序,为后面判重做准备
nums.sort((a, b) => a - b)
let result = [[]]
let path = []
function backtracking(startindex) {
if (startindex === nums.length) {
return
}
for (let i = startindex; i < nums.length; i++) {
// 判重
if (i > startindex && nums[i] === nums[i-1]) {
// 跳过这一步
continue
}
// 没跳过,说明不重复,接下来和子集那道题一样的操作
path.push(nums[i])
result.push([...path])
backtracking(i + 1)
path.pop()
}
}
backtracking(0)
return result
};
例题:递增子序列
给你一个整数数组 nums
,找出并返回所有该数组中不同的递增子序列,递增子序列中 至少有两个元素 。你可以按 任意顺序 返回答案。
数组中可能含有重复元素,如出现两个整数相等,也可以视作递增序列的一种特殊情况。
示例 1:
输入:nums = [4,6,7,7]
输出:[[4,6],[4,6,7],[4,6,7,7],[4,7],[4,7,7],[6,7],[6,7,7],[7,7]]
示例 2:
输入:nums = [4,4,3,2,1]
输出:[[4,4]]
这道题与前面两道子集题都不一样,前面两个由于只要拿子集,所以可以一开始进行排序,并通过排序排除重复的情况;
但这道题不一样,这道题数组本身是乱序的,但是又在本身数组里需要找到递增的子集,所以一开始不能排序,不然会影响最后的结果,比如
[6,4]
返回的是 [[6],[4]]
,但是[4,6]
返回的是[[4],[6],[4,6]]
var findSubsequences = function (nums) {
// 这里不能排序了,不然会影响结果
let result = []
let path = []
function backtracking(startindex) {
// 递归终止条件
if (startindex === nums.length) {
return
}
for (let i = startindex; i < nums.length; i++) {
// 要满足增序条件方可继续,不然这个可以直接略过了
if (i > 0 && nums[i] < nums[startindex - 1]) {
continue
}
// 判重,如果要选的元素里面之前出现过了,比如 --- 1234|(4)5(4) ---,4之前出现过了
// 这种情况也要略过
if ((nums.slice(startindex, i).indexOf(nums[i])) !== -1) {
continue
}
path.push(nums[i])
// 至少需要两个元素才能进入结果
if (path.length >= 2) {
result.push([...path])
}
backtracking(i+1)
path.pop()
}
}
backtracking(0)
return result
};
4、排列问题
- 在排列问题中,由于同一个组合有多种排列,去重操作就不能像组合那样轻松的用一个 startindex 来控制了,需要引入一个 used 数组记录已经使用过的元素
- 排列问题一般也是在叶子节点收割结果
例题:全排列
给定一个 不含重复数字 的数组 nums
,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。
示例 1:
输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
示例 2:
输入:nums = [0,1]
输出:[[0,1],[1,0]]
示例 3:
输入:nums = [1]
输出:[[1]]
首先,以[1,2,3]
为例,将该问题抽象为如下树形结构:
- 树的深度、宽度由需要排列的集合的大小决定
- 终止条件
path.length===nums.length
- 之前单层逻辑让
i
从startindex
开始是为了避免重复或者作为分割线,但是排列问题中不用startindex
控制重复了,在这个没有重复元素的集合中,我只要控制自己不重复出现就行了,那么引入了used[]
就是为了避免自己重复
本题代码:
var permute = function(nums) {
// 存放结果集
let result = []
// 存放一次的排列情况
let path = []
// 记录哪些已被使用
let used=new Array(nums.length).fill(0)
function backtracking(used){
// 递归终止条件
if(path.length===nums.length){
result.push([...path])
return
}
// 单次搜索的逻辑
for(let i=0;i<nums.length;i++){
if(used[i]===1){
// 这个元素已经使用过了,跳过本次循环
continue
}
path.push(nums[i])
used[i]=1
// 到下一层继续推进
backtracking(used)
// 回溯
path.pop()
used[i]=0
}
}
backtracking(used)
return result
};
例题:全排列 II
给定一个可包含重复数字的序列 nums
,按任意顺序 返回所有不重复的全排列。
示例 1:
输入:nums = [1,1,2]
输出:
[[1,1,2],
[1,2,1],
[2,1,1]]
示例 2:
输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
- 与上道题不一样的在于 树层去重
// 给定一个组合,对他进行全排列
[6,1,2,4,9,2⃣️,5]
// 以 2 为开头的排列情况
[2,...],[2,...],.... 与 [2⃣️,...],[2⃣️,...],.... 其实并无二致
代码如下:
var permuteUnique = function (nums) {
// 多了一层去重,因此我要先排序
nums.sort((a, b) => a - b)
let result = []
let path = []
let used = new Array(nums.length).fill(0)
function backtracking() {
if (path.length === nums.length) {
result.push([...path])
return
}
for (let i = 0; i < nums.length; i++) {
// 不仅要判断自己没有出现过,还要保证当前这个位置之前也没有出现过和我一样的值
if (used[i] === 1) {
continue
}
// 在我前面的这些未被使用元素里出现过和我相同的元素
// 解法 1:在这之前出现过的元素中,和我一样但没有被 used 的如果存在则跳过
// if (nums.slice(0, i).filter((item, index) => used[index] === 0).indexOf(nums[i]) !== -1) {
// continue
// }
// 解法 2: 前面的排序就是为了现在能够对树层去重进行简化,我前面的元素要是和我一样那么肯定就在我前面一位
if(i>0&&nums[i]===nums[i-1]&&used[i-1]===0){
continue
}
path.push(nums[i])
used[i] = 1
backtracking()
path.pop()
used[i] = 0
}
}
backtracking()
return result
};
5、棋盘问题
之前的组合问题、分割问题、子集、排列问题处理的都是一个个集合,一个个集合按照题目条件输出若干子集,但棋盘问题 处理的是一个二维数组/矩阵
例题:n 皇后问题
按照国际象棋的规则,皇后可以攻击与之处在 同一行 或 同一列 或 同一斜线 上的棋子。
n 皇后问题 研究的是如何将
n
个皇后放置在n×n
的棋盘上,并且使皇后彼此之间不能相互攻击。给你一个整数
n
,返回所有不同的 n 皇后问题 的解决方案。每一种解法包含一个不同的 n 皇后问题 的棋子放置方案,该方案中
'Q'
和'.'
分别代表了皇后和空位。
示例 1:
输入:n = 4
输出:[[".Q..","...Q","Q...","..Q."],["..Q.","Q...","...Q",".Q.."]]
解释:如上图所示,4 皇后问题存在两个不同的解法。
示例 2:
输入:n = 1
输出:[["Q"]]
假设棋盘的 n=3
,n=3
的情况的树形结构如下:
第一层递归在第一行,第二层递归在第二行,第三层递归在第三行,树的深度与宽度都由棋盘的 n
确定
由 row
控制当前的递归层数,有点类似于前面的 startindex
var solveNQueens = function (n) {
// 构建棋盘,棋盘是一个数字,每一行的情况都是用字符串表示,一种棋盘代表一种方式
let chessboard = new Array(n).fill('.')
chessboard = chessboard.map(item => item = new Array(n).fill('.').join(''))
// 收集棋盘结果
let result = []
// row 代表当前行
function backtracking(chessboard, n, row) {
// 检测是否满足收集条件,能到这一步说明满足条件
if (row === n) {
result.push([...chessboard])
}
for (let i = 0; i < n; i++) {
// 当前行 在这个位置放皇后 如果合法,那么就进入下一层递归 ; 如果不合法直接略过这一步
if (_isValid(row, i, chessboard, n)) {
// 这里的 row 是当前行索引,i 是当前列索引
// 字符串由于是简单数据类型,所以没法直接在指定位置修改,因此需要先转为数组
// 先转为数组再给指定位置赋值,不然最后赋的值会取代当前的chessboard[row]
chessboard[row] = chessboard[row].split('')
chessboard[row][i] = 'Q'
chessboard[row] = chessboard[row].join('')
backtracking(chessboard, n, row + 1)
// 回溯
chessboard[row] = chessboard[row].split('')
chessboard[row][i] = '.'
chessboard[row] = chessboard[row].join('')
}
}
}
backtracking(chessboard, n, 0)
return result
};
// 判断当前行在这个位置放皇后是否合法
function _isValid(row, i, chessboard, n) {
// 同一行必然不可能出现皇后了,因为我在不断回溯,因此只要判断当前列或者斜对面的情况
// 1. 前面几行的同列有没有出现过皇后,后面几行还没遍历,所以不可能出现皇后,因为我只更改过当前行上面的
if (chessboard.filter(item => item[i] === 'Q').length > 0) {
return false
}
// 2. 当前朝上的斜对面分为左上方与右上方
for (let j = 1; j <= row && j <= i; j++) {
if (chessboard[row - j][i - j] === 'Q') {
return false
}
}
for (let j = 1; j <= row && j < n - i; j++) {
if (chessboard[row - j][i + j] === 'Q') {
return false
}
}
return true
}
例题:单词搜索
给定一个二维字符网格
board
和一个字符串单词word
如果
word
存在于网格中,返回true
,否则返回false
单词必须按照字母排序,通过相邻的单元格内的字母构成
二维数组
board
=[ [‘A’,‘B’,‘C’,‘E’] , [‘S’,‘F’,‘C’,‘S’] , [‘A’,‘D’,‘E’,‘E’] ]目标:
word
=‘ABCCED’
- 递归的找当前字母的上下左右
var exist = function (board, word) {
// m 为行数
const m = board.length
// n 为列数
const n = board[0].length
let result = []
let path = []
// 这种情况容易忽略
if(word.length===1){
// 如果长度为1,只要表格中有我这个字母就返回 true
return board.some(item=>{
if(item.indexOf(word)!==-1){
return true
}
})
}
// a,b 为传入的位置坐标 index 代表匹配字符的索引
function backtracking(i, j, index) {
// m,n 为长度与宽度,实际上索引都取不到
if (i >= m || j >= n || i < 0 || j < 0) {
// 啥也没找到, 退出递归
return
}
// 已经全部匹配到了,将该组合放入结果
if (index >= word.length) {
result.push([...path])
return
}
if (board[i][j] === word[index]) {
path.push(word[index])
// 防止下面又找回来了
let temp = board[i][j]
board[i][j] = null
// 上下左右求索
backtracking(i - 1, j, index + 1)
backtracking(i, j - 1, index + 1)
backtracking(i + 1, j, index + 1)
backtracking(i, j + 1, index + 1)
// 变回来
board[i][j] = temp
path.pop()
}
return
}
for (let i = 0; i < m; i++) {
for (let j = 0; j < n; j++) {
backtracking(i, j, 0)
}
}
return result.length>0
};
6、使用心得
单个结果的保存形式
一般来说,结果集中的每一种结果都是用一个一位数组去存储,如果发现当前的一种结果需要用二维数组去存储,可以考虑将二维数组变成一个存储字符串的一位数组,就比如 n 皇后问题
时间复杂度
回溯法本质上也是暴力解题,是用在 for 循环无法控制层数时派上用场,遇到了时间限制的问题就要用其他方法了,比如三数之和的双指针