数据结构简明教程(第2版)微课版
上QQ阅读APP看书,第一时间看更新

3.1 栈

3.1.1 栈的基本概念

是一种特殊的线性表,其特殊性体现在元素插入和删除运算上,它的插入和删除运算仅限定在表的某一端进行,不能在表中间和另一端进行。允许进行插入和删除的一端称为栈顶,另一端称为栈底。栈的插入操作称为进栈(或入栈),删除操作称为出栈(或退栈)。处于栈顶位置的数据元素称为栈顶元素。不含任何数据元素的栈称为空栈。

正是这种受限的元素插入和删除运算,使得栈表现出先进后出或者后进先出的特点。举一个例子进行说明,假设有一个很窄的死胡同,胡同里能容纳若干人,但每次只能容许一个人进出。现有5个人,分别编号为①~⑤,按编号的顺序依次进入此死胡同,如图3.1(a)所示。此时若编号为④的人要退出死胡同,必须等⑤退出后才可以。若①要退出,则必须等到⑤、④、③、②依次都退出后才行,如图3.1(b)所示。这里人进出死胡同的原则是先进去的后出来。

图3.1 死胡同示意图

在该例中,死胡同就看作是一个栈,栈顶相当于死胡同口,栈底相当于死胡同的另一端,进、出死胡同可看作进栈、出栈操作。插入栈的示意图如图3.2所示。

图3.2 栈的示意图

栈的基本运算主要包括以下6种。

(1)初始化栈InitStack(st):建立一个空栈st。

(2)销毁栈DestroyStack(st):释放栈st占用的内存空间。

(3)进栈Push(st,x):将元素x插入栈st中,使x成为栈st的栈顶元素。

(4)出栈Pop(st,x):当栈st不空时,将栈顶元素赋给x,并从栈中删除当前栈顶元素。

(5)取栈顶元素GetTop(st,x):若栈st不空,取栈顶元素x并返回1;否则返回0。

(6)判断栈空StackEmpty(st):判断栈st是否为空栈。

包含基本运算的栈如图3.3所示,其中,op1~op6表示上述6个基本运算。

图3.3 包含基本运算的栈

例3.1】设一个栈的输入序列为abcd,借助一个栈(假设栈大小足够大)所得到的出栈序列不可能是________。

A.abcd

B.bdca

C.acdb

D.dabc

abcd序列经过栈的情况如图3.4所示,根据栈的特点,很容易得出dabc是不可能的,因为d先出栈,说明abc均已在栈中,按照进栈顺序,从栈顶到栈底的顺序应为cba,出栈的顺序只能是dcba。所以不可能的出栈序列是D。

图3.4 序列经过一个栈的情况

例3.2】已知一个栈的进栈序列是1,2,3,…,n,其出栈序列是p1p2,…,pn,若p1=n,则pi的值为________。

A.i

B.ni

C.ni+1

D.不确定

p1=n,则出栈序列是唯一的,即为nn—1,…,2,1,由此推出pi=ni+1。本题答案为C。

例3.3】元素abcde依次进入初始为空的栈中,假设栈大小足够大。若元素进栈后可停留、可立即出栈,直到所有的元素都出栈,则所有可能的出栈序列中,以元素d开头的出栈序列个数是________。

A.3

B.4

C.5

D.6

:若元素d第一个出栈,abc均在栈中,从栈顶到栈底的顺序应为cba,如图3.5所示,此后合法的栈操作如下。

(1)e进栈,e出栈,c出栈,b出栈,a出栈,得到的出栈序列decba

(2)c出栈,e进栈,e出栈,b出栈,a出栈,得到的出栈序列dceba

(3)c出栈,b出栈,e进栈,e出栈,a出栈,得到的出栈序列dcbea

(4)c出栈,b出栈,a出栈,e进栈,e出栈,得到的出栈序列dcbae

以元素d开头的出栈序列个数为4,本题答案为B。

图3.5 元素出栈的情况

3.1.2 栈的顺序存储结构

