栈结构完全指南:顺序栈实现精讲

引言

栈(Stack)是计算机科学中最基础、最重要的数据结构之一。它的"后进先出"(LIFO,Last In First Out)特性使其在函数调用、表达式求值、括号匹配、浏览器前进后退等场景中扮演着不可替代的角色。

本文将从零开始,详细讲解顺序栈的完整实现。顺序栈使用连续内存(数组)存储元素,是最直观的栈实现方式。

复制代码
栈的 LIFO 特性示意

    入栈 (Push)                    出栈 (Pop)
    ┌─────┐                        ┌─────┐
    │     │ ← top                  │     │
    │  3  │    进                  │  3  │    出
    │  2  │   ←──                  │  2  │   ──→
    │  1  │                        │  1  │
    └─────┘ ← base                 └─────┘ ← base
    
    后进 (3) 的在上方              后进 (3) 的先出来
    先进 (1) 的在下方              先进 (1) 的后出来

第一部分:顺序栈的设计原理

一、核心数据区:三指针设计

cpp 复制代码
typedef struct SeqStack {
    ElemType* base;      // 指向栈底(分配的起始地址)
    ElemType* top;       // 指向栈顶元素的上一个空闲位置
    size_t stacksize;    // 当前总容量(元素个数,不是字节数)
} SeqStack;

设计要点解析

  1. base → 指向分配的起始地址:用于内存管理和定位栈底

  2. top → 指向栈顶元素的上方 :这是经典设计,空栈时 top == base

  3. stacksize → 当前容量:用于判断是否已满,是否需要扩容

二、关键指针关系

关键规律

状态 base top 元素个数 (top - base)
空栈 !=NULL ==base 0
有元素 !=NULL >base >0
满栈 !=NULL ==base + stacksize ==stacksize
已销毁 ==NULL ==NULL 0 (无意义)

三、空栈与满栈判断

第二部分:基础常量设计

设计良好的常量是数据结构的骨架。在顺序栈中使用两个关键常量:

cpp 复制代码
#define STACKINITSIZE 10     // 初始容量
#define STACKINCREMENT 2     // 扩容倍数

一、初始容量 STACKINITSIZE

初始容量为 10 意味着第一次分配 10 个 ElemType 元素的空间(不是 10 字节)。

容量规划建议

初始容量 适用场景
8-16 通用小栈(如函数调用、表达式求值)
64-256 中等数据量(如临时缓存)
1024+ 大数据处理(需评估内存碎片影响)

二、扩容倍数 STACKINCREMENT

用 2 倍扩容,每次重新分配时旧空间翻倍。

扩容策略对比

第三部分:初始化与销毁

一、初始化实现

cpp 复制代码
void InitStack(SeqStack* ps) {
    assert(ps != NULL);
    
    ElemType* p = (ElemType*)malloc(sizeof(ElemType) * STACKINITSIZE);
    if (p == NULL) return;  // 分配失败,静默返回
    
    ps->base = p;
    ps->top = p;            // 空栈,top == base
    ps->stacksize = STACKINITSIZE;
}

初始化后状态

二、销毁实现

cpp 复制代码
void DestroyStack(SeqStack* ps) {
    assert(ps != NULL);
    
    free(ps->base);
    ps->base = NULL;
    ps->top = NULL;
    ps->stacksize = 0;
}

初始化和销毁的对称操作应当严格配对

  • 初始化 → malloc 分配空间

  • 销毁 → free 释放空间,所有指针置 NULL

  • 不正确的是:初始化后未使用就直接销毁,或同一次栈被多次销毁


第四部分:扩容机制

一、封装的扩容函数

cpp 复制代码
bool expansion(SeqStack* ps) {
    assert(ps != NULL);
    
    int newsize = ps->stacksize * STACKINCREMENT;
    ElemType* newbase = (ElemType*)malloc(sizeof(ElemType) * newsize);
    if (newbase == NULL) return false;
    
    // 使用 memmove 复制内容,因为原空间和新空间是独立的
    memmove(newbase, ps->base, sizeof(ElemType) * ps->stacksize);
    
    // 用新的基地址重新设置 top
    ps->top = newbase + (ps->base - ps->top);  // 注意:这里是 base - top(负值)
    
    free(ps->base);  // 释放原空间
    ps->base = newbase;
    ps->stacksize = newsize;
    
    return true;
}

二、扩容过程图解

三、扩容检查的时机

cpp 复制代码
bool Push(SeqStack* ps, ElemType val) {
    assert(ps != NULL);
    
    // 先检查是否需要扩容
    if (Isfull(ps)) {
        if (expansion(ps) == false) {
            printf("扩容失败\n");
            return false;
        }
    }
    
    // 然后入栈
    *ps->top++ = val;
    return true;
}

第五部分:核心操作实现

一、Push 入栈操作

cpp 复制代码
bool Push(SeqStack* ps, ElemType val) {
    assert(ps != NULL);
    
    if (Isfull(ps)) { 
        if (expansion(ps) == false) {
            printf("扩容失败\n");
            return false;
        }
    }
    
    *ps->top++ = val;  // ① 存入值 ② top指向下一个空闲位置
    return true;
}

操作对应的指针移动:

二、Pop 出栈并获取栈顶元素

cpp 复制代码
bool Pop(SeqStack* ps, ElemType* pval) {
    assert(ps != NULL);
    assert(pval != NULL);  // 不能为 NULL
    
    if (Isempty(ps)) return false;
    
    *pval = *--ps->top;  // ① top先减1 ② 读取元素
    return true;
}

