「回溯算法」专题介绍

「回溯算法」专题介绍

第 1 节:从全排列问题开始理解回溯搜索算法

引言

大家好,今天要和大家分享的主题是“回溯算法”。

“回溯算法”的全称是“回溯搜索算法”,“搜索”这个词揭示了“回溯”算法的应用:“搜索”。

首先我们来理解一下“搜索”这个词:

这里的“搜索”类似我们学习过的“查找”,类似于“线性查找”、“二分查找”,我们是为了查找某个元素而使用查找算法,而搜索算法更强大了,“回溯搜索算法”可以帮助我们查找到符合条件的一系列的解,这里“搜索”的含义完全可以理解成我们日常使用的“搜索引擎”里的“搜索”。

搜索引擎:在一个庞大的空间(互联网世界)里搜索我们需要的内容。

回溯搜索:在一个树形问题里搜索符合条件的解的集合。

也就是使用搜索算法,能够帮助我们找到的问题的答案不止一个,有多少个答案,搜索算法就会返回给你多少个答案。

例 1:「力扣」第 46 题:全排列

我们先来看一个最基本的使用回溯算法解决的问题,这道题是「力扣」上第 46 号问题:“全排列”。

给定一个没有重复数字的序列,返回其所有可能的全排列。
示例:
输入: [1, 2, 3]
输出: [[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]]

这道题要求我们返回一个没有重复数字的序列的所有可能的排列。以题目示例为例,如果让我们手写,相信大家一定都会。在动手尝试写出几个全排列以后,会慢慢找到规律,我们就以 [1, 2, 3] 为例,手写得到全排列的过程是这样的:

  • 先下以 1 开头的全排列,它们是:[1, 2, 3], [1, 3, 2]
  • 再写下以 2 开头的全排列,它们是:[2, 1, 3], [2, 3, 1]
  • 最后写下以 3 开头的全排列,它们是:[3, 1, 2], [3, 2, 1]

具体来说,这种做法思路是:

  • 按顺序枚举每一个位置可能出现的数字;
  • 之前已经出现的数字在接下来要选择的数字中不能出现。

按照这种思路就能够做到不重不漏,把所有的排列都枚举出来。
这样的思路可以用一个树形结构表示。看到这里的朋友,不妨自己先尝试画出“全排列”问题的树形结构。

全排列问题的树形结构

下面我就为大家展示一下,这个问题画成树形结构是怎样的。我们的做法是一个位置一个位置去尝试,穷举所有的可能性。

[1, 2, 3] 为例(说明得到全排列的树形结构)。

  • 一开始,排列为空列表;
  • 第 1 个位置可能出现的数值为 123
  • 第 2 个位置,我们看 1 这个分支,由于 1 已经使用过,那么第 2 个位置可能出现的数字只能是 23,同理不难写出 2 这个分支和 3 这个分支;
  • 第 3 个位置,我们看 1, 2 这个分支,由于只有 12 都被选了,只能选择 3,于是得到一个排列 [1, 2, 3],同理,这一条路径走到最后,能选择的数字只有 2,得到了另一个排列 [1, 3, 2],依次类推,得到排列 [2, 1, 3][2, 3, 1][3, 1, 2][3, 2, 1]。在这样一个树形结构中,我们画在叶子结点的所有排列就是 [1, 2, 3] 的全排列。

方法知道了,下面我们考虑如何写代码,事实上,我展示这个动画的过程就可以作为一种编码的方法,它是这棵树的广度优先遍历的过程,感兴趣的朋友不妨尝试一下广度优先遍历应该怎样写,大家真正去写一下就会发现广度优先遍历的写法并不好写。

既然我们说到了“遍历”,与“广度优先遍历”一起会在我们脑海里出现的名词就是“深度优先遍历”,我要为大家介绍的“回溯搜索算法”就是在这棵多叉树上做“深度优先遍历”。

