目录

题目描述

面试题 03.03. 堆盘子

题意分析

要实现一个"看起来像一个栈、内部是多摞盘子"的数据结构。每摞盘子最多放 cap 个,push 时若当前最后一摞已满就另起一摞;pop 从最后一摞的顶部拿;额外还有 popAt(index) 直接从第 index 摞的顶部拿。从外部看,push/pop 的行为必须和普通栈完全一致。

约束里有两个必须读出来的信号。第一,cap 可能为 0,此时任何 push 都应该被忽略,后续所有 pop/popAt 返回 -1;这个退化情况官方用例里是有的,不做特判会在建摞时陷入"永远满、无限新建"的怪圈。第二,popAt 会在中间挖洞,这是整题的难点来源:一旦某摞被掏空,后面所有摞的下标是否要前移,直接决定了 popAt 的语义。

边界上还要覆盖:结构完全为空时 pop 返回 -1popAtindex 越界返回 -1popAt 恰好掏空最后一摞;以及连续 popAt(0) 把前面的摞一个个删掉。题目并不要求把后面的盘子"往前挪"来填补空洞——只要求把空掉的那一摞整体移除,这是本题被普遍接受的处理方式。

解法:数组/列表维护多个子栈

核心思路

最先想到的是"用一个大数组存所有盘子,再靠算术下标 i / cap 换算出它属于第几摞"。瓶颈立刻出现在 popAt 上:从中间摞拿走一个盘子后,这个大数组就出现了空洞,后面元素的"第几摞第几个"全部错位,要么整体搬移($O(n)$ 且实现繁琐),要么额外维护每摞的真实长度——那还不如一开始就把每摞独立存。

于是换成显式的二维结构:外层是一个可变长列表 stacks,每个元素是一摞盘子(内层是一个栈)。这样每摞的长度天然独立,popAt 只影响它自己那一摞,不会波及别人。

由此确定要维护的不变量stacks 中不存在空摞;除最后一摞外,前面每一摞的元素个数恰好等于 cap(最后一摞的个数在 1..cap 之间)。前半句让"stacks 的下标"和"题目说的第几摞"始终对齐,也让 pop() 可以无脑地去 stacks 的末尾拿;后半句让 push 只需检查最后一摞是否已满,而不必扫描全部摞去找"还有空位的那一摞"。

维持这个不变量只需要两条规则。push:若 stacks 为空或最后一摞已满,先追加一个新的空摞,然后压入最后一摞——这保证了"只有最后一摞可能不满"。popAt(index):弹出该摞栈顶后,若它变空就把整摞从 stacks 里删掉——这保证了"不存在空摞"。有了这两条,pop() 就可以直接写成 popAt(stacks.size() - 1),两条路径共用同一份边界处理,不容易写歪。

解题步骤

  • 构造函数只记录 cap,不预建任何摞。懒建摞让"空结构"的表示唯一(stacks 为空),省掉"有一摞但它是空的"这种中间态;中间态越少,不变量越好维持。
  • push(val) 先处理 cap <= 0 直接返回。这是必须的短路:若不拦截,后面的"最后一摞已满就新建"会因为 size() == 0 == cap 恒成立而每次都新建一摞空摞,既违反不变量又会无限增长。
  • push(val) 判断"需不需要新建摞"。条件是 stacks.isEmpty() || 最后一摞.size() == cap。只看最后一摞就够了,正是因为不变量保证了前面的摞全是满的——这也是为什么 popAt 从中间掏走一个盘子后不需要把后面的盘子往前补:题目允许中间摞不满,代价只是 push 不会去填这些空位,而这并不违反外部可见的栈语义。
  • pop() 直接委托给 popAt(stacks.size() - 1)。复用而不是复制一遍逻辑,好处是"越界返回 -1"和"空摞要删除"这两件事只写一遍。当 stacks 为空时,传入的是 -1,正好被 popAt 的越界检查拦住返回 -1,不需要额外判空。
  • popAt(index) 先做双重合法性检查,再弹出,最后清理空摞。先查 index 是否落在 [0, stacks.size()),再查该摞是否为空(有不变量兜底,理论上不会为空,但保留这层检查能让代码对越界调用免疫)。弹出后若该摞空了,stacks.remove(index) 把它删掉,后面的摞下标整体前移——这正是题目期望的行为。

