「回溯算法」问题选讲-1(从「全排列」认识「回溯算法」)(自己的草稿,内容不严谨,与之前有重复,不用看)

从这一章节开始,我们将向大家介绍在基础算法领域非常重要的三个算法「回溯算法」「动态规划」和「贪心算法」,首先先给大家打一个预防针,这几个算法核心的思想都不难。

学习的方法依然是:我们在学习了相关的基础知识以后,一定要不断地通过练习,来体会对算法基本思想的理解,和熟练掌握如何将算法思想进行编码实现。

我们先介绍「回溯算法」,「回溯算法」的全称是「回溯搜索」算法。所以我们先来看一下「搜索」这个词是什么意思。

和「搜索」相近的一个词是「查找」,这里的「查找」同「二分查找」、「线性查找」,要找什么我们是很清楚的。

但是在「搜索」问题里,对要查找的事物的描述往往没有那么具体,就想我们使用的「搜索引擎」里的「搜索」,给出的是一个条件。

从结果上看,搜索出来的结果是一个列表,这和「搜索引擎」的搜索结果是一样的。因此,搜索问题,常常用于这样的场景:

一个问题解决的方案有若干个,并且得到每一个解决的方案的步骤也有若干个。

下面我们就来看一个最简单的使用「回溯算法」解决的问题。

这道题是「力扣」上第 46 号问题:全排列问题。

这道题给我们一个没有重复数字的序列,要求我们返回所有可能的全排列。我们知道排列是讲究顺序的,不同的顺序就产生了不同的排列。

我们来看示例:输入数组是 [1, 2, 3],输出是一个列表。列表中的每一个元素就是一个排列,这些排列是没有重复、没有遗漏的。

我们可以先尝试手写得到 [1, 2, 3] 的全排列。在手写出几个排列以后,相信大家就不难找到规律,我们先写出以 1 开头的排列,再写出以 2 开头的排列,最后写出以 3 开头的排列。

为了做到不重不漏,我们的思路是:一个位置、一个位置地去考虑每个位置可以填写的数字。并且之前已经出现的数字 在接下来 要选择的数字列表里就不能再次出现。而这样的思路我们可以把它画成一棵树的样子。

看到这里的朋友不妨先暂停一下先尝试画出全排列问题的树形结构:依然是以输入数组是 [1, 2, 3] 为例。

一开始,排列为空列表。第 1 个位置有 3 种可能:分别是 1 2 3。

我们画出 3 个分支,由于第 1 个位置已经使用了一个数字,那么第 2 个位置可以选择的数字就只有 2 个,因此这一层的每一个结点又都可以展开 2 个分支。

它们是这样的,选出了 2 个数字以后,由于一共就只有 3 个数字供我们选择,因此最后一个位置上的数是唯一确定的,每一个结点又都可以展开 1 个分支。

在这样一个树形结构中,我们画在叶子结点的所有排列就是 [1, 2, 3] 的全排列。

那么如何写代码得到全排列呢,其实我们展示这个动画的过程就可以作为一种编码的方法,它是这棵树的广度优先遍历的过程。感兴趣的朋友不妨尝试写一下这道题使用广度优先遍历的代码

那既然我们说到了遍历,与广度优先遍历一起出现在我们脑海里的名词就是「深度优先遍历」。

接下来,我们就模拟一下深度优先遍历的过程。这个过程其实也非常经典的,依然从一个空列表开始,先选择 1 然后因为 1 已经选择了,按顺序应该选择 2,还没完呢,还有一个数 3,这个时候我们发现没有数字可选了,得到了一个排列。

为了得到全部的排列,深度优先遍历有一个回退的过程最后一步我们选择的是 3,在回退的时候,需要撤销对 3 的选择,回到 [1, 2] 这个结点,由于在这个阶段, 3 已经被我们考虑了,因此继续回退,撤销对 2 的选择,回退到 [1] 这个结点,在这个阶段有 2 个选择:可以选择 2,也可以选择 3。2 已经走过了,接下来选择 3,选择了 3 以后 接下来可以选择的只有 2,于是又得到一个排列 [1, 3, 2]。