我们先把深度优先遍历的路径画在这棵树上是这样的,我们再具体跟踪一下深度优先遍历的遍历过程。

image-20200120041102590
  • 从一个空列表开始,先选择 1,然后因为 1 已经选择了,按顺序应该选择 2 ,还没完呢,还有一个数 3,此时我们发现没有数字可选了,即得到了一个排列;
  • 为了得到全部的排列,深度优先遍历有一个回退的过程,最后一步我们选择的是 3,在回退的时候,需要撤销对 3 的选择,回到 [1, 2] ,由于在这个阶段,选择 3 已经被我们考虑了,因此继续回退,撤销对 2 的选择,回退到 [1],在这个阶段有 2 个选择,可以选择 2,也可以选择 3,2 已经走过了,接下来选择 3,接下来可以选择的只有 2,于是又得到一个排列 [1, 3, 2],为了得到其他排列,我们撤销对 2 的选择,撤销对 3 的选择;
  • 1 这个结点,可以选择的第 2 个位置的数值 23 我们已经走过了,也就是我们得到了以 1 开头的排列,为了得到以 23 开头的全排列,我们依然是像刚刚做过的操作一样,在回退的时候,撤销对 1 的选择,回到空结点;
  • 接下来的步骤是类似的,我直接展示给大家看。下面我们总结一下深度优先遍历是如何在这棵树上完成遍历操作的:

第 1 点:首先介绍一个概念:状态。

每一个结点表示了求解“全排列”问题的不同阶段,这些阶段通过变量的“不同的值”体现,这些变量的不同的值,称之为“状态”;

例如 [3] 这个结点,表示的就是我们已经确定了一个排列的第一个位置的元素是 [3],这是一个阶段,还没完呢,在这个阶段下,我们还要去找后面两个位置可能出现的元素有哪一些情况。

第 2 点:深度优先遍历由于有“回头”的过程,在“回头”以后,状态变量需要设置成为和之前刚来到这个结点的时候一样。具体的表现就是:在回到上一层结点的时候,需要撤销上一次选择,这个操作也称之为“状态重置”,这里“状态重置”就是“回溯算法”里“回溯”的本意

  • 正因为回到了之前的的状态,才可以开始下一个选择的尝试,大家可以从反面想一下,如果不重置的话,会发生怎样的情况。

下面我们解释如何编码:

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

2、是递归,万年不变的前提就是要考虑递归终止条件。

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

depth == lenlen 是序列中数字的个数)的时候,递归终止。

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

(1)已经选了哪些数 path

由于排列是讲究顺序的,已经选择的数我们把它放进一个列表里,这个列表里的数字就是从根结点到某个中间结点或者叶子结点的一个路径,我们把这个列表命名为 path

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

(2)布尔数组 used

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

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

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

pathdepthused 这三个变量称之为“状态变量”,它们表示了我们在求解全排列问题的时候所处的阶段

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

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

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

public class Solution {

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

        List 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 path, boolean[] used, List> res) {
        if (depth == len) {
            // 这里留坑
            res.add(new ArrayList<>(path));
            return;
        }

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

            path.add(nums[i]);
            used[i] = true;
            dfs(nums, len, depth + 1, path, used, res);
            // 这个位置是深度优先遍历回退的步骤,我们需要在这里对状态遍历做重置的操作
            path.remove(depth);
            used[i] = false;
        }
    }
}

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

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

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

Java 代码:

if (depth == len) {
    res.add(path);
    return;
}

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

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

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

修改的部分:

Java 代码:

if (depth == len) {
    res.add(new ArrayList<>(path));
    return;
}

此时再提交到「力扣」上就能得到一个 Accept 了。

(复杂度分析最后说。)

刚学习回溯算法的朋友,可能对这里为什么需要回溯并不能很好地理解。为此我再花一点时间深入和大家探讨一下全排列问题的回溯算法。

几点说明

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

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

  • 在回到上一层结点的时候不需要“回溯”(也就是不需要“状态重置”);
  • 在递归终止的时候也不需要做拷贝。

