数据结构——堆栈

堆栈

一、堆栈的定义

1. 逻辑结构

堆栈(Stack),简称,是只允许在一端进行插入和删除操作的线性表。它是一种操作受限的线性表。

  • 允许插入和删除的一端称为栈顶(Top)
  • 另一端(不允许操作的一端)称为栈底(Bottom)

堆栈的操作规则是后进先出(Last In First Out, LIFO)——就像一摞盘子,后放上去的盘子反而先被拿走,而最早放上去的盘子被压在最下面,最后才能被取走。

当 $n = 0$ 时,称为空栈

为什么叫"堆栈"? 在中文教材中,"堆栈"和"栈"通常混用,指的都是 Stack。严格来说,"堆栈"一词是为了与"堆"(Heap)区分,但实际使用中两者含义相同。不过在操作系统内存管理中,"堆"(Heap)和"栈"(Stack)是两种完全不同的内存区域,不要混淆。

2. 基本运算

堆栈上常见的操作有:

运算 含义
置空栈 InitStack(&S) 初始化一个空的堆栈
判栈空 StackEmpty(S) 判断堆栈是否为空
取栈顶 GetTop(S, &e) 读取栈顶元素的值(不删除)
入栈 Push(&S, e) 将元素 $e$ 压入栈顶
出栈 Pop(&S, &e) 删除栈顶元素,并将其值赋给 $e$

其中 &S 表示需要修改堆栈本身,不加 & 表示只读取不修改。


二、堆栈的使用

1. 顺序栈

(1)什么是顺序栈?

顺序栈(Sequential Stack)是指用一组地址连续的存储单元(即数组)依次存储从栈底到栈顶的数据元素。同时附加一个栈顶指针(通常是一个整型变量)来指示当前栈顶元素的位置。

#define MAXSIZE 100  // 堆栈的最大容量

typedef struct {
    int data[MAXSIZE];  // 用数组存储栈中元素
    int top;            // 栈顶指针(指示栈顶元素的下标)
} SqStack;              // SqStack 即 Sequential Stack
  • top 指示栈顶元素在数组中的下标。通常约定:
    • top = -1 表示空栈
    • top = 0 表示栈中有一个元素(下标 0 处)
    • top = MAXSIZE - 1 表示栈满

为什么用 top = -1 表示空栈? 因为数组下标从 0 开始。空栈时没有任何元素,栈顶指针指向一个不存在的"虚拟位置"——下标 -1,这样当入栈第一个元素时,top 先加 1 变成 0,再将元素存入 data[0],非常自然。

(2)顺序栈的基本运算

① 置空栈
void InitStack(SqStack *S) {
    S->top = -1;  // 将栈顶指针置为 -1,表示空栈
}
② 判栈空
int StackEmpty(SqStack S) {
    return S.top == -1;
}
③ 入栈
int Push(SqStack *S, int e) {
    if (S->top == MAXSIZE - 1)   // 栈满
        return 0;
    S->data[++S->top] = e;       // 栈顶指针先加 1,再存入元素
    return 1;
}

注意 ++S->top 的含义:先将 top 加 1,指向新的空位,再将元素放入该位置。不能写成 S->data[S->top++] = e,否则会覆盖原来的栈顶元素。

④ 出栈
int Pop(SqStack *S, int *e) {
    if (S->top == -1)             // 栈空
        return 0;
    *e = S->data[S->top--];       // 先取栈顶元素,再将栈顶指针减 1
    return 1;
}
⑤ 取栈顶元素(只读,不修改栈)
int GetTop(SqStack S, int *e) {
    if (S.top == -1)              // 栈空
        return 0;
    *e = S.data[S.top];           // 直接读取栈顶元素,不移动指针
    return 1;
}

对比入栈和出栈的写法

  • 入栈:data[++top] = e——先移动指针,再放元素
  • 出栈:e = data[top--]——先取元素,再移动指针

两者是"对称"的操作。

(3)性能分析

操作 时间复杂度 说明
置空栈 $O(1)$ 只需重置栈顶指针
判栈空 $O(1)$ 比较 top 是否等于 -1
取栈顶 $O(1)$ 直接通过下标访问
入栈 $O(1)$ 只需修改 top 指针
出栈 $O(1)$ 只需修改 top 指针

顺序栈的所有基本操作都是 $O(1)$ —— 因为所有操作都只在栈顶进行,不需要移动其他元素。这与顺序表在中间插入/删除时需要移动大量元素形成了鲜明对比。


2. 链栈

(1)什么是链栈?

链栈(Linked Stack)是堆栈的链式存储结构,即用链表来实现堆栈。

链栈本质上就是一个不带头结点的单链表,并且限制了操作范围——只能在头部进行插入(入栈)和删除(出栈):

  • 栈顶指针就是链表的头指针,指向链表的第一个结点
  • 入栈相当于在链表头部插入一个新结点
  • 出栈相当于删除链表的第一个结点

为什么链栈不需要头结点? 因为栈的所有操作都发生在栈顶(链表头部),如果加上头结点反而会让入栈和出栈的代码变得更复杂。链栈的结构决定了头指针直接指向栈顶元素是最自然的设计。

为什么用链栈? 顺序栈的大小是固定的(MAXSIZE 定死),而链栈可以动态增长,没有容量上限,适合元素个数不确定或变化较大的场景。不过每个结点需要额外存储一个指针域,存储密度较低。

