核心思想
滑动窗口最大值问题的核心是维护一个双端队列(deque)或者单调队列,这个队列具有以下特点:
- 队列中存放的是元素的下标
- 队列从头到尾是严格递减的(如果求最小值则是严格递增)
- 队列头部始终保持着当前窗口的最大值
图解过程
假设有数组 [4, 3, 5, 4, 3, 3, 6, 7],窗口大小为 3,让我们看看队列是如何变化的:
初始状态:
数组:[4, 3, 5, 4, 3, 3, 6, 7]
↑
当前位置
1. 第一个元素4进入:
deque: [0](0是下标,对应值是4)
2. 3要进入时:
由于3小于4,直接放入队尾
deque: [0,1](值:4,3)
3. 5要进入时:
由于5大于前面的所有数,将前面的都弹出
deque: [2](值:5)
窗口形成,输出第一个最大值5
维护规则
-
入队规则:
- 新元素从队尾进入
- 入队前,把队尾所有小于(求最大值时)当前值的元素都删除
- 把新元素的下标放入队尾
-
出队规则:
- 检查队头的下标是否已经超出窗口范围
- 如果超出,则将队头元素弹出
代码示例
public class NormalDequeWindow {
public int[] maxSlidingWindow(int[] nums, int k) {
if (nums == null || nums.length == 0) return new int[0];
Deque<Integer> deque = new ArrayDeque<>();
int[] result = new int[nums.length - k + 1];
// 初始化第一个窗口
for (int i = 0; i < k; i++) {
deque.offerLast(nums[i]);
}
result[0] = getMax(deque);
// 滑动窗口
for (int i = k; i < nums.length; i++) {
deque.pollFirst(); // 移除窗口最左边的元素
deque.offerLast(nums[i]); // 添加新元素
result[i - k + 1] = getMax(deque);
}
return result;
}
// 获取队列中的最大值 - O(k)时间复杂度
private int getMax(Deque<Integer> deque) {
int max = Integer.MIN_VALUE;
for (int num : deque) {
max = Math.max(max, num);
}
return max;
}
}
为什么长度int[] result = new int[nums.length - k + 1];
result代表窗口数
让我用图解的方式来解释为什么结果数组的长度是 nums.length - k + 1。
假设我们还是用这个例子:
nums = [1, 3, -1, -3, 5, 3, 6, 7]
k = 3
让我们一步步分析:
=== 数组长度和窗口大小的关系 ===
原数组长度(nums.length) = 8
窗口大小(k) = 3
1️⃣ 第一个窗口位置:
[1 3 -1] -3 5 3 6 7
▼ ▼ ▼
位置索引:0
2️⃣ 第二个窗口位置:
1 [3 -1 -3] 5 3 6 7
▼ ▼ ▼
位置索引:1
3️⃣ 第三个窗口位置:
1 3 [-1 -3 5] 3 6 7
▼ ▼ ▼
位置索引:2
4️⃣ 第四个窗口位置:
1 3 -1 [-3 5 3] 6 7
▼ ▼ ▼
位置索引:3
5️⃣ 第五个窗口位置:
1 3 -1 -3 [5 3 6] 7
▼ ▼ ▼
位置索引:4
6️⃣ 第六个窗口位置:
1 3 -1 -3 5 [3 6 7]
▼ ▼ ▼
位置索引:5
总窗口数 = 6 = 8 - 3 + 1 = nums.length - k + 1
📝 计算公式解释:
- 如果数组长度是 n (nums.length)
- 窗口大小是 k
- 那么窗口可以滑动的次数是:
- 第一个位置:索引 0
- 最后一个位置:索引 n-k
- 总共位置数:(n-k) - 0 + 1 = n-k+1
🎯 生活类比: 想象你有一个长度为8米的长廊,你手里拿着一个3米长的尺子:
- 从最左边开始测量
- 每次向右移动1米
- 问你能测量多少次?
- 答案就是 8-3+1 = 6 次
🎬 电影胶片类比:
- 想象你有8帧电影胶片
- 你的放映窗口一次只能显示3帧
- 从第1帧开始放映
- 每次移动1帧
- 总共能形成 8-3+1 = 6 个不同的画面
所以:
- 当 nums.length = 8, k = 3 时
- result.length = 8 - 3 + 1 = 6
- 结果数组将存储 [3, 3, 5, 5, 6, 7]
- 正好6个最大值,对应6个窗口位置
这就是为什么结果数组的长度必须是 nums.length - k + 1,它代表了窗口可以滑动的总次数!
滑动的时候i为什么从k开始 和 result角标为什么是i - k + 1
i其实是代表新加入对象的角标
假设数组:[1, 3, -1, -3, 5, 3, 6, 7],窗口大小 k = 3
第一步:形成第一个窗口
[1, 3, -1], -3, 5, 3, 6, 7
这个结果已经存在 result[0] 里了
现在要开始滑动了,我们来看 i 从 k(3) 开始是什么意思:
i = 3 时:
原窗口:[1, 3, -1]
移除1,加入-3
新窗口:[3, -1, -3] --> 存入 result[1]
i = 4 时:
原窗口:[3, -1, -3]
移除3,加入5
新窗口:[-1, -3, 5] --> 存入 result[2]
i = 5 时:
原窗口:[-1, -3, 5]
移除-1,加入3
新窗口:[-3, 5, 3] --> 存入 result[3]
...以此类推
为什么 i-k+1 是正确的结果数组索引?
当 i = 3 时:3-3+1 = 1 (第二个窗口的结果存在result[1])
当 i = 4 时:4-3+1 = 2 (第三个窗口的结果存在result[2])
当 i = 5 时:5-3+1 = 3 (第四个窗口的结果存在result[3])
简单来说:
- i 从 3 开始,是因为前三个数已经处理过了
- i-k+1 就是在计算这是第几个窗口的结果
就像排队一样:
- 第一批3个人已经进去了
- 现在从第4个人开始,每进去一个,第一批的人要出来一个
- 记录的时候,要知道这是第几批人
代码详细步骤打印
public class NormalDequeWindow {
public int[] maxSlidingWindow(int[] nums, int k) {
if (nums == null || nums.length == 0) return new int[0];
System.out.println("原始数组: " + Arrays.toString(nums));
System.out.println("窗口大小: " + k);
System.out.println("=" .repeat(50));
Deque<Integer> deque = new ArrayDeque<>();
int[] result = new int[nums.length - k + 1];
// 初始化第一个窗口
System.out.println("\n初始化第一个窗口:");
for (int i = 0; i < k; i++) {
System.out.printf("添加元素 nums[%d] = %d\n", i, nums[i]);
deque.offerLast(nums[i]);
System.out.println("当前窗口: " + deque);
}
result[0] = getMax(deque);
System.out.println("第一个窗口的最大值: " + result[0]);
// 滑动窗口
System.out.println("\n开始滑动窗口...");
for (int i = k; i < nums.length; i++) {
System.out.printf("\n第%d次滑动:\n", i-k+1);
// 移除最左边的元素
int removed = deque.pollFirst();
System.out.printf("移除左边元素: %d\n", removed);
System.out.println("移除后窗口: " + deque);
// 添加新元素
System.out.printf("添加新元素 nums[%d] = %d\n", i, nums[i]);
deque.offerLast(nums[i]);
System.out.println("添加后窗口: " + deque);
// 计算最大值
result[i - k + 1] = getMax(deque);
System.out.printf("当前窗口最大值: %d\n", result[i - k + 1]);
}
System.out.println("\n最终结果: " + Arrays.toString(result));
return result;
}
private int getMax(Deque<Integer> deque) {
int max = Integer.MIN_VALUE;
System.out.print("计算最大值过程: ");
for (int num : deque) {
System.out.printf("%d ", num);
max = Math.max(max, num);
}
System.out.printf("-> %d\n", max);
return max;
}
public static void main(String[] args) {
NormalDequeWindow solution = new NormalDequeWindow();
int[] nums = {1, 3, -1, -3, 5, 3, 6, 7};
int k = 3;
solution.maxSlidingWindow(nums, k);
}
}
运行结果:
原始数组: [1, 3, -1, -3, 5, 3, 6, 7]
窗口大小: 3
==================================================
初始化第一个窗口:
添加元素 nums[0] = 1
当前窗口: [1]
添加元素 nums[1] = 3
当前窗口: [1, 3]
添加元素 nums[2] = -1
当前窗口: [1, 3, -1]
计算最大值过程: 1 3 -1 -> 3
第一个窗口的最大值: 3
开始滑动窗口...
第1次滑动:
移除左边元素: 1
移除后窗口: [3, -1]
添加新元素 nums[3] = -3
添加后窗口: [3, -1, -3]
计算最大值过程: 3 -1 -3 -> 3
当前窗口最大值: 3
第2次滑动:
移除左边元素: 3
移除后窗口: [-1, -3]
添加新元素 nums[4] = 5
添加后窗口: [-1, -3, 5]
计算最大值过程: -1 -3 5 -> 5
当前窗口最大值: 5
第3次滑动:
移除左边元素: -1
移除后窗口: [-3, 5]
添加新元素 nums[5] = 3
添加后窗口: [-3, 5, 3]
计算最大值过程: -3 5 3 -> 5
当前窗口最大值: 5
第4次滑动:
移除左边元素: -3
移除后窗口: [5, 3]
添加新元素 nums[6] = 6
添加后窗口: [5, 3, 6]
计算最大值过程: 5 3 6 -> 6
当前窗口最大值: 6
第5次滑动:
移除左边元素: 5
移除后窗口: [3, 6]
添加新元素 nums[7] = 7
添加后窗口: [3, 6, 7]
计算最大值过程: 3 6 7 -> 7
当前窗口最大值: 7
最终结果: [3, 3, 5, 5, 6, 7]
这个输出清楚地展示了:
- 初始窗口的形成过程
- 每次滑动时窗口的变化
- 每个窗口中最大值的计算过程
- 最终的结果数组
通过这些打印,我们可以更好地理解滑动窗口的工作原理!
流程图
开始
↓
检查队头是否过期 → 是 → 移除队头
↓
比较新元素与队尾 → 大于队尾 → 移除队尾
↓
将新元素加入队尾
↓
窗口已形成? → 是 → 记录当前最大值
↓
继续下一个元素
时间复杂度分析
- 每个元素最多进队一次,出队一次
- 所有操作都是O(1)的
- 总体时间复杂度:O(n)
使用场景
- 股票价格的最高值监控
- 网络流量的峰值检测
- 温度监测系统的最高温度追踪
- 系统负载的峰值监控
这个数据结构的精妙之处在于:
- 自动维护了一个递减序列
- 通过存储下标而不是值,既能判断元素是否过期,又能找到原始值
- 空间复杂度始终保持在O(k)级别
细节问题
(1)如何判断的检查队头是否过期
让我详细解释一下如何判断队头是否过期。
队头过期判断原理
// 检查队头是否已经超出窗口范围
if (!deq.isEmpty() && deq.peekFirst() <= i - k) {
deq.pollFirst();
}
让我们通过一个具体例子来说明:
假设数组:[4, 3, 5, 4, 3, 3, 6, 7],窗口大小 k = 3
当前窗口示意:
[4, 3, 5], 4, 3, 3, 6, 7
0 1 2 3 4 5 6 7 (下标)
判断过期公式:当前下标 i - 窗口大小 k = 最早合法下标
详细分析过程
- 窗口第一次形成(i = 2):
窗口:[4, 3, 5]
0 1 2
当前i=2,计算 2-3=-1
判断:deq.peekFirst() <= -1 ?
结果:不过期,因为队头下标肯定大于-1
- 窗口移动一步(i = 3):
窗口:[3, 5, 4]
1 2 3
当前i=3,计算 3-3=0
判断:deq.peekFirst() <= 0 ?
如果队头是0,就需要移除,因为下标0已经不在当前窗口内了
- 继续移动(i = 4):
窗口:[5, 4, 3]
2 3 4
当前i=4,计算 4-3=1
判断:deq.peekFirst() <= 1 ?
如果队头下标≤1,就需要移除,因为这些位置已经超出当前窗口了
图解说明
初始窗口:
[4, 3, 5], 4, 3, 3, 6, 7
0 1 2
有效范围:[0,2]
移动一步:
4, [3, 5, 4], 3, 3, 6, 7
1 2 3
有效范围:[1,3]
下标0过期
再移动一步:
4, 3, [5, 4, 3], 3, 6, 7
2 3 4
有效范围:[2,4]
下标1过期
过期判断公式解释
deq.peekFirst() <= i - k
i:当前正在处理的元素下标k:窗口大小i - k:当前窗口最左边界的前一个位置deq.peekFirst():队列中存储的最早下标
如果:
队头下标 <= i - k:说明队头元素已经在窗口左边界之外了,需要移除队头下标 > i - k:说明队头元素还在当前窗口内,保留
示例代码
public void explainExpiration(int[] nums, int k) {
for (int i = 0; i < nums.length; i++) {
System.out.println("当前处理下标: " + i);
System.out.println("窗口最早合法下标: " + (i - k + 1));
System.out.println("过期判断值(i-k): " + (i - k));
System.out.println("-------------------");
}
}
关键点总结
- 过期判断是基于下标进行的,而不是基于值
- 每次移动窗口时,都需要检查队头是否过期
- 过期的判断标准是:队头下标是否小于等于(当前下标-窗口大小)
- 这个判断可以保证队列中始终只包含窗口内的元素下标
(2)如何判断窗口的形成
让我详细解释如何判断窗口是否已经形成。
窗口形成的判断
// 当窗口形成后,记录最大值
if (i >= k - 1) {
result[resultIndex++] = nums[deq.peekFirst()];
}
详细分析
假设数组:[4, 3, 5, 4, 3, 3, 6, 7],窗口大小 k = 3
数组下标: 0 1 2 3 4 5 6 7
数组元素: [4, 3, 5, 4, 3, 3, 6, 7]
让我们逐步分析窗口的形成过程:
- i = 0 (第一个元素)
当前状态:[4], 3, 5, 4, 3, 3, 6, 7
判断:0 >= 3-1 (false)
结果:窗口未形成
- i = 1 (第二个元素)
当前状态:[4, 3], 5, 4, 3, 3, 6, 7
判断:1 >= 3-1 (false)
结果:窗口未形成
- i = 2 (第三个元素)
当前状态:[4, 3, 5], 4, 3, 3, 6, 7
判断:2 >= 3-1 (true)
结果:窗口形成!开始输出最大值
图解说明
k = 3 的情况:
i = 0: [4] _, _, ...
不足3个元素
i = 1: [4, 3] _, ...
不足3个元素
i = 2: [4, 3, 5] ...
✓ 第一个完整窗口形成!
i = 3: [3, 5, 4] ...
✓ 窗口滑动
i = 4: [5, 4, 3] ...
✓ 窗口滑动
窗口形成判断公式解释
i >= k - 1
这个公式的原理:
k-1是窗口形成需要的最小下标- 因为下标从0开始,所以第一个完整窗口在处理第
k-1个元素时形成 - 例如:k=3时,需要处理到下标2(第3个元素)才形成第一个完整窗口
示例代码
public void explainWindowFormation(int[] nums, int k) {
for (int i = 0; i < nums.length; i++) {
System.out.println("当前下标: " + i);
System.out.println("窗口形成需要的最小下标(k-1): " + (k-1));
System.out.println("窗口是否形成: " + (i >= k-1));
if (i >= k-1) {
System.out.println("当前窗口: [");
for (int j = i-k+1; j <= i; j++) {
System.out.print(nums[j] + " ");
}
System.out.println("]");
}
System.out.println("-------------------");
}
}
关键点总结
- 窗口形成的判断是基于当前处理的下标位置
- 第一个完整窗口在处理第k个元素时形成(下标k-1)
- 一旦窗口形成,每处理一个新元素就会产生一个结果
- 窗口形成后,结果数组开始记录最大值
完整示例
public int[] maxSlidingWindow(int[] nums, int k) {
if (nums == null || nums.length == 0 || k <= 0) {
return new int[0];
}
Deque<Integer> deq = new ArrayDeque<>();
int[] result = new int[nums.length - k + 1];
int resultIndex = 0;
for (int i = 0; i < nums.length; i++) {
// 移除过期元素
if (!deq.isEmpty() && deq.peekFirst() <= i - k) {
deq.pollFirst();
}
// 维护递减队列
while (!deq.isEmpty() && nums[deq.peekLast()] < nums[i]) {
deq.pollLast();
}
deq.offerLast(i);
// 窗口形成后记录结果
if (i >= k - 1) {
result[resultIndex++] = nums[deq.peekFirst()];
}
}
return result;
}
这样的判断方式确保了我们只在窗口完全形成后才开始记录结果,同时也保证了结果数组的大小正确(nums.length - k + 1)。
换种方式 - 单调队列方式
1. 基本概念
滑动窗口最大值/最小值问题实际上是使用单调队列这个数据结构来解决的。想象一下,就像是一个排队的队伍,但这个队伍有特殊规则:
- 队伍里的人按照身高排序(单调性)
- 新人来了会把比自己矮(或高)的都"请出去"
- 队伍前面的人"超时"了要离开
2. 工作原理
以求滑动窗口最大值为例:
- 维护一个双端队列(deque)
- 队列中存储数组元素的下标
- 队列中的元素保持单调递减(求最大值)或单调递增(求最小值)
3. 代码实现
import java.util.ArrayDeque;
import java.util.Deque;
public class MonotonicQueue {
// 双端队列存储数组下标
private Deque<Integer> deque;
public MonotonicQueue() {
deque = new ArrayDeque<>();
}
// 添加元素时维护单调性
public void push(int[] arr, int index) {
// 如果新元素比队尾元素大,则弹出队尾元素
while (!deque.isEmpty() && arr[deque.peekLast()] <= arr[index]) {
deque.pollLast();
}
deque.offerLast(index);
}
// 移除超出窗口范围的元素
public void pop(int leftBound) {
if (!deque.isEmpty() && deque.peekFirst() < leftBound) {
deque.pollFirst();
}
}
// 获取当前窗口最大值
public int max(int[] arr) {
return arr[deque.peekFirst()];
}
}
// 使用示例
public int[] maxSlidingWindow(int[] nums, int k) {
if (nums == null || nums.length == 0) {
return new int[0];
}
int[] result = new int[nums.length - k + 1];
MonotonicQueue window = new MonotonicQueue();
// 初始化第一个窗口
for (int i = 0; i < k; i++) {
window.push(nums, i);
}
result[0] = window.max(nums);
// 滑动窗口
for (int i = k; i < nums.length; i++) {
window.pop(i - k + 1); // 移除左边界
window.push(nums, i); // 添加右边界
result[i - k + 1] = window.max(nums);
}
return result;
}
4. 图解流程
假设数组 [1,3,-1,-3,5,3,6,7],窗口大小 k=3
初始窗口:
[1,3,-1]|-3,5,3,6,7
队列状态: [1] -> [3] -> 队列中保持3
滑动后:
1,[3,-1,-3]|5,3,6,7
队列状态: [3,-1,-3]
继续滑动:
1,3,[-1,-3,5]|3,6,7
队列状态: [5]
以此类推...
5. 核心要点总结
-
单调性维护:
- 新元素进入时,从队尾开始比较
- 保持队列单调递减(最大值)或递增(最小值)
-
时间窗口维护:
- 队首元素可能过期
- 每次滑动时检查队首元素是否在窗口范围内
-
性能分析:
- 时间复杂度:O(n),每个元素最多进出队列一次
- 空间复杂度:O(k),k为窗口大小
6. 使用场景
- 股票价格的最高/最低值监控
- 网络流量的峰值检测
- 温度变化的极值分析
这种数据结构非常适合处理需要在滑动窗口中快速获取最大值或最小值的场景,而且时间复杂度很优秀,是一个非常实用的算法技巧。
两种方式解决问题的区别
让我来详细解释单调队列和普通双端队列在处理滑动窗口问题时的区别。
1. 基本概念对比
双端队列 (Deque)
- 可以在两端进行插入和删除操作
- 保持所有元素的原始顺序
- 没有特殊的排序规则
单调队列
- 基于双端队列实现
- 维护队列元素的单调性(递增或递减)
- 在插入新元素时会移除不必要的元素
2. 代码实现对比
双端队列实现
public class NormalDequeWindow {
public int[] maxSlidingWindow(int[] nums, int k) {
if (nums == null || nums.length == 0) return new int[0];
Deque<Integer> deque = new ArrayDeque<>();
int[] result = new int[nums.length - k + 1];
// 初始化第一个窗口
for (int i = 0; i < k; i++) {
deque.offerLast(nums[i]);
}
result[0] = getMax(deque);
// 滑动窗口
for (int i = k; i < nums.length; i++) {
deque.pollFirst(); // 移除窗口最左边的元素
deque.offerLast(nums[i]); // 添加新元素
result[i - k + 1] = getMax(deque);
}
return result;
}
// 获取队列中的最大值 - O(k)时间复杂度
private int getMax(Deque<Integer> deque) {
int max = Integer.MIN_VALUE;
for (int num : deque) {
max = Math.max(max, num);
}
return max;
}
}
单调队列实现
public class MonotonicQueueWindow {
public int[] maxSlidingWindow(int[] nums, int k) {
if (nums == null || nums.length == 0) return new int[0];
Deque<Integer> deque = new ArrayDeque<>(); // 存储下标
int[] result = new int[nums.length - k + 1];
for (int i = 0; i < nums.length; 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;
}
}
这段代码是用来维护一个单调递减队列的,目的是保证队列头部始终是当前窗口内的最大值。
假设数组 nums = [3, 1, 4, 2], k = 2
让我们一步步看队列(deque)的变化:
- i = 0, nums[0] = 3
队列为空,直接加入索引0
deque = [0] (对应的值是3)
- i = 1, nums[1] = 1
比较 nums[1](1) < nums[0](3)
因为1小于3,所以可以直接加入
deque = [0, 1] (对应的值是3, 1)
- i = 2, nums[2] = 4
比较 nums[2](4) > nums[1](1),移除1
比较 nums[2](4) > nums[0](3),移除3
最后加入4
deque = [2] (对应的值是4)
- i = 3, nums[3] = 2
比较 nums[3](2) < nums[2](4)
因为2小于4,所以可以直接加入
deque = [2, 3] (对应的值是4, 2)
这样维护的目的是:
- 队列中的元素是严格单调递减的
- 队首永远是当前窗口中的最大值
- 如果来了一个更大的数,就把前面所有小于它的数都删掉,因为这些数不可能再成为任何窗口的最大值了
比如在步骤3中,当4进来时:
- 因为4比1大,1永远不可能成为最大值了,所以删除1
- 因为4比3大,3也永远不可能成为最大值了,所以删除3
- 最后把4加入队列
这就是为什么代码中要用while循环不断地比较和删除队尾元素,直到找到一个比当前数大的元素,或者队列为空。
这种方式可以保证:
- 队列的单调性(从大到小)
- 队首元素永远是当前窗口的最大值
- 时间复杂度仍然是O(n),因为每个元素最多进出队列一次
3. 主要区别
-
时间复杂度
- 双端队列:O(nk),其中n是数组长度,k是窗口大小
- 单调队列:O(n),每个元素最多入队和出队一次
-
空间利用
- 双端队列:存储窗口内的所有元素
- 单调队列:只存储可能成为最大值的元素
-
元素处理方式
双端队列示例: [1,3,2] -> [3,2,4] - 简单移除1,添加4 单调队列示例: [3] -> [4] - 维护单调性,2被跳过因为不可能成为最大值 -
查找最大值
- 双端队列:需要遍历整个队列
- 单调队列:队首元素即为最大值
4. 性能对比图
时间复杂度对比:
双端队列: O(nk) ████████████████████
单调队列: O(n) ████
空间使用对比:
双端队列: O(k) ████████
单调队列: O(k) ████
(实际使用空间通常更少)
5. 使用场景建议
-
适合使用双端队列的情况
- 需要维护窗口内所有元素
- 窗口大小较小
- 需要频繁访问所有元素
-
适合使用单调队列的情况
- 只关注窗口内的最大值/最小值
- 窗口大小较大
- 对时间效率要求高
6. 总结
单调队列是对双端队列的一种优化,通过维护队列的单调性,在处理滑动窗口最大值/最小值问题时能够达到更好的时间复杂度。虽然实现稍微复杂一些,但在处理大规模数据时,性能优势明显。选择哪种实现方式应该根据具体的应用场景和需求来决定。