1. 算法训练营第十一天核心内容解析
今天我们要啃下三道LeetCode中等难度题目:逆波兰表达式求值、滑动窗口最大值和前K个高频元素。这三道题分别考察了栈的应用、单调队列的使用以及堆结构的灵活运用,都是面试中经常出现的经典题型。
我在刷题过程中发现,很多同学容易陷入"看题解时恍然大悟,自己写时无从下手"的困境。究其原因,是没有真正理解这些数据结构的使用场景和底层逻辑。今天我们就从实际应用场景出发,结合代码实现细节,把这几个算法彻底吃透。
2. 150. 逆波兰表达式求值
2.1 逆波兰表达式基础认知
逆波兰表达式(Reverse Polish Notation,RPN)也叫后缀表达式,它的特点是将运算符写在操作数之后。比如常规表达式"3 + 4"在后缀表达式中写作"3 4 +"。这种表示法的最大优势是无需括号就能明确运算顺序,特别适合计算机处理。
我第一次接触这个概念是在编译原理课程中,当时就被它的简洁性惊艳到了。现代计算器(包括Windows自带的科学计算器)内部实际上都是先将中缀表达式转换为逆波兰表达式再进行计算。
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)
else:
stack.append(int(a / b)) # 注意除法向零取整
return stack[0]
关键细节:除法处理需要特别注意,题目要求向零取整,所以不能直接用Python的除法运算符"/",而要先用浮点数除法再转换为整数。
2.3 边界条件与异常处理
在实际编码中,有几个边界情况需要考虑:
- 单个数字的表达式(如["18"])
- 连续多个运算符的情况(非法输入)
- 最终栈中元素不止一个(非法输入)
虽然题目保证输入合法,但在面试中最好能主动讨论这些边界情况。我在实际实现中添加了这些检查:
python复制if len(stack) != 1:
raise ValueError("Invalid RPN expression")
3. 239. 滑动窗口最大值
3.1 暴力法的局限性
这道题最直观的解法是遍历每个窗口,找出最大值。对于长度为n的数组和大小为k的窗口,时间复杂度是O(nk)。当n很大时(比如10^5),这种解法显然会超时。
我在第一次尝试时就用暴力法提交了,结果不出所料地遇到了TLE(Time Limit Exceeded)。这提醒我们:当数据规模较大时,必须寻找更优的解法。
3.2 单调队列的精妙设计
最优解法是使用单调队列,能在O(n)时间内解决问题。单调队列的核心思想是维护一个递减的队列,队首元素始终是当前窗口的最大值。
具体实现要点:
- 队列中存储的是数组元素的索引而非值本身(方便判断是否在窗口内)
- 新元素入队前,从队尾移除所有比它小的元素
- 检查队首元素是否还在窗口内,不在则移除
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
实战技巧:使用双端队列(deque)而不是普通列表,因为popleft()操作的时间复杂度是O(1),而列表的pop(0)是O(n)。
3.3 复杂度分析与变种问题
单调队列解法的时间复杂度是O(n),因为每个元素最多入队出队一次。空间复杂度是O(k),因为队列最多存储k个元素。
这个问题有几个有趣的变种:
- 滑动窗口最小值(只需将单调队列改为递增)
- 滑动窗口中位数(可以使用两个堆)
- 多维滑动窗口最大值(需要更复杂的数据结构)
4. 347. 前K个高频元素
4.1 统计频率的多种方法
这道题首先需要统计每个元素的出现频率。常见的方法有:
- 使用哈希表(字典)统计
- 使用collections.Counter
- 对排序后的数组进行线性扫描
我个人推荐使用Counter,它简洁高效:
python复制from collections import Counter
count = Counter(nums)
4.2 堆结构的灵活运用
统计完频率后,我们需要找出频率最高的k个元素。这正适合使用堆结构,特别是最小堆,可以保持堆的大小为k,最终堆中剩下的就是前k个高频元素。
具体步骤:
- 构建元素-频率的元组列表
- 使用堆处理这些元组
- 最后取出堆中的元素
python复制import heapq
def topKFrequent(nums, k):
count = Counter(nums)
return heapq.nlargest(k, count.keys(), key=count.get)
虽然Python的heapq.nlargest已经封装得很好,但理解其底层原理很重要。手动实现的话:
python复制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 [num for freq, num in heap]
4.3 其他解法对比
除了堆解法,这个问题还有几种经典解法:
- 快速选择算法(类似快速排序的思路,平均O(n))
- 桶排序(当频率范围不大时效率很高)
- 直接排序(O(nlogn),简单但不够高效)
我在实际项目中遇到过类似需求,当时数据规模很大(上百万条记录),最终选择了快速选择算法,因为它不需要额外空间,且平均时间复杂度最优。
5. 算法实战中的常见陷阱
5.1 逆波兰表达式易错点
- 数字可能是负数("-11"是一个数字token,不是运算符和数字)
- 除法处理要特别注意,不同语言对整数除法的实现不同
- 栈弹出顺序:先弹出的是右操作数,后弹出的是左操作数
5.2 滑动窗口优化技巧
- 使用索引而非值存储,方便判断窗口范围
- 在移除队尾元素时要用while而不是if,确保移除所有较小元素
- 结果收集时机:当i >= k-1时才开始收集
5.3 前K问题的经验总结
- 当k接近n时,直接排序可能更高效
- 使用堆时,根据k的大小选择最小堆或最大堆
- 实际工程中要考虑元素频率的动态变化(这时可能需要更复杂的数据结构)
6. 三道题目的内在联系
虽然这三道题目看似独立,但它们都展示了如何根据问题特点选择合适的数据结构:
- 逆波兰表达式 → 栈的完美应用
- 滑动窗口最大值 → 单调队列的典型场景
- 前K个高频元素 → 堆结构的灵活使用
这种"问题特征 → 数据结构选择"的思维模式正是算法训练的核心目标。我在面试候选人时,特别看重他们能否快速识别问题特征并选择合适的数据结构。
刷题时建议多做这样的横向对比,比如这三道题都涉及到"维护一个动态集合,快速获取某种极值"的需求,只是具体约束条件不同。理解这种深层次的共性,才能真正做到举一反三。