Ch3 栈和队列 | 数据结构

160 阅读14分钟

第 3 章 栈和队列


3.1 栈

是一种特殊的线性表,限定插入和删除操作只能在表尾进行。具有后进先出(LIFO—Last In First Out )的特点。

基本运算:

  • 生成空栈
  • 销毁栈
  • 判栈空
  • 入栈
  • 出栈
  • 取栈顶元素
  • 置空栈
  • 求当前栈内元素个数

顺序栈

由于栈是运算受限的线性表,因此线性表的存储结构对栈也适用。

和顺序表相似,顺序栈的类型描述如下:

typedef struct {
	SElemType *base;
	SElemType *top;
	int stacksize;
} SqStack;

栈中的元素用一组连续的存储空间来存放的。栈底位置设置在存储空间的一个端点,而栈顶是随着插入和删除而变化的,非空栈中的栈顶指针 top 来指向栈顶元素的下一个位置。

好处:top - base 就是栈中元素的数量,top == base 时栈为空

(1)生成空栈

首先建立栈空间,然后初始化栈顶指针。

Status InitStack(SqStack &S)
{
	S.base = (SElemType*)malloc(STACK_INIT_SIZE * sizeof(SElemType));
	if (!S.base) exit(OVERFLOW);
	S.top = S.base;
	S.stacksize = STACK_INIT_SIZE;
	return OK;
}

(2)判断是否为空栈

Status StackEmpty(S) {
	if (S.top == S.base) return TRUE;
	else return FALSE;
}

(3)入栈

Status Push(SqStack &S, Elemtype e)
{
	if (S.top - S.base >= S.stacksieze) {
		S.base = (SElemType*)realloc(S.base, (S.stacksize + STACKINCREMENT) * sizeof(SElemType));
		if (!S.base) exit(OVERFLOW);
		S.top = S.base + S.stacksize; //有可能base在重新分配内存后改变了
		S.stacksize += STACKINCRMENT;
	}
	*S.top++ = e;
	return OK;
}

(4)出栈

Status Pop(SqStack &S, ElemType &e)
{
	if (S.top == S.base) return ERROR;
	e = *--(S.top); //先--再*
	return OK;
}

(5)取栈顶元素

Status GetTop(SqStack S, ElemType &e)
{
	if (S.top == S.base) return ERROR;
	e = *(S.top - 1);
	return OK;
}

说明:

  • 对于顺序栈,入栈时,首先判栈是否为满,栈满的条件为 S.top - S.base >= S.stacksize . 栈满时,不能入栈,否则出现空间溢出,这种现象称为上溢
  • 出栈和读栈顶元素操作,先判栈是否为空,为空时不能出栈和读栈顶元素,否则产生错误

链栈

类型定义:

typedef struct node {
	SElemtype data;
	struct node *next;
} LinkStack;

LinkStack *top;

特点:

  • 链栈不需要事先分配空间
  • 在进行入栈操作时不需要顾忌栈的空间是否已经被填满
  • 链栈的结点结构和单链表中的结点结构相同,由于栈只在栈顶作插入和删除操作,因此链栈中不需要头结点,但要特别注意链栈中指针的方向是从栈顶指向栈底的,这正好和单链表是相反的。

(1)入栈

核心思路:创建一个结点,把结点插入到链表的第一个位置

Status Push_LinkStack(LinkStack &top, SElemType e)
{
	s = malloc(sizeof(LinkStack));
	s->data = e;
	s->next = top;
	top = s;
	return OK;
}

(2)出栈

Status Pop_LinkStack(LinkStack &top, ElemType &e)
{
	if (top == NULL) return ERROR;
	e = top->data;
	p = top;
	top = top->next;
	free(p);
	return OK;
}

3.2 栈的应用举例

数制转换

将十进制数 N 转换为 r 进制的数,其转换方法利用辗转相除法

将得到的余数依次放入栈中

再依次出栈倒序输出即可

void conversion()
{
	InitStack(S);
	scanf("%d", &N);
	while (N) {
		Push(S, N % 8);
		N /= 8;
	}
	whiel (!StackEmpty(S)) {
		Pop(S, e);
		printf(" %d", e)
	}
}

括弧匹配检验

假设表达式中允许包含两种括号:圆括号和方括号,其嵌套的顺序随意,如 ([]())[([][])] 等为正确的匹配,[(])([]()(())) 均为错误的匹配。

要求检验一个给定表达式中的括弧是否正确匹配?

算法核心思路

检查表达式中的字符,遇到左括号入栈,遇到右括号则出栈栈顶元素与其匹配,如果匹配成功则继续,否则退出

那么,什么样的情况是“不匹配”的情况呢?

  • 1.和栈顶的左括弧不相匹配;  
  • 2.栈中并没有左括弧等在哪里;  
  • 3.栈中还有左括弧没有等到和它相匹配的右括弧。

