js算法之回溯算法+ 剪枝

2,068 阅读5分钟

一、回溯思想简介

回溯的处理思想,有点类似枚举搜索。我们枚举所有的解,找到满足期望的解。为了有规律地枚举所有可能的解,避免遗漏和重复,我们把问题求解的过程分为多个阶段。每个阶段,我们都会面对一个岔路口,我们先随意选一条路走,当发现这条路走不通的时候(不符合期望的解),就回退到上一个岔路口,另选一种走法继续走。
解决一个回溯问题,实际上就是一个决策树的遍历过程,一般来说我们需要解决三个问题:
1、路径:也就是已经做出的选择。
2、选择列表:也就是你当前可以做的选择。
3、结束条件:也就是到达决策树底层,无法再做选择的条件。
回溯算法模板:在递归之前做选择,在递归之后撤销选择

function backtrack (路径,选择列表){
    if(满足结束条件){
        result.add(结果);
    }
    for(选择:选择列表){
        做出选择;
        backtrack(路径,选择列表);
        撤销选择;
    }
}

二、剪枝

上面说到回溯算法就像一颗N叉树,底下有很多的子节点(树枝),比如我们将一个数组从小到大排序后,第二个数已经大于要求的结果,那么它后面的数一定也大于要求结果,所以没必要再往下进行遍历,直接剪掉这个枝(跳出循环),进行下一轮搜索。所以一般回溯算法离不开剪枝,搜索问题一般复杂度较高,能剪枝就尽量需要剪枝。把候选数组排个序。

三、例题解释

39、组合总和

思路:
由题意可知:原数组元素不重复,寻找一个符合条件的组合 且原数组的单个元素可以重复使用 只要结果中的子组合互不相同即可
题解:
且原数组的单个元素可以重复使用:a、意味着下一个for循环中的元素选取,要从前一个元素开始,因为可以重复使用,不然如果跟着for的自增变量i走,会漏掉可能解 b、将自增变量i传递下去
终止条件:target 一一减去符合组合的元素,最终为 0 ,才是一个符合题意的组合

图片

/**
 * @param {number[]} candidates
 * @param {number} target
 * @return {number[][]}
 */
var combinationSum = function(candidates, target) {
    let n = candidates.length;
    let res = [];
    let temp = [];
    candidates = candidates.sort((a, b) => a - b); // 先从小到大排序
    let help = (temp, target, start) => {
        if (target === 0) { // 终止条件
            res.push(temp);
            return;
        }
        for (let i = start; i < n; i++) {
            if (target < candidates[i]) break; // 剪枝操作
            temp.push(candidates[i]);
            help(temp.slice(),target - candidates[i],i);
            temp.pop(); // 撤销选择
        }
    }
    help(temp, target, 0);
    return res;
};

40、组合总和II

对于本题来说,它相对于39. 组合总和的不同之处在于数组 candidates会存在相同的数字,如果存在相同的数字,我们就可能会出一下重复的组合,例如:

以上,情况一与情况二是相同的情况,那我们要如何去避免的,之后就需要一个剪枝操作。一般对于像数组类的问题,在剪枝之前我们都需要对数据进行排序,排好序方便我们对数组按照顺序来进行剪枝。这里上面数组排好序为:[1,2,2,2,5]。
首先,第一个操作

之前选择过的不用再去选择 这说明第一次我们的选择列表是**[1,2,2,2,5],那第二次我们的选择列表就应该是[2,2,2,5]**。

第二个操作为:

如果以当前结点为头结点的所有组合都找完了,那么下一个与他他相同的头结点就不用去找了。

/**
 * @param {number[]} candidates
 * @param {number} target
 * @return {number[][]}
 */
var combinationSum2 = function(candidates, target) {
    let n = candidates.length;
    let res = [];
    let temp = [];
    candidates = candidates.sort((a, b) => a - b);
    function help(temp, target, start) {
        if(target === 0){
            res.push(temp);
            return;
        }
        for (let i = start; i < n; i ++) {
            if(target < candidates[i]) break; // 剪枝操作
            if(i > start && candidates[i-1] === candidates[i]) continue; // 当前元素不能重复使用
            temp.push(candidates[i]);
            help(temp.slice(),target - candidates[i],i + 1); // 区别:进行+1
            temp.pop();
        }
    }
    help(temp, target, 0);
    return res;
};

46、全排列

/**
 * @param {number[]} nums
 * @return {number[][]}
 */
var permute = function(nums) {
    const res = [];
    const paths = [];

    function backtrack(paths, selections) {
        if (paths.length === nums.length) {
            res.push(paths.slice());
            return
        }
        for (let selection of selections) {
        // 组合和全排列的区别是遇到当前已有的continue进行下一个循环
            if (paths.includes(selection)) continue;
            paths.push(selection);
            backtrack(paths, selections);
            paths.pop();
        }
    }
    backtrack(paths, nums);
    return res;
};

51、N皇后

这是一道比较经典的回溯 + 剪枝操作的题目,已经出现在字节跳动和腾讯的面试中,下面介绍下思路: 循环扫荡每一行,然后遍历每一列,要满足同一列,撇,捺方向上没有皇后,满足条件就给数组第n行push一个列的位置,生成一个[1, 3, 0, 2]这样一个皇后位置的数组,最后用'.'和'Q'填充成一个二维数组。
这里一个难点是怎么判断列、撇、捺上面有没有放置皇后,通过观察我们可以发现列直接判断纵坐标的点是否相等就可以。

通过观察我们发现撇上的皇后满足:x1 - y1 = y2 - x2
捺上的皇后满足:x1 - x2 = y1 - y2这样的规律。

/**
 * @param {number} n
 * @return {string[][]}
 */
var solveNQueens = function(n) {
    // 回溯 + 剪枝
    let res = [];
    dfs();
    function dfs(sub = [], row = 0) {
        if (row === n) {
            // sub: [1, 3, 0 2]
            res.push(sub.map(i => '.'.repeat(i) + 'Q' + '.'.repeat(n - i - 1)));
        }
        // 遍历每一行
        for (let i = 0; i < n; i++) {
            // 坐标点为(row, i)表示这个点是否可以放皇后
            // v: 列,j: 行
            // sub里面是皇后的坐标 sub: [1, 3, 0 2]
            if (!sub.some((v, j) => (
                // 列
                v === i
                // 撇
                || row - v === j - i
                // 捺
                || j - row === v - i))) {
                    sub.push(i);
                    dfs(sub, row + 1);
                    sub.pop();
            }
        }
    }
    return res;
};

以上就是回溯算法的简单介绍了,大家多进行一些定向练习:回溯算法