「用栈实现队列」听起来像个派对小魔术,直到你意识到它真正在考什么:你是否清楚自己语言里的集合各自要付多少代价,以及你能不能当着面试官的面把摊还时间讲清楚。本课一半是 Java 机制——array、ArrayListLinkedListStackQueueDeque 到底怎么表现,细到哪一行声明能不能编译;另一半是经典的栈/队列面试题:LC 232、LC 225、三种写法的 LC 155 Min Stack,以及攻克 LC 84 Largest Rectangle in Histogram 的单调栈。

⚡ 速览要点
  • 连续性就是全部——array 住在一整块连续内存里,所以下标算术给你 O(1) 访问;linked list 靠指针串起来,走到中间要 O(n)。ArrayList/LinkedList 那张表的每一行都是从这一条推出来的。
  • 摊还 O(1) 追加——ArrayList 初始容量 10,按 1.5× 增长;偶尔一次 O(n) 扩容,摊到 n 次追加上就是每次 O(1)。要能说出 amortized 这个词并为它辩护。
  • 声明类型决定能调哪些 API——List<Integer> list = new ArrayList<>() 不做强转就调不了 ArrayList 独有的方法,而且左边必须是右边的父类型——这正是 Stack<Integer> st = new LinkedList<>() 编译不过的原因。
  • Deque 是瑞士军刀接口——FIFO 还是 FILO 全在一套 API 里,而且每个操作都有抛异常版(addFirst)和返回特殊值版(offerFirst)。面试官最爱问哪个是哪个。
  • LC 232 里 lazy 胜过 eager——只在 out-stack 见底时才在两个栈之间转移。每个元素最多移动两次 → push O(1),pop 摊还 O(1)。
  • 单调栈把 O(n²) 变成 O(n)——LC 84 保存一段非递减柱子的下标;来了一根更矮的柱子就弹栈并结算矩形面积,末尾用一根高度 0 的哨兵把栈清空。
tldr

组合之前先摸清你的容器。array 是连续的 → O(1) 访问;linked list 拿它换来两端 O(1);ArrayList 靠 1.5× 增长让追加摊还 O(1)。ListQueueDeque 是接口;StackLinkedListPriorityQueue 是类——声明类型必须是实际对象的父类型。然后是那几道经典:用栈实现队列是 lazy transfer,用队列实现栈是 rotate-on-push,Min Stack 靠一个辅助 min,Largest Rectangle 是带哨兵的单调下标栈。

Java 内存模型:为什么 array 有 O(1) 而 list 没有

Java 里的 array 永远是连续存储int[] arr 字面上就是 n 个 int 挨着排,所以 arr[i] 只差一次乘法加一次加法——O(1),无需遍历。

再看 Student[] arr = new Student[10]Student 不是基本类型,所以连续的不是十个 Student 对象——而是十个引用。栈上的变量 arr 指向堆上的一个数组对象,这个数组装着十个指向 Student 对象的指针,而那些对象散落在分配器随手放的任何地方。引用数组本身仍然连续,所以下标访问仍是 O(1);不连续的是对象本身。

相比之下,linked List 完全不连续。每个节点只认识自己的邻居,所以没有下标算术——取第 i 个元素意味着走 i 跳。就这一个事实——连续 vs 串联——生成了下面整张复杂度表;里面没有一项需要死记硬背。

本课顺带复习的一个 Java 基础:访问修饰符。public 到处可见;protected 在包可见的基础上加上子类;default(不写关键字)只在包内可见;private 只在同类内可见。值得花三十秒过一遍,因为一旦你在面试里设计一个类——比如我们接下来要设计的这个——它立刻就用得上。

从零手写一个双向链表

在用 LinkedList 之前,本课先造一个——一道「Design List」练习,顺便当作 Java 构造器和泛型的巡礼。先看节点:

Java — ListNode + 双向链表
class ListNode<T> {
    public T val;
    public ListNode<T> prev;
    public ListNode<T> next;

    ListNode(T val) { this.val = val; }

    ListNode() {          // 重载:同名,签名不同
        this(null);       // 构造器链——委托给 ListNode(T val)
    }

