1.回溯算法理论基础
什么是回溯法
回溯法也可以叫做回溯搜索法,是一种搜索方式。
回溯法的效率
**回溯法的本质是穷举,穷举所有可能,然后选出我们想要的答案。**如果想要回溯法高效一些,可以加入一些剪枝的操作,但也改变不了回溯法就是穷举的本质。
回溯法解决的问题
回溯法一般可以解决一些几类问题:
- 组合问题:N个数里面按一定规则找出k个数的集合
- 切割问题:一个字符串按一定规则有几种切割方式
- 子集问题:一个N个数的集合里有多少符合条件的子集
- 排列问题:N个数按一定规则全排列,有几种排列方式
- 棋盘问题:N皇后,解数独等等
如何理解回溯法
回溯法解决的问题都可以抽象为树形结构。
因为回溯法解决的都是在集合中递归查找子集,集合的大小构成了树的宽度,递归的深度构成了树的深度。
递归要有终止条件,所以必然是一颗高度有限的树(N叉树)。
回溯法模板
回溯三部曲:
-
回溯函数参数以及返回值
- 回溯函数一般没有返回值
- 参数不容易确定,一般是先写逻辑,需要什么参数就填什么参数。
-
回溯函数终止条件
- 一般是满足条件的答案,就把答案存起来,并结束本层递归。
-
回溯的遍历过程
伪代码如下:
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) { 处理节点; backtracking(路径,选择列表); // 递归 回溯,撤销处理结果 }for循环就是遍历集合区间,可以理解一个节点有多少个孩子,这个for循环就执行多少次。
backtracking这里自己调用自己,实现递归。
回溯算法模板框架如下:
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
2.组合问题
77.组合
给定两个整数 n 和 k,返回范围 [1, n] 中所有可能的 k 个数的组合。
你可以按 任何顺序 返回答案。
示例 1:
输入:n = 4, k = 2
输出:
[
[2,4],
[3,4],
[2,3],
[1,2],
[1,3],
[1,4],
]
思路:
一开始想到用for循环来解决,例如示例中k为2,很容易想到用两个for循环。但如果k是5呢,难道要用5个循环吗?这时就得用回溯法来解决,有多少层循环就递归多少次即可。
回溯三部曲:
1.确定函数参数
入参:由于是组合,需要用一个变量startIndex来确定遍历的起始位置。
然后还需要两个全局变量,用来保存结果res和路径path
2.确定终止条件
当path的长度等于k时终止条件
3.确定函数单层逻辑
只要遍历数组,然后保存路径,调用递归,回溯。
代码如下:
var combine = function (n, k) {
let res = [], path = []
const backTracking = (startIndex) => {
if (path.length === k) {
res.push([...path])
return
}
for (let j = startIndex; j <= n; j++) {
path.push(j)
// 递归
backTracking(j + 1)
// 回溯
path.pop(j)
}
}
backTracking(1)
return res
}
剪枝优化
在遍历的时候,我们是用j <= n来确定范围的,但是这个范围是可以优化的。
举个栗子,当n=4,k=4的话,那么第一层for循环的时候,从第二个元素开始的遍历都没有意义了。因为从第二个元素开始的话,后面是凑不齐4个数的组合。
所以可以对这个范围进行一下优化:
1.已选元素的个数:path.length
2.还需要的元素的个数: k - path.length
3.遍历的结束位置: n - (k-path.length) + 1
代码如下:
var combine = function (n, k) {
let res = [], path = []
const backTracking = (startIndex) => {
if (path.length === k) {
res.push([...path])
return
}
for (let j = startIndex; j <= n - (k - path.length) + 1; j++) {
path.push(j)
// 递归
backTracking(j + 1)
// 回溯
path.pop(j)
}
}
backTracking(1)
return res
}
40.组合总和 II
给定一个数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。
candidates 中的每个数字在每个组合中只能使用一次。
**注意:**解集不能包含重复的组合。
示例 1:
输入: candidates = [10,1,2,7,6,1,5], target = 8,
输出:
[
[1,1,6],
[1,2,5],
[1,7],
[2,6]
]
思路:
这题有两个地方需要注意
- 每个数字在组合中只能使用一次
- 解集不能包含重复的组合
第一个地方还比较好解决,只要设置一个遍历的起始位置即可。第二个地方则需要对解集进行去重,怎么去重呢,可以对数组进行排序,在遍历的时候如果当前值和上一个相同,则跳过当前循环。
回溯三部曲:
1.确定回溯函数参数
需要用到两个全局变量,一个是res保存结果,一个是path保存路径。
还需要两个参数,一个是startIndex用来遍历的起始位置,一个是sum用来保存当前的总和。
2.确定终止条件
当sum>target则终止
当sum===target则保存路径并且终止
3.确定回溯函数单层搜索的逻辑
根据起始位置遍历数组,然后进行递归处理。这里有个需要注意的地方就是当遇到和上一个值相同,则跳过当前循环。
代码如下:
var combinationSum2 = function (candidates, target) {
let res = [], path = [], len = candidates.length;
// 对数组进行排序
candidates.sort((a, b) => a - b)
backTracking(0, 0)
return res
function backTracking(sum, startIndex) {
// 终止条件
if (sum > target) return
if (sum === target) {
res.push([...path])
return
}
for (let i = startIndex; i < len; i++) {
const item = candidates[i]
// 去重
if (i > startIndex && item === candidates[i - 1]) continue
sum += item
path.push(item)
backTracking(sum, i + 1)
// 回溯
sum -= item
path.pop()
}
}
};
3.分割问题
131.分割回文串
给你一个字符串 s,请你将 s 分割成一些子串,使每个子串都是 回文串 。返回 s 所有可能的分割方案。
回文串 是正着读和反着读都一样的字符串。
示例 1:
输入:s = "aab"
输出:[["a","a","b"],["aa","b"]]
思路:
用startIndex作为字符串切割的起始位置,判断切割的字符串是否是回文,是则找下个回文字符串,不是则找下个位置。
回溯三部曲:
1.确定函数的入参
入参是startIndex,也就是遍历和切割的起始位置。
2.确定函数的终止条件
当遍历完整个字符串则结束。
3.确定单层逻辑
从起始位置开始切割字符串,判断字符串是否回文,是则递归处理下一个,不是则回退。
代码如下:
var partition = function (s) {
let res = [], path = []
backTracking(0)
return res
function backTracking(startIndex) {
if (startIndex === s.length) {
res.push([...path])
return
}
for (let i = startIndex; i < s.length; i++) {
let item = s.slice(startIndex, i + 1)
if (!isValid(item)) continue
path.push(item)
backTracking(i + 1)
path.pop()
}
}
// 判断是否回文
function isValid(s) {
let left = -1, right = s.length
while (++left < --right) {
if (s[left] !== s[right]) {
return false
}
}
return true
}
};
93.复原IP地址
给定一个只包含数字的字符串,用以表示一个 IP 地址,返回所有可能从 s 获得的 有效 IP 地址 。你可以按任何顺序返回答案。
有效 IP 地址 正好由四个整数(每个整数位于 0 到 255 之间组成,且不能含有前导 0),整数之间用 '.' 分隔。
例如:"0.1.2.201" 和 "192.168.1.1" 是 有效 IP 地址,但是 "0.011.255.245"、"192.168.1.312" 和 "192.168@1.1" 是 无效 IP 地址。
示例 1:
输入:s = "25525511135"
输出:["255.255.11.135","255.255.111.35"]
思路:
跟上一题类似,其实就是切割字符串,判断字符串是否在0-255之间。
回溯三部曲:
1.确定回溯函数的入参
入参:startIndex ,切割的起始位置。
2.确定回溯函数的终止条件
当path的长度等于4时终止。如果是最后一个位置,则把path存放到res中。
3.确定回溯函数的单层逻辑
从startIndex开始遍历,切割字符串,如果是有效的ip则递归处理下一个,如果不是则遍历下一个位置。
代码如下:
var restoreIpAddresses = function (s) {
let res = [], path = [], len = s.length
backTracking(0)
return res
function backTracking(startIndex) {
if (path.length === 4) {
if (startIndex === len) {
res.push(path.join('.'))
}
return
}
for (let i = startIndex; i < len; i++) {
let ip = s.slice(startIndex, i + 1)
if (!isValid(ip)) continue
path.push(ip)
backTracking(i + 1)
path.pop()
}
}
// 是否有效ip
function isValid(ip) {
if (ip.length > 1 && ip[0] === '0') return false
if (+ip > 255) return false
return true
}
}
4.子集问题
78.子集
给你一个整数数组 nums ,数组中的元素 互不相同 。返回该数组所有可能的子集(幂集)。
解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。
示例 1:
输入:nums = [1,2,3]
输出:[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]
思路:
求子集问题,其实就是遍历这棵树的时候,把所有节点都记录下来。
回溯三部曲:
1.递归函数参数
全局变量path为子集收集元素,res存放子集集合。
入参需要startIndex
2.递归函数终止条件
当startIndex大于nums的长度时终止,但由于遍历是从startIndex开始的,所以终止条件不加也是可以的。
3.递归函数单层循环逻辑
求取子集问题,不需要任何剪枝,因为子集就是要遍历整棵树。
代码如下:
var subsets = function (nums) {
let res = [], path = []
function backTracking(startIndex) {
res.push([...path])
for (let i = startIndex; i < nums.length; i++) {
path.push(nums[i])
backTracking(i + 1)
path.pop(nums[i])
}
}
backTracking(0)
return res
};
90.子集II
给你一个整数数组 nums ,其中可能包含重复元素,请你返回该数组所有可能的子集(幂集)。
解集 不能 包含重复的子集。返回的解集中,子集可以按 任意顺序 排列。
示例 1:
输入:nums = [1,2,2]
输出:[[],[1],[1,2],[1,2,2],[2],[2,2]]
思路:
这题和上一题的区别在于数组中有重复的元素,而解集不能包含重复的子集,也就是要对解集进行去重。去重有一个好的方法,就是先对数组进行排序,在遍历的时候,如果当前的值和上一个值相同,则跳过即可。
回溯三部曲:
1.递归函数参数
全局变量path用来收集路径,res用来收集子集
入参:遍历的起始位置startIndex
2.递归函数终止条件
当startIndex大于数组长度时终止。
3.递归函数单层逻辑
这里由于需要去重,所以要对当前的值和上一个值进行比较,如果相同则跳过。
代码如下:
var subsetsWithDup = function (nums) {
let res = [], path = []
// 排序
nums.sort((a, b) => a - b)
backTracking(0)
return res
function backTracking(startIndex) {
res.push([...path])
for (let i = startIndex; i < nums.length; i++) {
if (i > startIndex && nums[i] === nums[i - 1]) continue;
path.push(nums[i])
backTracking(i + 1)
path.pop()
}
}
};
491.递增子序列
给你一个整数数组 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]]
思路:
这题和上一题很相似,都是求子集和去重。但有个区别,上一题可以通过对数组排序来进行去重,这题不能改变数组的顺序,我们可以用一个对象来达到去重的目的。
代码如下:
var findSubsequences = function (nums) {
let res = [], path = []
backTracking(0)
return res
function backTracking(startIndex) {
if (path.length >= 2) {
res.push([...path])
}
let uset = {}
for (let i = startIndex; i < nums.length; i++) {
// 当值比path最后一个元素小,或者已经存在uset里面则走下一步
if (path.length && nums[i] < path[path.length - 1] || uset[nums[i]]) continue
uset[nums[i]] = true
path.push(nums[i])
backTracking(i + 1)
path.pop(nums[i])
}
}
};
5.排列问题
46.全排列
给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。
示例 1:
输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
思路:
排列和组合的区别在于,排列是有序的,而组合是无序的。在解决组合问题的时候,我们需要用到startIndex来解决顺序的问题。而对于排列,我们需要判断值是否在数组中来解决重复的问题。
回溯三部曲:
1.回溯函数参数
全局变量res用来收集所有子集,path用来收集路径
2.回溯函数终止条件
当path的个数等于数组的长度时,则把path放到res中,然后终止。
3.回溯函数单层逻辑
遇到path存在的值时则跳过,否则把值放到path里面,递归处理下一个
代码如下:
var permute = function (nums) {
let res = [], path = []
backTracking()
return res
function backTracking() {
if (path.length === nums.length) {
res.push([...path])
return
}
for (let i = 0; i < nums.length; i++) {
// 如果路径已经包含了当前值,则跳过
if (path.includes(nums[i])) continue
path.push(nums[i])
backTracking()
path.pop()
}
}
};
47.全排列 II
给定一个可包含重复数字的序列 nums ,按任意顺序 返回所有不重复的全排列。
示例 1:
输入:nums = [1,1,2]
输出:
[[1,1,2],
[1,2,1],
[2,1,1]]
思路:
这题和上一题的区别在于序列包含重复数字,并且要返回不重复的子集。其实也就是去重。去重一定要对元素进行排序,这样才能通过相邻的节点来判断是否重复使用了。
var permuteUnique = function (nums) {
let res = [], path = []
nums.sort((a, b) => a - b)
backTracking()
return res
function backTracking(used = []) {
if (path.length === nums.length) {
res.push([...path])
return
}
for (let i = 0; i < nums.length; i++) {
if (i > 0 && nums[i] === nums[i - 1] && !used[i - 1]) continue
// 通过索引来判断是否访问过
if (used[i]) continue
used[i] = true
path.push(nums[i])
backTracking(used)
used[i] = false
path.pop()
}
}
};
这里面,去重最为关键的代码是:
if (i > 0 && nums[i] === nums[i - 1] && !used[i - 1]) continue
如果改成下面的代码也是对的
if (i > 0 && nums[i] === nums[i - 1] && used[i - 1]) continue
这是为什么呢,这是因为这是针对不同的位置去重,如果要对树层中前一位去重,就用used[i - 1] == false,如果要对树枝前一位去重用used[i - 1] == true。
对于排列问题,树层上去重和树枝上去重,都是可以的,但是树层上去重效率更高!
6.棋盘问题
332.重新安排行程
给你一份航线列表 tickets ,其中 tickets[i] = [fromi, toi] 表示飞机出发和降落的机场地点。请你对该行程进行重新规划排序。
所有这些机票都属于一个从 JFK(肯尼迪国际机场)出发的先生,所以该行程必须从 JFK 开始。如果存在多种有效的行程,请你按字典排序返回最小的行程组合。
- 例如,行程
["JFK", "LGA"]与["JFK", "LGB"]相比就更小,排序更靠前。
假定所有机票至少存在一种合理的行程。且所有的机票 必须都用一次 且 只能用一次。
示例 1:
输入:tickets = [["MUC","LHR"],["JFK","MUC"],["SFO","SJC"],["LHR","SFO"]]
输出:["JFK","MUC","LHR","SFO","SJC"]
思路:
这题有两个难点,一个是要构建一颗可以遍历的树,一个是要按字典排序返回返回最小的行程组合。
回溯三部曲:
1.确定递归函数的参数和返回值
需要两个全局变量,一个是res用来保存结果,一个是map用来保存每个城市的航班信息。
返回值:由于只要返回一个行程,所以需要设置返回值,当找到行程则结束
2.确定终止条件
当res的个数等于tickets的个数加1时,说明找到行程,返回true
当res最后一个城市没有行程时,则返回false
3.确定单层逻辑
遍历res最后一个城市的行程,把该行程从map中删除,然后递归处理下一个城市,最后做回溯。
代码如下:
var findItinerary = function (tickets) {
let res = ['JFK']
let map = {}
// 构造map
for (const ticket of tickets) {
const [from, to] = ticket
map[from] = map[from] || []
map[from].push(to)
}
// 排序
for (let city in map) {
map[city].sort()
}
backTracking()
return res
function backTracking() {
if (res.length === tickets.length + 1) return true
let citys = map[res[res.length - 1]]
if (!citys || !citys.length) return false
for (let i = 0; i < citys.length; i++) {
const city = citys[i]
res.push(city)
// 移除city
citys.splice(i, 1)
if (backTracking()) return true
citys.splice(i, 0, city)
res.pop()
}
}
};
51.N皇后
n 皇后问题 研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。
给你一个整数 n ,返回所有不同的 n 皇后问题 的解决方案。
每一种解法包含一个不同的 n 皇后问题 的棋子放置方案,该方案中 'Q' 和 '.' 分别代表了皇后和空位。
示例 1:
输入:n = 4
输出:[[".Q..","...Q","Q...","..Q."],["..Q.","Q...","...Q",".Q.."]]
解释:如上图所示,4 皇后问题存在两个不同的解法。
思路:
用一个数组path来存放皇后放置的位置,然后判断这个位置是否能放,能放则继续下一个行,不能放则找下个位置。最后当所有位置都找到的时候,生成要输出的格式。
回溯三部曲:
1.确定函数的参数
入参:当前的行数
2.确定函数的终止条件
当path的个数等于n时,说明找到所有的位置。
3.确定单层逻辑
遍历当前层的位置,然后判断这个位置是否冲突,冲突则找下一个位置,不冲突则找下一层的。
代码如下:
var solveNQueens = function (n) {
let res = [], path = []
backTracking(0)
return res
function backTracking(row) {
if (path.length === n) {
res.push(generate(path))
return
}
for (let i = 0; i < n; i++) {
// 判断是否符合条件
if (isConfilt(row, i)) continue
path.push(i)
backTracking(row + 1)
path.pop()
}
}
function generate(path) {
return path.map(i => {
let arr = new Array(n).fill('.')
arr[i] = 'Q'
return arr.join('')
})
}
function isConfilt(row, col) {
return path.some((c, r) => {
// 列是否一样,是否在一个斜线上
if (c === col || c + r === col + row || r - c === row - col) {
return true
}
})
}
}
37.解数独
编写一个程序,通过填充空格来解决数独问题。
数独的解法需 遵循如下规则:
- 数字
1-9在每一行只能出现一次。 - 数字
1-9在每一列只能出现一次。 - 数字
1-9在每一个以粗实线分隔的3x3宫内只能出现一次。(请参考示例图)
数独部分空格内已填入了数字,空白格用 '.' 表示。
示例:
输入:board = [["5","3",".",".","7",".",".",".","."],["6",".",".","1","9","5",".",".","."],[".","9","8",".",".",".",".","6","."],["8",".",".",".","6",".",".",".","3"],["4",".",".","8",".","3",".",".","1"],["7",".",".",".","2",".",".",".","6"],[".","6",".",".",".",".","2","8","."],[".",".",".","4","1","9",".",".","5"],[".",".",".",".","8",".",".","7","9"]]
输出:[["5","3","4","6","7","8","9","1","2"],["6","7","2","1","9","5","3","4","8"],["1","9","8","3","4","2","5","6","7"],["8","5","9","7","6","1","4","2","3"],["4","2","6","8","5","3","7","9","1"],["7","1","3","9","2","4","8","5","6"],["9","6","1","5","3","7","2","8","4"],["2","8","7","4","1","9","6","3","5"],["3","4","5","2","8","6","1","7","9"]]
思路:
从第一个位置开始,用1-9分别填入每个位置,如果不冲突则找下一个位置,冲突则回滚。一直到最后一个位置。
回溯三步走:
1.确定回溯函数的参数和返回值
入参:当前位置的行和列
返回值:遍历到最后一个位置则返回true,如果1-9都遍历完还找不到则返回false。
2.确定函数的终止条件
当遍历到第10行则结束。
3.确定函数的单层逻辑
判断当前位置是否能放置,可以则递归下一个,不可以会放置下个数字。
代码如下:
const solveSudoku = (board) => {
let len = board.length
backTracking(0, 0)
return board
function backTracking(row, col) {
if (row >= len) return true
if (col === len) return backTracking(row + 1, 0)
if (board[row][col] !== '.') return backTracking(row, col + 1)
for (let i = 1; i <= len; i++) {
// 判断条件
let item = String(i)
if (isConfilt(row, col, item)) continue
board[row][col] = item
if (backTracking(row, col + 1)) return true
board[row][col] = '.'
}
return false
}
function isConfilt(row, col, val) {
// 行不能一样
if (board[row].includes(val)) return true
// 列不能一样
for (let i = 0; i < len; i++) {
if (board[i][col] === val) return true
}
// 3*3不能一样
let r = Math.floor(row / 3) * 3
let c = Math.floor(col / 3) * 3
for (let i = 0; i < 3; i++) {
for (let j = 0; j < 3; j++) {
if (board[r + i][c + j] === val) {
return true
}
}
}
return false
}
}
参考链接: 回溯算法