
🔥铅笔小新z:个人主页
🎬博客专栏:数据结构
💫滴水不绝,可穿石;步履不休,能至渊。

目录
- 一、栈:后进先出的"摞盘子"
- 二、队列:先进先出的"排队打饭"
- 三、栈和队列经典算法题
- 四、全文总复习
一、栈:后进先出的"摞盘子"
1.1 概念与结构
栈 :一种特殊的线性表 ,其只允许在固定的一端 进行插入和删除元素操作。进行数据插入和删除操作的一端称为栈顶 ,另一端称为栈底 。栈中的数据元素遵守后进先出 LIFO(Last In First Out)的原则。

拆开理解这四件事:
- 它是线性表:栈的本质就是上一章学的线性表,数据之间仍有前后顺序关系。
- "特殊"在哪 :普通线性表可以在任意位置插入删除,栈却把操作限制死了------只能在"栈顶"这一端操作,另一端(栈底)完全不许动。
- 两个术语 :
- 栈顶:允许插入/删除的那一端(活跃端)。
- 栈底:固定不动的那一端。
- LIFO 原则:后进去的数据先出来,先进去的最后才能出来。
生活类比:一摞盘子
想想餐厅里洗好的盘子怎么摞:你总把新洗的盘子放最上面 (压栈),要拿盘子时也总从最上面 先拿(出栈)。最底下那个盘子是第一个放上去的,却是最后一个被拿走的------后进先出,就是这个感觉。
其它形象例子:手枪弹夹(后压进去的子弹先被推出)、浏览器的"后退"按钮(最后看的页面最先退回)、撤销键 Ctrl+Z(撤销最近一次操作)。
栈的专有名词
| 术语 | 含义 |
|---|---|
| 压栈 / 进栈 / 入栈 | 栈的插入操作,把数据放到栈顶 |
| 出栈 | 栈的删除操作,把栈顶的数据拿走 |
| 栈顶 top | 栈中可以操作的那一端 |
| 栈底 | 栈中固定不动的另一端 |
入栈过程: 1 → 栈 [1]
2 → 栈 [1,2] (2 在栈顶)
3 → 栈 [1,2,3] (3 在栈顶)
出栈顺序: 先出 3,再出 2,最后出 1 ← 这就是 LIFO
1.2 栈的底层结构选型:为什么数组更优?
栈可以用数组 实现,也可以用链表实现。课件结论:
相对而言,数组的结构实现更优 。因为数组在尾部插入删除数据的代价比较小。
推理过程是这样的:
- 栈的一切操作都发生在栈顶。
- 如果把数组的最后一个位置 当作栈顶,那么压栈 = 尾部插入、出栈 = 尾部删除,都是 O(1),不需要搬移任何元素。
- 反观链表:虽然插入删除也是 O(1),但每个结点要多存一个
next指针、还要频繁malloc/free申请和释放结点,额外开销更大;而且数组连续存放,对 CPU 缓存更友好。 - 结论:数组尾插尾删 O(1) + 缓存友好 + 无指针额外开销,所以用数组实现栈是首选。
1.3 栈的实现(代码逐行讲)
接口设计(Stack.h)
c
// Stack.h ------ 用"动态数组"实现栈
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <stdbool.h>
typedef int STDataType; // 栈中元素的类型别名
// 栈的结构体:本质就是一个"只会从尾巴上加/减"的动态数组
typedef struct Stack
{
STDataType* a; // a:指向堆上动态开辟的数组
int top; // top:栈顶元素的下一个位置,也等于栈中元素个数
int capacity; // capacity:数组的容量
} ST;
// 初始化栈
void STInit(ST* ps);
// 销毁栈
void STDestroy(ST* ps);
// 入栈(压栈)
void STPush(ST* ps, STDataType x);
// 出栈(弹出栈顶)
void STPop(ST* ps);
// 取栈顶元素(只读,不弹出)
STDataType STTop(ST* ps);
// 获取栈中有效元素个数
int STSize(ST* ps);
// 判断栈是否为空
bool STEmpty(ST* ps);
💡 关于 top 的两种约定:
- top 指向栈顶元素的下一个位置 :此时
top == 0表示栈空;top的值恰好等于栈内元素个数,STSize直接返回top即可。 - top 指向栈顶元素本身 :此时初始
top = -1表示栈空;入栈要先++top再放数据。
两种都对,只是下标差 1。选一种并全程保持一致即可。
实现(Stack.c)
c
// Stack.c ------ 栈的实现
#include "Stack.h"
// 初始化:得到空栈(不申请空间,第一次入栈时再扩容)
void STInit(ST* ps)
{
assert(ps);
ps->a = NULL;
ps->top = 0; // top 指向栈顶的下一个位置;top==0 说明栈空
ps->capacity = 0;
}
// 销毁:释放数组空间,防止内存泄漏
void STDestroy(ST* ps)
{
assert(ps);
free(ps->a);
ps->a = NULL;
ps->top = 0;
ps->capacity = 0;
}
// 入栈:在"数组尾部"放一个新数据(尾部 = 栈顶)
void STPush(ST* ps, STDataType x)
{
assert(ps);
// 空间不足就先扩容(与顺序表同理:空栈给 4,之后翻倍)
if (ps->top == ps->capacity)
{
int newCapacity = (ps->capacity == 0) ? 4 : ps->capacity * 2;
STDataType* tmp = (STDataType*)realloc(ps->a,
newCapacity * sizeof(STDataType));
if (tmp == NULL) // realloc 失败要处理
{
perror("realloc");
exit(1);
}
ps->a = tmp;
ps->capacity = newCapacity;
}
ps->a[ps->top] = x; // 新数据放到"栈顶元素的下一个位置"
ps->top++; // top 后移,指向新的栈顶的下一个位置
}
// 出栈:逻辑删除,把 top 往前挪一位即可(旧数据不用真的清掉)
void STPop(ST* ps)
{
assert(ps);
assert(!STEmpty(ps)); // 空栈不能出栈
ps->top--;
}
// 取栈顶元素:返回 top 前一个位置的元素,但不出栈
STDataType STTop(ST* ps)
{
assert(ps);
assert(!STEmpty(ps)); // 空栈没有栈顶可取
return ps->a[ps->top - 1];
}
// 栈中元素个数:因为 top 表示"下一个空位置",所以它正好等于个数
int STSize(ST* ps)
{
assert(ps);
return ps->top;
}
// 栈是否为空
bool STEmpty(ST* ps)
{
assert(ps);
return ps->top == 0; // 栈顶下标回到 0,说明一个元素都没有
}
用起来是什么感觉?
c
// 测试:入栈 1 2 3,再不断取出栈顶
ST st;
STInit(&st); // 建空栈
STPush(&st, 1); // 栈:[1]
STPush(&st, 2); // 栈:[1,2]
STPush(&st, 3); // 栈:[1,2,3]
printf("%d ", STTop(&st)); // 打印栈顶 3
STPop(&st); // 栈:[1,2]
printf("%d ", STTop(&st)); // 打印栈顶 2
STPop(&st); // 栈:[1]
STDestroy(&st); // 最后销毁,释放内存
// 输出:3 2
📌 知识点总结:什么是栈?为什么用数组实现?有哪些关键细节?
栈是一种只允许在固定一端进行插入和删除的特殊线性表,允许操作的一端叫栈顶,另一端叫栈底,数据遵循后进先出(LIFO)原则。插入操作叫入栈/压栈,删除操作叫出栈,都发生在栈顶。
实现栈一般优先选用数组而不是链表:因为栈的所有操作都集中在栈顶,把数组尾部当作栈顶后,入栈和出栈都是 O(1) 的尾部插入/删除,不需要搬移元素;而链表方案每个结点要额外存储指针、频繁申请释放结点,还有缓存不友好的问题。用动态数组实现时要注意 top 的语义约定------本实现让 top 指向栈顶元素的下一个位置,因此 top 同时就是元素个数,栈空判定是 top==0;扩容时和动态顺序表一样按倍数扩容以均摊开销。
栈在工程中应用极广:函数调用栈(递归的实现基础)、括号匹配、表达式求值、浏览器的前进后退、编辑器的撤销重做等,都建立在栈"后进先出"的特性之上。
二、队列:先进先出的"排队打饭"
2.1 概念与结构
队列 :只允许在一端进行插入 、在另一端进行删除 操作的特殊线性表。队列具有先进先出 FIFO(First In First Out)的特性。

