Java详解LeetCode 热题 100(11):LeetCode 239. 滑动窗口最大值(Sliding Window Maximum)详解

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]

示例 3:

输入:nums = [1,-1], k = 1
输出:[1,-1]

示例 4:

输入:nums = [9,11], k = 2
输出:[11]

示例 5:

输入:nums = [4,-2], k = 2
输出:[4]

提示:

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

2. 理解题目

这道题需要我们求解滑动窗口内的最大值。滑动窗口是固定大小为k的子数组,从数组左端开始,每次向右移动一个位置,直到窗口右端达到数组末尾。

关键理解:

  1. 窗口大小固定为k
  2. 窗口每次向右移动一个位置
  3. 需要返回每个窗口位置的最大值
  4. 最终结果是一个长度为n-k+1的数组,其中n是原数组长度

例如,对于示例1中的数组[1,3,-1,-3,5,3,6,7]和k=3:

  • 第一个窗口包含元素[1,3,-1],最大值是3
  • 第二个窗口包含元素[3,-1,-3],最大值是3
  • 第三个窗口包含元素[-1,-3,5],最大值是5
  • 依此类推…

最终输出:[3,3,5,5,6,7]

3. 解法一:暴力法

3.1 思路

最直接的思路是对每个窗口位置,遍历窗口内的所有元素来找出最大值。

具体步骤:

  1. 对于每个窗口位置(从0到n-k),遍历窗口内的k个元素
  2. 找出这k个元素中的最大值
  3. 将最大值添加到结果数组中

3.2 Java代码实现

public class Solution {
    public int[] maxSlidingWindow(int[] nums, int k) {
        int n = nums.length;
        if (n == 0 || k == 0) {
            return new int[0];
        }
        
        // 结果数组长度为 n-k+1
        int[] result = 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]);
            }
            
            result[i] = max; // 保存当前窗口的最大值
        }
        
        return result;
    }
}

3.3 代码详解

  1. 首先处理边界情况:如果数组为空或窗口大小为0,返回空数组
  2. 创建结果数组,其长度为n-k+1
  3. 外层循环遍历每个窗口的起始位置
  4. 内层循环遍历当前窗口内的元素,找出最大值
  5. 将当前窗口的最大值存入结果数组

3.4 复杂度分析

  • 时间复杂度:O(n*k),其中n是数组长度。对于每个窗口位置(共n-k+1个),我们需要O(k)时间找出窗口内的最大值。
  • 空间复杂度:O(n-k+1),即结果数组的大小。

3.5 适用场景

暴力法适用于数组长度不大且窗口大小较小的情况。当数组长度或窗口大小较大时,这种方法效率较低。

4. 解法二:优先队列(最大堆)

4.1 思路

我们可以使用优先队列(最大堆)来维护窗口中的元素,这样可以在O(log k)时间内获取窗口中的最大值。

为了处理元素移出窗口的情况,我们需要在队列中存储元素值和索引的对,这样可以判断堆顶元素是否还在当前窗口内。

具体步骤:

  1. 创建一个最大堆,存储元素值和索引的对
  2. 初始将前k个元素加入堆
  3. 对于每个窗口位置:
    • 检查堆顶元素是否在当前窗口内(通过索引判断)
    • 如果不在,弹出堆顶元素,直到找到一个在窗口内的元素
    • 将当前窗口的最大值(堆顶元素)添加到结果中
    • 将下一个元素加入堆(如果存在)

4.2 Java代码实现

import java.util.PriorityQueue;
import java.util.Comparator;

public class Solution {
    public int[] maxSlidingWindow(int[] nums, int k) {
        int n = nums.length;
        if (n == 0 || k == 0) {
            return new int[0];
        }
        
        // 结果数组
        int[] result = new int[n - k + 1];
        
        // 创建最大堆,按元素值降序排列
        PriorityQueue<int[]> maxHeap = new PriorityQueue<>(
            (a, b) -> b[0] - a[0] // 降序排列,最大值在堆顶
        );
        
        // 初始将前k个元素加入堆
        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;
    }
}

