活动发起人@小虚竹
目录
举例说明 239. 滑动窗口最大值 - 力扣(LeetCode)
前言
本篇博客我们介绍滑动窗口,这是一种特殊的双指针技巧思想,在算法竞赛和工程应用中,滑动窗口是一种用于优化暴力解法、降低时间复杂度的高效技巧,通常用于解决连续子数组、子字符串相关的问题。它通过左闭右开的窗口 [left, right)
,并通过移动窗口的左右边界来寻找符合条件的子数组或子字符串。
在计算机网络中,也有滑动窗口协议.
它的核心思想是: 发送方维护一个“窗口”,表示当前允许发送但尚未确认的数据报文数量;接收方也维护一个窗口,表示它能接收的最大数据量。数据在网络中传输时,每个数据包都有一个序号,窗口随着数据包的确认而“滑动”,从而实现流量控制和高效数据传输。
滑动窗口的基本思想
在一个线性数据结构(数组或字符串)中,使用两个指针(或索引)来维护一个窗口,窗口在数据结构上滑动,从而避免重复计算,提高效率。
它是一种双指针技巧,适用于连续子数组/子串的计算,比如求最大值、最小值、和、乘积等。
接下来,我们通过举例一道力扣的模板题来说明该技巧的优势
举例说明 239. 滑动窗口最大值 - 力扣(LeetCode)
暴力做法
- 直接遍历数组,对于每个大小为k的子数组,用Math.max 求取最大值
- 时间复杂度
O(N * K)
,每次计算最大值都要遍历窗口O(K)
。
代码实现:
import java.util.Arrays;
class Solution {
public int[] maxSlidingWindow(int[] nums, int k) {
if (nums.length == 0) return new int[0];
int n = nums.length;
int[] res = new int[n - k + 1]; // 结果数组
for (int i = 0; i <= n - k; i++) {
int maxVal = Integer.MIN_VALUE;
for (int j = i; j < i + k; j++) {
maxVal = Math.max(maxVal, nums[j]); // 计算窗口最大值
}
res[i] = maxVal;
}
return res;
}
public static void main(String[] args) {
Solution solution = new Solution();
int[] nums = {1, 3, -1, -3, 5, 3, 6, 7};
int k = 3;
System.out.println(Arrays.toString(solution.maxSlidingWindow(nums, k)));
}
}
滑动窗口优化
为了减少时间复杂度,我们可以创建一个单调队列当作滑动窗口
只需要O(n) 的复杂度,就可以得到最大值
笔者首先贴出代码,然后对它进行详细解释
import java.util.*;
class Solution {
public int[] maxSlidingWindow(int[] nums, int k) {
if (nums == null || nums.length == 0) return new int[0];
int n = nums.length;
int[] res = new int[n - k + 1]; // 结果数组
Deque<Integer> deque = new ArrayDeque<>(); // 单调递减队列(存索引)
for (int i = 0; i < n; i++) {
// 1. 移除不在窗口范围的元素(左端越界)
if (!deque.isEmpty() && deque.peekFirst() <= i - k) {
deque.pollFirst();
}
// 2. 维护队列单调递减(移除比当前值小的元素)
while (!deque.isEmpty() && nums[deque.peekLast()] <= nums[i]) {
deque.pollLast();
}
// 3. 添加新元素索引
deque.addLast(i);
// 4. 记录窗口最大值(窗口形成后才开始存结果)
if (i >= k - 1) {
res[i - k + 1] = nums[deque.peekFirst()];
}
}
return res;
}
public static void main(String[] args) {
Solution solution = new Solution();
int[] nums = {1, 3, -1, -3, 5, 3, 6, 7};
int k = 3;
System.out.println(Arrays.toString(solution.maxSlidingWindow(nums, k)));
}
}
算法分析
初始化双端队列: 我们首先要初始化一个双端队列。队列的作用是存储当前窗口内可能成为最大元素的下标。为什么要存储下标呢?因为这样可以避免每次都访问
nums[i]
数组中的元素,而是直接通过下标来获取元素的值。另外,存储下标还能够帮助我们追踪那些已经滑出窗口的元素。双端队列的第一个元素就是当前窗口的最大值的下标。队列会始终保持一个递减的顺序,即队头是当前窗口内最大的元素的下标。这样当我们在滑动窗口时,队头的元素始终是我们需要的最大值。
滑动窗口的两种情况: 当我们向右滑动窗口时,会遇到两种常见情况:
第一种情况:队头元素离开窗口:
这意味着队头的元素已经不在当前滑动窗口内了,必须将其从队列中弹出。这个操作是 O(1) 的时间复杂度。此时,我们更新队列,使其只包含当前窗口内的元素。第二种情况:从队尾进入新元素:
进入窗口的新元素需要和已经在队列中的元素进行比较大小。这一步是滑动窗口的关键。为了保证队列中的元素是递减的,我们会从队尾开始弹出队列中的元素,直到队尾的元素比当前元素小。然后将当前元素的下标加入队列中。这时,队列的队头始终是窗口内的最大元素。这个步骤类似单调栈的思路,因此我们需要保证队列中的元素保持递减顺序。
每次滑动都要固定添加后续元素: 在每一次滑动窗口时,我们需要进行以下操作:
- 检查并移除不再在窗口内的元素。
- 将新的元素索引加入队列。
- 每次滑动窗口后,队头的元素即为当前窗口的最大值,加入结果数组。
这一步非常关键,因为它保证了每个窗口的最大值能够有效计算,并且在时间复杂度上能够达到 O(N)。
一旦滑动窗口形成,记录最大值,什么时候形成呢? 当下标 i >= k-1 . 从此以后,窗口内的最大值可以在每次滑动时通过队头元素直接获取。每次滑动时,会有元素从队头离开,也会有新元素从队尾进入,从而更新队列的状态,维持最大值的正确性。
使用例题数据进行模拟
我们使用例题数据
nums = {1, 3, -1, -3, 5, 3, 6, 7} 和滑动窗口大小 k = 3 来进行模拟。
输入
- 数组
nums = {1, 3, -1, -3, 5, 3, 6, 7}
- 滑动窗口大小
k = 3
初始状态
- 数组
nums = {1, 3, -1, -3, 5, 3, 6, 7}
- 结果数组
res
(用来存储每个窗口的最大值):[]
- 滑动窗口大小
k = 3
- 使用一个双端队列
deque
来维护窗口内的元素索引。
步骤解析
步骤 1:处理
nums[0] = 1
deque
为空,直接将当前索引0
入队。
deque = [0]
。步骤 2:处理
nums[1] = 3
nums[1] = 3
大于队列中索引为0
的nums[0] = 1
,所以从队列中移除0
。将
1
入队。
deque = [1]
。步骤 3:处理
nums[2] = -1
nums[2] = -1
小于队列中nums[1] = 3
,所以直接入队。
deque = [1, 2]
。此时,滑动窗口已经形成了大小为 3 的窗口,窗口内容是
[1, 3, -1]
。窗口的最大值是3
,将其加入res
。
res = [3]
。步骤 4:处理
nums[3] = -3
nums[3] = -3
小于队列中nums[2] = -1
,所以直接入队。
deque = [1, 2, 3]
。此时,滑动窗口内容是
[3, -1, -3]
。窗口的最大值是3
,将其加入res
。
res = [3, 3]
。步骤 5:处理
nums[4] = 5
nums[4] = 5
大于队列中的nums[3] = -3
,所以移除3
。
nums[4] = 5
大于队列中的nums[2] = -1
,所以移除2
。
nums[4] = 5
大于队列中的nums[1] = 3
,所以移除1
。将
4
入队。
deque = [4]
。此时,滑动窗口内容是
[-1, -3, 5]
。窗口的最大值是5
,将其加入res
。
res = [3, 3, 5]
。步骤 6:处理
nums[5] = 3
nums[5] = 3
小于队列中的nums[4] = 5
,所以直接入队。
deque = [4, 5]
。此时,滑动窗口内容是
[5, 3, 3]
。窗口的最大值是5
,将其加入res
。
res = [3, 3, 5, 5]
。步骤 7:处理
nums[6] = 6
nums[6] = 6
大于队列中的nums[5] = 3
,所以移除5
。
nums[6] = 6
大于队列中的nums[4] = 5
,所以移除4
。将
6
入队。
deque = [6]
。此时,滑动窗口内容是
[3, 3, 6]
。窗口的最大值是6
,将其加入res
。
res = [3, 3, 5, 5, 6]
。步骤 8:处理
nums[7] = 7
nums[7] = 7
大于队列中的nums[6] = 6
,所以移除6
。将
7
入队。
deque = [7]
。此时,滑动窗口内容是
[3, 6, 7]
。窗口的最大值是7
,将其加入res
。
res = [3, 3, 5, 5, 6, 7]
。最终结果
滑动窗口的最大值数组为:
[3, 3, 5, 5, 6, 7]
。
综上所述,我们可以用滑动窗口思想实现更低的复杂度
部分OJ题分享
笔者再分享一些OJ题,可以加深理解
第一题2841. 几乎唯一子数组的最大和 - 力扣(LeetCode)
这题也是滑动窗口思想可以解决的,只需要额外的用HashMap 算一下是否有至少m个元素
.size() 方法 可以计算 HashMap 中键值对的数量
class Solution {
public long maxSum(List<Integer> nums, int m, int k) {
int n = nums.size();
long maxSum = 0;
long sum = 0;
// 哈希表记录窗口内元素的频次
HashMap<Integer,Integer> hashMap = new LinkedHashMap<>();
// 初始化前 k 个元素的窗口
for(int i = 0;i<k;i++)
{
hashMap.put(nums.get(i),hashMap.getOrDefault(nums.get(i),0)+1);
sum+=nums.get(i);
}
// 检查窗口是否符合“几乎唯一”条件
if(hashMap.size()>=m)
{
maxSum = sum;
}
// 滑动窗口,窗口右移
for(int i = k;i<n;i++)
{
// 移除窗口最左边的元素
int left = nums.get(i-k);
hashMap.put(left,hashMap.get(left)-1);
if(hashMap.get(left)==0)
{
hashMap.remove(left);
}
sum-=left;
// 添加窗口右边的新元素
int right = nums.get(i);
hashMap.put(nums.get(i),hashMap.getOrDefault(nums.get(i),0)+1);
sum+=right;
// 如果当前窗口包含至少 m 个不同的元素,更新最大和
if(hashMap.size()>=m)
{
maxSum = Math.max(sum,maxSum);
}
}
return maxSum;
}
}
第二题3090. 每个字符最多出现两次的最长子字符串 - 力扣(LeetCode)
这一题虽然标签是简单,但它要求我们构建一个不定长的滑动窗口
具体思路如下
- 使用两个指针(
left
和right
)来表示当前窗口的范围,left
表示窗口的左边界,right
表示窗口的右边界。- 使用一个哈希表(
charCount
)来存储窗口内每个字符出现的次数。- 当窗口内某个字符出现超过两次时,移动
left
指针,直到该字符的出现次数不超过两次。- 每次更新窗口时,计算当前窗口的长度,并记录最大值。
代码实现:
import java.util.HashMap;
class Solution {
public int maximumLengthSubstring(String s) {
// 哈希表,用于记录当前窗口内每个字符的出现次数
HashMap<Character, Integer> charCount = new HashMap<>();
int left = 0; // 滑动窗口的左边界
int maxLength = 0; // 最长符合条件的子字符串的长度
// 遍历字符串
for (int right = 0; right < s.length(); right++) {
// 更新当前字符的计数
char currentChar = s.charAt(right);
charCount.put(currentChar, charCount.getOrDefault(currentChar, 0) + 1);
// 如果当前窗口内某个字符出现超过2次,移动左指针
while (charCount.get(currentChar) > 2) {
char leftChar = s.charAt(left);
charCount.put(leftChar, charCount.get(leftChar) - 1);
left++; // 移动左边界
}
// 计算当前窗口的长度并更新最大长度
maxLength = Math.max(maxLength, right - left + 1);
}
return maxLength; // 返回最长符合条件的子字符串的长度
}
}
结尾
关于滑动窗口思想和单调队列的实现就简单介绍到此,感谢能看到这里的读者!