栈(Stack)详解:概念、实现与避坑指南

1. 栈的基本概念

栈是一种特殊的线性表 ,它只允许在表的一端进行插入和删除操作。这一端称为栈顶 (Top),另一端称为栈底 (Bottom)。栈遵循 后进先出(LIFO,Last In First Out)的原则。

  • 压栈(Push):在栈顶插入元素

  • 出栈(Pop):从栈顶删除元素

生活中的类比:叠盘子。新盘子总是放在最上面,取盘子也是从最上面先取。后放的盘子先被取走。

示意图:

cpp 复制代码
        压栈方向 → 
        ┌───┐
        │ 5 │ ← 栈顶
        ├───┤
        │ 4 │
        ├───┤
        │ 3 │
        ├───┤
        │ 2 │
        ├───┤
        │ 1 │
        └───┘
        栈底

2. 栈的底层结构选型

栈可以用数组链表实现,两者各有特点:

实现方式 入栈时间复杂度 出栈时间复杂度 优势
数组(顺序栈) O(1) O(1) 缓存友好,实现简单
链表(链式栈) O(1) O(1) 不会满(除非内存耗尽)

为什么通常选择数组?

因为栈的操作都在尾部进行,数组在尾部插入/删除的代价非常小(无需移动其他元素),而且连续内存对 CPU 缓存友好。链表则需要额外的指针存储开销。因此,绝大多数栈的实现都采用动态数组

3. 栈的实现(C语言)

下面我们以动态数组的方式实现一个通用的栈。代码分为三个文件:Stack.h(声明)、Stack.c(定义)、test.c(测试)。

3.1 结构体定义(Stack.h)

cpp 复制代码
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <stdbool.h>

// 栈存储的数据类型,可根据需要修改
typedef int STDataType;

// 栈结构体
typedef struct Stack
{
    STDataType* arr;    // 动态数组指针
    int top;            // 栈顶指针(指向下一个要插入的位置)
    int capacity;       // 当前数组的总容量
} ST;

// 函数声明
void StackInit(ST* ps);
void StackDestroy(ST* ps);
void StackPush(ST* ps, STDataType x);
void StackPop(ST* ps);
STDataType StackTop(ST* ps);
int StackSize(ST* ps);
bool StackEmpty(ST* ps);

关键点说明:

  • arr:指向动态分配的数组内存。

  • top指向下一个待插入的位置 。当栈为空时,top = 0。栈中有效元素的下标范围是 [0, top-1]

  • capacity:数组的当前容量,当 top == capacity 时表示数组已满,需要扩容。

3.2 初始化(StackInit)

cpp 复制代码
void StackInit(ST* ps)
{
    ps->arr = NULL;
    ps->top = ps->capacity = 0;
}
  • arr 置为 NULL,此时未分配任何内存。

  • topcapacity 都初始化为 0

为什么 top 初始为 0 而不是 -1?

这是一种常见的设计选择。top 表示下一个空位置的下标 ,这样栈空时 top == 0,栈中元素个数恰好等于 top,非常直观。如果 top 指向栈顶元素,则栈空时需要 top = -1,元素个数为 top+1,容易引起混淆。

3.3 销毁(StackDestroy)

cpp 复制代码
void StackDestroy(ST* ps)
{
    if (ps->arr)
        free(ps->arr);
    ps->arr = NULL;
    ps->top = ps->capacity = 0;
}
  • 如果 arr 非空,释放内存。

  • 将指针置为 NULL,并将 topcapacity 归零,防止悬空指针被误用。

坑点 :调用 free(ps->arr) 后,ps->arr 仍指向已被释放的内存(野指针)。务必将其置为 NULL,否则后续对 ps->arr 的判空可能导致错误。

3.4 入栈(StackPush)

cpp 复制代码
void StackPush(ST* ps, STDataType x)
{
    assert(ps);                     // 确保 ps 非空
    if (ps->top == ps->capacity)    // 空间不足,需要扩容
    {
        // 新容量:原容量为0时给4,否则翻倍
        int newCapacity = ps->capacity == 0 ? 4 : 2 * ps->capacity;
        STDataType* tmp = (STDataType*)realloc(ps->arr, newCapacity * sizeof(STDataType));
        if (tmp == NULL)
        {
            perror("realloc fail!");
            exit(1);
        }
        ps->arr = tmp;
        ps->capacity = newCapacity;
    }
    ps->arr[ps->top++] = x;         // 放入元素,然后 top 自增
}

流程图:

cpp 复制代码
开始
  │
  ▼
断言 ps 不为空
  │
  ▼
