说实话,看到“栈的使用(push、pop详解)”这个标题时,我心里有一瞬间的恍惚。因为很多人在学习数据结构时,栈往往是“最没存在感”的一个:没有树的递归美学,没有图的路径复杂,连链表都比它有“结构感”。但真正写代码写到深处,你会发现,凡是牵扯到“暂时寄存状态、回头再处理”的场景,底层几乎都是栈。
函数调用是靠栈完成的,编辑器撤销是靠栈记录的,浏览器后退按钮是拿栈维护的,递归能运行不爆掉也是因为系统帮你建了一个调用栈。甚至你在LeetCode刷“接雨水”“每日温度”这类经典题时,最后也会发现它们都指向同一个东西:单调栈。所以这篇文章我不打算只讲“栈是什么”,而是把 push、pop 这两个最基本操作的内部过程和实战细节彻底拆开,顺带把数组栈和链式栈的实现、常见坑和典型应用聊透。无论是第一次接触栈的新手,还是准备面试前想系统补一遍基础的同学,都可以直接拿这篇当学习笔记用。
1. 为什么是“后进先出”:栈模型从哪儿来
1.1 现实中到处是栈:一叠盘子和浏览器后退按钮
栈这个抽象,其实不是计算机科学家凭空发明的。你回想一下食堂里的一叠餐盘:新洗好的盘子会叠在最上面,用的时候也是先拿最上面的那个。后放上去的先被拿走,这就是“后进先出(Last In First Out,LIFO)”。
再看浏览器的后退按钮。你有过这样的经历吗:打开A页面,点链接跳到B,再点跳到C,然后一直点“后退”,页面会从C退到B,再退到A,绝不会从C直接跳到A。为什么?因为浏览器把访问过的页面按照顺序压进了栈里,后退按钮执行的就是pop操作,每次只弹最上层那一个。
我第一次带学生做前端项目时,有人问过一个问题:“为什么浏览器不干脆记录一个列表,然后直接跳到最开头那一页?”答案是:如果那样做,你会失去访问路径的层次感。栈记录的是状态上下文,而不是单纯的历史。后进先出意味着“当前正在处理的内容永远在最上面”,这恰好是计算机执行任务时的真实语义。
所以栈不是一种强制你记忆的抽象概念,它是对生活里“一层层叠放、一层层取回”这类行为的精确建模。理解这一点,后面所有操作和术语都是顺理成章的。
1.2 LIFO的严格定义:受限线性表
从数据结构的角度看,栈是一种“操作受限的线性表”。线性表大家都熟,就是一串元素排在一起,有先后顺序。但普通线性表(比如数组、链表)可以在任意位置插入和删除,栈不行,它只允许在同一端——称为“栈顶(top)”——进行插入和删除。
另一端叫“栈底(bottom)”。栈底的特征是:最早进来的元素压在最底下,想要动它,必须先把上面所有元素全部弹出。栈本身不关心栈底之外的任何位置,不允许你从中间抽走一个元素。
这个限制看似让栈变弱了,实际上却让它变得极度可靠。因为限制意味着只有一种访问方式,所有操作都在栈顶发生,配合push和pop两个动作,行为就变得非常容易预测。系统底层最需要的就是这种“可预测性”。
这里顺便提一个高频混淆点:热搜词里的“先进后出”和“后进先出”,说的是同一件事。先进后出强调结果,后进先出强调规则,二者完全等价。你只需要记住,栈顶永远是下一次操作的对象。
1.3 栈与队列:一次对比搞清楚两兄弟
学数据结构时,栈和队列经常被放在一起讲,很多人最后记混了。我的经验是不要对比着背,而是对比着用。
| 对比维度 | 栈(Stack) | 队列(Queue) |
|---|---|---|
| 基本原则 | 后进先出(LIFO) | 先进先出(FIFO) |
| 插入操作 | push(入栈) | offer/enqueue(入队) |
| 删除操作 | pop(出栈) | poll/dequeue(出队) |
| 访问位置 | 只在栈顶操作 | 队尾入、队头出 |
| 生活类比 | 叠盘子、撤销记录 | 排一条队买奶茶 |
我说一个自己的体验:如果把溢出场景放在内存解析里,比如你要检查一段代码的括号是否匹配,用栈是天然合理的;如果你要按到达顺序处理一批请求,比如打印机任务,用队列才是对的。判断到底该用哪个,不要看“谁的名字熟”,而是看“业务上是不是需要后处理优先”。后处理优先是栈,先来先服务是队列。这个判断标准,比记任何书面定义都管用。
2. push(入栈)操作拆解:数据是怎么进栈的
2.1 push的语义与两个前置条件
push,中文叫“入栈”或“压栈”,作用是把一个新元素放到栈顶。这个动作看似只有一行,但要真正理解它,必须拆成两个子问题:
第一,新元素放在哪里?答案永远是栈顶。无论栈里已经有多少元素,新元素一律压在栈顶上面。
第二,原来的栈顶变成什么?原来的栈顶往下退一层,不再是栈顶,新元素取而代之。
用一句话概括push:在栈顶位置插入元素,并将栈顶指针/索引更新到新元素上。
在实现层面,push必须满足一个前置条件:栈本身有空间容纳新元素。这个条件听起来多余,但恰恰是很多线上bug的源头。数组实现的栈,如果初始容量设小了,又没有扩容逻辑,push到一定数量就直接数组越界;链表实现的栈如果节点申请内存失败,也可能直接抛出异常。
2.2 数组栈的push完整流程:从下标移动到扩容
以数组作为底层存储的栈,称为顺序栈。它用一块连续内存和一个top变量来维护栈顶位置。我用Java里最朴素的方式给你拆一下:
public class ArrayStack { private int[] data; private int top; // 指向栈顶元素的下标,-1表示空栈 private int capacity; public ArrayStack(int capacity) { this.capacity = capacity; this.data = new int[capacity]; this.top = -1; } public void push(int value) { if (top + 1 >= capacity) { resize(); // 扩容 } top++; data[top] = value; } private void resize() { capacity *= 2; int[] newData = new int[capacity]; System.arraycopy(data, 0, newData, 0, data.length); data = newData; } public int pop() { if (top < 0) { throw new IllegalStateException("栈为空,无法出栈"); } int value = data[top]; top--; return value; } }注意几个细节:
- top初始值是-1,不是0。这是老手和新手的标志性区别。top=-1表示空栈,push时先top++再赋值,正好把第一个元素放在data[0]。如果你初始化top=0,push逻辑就要变成“先赋值再top++”,两种方式都行,但必须保持一致。
- push里先检查容量,再移动top。顺序不能反过来。如果你先top++,再去扩容,万一扩容失败或抛出异常,栈的状态就被污染了。
- 扩容倍数我习惯取2,也可以用1.5。扩容会先创建一个更大的数组,再把旧数据复制过去,最后替换引用。这里存在一次O(n)的复制成本,但均摊到每一次push上,代价就很低。
2.3 链式栈的push:头插法为什么是天然匹配
如果底层用链表,push其实就是头插法。因为链表的头部天然就是栈顶,新节点只需要插到head前面,并更新head,就能做到“新元素变栈顶”,不需要遍历链表。
class Node: def __init__(self, value): self.value = value self.next = None class LinkedStack: def __init__(self): self.head = None self.size = 0 def push(self, value): node = Node(value) node.next = self.head self.head = node self.size += 1 def pop(self): if self.head is None: raise IndexError("pop from empty stack") value = self.head.value self.head = self.head.next self.size -= 1 return value这段代码里,push是O(1)的,因为不管栈里有多少元素,只需要改两个指针引用,没有遍历。我见过一些初学者用链表实现栈时会把新节点加到尾部,然后维护一个tail指针,pop时从尾部删。这当然也能实现,但尾部删节点必须知道前驱节点,要么维护双向链表,要么遍历到倒数第二个节点,凭空多了复杂度。头插法才是链式栈的标准姿势,因为它和“栈顶”的定义完全一致。
2.4 push时间复杂度:O(1) 和均摊O(1)的区别
很多面试题会问:栈的push时间复杂度是O(1)吗?标准答案分两种情况:
- 链式栈:严格O(1)。每次push只做固定次数的指针操作,与栈大小无关。
- 数组栈:单次push看情况。当数组容量足够时,是一次数组赋值,O(1)。当容量不够时,需要扩容复制整个数组,O(n)。
但如果你连续执行n次push,数组栈的总代价是O(n)级别的。因为扩容虽然偶尔发生,但每次扩容后容量翻倍,后续n/2次push都不用再扩。把总代价分摊到n次操作上,平均下来还是O(1)。这就是“均摊O(1)”的含义。
我提供一个简单的心算方法:假设初始容量1,不断翻倍扩容到n,复制数组的总数据量约为1+2+4+...+n≈2n,也就是O(n)。n次push总代价O(n),单次均摊O(1)。这个推理在面试里反复出现,建议你亲手推一遍。
2.5 push的实战坑:容量、异常与引用残留
push看起来简单,实际工程里容易出问题的点一个都不少。我踩过的和看别人踩过的,至少有三个:
第一个坑是容量预估。很多新手给数组栈分配容量时,喜欢给一个固定值比如100,然后业务数据一多,push到第101个就崩。要么做自动扩容,要么在初始化时根据业务量级预留容量。推荐做法是选择会自动扩容的封装容器,比如Java的ArrayDeque,或者Python的list,让底层去管理扩容。
第二个坑是异常处理策略。push函数是静默失败还是抛异常?我见过有人写push时直接忽略满栈异常,结果数据丢了,排查到半夜才发现是push被吞了。正确的做法是:入栈失败必须显式抛异常或返回错误码,绝不能默认成功。
第三个坑是引用残留。这在Java里尤其隐蔽:当底层是Object[]数组时,你pop掉了元素,但如果只是移动top索引,数组里还保留着那个对象的引用,导致该对象无法被垃圾回收,形成内存泄漏。Java官方的Stack类在pop时特意把数组位写成null,就是为了防止这个问题。自定义数组栈时,这一手也要带上。
3. pop(出栈)操作拆解:数据是怎么出栈的
3.1 pop的语义:删除和读取必须同时发生
pop,中文叫“出栈”或“弹栈”,是从栈顶取出元素的操作。注意这里有两个动作同时发生:一是把栈顶元素的值取出来返回给调用者;二是把该元素从栈中删除,栈顶指针下移。
这两个动作缺一不可。如果你只取值不删除,那叫peek(读栈顶);只删除不取回,通常没有意义。设计上,pop的返回值就是被弹出的那个元素。
用之前的数组栈代码来看,pop内部逻辑是:
- 检查栈是否为空。
- 取出data[top]赋值给value。
- top--。
- 返回value。
步骤3就是“删除”:对数组栈而言,删除并不等于清空data[top]的内存,而只是让该值不再处于栈的有效范围内。后续再有push操作,新值会直接覆盖这个位置。
3.2 不同语言的pop返回值设计:Stack.pop、Deque.pollLast、list.pop
不同语言对pop的封装,细节差异很大。用的时候不留神,很容易踩到坑。
Java经典Stack类有一个坑:它本身继承了Vector,支持随机访问,所以从定位上它就不够“纯正”。Stack.pop会返回栈顶元素,但如果栈为空,会抛EmptyStackException。新版Java代码里我更推荐用ArrayDeque替代Stack,因为Stack在并发和性能上都不占优势。ArrayDeque用push入栈、pop出栈,效果完全一样,底层是循环数组,效率更高。
Python的list就非常直接:list.pop()默认弹出最后一个元素,正好就是栈顶;list.append(x)就是入栈。语言层面直接支持,这是Python写算法题很爽的原因之一。
但要注意:Java的Deque接口里,pop和pollFirst语义不同。pop在空栈时抛异常,pollFirst在空的时候返回null。如果你用Deque当栈,最好统一用push/pop这一对,别一会儿用addFirst/pollFirst,一会儿用offerFirst/removeFirst,容易混乱。
3.3 空栈问题:异常、null和Optional怎么选
pop操作最经典的问题就是:栈为空时怎么办。
常见的处理方式有三种:
- 抛异常。比如Java的EmptyStackException、Python的IndexError。适合“栈空是程序bug”的场景,尽早暴露问题。
- 返回null或默认值。适合“栈空是正常业务状态”的场景,比如遍历到叶子节点时需要回退,但栈里可能没东西。
- 返回Optional对象。Java里可以返回Optional ,让调用者显式处理空值,从类型层面强制你思考空栈。
我给一个实用建议:底层库的方法最好抛异常,因为底层不知道上层业务怎么处理空栈;业务层如果允许空栈,显式先调用isEmpty判断,而不是依赖pop返回null来判断空。因为pop返回null可能是“栈里本来就存了null”,这个歧义会让你排查问题时怀疑人生。
3.4 pop之后,内存真的释放了吗
这个问题我在讲引用残留时提过,这里展开讲。
对于链式栈,pop操作删除头节点后,如果没有其他引用指向该节点,它就会变成无引用对象,由垃圾回收器回收。对于C语言这种手动内存管理的语言,你需要free掉节点内存,否则就会内存泄漏。
对于数组栈,情况更有迷惑性。top从k变成k-1,但data[k]位置仍然保存着那个对象的引用。如果不手动置null,垃圾回收器会认为这个对象仍然被数组引用着,不会回收它。这就是常见的内存泄漏点。
解决办法很简单,pop时多写一行:
public Object pop() { if (top < 0) throw new EmptyStackException(); Object value = data[top]; data[top] = null; // 关键:释放引用 top--; return value; }不过注意:如果data里存的是基本类型int,比如最开始的ArrayStack代码,就不需要置null,因为基本类型不存在引用问题,覆盖直接生效。
3.5 出栈后的缩容:要不要做?什么时候做
数组栈pop之后,容量会空闲出来。要不要像扩容一样缩容?我的答案是别急着做。
原因很简单:如果业务模式是“大量push后又大量pop,再大量push”,频繁缩容扩容会带来严重的性能抖动。每次缩容要复制数组,扩容也要复制数组,一来一回相当于一次完整的O(n)复制,而你的真实数据量可能一直在某个区间波动。
缩容真正适用的场景,是那些长期使用的全局栈,比如线程调用栈,在高水位持续了一段时间后确定不会再用到那么大容量,才值得考虑。实现上可以加一个阈值判断:当元素数量小于容量的25%,且当前容量大于某个初始值,才缩容到一半。标准的动态数组比如Java的ArrayList就有类似策略,判断条件记得加上“容量大于初始值”,否则栈一旦为空就会被缩成0,下次push又要从头扩。
4. 栈的两种实现选型:数组栈与链式栈
4.1 顺序栈:内存连续,随机访问快,上限可扩容
顺序栈的底层是一块连续的内存区。它的优点非常具体:
- CPU缓存友好。数组元素在内存里挨着,遍历或压入时大概率命中缓存,性能比每次new节点的链表好。
- 实现简单。push和pop都只是操作下标,逻辑直观,不容易写错。
- 可以通过扩容突破初始大小限制。只要内存允许,数组可以不断翻倍。
缺点同样明显:扩容时有数据拷贝成本;如果存储对象,还要处理引用残留;初始容量分配不合理时,浪费内存或频繁扩容。
顺序栈适合什么场景?我总结为:元素类型明确、数量级可预估、对性能敏感。比如你要是自己实现一个有界表达式求值器,用顺序栈就很合适。
4.2 链式栈:内存按需分配,大小不受限,但节点开销高
链式栈每个元素对应一个节点,节点里除了数据还有next指针。它的特点是:
- 不需要预分配连续内存,每个节点独立分配,栈的大小只受限于系统内存总量。
- push和pop严格O(1),不受扩容影响。
- 不用担心“预留容量浪费”。
缺点是:每一个节点都要额外的指针存储空间,小数据量时内存开销甚至比数组栈更大。而且链表节点在内存中通常不连续,频繁访问时缓存命中率低,性能表现不如数组栈。
如果把栈用于语言解释器、浏览器历史这类“不知道将来会存多少记录”的场景,链式栈更安心。Java的LinkedList可以作为链式栈的现成容器,push用addFirst,pop用removeFirst,不要用get和remove混着用。
4.3 对比表和选型建议
我整理了一张自己常用的对比表。面试时如果被问“数组栈和链式栈怎么选”,照着这张表说,基本不会丢分。
| 维度 | 顺序栈(数组) | 链式栈(链表) |
|---|---|---|
| 内存结构 | 连续内存 | 离散节点 |
| 初始容量 | 需指定,可扩容 | 按需分配 |
| push/pop时间复杂度 | 均摊O(1) | 严格O(1) |
| 扩容/缩容代价 | 存在,且可能复制大数组 | 无 |
| 额外内存开销 | 低,最多浪费预留容量 | 高,每个节点多一个指针 |
| 缓存友好性 | 好 | 差 |
| 实现难度 | 低 | 中 |
| 适合场景 | 数量可预估、性能敏感 | 数量不确定、内存碎片化环境 |
选型时我的习惯是三条判断:
- 如果栈的最大深度能估算,直接用数组栈,初始容量给到最大深度的1.5倍左右,留点余量。
- 如果完全无法预估,并且元素本身是重量级对象,用链式栈,避免数组扩容时反复拷贝对象引用。
- 日常写算法题,直接用语言内置的栈结构,不要自己造轮子。
4.4 栈溢出和“Java中堆和栈的区别”
聊到栈,有两个热门词绕不开:一个叫“栈内存溢出”,另一个叫“Java中堆和栈的区别”。
函数调用时,每次调用都会在系统调用栈里分配一个栈帧,里面有局部变量、返回地址等信息。如果递归没有退出条件,或者调用层级太多,栈空间被耗尽,就会触发栈溢出。Java里对应StackOverflowError,Python里是RecursionError,C++里则是非法内存访问或段错误。这个和数据结构栈是一套模型:函数调用栈就是最著名的一个栈实例。
至于Java中堆和栈的区别,主要是指JVM运行时区域的划分:
- 栈:每个线程私有,存放局部变量、方法调用帧,大小相对固定,访问速度快。
- 堆:所有线程共享,存放对象实例和数组,空间更大,内存管理和垃圾回收主要发生在这里。
两者的容量限制和生命周期完全不同。栈随方法调用产生或销毁,堆中的对象由GC负责回收。搞清楚这两个,对定位StackOverflowError和OutOfMemoryError之间的差异很有帮助。
5. 栈的应用全景图:从函数调用到单调栈
5.1 函数调用栈:递归能运行的基本保障
递归函数是理解栈的最好入口。每次调用一个函数,系统就往调用栈里压入一个栈帧,里面保存着本次调用的局部变量和返回地址。函数执行完,栈帧被弹出,程序回到上一层继续执行。
举一个最简单的阶乘例子来说明递归和栈的关系:
def factorial(n): if n <= 1: return 1 return n * factorial(n - 1)当执行factorial(3)时,调用栈变化如下:
- 调用factorial(3),压栈帧A。
- A里执行到factorial(2),压栈帧B。
- B里执行到factorial(1),压栈帧C。
- C返回1,弹出C。
- 栈顶变成B,得到1,返回2,弹出B。
- 栈顶变成A,得到2,返回6,弹出A。
看到没有,函数执行的路径就是完全的LIFO。这也是为什么理论上任何递归都能改写为显式栈的非递归版本。当你理解了调用栈,就不会再对“递归为什么能保存中间状态”这件事感到玄学了。
5.2 括号匹配和表达式求值:栈的经典考法
算法题里最常出现的栈应用就是括号匹配。给定一个只包含'('、')'、'['、']'、'{'、'}'的字符串,判断括号是否有效。核心思路是:
- 遇到左括号,就push进栈。
- 遇到右括号,从栈里pop一个元素,看是否匹配。
- 如果pop时栈为空,或者类型不匹配,直接失败。
- 字符串遍历完后,如果栈不为空,说明有未闭合的左括号,失败。
这个算法LintCode/LeetCode上属于“闭眼都能写”的水平,它的精髓在于:栈天然记录了“最近出现的未匹配左括号”,不需要额外指针。
表达式求值也类似。中缀表达式转后缀表达式(逆波兰表达式),再到后缀求值,中间离不开两个栈:一个运算符栈、一个操作数栈。例如计算“3 + 4 * 2”时,遇到乘号优先级高于加号,运算符栈会把乘号压在加号上方,保证先算4 * 2再算3 + 8,后进先出在这里正好就是“优先级高的先算”。
5.3 浏览器后退、Undo/Redo和深度优先遍历
浏览器后退我之前提过了,这里说实现细节。浏览器维护一个访问历史栈,每当你跳转到新页面,就把当前页push进历史栈。点击后退,就pop出上一个页面并显示。如果又从历史页面跳转到新页面,通常意味着旧的“未来记录”被清空,因为此时历史栈的新分支已经和原来不一致了。
编辑器里的撤销功能同理。每次操作把操作记录push进撤销栈,Ctrl+Z执行一次pop回滚。如果要支持重做,再加一个重做栈:撤销时把操作从撤销栈弹出,压进重做栈;新操作时清空重做栈,因为历史已经分叉。
深度优先搜索(DFS)同样可以基于栈实现。树的前序、中序、后序遍历都能用显式栈来模拟。用递归写DFS是最简单的,但深入理解栈后,你会发现显式栈可以更好地控制遍历顺序,也避开了递归过深的问题。
5.4 单调栈:接雨水和最大矩形背后的公共套路
接下来是栈的高阶玩法:单调栈。所谓单调栈,就是栈内元素保持单调递增或单调递减。它解决的核心问题是“找每个元素左边/右边第一个比它大(或小)的邻居”,典型题目包括“每日温度”“接雨水”“柱状图中最大的矩形”。
以“接雨水”为例,如果柱子高度数组是[0,1,0,2,1,0,1,3,2,1,2,1],你很容易找到其中的逻辑关系。但用单调递减栈实现时,整体思路是这样的:
- 从左到右遍历柱子高度。
- 当当前柱子高度大于栈顶高度时,说明栈顶柱子可能形成一个凹槽,此时pop出栈顶,用它的左右两边较矮的高度乘以宽度,累加雨水量。
- 栈里存放的是柱子的下标,而不是高度,因为计算宽度需要下标差。
我建议你把这个题手写三遍以上。第一次照着抄,第二次改换数据,第三次尝试自己推导单调栈的写法。等你能熟练写出“单调递减栈”的实现,很多相关题目都是一通百通的。
5.5 “栈”这个字在IT圈的其他用法:全栈和技术栈
学习数据结构时,还有一个容易让刚入行的人迷糊的点:技术圈经常说“全栈”“技术栈”“调用栈”,这些“栈”是不是同一个概念?
答案:它们只是用了同一个汉字,本质完全不同。
- 数据结构中的栈:一种后进先出的抽象模型,push/pop操作的核心。
- 技术栈(Tech Stack):指解决一个项目所用的整套技术组合,比如前端用Vue,后端用Spring Boot,数据库用MySQL,合起来叫一个技术栈。
- 全栈(Full Stack):指工程师能同时处理前端、后端、数据库甚至运维等多层技术,“全栈工程师”跟数据结构里的栈一点关系都没有。
有学生问过我:“面试官问我队列和栈的区别,我回答技术栈和数据结构栈的区别,行不行?”当然不行,没有哪个面试官会这么出题。但反过来,当你在牛客或GitHub上看到“全栈项目”“技术栈那篇wp”这些表达时,至少别被吓到——它们不是让你用push和pop去写一个什么高深系统。
6. 从零手写一个栈:完整代码与常见错误排查
6.1 Python实现顺序栈:含扩容和完整测试
说了这么多,最后还是得自己动手写一遍,才能算真正掌握。下面我用Python实现一个带扩容的顺序栈,里面有完整的异常处理:
class Stack: def __init__(self, capacity: int = 16): if capacity <= 0: raise ValueError("capacity must be positive") self._data = [None] * capacity self._top = -1 self._capacity = capacity def push(self, item): if self._top + 1 >= self._capacity: self._resize(self._capacity * 2) self._top += 1 self._data[self._top] = item def pop(self): if self.is_empty(): raise IndexError("pop from empty stack") value = self._data[self._top] self._data[self._top] = None self._top -= 1 return value def peek(self): if self.is_empty(): raise IndexError("peek from empty stack") return self._data[self._top] def is_empty(self) -> bool: return self._top == -1 def size(self) -> int: return self._top + 1 def _resize(self, new_capacity: int): new_data = [None] * new_capacity for i in range(self.size()): new_data[i] = self._data[i] self._data = new_data self._capacity = new_capacity def __repr__(self): values = [str(self._data[i]) for i in range(self.size())] return "Stack([" + ", ".join(values) + "])" # 测试 s = Stack(2) s.push(1) s.push(2) print(s) # Stack([1, 2]) s.push(3) # 触发扩容 print(s.size()) # 3 print(s.peek()) # 3 print(s.pop()) # 3 print(s.pop()) # 2 print(s.pop()) # 1 s.pop() # 抛 IndexError这段代码里有几个细节值得你细看。第一,扩容后旧数据通过循环复制到新数组,我故意没用切片,因为切片会生成新列表,在底层逻辑上和列表原生存储有一些细微差别,写循环能让你更清楚“复制”到底做了什么。第二,pop取出值后把原位置置为None,避免引用残留。第三,peek和pop都检查了空栈,但报错信息不同,方便你调试时定位问题。
6.2 Java实现链式栈:用内部类写节点
Java实现链式栈同样不难,这里我给出一个通用版本。Java比Python啰嗦的地方在于类型声明和空值检查,但这也是Java工程化思路的体现。
public class LinkedStack<T> { private static class Node<T> { T value; Node<T> next; Node(T value, Node<T> next) { this.value = value; this.next = next; } } private Node<T> head; private int size; public void push(T item) { head = new Node<>(item, head); size++; } public T pop() { if (head == null) { throw new IllegalStateException("pop from empty stack"); } T value = head.value; head = head.next; size--; return value; } public T peek() { if (head == null) { throw new IllegalStateException("peek from empty stack"); } return head.value; } public boolean isEmpty() { return head == null; } public int size() { return size; } }这里的内部类Node用了泛型T,让栈可以存任意类型。push方法的写法head = new Node<>(item, head)是链式栈的精髓:新节点指向旧头节点,然后头指针指向新节点,一行代码完成“新节点变栈顶”的所有操作。pop时先把值保存下来,再移动头指针,这样即使value被业务代码清空,链表结构也不受影响。
6.3 常见错误排查清单
不管是你自己写的栈,还是别人写的,一旦行为不对,按下面的清单排查能省很多时间:
- 栈空判断写反。最典型的问题:isEmpty写成size==1,或者top==0,结果空栈时没有报错,反而返回了残留值。
- pop顺序错误。应该是先取栈顶,再移动指针。如果你先移动指针再取数,取出来的就是倒数第二个元素。
- 容量检查忘做。数组栈不检查容量,直接top++会导致数组越界或者数据覆盖。
- Java引用残留。pop之后没有把数组位置为null,长期运行就内存泄漏。
- 扩容系数太小。有些实现每次容量只加1,性能退化到O(n),数据量稍大就卡死。
- 试图从栈中间取元素。这是误用栈,栈只允许访问栈顶,任何“取第k个”的需求都意味着你应该用数组或列表。
6.4 栈学完之后,下一步怎么走
栈本身不算难,但它的意义在于帮你建立“状态回溯”的思维模式。学完栈以后,我建议你按这几个方向做练习:
第一,把栈和递归互相转换。写一个二叉树的先序遍历递归版本,再用显式栈改写成非递归版本。这个过程能让你同时理解递归和栈,两不耽误。
第二,实战经典栈题。我推荐的题目顺序是:有效的括号、最小栈、每日温度、柱状图中最大的矩形、接雨水、字符串解码。做完以后你对栈的理解会提升一个档次,尤其是单调栈题,做三遍都不过分。
第三,把栈和队列综合起来做“用队列实现栈”“用栈实现队列”这种互转题。这类题考察的是数据结构语义,而不是具体语法,做完你会真正明白栈和队列的底层差异在哪里。
从我自己的经历看,栈是少数“学会了就永远忘不了”的数据结构,因为它的应用实在太普遍。写好一个push,一个pop,你就掌握了无数高层功能背后的地基。那些看似光鲜的浏览器后退、代码撤销、函数递归调用,底层无非就是这两个操作在反复运转。数据结构这个东西,学到最后你会发现,大道至简,关键看你有没有真正理解那一步进、一步出的逻辑。