为了验证这种说法,我们写如下代码进行实验:

Java 代码:

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

public class Solution {

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

        List 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 path, boolean[] used, List> 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 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);
            // 无回溯过程
        }
    }
}

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

这就好比我们在实验室里做“对比实验”,只有每一个步骤的尝试都都使用同样的材料,才有可能保证“对比实验”结果的有效性。
为此我们有两种做法:

  • 每做完一种尝试,都把实验材料恢复成做上一个实验之前的样子,只有这样做出的对比才有意义;
  • 每一次尝试都使用同样的新的材料做实验。

在生活中做实验对材料有破坏性,这个过程通常不可逆。而在计算机的世界里,“恢复现场”和“回到过去”是相对容易的。

在一些字符串的“回溯”问题中,有时不需要回溯的原因是这样的:字符串变量在拼接的过程中会产生新的对象(针对 Java 和 Python 语言,其它语言我并不清楚)。

如果你使用 Python 语言,会知道有这样一种语法:[1, 2, 3] + [4] 创建了一个新的列表对象,请看“参考代码 3”。

参考代码 3

Python 代码:

from typing import List


class Solution:
    def permute(self, nums: List[int]) -> List[List[int]]:
        def dfs(nums, size, depth, path, state, res):
            if depth == size:
                res.append(path)
                return

            for i in range(size):
                if ((state >> i) & 1) == 0:
                    dfs(nums, size, depth + 1, path + [nums[i]], state ^ (1 << i), res)

        size = len(nums)
        if size == 0:
            return []

        state = 0
        res = []
        dfs(nums, size, 0, [], state, res)
        return res

说明:这里的整数 state 代替了布尔数组 used 的作用。布尔数组 used 在这题里的作用是判断某个位置上的元素是否已经使用过。它有两种等价的替换方式:

(1)位掩码,即使用一个整数表示布尔数组。此时可以将空间复杂度降到 (不包括 path 变量和 res 变量和递归栈空间消耗),本 Python 代码正好展示了这样的写法;

(2)哈希表。

代码留给大家完成。

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

(只与 Java 语言相关)但同时 Stack 这个类的文档告诉我们,由于一些设计上的问题,建议我们使用:

Deque stack = new ArrayDeque();

这一点让我们觉得比较左右为难:Deque 是双端队列,它提供了更灵活的接口,但同时破坏了语义,一不小心,如果用错了接口,就会导致程序错误。

在「力扣」这个网站上一起刷题的朋友给我的建议就是:是接受官方的建议,并且(1)在程序变量命名和使用的接口时让语义清晰;(2)加上必要的注释;(3)加强测试。

这里 path 需要表示它是从根结点到叶子结点的路径,我认为这个语义更重要,因此不改名为 stack。而在末尾添加元素和删除元素的时候,分别使用 addLast()removeLast() 方法这两个最直接的方法名强调只在 path 变量的末尾操作。

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

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

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

代码留给大家完成。

到此为止,回溯搜索算法的基本思想。相信大家已经认识到了,“回溯算法 = 深度优先遍历 + 状态重置”,事实上回溯算法还有一个话题就是“剪枝”,我们暂且不展开,下面先做已经介绍的知识点做一个简单的总结。

总结

回溯算法是在一个树形问题上做一次深度优先遍历,用于搜索所有可能的解。

它能解决的问题的特点是:1、有若干个解;2、每一个解的求解过程可以分为若干个步骤(阶段),得到所有解的过程是一个不断尝试的过程。

为什么使用深度优先遍历?

  • 首先是正确性,只有遍历状态空间,才能得到所有符合条件的解;
  • 在深度优先遍历的时候,不同状态之间的切换很容易,可以再看一下上面有很多箭头的那张图,每两个状态之间的差别只有 1 处,因此回退非常方便,这样全局就使用一份状态变量完成搜索;
  • 如果每一个状态都去创建新的变量,时间复杂度是 ,并且也有空间的消耗。
  • 搜索问题的状态空间一般很大,在候选数比较多的时候,在非叶子结点上创建新的状态变量的性能消耗就很严重;
  • 就本题而言,只需要叶子结点的那个状态,在叶子结点执行拷贝,时间复杂度是 。路径变量在深度优先遍历的时候,结点之间的转换只需要 。

