算法之由斐波那契数列深入递归

1,915 阅读7分钟

本文的目的

让大家明白明白什么是递归,如何运用递归,递归的使用场景

什么是递归

  • 函数内部自己调用自己就是递归
  • 递归分为两个部分
    • 递归头:什么时候不调用自己方法,为函数的结束条件
    • 递归体:什么时候需要调用自己方法,即自己调用自己

用递归解决斐波那契数列问题

什么是斐波那契数列

数列第1和第2项为1,从第3项开始,每一项都等于前两项之和
1、1、2、3、5、8、13、21、34、...,n 

求斐波那契数列的第 n 项

斐波那契数列初级解法

斐波那契数列初级解法分析与代码
  • 我们来分析一下这个问题,根据递归的定义我们只需要找出递归的两个部分递归头和递归体
  • 递归头:数列的第1项和第2项是确定的,所以当输入的 n 为1或者2时直接返回1即可
  • 递归体:数列的第3项开始每一项都是前两项之和,所以比如传传入的 n 为5时,第5项的结果为第4项和第3项之和,而第4项又是第3项和第2项之和,以此类推。
  • 由此我们可以得到斐波那契数列的递归解法
function fib(n) {
    if (n===1 || n===2) {
        return 1;
    }
    return fib(n-1) + fib(n-2);
}
const res = fib(100)
斐波那契数列初级解法的时间和空间复杂度

首先可以依照递归解法画出结构图

01.jpg

图1

  • 时间复杂度:可以从图中看出我们要求第5项的话可以分解为第4项和第3项之和,由此类推。每一个父项都会裂变为2个子项。而树的高度为 n 减1,那么这里时间复杂 O(n) 为2的 n 次方
  • 空间复杂度:这里假如要求第5项,那么执行栈的要从求第4项-->第3项-->第2项,到达第2项以后即到达出口,那么这里压栈的深度为n-1, 即空间复杂度 O(n) 为 n
斐波那契数列初级解法缺点

我们可以看到,随着 n 的增长,计算第 n 项所需要的时间的是爆炸性的指数级增长,这是很难以接受的。

带备忘录的斐波那契数列解法

带备忘录的斐波那契数列解法分析与代码
  • 观察图1,我们可以发现,为了计算第 5项,我们共需要计算1次第4项,2次第3项。我们可以想象一下,假如我们要找第9999项,那么我们肯定需要计算非常多次第3项的,然而这些计算真的是有必要的吗?而正是这些重复的计算就导致了该算法时间复杂度的爆炸性增长。
  • 那么知道问题的话解决办法也很简单,只要我们把以前计算的结果缓存起来即可。
const fib = (function () {
    const memo = {
        1: 1,
        2: 1,
    };
    return function _getFib(n){
        if (memo[n]) {
            return memo[n];
        }
        const res = _getFib(n-1) + _getFib(n-2);
        memo[n] = res;
        return memo[n]
    }
})()
带备忘录的斐波那契数列解法的时间和空间复杂度

首先可以依照递归解法画出结构图

02.jpg

  • 首先先解释一下缓存 memo 对象取值和新增属性时间复杂度都为 O(1),我们知道 js 代码是运行在 V8 引擎,而 V8 使用 C++ 实现,里面的对象实现采用的是 hash 映射的方式,取值和新增属性和属性值都可以认为时间复杂度是O(1),这里不再展开叙述。
  • 时间复杂度:由图2可以看出如果要求第5项,我们不再需要一直裂变下去,而是变成了一条直线,因为每次即将裂变的时候我们都可以在缓存里面找到对应的值,时间复杂度直接变为了O(n),已经和没有优化之前的解法有了本质区别。
  • 空间复杂度:这里空间复杂度还是没有改变的,当然空间复杂度也是有办法降低的,这里先挖一个坑,以后会在动态规划里面讲到。

从斐波那契数列看分析递归

我们可以看出想要求出第 n 项,那么我们必须得知第 n-1 和 n-2 项,以此类推。那么第 n 项便分解为求 n-1 和 n-2 项的子问题,而求第 n-1 的解法和第 n 项是没有区别的。就是说第 n 项的数据结构和第 n-1 项的数据结构是完全相同的。这就是为什么我们可以把求第n项分解为求n-1项。这也是递归的本质,把父问题分解为相同数据结构的子问题

K个一组翻转链表

给你一个链表,每 k 个节点一组进行翻转,请你返回翻转后的链表。

k 是一个正整数,它的值小于或等于链表的长度。

如果节点总数不是 k 的整数倍,那么请将最后剩余的节点保持原有顺序。

来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/reverse-nodes-in-k-group

这是 leetcode 中的第25题,难度为困难,我们按照之前总结出来的经验来尝试解决这一个问题。其实重点就在于寻找相同数据结构的子问题。

/**
 * Definition for singly-linked list.
 * function ListNode(val) {
 *     this.val = val;
 *     this.next = null;
 * }
 */
/**
 * @param {ListNode} head
 * @param {number} k
 * @return {ListNode}
 */
var reverseKGroup = function(head, k) {
    if (k<=1 || head===null || head.next===null) {
        return head;
    }
    let l = head;
    let count = 0;
    while (count<k-1 && l && l.next ) {
        l = l.next;
        count++;
    }
    if (count===k-1) {
        const right = l.next;
        l.next = null;
        const left = head;
        const rLeft = reverse(head);
        
        // 这里right的数据结构是和head相同,直接使用函数翻转即可。
        const nRight = reverseKGroup(right,k);
        let rL = rLeft;
        while (rL && rL.next) {
            rL = rL.next;
        }
        rL.next = nRight;
        return rLeft;
    } else {
        return head;
    }
};

// 翻转链表
function reverse(head){
    if (head===null || head.next===null) {
        return head;
    }
    let l = head;
    while (l && l.next && l.next.next) {
        l = l.next;
    }
    const newHead = l.next;
    l.next = null;
    const right = head;
    
    // right链表的的数据结构是和head相同的
    newHead.next = reverse(right);
    return newHead;
}
  • 对于初学者来说这个翻转的问题是极为困难的,但是该例题目的在于告诉大家一个道理,就是递归就是分解为相同数据结构的子问题,而非让大家去搞懂这一题的解法。再难的递归题目都是去寻找相同数据结构的子问题。

递归总结

  • 父问题过于复杂,却可以分解为相同数据结构的子问题,有确定的出口
    • 要找第 n 项的话可能过于复杂,而我们观察到求 n-1 项是和求第 n 项是完全相同的,那么我们就把问题直接交给 n-1 项就行了,以此类推,直到到达最低层的出口。
  • 例题:求一个班有多少学生。答案:一个学生+剩下的学生。
  • 例题:假设你正在爬楼梯。需要 n 阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?分析:想要爬到 n 阶,先爬到第 n-1 阶再走一步,或者先爬到 n-2 阶再走2步。好了我们已经把 n 的问题分解给了 n-1 和 n-2 了,读者可以自行尝试解决该问题。

递归奥义

其实我们上面所做的所有的叙述都是为了说明一个问题,就是递归本质就是寻找相同数据结构的子问题,只要找到了子问题,那么父问题的答案自然就解决了。新手刚入局递归的时候可能会陷入递归的泥潭,一直从第 n 项到第 n-1 项,第 n-2项……,一直这样无限压栈,但是人脑不是电脑,我们无需关注这些问题,我们只要去寻找相同数据结构的子问题就行了。如果你还要一直去陷入递归的话,我只能告诉你,当你遇到一个想不明白的问题的话,那就不要去想它,这样就没有问题了。