第二十三讲:栈与队列——顺序存储与链式存储

栈和队列,这两个数据结构,说白了就是操作受限的线性表。我刚开始学的时候觉得它们很“傻”——不就是数组和链表加了些规矩吗?后来做嵌入式项目才发现,正是这些“规矩”,让代码变得异常稳定和高效。

今天咱们就把栈和队列彻底讲透。从顺序存储到链式存储,再到实际应用,我会把我在项目中踩过的坑、积累的经验都抖出来。

一、栈:后进先出的艺术

栈的特点就四个字:后进先出(LIFO)。你想想看,往一个桶里放盘子,最后放进去的总是最先被拿出来。这就是栈的直观模型。

1. 顺序栈(数组实现)

顺序栈用数组来存数据,用一个整型变量 top 来标记栈顶位置。我个人习惯把 top 初始化为 -1,表示空栈。为什么?因为这样 top 指向的就是当前栈顶元素的下标,逻辑上很顺。

#define MAX_SIZE 100

typedef struct {
    int data[MAX_SIZE];
    int top;  // 栈顶指针,-1表示空栈
} SeqStack;

// 初始化
void initStack(SeqStack *s) {
    s->top = -1;
}

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

// 出栈
int pop(SeqStack *s, int *value) {
    if (s->top == -1) {
        return -1;  // 栈空
    }
    *value = s->data[(s->top)--];
    return 0;
}
⚠️ 避坑指南:我曾经在一个通信协议解析模块里,因为没检查栈满就继续 push,导致数据覆盖了相邻内存区域。调试了整整两天才发现问题。记住:入栈前一定要判满,出栈前一定要判空。这是铁律。

2. 链式栈(链表实现)

顺序栈有个硬伤:大小固定。你想想看,如果 MAX_SIZE 设大了,内存浪费;设小了,又可能溢出。链式栈就没有这个问题,它用链表来存数据,理论上只受堆内存限制。

typedef struct Node {
    int data;
    struct Node *next;
} StackNode;

typedef struct {
    StackNode *top;  // 栈顶指针
    int size;        // 栈中元素个数
} LinkedStack;

// 入栈(头插法)
int push(LinkedStack *s, int value) {
    StackNode *node = (StackNode *)malloc(sizeof(StackNode));
    if (!node) return -1;
    node->data = value;
    node->next = s->top;
    s->top = node;
    s->size++;
    return 0;
}

// 出栈
int pop(LinkedStack *s, int *value) {
    if (s->top == NULL) return -1;
    StackNode *temp = s->top;
    *value = temp->data;
    s->top = temp->next;
    free(temp);
    s->size--;
    return 0;
}
💡 我的选择:在嵌入式环境里,如果栈的最大深度可以预估,我优先用顺序栈。为什么?因为 malloc 在单片机上是奢侈品,频繁动态分配容易产生内存碎片。但如果是 PC 端或者 Linux 应用层,链式栈更灵活。

二、队列:先进先出的哲学

队列和栈正好相反:先进先出(FIFO)。就像排队买奶茶,先来的人先买到。这个模型在计算机里太常见了——任务调度、消息队列、缓冲区管理,到处都是它的影子。

1. 顺序队列(循环队列)

普通顺序队列有个严重问题:假溢出。队尾指针到了数组末尾,但队头前面还有空位。怎么办?循环队列。把数组首尾相连,形成一个环。

#define QUEUE_SIZE 100

typedef struct {
    int data[QUEUE_SIZE];
    int front;  // 队头下标
    int rear;   // 队尾下标
} CircularQueue;

// 初始化
void initQueue(CircularQueue *q) {
    q->front = 0;
    q->rear = 0;
}

// 判空
int isEmpty(CircularQueue *q) {
    return q->front == q->rear;
}

// 判满(牺牲一个元素空间来区分空和满)
int isFull(CircularQueue *q) {
    return (q->rear + 1) % QUEUE_SIZE == q->front;
}

// 入队
int enqueue(CircularQueue *q, int value) {
    if (isFull(q)) return -1;
    q->data[q->rear] = value;
    q->rear = (q->rear + 1) % QUEUE_SIZE;
    return 0;
}

// 出队
int dequeue(CircularQueue *q, int *value) {
    if (isEmpty(q)) return -1;
    *value = q->data[q->front];
    q->front = (q->front + 1) % QUEUE_SIZE;
    return 0;
}
🔑 关键点:循环队列判满时,我牺牲了一个元素空间。为什么?因为如果不这样,front == rear 既表示空又表示满,无法区分。当然,你也可以加一个 size 变量来记录元素个数,这样就不用浪费空间了。我个人更倾向于加 size,因为更直观。

