C语言数据结构:栈和队列详解,从概念到代码实现一篇讲清楚

往期回顾

《指针合集》《c语言基础》《数据结构》《机器学习导论》《前端基础》《操作系统》

目录


前言

在学习完顺序表和链表之后,我们会继续接触两种非常经典的线性数据结构:

栈
队列

它们看起来比顺序表、链表简单,因为它们的操作规则非常固定。

栈只允许在一端插入和删除。

队列只允许在一端插入,在另一端删除。

但也正因为规则固定,它们在很多场景里非常好用。

比如:

函数调用栈
浏览器前进后退
表达式求值
括号匹配
撤销和恢复
消息队列
任务调度
广度优先搜索
生产者消费者模型

如果说顺序表和链表是在研究“数据怎么存”,那么栈和队列更强调:

数据应该按照什么规则进出。

这一篇文章就系统梳理栈和队列的概念、结构、图解、C 语言实现以及常见应用。


一、栈和队列为什么重要?

栈和队列都属于线性结构。

所谓线性结构,就是数据之间存在前后关系。

比如:

a1 -> a2 -> a3 -> a4

但是栈和队列并不允许你随便在任意位置插入、删除。

它们都有自己的进出规则。


1. 栈强调“后进先出”

栈的规则是:

后进去的数据,先出来。

英文叫:

LIFO
Last In First Out

就像往桶里放盘子。

最后放上去的盘子,最先被拿走。


2. 队列强调“先进先出”

队列的规则是:

先进去的数据,先出来。

英文叫:

FIFO
First In First Out

就像排队买奶茶。

先排队的人,先被服务。


3. 它们解决的核心问题

栈和队列不是为了“存更多数据”,而是为了控制数据的访问顺序。

可以这样理解:

顺序表 / 链表:偏向底层存储结构
栈 / 队列:偏向操作规则和访问顺序

二、什么是栈?

栈是一种特殊的线性表。

它只允许在固定的一端进行插入和删除操作。

这一端叫:

栈顶

另一端叫:

栈底

栈中的数据遵循:

后进先出,这个也叫LIFO

栈的图解

入栈顺序:1 2 3 4

栈结构:

        栈顶
         ↓
      +-----+
      |  4  |
      +-----+
      |  3  |
      +-----+
      |  2  |
      +-----+
      |  1  |
      +-----+
         ↑
        栈底

如果现在出栈,出来的是:

4

因为 4 是最后入栈的,也是最先出栈的。

比如你连续访问了几个页面:

页面 A -> 页面 B -> 页面 C

点击返回时,一般先回到页面 B。

因为页面 C 是最后访问的,它最先被返回出去。

这就是栈的思想。


三、栈的核心操作

栈的常见操作主要有下面几个。

操作 含义
StackInit 初始化栈
StackPush 入栈 / 压栈
StackPop 出栈
StackTop 获取栈顶元素
StackSize 获取栈中元素个数
StackEmpty 判断栈是否为空
StackDestroy 销毁栈

1. 入栈 Push

入栈就是把数据放到栈顶。

原栈:

栈顶
 ↓
+---+
| 3 |
+---+
| 2 |
+---+
| 1 |
+---+

Push(4) 后:

栈顶
 ↓
+---+
| 4 |
+---+
| 3 |
+---+
| 2 |
+---+
| 1 |
+---+

2. 出栈 Pop

出栈就是删除栈顶元素。

原栈:

栈顶
 ↓
+---+
| 4 |
+---+
| 3 |
+---+
| 2 |
+---+
| 1 |
+---+

Pop() 后:

栈顶
 ↓
+---+
| 3 |
+---+
| 2 |
+---+
| 1 |
+---+

3. 获取栈顶元素 Top

获取栈顶元素只是查看,不删除。

Top() 返回 4

注意:

Top 不改变栈结构
Pop 才会真正删除栈顶元素

四、栈为什么适合用数组实现?

栈可以用两种方式实现:

数组
链表

但是在大多数基础实现中,数组实现栈更常见。

原因是:

栈只在栈顶插入和删除,而数组尾部插入删除的代价很低。


1. 用数组尾部作为栈顶

假设数组如下:

下标: 0   1   2   3   4
数据:10  20  30

我们可以让数组尾部作为栈顶:

栈底                     栈顶
 ↓                        ↓
+----+----+----+----+----+
| 10 | 20 | 30 |    |    |
+----+----+----+----+----+
  0    1    2    3    4

top = 3

