回溯算法

【回溯算法】在 最优解、排列组合、解空间搜素 中存在典型应用

【动态规划】和【贪心算法】都要求 无后效性,即 子问题的解是当前的最优解,不能回退。当这种要求得不到满足时,一种常用做的做法就是采用【回溯算法】进行求解。

【回溯算法】是一个类似枚举的搜索尝试过程,主要是在搜索尝试过程中寻找问题的解。当发现已不满足求解条件时,就 回溯 返回,尝试别的路径。
【回溯算法】是一种选优搜索法,按选优条件向前搜索,以达到目标。但当探索到某一步时,发现原先选择并不优或达不到目标,则退回一步重新选择。

这种走不通就退回再走的算法称为【回溯算法】,而满足回溯条件的某个状态点称为【回溯点】

许多复杂的、规模较大的问题都可以使用回溯算法,它又有 通用解题方法 的美称。

基本思想

在包含问题的所有解空间树中,按照 深度优先搜索的策略,从根节点出发,深度探索解空间树。
当探索到某一结点时,先判断该节点是否包含问题的解,如果包含,则从该节点出发继续探索下去;否则逐层向其祖先节点回溯。
若用回溯算法求问题的所有解时,要回溯到根,且根节点的所有可行的子树都要已被搜索遍历才结束。
若只求任意一个解,只要搜索到问题的一个解就可以结束。

其实【回溯算法】就是对隐式图的深度优先搜索算法

基本步骤

  • 针对所给的问题,确定问题的解空间:首先明确问题的解空间,应至少包含问题的一个(最优)解
  • 确定节点的扩展搜索规则
  • 深度优先 的方式搜索解空间,并在搜索过程中使用剪枝函数避免无效搜索。

解决一个回溯问题,实际上就是一个 决策树 的遍历过程

【回溯算法】只需要思考三个问题:

  • 路径:已经做出的选择。
  • 选择列表:当前可以做的选择。
  • 结束条件:到达决策树底层,无法再做选择的条件。

代码架构

result = []
def backtrack(路径, 选择列表):
    if 满足结束条件:
        result.add(路径)
        return

    for 选择 in 选择列表:
        做选择
        backtrack(路径, 选择列表)
        撤销选择

核心:在 for 循环里面的递归,在递归调用之前 做选择,在递归调用之后 撤销选择

全排列问题

高中数学里有排列组合的问题,n个不重复的数,全排列共有 n!
现有一组不重复的数字如[1, 2, 3],穷举出所有可能的组合?
通常的做法:
先固定第一位1,第二位可以是2,那么第三位只能是3;第二位是3,那么第三位只能是2
然后就只能变换第一位为1,然后穷举后两位......

其实这就是【回溯算法】,得到【回溯树】

回溯树

只要从根节点遍历这棵树,记录路径上的数字,其实就是所有的全排列!我们不妨把这棵树称为回溯算法的【决策树】
为什么说这事决策树呢?因为在每个节点上其实都在做决策。
比如,当前站在下图的红色节点上

决策节点

现在就是在做决策:可以选择标记为 [1] 的树枝,也可以选择标记为 [3] 的树枝。为什么只能在[1, 3]之中做选择呢?因为 [2] 树枝在红色节点背后,这个选择已经做过了,而全排列是不允许重复使用数字的。

由此可得:

  • [2]就是 路径,记录已经做过的选择
  • [1, 3] 就是 选择列表,表示当前可以做出的选择(决策)
  • 结束条件 就是遍历到叶子节点,这里的 选择列表 为空

可以把 路径选择列表 作为决策树上每个节点的属性,比如下图列出的节点属性

节点属性

前面定义的 backtrack 函数就像一个指针,在决策树上游走,同时要正确维护每个节点的属性,每当走到树的底层,其 路径 就是一个全排列。

