滑动窗口最大值:从暴力到单调队列,层层优化全解析
题目描述
给定一个整数数组 nums 和一个大小为 k 的滑动窗口,窗口从数组的最左侧移动到最右侧,每次只向右移动一位。你需要返回每个窗口内的最大值。
示例:
ini
输入:nums = [1,3,-1,-3,5,3,6,7], k = 3
输出:[3,3,5,5,6,7]
题目本身非常直观,几乎所有人第一眼都能想到"每个窗口扫描一遍"的做法。但如何降低时间复杂度,才是这道题真正的考点。接下来,我们将从暴力解出发,沿着"如何减少重复比较"的思路,一步步推导到 O(n) 的最优解。
解法一:暴力扫描
这是最直观的思路:枚举每个窗口的起点,然后在内层循环中遍历窗口内的 k 个元素,找出最大值。
java
public int[] maxSlidingWindow(int[] nums, int k) {
int n = nums.length;
int[] res = new int[n - k + 1];
for (int i = 0; i <= n - k; i++) {
int max = nums[i];
for (int j = i + 1; j < i + k; j++) {
max = Math.max(max, nums[j]);
}
res[i] = max;
}
return res;
}
复杂度分析:
- 时间复杂度:O(n·k)。当 k ≈ n/2 时,趋近于 O(n²),在 n = 10^5 级别时必然超时。
- 空间复杂度:O(1)(不计结果数组)。
暴力法的问题非常明显:相邻两个窗口存在大量重叠元素,但每次都要重新比较,做了太多重复工作。优化方向就是复用前一个窗口的信息。
解法二:大顶堆 + 延迟删除
要快速获取窗口内的最大值,第一时间会想到大顶堆(优先队列)。堆顶就是当前窗口的最大值。但窗口滑动时,需要移除最左侧离开窗口的元素。Java 标准库的 PriorityQueue 删除任意元素的时间复杂度是 O(k),这会导致整体复杂度回到 O(n·k)。
如何避免高昂的删除操作?可以用延迟删除技巧:不主动删除离开窗口的元素,而是每次查看堆顶时,判断其索引是否还在窗口内。如果不在,就弹出堆顶,直到堆顶元素有效为止。
java
public int[] maxSlidingWindow(int[] nums, int k) {
int n = nums.length;
int[] res = new int[n - k + 1];
// 大顶堆,int[]{值, 索引}
PriorityQueue<int[]> pq = new PriorityQueue<>((a, b) -> b[0] - a[0]);
// 初始化
for (int i = 0; i < k; i++) {
pq.offer(new int[]{nums[i], i});
}
res[0] = pq.peek()[0];
// 滑动
for (int i = k; i < n; i++) {
pq.offer(new int[]{nums[i], i});
// 清理堆顶的过期元素
while (pq.peek()[1] <= i - k) {
pq.poll();
}
res[i - k + 1] = pq.peek()[0];
}
return res;
}
复杂度分析:
- 时间复杂度:O(n log n)。每个元素入堆一次、出堆一次,每次堆操作 O(log n)。
- 空间复杂度:O(n),堆中最多存储 n 个元素。
这个解法已经可以通过大部分测试,但还不是最优。问题在于我们维护了整个窗口的所有元素,而实际上,成为最大值的元素只是极少数。有没有办法只维护"有可能成为最大值"的元素呢?
解法三:平衡树(TreeMap)
既然需要维护窗口内的有序性,又需要快速删除,平衡树也是一个自然的选择。Java 的 TreeMap 可以在 O(log k) 的时间内完成插入和删除,并能获取最大值(lastKey())。
java
public int[] maxSlidingWindow(int[] nums, int k) {
int n = nums.length;
int[] res = new int[n - k + 1];
TreeMap<Integer, Integer> map = new TreeMap<>();
// 初始化窗口
for (int i = 0; i < k; i++) {
map.put(nums[i], map.getOrDefault(nums[i], 0) + 1);
}
res[0] = map.lastKey();
// 滑动
for (int i = k; i < n; i++) {
// 移除左边界
int left = nums[i - k];
int cnt = map.get(left);
if (cnt == 1) map.remove(left);
else map.put(left, cnt - 1);
// 添加右边界
int right = nums[i];
map.put(right, map.getOrDefault(right, 0) + 1);
res[i - k + 1] = map.lastKey();
}
return res;
}
复杂度分析:
- 时间复杂度:O(n log k)。窗口内最多 k 个元素,平衡树操作 O(log k)。
- 空间复杂度:O(k)。
这个解法非常干净,没有延迟删除的"脏数据",而且能够直接扩展到求最小值、中位数等变体。但它仍然需要对每个元素进行 log k 级别的调整。能否进一步优化到 O(n) 呢?
解法四:单调队列(双端队列)
核心思想
回到大顶堆的优化思路:我们能不能只保存"有可能成为最大值"的元素?考虑一个性质:
在一个窗口中,如果存在 i < j 且 numsi < numsj,那么只要 j 还在窗口内,numsi 就永远不可能成为最大值。
因为 j 比 i 靠后,会更晚离开窗口,而且值更大。所以 i 是一个"无用"的元素,可以提前移除。
基于这一点,我们可以用一个双端队列 维护一个单调递减的序列:队列中存储的是下标,对应元素的值严格递减(或非严格)。队首永远是当前窗口的最大值。
操作流程
- 遍历数组,维护队列的单调递减性:每当新元素到来时,从队尾开始,把所有小于当前元素的值对应的下标全部弹出,因为它们再也用不上了。
- 将当前下标加入队尾。
- 检查队首是否已滑出窗口(下标 <= i - k),若是则弹出队首。
- 当形成完整窗口后(i >= k - 1),队首对应的元素即为当前窗口最大值。
图解示例
以 nums = [1,3,-1,-3,5,3,6,7], k = 3 为例:
ini
i=0, num=1: 队列空,加入0 → [0]
i=1, num=3: 3>1,弹出0,加入1 → [1] (窗口未满)
i=2, num=-1: -1<3,加入2 → [1,2] 窗口[1,3,-1] max=nums[1]=3
i=3, num=-3: 弹出2(索引2已出窗口,其实2还在窗口内,但值为-1,-3更小,加入3。队首1仍有效 → [1,3] 窗口[3,-1,-3] max=3
i=4, num=5: 5>3,弹出1、3,加入4 → [4] 窗口[-1,-3,5] max=5
i=5, num=3: 3<5,加入5 → [4,5] 窗口[-3,5,3] max=5
i=6, num=6: 6>3弹出5,6>5弹出4,加入6 → [6] 窗口[5,3,6] max=6
i=7, num=7: 7>6弹出6,加入7 → [7] 窗口[3,6,7] max=7
每一步,队首都是当前窗口最大值的下标,时间复杂度 O(n),因为每个下标最多入队一次、出队一次。
代码
java
public int[] maxSlidingWindow(int[] nums, int k) {
int n = nums.length;
int[] res = new int[n - k + 1];
Deque<Integer> deque = new ArrayDeque<>();
for (int i = 0; i < n; i++) {
// 维护单调递减:从队尾移除所有小于当前元素的下标
while (!deque.isEmpty() && nums[deque.peekLast()] < nums[i]) {
deque.pollLast();
}
deque.offerLast(i);
// 移除队首已滑出窗口的下标
if (deque.peekFirst() <= i - k) {
deque.pollFirst();
}
// 形成窗口后记录结果
if (i >= k - 1) {
res[i - k + 1] = nums[deque.peekFirst()];
}
}
return res;
}
复杂度分析
- 时间复杂度:O(n)。每个元素入队和出队各一次,均摊 O(1)。
- 空间复杂度:O(k)。队列中最多存储窗口内的元素下标。
易错点
- 严格递减还是非严格递减? 如果允许相等,当出现两个相同的最大值时,后出现的那个也会保留在队列中(因为队尾小于等于当前值的判断)。通常我们用
nums[deque.peekLast()] < nums[i]弹出,这样相等的元素会保留,保证在窗口滑动时不会误删。可以写成<=吗?如果弹出相等的元素,那么当队首最大值滑出后,下一个相等的最大值可能已经被错误弹出,导致结果出错。因此最好保持<,让相等的元素顺序保留。 - 队列存值还是下标? 存下标是绝对正确的,因为只有下标才能判断元素是否滑出窗口。存值无法获取位置信息。
单调队列是这道题的"标准答案",也是面试中最期望你写出的解法。它完美体现了空间换时间 和排除无用元素的思想。
解法五:分块预处理(稀疏表思想)
除了在线维护,我们也可以采用预处理的方式。思路来源于区间最值查询(RMQ)问题:将数组分成大小为 k 的块,预处理每个位置到块边界的最值,然后 O(1) 查询任意区间最值。
对于滑动窗口这种长度为 k 的连续区间,有更简洁的做法:
- 将数组按长度 k 分块,每个块大小为 k(最后一块可能不足 k)。
- 对每个块,从左到右扫描计算前缀最大值 数组
leftMax;从右到左扫描计算后缀最大值 数组rightMax。 - 对于窗口
[i, i+k-1],它可能跨越两个相邻的块。窗口最大值 =max(rightMax[i], leftMax[i+k-1])。
为什么这样能行?因为窗口长度固定为 k,且每次滑动一位。窗口要么完全落在一个块内(此时 rightMax[i] 就等于窗口最大值),要么跨两个块。在跨块情况下,窗口左半部分在左块的右侧,右半部分在右块的左侧,两者的最大值恰好被预处理的后缀和前缀数组覆盖。
java
public int[] maxSlidingWindow(int[] nums, int k) {
int n = nums.length;
int[] leftMax = new int[n];
int[] rightMax = new int[n];
// 从左到右,块内前缀最大值
for (int i = 0; i < n; i++) {
if (i % k == 0) {
leftMax[i] = nums[i];
} else {
leftMax[i] = Math.max(leftMax[i - 1], nums[i]);
}
}
// 从右到左,块内后缀最大值
rightMax[n - 1] = nums[n - 1];
for (int i = n - 2; i >= 0; i--) {
if ((i + 1) % k == 0) {
rightMax[i] = nums[i];
} else {
rightMax[i] = Math.max(rightMax[i + 1], nums[i]);
}
}
// 计算每个窗口的最大值
int[] res = new int[n - k + 1];
for (int i = 0; i <= n - k; i++) {
int j = i + k - 1;
res[i] = Math.max(rightMax[i], leftMax[j]);
}
return res;
}
复杂度分析:
- 时间复杂度:O(n),三次遍历。
- 空间复杂度:O(n),两个辅助数组。
这个方法极其精巧,充分利用了滑动窗口长度固定的特点。虽然需要 O(n) 额外空间,但常数极小,实际运行速度甚至可能快于单调队列(因为避免了频繁的双端队列操作)。同时也是对 RMQ 思想的一次精彩应用。
解法对比与总结
| 解法 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 |
|---|---|---|---|---|
| 暴力扫描 | O(n·k) | O(1) | 简单直接 | 超时,不适用大规模数据 |
| 大顶堆+延迟删除 | O(n log n) | O(n) | 思路自然,易扩展 | 有延迟删除的额外开销 |
| 平衡树 | O(n log k) | O(k) | 有序,易求其他分位数 | 常数因子较大 |
| 单调队列 | O(n) | O(k) | 最优时间,面试标准答案 | 只适用于最值,需注意单调方向 |
| 分块预处理 | O(n) | O(n) | 常数小,无分支预测开销,极快 | 占用额外空间,仅适用于固定窗口 |
选择建议:
- 面试首选单调队列,它能清晰展示你对该问题核心性质的洞察。
- 如果你能顺带提及分块预处理,并比较两种 O(n) 方法的优劣,会是非常亮眼的加分项。
- 实际工程中,如果窗口大小固定且对性能要求极高,分块法可能更优;如果需要动态改变窗口大小,或者不仅求最大值还要求中位数等,考虑用平衡树。
单调队列的延伸思考
单调队列的核心是及时舍弃无用元素。这一思想在非常多的问题中大放异彩:
- 带限制的最短路径(如"跳跃游戏"中最远可达位置的维护)
- 带过期时间的数据流最值(如"数据流中的移动平均值"的变体)
- 队列实现栈、栈实现队列的扩展(维护队列内最值)
当遇到"在一个不断变化的区间内,快速获取最值"的问题时,脑子里应该立刻蹦出单调队列。理解它的"舍弃无用"哲学,比记住代码更重要。
结语
从暴力的 O(n·k) 到堆的 O(n log n),再到单调队列和分块的 O(n),我们不仅是在优化时间复杂度,更是在一步步逼近问题的本质:哪些信息是冗余的?我们如何只保留真正有用的信息? 这种思维方式,是算法进阶的核心能力之一。
下次再看到滑动窗口,你一定能从容应对。如果这篇文章对你有帮助,欢迎点赞收藏,也欢迎在评论区留下你的思考和疑问。