开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第6天,点击查看活动详情
一、初识回溯算法
回溯算法实际上一个类似枚举的搜索尝试过程,主要是在搜索尝试过程中寻找问题的解,当发现已不满足求解条件时,就“回溯”返回,尝试别的路径。
一般步骤
- 针对所给问题,定义问题的解空间,它至少包含问题的一个(最优)解。
- 确定易于搜索的解空间结构,使得能用回溯法方便地搜索整个解空间 。
- 以深度优先的方式搜索解空间,并且在搜索过程中用剪枝函数避免无效搜索。
用通俗一点的语言来解释回溯算法:从一条路往前走,能进则进,不能进则退回来,换一条路再试。
2.1 迷路的机器人
题目:设想有个机器人坐在一个网格的左上角,网格 r 行 c 列。机器人只能向下或向右移动,但不能走到一些被禁止的网格(有障碍物)。设计一种算法,寻找机器人从左上角移动到右下角的路径。 网格中的障碍物和空位置分别用 1 和 0 来表示。
返回一条可行的路径,路径由经过的网格的行号和列号组成。左上角为 0 行 0 列。如果没有可行的路径,返回空数组。
分析: 规则:只能向下或者向右移动
- 我们可以将走过的路径放到栈中,path
- 每次只能向下走或者向右走。当下方有障碍物时,只能考虑向右走;当右方有障碍物时,只能考虑向下走;当下方和右方都有障碍物时,只能往回走,你从哪个地方进入这个死胡同的就回到哪个地方去。
- 约束条件:除了在“选择中的”约束之外,我们还不能走已经走过的地方
function pathWithObstacles(obstacleGrid) {
//判断数组的长度
const row = obstacleGrid.length
const col = obstacleGrid[0].length
// 若头左上角的数据或者右下角的数据为障碍物,则永远无法到达,直接返回空数组
if (obstacleGrid[0][0] != 0 || obstacleGrid[row - 1][col - 1] != 0) {
return []
}
//使用一个数组记录当前的位置是否不能走通,因为一个位置,如果我们之前走过一次发现不能走通,那之后的尝试如果再走到这就直接返回
let visited = new Array(row).fill(false).map(() => new Array(col).fill(false))
let path = []
//从(i,j)开始走,path记录了之前的路径
let backtrack = function (obstacleGrid, x, y, visited) {
// 超过边界、有障碍物、已经走过,则直接返回false
if (x >= row || y >= col || obstacleGrid[x][y] == 1 || visited[x][y]) {
return false
}
// 能走通,则放到path中
path.push([x, y])
visited[x][y] = true // 记录被访问过
// 判断是否到达终点
if (x == row - 1 && y == col - 1) {
return true;
}
// 选择后没到终点,先尝试向下,再尝试向右。若其中一个能到达重点,则能够到达
if (backtrack(obstacleGrid, x + 1, y, visited) || backtrack(obstacleGrid, x, y + 1, visited)) {
return true;
}
// 若是不能走通,则需要回溯
path.pop();
return false
}
backtrack(obstacleGrid, 0, 0, visited);
return path;
}
2.2 组合
给定两个整数 n 和 k,返回范围 [1, n] 中所有可能的 k 个数的组合。
输入: n = 4, k = 2
输出:
[
[2,4],
[3,4],
[2,3],
[1,2],
[1,3],
[1,4],
]
分析: 标准的回溯问题,套用代码模版
result = [];
function backtrack (path, list) {
if (满足条件) {
result.push(path);
return
}
for () {
// 做选择(前序遍历)
backtrack (path, list)
// 撤销选择(后续遍历)
}
}
我们接下来套用模板来实现这个题的
function combine(n, k) {
if (k <= 0 || n <= 0) return [];
const ans = []
// start:起始位置,track记录组合
function backtrack(n, k, start, track) {
if (k == track.length) {
ans.push(track);
return
}
for (let i = start; i <= n; i++) {
// 做选择(前序遍历)
track.push(i)
backtrack(n, k, i + 1, [...track])
// 撤销选择(后续遍历)
track.pop()
}
}
backtrack(n, k, 1, [])
return ans
}
2.3 解数独
在日常生活中,相信大家都玩过数独游戏,规则如下:
- 数字 1-9 在每一行只能出现一次。
- 数字 1-9 在每一列只能出现一次。
- 数字 1-9 在每一个以粗实线分隔的 3x3 宫内只能出现一次。
那让我们在算法的海洋中去分析解答这个有意思的难题解数独
思路:例如上述图案,我们尝试着去填写不同的数字,如果发现重复,则擦除重新进行新一轮尝试,直到把整个数组填充完成。 步骤:
- 数独首先
行,列,还有3*3的方格内数字是 1~9 不能重复 - 声明布尔数组,表明行列中某个数字是否被使用了, 被用过视为
true,没用过为false。此方法我们也可以采用set来进行计算 - 初始化布尔数组,表明哪些数字已经被使用过了
- 尝试去填充数组,只要
行,列, 还有3*3的方格内 出现已经被使用过的数字,我们就不填充,否则尝试填充 - 如果填充失败,那么我们需要回溯。将原来尝试填充的地方改回来
- 递归直到数独被填充完成
相关代码有注释:
const solveSudoku = (board) => {
const rows = new Array(9) // 存放每一行对应的可选数集
const cols = new Array(9);// 存放每一列对应的可选数集
const blocks = new Array(9); // 存放每一框对应的可选数集
const options = ['1', '2', '3', '4', '5', '6', '7', '8', '9'];
for (let i = 0; i < 9; i++) { // 集合的初始化
rows[i] = new Set(options);
cols[i] = new Set(options);
blocks[i] = new Set(options);
}
const getBlockIndex = (i, j) => { // 根据坐标,获取所在的小框的索引
return (i / 3 | 0) * 3 + j / 3 | 0; // |0 是向下取整
};
for (let i = 0; i < 9; i++) { // 根据现有的已填的数字,更新set们
for (let j = 0; j < 9; j++) {
if (board[i][j] != ".") {
rows[i].delete(board[i][j]); // 当前行出现过这个数字,这个数字就不能在这一行出现,删除该选项
cols[j].delete(board[i][j]);
blocks[getBlockIndex(i, j)].delete(board[i][j]);
}
}
}
const fill = (i, j) => {
// 判斷填充完成的條件
if (j === 9) { // 我们采取的是横向填充,若j === 9 ,则填充到最后一列,下一步我们填充下一行
i++;
j = 0;
if (i == 9) return true // 都填充完成,则返回true
}
// 若此位置有数据,则我们填充下一格
if (board[i][j] != ".") return fill(i, j + 1)
// 我们需要填充数据
// 获取所在小框的索引
const blockIndex = getBlockIndex(i, j)
for (let num = 1; num <= 9; num++) {
const s = String(num)
// 当前选择必须在三个set中都存在,如果有一个不存在,就说明发生了冲突,跳过该选择
if (!rows[i].has(s) || !cols[j].has(s) || !blocks[blockIndex].has(s)) continue;
// 我们填充此数据
board[i][j] = s;
rows[i].delete(s)
cols[j].delete(s);
blocks[blockIndex].delete(s);
if (fill(i, j + 1)) return true //如果基于当前选择,填下一个,最后可解出数独,直接返回真
// 基于当前选择,填下一个,怎么填都不行,回溯,恢复为空格
board[i][j] = '.'
rows[i].add(s); // set们,将之前删掉的当前数字,加回来
cols[j].add(s);
blocks[blockIndex].add(s);
}
return false // 尝试了1-9,每个都往下递归,都不能做完,返回false
}
// 开始填充数据
fill(0, 0)
return board
}
参考与感谢
- leetcode官网
相关算法题,也可在leetcode官网-回溯算法分类中查找,理解思路后多看多做才能加深印象。