top == capacity ?
  │
  ├─ 否 ──→ 直接放入元素 arr[top++] = x ──→ 结束
  │
  └─ 是 ──→ 计算新容量
              │
              ▼
            realloc 扩容
              │
              ├─ 失败 ──→ 打印错误并退出程序
              │
              └─ 成功 ──→ 更新 arr 和 capacity
                           │
                           ▼
                        放入元素

重点解释:

  1. 扩容策略

    • 初始容量为 0 时,分配 4 个元素的空间。

    • 之后每次扩容为原来的 2 倍。这种倍增策略可以保证平均每次入栈的时间复杂度为 O(1)(摊还分析)。

  2. realloc 的注意事项

    • ps->arr == NULL 时,realloc(NULL, size) 等同于 malloc(size),所以初始化状态可以直接复用。

    • realloc 可能失败,返回 NULL。此时原来的内存不会被释放,所以不能直接将 ps->arr = realloc(...),而应先用一个临时指针保存返回值,检查成功后再赋值。

    • 失败时我们直接 exit(1) 终止程序(简单处理)。实际项目中可返回错误码。

  3. top 的自增

    ps->arr[ps->top++] = x 等价于 ps->arr[ps->top] = x; ps->top += 1;。先存值,后移动指针。

入栈过程示意图:

cpp 复制代码
初始:capacity=4, top=0, arr 指向一块内存
        ┌───┬───┬───┬───┐
        │   │   │   │   │
        └───┴───┴───┴───┘
          ↑
         top

Push 1: arr[0]=1, top=1
        ┌───┬───┬───┬───┐
        │ 1 │   │   │   │
        └───┴───┴───┴───┘
            ↑
           top

Push 2: arr[1]=2, top=2
        ┌───┬───┬───┬───┐
        │ 1 │ 2 │   │   │
        └───┴───┴───┴───┘
                ↑
               top

... 继续 Push 3,4
        ┌───┬───┬───┬───┐
        │ 1 │ 2 │ 3 │ 4 │
        └───┴───┴───┴───┘
                        ↑
                       top (此时 top == capacity)
Push 5: 触发扩容,capacity 变为 8
        ┌───┬───┬───┬───┬───┬───┬───┬───┐
        │ 1 │ 2 │ 3 │ 4 │ 5 │   │   │   │
        └───┴───┴───┴───┴───┴───┴───┴───┘
                            ↑
                           top (5)

3.5 出栈(StackPop)

cpp 复制代码
void StackPop(ST* ps)
{
    assert(!StackEmpty(ps));   // 栈不能为空
    --ps->top;                 // 直接让 top 减一
}
  • 出栈只需将 top 减一,逻辑上元素就被"删除"了。实际上数组中的旧值仍然存在,但后续入栈会覆盖它。

  • 必须确保栈非空 ,否则对空栈执行 Pop 会导致 top 变成 -1,后续操作会越界。

为什么不真正 free 内存?

栈顶指针下移后,原来占用的空间仍属于数组的已分配内存,后续入栈时可以直接复用,避免了频繁的内存分配/释放,提升效率。

3.6 取栈顶元素(StackTop)

cpp 复制代码
STDataType StackTop(ST* ps)
{
    assert(!StackEmpty(ps));
    return ps->arr[ps->top - 1];   // 栈顶元素下标是 top-1
}
  • 只读取,不删除。

  • 同样需要断言栈非空。

3.7 获取栈中元素个数(StackSize)

cpp 复制代码
int StackSize(ST* ps)
{
    return ps->top;
}
  • 因为 top 正好表示元素个数,直接返回即可。

3.8 栈是否为空(StackEmpty)

cpp 复制代码
bool StackEmpty(ST* ps)
{
    assert(ps);
    return ps->top == 0;
}

3.9 完整代码汇总(Stack.c)

cpp 复制代码
#include "Stack.h"

void StackInit(ST* ps)
{
    ps->arr = NULL;
    ps->top = ps->capacity = 0;
}

void StackDestroy(ST* ps)
{
    if (ps->arr)
        free(ps->arr);
    ps->arr = NULL;
    ps->top = ps->capacity = 0;
}

void StackPush(ST* ps, STDataType x)
{
    assert(ps);
    if (ps->top == ps->capacity)
    {
        int newCapacity = ps->capacity == 0 ? 4 : 2 * ps->capacity;
        STDataType* tmp = (STDataType*)realloc(ps->arr, newCapacity * sizeof(STDataType));
        if (tmp == NULL)
        {
            perror("realloc fail!");
            exit(1);
        }
        ps->arr = tmp;
        ps->capacity = newCapacity;
    }
    ps->arr[ps->top++] = x;
}

