【算法】滑动窗口

滑动窗口模板

模板一

滑动窗口模板一:适用于窗口大小固定,整体向右移动。

此时只使用一个变量,代表右边界,不断向右移动。代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public int[] template01(int[] nums, int k) {
int ans = 0;
int sum = 0;
int maxSum = 0;
for (int i = 0; i < nums.length; i++) {
// 更新当前窗口内的累加和
sum += nums[i];
// 第一次满足该条件时,[0,i] 区间形成了第一个窗口
// 此时就该记录该区间内的数字和的
// 之后i不断++,窗口的右边界不断右移,左边界 i-k+1 也不断右移
if (i >= k - 1) {
if (sum > maxSum) {
maxSum = sum;
ans = i - k + 1;
}
// 窗口即将整体右移,更新sum:减去左边界的值
sum -= nums[i - k + 1];
}
}
}

这种方式当退出循环时无需其他操作,因为最后一个窗口位置的情况也考虑在内了。

模板二

滑动窗口模板二:适用于窗口任意大小

此时需要两个指针 left 与 right,二者根据题目的要求进行右移。代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public int template02(String s) {
int left = 0;
int right = 0;
// 词频表
Map<Character, Integer> freq = new HashMap<>();
int maxCount = 0;

while (right < s.length()) {
// 如果当前字符已经被访问过,则right不动,left++
if (freq.containsKey(s.charAt(right))) {
// 清掉left的字符的频次
freq.remove(s.charAt(left));
// 此时[left, right-1]区间即为一个局部不重复子串区间
// 更新最大值
maxCount = Math.max(maxCount, (right - left));
// left右移
left++;
} else {
// 如果当前字符没有被访问过,righ++
// 先更新当前字符的词频
freq.put(s.charAt(right), 1);
// right右移
right++;
}
}
// 跳出后还要额外判断一次当前的left和right间的距离
maxCount = Math.max(maxCount, (right - left));
return maxCount;
}

这种方式需要在最后 right 跳出循环后,额外判断此时的 [left, right) 区间内的情况。

模板三

模板三和模板二较为相似,区别在于模板三中内层不是 if 判断,而是 while 循环,一直在循环内移动 left,直到满足条件后退出,再更新当前局部最佳子区间的指标(例如长度):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public int numSubarrayProductLessThanK(int[] nums, int k) {
if (k <= 1) {
return 0;
}
int left = 0;
int right = 0;
int count = 0;
int product = 1;
// 外层更新 right
// 每次移动一次right,计算当前right必须包含且作为最后一个位置时的子数组个数(可能为0,代表不可行)
for (; right < nums.length; right++) {
// 先更新right在内的指标
product *= nums[right];
// 内层while循环更新left直到满足条件退出
while (product >= k) {
// 不断移走left,直到某个位置满足小于k
product /= nums[left];
left++;
}
// 更新当前局部最佳子区间 [left, right] 内的长度
count += right - left + 1;
}
return count;
}

可使用滑动窗口的题目有:

  • 最小包含子串长度
  • 最多有 K 个不同字符的最大子串长度
  • 正好有 K 个不同整数的子数组数量
  • 累加和至少为 K 的最短子数组长度
  • 至少有 K 个重复字符的最长子串

滑动窗口常见题目

最长不含重复字符的子字符串

剑指 Offer 48. 最长不含重复字符的子字符串:请从字符串中找出一个最长的不包含重复字符的子字符串,计算该最长子字符串的长度。

示例 1:

1
2
3
输入: "abcabcbb"
输出: 3
解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。

示例 2:

1
2
3
输入: "bbbbb"
输出: 1
解释: 因为无重复字符的最长子串是 "b",所以其长度为 1。

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public int lengthOfLongestSubstring(String s) {
if (s == null || s.length() < 1) {
return 0;
}
return lengthOfLongestSubstring01(s);
}

// 滑动窗口
public int lengthOfLongestSubstring01(String s) {
int left = 0;
int right = 0;
// 词频表
Map<Character, Integer> freq = new HashMap<>();
int maxCount = 0;

while (right < s.length()) {
// 如果当前字符已经被访问过,则right不动,left++
if (freq.containsKey(s.charAt(right))) {
// 清掉left的字符的频次
freq.remove(s.charAt(left));
// 此时[left, right-1]区间即为一个局部不重复子串区间
// 更新最大值
maxCount = Math.max(maxCount, (right - left));
// left右移
left++;
} else {
// 如果当前字符没有被访问过,righ++
// 先更新当前字符的词频
freq.put(s.charAt(right), 1);
// right右移
right++;
}
}
// 跳出后还要额外判断一次当前的left和right间的距离
maxCount = Math.max(maxCount, (right - left));
return maxCount;
}

