(转)回溯算法入门级详解 + 练习

(查看原文)

回溯算法与深度优先遍历

以下是维基百科中 回溯算法深度优先遍历 的定义。

回溯法

回溯法采用试错的思想,它尝试分步的去解决一个问题。 在分步解决问题的过程中,当它通过尝试发现现有的分布答案不能得到有效的正确的解答的时候,它将取消上一步甚至上几步的计算,再通过其他的可能的分步解答再次尝试寻找问题的答案。回溯法通过最常用最简单的递归来实现,在重复上述的步骤后可能出现两种情况

  • 找到一个可能存在的正确的答案
  • 在尝试了所有可能的分步方法后宣告该问题没有答案

深度优先搜索

深度优先搜索算法 (Depth-First-Search, DFS) 是一种用于遍历或搜索树或图的算法。这个算法会尽可能深地搜索树的分支。当节点 v 的所在边都已被探寻过,搜索将回溯到发现节点 v 的那条边的起始节点。这一过程一致进行到一发现从源节点可达的所有节点为止。如果还存在未被发现的节点。则选择其中一个作为源节点并重复以上过程,整个过程反复进行指导所有节点都被访问为止。

个人理解

「回溯算法」与「深度优先遍历」都有「不撞南墙不回头」的意思。我个人的理解是:「回溯算法」强调了「深度优先遍历」思想的用途,用一个 不断变化 的变量,在尝试各种可能的过程中,搜索需要的结果。强调了 回退 操作对于搜索的合理性。而「深度优先遍历」强调一种遍历的思想,与之对应的遍历思想是「广度优先遍历」。至于广度优先遍历为什么没有成为强大的搜索算法,我们在题解后面会提。

在「力扣」第 51 题的题解第46题:回溯+剪枝中,展示了如何使用回溯算法搜索 4 皇后问题的一个解,相信对大家直观地理解「回溯算法」是有帮助。

搜索与遍历

我们每天使用的搜索引擎帮助我们在庞大的互联网上搜索信息。搜索引擎的「搜索」和「回溯搜索」算法里「搜索」的意思是一样的。

搜索问题的解,可以通过 遍历 实现。所以很多教程把「回溯算法」称为爆搜(暴力解法)。因此回溯算法用于 搜索一个问题的所有的解 ,通过深度优先遍历的思想实现。

与动态规划的区别

共同点

用于求解多阶段决策问题。多阶段决策问题即:

求解一个问题分为很多步骤(阶段); 每一个步骤(阶段)可以有多种选择。

不同点

动态规划只需要求我们评估最优解是多少,最优解对应的具体解是什么并不要求。因此很适合应用于评估一个方案的效果; 回溯算法可以搜索得到所有的方案(当然包括最优解),但是本质上它是一种遍历算法,时间复杂度很高。

从全排列问题开始理解回溯算法

我们尝试在纸上写 3 个数字、4 个数字、5 个数字的全排列,相信不难找到这样的方法。以数组 [1, 2, 3] 的全排列为例。

先写以 1 开头的全排列,它们是:[1, 2, 3], [1, 3, 2],即 1 + [2, 3] 的全排列(注意:递归结构体现在这里); 再写以 2 开头的全排列,它们是:[2, 1, 3], [2, 3, 1],即 2 + [1, 3] 的全排列; 最后写以 3 开头的全排列,它们是:[3, 1, 2], [3, 2, 1],即 3 + [1, 2] 的全排列。 总结搜索的方法:按顺序枚举每一位可能出现的情况,已经选择的数字在 当前 要选择的数字中不能出现。按照这种策略搜索就能够做到 不重不漏。这样的思路,可以用一个树形结构表示。

看到这里的朋友,建议先尝试自己画出「全排列」问题的树形结构。

屏幕快照 2020-10-30 下午5.20.39

说明:

