回溯算法:排列与组合详解

1,032 阅读7分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第一天,点击查看活动详情

回溯算法,本质上是一种穷举算法,属于暴力搜索算法的一种。它虽然可以使用剪枝进行优化,仍不高效,但却实用。它往往能够解决可以抽象成树形结构的问题,亦可以认为是使用 K 层 for循环实现搜索的问题:

  • 组合问题:按一定规则在 N 个数中找出 K 个数的集合
  • 切割问题:一个字符串按一定规则切割成子串,求子串个数或符合条件的子集
  • 子集问题:在N 个数的集合中,存在按一定规则分割出的符合某些条件的子集
  • 排列问题:N 个数按一定规则全排列,求其排列结果
  • 棋盘问题:N 皇后问题、数独问题、迷宫问题等等

注:组合与全排列最大的不同就是,子集划分是否强调元素顺序,强调顺序的为全排列,即组合只往后看,排列前后都要看,可从下图清晰观察到两者的差别。

  • 排列每层都是从 0 开始
  • 排列需要 used 数组记录元素是否使用过,一个排列中元素只能使用一次

组合与排列

在回溯算法中,我们需要清楚以下几种规则即可:

  • 使用回溯算法前,可先将问题转化为树形结构

  • 回溯算法解决问题都是在集合中递归子集,即常常以递归为基础实现的

  • 回溯算法基本可以抽象成一颗 N 叉树形式的问题树,其宽度为集合大小,递归(纵向遍历)深度为树的深度

  • 回溯算法常常使用 for 循环来遍历集合区间,即层次(横向)遍历问题树

  • 回溯算法解决的问题结果常常在叶子结点之上

  • 回溯算法需要考虑集合内元素是否可以重复选取,要求不同,方案不同

  • 回溯算法常常使用布尔数组(used)来进行去重工作,必须先对目标集合(存在重复元素)进行排序才能去重

    • 树层去重:子集内可重复,子集间不可重复
    • 树枝去重:子集内不可重复,子集间可重复
  • 回溯算法剪枝优化常常从可获取的子集中剩余元素条件与要求元素条件相比较,不符合就剪枝

  • 回溯算法如果在递归函数调用前,对全局变量有所调整,必须在递归函数后添加撤销语句

回溯算法图例一

回溯算法模板

在了解到回溯算法的几项规则之后,再提供一套回溯算法的模板,如下:

  1. 确定回溯算法的返回值和相关参数
    • 返回值一般为 void ,最终结果常常定义为全局参数
    • 相关参数将会与回溯递归函数的相关参数一致,以递归要求为依据确定
  2. 确定回溯递归函数的终止条件
    • 注意剪枝判断在递归终止判断之前
    • 递归一定需要终止,即纵向遍历终止条件
    • 终止就意味着满足了题目要求的条件存储结果,或者剪枝去除提高效率
  3. 确定单层遍历的过程
    • 单层遍历即横向遍历,需要注意两个点,for 循环起始点及终止点,必须保证遍历完全
    • 起始点与集合大小、是否可重复选取、是否需要去重、是否为排列问题、是否为组合问题等等有关
    • 终止点与集合大小、剪枝条件等等

伪代码模板如下:

//全局变量
最终结果二维集:stack<stack<Interge>> result;
符合条件结果:stack<Interge> path;
//函数体
void backtracking(参数){
    if(剪枝条件){
        剪枝语句
    }
    if(终止条件){
        存放结果;
        return;
    }
    for(选择符合条件的本层元素){
        处理结点;
        backtracking(参数);
        撤销语句;
    }
}

接下来将结合相关实例说明规则和模板的具体使用与注意点。

组合问题

题目详情

LeetCode 题号:40. 组合总和 II

给定一个候选人编号的集合 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。

candidates 中的每个数字在每个组合中只能使用 一次 。

注意:解集不能包含重复的组合。

示例 1:

输入: candidates = [10,1,2,7,6,1,5], target = 8,

输出: [[1,1,6],[1,2,5],[1,7],[2,6]]

示例 2:

输入: candidates = [2,5,2,1,2], target = 5,

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

提示:

1 <= candidates.length <= 100

1 <= candidates[i] <= 50

1 <= target <= 30

题目分析

这道题目需要注意以下几点:

  • candidates 中的每个数字在每个组合中只能使用 一次
  • candidates 可能有重复元素
  • 解集不能包含重复的组合

由上可知:元素在同一个组合内是可重复的,但两个组合不能相同,即需对树层进行去重。

树层去重可以采用 used 数组记录元素是否使用过,使用过即为 true,否则为 false 。

为理解去重,以 candidates = [1,1,2] ,target = 3 为例,图示如下:

回溯算法图例二

