1. 逆波兰表达式求值:栈的经典应用
逆波兰表达式(Reverse Polish Notation,RPN)是一种数学表达式的表示方法,它将运算符放在操作数之后。这种表示法最大的优点是不需要括号来标识运算的优先级,所有运算顺序完全由运算符的位置决定。在计算机科学中,这种表达式特别适合用栈结构来处理。
1.1 逆波兰表达式的计算原理
逆波兰表达式的计算遵循以下规则:
- 从左到右扫描表达式
- 遇到数字时,将数字压入栈
- 遇到运算符时,弹出栈顶的两个数进行计算,并将结果压入栈
- 重复上述过程直到表达式最右端
这种计算方式之所以有效,是因为它完美利用了栈的后进先出(LIFO)特性。当遇到运算符时,我们总是能立即获取到它需要的两个操作数(即最近压入的两个数字)。
1.2 Java实现详解
让我们仔细分析提供的Java实现代码:
java复制class Solution {
public int evalRPN(String[] tokens) {
Deque<Integer> deque = new ArrayDeque<>();
for(String s : tokens){
if("+".equals(s)){
deque.push(deque.pop() + deque.pop());
}else if("-".equals(s)){
deque.push(-deque.pop() + deque.pop());
}else if("*".equals(s)){
deque.push(deque.pop() * deque.pop());
}else if("/".equals(s)){
int temp1 = deque.pop();
int temp2 = deque.pop();
deque.push(temp2 / temp1);
}else{
deque.push(Integer.valueOf(s));
}
}
return deque.pop();
}
}
几个关键点需要注意:
- 使用
Deque接口的ArrayDeque实现作为栈结构。虽然Java有Stack类,但ArrayDeque性能更好,是更现代的替代方案。 - 字符串比较使用
equals()方法而不是==,这是Java中比较字符串内容的正确方式。 - 减法和除法需要特别注意操作数的顺序。对于减法,我们需要先弹出的数作为减数;对于除法,先弹出的数作为除数。
1.3 边界条件与注意事项
在实际编码中,有几个容易出错的地方需要特别注意:
- 输入验证:虽然题目保证输入有效,但在实际应用中应该检查表达式是否合法。
- 除法处理:整数除法会截断小数部分,如果需要精确结果应该使用浮点数。
- 溢出问题:特别是乘法操作可能导致整数溢出,需要考虑使用更大的数据类型。
- 空输入:虽然题目不会出现,但实际应用中应该处理空数组的情况。
提示:在面试中,即使题目说明输入总是有效的,主动讨论边界条件和异常处理也能展示你的全面思考能力。
2. 滑动窗口最大值:单调队列的巧妙应用
滑动窗口问题是算法面试中的常客,它要求我们在一个数组或列表中,对于每个固定大小的"窗口",找出其中的最大值。暴力解法的时间复杂度是O(nk),而使用单调队列可以将复杂度优化到O(n)。
2.1 单调队列的工作原理
单调队列是一种特殊的队列,它保持队列中的元素单调递减(或递增)。对于滑动窗口最大值问题,我们使用单调递减队列:
- 队列头部始终是当前窗口的最大值
- 当新元素进入窗口时,从队列尾部开始移除比它小的元素,保持队列单调性
- 当窗口滑动时,检查队列头部元素是否已经不在窗口中,如果是则移除
这种设计确保了我们可以用O(1)时间获取每个窗口的最大值,而维护队列的均摊时间复杂度也是O(1)。
2.2 代码实现解析
提供的Java实现使用了自定义的MyQueue类来封装单调队列的操作:
java复制class MyQueue {
Deque<Integer> deque = new LinkedList<>();
void poll(int val) {
if (!deque.isEmpty() && val == deque.peek()) {
deque.poll();
}
}
void add(int val) {
while (!deque.isEmpty() && val > deque.getLast()) {
deque.removeLast();
}
deque.add(val);
}
int peek() {
return deque.peek();
}
}
class Solution {
public int[] maxSlidingWindow(int[] nums, int k) {
if (nums.length == 1) {
return nums;
}
int len = nums.length - k + 1;
int[] res = new int[len];
int n = 0;
MyQueue myQueue = new MyQueue();
for (int i = 0; i < k; i++) {
myQueue.add(nums[i]);
}
res[n++] = myQueue.peek();
for (int i = k; i < nums.length; i++) {
myQueue.poll(nums[i - k]);
myQueue.add(nums[i]);
res[n++] = myQueue.peek();
}
return res;
}
}
关键点分析:
poll(int val)方法:当窗口滑动时,检查移出窗口的元素是否是当前队列的最大值(即队首元素),如果是则移除。add(int val)方法:维护队列的单调递减性,移除所有小于当前值的尾部元素。- 主方法中先初始化第一个窗口,然后滑动窗口逐个处理。
2.3 时间复杂度分析
虽然add操作中有循环,但每个元素最多被加入和移除队列各一次,因此整个算法的均摊时间复杂度是O(n),比暴力解法的O(nk)要好得多。
3. 前K个高频元素:堆的典型应用场景
在数据处理中,经常需要找出出现频率最高的前K个元素。这个问题可以通过哈希表统计频率,然后使用堆(优先队列)来高效地找出前K个元素。
3.1 算法思路
- 使用哈希表统计每个元素的出现频率
- 维护一个大小为K的小顶堆(最小堆)
- 遍历哈希表,将元素加入堆中,当堆大小超过K时,移除堆顶(当前最小的元素)
- 最终堆中剩下的就是前K个高频元素
使用小顶堆而不是大顶堆的原因是:我们需要保留最大的K个元素,而小顶堆可以让我们高效地移除最小的元素,从而保留较大的元素。
3.2 Java实现详解
java复制class Solution {
public int[] topKFrequent(int[] nums, int k) {
Map<Integer, Integer> map = new HashMap<>();
for (int n : nums)
map.put(n, map.getOrDefault(n, 0) + 1);
PriorityQueue<Integer> pq = new PriorityQueue<>((a, b) -> map.get(a) - map.get(b));
for (int key : map.keySet()) {
pq.offer(key);
if (pq.size() > k)
pq.poll();
}
int[] res = new int[k];
int i = 0;
while (!pq.isEmpty())
res[i++] = pq.poll();
return res;
}
}
关键点说明:
map.getOrDefault(n, 0):优雅地处理首次出现的元素- 优先队列的比较器
(a, b) -> map.get(a) - map.get(b):根据元素的频率构建小顶堆 pq.size() > k时移除堆顶:确保堆中始终保留最大的K个元素
3.3 替代方案与比较
除了堆方法,这个问题还有其他解决方案:
- 快速选择算法(Quickselect):平均时间复杂度O(n),但最坏情况O(n²)
- 桶排序:如果知道最大频率,可以创建频率桶,时间复杂度O(n)
堆方法在大多数情况下是较好的选择,因为:
- 时间复杂度稳定为O(n log k)
- 不需要知道最大频率
- 实现相对简单
4. 算法实战中的经验分享
在实际编码和面试中,处理这类算法问题有几个通用技巧:
4.1 调试与验证技巧
- 对于栈和队列问题,手动模拟小例子非常有效。例如对于逆波兰表达式,可以手动模拟栈的变化。
- 对于滑动窗口问题,画出窗口移动的过程图有助于理解。
- 对于堆问题,可以打印堆的内容来验证算法是否正确。
4.2 常见错误与避免方法
-
逆波兰表达式:
- 操作数顺序错误(特别是减法和除法)
- 忘记处理字符串到整数的转换
- 使用
==而不是equals比较运算符
-
滑动窗口:
- 忘记处理窗口滑动时移出的元素
- 单调队列维护不正确,导致最大值不准确
- 边界条件处理不当(如数组长度等于窗口大小)
-
前K个高频元素:
- 混淆大顶堆和小顶堆的使用场景
- 频率统计错误(特别是使用
map.get而不是map.getOrDefault) - 结果顺序不符合要求(堆弹出的顺序是升序的)
4.3 性能优化思考
-
对于Java实现,选择合适的数据结构实现很重要。例如:
ArrayDeque比LinkedList作为栈更高效- 原始类型数组比集合类更节省空间
-
考虑空间复杂度时,看看是否可以复用已有数组或使用更紧凑的数据结构。
-
在极端情况下(如非常大的输入),可能需要考虑并行处理或分布式算法。
算法能力的提升不仅在于记住解法,更在于理解背后的设计思想和适用场景。这三个问题分别展示了栈、单调队列和堆的典型应用,掌握这些数据结构的特点和使用场景,能够帮助我们更高效地解决各类算法问题。
