Appearance
顺序栈
top 指哪儿,决定其余一切
顺序栈就是"一个数组 + 一个栈顶指针 top"。但 top 到底指向栈顶元素本身,还是指向栈顶元素的下一个空位,是两套不同的约定——约定一旦定下,栈空、栈满、入栈出栈的语句顺序就全部被确定了,不需要分开背两套公式。
| 约定 | 初始值 | 栈顶元素 | 栈空条件 | 栈满条件 | 元素个数 | 入栈 | 出栈 |
|---|---|---|---|---|---|---|---|
甲:top 指向栈顶元素 | top = -1 | data[top] | top == -1 | top == MaxSize - 1 | top + 1 | 先 ++top,再赋值 | 先取值,再 top-- |
乙:top 指向栈顶元素的下一个位置 | top = 0 | data[top - 1] | top == 0 | top == MaxSize | top | 先赋值,再 top++ | 先 --top,再取值 |
⚠️
top在这里是数组下标,不是 C 语言的指针。教材里说的"栈顶指针"是个逻辑称呼;教材另有用真指针写的版本,那里的S.top - S.base就等于这里的top。另外,题面不特别说明时通常按约定甲(
top = -1),但只要题面给出了初值或栈空条件,一律以题面为准。
先看一眼
压几个再弹几个,盯住两件事:top 每次移动的方向,以及弹出之后数组里那一格的值有没有变。第二点下面会专门说——那里有个容易判反的结论。
存储结构与五个基本操作(约定甲)
c
#define MaxSize 50 // 栈的最大容量,编译期确定
typedef struct {
int data[MaxSize]; // 存放栈中元素,静态分配
int top; // 栈顶指针(这里是数组下标,不是 C 指针)
} SqStack;
void InitStack(SqStack *S) {
S->top = -1; // 约定甲:-1 表示"还没有任何有效元素"
}
bool StackEmpty(SqStack S) {
return S.top == -1; // 与初始化保持同一口径,二者必须成对改动
}
bool Push(SqStack *S, int x) {
if (S->top == MaxSize - 1) // 栈满,上溢;注意是 MaxSize-1 不是 MaxSize
return false;
S->data[++S->top] = x; // 前缀 ++:先把指针移到空位,再写入
return true;
}
bool Pop(SqStack *S, int *x) {
if (S->top == -1) // 栈空,下溢
return false;
*x = S->data[S->top--]; // 后缀 --:先用当前 top 取值,再移指针
return true;
}
bool GetTop(SqStack S, int *x) {
if (S.top == -1)
return false;
*x = S.data[S.top]; // 只读取,不移动指针
return true;
}三处细节值得单独说明。MaxSize 是编译期常量,数组随结构体一起分配——这决定了顺序栈一定存在栈满,是它与链栈最本质的区别。GetTop 用传值而非指针,因为它不应该修改栈,传值在语义上就杜绝了误改;Push/Pop 则必须传指针,否则修改传不回调用方。初始化与判空是同一条约定的两面——凡是把 InitStack 改成 top = 0 的题目,StackEmpty 必然同步改成 top == 0。
那些前缀 / 后缀的顺序也不用死记,把约定当成一个不变量,要求它在每次操作前后都成立,顺序就被唯一确定了:
- 约定甲的不变量是"
top处存的是有效元素"。 入栈时新元素还没地方放,必须先把指针挪到空位(++top)再写入,写完不变量恢复;出栈时top处正是要取的元素,所以先取值,取完那格失效,再top--。 - 约定乙的不变量是"
top处是空位"。 入栈时top已经指着空位,直接写入,写完这格不再是空位,指针后移(top++);出栈时top处是空位、top-1才是元素,所以先--top落到元素上,再取值。
🔴 约定甲下,入栈必须用前缀
++top。 写成data[top++] = x,空栈时会先用旧值做下标写 data[-1]——越界写,随后top才变成 0;接着Pop取到的是从未赋值的data[0]。同理出栈误写成data[--top]会跳过真正的栈顶元素。🔴 约定甲的栈满是
top == MaxSize - 1,不是MaxSize。top指的是元素,最大合法下标就是MaxSize-1;写成MaxSize等于先越界写一格才发现满。
一段操作序列的完整跟踪(第一次学、或想手动模拟时展开)
设 MaxSize = 4、采用约定甲,执行 Push(A) → Push(B) → Pop → Push(C) → Push(D) → Push(E) → Pop → Pop:
| 步 | 操作 | 判断 | top | 数组 data[0..3] | 返回 |
|---|---|---|---|---|---|
| 0 | 初始化 | — | _ _ _ _ | — | |
| 1 | Push(A) | 0 | A _ _ _ | true | |
| 2 | Push(B) | 1 | A B _ _ | true | |
| 3 | Pop | 0 | A B _ _(B 仍在,但已不属于栈) | true,取回 B | |
| 4 | Push(C) | 不满 | 1 | A C _ _(C 覆盖了 B) | true |
| 5 | Push(D) | 不满 | 2 | A C D _ | true |
| 6 | Push(E) | 不满 | 3 | A C D E | true |
| 7 | Pop | 非空 | 2 | A C D E | true,取回 E |
| 8 | Pop | 非空 | 1 | A C D E | true,取回 D |
上面这段跟踪里有两个观察值得单独拎出来。一是第 3 步之后 data[1] 里还留着 B,直到第 4 步才被 C 覆盖——出栈只是移动指针,并不擦除数组里的数据,所以"出栈后数组内容不变、只有 top 变了"是一句正确的描述。二是第 6 步之后 top == 3 == MaxSize - 1,此时再 Push 会被判满拒绝,而数组确实已经写满 4 格,判满条件与实际容量恰好吻合。
约定乙的完整实现与教材的动态分配指针版(题面给的是另一套写法时展开)
c
// 约定乙:top 初值 0,指向栈顶元素的下一个空位
void InitStack2(SqStack *S) { S->top = 0; }
bool Push2(SqStack *S, int x) {
if (S->top == MaxSize) return false; // 栈满:空位已经跑到数组外
S->data[S->top++] = x; // 后缀 ++:先写入,再移指针
return true;
}
bool Pop2(SqStack *S, int *x) {
if (S->top == 0) return false; // 栈空
*x = S->data[--S->top]; // 前缀 --:先移指针,再取值
return true;
}把两组代码并排看,四个前缀/后缀恰好全部相反——这就是上面"不变量"分析的直接结果。
教材的动态分配写法用两个真指针 base 与 top 加一个 stacksize:
c
#define MAXSIZE 100
typedef struct {
int *base; // 栈底指针,初始化后始终指向栈底
int *top; // 栈顶指针,指向栈顶元素的上一个位置
int stacksize; // 栈可使用的最大容量
} SqStack;
bool InitStack(SqStack *S) {
S->base = (int *)malloc(MAXSIZE * sizeof(int));
if (S->base == NULL) return false; // 分配失败,栈结构不存在
S->top = S->base; // 初值:top 与 base 相等 → 栈空
S->stacksize = MAXSIZE;
return true;
}
bool Push(SqStack *S, int e) {
if (S->top - S->base == S->stacksize) return false; // 栈满
*S->top++ = e; // 先写入,再后移
return true;
}
bool Pop(SqStack *S, int *e) {
if (S->top == S->base) return false; // 栈空
*e = *--S->top; // 先前移,再取值
return true;
}它与本篇的约定乙逐条对应:
| 项 | 下标版(约定乙) | 教材的指针版 |
|---|---|---|
| 栈空 | top == 0 | S.top == S.base |
| 栈满 | top == MaxSize | S.top - S.base == S.stacksize |
| 元素个数 | top | S.top - S.base |
| 栈顶元素 | data[top-1] | *(S.top - 1) |
| 入栈 | data[top++] = x | *S.top++ = e |
| 出栈 | x = data[--top] | e = *--S.top |
"指针差就是下标"——S.top - S.base 与下标版的 top 是同一个量。 看到教材式写法不必重新理解,把 S.top - S.base 在心里换成 top 即可。
两种写法唯一的实质差别是空间来源:教材版用
malloc动态申请,容量可以在运行时决定 (甚至在栈满时realloc扩容);本篇的静态数组版容量在编译期就定死了。 但两者都属于顺序存储、都会栈满,与链栈仍然是两回事。
复杂度与它的空间困境
Push、Pop、GetTop、判空判满、求栈长全部是
和顺序表比一比就更清楚了:顺序表在第
个位置插入要把后面 个元素整体后移,平均 。顺序栈能做到 ,唯一的原因是它把插入删除的位置锁死在了末端——限制换来了效率。这也说明"操作受限"不是缺点,而是设计目的。
代价则是必须事先猜一个 MaxSize:猜大了浪费,猜小了上溢。若程序里要同时用两个栈,各开一个 MaxSize/2 的数组,很可能出现"一个已经满了、另一个还空着一大半"——解法是共享栈;若元素个数完全无法预估,解法是链栈,代价是每个结点多一个指针域、存储密度下降、缓存局部性变差。
考点速记
三条会被反复调用的结论:
- 一切都从"
top指哪儿"推出来,四条结论加前后缀顺序是被约定唯一确定的,别分开背两套。 - 约定甲的栈满是
top == MaxSize - 1,写成MaxSize会先越界再报满。 - 上溢是顺序存储独有的(链栈没有),下溢是逻辑层面的(链栈也有)。
这一节在真题里被考过的形式(下方「真题练习」逐题对应):这里有个反直觉的现象值得说明——挂在这个考点下的真题,考的几乎都不是 top 指针的实现细节,而是出栈序列。也就是说,顺序栈的代码是用来写对的,不是用来考的;真正被反复出题的是栈这个逻辑结构本身的性质:
- 以某个指定元素开头的出栈序列有多少个——固定开头之后,剩下的元素被切成互不干扰的两段,各自再数。
- 栈与队列组合使用时,哪个输出序列得不到——题面给出"出队直接输出/出队入栈/出栈输出"三种操作,本质仍是模拟。
- 关于入栈序列与出栈序列关系的命题判断——比如"两者是否一定不同""能否互为倒序"。
易错:"出栈就把数组里那格清空了"是错的。 出栈只移动
top,旧值原样留着,直到下次入栈把它覆盖。
易错:约定甲入栈写成
data[top++]。 空栈时会写到data[-1],越界。约定甲必须用前缀++top。
易错:判满写成
top == MaxSize。 那是约定乙的条件;约定甲写成这样等于先越界一格才发现满。改约定时,初始化、判空、判满、入栈、出栈五处必须一起改。
教材出处
- 顺序栈的存储结构(
base/top/stacksize三个分量,top初值指向栈底、 非空时指向栈顶元素的上一个位置):严蔚敏《数据结构(C 语言版)》(第 2 版)p58 「3.2.1 顺序栈——栈的顺序存储表示」 - 入栈算法 3.2(判满
S.top - S.base == S.stacksize后*S.top++ = e)与 出栈算法 3.3(判空S.top == S.base后e = *--S.top):同书 p59 - 取栈顶算法 3.4(不修改栈顶指针):同书 p59
教材采用的是"
top指向栈顶元素的上一个位置"(即本篇的约定乙), 所以它的入栈是后缀top++、出栈是前缀--top,与本篇约定乙的代码完全对应。
相关知识
栈和队列的基本概念(ADT 定义与合法出栈序列的判定/计数)| 共享栈(两个栈共享一个数组,栈满改为 top1 + 1 == top2)| 链栈(不存在上溢)| 顺序表(同样用数组,但允许任意位置插入删除)| 括号匹配、表达式求值(本篇代码的直接使用者)