4.3 代码详解

  1. 处理边界情况:如果数组为空或窗口大小为0,返回空数组
  2. 创建结果数组,其长度为n-k+1
  3. 创建最大堆,存储元素值和索引的二元组,按元素值降序排列
  4. 初始将前k个元素加入堆,并获取第一个窗口的最大值
  5. 对于每个后续窗口:
    • 将窗口中的新元素加入堆
    • 移除不在当前窗口内的元素(即索引小于或等于i-k的元素)
    • 将堆顶元素的值(当前窗口最大值)存入结果数组

4.4 复杂度分析

  • 时间复杂度:O(n log k),其中n是数组长度,k是窗口大小。每个元素最多入堆和出堆一次,每次操作时间为O(log k)。
  • 空间复杂度:O(k),优先队列中最多有k个元素。

4.5 适用场景

优先队列法适用于窗口大小较大的情况,因为它对每个窗口的处理不需要遍历所有k个元素。但当k很小时,暴力法可能更快。

5. 解法三:双端队列(Deque)

5.1 思路

双端队列法是本题的最优解法之一。它通过维护一个单调递减的队列(存储元素索引),保证队首始终是当前窗口的最大元素索引。

关键思想:

  • 如果一个元素比它前面的元素大,那么前面的元素就永远不会成为窗口的最大值
  • 保持队列中的元素索引对应的值是单调递减的
  • 队首元素始终是当前窗口的最大值索引

具体步骤:

  1. 创建一个双端队列,存储元素索引
  2. 遍历数组:
    • 移除队列中不在当前窗口的元素(即索引小于i-k+1的元素)
    • 从队尾开始,移除所有小于当前元素的索引
    • 将当前元素的索引加入队尾
    • 如果当前位置大于等于k-1(即已形成第一个窗口),将队首元素对应的值添加到结果中

5.2 Java代码实现

import java.util.ArrayDeque;
import java.util.Deque;