为什么不使用广度优先遍历?

  • 如果使用广度优先遍历,从浅层转到深层(请读者想象广搜的过程),状态的变化就很大,此时我们不得不在每一个状态都新建变量去保存它,上面已经分析过了,从性能来说是不划算的
  • 从编写代码的角度上来说,如果使用广度优先遍历就得使用队列,然后编写结点类。而使用深度优先遍历,我们可以直接使用了系统栈,系统栈帮助我们保存了每一个结点的状态信息。于是我们不用编写结点类,不必手动编写栈完成深度优先遍历。

这道题用广度优先遍历写是完全可以的,我尝试过,代码写出来非常不美观。感兴趣的朋友也可以尝试写一下,尝试写广搜的目的是更好地体会为什么“深搜”能成为强大的“回溯搜索算法”,而广搜不是。

认识“剪枝”

由于回溯算法的时间复杂度很高,因此,在遍历的时候,如果能够提前知道这一条分支不能搜索到满意的结果,这一分支就可以跳过,这一步操作就是在一棵树上剪去一个枝叶,被人们很形象地称之为剪枝

回溯算法会大量应用“剪枝”技巧达到以加快搜索速度。这里有几点提示:

1、有时,需要做一些预处理工作(例如排序)才能达到剪枝的目的。虽然预处理工作虽然也消耗时间,但和剪枝能够节约的时间相比是微不足道的。因此,能预处理的话,就尽量预处理;

2、正是因为回溯问题本身时间复杂度就很高,所以能用空间换时间就尽量使用空间。

如果大家玩得转“剪枝”,或许在开篇列出的一些简单的游戏问题就不在话下了,感兴趣的朋友不妨挑战一下。关于“剪枝”的技巧,以后有机会,我再和大家做一次分享。

复杂度分析

复杂度分析:(可以暂时跳过,或者永远跳过,不是很重要,为了知识完整性写在这里。)

说明:回溯算法的时间复杂度一般都比较高,有些问题分析起来很复杂,我个人觉得没有必要掌握,而且剪枝剪得好的话,复杂度会降得很低很多,因此分析最坏时间复杂度的意义也不是很大,视情况而定。

  • 时间复杂度:

首先计算“非叶子结点的个数”。依次为(按照层数来)

说明:根结点为 ,计算复杂度的时候忽略; 表示排列数,计算公式为 。

在第 1 层,结点个数为:在 个数选 1 个的排列,即 ;

在第 2 层,结点个数为:在 个数选 2 个的排列,即 。

以此类推,全部的非叶子结点的总数是:

\cfrac{N!}{(N - 1)!} + \cfrac{N!}{(N - 2)!} + \cdots + N! = N! \left( \cfrac{1}{(N - 1)!} + \cfrac{1}{(N - 2)!} + \cdots + 1 \right) \le N! \left( 1 + \cfrac{1}{2} + \cfrac{1}{3} + \cdots + \cfrac{1}{N - 1} \right) < 2N!

将常系数 视为 ,每个内部结点循环 次,故非叶子结点的时间复杂度为 ;

然后计算叶子结点的个数。最后一层共 个叶节点,在叶子结点处拷贝需要 ,叶子结点的时间复杂度也为 。

  • 空间复杂度:。递归树的深度是 ;全排列的总数是 ,每个全排列占空间 ,一共是 这么多空间。取较大者。

练习

下面提供一些我做过的“回溯”算法的问题,都是特别基础的使用回溯算法解决的问题,以便大家学习和理解“回溯算法”。以下提供一个经验:

