深入理解回溯算法

大家好,我是 方圆,本篇我们来讲回溯。回溯相当于穷举搜索,它会尝试各种可能的情况直到找到一个满足约束条件的解,寻找解的手段一般通过 DFS 实现,是一个 增量构造答案 的过程。回溯法适用于解决能够将原问题拆分成子问题的题目,以构造长为 n 的字符串为例进行讲解:

在构造长为 n 的字符串时,从可选择的字符中选取一个字符,这样就构造出了长为 1 的字符串,那么接下来便需要构造长 n - 1 的字符串,再选取一个字符,便构造除了长为 2 的字符串,那么接下来需要构造长为 n - 2 的字符串,以此类推,过程如下图所示:
在这里插入图片描述
这样不断地解决子问题,直到满足条件,得到问题的解的过程,便是对回溯法的应用。在这个过程中,我们需要考虑如下三个要点:

  1. 当前问题:即例子中的构造长为 n 的字符串
  2. 每一步的操作 :即例子中在每一步中的“枚举字母”
  3. 子问题:即例子中的构造长为 n - 1 的字符串

根据这三个要点,将其写成 Java 的回溯代码,如下:

    // 定义全局变量记录结果值
    List<String> res;

    int n;

    /**
     * 回溯法构造长为 n 的字符串
     *
     * @param selected 选择列表:路径元素的取值范围
     * @param path     走过的路径
     */
    private void backtrack(char[] selected, StringBuilder path) {
        // 结束条件(构造长为 n 的字符串)
        if (n == path.length()) {
            res.add(path.toString());
            return;
        }

        // 每一步的操作:在选择列表中,枚举字母,构造字符串
        for (char c : selected) {
            path.append(c);
            // 子问题:构造长为 n - 1 的字符串
            backtrack(selected, path);
            // 恢复现场
            path.deleteCharAt(path.length() - 1);
        }
    }

void backtrack(char[] selected, StringBuilder path) 方法中,定义 char[] selected“选择列表”,表示的是构造“路径”时的 决策范围,路径中的元素需要从该对象中选取;定义 StringBuilder path“路径” ,表示递归过程中已经做过的选择;每次递归完成时,通常都需要 “恢复现场” 的操作,即将走过的路径恢复到递归之前;定义边界条件,当路径满足该条件时,即可记录该路径为答案(之一)。

在解决回溯问题时,需要先考虑当前问题、每一步的操作和子问题,再根据这三个要点定义回溯方法。下面我们通过对子集型回溯、组合型回溯和排列型回溯对回溯问题进行学习和总结。

子集型回溯

子集型回溯的问题,对于 每个元素都可以选或不选,我们以 78. 子集 中等 为例,看看它该如何求解:

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

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

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

示例 2:
输入:nums = [0]
输出:[[],[0]]

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

根据题意,可知构造子集时每个元素都可以选或不选,接下来便要考虑解题时的三个要点:

  1. 当前问题:从下标大于等于 i 的子数组中构造子集
  2. 每一步的操作 :将下标大于等于 i 的元素加入到路径中
  3. 子问题:题目要求不能有重复的子集,所以子问题要从下标大于等于 i + 1 的子数组中构造子集

题解如下,注意关注其中的注释信息:

public class Solution78 {

    // 定义全局变量
    List<List<Integer>> res;

    public List<List<Integer>> subsets(int[] nums) {
        res = new LinkedList<>();
        backtrack(nums, 0, new LinkedList<>());
        return res;
    }

    /**
     * 回溯
     *
     * @param nums 选择列表
     * @param begin 构造子集开始的子数组索引
     * @param path 路径
     */
    private void backtrack(int[] nums, int begin, LinkedList<Integer> path) {
        // 走过的所有路径均为答案之一
        res.add((List<Integer>) path.clone());
        for (int i = begin; i < nums.length; i++) {
            // 每一步的操作:将下边大于等于 begin 的元素加入到路径中
            path.add(nums[i]);
            // 子问题:
            backtrack(nums, i + 1, path);
            // 恢复现场:即将递归前加入路径的元素移除
            path.removeLast();
        }
    }
}

接下来我们再看一道 131. 分割回文串 中等

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

示例 1:
输入:s = “aab”
输出:[[“a”,“a”,“b”],[“aa”,“b”]]

示例 2:
输入:s = “a”
输出:[[“a”]]

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

