1. 问题背景与需求分析
LeetCode 1172题"餐盘栈"是一个经典的栈结构变种问题,它模拟了一种特殊的餐厅餐具堆放场景。题目要求我们设计一个DinnerPlates类,该类能够支持以下操作:
- 初始化指定容量的栈(capacity)
- push(val):将元素压入最左侧未满的栈
- pop():弹出最右侧非空栈的栈顶元素
- popAtStack(index):弹出指定索引栈的栈顶元素
这个数据结构问题在实际工程中有诸多应用场景,比如内存分页管理、分布式存储系统中的数据分片、多线程任务调度等。理解并掌握这类数据结构的设计思想,对于提升算法能力和系统设计思维都大有裨益。
2. 核心数据结构设计
2.1 基础数据结构选择
解决这个问题需要同时考虑以下几个关键因素:
- 多个栈的动态管理
- 快速定位最左侧未满的栈
- 快速定位最右侧非空的栈
- 支持随机访问任意栈
基于这些需求,我们可以选择以下数据结构组合:
- 一个ArrayList来存储所有的栈(便于随机访问)
- 一个最小堆(优先队列)来记录有空位的栈索引
- 一个变量记录最右侧非空栈的索引
class DinnerPlates { private List<Stack<Integer>> stacks; private PriorityQueue<Integer> available; private int capacity; private int rightMost; public DinnerPlates(int capacity) { this.capacity = capacity; this.stacks = new ArrayList<>(); this.available = new PriorityQueue<>(); this.rightMost = -1; } }2.2 关键操作的时间复杂度分析
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| push(val) | O(log k) | k为有空位的栈数量 |
| pop() | O(1) | 直接访问rightMost指向的栈 |
| popAtStack() | O(log k) | 可能需要将空栈索引加入优先队列 |
3. 详细实现与边界处理
3.1 push操作的实现细节
push操作需要处理以下几种边界情况:
- 当前没有任何栈有空位时,需要新建一个栈
- 优先队列中的栈索引可能已经失效(该栈已被填满)
- 更新rightMost指针
public void push(int val) { // 处理没有可用栈的情况 if (available.isEmpty()) { stacks.add(new Stack<>()); available.add(stacks.size() - 1); } // 获取最小索引的空栈 int index = available.peek(); Stack<Integer> stack = stacks.get(index); // 压入元素 stack.push(val); // 更新rightMost rightMost = Math.max(rightMost, index); // 如果栈已满,从可用栈中移除 if (stack.size() == capacity) { available.poll(); } }3.2 popAtStack的特殊处理
popAtStack(index)操作有几个需要注意的细节:
- 索引越界检查
- 指定栈为空的情况
- 弹出元素后该栈变为有空位,需要加入优先队列
- 可能需要更新rightMost指针
public int popAtStack(int index) { // 边界检查 if (index < 0 || index >= stacks.size()) { return -1; } Stack<Integer> stack = stacks.get(index); if (stack.isEmpty()) { return -1; } // 弹出元素 int val = stack.pop(); // 如果栈从满变为未满,加入可用栈 if (stack.size() == capacity - 1) { available.offer(index); } // 更新rightMost指针 while (rightMost >= 0 && stacks.get(rightMost).isEmpty()) { rightMost--; } return val; }4. 性能优化与工程实践
4.1 内存管理优化
在实际工程实现中,我们可以考虑以下优化点:
- 当多个栈连续为空时,可以释放这些栈的内存
- 使用更高效的数据结构替代Stack类(如ArrayDeque)
- 对优先队列进行容量限制和动态调整
// 优化后的pop操作示例 public int pop() { // 处理所有栈都为空的情况 if (rightMost == -1) { return -1; } Stack<Integer> stack = stacks.get(rightMost); int val = stack.pop(); // 栈从非空变为空,需要处理 if (stack.isEmpty()) { available.offer(rightMost); // 更新rightMost指针 while (rightMost >= 0 && stacks.get(rightMost).isEmpty()) { rightMost--; } } else if (stack.size() == capacity - 1) { available.offer(rightMost); } return val; }4.2 并发安全考虑
如果这个数据结构需要在多线程环境下使用,我们需要考虑:
- 对关键操作加锁
- 使用线程安全的数据结构
- 减少锁的粒度以提高并发性能
// 线程安全版本的push操作 public synchronized void push(int val) { // 原有实现... }5. 测试用例设计与验证
5.1 基础功能测试
DinnerPlates D = new DinnerPlates(2); // 初始化容量为2的餐盘栈 D.push(1); D.push(2); D.push(3); D.push(4); D.push(5); // 栈状态: [ [1,2], [3,4], [5] ] assert D.popAtStack(0) == 2; // 返回2 // 栈状态: [ [1], [3,4], [5] ] assert D.pop() == 5; // 返回5 // 栈状态: [ [1], [3,4] ] assert D.pop() == 4; // 返回45.2 边界条件测试
- 测试capacity为1的极端情况
- 测试连续popAtStack操作
- 测试混合push/pop操作序列
- 测试大量数据下的性能表现
6. 实际应用场景扩展
这种数据结构模式在以下场景中有实际应用价值:
- 内存分页管理:操作系统需要管理多个内存页,每个页有固定容量,类似于我们的栈
- 分布式存储:数据分片存储在多个节点上,每个节点有容量限制
- 任务调度系统:多个工作队列,每个队列有最大任务数限制
- 数据库连接池:管理多个连接池,每个池有最大连接数限制
理解这类问题的解决方案,可以帮助我们在实际工程中设计更高效的系统架构。例如,在实现一个分布式缓存系统时,我们可以借鉴这种多栈管理的思路来设计数据分片和负载均衡策略。
7. 算法优化与变种思考
7.1 替代数据结构方案
除了使用优先队列,我们还可以考虑以下方案:
- 使用TreeSet维护可用栈索引(同样O(log n)时间复杂度)
- 使用双向链表维护非空栈(优化pop操作)
- 使用位图记录栈的空满状态(空间优化)
7.2 问题变种与扩展
- 如果capacity非常大(如10^9),如何优化空间?
- 如果支持动态调整capacity,如何设计?
- 如果push/pop操作有优先级概念,如何扩展?
- 如果栈可以自动合并/分裂,如何实现?
这些扩展问题可以帮助我们更深入地理解数据结构的灵活应用。在实际面试中,面试官可能会基于你的初始解决方案提出这些变种问题,考察你的思维灵活性和算法设计能力。