栈:后进先出的艺术
说实话,栈这个概念,我第一次接触时觉得挺简单的。不就是个后进先出吗?但真正在项目里用起来,才发现这里面门道不少。今天我就带你好好捋一捋栈的方方面面。
栈的定义
栈(Stack),说白了就是一种操作受限的线性表。它只允许在一端进行插入和删除操作,这一端我们叫它栈顶,另一端叫栈底。
你想想看,这像什么?就像一摞盘子。你每次只能从最上面拿盘子,也只能把新盘子放在最上面。这就是典型的后进先出(LIFO, Last In First Out)。
核心操作:
- 入栈(Push):在栈顶插入一个元素
- 出栈(Pop):删除栈顶元素并返回其值
- 取栈顶(Top/Peek):获取栈顶元素的值,但不删除
- 判空(IsEmpty):判断栈是否为空
我在项目中遇到过一个问题:函数调用时,局部变量的生命周期管理。其实底层就是靠栈来实现的。每次调用函数,就把局部变量压入栈;函数返回时,自动弹出。嗯,这就是栈最经典的应用场景。
栈的顺序存储结构
顺序存储,就是用数组来实现栈。我习惯用一个整型变量 top 来指示栈顶位置。空栈时 top = -1,每入栈一个元素,top 就加 1。
#define MAXSIZE 100
typedef struct {
int data[MAXSIZE];
int top; // 栈顶指针
} SeqStack;
// 初始化
void InitStack(SeqStack *s) {
s->top = -1;
}
// 入栈
int Push(SeqStack *s, int x) {
if (s->top == MAXSIZE - 1) {
return 0; // 栈满
}
s->data[++s->top] = x;
return 1;
}
// 出栈
int Pop(SeqStack *s, int *x) {
if (s->top == -1) {
return 0; // 栈空
}
*x = s->data[s->top--];
return 1;
}
注意:顺序栈有个硬伤——大小固定。我曾经在一个嵌入式项目里,因为预估的栈大小不够,导致程序在运行时莫名其妙地崩溃。排查了半天,才发现是栈溢出了。所以,如果你不确定数据量,建议用链式存储。
栈的链式存储结构
链式存储,说白了就是用链表来实现栈。它的好处是:理论上没有大小限制,只要内存够,随便压。
链栈的栈顶就是链表的头结点。入栈就是在头部插入,出栈就是删除头部节点。时间复杂度都是 O(1),非常高效。
typedef struct StackNode {
int data;
struct StackNode *next;
} StackNode;
typedef struct {
StackNode *top; // 栈顶指针
int count; // 栈中元素个数
} LinkStack;
// 入栈
void Push(LinkStack *s, int x) {
StackNode *node = (StackNode *)malloc(sizeof(StackNode));
node->data = x;
node->next = s->top;
s->top = node;
s->count++;
}
// 出栈
int Pop(LinkStack *s, int *x) {
if (s->top == NULL) {
return 0; // 栈空
}
StackNode *temp = s->top;
*x = temp->data;
s->top = temp->next;
free(temp);
s->count--;
return 1;
}
我的建议:如果你对性能要求极高,且数据量可预估,用顺序栈。如果数据量不确定,或者需要频繁创建销毁栈,用链栈更灵活。
两栈共享空间
这个技巧挺有意思的。当你有两个栈,而且它们的数据类型相同时,可以让它们共享一个数组。一个栈从数组左端向右生长,另一个从右端向左生长。
为什么会这样设计?你想想看,如果两个栈各自用独立的数组,很容易出现一个栈满了、另一个栈还有很多空位的情况。共享空间就能充分利用内存。
typedef struct {
int data[MAXSIZE];
int top1; // 栈1的栈顶
int top2; // 栈2的栈顶
} DoubleStack;
// 初始化
void InitDoubleStack(DoubleStack *s) {
s->top1 = -1;
s->top2 = MAXSIZE;
}
// 入栈(flag=1表示栈1,flag=2表示栈2)
int Push(DoubleStack *s, int x, int flag) {
if (s->top1 + 1 == s->top2) {
return 0; // 栈满
}
if (flag == 1) {
s->data[++s->top1] = x;
} else {
s->data[--s->top2] = x;
}
return 1;
}
关键点:两栈共享空间的栈满条件是 top1 + 1 == top2。也就是说,两个栈顶指针相遇时,数组就真的满了。这种结构特别适合此消彼长的场景——一个栈增长时,另一个栈在收缩。
我曾经在一个编译器项目中用过这个技巧。当时需要同时处理表达式求值和括号匹配,两个栈的数据类型一样,而且总数据量有上限。用两栈共享空间,内存利用率直接翻倍。嗯,这个设计确实漂亮。
知识体系总览
下面这张图,我把栈的核心知识点串起来了。你可以对照着看,心里有个谱。
这张图把栈的核心脉络理清楚了。从定义出发,分支出两种存储方式,最后引出两栈共享空间这个优化技巧。你对照着看,应该能对栈有个整体把握。
避坑指南:我曾经在写递归函数时,忘了考虑栈深度。结果递归层数一多,直接栈溢出。后来我改用循环+显式栈来模拟递归,问题就解决了。记住:递归虽好,可不要贪杯。
好了,栈的内容就聊到这儿。你把这些代码自己敲一遍,跑一跑,感受一下入栈出栈的过程。代码这东西,光看是学不会的,得动手。