根据题意,字符串所有子串必须都是回文串,而对于字符串中的每个字符元素,我们需要根据它是否能构成子串来判断它的选或不选,所以依然是子集型回溯。接下来,需要考虑下三个要点:

  1. 当前问题:题目要求分割方案中的所有子串都为回文串,那么当前问题便是从大于等于下标 i 的子数组中判断并构造回文串集合
  2. 每一步的操作:判断是否为回文串,为回文串的话加入到路径中
  3. 子问题:从大于等于下标 i + 1 的子数组中判断并构造回文串集合
public class Solution131 {

    // 定义全局变量
    List<List<String>> res;

    public List<List<String>> partition(String s) {
        res = new LinkedList<>();
        backtrack(s, 0, new LinkedList<>());
        return res;
    }

    private void backtrack(String s, int begin, LinkedList<String> path) {
        // 所有子串均为回文串,添加答案并结束
        if (begin == s.length()) {
            res.add((List<String>) path.clone());
            return;
        }

        for (int i = begin; i < s.length(); i++) {
            String cur = s.substring(begin, i + 1);
            // 每一步的操作:判断是否为回文串,是的话加入路径中,并继续处理子问题
            if (isReverse(cur)) {
                path.add(cur);
                // 子问题:从大于小于等于下标 i + 1 的子数组中判断并构造回文串集合
                backtrack(s, i + 1, path);
                // 恢复现场
                path.removeLast();
            }
        }
    }

    private boolean isReverse(String s) {
        int left = 0, right = s.length() - 1;
        while (left < right) {
            if (s.charAt(left) != s.charAt(right)) {
                return false;
            }
            left++;
            right--;
        }
        return true;
    }
}
相关练习

组合型回溯

组合型回溯与子集型回溯相似,它同样也会涉及元素的选与不选,不同的是组合型回溯需要 增加判断条件 来满足题意,达到 剪枝优化 的目的,以 77. 组合 中等 为例,看一下该如何解决:

给定两个整数 n 和 k,返回范围 [1, n] 中所有可能的 k 个数的组合。

你可以按 任何顺序 返回答案。

示例 1:
输入:n = 4, k = 2
输出:
[
[2,4],
[3,4],
[2,3],
[1,2],
[1,3],
[1,4],
]

示例 2:
输入:n = 1, k = 1
输出:[[1]]

提示:
1 <= n <= 20
1 <= k <= n

它限定了路径长度为 n,而在子集型回溯中是不会包含这个限制的,除了要考虑解决回溯问题的三个要点外,组合型回溯还需要考虑 剪枝优化 条件:

  1. 当前问题:从小于等于 n 的范围内,在路径中添加第 i 个元素
  2. 每一步的操作:加入当前元素或不加入当前元素
  3. 子问题:从小于等于 n - 1 的范围内,在路径中添加第 i + 1 个元素
  4. 剪枝优化:路径中的元素为 k 个时;路径中的元素加上可选择列表中的剩余的元素不足 k 个时;n 取值小于等于 0 时

题解如下:

public class Solution77 {

    List<List<Integer>> res;
    int k;

    public List<List<Integer>> combine(int n, int k) {
        res = new LinkedList<>();
        this.k = k;
        backtrack(n, new LinkedList<>());
        return res;
    }

    // 1. 当前问题:从小于等于 n 的范围内,在路径中添加第 i 个元素
    // 2. 每一步的操作:加入当前元素或不加入当前元素
    // 3. 子问题:从小于等于 n - 1 的范围内,在路径中添加第 i + 1 个元素
    // 4. 剪枝优化:路径中的元素为 k 个时;路径中的元素加上可选择列表中的剩余的元素不足 k 个时;n 取值小于等于 0 时
    private void backtrack(int n, LinkedList<Integer> path) {
        // 剪枝优化
        if (path.size() == k) {
            res.add((List<Integer>) path.clone());
            return;
        }
        if (n + path.size() < k) {
            return;
        }
        if (n <= 0) {
            return;
        }

        // 加
        path.add(n);
        backtrack(n - 1,  path);
        // 加入过元素后需要恢复现场
        path.removeLast();
        // 不加
        backtrack(n - 1, path);
    }
}

接下来,再看一题 216. 组合总和 III 中等,同样地,它限制了组合长度为 k:

找出所有相加之和为 n 的 k 个数的组合,且满足下列条件:

只使用数字1到9
每个数字 最多使用 一次
返回 所有可能的有效组合的列表 。该列表不能包含相同的组合两次,组合可以以任何顺序返回。

示例 1:
输入: k = 3, n = 7
输出: [[1,2,4]]
解释:
1 + 2 + 4 = 7
没有其他符合的组合了。