在以上分析的基础上就可以写出检验括弧匹配的算法了。

算法注意事项

  • 1.“匹配”不是“相等”。()

  • 2.和栈顶元素进行比较的前提是栈不为空

  • 3.“没有等到”即为栈不空的情况。因此在算法结束之前,需要判别栈是否已为空了?

    1. 别忘了使用栈之前一定要进行初始化。

表达式求值

表达式求值是程序设计语言编译中一个最基本的问题,它的实现也用到栈。

表达式由运算对象、运算符、括号组成的有意义的式子。运算符从运算对象的个数上分,有单目运算符和双目运算符。在此仅讨论只含双目运算符的算术表达式。

例如: 3*2 ^(4 + 2*2 - 1*3)- 5

每个双目运算符在两个运算量的中间的叫中缀表达式

设运算符包括 +-*/%^()

设运算规则为:

  • 1) 优先级 () →  ^  → */%  →  +-

  • 2)有括号出现时先算括号内的,后算括号外的,多层括号,由内向外进行;

  • 3) 乘方连续出现时先算最右面的。

(1)中缀表达式求值   

如表达式“3*2 ^(4 + 2*2 - 1*3)- 5

正确的处理过程是:需要两个栈,运算对象栈 s1算符栈 s2.

自左向右扫描表达式的每一个字符

  • 若当前字符是运算对象,则入对象栈

  • 如果是运算符时:

    • 若这个运算符比栈顶运算符高,则入栈,继续向后处理,
    • 若这个运算符比栈顶运算符低,则从对象栈出栈两个运算对象,从算符栈出栈一个运算符进行运算,并将其结果入对象栈
  • 继续处理当前字符,如处理完则处理下一字符,直到遇到结束符。

 中缀表达式表达式 “3*2 ^(4 + 2*2 - 1*3)- 5”求值过程中两个栈的状态情况如图所示:

为了使第一个运算符入栈,预设一个最低级运算符(

Pasted image 20220929102821.png

有些操作符在栈内外的优先级是不同的,

左括号在栈外时优先级最高,在栈内时优先级很低,仅高于栈外的右括号。

(2)后缀表达式

后缀表达式是运算符在运算对象之后,在后缀表达式中,不再引入括号,所有的计算按运算符出现的顺序,严格从左到右进行,而不用再考虑运算规则和级别。

中缀表达式表达式 “3*2 ^(4 + 2*2 - 1*3)- 5” 的后缀表达式为:

3 2 4 2 2 * + 1 3 * - ^ * 5 -

后缀表达式“32422*+13*-^*5-”,栈中状态变化情况:

Pasted image 20220929103557.png

(3)中缀表达式转换成后缀表达式

具体做法:遇到运算对象顺序向 B 数组中存放遇到运算符时比运算符栈顶优先级高则入栈,低则栈顶运算符出栈后送入 B 中存放。

a+b*c-d   ->   abc*+d-

快速手算法:

  • 1、按照运算符的优先级对所有的运算单位加括号
a+b*c-d   ->   ((a+(b*c))-d)
  • 2、把运算符移动到括号的后面,然后去除括号,得后缀表达式
((a+(b*c))-d)   ->   ((a(bc)*)+d)-   ->   abc*+d-

迷宫求解问题

计算机解迷宫时,通常用的是“穷举求解”的方法,即从入口出发,顺某一方向向前探索,若能走通,则继续往前走;否则沿原路退回,换一个方向再继续探索,直至所有可能的通路都探索到为止,如果所有可能的通路都试探过,还是不能走到终点,那就说明该迷宫不存在从起点到终点的通道。

  • 从入口进入迷宫之后,不管在迷宫的哪一个位置上,都是先往东走,如果走得通就继续往东走,如果在某个位置上往东走不通的话,就依次试探往南、往西和往北方向,从一个走得通的方向继续往前直到出口为止;

  • 如果在某个位置上四个方向都走不通的话,就退回到前一个位置,换一个方向再试,如果这个位置已经没有方向可试了就再退一步,如果所有已经走过的位置的四个方向都试探过了,一直退到起始点都没有走通,那就说明这个迷宫根本不通;

  • 所谓“走不通”不单是指遇到“墙挡路”,还有“已经走过的路不能重复走第二次”,它包括“曾经走过而没有走通的路”。

显然为了保证在任何位置上都能沿原路退回,需要用一个“后进先出”的结构即栈来保存从入口到当前位置的路径。并且在走出出口之后,栈中保存的正是一条从入口到出口的路径。

递归

递归是一个过程或函数直接或间接调用自身的一种方法,它可以把一个大型的问题层层转化为一个与原问题相似、但规模较小的问题来求解。

数学中阶乘的定义,n 的阶乘可以如下表示:

1 , n == 0
n * (n - 1)! , n > 0
int fact(int n)
{
	int tmp;
	if (n == 0)
		return 1;
	else {
		tmp = n * fact(n - 1);
		return tmp;
	}
}

再如,斐波那契(Fibonacci)数列指的是这样一个数列:

n , n == 0, 1
f(n - 1) + f(n - 2) , n >= 2
int fibonacci(int n)
{
	if (n == 0 || n == 1)
		return n;
	else 
		return fibonacci(n - 1) + fibonacci(n - 2)
}

递归算法的设计一般分为两步:

  • 第一步,将规模较大的原问题分解为一个或多个规模较小的而又类似于原问题特性的子问题,既将较大的问题递归地用较小的子问题来描述,解原问题的方法同样可以用来解决子问题;

  • 第二步,是确定一个或多个不需要分解、可直接求解的最小子问题。 

求阶乘的问题中,假设程序运行时,n=4,那么程序的执行过程如下:

Pasted image 20220929105610.png

从上面可以看出,递归调用的过程分为两个阶段:

  • 1)递归过程:将原始问题不断转化为规模小了一级的新问题,从求4!变成求3!,变成求2!,最终达到递归终结条件,求1!;

  • 2)回溯过程:从已知条件出发,沿递归的逆过程,逐一求值返回,直至递归初始处,完成递归调用。