这里 top 表示下一个可以插入的位置。

也就是说:

有效数据范围:[0, top - 1]
栈顶元素下标:top - 1

2. 入栈就是尾插

a[top] = x;
top++;

3. 出栈就是 top–

top--;

不需要移动数据。

所以数组实现栈时,入栈和出栈都非常高效。


五、动态栈的结构设计

静态栈可以用定长数组实现:

#define N 10

typedef int STDataType;

typedef struct Stack
{
    STDataType a[N];
    int top;
} Stack;

但是静态栈的问题很明显:

空间给少了,不够用
空间给多了,浪费
容量固定,不能灵活增长

所以实际学习中更推荐实现动态栈。


动态栈结构

typedef int STDataType;

typedef struct Stack
{
    STDataType* a;
    int top;
    int capacity;
} Stack;

三个成员分别表示:

成员 含义
a 指向动态数组
top 栈顶位置,也表示有效元素个数
capacity 当前容量

top 的两种常见设计

栈顶变量 top 常见有两种设计方式:

1. top 指向栈顶元素
2. top 指向栈顶元素的下一个位置

本文采用第二种:

top 指向栈顶元素的下一个位置

好处是:

top 天然表示元素个数
入栈:a[top++] = x
出栈:top--
栈顶元素:a[top - 1]

六、动态栈完整代码实现

下面给出一个比较标准的 C 语言动态栈实现。


1. Stack.h

#pragma once

#include <stdio.h>
#include <stdlib.h>
#include <assert.h>

typedef int STDataType;

typedef struct Stack
{
    STDataType* a;
    int top;
    int capacity;
} Stack;

void StackInit(Stack* ps);
void StackDestroy(Stack* ps);

void StackPush(Stack* ps, STDataType x);
void StackPop(Stack* ps);

STDataType StackTop(Stack* ps);
int StackSize(Stack* ps);
int StackEmpty(Stack* ps);

2. Stack.c

#include "Stack.h"

void StackInit(Stack* ps)
{
    assert(ps);

    ps->a = NULL;
    ps->top = 0;
    ps->capacity = 0;
}

void StackDestroy(Stack* ps)
{
    assert(ps);

    free(ps->a);
    ps->a = NULL;
    ps->top = 0;
    ps->capacity = 0;
}

void StackPush(Stack* ps, STDataType x)
{
    assert(ps);

    if (ps->top == ps->capacity)
    {
        int newCapacity = ps->capacity == 0 ? 4 : ps->capacity * 2;

        STDataType* tmp = (STDataType*)realloc(ps->a, sizeof(STDataType) * newCapacity);
        if (tmp == NULL)
        {
            perror("realloc fail");
            exit(1);
        }

        ps->a = tmp;
        ps->capacity = newCapacity;
    }

    ps->a[ps->top] = x;
    ps->top++;
}

void StackPop(Stack* ps)
{
    assert(ps);
    assert(!StackEmpty(ps));

    ps->top--;
}

STDataType StackTop(Stack* ps)
{
    assert(ps);
    assert(!StackEmpty(ps));

    return ps->a[ps->top - 1];
}

int StackSize(Stack* ps)
{
    assert(ps);

    return ps->top;
}

int StackEmpty(Stack* ps)
{
    assert(ps);

    return ps->top == 0;
}

3. 测试代码

#include "Stack.h"

int main()
{
    Stack st;
    StackInit(&st);

    StackPush(&st, 1);
    StackPush(&st, 2);
    StackPush(&st, 3);
    StackPush(&st, 4);

    while (!StackEmpty(&st))
    {
        printf("%d ", StackTop(&st));
        StackPop(&st);
    }

    StackDestroy(&st);

    return 0;
}

输出结果:

4 3 2 1

这正好体现了栈的特点:

后进先出

4. 栈操作复杂度

操作 时间复杂度 说明
入栈 Push 均摊 O(1) 扩容时需要搬移数据
出栈 Pop O(1) 只需要 top--
获取栈顶 Top O(1) 直接访问 a[top - 1]
判空 Empty O(1) 判断 top == 0
获取大小 Size O(1) 直接返回 top

七、什么是队列?

队列也是一种特殊的线性表。

它只允许在一端插入数据,在另一端删除数据。

插入的一端叫:

队尾

删除的一端叫:

队头

队列遵循:

先进先出 FIFO

队列图解

出队方向                          入队方向
  ←                                  ←