cap = 2,操作序列 push(1) → push(2) → push(3) → popAt(0) → pop() → pop() 走一遍

初始 stacks = []push(1):列表为空,新建一摞,stacks = [[1]]push(2):最后一摞有 1 个 < 2,直接压,stacks = [[1, 2]]push(3):最后一摞已满(2 == cap),新建一摞后压入,stacks = [[1, 2], [3]]

popAt(0)index = 0 合法,第 0 摞栈顶是 2,弹出得到 2,该摞变成 [1] 非空,不删除,stacks = [[1], [3]],返回 2。注意此时第 0 摞只有 1 个盘子,没有满,但我们不去把 3 挪过来。

pop():委托 popAt(1),第 1 摞栈顶 3 弹出,该摞变空,从列表中删除,stacks = [[1]],返回 3

pop():委托 popAt(0),弹出 1,该摞变空被删除,stacks = [],返回 1。若此时再调一次 pop(),传入 popAt(-1) 被越界检查拦下,返回 -1,不会崩溃。

代码实现

class StackOfPlates {
    private final List<Deque<Integer>> stacks = new ArrayList<>();
    private final int cap;

    public StackOfPlates(int cap) {
        this.cap = cap;
    }

    public void push(int val) {
        if (cap <= 0) {
            return;
        }
        if (stacks.isEmpty() || stacks.get(stacks.size() - 1).size() == cap) {
            stacks.add(new ArrayDeque<>());
        }
        stacks.get(stacks.size() - 1).push(val);
    }

    public int pop() {
        return popAt(stacks.size() - 1);
    }

    public int popAt(int index) {
        if (index < 0 || index >= stacks.size()) {
            return -1;
        }
        Deque<Integer> stack = stacks.get(index);
        if (stack.isEmpty()) {
            return -1;
        }
        int val = stack.pop();
        if (stack.isEmpty()) {
            stacks.remove(index);
        }
        return val;
    }
}
type StackOfPlates struct {
    stacks [][]int
    cap    int
}

func Constructor(cap int) StackOfPlates {
    return StackOfPlates{cap: cap}
}

func (s *StackOfPlates) Push(val int) {
    if s.cap <= 0 {
        return
    }
    if len(s.stacks) == 0 || len(s.stacks[len(s.stacks)-1]) == s.cap {
        s.stacks = append(s.stacks, []int{})
    }
    last := len(s.stacks) - 1
    s.stacks[last] = append(s.stacks[last], val)
}

func (s *StackOfPlates) Pop() int {
    return s.PopAt(len(s.stacks) - 1)
}

func (s *StackOfPlates) PopAt(index int) int {
    if index < 0 || index >= len(s.stacks) {
        return -1
    }
    stack := s.stacks[index]
    if len(stack) == 0 {
        return -1
    }
    val := stack[len(stack)-1]
    s.stacks[index] = stack[:len(stack)-1]
    if len(s.stacks[index]) == 0 {
        s.stacks = append(s.stacks[:index], s.stacks[index+1:]...)
    }
    return val
}

复杂度分析

  • 时间复杂度pushpop 都是均摊 $O(1)$——只访问最后一摞的栈顶,列表尾部的追加与删除是均摊常数。popAt(index) 是 $O(1)$,但当该摞被掏空需要从列表中间移除时是 $O(m)$,m 为当前摞数(最坏 $O(n / cap)$)。
  • 空间复杂度:$O(n)$,n 为当前存放的盘子总数。由于不变量保证没有空摞、且只有最后一摞可能不满,摞的数量不超过 $\lceil n / cap \rceil + 1$,没有额外浪费。

关键点总结

  • "外部像一个栈、内部是多个容器"的题,先把不变量定在容器层面。本题的两条不变量——没有空摞、只有最后一摞可能不满——一旦确立,三个接口的实现几乎是被推导出来的,不需要临场想边界。
  • 能复用就别复制:让 poppopAt。两条路径共用同一份"越界返回 -1 + 清理空摞"逻辑,避免了改一处忘一处;同时 stacks 为空时传入 -1 被越界检查自然接住,省掉一次判空。
  • 删除中间元素后,是否要"补位"要由题意决定,不要想当然。本题不补位(中间摞允许不满),如果补位反而会让 push 无法只看最后一摞,复杂度从 $O(1)$ 退化。设计题里每一个"要不要维持整齐"的选择都应该拿复杂度和题意去衡量。
  • 退化参数必须显式短路cap == 0 这类参数会让"满/不满"的判断恒真,进而破坏循环或增长逻辑。凡是拿参数做容量比较的设计题,都要先问一句"这个参数取 0 或负数会怎样"。
  • 面试视角:主动说明 popAt 的两种语义并确认。有的面试官期望"把后面的盘子往前挪以保持每摞满",有的接受"直接删空摞"。开口前先问一句"掏空中间一摞后,后面的盘子需要前移吗",既显示你读懂了难点,也避免写完被推翻。链表版实现(每摞用双向链表节点串起来)在需要前移时更灵活,可以作为追问的备选答案。

