想象一下你家厨房里的盘子堆——你总是把新洗好的盘子放在最上面,而取盘子时也总是从最上面拿走。这就是栈(Stack)的核心思想:后进先出(Last In First Out, LIFO)。
在计算机世界里,栈的应用无处不在:
核心定义:栈是一种限定仅在表尾进行插入和删除操作的线性表。我们称允许插入和删除的一端为栈顶(top),另一端为栈底(bottom)。不含任何元素的栈称为空栈。

小思考:你能想到哪些生活中的例子体现了栈的LIFO特性吗?欢迎在评论区分享!
栈作为一种抽象数据类型(ADT),其核心在于定义一组清晰的操作接口,而具体的实现细节则被隐藏起来。
// ADT Stack (stack)
Data:
同线性表。元素具有相同的类型,相邻元素具有前驱和后继关系。
Operation:
InitStack(*S); // 初始化操作,建立一个空栈S。
DestroyStack(*S); // 若栈存在,则销毁它。
ClearStack(*S); // 将栈清空。
StackEmpty(S); // 若栈为空,返回true,否则返回false。
GetTop(S, *e); // 若栈存在且非空,用*e返回栈顶元素。
Push(*S, e); // 若栈S存在,插入新元素e到栈S中并成为栈顶元素。
Pop(*S, *e); // 删除栈S中栈顶元素,并用*e返回其值。
StackLength(S); // 返回栈S的元素个数。
endADT这些操作非常直观:
Push 就是“压入”或“进栈”。Pop 就是“弹出”或“出栈”。GetTop 是查看栈顶元素但不移除它。关键点:栈的操作只发生在栈顶,这使得它的逻辑异常简洁,但也因此限制了其灵活性。
既然栈是线性表的特例,那么用数组来实现它是最直接的想法。这种实现方式被称为顺序栈。
我们定义一个整型变量 top 来指示当前栈顶元素在数组中的位置。这个 top 就像物理实验中的游标卡尺,它可以在数组范围内滑动,但永远不会超出边界。
top = -1 时,表示栈中没有任何元素。top = MAXSIZE - 1 时,表示栈已满,不能再插入新元素。top 加1,然后将新元素放入 data[top]。data[top] 的值赋给返回变量,再将 top 减1。
(图注:演示了元素 e 如何被压入栈顶的过程。)
代码实现:
typedef int SElemType; // 假设元素类型为int
typedef struct {
SElemType data[MAXSIZE]; // 存储元素的数组
int top; // 栈顶指针
} SqStack;
/* 进栈操作 */
Status Push(SqStack *S, SElemType e) {
if (S->top == MAXSIZE - 1) { // 判断是否栈满
return ERROR;
}
S->top++; // 栈顶指针加1
S->data[S->top] = e; // 将新元素赋值给栈顶空间
return OK;
}
/* 出栈操作 */
Status Pop(SqStack *S, SElemType *e) {
if (S->top == -1) { // 判断是否栈空
return ERROR;
}
*e = S->data[S->top]; // 将栈顶元素值赋给e
S->top--; // 栈顶指针减1
return OK;
}复杂度分析:由于没有循环语句,
Push和Pop操作的时间复杂度均为 O(1),效率极高。
顺序栈虽然简单高效,但它有一个致命缺陷:必须预先分配固定大小的数组。如果栈空间不足,程序就会溢出;如果空间太大,又会造成浪费。
一个聪明的解决方案是:两栈共享一个数组空间。
top1 和 top2 分别向数组中间增长。top1 + 1 == top2 时,表示整个空间已被占满,此时两个栈都满了。代码实现:
typedef struct {
SElemType data[MAXSIZE];
int top1; // 栈1的栈顶指针
int top2; // 栈2的栈顶指针
} SqDoubleStack;
/* 入栈操作,需指定是哪个栈 */
Status Push(SqDoubleStack *S, SElemType e, int stackNumber) {
if (S->top1 + 1 == S->top2) { // 栈满判断
return ERROR;
}
if (stackNumber == 1) {
S->data[++S->top1] = e; // 栈1入栈
} else if (stackNumber == 2) {
S->data[--S->top2] = e; // 栈2入栈
}
return OK;
}
/* 出栈操作,同样需指定是哪个栈 */
Status Pop(SqDoubleStack *S, SElemType *e, int stackNumber) {
if (stackNumber == 1) {
if (S->top1 == -1) { // 栈1空
return ERROR;
}
*e = S->data[S->top1--]; // 栈1出栈
} else if (stackNumber == 2) {
if (S->top2 == MAXSIZE) { // 栈2空
return ERROR;
}
*e = S->data[S->top2++]; // 栈2出栈
}
return OK;
}适用场景:这种方法特别适用于两个栈的空间需求呈“此消彼长”的情况,比如买股票时“买入”和“卖出”操作的记录。
顺序栈的缺点是空间固定,而链式栈则完美解决了这个问题。它使用单链表来实现,不需要预先分配空间,可以动态增长。
为了操作方便,我们通常将栈顶设置在链表的头部。这样,Push 和 Pop 操作都只需要修改头指针,时间复杂度依然是 O(1)。
top 为 NULL。next 指向原栈顶,再将 top 指向新节点。top 指向的节点的值取出,然后 top 指向下一个节点,最后释放原栈顶节点。当我们要将一个新元素 e 压入栈中时,需要执行以下三步:
s。s 的 next 指针指向当前的栈顶节点。top 指向新节点 s。
当我们要从栈中弹出一个元素时,也需要执行简单的三步:
p 的地址。top 指向下一个节点。p 的内存。
代码展示:
typedef struct StackNode {
SElemType data;
struct StackNode *next;
} StackNode, *LinkStackPtr;
typedef struct {
LinkStackPtr top; // 栈顶指针
int count; // 栈中元素个数
} LinkStack;
/* 进栈操作 */
Status Push(LinkStack *S, SElemType e) {
LinkStackPtr s = (LinkStackPtr)malloc(sizeof(StackNode));
if (!s) {
return ERROR;
}
s->data = e;
s->next = S->top; // 新节点指向原栈顶
S->top = s; // 栈顶指针指向新节点
S->count++;
return OK;
}
/* 出栈操作 */
Status Pop(LinkStack *S, SElemType *e) {
LinkStackPtr p;
if (S->top == NULL) { // 栈空
return ERROR;
}
*e = S->top->data;
p = S->top;
S->top = S->top->next; // 栈顶指针下移
free(p); // 释放原栈顶节点
S->count--;
return OK;
}复杂度分析:链式栈的
Push和Pop操作都非常简单,没有任何循环操作,时间复杂度均为 O(1)。 选择建议:对比顺序栈和链栈,它们在时间复杂度上是一样的。对于空间性能,顺序栈需要事先确定一个固定的长度,可能存在内存空间浪费的问题,但它的优势是存取时定位方便。而链栈则要求每个元素都有指针域,这同时也增加了一些内存开销,但对于栈的长度无限制。所以,如果栈的使用过程中元素变化不可预料,有时很小,有时非常大,那么最好使用链栈;反之,如果它的变化在可控范围内,建议使用顺序栈会更好一些。
理论知识学得再多,不如动手实践一次。让我们来看一道经典的LeetCode题目:
题目:有效的括号 (Valid Parentheses) 给定一个只包含
'(',')','{','}','[',']'的字符串,判断字符串是否有效。
解题思路:
(, {, [,则将其压入栈中。), }, ],则检查栈顶元素是否是对应的左括号。如果是,则弹出栈顶元素;如果不是,则字符串无效。参考代码:
#include <stdbool.h>
#include <stdlib.h>
#include <string.h>
bool isValid(char * s){
int len = strlen(s);
char *stack = (char*)malloc(len * sizeof(char)); // 用数组模拟栈
int top = -1;
for (int i = 0; i < len; i++) {
char c = s[i];
if (c == '(' || c == '{' || c == '[') {
stack[++top] = c; // 左括号入栈
} else {
if (top == -1) return false; // 栈空,却遇到右括号,无效
char topChar = stack[top--]; // 弹出栈顶
// 判断是否匹配
if ((c == ')' && topChar != '(') ||
(c == '}' && topChar != '{') ||
(c == ']' && topChar != '[')) {
return false;
}
}
}
free(stack);
return top == -1; // 栈空则有效
}这就是栈的魅力:一个简单的LIFO结构,就能优雅地解决复杂的匹配问题。
通过本文的学习,我们已经深入理解了栈的方方面面。现在,让我们来回答一个经典的问题:
为什么在实际应用中,链表比数组更适合实现队列?
所以,对于需要频繁在两端进行操作的数据结构(如队列),链表是更优的选择。
结语
栈,这个看似简单的数据结构,却是计算机科学中最基础、最重要的概念之一。栈的引入简化了程序设计的问题划分了不同关注层次’使得思考范围缩小’更加聚焦于我们要解决的问题核心。 从浏览器历史到函数调用栈,从算法设计到操作系统内核,它的身影无处不在。希望这篇博客能帮助你建立起对栈的系统化认知,并激发你进一步探索数据结构的兴趣。
如果你觉得这篇文章对你有帮助,别忘了点赞、收藏和分享!也欢迎在评论区提出你的疑问或见解。
下一篇博客更新队列。