[重学算法]基础算法-递归

273 阅读5分钟

序言

以前学校虽然学过算法,但是当初有种畏惧感,没有认真学习。直到成为前端工程师,也还是对算法有所畏惧。但是,战胜恐惧的最好办法就是面对恐惧。

于是,憨憨就在 leetcode 上注册了账号,开始每日一题。emmm,那是一个惨痛的经历,弱小无助,没有思路,无从下笔,慢慢的,怯意涌上心头。这时候,只能从题解区寻找帮助了,嗯!读懂了,脑子里C,手写V,提交的那一刻,仿佛自己又变为了 CV 工程师了。过了几天,我放弃了。

一是因为确实不会做,而是因为对算法的畏惧感越来越强。这时候,同事伸出了援手(他从去年就开始在力扣上游走出没了):算法要先学习基础,然后挑题目开做,没有思路可以参考官方题解,尝试多用几种方法,最后需要进行总结归纳。在学习有关空间复杂度,时间复杂度等基础知识的前提下,就有了本篇--递归篇。

递归

我就用百度百科的知识了,还是建议看更权威的文档,我就是随意总结一下。

定义

程序调用自身的编程技巧称为递归。嗯,简单明了,自己调用自己。

条件

怎么才能创建递归呢:

  • 子问题必须与原始问题为同样的问题,并且更为简单;
  • 不能无限制的调用本身,必须有个出口,化简为非递归状况处理。

应用

主要解决三类问题:

  1. 数据的定义时按递归定义的,如 斐波拉西函数(Fibonacci);
  2. 问题揭发按递归散发实现的,如 汉诺塔(Hanoi);
  3. 数据的结构形式是按递归定义的, 如 二叉树(Binary)。

缺点:

  • 运行效率低
  • 容易栈溢出

力扣题

1. 二叉树的层次遍历 II

这是同事推荐我做的 9 月份每日一题中的一题,题号107,具体如下:

给定一个二叉树,返回其节点值自底向上的层次遍历。 (即按从叶子节点所在层到根节点所在的层,逐层从左向右遍历)

例如: 给定二叉树 [3,9,20,null,null,15,7],

    3
   / \
  9  20
    /  \
   15   7

返回其自底向上的层次遍历为:

[ 
  [15,7],
  [9,20],
  [3]
]

设计知识

看到二叉树,我又是拒绝的,数据结构学过,有些生疏,同事推荐使用深度优先算法或广度优先算法。好家伙,一下子把力扣题型的 二叉树、递归、深度优先、广度优先 全考了。

先简单回忆一下

二叉树:
- 定义:树形结构的重要类型,关键是递归定义,一颗空树或者一颗根节点和两颗互不相交的,
    分别称为跟的左子树和右子树组成的非空树;左右子树同样是二叉树。
- 二叉树遍历:按照一定规则和顺序走遍二叉树的所有节点,每个节点都被访问并仅访问一次。
深度优先算法
1、把根节点压入栈中。
2、每次从栈中弹出一个元素,搜索所有在它下一级的元素,把这些元素压入栈中。
	并把这个元素记为它下一级元素的前驱。
3、找到所要找的元素时结束程序。
4、如果遍历整个树还没有找到,结束程序。
广度优先算法
1、把根节点放到队列的末尾。
2、每次从队列的头部取出一个元素,查看这个元素所有的下一级元素,把它们放到队列的末尾。
	并把这个元素记为它下一级元素的前驱。
3、找到所要找的元素时结束程序。
4、如果遍历整个树还没有找到,结束程序。

解决

广度优先

思路:广度优先可以一层一层的遍历二叉树,然后将得到的数组倒叙就可以了。

问题: - 不知道遍历到那一层:这一点在广度优先算法的概念上有突破口:将根节点放在队列的末尾。 那么,每次队列清空的时候,就进入到下一层了。 - 什么时候遍历结束:当队列清空,并且没有下一个未遍历的节点 实现:

function levelOrderBottom = function (root) {
	if (root === null) return []; // 这个是个大坑
    let arr = [root]; // 顶节点放入数组
    let res = []; // 需要返回的结果数组
    while (arr.length) { // 通过数组长度控制循环进程
		let n = arr.length;
        let temp = []; // 存放每层数据的暂时数组
        for (let i = 0; i < n; i++) { // 这一步就是判断层级
        	let temp = arr.shift(); // 这个是关键,将数组中的第一个数取出来
        	temp.push(m.val); // 将取出的数放入临时数组
            // 判断左右子节点是否存在,存在就放入数组
            if (m.left) arr.push(m.left);
            if (m.right) arr.push(m.right);
        }
        res.push([...temp]); // 将临时数组的浅拷贝放入结果数组
    }
    return res.reverse(); // 返回结果数组的反转数组
}

