数据结构(严蔚敏版)——第三章《栈和队列》

167 阅读16分钟

这是我参与「掘金日新计划 · 2 月更文挑战」的第 二十五 天

第三章 栈和队列

3.1、栈和队列的定义和特点

3.1.1、栈的定义和特点

定义:

栈是是一种特殊的线性表,是限定在表尾进行插入或删除操作的线性表。又称为后进先出的线性表,简称LIFO

相关概念:

  • 表尾(即an端)称为栈顶Top;表头(即a1端)称为栈底Base
  • 插入元素到栈顶(即表尾)称为入栈
  • 栈顶(即表尾)删除最后一个元素的操作,称为出栈

入栈的操作示意图

image-20221012215223918

出栈示意图

image-20221012215122869

思考:a、b、c3个元素,入栈顺序是a、b、c,则他们的出栈顺序有几种可能:

image-20221012215655461

栈的相关概念:

  1. 定义:限定只能在表的一端进行插入和删除运算的线性表(只能在栈顶操作)
  2. 逻辑结构: 与同线性表相同,仍为一对一关系
  3. 存储结构:用顺序栈或链栈存储均可,但以顺序栈更常见
  4. 运算规则:只能在栈顶运算,且访问结点时依照后进先出(LIFO)的原则
  5. 实现方式:关键是编写入栈和出栈函数,具体实现依顺序或链栈的不同而不同。

栈与一般线性表的不同:

栈与一般线性表的区别:仅在于运算规则不同

image-20221012220646138

3.1.2、队列的定义和特点

  • 队列是一种*先进先出(FIFO)*的线性表。在表的一端插入(表尾),在另一端(表头)删除

队列相关概念:

  1. 定义: 只能在表的一端进行插入运算,在表的另一端进行删除运算的线性表
  2. 逻辑结构: 与同线性表相同,仍为一对一关系
  3. 存储结构: 顺序队链队,以循环顺序队列更常见
  4. 运算规则: 只能在队首和队尾运算,且访问结点时依照先进先出的原则。
  5. 实现方式: 关键时掌握入队出队操作,具体实现依顺序队或链队的不同而不同

3.2、案例引入

3.2.1、案例一:进制转换

十进制整数N向其它进制数d(二、八、十六)的转换是计算机实现计算的基本问题

  • 转换法则:除以d倒取余
  • 原理为:n = (n div d) * d + n mod d ,其中div为整除运算,mod为求余运算

例:把十进制数1348= 转换成八进制数为2504。

NN div 8N mod 8
13481684
168210
2125
202

3.2.2、案例二:括号匹配的检验

  • 假设表达式中允许包含两种括号:圆括号和方括号

  • 其嵌套的顺序随意,即:

    • ([] ()) 或 [ ( [] [] ) ]为正确格式
    • [ ( ] ) 或 ( [ () ) 或 ( ( ) ])为错误格式

3.2.3、案例三:表达式求值

表达式求值是程序设计语言编译中的一个基本问题,在实现时也需要运用栈。

