本文的目的
让大家明白明白什么是递归,如何运用递归,递归的使用场景
什么是递归
- 函数内部自己调用自己就是递归
- 递归分为两个部分
- 递归头:什么时候不调用自己方法,为函数的结束条件
- 递归体:什么时候需要调用自己方法,即自己调用自己
用递归解决斐波那契数列问题
什么是斐波那契数列
数列第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)
斐波那契数列初级解法的时间和空间复杂度
首先可以依照递归解法画出结构图
图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]
}
})()
带备忘录的斐波那契数列解法的时间和空间复杂度
首先可以依照递归解法画出结构图
- 首先先解释一下缓存 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项……,一直这样无限压栈,但是人脑不是电脑,我们无需关注这些问题,我们只要去寻找相同数据结构的子问题就行了。如果你还要一直去陷入递归的话,我只能告诉你,当你遇到一个想不明白的问题的话,那就不要去想它,这样就没有问题了。