个人主页:《爱蹦跶的大A阿》
当前正在更新专栏:《VUE》 、《JavaScript保姆级教程》、《krpano》、《krpano中文文档》
✨ 前言
递归是一种常见的编程技巧,它意味着一个函数在其函数体内调用自身。递归函数在遇到基线条件时会停止调用自身,否则会陷入无限循环。
在JavaScript中,由于其单线程运行时的特点,递归函数的内部调用会被添加到调用栈(call stack)中。调用栈以LIFO(后进先出)的顺序运行,当函数执行完毕后会从栈中弹出。
✨ 正文
一、原理
递归函数的工作原理:
- 定义一个递归函数,指定输入参数和返回值。
- 在函数内部,添加一个条件来检查参数是否满足基线条件(停止递归的条件)。
- 如果参数满足基线条件,则直接返回结果。
- 如果参数不满足基线条件,则调用自身函数传入更新后的参数,并返回函数调用的结果。
一个计算 factorial 的示例:
function factorial(n) {
if (n === 1) return 1;
return n * factorial(n - 1);
}
factorial(5); // 120
在这个例子中,如果n等于1,则直接返回1。否则将调用自身函数factorial,传入n-1,并将返回值与n相乘,这种递归调用会一直进行直到n等于1。
随着递归深度的增大,调用栈也会不断地增长,函数调用会被添加到栈的顶部。当基线条件满足返回结果时,栈顶的函数会开始出栈,递归树才会逐步收敛。
如果递归函数无法满足基线条件,调用栈会持续增长直到抛出"Maximum call stack size exceeded"错误。所以编写递归时需要注意控制最大深度。
递归函数的执行过程可以通过栈的Push和Pop来模拟,更直观地展示递归函数的运行机制。
以计算factorial(5)为例:
- 当调用factorial(5)时,由于n不等于1,所以会进行递归调用factorial(4)。此时发生了一次函数调用,相当于栈Push了一次,栈顶函数是factorial(5)。
- factorial(4)函数执行,n不等于1,继续递归调用factorial(3),又Push了一次栈,栈顶函数是factorial(4)。
- 重复这个逻辑,直到调用factorial(1),这时候n等于1,满足基线条件,可以返回1,不再递归调用。
- 此时栈顶函数是factorial(2),在其内部执行return 2 * factorial(1),由于factorial(1)已经返回1,所以可以得到返回值2。
- factorial(2)执行完毕,将从栈中Pop出,栈顶变成factorial(3)。
- factorial(3)得到返回值3 * factorial(2),即3 * 2= 6,执行完毕后Pop。
- 依次类推,factorial(4)返回值是4 * 6 = 24,factorial(5)返回5 * 24 = 120。
- 最终factorial(5)也会从栈中Pop出,递归调用结束。
通过这个过程,递归函数可以看作是不断地栈Push和Pop的过程,最后递归树不断展开收敛,得到最终结果。
二、深入解析
- 递归函数必须有一个终止条件,否则会出现无限递归导致栈溢出。
- 递归函数在非终止条件情况下必须调用自身,并传入不同的参数,以推进向终止条件的执行。
- 普通递归在函数调用后还需要执行其它操作才能返回,所以无法进行尾递归优化。
- 尾递归是指递归调用作为函数的最后一步操作,这种可以进行优化,改写成迭代的形式。
- 递归函数空间复杂度受最大递归深度影响。每次递归调用会增加栈帧,空间复杂度为O(n)。
- 可以通过设置最大递归深度来控制空间 usage。
- 递归函数的时间复杂度和问题的递归结构相关。如二分查找为O(logn)。
- 尾递归优化后可以将空间复杂度降低为O(1)。
- 尾递归优化可以减少空间复杂度,使用循环来改写递归。
- 空间复杂度也可以通过限制最大递归深度来控制。
- 利用记忆化搜索可以避免重复计算,降低时间复杂度。
- 可以用递归求解分治问题,如mergesort、快速排序、二分查找等。
- 也可以解决树形结构的问题,如遍历DOM树、文件目录等。
- 动态规划问题也可以用递归求解,并配合记忆化搜索优化。
✨ 结语
通过上面的详细解读,我们可以看到递归是一种功能强大且优雅的编程技巧。正确使用递归可以写出简洁易懂的代码,提高开发效率。但是我们也要注意控制递归的最大深度,避免造成内存溢出。
当遇到可以递归解决的问题时,要思考清楚递归的终止条件和每一层的计算过程。若问题可用迭代实现,也要考虑使用尾递归优化递归算法的性能。
另外,递归可配合动态规划、分治法等算法范式发挥更大威力。掌握递归的精髓,可以让我们写出高效可靠的程序,解决更广泛的问题。