1. 算法训练营第十一天核心内容解析
今天我们将深入探讨三个经典算法问题:逆波兰表达式求值、滑动窗口最大值和前K个高频元素。这三个问题分别代表了栈、队列和堆这三种基础数据结构的典型应用场景,也是面试中经常出现的考察点。
1.1 逆波兰表达式求值(150题)
逆波兰表达式(Reverse Polish Notation)是一种不需要括号就能明确运算顺序的数学表达式表示方法。它的核心特点是操作符位于两个操作数之后,因此也被称为后缀表达式。
实现思路非常清晰:
- 初始化一个空栈
- 遍历表达式中的每个元素
- 遇到数字就压入栈中
- 遇到运算符就从栈中弹出两个数字进行计算
- 将计算结果重新压入栈中
- 最后栈中剩下的唯一元素就是最终结果
特别注意:除法运算需要特殊处理,因为Python的除法与题目要求可能不一致。题目要求向零截断,即 truncate toward zero。
python复制from operator import add, sub, mul
def div(x, y):
return int(x/y) if x*y > 0 else -(abs(x)//abs(y))
class Solution:
op_map = {'+':add, '-':sub, '*':mul, '/':div}
def evalRPN(self, tokens: List[str]) -> int:
stack = []
for token in tokens:
if token not in self.op_map:
stack.append(int(token))
else:
op2 = stack.pop()
op1 = stack.pop()
stack.append(self.op_map[token](op1, op2))
return stack.pop()
1.2 滑动窗口最大值(239题)
滑动窗口问题是算法中的经典问题,要求我们在一个数组上移动固定大小的窗口,并快速找出每个窗口中的最大值。
暴力解法的时间复杂度是O(n*k),而使用单调队列可以优化到O(n)。单调队列的核心思想是维护一个递减的队列,队首元素始终是当前窗口的最大值。
实现要点:
- 自定义单调队列类,实现push和pop操作
- push操作时要保证队列的单调性
- pop操作时要检查是否是当前最大值
- 滑动窗口时先处理移出的元素,再处理新加入的元素
python复制from collections import deque
class MonotonicQueue:
def __init__(self):
self.queue = deque()
def push(self, value):
while self.queue and value > self.queue[-1]:
self.queue.pop()
self.queue.append(value)
def pop(self, value):
if self.queue and value == self.queue[0]:
self.queue.popleft()
def max(self):
return self.queue[0]
class Solution:
def maxSlidingWindow(self, nums: List[int], k: int) -> List[int]:
mq = MonotonicQueue()
result = []
for i in range(k):
mq.push(nums[i])
result.append(mq.max())
for i in range(k, len(nums)):
mq.pop(nums[i-k])
mq.push(nums[i])
result.append(mq.max())
return result
1.3 前K个高频元素(347题)
这个问题要求我们找出数组中出现频率最高的k个元素。最直观的解法是统计频率后排序,但时间复杂度是O(nlogn)。使用堆可以将时间复杂度优化到O(nlogk)。
实现步骤:
- 使用哈希表统计每个元素的出现频率
- 维护一个大小为k的最小堆
- 遍历频率哈希表,将元素加入堆中
- 当堆大小超过k时,弹出最小元素
- 最后堆中剩下的就是前k个高频元素
python复制import heapq
class Solution:
def topKFrequent(self, nums: List[int], k: int) -> List[int]:
freq = {}
for num in nums:
freq[num] = freq.get(num, 0) + 1
heap = []
for num, count in freq.items():
heapq.heappush(heap, (count, num))
if len(heap) > k:
heapq.heappop(heap)
result = []
while heap:
result.append(heapq.heappop(heap)[1])
return result[::-1]
2. 算法实现中的关键技巧
2.1 栈的灵活运用
在逆波兰表达式求值中,栈的使用非常巧妙。每次遇到运算符时,我们只需要弹出栈顶的两个元素进行计算,然后将结果重新压入栈中。这种后进先出的特性完美匹配了后缀表达式的计算顺序。
2.2 单调队列的维护
滑动窗口问题中的单调队列是一个典型的空间换时间的例子。通过维护一个递减的队列,我们可以在O(1)时间内获取当前窗口的最大值。关键在于push操作时要移除所有小于当前元素的队尾元素,保证队列的单调性。
2.3 堆的选择与使用
在前K个高频元素问题中,我们选择最小堆而不是最大堆,是因为我们想要保留最大的k个元素。使用最小堆可以方便地移除最小的元素,从而保证堆中始终保留最大的k个元素。
3. 常见问题与解决方案
3.1 逆波兰表达式中的除法处理
Python的除法运算符/会返回浮点数,而//会向下取整。但题目要求的是向零截断,即正数向下取整,负数向上取整。因此需要自定义除法函数:
python复制def div(x, y):
return int(x/y) if x*y > 0 else -(abs(x)//abs(y))
3.2 滑动窗口的边界条件
处理滑动窗口问题时,特别要注意窗口的初始化和边界条件。通常我们会先处理前k个元素初始化窗口,然后再从第k个元素开始滑动。
3.3 堆的大小控制
在使用堆解决前K个问题时,必须严格控制堆的大小不超过k。每次添加新元素后都要检查堆的大小,如果超过k就弹出最小元素。
4. 性能分析与优化
4.1 时间复杂度比较
| 问题 | 暴力解法 | 优化解法 |
|---|---|---|
| 逆波兰表达式 | O(n) | O(n) |
| 滑动窗口最大值 | O(n*k) | O(n) |
| 前K个高频元素 | O(nlogn) | O(nlogk) |
4.2 空间复杂度分析
- 逆波兰表达式:O(n)的栈空间
- 滑动窗口最大值:O(k)的队列空间
- 前K个高频元素:O(n)的哈希表空间和O(k)的堆空间
5. 实际应用场景
5.1 逆波兰表达式的应用
逆波兰表达式在计算器设计中非常有用,因为它消除了运算符优先级和括号的歧义。许多编程语言的解释器和编译器也会先将中缀表达式转换为后缀表达式再进行计算。
5.2 滑动窗口的应用
滑动窗口算法广泛应用于:
- 网络流量控制
- 股票价格分析
- 信号处理
- 基因组序列分析
5.3 前K个问题的应用
前K个问题在大数据处理中非常常见,例如:
- 热门商品推荐
- 高频搜索词统计
- 异常检测
6. 代码实现中的注意事项
-
在逆波兰表达式求值中,要注意操作数的顺序。第一个弹出的操作数是第二个操作数,第二个弹出的才是第一个操作数。
-
滑动窗口问题中,自定义队列的pop操作需要判断是否移除的是当前最大值,只有是时才需要真正移除。
-
使用堆时,Python的heapq模块默认实现的是最小堆。如果需要最大堆,可以通过存储负值来实现。
-
在前K个问题中,最后输出结果时要注意顺序,因为堆弹出的顺序是从小到大的。
7. 算法思想的延伸
7.1 单调栈与单调队列
单调队列是单调栈的扩展,两者都维护一个单调的数据结构。单调栈常用于解决下一个更大元素等问题,而单调队列则适用于滑动窗口类问题。
7.2 堆的变种应用
除了最小堆和最大堆,还有其他变种如:
- 斐波那契堆
- 二项堆
- 配对堆
这些堆在某些特定场景下有着更好的性能。
7.3 表达式求值的其他方法
除了逆波兰表达式,还有:
- 中缀表达式直接求值
- 前缀表达式(波兰表达式)
- 语法树求值
每种方法都有其适用的场景和优缺点。
