第三章 栈和队列
栈和队列的定义和特点
栈的定义和特点
栈(stack)是限定仅在表尾进行插入或删除操作的线性表。因此,对栈来说,表尾端有其特
殊含义,称为栈顶(top),相应地,表头端称为栈底(bottom)。不含元素的空表称为空栈。
栈又称为后进先出(Last In First Out,LIFO) 的线性表。在程序设计中,如果需要按照保存数据时相反的顺序来使用数据,则可以利用栈来实现。
队列的定义和特点
和栈相反,队列(queue)是一种先进先出(First In First Out,FIFO) 的线性表。它只允许
在表的一端进行插入,而在另一端删除元素。这和日常生活中的排队是一致的,最早进入队列
的元素最早离开。在队列中,允许插入的一端称为队尾(rear),允许删除的一端则称为队头(front)。
栈的表示和操作的实现
栈的类型定义
顺序栈的表示和实现
顺序栈是指利用顺序存储结构实现的栈,即利用一组地址连续的存储单元依次存放自栈底到栈顶的数据元素,同时附设指针top指示栈顶元素在顺序栈中的位置。
- 顺序栈的定义
typedef struct {
int* base;
int* top;
int stacksize;
}SqStack;
- 初始化
顺序栈的初始化操作就是为顺序栈动态分配一个预定义大小的数组空间。
bool InitStack(SqStack& s) {
s.base = new int[MAXSIZE];
if (!s.base)
{
exit(-2);
return false;
}
s.top = s.base;
s.stacksize = MAXSIZE;
return true;
}
- 入栈
入栈操作是指在栈顶插入新的元素。
bool Push(SqStack& s, int e) {
if (s.top - s.base == s.stacksize)
{
return false;
}
*s.top++ = e;
return true;
}
- 出栈
出栈操作是指将栈顶元素删除。
bool Pop(SqStack& s, int& e) {
if (s.top == s.base)
{
return false;
}
e = *--s.top;
return true;
}
- 取栈顶元素
当栈非空时,此操作返回当前栈顶元素的值,栈顶指针保持不变。
int GetTop(SqStack s) {
if (s.top != s.base)
{
return *(s.top-1);
}
}
- 遍历栈元素
void StackTraverse(SqStack s) {
while (s.top != s.base)
{
printf("%5d", *(--s.top));
}
printf("\n");
}
- 运行测试
int main() {
SqStack s;
InitStack(s);
Push(s, 132);
Push(s, 58);
Push(s, 199);
StackTraverse(s);
printf("栈顶元素为%d\n", GetTop(s));
int e;
Pop(s, e);
printf("执行了一次出栈操作移除的栈顶元素为%d\n", e);
StackTraverse(s);
}
链栈的表示和实现
链栈是指采用链式存储结构实现的栈。通常链栈用单链表来表示,如图3.4所示。链栈的节点结构与单链表的结构相同,在此用StackNode表示,定义如下:
- 链栈的定义
typedef struct StackNode {
int data;
struct StackNode* next;
}StackNode,*LinkStack;
- 初始化
链栈的初始化操作就是构造一个空栈,因为没必要设头节点,所以直接将栈顶指针置空即可。
bool InitStack(LinkStack& s) {
s = NULL;
return true;
}
- 入栈
和顺序栈的入栈操作不同的是,链栈在入栈前不需要判断栈是否满,只需要为入栈元素动态分配一个节点空间,如图3.5所示。
bool Push(LinkStack& s, int e) {
StackNode *p = new StackNode;
p->data = e;
p->next = s;
s = p;
return true;
}
- 出栈
和顺序栈一样,链栈在出栈前也需要判断栈是否为空,不同的是,链栈在出栈后需要释放出栈元素的栈顶空间,如图3.6所示。
bool Pop(LinkStack& s, int& e) {
if (s == NULL)
{
return false;
}
StackNode* p = s;
e = p->data;
s = p->next;
delete p;
return true;
}
- 取栈顶元素
与顺序栈一样,当栈非空时,取栈顶元素操作返回当前栈顶元素的值,栈顶指针S保持不变。
int GetTop(LinkStack s) {
if (s != NULL)
{
return s->data;
}
}
- 遍历栈元素
void StackTraverse(LinkStack s) {
while (s)
{
printf("%5d", s->data);
s = s->next;
}
printf("\n");
}
- 运行测试
int main() {
LinkStack s;
InitStack(s);
Push(s, 132);
Push(s, 58);
Push(s, 199);
StackTraverse(s);
printf("栈顶元素为%d\n", GetTop(s));
int e;
Pop(s, e);
printf("执行了一次出栈操作移除的栈顶元素为%d\n", e);
StackTraverse(s);
}
栈与递归
采用递归算法解决的问题
所谓递归是指,若在一个函数、过程或者数据结构定义的内部又直接(或间接)出现定义本身的应用,则称其是递归的,或者是递归定义的。在以下3种情况下,常常使用递归的方法。
- 定义是递归的
有很多数学函数是递归定义的,如大家熟悉的阶乘函数:
采取“分治法”进行递归求解的问题需要满足以下3个条件。
- (1)能将一个问题转变成一个新问题,而新问题与原问题的解法相同或类似,不同的仅是处理的对象,并且其处理对象更小且变化有规律。
- (2)可以通过上述转化而使问题简化。
- (3)必须有一个明确的递归出口,或称递归的边界。
“分治法”求解递归问题算法的一般形式为:
void p(参数表)
{
if(递归结束条件成立)可直接求解; //递归终止的条件
else p(较小的参数); //递归步骤
}
- 数据结构是递归的
某些数据结构本身具有递归的特性,则它们的操作可递归地描述。 - 问题的解法是递归的
还有一类问题,虽然问题本身没有明显的递归结构,但用递归求解比迭代求解更简单,如Hanoi(汉诺)塔问题、八皇后问题、迷宫问题等。
具体的递归可以看一下这篇博客第七章 用函数实现模块化程序设计
递归过程与递归工作栈
递归算法的效率分析
- 时间复杂度的分析
在算法分析中,当一个算法中包含递归调用时,其时间复杂度的分析可以转化为一个递归方程求解。实际上,这是数学上求解渐近阶的问题,而递归方程的形式多种多样,其求解方法也不一而足。迭代法是求解递归方程的一种常用方法,其基本步骤是迭代地展开递归方程的右端,使之成为一个非递归的和式,然后通过对和式的估计来达到对方程左端(方程的解)的估计。
- 空间复杂度的分析
递归函数在执行时,系统需设立一个“递归工作栈”存储每一层递归所需的信息,此工作栈是递归函数执行的辅助空间,因此,分析递归算法的空间复杂度需要分析工作栈的大小。对于递归算法,空间复杂度S(n) = O(f (n))其中,f (n)为“递归工作栈”中工作记录的个数与问题规模n的函数关系。
根据这种分析方法不难得到,前面讨论的阶乘问题、斐波那契数列问题、Hanoi塔问题的递
归算法的空间复杂度均为O(n)。
利用栈将递归转换为非递归的方法
通过上述讨论,可以看出递归程序在执行时需要系统提供隐式栈这种数据结构来实现,对于一般的递归过程,仿照递归算法执行过程中递归工作栈的状态变化可直接写出相应的非递归算法。这种利用栈消除递归过程的步骤如下。
- (1)设置一个工作栈存放递归工作记录(包括实参、返回地址及局部变量等)。
- (2)进入非递归调用入口(被调用程序开始处)将调用程序传来的实参和返回地址入栈(递归程序不可以作为主程序,因而可认为初始是被某个调用程序调用)。
- (3)进入递归调用入口:当不满足递归结束条件时,逐层递归,将实参、返回地址及局部变量入栈,这一过程可用循环语句来实现——模拟递归分解的过程。
- (4)递归结束条件满足,将到达递归出口的给定常数作为当前的函数值。
- (5)返回处理:在栈不空的情况下,反复退出栈顶记录,根据记录中的返回地址进行题意规定的操作,即逐层计算当前函数值,直至栈空为止——模拟递归求值过程。
通过以上步骤,可将任何递归算法改写成非递归算法。但改写后的非递归算法和原来比较起来,结构不够清晰,可读性差,有的还需要经过一系列的优化。
队列的表示和操作的实现
队列的类型定义
队列的操作与栈的操作类似,不同的是,删除是在表的头部(队头)进行的。
下面给出队列的抽象数据类型定义:
循环队列——队列的顺序表示和实现
和顺序栈相类似,在队列的顺序存储结构中,除了用一组地址连续的存储单元依次存放从队头到队尾的元素之外,尚需附设两个整型变量front和rear分别指示队头元素及队尾元素的位置(后面分别称为头指针和尾指针)。队列的顺序存储结构表示如下:
#define MAXQSIZE 100
typedef struct {
int* base;
int front;
int rear;
}SqQueue;
为在C语言中描述方便起见,在此约定:初始化创建空队列时,令front = rear = 0,每当插入新的队尾元素时,尾指针 rear增1;每当删除队头元素时,头指针front增1。因此,在非空队列
中,头指针始终指向队头元素,而尾指针始终指向队尾元素的下一个位置,如图3.12所示。
假设当前队列分配的最大空间为6,则当队列处于图3.12(d)所示的状态时不可再继续插入新的队尾元素,否则会出现溢出现象,即因数组越界而导致程序的非法操作错误。事实上,此时队列的实际可
用空间并未占满,所以这种现象称为“假溢出”。这是由“队尾入队,队头出队”这种受限制的操作造成的。
怎样解决这种“假溢出”问题呢?一个较巧妙的办法是将顺序队列变为一个环状的空间,如图3.13所示,称这样的队列为循环队列。
头、尾指针以及队列元素之间的关系不变,只是在循环队列中,头、尾指针“依环状增1”的操作可用“模”运算来实现。通过取模,头指针和尾指针就可以在顺序表空间内以头尾衔接的方式“循环”移动。
- 在图3.14(a)中,队头元素是J5,在元素J6入队之前,Q.rear的值为5,当元素J6入队之后,通过“模”运算,Q.rear = (Q.rear +1)%6,得到Q.rear的值为0,而不会出现图3.12(d)中的“假溢出”状态。
- 在图3.14(b)中,J7、J8、J9、J10相继入队,则队列空间均被占满,此时头、尾指针相同。
- 在图3.14(c)中,若J5和J6相继从图3.14(a)所示的队列中出队,使队列此时呈“空”的状态,头、尾指针的值也是相同的。
区别队满还是队空的两种方法
- 少用一个元素空间,即当队列空间大小为m时,有m-1个元素就认为是队满。这样判断队空的条件不变,即当头、尾指针的值相同时,则认为队空;而当尾指针在循环意义上加1后等于头指针时,则认为队满。因此,在循环队列中队空和队满的条件如下。
队空的条件:Q.front == Q.rear
队满的条件:(Q.rear + 1)%MAXQSIZE == Q.front
- 另设一个标志位以区别队列是“空”还是“满”。
- 初始化
循环队列的初始化操作就是动态分配一个预定义大小为MAXQSIZE的数组空间。
bool InitQueue(SqQueue& q) {
q.base = new int[MAXQSIZE];
if (!q.base)
{
exit(-2);
return false;
}
q.front = q.rear = 0;
return true;
}
- 求队列长度
对于非循环队列,尾指针和头指针的差值便是队列长度;而对于循环队列,差值可能为负数,所以需要将差值加上MAXQSIZE,然后与MAXQSIZE求余。
int QueueLenth(SqQueue q) {
return (q.rear - q.front + MAXQSIZE) % MAXQSIZE;
}
- 入队
入队操作是指在队尾插入一个新的元素。
bool EnQueue(SqQueue& q, int e) {
if ((q.rear+1)%MAXQSIZE == q.front)
{
return false;
}
q.base[q.rear] = e;
q.rear = (q.rear + 1) % MAXQSIZE;
return true;
}
- 出队
出队操作是指将队头元素删除。
bool DeQueue(SqQueue& q, int& e) {
if (q.front == q.rear)
{
return false;
}
e = q.base[q.front];
q.front = (q.front + 1) % MAXQSIZE;
return true;
}
- 取队头元素
当队列非空时,此操作返回当前队头元素的值,队头指针保持不变。
int GetHead(SqQueue q) {
if (q.front != q.rear)
{
return q.base[q.front];
}
}
- 遍历队列元素
void QueueTraverse(SqQueue q) {
while (q.front != q.rear)
{
printf("%5d", q.base[q.front++]);
}
printf("\n");
}
- 运行测试
int main() {
SqQueue q;
InitQueue(q);
printf("当前队列的长度为:%d\n", QueueLenth(q));
EnQueue(q, 132);
EnQueue(q, 56);
EnQueue(q, 98);
EnQueue(q, 190);
printf("当前队列的队头元素为:%d\n",GetHead(q));
QueueTraverse(q);
printf("当前队列的长度为:%d\n", QueueLenth(q));
int e;
DeQueue(q, e);
printf("当前队列出队的元素为:%d\n", e);
printf("当前队列的队头元素为:%d\n",GetHead(q));
QueueTraverse(q);
printf("当前队列的长度为:%d\n", QueueLenth(q));
return 0;
}
链队列——队列的链式表示和实现
链队列是指采用链式存储结构实现的队列。通常链队列用单链表来表示,如图3.15所示。一个链队列显然需要两个分别指示队头和队尾的指针(分别称为头指针和尾指针)才能唯一确定。
- 链队列定义
typedef struct QNode {
int data;
struct QNode* next;
}QNode,*QueuePtr;
typedef struct {
QueuePtr front;
QueuePtr rear;
}LinkQueue;
- 初始化
链队的初始化操作就是构造一个只有一个头节点的空队,如图3.16(a)所示。
bool InitQueue(LinkQueue& q) {
q.front = q.rear = new QNode;
q.front->next = NULL;
return true;
}
- 入队
和循环队列的入队操作不同的是,链队列在入队前不需要判断队是否满,只需要为入队元素动态分配一个节点空间,如图3.16(b)和图3.16(c)所示。
bool EnQueue(LinkQueue& q, int e) {
QNode* p = new QNode;
p->data = e;
q.rear->next = p;
p->next = NULL;
q.rear = p;
return true;
}
- 出队
和循环队列一样,链队在出队前也需要判断队列是否为空,不同的是,链队列在出队后需要释放队头元素所占的空间,如图3.16(d)所示。
bool DeQueue(LinkQueue& q, int& e) {
if (q.front == q.rear)
{
return false;
}
QNode* p = q.front->next;
e = p->data;
q.front->next = p->next;
if (q.rear == p)
{
q.rear = q.front;
}
delete p;
return true;
}
需要注意的是,在链队列的出队操作中还要考虑当队列中最后一个元素被删后,队尾指针也会丢失,因此需对队尾指针重新赋值(指向头节点)。
- 取队头元素
与循环队列一样,当队列非空时,此操作返回当前队头元素的值,队头指针保持不变。
int GetHead(LinkQueue q) {
if (q.front != q.rear)
{
return q.front->next->data;
}
}
- 遍历链队元素
void QueueTraverse(LinkQueue q) {
QNode* p = q.front;
while (p != q.rear)
{
printf("%5d", p->next->data);
p = p->next;
}
printf("\n");
}
- 运行测试
int main() {
LinkQueue q;
InitQueue(q);
EnQueue(q, 80);
EnQueue(q, 95);
EnQueue(q, 85);
EnQueue(q, 190);
printf("当前队列的队头元素为:%d\n", GetHead(q));
QueueTraverse(q);
int e;
DeQueue(q, e);
printf("当前队列出队的元素为:%d\n", e);
printf("当前队列的队头元素为:%d\n", GetHead(q));
QueueTraverse(q);
return 0;
}
小结