本章的内容并不难,重点需要理解的是栈和队列是操作受限的线性表,是在前一章线性表的基础上增加了一些约束条件。另外本章涉及到递归的概念,这个在后面章节有更多的应用,一定要重点理解掌握~
栈和队列是两种常用的、重要的数据结构
栈和队列是限定插入和删除只能在表的“端点”进行的线性表
栈和队列是线性表的子集(是插入和删除位置受限的线性表)
普通线性表:插入范围为:1~n+1 删除范围为:1~n
栈:插入只能是插入在n+1位置,删除只能是在n位置(后进先出)
由于栈的操作具有后进先出的固有特性,使得栈成为程序设计中的有用工具。另外,如果问题求解的过程具有“后进先出”的天然特性的话,则求解的算法中也必然需要利用“栈”
队列:插入只能是插入在n+1位置,删除只能是在1位置(先进先出)
由于队列的操作具有先进先出的特性,使得队列成为程序设计中解决类似排队问题的有用工具
栈(stack)是一个特殊的线性表,是限定仅在一端(通常是表尾)进行插入和删除操作的线性表,又称为后进先出(Last In First Out)的线性表,简称LIFO结构
栈的相关概念
栈是仅在表尾进行插入、删除操作的线性表
表尾(即an端)称为栈顶Top,表头(即a1端)称为栈底Base
插入元素到栈顶(即表尾)的操作,称为入栈 “入” = 压入 = PUSH(x)
从栈顶(即表尾)删除最后一个元素的操作,称为出栈 “出” = 弹出 = POP(y)
(1)定义
限定只能在表的一端进行插入和删除运算的线性表(只能在栈顶操作)
(2)逻辑结构
与线性表相同,仍为一对一关系
(3)存储结构
用顺序栈或链栈存储均可,但以顺序栈更常见
(4)运算规则
只能在栈顶运算, 且访问结点时依照后进先出(LIFO)的原则
(5)实现方式
关键是编写入栈和出栈函数,具体实现依顺序栈或链栈的不同而不同
栈与一般线性表的区别:仅在于运算规则不同
一般线性表运算规则是随机存取,栈则是后进先出(LIFO)
队列(queue)是一种先进先出(Frist In Frist Out ---- FIFO)的线性表。在表一端插入(表尾),在另一端(表头)删除
队列的相关概念
(1)定义
只能在表的一端进行插入运算,在表的另一端进行删除运算的线性表(头删尾插)
(2)逻辑结构
与线性表相同,仍为一对一关系
(3)存储结构
顺序队或链队,以循环顺序队列更常见
(4)运算规则
只能在队首和队尾运算, 且访问结点时依照先进先出(FIFO)的原则
(5)实现方式
关键是掌握入队和出队操作,具体实现依顺序队或链队的不同而不同
案例1:进制转换
十进制整数N向其他进制数d(二、八、十六)的转换是计算机实现计算的基本问题
转换法则:除以d倒取余
该转换法则对应于一个简单算法原理:n = (n div d)d + n mod d 其中:div为整除运算,mod为求余运算
例如159转换成八进制数,每次除以8把余数存入栈中,运算结束后在从栈中将余数取出 (利用后进先出)
将7,3,2存入栈中,取出的顺序为2,3,7即为转换后的结果
案例2:括号匹配的检验
假设表达式中允许包含两种括号:圆括号和方括号
其嵌套的顺序随意,即:
1.( [] () )或 [ ( [] [] ) ] 为正确格式
2.[ ( ] ) 为错误格式
3.( [ ( ) ) 或 ( ( ) ] ) 为错误格式
可以利用一个栈结构保存每个出现的左括号,当遇到右括号时,从栈中弹出左括号,检验匹配情况
在检验过程中,若遇到以下几种情况之一,就可以得出括号不匹配的结论
(1)当遇到某一个右括号时,栈已空,说明到目前为止,右括号多于左括号
(2)从栈中弹出的左括号与当前检验的右括号类型不同,说明出现了括号交叉情况
(3)算术表达式输入完毕,但栈中还有没有匹配的左括号,说明左括号多于右括号
案例3:表达式求值
表达式求值是程序设计语言编译中一个最基本的问题,它的实现也需要运用栈
这里介绍的算法是由运算符优先级确定运算顺序的对表达式求值算法
表达式的组成
操作数(operand):常数、变量
运算符(operator):算术运算符、关系运算符和逻辑运算符
界限符(delimiter):左右括弧和表达式结束符
任何一个算术表达式都由操作数(常数、变量)、算术运算符(+、-、*、/)和界限符(括号、表达式结束符‘#’、虚设的表达式起始符‘#’)组成。后两者统称为算符。
为了实现表达式求值。需要设置两个栈:
一个是算符栈OPTR,用于寄存运算符
另一个称为操作数栈OPND,用于寄存运算数和运算结果
求值的处理过程是自左至右扫描表达式的每一个字符
当扫描到的是运算数,则将其压入栈OPND,
当扫描到的是运算符时
若这个运算符比OPTR栈顶运算符的优先级高,则入栈OPTR,继续向后处理
若这个运算符比OPTR栈顶运算符优先级低,则从OPND栈中弹出两个运算数,从栈OPTR中弹出栈顶运算符进行运算,并将运算结果压入栈OPND
继续处理当前字符,直到遇到结束符为止
案例4:舞伴问题
假设在舞会上,男士和女士各自排成一队。舞会开始时,依次从男队和女队的队头各出一人配成舞伴。如果两队初始人数不相同,则较长的那一队中未配对者等待下一轮舞曲。现要求写一算法模拟上述舞伴配对问题
显然,先入队的男士或女士先出队配成舞伴。因此该问题具有典型的先进先出特性,可以用队列作为算法的数据结构
首先构造两个队列
依次将队头元素出队配成舞伴
某队为空,则另外一队等待着则是下一舞曲第一个可获得舞伴的人
ADT Stack {
数据对象:
D = { ai | ai ∈ ElemSet,i = 1,2,…,n, n ≥0 }
数据关系:
R1 = {
约定an端为栈顶,a1端为栈底
基本操作:初始化、进栈、出栈、取栈顶元素等(只能在栈顶完成)
} ADT Stack
InitStack(&S) 初始化操作
操作结果:构造一个空栈S
DestroyStack(&S) 销毁栈操作
初始条件:栈S已存在
操作结果:栈S被销毁
StackEmpty(S) 判定S是否为空栈
初始条件:栈S已存在
操作结果:若栈S为空栈,则返回TRUE,否则FALSE
StackLength(S) 求栈的长度
初始条件:栈S已存在
操作结果:返回S的元素个数,即栈的长度
GetTop(S,&e) 取栈顶元素
初始条件:栈S已存在且非空
操作结果:用e返回S的栈顶元素
ClearStack(&S) 栈置空操作
初始条件:栈S已存在
操作结果:将S清为空栈
Push(&S,e) 入栈操作
初始条件:栈S已存在
操作结果:插入元素e为新的栈顶元素
Pop(&S,&e) 出栈操作
初始条件:栈S已存在且非空
操作结果:删除S的栈顶元素an,并用e返回其值
栈的表示和实现
由于栈本身就是线性表,于是栈也有顺序存储和链式存储两种实现方式
栈的顺序存储—顺序栈
栈的链式存储—链栈
存储方式:同一般线性表的顺序存储结构完全相同,利用一组地址连续的存储单元依次存放自栈底到栈顶的数据元素,栈底一般在低地址端
附设top指针,指示栈顶元素在顺序栈中的位置,另设base指针,指示栈底元素在顺序栈中的位置。但是,为了方便操作,通常top指示真正的栈顶元素之上的下标地址,另外,用stacksize表示栈可以使用的最大容量
空栈:base = top 是栈空标志
栈满:top-base = stacksize
栈满时的处理方法:
(1)报错,返回操作系统
(2)分配更大的空间,作为栈的存储空间,将原栈的内容移入新栈
使用数组作为顺序栈存储方式的特点:
简单、方便、但易产生溢出(数组大小固定)
上溢(overflow):栈已经满,又要压入元素
下溢(underflow):栈已经空,还要弹出元素
注:上溢是一种错误,使问题的处理无法进行;而下溢一般认为是一种结束条件,即问题处理结束
链栈的表示
链栈是运算受限的单链表,只能在链表头部进行操作
链栈中指针的方向和常规链表相反
链表的头指针就是栈顶
不需要头结点
基本不存在栈满的情况
空栈相当于头指针指向空
插入和删除仅在栈顶处执行
递归的定义
若一个对象部分地包含它自己,或用它自己给自己定义,则称这个对象是递归的
若一个过程直接地或间接地调用自己,则称这个过程是递归的过程
以下三种情况常常用到递归方法
(1)递归定义的数学函数
阶乘函数
2阶Fibonaci数列
(2)具有递归特性的数据结构
二叉树
广义表
(3)可递归求解的问题
迷宫问题
Hanoi塔问题
递归问题—用分治法求解
分治法:对于一个较为复杂的问题,能够分解成几个相对简单的且解法相同或类似的子问题来求解
必备的三个条件
(1)能将一个问题转变成一个新问题,而新问题与原问题的解法相同或类同,不同的仅是处理的对象,且这些处理对象是变化有规律的
(2)可以通过上述转化而使问题简化
(3)必须有一个明确的递归出口,或称递归的边界
分治法求解递归问题算法的一般形式:
void p(参数表)
{
if(递归结束条件)
可直接求解步骤; //基本项
else
p(较小的参数); //归纳项
}
函数调用过程
调用前,系统完成以下几步
(1)将实参,返回地址等传递给被调用函数
(2)为被调用函数的局部变量分配存储区
(3)将控制转移到被调用函数的入口
调用后,系统完成以下几步
(1)保存被调用函数的计算结果
(2)释放被调用函数的数据区
(3)依照被调用函数保存的返回地址将控制转移到调用函数
当多个函数构成嵌套调用时:遵循后调用的先返回
递归的优缺点
优点:结构清晰,程序易读
缺点:每次调用要生成工作记录,保存状态信息,入栈;返回时要出栈,回复状态信息。时间开销大。
递归程序->非递归程序
方法1:尾递归、单向递归->循环结构
方法2:自用栈模拟系统的运行时栈(有兴趣可以自行了解)
ADT Queue {
数据对象:
D = { ai | ai ∈ ElemSet,i = 1,2,…,n, n ≥0 }
数据关系:
R1 = {
约定a1端为队列头,an端为队列尾
基本操作:
InitQueue(&Q) 操作结果:构造空队列Q
DestroyQueue(&Q) 条件:队列Q已存在;操作结果:队列Q被销毁
ClearQueue(&Q) 条件:队列Q已存在;操作结果:将Q清空
QueueLength(Q) 条件:队列Q已存在;操作结果:返回Q的元素个数,即队长
GetHead(Q,&e) 条件:Q为非空队列; 操作结果:用e返回Q的队头元素
EnQueue(&Q,e) 条件:队列Q已存在;操作结果:插入元素e为Q的队尾元素
DeQueue(&Q,&e) 条件:Q为非空队列; 操作结果:删除Q的对头元素,用e返回值
…
} ADT Queue
队列的物理存储可以用顺序存储结构,也可用链式存储结构。相应地,队列的存储方式也分为两种,即顺序队列和链式队列
队列的顺序表示:用一维数组base[MAXQSIZE]
#define MAXQSIZE 100
Typdef struct{
QElemType *base; //初始化的动态分配存储空间
int front; //头指针
int rear; //尾指针
}SqQueue;
设数组大小为MAXQSIZE,当rear = MAXQSIZE时,发生溢出,若front = 0,rear = MAXQSIZE时,再入队为真溢出。front≠0,rear = MAXQSIZE时,再入队为假溢出
解决假上溢的方法
(1)将队中元素依次向队头方向移动
缺点:浪费时间。每移动一次,队中元素都要移动
(2)将队空间设想成一个循环的表
即分配给队列的m个存储单元可以循环使用,当rear为maxqsize时,若向量的开始端空着,又可从头使用空着的空间。当front为maxqsize时,也是一样
引入循环队列:循环使用为队列分配的存储空间
base[0]接在base[MAXQSIZE-1]之后,若rear+1 = M,则令rear = 0;
实现方法:利用模(mod,C语言中:%)运算
插入元素:
Q.base[Q.rear] = x;
Q.rear = (Q.rear + 1) % MAXQSIZE;
删除元素:
x = Q.base[s.front];
Q.front = (Q.front + 1)% MAXQSIZE;
队空:front = rear 队满:front = rear
解决方案:
(1)另外设一个标志以区别队空、队满
(2)另设一个变量,记录元素个数
(3)少用一个元素空间
队空:front = rear
队满:(rear + 1) % MAXQSIZE = front