+----+----+----+----+----+
| 10 | 20 | 30 | 40 | 50 |
+----+----+----+----+----+
  ↑                   ↑
 队头                队尾

如果现在出队,出来的是:

10

因为 10 最早进入队列。

如果现在入队 60,则插入到队尾:

+----+----+----+----+----+----+
| 10 | 20 | 30 | 40 | 50 | 60 |
+----+----+----+----+----+----+

八、队列的核心操作

队列常见操作如下:

操作 含义
QueueInit 初始化队列
QueuePush 入队
QueuePop 出队
QueueFront 获取队头元素
QueueBack 获取队尾元素
QueueSize 获取队列元素个数
QueueEmpty 判断队列是否为空
QueueDestroy 销毁队列

1. 入队 Push

入队就是在队尾插入数据。

原队列:

队头              队尾
 ↓                 ↓
10 -> 20 -> 30 -> NULL

QueuePush(40) 后:

队头                    队尾
 ↓                       ↓
10 -> 20 -> 30 -> 40 -> NULL

2. 出队 Pop

出队就是删除队头数据。

原队列:

队头                    队尾
 ↓                       ↓
10 -> 20 -> 30 -> 40 -> NULL

QueuePop() 后:

队头              队尾
 ↓                 ↓
20 -> 30 -> 40 -> NULL

3. 获取队头和队尾

QueueFront:返回队头元素
QueueBack:返回队尾元素

注意:

Front 和 Back 只是查看,不删除。
Pop 才会删除队头元素。

九、队列为什么更适合用链表实现?

队列可以用数组实现,也可以用链表实现。

但是如果直接用普通数组实现队列,会遇到一个问题:

出队发生在数组头部,删除头部元素后,后面的元素如果整体前移,效率会很低。

比如:

原数组:

+----+----+----+----+
| 10 | 20 | 30 | 40 |
+----+----+----+----+

出队删除 10 后,如果保持数据从下标 0 开始连续,就要把 20、30、40 都往前挪。

+----+----+----+----+
| 20 | 30 | 40 |    |
+----+----+----+----+

这个移动成本是:

O(n)

链表实现队列的优势

链表队列只需要维护两个指针:

front:队头
rear:队尾

入队时尾插。

出队时头删。

如果维护了队尾指针,那么:

入队 O(1)
出队 O(1)

所以链式队列非常适合队列这种一头进、一头出的结构。


十、链式队列的结构设计

链式队列需要两类结构:

1. 队列节点
2. 队列管理结构

1. 队列节点

typedef int QDataType;

typedef struct QueueNode
{
    QDataType data;
    struct QueueNode* next;
} QNode;

每个节点包含:

data:当前节点保存的数据
next:下一个节点地址

2. 队列管理结构

typedef struct Queue
{
    QNode* front;
    QNode* rear;
    int size;
} Queue;

这里比基础版本多维护了一个 size

这样可以让 QueueSize 直接 O(1) 返回。

成员 含义
front 指向队头节点
rear 指向队尾节点
size 队列中有效元素个数

链式队列图解

Queue
+--------+-------+------+
| front  | rear  | size |
+--------+-------+------+
    |       |
    v       v
+----+------+    +----+------+    +----+------+
| 10 | next | -> | 20 | next | -> | 30 | NULL |
+----+------+    +----+------+    +----+------+
  队头                                      队尾

十一、链式队列完整代码实现


1. Queue.h

#pragma once

#include <stdio.h>
#include <stdlib.h>
#include <assert.h>

typedef int QDataType;

typedef struct QueueNode
{
    QDataType data;
    struct QueueNode* next;
} QNode;

typedef struct Queue
{
    QNode* front;
    QNode* rear;
    int size;
} Queue;

void QueueInit(Queue* q);
void QueueDestroy(Queue* q);

void QueuePush(Queue* q, QDataType x);
void QueuePop(Queue* q);

QDataType QueueFront(Queue* q);
QDataType QueueBack(Queue* q);

int QueueSize(Queue* q);
int QueueEmpty(Queue* q);

2. Queue.c

#include "Queue.h"

void QueueInit(Queue* q)
{
    assert(q);

    q->front = NULL;
    q->rear = NULL;
    q->size = 0;
}

void QueueDestroy(Queue* q)
{
    assert(q);

    QNode* cur = q->front;

    while (cur)
    {
        QNode* next = cur->next;
        free(cur);
        cur = next;
    }

    q->front = NULL;
    q->rear = NULL;
    q->size = 0;
}

