递归与回溯的应用

412 阅读4分钟

主要的应用场景就是一些需要列举的情况,这里是跟着小册子做的练习记录,包含全排列子集组合三道中等题。
另外,语法树生成器推荐:mshang.ca/syntree/

全排列【力扣46】

给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。

示例

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

思路

image.png

要做的事情就是,给nums.length个位置填入数字,可以用树来表示,树的每一层代表排列的一个位置。在填数字的过程中需要遵守的规则:

  • 填入的数字在排列中没有填过,也就是每个结点不能与祖先结点相同
  • 没有填过的数字都可以填在这个位置,也就是每个结点的子结点,应该包括所有不是自己的祖宗的数字
  • 当所有位置都被填上时,这个排序完成,结束填数字。即深度为nums.length的结点为叶子结点 使用递归来完成填数字的重复操作,代码如下:

代码

var permute = function(nums) {
    const len=nums.length;
    let visited={};//映射num是否在当前排列中使用过
    let curr=[];//存放当前排列
    let res=[];//存放最终的结果
    let nth=0;//坑位号
    var dfs=function(nth){
        if(nth===len){//到达递归边界
            res.push(curr.slice());//将当前排列放入排列数组中
            return;
        }
        for(let i=0;i<len;i++){
            if(!visited[nums[i]]){
                visited[nums[i]]=1;//未访问过的数据放入排列并标记为已访问
                curr.push(nums[i]);
                dfs(nth+1);//搜索下一个坑位
                curr.pop();//让出坑位给下一个数字使用
                visited[nums[i]]=0;
            }
        }
    }
    dfs(nth);//从第一个位置开始遍历
    return res;
};

升级版:全排列Ⅱ 力扣47

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

示例

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

分析

image.png

如图示:

  • 第一个坑位遍历到nums[1]的1时不能放入,会和放入num[0]的1的结果重复
  • 当前队列为[1,null,null]时,第二个坑位可以放入nums[1],因为nums[0]在第一个坑位被使用 因此,只需要保证 未被使用的重复数字在同一坑位只被使用一次即可满足不重复的要求。 为了方便判断num的重复,可以对nums先排列,只需要判断nums[i]===nums[i-1]即可

代码

var permuteUnique = function(nums) {
    const len=nums.length;
    let visited=new Array(len).fill(false);//映射num是否在当前排列中使用过,因为nums中包含重复数字,因此不能用Map
    let curr=[];//存放当前排列
    let res=[];//存放最终的结果
    let nth=0;//坑位号
    var dfs=function(nth){
        if(nth===len){//到达递归边界
            res.push(curr.slice());//将当前排列放入排列数组中
            return;
        }
        for(let i=0;i<len;i++){
            if(visited[i]||(i>0&&nums[i]===nums[i-1]&&!visited[i-1])){//跳过:①已在当前排列的数字②重复数字
                continue;
            }
            visited[i]=1;//未访问过的数据放入排列并标记为已访问
            curr.push(nums[i]);
            dfs(nth+1);//搜索下一个坑位
            visited[i]=0;//让出当前坑位
            curr.pop();
        }
    }
    nums.sort((a,b)=>a-b);//排序
    dfs(nth);//从第一个位置开始遍历
    return res;
};

子集 力扣78

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

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

示例

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

思路

nums中的每一个数字都有在子集中和不在子集中的两种情况,如图:

image.png

遍历每个数字,将放入该数字和不放入该数字的情况都继续遍历下去,每次变化都是一个新子集,放入解集。

代码

var subsets = function(nums) {
    const subset = [];//当前子集
    const len = nums.length
    const res = [];//子集集合
    const dps = function(index){
        res.push(subset.slice());//每传入一次index都发生改变,都是不同的子集,放入数组
        for(let i=index;i<len;i++){
            subset.push(nums[i]);//有num[i]的情况
            dps(i+1);//继续向下遍历;
            subset.pop();//没有nums[i]的情况
        }
    }
    dps(0);
    return res;
};

升级:限定组合问题【力扣77】

给定两个整数 n 和 k,返回范围 [1, n] 中所有可能的 k 个数的组合。

你可以按 任何顺序 返回答案。

示例

输入:n = 4, k = 2
输出:
[
  [2,4],
  [3,4],
  [2,3],
  [1,2],
  [1,3],
  [1,4],
]

思路
在前一题的基础上修改递归边界就可以了,只有当子集长度为k时需要推入结果数组,并且不再向下遍历。(剪枝)
代码

var combine = function(n, k) {
    const res=[];
    const subset=[];

    const dfs=function(num){
        if(subset.length==k) { 
            res.push(subset.slice());
            return;
        }
        for(let i=num;i<=n;i++){
            subset.push(i);
            dfs(i+1);
            subset.pop();
        }
    }
    dfs(1);
    return res;
};

递归与回溯问题解答模板

function xxx(入参) {
  //前期的变量定义、缓存等准备工作 
  
  // 定义路径栈
  const path = []
  
  // 进入 dfs
  dfs(起点) 
  
  // 定义 dfs
  dfs(递归参数) {
    if(到达了递归边界) {
      结合题意处理边界逻辑,往往和 path 内容有关
      return   
    }
    
    for(遍历坑位的可选值) {
      path.push(当前选中值)
      处理坑位本身的相关逻辑
      path.pop()
    }
  }
}