2. 链式队列

链式队列用链表实现,需要维护两个指针:front 指向队头,rear 指向队尾。入队操作在队尾进行,出队操作在队头进行。

typedef struct QNode {
    int data;
    struct QNode *next;
} QNode;

typedef struct {
    QNode *front;  // 队头指针
    QNode *rear;   // 队尾指针
    int size;
} LinkedQueue;

// 入队(尾插法)
int enqueue(LinkedQueue *q, int value) {
    QNode *node = (QNode *)malloc(sizeof(QNode));
    if (!node) return -1;
    node->data = value;
    node->next = NULL;
    if (q->rear == NULL) {
        q->front = q->rear = node;
    } else {
        q->rear->next = node;
        q->rear = node;
    }
    q->size++;
    return 0;
}

// 出队(头删法)
int dequeue(LinkedQueue *q, int *value) {
    if (q->front == NULL) return -1;
    QNode *temp = q->front;
    *value = temp->data;
    q->front = temp->next;
    if (q->front == NULL) {
        q->rear = NULL;  // 队列变空时,rear也要置空
    }
    free(temp);
    q->size--;
    return 0;
}
⚠️ 注意:链式队列出队时,如果删除了最后一个节点,一定要把 rear 也置为 NULL。我曾经漏掉这一步,导致后续入队时 rear->next 访问了野指针。嗯,这种 bug 很难查,因为不是每次都复现。

三、栈与队列的实际应用场景

光讲理论没意思。我来说说我在实际项目中是怎么用它们的。

1. 栈的应用

  • 函数调用栈:每个函数调用时,局部变量、返回地址都被压入栈。函数返回时弹出。这是 CPU 硬件就支持的东西。
  • 表达式求值:比如计算器里的中缀转后缀,再用栈求值。我在一个手持设备上做过这个,用顺序栈,深度设了 20,够用。
  • 括号匹配:编译器检查你的代码括号是否成对。遇到左括号入栈,遇到右括号出栈匹配。不匹配就报错。
  • 撤销操作:Ctrl+Z 的实现。每次操作压栈,撤销时出栈恢复状态。

2. 队列的应用

  • 消息队列:我在一个物联网网关项目里,用循环队列做串口数据缓冲区。MCU 收到一帧数据就入队,主循环里出队解析。这样收发互不干扰。
  • 任务调度:RTOS 里的就绪队列,所有就绪任务排成一队,调度器按优先级或时间片从队头取任务执行。
  • 广度优先搜索:BFS 算法用队列来保存待访问的节点。比如走迷宫,从起点开始,把相邻节点入队,一层一层往外扩。
  • 打印任务队列:多个程序同时请求打印,系统把它们排成队列,一个一个处理。
💡 经验之谈:在嵌入式开发中,我经常用 环形缓冲区(循环队列) 来做数据流处理。比如音频采样、传感器数据采集。它不需要动态内存分配,性能稳定,非常适合实时系统。但要注意:缓冲区大小要设置为 2 的幂次方,这样取模运算可以用位运算替代,效率高很多。

四、知识体系总览

下面这张图把栈和队列的核心知识点串起来了。你可以把它当作复习提纲。

栈与队列知识体系 栈(Stack) 后进先出(LIFO) 顺序栈 数组实现 top 指针 需判满判空 链式栈 链表实现 头插法入栈 动态内存分配 应用场景 函数调用栈 表达式求值 括号匹配 撤销操作 队列(Queue) 先进先出(FIFO) 循环队列 数组实现 front/rear 假溢出问题 链式队列 链表实现 尾插法入队 头删法出队 应用场景 消息队列 任务调度 广度优先搜索 打印任务队列 对比

五、总结与选择建议

好了,栈和队列的核心内容就这些。最后我给出一些实际选择建议:

场景 推荐方案 理由
嵌入式裸机、RTOS 顺序栈 / 循环队列 无动态内存分配,性能稳定,可预测
Linux 应用层 链式栈 / 链式队列 数据量不确定,链表更灵活
高频数据采集 循环队列(2的幂次方大小) 位运算取模,效率极高
深度递归场景 顺序栈(预估最大深度) 避免递归导致的栈溢出
📌 核心原则:没有最好的数据结构,只有最合适的。在资源受限的嵌入式环境里,确定性比灵活性更重要。我宁愿用固定大小的顺序结构,也不愿意在运行时去 malloc。因为一旦 malloc 失败,整个系统可能就挂了。

栈和队列是数据结构的基石。理解透了它们,后面学树、图、哈希表都会轻松很多。嗯,今天就到这里,动手写代码吧。


公众号:蓝海资料掘金营,微信deep3321