void QueuePush(Queue* q, QDataType x)
{
    assert(q);

    QNode* newnode = (QNode*)malloc(sizeof(QNode));
    if (newnode == NULL)
    {
        perror("malloc fail");
        exit(1);
    }

    newnode->data = x;
    newnode->next = NULL;

    if (q->rear == NULL)
    {
        q->front = newnode;
        q->rear = newnode;
    }
    else
    {
        q->rear->next = newnode;
        q->rear = newnode;
    }

    q->size++;
}

void QueuePop(Queue* q)
{
    assert(q);
    assert(!QueueEmpty(q));

    QNode* del = q->front;
    q->front = q->front->next;

    free(del);
    q->size--;

    if (q->front == NULL)
    {
        q->rear = NULL;
    }
}

QDataType QueueFront(Queue* q)
{
    assert(q);
    assert(!QueueEmpty(q));

    return q->front->data;
}

QDataType QueueBack(Queue* q)
{
    assert(q);
    assert(!QueueEmpty(q));

    return q->rear->data;
}

int QueueSize(Queue* q)
{
    assert(q);

    return q->size;
}

int QueueEmpty(Queue* q)
{
    assert(q);

    return q->front == NULL;
}

3. 测试代码

#include "Queue.h"

int main()
{
    Queue q;
    QueueInit(&q);

    QueuePush(&q, 1);
    QueuePush(&q, 2);
    QueuePush(&q, 3);
    QueuePush(&q, 4);

    while (!QueueEmpty(&q))
    {
        printf("%d ", QueueFront(&q));
        QueuePop(&q);
    }

    QueueDestroy(&q);

    return 0;
}

输出结果:

1 2 3 4

这正好体现了队列的特点:

先进先出

4. 队列操作复杂度

操作 时间复杂度 说明
入队 QueuePush O(1) 尾插,直接通过 rear 找到队尾
出队 QueuePop O(1) 头删,直接移动 front
获取队头 QueueFront O(1) 直接访问 front->data
获取队尾 QueueBack O(1) 直接访问 rear->data
判空 QueueEmpty O(1) 判断 front == NULL
获取大小 QueueSize O(1) 因为额外维护了 size

十二、循环队列:数组如何高效实现队列?

前面说普通数组实现队列时,如果每次出队都移动元素,效率很低。

那数组是不是不能实现队列?

不是。

只要使用:

循环队列

数组也可以高效实现队列。


1. 什么是循环队列?

循环队列可以理解成:

把数组首尾相接,看成一个环。

rear 走到数组末尾后,下一个位置可以回到下标 0。


2. 循环队列的核心公式

假设数组容量为 N

下一个位置可以这样计算:

next = (index + 1) % N;

例如:

N = 5
index = 4

next = (4 + 1) % 5 = 0

这样就实现了从末尾回到开头。


3. 为什么循环队列要区分空和满?

如果只用 front == rear 判断队列状态,会出现一个问题:

空队列时:front == rear
满队列时:front 也可能 == rear

这样就无法区分空和满。


4. 常见解决方案一:浪费一个空间

最经典的做法是:

故意空出一个位置不用。

判断空:

front == rear

判断满:

(rear + 1) % N == front

有效元素个数:

(rear - front + N) % N

图解:

空队列:

front
  ↓
+----+----+----+----+----+
|    |    |    |    |    |
+----+----+----+----+----+
  ↑
 rear

满队列:

      rear
       ↓
+----+----+----+----+----+
|    | A  | B  | C  | D  |
+----+----+----+----+----+
  ↑
front

判断满:
(rear + 1) % N == front

5. 常见解决方案二:额外维护 size

也可以额外维护一个 size

typedef struct CircularQueue
{
    int* data;
    int front;
    int rear;
    int size;
    int capacity;
} CircularQueue;

这样:

size == 0 表示空
size == capacity 表示满

这种写法理解起来更直观,但需要额外维护 size


6. 循环队列常见应用

循环队列常用于:

生产者消费者模型
缓冲区
消息队列
键盘输入缓冲
网络数据缓冲
操作系统任务调度

它适合这种场景:

数据不断进入
数据不断出去
存储空间可以重复利用

十三、栈和队列的对比

栈和队列都是受限制的线性表,但规则完全不同。

对比项 队列
英文 Stack Queue
规则 后进先出 先进先出
缩写 LIFO FIFO
插入位置 栈顶 队尾
删除位置 栈顶 队头
常用实现 动态数组 链表 / 循环数组
典型操作 Push / Pop / Top Push / Pop / Front / Back

