资讯动态

栈(Stack):从数据结构基础到工程实践的完整解析

发布时间:2026/10/16 3:28:44 来源:尧图企业网站定制
总有人说数据结构是“面试专用”学完就忘。但你看任何一个合格的程序员写的第一段像样的代码里多半都有栈。函数调用要靠它表达式求值要靠它浏览器的回退要靠它就连游戏里的“技能连招”撤销逻辑也离不开它。栈Stack这个线性结构是计算机世界里最朴素的“后进先出”规则也是从初级写代码到理解操作系统原理绕不过去的一块基石。这篇内容适合正在学数据结构的学生、刚开始刷题准备面试的开发者以及那些“用栈写了代码但没想过为什么”的同行。我会从核心概念、手写实现、经典应用、进阶拓展到踩坑经验用最接近实战场的方式把栈讲透。不是教科书式的罗列定义而是你真正写代码时用得上的那些细节。1. 从“后进先出”说起栈的本质与设计思路1.1 栈的定义为什么它是线性结构栈的本质不复杂一个只允许在一端进行插入和删除操作的线性表。插入操作叫push压栈删除操作叫pop弹栈能操作的那一端叫栈顶另一端叫栈底。你在栈顶放了一个新元素想拿到早先进去的元素就必须先把上面所有元素移走。这为什么算“线性结构”因为栈里的元素在逻辑上是排成一排的每个元素只有一个前驱和一个后继栈顶的没后继栈底的没前驱。它和数组、链表一样处理的都是“线性序列”只是它的操作被限制了——你没法从中间访问也没法从底部弹出。正是这个“限制”反而成了它高效的原因。我刚学数据结构时一直有个误区觉得栈就是数组套个壳没什么技术含量。后来写递归、做表达式解析才意识到栈是一种思考方式当我们只关心“最近的状态”时栈就是最自然的模型。用一个数组模拟栈很容易但能不能抽象出“后进先出”这个逻辑才是关键。1.2 栈与数组、链表的区别三个核心操作决定一切push压栈把新元素放到栈顶同时调整栈顶指针。pop弹栈取出并删除当前栈顶元素。peek/top访问栈顶只读栈顶元素不删除。就这三个操作。数组能随时访问任意下标灵活得多链表也能从任意位置插入删除。但栈刻意放弃了这些能力换来了两个收益实现简单和操作时间复杂度恒定为 O(1)。你不用考虑“中间插入会不会破坏顺序”你想的就是“刚放进去的先拿出来”。你可以把栈想象成一个弹簧弹夹先压进去的子弹在弹夹底部后压进去的反而先被顶出去。程序里需要这种“后进先出”的场景遍地都是后面第 3 节我会详细展开。1.3 生活中的栈从弹夹到浏览器的回退键浏览器回退你访问了 A - B - C点回退先回到 B再回到 A这是典型的栈行为。如果你在 C 页面跳到了新链接 D那 B、C 的历史就“被覆盖”了因为浏览器会把回退栈里当前页之后的记录全部清掉。编辑器撤销每按一次撤销撤销栈弹出一个操作新做一步操作就会压入撤销栈。所以“撤销之后再做新操作原来的重做记录就没了”本质上就是栈顶覆盖。函数调用主函数调函数甲甲调乙乙执行完先返回给甲甲再返回给主函数。这个“谁最后调用谁最先返回”的顺序也是栈。这些场景有一个共同点执行顺序天然是嵌套的后进入的任务必须先退出不然外层任务没法继续。把握住这一点你再碰到一个新需求时就能敏锐地判断“这地方该不该用栈”。2. 手写一个栈关键实现细节与实操要点2.1 数组栈实现简单但要注意扩容策略数组栈最容易理解一块连续的内存 一个栈顶指针。空栈时指针指向 -1每次压栈先top再给数组赋值。#define INIT_CAPACITY 4 typedef struct { int *data; int size; // 当前元素个数 int capacity; // 当前容量 } Stack; Stack *stack_create() { Stack *s (Stack *)malloc(sizeof(Stack)); s-data (int *)malloc(sizeof(int) * INIT_CAPACITY); s-size 0; s-capacity INIT_CAPACITY; return s; } void stack_push(Stack *s, int val) { if (s-size s-capacity) { int new_capacity s-capacity * 2; s-data (int *)realloc(s-data, sizeof(int) * new_capacity); s-capacity new_capacity; } s-data[s-size] val; } int stack_pop(Stack *s) { if (s-size 0) { printf(stack underflow\n); exit(1); } return s-data[--s-size]; } int stack_top(Stack *s) { if (s-size 0) { printf(stack empty\n); exit(1); } return s-data[s-size - 1]; } int stack_is_empty(Stack *s) { return s-size 0; }这里最容易踩的坑是扩容时机。如果你每次 push 都执行 malloc/realloc数据量一大性能就崩了。正确做法是成倍扩容容量不够时翻倍这样平均下来每次 push 的复杂度仍然是 O(1)。那为什么扩容倍数不能随便定扩容倍数太小比如 1.25 倍扩容频繁数据搬移量大浪费性能。扩容倍数太大比如 4 倍内存一次性申请过多空闲内存被白白占用有可能造成内存碎片。2 倍是比较普遍的选择既有足够的内存余量也能让均摊成本可控。Python 的list底层也是动态数组但它不是简单的翻倍而是混合了特定增长策略核心思想一致分摊成本低、空间浪费少。写代码时你完全可以参考这个思路。2.2 链表栈内存分配灵活但要注意节点复用数组栈的缺点是容量固定就算扩容也可能导致原有数据被整体搬移。链表栈则是每次 push 时动态分配一个节点天然解决容量问题typedef struct Node { int data; struct Node *next; } Node; typedef struct { Node *top; } Stack; void stack_push(Stack *s, int val) { Node *new_node (Node *)malloc(sizeof(Node)); new_node-data val; new_node-next s-top; s-top new_node; } int stack_pop(Stack *s) { if (s-top NULL) { printf(stack empty\n); exit(1); } Node *tmp s-top; int val tmp-data; s-top tmp-next; free(tmp); return val; }链表栈的好处是没有容量上限坏处是每个节点都额外占一份指针内存。如果你存的是小整数链表栈的内存开销往往是数组栈的两倍以上。所以在存储大数据、或内存紧张的情况下优先考虑数组栈如果你明确知道数据规模不确定且增长幅度大链表栈更稳。我自己的习惯是优先数组栈因为 CPU 缓存在连续内存上命中率高链表节点散落在堆里频繁 malloc/free 还有碎片问题。只有在递归深度不确定、栈大小可能暴涨的场景我才换链表栈。2.3 动态扩容与缩容平衡时间和空间的取舍数组栈扩容到两倍缩小到多少合适通常采用“缩一半”策略当size capacity / 4时把容量缩小到原来的一半。不能缩到capacity / 2否则会出现“扩容-缩容-扩容”的反复抖动你恰好压一个元素到临界点扩容弹一次又触发缩容这种极端情况下均摊成本反而变高。具体来说如果size capacity / 2就缩容那 push 到扩容临界点后一次 pop 就可能触发缩容再一次 push 又扩容形成震荡。如果用size capacity / 4作为缩容触发点那从扩容后满容量被弹出 3/4 的空间才会缩容中间有一段缓冲区间避免了频繁分配。这个“缓冲区间”的思路不只适用于栈也适用于你用数组实现队列、哈希表等所有需要动态扩容的结构。最典型的反例是初学 C 时用vector觉得reserve是多余的做大量 push 的时候vector 反复申请内存搬元素性能肉眼可见地卡顿。预估规模 预留空间 按需缩容这三步是对付动态数组成本问题的标准答案。2.4 不同语言的栈实现对比语言常见实现特点与坑点C手写数组栈 / 链表栈完全可控但要自己处理扩容、判空、内存释放Cstd::stack默认基于 deque 实现容器适配器没有迭代器不能直接遍历Javajava.util.ArrayDeque官方文档建议用ArrayDeque优先于Stack类Stack是线程安全的代价是性能开销Pythonlist直接当栈用最方便但不要频繁切片或 insert(0)那是灾难Go标准库没有栈手写 sliceslice 动态扩容是翻倍策略append返回新 slice 的头要注意之前有同事用 Java 写代码遇到“栈”下意识写了Stack后来调性能才发现Stack继承自Vector每个方法都有 synchronized 锁。换ArrayDeque后耗时降了一截。这不是说Stack不能用而是你要知道自己的选择背后付出了什么代价。3. 栈的经典应用场景从表达式求值到系统底层3.1 括号匹配编译器为什么能检查出你的语法错误括号匹配是栈的入门级应用。规则很简单遇到左括号(、[、{就压栈遇到右括号就弹栈检查是否匹配。def is_valid_brackets(s: str) - bool: mapping {): (, ]: [, }: {} stack [] for ch in s: if ch in mapping.values(): stack.append(ch) elif ch in mapping: if not stack or stack.pop() ! mapping[ch]: return False return not stack编译器检查语法时就是把整个源代码扫一遍遇到一个右括号就看看栈顶是不是对应的左括号。[(])这种交叉嵌套在扫描到]时就会直接报错因为栈顶是(而不是[。你可能会问为什么括号匹配天然适合栈因为括号的嵌套结构本质上是后出现的左括号必须先被匹配。这和函数调用的嵌套返回逻辑一模一样。你想用优先级数组处理这个问题也可以但那既别扭又容易漏栈就是为嵌套结构量身定做的。3.2 表达式求值中缀转后缀是怎么做到的算一个表达式1 2 * 3如果你从左到右扫直接加起来等于 9显然错了。人的解法是看优先级和括号但计算机怎么做答案是中缀表达式转后缀表达式逆波兰表达式再用栈求值。例如1 2 * 3转后缀为1 2 3 * 。求值时遇到数字压栈。遇到运算符弹出两个操作数计算结果再压栈。整个过程不需要管优先级了因为后缀表达式已经把“谁先算”的顺序通过排列固定好了。具体转换算法是扫描中缀表达式数字直接输出运算符与栈顶比较优先级低于或等于就弹栈最后把剩余运算符全部弹出。我在某个项目里踩过一个坑用这种算法做带小数的公式计算远程传输时把浮点数精度误差直接展示给了用户结果出现“0.1 0.2 0.30000000000000004”这种尴尬。后来加了一层保留小数位的处理才把数字定下来。不要把“栈的应用”当成纯理论实际写计算器、写 SQL 解析器、写模板引擎时都会遇到这种“优先级 括号 嵌套”的组合问题。3.3 函数调用栈递归为什么会爆栈每次函数调用系统会把返回地址、局部变量、参数值压入调用栈函数返回时再把它们弹出。递归函数如果深度太深调用栈会一直增长。系统分配给你的调用栈空间是有限制比如 Linux 默认 8MB可通过ulimit -s查看/调整超过限制就触发栈溢出也就是我们常说的“爆栈”。def factorial(n): # 递归写法 if n 1: return 1 return n * factorial(n - 1)factorial(10000)在某些环境下会直接报 RecursionError。这不是逻辑写错而是每层递归都吃了一点栈空间1 万层已经把默认阈值撑爆了。避免爆栈的思路有几种把递归改成循环比如用显式栈模拟递归过程。增加递归深度限制如 Python 里sys.setrecursionlimit(100000)但只能缓解不能根治。换成尾递归优化但很多语言不支持。我在做深度优先遍历一棵超深的树时递归写法直接段错误。改成显式栈之后一切正常了——代码看起来不如递归优雅但能够在有限内存下稳定运行。在一些讲究“内存安全”的场景里迭代版本比递归版本可靠得多。3.4 浏览器的回退与撤销功能栈在 UI 里的身影浏览器回退按钮和编辑器撤销按钮背后都是两个栈一个叫历史栈一个叫未来栈重做栈。访问新页面压入历史栈。点击回退从历史栈弹出当前页并压入未来栈。如果此时访问新页面清空未来栈。编辑器撤销类似每次编辑操作压入撤销栈撤销时弹出该操作并压入重做栈重新做一步新操作时清空重做栈因为你已经在“当前状态”上分叉了。这个“清空重做栈”的细节很多第一次实现撤销功能的人都会漏掉。我当时做某个图像处理工具用户撤销两次后再画一笔重做历史完全消失了用户一脸懵。原因很简单历史已经分支原来的“重做路径”就不能再作为合法路径了。4. 进阶玩法单调栈与双栈的实践心得4.1 单调栈用 O(n) 解决“下一个更大元素”系列问题单调栈就是栈内元素保持单调递增或递减。它特别擅长解决“找下一个更大/更小元素”这类问题。典型题目“给定一个数组返回每个元素右边第一个比它大的元素没有则为 -1”。def next_greater(nums): n len(nums) result [-1] * n stack [] # 存下标 for i in range(n): while stack and nums[i] nums[stack[-1]]: idx stack.pop() result[idx] nums[i] stack.append(i) return result这段代码的精髓在于当前元素 nums[i] 就是栈顶元素的“下一个更大元素”。栈里维护的是“还没找到答案的下标序列”而且这些下标对应的值是单调递减的。一旦遇到更大的值就能把前面所有“等待答案”的下标一次出栈。单调栈的价值在于它用空间换时间最坏情况下每个元素最多入栈一次、出栈一次整体 O(n)。而两层暴力循环是 O(n²)。我在刷题时第一次看到这个写法也有“就这么几行”的错觉但把它用在实际场景里——比如实时监控的异常值预测或 K 线图中寻找“右侧更高点”——才能体会到它的厉害。4.2 双栈共享数组空间利用率的优化双栈共享数组指的是用一个数组实现两个栈一个从数组左端向右增长另一个从数组右端向左增长。只有两个栈的“栈顶指针”相遇时才表示空间耗尽。这样做的好处是如果一个栈空着另一个栈可以一直增长直到整体数组被填满。从空间利用率来看比开两个独立数组更优。但代价是两个栈不是独立隔离的一个栈增长过快可能挤占另一个栈的空间。你需要在 push 时检查两个栈顶是否相遇。实际工程中双栈共享数组用得不算多因为现代内存很充足没必要为这点空间增加这么多复杂度。但它在嵌入式、单片机这种内存受限场景里非常有用。前几年做一个小型设备内存只有几 KB把两个方向相反的栈塞进同一块静态数组里省了不少内存设备总算跑了起来。4.3 栈与 DFS深度优先搜索的迭代写法很多初学者学深度优先搜索DFS时只知道递归。递归确实天然对应 DFS——系统帮你维护了递归调用栈。但当你不想爆栈或想显式控制遍历过程时就需要手动维护一个栈。以二叉树前序遍历为例def preorder_traversal(root): if not root: return [] stack, result [root], [] while stack: node stack.pop() result.append(node.val) if node.right: stack.append(node.right) if node.left: stack.append(node.left) return result注意入栈顺序先压右子树再压左子树。因为栈是后进先出你希望先访问左子树就必须让左子树后入栈、先出栈。手动栈版本的优点是无须担心递归深度限制而且可以随时中断和恢复把栈存起来下次继续。做超大规模图的遍历、实现某个会暂停又恢复的 AI 搜索流程时这种“可恢复的显式栈”比递归灵活得多。5. 常见问题与排查技巧实录5.1 空栈操作Top 和 Pop 前的判空对空栈执行 pop 或 peek属于逻辑错误。C 语言里不判断直接访问s-data[s-size - 1]可能读到脏数据甚至访问非法地址崩溃Python 里对空 list 调用pop()直接抛出IndexError。我的习惯是在自定义栈的所有操作里统一入口判空失败时打印清晰的错误信息并中断而不是返回什么“特殊值”。因为特殊值很容易掩盖问题pop返回-1上层还以为拿到正常数据排查时浪费半天。宁可崩溃得响亮也不要“安安静静出错”。5.2 栈溢出可能是死循环也可能是容量不足程序报“栈溢出”不一定代表递归深度大。更常见的原因是压栈操作没有判满或某个循环不停地 push 但没有 pop。排查栈溢出的典型思路先看是不是递归调用没有终止条件比如没写好 base case。再查循环里 push 的速度是不是远大于 pop 的速度。检查是否在错误的位置调用函数导致调用栈不断累积。还有一类坑是对象或结构体太大压栈时按值复制了一整个大对象而不是指针或引用。我见过有人把一个大结构体元素整体 push 进栈每 push 一次吞掉几百字节很快容量就爆了。遇到这种情况栈里保存指针或者下标对象本体放在堆里管理是更合理的做法。5.3 需要“栈”却用“队列”的典型错误栈是后进先出LIFO队列是先进先出FIFO。这俩一旦搞混代码逻辑会“看起来正常”但运行结果就是不对。典型例子实现“近期最少使用缓存”时如果误用栈来保存访问顺序缓存淘汰逻辑就会错判因为栈永远优先淘汰最近访问的而不是最久未使用的。这种 bug 很隐蔽通常要到并发量上去才暴露出来。我在实际项目中比较稳妥的做法是写代码前先用一句话描述数据顺序需求。比如“撤销需要最近操作的先恢复”这句话直接指向栈“消息队列需要最早发送的最先被处理”这句话直接指向队列。顺序描述清楚了选型很难出错。5.4 实战排查清单从现象到根因现象可能原因排查方向程序运行一段时间后内存暴涨栈里积压了大量未 pop 的数据检查 push 与 pop 是否成对出现确认空栈是否被正确清理pop 得到脏数据未判空就访问栈顶给栈操作统一加判空检查印日志看当前 size递归调用一深就崩溃递归深度超限或栈帧过大改用显式栈替代递归或者调大调用栈空间数据整体一致但顺序颠倒应该用队列却用了栈重新梳理“后进先出/先进先出”的场景需求频繁 push 后性能明显下降扩容策略不够好改成成倍扩容预先预估规模并预留容量排查栈问题时我最常用的一招是加一个计数器记录所有 push 和 pop 次数每隔 1000 次打一次差值。差值持续增长说明栈在泄漏差值稳定或归零说明栈的健康状态没问题。这个方法在做实时监控系统时很管用因为线上环境没法一步步断点调试。我的个人建议是栈这个结构一定要亲手实现至少一遍数组版本和链表版本各写一遍。写的过程中你会自然理解为什么调用栈会溢出、为什么在某些地方只访问栈顶就够了、为什么有时候递归会慢。之后在工程里用现成库也不至于稀里糊涂。如果你现在还分不清什么时候该用栈、什么时候该用队列直接找几个真实场景想一想最近的任务先做还是最早的任务先做想清楚这一点你就真的懂栈了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