做回溯搜索问题第 1 步都是先画图,画图是非常重要的,只有画图才能帮助我们想清楚递归结构,看清楚、想清楚如何剪枝。具体的做法是:就拿题目中的示例,想一想人手动操作是怎么做的,一般这样下来,这棵递归树都不难画出。

大家在和小伙伴讲解回溯算法甚至是和面试官讲解回溯算法的时候,画一个树形图相信也是非常形象的。再过渡到编码,相信也是十分自然的事情。

在画图的过程中需要思考清楚的问题有:

1、分支如何产生,即:每一步有哪些选择;

2、题目需要的解在哪里?是在叶子结点、还是在非叶子结点、还是在从跟结点到叶子结点的路径?

3、哪些搜索是会产生不需要的解的,这里要特别清楚深搜是怎么运行的,在深搜的过程中,状态变量发生了什么变化。例如:产生重复是什么原因,如果在浅层就知道这个分支不能产生需要的结果,应该提前剪枝,剪枝的条件是什么,代码怎么写?

  • 这一点也告诉我们,画图可以帮助更清楚地分析代码的执行流程,以准确捕捉需要剪枝的条件。

擅长使用

image-20200120041346413

回溯算法基础问题列表

题目 提示
47. 全排列 II 思考一下,为什么造成了重复,如何在搜索之前就判断这一支会产生重复,从而“剪枝”。
17 .电话号码的字母组合 字符串问题,没有显式回溯的过程
22. 括号生成 字符串问题,没有显式回溯的过程。这道题广度优先遍历也很好写,可以通过这个问题理解一下为什么回溯算法都是深度优先遍历,并且都用递归来写。
39. 组合总和 使用题目给的示例,画图分析。
40. 组合总和 II
51. N皇后 其实就是全排列问题,注意设计清楚状态变量。
60. 第k个排列 利用了剪枝的思想,剪去了大量枝叶,直接来到需要的叶子结点。
77. 组合 组合问题按顺序找,就不会重复。并且举一个中等规模的例子,找到如何剪枝,这道题思想不难,难在编码。
78. 子集 为数不多的,解不在叶子结点上的回溯搜索问题。解法比较多,注意对比。
90. 子集 II 剪枝技巧同 47 题、39 题、40 题。
93. 复原IP地址
784. 字母大小写全排列

正是由于回溯算法具有“强大的”暴力搜索的能力,它被应用于一些游戏问题,例如:N 皇后、解数独、祖玛游戏、24 点游戏、走迷宫、生成迷宫。许多复杂的、规模较大的问题都可以使用回溯搜索算法得到所有可行解,进而得到最优解,因此回溯算法有“通用解题方法”的美称,回溯算法也是经典的人工智能的基础算法。


另外,由于执行的深度优先遍历,从较深层的结点返回到较浅层结点的时候,需要做“状态重置”,即“回到过去”、“恢复现场”,我们举一个例子:请大家看上面的树形图想象,代码是如何从叶子结点 [1, 2, 3] 到叶子结点 [1, 3, 2] 的。深度优先遍历是这样执行的:

  • [1, 2, 3] 回到 [1, 2] 的时候,需要撤销刚刚已经选择的数 3
  • 由于在上一层只有一个数 3 能选择,我们已经尝试过了,因此程序回到再上一层,需要撤销对 2 的选择,好让后面的程序知道,选择 3 了以后还能够选择 2

使用深度优先遍历编写代码,可以直接借助系统栈空间,为我们保存所需要的状态变量。在编码中需要注意:遍历到相应的结点的时候,状态变量的值是必须是正确的。此处我们来认识 path 变量作为状态变量,它在深度优先遍历中的变化:往下走一层的时候,path 变量在尾部追加一个数字,而往回走的时候,需要撤销上一次的选择,这一操作也是在 path 的尾部去掉一个数字,因此 path 变量是一个栈。

你可能感兴趣的:(「回溯算法」专题介绍)