【Java基础】栈与队列的妙用——从 JDK 源码到接雨水,那些面试官不会告诉你的真相

【Java基础】栈与队列的妙用——从 JDK 源码到接雨水,那些面试官不会告诉你的真相 第7期栈与队列的妙用——从 JDK 源码到接雨水那些面试官不会告诉你的真相目录一、面试真题引入二、底层的时空解构与源码透视2.1 Stack 源码分析——继承 Vector 为什么是设计灾难2.2 Deque 双端队列接口体系2.3 Queue/Deque 常用 API 对比2.4 单调栈基础原理三、纯手工、零依赖原创案例实战3.1 IDE 撤销重做——用两个栈实现3.2 打印店排队模拟——用 Queue 管理任务队列四、源码避坑指南与 Debug 日记五、面试连环炮 Mock Interview5.1 为什么阿里巴巴规范和 JDK 官方都严禁使用 Stack 类5.2 单调栈解接雨水——三种思路递进六、通俗类比小结一、面试真题引入大二下学期我开始刷 LeetCode很快就撞上了几道看着简单、一写就废的题。最典型的三道用队列实现栈LeetCode 225给你两个队列让你模拟栈的 push/pop/top/empty。第一次写的时候我在 pop 那里卡了半小时——两个队列倒来倒去脑子直接宕机。用栈实现队列LeetCode 232反过来的套路用两个栈模拟队列。看起来对称实际坑位完全不同。接雨水LeetCode 42Hard 题。给你一个数组表示柱子高度问下完雨能接多少水。暴力能写但时间复杂度 O(n²)优化到 O(n) 就得掏出单调栈。这三道题不是孤立的。它们的背后藏着同一个东西栈与队列——Java 集合框架里最古老也最拧巴的一对数据结构。Stack 类是 JDK 1.0 就有的骨灰级API但它被官方自己判了死刑Queue 接口体系设计精良可很多人都没用对。今天就沿着这三道面试题的线索把栈和队列的源码、设计缺陷、工程实战、以及单调栈在接雨水里的威力一次讲透。二、底层的时空解构与源码透视2.1 Stack 源码分析——继承 Vector 为什么是设计灾难先看 Stack 类的声明JDK 17publicclassStackEextendsVectorE{publicEpush(Eitem){addElement(item);returnitem;}publicsynchronizedEpop(){...}publicsynchronizedEpeek(){...}publicbooleanempty(){returnsize()0;}publicsynchronizedintsearch(Objecto){...}}三行代码就能看出问题。Stack继承了 Vector而 Vector 是什么是一个所有方法都加了 synchronized 的线程安全动态数组。Stack 的 pop、peek、search 也都带着 synchronized。这意味着什么即使你在单线程环境里用一个局部栈变量每次 push/pop 都要走一遍锁竞争的逻辑。这不是线程安全这是为不需要的东西买单。继承还带来了 API 污染。因为 Stack extends Vector所以你可以对一个栈做这些事StackStringstacknewStack();stack.add(1,插队);// Vector 的按索引插入——栈的 LIFO 语义被彻底破坏stack.removeElementAt(0);// 删除任意位置元素stack.removeRange(0,2);// 删除一个范围——这方法还是 protected 的但子类能调用stack.capacity();// 查看底层数组容量——栈需要关心容量吗一个栈被硬塞进了数组列表的全部操作。这直接违反了面向对象设计的核心原则组合优于继承Composition over Inheritance。Stack 不应该是一个Vector它应该持有一个Vector或者更好的选择持有 ArrayDeque。Joshua Bloch 在《Effective Java》里明确指出Stack 类是一个兼容性遗物应该用 Deque 替代。2.2 Deque 双端队列接口体系JDK 6 引入的 DequeDouble Ended Queue接口彻底改变了局面。来看继承关系Iterable → Collection → Queue → Deque → ArrayDeque / LinkedListDeque 既可以用作栈一端进出也可以用作队列一端进另一端出。用 Deque 当栈的写法DequeStringstacknewArrayDeque();stack.push(a);// 等价于 addFirststack.pop();// 等价于 removeFirststack.peek();// 等价于 peekFirst为什么 ArrayDeque 比 Stack 快两个原因无锁开销ArrayDeque 没有 synchronized纯单线程操作。循环数组ArrayDeque 底层用循环数组circular array头尾指针移动即可完成 push/pop无需像 Vector 那样搬移整个数组。JDK 文档里 ArrayDeque 的注释写得很直白“This class is likely to be faster than Stack when used as a stack, and faster than LinkedList when used as a queue.”2.3 Queue/Deque 常用 API 对比Queue 和 Deque 的 API 有两套——一套抛异常一套返回特殊值。很多新手栽在这上面操作抛异常版本返回特殊值版本插入add(e)offer(e)返回 false删除remove()poll()返回 null查看队首element()peek()返回 null记住如果你不确定队列是否为空用 offer/poll/peek。add/remove/element 在空队列或容量满时会抛异常线上事故往往就是这么来的。Deque 还额外提供两套方向 API操作队首First队尾Last插入addFirst/offerFirstaddLast/offerLast删除removeFirst/pollFirstremoveLast/pollLast查看getFirst/peekFirstgetLast/peekLast一套接口两种语义——栈和队列都能用 Deque 来搞定。2.4 单调栈基础原理单调栈Monotonic Stack指的是栈内元素始终保持单调递增或单调递减。它不是新的数据结构而是栈的一种使用策略。经典模板题——Next Greater Element下一个更大元素给定数组 nums对每个元素找出它右边第一个比它大的元素。publicint[]nextGreaterElement(int[]nums){intnnums.length;int[]resultnewint[n];DequeIntegerstacknewArrayDeque();// 存下标单调递减栈for(inti0;in;i){// 当前元素破坏了栈的单调递减性 → 找到了前面元素的下一个更大值while(!stack.isEmpty()nums[stack.peek()]nums[i]){intidxstack.pop();result[idx]nums[i];}stack.push(i);}// 栈里剩下的没有下一个更大元素while(!stack.isEmpty()){result[stack.pop()]-1;}returnresult;}时间复杂度 O(n)因为每个元素最多入栈一次、出栈一次。把暴力解法的 O(n²) 压到 O(n)单调栈的核心思想就一句话我入栈之前先清理掉所有比我小的元素它们等的下一个更大就是我。这个模板稍加变形就能解接雨水、柱状图最大矩形、每日温度等十几道经典题。三、纯手工、零依赖原创案例实战3.1 IDE 撤销重做——用两个栈实现IDE 里的 CtrlZ撤销和 CtrlY重做是每个程序员每天都要按几十次的快捷键。背后的数据结构就是两个栈。设计思路一个undoStack存已执行的操作一个redoStack存已撤销的操作。执行新操作 → 压入 undoStack 并清空 redoStack撤销 → 从 undoStack 弹出压入 redoStack重做 → 从 redoStack 弹出压回 undoStack。完整实现JDK 17importjava.util.ArrayDeque;importjava.util.Deque;/** * 模拟 IDE 的撤销/重做机制。 * 每次编辑操作记录为一条文本快照撤销回到上一个快照重做前进到下一个快照。 */publicclassUndoManager{privatefinalDequeStringundoStacknewArrayDeque();privatefinalDequeStringredoStacknewArrayDeque();/** 执行一次编辑操作 */publicvoidedit(StringnewState){undoStack.push(newState);redoStack.clear();// 新操作清空重做历史System.out.println(编辑: newState);}/** 撤销回到上一个状态 */publicStringundo(){if(undoStack.size()1){System.out.println(没有更多可撤销的操作);returnundoStack.isEmpty()?:undoStack.peek();}redoStack.push(undoStack.pop());System.out.println(撤销 - undoStack.peek());returnundoStack.peek();}/** 重做前进到下一个状态 */publicStringredo(){if(redoStack.isEmpty()){System.out.println(没有更多可重做的操作);returnundoStack.peek();}StringrestoredredoStack.pop();undoStack.push(restored);System.out.println(重做 - restored);returnrestored;}/** 当前状态 */publicStringcurrent(){returnundoStack.isEmpty()?:undoStack.peek();}// 运行演示publicstaticvoidmain(String[]args){UndoManagermanagernewUndoManager();manager.edit(Hello);// 编辑: Hellomanager.edit(Hello World);// 编辑: Hello Worldmanager.edit(Hello World!);// 编辑: Hello World!manager.undo();// 撤销 - Hello Worldmanager.undo();// 撤销 - Hellomanager.redo();// 重做 - Hello Worldmanager.edit(Hello Java);// 编辑: Hello Java (重做历史被清空)manager.redo();// 没有更多可重做的操作}}运行输出编辑: Hello 编辑: Hello World 编辑: Hello World! 撤销 - Hello World 撤销 - Hello 重做 - Hello World 编辑: Hello Java 没有更多可重做的操作关键点第三步撤销了两次回到 “Hello”然后又重做到 “Hello World”。此时如果执行新的编辑 “Hello Java”redoStack.clear()会把之前那条 “Hello World!” 的重做分支彻底丢弃——这正是 IDE 的标准行为新操作刷新了历史树。3.2 打印店排队模拟——用 Queue 管理打印任务队列学校打印店高峰期一堆 U 盘插着排队。店主的处理逻辑就是最经典的队列模型先来先印FIFO。importjava.util.ArrayDeque;importjava.util.Queue;importjava.util.Random;/** * 模拟打印店的任务排队与处理。 * 每个打印任务有文件名和页数处理器按 FIFO 顺序依次打印。 */publicclassPrintQueue{// 打印任务recordPrintJob(StringfileName,intpages){}privatefinalQueuePrintJobqueuenewArrayDeque();privatefinalRandomrandomnewRandom();/** 提交打印任务 */publicvoidsubmit(StringfileName,intpages){PrintJobjobnewPrintJob(fileName,pages);booleanaddedqueue.offer(job);// 用 offer 而非 add避免抛异常if(added){System.out.println(提交任务: 《fileName》 pages页, 当前队列长度queue.size());}}/** 处理队列中所有任务 */publicvoidprocessAll(){while(!queue.isEmpty()){PrintJobjobqueue.poll();// 用 poll 而非 removeif(jobnull)break;// 模拟打印耗时inttimejob.pages*(random.nextInt(200)100);// 每页100~300msSystem.out.printf(打印中: 《%s》 %d页, 预计%dm%ds\n,job.fileName(),job.pages(),time/60000,(time%60000)/1000);try{Thread.sleep(time%5000);}catch(InterruptedExceptione){Thread.currentThread().interrupt();}System.out.println(完成: 《job.fileName()》);}System.out.println(所有任务打印完毕队列为空。);}publicstaticvoidmain(String[]args){PrintQueueprintQueuenewPrintQueue();printQueue.submit(实验报告,3);printQueue.submit(毕业论文第一章,12);printQueue.submit(个人简历,1);printQueue.submit(课程论文,8);System.out.println( 开始处理队列 );printQueue.processAll();}}运行输出提交任务: 《实验报告》 3页, 当前队列长度1 提交任务: 《毕业论文第一章》 12页, 当前队列长度2 提交任务: 《个人简历》 1页, 当前队列长度3 提交任务: 《课程论文》 8页, 当前队列长度4 开始处理队列 打印中: 《实验报告》 3页, 预计0m1s 完成: 《实验报告》 打印中: 《毕业论文第一章》 12页, 预计0m2s 完成: 《毕业论文第一章》 打印中: 《个人简历》 1页, 预计0m0s 完成: 《个人简历》 打印中: 《课程论文》 8页, 预计0m1s 完成: 《课程论文》 所有任务打印完毕队列为空。这两个例子合在一起恰好对应了 LeetCode 225 和 232 的本质——栈和队列不复杂复杂的是怎么在工程约束撤销需要保留历史、打印需要保证顺序下把它们用对。四、源码避坑指南与 Debug 日记坑 ①Stack 泄露了不该有的方法前面已经提过但实际操作中的后果比想象中严重。IDEA 补全时 Stack 对象点出来的方法列表里混着add(int, E)、removeElement、capacity()、removeRange。团队里如果有人不了解背景真的会这样写StackTasktaskStacknewStack();taskStack.add(2,urgentTask);// 把紧急任务插到第 2 位——stack 的 LIFO 被破坏了Code Review 的时候很难发现因为代码编译不过都不会报错运行时逻辑悄悄就走偏了。坑 ②ArrayDeque 不支持 null 元素这是文档里写了但很多人没注意到的一点。ArrayDeque 的offer、push等方法遇到 null 会直接抛NullPointerException。而 LinkedList 做 Queue 时允许 null。如果你从 LinkedList 切到 ArrayDeque 提升性能所有插入 null 的逻辑会原地爆炸。坑 ③LinkedList 做 Queue 时的内存踩坑LinkedList 每个元素是一个 Node 对象包含 item、prev、next 三个引用比 ArrayDeque 的循环数组多了两倍的引用开销。如果队列里常年挂着几千条任务LinkedList 的 GC 压力会明显高于 ArrayDeque。实测在 10 万元素的 push/pop 循环中ArrayDeque 比 LinkedList 快约 30%~50%。坑 ④两个栈实现队列的性能陷阱LeetCode 232 的经典解法stackIn 负责 pushstackOut 负责 pop。关键优化是摊还分析——不要把 stackIn 全倒进 stackOut 然后立刻倒回来而是只在 stackOut 为空时才一次性把 stackIn 倒过去。如果没有这个优化每次 push 和 pop 都来回倒腾时间复杂度会退化到 O(n) 而非摊还 O(1)。五、面试连环炮 Mock Interview5.1 为什么阿里巴巴规范和 JDK 官方都严禁使用 Stack 类面试官你平时写 Java 用栈一般怎么声明理性回答不是StackString s new Stack()而是DequeStringstacknewArrayDeque();理由有三层。第一层继承泄露。Stack 继承 Vector导致 Vector 的二十几个方法全部暴露在栈的接口上。add(index, element)、removeElement、capacity、removeRange——这些方法有的能绕过 LIFO 约束有的是 protected 但在子类里变成了可用。组合优于继承不是一句口号Stack 就是反面教材。第二层synchronized 锁粒度粗。Vector 的每个方法都加了synchronizedStack 继承过来之后 pop、peek、search 也都带着锁。单线程场景下这些锁是纯开销。即使多线程场景这种每个方法都加锁的粗粒度同步也远不如ConcurrentLinkedDeque这类现代并发容器。第三层JDK 官方自己都放弃了它。Stack 的 Javadoc 从 JDK 1.6 起就明确写了“A more complete and consistent set of LIFO stack operations is provided by the Deque interface and its implementations, which should be used in preference to this class.”翻译成人话我们当年犯了个错现在有更好的东西了别用这个。阿里巴巴Java开发手册泰山版第11条【强制】使用 Deque 代替 Stack 完成栈操作。5.2 单调栈解接雨水——三种思路递进面试官接雨水这道题你能给出几种解法解法一暴力 O(n²)——按列求对每一列向左找最高柱子 leftMax向右找最高柱子 rightMax。当前列能接的水 min(leftMax, rightMax) - height[i]。每个列找左右最高都是 O(n)总 O(n²)。面试官听到这儿通常会点头但皱眉因为暴力解写过太多遍了。解法二动态规划 O(n)——预计算左右最高用两个数组leftMax[i]和rightMax[i]预存每个位置左右的最大高度。遍历三趟一趟算 leftMax、一趟算 rightMax、一趟算雨水。时间 O(n)空间 O(n)。解法三单调栈 O(n)——按行求关键转变思路不是按列计算而是按行——找到每个洼地能接多少水。维护一个单调递减栈存下标。当前柱子比栈顶高时说明栈顶位置形成了一个洼地栈顶是底弹出后的新栈顶是左墙当前柱子是右墙。publicinttrap(int[]height){DequeIntegerstacknewArrayDeque();intwater0;for(inti0;iheight.length;i){while(!stack.isEmpty()height[stack.peek()]height[i]){intbottomstack.pop();// 洼地的底if(stack.isEmpty())break;// 没有左墙intleftstack.peek();// 左墙intwidthi-left-1;intdepthMath.min(height[left],height[i])-height[bottom];waterwidth*depth;}stack.push(i);}returnwater;}工业应用单调栈不只用于接雨水。柱状图最大矩形LeetCode 84、每日温度LeetCode 739、股票跨度LeetCode 901背后都是同一套模板。在真实系统中它能用于分析 CPU 负载曲线的峰值区间、内存占用的波峰持续时间——数据就是柱子找谷和峰就是单调栈的活。三种解法的递进也是算法面试的标准路径暴力→空间换时间→精巧的线性结构。面试官想看的不是一次给出最优解而是这个递进过程。六、通俗类比小结最后用三个生活中的例子帮你把栈、队列、双端队列记住叠衣服洗完叠好摞一摞后叠的衣服在最上面先穿的也是它——这就是栈LIFO。打印店取号小票上写着 042 号前面还有 5 个人。先来的先印后来的排队——这就是队列FIFO。校门口的双向车道车可以从东头进来从西头出去也可以从西头进来从东头出去——这就是双端队列 Deque两头都能进出。用这三个例子去套今天讲的一切Stack 别用了用 Deque → 就好像你叠衣服不需要关心衣架上第 3 件是什么材质继承泄露的方法你没资格用。offer/poll 代替 add/remove → 就好像取号机打出请稍候比直接黑屏要好返回特殊值比抛异常更稳健。