易错点总结

  • 错误写法:push 里不判 cap <= 0 → 用例 cap = 0,然后 push(1):条件 最后一摞.size() == cap0 == 0 时恒成立,每次 push 都新建一摞再压入,stacks 变成 [[1]] 且这一摞已"超容量";连续 push 会不断新建,行为完全不可预期。
  • 错误写法:popAt 弹出后不删除空摞 → 用例 cap = 1push(1), push(2), popAt(0), pop()popAt(0) 后第 0 摞变成空摞但仍留在列表里,pop() 走到最后一摞返回 2 看似正确,但再 pop() 会落到那个空摞上返回 -1,而正确答案是没有元素可弹(结构其实已空)——下标语义已经错位。
  • 错误写法:pop() 写成 popAt(stacks.size())(漏减 1) → 用例 cap = 2push(1), pop()stacks.size() 是 1,popAt(1) 越界返回 -1,而正确答案是 1
  • 错误写法:pop() 前先判 if (stacks.isEmpty()) return -1;popAt 里去掉了越界检查 → 用例直接调 popAt(5):数组下标越界抛异常。两处检查删掉任意一处都不安全,稳妥做法是把检查集中在 popAt 里,pop 完全依赖它。
  • 错误写法:Java 里 stacks.remove(index)index 声明成 Integer → 用例 popAt(0)remove(Object) 重载被选中,按值查找一个等于 0 的元素,找不到就什么也不删,空摞永远留在列表里。必须保证参数是基本类型 int
  • 错误写法:popAt 里先判空摞再取 stacks.get(index),但把两个检查的顺序写反 → 用例 popAt(-1):先执行 stacks.get(-1)IndexOutOfBoundsException。越界检查必须在任何下标访问之前。
  • 错误写法:Go 里 Push 用值接收者 func (s StackOfPlates) Push(val int) → 用例 push(1), pop()Push 修改的是结构体副本,主对象的 stacks 始终为空,pop() 返回 -1
  • 错误写法:Go 里删除中间摞写成 s.stacks = append(s.stacks[:index], s.stacks[index+1:])(漏了 ... → 编译报错,类型不匹配:append 的可变参数需要展开切片。
  • 错误写法:Go 里 PopAt 直接 stack = stack[:len(stack)-1] 却不写回 s.stacks[index] → 用例 cap = 3push(1), push(2), popAt(0), popAt(0):局部变量 stack 的长度变了但底层数组和 s.stacks[index] 的长度没变,第二次 popAt(0) 仍然看到 2 个元素,返回的是已经被弹过的 2。切片的长度是值语义,必须显式写回。
  • 错误写法:用 stacks.get(stacks.size() - 1).size() >= cap 之外还额外维护一个 total 计数并用它算摞号 → 用例 cap = 2push(1), push(2), push(3), popAt(0), push(4)popAt 让第 0 摞变成 1 个元素,但 total 只知道总数是 3,按 total / cap 算出的摞号是 1,而实际应该压进第 1 摞的判断依赖的是"最后一摞的实际长度"。任何用总数反推分布的做法,在允许中间删除后都会失效。

相似题目

题目 难度 考察点
1172. 餐盘栈 困难 同样是多摞盘子,但要求 push 填补最左侧空位,需要额外的有序集合
155. 最小栈 中等 单栈但要 $O(1)$ 查询最小值,靠辅助栈同步维护极值
面试题 03.04. 化栈为队 简单 用两个栈模拟另一种容器,重点在均摊分析而非容量分摞
1381. 设计一个支持增量操作的栈 中等 栈上叠加区间增量,用懒标记把批量加法降到 $O(1)$
622. 设计循环队列 中等 固定容量下靠头尾指针取模复用空间,容量满的判定是核心