回溯+剪枝精髓都给你总结在这了

2,588 阅读10分钟

一、介绍

1.定义

回溯算法实际上一个类似枚举的搜索尝试过程,主要是在搜索尝试过程中寻找问题的解,当发现已不满足求解条件时,就“回溯”返回,尝试别的路径。这种走不通就退回再走的技术为回溯法,所以也经常称作试探法。

2.要点

通俗地概括回溯算法的基本思路就是:从一条路往前走,能进则进,不能进则退回来,换一条路再试, 虽然看似简单的一句话,但是这里面待挖掘的东西还很多,总结出几个要点,也是解题过程中必不可少的:

  • 选择列举 站在岔路口,你先得明确自己接下来有多少条路可以选择;

  • 选择判定 判断列举的道路中,哪些是可以排除掉的,俗称剪枝;

  • 结束条件 当当前所走路线满足我们需求时,结束前进;

后面我们会结合实际例子,来详细解释体会上面所述要点。

3.框架

框架代码看起来比较简单,但是我们需要真正把它吃透玩透!

List<> result
public void backtrack(路径, 选择列表):
    if 满足结束条件:
        result.add(路径)
        return
    for 选择 in 选择列表:
        做选择
        backtrack(路径, 选择列表)
        撤销选择

上面的框架中就正好呼应了前面总结的三个要点。乍一看这不就是DFS(深度优先搜索)吗?是的,回溯算法在大多数情况下都利用了DFS!

下面我将列举几个回溯算法最经典的例子由浅入深的进一步来体会回溯算法思想!

二、进阶

先来看看排列、组合和子集问题,这里为什么选择这类问题作为切入点,第一,这几题算是比较典型的回溯算法题,几乎不掺杂其它算法思想,可以更加深入的去了解回溯算法;第二,比较全面的囊括了回溯算法中常碰到的各种情形。第二,对于难度更高,隐藏更深的回溯算法题,他们可以当做成一种基础工具或者模型来辅助解决,就跟二分查找、BFS一样,需要自己在日后练习的过程中信手拈来!

1.排列

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

看到题目是不是觉得再熟悉不过,不就是高中数学里的排列嘛,在一个抽奖箱子里放三个乒乓球,每次拿一个出来,一共有几种情况,拿个本子画一下就出来了。来,我们也画一下:

第一次,我们面临三个选择,123号,当选择1后,1是已选择状态排除,剩下2,3两个选择,如果继续选择2的话,12是已选择状态排除那么只剩下3得到一组排列,如果选择3,13是已选择状态排除剩下2得到一组排列。如果第一次不选择1选择2,3就依次类推,整个思路就是前面总结的三个关键点!

需要注意的是,我们每次选择都要排除掉已经选择的,那么如何判定已经选择呢?这里常用的做法是记录路径,然后根据路径中是否包含已经当前节点来判定,另外一种方式就是节点标识,当把某个节点添加到路径后,则标记该节点为已访问,继续向前探索时,根据标识已访问的则跳过!两种情况需要根据实际情况选择使用,比如第一种在数组中存在重复元素时就无法判定。

下面我们就套框架,上代码:

List<List<Integer>> res = new ArrayList<>();

    public List<List<Integer>> permute(int[] nums) {
        List<Integer> list = new ArrayList<>();
        //用来记录节点是否被访问
        boolean[] visited = new boolean[nums.length];
        DFS(list, nums, visited);
        return res;
    }

    private void DFS(List<Integer> path, int[] nums, boolean[] visited) {
        //当到达决策树最底层的时候终止
        if (path.size() == nums.length) {
            res.add(new ArrayList<>(path));
            return;
        }
        for (int i = 0; i < nums.length; i++) {
            //如果已经访问过,则跳过
            if (visited[i]) {
                continue;
            }
            //标识当前位置元素已访问
            visited[i] = true;
            //选做择添加到路径中
            path.add(nums[i]);
            //继续向下探索
            DFS(path, nums, visited);
            //撤销选择
            path.remove(path.size() - 1);
            //撤销标记
            visited[i] = false;
        }
    }

是不是几乎就是套用了我们的框架,整个解就出来了。这里估计还有很多人对撤销选择和撤销标识的操作不是很理解,我们对着上面的分析图来看下,当我们第一次添加了一个完整的排列时,节点在第④层的3节点上,此时我们需要退回到第③层,然后继续横向遍历到3节点上,是不是path需要撤销之前④层的3和③层的2,然后重新添加③层的3,同理visited数组也是一样,需要进行一个标识状态恢复,不然在判定③层的3和④层的2的时候状态为已访问会被过滤掉。

