目录
- [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^41 <= k <= nums.length
2. 问题分析
2.1 题目理解
需要维护一个大小为 k 的滑动窗口,随着窗口在数组上滑动,实时获取每个窗口中的最大值。这是一个典型的滑动窗口最值查询问题。
2.2 核心洞察
- 窗口连续性 :窗口每次只移动一位,相邻窗口有
k-1个元素重叠 - 高效查询需求:需要为每个窗口快速找到最大值,不能每次都遍历窗口内所有元素
- 数据结构选择:需要一种能够快速添加、删除和查询最大值的数据结构
2.3 破题关键
问题的核心在于如何在窗口滑动时高效维护当前窗口的最大值。当窗口向右移动时:
- 左侧元素离开窗口,需要从数据结构中移除
- 右侧新元素进入窗口,需要添加到数据结构中
- 需要能够快速获取当前数据结构中的最大值
3. 算法设计与实现
3.1 暴力枚举法
核心思想
对于每个窗口位置,遍历窗口内的所有元素找到最大值。
算法思路
- 遍历所有可能的窗口起始位置
i(0 到 n-k) - 对于每个窗口,遍历窗口内的
k个元素找到最大值 - 将最大值添加到结果数组中
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 最大堆(优先队列)法
核心思想
使用最大堆(优先队列)维护窗口内的元素,堆顶即为当前窗口的最大值。
算法思路
- 创建一个最大堆(Java中可以使用优先队列,但需要自定义比较器)
- 将前k个元素加入堆中
- 堆顶元素即为第一个窗口的最大值
- 滑动窗口:移除离开窗口的元素,加入新进入窗口的元素
- 每次获取堆顶元素作为当前窗口的最大值
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 单调队列法(最优)
核心思想
使用双端队列维护一个单调递减的序列,队首元素始终是当前窗口的最大值。
算法思路
- 使用双端队列(Deque)存储元素索引,而不是值
- 队列中的索引对应的值是单调递减的
- 滑动窗口时:
- 移除队列中不在窗口范围内的索引(从队首)
- 从队尾开始,移除所有小于当前元素的索引(因为它们不可能是最大值了)
- 将当前元素索引加入队尾
- 队首索引对应的值就是当前窗口的最大值
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),预处理每个块的前缀最大值和后缀最大值。
算法思路
- 将数组分成大小为k的块
- 对于每个块,计算从左到右的前缀最大值(left数组)
- 对于每个块,计算从右到左的后缀最大值(right数组)
- 对于任意窗口[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 核心知识要点
- 单调队列的本质:维护一个递减(对于最大值)或递增(对于最小值)的序列
- 延迟删除技巧:对于堆解法,使用延迟删除处理移出窗口的元素
- 索引存储:单调队列存储索引而不是值,便于判断元素是否在窗口内
- 空间换时间:通过额外的数据结构(队列、堆、平衡树)换取时间效率
6.2 算法选择指南
- 小规模数据:可以使用暴力解法理解问题
- 一般场景:单调队列是最优选择,时间复杂度O(n),空间复杂度O(k)
- 特定需求 :
- 如果需要同时查询最大值和最小值,可以考虑使用平衡树(TreeMap)
- 如果需要查询中位数或第K大值,使用双堆或平衡树
- 二维滑动窗口问题可以通过两次一维操作解决
6.3 应用场景
- 实时数据流分析:如股票价格滑动窗口分析
- 网络流量监控:统计固定时间窗口内的最大流量
- 图像处理:局部区域最大值滤波
- 信号处理:滑动窗口峰值检测
6.4 面试技巧
- 从暴力解法开始,分析其缺点(O(n×k)时间复杂度)
- 提出堆解法,分析优缺点(O(n log k),但移除元素困难)
- 引入单调队列解法,详细解释其工作原理
- 分析时间复杂度和空间复杂度
- 讨论扩展问题(最小值、中位数、二维扩展等)
单调队列是解决滑动窗口最值问题的利器,掌握这一数据结构对于解决LeetCode中的许多难题都大有裨益。它不仅用于滑动窗口最大值问题,还可以用于解决"接雨水"、"柱状图中最大的矩形"等经典问题。
标签: #滑动窗口 #单调队列 #算法优化 #双端队列 #LeetCode #数据结构 #最大值查询