栈是一种特殊的线性表,和线性表存储结构类似,栈也有两种存储结构:顺序存储结构和链式存储结构。

栈的顺序存储结构称为顺序栈。顺序栈通常由一个一维数组data和一个记录栈顶元素位置的变量top组成。习惯上将栈底放在数组下标小的那端,栈顶元素由栈顶指针top所指向。顺序栈类型声明如下。

在上述顺序栈定义中,ElemType为栈元素的数据类型,MaxSize为一个常量,表示data数组中最多可放的元素个数,data元素的下标范围为0~MaxSize—1。当top=—1时表示栈空;当top=MaxSize—1时表示栈满。

图3.6说明了顺序栈st的几种状态(假设MaxSize=5)。图3.6(a)表示顺序栈为栈空,这也是初始化运算得到的结果。此时栈顶指针top=—1。如果做出栈运算,则会“下溢出”。

图3.6 顺序栈的几种状态

图3.6(b)表示栈中只含一个元素a,在图3.6(a)的基础上用进栈运算Push(st,'a'),可以得到这种状态。此时栈顶指针top=0。

图3.6(c)表示在图3.6(b)基础上又有两个元素bc先后进栈,此时栈顶指针top=2。

图3.6(d)表示在图3.6(c)状态下执行一次Pop(st,x)运算得到。此时栈顶指针top=1。故b为当前的栈顶元素。

图3.6(e)表示在图3.6(d)状态下执行两次Pop(st,x)运算得到。此时栈顶指针top=—1,又变成栈空状态。

归纳起来,对于顺序栈st,其初始时置st.top=—1,它的4个要素如下。

(1)栈空条件:st.top==—1。

(2)栈满条件:st.top==MaxSize—1。

(3)元素x进栈操作:st.top++;将元素x放在st.data[st.top]中。

(4)出栈元素x操作:取出栈元素x=st.data[st.top];st.top——。

顺序栈的基本运算算法如下。

1.初始化栈运算算法

其主要操作是设定栈顶指针top为—1,对应的算法如下。

2.销毁栈运算算法

这里顺序栈的内存空间是由系统自动分配的,在不再需要时由系统自动释放其空间。对应的算法如下。

      void DestroyStack(SqStack st)
      {  }

3.进栈运算算法

其主要操作是:栈顶指针加1,将进栈元素放在栈顶处。对应的算法如下。

4.出栈运算算法

其主要操作是:先将栈顶元素取出,然后将栈顶指针减1。对应的算法如下。

5.取栈顶元素运算算法

其主要操作是:将栈顶指针top处的元素取出赋给变量x,top保持不动。对应的算法如下。

6.判断栈空运算算法

其主要操作是:若栈为空(top==—1)则返回值1,否则返回值0。对应的算法如下。

提示:将顺序栈类型声明和栈基本运算函数存放在SqStack.cpp文件中。

当顺序栈的基本运算函数设计好后,给出以下程序调用这些基本运算函数,读者可以对照程序执行结果进行分析,进一步体会顺序栈的各种运算的实现过程。

上述程序的执行结果如图3.7所示。

图3.7 程序执行结果

例3.4】若一个栈用数组data[1..n]存储(n为一个大于2的正整数),初始栈顶指针top为n+1,则以下元素x进栈的正确操作是________。

A.top++;data[top]=x

B.data[top]=x;top++;

C.top——;data[top]=x

D.data[top]=x;top——;

:栈元素是向一端伸展的,即从栈底向栈顶方向伸展。这里初始栈顶指针top为n+1,说明data[n]端作为栈底,在进栈时top应递减,由于不存在data[n+1]的元素,所以在进栈时应先将top递减,再将x放在top处。本题答案为C。

3.1.3 栈的链式存储结构

栈的链式存储结构是采用某种链表结构,栈的链式存储结构简称为链栈。这里采用单链表作为链栈,如图3.8所示,该单链表是不带头结点的,通过首结点指针ls唯一标识整个单链表。同时,单链表的首结点就是链栈的栈顶结点(图中首结点值为an表示最近进栈的元素是an),所以ls也作为栈顶指针,栈中的其他结点通过next域链接起来,栈底结点的next域为NULL。因链栈本身没有容量限制,所以不必考虑栈满的情况,这是链栈相比顺序栈的一个优点。