递归调用的内部过程

在这两个阶段中,系统会分别完成一系列的操作。在递归调用之前,系统需完成三件事:

  • 为被调用过程的局部变量分配存储区;

  • 将所有的实参、返回地址等信息传递给被调用过程保存;

  • 将控制转移到被调过程的入口。

从被调用过程返回调用过程之前,系统也应完成三件工作:

  • 保存被调过程的计算结果;

  • 释放被调过程的数据区;

  • 依照被调过程保存的返回地址将控制转移到调用过程。

在计算机中,是通过使用系统栈来完成上述操作的。

递归应用举例-汉诺塔问题

汉诺塔问题描述

   有 a、b、c 三个底座,底座上面可以放盘子。初始时 a 座上有 n 个盘子,这些盘子大小各不相同,大盘子在下,小盘子在上,依次排列。要求将 a 座上 n 个盘子移至 c 座上,每次只能移动一个,并要求移动过程中保持小盘子在上,大盘子在下,可借助 b 座实现移动。编程序输出移动步骤

这个问题可用递归思想来分析,将 n 个盘子由 a 座移动到 c 座可分为如下三个过程:

  • 先将 a 座上 n-1 个盘子借助 c 座移至 b 座;

  • 再将 a 座上最下面一个盘子移至 c 座;

  • 最后将 b 上 n-1 个盘子借助 a 移至 c 座。

上述过程是把移动 n 个盘子的问题转化为移动 n-1 个盘子的问题,按这种思路,再将移动 n-1 个盘子的问题转化为移动 n-2 个盘子的问题,……

可以用两个函数来描述上述移动过程:

  • 从一个底座上借助某一个底座移动 n 个盘子到另一底座。

  • 从一个底座上移动 1 个盘子到另一底座。

void move(int num, char frompeg, char topeg)
{
	printf("Move Disk %d from %c to peg %c\n", num, frompeg, topeg);
}

void Hanoi(int num, char startpeg, char finalpeg, char auxpeg)
{
	if (num == 1) {
		move(1, startpeg, finalpeg);
		return;
	}
	Hanoi(num - 1, startpeg, auxpeg, finalpeg);
	move(num, startpeg, finalpeg);
	Hanoi(num - 1, auxpeg, finalpeg, startpeg);
}

3.4 队列

队列定义

队列是一种特殊的线性表,限定插入和删除操作分别在表的两端进行。具有先进先出(FIFO — First In First Out)的特点。

把允许插入的一端叫队尾(rear),把允许删除的一端叫队头(front)。没有元素时称为空队列。

先进入队列的元素总是先离开队列。因此队列也称作先进先出(First  In First Out)的线性表,简称 FIFO 表

队列结构的基本运算:

  • 构造空队列操作        InitQueue(&Q)

  • 销毁队列操作            DestroyQueue(&Q)

  • 判队空否函数           QueueEmpty (Q)

  • 元素入队操作           EnQueue(&Q,e)

  • 元素出队函数           DeQueue(&Q,&e)

  • 取队头元素函数        GetHead(Q,&e)

  • 队列置空操作          ClearQueue(&Q)

  • 求队中元素个数函数  QueueLength(Q)

思考:可否用两个栈实现一个队列?如何实现?

顺序队

需附设两个指针 front 和 rear 分别指示队列头元素及队列尾元素的位置,为了描述方便,约定:

空队列时 front=rear=0; 每当插入新的队列尾元素时,尾指针增 1,因此在非空队列中,头指针始终指向队列头元素,而尾指针始终指向队列尾元素的下一个位置。