    ListNode(T val, ListNode<T> next, ListNode<T> prev) {
        this.val = val;
        this.next = next;
        this.prev = prev;
    }
}

class MyLinkedList<T> {
    private ListNode<T> head;
    private ListNode<T> tail;
    private int size;

    public T getVal(int index) {
        if (index < 0 || index >= size)
            throw new IndexOutOfBoundsException();  // 比默默返回 null 好
        ListNode<T> cur = head;
        for (int i = 0; i < index; i++) cur = cur.next;  // 没有捷径:O(n) 遍历
        return cur.val;
    }

    public void addFromHead(T val) {
        ListNode<T> newHead = new ListNode<>(val);
        if (head == null) {       // 空表:head 和 tail 是同一个节点
            head = newHead;
            tail = head;
        } else {
            newHead.next = head;
            head.prev = newHead;
            head = newHead;
        }
        size++;
    }

    public void addFromTail(T val) {   // addFromHead 的镜像
        ListNode<T> newTail = new ListNode<>(val);
        if (tail == null) {
            head = newTail;
            tail = head;
        } else {
            newTail.prev = tail;
            tail.next = newTail;
            tail = newTail;
        }
        size++;
    }

    public int getSize() { return size; }
}

那个节点类里有两样东西是纯纯的面试加分项。第一,构造器重载:几个同名构造器,靠签名区分。第二——也是本课老师真正秀了一把的——this(...) 做构造器链:无参构造器不重复初始化逻辑,而是带着默认值委托给单参构造器(int 版里是 this(0),泛型化之后是 this(null))。一行代码,零重复,而且它传达出你是真懂这门语言,而不只是会写它的语法。

0 或 1 个节点的边界情况

双向链表上的每个增/删操作都有同一个陷阱:0 个或 1 个节点的链表。往空表里加必须同时设置 headtail;删掉最后一个节点必须把两者都置空。如果你写 addFromHead 时漏了 head == null 那个分支,你的链表能一直正常工作,直到面试官真正跑的第一个测试用例。写之前先把这个边界情况说出来。

List 是接口:ArrayList、LinkedList 与声明类型

在 Java 里,List 是一个接口——一份待实现的方法契约:get(i)set(i, val)add(val)add(i, val)remove(i)size()isEmpty()ArrayListLinkedList 是实现它的类——各自履行整份契约,再各加自己的方法。而且和类不同,接口支持多继承:一个类可以同时实现多个接口,LinkedList 正是靠这一点同时身兼 ListQueueDeque

ArrayList 是一个可变长数组:初始容量 10,空间用完就分配一个 1.5× 大的新数组,把所有东西拷过去。记住这句话——它是下一节摊还分析的关键。

这里有个大多数候选人会漏掉的微妙点。假设 ArrayList 有一个额外方法 myMethod(),它不在 List 接口里:

  • ArrayList<Integer> list = new ArrayList<>();list.myMethod() 正常编译。
  • List<Integer> list = new ArrayList<>();list.myMethod() 编译不过。编译器只把 list 当作 List;你得强转:((ArrayList) list).myMethod()

声明类型决定你能调用哪些方法;运行时类型决定这些方法做什么。笔记点到的一个小 API 细节同理:Integer.parseInt(s) 返回基本类型 int,而 Integer.valueOf(s) 返回 Integer 对象——类型不同,代价不同,在集合里偶尔行为也不同。顺便记一下问长度的三种不一致约定:arr.length(字段)、string.length()(方法)、list.size()(方法)——高压之下,这是笔误级编译错误的经典来源。

ArrayList vs LinkedList:完整复杂度表

本课的招牌表格,重建如下。每一项都从「连续 vs 串联」推出:

操作ArrayListLinkedList(双向)
取头/尾O(1)O(1)
取中间O(1)O(n)
改头/尾O(1)O(1)
改中间O(1)O(n)
头部插入O(n) —— 整体搬移O(1)
中间插入O(n)O(1) + O(n) 访问
尾部插入摊还 O(1)(扩容时 O(n))O(1)
头部删除O(n) —— 整体搬移O(1)
中间删除O(n)O(1) + O(n) 访问
尾部删除O(1)O(1)
size() / isEmpty()O(1)O(1)

