【算法】《算法图解》第三章学习笔记:递归的奥秘
前言
《算法图解》第三章为我们揭示了编程中一个强大且富有魅力的概念——递归(Recursion)。递归是一种解决问题的方法,它将问题分解为规模更小的相同问题,直到问题小到可以轻松解决。理解递归对于掌握更高级的算法(如快速排序、归并排序等)至关重要。本篇笔记将跟随书中的脚步,探索递归的原理、运作方式以及其在编程实践中的应用。
一、什么是递归
递归,简单来说,就是一个函数直接或间接调用自身的过程。书中用了一个生动的例子来解释:你找到一个上了锁的神秘手提箱,奶奶告诉你钥匙在另一个盒子里。你打开那个盒子,发现里面又是一个盒子… 这个寻找钥匙的过程,就是递归的体现——不断重复相同的动作(打开盒子),直到找到钥匙(达到某个终止条件)。
在编程中,递归函数会不断地调用自己,每次调用处理问题的一个更小的部分,直到达到一个无法再分解的”基本情况”。
二、递归函数的两个基本要素
每个递归函数都必须包含两部分,以确保其能正确执行并最终停止:
- 基线条件(Base Case):这是递归函数停止调用自身并开始返回结果的条件。没有基线条件,递归函数就会无限地调用下去,直到耗尽所有内存(导致栈溢出)。基线条件是递归的出口。
- 递归条件(Recursive Case):这是函数调用自身的部分。在递归条件中,函数会把问题分解成一个或多个规模更小的子问题,并对这些子问题进行递归调用。
(一)示例:倒计时函数
书中使用了一个简单的倒计时函数来说明这两个概念:
def countdown(i):
print(i)
if i <= 0: # 基线条件:当i小于等于0时,停止递归
return
else: # 递归条件:否则,继续调用自身,参数为i-1
countdown(i - 1)
# 调用函数
countdown(5)
# 输出:
# 5
# 4
# 3
# 2
# 1
# 0
在这个例子中:
if i <= 0:
就是基线条件。当i
减到0或以下时,函数打印数字后直接返回,不再调用countdown
。else: countdown(i - 1)
就是递归条件。只要i
大于0,函数就会调用自己,并传入一个更小的值i-1
,逐渐向基线条件靠近。
三、调用栈(The Call Stack)
计算机是如何跟踪递归函数中众多的函数调用的呢?答案是使用调用栈(Call Stack),也简称为”栈”。调用栈是一种后进先出(LIFO)的数据结构,用于存储函数调用时的信息(如局部变量、返回地址等)。
(一)调用栈的工作原理
- 压入(Push):每当一个函数被调用时,计算机会在调用栈顶部创建一个新的”栈帧”(Stack Frame),用于存储该函数调用的所有局部变量和参数。这个过程称为压栈或入栈。
- 弹出(Pop):当一个函数执行完毕并返回时,它的栈帧会从调用栈顶部被移除。这个过程称为弹栈或出栈。程序控制权返回到调用该函数的地方(即栈中下一个栈帧所代表的函数)。
(二)递归与调用栈示例:阶乘
以计算阶乘的递归函数为例:
def fact(x):
if x == 1: # 基线条件
return 1
else: # 递归条件
return x * fact(x - 1)
result = fact(3)
print(result) # 输出: 6
当调用 fact(3)
时,调用栈的变化大致如下(简化表示):
fact(3)
被调用,fact(3)
的栈帧入栈。x=3
。它需要计算3 * fact(2)
。fact(2)
被调用,fact(2)
的栈帧入栈(位于fact(3)
之上)。x=2
。它需要计算2 * fact(1)
。fact(1)
被调用,fact(1)
的栈帧入栈(位于fact(2)
之上)。x=1
。遇到基线条件,返回1
。fact(1)
的栈帧出栈。fact(2)
接收到返回值1
,计算2 * 1 = 2
,然后返回2
。fact(2)
的栈帧出栈。fact(3)
接收到返回值2
,计算3 * 2 = 6
,然后返回6
。fact(3)
的栈帧出栈。result
得到最终值6
。
每个 fact
函数调用都有自己独立的 x
变量,这些变量存储在各自的栈帧中,互不干扰。
四、递归的优缺点及与循环的对比
(一)优点
- 代码简洁明了:对于某些类型的问题,特别是那些可以自然地分解为较小子问题的问题(如树的遍历、分治算法),递归可以使解决方案非常直观和易于理解。
- 强大的表达能力:递归能够清晰地表达一些复杂的逻辑,用循环来实现可能会非常冗长和难以理解。
(二)缺点
- 性能开销:每次函数调用都有一定的开销(如创建栈帧、参数传递等)。对于深度递归,这种开销累积起来可能相当可观,通常递归比等效的循环要慢。
- 栈溢出(Stack Overflow):如果递归深度太深(即函数调用自身次数太多),调用栈可能会耗尽所有可分配的内存空间,导致程序崩溃。这是递归最常见的问题之一。
(三)递归 vs. 循环
《算法图解》中提到一句经典的话:”如果使用循环,程序的性能可能更高;如果使用递归,程序可能更容易理解。如何选择要看什么对你来说更重要。“
- 迭代(循环):通常效率更高,内存占用固定,不易出现栈溢出。但对于某些复杂问题,循环的逻辑可能难以构造和理解。
- 递归:代码更接近人类思考方式,对于某些问题(如分治)更自然。但有性能和栈溢出的风险。
在实践中,很多递归算法都可以用迭代的方式重写,反之亦然。有些语言支持尾递归优化(Tail Call Optimization),可以将某些特定形式的递归(尾递归)转化为迭代,从而避免栈溢出的问题,并提高性能。但Python默认不支持尾递归优化。
五、一个经典的递归例子:计算阶乘
上面在解释调用栈时已经用到了阶乘的例子,这里再次完整展示:
def factorial(n):
"""计算一个非负整数n的阶乘"""
if n < 0:
return "阶乘只对非负整数定义"
elif n == 0: # 基线条件1:0的阶乘是1
return 1
elif n == 1: # 基线条件2:1的阶乘是1 (这个基线条件可以合并到n==0,或作为主要基线)
return 1
else: # 递归条件:n! = n * (n-1)!
return n * factorial(n - 1)
# 测试
print(f"5! = {factorial(5)}") # 输出: 5! = 120
print(f"0! = {factorial(0)}") # 输出: 0! = 1
print(f"-2! = {factorial(-2)}") # 输出: -2! = 阶乘只对非负整数定义
这个阶乘函数清晰地展示了递归的两个核心:
- 基线条件:当
n
为0或1时,直接返回1。 - 递归条件:当
n
大于1时,函数调用自身计算(n-1)!
,然后乘以n
。
六、总结
递归是一种强大的编程范式,它通过函数调用自身来解决问题。理解递归的关键在于掌握它的两个基本组成部分:基线条件和递归条件。调用栈是实现递归的底层机制,它负责管理函数调用过程中的状态。虽然递归在某些情况下可能带来性能问题或栈溢出的风险,但其代码的简洁性和表达力使其在许多算法设计中不可或缺。选择递归还是循环,往往需要在代码可读性和程序性能之间做出权衡。
七、参考资料
- 《算法图解》 (Grokking Algorithms) by Aditya Y. Bhargava
- 《算法图解》——第三章递归 - CSDN博客
- [笔记]《算法图解》第三章递归 - bingo彬哥 - 博客园
- 递归算法知识 - 算法通关手册(LeetCode)