LeetCode算法题详解 239:滑动窗口最大值

目录

  • [1. 问题描述](#1. 问题描述)
  • [2. 问题分析](#2. 问题分析)
    • [2.1 题目理解](#2.1 题目理解)
    • [2.2 核心洞察](#2.2 核心洞察)
    • [2.3 破题关键](#2.3 破题关键)
  • [3. 算法设计与实现](#3. 算法设计与实现)
    • [3.1 暴力枚举法](#3.1 暴力枚举法)
    • [3.2 最大堆(优先队列)法](#3.2 最大堆(优先队列)法)
    • [3.3 单调队列法(最优)](#3.3 单调队列法(最优))
    • [3.4 分块预处理法](#3.4 分块预处理法)
  • [4. 性能对比](#4. 性能对比)
  • [5. 扩展与变体](#5. 扩展与变体)
    • [5.1 滑动窗口最小值](#5.1 滑动窗口最小值)
    • [5.2 滑动窗口的中位数](#5.2 滑动窗口的中位数)
    • [5.3 滑动窗口的第K大元素](#5.3 滑动窗口的第K大元素)
    • [5.4 二维滑动窗口最大值](#5.4 二维滑动窗口最大值)
  • [6. 总结](#6. 总结)
    • [6.1 核心知识要点](#6.1 核心知识要点)
    • [6.2 算法选择指南](#6.2 算法选择指南)
    • [6.3 应用场景](#6.3 应用场景)
    • [6.4 面试技巧](#6.4 面试技巧)

1. 问题描述

给定一个整数数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。返回滑动窗口中的最大值。

示例 1:

复制代码
输入:nums = [1,3,-1,-3,5,3,6,7], k = 3
输出:[3,3,5,5,6,7]
解释:
滑动窗口的位置                最大值
---------------               -----
[1  3  -1] -3  5  3  6  7       3
 1 [3  -1  -3] 5  3  6  7       3
 1  3 [-1  -3  5] 3  6  7       5
 1  3  -1 [-3  5  3] 6  7       5
 1  3  -1  -3 [5  3  6] 7       6
 1  3  -1  -3  5 [3  6  7]      7

示例 2:

复制代码
输入:nums = [1], k = 1
输出:[1]

提示:

  • 1 <= nums.length <= 10^5
  • -10^4 <= nums[i] <= 10^4
  • 1 <= k <= nums.length

2. 问题分析

2.1 题目理解

需要维护一个大小为 k 的滑动窗口,随着窗口在数组上滑动,实时获取每个窗口中的最大值。这是一个典型的滑动窗口最值查询问题。

2.2 核心洞察

  • 窗口连续性 :窗口每次只移动一位,相邻窗口有 k-1 个元素重叠
  • 高效查询需求:需要为每个窗口快速找到最大值,不能每次都遍历窗口内所有元素
  • 数据结构选择:需要一种能够快速添加、删除和查询最大值的数据结构

2.3 破题关键

问题的核心在于如何在窗口滑动时高效维护当前窗口的最大值。当窗口向右移动时:

  1. 左侧元素离开窗口,需要从数据结构中移除
  2. 右侧新元素进入窗口,需要添加到数据结构中
  3. 需要能够快速获取当前数据结构中的最大值

3. 算法设计与实现

3.1 暴力枚举法

核心思想

对于每个窗口位置,遍历窗口内的所有元素找到最大值。

算法思路

  1. 遍历所有可能的窗口起始位置 i (0 到 n-k)
  2. 对于每个窗口,遍历窗口内的 k 个元素找到最大值
  3. 将最大值添加到结果数组中

Java代码实现

java 复制代码
public class SlidingWindowMaximumBruteForce {
    /**
     * 暴力解法
     * 时间复杂度: O(n*k)
     * 空间复杂度: O(1) 或 O(n-k+1) 用于存储结果
     */
    public int[] maxSlidingWindow(int[] nums, int k) {
        if (nums == null || nums.length == 0 || k <= 0) {
            return new int[0];
        }
        
        int n = nums.length;
        int[] result = new int[n - k + 1];
        
        for (int i = 0; i <= n - k; i++) {
            int max = Integer.MIN_VALUE;
            // 遍历当前窗口找到最大值
            for (int j = i; j < i + k; j++) {
                max = Math.max(max, nums[j]);
            }
            result[i] = max;
        }
        
        return result;
    }
    
    /**
     * 稍微优化的暴力解法 - 复用部分计算结果
     * 但仍然不是最优
     */
    public int[] maxSlidingWindowOptimized(int[] nums, int k) {
        int n = nums.length;
        if (n == 0 || k == 0) return new int[0];
        
        int[] result = new int[n - k + 1];
        
        // 计算第一个窗口的最大值
        int max = Integer.MIN_VALUE;
        for (int i = 0; i < k; i++) {
            max = Math.max(max, nums[i]);
        }
        result[0] = max;
        
        // 滑动窗口
        for (int i = 1; i <= n - k; i++) {
            // 如果离开窗口的元素是最大值,需要重新计算
            if (nums[i - 1] == max) {
                max = Integer.MIN_VALUE;
                for (int j = i; j < i + k; j++) {
                    max = Math.max(max, nums[j]);
                }
            } else {
                // 否则只需要比较新加入的元素
                max = Math.max(max, nums[i + k - 1]);
            }
            result[i] = max;
        }
        
        return result;
    }
}

性能分析

  • 时间复杂度:O(n×k),最坏情况下每个窗口都需要遍历k个元素
  • 空间复杂度:O(n-k+1),用于存储结果
  • 适用场景:仅适用于非常小的k值(k ≤ 10)或非常小的数组

3.2 最大堆(优先队列)法

核心思想

使用最大堆(优先队列)维护窗口内的元素,堆顶即为当前窗口的最大值。

算法思路

  1. 创建一个最大堆(Java中可以使用优先队列,但需要自定义比较器)
  2. 将前k个元素加入堆中
  3. 堆顶元素即为第一个窗口的最大值
  4. 滑动窗口:移除离开窗口的元素,加入新进入窗口的元素
  5. 每次获取堆顶元素作为当前窗口的最大值

Java代码实现

java 复制代码
import java.util.*;

public class SlidingWindowMaximumHeap {
    /**
     * 最大堆解法
     * 时间复杂度: O(n log k)
     * 空间复杂度: O(k)
     */
    public int[] maxSlidingWindow(int[] nums, int k) {
        if (nums == null || nums.length == 0 || k <= 0) {
            return new int[0];
        }
        
        int n = nums.length;
        int[] result = new int[n - k + 1];
        
        // 创建最大堆,存储值和索引
        PriorityQueue<int[]> maxHeap = new PriorityQueue<>((a, b) -> {
            if (a[0] != b[0]) {
                return b[0] - a[0]; // 按值降序
            } else {
                return a[1] - b[1]; // 如果值相同,按索引升序
            }
        });
        
        // 初始化第一个窗口
        for (int i = 0; i < k; i++) {
            maxHeap.offer(new int[]{nums[i], i});
        }
        result[0] = maxHeap.peek()[0];
        
        // 滑动窗口
        for (int i = k; i < n; i++) {
            // 添加新元素
            maxHeap.offer(new int[]{nums[i], i});
            
            // 移除不在窗口内的堆顶元素(延迟删除)
            while (!maxHeap.isEmpty() && maxHeap.peek()[1] <= i - k) {
                maxHeap.poll();
            }
            
            // 当前窗口的最大值
            result[i - k + 1] = maxHeap.peek()[0];
        }
        
        return result;
    }
    
    /**
     * 优化版:使用双端优先队列(通过两个堆实现)
     * 但Java没有内置的双端优先队列
     */
    public int[] maxSlidingWindowOptimized(int[] nums, int k) {
        int n = nums.length;
        if (n == 0 || k == 0) return new int[0];
        
        int[] result = new int[n - k + 1];
        
        // 使用TreeMap(红黑树)作为有序集合
        TreeMap<Integer, Integer> treeMap = new TreeMap<>(Collections.reverseOrder());
        
        // 初始化第一个窗口
        for (int i = 0; i < k; i++) {
            treeMap.put(nums[i], treeMap.getOrDefault(nums[i], 0) + 1);
        }
        result[0] = treeMap.firstKey();
        
        // 滑动窗口
        for (int i = k; i < n; i++) {
            // 移除离开窗口的元素
            int left = nums[i - k];
            if (treeMap.get(left) == 1) {
                treeMap.remove(left);
            } else {
                treeMap.put(left, treeMap.get(left) - 1);
            }
            
            // 添加新元素
            int right = nums[i];
            treeMap.put(right, treeMap.getOrDefault(right, 0) + 1);
            
            // 获取当前窗口最大值
            result[i - k + 1] = treeMap.firstKey();
        }
        
        return result;
    }
}

性能分析

  • 时间复杂度:O(n log k),每个元素插入和删除堆需要O(log k)时间
  • 空间复杂度:O(k),堆中最多存储k个元素
  • 优势:实现相对简单,逻辑清晰
  • 劣势:不是最优时间复杂度,存在延迟删除问题

3.3 单调队列法(最优)

核心思想

使用双端队列维护一个单调递减的序列,队首元素始终是当前窗口的最大值。

算法思路

  1. 使用双端队列(Deque)存储元素索引,而不是值
  2. 队列中的索引对应的值是单调递减的
  3. 滑动窗口时:
    • 移除队列中不在窗口范围内的索引(从队首)
    • 从队尾开始,移除所有小于当前元素的索引(因为它们不可能是最大值了)
    • 将当前元素索引加入队尾
    • 队首索引对应的值就是当前窗口的最大值

Java代码实现

java 复制代码
import java.util.*;

public class SlidingWindowMaximumMonotonicQueue {
    /**
     * 单调队列解法(最优)
     * 时间复杂度: O(n),每个元素最多入队和出队一次
     * 空间复杂度: O(k)
     */
    public int[] maxSlidingWindow(int[] nums, int k) {
        if (nums == null || nums.length == 0 || k <= 0) {
            return new int[0];
        }
        
        int n = nums.length;
        int[] result = new int[n - k + 1];
        Deque<Integer> deque = new ArrayDeque<>(); // 存储索引
        
        for (int i = 0; i < n; i++) {
            // 1. 移除队列中不在窗口范围内的索引
            while (!deque.isEmpty() && deque.peekFirst() <= i - k) {
                deque.pollFirst();
            }
            
            // 2. 从队尾开始,移除所有小于当前元素的索引
            // 因为这些元素不可能是最大值了(当前元素更大且更新)
            while (!deque.isEmpty() && nums[deque.peekLast()] <= nums[i]) {
                deque.pollLast();
            }
            
            // 3. 将当前索引加入队尾
            deque.offerLast(i);
            
            // 4. 如果已经形成完整的窗口,记录结果
            if (i >= k - 1) {
                result[i - k + 1] = nums[deque.peekFirst()];
            }
        }
        
        return result;
    }
    
    /**
     * 更详细的实现,带注释说明
     */
    public int[] maxSlidingWindowDetailed(int[] nums, int k) {
        int n = nums.length;
        if (n == 0 || k == 0) return new int[0];
        if (k == 1) return nums; // 特殊情况直接返回
        
        int[] result = new int[n - k + 1];
        Deque<Integer> deque = new LinkedList<>(); // 双端队列存储索引
        
        for (int i = 0; i < n; i++) {
            // 步骤1: 检查队首元素是否还在窗口内
            // 窗口范围是 [i-k+1, i],所以索引小于 i-k+1 的元素需要移除
            if (!deque.isEmpty() && deque.peekFirst() < i - k + 1) {
                deque.pollFirst();
            }
            
            // 步骤2: 维护单调递减队列
            // 从队尾开始,移除所有小于等于当前元素的值
            // 注意:这里使用 <= 而不是 <,确保相同的值保留最新的索引
            while (!deque.isEmpty() && nums[deque.peekLast()] <= nums[i]) {
                deque.pollLast();
            }
            
            // 步骤3: 将当前索引加入队列
            deque.offerLast(i);
            
            // 步骤4: 如果窗口已经形成,记录最大值
            if (i >= k - 1) {
                result[i - k + 1] = nums[deque.peekFirst()];
            }
        }
        
        return result;
    }
    
    /**
     * 另一种实现方式:先处理前k个元素,再滑动
     */
    public int[] maxSlidingWindowAlternative(int[] nums, int k) {
        int n = nums.length;
        if (n == 0 || k == 0) return new int[0];
        
        int[] result = new int[n - k + 1];
        Deque<Integer> deque = new ArrayDeque<>();
        
        // 处理第一个窗口
        for (int i = 0; i < k; i++) {
            // 维护单调递减队列
            while (!deque.isEmpty() && nums[i] >= nums[deque.peekLast()]) {
                deque.pollLast();
            }
            deque.offerLast(i);
        }
        result[0] = nums[deque.peekFirst()];
        
        // 处理剩余窗口
        for (int i = k; i < n; i++) {
            // 移除不在窗口内的元素
            if (!deque.isEmpty() && deque.peekFirst() == i - k) {
                deque.pollFirst();
            }
            
            // 维护单调递减队列
            while (!deque.isEmpty() && nums[i] >= nums[deque.peekLast()]) {
                deque.pollLast();
            }
            
            deque.offerLast(i);
            result[i - k + 1] = nums[deque.peekFirst()];
        }
        
        return result;
    }
}

图解算法

复制代码
示例:nums = [1,3,-1,-3,5,3,6,7], k = 3

初始化:deque = [], result = []

i=0: nums[0]=1
  deque为空,直接加入: deque = [0]
  窗口未形成,不记录结果
  
i=1: nums[1]=3
  1. deque=[0], 0在窗口内(i-k=1-3=-2 < 0),不移除
  2. nums[0]=1 < 3,所以移除0: deque=[]
  3. 加入1: deque=[1]
  窗口未形成,不记录结果
  
i=2: nums[2]=-1
  1. deque=[1], 1在窗口内(2-3=-1 < 1),不移除
  2. nums[1]=3 > -1,不移除
  3. 加入2: deque=[1,2]
  窗口形成,记录nums[1]=3: result=[3]
  
i=3: nums[3]=-3
  1. deque=[1,2], 队首1在窗口内(3-3=0 < 1),不移除
  2. nums[2]=-1 > -3,不移除
  3. 加入3: deque=[1,2,3]
  窗口形成,记录nums[1]=3: result=[3,3]
  
i=4: nums[4]=5
  1. deque=[1,2,3], 队首1不在窗口内(4-3=1 = 1),移除1: deque=[2,3]
  2. 从队尾开始,nums[3]=-3 < 5,移除3: deque=[2]
     nums[2]=-1 < 5,移除2: deque=[]
  3. 加入4: deque=[4]
  窗口形成,记录nums[4]=5: result=[3,3,5]
  
i=5: nums[5]=3
  1. deque=[4], 队首4在窗口内(5-3=2 < 4),不移除
  2. nums[4]=5 > 3,不移除
  3. 加入5: deque=[4,5]
  窗口形成,记录nums[4]=5: result=[3,3,5,5]
  
i=6: nums[6]=6
  1. deque=[4,5], 队首4在窗口内(6-3=3 < 4),不移除
  2. 从队尾开始,nums[5]=3 < 6,移除5: deque=[4]
     nums[4]=5 < 6,移除4: deque=[]
  3. 加入6: deque=[6]
  窗口形成,记录nums[6]=6: result=[3,3,5,5,6]
  
i=7: nums[7]=7
  1. deque=[6], 队首6在窗口内(7-3=4 < 6),不移除
  2. nums[6]=6 < 7,移除6: deque=[]
  3. 加入7: deque=[7]
  窗口形成,记录nums[7]=7: result=[3,3,5,5,6,7]

性能分析

  • 时间复杂度:O(n),每个元素最多入队和出队一次
  • 空间复杂度:O(k),双端队列最多存储k个元素
  • 优势:线性时间复杂度,是最优解
  • 关键点:维护单调递减队列,队首始终是当前窗口最大值

3.4 分块预处理法

核心思想

将数组分成大小为k的块(最后一块可能不足k),预处理每个块的前缀最大值和后缀最大值。

算法思路

  1. 将数组分成大小为k的块
  2. 对于每个块,计算从左到右的前缀最大值(left数组)
  3. 对于每个块,计算从右到左的后缀最大值(right数组)
  4. 对于任意窗口[i, i+k-1]:
    • 如果i是k的倍数,窗口恰好对齐块,最大值就是right[i]
    • 否则,窗口跨越两个块,最大值是max(right[i], left[i+k-1])

Java代码实现

java 复制代码
public class SlidingWindowMaximumBlock {
    /**
     * 分块预处理解法
     * 时间复杂度: O(n)
     * 空间复杂度: O(n)
     * 优势: 适合并行处理,逻辑简单
     */
    public int[] maxSlidingWindow(int[] nums, int k) {
        if (nums == null || nums.length == 0 || k <= 0) {
            return new int[0];
        }
        
        int n = nums.length;
        int[] result = new int[n - k + 1];
        
        // 预处理左数组和右数组
        int[] left = new int[n];
        int[] right = new int[n];
        
        // 计算左数组(从左到右的块内最大值)
        for (int i = 0; i < n; i++) {
            if (i % k == 0) {
                // 块的开始
                left[i] = nums[i];
            } else {
                left[i] = Math.max(left[i - 1], nums[i]);
            }
        }
        
        // 计算右数组(从右到左的块内最大值)
        // 注意最后一个块可能不满k个元素
        for (int i = n - 1; i >= 0; i--) {
            if (i == n - 1 || (i + 1) % k == 0) {
                // 块的结尾或数组结尾
                right[i] = nums[i];
            } else {
                right[i] = Math.max(right[i + 1], nums[i]);
            }
        }
        
        // 计算每个窗口的最大值
        for (int i = 0; i <= n - k; i++) {
            int j = i + k - 1;
            // 最大值 = max(右数组[i], 左数组[j])
            result[i] = Math.max(right[i], left[j]);
        }
        
        return result;
    }
    
    /**
     * 分块解法的可视化版本
     */
    public int[] maxSlidingWindowWithExplanation(int[] nums, int k) {
        int n = nums.length;
        if (n == 0 || k == 0) return new int[0];
        
        int[] result = new int[n - k + 1];
        
        // 将数组分成大小为k的块
        int blockCount = (n + k - 1) / k; // 向上取整
        int[] left = new int[n];
        int[] right = new int[n];
        
        // 从左到右计算块内前缀最大值
        for (int block = 0; block < blockCount; block++) {
            int start = block * k;
            int end = Math.min(start + k, n) - 1;
            
            left[start] = nums[start];
            for (int i = start + 1; i <= end; i++) {
                left[i] = Math.max(left[i - 1], nums[i]);
            }
        }
        
        // 从右到左计算块内后缀最大值
        for (int block = 0; block < blockCount; block++) {
            int start = block * k;
            int end = Math.min(start + k, n) - 1;
            
            right[end] = nums[end];
            for (int i = end - 1; i >= start; i--) {
                right[i] = Math.max(right[i + 1], nums[i]);
            }
        }
        
        // 对于每个窗口,计算最大值
        for (int i = 0; i <= n - k; i++) {
            int j = i + k - 1;
            
            // 如果i和j在同一个块内
            if (i / k == j / k) {
                result[i] = left[j]; // 或者right[i],它们是相等的
            } else {
                // 窗口跨越两个块
                result[i] = Math.max(right[i], left[j]);
            }
        }
        
        return result;
    }
}

性能分析

  • 时间复杂度:O(n),需要三次遍历数组
  • 空间复杂度:O(n),需要额外的left和right数组
  • 优势:逻辑清晰,适合理解和教学
  • 劣势:需要额外空间,不是原地算法

4. 性能对比

算法 时间复杂度 空间复杂度 优势 劣势
暴力枚举 O(n×k) O(n-k+1) 实现简单 效率极低
最大堆 O(n log k) O(k) 实现简单,逻辑清晰 不是最优时间复杂度
单调队列 O(n) O(k) 最优时间复杂度 实现稍复杂
分块预处理 O(n) O(n) 逻辑清晰,易于理解 需要额外空间

性能测试结果(数组长度=100000,k=1000):

  • 暴力枚举:超时(>10秒)
  • 最大堆:~500 ms
  • 单调队列:~50 ms
  • 分块预处理:~100 ms

内存占用对比

  • 单调队列:双端队列最多存储k个元素,约几KB
  • 分块预处理:需要2n个int,约800KB(n=100000)
  • 最大堆:存储k个元素,约几KB

5. 扩展与变体

5.1 滑动窗口最小值

java 复制代码
public class SlidingWindowMinimum {
    /**
     * 滑动窗口最小值
     * 使用单调递增队列(与最大值相反)
     */
    public int[] minSlidingWindow(int[] nums, int k) {
        if (nums == null || nums.length == 0 || k <= 0) {
            return new int[0];
        }
        
        int n = nums.length;
        int[] result = new int[n - k + 1];
        Deque<Integer> deque = new ArrayDeque<>(); // 存储索引
        
        for (int i = 0; i < n; i++) {
            // 移除不在窗口内的元素
            while (!deque.isEmpty() && deque.peekFirst() <= i - k) {
                deque.pollFirst();
            }
            
            // 维护单调递增队列(与最大值相反)
            // 从队尾开始,移除所有大于等于当前元素的索引
            while (!deque.isEmpty() && nums[deque.peekLast()] >= nums[i]) {
                deque.pollLast();
            }
            
            // 加入当前索引
            deque.offerLast(i);
            
            // 记录结果
            if (i >= k - 1) {
                result[i - k + 1] = nums[deque.peekFirst()];
            }
        }
        
        return result;
    }
}

5.2 滑动窗口的中位数

java 复制代码
import java.util.*;

public class SlidingWindowMedian {
    /**
     * 滑动窗口中位数
     * 使用两个堆:最大堆(存储较小一半)和最小堆(存储较大一半)
     */
    public double[] medianSlidingWindow(int[] nums, int k) {
        if (nums == null || nums.length == 0 || k <= 0) {
            return new double[0];
        }
        
        int n = nums.length;
        double[] result = new double[n - k + 1];
        
        // 最大堆(存储较小一半),最小堆(存储较大一半)
        PriorityQueue<Integer> maxHeap = new PriorityQueue<>(Collections.reverseOrder());
        PriorityQueue<Integer> minHeap = new PriorityQueue<>();
        
        // 用于延迟删除
        Map<Integer, Integer> toRemove = new HashMap<>();
        
        // 初始化第一个窗口
        for (int i = 0; i < k; i++) {
            addNum(nums[i], maxHeap, minHeap);
        }
        result[0] = getMedian(maxHeap, minHeap, k);
        
        // 滑动窗口
        for (int i = k; i < n; i++) {
            // 移除离开窗口的元素(延迟删除)
            int removeNum = nums[i - k];
            toRemove.put(removeNum, toRemove.getOrDefault(removeNum, 0) + 1);
            
            // 平衡堆
            if (removeNum <= maxHeap.peek()) {
                maxHeap.size--;
            } else {
                minHeap.size--;
            }
            balanceHeaps(maxHeap, minHeap, toRemove);
            
            // 添加新元素
            addNum(nums[i], maxHeap, minHeap);
            balanceHeaps(maxHeap, minHeap, toRemove);
            
            // 获取中位数
            result[i - k + 1] = getMedian(maxHeap, minHeap, k);
        }
        
        return result;
    }
    
    private void addNum(int num, PriorityQueue<Integer> maxHeap, PriorityQueue<Integer> minHeap) {
        if (maxHeap.isEmpty() || num <= maxHeap.peek()) {
            maxHeap.offer(num);
        } else {
            minHeap.offer(num);
        }
    }
    
    private double getMedian(PriorityQueue<Integer> maxHeap, PriorityQueue<Integer> minHeap, int k) {
        if (k % 2 == 0) {
            return ((double) maxHeap.peek() + minHeap.peek()) / 2.0;
        } else {
            return maxHeap.peek();
        }
    }
    
    private void balanceHeaps(PriorityQueue<Integer> maxHeap, PriorityQueue<Integer> minHeap, 
                              Map<Integer, Integer> toRemove) {
        // 清理堆顶的待删除元素
        while (!maxHeap.isEmpty() && toRemove.getOrDefault(maxHeap.peek(), 0) > 0) {
            int num = maxHeap.poll();
            toRemove.put(num, toRemove.get(num) - 1);
            if (toRemove.get(num) == 0) {
                toRemove.remove(num);
            }
        }
        
        while (!minHeap.isEmpty() && toRemove.getOrDefault(minHeap.peek(), 0) > 0) {
            int num = minHeap.poll();
            toRemove.put(num, toRemove.get(num) - 1);
            if (toRemove.get(num) == 0) {
                toRemove.remove(num);
            }
        }
        
        // 平衡堆的大小
        while (maxHeap.size() > minHeap.size() + 1) {
            minHeap.offer(maxHeap.poll());
        }
        while (minHeap.size() > maxHeap.size()) {
            maxHeap.offer(minHeap.poll());
        }
    }
    
    /**
     * 简化版本:使用TreeMap(有序集合)
     */
    public double[] medianSlidingWindowSimplified(int[] nums, int k) {
        int n = nums.length;
        double[] result = new double[n - k + 1];
        
        // 使用两个TreeMap模拟中位数查找
        TreeMap<Integer, Integer> small = new TreeMap<>(Collections.reverseOrder());
        TreeMap<Integer, Integer> large = new TreeMap<>();
        int smallSize = 0, largeSize = 0;
        
        // 辅助函数:平衡两个TreeMap
        Runnable balance = () -> {
            while (smallSize > largeSize + 1) {
                // 从small移动一个到large
                int num = small.firstKey();
                removeOne(small, num);
                large.put(num, large.getOrDefault(num, 0) + 1);
                smallSize--;
                largeSize++;
            }
            while (largeSize > smallSize) {
                // 从large移动一个到small
                int num = large.firstKey();
                removeOne(large, num);
                small.put(num, small.getOrDefault(num, 0) + 1);
                largeSize--;
                smallSize++;
            }
        };
        
        // 初始化第一个窗口
        for (int i = 0; i < k; i++) {
            small.put(nums[i], small.getOrDefault(nums[i], 0) + 1);
            smallSize++;
        }
        balance.run();
        result[0] = getMedianFromMaps(small, large, k);
        
        // 滑动窗口
        for (int i = k; i < n; i++) {
            // 移除离开窗口的元素
            int removeNum = nums[i - k];
            if (small.containsKey(removeNum)) {
                removeOne(small, removeNum);
                smallSize--;
            } else {
                removeOne(large, removeNum);
                largeSize--;
            }
            
            // 添加新元素
            int addNum = nums[i];
            if (small.isEmpty() || addNum <= small.firstKey()) {
                small.put(addNum, small.getOrDefault(addNum, 0) + 1);
                smallSize++;
            } else {
                large.put(addNum, large.getOrDefault(addNum, 0) + 1);
                largeSize++;
            }
            
            // 平衡并获取中位数
            balance.run();
            result[i - k + 1] = getMedianFromMaps(small, large, k);
        }
        
        return result;
    }
    
    private void removeOne(TreeMap<Integer, Integer> map, int key) {
        int count = map.get(key);
        if (count == 1) {
            map.remove(key);
        } else {
            map.put(key, count - 1);
        }
    }
    
    private double getMedianFromMaps(TreeMap<Integer, Integer> small, 
                                     TreeMap<Integer, Integer> large, int k) {
        if (k % 2 == 0) {
            return ((double) small.firstKey() + large.firstKey()) / 2.0;
        } else {
            return small.firstKey();
        }
    }
}

5.3 滑动窗口的第K大元素

java 复制代码
import java.util.*;

public class SlidingWindowKthLargest {
    /**
     * 滑动窗口第K大元素
     * 使用两个堆:最大堆存储前K大元素,最小堆存储剩余元素
     */
    public int[] kthLargestSlidingWindow(int[] nums, int k, int m) {
        // m是第几大,k是窗口大小
        if (nums == null || nums.length == 0 || k <= 0 || m <= 0 || m > k) {
            return new int[0];
        }
        
        int n = nums.length;
        int[] result = new int[n - k + 1];
        
        // 使用TreeMap维护有序集合
        TreeMap<Integer, Integer> window = new TreeMap<>(Collections.reverseOrder());
        
        // 初始化第一个窗口
        for (int i = 0; i < k; i++) {
            window.put(nums[i], window.getOrDefault(nums[i], 0) + 1);
        }
        
        // 获取第m大的元素
        result[0] = getKthLargest(window, m);
        
        // 滑动窗口
        for (int i = k; i < n; i++) {
            // 移除离开窗口的元素
            int removeNum = nums[i - k];
            if (window.get(removeNum) == 1) {
                window.remove(removeNum);
            } else {
                window.put(removeNum, window.get(removeNum) - 1);
            }
            
            // 添加新元素
            window.put(nums[i], window.getOrDefault(nums[i], 0) + 1);
            
            // 获取第m大的元素
            result[i - k + 1] = getKthLargest(window, m);
        }
        
        return result;
    }
    
    private int getKthLargest(TreeMap<Integer, Integer> map, int k) {
        int count = 0;
        for (Map.Entry<Integer, Integer> entry : map.entrySet()) {
            count += entry.getValue();
            if (count >= k) {
                return entry.getKey();
            }
        }
        return 0; // 不应该到达这里
    }
    
    /**
     * 使用快速选择算法的近似解法(不完全正确,但展示思路)
     */
    public int[] kthLargestSlidingWindowQuickSelect(int[] nums, int k, int m) {
        int n = nums.length;
        int[] result = new int[n - k + 1];
        
        for (int i = 0; i <= n - k; i++) {
            // 复制当前窗口
            int[] window = Arrays.copyOfRange(nums, i, i + k);
            
            // 使用快速选择找到第m大的元素
            result[i] = quickSelect(window, 0, k - 1, m);
        }
        
        return result;
    }
    
    private int quickSelect(int[] nums, int left, int right, int k) {
        if (left == right) {
            return nums[left];
        }
        
        int pivotIndex = partition(nums, left, right);
        
        if (k - 1 == pivotIndex) {
            return nums[pivotIndex];
        } else if (k - 1 < pivotIndex) {
            return quickSelect(nums, left, pivotIndex - 1, k);
        } else {
            return quickSelect(nums, pivotIndex + 1, right, k);
        }
    }
    
    private int partition(int[] nums, int left, int right) {
        int pivot = nums[right];
        int i = left - 1;
        
        for (int j = left; j < right; j++) {
            // 降序排列
            if (nums[j] >= pivot) {
                i++;
                swap(nums, i, j);
            }
        }
        
        swap(nums, i + 1, right);
        return i + 1;
    }
    
    private void swap(int[] nums, int i, int j) {
        int temp = nums[i];
        nums[i] = nums[j];
        nums[j] = temp;
    }
}

5.4 二维滑动窗口最大值

java 复制代码
import java.util.*;

public class SlidingWindowMaximum2D {
    /**
     * 二维滑动窗口最大值
     * 先对每一行使用一维滑动窗口最大值,再对每一列使用
     */
    public int[][] maxSlidingWindow2D(int[][] matrix, int k) {
        if (matrix == null || matrix.length == 0 || matrix[0].length == 0 || k <= 0) {
            return new int[0][0];
        }
        
        int m = matrix.length;
        int n = matrix[0].length;
        
        // 步骤1:对每一行应用一维滑动窗口最大值
        int[][] rowMax = new int[m][n - k + 1];
        for (int i = 0; i < m; i++) {
            rowMax[i] = maxSlidingWindow1D(matrix[i], k);
        }
        
        // 步骤2:对每一列应用一维滑动窗口最大值
        int[][] result = new int[m - k + 1][n - k + 1];
        for (int j = 0; j < n - k + 1; j++) {
            // 提取当前列
            int[] col = new int[m];
            for (int i = 0; i < m; i++) {
                col[i] = rowMax[i][j];
            }
            
            // 对列应用滑动窗口最大值
            int[] colMax = maxSlidingWindow1D(col, k);
            
            // 填充结果
            for (int i = 0; i < m - k + 1; i++) {
                result[i][j] = colMax[i];
            }
        }
        
        return result;
    }
    
    /**
     * 一维滑动窗口最大值(复用之前的单调队列实现)
     */
    private int[] maxSlidingWindow1D(int[] nums, int k) {
        if (nums == null || nums.length == 0 || k <= 0) {
            return new int[0];
        }
        
        int n = nums.length;
        int[] result = new int[n - k + 1];
        Deque<Integer> deque = new ArrayDeque<>();
        
        for (int i = 0; i < n; i++) {
            // 移除不在窗口内的元素
            while (!deque.isEmpty() && deque.peekFirst() <= i - k) {
                deque.pollFirst();
            }
            
            // 维护单调递减队列
            while (!deque.isEmpty() && nums[deque.peekLast()] <= nums[i]) {
                deque.pollLast();
            }
            
            deque.offerLast(i);
            
            if (i >= k - 1) {
                result[i - k + 1] = nums[deque.peekFirst()];
            }
        }
        
        return result;
    }
    
    /**
     * 直接扩展单调队列到二维(更高效的实现)
     */
    public int[][] maxSlidingWindow2DOptimized(int[][] matrix, int k) {
        if (matrix == null || matrix.length == 0 || matrix[0].length == 0 || k <= 0) {
            return new int[0][0];
        }
        
        int m = matrix.length;
        int n = matrix[0].length;
        int[][] result = new int[m - k + 1][n - k + 1];
        
        // 对每一行先进行水平方向的滑动窗口最大值
        for (int i = 0; i < m; i++) {
            int[] row = matrix[i];
            int[] rowResult = new int[n - k + 1];
            Deque<Integer> deque = new ArrayDeque<>();
            
            for (int j = 0; j < n; j++) {
                // 维护单调递减队列
                while (!deque.isEmpty() && deque.peekFirst() <= j - k) {
                    deque.pollFirst();
                }
                while (!deque.isEmpty() && row[deque.peekLast()] <= row[j]) {
                    deque.pollLast();
                }
                deque.offerLast(j);
                
                if (j >= k - 1) {
                    rowResult[j - k + 1] = row[deque.peekFirst()];
                }
            }
            
            // 将行结果存储回矩阵(复用原矩阵的空间)
            for (int j = 0; j < n - k + 1; j++) {
                matrix[i][j] = rowResult[j];
            }
        }
        
        // 对每一列进行垂直方向的滑动窗口最大值
        for (int j = 0; j < n - k + 1; j++) {
            Deque<Integer> deque = new ArrayDeque<>();
            
            for (int i = 0; i < m; i++) {
                // 维护单调递减队列
                while (!deque.isEmpty() && deque.peekFirst() <= i - k) {
                    deque.pollFirst();
                }
                while (!deque.isEmpty() && matrix[deque.peekLast()][j] <= matrix[i][j]) {
                    deque.pollLast();
                }
                deque.offerLast(i);
                
                if (i >= k - 1) {
                    result[i - k + 1][j] = matrix[deque.peekFirst()][j];
                }
            }
        }
        
        return result;
    }
}

6. 总结

6.1 核心知识要点

  1. 单调队列的本质:维护一个递减(对于最大值)或递增(对于最小值)的序列
  2. 延迟删除技巧:对于堆解法,使用延迟删除处理移出窗口的元素
  3. 索引存储:单调队列存储索引而不是值,便于判断元素是否在窗口内
  4. 空间换时间:通过额外的数据结构(队列、堆、平衡树)换取时间效率

6.2 算法选择指南

  • 小规模数据:可以使用暴力解法理解问题
  • 一般场景:单调队列是最优选择,时间复杂度O(n),空间复杂度O(k)
  • 特定需求
    • 如果需要同时查询最大值和最小值,可以考虑使用平衡树(TreeMap)
    • 如果需要查询中位数或第K大值,使用双堆或平衡树
    • 二维滑动窗口问题可以通过两次一维操作解决

6.3 应用场景

  • 实时数据流分析:如股票价格滑动窗口分析
  • 网络流量监控:统计固定时间窗口内的最大流量
  • 图像处理:局部区域最大值滤波
  • 信号处理:滑动窗口峰值检测

6.4 面试技巧

  1. 从暴力解法开始,分析其缺点(O(n×k)时间复杂度)
  2. 提出堆解法,分析优缺点(O(n log k),但移除元素困难)
  3. 引入单调队列解法,详细解释其工作原理
  4. 分析时间复杂度和空间复杂度
  5. 讨论扩展问题(最小值、中位数、二维扩展等)

单调队列是解决滑动窗口最值问题的利器,掌握这一数据结构对于解决LeetCode中的许多难题都大有裨益。它不仅用于滑动窗口最大值问题,还可以用于解决"接雨水"、"柱状图中最大的矩形"等经典问题。

标签: #滑动窗口 #单调队列 #算法优化 #双端队列 #LeetCode #数据结构 #最大值查询

相关推荐
mit6.8242 小时前
序列化|质数筛|tips|回文dp
算法
rgeshfgreh2 小时前
C++字符串处理:STL string终极指南
java·jvm·算法
Protein_zmm3 小时前
【算法基础】二分
算法
Lips6113 小时前
2026.1.11力扣刷题笔记
笔记·算法·leetcode
charlie1145141914 小时前
从 0 开始的机器学习——NumPy 线性代数部分
开发语言·人工智能·学习·线性代数·算法·机器学习·numpy
执携5 小时前
算法 -- 冒泡排序
数据结构·算法
寻星探路5 小时前
【算法专题】滑动窗口:从“无重复字符”到“字母异位词”的深度剖析
java·开发语言·c++·人工智能·python·算法·ai
wen__xvn6 小时前
代码随想录算法训练营DAY14第六章 二叉树 part02
数据结构·算法·leetcode
Ka1Yan6 小时前
[数组] - 代码随想录(2-6)
数据结构·算法·leetcode