算法与数据结构(一):滑动窗口法总结

1. 介绍

滑动窗口法,也叫尺取法(可能也不一定相等,大概就是这样 =。=),可以用来解决一些查找满足一定条件的连续区间的性质(长度等)的问题。由于区间连续,因此当区间发生变化时,可以通过旧有的计算结果对搜索空间进行剪枝,这样便减少了重复计算,降低了时间复杂度。往往类似于“请找到满足xx的最x的区间(子串、子数组)的xx”这类问题都可以使用该方法进行解决。

2. 引入的小例子

2.1 Leetcode 209. 长度最小的子数组

给定一个含有 n 个正整数的数组和一个正整数 s ,找出该数组中满足其和 ≥ s 的长度最小的连续子数组。如果不存在符合条件的连续子数组,返回 0。

示例: 

输入: s = 7, nums = [2,3,1,2,4,3]
输出: 2
解释: 子数组 [4,3] 是该条件下的长度最小的连续子数组。

这道题目最简单的解法自然是枚举每个数组起点和终点,这种解法的时间复杂度是O(N^2)

def solver(nums, s):
    optim = len(nums) + 1
    for start in range(len(nums)):
        summation = 0
        for end in range(start, len(nums)):
            summation += nums[end]
            if summation >= s:
                optim = min(optim, end - start + 1) 
                break
    return optim

通过分析可以发现,这种解法进行了很多重复计算,首先是对于状态的重复计算,比如当start为0时,我们计算了区间[0,0], [0,1], [0,2],…等的和,但当start为1时,我们又重新计算了区间[1,1], [1,2], …,的和,但事实上,这些区间的值是可以根据上一次计算的结果直接得到的,如区间[1,2]等于区间[0,2]减去nums[0]的值。换句话说,我们可以根据之前计算得到的结果来推断还未进行计算的结果,这也为剪枝带来了可能。

考虑这样一个例子,给定数组为[2,3,1,2,4,3],并给定要求的最小和s为7,通过第一次枚举,我们得知子数组[2,3],[2,3,1]都是小于7的,那我们也就没有必要在接下来的阶段对子数组[3],子数组[3,1]进行枚举检查了,因为他们的和一定是小于7的。若实现了这种剪枝,时间复杂度便可以得到大幅的优化。

那么如何实现这样的剪枝呢?考虑这样一种情形,数轴上存在一个滑动窗口,假设其左右端点分别为L和R。首先我们移动R,使得滑动窗口的区间满足给定的条件,然后我们再移动L,直到滑动区间不再满足给定的条件,如此循环往复,并在其过程中记录最优值。继续之前的例子,如图所示,过程如下:

  1. 滑动窗口的长度为0,位于数轴的最左端
  2. 滑动窗口右端R开始移动,直到区间满足给定的条件,也就是和大于7,停止于第三个元素2,记录下来当前的最优长度为4
  3. 滑动窗口左端L开始移动,并停止于第一个元素3,此时区间和为6,使得区间和不满足给定的条件
  4. 滑动窗口右端R继续移动,停止于第四个元素4,在过程中,最优长度仍然为4
  5. 滑动窗口左端L移动至第三个元素2,过程中更新最优长度为3
  6. 滑动窗口右端R移动至最后一个元素3,
  7. 滑动窗口左端L移动至最后一个元素,并在过程中更新最优长度为2

算法与数据结构(一):滑动窗口法总结_第1张图片
滑动窗口法 Leetcode 209 的过程示意图

3. 滑动窗口法的大体框架

通过归纳,我们可以勾勒出滑动窗口法的大体框架(只是基本框架,根据不同的问题应适当变动,重在把握精神)

初始化窗口端点L,R,一般L为0,R为1
    初始化最优值
    while R < len(Array):
        while R < len(Array):
            R += 1              #移动右端点
            if R < len(Array):
                更新状态        
            if 状态满足条件:
                可选的更新最优值的位置
                break           #一旦满足条件即跳出
        if R == len(Array):     # 若循环是由于移动到数组末尾结束,则停止整个程序。因为之后已经不再有可能的解
            break
        while L < R:
            更新状态    # 移动左端点,需要更新状态
            L += 1
            if 状态满足条件:
                可选的更新最优值的位置
            else# 一旦窗口所在区间不再满足条件即跳出,去移动右端点
                break
        可选的对于L,R端点的后续处理
    return 最优值

4. 滑动窗口法实例

4.1 Leetcode 209. 长度最小的子数组

给定一个含有 n 个正整数的数组和一个正整数 s ,找出该数组中满足其和 ≥ s 的长度最小的连续子数组。如果不存在符合条件的连续子数组,返回 0。

示例: 

输入: s = 7, nums = [2,3,1,2,4,3]
输出: 2
解释: 子数组 [4,3] 是该条件下的长度最小的连续子数组。

这道题是上面讲解过的题目,这里套用之前提出的框架再讲一遍。我们设置一个状态为summation,表示当前区间的和,而状态满足的条件是summation >= s,寻找最优值则是去比较当前的最优值以及目前滑动窗口的长度。代入框架即得到了求解该问题的程序:

def minSubArrayLen(s: int, nums: List[int]) -> int:
    summation = 0
    L, R = 0, -1
    optim = len(nums) + 1
    while R < len(nums):
        while R < len(nums):
            R += 1
            if R < len(nums):
                summation += nums[R]
            if summation >= s:
                optim = min(optim, R - L + 1)
                break

        if R == len(nums):
            break

        while L < R:
            summation -= nums[L]
            L += 1
            if summation >= s:
                optim = min(optim, R - L + 1)
            else:
                break
    return optim if optim != len(nums) + 1 else 0

4.2 Leetcode 3. 无重复字符的最长子串

给定一个字符串,请你找出其中不含有重复字符的 最长子串 的长度。

