一、栈
1. 栈的定义
栈(Stack)又称堆栈,它是一种运算受限的线性表,其限制是仅允许在表的一端进行插入和删除运算。人们把此端称为栈顶,栈顶的第一个元素被称为栈顶元素,相对地,把另一端称为栈底。向一个栈插入新元素又称为进栈或入栈,它是把该元素放到栈顶元素的上面,使之成为新的栈顶元素;从一个栈删除元素又称为出栈或退栈,它是把栈顶元素删除掉,使其下面的相邻元素成为新的栈顶元素。
在日常生活中,有许多类似栈的例子,如刷洗盘子时,依次把每个洗净的盘子放到洗好的盘子上,相当于进栈;取用盘子时,从一摞盘子上一个接一个地向下拿,相当于出栈。又如向枪支弹夹里装子弹时,子弹被一个接一个地压入,则为进栈;射击时子弹总是从顶部一个接一个地被射出,此为子弹出栈。
由于栈的插入和删除运算仅在栈顶一端进行,后进栈的元素必定先出栈,所以又把栈称为后进先出表(Last In First Out, 简称LIFO)。
例如,假定一个栈S为(a,b,c),其中字符c的一端为栈顶,字符c为栈顶元素。若向S压入一个元素d,则S变为(a,b,c,d),此时字符d为栈顶元素;若接着从栈S中依次删除两个元素,则首先删除的是元素d,接着删除的使元素c,栈S变为(a,b),栈顶元素为b。
栈既然是一种线性表,所以线性表的顺序存储和链接存储结构同样适用于栈。
(1) 栈的顺序存储结构
栈的顺序存储结构同样需要使用一个数组和一个整型变量来实现,利用数组来顺序存储栈中的所有元素,利用整型变量来存储栈顶元素的下标位置。假定栈数组用stack[StackMaxSize]表示,指示栈顶位置的整型变量用top表示,则元素类型为ElemType的栈的顺序存储类型可定义为:
ElemType stack[StackMaxSize];
int top;
其中,StackMaxSize为一个整型全局常量,需事先通过const语句定义,由它确定顺序栈(即顺序存储的栈)的最大深度,又称为长度,即栈最多能够存储的元素个数;由于top用来指示栈顶元素的位置,所以把它称为栈顶指针。
栈的顺序存储结构同样可以定义在一个记录类型中,假定该记录类型用Stack表示,则定义为:
struct Stack {
ElemType stack[StackMaxSize];
int top;
};
在顺序存储的栈中,top的值为-1表示栈空,每次向栈中压入一个元素时,首先使top增1,用以指示新的栈顶位置,然后再把元素赋值到这个位置上,每次从栈中弹出一个元素时,首先取出栈顶元素,然后使top减1,指示前一个元素成为新的栈顶元素。由此可知,对顺序栈的插入和删除运算相当于是在顺序表(即顺序存储的线性表)的表尾进行的,其时间复杂度为O(1)。
设一个栈S为(a,b,c,d,e),对应的顺序存储结构如图4-1(a)所示。若向S中插入一个元素f,则对应如图4-1(b)所示。若接着执行两次出栈操作后,则栈S对应如图4-1(c)所示。若依次使栈S中的所有元素出栈,则s变为空,如图4-1(d)所示。在图4-1中栈是垂直画出的,并且使下标编号向上递增,这样可以形象地表示出栈顶在上,栈底在下。
在一个顺序栈中,若top已经指向了StackMaxSize-1的位置,则表示栈满,若top的值已经等于-1,则表示栈空。向一个满栈插入元素和从一个空栈删除元素都是不允许的,应该停止程序运行或进行特别处理。
(2) 栈的链接存储结构
栈的链接存储结构与线性表的链接存储结构相同,是通过由结点构成的单链表实现的,此时表头指针被称为栈顶指针,由栈顶指针指向的表头结点被称为栈顶结点,整个单链表被称为链栈,即链接存储的栈。当向一个链栈插入元素时,是把该元素插入到栈顶,即使该元素结点的指针域指向原来的栈顶结点,而栈顶指针则修改为指向该元素结点,使该结点成为新的栈顶结点。当从一个链栈中删除元素时,是把栈顶元素结点删除掉,即取出栈顶元素后,使栈顶指针指向原栈顶结点的后继结点。由此可知,对链栈的插入和删除操作是在单链表的表头进行的,其时间复杂度为O(1)。
设一个栈为(a,b,c),当采用链接存储时,对应的存储结构示意图如图4-2(a)所示,其中HS表示栈顶指针,其值为存储元素c结点的地址。当向这个栈插入一个元素d后,对应如图4-2(b)所示。当从这个栈依次删除两个元素后,对应如图4-2(c)所示。当链栈中的所有元素全部出栈后,栈顶指针HS的值为空,即常量NULL所表示的数值0。
3. 栈的抽象数据类型
栈的抽象数据类型中的数据部分为具有ElemType元素类型的一个栈,它可以采用任一种存储结构实现;操作部分包括元素进栈、出栈、读取栈顶元素、检查栈是否为空等。下面给出栈的抽象数据类型的具体定义。
ADT STACK is
Data:
采用顺序或链接方式存储的栈,假定其存储类型用StackType
标识符表示。
Operation:
void InitStack(StackType& S);
// 初始化栈S,即把它置为空
void ClearStack(StackType& S);
// 清除栈S中的所有元素,使之成为一个空栈
int StackEmpty(StackType& S);
// 判断S是否为空,若是则返回1,否则返回0
ElemType Peek(StackType& S)
// 返回S的栈顶元素,但不移动栈顶指针
void Push(StackType& S, const ElemType& item)
// 元素item进栈,即插入到栈顶
ElemType Pop(StackType& S)
// 删除栈顶元素并返回之
int StackFull(Stack& S)
// 若栈已满则返回1,否则返回0,此函数为顺
// 序存储的栈所特有
end STACK
对于判断栈是否为空和返回栈顶元素的两种操作,由于它们不改变栈的状态,所以可在参数类型说明前使用常量定义符const。
假定栈a的元素类型为int,下面给出调用上述栈操作的一些例子。
(1) InitStack(a); // 把栈a置空
(2) Push(a,35); // 元素35进栈
(3) int x=48; Push(a,x); // 48进栈
(4) Push(a,x/2); // x除以2的值24进栈
(5) x=Pop(a); // 栈顶元素24退栈并赋给x
(6) x=Peek(a); // 读取栈顶元素48并赋给x
(7) Pop(a); // 栈顶元素48退栈
(8) StackEmpty(a); // 因栈非空,应返回0
(9) cout<<Pop(a)<<endl; // 栈顶元素35退栈并输出
(10) x=StackEmpty(a); // 因栈为空,应返回1,然后赋给x
4. 栈运算的实现
栈运算(操作)的具体实现取决于栈的存储结构,存储结构不同,其算法描述也不同。下面分别给出栈的运算在顺序存储结构和链接存储结构上实现的具体算法。
(a) 栈的操作在顺序存储结构上的实现
假定采用顺序存储结构定义的栈用标识符S 表示,其类型为已经给出过的Stack记录类型。
(1) 初始化栈
void InitStack(Stack& S)
// 初始化栈S,即把它置为空
{
S.top=-1;
}
(2) 把一个栈清除为空
在顺序存储方式下,同初始化栈的算法相同。
void ClearStack(Stack& S)
// 清除栈S中的所有元素,使之成为一个空栈
{
S.top=-1;
}
(3) 检查一个栈是否为空
int StackEmpty(Stack& S)
// 判断S是否为空,若是则返回1,否则返回0
{
return S.top==-1;
}
(5) 读取栈顶元素
ElemType Peek(StackType& S)
// 返回S的栈顶元素,但不移动栈顶指针
{
// 若栈为空则终止程序运行
if(S.top==-1) {
cerr<<"Stack is empty!"<<endl;
exit(1);
}
// 返回栈顶元素的值
return S.stack[S.top];
}
(5) 向栈中插入元素
void Push(Stack& S, const ElemType& item)
// 元素item进栈,即插入到栈顶
{
// 若栈已满则终止程序运行
if(S.top==StackMaxSize-1) {
cerr<<"Stack overflow!"<<endl;
exit(1);
}
// 将栈顶指针后移一个位置
S.top++;
// 将item的值赋给新的栈顶位置
S.stack[S.top]=item;
}
(6) 从栈中删除元素
ElemType Pop(StackType& S)
// 删除栈顶元素并返回之
{
// 若栈为空则终止程序运行
if(S.top==-1) {
cerr<<"Stack is empty!"<<endl;
exit(1);
}
// 暂存栈顶元素以便返回
ElemType temp=S.stack[S.top];
// 栈顶指针前移一个位置
S.top--;
// 返回原栈顶元素的值
return temp;
}
从出栈算法可以看出,原栈顶元素的值没有被改变,所以可以不使用临时变量保存它,返回语句中返回S.stack[S.top+1]的值即可。
(7) 检查栈是否已满
int StackFull(Stack& S)
// 若栈已满则返回1,否则返回0,此函数为顺序栈所特有
{
return S.top==StackMaxSize-1;
}
(b) 栈的操作在链接存储结构上的实现
假定链栈中的结点仍采用在第二章中已经定义的LNode结点类型,并假定栈顶指针用HS表示,下面给出对由HS所指向的链栈进行每一种栈操作的算法。
(1) 初始化链栈
void InitStack(LNode*& HS)
{
HS=NULL; // 将链栈置空。
}
(2) 清除链栈为空
void ClearStack(LNode*& HS)
{
LNode *cp, *np;
// 用cp作为指向待删除的结点,np指向cp的后继结点。
cp=HS; // 给cp指针赋初值,使之指向栈顶结点。
while(cp!=NULL)
{ // 从栈顶到栈底依次删除每个结点
np=cp->next;
delete cp;
cp=np;
}
HS=NULL; // 置链栈为空
}
(3) 检查链栈是否为空
int StackEmpty(LNode* HS)
// HS为值参或引用形参均可
{
return HS==NULL;
}
(4) 读取栈顶元素
ElemType Peek(LNode* HS) // HS为值参或引用形参均可
{
if(HS==NULL) {
cerr<<"Linked stack is empty!"<<endl;
exit(1);
}
return HS->data;
}
(5) 向链栈中插入一个元素
void Push(LNode*& HS, const ElemType& item)
{
// 为插入元素获取动态结点
LNode* newptr= new LNode;
if(newptr==NULL) {
cerr<<"Memory allocation failare!"<<endl;
exit(1);
}
// 给新分配的结点赋值
newptr->data=item;
// 向栈顶插入新结点
newptr->next=HS;
HS=newptr;
}
(6) 从链栈中删除一个元素
ElemType Pop(LNode*& HS)
{
if(HS==NULL) {
cerr<<"Linked stack is empty!"<<endl;
exit(1);
}
LNode* p=HS; // 暂存栈顶结点
HS=HS->next; // 使栈顶指针指向其后继结点
ElemType temp=p->data; // 暂存原栈顶元素
delete p; // 回收原栈顶结点
return temp; // 返回原栈顶元素
}
例1. 从键盘上输入一批整数,然后按照相反的次序打印出来。
根据题意可知,后输入的整数将先被打印出来,这正好符合栈的后进先出的特点。所以此题很容易用栈来解决。参考程序如下:
#include<iostream.h>
#include<stdlib.h>
const int StackMaxSize=30;
typedef int ElemType; // 定义元素类型为整型
struct Stack {
ElemType stack[StackMaxSize];
int top;
};
#include"stack.h"
// 假定对顺序栈操作的算法已经存于"stack.h"头文件中
void main()
{
Stack a;
InitStack(a);
int x;
cin>>x;
while(x!=-1) {
// 假定用-1作为终止键盘输入的标志,输入的整数个数
// 不能超过StackMaxSize所规定的值
Push(a,x);
cin>>x;
}
while(!StackEmpty(a)) // 栈不为空时依次退栈打印出来
cout<<Pop(a)<<" ";
cout<<endl;
}
假定从键盘上输入为:
78 63 45 82 91 34 -1
则输出为:
34 91 82 45 63 78
在这个算法中,需要扫描待检查程序中的每一个字符,当扫描到每个花、中、圆左括号时,令其进栈,当扫描到每个花、中、圆右括号时,则检查栈顶是否为相应的左括号,若是则作退栈处理,若不是则表明出现了语法错误,应返回0。当扫描到程序文件结尾后,若栈为空则表明没有发现括号配对错误,应返回1,否则表明栈中还有未配对的括号,应返回0。另外,对于一对单引号或双引号内的字符不进行括号配对检查。
根据分析,编写出算法如下:
int BracketsCheck(char* fname)
// 对由fname所指字符串为文件名的文件进行括号配对检查
{
ifstream ifstr(fname, ios::in|ios::nocreate);
// 用文件输入流对象ifstr打开以fname所指字符串为文件名的文件
if(!ifstr) {
cerr<<"File"<<"\'"<<fname<<"\'"<<"not found!"<<endl;
exit(1);
}
Stack a; // 定义一个顺序栈
InitStack(a); // 栈a初始化
char ch;
while(ifstr>>ch) // 顺序扫描文件中的每一个字符
{
if(ch==39) { // 单引号内的字符不参与配对比较
while(ifstr>>ch)
if(ch==39) // 39为单引号的ASCII值
break;
if(!ifstr)
return 0;
}
else if(ch==34) { // 双引号内的字符不参与配对比较
while(ifstr>>ch)
if(ch==34) // 34为双引号的ASCII值
break;
if(!ifstr)
return 0;
}
switch (ch)
{
case '{':
case '[':
case '(':
Push(a,ch); // 出现以上三种左括号则进栈
break;
case '}':
if(Peek(a)=='{')
Pop(a); // 栈顶的左花括号出栈
else
return 0;
break;
case ']':
if(Peek(a)=='[')
Pop(a); // 栈顶的左中括号出栈
else
return 0;
break;
case ')':
if(Peek(a)=='(')
Pop(a); // 栈顶的左圆括号出栈
else
return 0;
}
}
if(StackEmpty(a))
return 1;
else
return 0;
}
由计算机基础知识可知,把一个十进制整数x转换为任一种r进制数得到的是一个r进制的整数,假定为y,转换方法是逐次除基数r取余法。具体叙述为:首先用十进制整数x除以基数r,得到的整余数是r进制数y的最低位y0,接着以x除以r的整数商作为被除数,用它除以r得到的整余数是y的次最低位y1,依次类推,直到商为0时得到的整余数是y的最高位ym,假定y共有m+1位。这样得到的y与x等值,y的按权展开式为:
y=y0+y1.r+y2.r2+...+ym.rm
例如,若十进制整数为3425,把它转换为八进制数的过程如图4-3所示。
图4-3 十进制整数3425转换为八进制数的过程
最后得到的八进制数为(6541)8,对应的十进制数为6×83+5×82+4×8+1=3425,即为被转换的十进制数,证明转换过程是正确的。
从十进制整数转换为r进制数的过程中,由低到高依次得到r进制数中的每一位数字,而输出时又需要由高到低依次输出每一位。所以此问题适合利用栈来解决,具体算法描述为:
void Transform(long num, int r)
// 把一个长整型数num转换为一个r进制数输出
{
Stack a; // 利用栈a存储转换后得到的每一位数字
InitStack(a); // 初始化栈
while(num!=0)
{ // 由低到高求出r进制数的每一位并入栈
int k=num % r;
Push(a,k);
num/=r;
}
while(!StackEmpty(a)) // 由高到低输出r进制数的每一位
cout<<Pop(a);
cout<<endl;
}
假定用下面的主程序调用Transform过程。
void main()
{
cout<<"3425的八进制数为:";
Transform(3425,8);
cout<<"3425的六进制数为:";
Transform(3425,6);
cout<<"3425的四进制数为:";
Transform(3425,4);
cout<<"3425的二进制数为:";
Transform(3425,2);
}
则得到的运行结果如下:
3425的八进制数为:6541
3425的六进制数为:23505
3425的四进制数为:311201
3425的二进制数为:110101100001
二、算术表达式的计算
在计算机中进行算术表达式的计算是通过栈来实现的。这一节首先讨论算术表达式的两种表示方法,即中缀表示法和后缀表示法,接着讨论后缀表达式求值的算法,最后讨论中缀表达式转换为后缀表达式的算法。
1. 算术表达式的两种表示
通常书写的算术表达式是由操作数(又叫运算对象或运算量)和运算符以及改变运算次序的圆括号连接而成的式子。操作数可以是常量、变量和函数,同时还可以是表达式。运算符包括单目运算符和双目运算符两类,单目运算符只要求一个操作数,并被放在该操作数的前面,双目运算符要求有两个操作数,并被放在这两个操作数的中间。单目运算符为取正’+’和取负’-’,双目运算符有加’+’,减’-’,乘’*’和除’/’等。为了简便起见,在我们的讨论中只考虑双目运算符。
如对于一个算术表达式2+5*6,乘法运算符’*’的两个操作数是它两边的5和6;对于加法运算符’+’的两个操作数,一个是它前面的2,另一个是它后面的5*6的结果即30。我们把双目运算符出现在两个操作数中间的这种习惯表示叫做算术表达式的中缀表示,这种算术表达式被称为中缀算术表达式或中缀表达式。
中缀表达式的计算比较复杂,它必须遵守以下三条规则:
(1) 先计算括号内,后计算括号外;
(2) 在无括号或同层括号内,先进行乘除运算,后进行加减运算,即乘除运算的优先级高于加减运算的优先级;
(3) 同一优先级运算,从左向右依次进行。
从这三条规则可以看出,在中缀表达式的计算过程中,既要考虑括号的作用,又要考虑运算符的优先级,还要考虑运算符出现的先后次序。因此,各运算符实际的运算次序往往同它们在表达式中出现的先后次序是不一致的,是不可预测的。当然凭直观判别一个中缀表达式中哪个运算符最先算,哪个次之,……,哪个最后算并不困难,但通过计算机处理就比较困难了,因为计算机只能一个字符一个字符地扫描,要想得到哪一个运算符先算,就必须对整个中缀表达式扫描一遍,一个中缀表达式中有多少个运算符,原则上就得扫描多少遍才能计算完毕,这样就太浪费时间了,显然是不可取的。
那么,能否把中缀算术表达式转换成另一种形式的算术表达式,使计算简单化呢? 回答是肯定的。波兰科学家卢卡谢维奇(Lukasiewicz)很早就提出了算术表达式的另一种表示,即后缀表示,又称逆波兰式,其定义是把运算符放在两个运算对象的后面。采用后缀表示的算术表达式被称为后缀算术表达式或后缀表达式。在后缀表达式中,不存在括号,也不存在优先级的差别,计算过程完全按照运算符出现的先后次序进行,整个计算过程仅需一遍扫描便可完成,显然比中缀表达式的计算要简单得多。例如,对于后缀表达式12!4!-!5!/,其中’!’字符表示成分之间的空格,因减法运算符在前,除法运算符在后,所以应先做减法,后做除法;减法的两个操作数是它前面的12和4,其中第一个数12是被减数,第二个数4是减数;除法的两个操作数是它前面的12减4的差(即8)和5,其中8是被除数,5是除数。
中缀算术表达式转换成对应的后缀算术表达式的规则是:把每个运算符都移到它的两个运算对象的后面,然后删除掉所有的括号即可。
例如,对于下列各中缀表达式:
(1) 3/5+6
(2) 16-9*(4+3)
(3) 2*(x+y)/(1-x)
(4) (25+x)*(a*(a+b)+b)
对应的后缀表达式分别为:
(1) 3!5!/!6!+
(2) 16!9!4!3!+!*!-
(3) 2!x!y!+!*!1!x!-!/
(4) 25!x!+!a!a!b!+!*!b!+!*
后缀表达式的求值比较简单,扫描一遍即可完成。它需要使用一个栈,假定用S表示,其元素类型应为操作数的类型,假定为浮点型float,用此栈存储后缀表达式中的操作数、计算过程中的中间结果以及最后结果。假定一个后缀算术表达式以字符’@’作为结束符,并且以一个字符串的方式提供。后缀表达式求值算法的基本思路是:把包含后缀算术表达式的字符串定义为一个输入字符串流对象,每次从中读入一个字符(空格作为数据之间的分隔符,不会被作为字符读入)时,若它是运算符,则表明它的两个操作数已经在栈S中,其中栈顶元素为运算符的后一个操作数,栈顶元素的下一个元素为运算符的前一个操作数,把它们弹出后进行相应运算即可,然后把运算结果再压入栈S中;否则,读入的字符必为操作数的最高位数字,应把它重新送回输入流中,然后把下一个数据作为浮点数输入,并把它压入到栈S中。依次扫描每一个字符(对于浮点数只需扫描它的最高位并一次输入整个浮点数)并进行上述处理,直到遇到结束符’@’为止,表明后缀表达式计算完毕,最终结果保存在栈中,并且栈中仅存这一个值,把它弹出返回即可。具体算法描述为:
float Compute(char* str)
// 计算由str字符串所表示的后缀表达式的值,
// 表达式要以'@'字符结束。
{
Stack S; // 用S栈存储操作数和中间计算结果
InitStack(S); // 初始化栈
istrstream ins(str); // 把str定义为输入字符串流对象ins
char ch; // 用于输入字符
float x; // 用于输入浮点数
ins>>ch; // 从ins流对象(即str字符串)中顺序读入一个字符
while(ch!='@')
{ // 扫描每一个字符并进行相应处理
switch(ch)
{
case '+':
x=Pop(S)+Pop(S);
break;
case '-':
x=Pop(S); // Pop(S)弹出减数
x=Pop(S)-x; // Pop(S)弹出的是被减数
break;
case '*':
x=Pop(S)*Pop(S);
break;
case '/':
x=Pop(S); // Pop(S)弹出除数
if(x!=0.0)
x=Pop(S)/x; // Pop(S)弹出的是被除数
else { // 除数为0时终止运行
cerr<<"Divide by 0!"<<endl;
exit(1);
}
break;
default: // 读入的必为一个浮点数的最高位数字
ins.putback(ch); // 把它重新回送到输入流中
ins>>x; // 从字符串输入流中读入一个浮点数
}
Push(S,x); // 把读入的一个浮点数或进行相应运算
// 的结果压入到S栈中
ins>>ch; // 输入下一个字符,以便进行下一轮循环处理
}
if(!StackEmpty(S))
{ // 若栈中仅有一个元素,则它是后缀表达式的值,否则为出错
x=Pop(S);
if(StackEmpty(S))
return x;
else {
cerr<<"expression error!"<<endl;
exit(1);
}
}
else { // 若最后栈为空,则终止运行
cerr<<"Stack is empty!"<<endl;
exit(1);
}
}
此算法的运行时间主要花在while循环上,它从头到尾扫描后缀表达式中的每一个数据(每个操作数或运算符均为一个数据),若后缀表达式由n个数据组成,则此算法的时间复杂度为O(n)。此算法在运行时所占用的临时空间主要取决于栈S的大小,显然,它的最大深度不会超过表达式中操作数的个数,因为操作数的个数与运算符(假定把’@’也看作为一个特殊运算符,即结束运算符)的个数相等,所以此算法的空间复杂度也同样为O(n)。
假定一个字符串a为:
char a[30]="12 3 20 4 / * 8 - 6 * +@";
对应的中缀算术表达式为12+(3*(20/4)-8)*6@,则使用如下语句调用上述函数得到的输出结果为54。
cout<<Compute(a)<<endl;
在进行这个后缀算术表达式求值的过程中,从第四个操作数入栈开始,每处理一个操作数或运算符后,栈S中保存的操作数和中间结果的情况如图4-4所示。
图4-4 栈S中数据的变化
3. 把中缀表达式转换为后缀表达式的算法
设以’@’字符作为结束符的中缀算术表达式已经保存在s1字符串中,转换后得到的后缀算术表达式拟存于s2字符串中。由中缀表达式转换为后缀表达式的规则可知:转换前后,表达式中的数值项的次序不变,而运算符的次序发生了变化,由处在两个运算对象的中间变为处在两个运算对象的后面,同时去掉了所有的括号。为了使转换正确,必须设定一个运算符栈,并在栈底放入一个特殊算符,假定为’@’字符,让它具有最低的运算符优先级,假定为数值0,此栈用来保存扫描中缀表达式得到的暂不能放入后缀表达式中的运算符,待它的两个运算对象都放入到后缀表达式以后,再令其出栈并写入到后缀表达式中。
把中缀表达式转换为后缀表达式算法的基本思路是从头到尾地扫描中缀表达式中的每个字符,对于不同类型的字符按不情况进行处理。若遇到的是空格则认为是分隔符,不需要进行处理;若遇到的是数字或小数点,则直接写入到s2中,并在每个数值的最后写入一个空格;若遇到的是左括号,则应把它压入到运算符栈中,待以它开始的括号内的表达式转换完毕后再出栈;若遇到的是右括号,则表明括号内的中缀表达式已经扫描完毕,把从栈底直到保存着的对应左括号之间的运算符依次退栈并写入s2串中;若遇到的是运算符,当该运算符的优先级大于栈顶运算符的优先级(加减运算符的优先级设定为1,乘除运算符的优先级设定为2,在栈中保存的特殊运算符’@’和’(’的优先级设定为0)时,表明该运算符的后一个运算对象还没有被扫描并放入到s2串中,应把它暂存于运算符栈中,待它的后一个运算对象从s1串中读出并写入到s2串中后,再另其出栈并写入s2串中;若遇到的运算符的优先级小于等于栈顶运算符的优先级,这表明栈顶运算符的两个运算对象已经被保存到s2串中,应将栈顶运算符退栈并写入到s2串中,对于新的栈顶运算符仍继续进行比较和处理,直到被处理的运算符的优先级大于栈顶运算符的优先级为止,然后另该运算符进栈即可。
按照以上过程扫描到中缀表达式结束符’@’时,把栈中剩余的运算符依次退栈并写入到后缀表达式中,再向s2写入表达式结束符’@’和字符串结束符’\0’,整个转换过程就处理完毕,在s2中就得到了转换成的后缀表达式。
例如,设中缀算术表达式s1为:10+(18+9*3)/15-6@,使用的运算符栈用R表示,则转换过程如下:
(1)开始时存放后缀表达式的字符串s2为空,R中压入有’@’算符,它具有最低的优先级0:
@
(2)当扫描到s1中的左括号时,s2和R中的数据变化如下:
1 0
@ + (
(3)当扫描到s1中的数值3时,s2和R中的数据变化为:
1 0 1 8 9 3
@ + ( + *
(4)当扫描到s1中的右括号时,s2和R变为:
1 0 1 8 9 3 * +
@ +
(5)当扫描到s1中的数值15时,s2和R又变为:
1 0 1 8 9 3 * + 1 5
@ + /
(6)当扫描到s1中的’@’字符时,s2和R为:
1 0 1 8 9 3 * + 1 5 / + 6
@ -
1 0 1 8 9 3 * + 1 5 / + 6 - @ ?
(7)当整个处理过程结束后,R栈为空,s2为:
将中缀算术表达式转换为后缀算术表达式的算法描述如下:
void Change(char* s1, char* s2)
// 将字符串s1中的中缀表达式转换为存于字符串s2中的后缀表达式
{
Stack R; // 定义用于暂存运算符的栈
InitStack(R); // 初始化栈
Push(R,'@'); // 给栈底放入'@'字符,它具有最低优先级0
int i,j;
i=0; // 用于指示扫描s1串中字符的位置,初值为0
j=0; // 用于指示s2串中待存字符的位置,初值为0
char ch=s1[i]; // ch保存s1串中扫描到的字符,初值为第一个字符
while(ch!='@')
{ // 顺序处理中缀表达式中的每个字符
if(ch==' ')
// 对于空格字符不做任何处理,顺序读取下一个字符
ch=s1[++i];
else if(ch=='(')
{ // 对于左括号,直接进栈
Push(R,ch);
ch=s1[++i];
}
else if(ch==')')
{ // 对于右括号,使括号内的仍停留在栈中的运算符依次
// 出栈并写入到s2中
while(Peek(R)!='(')
s2[j++]=Pop(R);
Pop(R); // 删除栈顶的左括号
ch=s1[++i];
}
else if(ch=='+'||ch=='-'||ch=='*'||ch=='/')
{ // 对于四则运算符,使暂存在栈中的不低于ch优先级
// 的运算符依次出栈并写入到s2中
char w=Peek(R);
while(Precedence(w)>=Precedence(ch))
{ // Precedence(w)函数返回运算符形参的优先级
s2[j++]=w;
Pop(R); w=Peek(R);
}
Push(R,ch); // 把ch运算符写入栈中
ch=s1[++i];
}
else
{ // 此处必然为数字或小数点字符
while(isdigit(ch)||ch=='.')
{ // 把一个数值中的每一位依次写入到s2串中
s2[j++]=ch;
ch=s1[++i];
}
s2[j++]=' '; // 被转换后的每个数值后放入一个空格
}
}
// 把暂存在栈中的运算符依次出栈并写入到s2串中
ch=Pop(R);
while(ch!='@') {
if(ch=='(') {
cerr<<"expression error!"<<endl;
exit(1);
}
else {
s2[j++]=ch;
ch=Pop(R);
}
}
// 在后缀表达式的末尾放入表达式结束符和字符串结束符
s2[j++]='@';
s2[j++]='\0';
}
求运算符优先级的Precedence函数为:
int Precedence(char op)
// 返回运算符op所对应的优先级数值
{
switch(op)
{
case '+':
case '-':
return 1; // 定义加减运算的优先级为1
case '*':
case '/':
return 2; // 定义乘除运算的优先级为2
case '(':
case '@':
default:
return 0; // 定义在栈中的左括号和栈底字符的优先级为0
}
}
在转换算法中,中缀算术表达式中的 每个字符均需要扫描一遍,对于扫描到的每个运算符,最多需要进行入栈、出栈和写入后缀表达式这三次操作,对于扫描到的数字或小数点,只需要把它直接写入到后缀表达式即可。所以,此算法的时间复杂度为O(n),n为后缀表达式中字符的个数。该算法需要使用一个运算符栈,需要的深度不会超过中缀表达式中运算符的个数,所以此算法的空间复杂度至多也为O(n)。
利用表达式的后缀表示和堆栈技术只需要两遍扫描就可完成中缀算术表达式的计算,显然比直接进行中缀算术表达式计算的扫描次数要少得多。
在上述讨论的中缀算术表达式求值的两个算法中,把中缀表示转换为后缀表示的算法需要使用一个字符栈,而进行后缀表达式求值的算法又需要使用一个浮点数栈,这两个栈的元素类型不同,所以栈的类型无法作为全局量来定义,栈运算的函数也无法适应这种要求。为了解决这个问题,必须把Stack栈类型定义为模板类,把栈运算的函数定义为该类的公用成员函数,通过调用成员函数来实现栈的运算。这里对此不作深入讨论,留给读者作为练习。
假定采用类模板来定义Stack类和编写计算中缀算术表达式值的程序,若执行下面的主程序:
void main()
{
char a[30];
char b[30];
cout<<"请输入一个以'@'字符结束的中缀算术表达式:"<<endl;
cin.getline(a,sizeof(a)); // 从键盘上输入一行表示中缀算术表达
// 式的字符串存入到字符数组a中
Change(a,b);
cout<<"对应的后缀算术表达式为:"<<endl;
cout<<b<<endl;
cout<<"求值结果为:"<<Compute(b)<<endl;
}
则得到的显示结果如下:
请输入一个以'@'字符结束的中缀算术表达式:
12+(3*(20/4)-8)*6@
对应的后缀算术表达式为:
12 3 20 4 /*8 -6 *+@
求值结果为:54
三、队列
1. 队列的定义
队列(Queue)简称队,它也是一种运算受限的线性表,其限制是仅允许在表的一端进行插入,而在表的另一端进行删除。我们把进行插入的一端称作队尾(rear),进行删除的一端称作队首(front)。向队列中插入新元素称为进队或入队,新元素进队后就成为新的队尾元素;从队列中删除元素称为离队或出队,元素离队后,其后继元素就成为队首元素。由于队列的插入和删除操作分别是在各自的一端进行的,每个元素必然按照进入的次序离队,所以又把队列称为先进先出表(First In First Out, 简称FI
FO)。
在日常生活中,人们为购物或等车时所排的队就是一个队列,新来购物或等车的人接到队尾(即进队),站在队首的人购到物品或上车后离开(即出队),当最后一人离队后,则队列为空。
例如,假定有a,b,c,d四个元素依次进队,则得到的队列为(a,b,c,d),其中字符a为队首元素,字符d为队尾元素。若从此队中删除一个元素,则字符a出队,字符b成为新的队首元素,此队列变为(b,c,d);若接着向该队列插入一个字符e,则e成为新的队尾元素,此队列变为(b,c,d,e);若接着做三次删除操作,则队列变为(e),此时只有一个元素e,它既是队首元素又是队尾元素,当它被删除后队列变为空。
2. 队列的存储结构
队列的存储结构同线性表和栈一样,既可以采用顺序结构,也可以采用链接结构。
(1) 队列的顺序存储结构
队列的顺序存储结构需要使用一个数组和两个整型变量来实现,利用数组来顺序存储队列中的所有元素,利用两个整型变量来分别存储队首元素和队尾元素的下标位置,分别称它们为队首指针和队尾指针。假定存储队列的数组用queue[QueueMaxSize]表示,队首和队尾指针分别用front和rear表示,则元素类型为ElemType的队列的顺序存储类型可定义为:
ElemType queue[QueueMaxSize];
int front, rear;
其中QueueMaxSize为一个整型全局常量,需事先通过const语句定义,由它确定顺序队列(即顺序存储的队列)的最大长度,即最多能够存储的元素个数。当然,队列的顺序存储空间也可以采用动态分配,此时用于决定最大长度的量可以为全局常量,也可以为全局或局部变量。如在一个函数的函数体中使用下面语句能够为一个队列分配长度为n的数组空间,该数组名仍用queue表示。
ElemType* queue=new ElemType[n];
队列的顺序存储类型同样可以用一个记录类型来表示,假定记录类型名为Queue,则该类型定义为:
struct Queue {
ElemType queue[QueueMaxSize];
int front, rear;
};
假定一个队列的当前状态如图4-10(a)所示,此时已经有a,b,c三个元素相继出栈(为了同队列中的元素相区别,把它们分别括了起来),队首指针front的值为3,指向的队首元素为d,队尾指针的值为7,指向的队尾元素为h;若接着插入一个新元素i,则队列的当前状态如图4-10(b)所示;若再接着删除一个元素,则变为图4-10(c)所示。
0 1 2 3 4 5 6 7 8 QueueMaxSize-1
(a) (b) (c) d e f g h
front rear
(a)
0 1 2 3 4 5 6 7 8 QueueMaxSize-1
(a) (b) (c) d e f g h i
front rear
(b)
0 1 2 3 4 5 6 7 8 QueueMaxSize-1
(a) (b) (c) (d) e f g h i
front rear
(c)
图4-10 顺序队列的插入和删除操作示意图
同对线性表和栈的插入一样,每次在进行队列插入前,也要判断队列是否已满(即数组空间是否已被用完),若是则停止插入,终止程序运行,否则可向队列中插入新元素。若队尾指针的下一个位置(采用(rear+1)%QueueMaxSize计算出来)恰是队首指针front所指的位置,则表明队列已满,可知判断队满的条件是(rear+1)%QueueMaxSize==front。从另一方面看,若队首指针和队尾指针已经指向了同一个位置,则表明队列中只有一个元素,当删除该元素后,队列为空,队尾指针不变,而队首指针指向了下一个位置,此时队尾指针的下一个位置正好也是队首指针所指的位置,由此可知,当条件(rear+1)%QueueMaxSize==front成立时,可能是队满的情况,也可能是队空的情况。为了区别这两种情况,可设置一个标记,当进行插入操作后,置该标记为1,当进行删除操作后,置该标记为0。在标记为1的情况下,上述条件成立则表明队列已满,在标记为0的情况下,上述条件成立则表明队列为空。为了省去设置一个标记的麻烦,通常采用的处理方法是:让front指针不是指向队首元素的位置,而是指向它的前一个位置,当上述条件成立时队列必然为满,当队首指针等于队尾指针时队列为空,此时整个数组空间只能利用QueueMaxSize-1个存储位置,而不是QueueMaxSize个存储位置。
在顺序队列中进行插入和删除时,不需要比较和移动任何元素,只需要修改队尾和队首指针,并向队尾写入元素或从队首取出元素,所以其时间复杂性为O(1)。
(2) 队列的链接存储结构
队列的链接存储结构也是通过由结点构成的单链表实现的,此时只允许在单链表的表头进行删除和在单链表的表尾进行插入,因此它需要使用两个指针:队首指针front和队尾指针rear。用front指向队首(即表头)结点的存储位置,用rear指向队尾(即表尾)结点的存储位置。用于存储队列的单链表简称链接队列或链队。假定链队中的结点类型仍采用第二章定义的LNode结点类型,那么队首和队尾指针为LNode*指针类型。若把一个链队的队首指针和队尾指针定义在一个记录类型中,并假定该记录类型用标识符LinkQueue表示,则定义如下:
struct LinkQueue {
LNode* front;
LNode* rear;
};
其中LNode结点类型重写如下:
struct LNode {
ElemType data;
LNode* next;
};
队列的抽象数据类型中的数据部分为具有ElemType元素类型的一个队列,它可以采用任一种存储结构实现;操作部分包括元素进队、出队、读取队首元素、检查队列是否为空等。下面给出队列的抽象数据类型的具体定义:
ADT QUEUE is
Data:
采用顺序或链接方式存储的栈,假定其存储类型
用QueueType标识符表示。
Operation:
void InitQueue(QueueType& Q);
file://初始化队列Q,即置Q为空
void ClearQueue(QueueType& Q);
file://清除队列Q中的所有元素,使之成为一个空队
int QueueEmpty(QueueType& Q);
file://判断Q是否为空,若是则返回1,否则返回0
ElemType QFront(QueueType& Q);
file://返回队首元素,但不改变队列状态
void QInsert(QueueType& Q, const ElemType& item);
file://将新元素item的值插入到队尾
ElemType QDelete(QueueType& Q);
file://从队列Q中删除队首元素并返回之
int QueueFull(Queue& Q)
file://若队列已满则返回1,否则返回0,此函数为顺序
file://存储的队列所特有
end QUEUE
假定队列q的元素类型为整型int,下面给出调用上述操作的一些例子。
(1) InitQueue(q); file://把队列置空
(2) QInsert(q,20); file://元素20进队
(3) int x=5; QInsert(q,10*x); file://元素10*x的值50进队
(4) cout<<QFront(q)<<endl; file://输出队首元素20
(5) QDelete(q); file://删除队首元素20
(6) cout<<(x=QDelete(q))<<endl;
file://删除队首元素50,把它赋给x同时输出
(7) x=QueueEmpty(q); file://因队列为空,返回1并赋给x
(8) while(!QueueEmpty(q)) cout<<QDelete(q)<<” ”; file://依次输出队
file://列q中的所有元素,因q已经为空,所以不会得到任何输出
同线性表和栈一样,队列运算(操作)的具体实现取决于队列的存储结构,存储结构不同,其算法描述也不同。下面分别给出队列的运算在顺序和链接两种存储结构上的实现的算法。
(a) 队列运算在顺序存储结构上的实现
假定采用Queue记录类型的对象Q来表示顺序存储的队列,则在Q上进行各种队列运算的算法描述如下:
(1) 初始化队列
void InitQueue(Queue& Q)
file://把队首和队尾指针置为同一下标值0,表示队空
{
Q.front=Q.rear=0;
}
(2) 把一个队列清除为空
void ClearQueue(Queue& Q)
file://对于顺序队列,此算法与初始化队列的算法相同
{
Q.front=Q.rear=0;
}
(3) 检查一个队列是否为空
int QueueEmpty(Queue& Q)
file://当队首与队尾指针相同时表示队空,返回1,否则返回0
{
return Q.front==Q.rear;
}
(4) 读取队首元素
ElemType QFront(Queue& Q)
file://返回队首元素,但不改变队列状态
{
if(Q.front==Q.rear)
{ file://队列为空时退出程序运行
cerr<<"Queue is empty!"<<endl;
exit(1);
}
return Q.queue[(Q.front+1)%QueueMaxSize];
file://队首元素是队首指针的下一个位置中的元素
}
(5) 向队列插入元素
void QInsert(Queue& Q, const ElemType& item)
file://将新元素item的值插入到队尾
{
int k=(Q.rear+1)%QueueMaxSize;
file://求出队尾的下一个位置
if(k==Q.front)
{ file://若对列已满则终止程序运行
cerr<<"Queue overflow!"<<endl;
exit(1);
}
Q.rear=k; file://修改队尾指针使之指向下一个位置
Q.queue[k]=item; file://item的值被赋给新的队尾位置
}
(6) 从队列中删除元素
ElemType QDelete(Queue& Q)
file://删除队首元素并返回之
{
if(Q.front==Q.rear)
{ file://若队列为空则终止运行
cerr<<"Queue is empty!"<<endl;
exit(1);
}
Q.front=(Q.front+1)%QueueMaxSize;
file://修改队首指针使之后移一个位置
return Q.queue[Q.front];
file://返回队首元素
}
(7) 检查一个队列是否已满
int QueueFull(Queue& Q)
file://若队列已满则返回1,否则返回0,此函数为顺序队列所特有
{
return (Q.rear+1)%QueueMaxSize==Q.front;
}
(b) 队列运算在链接存储结构上的实现
假定采用LinkQueue类型的对象HQ来表示链接存储的队列,则在HQ上进行各种队列运算的算法描述如下:
(1) 初始化链队
void InitQueue(LinkQueue& HQ)
file://初始化链队,即把队首和队尾指针置为空,则为置队空
{
HQ.front=HQ.rear=NULL;
}
(2) 清除链队为空
void ClearQueue(LinkQueue& HQ)
file://清除链队中的所有结点,使之成为空队
{
LNode* p=HQ.front;
while(p!=NULL) {
HQ.front=HQ.front->next;
delete p;
p=HQ.front;
} file://此循环结束后,所有结点被清除,队首指针变为空
HQ.rear=NULL; file://置队尾指针为空
}
(3) 检查链队是否为空
int QueueEmpty(LinkQueue& HQ)
file://若链队为空则返回1,否则返回0
{
return HQ.front==NULL;
file://判断队首或队尾任一个指针是否为空即可
}
(4) 读取队首元素
ElemType QFront(LinkQueue& HQ)
file://读取链队中的队首元素
{
if(HQ.front==NULL)
{ file://若链队为空则终止执行
cerr<<"Linked queue is empty!"<<endl;
exit(1);
}
return HQ.front->data; file://返回队首元素
}
(5) 向链队中插入一个元素
void QInsert(LinkQueue& HQ, const ElemType& item)
file://使新元素item的值插入链队
{
LNode* newptr=new LNode;
file://得到一个由newptr指针所指向的新结点
if(newptr==NULL) { file://若内存动态存储空间用完则终止程序
cerr<<"Memory allocation failare!"<<endl;
exit(1);
}
newptr->data=item; file://把item的值赋给新结点的值域
newptr->next=NULL; file://把新结点的指针域置空
if(HQ.rear==NULL)
HQ.front=HQ.rear=newptr;
file://若链队为空,则新结点既是队首结点又是队尾结点
else
HQ.rear=HQ.rear->next=newptr; file://依次修改队尾结点的
file://指针域和队尾指针使之指向新的队尾结点
}
(6) 从队列中删除一个元素
ElemType QDelete(LinkQueue& HQ)
file://从链队中删除队首元素
{
if(HQ.front==NULL) { file://若链队为空则终止运行
cerr<<"Linked queue is empty!"<<endl;
exit(1);
}
ElemType temp=HQ.front->data; file://暂存队首元素以便返回
LNode* p=HQ.front; file://暂存队首指针以便回收队首结点
HQ.front=p->next; file://使队首指针指向下一个结点
if(HQ.front==NULL)
HQ.rear=NULL; file://若链队变为空,则需同时使队尾指针变为空
delete p; file://回收原队首结点
return temp; file://返回被删除的队首元素
}
程序1:
#include<iostream.h>
#include<stdlib.h>
const int QueueMaxSize=6;
typedef int ElemType;
struct Queue {
ElemType queue[QueueMaxSize];
int front, rear;
};
#include"queue.h" file://该头文件保存着对顺序队列进行各种操作的算法
void main()
{
Queue q;
InitQueue(q);
for(int i=0; i<6; i++)
{
int x=rand()%100;
int y=rand()%100;
if(!QueueFull(q)) {
QInsert(q,x);
cout<<x<<" 进队, ";
}
if(!QueueFull(q)) {
QInsert(q,y);
cout<<y<<" 进队";
}
cout<<endl;
cout<<QDelete(q)<<" 出队"<<endl;
}
cout<<endl;
while(!QueueEmpty(q)) {
cout<<QDelete(q)<<" 出队"<<endl;
}
}
此程序使用一个长度为6的顺序队列,利用此队列保存由计算机产生的随机数。主函数中的for循环体共执行6次,每次执行时首先产生出两个100以内的随机整数,接着在队列未满时入队,然后进行一次出栈操作,在主函数的最后使队列中的所有元素依次出队。该程序的运行结果为:
41 进队, 67 进队
41 出队
34 进队, 0 进队
67 出队
69 进队, 24 进队
34 出队
78 进队, 58 进队
0 出队
62 进队,
69 出队
5 进队,
24 出队
58 出队
62 出队
5 出队
#include<iostream.h>
#include<stdlib.h>
typedef int ElemType;
struct LNode {
ElemType data;
LNode* next;
};
struct LinkQueue {
LNode* front;
LNode* rear;
};
#include"linkqueue.h" file://此头文件保存着对链队的各种操作的算法
void main()
{
LinkQueue q1,q2;
InitQueue(q1);
InitQueue(q2);
for(int i=0; i<20; i++)
{
int x=rand()%100;
cout<<x<<" ";
if(x%2==1)
QInsert(q1,x); file://用q1链队存放奇数
else
QInsert(q2,x); file://用q2链队存放偶数
}
cout<<endl;
while(!QueueEmpty(q1)&&!QueueEmpty(q2))
{ file://每一行输出一个奇数和一个偶数,直到任一队列空为止
cout<<QDelete(q1)<<" "<<QDelete(q2)<<endl;
}
}
此程序使用了两个链队q1和q2,用来分别存储由计算机随机产生的20个100以内的奇数和偶数,然后每行输出q1和q2中的一个值,即奇数和偶数配对输出,直到任一队列为空时止。该程序的运行结果为:
41 67 34 0 69 24 78 58 62 64 5 45 81 27 61 91 95 42 27 36
41 34
67 0
69 24
5 78
45 58
81 62
27 64
61 42
91 36
在后面的章节中将会看到队列在具体算法中的应用,这里仅从两个方面来简述队列在计算机科学领域所起的作用。第一个方面是解决主机与外部设备之间速度不匹配的问题,第二个方面是解决由多用户引起的资源竞争问题。对于第一个方面,仅以主机和打印机之
间速度不匹配的问题为例作一下简要说明。主机输出数据给打印机打印,输出数据的速度比打印数据的速度要快得多,若直接把输出的数据送给打印机打印,由于速度不匹配,显然是不行的。所以解决的方法是设置一个打印数据缓冲区,主机把要打印输出的数据依次写入到这个缓冲区中,写满后就暂停输出,转去做其它的事情;打印机就从缓冲区中按照先进先出的原则依次取出数据并打印,打印完后再向主机发出请求,主机接到请求后再向缓冲区写入打印数据,这样做既保证了打印数据的正确,又使主机提高了效率。由此
可见,打印数据缓冲区中所存储的数据就是一个队列。对于第二个方面,CPU(即中央处理器,它包括运算器和控制器)资源的竞争就是一个典型的例子。在一个带有多终端的计算机系统上,有多个用户需要CPU各自运行自己的程序,它们分别通过各自终端向操作系统提出占用CPU的请求,操作系统通常按照每个请求在时间上的先后顺序,把它们排成一个队列,每次把CPU分配给队首请求的用户使用,当相应的程序运行结束或用完规定的时间间隔后,则令其出队,再把CPU分配给新的队首请求的用户使用,这样既满足了每个用户的请求,又使CPU能够正常运行。关于队列在这两个方面应用的详细情况将会在操作系统课程中讨论。