首页 算法思想-滑动窗口
文章
取消

算法思想-滑动窗口

滑动窗口思想介绍

滑动窗口思想有趣的一个点在于,很多时候都可以通过暴力的方式解决问题,但是暴力的方法往往不会是这个问题的最佳方式,这个思路挺有趣的,很多时候没有一个固定的模板,往往就是见招拆招式的做法,但还是计划在这里做一个汇总,这种思维的训练还是值得多多锻炼的;

滑动窗口(Sliding Window)是一种用于解决数组或字符串中子数组或子串问题的有效算法技巧。它通过维护某个大小的窗口(子数组或子串),在数组上滑动窗口,以便在不重复计算的情况下,解决问题。

滑动窗口算法的一般思路如下:

  • 初始化左指针和右指针,用于定义窗口的范围。
  • 移动右指针,扩展窗口,直到满足特定条件(或窗口内的元素满足某些条件)。
  • 当窗口满足条件时,如果需要,移动左指针,缩小窗口,直到窗口不再满足条件。
  • 在每次移动窗口的过程中,根据窗口内的元素更新所需的结果。
  • 滑动窗口的优势在于它在大多数情况下具有线性时间复杂度,相比于暴力遍历所有子数组或子串,它可以大大减少重复计算,从而提高算法的效率。

滑动窗口常见的应用场景包括:

  • 找到数组或字符串中的子数组或子串满足特定条件的问题,如求和、平均值等。
  • 在字符串中查找特定长度的连续子串或子序列。
  • 解决子串匹配问题,如找到包含特定字符的最短子串。

滑动窗口问题

连续子数组最值问题

先看这个问题下的第一个题目:

1
2
3
题目:
- 给定一个数组,数组中只有0和1,给定一个k,可以将k个0翻转为1
- 求经过翻转后整个数组种最长的连续的1的数目;

之所以首先要说这道题,是因为在一次笔试的时候,明明有了解决策略,但是边界问题搞混淆了,没做出来实在可惜;

思路:

  • 设定初始边界,分别是左右指针leftright
  • right在数组内部不断移动,如果right在数组内对应的值为0,说明右指针先遇到了一个0;
  • 这时候记录0出现的个数,记录的变量记作zeroCount,直到0的个数超限,达到了k,这个时候就准备移动左指针left
  • 如果左指针left对应位置的元素是0,我们需要将zeroCount的个数减去1个,因为这个时候窗口内0的个数随着左指针的移动在不断减小;
  • 直到零的个数等于k,这个时候left不能再动,此时记录下子数组长;
  • 不断移动right直到超限,一旦超限,不断移动left直到零的个数符合要求;
  • 经过这样连续不断的处理,最终可以处理完整个数组

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int longestOnes(vector<int>& nums, int k) {
    int left = 0, right = 0;
    int zeroCount = 0;  // 该变量用来记录零的数目
    int maxOnes = 0;    // 最大结果

    while (right < nums.size()) {
        if (nums[right] == 0) { // 遇到一个零,增加一个统计数目
            zeroCount++;
        }

        while (zeroCount > k) { // 如果数目超过了k个,说明超出了范围
            if (nums[left] == 0) {
                zeroCount--;
            }
            left++;             // 不断移动窗口左值
        }

        maxOnes = max(maxOnes, right - left + 1);
        right++;
    }

    return maxOnes;
}

简单吗?真挺简单的;

————————————————————————————————————————————————————

再来看一个类似的问题:

1
2
3
4
5
6
7
题目:
- 给定一个含有n个正整数的数组和一个正整数target。
- 找出该数组中满足其和 ≥ target的长度最小的连续子数组,只要求索引连续而并非数据连续,并返回其长度。
- 如果不存在符合条件的子数组,返回0。

注意:
- 题目已经给定范围是正整数了,因此不需要考虑负;

由于题目已经告知全为正数,因此这个问题也是适合用滑动窗口来解决的:

思路:

  • 同样给定双指针left以及right,初始位都是0,初始时窗口内元素的和为nums[0]
  • 如果和大于target,且子数组长已经取到了1,显然没有必要再继续下去了,返回结果;
  • 如果和小于target,拓展右边界,right不断移动,此时和随着right的移动不断更新;
  • 不断移动,移动到和>=目标值,此时记录下窗口长,如果更小则更新;
  • 在满足这个条件的情况下,不断更新left指针,直到和又小于target
  • 就这样不停处理,直到right到终点;

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int minSubArrayLen(int target, const vector<int>& nums) {
    int left = 0;       // 指向我们要维护的窗口左边
    int right = left;   // 指向我们要维护的窗口右边
    int res = INT_MAX;  // 初始结果
    int temp_sum = 0;
    // 获取初始的窗口长度
    while (right < nums.size()) {
        temp_sum += nums[right];
        if (temp_sum >= target) {
            while (temp_sum >= target) {
                temp_sum -= nums[left];
                left++;     // 大于等于目标值的情况下不断移动左窗口
            }
            res = min(res, right - left + 2);   // 移动之后判断情况
            if (res == 1)   return res;         // 到1就不需要再判断了
        }

        right++;    // right是一定要不断移动的
    }
    
    return (res == INT_MAX) ? 0 : res;
}
————————————————————————————————————————————————————

子数组存在性问题

这种题目一般都固定了窗口的大小,题目类型就不详细描述了,直接看题:

1
2
3
4
5
6
7
题目:
- 给你一个整数数组nums和两个整数indexDiff和valueDiff。
- 找出满足下述条件的下标对(i, j):
    -- i != j
    -- abs(i - j) <= indexDiff
    -- abs(nums[i] - nums[j]) <= valueDiff
- 如果存在,返回true;否则,返回false。

看到题的第一步就是转换题目的意思,这个思想很重要:

  • 窗口的长度(说法并不严格)要小于等于indexDiff
  • 窗口左右两边的元素之差的绝对值要小于等于valueDiff
  • 是否存在这么一个窗口?

题目的需求已经明确,接下来就是做滑动处理了,不妨认为i > j

  • 我们要想判断两个值的距离:|a - b| <= c
  • 显然,以a为中心,b的范围必然是:[a - c, a + c]
  • 也就是说,当判断一个a的时候,我们只需要查找窗口中是否存在这么一个b在上述范围内即可;
  • 如果存在,得到结果,如果不存在,判断下一个窗口
  • 怎么快速获取到存在性呢?遍历数组显然不是一个好方法,试试空间换时间
  • 假设我们通过某些方式保存了窗口内的元素,对于窗口的最右边的元素
  • 在窗口内找到[a - c, a + c]范围内的最小值,也就是通过lower_bound找到大于等于a - c的最小值
  • 判断这个值是不是同时也是小于等于a + c,如果是,恭喜,找到了结果;
  • 如果不是,就接着判断
  • 但是上述这个思路有个很重要的前提,lower_bound要求元素内有序
  • 而我们又需要通过这么一个窗口维护窗口元素信息,显然,想到了set
    • 假设元素是[1 7 3 5 6]indexDiff为2;
    • 那么正好要判断的就是窗口[1 7]中有没有符合范围要求的数
    • 我们只需要保证[1 7]内有序即可

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
bool containsNearbyAlmostDuplicate(vector<int>& nums, int indexDiff, int valueDiff) {
    int size = nums.size();
    set<int> window;
    for (int i = 0; i < size; ++i) {
        auto it = lower_bound(window.begin(), window.end(), nums[i] - valueDiff);   // lower_bound含等于,切记同时要求元素有序,因此不能用无序容器
        if (it != window.end() && *it <= (nums[i] + valueDiff)) return true;        // 为空这一步不会执行,没找到符合条件的元素这一步也不会执行

        window.insert(nums[i]); // 插入元素,这个插入操作保证整个元素有序
        if (i >= indexDiff) window.erase(nums[i - indexDiff]);  // 超过范围需要清除掉左边界的元素,后面插入进来的始终还是有序
    }
    
    return false;
}

这道题这种方法的时间复杂度是最优解法,没想到这个方法处理这个问题还是挺难的;

(未完待续,不断拓展更新)

本文由作者按照 CC BY 4.0 进行授权

算法思想-动态规划

Linux使用技巧