1. 题目背景与需求分析
LeetCode 1172题"餐盘栈(Dinner Plates)"是一个经典的栈结构设计问题,要求实现一个特殊的栈集合。这个数据结构需要支持以下几种操作:
- 初始化一个固定容量的栈集合(构造函数DinnerPlates(int capacity))
- 将元素压入最左侧未满的栈(push(int val))
- 弹出最右侧非空栈的栈顶元素(pop())
- 弹出指定栈的栈顶元素(popAtStack(int index))
这个问题的实际应用场景类似于餐厅中叠放餐盘的系统:多个栈(相当于餐盘架)以一定顺序排列,当需要放入餐盘时,总是选择最左边还有空间的架子;当需要取用餐盘时,总是从最右边非空的架子取用。这种数据结构在资源池管理、内存分配等场景中都有实际应用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据结构选型与设计思路
2.1 核心数据结构选择
解决这个问题需要精心设计数据结构,主要考虑以下几点:
- 栈集合管理:需要一个容器来管理多个栈,通常选择数组或动态数组(vector)来存储各个栈
- 快速查找未满栈:需要高效找到最左侧未满的栈进行push操作
- 快速查找非空栈:需要高效找到最右侧非空的栈进行pop操作
- 支持随机访问:popAtStack操作需要能够快速访问任意位置的栈
基于以上需求,我们可以选择以下数据结构组合:
- stacks:vector<stack
>,存储所有栈实例 - available:优先队列(最小堆),记录当前有空位的栈索引,用于快速找到最左侧未满栈
- rightMost:记录当前最右侧非空栈的索引,用于快速pop操作
2.2 关键操作的时间复杂度分析
-
push操作:
- 从优先队列获取最小索引:O(log n)
- 向对应栈压入元素:O(1)
- 如果栈满,从优先队列移除:O(log n)
- 总时间复杂度:O(log n)
-
pop操作:
- 访问rightMost指向的栈:O(1)
- 弹出栈顶元素:O(1)
- 更新rightMost:最坏情况O(n)
- 总时间复杂度:平均O(1),最坏O(n)
-
popAtStack操作:
- 直接访问指定索引的栈:O(1)
- 弹出栈顶元素:O(1)
- 如果栈从满变为未满,加入优先队列:O(log n)
- 总时间复杂度:O(log n)
3. 具体实现与代码解析
3.1 类定义与成员变量
cpp复制class DinnerPlates {
private:
int capacity; // 每个栈的容量
vector<stack<int>> stacks; // 存储所有栈
priority_queue<int, vector<int>, greater<int>> available; // 可用栈索引的最小堆
int rightMost; // 当前最右侧非空栈的索引
public:
DinnerPlates(int capacity) {
this->capacity = capacity;
rightMost = -1; // 初始时没有栈
}
void push(int val) {
// 实现细节见3.2节
}
int pop() {
// 实现细节见3.3节
}
int popAtStack(int index) {
// 实现细节见3.4节
}
};
3.2 push操作实现
cpp复制void push(int val) {
// 如果没有可用栈或者所有栈都满了,需要新建一个栈
if (available.empty()) {
stacks.emplace_back(); // 添加一个新栈
available.push(stacks.size() - 1); // 新栈索引加入可用队列
}
// 获取最左侧可用栈的索引
int index = available.top();
stacks[index].push(val);
// 更新rightMost
rightMost = max(rightMost, index);
// 如果当前栈已满,从可用队列中移除
if (stacks[index].size() == capacity) {
available.pop();
}
}
3.3 pop操作实现
cpp复制int pop() {
// 如果没有栈或者所有栈都为空
if (rightMost == -1) {
return -1;
}
int val = stacks[rightMost].top();
stacks[rightMost].pop();
// 如果栈从非空变为空,需要更新rightMost
while (rightMost >= 0 && stacks[rightMost].empty()) {
rightMost--;
}
// 当前栈现在肯定未满,加入可用队列
if (stacks[rightMost + 1].size() == capacity - 1) {
available.push(rightMost + 1);
}
return val;
}
3.4 popAtStack操作实现
cpp复制int popAtStack(int index) {
// 检查索引是否有效或栈是否为空
if (index < 0 || index >= stacks.size() || stacks[index].empty()) {
return -1;
}
int val = stacks[index].top();
stacks[index].pop();
// 如果栈从满变为未满,加入可用队列
if (stacks[index].size() == capacity - 1) {
available.push(index);
}
// 更新rightMost
if (index == rightMost && stacks[index].empty()) {
while (rightMost >= 0 && stacks[rightMost].empty()) {
rightMost--;
}
}
return val;
}
4. 边界条件与测试用例
4.1 常见边界情况
- 初始化容量为0或负数:题目保证capacity为正整数,实际工程中需要处理
- 连续push超过初始容量:应自动创建新栈
- pop空栈:应返回-1或抛出异常
- popAtStack无效索引:应返回-1
- 混合操作:push、pop、popAtStack随机组合
4.2 测试用例示例
cpp复制void testDinnerPlates() {
DinnerPlates D(2); // 初始化容量为2的餐盘栈
D.push(1);
D.push(2);
D.push(3); // 应该创建第二个栈
assert(D.pop() == 3); // 从第二个栈弹出
assert(D.popAtStack(0) == 2); // 从第一个栈弹出
assert(D.pop() == 1); // 从第一个栈弹出
assert(D.pop() == -1); // 所有栈都为空
}
5. 性能优化与扩展思考
5.1 pop操作的最坏时间复杂度优化
当前实现中,pop操作在最坏情况下需要O(n)时间(当弹出元素后需要更新rightMost)。可以通过以下方法优化:
- 维护一个非空栈的有序集合:使用TreeSet或类似结构记录非空栈索引
- 延迟更新策略:只在必要时才更新rightMost,减少不必要的遍历
5.2 内存管理优化
当多个栈变为空时,可以考虑释放这些栈的内存。但需要注意:
- 索引一致性:释放栈会影响其他栈的索引
- 实现复杂度:需要重新映射索引,增加实现复杂度
5.3 扩展功能
- 动态容量:允许每个栈有不同的容量
- 批量操作:支持一次push/pop多个元素
- 栈合并/分裂:根据需求动态调整栈数量
6. 同类问题与变种
- LeetCode 895. Maximum Frequency Stack:基于频率的栈操作
- LeetCode 716. Max Stack:支持查找最大元素的栈
- 多栈共享存储空间:类似内存池管理的实现
- 带过期时间的栈元素:每个元素有TTL,过期自动移除
这类设计问题在面试中很常见,主要考察对基础数据结构的灵活运用和系统设计能力。掌握这类问题的关键在于:
- 明确每个操作的要求和约束
- 选择合适的数据结构组合
- 处理好各种边界条件
- 在时间复杂度和空间复杂度之间取得平衡