bool StackEmpty(ST* ps)
{
    assert(ps);
    return ps->top == 0;
}

void StackPop(ST* ps)
{
    assert(!StackEmpty(ps));
    --ps->top;
}

STDataType StackTop(ST* ps)
{
    assert(!StackEmpty(ps));
    return ps->arr[ps->top - 1];
}

int StackSize(ST* ps)
{
    return ps->top;
}

4. 测试代码及使用示例

cpp 复制代码
#include "Stack.h"

void test01()
{
    ST st;
    StackInit(&st);

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

    printf("size: %d\n", StackSize(&st));   // 输出 5

    // 依次弹出并打印所有元素
    while (!StackEmpty(&st))
    {
        int top = StackTop(&st);
        printf("%d ", top);
        StackPop(&st);
    }
    // 输出 5 4 3 2 1 (后进先出)

    StackDestroy(&st);
}

int main()
{
    test01();
    return 0;
}

输出:

cpp 复制代码
size: 5
5 4 3 2 1

5. 常见坑点与避坑指南

坑点 后果 正确做法
忘记初始化栈结构体 arr 为随机值,top 随机,操作导致崩溃 始终调用 StackInit
StackPopStackTop 前未判空 对空栈操作,top 可能变负,导致越界访问 使用 assert(!StackEmpty(ps))
realloc 失败后未处理 内存泄漏,程序继续运行可能出错 用临时指针接收返回值,失败时处理错误
销毁后未置 NULL 再次使用该栈时可能误判 arr 不为空而访问非法内存 ps->arr = NULL;
top 含义混淆(指向栈顶元素还是下一个空位) 入栈、出栈、取栈顶的逻辑全错 统一设计,建议 top 指向下一个空位
扩容时直接 ps->arr = realloc(...) 如果 realloc 失败返回 NULL,原内存丢失 使用临时变量
多线程环境下未加锁 数据竞争 栈一般用于单线程或需同步

6. 栈的应用场景

  • 函数调用栈:保存函数返回地址、局部变量。

  • 表达式求值:中缀转后缀、计算器实现。

  • 括号匹配:LeetCode 经典题目(有效的括号)。

  • 撤销操作(Undo):编辑器中的撤销栈。

  • 浏览器的后退功能:每次访问页面入栈,后退即出栈。

7. 复杂度总结

操作 时间复杂度
入栈(Push) 均摊 O(1)
出栈(Pop) O(1)
取栈顶(Top) O(1)
判空(Empty) O(1)
获取大小(Size) O(1)

8. 扩展:链式栈的简单对比

如果使用链表实现栈,通常采用头插法 (让头结点作为栈顶),这样入栈和出栈都是 O(1)。但需要额外的 next 指针,且每个节点单独分配内存,缓存局部性较差。代码结构大致如下:

cpp 复制代码
typedef struct StackNode {
    STDataType data;
    struct StackNode* next;
} StackNode;

typedef struct LinkedStack {
    StackNode* top;
    int size;
} LinkedStack;

不过,在绝大多数场景下,动态数组实现的顺序栈是更优的选择。


总结:栈是一种简单但极其重要的数据结构。掌握它的数组实现,不仅要理解其逻辑,还要注意内存管理、边界条件等细节。希望本文能帮助你彻底搞懂栈的原理与实现。如果有任何疑问,欢迎留言讨论!

相关推荐
草莓熊Lotso2 小时前
【Linux网络】深入理解 HTTP 协议(四):完善 C++ HTTP 服务器:从协议原理到生产级实现
linux·运维·服务器·c语言·网络·c++·http
少司府2 小时前
C++进阶:map和set的使用
开发语言·数据结构·c++·容器·stl·set·map
cpp_25012 小时前
P11375 [GESP202412 六级] 树上游走
数据结构·c++·算法·题解·洛谷·树形结构·gesp六级
天才程序YUAN2 小时前
Windows 11 C 盘扩容完整教程:恢复分区拦路、页面文件锁盘、WinRE 重建全记录
c语言·开发语言·windows
川冰ICE2 小时前
JavaScript进阶③|Map_Set_WeakMap_WeakSet,新型数据结构
开发语言·javascript·数据结构
0x3F(小茶)2 小时前
STM32 Bootloader与OTA升级
c语言·stm32·单片机·嵌入式硬件·物联网
我是一颗柠檬2 小时前
C语言最全面复习:从入门到精通(2026年)
c语言·开发语言
此生决int2 小时前
算法从入门到精通——字符串
数据结构·c++·算法·蓝桥杯
Luminous.2 小时前
C语言--day26
c语言·开发语言