前面我们掌握了数组、链表,以及双向/循环链表的灵活操作。今天,我们要用这些基本工具来构建两种经典的抽象数据结构 ------栈(Stack) 和 队列(Queue)。
它们不是全新的数据类型,而是一种访问规则的约定。比如栈规定"后进先出",队列规定"先进先出"。你可以用数组或链表来承载这些规则,具体怎么实现,就看你的选择。这种"接口与实现分离"的思想,正是数据结构的精髓。
一、栈:后进先出(LIFO)
栈 就像一摞盘子:你每次只能取最上面的那个,放也只能放在最上面。最后放上去的(Last In),最早被取出(First Out),所以叫 LIFO。
栈有三个核心操作:
- push(压栈):把元素放到栈顶。
- pop(出栈):把栈顶元素取出来。
- peek(查看栈顶):只看不取。
我们用两种方式来实现栈:数组 和 链表。
1. 数组实现栈
用一个固定大小的数组存储数据,用一个整数 top 指示栈顶位置。top == -1 表示空栈。
c
#include <stdio.h>
#include <stdbool.h>
#include <stdlib.h>
#define STACK_MAX 100
typedef struct {
int data[STACK_MAX];
int top;
} ArrayStack;
void stack_init(ArrayStack *s) {
s->top = -1;
}
bool stack_is_empty(ArrayStack *s) {
return s->top == -1;
}
bool stack_is_full(ArrayStack *s) {
return s->top == STACK_MAX - 1;
}
bool stack_push(ArrayStack *s, int value) {
if (stack_is_full(s)) return false;
s->data[++(s->top)] = value;
return true;
}
bool stack_pop(ArrayStack *s, int *out) {
if (stack_is_empty(s)) return false;
*out = s->data[(s->top)--];
return true;
}
bool stack_peek(ArrayStack *s, int *out) {
if (stack_is_empty(s)) return false;
*out = s->data[s->top];
return true;
}
特点:访问快速,但容量固定。适合元素数量可预知的场景。
2. 链表实现栈
用动态内存构建无容量限制的栈。栈顶就是链表头------每次 push 就是头插,pop 就是删除头结点。
c
typedef struct SNode {
int data;
struct SNode *next;
} SNode;
typedef struct {
SNode *top; // 指向栈顶结点
} LinkedStack;
void lstack_init(LinkedStack *s) {
s->top = NULL;
}
bool lstack_is_empty(LinkedStack *s) {
return s->top == NULL;
}
bool lstack_push(LinkedStack *s, int value) {
SNode *node = (SNode*)malloc(sizeof(SNode));
if (node == NULL) return false;
node->data = value;
node->next = s->top; // 新结点的 next 指向旧栈顶
s->top = node; // 栈顶更新为新结点
return true;
}
bool lstack_pop(LinkedStack *s, int *out) {
if (lstack_is_empty(s)) return false;
SNode *temp = s->top;
*out = temp->data;
s->top = temp->next; // 栈顶下移
free(temp);
return true;
}
bool lstack_peek(LinkedStack *s, int *out) {
if (lstack_is_empty(s)) return false;
*out = s->top->data;
return true;
}
void lstack_free(LinkedStack *s) {
while (!lstack_is_empty(s)) {
int dummy;
lstack_pop(s, &dummy);
}
}
注意:pop 操作要释放结点内存,并更新 top 指针。链表栈不会满,但可能因内存不足而 push 失败。
二、栈应用一:括号匹配
编译器检查代码中大括号、中括号、圆括号是否匹配,就是栈的经典案例。
思路:遇到左括号就压栈,遇到右括号时检查栈顶。如果栈顶是匹配的左括号则弹出,否则报错。扫描完后栈必须为空。
c
#include <stdbool.h>
#include <string.h>
bool is_balanced(const char *expr) {
LinkedStack s;
lstack_init(&s);
for (int i = 0; expr[i] != '\0'; i++) {
char ch = expr[i];
if (ch == '(' || ch == '[' || ch == '{') {
lstack_push(&s, ch);
} else if (ch == ')' || ch == ']' || ch == '}') {
if (lstack_is_empty(&s)) {
return false; // 右括号多出来
}
int top_val;
lstack_pop(&s, &top_val);
char top = (char)top_val;
if ((ch == ')' && top != '(') ||
(ch == ']' && top != '[') ||
(ch == '}' && top != '{')) {
return false; // 类型不匹配
}
}
}
bool ok = lstack_is_empty(&s);
lstack_free(&s);
return ok;
}
测试:
c
printf("%d\n", is_balanced("({[]})")); // 1 (true)
printf("%d\n", is_balanced("({[})")); // 0 (false)
三、栈应用二:迷宫回溯(简单版)
栈可以用来做深度优先搜索(DFS)。想象一个二维迷宫,用 0 表示通路,1 表示墙。从起点开始,把走过的位置压栈,如果遇到死路就退栈(回溯),直到找到出口。
简化实现:用一个栈保存每一步的坐标。每一步尝试上、右、下、左四个方向,如果可走就前进一步,压栈;如果四个方向都走不通,就出栈(回退)。
代码较长,这里只给出核心思想,完整代码可参考课后练习。栈在这里扮演的角色是记录历史路径------正是"后进先出"让你能沿着原路返回。
四、队列:先进先出(FIFO)
队列 就像排队买票:先来的先服务,后来的排后面。这就是 FIFO(First In First Out)。
核心操作:
- enqueue(入队):将元素放到队尾。
- dequeue(出队):从队头取走元素。
1. 数组实现循环队列
如果用普通数组,出队后前面的空间就浪费了,或者要频繁移动元素。循环队列解决此问题:通过模运算让数组"头尾相连"。
维护 front(队头索引)和 rear(队尾索引)。rear 指向下一次插入的位置。为了区分队空和队满,我们牺牲一个存储单元。
c
#include <stdbool.h>
#define QUEUE_MAX 100
typedef struct {
int data[QUEUE_MAX];
int front;
int rear;
} ArrayQueue;
void aqueue_init(ArrayQueue *q) {
q->front = 0;
q->rear = 0;
}
bool aqueue_is_empty(ArrayQueue *q) {
return q->front == q->rear;
}
bool aqueue_is_full(ArrayQueue *q) {
return (q->rear + 1) % QUEUE_MAX == q->front;
}
bool aqueue_enqueue(ArrayQueue *q, int value) {
if (aqueue_is_full(q)) return false;
q->data[q->rear] = value;
q->rear = (q->rear + 1) % QUEUE_MAX;
return true;
}
bool aqueue_dequeue(ArrayQueue *q, int *out) {
if (aqueue_is_empty(q)) return false;
*out = q->data[q->front];
q->front = (q->front + 1) % QUEUE_MAX;
return true;
}
(rear + 1) % MAX == front 是判满条件,那个空间始终空闲,以避免 front == rear 既表示空也表示满的歧义。
2. 链表实现队列
用链表实现队列需要维护头指针 front 和尾指针 tail。入队是在尾部插入,出队是删除头结点。双向链表当然可以,但单向链表配合尾指针足矣。
c
typedef struct QNode {
int data;
struct QNode *next;
} QNode;
typedef struct {
QNode *front; // 队头
QNode *rear; // 队尾
} LinkedQueue;
void lqueue_init(LinkedQueue *q) {
q->front = NULL;
q->rear = NULL;
}
bool lqueue_is_empty(LinkedQueue *q) {
return q->front == NULL;
}
bool lqueue_enqueue(LinkedQueue *q, int value) {
QNode *node = (QNode*)malloc(sizeof(QNode));
if (node == NULL) return false;
node->data = value;
node->next = NULL;
if (lqueue_is_empty(q)) {
q->front = node;
q->rear = node;
} else {
q->rear->next = node; // 旧尾连接新结点
q->rear = node; // 更新尾
}
return true;
}
bool lqueue_dequeue(LinkedQueue *q, int *out) {
if (lqueue_is_empty(q)) return false;
QNode *temp = q->front;
*out = temp->data;
q->front = temp->next;
if (q->front == NULL) {
q->rear = NULL; // 队列空了,尾也要置空
}
free(temp);
return true;
}
void lqueue_free(LinkedQueue *q) {
while (!lqueue_is_empty(q)) {
int dummy;
lqueue_dequeue(q, &dummy);
}
}
链表队列没有容量限制,内存足够即可。
五、队列应用:广度优先搜索(BFS)简介
栈对应 DFS(深度优先搜索),队列对应 BFS(广度优先搜索)。在迷宫问题中,BFS 能找到最短路径。
思路:从起点入队。每次取出队首,将其可达且未访问的邻居全部入队,并标记已访问。一层层扩散,直到找到出口。
栈和队列就像算法的左右手:DFS 用栈(隐式或显式),BFS 用队列。它们本质上只是规定了数据进出的顺序,却带来了截然不同的遍历策略。
#include <stdio.h>
#include <stdbool.h>
#define N 5
#define MAX_QUEUE 25
// 坐标结构体
typedef struct {
int x, y;
} Point;
// 队列结构体(循环队列)
typedef struct {
Point data[MAX_QUEUE];
int front, rear;
} Queue;
void initQueue(Queue *q) {
q->front = q->rear = 0;
}
bool isEmpty(Queue *q) {
return q->front == q->rear;
}
bool enqueue(Queue *q, Point p) {
if ((q->rear + 1) % MAX_QUEUE == q->front) return false; // 队满
q->data[q->rear] = p;
q->rear = (q->rear + 1) % MAX_QUEUE;
return true;
}
Point dequeue(Queue *q) {
// 调用前确保非空
Point p = q->data[q->front];
q->front = (q->front + 1) % MAX_QUEUE;
return p;
}
// 检查坐标合法性
bool isValid(int maze[N][N], bool visited[N][N], int x, int y) {
return (x >= 0 && x < N && y >= 0 && y < N &&
maze[x][y] == 0 && !visited[x][y]);
}
// BFS 求解迷宫,返回 true 表示有解,并打印路径
bool solveMazeBFS(int maze[N][N]) {
bool visited[N][N] = { false };
Point parent[N][N]; // 记录前驱节点,用于回溯路径
int dir[4][2] = {{-1,0}, {1,0}, {0,-1}, {0,1}}; // 上、下、左、右
Queue q;
initQueue(&q);
Point start = {0, 0};
enqueue(&q, start);
visited[0][0] = true;
parent[0][0] = (Point){-1, -1}; // 起点的父节点设为(-1,-1)
while (!isEmpty(&q)) {
Point cur = dequeue(&q);
// 到达出口
if (cur.x == N - 1 && cur.y == N - 1) {
// 回溯路径,存入数组以便正序输出
Point path[N * N];
int len = 0;
Point p = cur;
while (p.x != -1 && p.y != -1) {
path[len++] = p;
p = parent[p.x][p.y];
}
// 正序输出(从入口到出口)
printf("找到路径(BFS):\n");
for (int i = len - 1; i >= 0; i--) {
printf("(%d,%d)", path[i].x, path[i].y);
if (i > 0) printf(" -> ");
}
printf("\n");
return true;
}
// 探索四个方向
for (int i = 0; i < 4; i++) {
int nx = cur.x + dir[i][0];
int ny = cur.y + dir[i][1];
if (isValid(maze, visited, nx, ny)) {
visited[nx][ny] = true;
parent[nx][ny] = cur; // 记录来路
Point next = {nx, ny};
enqueue(&q, next);
}
}
}
return false; // 队列空,无解
}
int main() {
// 5×5 迷宫,0:通路,1:障碍
int maze[N][N] = {
{0, 1, 0, 0, 0},
{0, 1, 0, 1, 0},
{0, 0, 0, 1, 0},
{0, 1, 1, 1, 0},
{0, 0, 0, 0, 0}
};
printf("迷宫 (0:路, 1:墙):\n");
for (int i = 0; i < N; i++) {
for (int j = 0; j < N; j++) {
printf("%d ", maze[i][j]);
}
printf("\n");
}
if (!solveMazeBFS(maze)) {
printf("无解\n");
}
return 0;
}
输出:
迷宫 (0:路, 1:墙):
0 1 0 0 0
0 1 0 1 0
0 0 0 1 0
0 1 1 1 0
0 0 0 0 0
找到路径(BFS):
(0,0) -> (1,0) -> (2,0) -> (3,0) -> (4,0) -> (4,1) -> (4,2) -> (4,3) -> (4,4)
六、 BFS与DFS路径对比
BFS(队列实现)的特点:
- 最短路径保证:BFS按层扩展,第一次到达终点时找到的路径一定是最短的
- 空间复杂度:最坏情况O(n²),需要存储所有可达节点
- 时间复杂度:O(V+E),每个节点和边访问一次
- 路径特征:找到的路径通常"绕路少",直接走向目标
DFS(栈实现)的特点:
- 不一定最短:DFS深度优先,可能找到很长很绕的路径
- 空间复杂度:O(h),h为递归深度,通常比BFS节省空间
- 时间复杂度:同样O(V+E),但实际访问顺序不同
- 路径特征:可能找到"钻胡同"的路径,先深入再回溯
对比示例:
在同一个迷宫中 (0:路, 1:墙):
0 1 0 0 0
0 1 0 1 0
0 0 0 1 0
0 1 1 1 0
0 0 0 0 0
- BFS找到的路径:
(0,0) -> (1,0) -> (2,0) -> (3,0) -> (4,0) -> (4,1) -> (4,2) -> (4,3) -> (4,4) - DFS可能找到的路径:
(0,0) -> (1,0) -> (2,0) -> (2,1) -> (2,2) -> (1,2) -> (0,2) -> (0,3) -> (0,4) -> (1,4) -> (2,4) -> (3,4) -> (4,4)(相同)或更长的路径
选择建议:
- 需要最短路径 → 用BFS
- 需要探索所有可能/路径数量少 → 用DFS
- 图很大但深度有限 → 用DFS(节省内存)
- 图较宽但深度不大 → 用BFS
这个BFS实现展示了队列在算法中的核心作用:先进先出的特性确保了按"层"探索,这正是找到最短路径的关键。
七、数组 vs 链表:实现选择
| 数组实现 | 链表实现 | |
|---|---|---|
| 大小限制 | 有(可预设或动态扩容) | 无 |
| 内存使用 | 可能浪费预留空间 | 每个元素多一个指针开销 |
| 操作复杂度 | O(1) | O(1) |
| 实现难度 | 简单 | 稍复杂(需管理内存) |
| 缓存友好 | 是(连续内存) | 否 |
如果元素数量可预知且性能要求高,用数组;如果数量不定或频繁增删,用链表。无论选择哪种,暴露给使用者的接口(push/pop/enqueue/dequeue)是一样的------这就是抽象的力量。
八、小结
今天我们学习了两种基础抽象数据结构:
- 栈(Stack) :LIFO,后进先出。用数组(固定大小)或链表(动态)实现。核心操作
push、pop、peek。应用:括号匹配、表达式求值、迷宫回溯、函数调用栈。 - 队列(Queue):FIFO,先进先出。数组循环队列高效利用空间,链表队列灵活无界。应用:BFS、任务调度、缓冲区管理。
栈和队列是最简单的"受限访问"结构,但它们构建了无数算法的基础。当你在高级语言中使用 list 当作栈,或使用 deque 时,底层的原理就来自这里。
现在你已经有了链表、栈、队列三件套。下一篇文章,我们将进入另一个迷人的世界------二叉树与递归。递归函数本质上就是系统栈的应用,而树是自然界中最常见的数据结构之一。理解树,你将开启算法新篇章。
课后小练习
- 用链表栈实现一个简单的四则运算表达式求值器(只含 +、-、*、/,无括号)。提示:使用两个栈(操作数栈、运算符栈),按照优先级规则处理。
- 用循环队列实现一个简单的"生产者-消费者"模拟器:生产者向队列中添加数字(1 到 20),消费者从中取出并打印。两个操作交替进行。
- 实现一个最小栈:在 O(1) 时间内能获取栈中的最小值。提示:使用辅助栈记录每个状态下的最小值。
- (小挑战)基于栈实现一个迷宫求解程序:将迷宫定义为一个 5×5 的 0/1 矩阵,
(0,0)是入口,(4,4)是出口。用栈记录路径,输出从入口到出口的完整坐标序列。如果无解,输出"无解"。
我们下期见!
💡获取本系列示例代码请访问 GitCode 仓库。