第6章 栈:后进先出的艺术
栈这个东西,说白了就是一种「后进先出」的数据结构。我刚开始学的时候觉得它太简单了,不就是个只能从一头操作的线性表吗?后来才发现,越是简单的结构,用好了越能解决复杂问题。
6.1 栈的定义与核心操作
栈(Stack)是一种操作受限的线性表。它只允许在一端进行插入和删除操作,这一端叫做栈顶(Top),另一端叫做栈底(Bottom)。
核心操作就五个:
- InitStack(&S):初始化一个空栈
- Push(&S, x):元素x入栈
- Pop(&S, &x):栈顶元素出栈,用x返回
- GetTop(S, &x):读取栈顶元素,不出栈
- StackEmpty(S):判断栈是否为空
嗯,这里要注意:Pop和GetTop的区别。Pop是拿走,GetTop只是看一眼。我在项目中见过有人把这两个搞混,结果数据被意外删除了。
栈的特性:后进先出(LIFO, Last In First Out)。就像一摞盘子,你总是先拿最上面的那个。
6.2 顺序存储实现
顺序栈,就是用数组来实现。我习惯用一个整型变量top来指示栈顶位置。空栈时top = -1,有元素时top指向当前栈顶元素的下标。
#define MAXSIZE 100
typedef struct {
int data[MAXSIZE];
int top; // 栈顶指针
} SqStack;
// 初始化
void InitStack(SqStack *S) {
S->top = -1;
}
// 判断空栈
int StackEmpty(SqStack S) {
return S.top == -1;
}
// 入栈
int Push(SqStack *S, int x) {
if (S->top == MAXSIZE - 1) {
return 0; // 栈满
}
S->data[++S->top] = x;
return 1;
}
// 出栈
int Pop(SqStack *S, int *x) {
if (S->top == -1) {
return 0; // 栈空
}
*x = S->data[S->top--];
return 1;
}
// 读栈顶
int GetTop(SqStack S, int *x) {
if (S.top == -1) {
return 0;
}
*x = S.data[S.top];
return 1;
}
避坑指南:我曾经在嵌入式项目里用顺序栈,没注意栈的大小限制。结果递归调用太深,栈溢出了,整个系统直接崩溃。从那以后,我养成了一个习惯——凡是栈深度不确定的场景,优先考虑链式存储。
6.3 链式存储实现
链栈,说白了就是用链表来实现栈。入栈就是头插法,出栈就是删除头结点。你想想看,这样操作的时间复杂度都是O(1),而且不用担心栈满的问题。
typedef struct StackNode {
int data;
struct StackNode *next;
} StackNode, *LinkStack;
// 初始化
void InitStack(LinkStack *S) {
*S = NULL;
}
// 入栈
int Push(LinkStack *S, int x) {
StackNode *p = (StackNode *)malloc(sizeof(StackNode));
if (!p) return 0;
p->data = x;
p->next = *S;
*S = p;
return 1;
}
// 出栈
int Pop(LinkStack *S, int *x) {
if (*S == NULL) return 0;
StackNode *p = *S;
*x = p->data;
*S = p->next;
free(p);
return 1;
}
我建议你在实际开发中这样选:如果数据量可控、栈深度已知,用顺序栈;如果数据量不确定、频繁动态变化,用链栈。
6.4 栈的应用:括号匹配
括号匹配是栈最经典的应用之一。你想想看,编译器怎么知道你的代码括号有没有配对?其实就是用栈来做的。
算法思路很简单:遍历字符串,遇到左括号就入栈,遇到右括号就检查栈顶是否匹配。如果最后栈为空,说明全部匹配。
int isMatching(char left, char right) {
return (left == '(' && right == ')') ||
(left == '[' && right == ']') ||
(left == '{' && right == '}');
}
int checkBrackets(char *expr) {
SqStack S;
InitStack(&S);
char ch, top;
for (int i = 0; expr[i] != '\0'; i++) {
ch = expr[i];
if (ch == '(' || ch == '[' || ch == '{') {
Push(&S, ch);
} else if (ch == ')' || ch == ']' || ch == '}') {
if (StackEmpty(S)) return 0; // 右括号多余
Pop(&S, &top);
if (!isMatching(top, ch)) return 0; // 不匹配
}
}
return StackEmpty(S); // 栈空则全部匹配
}
小技巧:我在写代码编辑器插件时,用这个算法实现了括号高亮功能。每次用户输入一个括号,就实时检查匹配情况,不匹配的标红。用户体验特别好。
6.5 栈的应用:表达式求值
表达式求值,说白了就是让计算机理解数学公式。计算机不像人,它看不懂中缀表达式(比如 3+4*2),它更喜欢后缀表达式(比如 3 4 2 * +)。
转换规则:
- 遇到操作数,直接输出
- 遇到运算符,与栈顶比较优先级
- 栈顶优先级高或相等,则出栈并输出
- 当前运算符入栈
- 遇到左括号直接入栈,遇到右括号则出栈直到左括号
// 运算符优先级
int priority(char op) {
switch(op) {
case '+': case '-': return 1;
case '*': case '/': return 2;
case '(': return 0;
default: return -1;
}
}
// 中缀转后缀
void infixToPostfix(char *infix, char *postfix) {
SqStack S;
InitStack(&S);
int j = 0;
for (int i = 0; infix[i] != '\0'; i++) {
char ch = infix[i];
if (isdigit(ch)) {
postfix[j++] = ch;
} else if (ch == '(') {
Push(&S, ch);
} else if (ch == ')') {
char top;
while (!StackEmpty(S)) {
Pop(&S, &top);
if (top == '(') break;
postfix[j++] = top;
}
} else {
char top;
while (!StackEmpty(S)) {
GetTop(S, &top);
if (priority(top) >= priority(ch)) {
Pop(&S, &top);
postfix[j++] = top;
} else break;
}
Push(&S, ch);
}
}
while (!StackEmpty(S)) {
char top;
Pop(&S, &top);
postfix[j++] = top;
}
postfix[j] = '\0';
}
6.6 栈的应用:递归转非递归
递归虽然写起来爽,但性能是个大问题。每次递归调用都要压栈,深度大了容易栈溢出。我建议你在以下场景考虑转非递归:
- 递归深度超过1000层
- 对性能要求极高
- 嵌入式系统内存受限
以斐波那契数列为例,递归写法:
int fib_recursive(int n) {
if (n <= 1) return n;
return fib_recursive(n-1) + fib_recursive(n-2);
}
转成非递归,其实就是用栈模拟函数调用:
typedef struct {
int n; // 参数
int state; // 0: 未计算, 1: 已计算左子树
int result; // 计算结果
} Frame;
int fib_non_recursive(int n) {
SqStack S;
InitStack(&S);
Frame f = {n, 0, 0};
Push(&S, f);
while (!StackEmpty(S)) {
Frame *top = &S.data[S.top];
if (top->n <= 1) {
top->result = top->n;
Pop(&S, &f);
if (!StackEmpty(S)) {
Frame *parent = &S.data[S.top];
if (parent->state == 0) {
parent->result += f.result;
parent->state = 1;
Frame child = {parent->n - 2, 0, 0};
Push(&S, child);
} else {
parent->result += f.result;
}
}
} else if (top->state == 0) {
top->state = 1;
Frame child = {top->n - 1, 0, 0};
Push(&S, child);
} else {
Pop(&S, &f);
if (!StackEmpty(S)) {
Frame *parent = &S.data[S.top];
parent->result += f.result;
}
}
}
return f.result;
}
核心思想:递归转非递归,本质上就是用栈来模拟函数调用栈。每次递归调用对应一次入栈,每次返回对应一次出栈。你只要把函数参数、局部变量、返回地址这三个东西存到栈帧里,就能手动控制调用流程了。
好了,栈的内容就讲到这里。记住一句话:栈虽小,用处大。从括号匹配到表达式求值,从函数调用到深度优先搜索,处处都有栈的身影。你写代码的时候,遇到「后进先出」的场景,第一个想到的就应该是栈。