数据结构&算法 - 回溯

22 阅读8分钟

回溯

1. 算法模板

let path = [],
    result = [];

function backTrace(args) {
    // 出口
    if (...) return; // 结果在叶子节点 result.push([...path])

        for (let i = 0; i < args.length; i++) {
        // 确定当前选项
        let curItem = '';
        // 根据当前选项剪枝
        if (...) continue;

        // 选择:当前选项加入path
        path.push(curItem);
        // 递归
        backTrace(args.slice(i or i + 1)); // 结果在所有节点 result.push([...path])
        // 取消选择(回溯):当前选项弹出path
        path.pop();
    }
}

2. 基础问题

78. 子集

对于集合{1,2,3}, 其求子集 traceBack([1,2,3]) 的方式为:

​ 维护一个 path 表示当前被选择的元素,对于 [1,2,3] 有三种选择:path=[1], path=[2], path=[3]。当 path=[1]时,选项只剩下 [2,3],继续做出选择并更新path,path = [1,2],选项=[3]。直到,path = [1,2,3], 选项=[]。递归出口即 传入的选项为空。

​ 注意 path = [2] 时,2已经被选了,其剩下选项为 [3] , 并不是 [1,3]。已经包含1的子集已经在 1 那个子树上产生,这样做不会产生重复元素。

​ 可以画一颗行为树,表示每次做出的选项,每当有新的节点产生,就多一种子集,将子集加入结果中。

var subsets = function(nums) {
    let path = [],
        result = [
            []
        ];
    traceBack(nums);
    return result;

    function traceBack(arr) {
        // 出口
        if (arr.length == 0) return;
        
        for (let i = 0; i < arr.length; i++) {
            // 选择:将元素放入路径
            path.push(arr[i]);
            // 递归
            result.push([...path]); // 每选择一个元素,就会产生一个子集
            traceBack(arr.slice(i + 1)); // 不能选择已经选过的元素
            // 取消选择:将元素从路径中取出
            path.pop();
        }
    }
};

77. 组合

​ 对于集合 {1,2,3,...,n}, 求其所有长度为k的子集。方式为:

​ 思路与78. 子集 相同,所不同的是程序的出口为 path.length == k, 即行为树的第k层就中止选择,且子集都是第k层节点产生的。

var combine = function (n, k) {
    let arr = new Array(n).fill(0).map((item, i) => i + 1);
    let path = [],
        result = [];
    traceBack(arr);
    return result;

    function traceBack(arr) {
        // 出口
        if (path.length == k) return result.push([...path]);

        for (let i = 0; i < arr.length; i++) {
            // 选择:将元素放入路径
            path.push(arr[i]);
            // 递归
            traceBack(arr.slice(i + 1)); // 不能选择已经选过的元素
            // 取消选择:将元素从路径中取出
            path.pop();
        }
    }
};

39. 组合总和

​ 将数组 [2,3,6,7] 中的数任意组合成目的数target。条件:数组中不存在重复元素,且元素可以无限取 方式为:

​ 思路与 78.子集 相同,所不同的是必须记录住选择的路径总和 sum,出口为 sum > k 或者 sum == k, 递归的时候由于元素可无数次选取,所以选项可以从arr.slice(i)开始。不从arr.slice(0)开始是因为包含0~i-1的子集,已经选择 0~i-1 过程中产生。

var combinationSum = function(candidates, target) {
    let sum = 0,
        path = [];
    let result = [];
    traceBack(candidates, target);
    return result;

    function traceBack(arr, k) {
        // 出口
        if (sum > k) return;
        if (sum == k) return result.push([...path]);
        
        for (let i = 0; i < arr.length; i++) {
            // 选取当前元素
            sum += arr[i];
            path.push(arr[i]);
            // 递归:考虑元素可无限制读取
            traceBack(arr.slice(i), k);
            // 退出当前元素
            sum -= arr[i];
            path.pop();
        }
    }
};

40. 组合总和Ⅱ

​ 将数组 [2,3,6,7] 中的数任意组合成目的数target。条件:数组中存在重复元素,且每个元素只能取一次 。这道题目和上一道题目思路相同,但是得考虑去重的问题。