每一个结点表示了求解全排列问题的不同的阶段,这些阶段通过变量的「不同的值」体现,这些变量的不同的值,称之为「状态」; 使用深度优先遍历有「回头」的过程,在「回头」以后, 状态变量需要设置成为和先前一样 ,因此在回到上一层结点的过程中,需要撤销上一次的选择,这个操作称之为「状态重置」; 深度优先遍历,借助系统栈空间,保存所需要的状态变量,在编码中只需要注意遍历到相应的结点的时候,状态变量的值是正确的,具体的做法是:往下走一层的时候,path 变量在尾部追加,而往回走的时候,需要撤销上一次的选择,也是在尾部操作,因此 path 变量是一个栈; 深度优先遍历通过「回溯」操作,实现了全局使用一份状态变量的效果。 使用编程的方法得到全排列,就是在这样的一个树形结构中完成 遍历,从树的根结点到叶子结点形成的路径就是其中一个全排列。

设计状态变量

首先这棵树除了根结点和叶子结点以外,每一个结点做的事情其实是一样的,即:在已经选择了一些数的前提下,在剩下的还没有选择的数中,依次选择一个数,这显然是一个 递归 结构; 递归的终止条件是: 一个排列中的数字已经选够了 ,因此我们需要一个变量来表示当前程序递归到第几层,我们把这个变量叫做 depth,或者命名为 index ,表示当前要确定的是某个全排列中下标为 index 的那个数是多少; 布尔数组 used,初始化的时候都为 false 表示这些数还没有被选择,当我们选定一个数的时候,就将这个数组的相应位置设置为 true ,这样在考虑下一个位置的时候,就能够以 O(1) 的时间复杂度判断这个数是否被选择过,这是一种「以空间换时间」的思想。 这些变量称为「状态变量」,它们表示了在求解一个问题的时候所处的阶段。需要根据问题的场景设计合适的状态变量。

题目

46. 全排列

给定一个 没有重复 数字的序列,返回其所有可能的全排列。

示例:

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

来源:力扣(LeetCode) 链接:https://leetcode-cn.com/problems/permutations 著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。

题目分析:
  • 很明显是回溯的相关题目
  • 回溯探索每一条路径
  • 相对来说比较简单,因为没有复杂一些需要分析的剪枝
代码如下:
 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
35
36
37
38
39
40
41
42
43
44
45
46
// 主函数,输入一组不重复的数字,返回它们的全排列
    List<List<Integer>> permute(int[] nums){
        List<List<Integer>>res = new ArrayList<>();
        int n = nums.length;
        if (n == 0) return res;
        // 路径栈
        Deque<Integer> path = new ArrayDeque<Integer>();
        // 是否使用过的数组
        boolean[] used = new boolean[nums.length];
        // 回溯
        dfs(nums, n, 0, path, used, res);
        return res;
    }

    /**
     * 回溯
     * @param nums    输入全排列的数组
     * @param length  需要全排列的数组的长度
     * @param depth   遍历的深度
     * @param path    记录遍历的路径
     * @param used    是否访问过
     * @param res     存储结果的数组
     */
    public void dfs(int[] nums, int length, int depth, Deque<Integer>path, boolean[] used, List<List<Integer>>res){
        if (depth == length){
            // 如果 depth == length 说明已经遍历到最后一层。 将path添加到结果中
            res.add(new ArrayList<>(path));
            return;
        }

        // 回溯选择
        for (int i = 0; i < nums.length; i++) {
            // 如果选择过,则进度下层循环
            if (used[i] == true) continue;

            // 访问路径添加当前数组
            path.addLast(nums[i]);
            // 设置为访问过
            used[i] = true;
            // 进入下层选择
            dfs(nums, length, depth + 1, path, used, res);
            // 状态充值
            used[i] = false;
            path.removeLast();
        }
    }

47. 全排列 II

给定一个可包含重复数字的序列,返回所有不重复的全排列。

示例:

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

来源:力扣(LeetCode) 链接:https://leetcode-cn.com/problems/permutations-ii 著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。

题目分析:
  • 跟 46 题类似,区别就在于给定数组包含重复数字

  • 同样使用回溯法,但是需要去除重复(剪枝)

  • 难点在于:剪枝条件的判断

  • 代码与 46题基本一样的,加了一个剪枝条件

  • 剪枝详解 : 参考weiwei大神

    1
    
    if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) continue;