这就涉及到【树的遍历】
多叉树的前序遍历(根->左->右)与后序遍历(左->右->根

void traverse(TreeNode root) {
    for(TreeNode child: root.children) {
        //... 前序遍历需要的操作
        traverse(child)
        //... 后序遍历需要的操作
    }
}

【前序遍历】的处理是在进入某个节点之前的时间点执行,【后序遍历】的处理在离开某个节点之后的时间点处理。

【路径】和【选择列表】是每个节点的属性函数在树上游走要正确维护节点的属性,那么就要在两个时间点上做处理:

处理时间点

重新理解【回溯算法】的核心框架:

for 选择 in 选择列表:
    # 做选择
    将该选择从选择列表移除
    路径.add(选择)
    backtrack(路径, 选择列表)
    # 撤销选择
    路径.remove(选择)
    将该选择再加入选择列表

即:只要在递归之前做出选择,在递归之后撤销刚做的选择,就能正确得到每个节点的【选择列表】和【路径】。

// 统计排列的链表
List<List<Integer>> res = new LinkedList<>();

/* 主函数 */
List<List<Integer>> permute(int[] nums) {
    // 记录「路径」
    LinkedList<Integer> track = new LinkedList<>();
    backtrack(nums, track);
    return res;
}

// 路径:记录在 track 中
// 选择列表:nums 中不存在于 track 中的元素
// 结束条件:nums 中的元素全都在 track 中出现
void backtrack(int[] nums, LinkedList<Integer> track) {
    // 触发结束条件
    if (track.size() == nums.length) {
        // 加入统计的链表中
        res.add(new LinkedList(track));
        return;
    }

    for (int i = 0; i < nums.length; i++) {
        // 排除不合法的选择
        if (track.contains(nums[i]))
            continue;
        // 做选择
        track.add(nums[i]);
        // 进入下一层决策树
        backtrack(nums, track);
        // 取消选择
        track.removeLast();
    }
}

注:这里并没有显式记录【选择列表】,而是通过numstrack推导出当前的【选择列表】,即:nums中不存在于track里的元素。

当然,这个算法解决全排列问题不是很高效,因为对链表使用 contains() 方法的时间复杂度为O(N),可以通过 交换元素 优化。但是,不管怎么优化,都符合【回溯框架】,而且时间复杂度都不可能低于O(N!),因为穷举整颗决策树是无法避免的,这也正是【回溯算法】的特点。不像【动态规划】存在重叠子问题可以优化,【回溯算法】就是纯暴力穷举,复杂度一般都比较高。

全排列 - 交换法

function backtrack(arr, k, result) {
    if(k === arr.length) {
        // 触发结束条件,加入统计列表
        result.push(arr.join('-'))
    }
    // 核心: 以 k 为起点, 依次与后面的元素交换, 产生新的组合
    for(let i = k; i < arr.length; i++) {
        // 做选择:交换 k 与 i 的元素
        swap(arr, i, k)
        // 交换完成后, 继续交换下一个元素, 直至 k == arr.length
        backtrack(arr, k+1, result)
        // 撤销选择:回溯到上一个状态,把两个元素交换回来
        swap(arr, k, i)
    }
}
function swap(arr, i, j) {
    if(i == j) return
    let tmp = arr[i]
    arr[i] = arr[j]
    arr[j] = tmp
}
进阶版 - 含有重复元素

考虑到重复元素,一定要优先 排序,将重复的元素放在一起,以便找到重复元素和剪枝。

重复元素的剪枝

由图可知,在第一次使用重复元素时,得到的排列组合是没有问题的。只有在重复使用元素时,才会出现重复的组合。

  • 第一次使用元素时,并不需要考虑是否重复,即 重复元素一定会使用一次。
    -- 使用一个变量记录元素是否使用过
  • 再次使用一个元素时,发现与前一个元素重复,则剪枝。
    -- 当 arrs[i] == arr[i-1] 时,表示重复使用一个元素(隐含 i > 0
function backtrack(arr, k, used, result) {
    if(k === arr.length) {
        // 触发结束条件,加入统计列表
        result.push([...arr])
    }
    // 核心: 以 k 为起点, 依次与后面的元素交换, 产生新的组合
    for(let i = k; i < arr.length; i++) {
        // 过滤重复元素,不生成重复组合
        if(i > 0 && arr[i] == arr[i-1] && !used[i-1]) {
            // 第i个元素与第[i-1]个元素相等
            // 且当前并不是在使用第[i-1]个元素, 即第[i-1]个元素已经使用过了
            // 如果继续使用第i个元素,那么产生的组合将重复,故而过滤
            continue
        }
        // 变换状态: 当前正在使用第 i 个元素
        used[i] = true
        swap(arr, i, k)
        // 递归
        backtrack(arr, k+1, used, result)
        // 回溯到上一个状态: 第 i 个元素使用完毕
        used[i] = false
        swap(arr, k, i)
    }
}

let arr = [1, 1, 3], used = [], result
backtrack(arr, 0, used, result)
// result = [[1, 1, 3], [1, 3, 1], [3, 1, 1]]

N 皇后问题

很经典的一个问题:一个 N×N 的国际棋盘,放置 N 个皇后,使得它们不能互相攻击,问有多少种摆法?
注:皇后可以攻击同一行、同一列、左上左下右上右下(同斜线)的任意单位。

棋盘上的皇后

N皇后问题由8皇后问题演变而来,是由国际西洋棋棋手马克思·贝瑟尔于1848年提出:在 8 x 8 格的国际棋盘上摆放 8 个皇后,使其不能相互攻击,即 任意两个皇后都不能处在同一行、同一列、同一斜线上,问有多少种摆法?高斯认为有76种方案,1854年在柏林的象棋杂志上不同的作者发表了40种不同的解,后来有人用图论的方法解出92种结果。

N皇后问题是【回溯算法】的经典案例,有递归版和非递归版。除了递归算法,还有一种【位运算】

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容

  • 1.基本概念 回溯算法实际上一个类似枚举的搜索尝试过程,主要是在搜索尝试过程中寻找问题的解,当发现已不满足求解条件...
    RavenX阅读 8,309评论 1 2
  • 引言:这道题目老师强调了肯定要考,所以只有硬着头皮将其复习了;下面是自己学习回溯算法的学习,仅供参考;一:基本概念...
    cp_insist阅读 8,622评论 4 3
  • 思想 回溯法(back tracking)(探索与回溯法)是一种选优搜索法,又称为试探法,按选优条件向前搜索,以达...
    f155b8f6e0ac阅读 391评论 0 2
  • 贪心算法 先来比较一下贪心算法和动态规划 贪心算法是指在对问题求解时,总是做出在当前看来是最好的选择,不考虑整体,...
    Moonsmile阅读 2,809评论 0 1
  • 回溯(backtracking): 有“通用解题法”之称。用它可以系统地搜索问题的所有解。它在问题的解空间树中,按...
    皮了个卡丘喵喵哒阅读 431评论 0 0