乘积小于K的子数组

713. 乘积小于K的子数组:给定一个正整数数组 nums和整数 k 。请找出该数组内乘积小于 k 的连续的子数组的个数。

思路:外层循环每次移动一次 right,然后内层循环迭代 left 直到找到 [left, right] 区间内为符合条件的区间,然后更新该子区间内的新增子数组个数(规定每次必须以 right 位置为子数组的末尾,这样就不会重复计算)。

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public int numSubarrayProductLessThanK(int[] nums, int k) {
// 一旦right到了一个位置使得整体的乘积大于等于k,就计算当前[left, right - 1]范围内的连续子数组的个数
// N = N(N - 1)/2
if (nums == null || nums.length == 0) {
return 0;
}
// 一定要注意k=1的情况,因为如果是[1,1,1] 1 这样的输入,会导致left的那个循环一直执行直到越界
if (k <= 1) {
return 0;
}
int left = 0;
int right = 0;
int count = 0;
int product = 1;
// 外层更新 right
// 每次移动一次right,计算当前right必须包含且作为最后一个位置时的子数组个数(可能为0,代表不可行)
for (; right < nums.length; right++) {
// 先更新right在内的指标
product *= nums[right];
// 内层while循环更新left直到满足条件退出
while (product >= k) {
// 不断移走left,直到某个位置满足小于k
product /= nums[left];
left++;
}
// 此时[left,right]中就是所有新增的子数组
// 注意此时新增的子数组必须以 right 为数组中的最后一个元素
// 所以整体来看不会重复
count += right - left + 1;
}
return count;
}

长度最小的子数组

209. 长度最小的子数组:给定一个含有 n 个正整数的数组和一个正整数 target。找出该数组中满足其和 ≥ target 的长度最小的 连续子数组 [numsl, numsl+1, ..., numsr-1, numsr] ,并返回其长度**。**如果不存在符合条件的子数组,返回 0

思路:外层循环不断更新 right。 内层 while 不断循环更新 left 直到满足条件后退出,此时更新了 left,而且使得 [left, right] 区间尽可能的短。

该题和上一题有类似的思路:在外层先循环更新 right。在内层先把当前 right 的值加入到指标中,然后再 while 循环不断更新 left,直到指标达到条件后退出。此时的 [left, right] 就是局部满足条件的一个区间,更新该区间的长度值

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public int minSubArrayLen(int target, int[] nums) {
if (nums == null || nums.length == 0) {
return 0;
}
int left = 0;
int right = 0;
int minLength = Integer.MAX_VALUE;
int sum = 0;
// 外层更新 right
while (right < nums.length) {
// 首先将当前 right 的值计算到指标内
sum += nums[right];
right++;
// 内层循环不断移动 left 直到满足条件后退出
// 一直遍历直到left移动到[left, right-1]的sum值小于target
while (sum >= target) {
minLength = Math.min(minLength, right - left);
sum -= nums[left];
left++;
}
}
return minLength == Integer.MAX_VALUE ? 0 : minLength;
}

最小覆盖子串

76. 最小覆盖子串:给你一个字符串 s 、一个字符串 t 。返回 s 中涵盖 t 所有字符的最小子串。如果 s 中不存在涵盖 t 所有字符的子串,则返回空字符串 ""

注意:

  • 对于 t 中重复字符,我们寻找的子字符串中该字符数量必须不少于 t 中该字符数量。
  • 如果 s 中存在这样的子串,我们保证它是唯一的答案。

示例 :

