一文带你了解回溯算法的套路

372 阅读4分钟

回溯可以说是非常常见的一种算法了,诸如子集、组合和排列等问题都可以用回溯算法来解决。回溯算法的本质是递归,而涉及到递归的解法,中间的运算过程总会使笔者难以理解,这个时候一套解题方法论就显得非常重要了。本文将剖析子集问题的解法,将笔者在网上找到的优秀学习资源(详见文末参考链接)与大家分享。

回溯算法解题思想

参考 labuladong 大佬的回溯算法详解,回溯算法的解题框架如下:

result = []
def backtrack(路径, 选择列表):
    if 满足结束条件:
        result.add(路径)
        return

    for 选择 in 选择列表:
        做选择
        backtrack(路径, 选择列表)
        撤销选择

这个解题框架下我们只需要关注三个概念:

  1. 路径:已经做出的选择
  2. 选择列表:当前可以做的选择
  3. 结束条件:当满足结束条件时,将路径存储到结果中

框架只是提供了一个大概的解法思想,在实际应用中我们往往需要具体问题具体分析,对框架进行微调,比如调整 backtrack 函数的参数定义,再比如考虑剪枝等等。

那么我们要如何确定这个 backtrack 函数的定义呢?

C++ 总结了回溯问题类型 带你搞懂回溯算法(大量例题) 一文中总结了以下几个步骤,笔者认为非常有效:

①画出递归树,找到状态变量(回溯函数的参数),这一步非常重要※
②根据题意,确立结束条件
③找准选择列表(与函数参数相关),与第一步紧密关联※
④判断是否需要剪枝
⑤作出选择,递归调用,进入下一层
⑥撤销选择

子集

题目描述(来自leetcode

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

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

示例 1:

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

示例 2:

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

解题步骤

递归树

image.png

第一个步骤永远都是先把递归树画出来,方便我们更直观地进行分析。

树中的节点代表选择列表,边代表路径。可以看到,对于某个路径,选择列表永远只有数组中索引值位于它后面的数,比如 [1],选择列表有 [2,3],而对于 [2],选择列表只有 [3]。因此我们应该采用一个变量定义当前选择路径值的索引值,则 backtrack 函数定义如下:

const backtrack = (path: number[], nums: number[], start: number) => {
    // TODO
}

确定结束条件

由图中可以看到每条路径都应该加入到结果集中,因此这里没有结束条件。

const res = []
const backtrack = (path: number[], nums: number[], start: number) => {
    res.push([...path])
}

确定选择列表

如「递归树」步骤中分析,对于某个路径,选择列表永远只有数组中索引值位于它后面的数。

const res = []
const backtrack = (path: number[], nums: number[], start: number) => {
    res.push([...path])
    for (let i = start; i < nums.length; i++) {
    }
}

是否需要剪枝

所有路径都应该加入结果集,因此不需要剪枝。

做出选择

其实就是循环里面的逻辑:将选择列表中的数值加入路径,进入下一层。

const res = []
const backtrack = (path: number[], nums: number[], start: number) => {
    res.push([...path])
    for (let i = start; i < nums.length; i++) {
        path.push(nums[i])
        backtrack(path, nums, i + 1)
    }
}

撤销选择

将路径中入栈的值出栈,状态重置,这个很好理解。

完整代码

function subsets(nums: number[]): number[][] {
    const res = []
    // 路径,选择列表
    const backtrack = (path: number[], nums: number[], start: number) => {
        // 满足条件的话将路径放进结果
        res.push([...path])
        for (let i = start; i < nums.length; i++) {
            path.push(nums[i])
            backtrack(path, nums, i + 1)
            path.splice(path.length - 1, 1)
        }
    }
    backtrack([], nums, 0)
    return res
};

子集2

题目描述(来自leetcode

给你一个整数数组 nums ,其中可能包含重复元素,请你返回该数组所有可能的子集(幂集)。

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

示例 1:

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

示例 2:

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

解题步骤

递归树

image.png

第一步当然还是画个图。可以看到图中出现了一些重复的路径,这意味着我们需要进行剪枝。

结束条件和选择列表和「子集」类似,这里不再赘述。

是否需要剪枝

递归树中看到出现了很多重复的路径,因此可以肯定需要进行剪枝,那么要如何进行剪枝呢?

image.png

将需要剪枝的路径使用蓝色方框标识出来,可以看到 需要剪枝的是选择列表中当前数值与前一个数值相同的部分。而要判断相邻数值是否相同,我们首先要进行排序

完整代码

function subsetsWithDup(nums: number[]): number[][] {
    const res = []
    const backtrack = (path: number[], nums: number[], start: number) => {
        res.push([...path])
        for (let i = start; i < nums.length; i++) {
            if (i > start && nums[i] === nums[i - 1]) continue // 剪枝
            // 做选择
            path.push(nums[i])
            backtrack(path, nums, i + 1)
            path.splice(path.length - 1, 1)
        }
    }
    // 对数组进行排序
    nums.sort()
    backtrack([], nums, 0)
    return res
};

总结

回溯的本质是递归,递归的解法实际上就是将一个大问题划分为规模更小的子问题,笔者认为分析递归最重要的画出递归树,再结合一些解题方法论往往会事半功倍。

本文以「子集」问题为例尝试应用回溯算法的解题方法论,后续也会继续应用到组合和排列等场景中。算法没有捷径,多做多看多总结,共勉~

参考链接