第4章 栈不队列 §4.2 栈不逑弻
在Windows等大部分操作系统中,每个运行中的二进制程序都配有一个调用栈(call
stack)或执行栈(execution stack)。借助调用栈可以跟踪属于同一程序的所有函数,记录
它们之间的相互调用关系,并保证在每一调用实例执行完毕之后,可以准确地返回。
函数调用
如图4.3所示,调用栈的基本单位是帧(frame)。每次函数调用时,都会相应地创建一帧,
记录该函数实例在二进制程序中的返回地址(return address),以及局部变量、传入参数等,
并将该帧压入调用栈。若在该函数返回之前又发生新的调用,则同样地要将与新函数对应的一帧
压入栈中,成为新的栈顶。函数一旦运行完毕,对应的帧随即弹出,运行控制权将被交还给该函
数的上层调用函数,并按照该帧中记录的返回地址确定在二进制程序中继续执行的位置。
在任一时刻,调用栈中的各帧,依次对应于那些尚未返回的调用实例,亦即当时的活跃函数
实例(active function instance)。特别地,位于栈底的那帧必然对应于入口主函数main(),
若它从调用栈中弹出,则意味着整个程序的运行结束,此后控制权将交还给操作系统。
仿照递归跟踪法,程序执行过程出现过的函数实例及其调用关系,也可构成一棵树,称作该
程序的运行树。任一时刻的所有活跃函数实例,在调用栈中自底到顶,对应于运行树中从根节点
到最新活跃函数实例的一条调用路径。
此外,调用栈中各帧还需存放其它内容。比如,因各帧规模不一,它们还需记录前一帧的起
始地址,以保证其出栈之后前一帧能正确地恢复。
递归
作为函数调用的特殊形式,递归也可借助上述调用栈得以实现。比如在图4.3中,对应于
funcB()的自我调用,也会新压入一帧。可见,同一函数可能同时拥有多个实例,并在调用栈中
各自占有一帧。这些帧的结构完全相同,但其中同名的参数或变量,都是独立的副本。比如在
funcB()的两个实例中,入口参数m和内部变量i各有一个副本。
4.2.2 避免递归
今天,包括C++在内的各种高级程序设计语言几乎都允许函数直接或间接地自我调用,通过
递归来提高代码的简洁度和可读性。而Cobol和Fortran等早期的程序语言虽然一开始并未采用
栈来实现过程调用,但在其最新的版本中也陆续引入了栈结构来支持过程调用。
尽管如此,系统在后台隐式地维护调用栈的过程中,难以区分哪些参数和变量是对计算过程
有实质作用的,更无法以通用的方式对它们进行优化,因此不得不将描述调用现场的所有参数和
变量悉数入栈。再加上每一帧都必须保存的执行返回地址以及前一帧起始位置,往往导致程序的
空间效率不高甚至极低;同时,隐式的入栈和出栈操作也会令实际的运行时间增加不少。
因此在追求更高效率的场合,应尽可能地避免递归,尤其是过度的递归。实际上,我们此前
已经介绍过相应的方法和技巧。例如,在1.4.4节中将尾递归转换为等效的迭代形式;在1.4.5
节中采用动态规划策略,将Fibonacci数算法中的二分递归改为线性递归,直至完全消除递归。
既然递归本身就是操作系统隐式地维护一个调用栈而实现的,我们自然也可以通过显式地模
拟调用栈的运转过程,实现等效的算法功能。采用这一方式,程序员可以精细地裁剪栈中各帧的
内容,从而尽可能降低空间复杂度的常系数。尽管算法原递归版本的高度概括性和简洁性将大打
折扣,但毕竟在空间效率方面可以获得足够的补偿。