1
2
输入:s = "ADOBECODEBANC", t = "ABC"
输出:"BANC"

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
public String minWindow(String s, String t) {
if (s.length() < t.length()) {
return "";
}
char[] schar = s.toCharArray();
char[] tchar = t.toCharArray();

// 先准备一个词频表
int[] map = new int[256];
// 记录“欠的总债”
int all = t.length();
for (char cur : tchar) {
// 每个字符的词频加一,即先记录下来“欠的债”
// 将在后面被不断还债
map[cur]++;
}

// 用于记录待返回的答案
int minLength = Integer.MAX_VALUE;
int ansl = -1;
int ansr = -1;

// [left, right):左闭右开,left == right 时代表区间内没有元素
int left = 0;
int right = 0;

// right 右移的过程,是在不断“还债”,词频表里的当前字符的频率值不断减少
// left 右移的过程,是在不断“欠债”,词频表里的当前字符的频率值不断增加
// right 在到达 s 字符串的末尾前一直循环
while (right < s.length()) {
// 先把当前 right 位置的词频减一(还一次债)
map[schar[right]]--;

// 判断减一后的词频是否大于等于0
if (map[schar[right]] >= 0) {
// 如果当前字符在还了一次债后还是大于等于0,
// 说明当前还债是一次有效还债,总债数 all 需要减一
all--;
}
// 当 right 到了某个位置后满足 all == 0 的条件时,说明找到了一个局部子串
// 但是该区间不一定是最短的,因此 left 该开始右移了
// 开始不断右移 left,直到某个位置的 left 与 right 一起构成局部最小子串
if (all == 0) {
// 当前字符的词频不断增加,直到其重新等于0(该过程就是在不断欠债)
// 目的是让之前词频小于0的字符的词频重新回到0。
// 这些字符在之前还债时词频不断--,小于了0(可以理解为多还了这些债,是一种浪费,导致子串变长),那些词频等于0的就是没有多还债的,不是浪费,需要保留的
// 注意,在这个过程中,是一直满足 all == 0 的,不会令任意一个词频大于 0 的
// 因为一旦词频等于了0,就退出了 while 循环
// 当前位置词频小于0,说明 left 能往右移动
while (map[schar[left]] < 0) {
map[schar[left++]]++;
}
// 跳出 while 循环的时机是,left 来到了一个出现在 s 中的字符,
// 这个字符当前的词频等于0,说明不能再欠债了,否则其词频就大于0了,all就大于0了
// [left,right] 的位置就是局部的最小子串

// 解释肯定是在 s 中的字符的原因:因为不在 s 中的字符,其词频上限就是0。
// 一旦这些字符在前面有还债,那么其对应的词频肯定小于0,不可能会等于0,
// 因此这里等于0的肯定是那些在 s 中的字符,其在left右移欠债的过程中,使得其词频从负转为0,
// 因此此时就不能再右移left了,否则all就会大于0了,就不是最小子串了。我们要保证整个过程中 all 一直等于0

// 保存该值
if (minLength > (right - left + 1)) {
minLength = right - left + 1;
ansl = left;
ansr = right;
}
// 找到了一个局部最小子串后,left 需要继续右移,开始寻找新的局部子串
// 人为增加一个all,为了是让 right 继续向右寻找新的局部子串
all++;
map[schar[left++]]++;
}
// left 此时已经调整到了局部最小子串的位置,接下来继续移动 right,进行新的“还债”过程
right++;
}
// 注意 substring 方法是左闭右开的 [ansl, ansr + 1),所以 ansr 要加一
return minLength == Integer.MAX_VALUE ? "" : s.substring(ansl, ansr + 1);
}

K 个不同整数的子数组

992. K 个不同整数的子数组:给定一个正整数数组 A,如果 A 的某个子数组中不同整数的个数恰好为 K,则称 A 的这个连续、不一定不同的子数组为好子数组。返回 A 中好子数组的数目。

例如,[1,2,3,1,2] 中有 3 个不同的整数:1,2,以及 3。

思路:将恰好有 K 个不同整数的问题转换为 “最多有 K 个不同” - “最多有 K-1 个不同”

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
public int subarraysWithKDistinct(int[] nums, int k) {
// 将恰好有 K 个不同整数的问题转换为 “最多有 K 个不同” - “最多有 K-1 个不同”
return atMostKDistinct(nums, k) - atMostKDistinct(nums, k-1);
}

