第 02 章 栈与递归——为什么计算机必须记住自己从哪里来?标记本章已读大字阅读打印本章回到上次阅读位置交互模块加载失败。正文仍可阅读,请刷新重试。正在加载交互实验…第一季 · 人类如何驯服数据来源公元前的希腊神话里,克里特岛上有一座迷宫。传说,迷宫里关着可怕的牛头怪弥诺陶洛斯(Minotaur)。年轻的忒修斯(Theseus)进入迷宫,准备杀死怪物。可是,他遇到了一个比怪物更棘手的问题:即使打败怪物,他怎么走出迷宫?迷宫中有无数岔路,而且通道看起来都差不多。幸好,阿里阿德涅(Ariadne)给了他一团线。来源忒修斯把线的一端系在入口,带着线团走进迷宫。沿着留下的线,他便能找到回去的路。这是古希腊神话,不是栈的真实发明史。但它恰好可以帮助我们理解计算机科学中一个非常重要的问题:一个程序进入了很多层任务,怎样才能按照正确的顺序返回?答案,就藏在我们今天的主角里。第一幕:一摞盘子,改变了计算机世界假设一家餐厅有一摞盘子。服务员不断把洗好的盘子放在最上面,客人也总是从最上面拿盘子。最后放上去的盘子,最先被取走。来源这就是后进先出:Last In, First Out,简称 LIFO。它看起来只是一个日常生活中的小细节。但如果把盘子变成数据,就得到了一种非常有用的数据结构:栈(Stack)。栈最重要的操作只有两个:push:把一个元素压到栈顶。pop:从栈顶取出一个元素。对于合理实现的栈,这两项操作通常都是 O(1)O(1)O(1)。亲自操作一下你的第一台栈机器2 个元素B ← TOPA ◇ Push◇ Pop↶试着连续压入三个元素,然后连续弹出三个元素,观察顺序。你可能会问:这种只能从顶部操作的数据结构,看起来反而限制很多,为什么要发明它?因为在某些场景里,这种限制不是缺点,而是正确性的重要保障。第二幕:函数调用之后,计算机怎么回家?现在,神话结束,我们回到真实的计算机。假设你编写了三个 Python 函数:python复制代码def A(): print("进入 A") B() print("离开 A") def B(): print("进入 B") C() print("离开 B") def C(): print("进入 C") print("离开 C") A()你能预测打印顺序吗?程序首先进入 A,然后 A 调用 B,B 又调用 C。可是一个奇怪的问题出现了:当 C 结束的时候,计算机怎么知道该回到 B,而不是直接返回 A?更进一步,它怎么知道 B 应该从哪一行代码继续执行?这就需要一种特殊的记忆。计算机在调用函数时,会保存与调用相关的状态,通常包括返回地址,以及必要的局部状态或上下文。这类信息由运行时使用**调用栈(Call Stack)**管理。函数调用与返回模拟器调用栈(栈顶在上)A()当前事件进入 A()A 开始执行,准备调用 B。步骤 1 / 6上一步下一步现在你发现一个重要的规律:最后被调用的函数,必须最先结束并返回,外层函数才有机会继续。这不就是后进先出吗?假如使用普通队列来管理这些嵌套函数调用,先进入的 A 反而会先退出,显然不符合执行逻辑。所以调用栈不仅是工程实现方式,更与嵌套调用的结构天然匹配。第三幕:1959年,一个真正的历史转折点来源Edsger W. Dijkstra(艾兹赫尔·戴克斯特拉)荷兰计算机科学家。你今后学习图论时,还会遇到以他命名的最短路径算法。但今天,我们要谈的是他在程序语言实现方面的贡献。1959年,Dijkstra 正在思考一个难题:如何让计算机正确执行可以递归调用的程序?当时,程序员并不总能像今天一样,自然而然地让函数调用自己。假如一个函数只有一块固定的局部变量存储空间,那么函数再次调用自己时,就可能覆盖上一层调用的变量和返回信息。Dijkstra 在1959—1960年参与解决了 ALGOL 60 中递归调用与变量作用域的实现问题。调用栈由此成为支持递归的重要机制之一。[Stackrev7.doc][cs.utexas.edu][heerdebeer.org]这段历史还有一个精彩的联系。还记得上一章发明快速排序的 Tony Hoare 吗?他曾经有了快速排序的思想,却难以用当时掌握的工具把递归部分实现出来。后来,ALGOL 60 对递归的支持帮助他完成了实现。[德克萨斯大学奥斯汀分校计算机科学][Stackrev7.doc]所以我们前两章其实连起来了:栈支撑了递归,而递归帮助快速排序成为了现实。第四幕:递归——一个不断把任务交给自己的函数我们用一个非常简单的 Python 函数来理解递归。python复制代码def countdown(n): if n == 0: print("发射!") return print(n) countdown(n - 1) countdown(3)运行过程是:countdown(3)打印 3,然后调用 countdown(2)countdown(2)打印 2,然后调用 countdown(1)countdown(1)打印 1,然后调用 countdown(0)countdown(0)打印“发射!”,停止继续递归这里有两个关键概念:递归条件(Recursive Case):问题还没有结束,继续调用自己,但把问题变小。终止条件(Base Case):问题已经小到可以直接解决,不再继续调用自己。如果缺少终止条件,或者递归过程始终无法抵达终止条件,就可能不断增加调用栈深度。Python 通常会在达到递归深度限制时抛出 RecursionError。第五幕:真正困难的不是进入,而是返回我们稍微增加一点难度。python复制代码def mystery(n): if n == 0: return print("进入", n) mystery(n - 1) print("离开", n) mystery(3)这一次,为什么输出会先是 3、2、1,再变成 1、2、3?因为每一层函数在调用下一层之前,都会暂时停下来。最深层返回后,之前暂停的函数才能依次恢复。递归执行时间线◇进入 3向深处调用○进入 2向深处调用○进入 1向深处调用○到达终止条件开始返回○离开 1逐层返回○离开 2逐层返回○离开 3逐层返回1 / 7重置下一步这就是递归最重要的特点之一:调用时不断深入,返回时逐层恢复。数学上,一个复杂的问题不断转化成规模更小、结构相同的问题;计算机中,调用栈保存尚未完成的工作。第六幕:如果不允许使用递归,还能解决问题吗?当然可以。假设要遍历一棵树,我们可以使用递归:python复制代码def dfs(node): if node is None: return print(node.value) dfs(node.left) dfs(node.right)也可以自己维护一个显式栈:python复制代码def dfs(root): if root is None: return stack = [root] while stack: node = stack.pop() print(node.value) if node.right: stack.append(node.right) if node.left: stack.append(node.left)注意,为什么要先压入右孩子,再压入左孩子?因为栈是后进先出,左孩子后压入,反而会先被处理。于是你会发现:递归可以隐式使用调用栈,而迭代算法可以显式维护一个栈。两种方法并不是完全相反,而是经常能够相互转化。终章挑战:一个栈能解开怎样的密码?在继续下一章之前,给你一道推理题。一个空栈依次执行:text复制代码push(4) push(7) pop() push(2) push(9) pop() pop()最后栈顶是什么数字?4729这一章,你真正需要带走的,不只是 push() 和 pop()。而是这样一种思维:当任务一层套着一层,最后开始的任务必须最先结束时,栈就是一种自然的组织方式。它出现在函数调用、表达式解析、深度优先搜索、撤销操作、括号匹配等许多场景中。下一章我们将认识栈的另一位亲戚:《队列:为什么最先到达的人,应该最先得到服务?》从银行排队到操作系统调度,再到广度优先搜索,我们会看到一种简单的先进先出规则,如何让计算机安排成千上万项工作。上一章第一章:数组与链表——一场关于秩序与自由的战争下一章第三章:队列——一台价值350万美元的计算机,为什么还要排队?