十四、栈和队列的典型应用场景


1. 栈的应用

函数调用栈

函数调用天然符合栈结构。

比如:

main()
{
    A();
}

A()
{
    B();
}

B()
{
    C();
}

调用过程:

main -> A -> B -> C

返回过程:

C -> B -> A -> main

最后调用的函数最先返回,这就是后进先出。


撤销操作

比如编辑器中连续输入:

A
B
C

按撤销时,一般先撤销 C。

因为 C 是最后一次操作。

这就是栈。


浏览器返回

访问页面顺序:

页面1 -> 页面2 -> 页面3

点击返回:

页面3 -> 页面2 -> 页面1

也符合栈的思想。


表达式处理

在表达式求值、中缀转后缀、括号结构分析中,栈非常常见。

比如:

(1 + 2) * 3

括号、运算符优先级等都可以借助栈处理。


2. 队列的应用

排队系统

最经典的队列场景就是排队。

先来的人先服务
后到的人后服务

打印任务队列

多个文件提交打印时,通常按照提交顺序处理。

任务1 -> 任务2 -> 任务3

先提交的先打印。


操作系统任务调度

操作系统中经常会维护各种任务队列。

例如:

就绪队列
阻塞队列
消息队列

广度优先搜索 BFS

图和树的广度优先搜索通常使用队列。

思想是:

先访问距离近的节点
再访问距离远的节点

这符合 FIFO。


生产者消费者模型

生产者不断生产数据,消费者不断取出数据。

中间常用队列作为缓冲区。

生产者 -> 队列缓冲区 -> 消费者

如果缓冲区用固定数组实现,就经常会用到循环队列。


十五、栈和队列常见易错点


1. 空栈不能 Pop 或 Top

错误:

StackPop(&st);
StackTop(&st);

如果栈为空,不能出栈,也不能取栈顶。

所以要加断言:

assert(!StackEmpty(ps));

2. 空队列不能 Pop、Front、Back

队列为空时,下面操作都不合法:

QueuePop(&q);
QueueFront(&q);
QueueBack(&q);

必须先判断非空。


3. 栈的 top 含义要统一

如果你设计:

top 指向栈顶元素

代码会是一种写法。

如果你设计:

top 指向栈顶元素的下一个位置

代码又是另一种写法。

本文采用的是:

top 指向栈顶元素的下一个位置

所以:

栈顶元素 = a[top - 1];
元素个数 = top;

写代码时一定要保持统一。


4. 队列出队后要处理 rear

链式队列中,如果删除的是最后一个节点,删除后:

front == NULL

此时必须同步让:

rear = NULL;

否则 rear 会变成野指针。

正确写法:

if (q->front == NULL)
{
    q->rear = NULL;
}

5. malloc 后要检查返回值

QNode* newnode = (QNode*)malloc(sizeof(QNode));
if (newnode == NULL)
{
    perror("malloc fail");
    exit(1);
}

动态内存申请失败时,如果不检查,后面直接解引用会出问题。


6. 销毁时要释放所有节点

链式队列中每个节点都是动态申请的。

销毁时必须一个个释放。

while (cur)
{
    QNode* next = cur->next;
    free(cur);
    cur = next;
}

否则会造成内存泄漏。


7. 循环队列容易写错取模

循环队列中,指针往后走不能简单写:

rear++;

应该写:

rear = (rear + 1) % capacity;

这样才能从数组末尾回到开头。


全文总结

栈和队列都是非常经典的线性数据结构。

它们和顺序表、链表最大的区别是:

顺序表和链表更关注底层如何存储
栈和队列更关注数据如何进出

栈的核心

栈:只允许在栈顶插入和删除
规则:后进先出 LIFO
常用实现:动态数组
核心操作:Push、Pop、Top


队列的核心

队列:队尾插入,队头删除
规则:先进先出 FIFO
常用实现:链表或循环数组
核心操作:Push、Pop、Front、Back


循环队列的核心

循环队列本质是:

把数组当成环来使用

核心公式:

next = (index + 1) % capacity;

它解决了普通数组队列出队后空间不能高效复用的问题。

栈和队列看起来简单,但它们背后的思想非常重要。

栈适合处理:

需要回退、反向恢复、后进先出的场景

队列适合处理:

需要排队、公平调度、先进先出的场景

当你以后学习树、图、操作系统、编译原理、网络缓冲区时,会发现栈和队列几乎无处不在。


Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