时间复杂度:O(n)--每一个节点放入数组进行 while 循环,而 while 中的 for 循环是常数级的(n 很小) 空间复杂度:O(n)-- 效果:执行用时: 76 ms,内存消耗: 40.1 MB

深度优先

终于到本篇的重点了,设计遍历的深度优先算法

思路:通过存储对象 obj 和层数计数 count,配合递归函数遍历每一个节点。当对象中存在层数键值,就在对象对中键值对应的数组中添加节点值,否则单独建新建层数键,存放再将包裹节点值的数组。接着是递归的出口--节点是否有左右子节点,有接进入下一层递归,否则跳出递归。

实现:

var levelOrderBottom = function (root) {
	if (root === null) return [];
	let count = 0; // 层数计数
    let obj = {}; // 存放对象
    const dfs = function (node, count) {
    	if (obj[count]) obj[count].push(root.val); // 对象中存在该层对应键值,就添加到对应数组中
        else obj[count] = [root.val]; // 否则新建层,添加数组
        // 递归出口
        if (root.left) dfs(root.left, count + 1);
        if (root.right) dfs(root.right, count + 1);
    }
	dfs(root, 0);
    let res = [];
    // 对象转数组
    for (key in obj) {
    	res.push(obj[key]];
    }
    return res.reverse();
}

时间复杂度:O(n),同样递归遍历了整个数组;

空间复杂度: O(n);

结果:执行用时: 80 ms,内存消耗: 40.2 MB

2. 组合

再来一道中等难度的题来巩固一下递归,题号77,题目如下:

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

示例:

输入: n = 4, k = 2

输出:

[
  [2,4],
  [3,4],
  [2,3],
  [1,2],
  [1,3],
  [1,4],
]

吐槽

在以前数学题的印象中,题干越简单,题目可能越复杂,头疼,怎么设计递归呢,画图: 灵魂画图 图示是 n=6,k=3 时的示意图,从图分析,从顶部向下分为分支,在没有到最后一层前,除本身外的数都是成立的,到下一层,就是关键的一层(层数等于 k),该层从左到右,可选项逐步减小,直到最后 6 无可选项(无法组成三个数)

思路

递归传入 1 到 n 所对应的位置以及当前层数,优点类似于之前的广度优先算法。

    1. 每次传入递归的位置加一,代表该层的数遍历
    1. 如果对应位置没有数,就对应图示的最后一种无法组成 k 的元素的情况,需要剪枝这时候递归要跳出,返回到上一层
    1. 每一层遍历完后又会进入下一层,层数加一,接着第一步,直到层数等于 k 跳出

这里会用到涉及到二进制枚举、回溯算法、剪枝,是一道中等题了。

实现:

var combine = function (n, k) {
	if (n === 0 || k ===0 || n < k) return [];
    let res = [];
    let temp = [];
    function dfs (pos, count) {
    	if (pos > n) return; // 位置 +1 就是对应的数,不能超出 n,否则跳入上一层 -- dfs(pos + 1, count)
        if (count === k) { // 层数等于 k,记录合法答案
        	res.push([...temp]);
            return;
        }
        dfs(pos + 1, count); // 考虑当前位置
        temp.push(pos + 1); // 存入每层递归前的顶点数
        dfs(pos + 1, count + 1); // 不考虑当前位置
        temp.pop(); // 将临时数组清空,存放下一层的数
    }
    dfs(0, 0) // 启动递归
    return res;
}

官方的写法更简洁

var combine = function (n, k) {
	const ans = [];
    const dfs = (cur, n, k, temp) => {
    	if (temp.length + (n - cur + 1) return; // 剪枝
        if (temp.length === k) {
        	ans.push(temp);
            return;
        }
        dfs(cur + 1, n, k, [...temp, cur]); // 考虑当前位置
        dfs(cur + 1, n, k, temp); // 不考虑当前位置
    }
    dfs(1, n, k, []);
    return ans;
}

总结

个人觉得递归的写法很帅,不过需要认真考虑传入的初始实参,以及递归跳出条件。后面还会有补充其他的涉及遍历的算法,听说后面还有 N 皇后等问题,路长且艰,砥砺前行。