public class Solution {
    public int[] maxSlidingWindow(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 < n; i++) {
            // 移除不在当前窗口的元素
            if (!deque.isEmpty() && deque.peekFirst() < i - k + 1) {
                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.3 代码详解

  1. 处理边界情况:如果数组为空或窗口大小为0,返回空数组
  2. 创建结果数组,其长度为n-k+1
  3. 创建双端队列,用于存储元素的索引
  4. 遍历数组:
    • 移除队列中不在当前窗口范围内的元素索引
    • 从队尾开始,移除所有对应值小于当前元素的索引,这样保证队列中元素的单调递减性
    • 将当前元素的索引加入队尾
    • 如果已经形成窗口(i >= k-1),则将队首元素对应的值添加到结果数组

5.4 复杂度分析

  • 时间复杂度:O(n),其中n是数组长度。每个元素最多入队和出队一次,所有操作都是O(1)时间。
  • 空间复杂度:O(k),双端队列中最多有k个元素。

5.5 适用场景

双端队列法是该问题的最优解法,尤其适用于大型数组和大窗口。由于其线性时间复杂度,在大多数情况下都优于前两种方法。

6. 解法四:动态规划法

6.1 思路

动态规划法利用了预处理技术,将数组分为大小为k的块,并预先计算每个块内的最大值。

具体步骤:

  1. 将数组分为大小为k的块
  2. 预处理两个辅助数组:
    • leftMax[i]:从块的左边界到位置i的最大值
    • rightMax[i]:从块的右边界到位置i的最大值
  3. 对于每个窗口,其最大值为max(rightMax[i], leftMax[i+k-1]),其中i是窗口的左边界

6.2 Java代码实现

public class Solution {
    public int[] maxSlidingWindow(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[] leftMax = new int[n];
        int[] rightMax = new int[n];
        
        // 计算leftMax
        for (int i = 0; i < n; i++) {
            // 如果i是块的左边界
            if (i % k == 0) {
                leftMax[i] = nums[i];
            } else {
                leftMax[i] = Math.max(leftMax[i - 1], nums[i]);
            }
        }
        
        // 计算rightMax
        for (int i = n - 1; i >= 0; i--) {
            // 如果i是块的右边界,或者是数组的最后一个元素
            if (i % k == k - 1 || i == n - 1) {
                rightMax[i] = nums[i];
            } else {
                rightMax[i] = Math.max(rightMax[i + 1], nums[i]);
            }
        }
        
        // 计算每个窗口的最大值
        for (int i = 0; i <= n - k; i++) {
            // 窗口的右边界是i+k-1
            result[i] = Math.max(rightMax[i], leftMax[i + k - 1]);
        }
        
        return result;
    }
}

6.3 代码详解

  1. 处理边界情况
  2. 创建结果数组和两个辅助数组leftMaxrightMax
  3. 计算leftMax数组:
    • 如果i是块的左边界,leftMax[i] = nums[i]
    • 否则,leftMax[i] = max(leftMax[i-1], nums[i])
  4. 计算rightMax数组:
    • 如果i是块的右边界或数组的最后一个元素,rightMax[i] = nums[i]
    • 否则,rightMax[i] = max(rightMax[i+1], nums[i])
  5. 计算每个窗口的最大值:
    • 对于窗口[i, i+k-1],最大值为max(rightMax[i], leftMax[i+k-1])

6.4 复杂度分析

  • 时间复杂度:O(n),其中n是数组长度。预处理数组和计算结果都只需一次遍历。
  • 空间复杂度:O(n),需要两个辅助数组。

6.5 适用场景

动态规划法与双端队列法一样高效,但实现更直观。当需要处理静态数据(不会动态更新)时,这种方法尤其适用。

7. 优化与提升

7.1 滑动窗口处理技巧

在处理滑动窗口问题时,有一些常用技巧:

  1. 窗口形式的循环

    for (int right = 0; right < nums.length; right++) {
        // 扩展窗口右边界
        
        while (/* 需要缩小窗口的条件 */) {
            // 缩小窗口左边界
            left++;
        }
        
        // 计算当前窗口的结果
    }
    
  2. 双指针技术:使用左右两个指针表示窗口的边界,根据条件移动指针。

  3. 队列/堆的应用:使用适当的数据结构来高效维护窗口内的最值。

7.2 双端队列优化

上述双端队列解法可以进一步优化:

  1. 初始填充队列:先处理前k个元素,构建初始队列:

    // 填充初始队列
    for (int i = 0; i < k; i++) {
        while (!deque.isEmpty() && nums[deque.peekLast()] <= nums[i]) {
            deque.pollLast();
        }
        deque.offerLast(i);
    }
    result[0] = nums[deque.peekFirst()];
    
    // 处理后续元素
    for (int i = k; i < nums.length; i++) {
        // 常规处理
    }
    
  2. 减少不必要的检查:对于大型数组,可以考虑每k个元素清空并重建队列,避免频繁检查元素是否在窗口内。

7.3 处理大规模数据

对于非常大的数组,可以考虑以下优化:

  1. 分块处理:将数组分成多个块,分别处理后合并结果。
  2. 并行计算:利用多线程并行计算不同块的结果。
  3. 内存优化:对于动态规划解法,可以使用循环数组减少内存使用。

8. 进阶思考与变体问题

8.1 滑动窗口最小值

与本题相似,找出滑动窗口中的最小值。解法与找最大值类似,只需调整比较方向:

public int[] minSlidingWindow(int[] nums, int k) {
    int n = nums.length;
    int[] result = new int[n - k + 1];
    Deque<Integer> deque = new ArrayDeque<>();
    
    for (int i = 0; i < n; i++) {
        // 移除不在当前窗口的元素
        if (!deque.isEmpty() && deque.peekFirst() < i - k + 1) {
            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;
}

8.2 多个窗口大小

如果需要计算多个不同大小的窗口的最大值,一个有效的方法是预处理出各种范围的最大值(稀疏表/Sparse Table):

// 构建稀疏表
int[][] sparseTable = new int[n][log2(n) + 1];
for (int i = 0; i < n; i++) {
    sparseTable[i][0] = nums[i];
}

for (int j = 1; (1 << j) <= n; j++) {
    for (int i = 0; i + (1 << j) - 1 < n; i++) {
        sparseTable[i][j] = Math.max(sparseTable[i][j-1], 
                                     sparseTable[i + (1 << (j-1))][j-1]);
    }
}

// 查询范围[L,R]的最大值
public int queryMax(int L, int R) {
    int j = (int) Math.log(R - L + 1) / Math.log(2);
    return Math.max(sparseTable[L][j], sparseTable[R - (1 << j) + 1][j]);
}

8.3 实时数据流中的滑动窗口最大值

对于实时数据流,可以使用双端队列方法,不断添加新元素并移除过期元素:

class SlidingWindowMaximum {
    private Deque<Integer> deque = new ArrayDeque<>();
    private int[] nums;
    private int k;
    private int count = 0;
    
    public SlidingWindowMaximum(int k) {
        this.k = k;
        this.nums = new int[k];
    }
    
    public int add(int val) {
        // 计算当前元素在数组中的位置
        int index = count % k;
        count++;
        
        nums[index] = val;
        
        // 重建队列
        deque.clear();
        for (int i = 0; i < k; i++) {
            while (!deque.isEmpty() && nums[deque.peekLast()] <= nums[i]) {
                deque.pollLast();
            }
            deque.offerLast(i);
        }
        
        return nums[deque.peekFirst()];
    }
}

9. 常见错误与优化

9.1 常见错误

  1. 忘记移除窗口外的元素

    // 错误:没有移除窗口外的元素
    for (int i = 0; i < n; i++) {
        // 添加当前元素
        deque.offerLast(i);
        
        // 检查当前窗口最大值
        // ...
    }
    

    正确做法:

    for (int i = 0; i < n; i++) {
        // 移除窗口外的元素
        if (!deque.isEmpty() && deque.peekFirst() < i - k + 1) {
            deque.pollFirst();
        }
        
        // 添加当前元素
        // ...
    }
    
  2. 错误的队列维护

    // 错误:没有正确维护队列的单调性
    while (!deque.isEmpty() && nums[deque.peekLast()] <= nums[i]) {
        deque.pollLast();
    }
    

    这个比较条件应该是<还是<=取决于我们是否需要处理重复元素。如果队列中保留了重复的最大值,当最大值移出窗口时,可能会错误地保留这个值。

  3. 索引混淆

    // 错误:窗口索引计算错误
    result[i - k] = nums[deque.peekFirst()];
    

    正确做法:

    result[i - k + 1] = nums[deque.peekFirst()];
    

9.2 性能优化

  1. 避免频繁检查窗口边界

    // 优化前
    for (int i = 0; i < n; i++) {
        if (!deque.isEmpty() && deque.peekFirst() < i - k + 1) {
            deque.pollFirst();
        }
        // ...
    }
    
    // 优化后
    for (int i = 0; i < n; i++) {
        // 只有当队列非空且第一个元素肯定过期时才检查
        if (!deque.isEmpty() && i - deque.peekFirst() >= k) {
            deque.pollFirst();
        }
        // ...
    }
    
  2. 预分配足够的空间

    // 预先知道结果大小
    int[] result = new int[n - k + 1];
    Deque<Integer> deque = new ArrayDeque<>(k); // 指定初始容量
    
  3. 使用数组实现双端队列:自定义双端队列实现,避免Java内置集合类的开销:

    class ArrayDeque {
        private int[] array;
        private int front, rear;
        private int capacity;
        
        public ArrayDeque(int capacity) {
            this.capacity = capacity;
            array = new int[capacity];
            front = rear = -1;
        }
        
        // 实现offerLast, pollFirst, peekFirst, peekLast等方法
        // ...
    }
    

10. 完整的 Java 解决方案

下面是结合了各种优化的完整解法,使用双端队列实现:

import java.util.ArrayDeque;
import java.util.Deque;

class Solution {
    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<>(k);
        
        // 处理前k个元素,建立初始单调队列
        for (int i = 0; i < k; i++) {
            // 保持队列单调递减
            while (!deque.isEmpty() && nums[deque.peekLast()] <= nums[i]) {
                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[deque.peekLast()] <= nums[i]) {
                deque.pollLast();
            }
            deque.offerLast(i);
            
            // 当前窗口的最大值
            result[i - k + 1] = nums[deque.peekFirst()];
        }
        
        return result;
    }
}

11. 实际运用示例

11.1 LeetCode提交结果

双端队列解法在LeetCode上提交的结果通常如下:

  • 执行用时:约 15-30 ms(击败约 90-95% 的 Java 提交)
  • 内存消耗:约 50-60 MB(击败约 80-85% 的 Java 提交)

11.2 应用场景

滑动窗口最大值问题在实际编程中有很多应用场景,例如:

  • 股票市场中计算n天内的最高价格
  • 网络流量监控中检测特定时间窗口内的峰值流量
  • 图像处理中的最大值滤波器
  • 数据流处理中的实时数据分析

11.3 扩展用例

public class SlidingWindowMaximumApplication {
    public static void main(String[] args) {
        Solution solution = new Solution();
        
        // 基本测试用例
        test(solution, new int[]{1, 3, -1, -3, 5, 3, 6, 7}, 3);
        
        // 边界测试用例
        test(solution, new int[]{1}, 1);
        test(solution, new int[]{1, -1}, 1);
        
        // 窗口大小等于数组长度
        test(solution, new int[]{1, 3, 5, 7}, 4);
        
        // 大型测试用例
        int[] largeArray = new int[10000];
        for (int i = 0; i < 10000; i++) {
            largeArray[i] = (int)(Math.random() * 10000);
        }
        long startTime = System.currentTimeMillis();
        solution.maxSlidingWindow(largeArray, 100);
        long endTime = System.currentTimeMillis();
        System.out.println("大型测试用例耗时: " + (endTime - startTime) + "ms");
    }
    
    private static void test(Solution solution, int[] nums, int k) {
        int[] result = solution.maxSlidingWindow(nums, k);
        System.out.print("数组: ");
        printArray(nums);
        System.out.println("窗口大小: " + k);
        System.out.print("结果: ");
        printArray(result);
        System.out.println();
    }
    
    private static void printArray(int[] arr) {
        System.out.print("[");
        for (int i = 0; i < arr.length; i++) {
            System.out.print(arr[i]);
            if (i < arr.length - 1) {
                System.out.print(", ");
            }
        }
        System.out.println("]");
    }
}

12. 总结与技巧

12.1 解题要点

  1. 理解滑动窗口概念:理解窗口的移动过程和窗口内元素的更新方式。
  2. 选择合适的数据结构:根据问题特点选择合适的数据结构,如双端队列、优先队列等。
  3. 单调队列技巧:掌握维护单调队列的方法,这是解决滑动窗口最值问题的关键。
  4. 处理边界情况:注意窗口初始形成和结束时的特殊处理。
  5. 优化时间复杂度:尽量避免重复计算,争取O(n)时间复杂度。

12.2 学习收获

通过学习这道题,你可以掌握:

  • 滑动窗口算法的基本思想
  • 双端队列的应用
  • 单调队列的维护方法
  • 优化算法的常用技巧
  • 处理序列和窗口问题的通用方法

12.3 面试技巧

在面试中遇到类似问题时:

  1. 先分析最简单的解法(如暴力法)
  2. 分析其时间和空间复杂度的瓶颈
  3. 引入双端队列或堆等优化方法
  4. 讨论维护窗口最大值的策略(单调队列)
  5. 处理各种边界情况

13. 参考资料

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

全栈凯哥

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值