原因分析top 指向的是空闲位置的上方,出栈时需要先将 top 减1才能读取

三、GetTop 仅获取栈顶元素

cpp 复制代码
bool GetTop(SeqStack* ps, ElemType* pval) {
    assert(ps != NULL);
    assert(pval != NULL);
    
    if (Isempty(ps)) return false;
    
    *pval = *(ps->top - 1);  // 直接读取,不移动 top
    return true;
}

Pop 与 GetTop 的对比

操作 top 变化 用途
Pop 移动(减1) 真正地取出元素
GetTop 不变 只是"偷看"栈顶

四、清空栈

cpp 复制代码
void ClearStack(SeqStack* ps) {
    assert(ps != NULL);
    ps->top = ps->base;  // 仅修改 top 指针,不释放内存
}

清空 vs 销毁的区别

操作 内存 base top stacksize
清空 保留 不变 重置为 base 不变
销毁 释放 NULL NULL 0

清空后栈仍可继续使用(已分配空间不会收回),销毁后需要重新初始化。


第六部分:遍历与辅助操作

一、打印(遍历)

cpp 复制代码
void Print(SeqStack* ps) {
    assert(ps != NULL);
    
    // 从 top-1 开始,向 base 方向递减打印
    for (ElemType* p = ps->top - 1; p >= ps->base; p--) {
        printf("%c ", *p);
    }
    printf("\n");
}

遍历方向:从栈顶向栈底打印,符合 LIFO 的视觉习惯。

二、各操作时间复杂度

操作 时间复杂度 空间复杂度
初始化 O(1) O(n),n为初始容量
入栈 (Push) O(1) 均摊 扩容时 O(m),m为已有元素
出栈 (Pop) O(1) O(1)
获取栈顶 (GetTop) O(1) O(1)
判空/判满 O(1) O(1)
获取长度 O(1) O(1)
清空 O(1) O(1)
销毁 O(1) O(1)

所有操作都是 O(1),效率极高,这得益于顺序表的连续存储特性。


第七部分:顺序栈的优缺点

优点

cpp 复制代码
// 1. 操作简单,就地存取值
*ps->top++ = val;      // 入栈
*--ps->top;             // 出栈

// 2. 遍历方便
ps->top - ps->base     // 直接计算元素个数

缺点

cpp 复制代码
// 1. 扩容时开销大
//    需要重新 malloc、memmove、free

// 2. 空间浪费
//    扩容后可能多出很多未用的空间

// 3. 内存碎片
//    动态分配回收可能产生碎片

第八部分:实用技巧

一、常见错误防范

cpp 复制代码
// ❌ 错误:top 指针忘记更新
*ps->top = val;      // 已写入,但 top 未移向下一位置

// ✓ 正确
*ps->top++ = val;    // 先写再后移

// ❌ 错误:忘记检查输入参数
bool Pop(SeqStack* ps, ElemType *pval) {
    *pval = *--ps->top;  // 如果 pval==NULL 会崩溃
}

// ✓ 正确:加断言
bool Pop(SeqStack* ps, ElemType *pval) {
    assert(ps != NULL && pval != NULL);
    if (Isempty(ps)) return false;
    *pval = *--ps->top;
    return true;
}

二、通用设计模式

cpp 复制代码
// ① 总是用 assert 保护指针参数
void Func(SeqStack* ps) {
    assert(ps != NULL);  // 调试阶段发现调用错误
    // ...
}

// ② 扩容失败时优雅降级
if (expansion(ps) == false) {
    printf("扩容失败\n");
    return false;  // 不丢失已有数据
}

// ③ 清空不释放内存,可频繁复用
ClearStack(ps);  // 只是重置 top 指针
// 继续 Push 无需重新 malloc

总结

知识点 要点
设计核心 basetopstacksize 三个成员
判空 base == top
判满 top - base == stacksize
入栈 *ps->top++ = val
出栈 *--ps->top
扩容 重新分配、内容迁移、更新指针
销毁 free(base),三成员全置零
清空 top = base,保留空间
时间复杂度 所有操作 O(1)
适用场景 数据量可预估、不需频繁扩容

一句话总结 :顺序栈用三根指针在连续内存上实现 LIFO 行为。Pop 与 Push 都只需要 O(1) 时间,通过覆盖 top 指针实现逻辑上的增减 (不实际清除旧元素)。当容量不够时采用倍数扩容策略,确保均摊复杂度仍为 O(1)。

相关推荐
平凡但不平庸的码农1 小时前
Go 错误处理详解
开发语言·后端·golang
样例过了就是过了1 小时前
LeetCode热题100 编辑距离
数据结构·c++·算法·leetcode·动态规划
z200509301 小时前
C++中位图和布隆过滤器的一些面试题
开发语言·c++
Bat U2 小时前
JavaEE|文件操作和IO
java·开发语言
脉动数据行情2 小时前
Python 实现融通金行情数据对接(实时推送 + K 线 + 产品列表)
开发语言·python
khalil10202 小时前
代码随想录算法训练营Day-46 动态规划13 | 647. 回文子串、516.最长回文子序列、动态规划总结
数据结构·c++·算法·leetcode·动态规划·回文子串·回文子序列
skywalk81632 小时前
Trae生成的中文编程语言关键字(如“定“、“函“、“印“等)需要和标识符之间用 空格 隔开,以确保正确识别
服务器·开发语言·编程
红色的小鳄鱼3 小时前
前端面试js手写
开发语言·前端·javascript
海盗12343 小时前
C#中的IEqualityComparer<T>使用
开发语言·c#