46. 78. 79. - 回溯算法

就我目前遇到的回溯问题,这要分为三类:

  1. 子集问题

    比如数组[1,2,3],[1]可以是子集,[1,2]和[1,2,3]都是子集,甚至[ ]也是子集。因此,在这一类问题中,主要有以下几个特点;

    • 符合要求的结果长度不一定
    • 从何开始作为第一个遍历是不定的,比如[1,2]和[2,3]均可,但第一个元素分别是1和2
    • 相同位置的元素不能再次遍历
    • 结果中的顺序无关,比如[1,2]和[2,1]是一样的
  2. 排列问题

    一般针对全排列问题,和子集问题有类似之处:

    • 从何开始作为第一个遍历也是不定的
    • 相同位置的元素不能再次遍历

    但是也有不同之处:

    • 符合要求的结果长度一定,等于原数组长度
    • 结果中的顺序相关
  3. 匹配问题

    (其实并不知道如何概括这一类,姑且称之为匹配问题)

    所谓匹配问题,一般是指题目给出一个固定的(一维或者二维)数组或者字符串等,要求从中找出符合条件的元素。简单来说,比如从一个数组中找出和为sum的几个元素,若仅仅是这样则相当于子集问题,无非是增加了对和的判断而已。但是,==在匹配问题中,很多时候对向下遍历的路径是由要求的,==比如在二维数组中,要求只能向当前节点的水平或者竖直且是相邻方向遍历,那么此时回溯函数内部的构造和之前两类问题不同。


子集问题

以数组[1,2,3]为例,用树状结构表示回溯过程,且每个遍历的对象是从左到右依次遍历,这样的好处在于不会在下一次遍历的时候取到之前遍历过的数。用list存储每一次遍历的结果,用result表示最终的结果集。

在这里插入图片描述
回溯过程:

  1. 首先,list为空,因为空集也是子集,所以加入result
  2. 第1层遍历,因为是从左到右,所以第一次遍历到的数是1,此时list = [1],加入到result中
  3. 向下遍历,1后面是2和3,因此第2层的首先遍历到的是2,此时list = [1,2],加入到result中
  4. 向下遍历,2后面只有3,因此第3层遍历到的只有3,此时list = [1,2,3],加入到result中
  5. 向下无法遍历,因此回溯到第2层,但要去掉上一层遍历的元素,即list由[1,2,3]变为[1,2]。但此时,以[1,2]为节点,除了3之外无法再向下遍历,因此,再回溯到第1层,list变为[1]
  6. 在第1层,因为2已经遍历过,因此,第2层遍历到3,list = [1,3],加入到result中
  7. 无法向下遍历,回溯到第1层,list变为[1],但是因为2和3都已经遍历过,[1]节点也无法再向下遍历,因此再回溯到首层,list变为[ ]
  8. 首层中,按顺序遍历到2,list = [2],加入到result中
  9. 向下遍历到第2层,只有3,因此list = [2,3],加入到result中
  10. 同样,回溯到第1层,list变为[2],再回溯到首层,list变为[ ]
  11. 首层向下遍历,还剩最后的3,list = [3],加入到result中
  12. 至此,回溯法结束,result中已经包含所有的子集

对应到leetcode中的是第78题:https://leetcode-cn.com/problems/subsets/

给你一个整数数组 nums ,数组中的元素 互不相同 。返回该数组所有可能的子集(幂集)。

解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。

class Solution {
    public List<List<Integer>> subsets(int[] nums) {
        List<List<Integer>> result = new ArrayList<>();

        // 深度优先遍历, 回溯算法
        // begin = 0,从头开始遍历
        dfs(0, nums, result, new ArrayList<Integer>());

        return result;
    }

    // 回溯函数
    public void dfs(int begin, int[] nums, List<List<Integer>> result, ArrayList<Integer> list) {
        // 将当前的list加入到result中
        result.add(new ArrayList<>(list));
        // 按顺序遍历,保证了相同位置不会被重复遍历到
        for (int i=begin; i<nums.length; i++){
            // 在list中加入当前遍历到的元素
            list.add(nums[i]);
            // 按顺序向下遍历
            dfs(i+1, nums, result, list);
            // 回溯,去掉当前遍历元素,待放入下一个遍历的元素
            // 每次遍历,都需要回溯操作
            list.remove(list.size() - 1);
        }
    }
}

