递归与回溯思想--小册专项练习笔记

511 阅读5分钟

这是我参与更文挑战的第5天,活动详情查看: 更文挑战

递归与回溯思想的应用

从全排列看递归

全排列在我们以前的学习中,其实大家都知道,就是一种对所有排列顺序的穷举,换成更通俗的说法,假如有三个数,我们每一次对一个坑位看手里有什么数,因为每一个坑位放一个数,直到最后一个坑位就可以得到一个排列。这其中我们发现涉及到一个事情,就是我们在每一个坑位做的事情其实是一样的,我们也知道穷举的事情,我们都会用递归,那么既然涉及到递归,我们就需要一个边界,我们假设每次的坑位有一个下标,那么总共的坑位数就是n,而下标从0开始的话,最后一个的下标就是n-1,等我们找到第n个坑位的时候就是边界了,因为我们根本找不到下标为n的坑位,那我们用代码来实现一下:

 function permute(nums) {
    // 定义数组的长度
    const len = nums.length
    // 定义一个数组用来记录当前排列的内容
    const curr = []
    // 定义一个对象,用来存放使用过的数组
    const visited = {}
    // 定义一个数组用来存放已有的排列
    const res = []
    // 定义dfs函数,入参是坑位的索引(从0计数)
    function dfs(num) {
        // 这里就是边界
        if(num === len) {
            // 此时前 len 个坑位已经填满,将对应的排列记录下来
            res.push(curr.slice())
            return
        }
        // 检查手里剩下的数字有哪些
        for(let i = 0; i < len; i++) {
            // 如果这个数字在之前的坑位中都没用过
            if(!visited(nums[i])) {
                // 记上一个标记
                visited[nums[i]] = 1
                // 将当前的这个数放入curr中
                curr.push(nums[i])
                // 基于这个排列继续往下一个坑走去
                // 这里下一个坑是在前一个坑的基础上,
                // 所以是得保留已经放入坑的数的状态,
                // 递归到最后就是获得一个数组
                dfs(num+1)
                // 走到这里是已经完成上一个遍历了
                // 所以需要取消当前数的已使用的标记状态,并将这个数排出当前数组中
                // nums[i]让出当前坑位
                curr.pop()
                // 下掉“已用过”标识
                visited[nums[i]] = 0
            }
        }
    }
    // 从索引为0的坑位开始
    dfs(0)
    return res
}

这里我们要注意两点:

  • 为什么要用Map的结构来标记?因为我们在没用一个数字的时候,避免已经用过的数字被重复使用,而当这个数字让出这个坑位的时候,就需要取消他的visited状态

  • 第二点就是走到递归边界的时候,我们为什么不是用push(curr),而是要用push(curr.slice()),因为curr是一个全局的变量,他的值会随着dfs函数的执行而更新,因此我们用slice方法的目的是拷贝出一个不影响curr的副本,以防直接修改到curr的引用

组合问题:变化的“坑位”,不变的“套路”

真题:

题目描述:给定一组不含重复元素的整数数组 nums,返回该数组所有可能的子集(幂集)。 说明:解集不能包含重复的子集。

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

题目其实也很好理解,跟我们上一题其实差不多,也是一个DFS的类型,涉及到DFS的话,就不可少的想到递归,树形这些知识点,上一题是每个坑位放什么数,这一题是每个坑位放不放数,所以总的思想是一样的,我们直接看代码:

function subsets(arr) {
    // 定义一个结果数组
    const res = []
    // 定一个边界
    const len = arr.length
    // 定义一个变量存放组合
    let subset = []
    // 定义递归函数
    function dfs(index) {
        // 每次进入这个函数,说明有了一种新的排列,所以将副本放入结果数组
        res.push(subset.slice())
        // 从当前的index开始索引
        for(let i = index; i< len; i++) {
            // 将当前的 数字放入数组
            subset.push(arr[i])
            // 在对下一个索引开始递归
            dfs(i+1)
            // 走到这一步说明上一个情况已经完成了,因此释放这个数
            subset.pop()
        }

    }

    // 从index为0开始
    dfs(0)

    // 最后返回结果
    return res
}

console.log(subsets([1,2,3,4,5]))

经过上面的思路我们发现,其实主要是在于如何递归,因此我们需要考虑清楚,这道题的主要的需要递归的地方在哪里,这一步想清楚了,我们就能下手了。

啥叫回溯啊

一天这词,高端啊,回溯,是那个时间回溯不?我现在一念回到3秒前,我是时间刺客!对不起,走错片场了!其实,我们已经经历过回溯法了,回溯法其实是一种优先算法,即我们尝试一种选优条件去搜索,但是当我们没办法得到我们想要的目标时,我们在退回一步重新去走另外一条路,是不是似曾相识,我们在上一节中就描述过DFS这种深度优先搜索,那么我们可以将回溯看成是DFS回退这一步的动作,其实两者追究到底其实讲的基本是一回事。

总结

其实,递归与回溯两者相依存在,有递归的地方就有回溯,所以我们一般会将两者结合起来理解学习,那么你学废了吗?