栈与队列基础:从结构到实战

栈和队列,这两个概念在数据结构里属于入门级,但说实话,我做了十几年嵌入式开发,几乎每天都在跟它们打交道。任务调度、函数调用、中断处理……底层系统里到处都是它们的影子。今天我们就来彻底搞懂它们。

栈的定义与操作

栈,说白了就是一种“后进先出”的结构。你想想看,就像一摞盘子,你总是先拿最上面那个。放盘子(入栈)和取盘子(出栈)都只能在顶部操作。

我个人习惯把栈想象成一个只有一个口的盒子。你往里面放东西,只能从那个口放;取东西,也只能从那个口取。最后放进去的,反而最先被拿出来。

栈的核心操作其实就几个:

  • push:入栈,把元素放到栈顶
  • pop:出栈,把栈顶元素移除
  • top/peek:看一眼栈顶元素,但不移除
  • isEmpty:判断栈是不是空的
  • size:获取栈中元素个数

关键点:栈的操作时间复杂度都是 O(1),这是它最大的优势。不管栈里有多少数据,入栈出栈都是一步到位。

栈的顺序存储

顺序存储,就是用数组来实现栈。这是最直观的方式。我刚开始学数据结构时,第一个手写的栈就是数组版的。

实现思路很简单:用一个数组,再加一个变量记录栈顶位置。入栈时,栈顶指针往上走;出栈时,栈顶指针往下走。

#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;
}

我曾经在一个项目里,因为没检查栈是否已满,导致数据覆盖了数组后面的内存。那一次调试花了我整整一个下午。记住:顺序栈一定要处理栈满的情况,尤其是在嵌入式环境里,内存有限,栈溢出是真实存在的风险。

顺序栈的优点很明显:实现简单,访问速度快。缺点呢?大小固定,用多少空间得提前想好。我一般用在确定数据量不会太大的场景,比如表达式求值、括号匹配这些。

栈的链式存储

链式存储,就是用链表来实现栈。每个节点包含数据和指向下一个节点的指针。入栈出栈都在链表头部操作。

为什么要用链式栈?说白了,就是不怕栈满。只要内存够,想放多少放多少。我在做动态内存管理的模块时,就特别喜欢用链式栈。

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

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

// 入栈
void push(LinkedStack *s, int value) {
    StackNode *node = (StackNode *)malloc(sizeof(StackNode));
    node->data = value;
    node->next = s->top;
    s->top = node;
    s->size++;
}

// 出栈
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/free,这玩意儿在实时系统里可是个大坑。我见过一个同事,因为频繁动态分配内存,导致系统响应时间变得不可预测。

链式栈的缺点也很明显:每个节点额外占用一个指针的空间,而且内存分配有开销。但好处是灵活,不用担心栈满的问题。

队列的定义与操作

队列和栈正好相反,它是“先进先出”的结构。就像排队买票,先来的人先买到票。队列有两个口:一个入口(队尾),一个出口(队头)。

队列的核心操作:

  • enqueue:入队,从队尾添加元素
  • dequeue:出队,从队头移除元素
  • front:查看队头元素
  • isEmpty:判断队列是否为空

队列的实现也有顺序和链式两种。顺序队列有个经典问题叫“假溢出”——队尾指针到数组末尾了,但队头前面还有空位。解决办法是用循环队列。

#define MAX_QUEUE 100

typedef struct {
    int data[MAX_QUEUE];
    int front;  // 队头指针
    int rear;   // 队尾指针
} SeqQueue;

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

// 入队
int enqueue(SeqQueue *q, int value) {
    if ((q->rear + 1) % MAX_QUEUE == q->front) {
        return -1;  // 队列满了
    }
    q->data[q->rear] = value;
    q->rear = (q->rear + 1) % MAX_QUEUE;
    return 0;
}

// 出队
int dequeue(SeqQueue *q, int *value) {
    if (q->front == q->rear) {
        return -1;  // 空队列
    }
    *value = q->data[q->front];
    q->front = (q->front + 1) % MAX_QUEUE;
    return 0;
}

注意:循环队列判断满的条件是 (rear + 1) % MAX == front。这意味着数组里始终有一个位置是空的。为什么?因为如果不留这个空位,满和空的条件就一样了(front == rear),你分不清到底是空还是满。

链式队列就更直接了,用链表实现,头指针指向队头,尾指针指向队尾。入队操作在尾部,出队操作在头部。

我在项目中用过队列的地方太多了:串口数据接收、任务调度、按键事件处理……几乎只要有“先来后到”的需求,队列就是首选。

知识体系总览

下面这张图,把栈和队列的核心知识点串起来了。你可以把它当作一个快速索引,复习时扫一眼就能想起来。

栈与队列知识体系 栈(Stack) 后进先出(LIFO) 核心操作 • push:入栈 • pop:出栈 • top:查看栈顶 • isEmpty:判空 存储方式 • 顺序栈:数组实现 • 链式栈:链表实现 应用场景 • 函数调用、表达式求值 • 括号匹配、撤销操作 队列(Queue) 先进先出(FIFO) 核心操作 • enqueue:入队 • dequeue:出队 • front:查看队头 • isEmpty:判空 存储方式 • 循环队列:数组实现 • 链式队列:链表实现 应用场景 • 任务调度、消息队列 • 缓冲区、BFS遍历

栈和队列,一个后进先出,一个先进先出。看似简单,但它们是很多复杂算法和系统设计的基础。我个人觉得,理解这两个结构的关键不在于记住代码,而在于理解它们的“规则”——什么时候该用栈,什么时候该用队列,这比背代码重要得多。

一个小建议:刚开始学的时候,别急着写复杂的代码。先拿纸笔画一画入栈出栈的过程,画清楚了,代码自然就写出来了。我当年就是这么过来的。

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