1. 算法训练营第十一天核心题目解析
今天我们要啃下三块硬骨头:逆波兰表达式求值、滑动窗口最大值和前K个高频元素。这三个题目分别考察了栈的应用、单调队列的使用以及堆排序的思想,都是面试中的高频考点。我按照从易到难的顺序,结合自己的刷题经验,带大家逐个击破。
1.1 题目难度与考察重点分析
这三道题在LeetCode上的难度评级都是中等,但实际考察的知识点各有侧重:
- 150题是典型的栈结构应用题
- 239题需要掌握单调队列的优化思想
- 347题则综合运用了哈希表和堆结构
从代码实现量来看,150题最简单(约20行代码),239题次之(约30行),347题最复杂(约40行)。但实际思维难度反而是239题最高,需要理解为什么单调队列能优化时间复杂度。
2. 150. 逆波兰表达式求值
2.1 逆波兰表达式基础
逆波兰表达式(Reverse Polish Notation)也叫后缀表达式,它的特点是操作符位于两个操作数之后。比如常规表达式 "2 + 3" 在后缀表达式中写作 "2 3 +"。
这种表示法的最大优点是不需要括号来标识优先级,所有运算顺序完全由操作数和运算符的位置决定。计算时只需要一个栈结构就能轻松处理:
- 遇到数字:入栈
- 遇到运算符:弹出栈顶两个元素运算,结果入栈
- 最终栈内剩余元素即为结果
2.2 代码实现与边界处理
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的//是向下取整,而题目要求向零取整,所以要用int(a/b)
- 操作数顺序:减法和除法要注意a和b的弹出顺序
- 最终结果:题目保证表达式有效,所以栈内最后只剩一个元素
提示:实际面试时,可以问面试官输入是否保证合法。如果不保证,需要增加对栈空和除零的判断。
3. 239. 滑动窗口最大值
3.1 暴力解法与时间复杂度分析
最直观的解法是遍历每个窗口,找出最大值。对于长度为n的数组和大小为k的窗口,共有n-k+1个窗口,每个窗口找最大值需要O(k)时间,总时间复杂度为O(nk)。
当n=10^5时,这样的解法显然会超时(LeetCode测试用例最大n为10^5)。我们需要找到O(n)的解法。
3.2 单调队列的优化思路
维护一个双端队列(deque),存储可能成为窗口最大值的元素的索引。队列中的元素按照从大到小的顺序排列,且索引是递增的。具体操作:
- 移除超出窗口范围的元素(从队首)
- 移除比当前元素小的元素(从队尾)
- 将当前元素索引加入队尾
- 队首元素即为当前窗口最大值
python复制from collections import deque
def maxSlidingWindow(nums, k):
q = deque()
res = []
for i, num in enumerate(nums):
# 移除超出窗口的元素
while q and q[0] <= i - k:
q.popleft()
# 移除比当前小的元素
while q and nums[q[-1]] < num:
q.pop()
q.append(i)
# 记录结果
if i >= k - 1:
res.append(nums[q[0]])
return res
3.3 为什么这个算法是O(n)
虽然有两层while循环,但每个元素最多被加入和弹出队列各一次,所以总操作次数是2n,时间复杂度是O(n)。这是典型的均摊分析思想。
实际踩坑:我第一次实现时忘记处理空队列情况,导致索引越界。建议在while条件中始终先检查q是否为空。
4. 347. 前K个高频元素
4.1 统计频率与堆的应用
这道题需要两个主要步骤:
- 统计每个元素的出现频率(哈希表)
- 找出频率最高的k个元素(堆排序)
统计频率很简单,关键是第二步的优化。直接排序所有元素是O(nlogn),而使用堆可以优化到O(nlogk)。
4.2 最小堆的实现细节
我们维护一个大小为k的最小堆,堆顶是当前k个元素中频率最小的。遍历所有元素:
- 将元素加入堆
- 如果堆大小超过k,弹出堆顶(最小元素)
- 最终堆中剩下的就是前k大元素
python复制import heapq
from collections import defaultdict
def topKFrequent(nums, k):
freq = defaultdict(int)
for num in nums:
freq[num] += 1
heap = []
for num, count in freq.items():
heapq.heappush(heap, (count, num))
if len(heap) > k:
heapq.heappop(heap)
return [num for count, num in heap]
4.3 时间复杂度对比
- 直接排序:O(nlogn)
- 堆方法:建堆O(n),每次插入/删除O(logk),共n次操作,所以是O(nlogk)
- 当k远小于n时,堆方法优势明显
经验分享:Python的heapq默认是最小堆,如果要最大堆可以存储负值。这里我们利用最小堆来淘汰最小元素,保留大的。
5. 三道题的共性技巧与个人心得
5.1 数据结构的选择艺术
这三道题展示了如何根据问题特点选择数据结构:
- 栈:适合处理具有"最近相关性"的问题(如括号匹配、表达式求值)
- 单调队列:维护区间最值的利器
- 堆:高效处理TopK问题
5.2 我的刷题路线建议
对于算法初学者,我建议按照这个顺序掌握:
- 先理解每种数据结构的特性
- 做经典例题(如今天的三个题目)
- 尝试变种题(如栈的题目可以尝试括号匹配、表达式转换等)
- 最后做综合应用题
5.3 调试技巧分享
在实现这些算法时,我常用的调试方法:
- 对于栈/队列问题:在关键操作前后打印整个数据结构的状态
- 对于堆问题:可以写一个辅助函数验证堆的性质
- 对于滑动窗口:画出窗口移动过程,标注队列内容变化
例如调试239题时,我通常会打印:
code复制i=2, num=3, q=[1,2], res=[1]
i=3, num=1, q=[1,2,3], res=[1,3]
这样能直观看到单调队列是如何维护窗口最大值的。