如果把题目稍稍变化一下,如果给定的序列是可以存在重复元素的,如[1,2,2],要求返回不重复的全排列,我们该如何处理?我们按照上题的思路来看下:

可以看出,如果延续之前的做法,最终的结果中,肯定存在重复的排列。那么如何去重呢?对得到的结果去重可以吗?当然是可以,但是想想这是数组,判断两个数组是否完全一样是不是得把它们每个位置遍历然后对比,显然这种方式不可取,那么有什么更好的办法呢?既然不能对结果去重,那么就想办法对可能造成相同的结果的过程去重。仔细看看上图,就会发现图中红色框框内形成的路径与后面那个重复元素为根节点形成的路径是完全一样的,那么我们在横向遍历的时候跳过这个重复元素即可!

    List<List<Integer>> res = new ArrayList<>();

    public List<List<Integer>> permuteUnique(int[] nums) {
        boolean[] visited = new boolean[nums.length];
        Arrays.sort(nums);
        DFS(nums, new ArrayList<Integer>(), visited);
        return res;
    }

    private void DFS(int[] nums, List<Integer> list, boolean[] visited) {
        if (list.size() == nums.length) {
            res.add(new ArrayList<>(list));
            return;
        }

        for (int i = 0; i < nums.length; i++) {
            if (visited[i]) {
                continue;
            }
            //当后面的元素与前面的元素相同,并且前一个元素没有访问,则过滤
            if (i > 0 && nums[i] == nums[i - 1] && !visited[i - 1]) {
                continue;
            }
            visited[i] = true;
            list.add(nums[i]);
            DFS(nums, list, visited);
            list.remove(list.size() - 1);
            visited[i] = false;
        }
    }

有几个细节需要注意,这里先对数组进行排序,是为了让重复元素都排在一起。还有为什么加!visited[i - 1]的限定呢,也就是前一个元素必须是没有访问过的,如果不加会怎样?我们看一种情形,上图中122的那条路径我们现在走到第④层的2节点,此时满足nums[i] == nums[i - 1]吧,此时前一个2是访问过的,如果不加!visited[i - 1]就会把自身给过滤掉,但实际上这个2不能过滤。当在横向遍历的时候,碰到第一个红框内的2时,前一个2,是处于未访问状态(虽然曾经访问过,但是向上回溯导致状态恢复),正好过滤掉,达到去重目的!

2.组合

给定一个无重复元素的数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。candidates 中的数字可以无限制重复被选取,解集不能包含重复的组合。如:candidates = [2,3,5], target = 8,输出结果为[[2,2,2,2],[2,3,3],[3,5]]

我们把这道题跟上面的排列对比下,分析下异同在哪。

选择列表 在全排列中,相当于抽奖箱中每抽一次,剩下的选择中就要排除掉自己。但这里由于要求不能存在相同的组合(注意理解排列与组合的区别),所以乒乓球在箱子中还得有固定的顺序位置,抽到某个位置后,接下来抽只能继续往前,例如给定[1,2,4]target为3,从1开始,抽取12组合满足条件,当从2开始时,就不能回过头抽1了,不然就重复了。具体遍历方式见下图。这一幕想想是不是似曾相识,我们在选择排序中是不是每次只能与后面的数进行对比!由于数组中的数可以重复选取,那么抽出来的数还得放回原位,下次继续从这个位置出发。

结束条件 在全排列中,我们需要将所有球都抽完,才能算结束,而这里我们只需要将抽出来的数凑到target就可以。稍稍转变一下,抽一个数我们就用目标数减去它,那么当目标数为0的时候是不是就可以结束了。

选择判定 如果我们将球按照从小到大的顺序排列起来,抽取的顺序也按照如此,如果抽到一个数已经大于了最新的目标数,那么后面的数是不是就可以都排除了,毕竟后面会越来越大。

结合上面的思路,来看最终代码

    List<List<Integer>> res = new ArrayList<>();

    public List<List<Integer>> combinationSum(int[] candidates, int target) {
        Arrays.sort(candidates);
        DFS(candidates, new ArrayList<Integer>(), target, 0);
        return res;
    }

    private void DFS(int[] candidates, List<Integer> path, int target, int start) {
        if (target == 0) {
            res.add(new ArrayList<>(path));
            return;
        }
        for (int i = start; i < candidates.length; i++) {
            if (target < candidates[i]) {   
                break;
            }
            target -= candidates[i];
            path.add(candidates[i]);
            DFS(candidates, path, target, i);
            target += candidates[i];
            path.remove(path.size() - 1);
        }
    }