​ 去重的常见手法:

  1. 在求解过程中不去重,求出结果后用set去重数组。但是这里如果在求解过程中不去重,最后三个测试用例会超时。
  2. 在求解过程中去重,回溯过程中根据题目特性进行剪枝。例如用例 [2,5,2,1,2] => [1,2,2,2,5]。如果第一个2被选择,则选项剩下[1,2,2,5]; 第二个2被选择,选项还是 [1,2,2,5], 相同结果已经在选择第一2时产生。所以减枝操作分为两步:1. 递归前将参数变为有序 2. 相同元素选择第一个即可,其他的逐次跳过。
var combinationSum2 = function(candidates, target) {
    candidates.sort((a, b) => a - b); // 排序candidates用于回溯中剪枝
    let sum = 0,
        path = [];
    let result = [];
    traceBack(candidates, target);
    return result;

    function traceBack(arr, k) {
        // 出口
        if (sum > k) return;
        if (sum == k) return result.push([...path]);

        for (let i = 0; i < arr.length; i++) {
            if (i > 0 && arr[i] == arr[i - 1]) continue; // 剪枝
            // 选择当前元素
            path.push(arr[i]);
            sum += arr[i];
            // 递归:由于每个元素只能被选一次,所以i+1
            traceBack(arr.slice(i + 1), k);
            // 取消选择当前元素
            path.pop();
            sum -= arr[i];
        }
    }
};

46. 全排列

​ 给一个数组 nums=[1,2,3],返回其所有可能的全排列。条件:数组不含重复数字。 ​ 解法和39. 组合总和 一致,每一次选择一个数,由于不能重复选取,所以从选项数组中删除被选择元素后进行下一次选择。

var permute = function (nums) {
    let path = [],
        result = [];
    traceback(nums);
    return result;

    function traceback(arr) {
        // 出口
        if (arr.length == 0) return result.push([...path]);

        for (let i = 0; i < arr.length; i++) {
            // 选择:元素加入路径
            path.push(arr[i]);
            // 递归
            let copArr = [...arr];
            copArr.splice(i, 1);
            traceback(copArr);
            // 取消选择:元素弹出路径
            path.pop();
        }
    }
};

47. 全排列Ⅱ