为了得到其他排列,接下来我们撤销对 2 的选择,同理,再撤销对 3 的选择,回到了 1 这个结点。

这个结点可以选择的第 2 个位置的数字 2 和 3,我们已经都走过了,也就是我们得到了以 1 开头的所有的排列。为了得到以 2、3 开头的所有的排列,我们依然是像刚刚做过的操作一样,在回退的时候,撤销对 1 的选择,回到空列表这个根结点。

接下来的步骤是类似的,我门就不带着大家一点一点介绍完深度优先遍历是如何遍历完这棵树的

下面我们总结一下深度优先遍历的操作:

我们先介绍一个概念:状态,这棵树的每一个结点表示了求解全排列问题的不同的阶段,这些阶段可以通过一些变量的不同的值来刻画,我们把这些变量的不同的值称之为状态。

例如 [2, 1] 这个结点,表示的就是我们已经确定了一个排列的第一个位置的元素是 2,第二个位置的元素是 1,在这个阶段下,或者是可以继续搜索第 3 个位置的值,也可以是第 3 位置都考虑完了,回到上一层的结点

其次,由于深度优先遍历有回头的过程,在回头以后,状态变量就需要设置成为和之前刚来到这个结点的时候一样。具体的做法就是:在回到上一层结点的时候,撤销对上一次选择,这个操作称之为「状态重置」,正是由于回到了之前的状态,才可以开始下一个选择的尝试。在 [1, 3] 这个结点,由于深度优先遍历从 [1, 2] 这个结点回到结点 [1] 的时候结撤销了对 2 的选择,接下来的代码才会知道可以重新选择 2,得到一个与之前不同的排列。

在这里大家可以简单想象一下,如果状态不重置会发生什么样的现象呢。相信说道这里。大家也已经明白了我们使用深度优先遍历 和 状态重置的思路:我们就只使用一个状态变量去搜索整个状态树(或者说为状态空间)的所有我们所需要的状态。这样的做法是比较节约空间的。

而树形问题上深度优先遍历,就是大名鼎鼎的回溯算法。而状态重置 就是 回溯算法 里 回溯 的意思。

下面我们解释如何编码:

1、首先这棵树除了叶子结点以外,每一个结点做的事情其实是一样的,即在已经选了一些数的前提下,需要在剩下还没有选择的数字列表里,按照顺序选择一个数,这显然是一个递归结构;

那么递归终止条件:数字的个数已经选够了。因此我们需要一个变量来表示当前已经选了几个数字,这个变量等价的含义是在这棵树上当前遍历到了这棵树的第几层:depth

很显然,遍历到的层数和输入的数组的个数相等的时候,所有的元素已经考虑完了,这个时候得到了输入数组的一个排列。这是递归终止条件。

刚刚我们说了,这些结点 表示了深度优先遍历的不同阶段,为了区分这些不同的阶段,我们就需要一些变量来记录为了得到一个排列,程序进行到了哪一步。

在这里我们还需要设计两个变量:

1、第一个是已经选了哪些数,它是一个列表。由于排列是讲究顺序的,已经选择的数我们把它放进一个列表里。这个列表里的数字就是从根结点到某个中间结点或者叶子结点的一个路径,我们把这个列表命名为 path。深度优先遍历的过程就是不断地在列表的末尾添加和删除元素,因此这个列表是一个栈。

注意到,在每一个结点,我们要判断有哪些数字还可以被选择,我们当然可以通过看 path 这个变量知道已经被选择的数,但是每做一次判断,就需要把 path 遍历一次。

2、布尔数组 used,一个经典的做法就是,我们再设置一个布尔数组,表示当前考虑的数字是否在之前已经选择过,也就是当前考虑的数字是否在 path 变量里,这样我们就可以以 O ( 1 ) O(1) O(1) 的时间复杂度完成这个判断。

我把这个布尔数组命名为 used,初始化的时候都为 false,表示这些数还没有被选择,当我们选定一个数的时候,就将这个数组的相应的下标 设置为 true

设置布尔数组,是一种典型的“以空间换时间”的思想。

