1. 栈的基本概念与核心特性
栈(Stack)是计算机科学中最基础且重要的数据结构之一,它的核心特性可以用一个简单的比喻来理解:想象一摞盘子,你只能从最上面放入新盘子(入栈),也只能从最上面拿走盘子(出栈)。这种"后进先出"(Last In First Out, LIFO)的特性使得栈在程序设计中有着不可替代的作用。
1.1 栈的ADT抽象数据类型
从抽象层面看,栈支持以下基本操作:
- Push(压栈/入栈):在栈顶添加新元素
- Pop(出栈):移除并返回栈顶元素
- Peek/Top(查看栈顶):获取但不移除栈顶元素
- IsEmpty(判空):检查栈是否为空
- Size(大小):获取栈中元素数量
这些操作的时间复杂度都是O(1),这是栈结构高效的关键。在实际系统设计中,函数调用栈、表达式求值、括号匹配等场景都深度依赖这些基础操作。
1.2 栈的物理实现方式
栈的实现主要有两种方式:
- 顺序栈(数组实现):使用连续内存空间存储,通过top指针标记栈顶位置。C语言中通常结合动态数组实现自动扩容。
- 链式栈(链表实现):每个元素通过指针连接,内存动态分配,理论上没有容量限制但开销更大。
选择哪种实现取决于具体场景。顺序栈缓存友好、访问速度快,适合元素数量可预估的场景;链式栈灵活性高,适合元素数量变化大的情况。
提示:在嵌入式开发中,由于内存限制,通常优先考虑顺序栈实现;而在通用程序开发中,现代语言的标准库栈实现多采用动态扩容的顺序存储。
2. C语言实现顺序栈的完整方案
2.1 结构体设计与初始化
我们先定义栈的结构体,包含三个关键字段:
c复制#define INIT_CAPACITY 4 // 初始容量
typedef struct {
int *arr; // 存储元素的数组
int top; // 栈顶指针(下一个要插入的位置)
int capacity; // 当前分配的总容量
} Stack;
// 初始化栈
void StackInit(Stack *ps) {
ps->arr = (int*)malloc(sizeof(int) * INIT_CAPACITY);
if (ps->arr == NULL) {
perror("malloc fail");
exit(EXIT_FAILURE);
}
ps->top = 0;
ps->capacity = INIT_CAPACITY;
}
这种设计有几个关键点:
- 使用动态内存分配,避免固定大小数组的局限性
- top指针指向的是下一个可用位置(而非当前栈顶),这种设计使得判空条件更直观(top == 0)
- 初始容量设为4是经验值,太小会导致频繁扩容,太大可能浪费内存
2.2 动态扩容策略实现
当栈空间不足时,我们需要进行扩容。常见的策略是倍增容量:
c复制void CheckCapacity(Stack *ps) {
if (ps->top == ps->capacity) {
int newCapacity = ps->capacity * 2;
int *tmp = (int*)realloc(ps->arr, sizeof(int) * newCapacity);
if (tmp == NULL) {
perror("realloc fail");
exit(EXIT_FAILURE);
}
ps->arr = tmp;
ps->capacity = newCapacity;
printf("栈已扩容至%d\n", newCapacity); // 调试信息
}
}
扩容时需要注意:
- 使用realloc而不是malloc+memcpy,因为realloc可能原地扩展内存,效率更高
- 扩容后要更新capacity字段
- 实际项目中可以添加最大容量限制,防止内存耗尽
2.3 核心操作实现
压栈操作:
c复制void StackPush(Stack *ps, int data) {
assert(ps != NULL);
CheckCapacity(ps); // 检查是否需要扩容
ps->arr[ps->top++] = data; // 先赋值再移动top指针
}
出栈操作:
c复制int StackPop(Stack *ps) {
assert(ps != NULL);
assert(!StackEmpty(ps)); // 确保栈不为空
return ps->arr[--ps->top]; // 先移动top指针再返回值
}
查看栈顶元素:
c复制int StackTop(Stack *ps) {
assert(ps != NULL);
assert(!StackEmpty(ps));
return ps->arr[ps->top - 1]; // top指向的是下一个位置,所以要-1
}
判空与获取大小:
c复制bool StackEmpty(Stack *ps) {
assert(ps != NULL);
return ps->top == 0;
}
int StackSize(Stack *ps) {
assert(ps != NULL);
return ps->top;
}
3. 栈的经典应用场景实战
3.1 括号匹配问题
这是栈最典型的应用之一。给定一个包含括号的字符串,判断括号是否匹配。例如:"([])"返回true,"([)]"返回false。
实现思路:
- 遇到左括号就入栈
- 遇到右括号就与栈顶元素匹配
- 最后检查栈是否为空
c复制bool isValid(char *s) {
Stack st;
StackInit(&st);
while (*s != '\0') {
if (*s == '(' || *s == '[' || *s == '{') {
StackPush(&st, *s);
} else {
if (StackEmpty(&st)) return false;
char top = StackTop(&st);
if ((*s == ')' && top != '(') ||
(*s == ']' && top != '[') ||
(*s == '}' && top != '{')) {
StackDestroy(&st);
return false;
}
StackPop(&st);
}
s++;
}
bool ret = StackEmpty(&st);
StackDestroy(&st);
return ret;
}
3.2 表达式求值
栈可以高效处理中缀表达式转后缀表达式,以及后缀表达式求值。这是编译器设计的基础。
中缀转后缀算法步骤:
- 初始化操作数栈和运算符栈
- 遇到数字直接输出
- 遇到运算符与栈顶比较优先级,弹出更高或相等优先级的运算符
- 左括号入栈,遇到右括号弹出直到左括号
后缀表达式求值:
- 遇到数字入栈
- 遇到运算符弹出两个操作数计算,结果入栈
- 最后栈中剩余的就是结果
c复制// 简化版后缀表达式求值
int evalRPN(char **tokens, int tokensSize) {
Stack st;
StackInit(&st);
for (int i = 0; i < tokensSize; i++) {
if (strcmp(tokens[i], "+") == 0) {
int b = StackPop(&st);
int a = StackPop(&st);
StackPush(&st, a + b);
} else if (strcmp(tokens[i], "-") == 0) {
int b = StackPop(&st);
int a = StackPop(&st);
StackPush(&st, a - b);
} else if (strcmp(tokens[i], "*") == 0) {
int b = StackPop(&st);
int a = StackPop(&st);
StackPush(&st, a * b);
} else if (strcmp(tokens[i], "/") == 0) {
int b = StackPop(&st);
int a = StackPop(&st);
StackPush(&st, a / b);
} else {
StackPush(&st, atoi(tokens[i]));
}
}
int ret = StackPop(&st);
StackDestroy(&st);
return ret;
}
4. 栈的高级应用与优化技巧
4.1 最小栈设计
如何在O(1)时间内获取栈中的最小元素?这需要额外维护一个最小栈:
c复制typedef struct {
Stack dataStack; // 常规数据栈
Stack minStack; // 存储当前最小值的栈
} MinStack;
void MinStackPush(MinStack *ms, int val) {
StackPush(&ms->dataStack, val);
if (StackEmpty(&ms->minStack) || val <= StackTop(&ms->minStack)) {
StackPush(&ms->minStack, val);
}
}
int MinStackPop(MinStack *ms) {
int val = StackPop(&ms->dataStack);
if (val == StackTop(&ms->minStack)) {
StackPop(&ms->minStack);
}
return val;
}
int MinStackGetMin(MinStack *ms) {
return StackTop(&ms->minStack);
}
这种设计在LeetCode等算法题中很常见,也是面试高频考点。
4.2 多栈共享存储空间
在内存受限的环境中,可以使用一个数组实现多个栈。常见的有两种方式:
- 固定分割法:将数组均分给各个栈
- 弹性分割法:栈顶指针相向生长,更高效利用空间
以下是双栈共享空间的实现:
c复制typedef struct {
int *arr;
int capacity;
int top1; // 栈1的栈顶指针(从左向右增长)
int top2; // 栈2的栈顶指针(从右向左增长)
} DoubleStack;
void DoubleStackInit(DoubleStack *ds, int cap) {
ds->arr = (int*)malloc(sizeof(int) * cap);
ds->capacity = cap;
ds->top1 = 0;
ds->top2 = cap - 1;
}
bool DoubleStackPush1(DoubleStack *ds, int val) {
if (ds->top1 > ds->top2) return false; // 空间已满
ds->arr[ds->top1++] = val;
return true;
}
bool DoubleStackPush2(DoubleStack *ds, int val) {
if (ds->top1 > ds->top2) return false;
ds->arr[ds->top2--] = val;
return true;
}
4.3 栈在递归算法中的应用
递归本质上就是使用系统调用栈来实现的。理解这一点可以帮助我们:
- 将递归算法改写成非递归的迭代形式
- 分析递归调用的空间复杂度
- 避免栈溢出错误
以经典的汉诺塔问题为例,其非递归实现就需要显式使用栈:
c复制typedef struct {
int n;
char from, to, via;
bool processed;
} HanoiFrame;
void hanoiIterative(int n, char from, char to, char via) {
Stack st;
StackInit(&st);
HanoiFrame initial = {n, from, to, via, false};
StackPush(&st, initial);
while (!StackEmpty(&st)) {
HanoiFrame current = StackPop(&st);
if (current.n == 1) {
printf("Move disk 1 from %c to %c\n", current.from, current.to);
} else {
if (current.processed) {
printf("Move disk %d from %c to %c\n", current.n, current.from, current.to);
HanoiFrame newFrame = {current.n - 1, current.via, current.to, current.from, false};
StackPush(&st, newFrame);
} else {
HanoiFrame frame1 = {current.n - 1, current.from, current.via, current.to, false};
HanoiFrame frame2 = {current.n, current.from, current.to, current.via, true};
HanoiFrame frame3 = {current.n - 1, current.via, current.to, current.from, false};
StackPush(&st, frame3);
StackPush(&st, frame2);
StackPush(&st, frame1);
}
}
}
StackDestroy(&st);
}
这种实现虽然代码量比递归版本多,但在处理大规模问题时可以避免栈溢出,也更利于调试和优化。
