很多学C语言的同学第一次接触“两个堆栈共享一个数组”这个概念,基本都是从PTA的6-7题开始的。题目本身不难,但背后涉及的设计思路——两个栈顶指针如何摆放、栈满栈空条件怎么判断、入栈出栈操作怎么写,才是真正值得琢磨的地方。我当初做这道题的时候,一开始也想不通:明明一个数组,怎么塞下两个栈?后来想通了,其实就是“一个从左边往右长,一个从右边往左长”。这篇文章我把完整思路、代码实现和踩过的坑都整理出来,希望能帮到正在跟这道题较劲的朋友。
1. 两个堆栈为什么能在同一个数组里“相向生长”
1.1 一个看似矛盾的需求:两份数据,一个空间
先把这个题目的场景说清楚。PTA 6-7的要求是:用一个数组去实现两个堆栈,并且要求 Push、Pop 两个操作都能正常工作。简单说,就是给了一块连续的内存空间(数组),要同时给两个栈用。
最直觉的做法是什么?把数组一分为二,前半段给栈1,后半段给栈2。比如数组长度是10,那0到4给栈1,5到9给栈2。这种做法可行,但有一个明显的问题:两个栈的使用量往往是不均衡的。如果栈1的数据特别多,很快就满了,明明栈2那边还有一大半空间空着,却又用不上。反过来也一样。
这和现实生活中分配停车位很像。如果地面画了一条线,左边归A单位,右边归B单位,哪怕A单位车满为患,B单位车位空着一大半,A的车也不能停过去。空间利用率自然就低了。
所以这道题的设计关键,不是“把数组分成两半”,而是“两个栈共享整块数组空间,从两端同时往里长”。这就是标题里“在一个数组中实现两个堆栈”的核心含义。
1.2 相向策略的核心:栈顶指针从两端出发
具体怎么设计?要这样理解:
- 栈1从数组的左端(下标0)开始,向右边生长。
- 栈2从数组的右端(下标MaxSize-1)开始,向左边生长。
- 两个栈的栈顶指针,一个叫 Top1,一个叫 Top2,初始时分别指向数组两端的外侧。
这里有个容易混淆的点。一般的单栈实现,栈顶指针指向“当前栈顶元素的位置”;但在PTA这道题的结构体定义里,Top1 和 Top2 往往被设计成“下一次入栈时要写入的位置”。具体要看题目给出的 Stack 结构体定义。以PTA 6-7常见的定义来说:
c复制typedef int Position;
struct SNode {
ElementType *Data; /* 存储元素的数组 */
Position Top1; /* 栈1的栈顶指针 */
Position Top2; /* 栈2的栈顶指针 */
int MaxSize; /* 堆栈最大容量 */
};
typedef struct SNode *Stack;
这里 Top1 初始为 -1,Top2 初始为 MaxSize。为什么初始值是这样?因为:
Top1 = -1时,执行++Top1得到 0,第一个元素写到Data[0]。Top2 = MaxSize时,执行--Top2得到MaxSize-1,第一个元素写到数组的最后一个位置。- 这样两个栈的初始方向天然错开,一个向右,一个向左。
如果你把初始值设成 0 和 MaxSize-1,那入栈逻辑就要写成“先写再移动指针”,语义就不一致了,也容易跟栈空判断混淆。所以建议严格按照这个约定来。
1.3 边界条件的底层逻辑
不管是入栈还是出栈,都要先判断栈满或栈空。这道题里栈满的条件很特别,和单栈完全不同。
单栈的栈满条件是 Top == MaxSize - 1;但双栈共享数组时,栈满不是看某一个栈顶是否到了数组尽头,而是看两个栈顶是否“相遇”。当 Top1 + 1 == Top2 时,说明中间已经没有空位了,这时候无论哪个栈再入栈,都会失败。
理解这个条件,可以想象成两拨人从一条走廊的两端往中间走,走着走着碰头了,那走廊就站满了,谁也再挤不进去了。
栈空条件也要分别处理:
- 栈1空:
Top1 == -1 - 栈2空:
Top2 == MaxSize
这两个条件千万不要搞混,尤其是栈2的判空,很多初学者会写错成 Top2 == MaxSize - 1。这个差别其实就取决于你的 Top2 初始值和入栈时的移动方式,所以一开始设计时就要把“指针指向哪”想清楚。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 手写实现:从结构体定义到Push/Pop完整代码
2.1 结构体与初始化
这道题PTA通常会提供 Stack CreateStack(int MaxSize) 的接口,要求自己写创建函数。需要注意两件事:
- 要给
Data数组动态分配内存,因为MaxSize是运行时参数。 - 要给
SNode结构体本身分配内存。
经常会有人忘记给结构体指针分配空间,就直接操作 S->Data,导致段错误。完整代码如下:
c复制Stack CreateStack(int MaxSize)
{
Stack S = (Stack)malloc(sizeof(struct SNode));
S->Data = (ElementType *)malloc(MaxSize * sizeof(ElementType));
S->Top1 = -1;
S->Top2 = MaxSize;
S->MaxSize = MaxSize;
return S;
}
Top1 = -1 和 Top2 = MaxSize 这两个初始值的意义我在前面说过,这里再强调一次:它们是判断栈空的基础。如果这两个值设置错了,后面的判断全都会出问题。
2.2 Push操作的两个方向
Push 函数的原型一般是 bool Push(Stack S, ElementType X, int Tag),其中 Tag 用来区分是往哪个栈压入数据,题目通常约定 Tag = 1 表示栈1,Tag = 2 表示栈2。实现时要先判断栈满:
c复制bool Push(Stack S, ElementType X, int Tag)
{
if (S->Top1 + 1 == S->Top2) {
printf("Stack Full\n");
return false;
}
if (Tag == 1) {
S->Data[++(S->Top1)] = X;
} else {
S->Data[--(S->Top2)] = X;
}
return true;
}
这里有个值得注意的写法:S->Data[++(S->Top1)] = X 是先移动指针再赋值,因为 Top1 初始为 -1,先加再写可以保证第一个元素落在下标0。而栈2则是 S->Data[--(S->Top2)] = X,先减再写,保证第一个元素落在 MaxSize-1。
有同学会写成 S->Data[(S->Top1)++] = X,那第一个元素会被写到 Data[-1],直接越界。这个细节看起来小,但在PTA判题里就是内存访问错误。
2.3 Pop操作与错误码约定
Pop 函数原型一般是 ElementType Pop(Stack S, int Tag),出栈时要先判空。题目要求,如果栈空,则输出 Stack Empty 并返回 ERROR,这里的 ERROR 也是一个由题目定义的宏,一般定义为 1e9 之类的异常值。
c复制ElementType Pop(Stack S, int Tag)
{
if (Tag == 1) {
if (S->Top1 == -1) {
printf("Stack Empty\n");
return ERROR;
}
return S->Data[(S->Top1)--];
} else {
if (S->Top2 == S->MaxSize) {
printf("Stack Empty\n");
return ERROR;
}
return S->Data[(S->Top2)++];
}
}
另一个容易出错的细节是:当两个栈都为空时,调用 Pop(S, 1) 和 Pop(S, 2) 都应该各自输出 Stack Empty。有些同学会想着“反正都空了,随便判断一个就行”,这就不对了。因为题目要求的是“对当前栈做判空”,不是“对整体做判空”。两个栈独立工作,判空条件彼此无关。
3. PTA判题规则与容易踩的坑
3.1 输出格式的隐形要求
PTA的题目要求输出 Stack Full 和 Stack Empty 时,字符串必须严格匹配,包括大小写和空格。我第一次提交的时候,把 Stack Full 写成了 Stack full,小写f,结果两个测试点直接没过。后来对着题目输出说明一行一行对照才发现。
另外,注意输出的时机。Push 和 Pop 函数内部在失败时要直接打印输出,而不是返回一个标志让外部去打印。一旦顺序搞错,输出就会和预期错位。
3.2 栈满判断的两种写法
栈满条件写成 S->Top1 + 1 == S->Top2 是最直观的。但也有同学喜欢写成 S->Top1 == S->Top2 - 1,这两种写法等价。不过,如果写成 S->Top1 >= S->Top2 之类的形式,在某些边界情况下就不对了。
举个例子,如果 MaxSize = 2,初始 Top1 = -1,Top2 = 2。此时 Top1 + 1 == 0,不等于2,说明栈不满。
- 往栈1压入一个元素后,
Top1 = 0,Top1 + 1 == 1 != 2,还不满。 - 再往栈1压入一个元素,
Top1 = 1,此时Top1 + 1 == 2 == Top2,栈满。
所以,Top1 + 1 == Top2 这个条件在“数组中只剩一个空位”时就已经成立。也就是说,当两个栈的指针相邻时,中间已经无法容纳任何元素了,这时候就是满。这个判断不受入栈方向影响,两侧都适用。
3.3 我的实测踩坑记录
我第一次实现这道题的时候,犯过一个比较隐蔽的错误:我在 Push 的栈满判断里,只判断了“当前要入栈的那一侧是否还有空位”,比如往栈1入栈时,只判断 Top1 == MaxSize - 1,结果就是栈2明明有大量空间,栈1却报满了。这完全违背了双栈共享空间的初衷。
还有一次,我在 Pop 函数里没有判断 S == NULL,结果测试用例传入空指针时直接段错误。虽然PTA题目里可能不会故意传空指针,但作为一个严谨的封装函数,防御性判断最好加上。
c复制if (S == NULL) {
printf("Stack Empty\n");
return ERROR;
}
这种细节在工程实践里很常见。真实项目中,你写的函数不知道会被谁调用,传参有没有校验,完全不可控。所以从一开始就养成判空习惯,不会吃亏。
4. 从这道题延伸出去的实用价值
4.1 内存池与双缓冲区设计的现实版本
很多人觉得PTA题做完就完了,跟实际开发没关系。其实“两个栈共享一个数组”这个思想,在真实场景里非常常见。
举个嵌入式开发里的例子:设备里只有一块有限的SRAM,需要同时处理发送缓冲区和接收缓冲区。如果给两者各分配固定大小,很可能出现发得多收得少、或者收得多发得少的情况,固定分配总是浪费。这时候就可以用相向增长的共享缓冲方案,让两个缓冲区从两端向中间生长,总体内存利用率能明显提升。实际工程项目里,这种方案在一些协议栈实现中是能找到影子的。
再比如一些状态机或文本编辑器里的“双栈回退”设计,用两个栈共享一块内存来保存历史和未来的操作记录,也是在类似思路上做的优化。所以这道题不是孤立的“考试题”,它本质上是在训练一种“资源受限时如何做空间复用”的思维方式。
4.2 从“栈满检测”看本质:一切堆栈问题都是边界问题
这道题最核心的考点,说白了就是:你自己能不能说清楚栈顶指针在哪个位置、栈的空和满分别由什么条件决定。
做过几道栈的题之后你会发现,不管是单栈、双栈、还是循环队列,核心思路都是盯着指针和边界条件。指针的初始值、移动时机、判断条件三者必须完全统一。比如:
- 如果指针指向当前元素,那判空和入栈逻辑是一套;
- 如果指针指向下一个空闲位置,那判空和入栈逻辑是另一套。
最怕的就是混着用。今天写的是“指向下一个空闲位置”的版本,明天加了个功能又按“指向当前元素”来写,边界直接乱套。这道题之所以被无数C语言初学者反复练习,就是因为它用最小的代码量,把这种边界思维训练得非常扎实。
4.3 面试里的延伸考察
面试官如果看到简历上写了“熟悉数据结构”,有很大概率会追问这题的变化版。比如:如果两个栈不是相向生长,而是都从左边开始,怎么实现?或者,如何用三个栈共享一个数组?再比如,这个双栈结构如何支撑表达式求值中的操作数栈和运算符栈?
这些问题背后的核心逻辑是一致的:你理解了一个数组上多栈共享的本质,就能推导出更复杂场景下的实现。所以建议做题的人不只满足于把代码提交通过,更进一步想一想:如果数组变成链表,这个结构要改哪些部分?如果我要实现栈的扩容,MaxSize 动态增大时,两个栈顶指针应该怎么调整?
这些思考,才是这道题真正的收获。
5. 把这道题变成自己的东西
最后再分享一个我用这道题检验自己是否真正理解的方法:不看任何参考代码,直接在一张白纸上画出 MaxSize = 6 的数组,模拟连续执行这些操作,画出每一步之后 Top1、Top2 和数组内容的变化:
text复制Push(1, 10)
Push(1, 20)
Push(2, 99)
Push(1, 30)
Pop(1)
Pop(2)
Pop(1)
Pop(1)
Pop(2)
画完这张纸,如果你能准确说出哪些步骤能成功、哪些会输出 Stack Full 或 Stack Empty,并且能分析出每一步之后两个指针的具体数值,那这道题的核心知识你就真正吃透了。
我自己带新人的时候,也经常用这道题来做入门测试。因为代码量不大,不管是用C写还是用C++、甚至Java改写,逻辑都不复杂,但能不能写对、能不能一次通过、能不能在满栈和空栈的边界上不出错,很能反映一个人的基本功是否扎实。如果你正在学这一块,建议不要满足于“提交通过”,多想想那几个为什么,收获会大得多。