术语约定:
-
入队列(队尾入) :进行插入 操作的一端叫队尾(rear/tail)。
-
出队列(队头出) :进行删除 操作的一端叫队头(front/head)。
入队顺序: 1 → 队列 [1]
2 → 队列 [1,2] (2 排在队尾)
3 → 队列 [1,2,3] (3 排在队尾)
出队顺序: 先出 1,再出 2,最后出 3 ← 这就是 FIFO(先进先出)
注意对比 :栈是"先进后出",队列是"先进先出"。一个像摞盘子,一个像排队打饭。栈只有一个口子进出,队列一头进、另一头出。
生活类比
- 食堂打饭的队伍:先来的人先打到饭、先离开(队头出),后来的人排在队伍最后(队尾入)。
- 打印机任务队列:先提交的文档先打印。
- 医院挂号、银行叫号、消息队列、CPU 进程调度、树的层序遍历(BFS),本质都是"排队"。
2.2 队列的底层结构选型:为什么链表更优?
队列也可以用数组或链表实现。课件结论:
使用链表 的结构实现更优。因为如果使用数组的结构,出队列在数组头上出数据,效率会比较低。
推理如下:
- 队列的删除操作发生在队头。
- 若用数组实现:队头是数组下标 0,出队 = 头部删除,要把后面所有元素整体前移 ,O(N),效率低。
(如果硬要用数组,就需要引入"头尾下标 + 循环数组"来做循环队列,实现更复杂------这会在算法题部分看到。) - 若用带头尾指针的链表实现:入队在队尾 插入(利用
ptail尾指针,O(1)),出队在队头 删除(利用phead头指针,O(1)),两头都 O(1),且不需要扩容。 - 结论:链表头删尾插都是 O(1) + 按需申请不浪费,所以用链表实现队列是首选。
2.3 队列的实现(代码逐行讲)
接口设计(Queue.h)
c
// Queue.h ------ 用"带头尾指针的单链表"实现队列
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <stdbool.h>
typedef int QDataType; // 队列元素的类型别名
// 队列的"结点"结构(复用单链表结点)
typedef struct QueueNode
{
QDataType val; // val:本结点保存的数据
struct QueueNode* next; // next:指向下一个结点
} QNode;
// 队列的"头尾指针"结构:只用一个指针不够,头删尾插都需要 O(1)
typedef struct Queue
{
QNode* phead; // phead:指向队头结点(出队从这里删)
QNode* ptail; // ptail:指向队尾结点(入队从这里加)
int size; // size:队列中有效元素个数
} Queue;
// 初始化队列
void QueueInit(Queue* pq);
// 销毁队列(释放所有结点)
void QueueDestroy(Queue* pq);
// 入队列:从队尾插入
void QueuePush(Queue* pq, QDataType x);
// 出队列:从队头删除
void QueuePop(Queue* pq);
// 取队头数据
QDataType QueueFront(Queue* pq);
// 取队尾数据
QDataType QueueBack(Queue* pq);
// 队列判空
bool QueueEmpty(Queue* pq);
// 队列有效元素个数
int QueueSize(Queue* pq);
💡 为什么队列要封装一层"头尾指针"结构,而不像单链表那样只传一个头指针?
因为队列要求入队(尾插)和出队(头删)都达到 O(1)。若只有头指针,尾插必须遍历到尾巴,O(N)。所以在外面再套一层 Queue 结构,同时记录 phead、ptail 和 size,三个操作全是 O(1)。
实现(Queue.c)
c
// Queue.c ------ 队列的实现
#include "Queue.h"
// 内部辅助:申请并初始化一个新结点
static QNode* BuyQueueNode(QDataType x)
{
QNode* node = (QNode*)malloc(sizeof(QNode));
if (node == NULL)
{
perror("malloc");
exit(1);
}
node->val = x;
node->next = NULL;
return node;
}
// 初始化:空队列,头尾指针都为空,元素个数 0
void QueueInit(Queue* pq)
{
assert(pq);
pq->phead = NULL;
pq->ptail = NULL;
pq->size = 0;
}
// 销毁:把链上所有结点都 free,再让指针置空
void QueueDestroy(Queue* pq)
{
assert(pq);
QNode* cur = pq->phead;
while (cur) // 从头到尾逐个释放
{
QNode* next = cur->next;
free(cur);
cur = next;
}
pq->phead = pq->ptail = NULL;
pq->size = 0;
}
// 入队列:在队尾(ptail 指向的结点后面)挂一个新结点
void QueuePush(Queue* pq, QDataType x)
{
assert(pq);
QNode* node = BuyQueueNode(x);
if (pq->ptail == NULL) // 队列为空:新结点既是队头又是队尾
{
pq->phead = pq->ptail = node;
} else
{
pq->ptail->next = node; // 挂到当前队尾后面
pq->ptail = node; // 更新队尾指针,指向新结点
}
pq->size++; // 元素个数 +1
}
// 出队列:删除队头结点(phead 指向的结点)
void QueuePop(Queue* pq)
{
assert(pq);
assert(!QueueEmpty(pq)); // 空队列不能出队
QNode* del = pq->phead; // 记下要删的队头结点
pq->phead = pq->phead->next;// 队头指针后移,指向下一个结点
free(del); // 释放被删的结点
if (pq->phead == NULL) // 删空了?
{
pq->ptail = NULL; // 队尾指针也要置空(否则成为野指针)
}
pq->size--;
}
// 取队头数据
QDataType QueueFront(Queue* pq)
{
assert(pq);
assert(!QueueEmpty(pq));
return pq->phead->val;
}
// 取队尾数据
QDataType QueueBack(Queue* pq)
{
assert(pq);
assert(!QueueEmpty(pq));
return pq->ptail->val;
}
// 判空
bool QueueEmpty(Queue* pq)
{
assert(pq);
return pq->phead == NULL; // 队头为空即队列为空
}
// 有效元素个数:直接返回记录的 size(O(1),不用遍历)
int QueueSize(Queue* pq)
{
assert(pq);
return pq->size;
}
注意 QueuePop 里的一个易错细节 :当队列里只有一个结点时,删完 phead 变 NULL,但 ptail 还指着那个已经被 free 的结点------必须同步把 ptail 置 NULL,否则它就是"野指针"。这类"删到尾"的边界情况是链表类代码 bug 的高发区,务必养成检查的习惯。
📌 知识点总结:什么是队列?为什么用链表实现?和栈如何区分?
队列是只允许在一端插入、在另一端删除的特殊线性表,插入端叫队尾、删除端叫队头,数据遵循先进先出(FIFO)原则。它与栈最大的区别是操作口:栈只有一个口,进出都在栈顶,后进先出;队列是两个口,从队尾进、从队头出,先进先出。
实现队列通常用带头尾指针的单链表:因为队列需要同时高效支持入队(尾插)和出队(头删),用数组实现时队头删除需要整体搬移元素、代价 O(N);而链表只要额外维护 ptail 和 phead 两个指针,尾插和头删都能做到 O(1),并且按需申请结点不会浪费空间。封装队列时要注意边界处理,例如删除最后一个结点后要把 ptail 一起置空,防止野指针。
队列在工程中广泛用于任务调度、消息队列、缓冲区、树的层序遍历等场景,它天然表达了"谁先到谁先被处理"的公平顺序。
三、栈和队列经典算法题
学了结构,来看看栈和队列能玩出什么花样。这四题是超高频经典题。
3.1 有效的括号
题目 :给定一个只包含 ( ) [ ] { } 的字符串,判断括号是否有效。有效需满足:左括号必须用同类型右括号闭合,且按正确顺序闭合。
思路(栈的经典应用):
- 遇到左括号就压栈(先记下来,等匹配)。
- 遇到右括号 :若栈为空,说明"右括号多了",无效;否则弹出栈顶,检查是否与当前右括号同类型------不同即无效。
- 全部处理完后,若栈恰好为空,说明所有左括号都找到了自己的右括号,有效;若栈里还有剩余左括号,无效。
为什么用栈?因为括号的闭合满足"最近打开的括号最先被关闭",正是 LIFO!
c
// LeetCode 20. 有效的括号 ------ C 语言实现
// 用"字符数组当栈"(不用自建栈,OJ 里最简洁)
#include <string.h>
#include <stdbool.h>
#include <stdlib.h>
bool isValid(char* s)
{
int n = (int)strlen(s);
char* st = (char*)malloc((n + 1) * sizeof(char)); // 栈:最多压入 n 个字符
int top = 0; // top:栈顶元素的下一个位置,同时等于栈内元素个数
bool ok = true; // ok:标记是否已出现不匹配
for (int i = 0; i < n && ok; ++i) // 遍历每个字符
{
char c = s[i];
if (c == '(' || c == '[' || c == '{') // 1. 左括号:压栈
{
st[top++] = c;
} else { // 2. 右括号:弹栈匹配
if (top == 0) // 栈空却来了右括号 → 无效
{
ok = false;
break;
}
char topc = st[--top]; // 弹出栈顶左括号
if ((c == ')' && topc != '(') || // 类型不匹配 → 无效
(c == ']' && topc != '[') ||
(c == '}' && topc != '{'))
{
ok = false;
break;
}
}
}
bool ret = ok && (top == 0); // 3. 处理完且栈空 → 完全匹配
free(st); // 释放堆空间
return ret;
}
复杂度:每个字符进/出栈各一次,时间 O(N);栈最长 N,空间 O(N)。
3.2 用队列实现栈
题目:仅用两个队列实现一个栈(支持 push、pop、top、empty)。
核心矛盾 :队列是"先进先出",栈要"后进先出"。怎么用 FIFO 模拟 LIFO?
思路(两个队列 + 入栈时"洗牌") :始终保持一个队列 q1 存数据,且让最新入栈的元素总是排在队头,这样出栈 = 直接出队。
-
push(x):- 把 x 先入"空队列"
q2; - 再把
q1里所有元素依次出队并入到q2的尾巴上(这样 x 就被挤到了最前面); - 交换
q1、q2的角色。
- 代价:O(N)。(也可以反过来:平时正常入队,出栈时把前 size-1 个搬走,再出队最后一个------那样 push O(1)、pop O(N),两种都对,选一种统一即可。)
- 把 x 先入"空队列"
-
pop():直接对q1出队(队头就是最新元素)。 -
top():取q1的队头。 -
empty():两个队列都空即空。
关键心法:栈顶 = 我手动安排到队列最前面的那个元素。
完整代码(LeetCode 225. 用队列实现栈)
下面复用前面实现的 Queue(见 Queue.c):用两个队列模拟一个栈,让 q1 的队头永远等于栈顶。
c
// LeetCode 225. 用队列实现栈 ------ 复用前面实现的 Queue
// 方法:让"最新入栈的元素"永远待在 q1 的队头(把队列倒过来当栈用)
#include "Queue.h" // 复用 Queue 及其接口(QueueInit/Push/Pop/Front/Empty/Size)
typedef struct
{
Queue q1; // q1:存栈数据,始终保证【队头 = 栈顶】
Queue q2; // q2:辅助队列,入栈时当"中转站"
} MyStack;
MyStack* myStackCreate()
{
MyStack* obj = (MyStack*)malloc(sizeof(MyStack));
QueueInit(&obj->q1);
QueueInit(&obj->q2);
return obj;
}
// 入栈:O(N)
// 三步走:新元素进 q2 -> 把 q1 全部搬到 q2 后面 -> 交换 q1、q2 身份
void myStackPush(MyStack* obj, int x)
{
QueuePush(&obj->q2, x); // 1. 新元素先进空队列 q2
while (!QueueEmpty(&obj->q1)) // 2. 把 q1 里的老元素依次搬到 q2 的队尾
{
QueuePush(&obj->q2, QueueFront(&obj->q1));
QueuePop(&obj->q1);
} // 搬完后新元素被"挤"到 q2 队头
// 3. 交换 q1 与 q2 的角色:结构体整体赋值,等于互换两个队列的头尾指针
Queue tmp = obj->q1;
obj->q1 = obj->q2;
obj->q2 = tmp;
}
// 出栈:直接删 q1 的队头(队头就是栈顶)
int myStackPop(MyStack* obj)
{
int top = QueueFront(&obj->q1); // 先取出栈顶元素
QueuePop(&obj->q1); // 再把队头删掉
return top;
}
// 取栈顶:就是 q1 的队头
int myStackTop(MyStack* obj)
{
return QueueFront(&obj->q1);
}
// 判空:两个队列都空,栈才为空
bool myStackEmpty(MyStack* obj)
{
return QueueEmpty(&obj->q1) && QueueEmpty(&obj->q2);
}
// 释放:先销毁内部两个队列,再释放 MyStack 本体
void myStackFree(MyStack* obj)
{
QueueDestroy(&obj->q1);
QueueDestroy(&obj->q2);
free(obj);
}
复杂度:push 需要把 q1 整体搬一次,O(N);pop/top/empty 都是 O(1)。
3.3 用栈实现队列
题目:仅用两个栈实现一个队列(支持 push、pop、peek、empty)。
核心矛盾 :栈是"后进先出",队列要"先进先出"。怎么用 LIFO 模拟 FIFO?
思路(两个栈倒来倒去,经典的"负负得正"):
- 准备两个栈:
pushStack(负责入队)和popStack(负责出队)。 push(x):直接把 x 压入pushStack。pop()/peek():当需要出队时,如果popStack为空,就把pushStack里的元素全部依次弹出并压入popStack。此时元素的顺序被反转 了两次 = 恢复原序,popStack的栈顶恰好就是"最早进来的元素"(队头)。然后对popStack出栈即可。empty():两个栈都空才为空。
为什么可行:入栈时是正序 1,2,3,全倒进 popStack 后变成 3,2,1(栈顶是 1),而 1 正是队头!栈顶恰好是 FIFO 需要的"最老元素"。
复杂度小贴士:虽然单次 pop 可能要把一整栈搬过去(O(N)),但每个元素最多被搬动一次,所以均摊复杂度仍是 O(1)。这种"把昂贵的动作推迟、分摊到每次操作上"的思路(均摊分析)很值得体会。
完整代码(LeetCode 232. 用栈实现队列)
下面复用前面实现的 ST(见 Stack.c):两个栈一个管进、一个管出,倒一次就能把顺序"翻正"。
c
// LeetCode 232. 用栈实现队列 ------ 复用前面实现的 ST
// pushStack 管"入队",popStack 管"出队";
// 出队时把 pushStack 整体倒进 popStack:栈里顺序反转两次 = 恢复原序,栈顶恰好是队头
#include "Stack.h" // 复用 ST 及其接口(STInit/Push/Pop/Top/Empty/Size)
typedef struct
{
ST pushStack; // pushStack:所有新元素先压这里,从底到顶 = 入队顺序
ST popStack; // popStack:出队时从这里弹,它的栈顶 = 队头
} MyQueue;
MyQueue* myQueueCreate()
{
MyQueue* obj = (MyQueue*)malloc(sizeof(MyQueue));
STInit(&obj->pushStack);
STInit(&obj->popStack);
return obj;
}
// 入队:直接压入 pushStack(先来的在下面,后来的在上面)
void myQueuePush(MyQueue* obj, int x)
{
STPush(&obj->pushStack, x);
}
// 核心辅助:把 pushStack 整体倒入 popStack(只在 popStack 为空时执行)
// 例:pushStack 从底到顶 [1,2,3],全部弹出压入 popStack 后从底到顶 [3,2,1]
// 此时 popStack 栈顶是 1,恰好是"最先入队"的元素 = 队头
void PushToPop(MyQueue* obj)
{
if (STEmpty(&obj->popStack)) // 为空才倒,保证顺序不乱
{
while (!STEmpty(&obj->pushStack))
{
STPush(&obj->popStack, STTop(&obj->pushStack)); // 取 pushStack 栈顶
STPop(&obj->pushStack); // 并弹出
}
}
}
// 出队:popStack 的栈顶就是队头
int myQueuePop(MyQueue* obj)
{
PushToPop(obj); // 先确保 popStack 里有数据
int front = STTop(&obj->popStack); // 取队头
STPop(&obj->popStack); // 弹出
return front;
}
// 取队头(不删除)
int myQueuePeek(MyQueue* obj)
{
PushToPop(obj);
return STTop(&obj->popStack);
}
// 判空:两个栈都空,队列才为空
bool myQueueEmpty(MyQueue* obj)
{
return STEmpty(&obj->pushStack) && STEmpty(&obj->popStack);
}
// 释放:先销毁内部两个栈,再释放 MyQueue 本体
void myQueueFree(MyQueue* obj)
{
STDestroy(&obj->pushStack);
STDestroy(&obj->popStack);
free(obj);
}
复杂度:push 是 O(1);pop/peek 只有在 popStack 为空时才搬一次(均摊 O(1)),每个元素最多被搬动一次。
3.4 设计循环队列
循环队列的概念与结构
实际工程中还有一类特殊的队列叫循环队列 ,它首尾相连成环 。环形队列既可以用数组 实现,也可以用循环链表实现。
为什么要"循环"?
普通队列如果用数组实现,队头不断出队会让"队头下标"越来越大,前面空出来的位置用不上,很快数组就"假满"了。让数组首尾相接 (下标走到末尾自动回到 0),就能把这些空间循环利用起来------这就是循环队列。它常用于固定缓冲区(如操作系统的环形缓冲区、生产者消费者模型),因为容量固定、不扩容,速度快。
用数组实现时最关键的问题:如何区分"队空"和"队满"?
用两个下标 front(队头)和 rear(队尾)管理:
front永远指向队头元素。rear指向下一个要插入的位置(即队尾元素的下一个)。
如果让 rear 也存数据,就会出现一个尴尬:队空和队满时都满足 rear == front,分不清!
解决思路(课件重点) :故意让数组多留一个空位不存数据。
- 判空 :
front == rear→ 队列为空。 - 判满 :
(rear + 1) % 容量 == front→ 队列已满。
举个直观例子(数组长度 4,最多存 3 个数据):
- 初始:front=0,rear=0 → 空。
- 存 3 个数据后:rear 走位到下标 3,此时
(rear+1)%4 = 0 == front→ 满!第 4 个空位永远闲置,用来区分空满。
之所以要 % 容量,是因为 rear 或 front 走到数组末尾后要回到下标 0,形成"环"。
完整代码(LeetCode 622. 设计循环队列)
c
// LeetCode 622. 设计循环队列 ------ 数组 + 留一个空位区分队空队满
#include <stdlib.h>
#include <stdbool.h>
typedef struct
{
int* a; // a:底层数组,实际长度为 capacity + 1(多留一个空位)
int front; // front:队头下标(指向队头元素)
int rear; // rear:队尾下标(指向下一个可插入的位置,该位置不存数据)
int capacity; // capacity:队列最多能容纳的元素个数
} MyCircularQueue;
// 创建容量为 k 的循环队列
MyCircularQueue* myCircularQueueCreate(int k)
{
MyCircularQueue* q = (MyCircularQueue*)malloc(sizeof(MyCircularQueue));
q->capacity = k;
q->a = (int*)malloc((k + 1) * sizeof(int)); // 多申请 1 个,用于区分空/满
q->front = 0;
q->rear = 0;
return q;
}
// 判空:front 追上 rear 就是空
bool myCircularQueueIsEmpty(MyCircularQueue* obj)
{
return obj->front == obj->rear;
}
// 判满:rear 的下一个位置是 front,说明只剩一个空位(那个空位留给"区分空满"用)
bool myCircularQueueIsFull(MyCircularQueue* obj)
{
return (obj->rear + 1) % (obj->capacity + 1) == obj->front;
}
// 入队:满了返回 false,否则在 rear 位置放数据,rear 绕环前进
bool myCircularQueueEnQueue(MyCircularQueue* obj, int value)
{
if (myCircularQueueIsFull(obj))
{
return false; // 队满,入队失败
}
obj->a[obj->rear] = value; // 数据放进 rear 指向的位置
obj->rear = (obj->rear + 1) % (obj->capacity + 1); // rear 后移,到末尾就绕回 0
return true;
}
// 出队:空了返回 false,否则 front 绕环前进(逻辑删除)
bool myCircularQueueDeQueue(MyCircularQueue* obj)
{
if (myCircularQueueIsEmpty(obj))
{
return false; // 队空,出队失败
}
obj->front = (obj->front + 1) % (obj->capacity + 1); // front 后移一位
return true;
}
// 取队头:队空返回 -1
int myCircularQueueFront(MyCircularQueue* obj)
{
if (myCircularQueueIsEmpty(obj))
{
return -1;
}
return obj->a[obj->front]; // front 指向的位置就是队头
}
// 取队尾:队空返回 -1
int myCircularQueueRear(MyCircularQueue* obj)
{
if (myCircularQueueIsEmpty(obj))
{
return -1;
}
int rearIndex = (obj->rear - 1 + obj->capacity + 1) % (obj->capacity + 1);
return obj->a[rearIndex]; // 队尾元素在 rear 的前一个位置
}
// 释放内存
void myCircularQueueFree(MyCircularQueue* obj)
{
free(obj->a);
free(obj);
}
复杂度:四个操作全部 O(1),空间 O(k)。
📌 知识点总结:栈和队列能解决什么典型问题?循环队列怎么设计?
栈和队列虽然结构简单,却能组合出很多经典算法。括号匹配是栈的招牌应用------左括号压栈、遇到右括号弹栈配对、结束后栈空才有效,复杂度 O(N);用队列实现栈的核心是让最新元素永远排到队头,可以用双队列在入栈时搬移实现 push O(N)、pop O(1);用栈实现队列则准备 push 栈和 pop 栈,出队时若 pop 栈为空就把 push 栈整体倒入,元素顺序反转两次恢复原序,栈顶恰好是最先入队的元素,均摊复杂度 O(1)。
循环队列是数组实现队列的进阶形态,通过 front、rear 两个下标让数组首尾相连、循环利用空间,适合固定容量的缓冲场景。它的关键设计在于区分队空与队满:由于两个下标相等既可能表示空也可能表示满,所以通常刻意牺牲一个存储位,让队满条件是 (rear+1) % (k+1) == front,队空条件是 front == rear,从而让队空队满可判定,所有操作在 O(1) 内完成。
四、全文总复习
本章心智地图
受限的线性表:把普通线性表的插入/删除规则"限制死"
│
├── 栈 Stack:后进先出 LIFO,一头进出(栈顶)
│ ├─ 术语:栈顶 / 栈底 / 压栈(入栈) / 出栈
│ ├─ 选型:数组更优(尾部当栈顶,尾插尾删 O(1),缓存友好)
│ ├─ 实现:a + top + capacity;top 指向栈顶下一个位置,top==0 为空
│ └─ 应用:函数调用栈、括号匹配、撤销/后退、表达式求值
│
└── 队列 Queue:先进先出 FIFO,队尾进、队头出
├─ 术语:队尾(rear)入队 / 队头(front)出队
├─ 选型:链表更优(phead + ptail 使头删尾插都 O(1))
├─ 实现:QueueNode + Queue{ phead, ptail, size }
└─ 应用:任务调度、消息队列、层序遍历 BFS、缓冲区
进阶:循环队列(数组首尾相连)
├─ 作用:循环利用数组空间,适合固定容量缓冲区
└─ 关键:多留一个空位 → 队空 front==rear,队满 (rear+1)%len==front
栈 vs 队列对比速查表
| 对比项 | 栈 | 队列 |
|---|---|---|
| 原则 | LIFO 后进先出 | FIFO 先进先出 |
| 操作端 | 只有栈顶一个口(进/出都在这) | 队尾进、队头出,两个口 |
| 生活类比 | 摞盘子、弹夹 | 排队打饭 |
| 底层选型 | 数组更优 | 链表更优 |
| 入/出复杂度 | 入栈 O(1)、出栈 O(1) | 入队 O(1)、出队 O(1) |
| 典型应用 | 函数调用栈、括号匹配、撤销 | 打印队列、消息队列、BFS |
一句话总结二者关系
栈和队列都是"被限制的线性表":普通线性表哪里都能增删,栈和队列却各自约法三章。正因为限制明确、行为可预测,它们反而在很多场景下比"自由"的线性表更好用------约束,有时候恰恰是优势。