再和大家强调一下 depthpathused 这三个变量称之为“状态变量”,它们表示了 我们在 求解全排列问题的时候 所处的阶段。

下面是编写递归函数的逻辑:

1、在非叶子结点处,产生不同的分支,这一操作就是在还未选择的数中 依次选择一个元素作为下一个位置的元素,这显然需要通过一个循环实现;

2、在递归函数里通过循环产生分支。

接下来,我们看一下代码怎么写:

Java 代码:

import java.util.ArrayList;
import java.util.List;

public class Solution {

    public List<List<Integer>> permute(int[] nums) {
        // 首先把输入数组的长度赋值为一个变量
        int len = nums.length;
        List<List<Integer>> res = new ArrayList<>();
        // 然后做一个特殊的判断,如果输入数组的长度为 0
        // 需要返回一个空列表,为此,我们先实例化一个空列表
        // 这个空列表是一个动态数组
        if (len == 0) {
            return res;
        }

        List<Integer> path = new ArrayList<>();
        boolean[] used = new boolean[len];
 
        // 接下来编写递归函数,需要的参数有 
        // 1、输入数组
        // 2、输入数组的长度,这个长度虽然可以从输入数组中得到,但是我们不想在整个递归中一直读取,所以设计一个冗余的变量
        // 3、然后是已经选择了几个数字,这个数字等价于递归树的深度,初始化的时候传入 0
        // 4、然后是一个表示从根节点到任意结点的路径的变量
        // 5、我们还需要一个布尔数组
        // 6、最后是保存全排列的结果变量
        
        // 由于路径变量 path 和布尔数组 used 是对象类型,我们需要初始化一下
        // 其实这些参数有些可以设置成为全局的参数,但是这我们为了方便说明,就都作为方法的参数传递下去
        dfs(nums, len, 0, path, used, res);
        return res;
    }

    /**
     * @param nums  候选数字列表
     * @param len   列表长度,可以直接从 nums.length 里获取,因为需要使用的次数很多,设计这个冗余的变量
     * @param depth 已经选了几个数字
     * @param path  已经选择的数字列表
     * @param used  快速判断某个数是否已经被选择
     * @param res   记录结果集的列表
     */
    private void dfs(int[] nums, int len, int depth, List<Integer> path, boolean[] used, List<List<Integer>> res) {
        // 首先先写递归终止的条件,当递归深度等于元素的个数的时候,说明当前所有的元素都考虑完了
        // 这个时候将路径变量添加到结果集中
        if (depth == len) {
            // 这里要注意,这样写其实是有问题的,我们稍后再说
            res.add(new ArrayList<>(path));
            // 递归终止的时候,程序就没有必要执行下去了,我们在这里显式地写上 return 让程序不要在往下面走
            return;
        }

        // 接下来递归的逻辑就是一个 for 循环
        for (int i = 0; i < len; i++) {
            // for 循环的内部,我们只考虑当前没有被选则的数
            // 所以如果这个数已经选择过,我们就跳过
            if (used[i]) {
                continue;
            }

            // 如果没有选择过,我们就应该把它添加到 path 里,同时将 i 这个位置标记为 true
            path.add(nums[i]);
            used[i] = true;
            // 然后就进入下一层递归,下一层递归的参数与这一层递归只有一个不同,那就是递归的深度
            dfs(nums, len, depth + 1, path, used, res);
            
            // 在这一层递归调用结束以后,我们要回退到上一个结点,因此需要做状态重置或者说状态撤销
            // 具体的步骤就是递归之前做了什么,递归之后就是这些操作的逆向操作
            path.remove(depth);
            used[i] = false;
        }
    }
}

我们编写一个测试方法,就以 [1,2,3] 为测试用例

这段代码在运行以后输出如下:

[[], [], [], [], [], []]

得到了 6 个空列表的原因出现在递归终止条件这里:

Java 代码:

if (depth == len) {

    res.add(path);

    return;

}

解释:path 这个变量所指向的对象在递归的过程中只有一份。在深度优先遍历完成以后,由于最后回到了根结点, path 这个变量为空列表。