(2)链栈的结点结构

typedef struct StackNode {
    int data;                // 数据域
    struct StackNode *next;  // 指针域,指向下一个结点
} StackNode;

链栈本身只需要一个栈顶指针:

typedef struct {
    StackNode *top;  // 栈顶指针(指向链表的第一个结点)
} LinkStack;         // LinkStack 即 Linked Stack

也可以直接用一个 StackNode *top 指针来表示链栈,而不必将其包装为结构体。但包装成结构体更符合"栈是一种数据类型"的抽象思想,也方便以后扩展(比如增加一个记录栈大小的字段)。

(3)链栈的基本运算

① 置空栈

注意:对于刚声明的局部变量 LinkStack S;,其 top 成员是未初始化的垃圾值,因此 InitStack 只需将 S->top 置为 NULL 即可,不能试图去释放"原有结点"——因为根本就不存在原有结点,强行遍历垃圾指针会导致程序崩溃。

void InitStack(LinkStack *S) {
    S->top = NULL;  // 将栈顶指针置空
}

如果希望销毁一个已有数据的栈(释放所有结点的内存),应使用专门的销毁函数:

void DestroyStack(LinkStack *S) {
    StackNode *p = S->top;
    while (p != NULL) {
        StackNode *q = p;   // 保存当前结点
        p = p->next;        // 指针后移
        free(q);            // 释放当前结点
    }
    S->top = NULL;          // 最后将栈顶指针置空
}
② 判栈空
int StackEmpty(LinkStack S) {
    return S.top == NULL;
}
③ 入栈
int Push(LinkStack *S, int e) {
    StackNode *p = (StackNode *)malloc(sizeof(StackNode));
    if (p == NULL) return 0;       // 内存分配失败
    p->data = e;
    p->next = S->top;              // 新结点指向原来的栈顶
    S->top = p;                    // 栈顶指针指向新结点
    return 1;
}
④ 出栈
int Pop(LinkStack *S, int *e) {
    if (S->top == NULL) return 0;  // 栈空
    StackNode *p = S->top;         // 指向要删除的栈顶结点
    *e = p->data;
    S->top = p->next;              // 栈顶指针后移
    free(p);                       // 释放原栈顶结点的内存
    return 1;
}
⑤ 取栈顶元素(只读,不修改栈)
int GetTop(LinkStack S, int *e) {
    if (S.top == NULL) return 0;   // 栈空
    *e = S.top->data;              // 直接读取栈顶结点的数据
    return 1;
}

注意:链栈的入栈和出栈操作涉及动态内存分配和释放(malloc / free),频繁操作时会产生内存碎片开销。而顺序栈只需移动指针,效率更高。因此在元素个数大致已知、追求高效率的场景下,优先使用顺序栈。


三、堆栈的简单应用

1. 括号匹配

编译器在检查代码中的括号是否成对出现时,就用到了堆栈:

  1. 从左到右扫描字符串,遇到左括号({[()就入栈
  2. 遇到右括号(}]))就出栈一个元素,检查是否匹配
  3. 扫描结束后,如果栈为空,则括号匹配;否则说明有多余的左括号

2. 表达式求值

计算器在计算算术表达式(如 3 + 5 * 2 - 8 / 4)时,需要处理运算符优先级。一种经典方法是用两个栈——一个操作数栈和一个运算符栈来实现:

  • 扫描表达式,数字入操作数栈,运算符入运算符栈
  • 遇到优先级较低的运算符时,先将栈中优先级高的运算符弹出计算,再将结果压回去
  • 最终操作数栈中剩下的就是表达式的结果

该算法由 Dijkstra 提出,称为调度场算法(Shunting Yard Algorithm)。

3. 函数调用与递归

程序在运行过程中,每次调用函数时,系统会将返回地址、参数、局部变量等信息压入一个称为"调用栈"(Call Stack)的内存区域中;当函数返回时,再从栈中弹出这些信息。这也正是 Stack 被称为"堆栈"的由来——函数调用像叠盘子一样层层堆放,后调用的先返回。

递归函数特别依赖栈:以计算阶乘为例——factorial(5) 调用 factorial(4)factorial(4) 调用 factorial(3),依次到 factorial(1) 返回 1,然后逐层返回计算结果。这一过程完全遵循栈的 LIFO 规则。


四、堆栈类别的对比

顺序栈 vs 链栈

对比维度 顺序栈 链栈
空间大小 固定(MAXSIZE),可能溢出或浪费 动态,只要内存够就不会溢出
存储密度 高(只存数据) 较低(额外存指针)
基本操作效率 均为 $O(1)$ 均为 $O(1)$
内存分配 静态分配(编译时确定) 动态分配(运行时 malloc
适用场景 元素个数已知、追求效率 元素个数不确定、变化较大

总结:堆栈是一种后进先出的线性表,所有操作都限制在栈顶进行。顺序栈用数组实现,操作简单高效但大小固定;链栈用链表实现,灵活可扩展但每个结点额外占用指针空间。堆栈在括号匹配、表达式求值、函数调用与递归等场景中有广泛应用,是所有数据结构中应用最广泛的之一。

posted @ 2026-07-14 17:03  Javenwww  阅读(10)  评论(0)    收藏  举报