实现:我们会使用算法——算符优先算法(运算符优先级确定运算顺序的表达式求值算法)

  • 表达式组成

    • 操作数:常数、变量
    • 运算符:算术运算符、关系运算符和逻辑运算符
    • 界限符:左右括弧和表达式结束符
  • 任何一个算术表达式都是由操作数(常数、变量)、算术运算符(+、-、*、/)和界限符(括号、表达式结束符 ’#‘、虚设的表达式起始符'#')组成。

image-20221012225013051

3.2.4、案例四:舞伴问题

假设在舞会上,男士和女士各自排成一队,舞会开始时,依次从男队和女队的队头个出一人配成舞伴。如果两队初始人数不同,则较长的那一队未配对者等待下一轮舞曲。

3.3、栈的表示和操作的实现

3.3.1、栈的类型定义

栈的基本操作的抽象数据类型定义:

ADT Stack {
  数据对象; D  = {ai | ai 属于 ElementSet, i = 1, 2, ... , n, n >= 0}
  数据关系: R1 = {<ai - 1, ai> | ai - 1, ai 属于 D, i = 2, ... , n }
                    约定an端为栈顶,a1端为栈底
  基本操作:
    InitStack(&S)
    操作结果:构造一个空栈
    DestroyStack(&S)
    初始条件:栈S已存在
    操作结果:栈S被销毁
    ClearStack(&S)
    初始条件:栈S已存在
    操作结果:将栈S清空为空栈
    StackEmpty(S)
    初始条件:栈S已存在
    操作结果:若栈S为空栈,则返回true,否则则返回false
    StackLength(S)
    初始条件:栈S已存在
    操作结果:返回S的元素个数,即栈的长度
    GetTop(S, e)
    初始条件:栈S已存在
    操作结果:返回S的栈顶元素,不修改栈顶的指针 
    Push(&S, e)
    初始条件:栈S已存在
    操作结果:插入元素e为新的栈顶元素
    Pop(S)
    初始条件:栈S已存在
    操作结果:删除S的栈顶元素,并用e返回其值
    StackTraverse(S)
    初始条件:栈S已存在且非空
    操作结果:从栈底到栈顶依次对S的每个数据元素进行访问          
}ADT Stack

3.3.2、顺序栈的表示和实现

  • 栈的存储方式有两种:顺序存储和链式存储

    • 栈的顺序存储——顺序栈
    • 栈的链式存储——链栈
  • 存储方式:同一般的线性表的顺序存储结构完全相同,

  • 利用一组地址连续的存储单元依次存放自栈底到栈顶的数据元素。栈底一般在低地址端

    • 附设top,指示栈顶元素在顺序栈中的位置
    • 另设base指针,指示栈底元素在顺序栈中的位置

    为了方便操作,通常top指示真正的栈顶元素之上的下标地址

image-20221013084655325

顺序栈的定义:

#define MAXSIZE 100             // 顺序栈存储空间的初始分配量
typedef struct 
{
  SElemType *base;              // 栈底指针
  SElemType *top;                   // 栈顶指针
  int stacksize;                    // 栈可用的最大容量
}SqStack;

说明:

  1. base为栈底指针,初始化完成之后,栈底指针始终指向栈底的位置,若base为NULL。则表明栈的结构不存在。top为栈顶指针,其初值指向栈底。每插入新的栈顶元素时,指针top增1;删除栈顶元素时,指针top减1.因此栈空时top和base的值相等,即空栈:base == top;栈非空时,top始终指向栈顶元素的上一个位置。栈满的标志:top - base == stacksize
  2. stacksize指示栈可使用的最大容量,后面的算法将stacksize置为MAXSIZE
  3. 上溢:栈已经满,又要压入元素
  4. 下溢:栈已经空,还要弹出元素

顺序栈的表示:

image-20221013100723572

1、顺序栈的初始化

【算法步骤】

  • 为顺序栈动态分配一个最大容量为MAXSIZE的数组空间,使base指向这段空间的基地址,即栈底
  • 栈顶指针top初始为base,表示栈为空
  • stacksize置为栈的最大容量MAXSIZE

【算法描述】

Status InitStack(SqStack &S)
{ // 构造一个空栈
  S.base = new SElemType[MASIZE]; // 或S.base = (SElemType*)malloc(MAXSIZE*sizeof(SElemType));
  if (!S.base) exit (OVERFLOW);     // 存储分配失败
  S.top = S.base;                                   // 栈顶指针等于栈底指针
  S.stacksize = MAXSIZE;                    // stacksize置为栈的最大容量MAXSIZE
  return OK;
}

2、判断顺序栈是否为空

判断条件:是否满足top == base

Status StackEmpty(SqStack S)
{ // 若栈为空,返回TRUE;否则返回FALSE
  if (S.top == S.base) 
    return TRUE;
  else 
    return FALSE;
  
}

3、求顺序栈长度

int StackLength(SqStack S)
{
   return S.top - S.base;
}

4、清空顺序栈

Status ClearStack(SqStack S)
{
  if (S.base) S.top = S.base;
  return OK;
}

5、销毁顺序栈

Status DestroyStack(SqStack &S)
{
  if (S.base) 
  {
    delete S.base;
    S.stacksize = 0;
    S.base = S.top =NULL;
  }
  return OK;
}

6、顺序栈的入栈

  • 判断栈是否满,若满则返回ERROR
  • 将新元素压入栈顶,栈顶指针加1
Status Push(SqStack &S, SElemType e)
{   // 插入元素e为新的栈顶元素
  if (S.top - S.base == S.stacksize) return ERROR;      // 栈满
  *S.top++ = e;
  // 或 *S.top = e; S.top++;
  return OK;
}

7、顺序栈的出栈

【算法步骤】

  • 判断栈是否为空,若为空则返回ERROR
  • 栈顶指针减1,栈顶元素出栈

【算法描述】

Status Pop(SqStack &S, SElemType &e)
{ // 删除S的栈顶元素,用e返回其值
  if (S.top == S.base) return ERROR; 	// 栈空
  e = *--S.top;												// 栈顶指针减1,将栈顶元素赋给e
  // 或 e = S.top; S.top--;
  return OK;
  
}

8、取栈顶元素

  • 当栈非空时,此操作返回当前栈顶的元素值,栈顶指针保持不变。

【算法描述】

SElemType GetTop(SqStack S) 
{ // 返回S的栈顶元素,不修改栈顶指针
  if(S.top != S.base) 				// 栈非空
    return *(S.top - 1);			// 返回栈顶元素的值,栈顶指针不变
  
}

3.3.3、链栈的表示和实现

  • 链栈是运算受限的单链表,只能在链表头部进行操作
  • 链栈的指针指向的元素是数据域的前驱
  • 链表的头指针就是栈顶、不需要头结点,基本不存在栈满的情况
  • 空栈相当于头指针指向空
  • 插入和删除仅在栈顶处执行

image-20221013110241170

链栈的定义:

typedef struct StackNode
{
  SElemType data;
  struct StackNode *next;
}StackNode, *LinkStack;
LinkStack S;

1、链栈的初始化

void InitStack(LinkStack &S) 
{  // 构造一个空栈,栈顶指针置为空
  S = NULL;
  return OK;
}

2、判断链栈是否为空

Status StackEmpty(LinkStack S)
{
  if (S == NULL)	return NULL;
  elese return FALSE;
}

3、链栈的入栈

【算法步骤】

  • 为入栈元素e分配空间,用指针p指向
  • 将新结点数据域置为e
  • 将新结点插入栈顶
  • 修改栈顶指针为p

【算法描述】

Status Push(LinkList &S, SElemType e)
{  // 在栈顶插入元素e
  p = new StackNode; 									// 生成新的结点
  p -> data = e;											// 将新结点的数据域置为e
  p -> next = S;											// 将新结点插入栈顶
  S = p;															// 修改栈顶指针为p
  return OK;
}

4、链栈的出栈

【算法步骤】

  • 判断栈是否为空,若为空则返回ERROR
  • 将栈顶元素赋给e
  • 临时保存栈顶指针,指向新的栈顶元素
  • 释放原栈顶元素的空间

【算法描述】

Status Pop(LinkStack &S, SElemType &e)
{  // 删除S的栈顶元素,用e返回其值
  if (S == NULL)
 		return ERROR;										// 栈空
  e = S -> data;										// 将栈顶元素赋给e
  p = S;														// 用p临时保存栈顶元素空间,以备释放
  S = S -> next;										// 修改栈顶指针
  delete p;													// 释放原栈顶元素的空间
  return OK;
}

5、取栈顶元素

与顺序栈一样,当栈非空时,此操作返回当前栈顶元素的值,栈顶指针S保持不变。

【算法描述】

SElemType GetTop(LinkList S)
{  // 返回S的栈顶元素,不修改栈顶元素
  if (S != NULL)								// 栈非空
  {
    return S -> data;						// 返回栈顶元素的值,栈顶指针不变
  }
}

3.4、栈与递归

3.4.1、采用递归算法解决的问题

1、定义是递归的:

  • 若一个对象部分地包含自己,或用它自己给自己定义,则称这个对象是递归的;
  • 若一个过程直接或者间接的调用自己,则称这个过程是递归的过程。

递归问题——用分治法求解

分治法:对于一个较为复杂的问题,能够分解成几个相对简单且解法相同或类似的子问题来求解

必备的三个条件

  • 1、能将一个问题转变成一个新问题,而且新问题与原问题的解法相同或类同,不同的仅是处理的对象,且这些处理对象是变化有规律的
  • 2、可以通过上述转化而使问题简化
  • 3、必须有一个明确的递归出口,或者递归的边界

”‘分治法“求解递归问题算法的一般形式为:

void p(参数) {
  if (递归结束条件成立) 可直接求解;			// 递归终止的条件
  else p(较小的参数);								 // 递归步骤
}

// 例如
long Fact (long n) {
  if (n == 0) return 1;							// 基本项
  else return n * Fact(n - 1);			// 归纳项
}

函数调用过程:

调用前,系统完成:

  1. 将实参,返回地址等传递给被调用函数
  2. 为被调用函数的局部变量分配存储区
  3. 将控制转移到被调用函数的入口

调用后,系统完成:

  1. 保存被调用函数的计算结果
  2. 释放被调用的函数的数据区
  3. 依照被调用函数保存的返回地址将控制转移到调用函数

多个函数构成嵌套调用时:

image-20221014150823749

第四节记录不全:详细内容请观看第05周12--3.5队列的表示和实现1--3.5.1队列的类型定义哔哩哔哩

3.5、队列的表示和操作的实现

为了防止大家忘记队列的相关概念

3.5.1、相关术语:

  • 队列(Queue)是仅在表尾进行插入操作,在表头进行删除操作的线性表
  • 表尾即an端,称为队尾;表头即a1端,称为队头。
  • 它是一种先进先出(FIFO)的线性表
  • 插入元素称为入队;删除元素称为出队
  • 队列的存储结构为链队或顺序队(常用循环顺序队)

3.5.2、队列的相关概念

  1. 定义:只能在表的一端进行插入运算,在表的另一端进行删除运算的线性表
  2. 逻辑结构: 与线性表相同,仍为一对一关系
  3. 存储结构:顺序队或链队,以循环顺序队列更常见。
  4. 运算规则:只能在队首或队尾运算,且访问结点时依照*先进先出(FIFO)*的原则
  5. 实现方式:关键是掌握入队和出队操作,具体实现依顺序队或链队的不同而不同

3.5.3、队列的类型定义

ADT Queue {
  数据对象:D = {ai | ai 属于 ElemSet, i = 1, 2, ... , n, n >= 0}
  数据关系:R = { <ai-1, ai> | ai-1, ai 属于D, i = 2, ... , n} // 约定其中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返回值
    QueueTraverse(Q);
  	条件:Q存在且非空
    操作结果:从队头到队尾,依次对Q的每个数据元素访问
}
  • 队列的物理存储可以分为顺序存储结构,也可用链式存储结构即:顺序队列链式队列

3.5.4、队列的顺序表示和实现

1、顺序存储结构
  • 队列的顺序表示—用一维数组base[MAXQSIZE]
#define MAXQSIZE 100			// 最大队列长度
Typedef struct {
  QElemType *base;				// 初始化的动态分配存储空间
  int front;							// 头指针
  int rear;								// 尾指针
}SqQueue;					
2、解决假上溢的方法——引入循环队列

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;

两个重要的判断条件:

队空的条件:Q.front == Q.rear

队满的条件:(Q.rear+1) % MAXQSIZE == Q.front

3、队列的初始化

【算法步骤】

  • 为队列分配一个最大容量为MAXQSIZE的数组空间,base指向数组空间的首地址。
  • 头指针和尾指针置为0,表示队列为空

【算法描述】

Status InitQueue(SqQueue &Q) {
  Q.base = new QElemType[MAXQSIZE];						// 分配数组空间
  // Q.base = (QElemType*)malloc(MAXQSIZE * sizeof(QElemType));
  if (!Q.base) exit(OVERFLOW);								// 存储分配失败
  Q.front = Q.rear = 0;												// 头指针尾指针置为0,队列为空
  return OK;
}
4、求队列的长度
  • 对于非循环队列,尾指针和头指针的差值就是队列的长度,而循环队列,差值可能为负数,所以需要加上MAXQSIZE,再与MAXQSIZE求余

【算法描述】

int QueueLength(SqQueue Q) {
  return (Q.rear - Q.front + MAXQSIZE) % MAXQSIZE;
}
5、循环队列入队

【算法步骤】

  • 判断队列是否满,若满则返回ERROR
  • 将新元素插入队尾
  • 队尾指针加1

【算法描述】

Status EnQueue(SqQueue &Q, QElemType e) {
  // 插入元素e为Q的新的队尾元素
  if ((Q.rear + 1) % MAXQSIZE == Q.front) 				// 尾指针在循环意义上加1后等于头指针,表明队满
    return ERROR;
  Q.base[Q.rear] = e;															// 新元素插入队尾
  Q.rear = (Q.rear + 1) % MAXQSIZE;								// 队尾指针加1
  return OK;
}
6、循环队列的出队

【算法步骤】

  • 判断队列是否为空,若空则返回ERROR
  • 保存队头元素
  • 队头指针加1

【算法描述】

Status DeQueue(SqQueue &Q, QElemType &e) {
  // 删除Q的队头元素
  if (Q.front == Q.rear) return ERROR;						// 队空
  e = Q.base[Q.front];														// 保存队头元素
  Q.front = (Q.front + 1) % MAXQSIZE;							// 队头指针加1
  return OK;
}
7、循环队列取队头元素
  • 当队列非空时,此操作返回当前队头元素的值,队头指针保持不变

【算法描述】

SElemType GetHead(SqQueue Q) {
  // 返回Q的队头元素,不修改队头指针
  if (Q.front != Q.rear)						// 队列非空
    return Q.base[Q.front];					// 返回队头元素的值,队头指针不变
}

3.5.5、队列的链式表示和实现

链队列运算指针变化状况

image-20221017182033498

1、链式队列的定义
#define MAXQSIZE 100				// 最大队列长度
typedef struct Qnode {
  QElemType data;
  struct QNode *next;
}QNode, *QueuePtr;

typedef struct {
  QueuePtr front;						// 队头指针
  QueuePtr rear;						// 队尾指针
}LinkQueue;
2、链队列初始化

【算法步骤】

  • 生成新结点作为头结点,队头和队尾指针指向此结点
  • 头结点的指针域为空

【算法描述】

Status InitQueue(LinkQueue &Q) {
  // 构造一个空队列
  Q.front = Q.rear = new QNode;							// 生成新结点作为头结点,队头和队尾指针指向该结点
  Q.front->next = NULL;											// 头结点的指针域为空
  return OK;
}
3、链队的销毁

【算法步骤】

  • 从头结点开始,依次释放所有结点

【算法描述】

Status DestroyQueue (LinkQueue &Q) {
  while(Q.front) {
    Q.rear = Q.front->next;
    free(Q.front);
    Q.front = Q.rear;
  }+
  return OK;
}
4、链队列的入队

【算法描述】

  • 为入队元素分配结点空间,用指针p指向
  • 将新结点数据域置为e
  • 将新结点插入到队尾
  • 修改队尾指针为p

【算法描述】

Status EnQueue(LinkQueue &Q, QElemType e) {
  // 插入元素e为Q的新的队尾元素
  p = new QNode;												// 为入队元素分配结点空间,用指针p指向
  p -> data = e;												// 将新结点数据域置为e
  p -> next = NULL; Q.rear -> next = p;	// 将新结点插入到队尾
  Q.rear = p;														// 修改队尾指针
  return OK;
}
5、链队列的出队

【算法步骤】

  • 判断队列是否为空,若为空则返回ERROR
  • 临时保存队头元素的空间,以备释放
  • 修改头结点的指针域, 指向下一个结点
  • 判断出队元素是否为最后一个元素,若是,则将队尾指针重新赋值,指向头结点
  • 释放原队头元素的空间

【算法描述】

Status DeQueue(LinkQueue &Q, QElemType &e){
  // 删除Q的队头元素,用e返回其值
  if (Q.front == Q.rear) return ERROR;					// 若队列为空,则返回ERROR
  p = Q.front -> next;												  // p指向队头元素
  e = p -> data;																// e保存队头元素的值
  Q.front -> next = p -> next;									// 修改头结点的指针域
  if (Q.rear == p) Q.rear = Q.front;						// 最后一个元素被删,队尾指针指向头结点
  delete p;																			// 释放原队头元素的空间
  return OK;
}
6、链队中求队头元素

【算法描述】

SElemType GetHead(LinkQueue Q) {
  // 返回Q的队头元素,不修改队头指针
  if (Q.front != Q.rear)													// 队列非空
    return Q.front -> next -> data;								// 返回队头元素的值,队头指针不变
}

3.6、案例分析与实现