栈的使用
本章我们主要介绍一下栈的使用。
1. 栈
1.1 抽象数据类型栈的定义
栈(stack)是限定仅在表尾进行插入或删除操作的线性表。因此,对栈来说,表尾端有其特殊含义,称为栈顶(top),相反地,表头端称为栈底(bottom)。不含元素的空表称为空栈。
假设栈S=(a1, a2, a3, …, an),则称a1为栈底元素,an为栈顶元素。栈中元素按a1, a2, …, an的次序进栈,退栈的第一个元素为栈顶元素。换句话说,栈的修改是按后进先出的原则进行的(如下图3.1(a)所示)。因此,栈又称为后进先出(last in first out)的线性表(简称LIFO结构),它的这个特点可用3.1(b)所示的铁路调度站形象地表示。
栈的基本操作除了在栈顶进行插入或删除外,还有栈的初始化、判空及取栈顶元素等。下面给出栈的抽象数据类型的定义:
1.2 栈的表示和实现
和线性表类似,栈也有两种存储表示方法。
顺序栈,即栈的顺序存储结构是利用一组地址连续的存储单元依次存放自栈底到栈顶的数据元素,同时附设指针top指示栈顶元素在顺序栈中的位置。通常的习惯做法是以top=0表示空栈,鉴于C语言中数组的下标约定从0开始,则当以C作为描述语言时,如此设定会带来很大的不便;另一方面,由于栈在使用过程中所需最大空间的大小很难估计,因此,一般来说,在初始化设空栈时不应限定栈的最大容量。一个较合理的做法是: 先为栈分配一个基本容量,然后在应用过程中,当栈的空间不够使用时再逐段扩大。为此,可设定两个常量:STACK_INIT_SIZE(存储空间初始分配量)和STACKINCREMENT(存储空间分配增量),并以下述类型说明作为顺序栈的定义。
其中,stacksize指示栈的当前可使用的最大容量。栈的初始化操作为: 按设定的初始分配量进行第一次存储分配,base可称为栈底指针,在顺序栈中,它始终指向栈底的位置,若base的值为NULL,则表明栈结构不存在。称top为栈顶指针,其初值指向栈底,即top == base可作为栈空的标记,每当插入新的栈顶元素时,指针top增1;删除栈顶元素时,指针top减1,因此,非空栈中的栈顶指针始终在栈顶元素的下一个位置上。如下图3.2展示了顺序栈中数据元素和栈顶指针之间的对应关系。
以下是顺序栈的模块说明:
对于栈的链式表示,如下图3.3所示。由于栈的操作是线性表操作的特例,则链栈的操作易于实现,在此不作详细讨论。
2. 栈的应用举例
2.1 迷宫求解
求迷宫中从入口到出口的所有路径是一个经典的程序设计问题。由于计算机解迷宫时,通常用的是“穷举求解”的方法,即从入口出发,顺某一方向向前探索,若能走通,则继续往前走;否则沿原路退回,换一个方向再继续探索,直至所有可能的通路都探索到为止。为了保证在任何位置上都能沿原路退回,显然需要用一个后进先出的结构来保存从入口到当前位置的路径。因此,在求迷宫通路的算法中应用“栈”也就是自然而然的事了。
首先,在计算机中可以用如图3.4所示的方块图表示迷宫。图中的每个方块或为通道(以空白方块表示)或为墙(以带影线的方块表示)。所求路径必须是简单路径,即在求得的路径上不能重复出现同一通道块。
假设“当前位置”
指的是“在搜索过程中某一时刻所在图中某个方块位置”,则求迷宫中一条路径的算法的基本思想是: 若当前位置“可通”
,则纳入“当前路径”
,并继续朝“下一位置”
探索,即切换“下一位置”
为“当前位置”
,如此重复直至到达出口;若当前位置“不可通”
,则应顺着“来向”
退回到“前一通道块”
,然后朝着除“来向”
之外的其他方向继续探索;若该通道块的四周4个方块均“不可通”
,则应从“当前路径”
上删除该通道块。所谓“下一位置”指的是“当前位置”四周4个方向(东南西北)上相邻的方块。
假设以栈S记录“当前路径”,则栈顶中存放的是“当前路径上最后一个通道块”。由此,“纳入路径”的操作即为“当前位置入栈”; “从当前路径上删除前一通道块”的操作即为“出栈”。
求迷宫中一条从入口到出口的路径的算法可简单描述如下:
在此,尚需说明的一点是,所谓当前位置可通
,指的是未曾走到过的通道块
,即要求该方块位置不仅是通道块,而且既不在当前路径上(否则所求路径就不是简单路径),也不是曾经纳入过路径的通道块(否则只能在死胡同内转圈)。
下面我们给出大体的算法实现:
2.2 表达式求值
求值过程描述
表达式求值是程序设计语言编译中的一个最基本问题。它的实现是栈应用的又一个典型例子。这里介绍一种简单直观、广为使用的算法,通常称为算符优先法
。
要把一个表达式翻译成正确求值的一个机器指令序列,或者直接对表达式求值,首先要能够正确解释表达式。例如,要对下面的算术表达式求值:
4+2x3-10/5
首先要了解算术四则运算的规则。即:
1) 先乘除,后加减
2) 从左算到右
3) 先括号内,后括号外
由此,这个算术表达式的计算顺序应为:
4+2x3-10/5 = 4+6-10/5 = 10-10/5 = 10-2 = 8
算符优先法就是根据这个运算优先关系的规定来实现对表达式的编译或解释执行的。
任何一个表达式都是由操作数
(operand)、运算符
(operator)和界限符
(delimiter)组成的,我们称它们为单词。一般地,操作数既可以是常数也可以是被说明为变量或常量的标识符;运算符可以分为算术运算符
、关系运算符
和逻辑运算符
3类;基本界限符有左右括号和表达式结束符等。为了叙述简洁,我们仅讨论简单算术表达式的求值问题。这种表达式只含加、减、称、除4种运算符。
我们把运算符
和界限符
统称为算符,它们构成的集合命名为OP
。根据上述3条
运算规则,在运算的每一步中,任意两个相继出现的算符θ1
和θ2
之间的优先关系至多是下面3种关系之一:
-
θ1<θ2: θ1的优先权低于θ2
-
θ1=θ2: θ1的优先权等于θ2
-
θ1>θ2: θ1的优先权高于θ2
下表定义了算符之间的这种优先关系:
由规则3)
,+、-、*和/为θ1的优先性均低于(
但高于)
; 由规则2)
,当θ1=θ2时,令θ1>θ2; #
是表达式的结束符。为了算法简洁,在表达式最左边也虚设一个#
构成整个表达式的一对括号。表中“(” = “)”表示当左右括号相遇时,括号内的运算已经完成。同理,“#” = “#”表示整个表达式求值完毕。“)”与“(”、“#”与“)” 以及 “(”与“#” 之间无优先关系,这是因为表达式中不允许它们相继出现,一旦遇到这种情况,则可以认为出现了语法错误。在下面的讨论中,我们暂假定所有输入的表达式不会出现语法错误。
为了实现算符优先算法,可以使用两个工作栈。一个称作OPTR
,用于寄存运算符;另一个称作OPND
,用于寄存操作数或运算结果。算法的基本思想是:
1) 首先置操作数栈为空栈,表达式起始符“#”为运算符栈的栈底元素;
2) 依次读入表达式中每个字符,若是操作数则进OPND
栈,若是运算符则和OPTR
的栈顶运算符比较优先权后作相应操作,直至整个表达式求值完毕(即OPTR的栈顶元素和当前读入的字符均为“#”)
算法实现
如下描述了表达式求值的过程:
上面算法中还调用了两个函数。其中,Precede()函数是判定运算符栈的栈顶运算符θ1
与读入的运算符θ2
之间优先关系的函数; Operate()函数为进行二元运算a θ b
的函数,如果是编译表达式,则产生这个运算的一组相应指令并返回存放结果的中间变量名;如果是解释执行表达式,则直接进行该运算,并返回运算的结果。
运算示例
如下是利用上述算法对算术表达式3*(7-2)
求值,操作过程如下所示:
2.3 栈与递归的实现
栈还有一个重要应用是在程序设计语言中实现递归。一个直接调用自己或通过一系列的调用语句间接调用自己的函数,称作递归函数。下面我们来看一个n阶汉诺塔问题
。
n阶汉诺塔问题
假设有3个分别命名为X、Y和Z的塔座,在塔座X上插有n个直径大小各不相同、依小到大编号为1、2、…、n的圆盘(如下图所示)。现要求将X轴上的n个圆盘移至塔座Z上并按同样的顺序叠排,圆盘移动时必须遵守如下规则。
1) 每次只能移动一个圆盘;
2) 圆盘可以插在X、Y和Z中的任一塔座上;
3) 任何时刻都不能将一个较大的圆盘压在较小的圆盘之上。
如何实现移动圆盘的操作呢? 当n=1时,问题比较简单,只要将编号为1的圆盘从塔座X直接移至塔座Z上即可;当n>1时,需利用塔座Y作辅助塔座,若能设法将压在编号为n的圆盘之上的n-1个圆盘从塔座X
(依照上述法则)移至塔座Y
上,则可先将编号为n的圆盘从塔座X
移至塔座Z
上,然后再将塔座Y
上的n-1个圆盘(依照上述法则)移至塔座Z上。而如何将n-1个圆盘从一个塔座移至另一个塔座的问题是一个和原文件具有相同特征属性的问题,只是问题的规模小1,因此可以用同样的方法求解。由此我们可以用如下算法来求解n阶Hanoi塔
问题:
函数调用栈分析
上面hanoi()
函数是一个递归函数,在函数的执行函数中,需多次进行自我调用。那么,这个递归函数是如何执行的? 先看任意两个函数之间进行调用的情形。
与汇编程序设计中主程序和子程序之间的链接及信息交换类似,在高级语言编制的程序中,调用函数和被调用函数之间的链接及信息交换需通过栈来进行。
通常,当在一个函数的运行期间调用另一个函数时,在运行被调用函数之前,系统需完成3件事:1) 将所有的实际参数、返回地址等信息传递给被调用函数保存; 2) 为被调用函数的局部变量分配存储区; 3) 将控制转移到被调函数的入口。 而从被调函数返回调用函数之前,系统也应完成3件工作: 1) 保存被调函数的计算结果; 2) 释放被调函数的数据区; 3) 依照被调函数保存的返回地址将控制转移到调用函数。当有多个函数构成嵌套调用时,按照“后调用先返回”的原则,上述函数之间的信息传递和控制转移必须通过“栈”来实现,即系统将整个程序运行时所需的数据空间安排在一个栈中,每当调用一个函数时,就为它在栈顶分配一个存储区,每当从一个函数退出时,就释放它的存储区,则当前正运行的函数的数据区必在栈顶。如下图(c)所示主函数main中调用了函数first(),而在函数first()中又调用了函数second(),则下图(a)展示了当前正在执行函数second()中某个语句时栈的状态,而图(b)展示从函数second()退出之后正执行函数first()中某个语句时栈的状态(图中以语句标号表示返回地址):
一个递归函数的运行过程类似于多个函数的嵌套调用,只是调用函数和被调用函数是同一个函数,因此,和每次调用相关的一个重要概念是递归函数运行的层次。假设调用该递归函数的主函数为第0层,则从主函数调用递归函数进入第1层;从第i层递归调用本函数为进入“下一层”,即第i+1
层。反之,退出第i层递归应返回至上一层
,即第i-1层。为了保证递归函数正确执行,系统需设立一个递归工作栈
作为整个递归函数运行期间使用的数据存储区。每一层递归所需信息构成一个“工作记录”,其中包括所有实际参数、所有的局部变量以及上一层的返回地址。每进入一层递归,就产生一个新的工作记录压入栈顶。每退出一层递归,就从栈顶弹出一个工作记录,则当前执行层的工作记录必是递归工作栈栈顶的工作记录,称这个记录为“活动记录”,并称指示活动记录的栈顶指针为“当前环境指针”。
例如,下图展示了语句:
hanoi(3,a,b,c);
执行过程(从主函数进入递归函数到退出递归函数而返回主函数)中递归工作栈状态的变化情况。由于上面算法所示的递归函数中只含有4个值参数,则每个工作记录包含5个数据项: 返回地址和4个实际参数,并以递归函数中的语句行号表示返回地址,同时假设主函数的返回地址为0。图中以箭头指示栈顶指针
。
实际上,在调用函数和被调用函数之间不一定传递参数的值,也可以传递参数的地址。通常,每个程序设计语言都有它自己约定的传递方法(包括被调用函数的执行结果如何返回调用函数等)。
由于递归函数结构清晰,程序易读,而且它的正确性容易得到证明,因此,利用允许递归调用的语言(例如C语言)进行程序设计时,给用户编制程序和调试程序带来很大方便。因为对这样一类递归问题编程时,不需用户自己而由系统来管理递归工作栈。