回溯算法模板步骤

  1. 确定回溯算法的返回值和相关参数

    • 参数:
      • used 数组: 记录重复元素

      • result 存放栈的栈:存放组合元素

      • path 栈 :记录组合

      • 普通参数:candidates,target,sum(可以使用 target - candidates[i] == 0),startIndex(也可以用它去重,used 更具备普适性)

    代码如下:

    List<List<Integer>> lists = new ArrayList<>();
    Deque<Integer> deque = new LinkedList<>();
    int sum = 0;
    void backTracking(int[] arr, int target, int index, boolean[] used) 
    
  2. 确定回溯递归函数的终止条件

    • sum > target :可不用加,单层遍历会进行剪枝

    • sum == target :寻找到符合条件的目标

    代码如下:

    if(sum > target){
        return;
    }
    if (sum == target) {
        //加入结果集
        lists.add(new ArrayList(deque));
        return;
    }
    
  3. 确定单层遍历的过程

    去重的基本思路:

    假设此时 i = 1,且 candidates[i] == candidates[i-1]

    • 树层去重:used[i-1] == false,则说明同一树层已使用过 candidates[i-1]

    • 树枝去重:used[i-1] == true,则说明同一树枝已使用过 candidates[i-1]

      本题中,需要使用到的是树层去重,所以 i > 0 && arr[i] == arr[i - 1] && !used[i - 1] 结果为true,则跳过结点,continue 。

    代码如下:

    for (int i = index; i < arr.length && arr[i] + sum <= target; i++) {
    //出现重复节点,同层的第一个节点已经被访问过,所以直接跳过
    if (i > 0 && arr[i] == arr[i - 1] && !used[i - 1]) {
    continue;
    }
    //处理数据
    used[i] = true;
    sum += arr[i];
    deque.push(arr[i]);
    //每个节点仅能选择一次,所以从下一位开始
    backTracking(arr, target, i + 1, used);
    //回溯,撤销结果
    int temp = deque.pop();
    used[i] = false;
    sum -= temp;
    }
    

整体代码如下:

class Solution {
    List<List<Integer>> lists = new ArrayList<>();
    Deque<Integer> deque = new LinkedList<>();
    int sum = 0;

    public List<List<Integer>> combinationSum2(int[] candidates, int target) {
        //为了将重复的数字都放到一起,所以先进行排序
        Arrays.sort(candidates);
        //加标志数组,用来辅助判断同层节点是否已经遍历
        boolean[] used = new boolean[candidates.length];
        backTracking(candidates, target, 0, used);
        return lists;
    }

    public void backTracking(int[] arr, int target, int index, boolean[] used) {
        if (sum == target) {
            lists.add(new ArrayList(deque));
            return;
        }
        for (int i = index; i < arr.length && arr[i] + sum <= target; i++) {
            //出现重复节点,同层的第一个节点已经被访问过,所以直接跳过
            if (i > 0 && arr[i] == arr[i - 1] && !used[i - 1]) {
                continue;
            }
            used[i] = true;
            sum += arr[i];
            deque.push(arr[i]);
            //每个节点仅能选择一次,所以从下一位开始
            backTracking(arr, target, i + 1, used);
            int temp = deque.pop();
            used[i] = false;
            sum -= temp;
        }
    }
}

全排列问题

题目详情

LeetCode 题目:47. 全排列 II

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

示例 1:

输入:nums = [1,1,2]

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

示例 2:

输入:nums = [1,2,3]

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

提示:

1 <= nums.length <= 8

-10 <= nums[i] <= 10

题目分析

  • 从一个可包含重复数字的序列,要返回一个不重复的全排列,涉及到去重问题,本题两种去重方法都能解决,但树层去重会更高效

    • 树层去重:在判断初始结点不符合条件时,直接剪枝
    • 树枝结点:至少需要在第二层才能进行剪枝,较多无用搜索
  • 排列问题:有序,[3,2] 与 [2,3] 不同,for 循环每次从零开始

  • 叶子结点为最终结果处:收集元素数组长度与 nums 数组一样长时,找到一个全排列结果

整体代码如下:

class Solution {
    //存放结果
    List<List<Integer>> result = new ArrayList<>();
    //暂存结果
    List<Integer> path = new ArrayList<>();
	
    public List<List<Integer>> permuteUnique(int[] nums) {
        boolean[] used = new boolean[nums.length];
        Arrays.fill(used, false);
        Arrays.sort(nums);
        backTrack(nums, used);
        return result;
    }

    private void backTrack(int[] nums, boolean[] used) {
        if (path.size() == nums.length) {
            result.add(new ArrayList<>(path));
            return;
        }
        for (int i = 0; i < nums.length; i++) {
            // used[i - 1] == true,说明同⼀树枝nums[i - 1]使⽤过
            // used[i - 1] == false,说明同⼀树层nums[i - 1]使⽤过
            // 如果同⼀树层nums[i - 1]使⽤过则直接跳过
            if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == false) {
                continue;
            }
            //如果同⼀树⽀nums[i]没使⽤过开始处理
            if (used[i] == false) {
                used[i] = true;//标记同⼀树⽀nums[i]使⽤过,防止同一树支重复使用
                path.add(nums[i]);
                backTrack(nums, used);
                path.remove(path.size() - 1);//回溯,说明同⼀树层nums[i]使⽤过,防止下一树层重复
                used[i] = false;//回溯
            }
        }
    }
}

总结

回溯算法最主要的难点就是怎么去理解回溯的搜索过程,并且在搜索的过程中完成去重与剪枝的工作,使算法尽可能高效。在进行搜索的时候,也要充分理解单层遍历的逻辑(起始点与终止点的选择)。当然,对于 N 皇后问题的去重问题,以及解数独的二维递归方法,本文,暂时没有提及!后期,在进行说明。

参考资料

求点赞转发