示例 2:
输入: k = 3, n = 9
输出: [[1,2,6], [1,3,5], [2,3,4]]
解释:
1 + 2 + 6 = 9
1 + 3 + 5 = 9
2 + 3 + 4 = 9
没有其他符合的组合了。

示例 3:
输入: k = 4, n = 1
输出: []
解释: 不存在有效的组合。
在[1,9]范围内使用4个不同的数字,我们可以得到的最小和是1+2+3+4 = 10,因为10 > 1,没有有效的组合。

提示:
2 <= k <= 9
1 <= n <= 60

考虑组合型回溯的四个要点:

  1. 当前问题:在小于等于 n 的条件下,在路径中添加第 i 个元素
  2. 每一步的操作:添加当前值或不添加当前值
  3. 子问题:在小于等于 n - 1 的条件下,在路径中添加第 i + 1 个元素
  4. 剪枝优化:元素大于等于 k 个;元素和大于等于 n;num 取值小于等于 0
class Solution216 { 

    List<List<Integer>> res;

    int n;

    int k;

    public List<List<Integer>> combinationSum3(int k, int n) {
        this.res = new LinkedList<>();
        this.k = k;
        this.n = n;
        backtrack(9, new LinkedList<>(), 0);
        return res;
    }

    // 1. 当前问题:在小于等于 n 的条件下,在路径中添加第 i 个元素
    // 2. 每一步的操作:添加当前值或不添加当前值
    // 3. 子问题:在小于等于 n - 1 的条件下,在路径中添加第 i + 1 个元素
    // 4. 剪枝优化:元素大于等于 k 个;元素和大于等于 n;num 取值小于等于 0
    private void backtrack(int num, LinkedList<Integer> path, int sum) {
        if (path.size() == k && sum == n) {
            res.add((List<Integer>) path.clone());
            return;
        }
        if (path.size() >= k) {
            return;
        }
        if (sum > n) {
            return;
        }
        if (num <= 0) {
            return;
        }

        // 添加
        path.add(num);
        backtrack(num - 1, path, sum + num);
        path.removeLast();
        // 不添加
        backtrack(num - 1, path, sum);
    }
}
相关练习

排列型回溯

排列型相比于组合型,对于不同元素的排列顺序是有区别的,比如在排列型回溯中,[1, 2][2, 1] 是两种 不同的 排列,而在组合中,它们是相同的组合。求解排列型回溯问题时,一般会使用 boolean visited[] 数组来标记对应下标处的元素有没有被选择过,以此来判断哪些元素是能选的,哪些元素是不能选的。下面我们以 46. 全排列 中等 为例,看看该如何求解:

给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。

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

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

示例 3:
输入:nums = [1]
输出:[[1]]

提示:
1 <= nums.length <= 6
-10 <= nums[i] <= 10
nums 中的所有整数 互不相同

首先我们需要考虑下解决回溯问题的三个要点:

  1. 当前问题:向路径中添加第 i 个元素
  2. 每一步的操作:在路径中添加未被选择过的元素
  3. 子问题:向路径中添加第 i + 1 个元素
public class Solution46 {

    List<List<Integer>> res;

    public List<List<Integer>> permute(int[] nums) {
        res = new LinkedList<>();
        backtrack(nums, new boolean[nums.length], new LinkedList<>());
        return res;
    }

    // 1. 当前问题:向路径中添加第 i 个元素
    // 2. 每一步的操作:在路径中添加未被选择过的元素
    // 3. 子问题:向路径中添加第 i + 1 个元素
    private void backtrack(int[] nums, boolean[] visited, LinkedList<Integer> path) {
        // 结束条件
        if (path.size() == nums.length) {
            res.add((List<Integer>) path.clone());
            return;
        }

        for (int i = 0; i < nums.length; i++) {
            if (visited[i]) {
                continue;
            }
            path.add(nums[i]);
            visited[i] = true;
            backtrack(nums, visited, path);
            // 恢复现场
            path.removeLast();
            visited[i] = false;
        }
    }
}
相关练习

回溯算法使用的注意点

注意使用回溯法时需要关注题目中是否要求返回所有路径(组合),如果不需要的话,可以考虑使用动态规划或其他方法,如题目 377. 组合总和 Ⅳ 中等,该题并不要求返回所有组合,而是组合数目。


巨人的肩膀

  • 22
    点赞
  • 30
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

方圆想当图灵

嘿嘿,小赏就行,不赏俺也不争你

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值