依然是去想象深度优先遍历的过程,从而理解为什么会到深搜会到原点以后为空列表,因为一开始就是空列表,深搜的过程转了一圈,在不断的选择和回溯的过程以后,回到原点,依然是空列表。

在 Java 语言中,方法传递都是值传递。对象类型的变量在传参的过程中,复制的都是变量的地址。这些地址被添加到 res 变量,但这些地址实际上指向的是同一块内存的地址,因此我们会看到 6 个空的列表对象。解决这个问题的方法很简单,在 res.add(path); 这里做一次拷贝即可。

这就是这道题的代码。

下面我们对这一版代码做几点说明:

首先是可不可以不“回溯”呢,我们在刚刚测试的过程中也发现,new ArrayList<>(path) 这段代码显得不是那么自然,很容易被我们忽略。下面我们分析一下其中的原因,其实我们刚刚也说了,是由于 path 这个变量所指向的对象在递归的过程中只有一份。一个大胆的想法是:

1、如果在每一个非叶子结点分支的尝试,都创建新的变量表示状态,那么

  • 在回到上一层结点的时候不需要“回溯”(也就是不需要“状态重置”);

  • 在递归终止的时候也不需要做拷贝。

大家可以自行编码来验证这种说法,但是大家真正这么做了就会发现,这样做是不划算的:

Java 代码:

import java.util.ArrayList;
import java.util.List;

public class Solution {

    public List<List<Integer>> permute(int[] nums) {
        int len = nums.length;
        List<List<Integer>> res = new ArrayList<>();
        if (len == 0) {
            return res;
        }

        List<Integer> path = new ArrayList<>();
        boolean[] used = new boolean[len];
        dfs(nums, len, 0, path, used, res);
        return res;
    }

    /**
     * @param nums  候选数字列表
     * @param len   列表长度,可以直接从 nums.length 里获取,因为需要使用的次数很多,设计这个冗余的变量
     * @param depth 已经选了几个数字
     * @param path  已经选择的数字列表
     * @param used  快速判断某个数是否已经被选择
     * @param res   记录结果集的列表
     */
    private void dfs(int[] nums, int len, int depth, List<Integer> path, boolean[] used, List<List<Integer>> res) {
        if (depth == len) {
            // 无需拷贝
            // res.add(new ArrayList<>(path));
            res.add(path);
            return;
        }

        for (int i = 0; i < len; i++) {
            if (used[i]) {
                continue;
            }

            // 在每一个结点创建新变量有一定性能消耗
            List<Integer> newPath = new ArrayList<>(path);
            newPath.add(nums[i]);

            boolean[] newUsed = new boolean[len];
            System.arraycopy(used,0,newUsed,0,len);
            newUsed[i] = true;

            dfs(nums, len, depth + 1, newPath, newUsed, res);
            // 无回溯过程
        }
    }
}

这样的做法虽然可以得到解,但也会创建很多中间变量,这些中间变量很多时候是我们不需要的,会有一定空间和时间上的消耗。

2、path 变量我们发现只是对它的末尾位置进行增加和删除的操作,显然它是一个栈,因此,使用栈语义会更清晰。

3、最后这一点只和 Java 语法相关。

(只与 Java 语言相关)ArrayList 是 Java 中的动态数组,Java 建议我们如果一开始就知道这个集合里需要保存元素的大小,可以在初始化的时候直接传入。

这是为了避免在动态数组扩容和缩容的过程中带来的性能消耗。

在这里,由于我们很清楚全排列的总是就是候选数组长度的阶乘值,因此在 res 变量初始化的时候,最好传入 len 的阶乘,让 ArrayList 在代码执行的过程中不发生扩容行为。同理,在 path 变量初始化的时候,最好传入 len,事实这个路径变量最长也就到 len

由于全排列的问题复杂度很高,因此输入数组的规模并不会很大,因此扩容的机会也相对较少。

到此为止,回溯搜索算法的基本思想已经和大家介绍完了,「回溯算法 = 深度优先遍历 + 状态重置」,事实上「回溯算法」还有一个话题就是「剪枝」,我们下一节再和大家介绍。

你可能感兴趣的:(力扣,回溯算法)