public int atMostKDistinct(int[] nums, int k) {
// 题目条件:1 <= A[i] <= A.length,因此词频表的范围最大就是 nums.length-1
// 创建词频表
int[] map = new int[nums.length + 1];

// [left, right):左闭右开的范围内存储着符合题意的“最多有K个不同整数”的子数组
int left = 0;
int right = 0;
// 记录当前子数组[left, right)中,存在的不同整数的种类数
// 当该种类数大于K时,说明不符合题目中最多有K个的条件,此时就需要left++
int kinds = 0;
// 记录返回结果
int res = 0;

while (right < nums.length) {
// 如果当前的right位置的词频为0,说明这个种类目前还没计算过,则kinds+1
// 记得词频也要++
if (map[nums[right]]++ == 0) {
kinds++;
}
// 无论当前的right是否遇到新的种类,都要后移,因为子数组的区间是[left, right),
// 是不包含right在内的,因此right就算后移也不影响,计算子数组长度时是不会算上这一位的
right++;

// 如果 kinds 大于了 K,说明上面那一步的kinds++导致子数组加入了一个新种类,因此需要left右移来抵消这个增加了
// 当left右移时,当遇到某个词频要归零了,就说明这个种类要消失了,也就和刚才的kind++抵消掉了,重新符合了题意的最多K个
// 一直循环着,直到 kinds == k,才会继续到下面的 res += right - left,否则算出来的就不是最多有K个了
while (kinds > k) {
// 当前left位置的词频为1,说明这个种类是目前子数组中的唯一一个,将其剔除后,kinds就要减一了
if (map[nums[left]]-- == 1) {
kinds--;
}
// 无论当前的left位置的数字是否即将消失,都需要left++,因为无论是否抵消,left都要右移来减小区间范围
// 1. left 位置的数字要抵消了,那么肯定要left++
// 2. left 位置的数字的词频还大于0,说明后面还有该数字呢,肯定也要left++
left++;
}

// 注意是左闭右开[left, right),不能把right这一位算进去,因为这一位在上面的 right++ 里多移动了,是出于开区间的
res += right - left;
}

return res;
}

三个无重叠子数组的最大和

689. 三个无重叠子数组的最大和:给你一个整数数组 nums 和一个整数 k ,找出三个长度为 k 、互不重叠、且 3 * k 项的和最大的子数组,并返回这三个子数组。以下标的数组形式返回结果,数组中的每一项分别指示每个子数组的起始位置(下标从 0 开始)。如果有多个结果,返回字典序最小的一个。

示例 1:

1
2
3
4
输入:nums = [1,2,1,2,6,7,5,1], k = 2
输出:[0,3,5]
解释:子数组 [1, 2], [2, 6], [7, 5] 对应的起始下标为 [0, 3, 5]。
也可以取 [2, 1], 但是结果 [1, 3, 5] 在字典序上更大。

示例 2:

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

思路:我们使用三个大小为 k 的滑动窗口。设 sum1 为第一个滑动窗口的元素和,该滑动窗口从 [0, k-1] 开始;sum2 为第二个滑动窗口的元素和,该滑动窗口从 [k, 2k-1] 开始;sum3 为第三个滑动窗口的元素和,该滑动窗口从 [2k, 3k-1] 开始。

我们同时向右滑动这三个窗口,维护 maxSum12 及其对应位置。每次滑动时,计算当前 maxSum12 与 sum3 之和。统计这一过程中的 maxSum12 + sum3 的最大值及其对应位置。

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public int[] maxSumOfThreeSubarrays(int[] nums, int k) {
int[] ans = new int[3];
int sum1 = 0, maxSum1 = 0, maxSum1Idx = 0;
int sum2 = 0, maxSum12 = 0, maxSum12Idx1 = 0, maxSum12Idx2 = 0;
int sum3 = 0, maxTotal = 0;
for (int i = k * 2; i < nums.length; ++i) {
sum1 += nums[i - k * 2];
sum2 += nums[i - k];
sum3 += nums[i];
if (i >= k * 3 - 1) {
if (sum1 > maxSum1) {
maxSum1 = sum1;
maxSum1Idx = i - k * 3 + 1;
}
if (maxSum1 + sum2 > maxSum12) {
maxSum12 = maxSum1 + sum2;
maxSum12Idx1 = maxSum1Idx;
maxSum12Idx2 = i - k * 2 + 1;
}
if (maxSum12 + sum3 > maxTotal) {
maxTotal = maxSum12 + sum3;
ans[0] = maxSum12Idx1;
ans[1] = maxSum12Idx2;
ans[2] = i - k + 1;
}
sum1 -= nums[i - k * 3 + 1];
sum2 -= nums[i - k * 2 + 1];
sum3 -= nums[i - k + 1];
}
}
return ans;
}

