算法----回溯算法

202 阅读5分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第6天,点击查看活动详情

一、初识回溯算法

回溯算法实际上一个类似枚举的搜索尝试过程,主要是在搜索尝试过程中寻找问题的解,当发现已不满足求解条件时,就“回溯”返回,尝试别的路径。

一般步骤

  1. 针对所给问题,定义问题的解空间,它至少包含问题的一个(最优)解。
  2. 确定易于搜索的解空间结构,使得能用回溯法方便地搜索整个解空间 。
  3. 以深度优先的方式搜索解空间,并且在搜索过程中用剪枝函数避免无效搜索。

用通俗一点的语言来解释回溯算法:从一条路往前走,能进则进,不能进则退回来,换一条路再试。

2.1 迷路的机器人

题目:设想有个机器人坐在一个网格的左上角,网格 r 行 c 列。机器人只能向下或向右移动,但不能走到一些被禁止的网格(有障碍物)。设计一种算法,寻找机器人从左上角移动到右下角的路径。 网格中的障碍物和空位置分别用 1 和 0 来表示。

image.png

返回一条可行的路径,路径由经过的网格的行号和列号组成。左上角为 0 行 0 列。如果没有可行的路径,返回空数组。

分析: 规则:只能向下或者向右移动

  1. 我们可以将走过的路径放到栈中,path
  2. 每次只能向下走或者向右走。当下方有障碍物时,只能考虑向右走;当右方有障碍物时,只能考虑向下走;当下方和右方都有障碍物时,只能往回走,你从哪个地方进入这个死胡同的就回到哪个地方去。
  3. 约束条件:除了在“选择中的”约束之外,我们还不能走已经走过的地方
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 宫内只能出现一次。

那让我们在算法的海洋中去分析解答这个有意思的难题解数独

image.png

思路:例如上述图案,我们尝试着去填写不同的数字,如果发现重复,则擦除重新进行新一轮尝试,直到把整个数组填充完成。 步骤

  1. 数独首先,还有 3*3 的方格内数字是 1~9 不能重复
  2. 声明布尔数组,表明行列中某个数字是否被使用了, 被用过视为 true,没用过为 false。此方法我们也可以采用set来进行计算
  3. 初始化布尔数组,表明哪些数字已经被使用过了
  4. 尝试去填充数组,只要, 还有 3*3 的方格内 出现已经被使用过的数字,我们就不填充,否则尝试填充
  5. 如果填充失败,那么我们需要回溯。将原来尝试填充的地方改回来
  6. 递归直到数独被填充完成

相关代码有注释:

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官网-回溯算法分类中查找,理解思路后多看多做才能加深印象。