示例 1:

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

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

输入: "pwwkew"
输出: 3
解释: 因为无重复字符的最长子串是 "wke",所以其长度为 3。
     请注意,你的答案必须是 子串 的长度,"pwke" 是一个子序列,不是子串。

这道题是寻找无重复字符的最长子串,我们设置一个set来保存当前区间内的字符。对于右端点而言,S[R]不在set当中即满足条件。对于左端点而言,只要让左端点移动到目前S[R]的值第一次出现的位置后面即可,也就是说,不让滑动窗口包含重复的字符(因为重复的字符一定是当前右端点指向的字符)。这道题和前面一道题不同的地方在于,前一道题右端点是从不满足给定条件到移动满足给定条件,而这道题则相反。因此右端点会移动到第一次不满足条件的位置,而左端点则移动到再一次满足条件的位置。代码如下:

def lengthOfLongestSubstring(s: str) -> int:
    L, R = 0, -1
    optim = 0
    status = set()
    while R < len(s):
        while R < len(s):
            R += 1
            if R == len(s):
                break
            if s[R] not in status:
                status.add(s[R])
                optim = max(optim, R - L + 1)
            else:
                break

        if R == len(s):
            break

        while L < R:
            if s[L] != s[R]:
                status.remove(s[L])
                L += 1
            else:
                L += 1
                break
    return optim

4.3 Leetcode 1004. 最大连续1的个数 III

给定一个由若干 0 和 1 组成的数组 A,我们最多可以将 K 个值从 0 变成 1 。
返回仅包含 1 的最长(连续)子数组的长度。

示例 1:

输入:A = [1,1,1,0,0,0,1,1,1,1,0], K = 2
输出:6
解释: 
[1,1,1,0,0,1,1,1,1,1,1]
粗体数字从 0 翻转到 1,最长的子数组长度为 6。

示例 2:

输入:A = [0,0,1,1,0,0,1,1,1,0,1,1,0,0,0,1,1,1,1], K = 3
输出:10
解释:
[0,0,1,1,1,1,1,1,1,1,1,1,0,0,0,1,1,1,1]
粗体数字从 0 翻转到 1,最长的子数组长度为 10。

这道题和上一题一样,右端点移动到第一次不满足条件的位置,而左端点则移动到再一次满足条件的位置。需要满足的条件是,K值大于等于0。当碰到零时,K减去1。代码如下所示:

def longestOnes(A: List[int], K: int) -> int:
    L, R = 0, -1
    optim = 0
    while R < len(A):
        while R < len(A):
            R += 1
            if R == len(A):
                break
            if A[R] == 0:
                K -= 1
            if K < 0:   #第一次不满足条件
                break
            else:   #满足条件时更新最优值
                optim = max(optim, R - L + 1)

        if R == len(A):
            break

        while L < R:
            if A[L] == 0:
                K += 1
            L += 1
            if K >= 0:
                break
    return optim

换双指针解法更容易理解一些

虫取法/双指针:

这个题最开始的想法是 DP,但是没做出来。其实最简单的方法是虫取法或者叫做双指针。

[left, right] 双闭区间表示一个经过一定次数的翻转后已经全部是1的区间。我们要求的长度就是这个区间的最大长度。我们需要把这个区间内的所有0都翻转成1,使用变量 zero 统计该区间内的被翻转的 0 的个数。易知,zero <= K.

我们把 left 和 right 的起始位置都设定为 0,我们每次都向右移动一次right指针代表新判断一个元素。此时,如果right指向的数字是 0,我们需要将 zero+1,代表我们把这个0进行了翻转。然后我们就会想,如果翻转之后 zero > K 了怎么办?所以我们此时需要移动left指针啊!left有可能指向了1,所以需要一直移动直至 zero <= K 为止。

使用 res 保存最大区间长度即可。

这个方法是常见的虫取法,这个虫取法的精髓是保证每次取到的区间是一个严格满足题目要求的区间。具体到这个题目来说就是维护了一个最多翻转 K 个 0 的全 1 区间。只要这个维护是有效的,那么我们就可以根据区间长度更新 res。维护的过程我在上面已经讲解,核心是区间内统计 0 的个数,不过这个统计不是每次都遍历一次区间,而是使用一个变量,这个变量和区间同时维护即可。

class Solution(object):
    def longestOnes(self, A, K):
        """
        :type A: List[int]
        :type K: int
        :rtype: int
        """
        left,right = 0,0
        res = 0
        zeros = 0
        for right in range(len(A)):
            if A[right] == 0:
                zeros += 1        
            while zeros > K:
                if A[left] == 0:
                    zeros -= 1
                left += 1
            res = max(res, right - left + 1)
        return res
        

对应 C++ 版本:

class Solution {
public:
    int longestOnes(vector<int>& A, int K) {
        int res = 0;
        int left = 0;
        int zero = 0;
        const int N = A.size();
        for (int right = 0; right < N; ++right) {
            if (A[right] == 0) 
                ++zero;
            while (zero > K) {
                if (A[left++] == 0)
                    --zero;
            }
            res = max(res, right - left + 1);
        }
        return res;
    }
};

总结

滑动窗口法可以用来解决一些查找满足一定条件的连续区间的性质(长度等)问题,个人认为可以看做是一种双指针方法的特例,两个指针都起始于原点,并一前一后向终点前进。还有一种双指针方法,其两个指针一始一终,并相向靠近,这种方法的内在思想和滑动窗口也非常类似,如Leetcode11. 盛最多水的容器就可以使用这种解法求解。

参考文献

[1] Leetcode刷题总结之滑动窗口法(尺取法)
[2] 【LeetCode】1004. Max Consecutive Ones III 解题报告(C++)

你可能感兴趣的:(算法与数据结构)