图3.8 链栈示意图

链栈的类型定义如下。

归纳起来,链栈ls初始时ls=NULL,其4个要素如下。

(1)栈空条件:ls==NULL。

(2)栈满条件:不考虑。

(3)元素x进栈操作:创建存放元素x的结点p,将其插入到栈顶位置上。

(4)出栈元素x操作:置x为栈顶结点的data域,并删除该结点。

链栈的基本运算算法如下。

1.初始化栈运算算法

其主要操作是:置s为NULL标识栈为空栈。对应的算法如下。

2.销毁栈运算算法

链栈的所有结点空间都是通过malloc函数分配的,在不再需要时需通过free函数释放所有结点的空间,与单链表销毁算法类似,只是这里链栈是不带头结点的。对应的算法如下。

3.进栈运算算法

其主要操作是:先创建一个新结点p,其data域值为x;然后将该结点插入到开头作为新的栈顶结点。对应的算法如下。

4.出栈运算算法

其主要操作是:将栈顶结点(即ls所指结点)的data域值赋给x,然后删除该栈顶结点。对应的算法如下。

说明:尽管链栈操作时不考虑上溢出,但仍然需要考虑出栈操作时的下溢出。

5.取栈顶元素运算算法

其主要操作是:将栈顶结点(即ls所指结点)的data域值赋给x。对应的算法如下。

6.判断栈空运算算法

其主要操作是:若栈为空(即ls==NULL)则返回值1,否则返回值0。对应的算法如下。

      int StackEmpty(LinkStack ∗ ls)
      {    if(ls==NULL) return 1;
           else return 0;
      }

提示:将链栈结点类型声明和链栈基本运算函数存放在LinkStack.cpp文件中。

当链栈的基本运算函数设计好后,给出以下程序调用这些基本运算函数,读者可以对照程序执行结果进行分析,进一步体会顺序栈的各种运算的实现过程。

上述程序的执行结果如图3.7所示。

例3.5】以下各链表均不带有头结点,其中最不适合用作链栈的链表是________。

A.只有表尾指针没有表头指针的循环单链表

B.只有表头指针没有表尾指针的循环单链表

C.只有表头指针没有表尾指针的循环双链表

D.只有表尾指针没有表头指针的循环双链表

:由于各链表均不带有头结点,这里表头指针就是首结点指针。采用一种链表作为链栈时,通常将链表首结点处作为栈顶。一个链表适不适合作为链栈,就看它是否能够高效地实现栈的基本运算,而栈的主要操作是进栈和出栈。

考虑选项A,只有表尾指针没有表头指针的循环单链表,假设表尾指针为rear,它指向循环单链表的尾结点,如图3.9所示。元素x进栈的操作是:创建存放x的结点p,将结点p插入在rear结点之后作为栈顶结点,不改变rear;出栈的操作是:删除rear结点之后的结点。两种操作的时间复杂度均为O(1)。

图3.9 只有表尾指针没有表头指针的循环单链表

考虑选项B,只有表头指针没有表尾指针的循环单链表,假设表头指针为first,它指向循环单链表的首结点,如图3.10所示。元素x进栈的操作是:创建存放x的结点p,将结点p插入在first结点之前,即p—>next=first,first=p,但还要将其改变为循环单链表,而尾结点需要遍历所有结点找到,遍历的时间为On),所以进栈操作的时间复杂度为On);出栈的操作是:删除first结点,删除后仍然需要将其改变为循环单链表,所以出栈操作的时间复杂度为On)。两种操作的时间复杂度均为On)。

图3.10 只有表头指针没有表尾指针的循环单链表

考虑选项C和D,由于都是循环双链表,可以通过表头结点直接找到尾结点,在插入和删除后改为循环双链表的时间均为O(1),所以它们的进栈和出栈操作的时间复杂度都是O(1)。

