作为算法训练营的第十一天内容,我们进入了栈和队列专题的进阶部分。今天的三个题目在LeetCode中都属于中等或困难难度,但恰恰是面试中最常出现的题型。掌握这些问题的解法,不仅能加深对数据结构的理解,更能培养解决复杂问题的思维能力。
这三个题目看似独立,实则都体现了栈和队列在不同场景下的巧妙应用。逆波兰表达式展示了栈如何优雅地处理运算符优先级问题;滑动窗口最大值则通过单调队列优化了暴力解法;前K个高频元素则结合了哈希表和优先队列的优势。接下来,我将从思路解析到代码实现,详细拆解每个问题的解决过程。
逆波兰表达式(Reverse Polish Notation,RPN),又称后缀表达式,是一种不需要括号就能明确运算顺序的数学表示方法。其核心特点是运算符位于两个操作数之后,例如常规表达式"3 + 4"在后缀表达式中写作"3 4 +"。
使用栈求解逆波兰表达式的算法之所以高效,是因为它完美契合了后缀表达式的计算特性。当遇到数字时压栈,遇到运算符时弹出栈顶两个元素进行计算,这种后进先出的处理方式正是栈数据结构的专长。
关键点:逆波兰表达式的优势在于完全消除了运算符优先级和括号的歧义,计算过程可以严格从左到右线性处理,这使得栈结构成为其天然的解码器。
在实现过程中,有几个关键细节需要特别注意:
操作数顺序问题:对于减法和除法,操作数的顺序直接影响结果。例如表达式"4 3 -"应该计算为4-3=1,而不是3-4=-1。在代码中,我们需要注意先弹出的是右操作数,后弹出的是左操作数。
数字转换处理:虽然题目保证输入合法,但在实际工程中应该添加对数字格式的校验。Integer.valueOf()方法在遇到非法数字时会抛出NumberFormatException。
栈的选择:Java中虽然提供了Stack类,但官方推荐使用Deque接口的实现类(如LinkedList)来模拟栈操作。这是因为Stack继承自Vector,具有同步开销,而Deque提供了更完整的栈操作API。
java复制class Solution {
public int evalRPN(String[] tokens) {
// 使用LinkedList实现Deque作为栈,比Stack更高效
Deque<Integer> stack = new LinkedList<>();
for (String token : tokens) {
if (isOperator(token)) {
// 处理运算符:弹出两个操作数并计算
int b = stack.pop(); // 第二个操作数
int a = stack.pop(); // 第一个操作数
int result = calculate(a, b, token);
stack.push(result);
} else {
// 处理数字:转换为整数后压栈
stack.push(Integer.parseInt(token));
}
}
return stack.pop();
}
private boolean isOperator(String s) {
return s.equals("+") || s.equals("-") || s.equals("*") || s.equals("/");
}
private int calculate(int a, int b, String op) {
switch (op) {
case "+": return a + b;
case "-": return a - b;
case "*": return a * b;
case "/": return a / b; // 题目保证除数不为0
default: throw new IllegalArgumentException("Invalid operator");
}
}
}
时间复杂度:O(n),其中n是tokens数组的长度。每个元素只被处理一次,所有栈操作都是O(1)时间。
空间复杂度:O(n),最坏情况下栈中需要存储所有操作数(当所有运算符都在表达式末尾时)。
实际工程中可以考虑的优化:
滑动窗口最大值问题最直观的解法是暴力法:对每个窗口遍历其中的所有元素找出最大值。对于一个长度为n的数组和大小为k的窗口,这种方法的时间复杂度是O(nk),当n和k都很大时(如n=10^5,k=10^4),这种解法显然无法接受。
单调队列(Monotonic Queue)是解决此类滑动窗口极值问题的利器。其核心思想是维护一个队列,其中元素按照某种单调顺序排列(本题中是单调递减),并且及时移除不再属于当前窗口的元素。
这种做法的精妙之处在于:
java复制class Solution {
public int[] maxSlidingWindow(int[] nums, int k) {
if (nums == null || nums.length == 0) return new int[0];
int n = nums.length;
int[] result = new int[n - k + 1];
Deque<Integer> deque = new LinkedList<>(); // 存储索引
for (int i = 0; i < n; i++) {
// 移除超出窗口范围的元素
while (!deque.isEmpty() && deque.peekFirst() < i - k + 1) {
deque.pollFirst();
}
// 维护单调递减性质:移除所有小于当前元素的索引
while (!deque.isEmpty() && nums[deque.peekLast()] < nums[i]) {
deque.pollLast();
}
// 添加当前元素索引
deque.offerLast(i);
// 当窗口完整时记录最大值
if (i >= k - 1) {
result[i - k + 1] = nums[deque.peekFirst()];
}
}
return result;
}
}
时间复杂度:O(n),每个元素最多被加入和移除队列各一次。
空间复杂度:O(k),队列最多存储k个元素。
单调队列的技巧不仅适用于最大值问题,还可以解决:
在实际工程中,这种技术可以应用于:
统计元素频率是这个问题的基础步骤,除了使用HashMap外,还可以考虑:
排序+计数:先排序数组,然后线性扫描统计连续相同元素的个数。时间复杂度O(nlogn),空间复杂度O(1)(如果不考虑输出空间)
TreeMap:自动按键排序,但统计频率的时间复杂度仍然是O(nlogn)
HashMap:最优选择,O(n)时间完成频率统计
获取前K个高频元素有多种实现方式:
大顶堆(Max-Heap):将所有元素入堆,然后弹出前K个。时间复杂度O(n + klogn),空间复杂度O(n)
小顶堆(Min-Heap):维护一个大小为K的小顶堆,当堆满时,只有比堆顶大的元素才能入堆。时间复杂度O(nlogk),空间复杂度O(k)
快速选择算法:类似快速排序的partition思想,平均时间复杂度O(n),最坏O(n^2)
对于K远小于n的情况(如K=10,n=10^6),小顶堆是更优的选择,因为它只需要O(nlogk)时间和O(k)空间。
java复制class Solution {
public int[] topKFrequent(int[] nums, int k) {
// 统计频率
Map<Integer, Integer> frequencyMap = new HashMap<>();
for (int num : nums) {
frequencyMap.put(num, frequencyMap.getOrDefault(num, 0) + 1);
}
// 创建小顶堆,按频率排序
PriorityQueue<Map.Entry<Integer, Integer>> heap =
new PriorityQueue<>((a, b) -> a.getValue() - b.getValue());
// 维护大小为K的堆
for (Map.Entry<Integer, Integer> entry : frequencyMap.entrySet()) {
heap.offer(entry);
if (heap.size() > k) {
heap.poll();
}
}
// 提取结果
int[] result = new int[k];
for (int i = k - 1; i >= 0; i--) {
result[i] = heap.poll().getKey();
}
return result;
}
}
在实际工程中,处理海量数据的前K问题时,还可以考虑:
多级哈希:当数据量太大无法单机处理时,可以先用分布式哈希统计频率,再汇总处理
计数排序:当元素范围有限时,可以使用计数数组代替HashMap
近似算法:对于实时性要求高但精度要求不严格的场景,可以使用抽样或sketch算法(如Count-Min Sketch)
这类技术的应用场景包括:
通过这三个问题的深入分析,我们可以总结出栈和队列在不同场景下的应用特点:
| 数据结构 | 适用场景 | 时间复杂度 | 空间复杂度 | 典型问题 |
|---|---|---|---|---|
| 栈 | 后进先出,递归/回溯 | O(n) | O(n) | 逆波兰表达式、括号匹配、DFS |
| 单调队列 | 滑动窗口极值 | O(n) | O(k) | 滑动窗口最大值、队列极值 |
| 优先队列 | Top K问题 | O(nlogk) | O(k) | 前K高频元素、合并K个有序链表 |
在实际面试中,面试官可能会对这些问题的变种进行考察,例如:
掌握这些核心算法不仅有助于通过技术面试,更能培养解决实际工程问题的思维能力。建议读者在理解这些解法后,尝试在LeetCode上寻找相关题目进行巩固练习。