滑动窗口中的双端队列

由该题引出滑动窗口问题中常使用的双端队列,用于维护当前窗口区间内的最大/小值。

维持窗口内部最大值或者最小值

image-20211125143219920

窗口只能右边界或左边界向右滑的情况下,维持窗口内部最大值或者最小值快速更新的结构。

思路:使用一个双端队列,在其内保存当前窗口内的索引值(不保存数值,保存索引值,用于在左指针移动时判断队首的索引值是否等于旧的左指针的索引值(要淘汰的)),并且保证队列内的元素大小是递增或递减的且不能相等(对应求窗口内的最小值或最大值)。其含义是:如果此时形成的窗口状况不想让右指针向右移动而让左指针向右移动,哪个元素会成为最大/小值的优先级

以求窗口内的最大值为例:

  • 定义左右指针边界,一起向右移动
  • 先将当前窗口内队首索引值对应的元素值获取到,赋值给要返回给用户的数组 res[] 中,代表获取到了当前窗口内的最大值。然后窗口即将整体右移(体现在左右指针先后右移)
  • 左指针先右移,判断当前队首元素的索引值是否等于左指针右移前的位置的索引值。目的是:左指针一旦右移,说明左指针指向的前一个位置已经离开了窗口范围,该位置需要从队列中淘汰掉。如果队首元素的索引值等于这个旧的索引值,说明队首的这个元素已经要离开窗口范围了(因为窗口整体更新右移了),因此将该元素从队列中弹出。注意队列中存储的是索引值而不是元素值,要判断刚离开的左指针的位置是否等于队首的索引值。
  • 右指针后右移,判断当前队尾元素是否小于等于当前元素
    • 若是,则说明之前窗口内有某些元素的值小于当前新加入的元素,则这些比较小的元素肯定不会是新的窗口内的最大值了,那么就直接将其踢出队列,不再使用。重复进行该过程,直到没有队尾元素比当前元素大,或为空
    • 若不是,则说明之前窗口内的所有元素值都比当前新加入的元素大,则不需要踢出之前的元素
    • 最后将当前元素的索引值加入到队尾
    • 注意相等的情况也是要移出队列的,因为之前的元素是旧的,现在来的是新的,要保证队列里保存又新又大的元素的索引
  • 重复进行上述过程,直到左指针来到 nums.length - (w - 1) 的位置时,说明到底了,直接跳出循环返回答案

如果一个数又旧又小,那一定不是最大值。在队列里的值,要么更新,要么更大。

时间复杂度分析:

  • 队列总的更新时间代价为 O(N),因为数组中每个元素都只进出一次队列,丢掉的不在找回,而且左指针和右指针都在一起向右移动,不回头
  • 队列单次的更新时间代价为 O(N) / N = O(1)

若采用最直接的遍历,时间复杂度是 O(M*N)。其中,M 是窗口内元素的个数,因为要遍历窗口内的元素才能取得最大值。

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
public static int[] getMaxWindow(int[] nums, int w) {
if (nums == null || nums.length < w || w < 1) {
return null;
}

Deque<Integer> queueMax = new LinkedList<>();
// 先创建出来第一个窗口
for (int i = 0; i < w; i++) {
// 如果队尾元素小于等于当前元素,则队尾元素弹出(代表该元素不可能为当前窗口内的最大值了,直接丢弃)
if (!queueMax.isEmpty() && nums[queueMax.peekLast()] <= nums[i]) {
queueMax.pollLast();
}
// 直到队尾元素大于当前元素时停止弹出,将当前元素的索引加入队列
// 注意要加入的是索引而不是值
queueMax.addLast(i);
}

// 初始化两个指针位置,一个在0位置,一个在w-1位置
int left = 0;
int right = w - 1;
// 要返回的数组的长度是 nums.length - w + 1,注意不要忘了加一
int[] res = new int[nums.length - w + 1];

// right 一直遍历到数组尾部
while (right < nums.length) {
// left 指向当前位置索引
// 先将队列中的队首元素添加到res中,其就是当前窗口内的最大值
// 注意不能弹出,只是获取该值。要在该元素过期时才弹出
res[left++] = nums[queueMax.peekFirst()];

// 此时的 left -1 就是已经过期的元素,需要弹出
if (left - 1 == queueMax.peekFirst()) {
queueMax.pollFirst();
}

// 若成立,则说明窗口走到尽头了,不能再往下继续right++了,要越界了,直接break;
if (left == nums.length - (w - 1)) {
break;
}

// 此时 left 已经右移了一位,窗口需要整体右移,因此该 right 右移了
right++;
// right 右移时,需要将新的 right 加入到队列中,更新窗口内的最大值
// 注意相等的情况也是要移出队列的,因为之前的元素是旧的,现在来的是新的
while (!queueMax.isEmpty() && nums[queueMax.peekLast()] <= nums[right]) {
queueMax.pollLast();
}
queueMax.addLast(right);
}

return res;
}


