栈与队列:顺序存储与链式存储,以及实际应用场景

大家好,我是老李。今天我们来聊聊数据结构里两个最基础、也最实用的家伙——栈和队列。说实话,我做了十几年嵌入式开发,这两个结构几乎天天见。你想想看,从函数调用到任务调度,从消息处理到数据缓冲,到处都有它们的身影。

栈和队列,说白了就是两种操作受限的线性表。栈是后进先出(LIFO),队列是先进先出(FIFO)。概念很简单,但用好了能解决很多实际问题。我刚开始学的时候也觉得这玩意儿有啥难的?后来在项目中踩过坑,才明白细节决定成败。

核心要点:栈和队列的本质是线性表,但操作受限。栈只允许在一端插入和删除,队列只允许在一端插入、另一端删除。

一、栈的顺序存储与链式存储

栈的实现方式有两种:顺序栈和链式栈。顺序栈用数组实现,链式栈用链表实现。各有优劣,看场景选。

1. 顺序栈

顺序栈,就是用数组来模拟栈。我们需要一个数组、一个栈顶指针(top)。top指向当前栈顶元素的位置。空栈时top为-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;
}

嗯,这里要注意。顺序栈的优点是访问速度快,因为数组是连续内存。但缺点也很明显——栈的大小是固定的。我在项目中遇到过这种情况:一个通信协议栈的缓冲区,一开始预估100个元素够用了,结果后来协议升级,数据量翻倍,栈直接溢出了。那叫一个惨啊。

避坑指南:我曾经在RTOS的任务栈配置上吃过亏。任务栈用顺序存储,但栈大小设小了,导致任务运行时栈溢出,系统随机崩溃。排查了整整两天才发现是栈溢出的问题。所以,顺序栈一定要留足余量,或者实现动态扩容机制。

2. 链式栈

链式栈用链表实现,每个节点包含数据和指向下一个节点的指针。栈顶就是链表的头节点。

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,在嵌入式系统里这可是大忌——频繁的内存分配会导致碎片化。我个人习惯在资源受限的MCU上尽量用顺序栈,除非数据量确实不可预估。

二、队列的顺序存储与链式存储

队列和栈类似,也有顺序和链式两种实现。但队列有个特殊问题——顺序队列的“假溢出”现象。

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

顺序队列用数组实现,需要两个指针:front(队头)和rear(队尾)。入队时rear后移,出队时front后移。但普通顺序队列有个问题:front前面的空间用不上了,造成假溢出。

解决办法就是循环队列。把数组当成一个环,rear和front在数组末尾时,下一个位置回到数组开头。

#define MAX_SIZE 100

typedef struct {
    int data[MAX_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) % MAX_SIZE == q->front;
}

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

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

循环队列里,判断队满的条件是 (rear+1)%MAX_SIZE == front。这意味着数组里始终有一个空位没用。为什么?因为如果不留这个空位,队满和队空的条件就一样了(front==rear)。

小技巧:我习惯在循环队列里额外加一个count字段记录元素个数。这样判断队空队满就简单了,也不用浪费一个数组元素。代价是多占4字节内存,但在嵌入式里这不算啥。

2. 链式队列

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

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

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

// 入队
void enqueue(LinkedQueue *q, int value) {
    QueueNode *node = (QueueNode *)malloc(sizeof(QueueNode));
    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++;
}

// 出队
int dequeue(LinkedQueue *q, int *value) {
    if (q->front == NULL) {
        return -1;  // 队列空
    }
    QueueNode *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。我见过有人只更新front,结果下次入队时rear还指着已经被释放的节点,直接野指针崩溃。

三、栈与队列的对比

特性 顺序栈 链式栈 循环队列 链式队列
存储结构 数组 链表 数组 链表
空间大小 固定 动态 固定 动态
访问速度 较慢(malloc开销) 较慢(malloc开销)
内存碎片
适用场景 大小可预估、性能要求高 大小不可预估 大小可预估、性能要求高 大小不可预估

四、实际应用场景

说了这么多理论,咱们来看看实际中怎么用。我挑几个典型的场景说说。

1. 函数调用栈

这个大家应该都熟悉。每次函数调用,系统会把返回地址、局部变量等信息压入栈中。函数返回时再弹出。这就是栈的经典应用。如果你在调试时看到Call Stack,那就是栈的内容。

2. 表达式求值

编译器里对算术表达式求值,比如"3+4*2",需要把中缀表达式转成后缀表达式,然后用栈来计算。我记得有一次写一个简易计算器,就是用两个栈实现的——一个操作数栈,一个运算符栈。

3. 消息队列

在嵌入式系统里,任务之间通信经常用消息队列。发送方把消息放入队列尾部,接收方从队列头部取出。这就是典型的先进先出。我在一个物联网项目中,用循环队列实现了传感器数据缓冲,MCU从队列里取数据发送到云端,效果很好。

4. 任务调度

RTOS里的就绪队列,就是按优先级排列的队列。高优先级任务先执行。有些RTOS还支持时间片轮转,每个任务在队列里轮流执行一段时间。

5. 广度优先搜索(BFS)

图算法里,BFS用队列实现。从起点开始,把相邻节点入队,然后依次出队处理。这个在路径规划、网络爬虫里很常见。

五、知识体系总览

下面这张图把栈和队列的知识结构梳理了一下,方便大家理解。

栈与队列知识体系 栈(Stack) 后进先出(LIFO) 顺序栈 数组实现,大小固定 链式栈 链表实现,动态扩展 队列(Queue) 先进先出(FIFO) 循环队列 数组实现,解决假溢出 链式队列 链表实现,动态扩展 实际应用场景 函数调用栈 表达式求值 消息队列 任务调度 广度优先搜索 缓冲区管理

六、总结与建议

栈和队列,说难不难,说简单也不简单。关键是要理解它们的本质——操作受限的线性表。选哪种实现方式,取决于你的应用场景。

我个人建议:

  • 在资源受限的嵌入式系统里,优先用顺序存储。性能好,没碎片。
  • 如果数据量不可预估,或者需要频繁扩容,用链式存储。
  • 循环队列一定要搞清楚队空队满的判断条件,这是最容易出错的地方。
  • 链式队列要注意内存管理,malloc/free要配对,避免内存泄漏。

好了,栈和队列就讲到这里。这些东西看着基础,但用好了能解决很多实际问题。下次遇到需要缓冲数据、管理任务、处理嵌套调用的情况,想想能不能用栈或队列来搞定。

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