有两行值得特别说明,因为面试官恰好会盯着这两处问:

尾部插入,ArrayList。通常有富余容量 → O(1)。偶尔数组满了 → 分配 1.5× 大的新数组,拷贝 n 个元素 → O(n)。但多久一次?从 10 开始增长,扩容发生在大小 10、15、22、33……——按几何间隔分布。n 次追加的总拷贝工作量是 O(n),所以每次追加的平均代价是 O(1)。这就是摊还分析:没有任何单次操作保证便宜,但整个序列便宜。这套论证下面在 LC 232 会原样再出现一次——它是整节课杠杆率最高的概念之一。

「+ 访问时间」那行小字,LinkedList。删除一个你已经攥在手里的节点是 O(1) 的指针手术——这是双向链表诚实的强项。但如果你得先找到那个节点,就得加上 O(n) 的遍历。笔记把这写成「O(1) + 访问时间」——保留这个说法,因为它证明你分得清操作本身和抵达它的路程。

Stack、Queue、Deque:什么能 new,什么不能

Java 的栈/队列版图是一片「它是类还是接口?」的雷区——面试官爱用一行声明语句做快速能力探测。所有情况背后的规则:左边声明的类型必须是右边类型的同一个类、父类,或它所实现的接口

Java — 哪些声明能编译?
Stack<Integer> st  = new Stack<>();         // OK —— Stack 是具体类
List<Integer>  st2 = new Stack<>();         // OK —— Stack 实现了 List
List<Integer>  st3 = new LinkedList<>();    // OK —— LinkedList 实现了 List
Stack<Integer> st4 = new LinkedList<>();    // NO —— LinkedList 不是 Stack 的子类

Queue<Integer> q   = new LinkedList<>();    // OK —— LinkedList 实现 Deque ⊂ Queue
Deque<Integer> dq  = new LinkedList<>();    // OK —— 当栈或队列都行
Queue<Integer> q2  = new Queue<>();          // NO —— Queue 是接口,不能 new

PriorityQueue<Integer> pq = new PriorityQueue<>();  // OK —— PriorityQueue 是类
Queue<Integer>         pq2 = new PriorityQueue<>();  // OK —— 这才是惯用写法

一口气讲完这套分类:Stack 是类(你也可以只在头部单端操作,拿 LinkedList 自己撸一个栈);QueueDeque 是接口,最常实例化成 LinkedList;PriorityQueue 是实现了 Queue 的类Deque 既是 FIFO 又是 FILO——同一个结构,取决于你碰哪一端,既能当队列也能当栈。

Deque 的 API 是一张严格的 2×2×3 网格——头或尾、失败抛异常或返回特殊值、插入/删除/查看:

操作头部——抛异常头部——特殊值尾部——抛异常尾部——特殊值
插入addFirst(e)offerFirst(e)addLast(e)offerLast(e)
删除removeFirst()pollFirst()removeLast()pollLast()
查看getFirst()peekFirst()getLast()peekLast()
哪种结构配哪种算法

Queue(FIFO) → BFS → 层序遍历 → 滑动窗口 → 边权全相等时的 Dijkstra。Stack(FILO) → DFS,以及递归隐式做的一切 → push / pop / peek。这一行映射是后面课程里一半题目的路由器——当一道题闻起来像「一层一层」,就伸手拿队列;闻起来像「先扎到底,再回头」,就伸手拿栈。

LC 232 —— 用栈实现队列

一个栈做不到——把 FILO 反转成 FIFO 需要第二次反转。所以:两个栈。设计问题是何时反转,而两个答案的价签天差地别。

解法 0 —— push 时重排。始终把「队列顺序」维持在 st 里:每次 push 都把 st 倒进一个缓冲栈,把新元素放到底部,再全部倒回来。pop 和 peek 是 O(1),但每次 push 都是 O(n)。正确,而且有教学价值,因为它把下一个想法衬托得格外漂亮。