按照遍历的层数,打印结果:

在这里插入图片描述


排列问题

主要的问题在于相同位置的数不能重复遍历。在子集问题中,解决办法是按照顺序从头遍历,且这个“头”依次后移,这意味着只遍历从“头”到“尾”的部分元素即可,这是由于子集问题结果的特殊性。

但是,在排列问题中,结果的长度和原数组的长度相同,因此,无论从谁开始作为第一个遍历,都必须全部遍历到,且相同位置的数不能重复遍历,因此,==可以维护一个boolean类型的数组,如果对应位置没有被遍历到,则会false,反之,则为true。==每次遍历的时候,做判断,没有遍历到就取出。

同样,以数组[1,2,3]为例,如图:

在这里插入图片描述
回溯过程:

  1. 首层遍历,list = [ ]

  2. 第1层遍历,有3个选择:1、2和3,这里以2为例,将其加入到list中,list = [2]

  3. 第2层遍历,同样有3个选择:1、2和3,但是2已经用过,所以list只能是[2,1]或者[2,3]

  4. 以[2,1]为例,向下到第3层遍历,3个选择中只有3符合,1和2都已经遍历过,则list = [2,1,3],此时,list的长度等于原数组长度,可以作为一个排列结果,加入到result中

  5. 第3层向上回溯,list变为[2,1],但是[2,1]无法再向下遍历,因此再向上回溯,list变为[2],此时,刚刚遍历的是1,现在可遍历3,向下做第3层遍历,list = [2,3]

  6. 。。。。。。

对应leetcode中的第46题:https://leetcode-cn.com/problems/permutations/

给定一个 没有重复 数字的序列,返回其所有可能的全排列。

class Solution {
    public List<List<Integer>> permute(int[] nums) {
        List<List<Integer>> result = new ArrayList<>();
        if (nums.length==0)
            return result;

        dfs(nums, new boolean[nums.length], new ArrayList<>(), result);

        return result;
    }
    
    // 回溯函数
    public void dfs(int[] nums, boolean[] used, List<Integer>list, List<List<Integer>> result){
        // 当前保存的排列已经是最长的排列
        if (list.size() == nums.length) {
            result.add(new ArrayList<>(list));
            return;
        }

        for (int i=0; i<nums.length; i++){
            // 前提是当前数没有遍历过
            if (!used[i]){
                list.add(nums[i]);
                used[i] = true;
                dfs(nums, used, list, result);
                // 回溯时,不仅仅退回当前遍历的数
                // 还要将当前位置的used状态重置为false
                list.remove(list.size() - 1);
                used[i] = false;
            }
        }
    }
}

按照树状图,第3层遍历中的从左到右的打印结果为:

在这里插入图片描述


匹配问题

以leetcode79题为例:https://leetcode-cn.com/problems/word-search/

给定一个 m x n 二维字符网格 board 和一个字符串单词 word 。如果 word 存在于网格中,返回 true ;否则,返回 false 。

单词必须按照字母顺序,通过相邻的单元格内的字母构成,其中“相邻”单元格是那些水平相邻或垂直相邻的单元格。同一个单元格内的字母不允许被重复使用。

在这里插入图片描述
在之前的两类问题中,回溯函数dfs有一个共同点:dfs函数有for循环,循环之中再递归dfs,实际上就是循环+递归的操作:

public void dfs(){
    ...;
    // 循环
    for(...){
        ...;
        // 递归
        dfs();
        ...;
    }
    ...;
}

之所以这样写,是因为之前的问题中对于遍历方式没有要求,从头遍历即可。但是,在79题中,对于遍历方式有明确的要求,因此,dfs里面不可再加循环,而是将循环放在主函数中,再将dfs嵌套在循环内:

public static void main(String[] args){
    ...;
    // 循环
    for(...){
        ...;
        // 递归
        dfs();
        ...;
    }
    ...;
}

在dfs内部,只需按照规定的遍历方式依次遍历即可。上述写法的含义在于:遍历的起点不固定,且按顺序作为起点,之后,从起点开始,按方式依次遍历,判断遍历到的字符串是否匹配。

同时,在此题中,要求相同位置的字母不可被重复使用,因此,维护一个boolean类型的二维数组used,标记各个位置是否被使用过。代码如下:

class Solution {
    public boolean exist(char[][] board, String word) {
        if (word.isEmpty() || word.length()>board.length*board[0].length)
            return false;

        // 以首字母为准,从第一个开始遍历
        for (int i=0; i<board.length; i++){
            for ( int j=0; j<board[0].length; j++){
                // 首字母一样,继续向下遍历
                if (board[i][j] == word.charAt(0)){
                    if (dfs(i, j, board, word, new boolean[board.length][board[0].length], new ArrayList<>())){
                        return true;
                    }
                }
            }
        }

        return false;
    }

    public boolean dfs(int row, int col, char[][] board, String word, boolean[][] used, List<Character> list){
        // 由于每个字母逐一比较是否相同,因此,只要长度相同,必然字符串相同
        if (list.size() == word.length())
            return true;

        // 出界,或者当前字母已经用过
        if (row<0 || row>=board.length || col<0 || col>=board[0].length || used[row][col])
            return false;

        // 当前遍历字母与对应位置的字母不同
        if (board[row][col] != word.charAt(list.size()))
            return false;

        list.add(board[row][col]);
        used[row][col] = true;

        // 按顺时针方向依次对相邻字母遍历
        boolean flag = dfs(row-1, col, board, word, used, list) ||
                dfs(row, col+1, board, word, used, list) ||
                dfs(row+1, col, board, word, used, list) ||
                dfs(row, col-1, board, word, used, list);

        // 如果向下遍历不符合,则回溯
        // 释放当前字母
        if (!flag){
            list.remove(list.size() - 1);
            used[row][col] = false;
        }

        return flag;
    }
}

以示例图片中的数据为例,打印结果:

在这里插入图片描述


剪枝

以上述的树形结构来说,所谓剪枝,是指在向下遍历的时候,可以添加一些判断条件,若是不符合这些条件,则不用再向下遍历,相当于在树形图中“剪去”此分支,这样可以提高效率,不需要全部遍历。

比如第3类问题中的79题,在每次向下遍历的时候,都做了3个判断:

  1. 判断当前遍历的字母和所给单词的对应位置的字母是否相同?若相同,则继续向下遍历,若不同,则不需要再比较,后续无论怎样都不会匹配
  2. 判断数组中此位置的字母是否使用过?
  3. 判断当前的下标有没有出界?
  4. 判断当前字符串是否和所给单词已经匹配?在代码中,是通过比较长度来判断是否匹配的,因为每次加入一个字母,都会经过第1个判断,因此,只要长度相同,那么必定匹配

以上4点判断,我觉得可以作为剪枝方法,在向下遍历的时候,可以省去很多不必要的遍历!

我个人理解,剪枝没有既定的算法,都是根据题目的实际情况而定,因此,如果用过回溯算法,最好是多考虑考虑是否可以剪枝?因为回溯算法属于穷举类型的方法,如果数据量很大,那么时间消耗还是比较大的,加上剪枝算法之后,可以有效减少时间消耗。


总结

还有很多回溯算法题,但是我觉得目前这三种给可以作为一个模板,在用导回溯方法的时候,可以从以下几个方面先做思考:

  1. 每次首先遍历的起点是否固定?

    如果起点固定,或者对于起点有特殊要求,那么可以考虑将起点的遍历方式单独拿出来,放在调用dfs的函数中,此时dfs函数主要关注的是向下遍历的方式,不需要考虑起点的问题

  2. 相同位置的元素是否可以重复使用?

    这个问题一般都是维护一个used数组作为标记。但是也不一定真的需要,比如子集问题中,同样要求相同位置的元素不能重复使用,但是通过特殊的遍历方式,可以左到不重复遍历。因此,这也是视情况而定的,如果拿不准的话,那就粗暴点,维护个used数组,反正不会错。

  3. 向下遍历的方式是否有要求?

    这一点主要体现在dfs函数内部的构造,如果没有要求遍历方式,为了达到穷举的目的,所以在dfs函数内部一般都会有循环体。但是,如果有对遍历方式做特别要求,那么就得重新考虑如何递归dfs,比如上面的单词匹配问题。

  4. dfs函数是否需要返回值

    这个得看结果需要什么?如果仅仅是证明是否可以达到一个要求?比如单词匹配问题中就只是看是否能够匹配到单词,那么可以添加一个boolean返回值,如果匹配到了则返回true,这样不仅仅得到结果,而且还能省去之后的时间开销。但是,如果要求具体的值,那么返回值就没必要存在,反正是遍历到底。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值