比较起来,只有表头指针没有表尾指针的循环单链表作为链栈时,进栈和出栈操作的时间性能最差。本题答案为B。

3.1.4 栈的应用示例

在较复杂的数据处理过程中,通常需要保存多个临时产生的数据,如果先产生的数据后进行处理,那么需要用栈来保存这些数据。本节通过几个经典示例说明栈的应用方法,并且算法中均采用顺序栈,实际上采用链栈完全相同。

例3.6】假设以I和O分别表示进栈和出栈操作,栈的初态和终态均为空,进栈和出栈的操作序列可表示为仅由I和O组成的序列。

(1)下面所示的序列中哪些是合法的?

A.IOIIOIOO

B.IOOIOIIO

C.IIIOIOIO

D.IIIOOIOO

(2)通过对(1)的分析,设计一个算法利用链栈的基本运算判定操作序列str是否合法。若合法返回1;否则返回0。

:(1)A、D均合法,而B、C不合法。因为在B中,先进栈一次,立即出栈两次,这会造成栈下溢。在C中共进栈5次,出栈3次,栈的终态不为空。

归纳起来,这样的操作序列是合法的,当且仅当其中所有I的个数与O的个数相等,而且任何前缀中I的个数大于或等于O的个数。

(2)本例用一个顺序栈st来判断操作序列是否合法,其中,str为存放操作序列的字符数组,n为该数组的元素个数。对应的算法如下。

说明:本例的另一种解法是用cnt累计I与O的个数差,cnt从0开始,循环遍历str,遇到I增1,遇到0减1。cnt小于0时返回0。循环结束后cnt为0则返回1。

例3.7】回文指的是一个字符串从前面读和从后面读都一样,如"abcba"、"123454321"都是回文。设计一个算法利用栈判断一个字符串是否为回文。

:由于回文是从前到后以及从后到前都是一样的,所以只要将待判断的字符串颠倒,然后与原字符串相比较,就可以决定是否回文了。

将字符串str从头到尾的各个字符依次进栈到顺序栈st中,由于栈的特点是后进先出,则从栈顶到栈底的各个字符正好与字符串str从尾到头的各个字符相同;然后将字符串str从头到尾的各个字符,依次与从栈顶到栈底的各个字符相比较,如果两者不相同,则表明str不是回文,在相同时继续比较;如果相应字符全部匹配,则说明str是回文。对应的算法如下。

例3.8】设计一个算法,判断一个可能含有小括号(“(”与“)”、)、中括号(“[”与“]”)和大括号(“{”与“}”)的表达式中各类括号是否匹配。若匹配,则返回1;否则返回0。

:设置一个顺序栈st,定义一个整型flag变量(初始为1)。用i扫描表达式exp,当in并且flag=1时循环:当遇到左括号“(”“[”“{”时,将其进栈;遇到“}”“]”“)”时,出栈字符ch,若出栈失败(下溢出)或者ch不匹配,则置flag=0退出循环,否则直到exp扫描完毕为止。若栈空并且flag为1则返回1,否则返回0。

例如,对于表达式"[(])",其括号不匹配,匹配过程如图3.11(a)所示;对于表达式"[()]",其括号是匹配的,匹配过程如图3.11(b)所示。

图3.11 判断表达式括号是否匹配

对应的算法如下。

例3.9】设计一个算法将一个十进制正整数转换为相应的二进制数。

:将十进制正整数转换成二进制数通常采用除2取余数法(称为辗转相除法)。在转换过程中,二进制数是从低位到高位的次序得到的,这和通常的从高位到低位输出二进制的次序相反。为此设计一个栈,用于暂时存放每次得到的余数,当转换过程结束时,退栈所有元素便得到从高位到低位的二进制数。如图3.12所示是十进制数12转换为二进制数1100的过程。

图3.12 12转换为二进制数的过程

对应的算法如下。

设计如下主函数。

本程序的一次执行结果如下。

      输入一个正整数:120↙
      对应的二进制数:1111000