回溯算法题(一)

130 阅读5分钟

前情提要

来这家公司也一年半了,公司同事走了一批又一批,我都快成部门最老的员工了,部门管理也越来越差,索性想趁着最近的金(tong)三银(tie)四的尾巴,看看能不能换个好(qian)点(duo)的工作,再加上女朋友也一直催我找她过去一起干饭,奈何自身实力不够,只能从长计议,都说现在行情更差了,但是不去试试终究是小马过河,最近在刷算法题,就做个记录,方便复习,祝大家早日上岸。

回溯算法的定义

         回溯算法实际上一个类似枚举的搜索尝试过程,主要是在搜索尝试过程中寻找问题的解,当发现已不满足求解条件时,就“回溯”返回,尝试别的路径。回溯法是一种选优搜索法,按选优条件向前搜索,以达到目标。但当探索到某一步时,发现原先选择并不优或达不到目标,就退回一步重新选择,这种走不通就退回再走的技术为回溯法,而满足回溯条件的某个状态的点称为“回溯点”。

用回溯算法的解题思路:

  • 针对所给问题,定义问题的解空间,它至少包含问题的一个(最优)解。

  • 确定易于搜索的解空间结构,使得能用回溯法方便地搜索整个解空间 。

  •  以深度优先的方式搜索解空间,并且在搜索过程中用剪枝函数避免无效搜索。

        通俗点说就是:

  • 确定backtrack回溯函数的入参和返回值
  • 确定函数终止的边界条件,避免死循环,然后把符合问题的解存到结果中
  • 确定函数内部for循环的逻辑,确定剪枝函数的限制条件

        回溯算法的通用代码模版一般如下:

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

核心就是for循环里面的选择,确定剪枝函数的限制条件然后做递归,递归之后撤销选择回到上一步状态。

今天先看一下全排列类型的题。

全排列 I

给定一个 没有重复 数字的序列,返回其所有可能的全排列。来源:leetcode 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. 确定for循环里面的逻辑,确定剪枝函数,这里的剪枝函数就是上一步已经选择过的数字,这一步不能再选择,因为再选择的话是会导致出现[1,1,1],[2,2,2],[3,3,3]这种不符合题解的结果的,这个条件就是剪枝函数的条件。

上代码:

function combination(arr) {
    const result = [];
    function backtrack(path, used) {
        // 入参 path代表一个全排列结果 used记录每一步的数字有没有被选择过
        // 终止条件:当当前路径的长度等于数组长度时,说明找到一个全排列子集
        if (path.length === arr.length) {
            result.push([...path]); // 将当前路径拷贝一份加入结果集
            return;        
        }
        // 遍历数组元素        
        for (let i = 0; i < arr.length; i++) {
            // 跳过已经使用过的元素
            if (used[i]) continue;
            // 做出选择:将当前元素加入路径,并标记为已使用
            path.push(nums[i]);
            used[i] = true;
            // 递归调用,继续往下选择
             backtrack(path, used);
            // 撤销选择:回溯到上一层状态
            path.pop();
            used[i] = false;
        }
    }
    // 初始调用回溯函数
    backtrack([], []);
    return result;
}
combination([1,2,3]) //[1,2,3] [1,3,2] [2,1,3] [2,3,1] [3,1,2] [3,2,1]

网上找了一张图,配合理解起来比较容易:

        上图树中的每一个节点都代表了递归中的不同阶段,每一个阶段都有相应的状态来体现,就是代码中path的值,当当前状态满足题解条件的时候,这个状态就是我们想要的值,也就是全排列的一种解。

        在使用深度遍历优先的时候,有一个‘回头’的过程,这个过程就是回溯,回溯完成之后需要把当前状态重置成上一步的状态,代码里就是对path进行pop操作,撤销上一步push选择的值,从而继续选择没有选择过的节点,回溯算法就是在不断的选择和撤销中找到符合条件的解。

