力扣(LeetCode)100题:239.滑动窗口最大值
239.滑动窗口最大值
我的题解:暴力--超时┭┮﹏┭┮
class Solution:def maxSlidingWindow(self, nums: List[int], k: int) -> List[int]:key=[]for j in range(len(nums)-k+1):k_max=nums[j]for i in range(j,j+k):if nums[i]>k_max:k_max=nums[i]key.append(k_max)return key
官方题解:
一、优先队列
这段代码使用优先队列(最大堆) 来解决「滑动窗口最大值」问题(LeetCode 239)。
class Solution:def maxSlidingWindow(self, nums: List[int], k: int) -> List[int]:n = len(nums)# Python 的 heapq 是小根堆(最小堆),但我们想要最大值。# 因此我们将数值取负(-nums[i]),这样最小堆就能模拟“最大堆”的行为。# 同时存储 (负值, 索引),以便后续判断元素是否还在当前窗口内。q = [(-nums[i], i) for i in range(k)]heapq.heapify(q) # 将列表转换为堆结构,时间复杂度 O(k)# 初始窗口 [0, k-1] 的最大值是堆顶元素的负值(因为存的是负数)ans = [-q[0][0]] # q[0] 是堆顶(最小的负数 → 对应原数组中最大的正数)# 从第 k 个元素开始,滑动窗口向右移动for i in range(k, n):# 将当前元素 nums[i] 加入堆(同样取负)heapq.heappush(q, (-nums[i], i))# 堆顶可能包含“过期”元素(即索引不在当前窗口 [i-k+1, i] 内)# 当前窗口左边界是 i - k + 1,所以索引 <= i - k 的元素已失效while q[0][1] <= i - k:heapq.heappop(q) # 弹出堆顶的过期元素# 此时堆顶一定是当前窗口内的最大值(因为其他过期元素已被清理)ans.append(-q[0][0]) # 取负还原为原始最大值return ans
🔍 关键函数与机制说明
1. heapq.heapify(q)
- 将普通列表
q转换为最小堆(堆顶是最小元素)。 - 时间复杂度:O(k)
2. heapq.heappush(q, item)
- 向堆中插入一个元素,并维持堆性质。
- 时间复杂度:O(log size)
3. heapq.heappop(q)
- 弹出并返回堆顶(最小)元素。
- 时间复杂度:O(log size)
4. 为什么用 (-nums[i], i)?
- Python 没有内置最大堆,但可以通过存储负值,用最小堆模拟最大堆。
- 例如:原数组
[1, 3, -1]→ 存为[-1, -3, 1],堆顶是-3,对应原值3(最大值)。 - 同时保存索引
i,用于判断该元素是否仍在当前滑动窗口中。
5. 过期元素清理逻辑
- 滑动窗口范围:
[i - k + 1, i] - 所以任何索引
≤ i - k的元素都不在窗口内,必须被移除。 - 使用
while循环持续弹出堆顶,直到堆顶索引有效。
⚠️ 注意:堆中可能残留多个过期元素,但只要堆顶是有效的,就可以保证当前最大值正确。过期元素会在后续被逐步清理。
📊 示例演示
输入:nums = [1,3,-1,-3,5,3,6,7], k = 3
初始堆(前3个元素):
- 存入:
[(-1,0), (-3,1), (1,2)] - 堆化后堆顶:
(-3,1)→ 最大值为3 ans = [3]
继续滑动……最终输出:
[3, 3, 5, 5, 6, 7]
⏱️ 复杂度分析
- 时间复杂度:O(n log n)
- 每个元素最多入堆一次、出堆一次;
- 堆操作每次 O(log n),最坏情况下堆大小接近 n。
- 空间复杂度:O(n)
- 堆中最多存储 n 个元素。
二、单调队列
下面是对这段使用 双端队列(deque)实现滑动窗口最大值 的 Python 代码的逐行详细注释与解释。这是解决 LeetCode 第 239 题「滑动窗口最大值」的最优 O(n) 解法。
class Solution:def maxSlidingWindow(self, nums: List[int], k: int) -> List[int]:n = len(nums)# 使用双端队列 q 存储数组下标(不是值!)# 队列中的下标对应的 nums 值是【单调递减】的# 即:nums[q[0]] >= nums[q[1]] >= ... >= nums[q[-1]]q = deque()# 初始化第一个窗口 [0, k-1]for i in range(k):# 维护单调递减:如果当前 nums[i] >= 队尾对应元素,# 说明队尾元素“永远不可能成为最大值”(因为 i 更靠右且更大/相等),# 所以从队尾弹出这些“无用”元素while q and nums[i] >= nums[q[-1]]:q.pop()# 将当前下标 i 加入队尾q.append(i)# 第一个窗口的最大值就是队首元素对应的值ans = [nums[q[0]]]# 滑动窗口:i 从 k 到 n-1,每次窗口右移一位for i in range(k, n):# 同样维护单调递减队列:移除队尾所有小于等于 nums[i] 的元素while q and nums[i] >= nums[q[-1]]:q.pop()q.append(i)# 检查队首元素是否还在当前窗口 [i - k + 1, i] 内# 如果队首下标 <= i - k,说明它已经滑出窗口左侧,需弹出while q[0] <= i - k:q.popleft()# 此时队首一定是当前窗口内的最大值下标ans.append(nums[q[0]])return ans
🔍 核心思想:单调双端队列(Monotonic Deque)
✅ 为什么用下标而不是值?
- 需要判断元素是否仍在当前窗口内 → 必须知道其位置(下标)。
- 通过
q[0] <= i - k可快速判断队首是否过期。
✅ 为什么队列要保持“单调递减”?
- 队首始终是当前窗口的最大值下标。
- 对于新加入的元素
nums[i]:- 如果它比队尾元素大(或相等),那么队尾元素在之后任何窗口中都不可能成为最大值(因为
i更靠右、存活时间更长、值还不小)。 - 因此可以安全地从队尾删除这些“被支配”的元素。
- 如果它比队尾元素大(或相等),那么队尾元素在之后任何窗口中都不可能成为最大值(因为
🧠 这种策略保证了队列中每个元素都有“成为最大值的机会”,且不会遗漏。
📊 示例演示
输入:nums = [1, 3, -1, -3, 5, 3, 6, 7], k = 3
初始化窗口 [1, 3, -1]:
- i=0: q = [0]
- i=1: 3 > 1 → 弹出 0,q = [1]
- i=2: -1 < 3 → q = [1, 2]
- ans = [nums[1]] = [3]
继续滑动:
- i=3 (
-3):q = [1,2,3] → 队首 1 仍在窗口 → ans += [3] - i=4 (
5):5 > -3, -1, 3 → 全部弹出,q = [4] → ans += [5] - i=5 (
3):3 < 5 → q = [4,5] → ans += [5] - i=6 (
6):6 > 3,5 → 弹出全部,q = [6] → ans += [6] - i=7 (
7):7 > 6 → 弹出 6,q = [7] → ans += [7]
最终结果:[3, 3, 5, 5, 6, 7]
⏱️ 复杂度分析
- 时间复杂度:O(n)
- 每个元素最多入队一次、出队一次,总操作数 ≤ 2n。
- 空间复杂度:O(k)
- 队列中最多保存 k 个下标(一个完整窗口)。
✅ 这是理论最优解,比堆(O(n log n))更高效。
💡 关键技巧总结
| 操作 | 目的 |
|---|---|
while q and nums[i] >= nums[q[-1]]: q.pop() | 维护队列单调递减(从队尾删“弱者”) |
q.append(i) | 加入新候选 |
while q[0] <= i - k: q.popleft() | 移除过期元素(从队首删“老人”) |
ans.append(nums[q[0]]) | 队首即当前窗口最大值 |
