滑动窗口进阶:统计满足条件的子数组数量
在上一篇文章中,我们学习了如何使用单调队列解决滑动窗口最大值问题。今天我们要挑战一个更复杂的题目:统计满足特定条件的子数组数量。这个问题不仅考察了滑动窗口的应用,还涉及了双指针和单调队列的巧妙结合。
问题描述
给定一个整数数组 arr 和一个整数 num,找出所有满足条件的子数组数量。子数组 sub 达标的条件是:
sub中的最大值 - sub中的最小值 <= num
示例:
- 输入:
arr = [1, 2, 3, 4],num = 2 - 输出:
8 - 解释:满足条件的子数组有
[1],[2],[3],[4],[1,2],[2,3],[3,4],[2,3,4]
暴力解法分析
最直观的方法是枚举所有可能的子数组,然后检查每个子数组是否满足条件。
// 暴力解法(时间复杂度 O(n³))
public static int right(int[] arr, int num) {
if (arr == null || arr.length == 0 || num < 0) {
return 0;
}
int count = 0;
int n = arr.length;
// 枚举所有子数组
for (int left = 0; left < n; left++) {
for (int right = left; right < n; right++) {
// 找出当前子数组的最大值和最小值
int max = arr[left];
int min = arr[left];
for (int i = left + 1; i <= right; i++) {
max = Math.max(max, arr[i]);
min = Math.min(min, arr[i]);
}
// 检查是否满足条件
if (max - min <= num) {
count++;
}
}
}
return count;
}
问题: 时间复杂度为 O(n³),对于大数组来说效率极低。
优化思路:滑动窗口 + 单调队列
观察发现,如果我们固定左边界 L,那么随着右边界 R 的增大:
- 子数组的最大值不会减小
- 子数组的最小值不会增大
- 因此
(最大值 - 最小值)不会减小
这意味着:如果某个窗口 [L, R] 不满足条件,那么所有更大的窗口 [L, R+1], [L, R+2], ... 也都不满足条件。
核心思想
对于每个左边界 L,找到最大的右边界 R,使得窗口 [L, R-1] 满足条件,而窗口 [L, R] 不满足条件。
这样,以 L 为左边界的所有满足条件的子数组数量就是 R - L。
数据结构选择
我们需要高效地维护窗口内的最大值和最小值,这正是单调队列的用武之地:
- 最大值队列:单调递减队列,队首是最大值
- 最小值队列:单调递增队列,队首是最小值
算法步骤详解
步骤1:初始化
- 使用两个双端队列分别维护最大值和最小值的索引
- 使用双指针
L(左边界)和R(右边界)
步骤2:扩展右边界
对于每个固定的 L,尽可能向右扩展 R:
while (R < N) {
// 维护最大值单调递减队列
while (!maxQueue.isEmpty() && arr[maxQueue.peekLast()] <= arr[R]) {
maxQueue.pollLast();
}
maxQueue.addLast(R);
// 维护最小值单调递增队列
while (!minQueue.isEmpty() && arr[minQueue.peekLast()] >= arr[R]) {
minQueue.pollLast();
}
minQueue.addLast(R);
// 检查当前窗口是否满足条件
if (arr[maxQueue.peekFirst()] - arr[minQueue.peekFirst()] > num) {
break; // 不满足条件,停止扩展
}
R++; // 满足条件,继续扩展
}
步骤3:统计结果并收缩左边界
// 以L为左边界的所有满足条件的子数组数量
count += R - L;
// 移除L位置的元素(如果它在队列头部)
if (maxQueue.peekFirst() == L) {
maxQueue.pollFirst();
}
if (minQueue.peekFirst() == L) {
minQueue.pollFirst();
}
完整代码实现
package com.fjd.windows;
import java.util.LinkedList;
/**
* 统计满足条件的子数组数量
* 条件:子数组中最大值 - 最小值 <= num
*/
public class AllLessNumSubArray {
/**
* 使用滑动窗口 + 单调队列优化算法
* 时间复杂度:O(n)
* 空间复杂度:O(n)
*/
public static int countValidSubarrays(int[] arr, int num) {
// 边界条件检查
if (arr == null || arr.length == 0 || num < 0) {
return 0;
}
// 单调递减队列:维护窗口内最大值的索引
LinkedList<Integer> maxQueue = new LinkedList<>();
// 单调递增队列:维护窗口内最小值的索引
LinkedList<Integer> minQueue = new LinkedList<>();
int validCount = 0; // 满足条件的子数组数量
int right = 0; // 右边界指针
int n = arr.length; // 数组长度
// 遍历每个可能的左边界
for (int left = 0; left < n; left++) {
// 尝试尽可能扩展右边界
while (right < n) {
// 维护最大值单调递减队列
while (!maxQueue.isEmpty() &&
arr[maxQueue.peekLast()] <= arr[right]) {
maxQueue.pollLast();
}
maxQueue.addLast(right);
// 维护最小值单调递增队列
while (!minQueue.isEmpty() &&
arr[minQueue.peekLast()] >= arr[right]) {
minQueue.pollLast();
}
minQueue.addLast(right);
// 检查当前窗口 [left, right] 是否满足条件
int currentMax = arr[maxQueue.peekFirst()];
int currentMin = arr[minQueue.peekFirst()];
if (currentMax - currentMin > num) {
// 不满足条件,停止扩展右边界
break;
}
right++; // 满足条件,继续向右扩展
}
// 以left为左边界的所有满足条件的子数组数量为 (right - left)
validCount += right - left;
// 收缩左边界:移除left位置的元素(如果在队列头部)
if (!maxQueue.isEmpty() && maxQueue.peekFirst() == left) {
maxQueue.pollFirst();
}
if (!minQueue.isEmpty() && minQueue.peekFirst() == left) {
minQueue.pollFirst();
}
}
return validCount;
}
/**
* 暴力解法(用于对数器验证)
* 时间复杂度:O(n³)
*/
public static int bruteForceCount(int[] arr, int num) {
if (arr == null || arr.length == 0 || num < 0) {
return 0;
}
int count = 0;
int n = arr.length;
// 枚举所有子数组
for (int left = 0; left < n; left++) {
for (int right = left; right < n; right++) {
// 计算当前子数组的最大值和最小值
int maxVal = arr[left];
int minVal = arr[left];
for (int i = left + 1; i <= right; i++) {
maxVal = Math.max(maxVal, arr[i]);
minVal = Math.min(minVal, arr[i]);
}
// 检查是否满足条件
if (maxVal - minVal <= num) {
count++;
}
}
}
return count;
}
// ==================== 测试工具方法 ====================
/**
* 生成随机测试数组
*/
public static int[] generateRandomArray(int maxSize, int maxValue) {
int size = (int) (Math.random() * (maxSize + 1));
int[] arr = new int[size];
for (int i = 0; i < size; i++) {
arr[i] = (int) ((maxValue + 1) * Math.random()) - (int) (maxValue * Math.random());
}
return arr;
}
/**
* 打印数组(用于调试)
*/
public static void printArray(int[] arr) {
if (arr == null) {
System.out.println("null");
return;
}
for (int i = 0; i < arr.length; i++) {
System.out.print(arr[i] + " ");
}
System.out.println();
}
/**
* 主函数:运行对数器测试
*/
public static void main(String[] args) {
int maxLen = 100;
int maxValue = 200;
int testTime = 100000;
System.out.println("开始对数器测试...");
boolean success = true;
for (int i = 0; i < testTime; i++) {
int[] arr = generateRandomArray(maxLen, maxValue);
int num = (int) (Math.random() * (maxValue + 1));
int expected = bruteForceCount(arr, num);
int actual = countValidSubarrays(arr, num);
if (expected != actual) {
System.out.println("测试失败!");
System.out.println("数组:");
printArray(arr);
System.out.println("num = " + num);
System.out.println("期望结果: " + expected);
System.out.println("实际结果: " + actual);
success = false;
break;
}
}
if (success) {
System.out.println("所有测试通过!");
}
}
}
算法复杂度分析
时间复杂度:O(n)
- 每个元素最多入队一次,出队一次
- 左右指针各遍历数组一次
- 总体时间复杂度为线性
空间复杂度:O(n)
- 两个队列最多存储 n 个元素
- 其他变量占用常数空间
相比暴力解法的 O(n³),这是一个巨大的优化!
执行过程演示
以 arr = [1, 2, 3, 4], num = 2 为例:
| left | right范围 | 满足条件的子数组 | 数量 |
|---|---|---|---|
| 0 | [0, 2) | [1], [1,2] | 2 |
| 1 | [1, 4) | [2], [2,3], [2,3,4] | 3 |
| 2 | [2, 4) | [3], [3,4] | 2 |
| 3 | [3, 4) | [4] | 1 |
总计:2 + 3 + 2 + 1 = 8
关键要点总结
- 单调性利用:窗口扩展时,最大值不减,最小值不增
- 双队列维护:一个维护最大值,一个维护最小值
- 双指针技巧:右指针不回退,保证线性时间复杂度
- 边界处理:注意队列为空的情况和索引越界
扩展应用
这种思想可以应用到更多类似问题:
- 统计满足
max - min >= k的子数组数量 - 统计满足其他极值条件的子数组
- 结合其他数据结构解决更复杂的窗口问题
掌握滑动窗口和单调队列的组合使用,会让你在解决数组相关问题时更加得心应手!


1184

被折叠的 条评论
为什么被折叠?