看起来很简单,但是对于第一次接触回溯算法并且之前没有了解过二叉树和二叉树的深度优先搜索的人来说递归这块还是有点绕的,作者刚开始做的时候总是想搞明白递归的每一步中的path的值和当前递归到哪一步了,然后就被绕进去了,建议理解起来费劲的小伙伴先去看一下二叉树相关的知识,再回来看这道题理解起来就没有那么难了。

下面是一个升级版的全排列:

全排列 II

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

来源: leetcode 47.全排列

II

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

这题和上面那道题的区别就是给定的数组中出现了重复数字,如果还和上面一样的写法的话,那么会出现很多重复的排列结果,因为数组中前两个数是重复的,所以两个元素1只能选择一次,选择第一个1之后,第二个1在选择的时候就得剪掉,所以这道题的解法重点就在剪枝函数的条件上。

这时候有聪明的小伙伴就要说了,我用上面的解法得到结果之后再对结果进行去重不可以吗?

从理论上来讲,可以是可以,且不说对二维数组去重比较麻烦,就算你写出来了,面试官看到之后也会两眼一黑,所以咱们还是要从根源上解决问题。

直接上代码:

function combination(arr) {
     const result = [];
    // 对给的数组进行排序,方便后续剪枝函数判断元素是否已经在前面被选择过    
    arr = arr.sort((a,b) => a-b)    
    function backtrack(path, used) {        
        // 终止条件:当当前路径的长度等于数组长度时,说明找到一个全排列子集        
        if (path.length === arr.length) {
            result.push([...path]); // 将当前路径拷贝一份加入结果集
            return;        
        }
        // 遍历数组元素        
        for (let i = 0; i < arr.length; i++) {  
            // 注意 这里的剪枝判断条件就不单单是跳过已使用过的元素了             
            // 跳过已经使用过的元素            
            if (used[i]) continue;
            // i>0:  当i>0也就是当前循环轮次不是第一次的时候
            // arr[i] === arr[i-1] && !used[i-1]: 经过上面的排序可知重复元素是挨着的所以当相邻两个元素为重复元素时,且当前元素的上一个元素还没被选择过的时候,跳过
            if (i > 0 && arr[i] === arr[i-1] && !used[i-1]) continue;
            // 做出选择:将当前元素加入路径,并标记为已使用
            path.push(nums[i]);            
            used[i] = true;
            // 递归调用,继续往下选择 
            backtrack(path, used);
            // 撤销选择:回溯到上一层状态
            path.pop();
            used[i] = false;
        }    
    }    
    // 初始调用回溯函数
    backtrack([], []);    
    return result;
}
combination([1,1,2]) //[1,1,2] [1,2,1] [2,1,1]

其实是有点绕的,不太清楚的咱们看图:

这道题的难点和不易理解的点是上面的剪枝函数判断条件:i > 0 && arr[i] === arr[i-1] && !used[i-1]

  • 首先i>0排除第一个元素(因为首位元素没有前一位进行比较)

  • 其次nums[i]==nums[i-1]表示相邻元素需要相等,由于提前对数组进行排序,所以相等的元素必然相邻。

  • 最后!used[i-1]是核心和难点,因为在回溯问题中,我们做出决定进行递归完成后,会撤销选择。**因此在同一树层遍历所有路径时,无论相邻的前一树枝路径nums[i-1]是否能走通,遍历到当前树枝路径nums[i]时,used[i-1]都会为false,即已经撤销之前路径的决定。**而当nums[i-1]设置为true后,它只有在树状图的下一树层才会维持true的状态,当在子树层遍历到同一个nums[i]元素时,哪怕nums[i]==nums[i-1],此时却可以仍然选择该路径,而当遍历不同树枝路径时,nums[i]==nums[i-1]的情况下,nums[i-1]为false,所以该方法只进行树层去重,而对于树枝去重不进行干涉。

所以全排列的题难点就是在于剪枝函数的限制条件,能找到限制条件,题目也就迎刃而解了。

以上就是回溯算法中关于全排列的题,下期再总结回溯算法中子集相关的题,诸位加油。