​ 给一个数组 nums=[1,2,3],返回其所有可能的全排列。条件:数组包含重复数字。 ​ 思路与 [40. 组合总和Ⅱ](#40. 组合总和Ⅱ) 一致,由于存在重复元素,所以必须去重。在回溯过程中去重,对数组排序后进行剪枝操作。

var permuteUnique = function (nums) {
    nums.sort((a, b) => a - b); // 排序,方便去重
    let path = [],
        result = [];
    traceback(nums);
    return result;

    function traceback(arr) {
        // 出口
        if (arr.length == 0) return result.push([...path]);

        for (let i = 0; i < arr.length; i++) {
            if (i > 0 && arr[i] == arr[i - 1]) continue; // 剪枝
            // 选择:元素加入路径
            path.push(arr[i]);
            // 递归
            let copArr = [...arr];
            copArr.splice(i, 1);
            traceback(copArr);
            // 取消选择:元素弹出路径
            path.pop();
        }
    }
};

131. 分割回文串

​ 将字符串 s 进行分割,使得每个子串都是回文串。s = "aab", result = [ [a,a,b], [aa,b]]。

​ 思路与上述回溯算法逻辑一致,for循环中每次操作是对str的一个切割,如 aab, 有三次选择(切割):

a|ab, aa|b ,aab|。出口是当前字符串切割完毕,即切割线 | 走到最后 a,a,b| 。注意行为树过程中的剪枝操作,例如对于 ababa, 有一种切割方式是 ab|aba, 因为 ab 不是回文子串,这颗子树就不需要走,即不必对 aba进行切割,而是走下一颗子树 aba|ba

var partition = function(s) {
    let path = [],
        result = [];
    backTrace(s);
    return result;

    function backTrace(str) {
        // 出口
        if (str.length == 0) return result.push([...path]);

        for (let i = 0; i < str.length; i++) {
            let subStr = str.substring(0, i + 1);
            if (!isPalin(subStr)) continue; // 剪枝
            // 选择:将当前回文子串放入path
            path.push(subStr);
            // 递归
            backTrace(str.substring(i + 1));
            // 取消选择:将当前回文子串弹出path
            path.pop();
        }
    }

    // 工具函数:判断是否回文
    function isPalin(str) {
        return str == str.split('').reverse().join('');
    }
};

3. 衍生

22. 括号生成

​ 数字n代表生成括号的对数,设计函数生成所有可能并且有效的括号组合。比如 n=3, 输出 ["((()))","(()())","(())()","()(())","()()()"]

​ 思路符合普通的回溯算法,主要确定回溯函数的输入参数,以及剪枝条件。行为树如下:

​ 回溯函数的参数为一个二维数组:[剩余左括号数量,剩余右括号数量]。每次可做两种选择:选择左括号或选择右括号。剪枝条件为两个:1. left > right 说明path中右括号插入数量过多 2. left == 0 && i==0 说明没有剩余左括号了,且依然想向path中插入左括号

var generateParenthesis = function(n) {
    let path = [],
        result = [];
    traceBack([n, n]);
    return result;

    function traceBack(arr) {
        // 出口
        if (arr[0] == 0 && arr[1] == 0) return result.push(path.join(""));

        for (let i = 0; i < 2; i++) {
            if ((arr[0] == 0 && i == 0) || arr[0] > arr[1]) continue; // 剪枝
            let curItem = i == 0 ? '(' : ')';
            // 选择
            path.push(curItem);
            curItem == '(' ? arr[0]-- : arr[1]--;
            // 递归
            traceBack(arr);
            // 取消选择
            path.pop();
            curItem == '(' ? arr[0]++ : arr[1]++;
        }
    }
};

93. 复原IP

​ 给一个由数字组成的字符串,返回所有可能的有效IP地址。

​ 思路符合回溯算法,每次截取字符串的最多前3位作为一段,在加入path之前,得判断当前子串对应数字是否符合IP规范,剪枝条件为两个:

  • 当前段数字大于255
  • 当前段数字以0开头且长度大于2

注意剪枝用的是 break 而非continuebreak意味者从当前 i=2开始,后面的3,4,5,...到s.length都不参与回溯;而continue只是跳过当前回溯,i=2,后面的3,4,5,...到 s.length 还是会继续处理。 最后在出口处注意判断path是否为4段,因为有可能产生小于4段或者大于4段的结果。

var restoreIpAddresses = function(s) {
    // 特判
    if (s.length > 12 || s.length < 4) return [];
    let path = [],
    result = [];
    traceBack(0);
    return result;

    function traceBack(startIndex) {
        // 出口
        if (startIndex == s.length && path.length == 4) return result.push(path.join('.'));

        for (let i = startIndex; i < s.length && i < startIndex + 3; i++) {
            let curStr = s.substring(startIndex, i + 1);
            // 减枝 
            if (path.length > 3) break; // 用于优化:当前分段超过4段
            if (parseInt(curStr) > 255) break; // 当前分段的数字超过255
            if (curStr[0] == '0' && curStr.length > 1) break; // 当前分段的数字以0开头且长度大于1

            // 选择
            path.push(curStr);
            // 递归
            traceBack(i + 1);
            // 取消选择
            path.pop();
        }
    }
};

494. 目标和

题目:

​ 给你一个整数数组 nums 和一个整数 target 。向数组中的每个整数前添加 '+''-' ,然后串联起所有整数,可以构造一个 表达式 ,例如,nums = [2, 1] ,可以在 2 之前添加 '+' ,在 1 之前添加 '-' ,然后串联起来得到表达式 "+2-1" 。返回可以通过上述方法构造的、运算结果等于 target 的不同 表达式 的数目。

​ nums = [1,1,1,1,1], target = 3。 result = 5。

思路:

​ 根据回溯算法的思想,对于每个nums [i] 都有两次选择 + or -,path 记录当前选择后的 sum。每次递归传入下一个被选择元素的索引 startIndex,出口为操作完所有的元素,即 startIndex == nums.length。

var findTargetSumWays = function (nums, target) {
    let path = 0,
        result = 0;
    traceBack(0);
    return result;

    function traceBack(startIndex) {
        // 出口
        if (startIndex == nums.length) {
            if (path == target) result++;
            return;
        }
        for (let i = 0; i < 2; i++) {
            // 选择
            i == 0 ? path += nums[startIndex] : path -= nums[startIndex];
            // 递归
            traceBack(startIndex + 1);
            // 取消选择
            i == 0 ? path -= nums[startIndex] : path += nums[startIndex];
        }
    }
};