解法 1 —— 惰性重排,在 pop/peek 时做。push 永远落进 st1,O(1)。pop 和 peek 从 st2 读——只有 st2 空了才把 st1 整个翻过去。已经躺在 st2 里的元素本来就是反转后(即队列)顺序,所以我们从不把任何东西翻两次:

Java — LC 232,惰性转移
class MyQueue {
    private Stack<Integer> st1;   // in-stack:所有 push 都进这里
    private Stack<Integer> st2;   // out-stack:装着已经是队列顺序的元素

    public MyQueue() {
        st1 = new Stack<>();
        st2 = new Stack<>();
    }

    public void push(int x) {
        st1.push(x);              // O(1) —— 写路径上不做任何重排
    }

    public int pop() {
        if (!st2.isEmpty()) return st2.pop();
        while (!st1.isEmpty()) {
            st2.push(st1.pop());  // 只有 out-stack 空了才转移
        }
        return st2.pop();
    }

    public int peek() {
        if (!st2.isEmpty()) return st2.peek();
        while (!st1.isEmpty()) {   // 和 pop 一样的转移——可复用逻辑
            st2.push(st1.pop());
        }
        return st2.peek();
    }

    public boolean empty() {
        return st1.isEmpty() && st2.isEmpty();
    }
}

为什么单次 pop 可能 O(n),整体却是摊还 O(1)?追踪一个元素的一生:它被 push 进 st1 一次,transfer 到 st2 一次,pop 出去一次——无论周围发生多少次 pop,它这辈子最多经历三次栈操作。所以 n 个元素总共最多产生约 3n 次操作,平均每次操作 O(1)。这是 ArrayList 扩容那套论证换了身戏服——认出这个模式一次,以后你就再不用在高压下重新推导。

LC 225 —— 用队列实现栈

镜像问题,配一份镜像的解法菜单。解法 1:「用 Deque」——技术上属于队列家族的结构,本课把它归到能证明你懂 API 的玩笑答案一类。解法 2:两个队列——能用,但 push 和 pop 不可能都 O(1),而且在这里玩两个队列什么好处也换不来。解法 3 才是留下的那个:一个队列,push 时旋转。新元素到来时,先入队,再把排在它前面的所有元素依次出队再入队。最新的元素现在到了队头——而且永久待在那儿——所以队头的行为和栈顶一模一样:

Java — LC 225,一个队列,push 时旋转
class MyStack {
    Queue<Integer> q;

    public MyStack() {
        this.q = new LinkedList<>();
    }

    public void push(int x) {
        q.offer(x);
        for (int i = 0; i < q.size() - 1; i++) {
            q.offer(q.poll());   // 旋转:所有旧元素轮到新元素后面
        }
    }

    public int pop()  { return q.poll(); }   // 队头 == 栈顶
    public int top()  { return q.peek(); }
    public boolean empty() { return q.isEmpty(); }
}

代价:push O(n),pop 和 top O(1)。注意它和 LC 232 的不对称——那边,惰性给我们换来两边都摊还 O(1);这边,旋转必须在 push 时急切地做,因为队列没法免费反转自己。能说清楚为什么这两道题不完美对称,恰恰是那种把「正确答案」升级成「强答案」的观察。

LC 155 —— Min Stack 的三种写法

设计一个支持 pushpoptopgetMin 的栈——全部 O(1)。三种解法层层递进才是真正的课:每一种都用多一点巧劲换少一点内存。

解法 1 —— 两个同步栈。一个数据栈加一个亦步亦趋的 min 栈:每次 push 也 push min(x, minStack.peek()),每次 pop 两个都弹。轻松正确,O(n) 额外空间,不需要任何巧思——一个完全合格的第一答案。

解法 2 —— 一个栈,min 改变时把旧 min 压进去。只保留一个 min 变量;诀窍在于记住之前的最小值。每当一个新值成为新的最小值,就把即将退位的旧最小值压在它下面当面包屑:

Java — LC 155,埋下旧 min 面包屑
class MinStack {
    int min = Integer.MAX_VALUE;
    Stack<Integer> stack = new Stack<>();

    public void push(int x) {
        if (x <= min) {          // min 即将改变:先把旧 min 埋进去
            stack.push(min);
            min = x;
        }
        stack.push(x);
    }

    public void pop() {
        if (stack.pop() == min) min = stack.pop();  // 挖出上一个 min
    }

    public int top()    { return stack.peek(); }
    public int getMin() { return min; }
}

注意 push 里用的是 <= 而不是 <——当最小值有重复时,每一份都必须留下自己的面包屑,否则弹出其中一个重复值时会过早地恢复一个更旧的 min。

解法 3 —— 一个栈,存差值。炫技版:压入 x - min(用 long,因为差值可能溢出 int)而不是 x一个存进去的负值就是「这次 push 时最小值变过」的化石记录——于是 pop 时,负值告诉你要恢复 min = min - pop;top 时,一个非正的 peek 意味着当前值本身就是 min。一个栈,一个变量,不存重复数据——同时也漂亮地演示了「额外信息」可以编码成一个增量,而不必原样存下来。

本课还抛出一道同一路子的收尾谜题:只用栈做选择排序(两三个栈,汉诺塔风格)。st1 装所有值;把它们倒进 st2,同时记录这一轮的最小值;把最小值停到第三个栈里(或倒回去、把 min 单独留下),重复。它是 O(n²),没人会真拿去用——但它逼你思考一次两栈之间的倾倒会保留下什么信息,而这正是 LC 232 和 Min Stack 锻炼的同一块肌肉。

LC 84 —— Largest Rectangle in Histogram

本课最难的一题,也是一件将在你余下面试生涯里持续收租的工具的首秀:单调栈。给定柱子高度,求能塞进直方图下方的最大矩形。暴力枚举每个 (左, 右) 对——O(n²)。单调栈一趟搞定:

Java — LC 84,单调栈
public class Solution {
    public int largestRectangleArea(int[] height) {
        int len = height.length;
        Stack<Integer> s = new Stack<>();     // 存的是一段非递减序列的下标
        int maxArea = 0;
        for (int i = 0; i <= len; i++) {
            int h = (i == len ? 0 : height[i]); // 末尾高度为 0 的哨兵把栈清空
            if (s.isEmpty() || h >= height[s.peek()]) {
                s.push(i);                       // 还在爬升——先不下判断
            } else {
                int tp = s.pop();                // 这根柱子的矩形现在定了
                maxArea = Math.max(maxArea,
                    height[tp] * (s.isEmpty() ? i : i - 1 - s.peek()));
                i--;                             // 让 i 和下一个栈顶重新比较
            }
        }
        return maxArea;
    }
}

不变量:栈里保存的下标,其对应高度是非递减的。只要柱子还在往上爬,就没有任何矩形的命运被敲定——压栈,继续走。一旦来了一根更矮的柱子,栈里每一根更高的柱子都刚刚找到了它的右边界,于是逐个弹出并结算它的最佳矩形:高度是 height[tp],宽度是新栈顶和 i 之间敞开的走廊。

所有人都会问的那两行

为什么用 i <= len 加一根虚拟的高度 0?末尾一根高度 0 的哨兵柱比所有东西都矮,所以它强制把每个残留的下标挤出栈——没有它,一个全程上升的直方图会以栈满、却一个面积都没算的状态收场。为什么宽度是 i - 1 - s.peek()?弹出 tp 后,新栈顶是 tp 左边第一个高度更小的下标——所以矩形从 s.peek() + 1 延伸到 i - 1,也就是 i - 1 - s.peek() 列。如果栈空了,说明左边没有更矮的,宽度就是整个 i

每个下标压入一次、弹出一次——2n 次栈操作,总计 O(n)。同一套「先搁置,直到撞上你的边界」模式驱动着 Daily Temperatures、Trapping Rain Water 和 Maximal Rectangle;LC 84 是它的经典入口。

更小的栈套路:去重与计算器

两个快速套路给工具箱收尾,都值得在你的脑内索引里占一句话:

消除相邻重复。一个栈能把 abbbacd → abacd → acd 这样的字符串塌缩:扫描字符,当进来的字符和栈顶匹配(或和栈顶凑成一段可消除的连续段)时,弹出而不是压入——并注意删除是如何级联的,因为删掉那段 b 让两个 a 相遇。这就是 Remove All Adjacent Duplicates 家族(LC 1047 及其计数变体)背后的引擎。

表达式求值。对于像 a + b * c 这样的中缀表达式,跑两个栈——一个装数字,一个装运算符——把低优先级运算符搁置,让高优先级的先解决;这就是 Basic Calculator 系列(LC 224 / 227)的骨架。而本课收尾抛出的编译原理级妙语是:先把表达式转成逆波兰式(a*b+c*dab*cd*+),求值就只需要一个栈——见到数字就压,见到运算符就弹两个、算、压回去。这就是 LC 150 Evaluate Reverse Polish Notation,也正是机器实际求值表达式的方式:第二个栈从头到尾只是为了处理优先级,而 RPN 把优先级烘焙进了顺序里。

刷题清单

题目套路复杂度
手写双向链表head/tail/size 字段;0 或 1 节点边界;构造器链get O(n),两端 add/remove O(1)
LC 232 用栈实现队列两个栈,pop/peek 时惰性转移push O(1),pop 摊还 O(1)
LC 225 用队列实现栈一个队列,push 时旋转push O(n),pop O(1)
LC 155 Min Stack同步 min 栈 / 面包屑旧 min / 差值编码所有操作 O(1)
LC 84 Largest Rectangle单调下标栈 + 哨兵 0O(n)
用栈做选择排序汉诺塔式倾倒,每轮记录 minO(n²) —— 思维练习
消除相邻重复(LC 1047 家族)栈:匹配就弹,删除级联O(n)
Basic Calculator 家族(LC 224/227)两个栈:数字 + 运算符O(n)
LC 150 逆波兰式求值一个栈 —— 优先级已预烘焙O(n)
总结

本课真正的交付物是一个成本模型。连续存储买来 O(1) 访问,在头部付 O(n);串联存储买来两端 O(1),抵达中间要付 O(n);按 1.5× 增长让追加摊还 O(1)。在这个模型之上坐着四个组合技巧——惰性转移(LC 232)、push 时旋转(LC 225)、面包屑最小值(LC 155),以及单调栈(LC 84)。学会模型,技巧就不再是死记硬背:每一个都只是把底层结构不原生提供的某个操作,用最便宜的方式买回来。

🎯 面试速答

ArrayList 还是 LinkedList——什么时候用哪个? ArrayList 用于随机访问(O(1) get/set)和摊还 O(1) 追加;LinkedList 用于两端 O(1) 操作和 Queue/Deque 职责。一旦算上遍历,中间操作两者都是 O(n)。默认:ArrayList。
为什么 ArrayList 的追加是摊还 O(1)? 容量按 1.5× 增长——扩容发生在几何间隔的大小上,意味着 n 次追加总共做 O(n) 拷贝工作,每次操作平均 O(1)。和 LC 232 的 pop 是同一套论证。
为什么 Stack<Integer> st = new LinkedList<>() 编译不过? 声明类型必须是实际对象的父类型;Stack 是一个类,LinkedList 并不继承它。Queue<Integer> q = new LinkedList<>() 没问题(LinkedList 实现 Deque ⊂ Queue),而 new Queue<>() 永远不行——接口不能实例化。
两个栈怎么做出摊还 O(1) 的队列? push 进 in-stack;pop/peek 从 out-stack 取,只在它空了时才回填。每个元素最多被 push、transfer、pop 各一次——n 个元素约 2n 次操作。
单调栈怎么以 O(n) 攻克 LC 84? 保存非递减柱子的下标;一根更矮的柱子弹出更高的柱子并用宽度 i - 1 - s.peek() 结算面积;一根高度 0 的哨兵清空栈。每个下标恰好压入并弹出一次。

← 上一篇
链表、队列与栈