用两个栈模拟队列的算法实现与原理分析
本文详解了使用两个栈模拟队列的数据结构实现。通过输入栈负责入队、输出栈负责出队的职责分离设计,结合懒惰转移策略,在输出栈为空时将输入栈元素整体反转压入输出栈。该方案实现了 O(1) 的入队操作和均摊 O(1) 的出队及查看队头操作,空间复杂度为 O(N)。文章深入分析了时间复杂度摊还理论、内存管理策略及与链表队列的对比,并探讨了线程安全与泛型扩展的可能性,是理解数据结构互模拟与适配器模式的经典案例。

本文详解了使用两个栈模拟队列的数据结构实现。通过输入栈负责入队、输出栈负责出队的职责分离设计,结合懒惰转移策略,在输出栈为空时将输入栈元素整体反转压入输出栈。该方案实现了 O(1) 的入队操作和均摊 O(1) 的出队及查看队头操作,空间复杂度为 O(N)。文章深入分析了时间复杂度摊还理论、内存管理策略及与链表队列的对比,并探讨了线程安全与泛型扩展的可能性,是理解数据结构互模拟与适配器模式的经典案例。

传统的队列操作——Push(入队)和 Pop(出队)——要求元素从一端进入,从另一端离开。栈的天然特性使得元素只能从同一端(栈顶)进出。要用栈模拟队列,核心挑战在于如何将栈中天然倒置的元素顺序(LIFO)转化为队列需要的正序(FIFO)。
双栈模型(Two-Stack Model)巧妙地解决了这个问题,它将队列的职责拆分给两个独立的栈:
s1,Push Stack): 专门负责入队操作(Push)。所有新元素都无条件地推入这个栈。它就像一个高效的'原料仓库'。s2,Pop Stack): 专门负责出队操作(Pop/Peek)。它维护了队列的正确顺序,只在需要提供队头元素时才被使用。它就像一个面向客户的'售货柜台'。我们可以将这个模型想象成一个餐厅的点菜与上菜系统:
职责分离的精髓在于:s1永远只做入队,保持 O(1) 的效率;而s2永远只做出队/查看队头,仅在空时才进行昂贵的转移操作。
为了加深理解,我们模拟一个操作序列:push(1), push(2), peek(), pop(), push(3), pop()。
| 操作 | stackIn 状态 (栈底 → 栈顶) | stackOut 状态 (栈底 → 栈顶) | transfer() 动作 | 结果 |
|---|---|---|---|---|
| push(1) | [1] | [] | 否 | - |
| push(2) | [1, 2] | [] | 否 | - |
| peek() | [1, 2] | [] | 是:[1, 2] → [2, 1] | stackOut 变为 [1, 2] (栈顶为 1)。返回 1 |
| pop() | [] | [2, 1] | 否 | 移除并返回 1 |
| push(3) | [3] | [2] | 否 | - |
| pop() | [3] | [2] | 否 | 移除并返回 2 |
关键点:stackOut 只有在为空时才会被重新填充。在 pop 2 之后,队列的队头(如果还有)将位于 stackIn 的 [3] 之后。
双栈实现队列的性能优势,并不在于所有操作都能达到 O(1) 的最优时间复杂度,而在于其**摊还时间复杂度(Amortized Time Complexity)**为 O(1)。
s2为空,且s1中存有 N 个元素时。此时需要执行 N 次 Pop 和 N 次 Push,将所有元素转移到s2,总时间复杂度为 O(N)。s2非空时,直接进行 Pop 或 Peek,时间复杂度为 O(1)。s2取)。在 N 次操作的总成本中,O(N) 的转移成本只发生了一次。因此,平均到每次操作的成本为 O(N)/N = O(1)。这种将高成本分摊到多次低成本操作上的分析方法,正是双栈队列高效的关键。myQueuePush函数详解函数功能: 实现队列的入队操作(Enqueue)。
// [O(1)] 队列入队操作:将元素 x 压入输入栈 s1
void myQueuePush(MyQueue* obj, int x) {
assert(obj);
// 算法步骤:
// 1. 断言检查指针有效性。
// 2. 将新元素 x 直接压入输入栈 s1。
STPush(&(obj->s1), x); // O(1)
}
s1?myQueuePush的操作极为简洁,它不需要检查s2的状态,也不需要执行任何转移操作。这体现了职责分离的设计原则:s1的唯一职责就是接收入队请求,并安全地存储数据。
从队列的FIFO特性来看,新入队的元素 x 必须排在所有现有元素之后。栈s1是 LIFO,将 x 压入栈顶,它自然位于当前所有元素之'上'。当未来需要进行 Pop 操作时,所有元素会被转移到s2,此时 x 将位于s2的栈底,从而保证了其是最晚出队的元素,完美满足 FIFO 要求。
假设底层栈的STPush操作(包括动态扩容的摊还成本)是 O(1) 的,那么myQueuePush函数中只包含一个STPush调用,其时间复杂度为严格的 O(1)。这是该模型性能稳定的基石之一。
myQueuePush操作本身不涉及额外的辅助空间分配,其空间开销仅是存储新元素 x 所需的一个STDataType大小的空间,以及栈底层可能触发的动态扩容操作。整个队列结构的空间复杂度取决于存储的元素总数 N,为 O(N)。
push的对比| 特性 | 双栈队列 (myQueuePush) | 数组队列 (如循环队列) | 链表队列 |
|---|---|---|---|
| 操作步骤 | 只需一次STPush到s1。 | 需要计算尾指针并存储。 | 需要创建新节点并调整尾指针。 |
| 时间复杂度 | 严格 O(1)。 | 严格 O(1)。 | 严格 O(1)。 |
| 内存开销 | 两个栈的动态数组存储。 | 一个固定大小的数组存储。 | 每个元素需额外存储指针。 |
myQueuePeek函数详解函数功能: 获取队头元素(Front),但不移除。
// [摊还 O(1),最坏 O(N)] 队列查看队头元素操作
int myQueuePeek(MyQueue* obj) {
assert(obj);
// 1. 检查输出栈 s2 是否为空。
if (STEmpty(&(obj->s2))) {
// 2. 关键的'懒惰转移'策略:当 s2 为空时,才执行数据转移。
// 转移操作确保了 FIFO 的顺序:s1 的栈底(最早入队)将成为 s2 的栈顶。
while (!STEmpty(&(obj->s1))) {
// a. 取出 s1 栈顶元素
STDataType data = STTop(&(obj->s1)); // O(1)
STPop(&(obj->s1)); // O(1)
// b. 压入 s2
STPush(&(obj->s2), data); // O(1)
}
}
// 3. 返回 s2 的栈顶元素,即当前队头。
return STTop(&(obj->s2)); // O(1)
}
注意:在提供的代码中,转移数据的while循环也可以使用的是int x = STSize(&(obj->s1)); while (x--),虽然写法不同,但逻辑功能是等价的,都是将s1中的所有元素依次弹出并压入s2。
myQueuePeek(以及myQueuePop)的核心是 '懒惰转移'(Lazy Transfer) 策略。
Push时立即执行,也不是在每次Peek或Pop时都执行。它只在不得不执行的时候才触发,即当输出栈s2为空时。s1中的所有元素一次性全部转移到s2。这个策略的优势在于:它避免了不必要的重复转移。只要s2非空,所有的 Peek/Pop 操作都能以 O(1) 的速度进行,直到s2被耗尽。
while循环的精确执行次数分析假设在触发转移时,s1中恰好有 N 个元素。
!STEmpty(&(obj->s1)) 或 x-- 循环 N 次。STTop、一次STPop(从s1)和一次STPush(到s2),都是 O(1) 操作。转移完成后,s1变空,s2中也正好有 N 个元素,且顺序完全反转,队头元素位于s2的栈顶。
我们考虑一个包含 N 次Push和 Peek/Pop混合操作的序列。
在一个完整的生命周期中(从队列为空开始,到再次为空),假设总共 Push 了 K 个元素。这 K 个元素只会从s1转移到s2一次。
摊还成本 ≈ (Push 成本 + 转移成本 + Pop 成本) / 总操作次数 ≈ (O(K) + O(K) + O(K)) / 2K = O(1)
'懒惰转移'是性能优化的关键,因为它确保了每个元素只会被移动两次:一次是 Push 到s1,另一次是从s1转移到s2。尽管转移操作在最坏情况下耗时 O(N),但其成本被平摊到了 N 次 O(1) 的 Pop/Peek 操作上。这种 平滑(amortize) 了峰值延迟的特性,使得双栈队列在实际应用中表现出和原生队列一样高效的平均性能。
myQueuePop函数详解函数功能: 移除并返回队头元素(Dequeue)。
// [摊还 O(1),最坏 O(N)] 队列出队操作
int myQueuePop(MyQueue* obj) {
assert(obj);
// 1. 复用 myQueuePeek 获取队头元素。
// 此操作会确保 s2 非空,并在 s2 为空时触发数据转移。
int ret = myQueuePeek(obj); // 摊还 O(1) 或 最坏 O(N)
// 2. 将 s2 的栈顶元素弹出,完成移除操作。
STPop(&(obj->s2)); // O(1)
// 3. 返回被移除的元素。
return ret;
}
myQueuePeek的巧妙设计myQueuePop的实现极其优雅,它完全复用了myQueuePeek的核心逻辑,避免了代码冗余和逻辑重复。
myQueuePeek(obj): 这一步解决了两个关键问题:
s2为空,它会执行s1到s2的完整转移,保证队头元素已经位于s2的栈顶。STPop(&(obj->s2)): 在确定队头元素已经安全位于s2的栈顶后,只需执行一次 O(1) 的STPop,即可将该元素从队列中移除。assert)代码在myQueuePeek的内部通过assert(!STEmpty(&(obj->s2)))来检查转移完成后s2是否仍然为空,从而间接检查了整个队列是否为空。而在myQueuePop函数开头,虽然没有显式的空检查,但由于myQueuePeek内部的断言(或在STPop的断言中),如果尝试对一个空队列进行 Pop,程序将断言失败(Assert Crash),起到了边界条件保护的作用。
peek函数的协同工作模式Pop和 Peek通过共享s2的状态和懒惰转移机制,形成了一个高效的协同工作模式:
| 函数 | 目标 | 核心逻辑 | 转移触发 |
|---|---|---|---|
Peek | 查看队头 | 确保队头在s2栈顶。 | 是,若s2为空。 |
Pop | 移除队头 | 调用Peek定位,然后STPop移除。 | 是,通过Peek触发。 |
无论调用Peek还是 Pop,只要s2为空,都将引发一次完整的转移。这保证了在需要队头元素时,数据总是处于正确的位置。
myQueueEmpty函数详解函数功能: 判断队列是否为空。
// [O(1)] 队列判空操作
bool myQueueEmpty(MyQueue* obj) {
// 算法步骤:
// 1. 当且仅当输入栈 s1 和输出栈 s2 同时为空时,队列才为空。
return (STEmpty(&(obj->s1)) && STEmpty(&(obj->s2))); // O(1)
}
队列中的所有元素要么在输入栈s1中(待处理),要么在输出栈s2中(待取出)。因此,一个队列为空的充要条件是两个存储位置都必须是空的。
s1为空,s2非空。队列非空,元素在s2中等待出队。s1非空,s2为空。队列非空,元素在s1中等待转移。s1非空,s2非空。队列非空,元素分散在两个栈中。只有当两个栈都返回true时(s1空 且s2空),myQueueEmpty才返回true,判断逻辑是精确且完备的。
myQueueEmpty函数只包含两个 O(1) 的STEmpty调用和一个逻辑与操作,因此其时间复杂度为严格的O(1)。它不依赖于队列的大小 N,是一个非常高效的操作。
size函数的内在联系如果实现了一个myQueueSize函数(代码中已提供),它将返回 STSize(&(obj->s1)) + STSize(&(obj->s2))。那么myQueueEmpty实际上等价于 myQueueSize(obj) == 0。两者都依赖于对两个底层栈状态的查询。
内存管理是 C 语言编程中不可或缺的一环,对于动态数据结构尤为重要。
myQueueCreate的初始化策略功能: 为MyQueue结构体分配内存并初始化其内部的两个栈。
MyQueue* myQueueCreate() {
MyQueue* obj = (MyQueue*)malloc(sizeof(MyQueue));
// 1. 分配 MyQueue 结构体空间
if (obj == NULL) {
perror("malloc");
exit(-1);
}
STInit(&(obj->s1)); // 2. 初始化 s1 (Input Stack)
STInit(&(obj->s2)); // 3. 初始化 s2 (Output Stack)
return obj; // 4. 返回指针
}
// 复杂度:O(1)
malloc为上层的MyQueue结构体分配内存。STInit来初始化s1和s2。STInit负责将栈的内部状态(如a指针设为NULL,capacity和top设为 0)置为安全初始状态。这种自底向上的初始化策略(先初始化内嵌结构,再返回外部结构)保证了新创建的队列对象是一个完全可用的、空队列。
myQueueFree的销毁顺序重要性功能: 释放MyQueue占用的所有动态内存。
void myQueueFree(MyQueue* obj) {
STDestroy(&(obj->s1)); // 1. 销毁 s1 占用的动态数组空间
STDestroy(&(obj->s2)); // 2. 销毁 s2 占用的动态数组空间
free(obj); // 3. 释放 MyQueue 结构体本身的堆空间
}
// 复杂度:O(1) (忽略底层栈的内存释放成本)
销毁顺序的重要性:
STDestroy来释放s1和s2内部指向的动态数组内存(free(pst->a)),这是存储实际数据的地方。free(obj)来释放MyQueue结构体本身在堆上占用的内存。如果顺序颠倒,先释放了obj,那么将无法通过obj->s1和obj->s2访问到两个栈,导致栈内部的动态数组内存泄漏。这种自上而下的释放顺序保证了资源释放的完整性。
通过实现'用栈实现队列'和前一篇的'用队列实现栈',我们完成了数据结构互模拟系列中的两个经典问题。
| 对比维度 | 用两个队列实现栈 | 用两个栈实现队列 |
|---|---|---|
| 模拟目标 | FIFO → LIFO | LIFO → FIFO |
| 核心结构 | 两个队列 (q1, q2) | 两个栈 (stackIn, stackOut) |
| 关键策略 | pop时,将 n − 1 个元素转移到辅助队列,留最后一个。 | pop/peek时,若stackOut空,则一次性反转搬运stackIn所有元素。 |
| 时间复杂度 | push O(1), pop O(n)(或反之) | push O(1), pop/peek 均摊 O(1) |
| 思想核心 | 元素筛选/单次转移(暴露最后进入的元素) | 顺序反转/懒惰加载(将 LIFO 转化为 FIFO) |
这两道题目都体现了计算机科学中的 适配器设计模式(Adapter Pattern) 思想。我们没有直接修改栈或队列的底层实现,而是将一个或多个既有组件(栈)封装起来,对外提供一个全新的接口(队列)。
通过以上对比,我们可以发现,这两道题就像一对'镜像问题',共同揭示了数据结构抽象的强大魅力。它们都要求我们绕过数据结构的直接行为,通过对其基本操作的组合与调度,来构建新的语义。这种对抽象能力的锻炼,对于未来理解和应用更复杂的设计模式至关重要。
| 函数 | 最佳情况(s2 非空) | 最坏情况(需要转移) | 摊还情况(连续操作序列) |
|---|---|---|---|
myQueuePush | O(1) | O(1) | O(1) |
myQueuePeek | O(1) | O(N) (当 s2 为空,且 s1 有 N 个元素时) | O(1) |
myQueuePop | O(1) | O(N) (同Peek) | O(1) |
myQueueEmpty | O(1) | O(1) | O(1) |
myQueueCreate/Free | O(1) | O(1) | O(1) |
摊还分析的数学之美: 正如第二部分所述,双栈队列的精妙之处在于将 O(N) 的高峰成本摊还成了 O(1) 的平均成本。这是一种非常重要的算法设计思想,它允许我们在局部牺牲效率,以换取全局的效率保证。
s1和s2的动态数组中。由于s1和s2的总容量会根据元素数量 N 进行动态调整,因此存储 N 个元素的总空间复杂度为O(N)。MyQueue结构体只包含两个ST结构体,它们存储了指针、容量和栈顶索引等少数元数据,这部分开销是 O(1)。| 特性 | 双栈队列(基于数组栈) | 链表队列 |
|---|---|---|
| Push | O(1) | O(1) |
| Pop | 摊还 O(1) | O(1) |
| 空间效率 | 数组存储紧凑,无额外指针开销,空间效率高。 | 每个节点需额外存储一个或两个指针,空间开销略大。 |
| 局部性 | 数组存储,数据访问具有更好的缓存局部性(Cache Locality)。 | 链表节点分散在内存中,缓存局部性较差。 |
因此,基于双栈和动态数组实现的队列,在空间效率和缓存性能方面通常优于传统的链表实现。
队列作为基础数据结构,其 FIFO 特性决定了它在需要保持顺序的场景中不可替代。双栈实现提供了一种灵活的替代方案:
在多线程环境中,多个线程可能同时调用myQueuePush和myQueuePop,这将导致竞争条件(Race Condition)。
myQueuePush中,在STPush前后加锁和解锁。myQueuePeek和myQueuePop中,在执行懒惰转移操作的整个临界区(if (STEmpty(&(obj->s2))) { ... 转移代码 ... })加锁。STTop)或移除队头(STPop)时也需保持锁,以确保操作的原子性。C 语言不支持原生泛型。但可以通过以下方式实现:
void* 指针: 将STDataType定义为void*,让栈存储数据的指针。用户在 Push 时需要将数据指针传递给队列,Pop 时获取到指针,并自行进行类型转换和解引用。双向队列(Deque) 允许在两端进行 Push 和 Pop。
双栈实现队列是一种将 LIFO 结构巧妙地转化为 FIFO 结构的高级应用。它不仅是算法思想的体现,更是工程实践中对效率和空间的一种权衡。
通过职责分离的设计哲学和懒惰转移的优化策略,我们实现了:
这种设计模式提醒我们,在面对数据结构转换问题时,不必拘泥于单一结构的直接操作,而可以通过组合和精妙的转移策略,实现功能上的完美模拟和性能上的高效保证。理解其背后的摊还分析原理,是掌握工业级算法设计的关键。
附录:常见面试问题解析
Push时不能直接转移数据?
Push都将s1中的数据转移到s2再加新元素,Push操作将退化为 O(N),且每次Push都会打乱s2中的正确顺序,导致算法失败。s1的所有元素?
Pop时s1的队头仍然在s1的底部,无法取出,且转移操作会反复进行,效率极低。一次性转移保证了 s1 中最底部的元素(最早入队)被放到 s2 的顶部,等待连续 N 次的 O(1) Pop。realloc而不是malloc,底层栈的实现会有什么变化?
realloc,STPush在扩容时会更高效且更安全:它能原地扩展内存或在新地址分配空间并自动拷贝数据,避免了手动拷贝数据的开销和潜在错误。这将使底层栈的Push操作(包括扩容)在摊还意义上更稳定地保持 O(1)。
微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online