栈专题:两道经典栈算法题全解析(字典序最大出栈序列 + O (1) 最小值栈)
栈是算法中最基础的数据结构之一,本文结合「字典序最大的栈出栈序列」和「单车道停车场(O (1) 最小值栈)」两道经典题,从场景理解、核心原理、代码实现到复杂度分析,系统总结栈的核心用法和优化技巧。
一、第一道题:字典序最大的合法出栈序列
1. 题目核心场景
给定 1~n 的排列 P,按顺序入栈,可随时出栈,要求输出字典序最大的合法出栈序列(合法 = 按顺序入栈、仅栈顶出栈;字典序最大 = 序列前半部分尽可能大)。
2. 核心概念铺垫
(1)合法出栈序列
- 入栈顺序不可打破:必须按 P [0]→P [1]→…→P [n-1] 入栈;
- 出栈仅能弹栈顶:不能跳过栈顶直接弹栈底 / 中间元素。
例:P=[3,1,2,4],合法序列如 [4,2,1,3],非法序列如 [3,4,2,1](无法通过合法入栈出栈生成)。
(2)字典序
本质是「查字典规则」:从左到右逐个比较数字,第一个位置数字越大,字典序越大;若第一个相同,再比第二个,以此类推。
例:[4,2,1,3] > [3,4,2,1](第一个数字 4>3);[5,4,1] > [5,3,2](第二个数字 4>3)。
(3)贪心算法
每一步做「当前最优选择」,最终得到全局最优:本题中 “当前最优” = 弹出「栈顶 > 后续所有未入栈元素最大值」的元素(保证前面的数字尽可能大)。
(4)后缀最大值(suffixMax)
- 定义:suffixMax[i] = 数组 P 中从 i 到末尾的最大值;
- 作用:快速判断「后续未入栈元素的最大值」,避免遍历,保证算法效率。
3. 核心解题思路
- 预处理后缀最大值数组:从后往前遍历 P,计算每个位置的后缀最大值;
- 贪心 + 栈操作:
-
- 遍历 P,逐个入栈;
-
- 入栈后循环检查:若栈顶 > 后续未入栈元素的最大值,弹出栈顶(加入结果);
- 处理剩余元素:遍历结束后,栈中剩余元素全部弹出(后续无新元素);
- 结果拼接:用 StringBuilder 高效拼接结果,避免字符串频繁拼接的性能损耗。
4. 完整代码实现
import java.util.Scanner;
import java.util.Deque;
import java.util.ArrayDeque;
public class Main {
public static void main(String[] args) {
// 输入处理(Scanner版,贴合新手模板)
Scanner in = new Scanner(System.in);
int n = in.nextInt();
int[] P = new int[n];
for (int i = 0; i < n; i++) {
P[i] = in.nextInt();
}
// 步骤1:预处理后缀最大值数组
int[] suffixMax = new int[n];
suffixMax[n - 1] = P[n - 1];
for (int i = n - 2; i >= 0; i--) {
suffixMax[i] = Math.max(P[i], suffixMax[i + 1]);
}
// 步骤2:贪心栈操作
Deque<Integer> stack = new ArrayDeque<>();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < n; i++) {
stack.push(P[i]); // 当前元素入栈
// 循环检查栈顶是否可出栈
while (!stack.isEmpty()) {
int top = stack.peek();
int nextMax = (i + 1 < n) ? suffixMax[i + 1] : -1;
if (top > nextMax) {
sb.append(stack.pop()).append(" ");
} else {
break;
}
}
}
// 步骤3:处理栈中剩余元素
while (!stack.isEmpty()) {
sb.append(stack.pop()).append(" ");
}
// 输出结果(去除末尾空格)
System.out.println(sb.toString().trim());
in.close();
}
}
5. 复杂度分析
| 指标 | 复杂度 | 说明 |
|---|---|---|
| 时间复杂度 | O(n) | 每个元素最多入栈、出栈各 1 次,无嵌套循环;后缀最大值计算为 O (n) |
| 空间复杂度 | O(n) | 后缀最大值数组、栈、StringBuilder 均占用 O (n) 空间(空间换时间) |
6. 关键 API 说明
| API | 作用 | 场景 |
|---|---|---|
| Deque.push() | 元素入栈顶 | 模拟入栈操作 |
| Deque.peek() | 获取栈顶(不弹出) | 判断栈顶是否可出栈 |
| Deque.pop() | 弹出栈顶 | 合法出栈时调用 |
| StringBuilder.append() | 高效拼接字符串 | 避免 n 规模下字符串拼接的性能损耗 |
| Math.max() | 计算两个数的最大值 | 构建后缀最大值数组 |
二、第二道题:O (1) 时间获取最小值的栈(单车道停车场)
1. 题目核心场景
模拟单车道停车场的车辆驶入 / 驶离,要求实现 4 个 O (1) 操作:
| 方法 | 功能 | 对应栈操作 |
|---|---|---|
| push(int node) | 车辆 node 驶入(停最里面) | 入栈 |
| pop() | 最门口车辆驶离 | 出栈 |
| top() | 返回最门口车号 | 取栈顶 |
| min() | 返回场内最小车号 | O (1) 查最小值 |
2. 核心概念铺垫
(1)访问修饰符(public/private)
- public:修饰对外暴露的方法(push/pop/top/min),允许外部调用;
- private:修饰内部变量(主栈 / 辅助栈),保护数据不被外部篡改;
- 无修饰符(默认):仅同包可访问,外部调用会失败,不推荐。
(2)参数名 node
node 是 push 方法的参数名,仅代表「驶入车辆的编号」,可改为 carNum/x 等,不影响功能。
3. 核心解题思路:双栈设计
普通栈无法实现 O (1) 查最小值,引入「辅助最小值栈」与主栈同步操作:
- 主栈:存储车辆编号(模拟停车场车辆);
- 辅助栈:维护「当前主栈的最小值」,栈顶始终是主栈最小值。
关键规则
- push 同步:主栈入一个元素,辅助栈必入一个元素:
-
- 辅助栈空 → 入当前元素;
-
- 当前元素 ≤ 辅助栈顶 → 入当前元素(新最小值);
-
- 当前元素 > 辅助栈顶 → 入辅助栈顶(最小值不变,维持栈长度)。
- pop 同步:主栈弹出一个元素,辅助栈同步弹出(保证双栈长度一致);
- min 查询:直接取辅助栈顶(O (1))。
4. 完整代码实现
import java.util.Stack;
public class Solution {
// 主栈:存储车辆编号(private 保护内部数据)
private Stack<Integer> mainStack = new Stack<>();
// 辅助栈:维护当前最小值
private Stack<Integer> minStack = new Stack<>();
/**
* 车辆驶入(入栈)
* @param node 驶入车辆编号
*/
public void push(int node) {
mainStack.push(node);
// 辅助栈同步维护最小值
if (minStack.isEmpty() || node <= minStack.peek()) {
minStack.push(node);
} else {
minStack.push(minStack.peek()); // 复用当前最小值
}
}
/**
* 最门口车辆驶离(出栈)
*/
public void pop() {
// 判空避免空栈异常
if (!mainStack.isEmpty() && !minStack.isEmpty()) {
mainStack.pop();
minStack.pop();
}
}
/**
* 返回最门口车号(取栈顶)
* @return 栈顶车辆编号
*/
public int top() {
return mainStack.peek();
}
/**
* 返回场内最小车号(O(1))
* @return 当前最小值
*/
public int min() {
return minStack.peek();
}
}
5. 核心疑问解答
Q1:minStack.push(minStack.peek()) 为什么重复压入栈顶?
A:核心是「维持双栈长度一致」+「保留当前最小值」:
- 主栈入一个元素,辅助栈必须入一个,否则 pop 后长度不一致;
- 新元素不改变最小值时,重复压入栈顶,保证辅助栈顶始终是当前最小值。
Q2:pop 同步操作后,minStack 为何仍正确?
A:辅助栈的每个元素对应「主栈到该位置的最小值」,pop 时同步砍掉最后一个 “最小值标记”,剩余栈顶即为主栈剩余元素的最小值。
测试用例验证
| 操作 | 主栈状态 | 辅助栈状态 | top() | min() |
|---|---|---|---|---|
| push(8) | [8] | [8] | 8 | 8 |
| push(4) | [8,4] | [8,4] | 4 | 4 |
| push(6) | [8,4,6] | [8,4,4] | 6 | 4 |
| push(2) | [8,4,6,2] | [8,4,4,2] | 2 | 2 |
| pop() | [8,4,6] | [8,4,4] | 6 | 4 |
| pop() | [8,4] | [8,4] | 4 | 4 |
6. 复杂度分析
| 指标 | 复杂度 | 说明 |
|---|---|---|
| 时间复杂度 | O(1) | 所有方法均为单次栈操作,无循环 |
| 空间复杂度 | O(n) | 辅助栈最坏情况存储 n 个元素(空间换时间) |
三、核心知识点总结(跨两道题)
1. 栈的核心特性
- 先入后出(FILO):两道题均基于栈的这一核心特性(出栈仅能弹栈顶);
- 栈的常用操作:push(入栈)、pop(出栈)、peek(取栈顶),均为 O (1) 时间。
2. 算法优化思路
- 空间换时间:第一道题用后缀最大值数组避免遍历,第二道题用辅助栈实现 O (1) 查最小值;
- 贪心策略:第一道题通过 “优先弹出当前最大元素” 实现字典序最大;
- 同步操作:第二道题通过双栈同步,保证最小值查询的正确性。
3. 工程化技巧
- 输入输出优化:第一道题用 Scanner(新手友好),大数据量可换 BufferedReader;
- 字符串拼接:用 StringBuilder 替代 String 拼接,避免性能损耗;
- 数据保护:用 private 修饰内部变量,public 暴露方法,符合面向对象设计原则;
- 异常防护:第二道题 pop 时判空,避免空栈异常。
4. 举一反三
- 字典序最大出栈序列:可扩展为 “字典序最小出栈序列”(贪心策略改为优先弹出最小元素);
- O (1) 最小值栈:可扩展为 “O (1) 最大值栈”(辅助栈逻辑改为维护最大值)。
最后
栈是算法的基础数据结构,这两道题分别覆盖了「栈的贪心应用」和「栈的优化扩展」,掌握这两类场景后,大部分栈相关的算法题都能举一反三。建议结合测试用例手动模拟栈的操作过程,理解每一步的核心逻辑~