数据结构

队列
数据结构

【筑基·039】栈和队列:先入先出还是后入先出

码农修仙传 · 筑基期 · 第39篇
我是玄芯散人,带你从炼气修到大乘。


境界标识

╔══════════════════════════════════════╗
║     筑基期 · 第39篇                   ║
║     栈和队列:先入先出还是后入先出      ║
║     预计阅读:15分钟                   ║
╚══════════════════════════════════════╝

修仙引入

修士的储物匣有两种规矩。一种像码盘子的桶,你往里放盘子,只能从顶上拿,最后放进去的最先出来。另一种像排队领丹药,先到的先领,排在前面的先走。

前者叫栈,后者叫队列。两个结构简单到十行代码就能写完,但函数调用靠栈,消息通信靠队列,编译器求值靠栈,操作系统调度靠队列。大道至简的两个容器,撑起了计算机世界一半的运转逻辑。

上一篇讲了数组和链表怎么选,这一篇讲两种受限的数据结构为什么"受限"反而成了优势。


硬核主体

一、栈:只能从顶部进出的储物匣

栈是一种受限的线性结构,只允许在一端(栈顶)进行插入和删除。后进先出,LIFO。

用数组实现一个最简单的栈:

#include <stdio.h>
#include <stdbool.h>

#define MAX 100

typedef struct {
    int data[MAX];  // 用数组存储
    int top;         // 栈顶指针,-1表示空栈
} Stack;

void init(Stack *s) {
    s->top = -1;  // 空栈
}

bool push(Stack *s, int val) {
    if (s->top >= MAX - 1) return false;  // 栈满
    s->data[++s->top] = val;  // 先移动top,再写入
    return true;
}

bool pop(Stack *s, int *val) {
    if (s->top < 0) return false;  // 栈空
    *val = s->data[s->top--];  // 先读出,再移动top
    return true;
}

bool peek(Stack *s, int *val) {
    if (s->top < 0) return false;
    *val = s->data[s->top];  // 只看不取
    return true;
}

三个操作全是O(1)。栈的"受限"在于你不能从中间取数据,只能从顶部。这种限制看起来是缺点,实际上正是这种限制让栈的行为可预测,适合需要"回溯"的场合。

二、栈的第一个用法:函数调用

炼气期018篇讲过函数调用栈的概念。这里从数据结构的角度再看一遍。

当main调用funcA,funcA又调用funcB时,每个函数的栈帧依次压入调用栈。funcB执行完,它的栈帧最先弹出。funcA执行完,它的栈帧弹出。最后回到main。

调用栈变化过程

出栈阶段

入栈阶段

funcB返回
弹出funcB

main栈帧
a=1

funcA栈帧
x=10

funcB栈帧
y=20

funcA返回
弹出funcA

回到main
栈空

为什么用栈而不是其他结构?因为函数调用天然符合"后调用先返回"的规则。A调B,B没返回之前A不可能先返回。这种嵌套关系就是LIFO。

递归也是同理。fib(5)调用fib(4)调用fib(3),最内层的fib(3)先返回,逐层往外。每一层等待内层返回时,自己的状态(局部变量、返回地址)都安全地存在栈帧里,不会丢失。

栈的大小是有限的。Linux默认线程栈8MB,嵌入式中可能只有几KB。递归层数太深就会栈溢出,因为栈帧堆积超过了栈空间。

// 无限递归:栈溢出
void infinite(int n) {
    int buf[1024];  // 每层栈帧约4KB
    infinite(n + 1);  // 递归无终止条件
}
// 8MB / 4KB ≈ 2048层就会溢出

三、栈的第二个用法:表达式求值

编译器怎么计算 3 + 4 * 2?人的直觉是先算4*2得8,再算3+8得11。但计算机按顺序扫描,遇到 + 时还不知道后面的 * 优先级更高。

解决方案是把中缀表达式转成后缀表达式(逆波兰表示法)。中缀 3 + 4 * 2 变成后缀 3 4 2 * +,然后求值。

转换过程用到两个东西:一个输出队列,一个运算符栈。扫描到数字直接输出,扫描到运算符就与栈顶比较优先级。

// 后缀表达式求值(简化版,只处理个位整数加减乘除)
int eval_postfix(const char *expr) {
    Stack s;
    init(&s);
    
    for (int i = 0; expr[i]; i++) {
        char c = expr[i];
        if (c >= '0' && c <= '9') {
            push(&s, c - '0');  // 数字直接压栈
        } else if (c == '+' || c == '-' || c == '*' || c == '/') {
            int b, a;
            pop(&s, &b);  // 先弹出的是右操作数
            pop(&s, &a);  // 后弹出的是左操作数
            int r;
            switch (c) {
                case '+': r = a + b; break;
                case '-': r = a - b; break;
                case '*': r = a * b; break;
                case '/': r = a / b; break;
            }
            push(&s, r);  // 结果压回栈
        }
    }
    int result;
    pop(&s, &result);
    return result;
}

// 输入 "3 4 2 * +" → 输出 11

栈在这里干什么?暂存"待处理的数据"。扫描到运算符时,它需要的两个操作数正好在栈顶。如果运算符有优先级差异,栈的LIFO特性保证了高优先级运算符先被处理。

四、队列:先排队先走的通道

队列是另一种受限的线性结构,只允许在一端(队尾)插入,另一端(队头)删除。先进先出,FIFO。

同样用数组实现:

#define MAX 100

typedef struct {
    int data[MAX];
    int front;  // 队头
    int rear;   // 队尾
    int size;   // 当前元素个数
} Queue;

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