可以看到我们在每次遍历的时候都传递了一个start,这个start便是加入到路径中的元素的索引,这样下一层搜索的时候就保证了只能从该位置开始搜索,从而避免了向后搜索造成重复的问题。

如果我们再给它加个限定,candidates可能存在重复的数,并且最终组合中每个数只能出现一次,想想跟上面有什么区别?

首先每个数只能出现一次,那么自己不能再被选择,所以下探后列表的起始点需要+1,candidates存在重复的数,我们就按照之前全排列去重的思路,过滤掉。

List<List<Integer>> res = new ArrayList<>();

    public List<List<Integer>> combinationSum2(int[] candidates, int target) {
        Arrays.sort(candidates);
        DFS(candidates, new ArrayList<Integer>(), target, 0);
        return res;
    }

    private void DFS(int[] candidates, List<Integer> path, int target, int start) {
        if (target == 0) {
            res.add(new ArrayList<>(path));
            return;
        }
        for (int i = start; i < candidates.length; i++) {
            if (target < candidates[i]) {
                break;
            }
            if (i > start && candidates[i - 1] == candidates[i]) {
                continue;
            }
            target -= candidates[i];
            path.add(candidates[i]);
            DFS(candidates, path, target, i + 1);
            target += candidates[i];
            path.remove(path.size() - 1);
        }
    }

这里有个细节不知道大家有没有注意,那就是在去重的时候为什么不用像全排列去重判断visited状态而仅仅candidates[i - 1] == candidates[i]就够了,当个思考题留给大家。

3.子集

给你一个整数数组nums,数组中的元素互不相同,返回该数组所有可能的子集(幂集)。解集不能包含重复的子集。你可以按任意顺序返回解集。如输入[1,2,3],返回[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]

我们继续对照要点分析:

选择列表: 不能包含重复子集,那么是不是跟组合一样,需要对[1,2]和[2,1]这种情况去重,所以当选取某个位置的数后,下次只能继续往前选择。

选择判定: 实际上在选择列表阶段,我们就排除掉了不能选择的,在剩下能选择的元素中没有条件限制;

结束条件: 需要把每个路径都记录下来,所以要把整个决策树遍历完,等遍历完就结束,没有其他结束条件;

  List<List<Integer>> res = new ArrayList<>();

    public List<List<Integer>> subsets(int[] nums) {
        // Arrays.sort(nums);  //去重步骤1
        DFS(nums, new ArrayList<Integer>(), 0);
        return res;
    }

    private void DFS(int[] nums, List<Integer> path, int start) {
        res.add(new ArrayList<>(path));
        for (int i = start; i < nums.length; i++) {
           //去重步骤2
           //if (i > start && nums[i] == nums[i - 1]) {
           //   continue;
           //}
            path.add(nums[i]);
            DFS(nums, path, i + 1);
            path.remove(path.size() - 1);
        }
    }

继续来看如果数组中存在重复元素的情况,有了上面两题去重的经验,这里也比较简单,首先排序,然后过滤掉重复元素即可,即上面代码中已注释部分。

三、实战

如果把上面排列、组合及子集6种情形的练习当做基础工具打磨以及加深对回溯算法的理解,接下来我们就挑选两道,作为实战练兵,一起来看看他们到底有多难! leetcode-cn.com/problems/ju…

请设计一个函数,用来判断在一个矩阵中是否存在一条包含某字符串所有字符的路径。路径可以从矩阵中的任意一格开始,每一步可以在矩阵中向左、右、上、下移动一格。如果一条路径经过了矩阵的某一格,那么该路径不能再次进入该格子。

image.png

上图矩阵中则存在一条包含bfce所有字符的路径.

分析: 首先我们得按从左到右从上到下搜索是否存在第一个字符,当搜索到第一个后,自然我们不能再延续刚才的搜索方式,得围绕着该字符的上下左右继续去搜索接下来的的第二第三个。如果当所有字符都搜索到后,则返回true,否则当搜索到最右下角时还没有搜索到,则返回false。

