Skip to content

第三章 栈、队列与受限线性结构

📖 ⏱️ 预计阅读时长

1. 栈

栈是一种后进先出(LIFO, Last In First Out)的线性结构。栈只允许在一端进行插入和删除,这一端称为栈顶,另一端称为栈底。

与普通线性表相比,栈的最大特征在于访问规则受限。用户不能随意访问中间元素,而只能围绕栈顶进行操作。正是这种“看似受限”的设计,使栈非常适合描述一层套一层的处理过程。

1.1 栈的基本操作

  1. push:入栈。
  2. pop:出栈。
  3. toppeek:读取栈顶元素但不删除。
  4. isEmpty:判断是否为空栈。

1.2 栈的典型应用

  1. 函数调用与递归。
  2. 表达式求值与括号匹配。
  3. 撤销操作、回溯搜索、深度优先遍历。

函数调用为什么能“调用结束后回到原位置继续执行”,本质上就是因为系统运行时把返回地址、局部变量和现场信息压入了调用栈。

从抽象角度看,栈适合描述“最近进入的状态,最先需要处理”的问题。凡是具有明显嵌套关系的问题,例如括号嵌套、函数嵌套、表达式嵌套、回溯搜索中的路径回退,本质上都符合后进先出的规律,因此自然适合用栈表示。

2. C 语言示例:顺序栈

c
#include <stdio.h>

#define MAX_SIZE 100

typedef struct {
    int data[MAX_SIZE];
    int top;
} Stack;

void init_stack(Stack *s) {
    s->top = -1;
}

int push(Stack *s, int value) {
    if (s->top == MAX_SIZE - 1) {
        return 0;
    }
    s->data[++s->top] = value;
    return 1;
}

int pop(Stack *s, int *value) {
    if (s->top == -1) {
        return 0;
    }
    *value = s->data[s->top--];
    return 1;
}

int main(void) {
    Stack s;
    int x;
    init_stack(&s);
    push(&s, 1);
    push(&s, 2);
    push(&s, 3);
    if (pop(&s, &x)) {
        printf("%d\n", x);   /* 输出:3 */
    }
    return 0;
}

当栈采用数组实现时,入栈和出栈通常都是 O(1)。这也是栈成为基础运行时结构的重要原因。

之所以能够保持常数级时间,是因为栈只在一端进行操作,不需要像普通线性表那样处理中间位置元素。换言之,栈的高效并不是来自“结构更神秘”,而是来自“规则更严格”。正因为限制了使用方式,才换来了操作上的简单和稳定。

3. 队列

队列是一种先进先出(FIFO, First In First Out)的线性结构。插入通常在队尾进行,删除通常在队头进行。

现实生活中的排队现象是理解队列最直观的例子。先到的人先处理,后到的人后处理。操作系统中的任务调度、消息系统中的请求缓冲、打印任务的等待队列,都体现了这种思想。

队列尤其适合描述“按到达顺序处理任务”的问题。当系统希望保证公平性,或者希望按时间先后来消费事件时,队列就会成为天然选择。许多消息中间件、任务队列系统和网络数据缓冲区,都是这一思想在工程中的体现。

3.1 队列的典型应用

  1. 操作系统中的任务调度。
  2. 打印任务排队。
  3. 缓冲区管理。
  4. 图的广度优先遍历。

3.2 顺序队列与“假溢出”

如果用普通数组实现队列,当队头不断后移时,即使前面出现空位,也可能因为队尾到达数组末端而无法继续入队,这种现象称为“假溢出”。

为解决这个问题,常采用循环队列。循环队列把数组逻辑上看作一个首尾相接的环,从而提高存储空间利用率。

循环队列的设计之所以合理,是因为队列只关心“逻辑上的前后次序”,并不要求元素在数组中永远向一个方向线性推进。只要队头和队尾的相对关系保持正确,物理位置回绕并不会破坏队列语义。这个例子非常适合说明:数据结构设计并不是机械照搬现实对象,而是对现实规则进行抽象后的高效实现。

4. C 语言示例:循环队列

c
#include <stdio.h>

#define MAX_SIZE 6

typedef struct {
    int data[MAX_SIZE];
    int front;
    int rear;
} Queue;

void init_queue(Queue *q) {
    q->front = 0;
    q->rear = 0;
}

int is_empty(Queue *q) {
    return q->front == q->rear;
}

int is_full(Queue *q) {
    return (q->rear + 1) % MAX_SIZE == q->front;
}

int enqueue(Queue *q, int value) {
    if (is_full(q)) {
        return 0;
    }
    q->data[q->rear] = value;
    q->rear = (q->rear + 1) % MAX_SIZE;
    return 1;
}

int dequeue(Queue *q, int *value) {
    if (is_empty(q)) {
        return 0;
    }
    *value = q->data[q->front];
    q->front = (q->front + 1) % MAX_SIZE;
    return 1;
}

int main(void) {
    Queue q;
    int x;
    init_queue(&q);
    enqueue(&q, 10);
    enqueue(&q, 20);
    dequeue(&q, &x);
    printf("%d\n", x);   /* 输出:10 */
    return 0;
}

5. 双端队列与优先队列

5.1 双端队列

双端队列(Deque)允许在队头和队尾两端都进行插入和删除。它结合了栈和队列的部分特点,适合实现滑动窗口、单调队列等结构。

5.2 优先队列

优先队列不是按进入顺序出队,而是按照优先级规则取出元素。其典型实现是堆。操作系统调度、最短路径算法、任务管理等都广泛使用优先队列。

优先队列的本质在于“顺序由优先级决定,而不是由时间决定”。这一点与普通队列构成根本区别。

5.3 栈和队列为何高频出现在算法题中

这是因为很多问题本身就带有明显的访问规则。例如:

  1. 遇到最近进入、最先回退的问题,往往对应栈。
  2. 遇到按层推进、先来先处理的问题,往往对应队列。
  3. 遇到需要动态维护最大值或最小值优先处理的问题,往往对应优先队列。

因此,判断何时使用栈和队列,本质上是在识别问题的“访问顺序约束”。

5.4 为什么栈和队列被称为“受限线性结构”

普通线性表允许在任意合法位置访问、插入和删除,而栈和队列只允许在特定端点执行操作,因此它们比线性表“自由度更低”。但这种限制并不是缺陷,反而是一种对问题规律的主动利用。很多时候,正因为只允许少数操作,程序的逻辑才会更清晰,复杂度才会更稳定。

6. 栈与队列的比较

6.1 共性

栈和队列都属于线性结构,也都属于受限访问结构。它们都不允许像普通线性表那样任意位置插入和删除。

6.2 差异

  1. 栈强调后进先出,适合嵌套与回退。
  2. 队列强调先进先出,适合调度与排队。

理解这一区别后,很多算法中的结构选择就会变得自然。例如 DFS 常配合栈,BFS 常配合队列。

7. 本章小结

栈和队列并不是“功能更少”的线性表,而是“规则更强”的线性表。正因为访问规则明确,它们在表达递归、调度、回溯、缓冲和图遍历等问题时具有很高效率。学习本章时,应特别注意三点:第一,理解 LIFO 与 FIFO 的本质差异;第二,掌握顺序实现与循环实现思想;第三,能根据问题特征判断究竟应该使用栈还是队列。