随着入队出队的进行,会使整个队列整体向后移动,出现“假溢出”

循环队列

解决假溢出的方法:将队列的数据区看成头尾相接的循环结构,头尾指针的关系不变,将其称为“循环队列”,“循环队列”如下图所示。

  • 只要队列元素个数小于总的可用空间,插入删除就可以一直进行下去

  • 队尾插入一个元素时:rear = (rear + 1) %10

  • 队头删除一个元素时:front = (front + 1) %10

注意:循环队在队满和队空情况下都有:front == rear,这显然是必须要解决的一个问题。

方法一:

  • 附设一个存储队中元素个数的变量如 num,当 num == 0 时队空,当num == MAXSIZE 时为队满。     

方法二:

  • 少用一个元素空间,当队尾指针加 1 就会从后面赶上队头指针,这种情况下队满的条件是:(rear+1) % MAXSIZE == front,也能和空队区别开。

我们采用第二种方法。

数据结构定义

typedef struct {//队列的顺序存储结构
     QElemType *base;
     int front;
     int rear;
} SeQueue;

循环队列的操作:

(1)初始化

//初始化
Status InitQueue(SqQueue &Q)
{ 
	Q.base = (QElemType *)malloc(MAXQSIZE * sizeof(QElemtype));
	if(!Q.base) exit (OVERFLOW);
	Q.front = Q.rear = 0;
	return OK;
}

(2)判队空

Status QueueEmpty(SqQueue Q)
{
	if (Q.front == Q.rear) return TRUE;
	else return FALSE;
}

(3)判队满

Status QueueFull(SqQueue Q)
{
	if ((Q.rear + 1) % MAXQSIZE == Q.front) return TRUE;
	else return FALSE;
}

(4)求队长

int QueueLength(SqQueue Q)
{
	return (Q.rear - Q.front + MAXQSIZE) % MAXQSIZE;
}

(5)入队

Status EnQueue(SqQueue &Q, Elemtype e)
{
	if ((Q.rear + 1) % MAX == Q.front) return ERROR;
	Q.base[Q.rear] = e;
	Q.rear = (Q.rear + 1) % MAXSIZE;
	return OK;
}

(6)出队

Status DeQueue(SqQueue &Q, Elemype &e)
{
	if (Q.front == Q.rear) return ERROR;
	e = Q.base[Q.front];
	Q.front = (Q.fornt + 1) % MAXQSIZE;
	return OK;
}

链队

链式存储的队称为链队。和链栈类似,用单链表来实现链队,根据队的FIFO原则,为了操作上的方便,我们分别需要一个头指针和尾指针,如图所示。

Pasted image 20220929121646.png

数据结构定义

队头指针 + 队尾指针

typedef  struct QNode  { 
    QElemtype data;
    struct QNode *next;
}  Qnode, *QueuePtr;

typedef struct  {
     QueuePtr front;
     QueuePtr rear;
}LinkQueue;

带头结点的链队:

Pasted image 20220929121926.png

带头结点链队的操作:

(1)初始化链队

Status InitQueue(LinkQueue &Q)
{// 链队带头结点
    Q.front = Q.rear = (QueuePtr)malloc(sizeof(Qnode));
    if(!Q.front) exit(OVERFLOW);
    Q.front->next = NULL; 
    return OK;
}

(2)入链队

Status EnQueue(LinkQueue &Q, QElemtype e)         
{ 
    p = (QueuePtr)malloc(sizeof(QNode));
	if (!p)  exit(OVERFLOW);
    p->data = e; 
    p->next = NULL;
    Q.rear->next = p;
    Q.rear = p;
    return OK;
 }

(3)出链队

如果出队的就是最后一个元素,记得把 rear 更新,不然成野指针了。

Status DeQueue(LinkQueue &Q, QElemtype &e)
{//链队带头结点
	if (Q.front == Q.rear) return ERROR; //如果为空
	p = Q.front->next;
	e = p->data;
	Q.front->next = p->next;
	if (Q.rear == p) Q.rear = Q.front; //如果出队的是最后一个元素,别丢了
	free(p);
	return OK;
}

队列应用举例

设计一个算法找一条从迷宫入口到出口的最短路径。

两种搜索方案:

深度优先搜索(DFS,Depth-first Search),优先对最近才发现的结点进行探索,走不通再回来,适合用栈实现

广度优先搜索(BFS,Breadth-first_Search),完全搞清楚一个结点再换下一个稳扎稳打的向外扩,适合用队列实现。


3.5 本章知识点小结

  • 栈的定义

  • 顺序栈的实现

  • 上溢、下溢

  • 链式栈的实现

  • 队列的定义

  • 顺序队列、假上溢

  • 循环队列的实现

  • 链式存储队列的实现