总结要点: 选择列表为当前匹配字符的上下左右四个元素,然后由于不能走回头路那么已经走过的需要排除掉,结束条件为当字符串的所有字符都匹配到后结束,是不是瞬间觉得很简单!上代码:

    //用来记录上下左右四个方向索引变化
   int[][] dir = {{0, -1}, {-1, 0}, {0, 1}, {1, 0}};

    public boolean exist(char[][] board, String word) {
        if (board == null || board.length == 0 || word == null || word.isEmpty()) {
            return false;
        }
        //记录已访问过的路径
        boolean[][] isVisite = new boolean[board.length][board[0].length];
        for (int i = 0; i < board.length; i++) {
            for (int j = 0; j < board[i].length; j++) {
                char c = word.charAt(0);
                //从左到右,从上到下查找第一个元素
                if (board[i][j] == c) {
                    if (findChar(board, word, i, j, 0, isVisite)) {
                        return true;
                    }
                }
            }
        }
        return false;
    }

    private boolean findChar(char[][] board, String word, int row, int col, int index, boolean[][] isVisited) {
        //当字符串全部匹配上后结束
        if (index == word.length() - 1) {
            return true;
        }

        for (int i = 0; i < dir.length; i++) {
            //得到下一个格子坐标,有可能是上下左右
            int newRow = row + dir[i][0];
            int newCol = col + dir[i][1];
            //合法性判断,不能越界
            boolean pointlegal = newRow >= 0 && newRow < board.length && newCol >= 0 && newCol < board[row].length;
            //如果下个格子坐标合法,且字符匹配上,并且没有访问过
            if (pointlegal && word.charAt(index + 1) == board[newRow][newCol] && !isVisited[newRow][newCol]) {
                //标识访问状态
                isVisited[row][col] = true;
                //交给下一层
                if (findChar(board, word, newRow, newCol, index + 1, isVisited)) {
                    return true;
                }
                 //撤销访问状态
                isVisited[row][col] = false;
            }
        }
        return false;
    }

紧紧抓住三个要点思路就很清晰了。这里有个dir二维数组来处理矩阵方向问题,小技巧,相信以后你也用的上!

最后再来看另外一个最经典的回溯算法,数独!相信很多人都玩过这里我们还是贴一下题: leetcode-cn.com/problems/su…

给定一个预设了值的9*9宫格,要求填充空白地方,并满足如下规则:1.数字 1-9 在每一行只能出现一次;2.数字 1-9 在每一列只能出现一次;3.数字 1-9 在每一个以粗实线分隔的 3x3 宫内只能出现一次。空白格用 '.' 表示。如下:

Lark20210311-171515.png

分析: 同样我们按照从左往右从上到下的顺序进行填充,当格子已经被预填充后,那么直接跳过进行到该行的下一列,如果是空白就从1-9中选择填充,所以我们的选择列表就是1-9。很显然这1-9并不是都能使用,还得遵循行列以及框内的不重复规则,所以进一步进行剪枝。那么什么时候结束呢,自然是最后一个格子访问完!

结合要点和框架,我们上代码:

    public void solveSudoku(char[][] board) {
        DFS(board, 0, 0);
    }

    private boolean DFS(char[][] board, int row, int col) {
        //最后一行都执行完,那么就跑完了
        if (row == 9) {
            return true;
        }
        if (col == 9) {
            //转到下一行
            return DFS(board, row + 1, 0);
        }
        if (board[row][col] != '.') {
            //如果该位置预设了,那么找下一列
            return DFS(board, row, col + 1);
        }
        for (char i = '1'; i <= '9'; i++) {
            //判断char是否可用
            if (!isValid(board, i, row, col)) {
                continue;
            }
            board[row][col] = i;
            //找到一个可行的解就直接返回,降低复杂度
            if(DFS(board, row, col + 1)){
                return true;
            }
            board[row][col] = '.';
        }
        return false;
    }

    private boolean isValid(char[][] board, char c, int row, int col) {
        for (int i = 0; i < 9; i++) {
            //判断当前行列里是否有该字符
            if (board[row][i] == c) return false;
            if (board[i][col] == c) return false;
            // 判断 3 x 3 方框是否存在重复,比较难想到需要画图理解没啥技巧
            if (board[(row / 3) * 3 + i / 3][(col / 3) * 3 + i % 3] == c)
                return false;
        }
        return true;
    }

有人可能会注意到这里为什么DFS增加一个boolean返回值,其实跟上面找路径一样,找到一个可行的解我们就不再继续了降低复杂度,虽然定位为hard,但是我想看到这里,经过前面的铺垫,你还觉得难吗?

四、最后

分析一下回溯算法时间复杂度,基本上取决于递归的深度X递归中选择次数,具体题型需要再具体去分析。但是无法避免的是我们往往需要穷举整个决策树,所以复杂度相对比较高。

可以看到,在遇到回溯算法题的时候,我们牢牢抓住上面三个要点以及框架,很多问题变的迎刃而解。当然也不能一招吃遍天下,算法很多时候讲究的还是思想,需要我们去深入体会。另外正如上面说的,排列、组合、子集这类问题要它们当做模板基础工具一样信手拈来,在碰到更加复杂问题的时候用来对比分析,辅助解决,所以要彻底弄懂弄透彻!