滑动窗口最大值:从暴力到单调队列,层层优化全解析

滑动窗口最大值:从暴力到单调队列,层层优化全解析

题目描述

给定一个整数数组 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 是一个"无用"的元素,可以提前移除。

基于这一点,我们可以用一个双端队列 维护一个单调递减的序列:队列中存储的是下标,对应元素的值严格递减(或非严格)。队首永远是当前窗口的最大值。

操作流程

  1. 遍历数组,维护队列的单调递减性:每当新元素到来时,从队尾开始,把所有小于当前元素的值对应的下标全部弹出,因为它们再也用不上了。
  2. 将当前下标加入队尾。
  3. 检查队首是否已滑出窗口(下标 <= i - k),若是则弹出队首。
  4. 当形成完整窗口后(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 的连续区间,有更简洁的做法:

  1. 将数组按长度 k 分块,每个块大小为 k(最后一块可能不足 k)。
  2. 对每个块,从左到右扫描计算前缀最大值 数组 leftMax;从右到左扫描计算后缀最大值 数组 rightMax
  3. 对于窗口 [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),我们不仅是在优化时间复杂度,更是在一步步逼近问题的本质:哪些信息是冗余的?我们如何只保留真正有用的信息? 这种思维方式,是算法进阶的核心能力之一。

下次再看到滑动窗口,你一定能从容应对。如果这篇文章对你有帮助,欢迎点赞收藏,也欢迎在评论区留下你的思考和疑问。

相关推荐
fluffyox1 小时前
Notion 的公式栏里,藏着一台虚拟机——逆向 + 用 600 行 JS 复刻它的编译器与栈式 VM
前端
kyriewen2 小时前
2026 年了,这 6 个 npm 包可以卸载了——浏览器原生 API 已经能替代
前端·javascript·npm
沉默王二3 小时前
面试结束后,我反问:“就面个实习至于上这么大强度吗?”面试官:“你对 RAG、Agent、MCP、Skill 理解得很到位,所以要求高一点。”
面试·agent·ai编程
铁皮饭盒4 小时前
bun直接tsx,优雅!
javascript·后端
Csvn5 小时前
Monorepo 迁移血泪史:从 Multi-Repo 到 Turborepo,这 3 个坑我帮你踩完了
前端
星栈5 小时前
Dioxus 多页面怎么做:`dioxus-router`、嵌套路由、`Outlet` 和页面组织,一篇给你讲顺
前端·rust·前端框架
用户987409238875 小时前
用 Remotion + edge-tts 打造中文教学视频全自动流水线
前端
风骏时光牛马5 小时前
Less前端工程化实战:变量混合器与项目样式分层落地
前端
假如让我当三天老蒯5 小时前
Options API(选项式 API) 和 Composition API(组合式 API)
前端·vue.js·面试