public static int[] getMaxWindow1(int[] arr, int w) {
if (arr == null || w < 1 || arr.length < w) {
return null;
}
LinkedList<Integer> qmax = new LinkedList<Integer>();
int[] res = new int[arr.length - w + 1];
int index = 0;
for (int i = 0; i < arr.length; i++) { // 窗口的 R
while (!qmax.isEmpty() && arr[qmax.peekLast()] <= arr[i]) {
qmax.pollLast();
}
qmax.addLast(i);
if (qmax.peekFirst() == i - w) { // i-w :过期的下标
qmax.pollFirst(); // 这里的i-w已经是过期了的,不在当前窗口里的了
}
if (i >= w - 1) { // 窗口形成了 i 从 W-1 开始,窗口已经形成了
res[index++] = arr[qmax.peekFirst()];
}
}
return res;
}

// for test
public static void printArray(int[] arr) {
for (int i = 0; i != arr.length; i++) {
System.out.print(arr[i] + " ");
}
System.out.println();
}

public static void main(String[] args) {
int[] arr = { 4, 3, 5, 4, 3, 3, 6, 7 };
int w = 3;
printArray(getMaxWindow(arr, w));
}

达标子数组的数量

给定一个整型数组 nums 和一个整数 threshold,某个 nums 中的子数组 sub 如果想达标必须满足:sub 中的最大值 - sub 中的最小值 <= num。返回 nums 中达标子数组的数量。

思路:使用两个双端队列分别维护当前窗口内的最大值和最小值。然后以每个 left 为左边界,不断移动 right 直到不达标,计算以该 left 为左边界时的达标子数组数量。接着 left++,继续该过程,计算以此时的 left 为左边界时的达标子数组数量。

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
public static int allLessNumSubArray(int[] nums, int threshold) {
// 区间:[left, right) 左闭右开,right 代表可行的区间右边界的下一个元素
int left = 0;
int right = 0;

// 两个双端队列
Deque<Integer> minQueue = new LinkedList<>();
Deque<Integer> maxQueue = new LinkedList<>();

int count = 0;

// 外层循环不断移动 left,计算出以每个 left 为左边界的区间包含几个符合题意的子数组
while (left < nums.length) {
while (right < nums.length) {
// 注意:以下的更新队列内容很可能对同一个 right 重复执行多次,因为每次 left 更新后,之前已经处理过的 right 还会再判断一次,不过并不影响结果
// 更新最小值队列
while (!minQueue.isEmpty() && nums[minQueue.peekLast()] >= nums[right]) {
minQueue.pollLast();
}
// 注意队列维护的是索引信息
minQueue.addLast(right);
// 更新最大值队列
while (!maxQueue.isEmpty() && nums[maxQueue.peekLast()] <= nums[right]) {
maxQueue.pollLast();
}
// 注意队列维护的是索引信息
maxQueue.addLast(right);

// 如果当前最大值和最小值大于了阈值,则当前 right 不能继续右移了,要更新当前 [left, right) 区间内的子区间总数
// 注意队列维护的是索引信息
if (nums[maxQueue.getFirst()] - nums[minQueue.getFirst()] > threshold) {
break;
}

right++;
}

// 更新当前 [left, right) 区间内的子区间总数
count += right - left;

// 如果最小最大队列里的队首索引等于当前位置,则弹出
if (minQueue.peekFirst() == left) {
minQueue.pollFirst();
}
if (maxQueue.peekFirst() == left) {
maxQueue.pollFirst();
}

// 当前位置遍历完毕后,右移 left 继续计算以该值为左边界的区间包含几个符合题意的子数组
left++;
}
return count;
}

必须以当前值为最小值的最长子数组(越长累加和越大)