bool enqueue(Queue *q, int val) {
    if (q->size >= MAX) return false;  // 队满
    q->data[q->rear] = val;
    q->rear = (q->rear + 1) % MAX;  // 环形回绕
    q->size++;
    return true;
}

bool dequeue(Queue *q, int *val) {
    if (q->size <= 0) return false;  // 队空
    *val = q->data[q->front];
    q->front = (q->front + 1) % MAX;  // 环形回绕
    q->size--;
    return true;
}

注意 (q->rear + 1) % MAX 这个写法。数组用完一圈后从头开始用,这就是环形缓冲区。如果没有这个回绕,数组前面的空间就浪费了。

环形缓冲区三个状态

状态1
data: A B C
front=0 rear=3

状态2
data: C D
front=2 rear=4

状态3
data: D E
front=2 rear=0

五、队列的第一个用法:缓冲区

串口接收数据时,CPU可能正在处理其他事情,来不及逐个处理收到的字节。这时候把收到的字节先塞进队列,等CPU忙完了再从队列里取出来处理。

// 串口接收中断里:把字节塞进队列
void uart_rx_handler(uint8_t byte) {
    enqueue(&rx_queue, byte);  // 不阻塞,塞进去就走
}

// 主循环里:取出字节处理
void process_uart() {
    int byte;
    while (dequeue(&rx_queue, &byte)) {
        handle_byte(byte);  // 慢慢处理
    }
}

这里队列解决了一个速度不匹配的问题:中断处理必须快(微秒级),但协议解析可能慢(毫秒级)。队列作为缓冲,让快的一端不停下来等慢的一端。

六、队列的第二个用法:生产者消费者

生产者消费者模式是多线程编程的经典写法。生产者生产数据放入队列,消费者从队列取出数据处理。两者通过队列解耦,各自按自己的速度工作。

import threading
import queue
import time

buffer = queue.Queue(maxsize=10)  # 容量10的缓冲队列

def producer():
    for i in range(20):
        buffer.put(i)  # 队列满了会阻塞
        print(f"生产: {i}")
        time.sleep(0.01)

def consumer():
    for _ in range(20):
        item = buffer.get()  # 队列空了会阻塞
        print(f"消费: {item}")
        time.sleep(0.05)

t1 = threading.Thread(target=producer)
t2 = threading.Thread(target=consumer)
t1.start()
t2.start()
t1.join()
t2.join()

队列在这里起什么效果?“削峰填谷”。生产者速度快时,数据堆在队列里;消费者速度快时,从队列里取完就等待。两边不需要同步协调,队列本身就提供了缓冲能力。

七、栈和队列的选型对比

两者都是受限结构,但适用方向完全不同:

对比项栈 (LIFO)队列 (FIFO)
操作端单端双端
顺序后进先出先进先出
适合回溯
适合缓冲
典型用法函数调用和表达式求值消息队列和任务调度

选择标准很简单:需要"回退到上一步"用栈,需要"公平排队"用队列。

选型决策

需要回退?

用栈

需要排队?

用队列

需要两端操作?

用双端队列deque

八、双端队列:两者的结合

有些语言提供双端队列(deque),两端都能进出。Python的collections.deque,C++的std::deque。它兼具栈和队列的能力,当不确定用栈还是队列时,双端队列是个安全的退路。

from collections import deque

d = deque()
d.append(1)      # 右端入队(当队列用)
d.append(2)
d.appendleft(0)  # 左端入队(当栈用)
print(d)         # deque([0, 1, 2])
d.popleft()      # 左端出队 → 0(FIFO)
d.pop()          # 右端出队 → 2(LIFO)

双端队列在Python中比list更适合做队列,因为list的pop(0)是O(n)要搬移整个数组,deque的popleft是O(1)。


修仙术语对照表

修仙术语技术现实本篇位置
储物匣(竖桶)栈(LIFO结构)栈的定义
领丹药队列队列(FIFO结构)队列的定义
碟盘取顶栈的push/pop操作栈的实现
栈帧封存函数调用时局部变量压栈函数调用用法
递归叠层递归调用导致栈帧堆积栈溢出
逆序运算后缀表达式求值表达式求值
灵力回绕环形缓冲区取模回绕队列实现
速度不匹配的缓冲队列解决生产消费速率差缓冲区用法
削峰填谷队列在多线程中的缓冲效果生产者消费者
宗门回退令撤销/返回操作用栈实现选型对比
公平排队队列的FIFO公平性选型对比
法器双口双端队列deque双端队列

进阶条件

  • 能用数组实现栈和队列,包含环形缓冲区的回绕逻辑
  • 能解释为什么函数调用用栈而不是队列(后调用先返回)
  • 能手动把中缀表达式 3 + 4 * 2 - 1 转成后缀表达式并求值
  • 能说出栈溢出的原因和x86-64 Linux默认线程栈大小(8MB)
  • 能写一个生产者消费者模式,用队列做缓冲
  • 能解释Python中list做队列为什么比deque慢(pop(0)是O(n))
  • 面对"需要回退"和"需要排队"两种需求,能正确选择栈或队列

全部勾掉,栈和队列这两个最基础的结构你就真吃透了。下一篇讲哈希表,一个用空间换时间的法器,查找速度O(1)的秘密在哪。


下期预告 + 互动

下一篇:哈希表:用空间换时间。哈希函数怎么把任意key变成数组下标,冲突怎么解决,为什么哈希表查找能做到O(1)。

互动问题:你写代码时有没有手动用过栈或队列?还是只用了语言内置的list和deque?有没有哪次"手动写栈"解决了用递归搞不定的问题?评论区聊聊。

我是玄芯散人,带你从炼气修到大乘。


本文是「码农修仙传」系列第39篇。系列导航见 xren.ren

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