深入理解回溯算法

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

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

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

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

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 的子数组中构造子集

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

java 复制代码
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 的子数组中判断并构造回文串集合
java 复制代码
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 时

题解如下:

java 复制代码
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
java 复制代码
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 个元素
java 复制代码
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. 组合总和 Ⅳ 中等,该题并不要求返回所有组合,而是组合数目。


巨人的肩膀

相关推荐
Victor35610 分钟前
Hibernate(13) Hibernate的一级缓存是什么?
后端
毕设源码-赖学姐14 分钟前
【开题答辩全过程】以 基于SpringBoot的健身房管理系统的设计与实现为例,包含答辩的问题和答案
java·spring boot·后端
Victor35616 分钟前
Hibernate(14)什么是Hibernate的二级缓存?
后端
czlczl2002092518 分钟前
SpringBoot自动配置AutoConfiguration原理与实践
开发语言·spring boot·后端
仙俊红18 分钟前
LeetCode322零钱兑换
算法
颖风船21 分钟前
锂电池SOC估计的一种算法(改进无迹卡尔曼滤波)
python·算法·信号处理
551只玄猫35 分钟前
KNN算法基础 机器学习基础1 python人工智能
人工智能·python·算法·机器学习·机器学习算法·knn·knn算法
charliejohn35 分钟前
计算机考研 408 数据结构 哈夫曼
数据结构·考研·算法
POLITE343 分钟前
Leetcode 41.缺失的第一个正数 JavaScript (Day 7)
javascript·算法·leetcode