首页 重启DAY9 回溯算法套路①子集型回溯
文章
取消

重启DAY9 回溯算法套路①子集型回溯

跟着灵茶山艾府大佬学习算法思路的day9,目标是在一个月内掌握hot100

b站链接如下:

回溯算法套路①子集型回溯【基础算法精讲 14】

递归大概有两大流派:

  1. 分治型递归:拆独立子问题,合并结果(二叉树高度、归并排序、快排)
  2. 回溯型递归:在部分解上逐步延伸,穷举所有方案(子集、排列、组合)

回溯类问题可以分为子集型回溯、组合型回溯和排列型回溯。0-1背包问题也算是一种子集型回溯。

电话号码的字母组合 题目描述:

给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。

给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。

img

示例 1:

1
2
输入:digits = "23"
输出:["ad","ae","af","bd","be","bf","cd","ce","cf"]

示例 2:

1
2
输入:digits = "2"
输出:["a","b","c"]

思路:

单纯的循环嵌套表达能力是有局限的,但通过递归可以达到多重循环的效果。增量构造答案的过程就是回溯的特点,这个过程通常就用递归实现。只要把边界条件和非边界条件写对,那递归就一定是对的,其他部分可以完全交给数学归纳法,不用想太多。

image-20260503213427147

image-20260503213521559

image-20260503215236583

灵神的套路:回溯三问

递归参数中的i不是第i个,而是大于等于i的这部分问题仍然尚待枚举。

image-20260503215448304

代码:

var letterCombinations = function(digits) {
    if (!digits.length) return [];
 
    const map = {
        2: 'abc', 3: 'def', 4: 'ghi', 5: 'jkl',
        6: 'mno', 7: 'pqrs', 8: 'tuv', 9: 'wxyz'
    };
 
    const ans = [];
    const path = []; // 当前路径
    
    function backtrack(index) {
        // 终止条件:所有数字都处理完了
        if (index === digits.length) {
            ans.push(path.join('')); // 把路径数组转成字符串,然后存进结果数组
            return;
        }
    
        // 遍历当前数字对应的所有字母
        for (const ch of map[digits[index]]) { // 字符串的可迭代性
            path.push(ch); // 做选择
            backtrack(index + 1); // 递归处理下一个数字
            path.pop(); // 撤销选择(回溯)
        }
    }
    
    backtrack(0);
    return ans;
};

总结:

回溯类的题目基本就是dfs加上剪枝,也总是涉及push和pop的操作,因为需要记录经过的所有路径,如果只是前序遍历那样只需要知道当前到达的节点,是不需要对路径push和pop的,这两个操作已经被递归的调用隐式地完成了。

子集 题目描述:

给你一个整数数组 nums ,数组中的元素 互不相同 。返回该数组所有可能的子集(幂集)。

解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。

示例 1:

1
2
输入:nums = [1,2,3]
输出:[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]

示例 2:

1
2
输入:nums = [0]
输出:[[],[0]]

提示:

  • 1 <= nums.length <= 10
  • -10 <= nums[i] <= 10
  • nums 中的所有元素 互不相同

思路:

这道题最重要的就是将原问题往树的构造上思考。

第一种方法是从构造答案的思路来思考,可以称之为对于某个元素的选或不选。这样构造的树是二叉树,只有叶子结点构成答案。

image-20260504021948285

第二种方法是直接从最终的所有答案的角度出发,枚举第几个数应该选什么数,这样构造的树就是一个n叉树,每个节点都是答案。为避免子集重复,可以规定枚举的数的索引必须递增。

image-20260504023457751

image-20260504023717208

代码:

选或不选二叉树:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
var subsets = function(nums) {
    const n = nums.length;
    const ans = []
    const path = []

    // 选或不选:讨论 nums[i] 是否加入 path
    function dfs(i) {
        if (i === n) { // 子集构造完毕
            ans.push(path.slice()); // 复制 path
            return;
        }
        
        // 不选 nums[i],直接 + 1
        dfs(i + 1); // 考虑下一个数 nums[i+1] 选或不选
        
        // 选 nums[i]
        path.push(nums[i]);
        dfs(i + 1); // 考虑下一个数 nums[i+1] 选或不选
        path.pop(); // 恢复现场,撤销 path.push(nums[i])
    }

    dfs(0);
    return ans;
};

枚举n叉树:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var subsets = function(nums) {
    const n = nums.length;
    const ans = []
    const path = []

    // 枚举选哪个:在下标 i 到 n-1 中选一个数,加到 path 末尾
    function dfs(i) {
        ans.push(path.slice()); // 把当前子集加入答案
        for (let j = i; j < n; j++) { // 枚举能够被选择的数字
            path.push(nums[j]); // 选入当前数字
            dfs(j + 1); // 选 nums[j] 意味着 i 到 j-1 都跳过不选,下一个数从 j+1 开始选
            path.pop(); // 恢复现场,弹出当前数字
        }
    }

    dfs(0);
    return ans;
};

总结:

对于后续 DP 学习来说,用的最多的是选或不选,枚举选哪个相对少一些。

分割回文串 题目描述:

给你一个字符串 s,请你将 s 分割成一些 子串,使每个子串都是 回文串 。返回 s 所有可能的分割方案。

示例 1:

1
2
输入:s = "aab"
输出:[["a","a","b"],["aa","b"]]

示例 2:

1
2
输入:s = "a"
输出:[["a"]]

提示:

  • 1 <= s.length <= 16
  • s 仅由小写英文字母组成

思路:

枚举每个字符之间的逗号,对于每个逗号的是否选择插入,就构成了子集问题。

image-20260504235922778

image-20260504235959355

image-20260505000008079

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
var isPalindrome = function(s, left, right) {
    while (left < right) {
        if (s.charAt(left++) !== s.charAt(right--)) {
            return false;
        }
    }
    return true;
}

var partition = function(s) {
    const n = s.length;
    const ans = [];
    const path = [];

    // 现在 s 未被分割的部分为 [i, n-1]
    // 枚举下一刀切在哪
    function dfs(i) {
        if (i === n) { // 目前的path已经记录了一组回文切割,对应一个子集,分割完毕
            ans.push(path.slice()); // 复制 path
            return;
        }
        for (let j = i; j < n; j++) { // 枚举子串的结束位置
            if (isPalindrome(s, i, j)) { // 判断 [i, j] 是不是回文串
                path.push(s.substring(i, j + 1)); // 分割!
                // 现在 s 未被分割的部分为 [j+1, n-1]
                dfs(j + 1);
                path.pop(); // 恢复现场
            }
        }
    }

    dfs(0);
    return ans;
};

总结:

子集题里每个选择都合法,这题多了一个条件:只有 s[i…j] 是回文才能在这里切,也就是添加逗号。所以 for 循环里加了 if (isPalindrome(s, i, j)) 过滤。换句话说,每一个逗号插入方式实际上对应了一个子集问题的一个子集,但是这题要筛去一些子集。

做这题不必要纠结回文串到底要怎么切怎么构成,我们只需要关注一点,逗号到底能不能加,然后通过isPalindrome剪枝。

本文由作者按照 CC BY 4.0 进行授权

重启DAY9 二叉树的层序遍历

阅读DAY13 JavaScript高级程序设计 10章上 函数