栈和队列
文章目录
栈(Stack)
后进先出(LIFO)Last In First Out
栈的顺序存储结构(SqStack——Sequence Stack)
表示方法
#define MaxSize 10;
typedef struct SqStack {
ElemType data[MaxSize];
int top; // 栈顶指针,指向栈顶数组的下标,而非位序
}SqStack;
SqStack S;
-
栈顶指针:S.top,初始时 S.top = -1;
-
栈顶元素:S.data[S.top];
-
进栈操作(push):由于是顺序表,有最大存储空间限制,栈不满时,栈顶指针加 1,送值到栈顶元素。
-
出栈操作(pop):出栈先判断是否空栈,非空,先取出栈顶元素,后栈顶指针减 1。
-
栈空条件:S.top = -1;
-
栈满条件:S.top = MaxSize - 1;
-
栈的当前长度:S.top + 1;
基本操作
初始化 InitStack(SqStack &S)
InitStack(SqStack &S) {
S.top = -1; // 栈顶指针置为 -1,当第一个元素进栈,指针 + 1,便指向下标 0,即数组第一元素
}
判栈空 StackEmpty(SqStack S)
bool StackEmpty(SqStack S) {
if (S.top == -1)
return true;
else
return false;
}
进栈 Push(SqStack &S, ElemType x)
bool Push(SqStack &S, ElemType x) {
if (S.top == MaxSize - 1)
return false; // 栈满
S.top++;
S.data[S.top] = x; // 两步并一步:S.data[++S.top] = x;
return true;
}
出栈 Pop(SqStack &S, ElemType &x)
bool Pop(SqStack &S, ElemType &x) {
if (S.top == -1)
return false; // 栈空
x = S.data[S.top--]; // 先返回,再减 1。【注】看变量和 ++ / -- 的位置,在前先执行。变量在前先返回。
return true;
}
读栈顶元素 GetTop(SqStack &S, ElemType &x)
bool GetTop(SqStack &S, ElemType &x) {
if (S.top = -1)
return false; // 栈空读取不到任何元素。
x = S.data[S.top];
return true;
}
读栈顶其实就是在判断栈是否为空罢了。
共享栈
栈 0 从前向后移动,栈 1 从后先前移动。
typedef struct SqStack {
ElemType data[MaxSize];
int top0;
int top1;
}SqStack;
-
栈顶指针:初始时,S.top0 = -1; S.top1 = MaxSize;
-
栈顶元素:S.data[S.top0]; S.data[S.top1];
-
进栈操作(push):
- 栈 0:栈顶指针加 1,再存数。
- 栈 1:栈顶指针减 1,再存数。
-
出栈操作(pop):
- 栈 0:取数,栈顶指针减 1
- 栈 1:取数,栈顶指针加 1
-
栈空:
- 栈 0:S.top0 = -1;
- 栈 1:S.top1 = MaxSize;
-
栈满:S.top0 = S.top1 - 1;
-
栈当前长度:
- 栈 0:S.top0 + 1;
- 栈 1:MaxSize - S.top1;
栈的链式存储结构(LiStack——LinkStack)
链式存储没有最大长度 MaxSize 的限制,不存在栈满上溢情况。
链式存储的栈使用单链表、双链表都可以实现,双链表实现非常简单,但是存储密度不高,一般使用单链表。
栈的特点是后进先出,如果我们在单链表的尾部进栈,则栈顶指针指向尾部,进栈时,可以实现栈顶指针“先加 1”,再添加数据元素的操作;但是在出栈时,由于是单链表,数据元素取出后,栈顶指针无法“减 1”,因此在表尾进行操作并不合理。
所以基本操作都在表头实现,这时,回想前面单链表中的头插法,恰好满足了“后进先出”的特点。
表示方法
typedef struct Linknode {
ElemType data;
struct Linknode *next;
} *LiStack;
LiStack p 等价于 Linknode *p,前者强调栈,后者强调数据元素。
- 栈顶指针:S 即单链表的第一个数据元素的地址,也就是栈顶地址,S 可看作栈顶指针。
基本操作
只需操作栈顶,因此使用不带头结点的单链表更加方便~
初始化:InitStack(LiStack &S)
void InitStack(LiStack S) {
S = NULL;
}
判栈空:StackEmpty(LiStack S)
bool StackEmpty(LiStack S) {
if (S == NULL)
return true;
}
进栈:Push(LiStack &S, Linknode *p)
bool Push(LiStack S, Linknode *p) {
if (p == NULL)
return false;
p -> next = S; // 这里比较特殊,需要先将 p 连在 S 前面,也就是放在栈顶
S = p; // 再让栈顶指针指向 p 栈顶元素。❗ 这一步容易遗漏
return true;
}
出栈:Pop(LiStack &S, ElemType e)
bool *Pop(LiStack &S) {
if (S == NULL)
return false; // 空栈出个屁
e = S -> data; // 出栈的数据
Linknode *p = S; // 用一个 p 指向栈顶元素,取数后,需要将其释放
S = S -> next; // 栈顶指针 “减 1”
free(p);
return true;
}
注意出栈需要释放栈顶数据元素的存储空间。
读栈顶元素:GetTop(LiStack S)
Linknode *GetTop(LiStack S) {
if (S == NUll)
return NULL;
return S;
}
其实就是在判断栈是否为空。
栈的应用
括号匹配
主要流程如图所示
程序实现
表达式求值
后缀型:
后缀表达式中,越靠左的操作符,越先参与计算。
中缀表达式转后缀表达式(手算)
规则:a + b
→ a b +
,如法炮制即可。
-
在操作符下标注运算顺序①②③…按照“左优先原则”排序
左优先原则:在确定好一个操作符的运算顺序并标号后,从最左向右检索找能第一个能参与运算的操作符,作为下一个操作符。
-
在该操作符下,将两个操作数和操作符按照转换规则转成后缀表达式,此时将它们整体看做一个新的操作数。
-
还有操作符未处理,重复 2 直至完成。
💯中缀表达式转后缀表达式(机算)
思路:
根据 括号 > 乘除 > 加减 的原则,在从左往右扫描表达式的过程中,确定表达式操作符的运算顺序,
先初始化一个栈,栈中用来存储“暂时无法确定运算顺序的操作符” 和 “左括号”。
机器扫描顺序是从左往右,因此每得到一个运算顺序,就可以确定一部分后缀表达式,
-
在不遇到括号情况下,需要多次判断加减乘除计算顺序,在每一次判断依赖当前扫描到的操作符和前面一个操作符,将整个表达式看做
[已经转换为后缀表达式的部分——NumA] [前一个运算符op_prior] [一个单独的数字 NumB] [当前扫描到的运算符op_present] [尚未参与转换的部分 NumC]
的样式。[NumA] [op_prior] [NumB] [op_present] [NumC] // 每一步的形式
此时 op_prior 在栈顶(后面会解释为什么它在栈顶),循环扫描至 op_present 操作符。
每一步中,两个操作符的优先级将决定 op_prior是否能弹出栈参与运算。(这也是每一步的本质工作)
若 op_prior 运算优先级 >= op_present(另一种情况便是不能弹出 op_prior,此时它的有缘人便是右括号或循环结束),按照左优先原则,需先计算前面
A op_prior B
部分,从而op_prior 能弹出栈参与运算,将 op_prior 弹出并加入后缀表达式尾部:NumA NumB op_prior
,这部分可视作一个操作数NumA1
。此时整体表达式形式变为
[NumA1] [op_present] [NumC]
。前面提到过 NumC 是尚未参与转换的部分(形式为
Num1 op1 Num2 op2 ...
),我们将再 NumC 中 num1、op1 和剩余部分视作[Num1] [op1] [剩余部分Num2 op2 ...]
的形式。此时整体表达式便可视作
[NumA1] [op_present] [Num1] [op1] [Num2 op2 ...]
。这就变成了 我们最开始想要的
[NumA] [op_prior] [NumB] [op_present] [NumC]
的形式。此时原先的 op_present 将被视作新的 “op_prior” ,如果不依靠后面的操作符,无法得出**“NumA NumB”以何种顺序进行计算**,因此它是上面初始化栈时提到的“暂时无法确定运算顺序的操作符”,将其入栈。
开始新的循环,重复以上步骤直至结束。
【注】虽然每一步形式如
[NumA] [op_prior] [NumB] [op_present] [NumC]
,但是实质上每次比较的是 当前扫描的运算符和栈中暂时无法确定运算顺序的运算符。 -
在遇到括号时,遇到左括号压入栈顶,括号内部格式仍然符合上述形式,按上述执行,直至遇到右括号,弹出栈中运算符加入到后缀表达式中,再弹出左括号,表明结束了该组括号的匹配。
算法如下:
初始化一个栈,栈中用来存储“暂时无法确定运算顺序的操作符” 和 “左括号”。
-
从左往右扫描表达式的每一个元素,有 3 种情况:
-
遇到 操作数 ,直接加入后缀表达式中。
-
遇到 界限符(括号):
-
左括号:
直接压入栈顶,等待一个右括号与其匹配,匹配到后,便可将括号里内容看做一个操作数加入到后缀表达式中。
-
右括号:
弹出栈顶操作符并将其加入后缀表达式,重复,直至遇到与其匹配的左括号,将左括号弹出,左括号不加入后缀表达式。
-
-
遇到 操作符 :依次弹出栈中 优先级高于或等于 当前操作符的所有操作符,弹出时依次加入后缀表达式,直至栈顶元素为左括号或栈空为止。此时将当前操作符入栈。
【注】上面的优先级指的是:
*
=/
>+
=-
-
-
扫描结束后,将栈中剩余运算符依次弹出、加入后缀表达式。
后缀表达式转中缀表达式计算(手算)
- 从左往右,找操作符
- 遇到操作符,找该操作符前面的两个操作数,按照
a b +
→a + b
这样的规则进行计算。将操作符和两个操作数整体看做一个新的操作数。 - 若还有未处理的操作符,重复 2 直至结束。
后缀表达式转中缀表达式(机算)
算法如下:
由上面手算过程可以看到,每个操作符需要匹配前面两个操作数,而且我们自始至终都只操作这两个操作数,因此使用“栈”这种数据结构可以很好地完成该算法。
- 从左往右扫描下一个元素,执行 2 或执行 3,直至处理完所有元素。
- 若扫描到一个操作数,则执行“入栈”,将其压入栈顶,返回第 1 步。否则执行第 3 步。
- 若扫描到一个操作符,则执行两次“出栈”,弹出两个操作数(先出栈的是右操作数),执行相应运算,将运算结果压入栈顶,返回第 1 步。
【注】所有元素结束后,栈内应仅剩余一个元素——最终的运算结果。
💯计算机计算中缀表达式(非常重要的算法,无处不在)
并非先把一个中缀表达式整体转成后缀表达式,随后整个后缀表达式执行后缀转中缀,在转的过程中完成计算。❌
而是在中缀表达式转后缀表达式的过程中,在每一步“弹出运算符”的时候,完成一步计算,最终计算完整个中缀表达式。
算法如下:
初始化两个栈,操作数栈 和 运算符栈
操作数栈:每扫描到一个操作数,将其入栈,而非“中缀转后缀(机算)”中加入后缀表达式;栈存储尚未参与运算的操作数。
运算符栈:每扫描到一个运算符或左界限符,按照“中缀转后缀(机算)”相同逻辑,对前一个运算符进行处理后,将运算符或左界限符入栈;栈存储暂时无法确定运算顺序的操作符和左界限符。
每弹出一个运算符时,表明弹出的运算符参与了一次运算,此时从操作数栈中弹出两个操作数——该操作符对应的左右操作数,进行计算,将运算结果压入操作数栈顶。
最终操作数栈内仅剩的元素便是计算结果。
前缀型:
中缀表达式转前缀表达式(手算)
规则:a + b
→ + a b
,如法炮制即可。
-
在操作符下标注运算顺序①②③…按照“右优先原则”排序
右优先原则:在确定好一个操作符的运算顺序并标号后,从最右向左检索找能第一个能参与运算的操作符,作为下一个操作符。
-
在该操作符下,将两个操作数和操作符按照转换规则转成后缀表达式,此时将它们整体看做一个新的操作数。
-
还有操作符未处理,重复 2 直至完成。
前缀表达式转中缀表达式计算(手算)
- 从右往左,找操作符
- 遇到操作符,找该操作符前面的两个操作数,按照
+ a b
→a + b
这样的规则进行计算。将操作符和两个操作数整体看做一个新的操作数。 - 若还有未处理的操作符,重复 2 直至结束。
前缀表达式转中缀表达式计算(机算)
由上面手算过程可以看到,每个操作符需要匹配后面两个操作数,而且我们自始至终都只操作这两个操作数,因此使用“栈”这种数据结构可以很好地完成该算法。
- 从右往左扫描下一个元素,执行 2 或执行 3,直至处理完所有元素。
- 若扫描到一个操作数,则执行“入栈”,将其压入栈顶,返回第 1 步。否则执行第 3 步。
- 若扫描到一个操作符,则执行两次“出栈”,弹出两个操作数(先出栈的是左操作数),执行相应运算,将运算结果压入栈顶,返回第 1 步。
【注】所有元素结束后,栈内应仅剩余一个元素——最终的运算结果。
迷宫求解
每一步都要入栈,走不通需要找上一步,这便用到了栈的思想
进制转换
每一步 % 一个数,最后需要逆序输出,这使用了栈的后进先出思想(Last In First Out)
队列(Queue)
操作受限的线性表——队列,就像检站口排队一样,🦮🐕🦺🐩🐕🐈🐅🐆🐎🦌🦏🦛,依次等候检票,进队列在队尾进,出队列在队头出,即插入从后面出入,删除从前面删除。延伸一下:插入和删除操作只允许分别在固定一端进行。队列特点是:“先进先出”,FIFO(First In First Out)
队列的顺序存储结构(SqQueue——Sequence Queue)
表示方法
#define MaxSize 10;
typedef struct SqQueue {
ElemType data[MaxSize];
int front, rear; // 队头指针,对尾指针,队头指向第一个数据,队尾指向末尾元素的后一位(空位置)
}SqQueue;
- 初始状态:
S.front == S.rear == 0;
队列长度为 0 - 进队操作:判断队列是否满,非满,队尾元素赋值,队尾指针加 1
- 出队操作:判断队列是否为空,非空,取出队头元素,队头指针加1
- 判空操作:
S.front == S.rear
- 判满操作:无法判满
队列可以在两端操作,因此,若不用循环队列,队满后,不断执行出队操作,虽然前面的存储空间空出来了,但由于队尾指针不可再改变,无法入队,浪费存储空间,很难判断队列满与否。
🔹🔹🔹因此使用循环队列是一种好办法。
循环队列依赖 (下标改变) % MaxSize
来使得下标永远处于合法范围;总之,如果你搞不清楚那种情况用了 % ,无脑套就 vans。
-
初始时:
Q.front == Q.rear == 0;
-
队头指针进 1:
Q.front = (Q.front + 1) % MaxSize;
-
队尾指针进 1:
Q.rear = (Q.rear + 1) % MaxSize;
-
出队入队都按照顺时针方向进行。
-
判空:
Q.front == Q.rear;
-
判满:
循环队列即使是满队列,也会有
Q.front == Q.rear
的情况,我认为下面两种方法较好,一般使用第一种。- 舍弃队列最后一个存储空间,当
Q.front = (Q.rear + 1) % MaxSize
时,队列满。 - struct 结构体内新增
int size
来存储当前长度,若Q.size == MaxSize
,则队列满。
- 舍弃队列最后一个存储空间,当
-
队列长度:
(Q.rear - Q.front + MaxSize) % MaxSize
-
队头元素:
Q.data[Q.front]
❗ Q.front 和 Q.rear 都是下标 -
队尾元素:
Q.data[Q.rear - 1]
Q.rear 指向的是队尾元素后面的“空位”
循环队列基本操作
初始化: InitQueue(SqQueue &Q)
void InitQueue(SqQueue &S) {
Q.front = Q.rear = 0;
}
判队空: StackEmpty(SqQueue Q)
bool StackEmpty(SqQueue Q) {
if (Q.front == Q.rear)
return true;
}
入队: EnQueue(SqQueue &Q, Elemtype x)
(使用方式 1 判断队满情况下)
bool EnQueue(SqQueue &Q, Elemtype x) {
if (Q.front == (Q.rear + 1) % MaxSize)
return false; // 判满
Q.data[Q.rear] = x; // 空位赋值
Q.rear = (Q.rear + 1) % MaxSize; // 指针进 1
return ture;
}
出队: DeQueue(SqQueue &Q, ElemType &x)
bool DeQueue(SqQueue &Q, ElemType &x) {
if (Q.front == Q.rear)
return false; // 判空
x = Q.data[Q.front]; // 返回出队列的值
Q.front = (Q.front + 1) % MaxSize; // 队头指针进 1
return true;
}
读队头元素: GetHead(SqQueue &Q, ElemType &x)
bool GetHead(SqQueue &Q, ElemType &x) {
if (Q.front == Q.rear) //队列空,读个屁
return false;
x = Q.data[Q.front];
return true;
}
队尾指针指向队尾元素情况
初始化: InitQueue(SqQueue &Q)
void InitQueue(SqQueue &S) {
Q.front = 0;
Q.rear = -1; // 队列中没有元素,rear 指向 -1
}
判队空: StackEmpty(SqQueue Q)
bool StackEmpty(SqQueue Q) {
if (Q.front == (Q.rear + 1) % MaxSize) // 若队列空,队列无数据,rear 在 front 的“前一位”
return true;
}
入队: EnQueue(SqQueue &Q, Elemtype x)
(使用方式 1 判断队满情况下)
bool EnQueue(SqQueue &Q, Elemtype x) {
if (Q.front ==(Q.rear + 2) % MaxSize)
return false; // 若队列满,rear 在 front 的“前 2 位”,队尾后的一位“空出”。
Q.rear = (Q.rear + 1) % MaxSize; // 此时 rear 指向队尾元素,需要先指针进 1
Q.data[Q.rear] = x; // 再给新的队尾赋值
return ture;
}
出队: DeQueue(SqQueue &Q, ElemType &x)
bool DeQueue(SqQueue &Q, ElemType &x) {
if (Q.front == (Q.rear + 1) % MaxSize)
return false; // 判空
x = Q.data[Q.front]; // 返回出队列的值
Q.front = (Q.front + 1) % MaxSize; // 队头指针进 1
return true;
}
读队头元素: GetHead(SqQueue &Q, ElemType &x)
与队尾指针指向队尾后一位“空位”的情况相同。
队列的链式存储结构(LinkQueue)
表示方法
队列仅需要在队首和队尾进行操作,使用单链表可以轻松实现。
队列中的数据元素类似单链表,用一个结构体存储。但队列虽然可以像先前的链表、栈那样知道它们的首地址即可窥其全貌,但在队尾操作时间复杂度为O(n);为减少队尾操作时间复杂度,表示一个队列需要专门定义一个新的结构体,内部存储指向队头和队尾的指针。
typedef struct LinkNode {
ElemType data;
struct LinkNode *next;
}LinkNode;
typedef struct LinkQueue {
LinkNode *front, *rear;
}LinkQueue; // 注意这里没有 define 成指针,因为结构体本身就包含的是两个指针!!!细品,你细品
此时 LinkQueue 代表队列,LinkNode 代表数据元素。
基本操作(带头结点更加方便)
初始化: InitQueue(LinkQueue &Q)
// 带头结点
bool InitQueue(LinkQueue &Q){
Q.front = Q.rear = (LinkNode)malloc(sizeof(LinkNode)); // 申请内存空间,队头队尾同时指向它
if (Q.front == NUll) // 申请内存失败
return false;
Q.front -> next = NULL; // 头结点初始化
return true;
}
// 不带头结点
void InitQueue(LinkQueue &Q){
Q.rear = Q.front = NULL; // 队列中无元素,队头队尾指针 NULL。
}
【注】若插入操作,传入的数据元素类型为 LinkNode *,则不需要专门给 Q.rear 申请空间,直接令传入的指针插入在队尾,再让 Q.rear 指向即可。若传入数据元素类型为ElemType,则队尾指针指向新申请一片空间,赋值,再插入即可。
判队空: StackEmpty(LinkQueue Q)
// 带头结点
bool StackEmpty(LinkQueue Q) {
if (Q.front == Q.rear) // 或者 Q.front -> next == NULL
return true;
else
return false;
}
// 不带头结点
bool StackEmpty(LinkQueue Q) {
if (Q.front == Q.rear) // 或者 Q.front == NULL 或 Q.rear == NULL
return true;
else
return false;
}
入队: EnQueue(LinkQueue &Q, ElemType *x)
// 带头结点
bool EnQueue(SqQueue &Q, ElemType *x) {
LinkNode *s = (LinkNode*)malloc(sizeof(LinkNode)); // 插入结点申请空间
if (s == NUll)
return false;
s -> data = x; // 预备尾结点赋值
s -> next = NULL; // next 为 NULL
Q.rear -> next = s; // 将其链接到尾部
Q.rear = s; // 改变 rear 指向
return true;
}
// 不带头结点——尾部插入结点操作涉及 2 个结点:尾部结点 和 新插入的结点,而队列空时,没有尾部结点,需特殊处理
bool EnQueue(LinkQueue &Q, ElemType *x) {
LinkNode *s = (LinkNode*)malloc(sizeof(LinkNode)); // 插入结点申请空间
if (s == NUll)
return false;
s -> data = x; // 预备尾结点赋值
s -> next = NULL; // next 为 NULL
if (Q.front == NULL) { // 不带头结点,需对空表进行特殊处理
Q.front = s;
Q.rear = s;
}
Q.rear -> next = s; // 将其链接到尾部
Q.rear = s; // 改变 rear 指向
return true;
}
出队: DeQueue(LinkQueue &Q, ElemType &x)
❗ 队尾元素出队列需要先将 rear 指向 front,否则丢失
// 带头结点
bool DeQueue(SqQueue &Q, ElemType &x) {
if (Q.front -> next == NULL)
return false; // 队列空返回勾八啊
LinkNode *p = Q.front -> next; // p 指向队列第一个数据元素——队头
x = p -> data; // 传回数据 x
Q.front -> next = p -> next; // 队头指针的 next 后移
if (Q.rear = p) // 若此时 p 是尾结点,若直接 free(p),则 rear 就会“迷路”
Q.rear = Q.front; // 尾结点出队后,队列变空队列
free(p); // 释放出队数据元素占用的空间
p = NULL; // p 置为 NULL,防止野指针
}
读队头元素: GetHead(LinkQueue &Q, LinkNode *x)
bool GetHead(LinkQueue &Q, LinkNode *x) {
if (Q.front == Q.rear) // 判空
return false;
x = Q.front -> data;
return true;
}
队列的应用
树的层次遍历
图的广度优先遍历
缓冲区
打印缓冲区,先进先出。
CPU 资源分配、竞争
Conclusion
传入参数
还是老生常谈,栈、队列、栈的数据元素、队列的数据元素传入时都需要进行合理判断——判空 of 判满
返回参数
- 链表中,不带头结点一般初始化都可以成功,返回为 void
栈
-
栈顶指针 top 在顺序存储中是下标,在链式存储中是第一个数据元素。
-
进栈判满,出栈判空
-
进栈后不忘将栈顶指针再指向栈顶,出栈同理
队列
- 顺序存储结构中,队头指针、队尾指针为下标;链式存储中指向队头和队尾数据元素。
- 链式存储中,若无头结点,队头指向第一个数据元素;有头结点,指向头结点,即 Q.front -> next 为第一个数据元素。
- 进队列后,不忘移动队头指针。
- 出队列操作中,出队列元素需要增加其是否是队尾元素的判断——当队尾元素出队列时,若直接 free ,此时 Q.rear 还指向队尾结点,free 后,Q.rear 则“迷路”,因此需要先将 Q.rear 指向 Q.front,此时队列为空,再 free。
- 不带头结点——尾部插入结点操作涉及 2 个结点:尾部结点 和 新插入的结点,而队列空时,没有尾部结点,需特殊处理
关于带不带头结点
- 栈的链式存储结构:不带头结点
- 链栈入栈、出栈操作都在链表头部,只有在表头一种情况。
- 队列的链式存储结构:带头结点
- 入队操作在队尾,通用的入队操作涉及两个结点——原尾部结点和新入队结点,若队列为空且无头结点,则相当于没有“原尾部结点”,需特殊处理。
- 出队操作无需特殊处理。(相当于出栈)