1. 栈与队列算法专题收官之战
今天我们要解决栈与队列专题的最后三道压轴题:LC 150(逆波兰表达式求值)、LC 239(滑动窗口最大值)和 LC 347(前K个高频元素)。这三道题分别代表了栈与队列应用的三个重要方向,每道题都有其独特的解题思路和实现技巧。
作为算法工程师,我经常在面试中遇到这三类问题。它们不仅是面试高频考点,更是实际工程中常用的算法范式。比如电商平台的实时热销商品排行、金融系统的滑动窗口风控计算、编译器中的表达式求值等场景,都会用到这些算法思想。
2. LC 150 · 逆波兰表达式求值
2.1 逆波兰表达式简介
逆波兰表达式(Reverse Polish Notation,RPN),也叫后缀表达式,是一种不需要括号就能明确运算顺序的数学表达式表示方法。它的核心特点是运算符位于两个操作数之后。
例如:
- 常规表达式:(2 + 3) × 4
- 逆波兰表达式:2 3 + 4 ×
这种表示法最大的优势是完全消除了运算符优先级和括号的歧义,特别适合用栈结构来计算。
2.2 栈模拟计算过程
实现逆波兰表达式求值的算法非常直观:
- 初始化一个空栈
- 遍历表达式中的每个token:
- 如果是数字:压入栈中
- 如果是运算符:弹出栈顶两个元素,进行运算,将结果压回栈中
- 最后栈中剩下的唯一元素就是表达式结果
python复制def evalRPN(tokens):
stack = []
for token in tokens:
if token not in '+-*/':
stack.append(int(token))
else:
b = stack.pop()
a = stack.pop()
if token == '+':
stack.append(a + b)
elif token == '-':
stack.append(a - b)
elif token == '*':
stack.append(a * b)
elif token == '/':
stack.append(int(a / b)) # 注意除法处理
return stack[0]
2.3 关键细节:除法处理
这里有个非常重要的细节:Python中的除法运算符//是向下取整,而题目要求向零取整。这意味着负数除法会出现问题:
python复制6 // -132 = -1 # 向下取整
int(6 / -132) = 0 # 向零取整
因此我们必须使用int(a / b)而不是a // b来实现向零取整。
提示:在实际工程中,处理金融计算等场景时,除法舍入方式的选择非常重要,必须与业务需求保持一致。
2.4 复杂度分析
- 时间复杂度:O(n),只需遍历一次表达式
- 空间复杂度:O(n),最坏情况下栈中需要存储所有数字
3. LC 239 · 滑动窗口最大值
3.1 问题理解与暴力解法
给定一个数组和滑动窗口大小k,我们需要找出每个窗口中的最大值。最直观的暴力解法是对每个窗口遍历其中的所有元素找最大值:
python复制def maxSlidingWindow(nums, k):
return [max(nums[i:i+k]) for i in range(len(nums)-k+1)]
这种解法的时间复杂度是O(nk),当n和k都很大时(比如n=10^5,k=10^4),性能会非常差。
3.2 单调队列优化
我们可以使用单调递减队列将时间复杂度优化到O(n)。单调队列的核心思想是维护一个队列,其中元素按照从大到小的顺序排列,且队首元素始终是当前窗口的最大值。
实现细节:
- 队列中存储的是元素的下标而非值本身,这样可以方便判断元素是否还在当前窗口内
- 当新元素要入队时,从队尾开始移除所有比它小的元素,保持队列单调递减
- 检查队首元素是否已经超出窗口范围,如果是则移除
- 当窗口形成后(i >= k-1),队首元素就是当前窗口的最大值
python复制from collections import deque
def maxSlidingWindow(nums, k):
q = deque()
result = []
for i, num in enumerate(nums):
# 移除队尾小于当前元素的元素
while q and nums[q[-1]] < num:
q.pop()
q.append(i)
# 移除超出窗口的队首元素
if q[0] <= i - k:
q.popleft()
# 当窗口形成后记录结果
if i >= k - 1:
result.append(nums[q[0]])
return result
3.3 复杂度分析
- 时间复杂度:O(n),每个元素最多入队出队一次
- 空间复杂度:O(k),队列最多存储k个元素
3.4 实际应用场景
滑动窗口最大值算法在以下场景非常有用:
- 实时计算股票价格的近期最高点
- 网络流量监控中的峰值检测
- 用户行为分析中的异常检测
4. LC 347 · 前K个高频元素
4.1 问题分析与基本解法
给定一个非空整数数组,返回出现频率前k高的元素。最直观的解法是:
- 使用哈希表统计每个元素的频率
- 对频率进行排序
- 取前k个元素
python复制from collections import Counter
def topKFrequent(nums, k):
count = Counter(nums)
return [x[0] for x in count.most_common(k)]
这种解法的时间复杂度是O(nlogn),主要来自排序步骤。
4.2 堆优化解法
我们可以使用最小堆将时间复杂度优化到O(nlogk)。思路是:
- 统计元素频率
- 维护一个大小为k的最小堆
- 遍历频率字典:
- 如果堆未满,直接加入
- 如果堆已满,比较当前元素频率与堆顶元素频率
- 如果当前频率更高,替换堆顶元素
- 最后堆中的元素就是前k高频元素
python复制import heapq
from collections import Counter
def topKFrequent(nums, k):
count = Counter(nums)
heap = []
for num, freq in count.items():
if len(heap) < k:
heapq.heappush(heap, (freq, num))
else:
if freq > heap[0][0]:
heapq.heappop(heap)
heapq.heappush(heap, (freq, num))
return [x[1] for x in heap]
4.3 复杂度分析
- 时间复杂度:O(nlogk),构建堆的时间
- 空间复杂度:O(n),存储哈希表和堆
4.4 其他优化方法
对于这个问题,还可以使用快速选择算法(Quickselect)将时间复杂度优化到平均O(n),但在实际面试中,堆解法通常是更安全的选择。
5. 常见问题与调试技巧
5.1 逆波兰表达式常见错误
-
运算顺序错误:确保先弹出的是右操作数,再弹出左操作数。特别是减法和除法,顺序很重要。
python复制# 错误示例 result = b - a # 应该是 a - b -
类型转换遗漏:从栈中弹出的数字可能是字符串形式,需要转换为整数。
python复制# 错误示例 stack.append(a + b) # 如果a和b是字符串,会变成字符串拼接 -
空栈检查:在弹出操作数前要检查栈是否为空,避免运行时错误。
5.2 单调队列调试技巧
-
可视化窗口滑动:在纸上画出数组和窗口移动过程,标注队列状态变化。
code复制数组: [1,3,-1,-3,5,3,6,7], k=3 窗口位置 队列内容 最大值 [1 3 -1] [3,-1] 3 [3 -1 -3] [3,-1,-3] 3 [-1 -3 5] [5] 5 -
边界条件检查:
- 空输入数组
- k=1的情况
- k等于数组长度的情况
-
队列存储下标:这是关键技巧,可以方便判断元素是否在窗口内。
5.3 堆应用的注意事项
-
Python的heapq模块:它只实现了最小堆,如果需要最大堆,可以存储负值。
python复制# 最大堆技巧 heapq.heappush(heap, -freq) -
堆大小维护:确保堆的大小不超过k,这是保证O(nlogk)时间复杂度的关键。
-
相等频率处理:题目通常不要求特定顺序,但面试时要确认如何处理频率相同的元素。
6. 算法思想扩展与应用
6.1 栈的更多应用场景
- 括号匹配:检查表达式中的括号是否合法嵌套
- 浏览器前进后退:使用双栈实现浏览历史导航
- 函数调用栈:程序执行时的函数调用关系
- 撤销操作:编辑器的撤销功能通常用栈实现
6.2 单调队列的变种
- 滑动窗口最小值:只需将单调递减改为单调递增
- 区间极值查询:预处理数组,支持快速查询任意区间的最大值
- 队列中的最大值:实现一个支持获取当前最大值的队列
6.3 Top K问题的其他解法
- 快速选择:类似快速排序的分区思想,平均O(n)时间
- 桶排序:当元素范围有限时,可以按频率分桶
- 二叉搜索树:维护一个大小为k的BST,也可以实现类似堆的效果
在实际工程中,选择哪种方法取决于数据特点和系统需求。例如,对于流式数据(数据持续到达),堆方法是更好的选择,因为它可以逐步处理数据而不需要存储全部数据集。