算法锦囊10:一文搞定滑动窗口

1,088 阅读4分钟

这是我参与更文挑战的第18天,活动详情查看: 更文挑战

最近想把自己刷算法题的经验心得整理一下,一方面为了复习巩固,另一方面也希望我的分享能够帮助到更多在学习算法的朋友。

专栏名称叫《算法锦囊》,在讲解算法时会注重整体性,但不会面面俱到,适合有一定算法经验的人阅读。

这一次我们重点来看滑动窗口,这一部分的所有题目和源码都上传到了github的该目录下,题解主要用Python语言实现。

概述

滑动窗口指的是这样一类问题的求解方法,在数组上通过双指针同向移动而解决的一类问题。其实这样的问题我们可以不必为它们专门命名一个名字,它们的解法其实是很自然的。

使用滑动窗口解决的问题通常是暴力解法的优化,掌握这一类问题最好的办法就是练习,然后思考清楚为什么可以使用滑动窗口。

滑动窗口的解题模板可参考负雪明烛的这个答案

模板如下:

def findSubArray(nums):
    N = len(nums) # 数组/字符串长度
    left, right = 0, 0 # 双指针,表示当前遍历的区间[left, right],闭区间
    sums = 0 # 用于统计 子数组/子区间 是否有效,根据题目可能会改成求和/计数
    res = 0 # 保存最大的满足题目要求的 子数组/子串 长度
    while right < N: # 当右边的指针没有搜索到 数组/字符串 的结尾
        sums += nums[right] # 增加当前右边指针的数字/字符的求和/计数
        while 区间[left, right]不符合题意:# 此时需要一直移动左指针,直至找到一个符合题意的区间
            sums -= nums[left] # 移动左指针前需要从counter中减少left位置字符的求和/计数
            left += 1 # 真正的移动左指针,注意不能跟上面一行代码写反
        # 到 while 结束时,我们找到了一个符合题意要求的 子数组/子串
        res = max(res, right - left + 1) # 需要更新结果
        right += 1 # 移动右指针,去探索新的区间
    return res

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

首先看这一题,3. 无重复字符的最长子串

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

示例 1:

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

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

这是典型的滑动窗口的题目,维护left和right两个指针。不断向右移动right,找到满足条件的最大长度,如果不符合,则向右移动left,找到最大长度。

套用上面的模板,我们可以完美写出答案。我用了一个集合去存储当前已经遍历的值,如果right指针向右遍历时不满足条件,会从集合里先去掉该元素。

class Solution:
    def lengthOfLongestSubstring(self, s: str) -> int:
        left = right = res = 0
        window_set = set()
        while right < len(s):
            while s[right] in window_set:
                window_set.remove(s[left])
                left += 1
            window_set.add(s[right])
            res = max(res, right - left + 1)
            right += 1
        return res

159. 至多包含两个不同字符的最长子串

趁热打铁,我们继续看 159. 至多包含两个不同字符的最长子串

对于这道题,我们仍可以沿用上面的模板,与前面的不同的时,在判断滑动窗口何时增加和减少元素时,需要根据题目做不同的判断。

我这里使用了一个字典,来存储当前遍历过的元素的数目,一旦出现字典元素个数比2小,则左移left指针,并调整字典。

import collections
class Solution:
    def lengthOfLongestSubstringTwoDistinct(self, s: str) -> int:
        dic = collections.defaultdict(int)
        left = right = res = 0
        while right < len(s):
            dic[s[right]] += 1
            while len(dic) > 2:
                dic[s[left]] -= 1
                if dic[s[left]] == 0:
                    del dic[s[left]]
                left += 1
            res = max(res, right-left+1)
            right += 1
        return res

这道题还有引申版本340. 至多包含 K 个不同字符的最长子串,不过掌握方法后,万变不离其宗。

给定一个字符串 s ,找出 至多 包含 k 个不同字符的最长子串 T。

示例 1:

输入: s = "eceba", k = 2
输出: 3
解释: 则 T 为 "ece",所以长度为 3。
class Solution:
    def lengthOfLongestSubstringKDistinct(self, s: str, k: int) -> int:
        dic = collections.defaultdict(int)
        left = right = res = 0
        while right < len(s):
            dic[s[right]] += 1
            while len(dic) > k:
                dic[s[left]] -= 1
                if dic[s[left]] == 0:
                    del dic[s[left]]
                left += 1
            res = max(res, right-left+1)
            right += 1
        return res

424. 替换后的最长重复字符

我们接着来看424. 替换后的最长重复字符

给你一个仅由大写英文字母组成的字符串,你可以将任意位置上的字符替换成另外的字符,总共可最多替换 k 次。在执行上述操作后,找到包含重复字母的最长子串的长度。

注意:字符串长度 和 k 不会超过 104。


示例 1:

输入:s = "ABAB", k = 2
输出:4
解释:用两个'A'替换为两个'B',反之亦然。

这道题初看的时候,也许意识不到要用滑动窗口的解法。我们可以通过一个数组来记录每个元素的出现次数,取改数组的最大值,看滑动窗口的长度减去这个值的结果是否比k大,来判断是否符合题目要求。

class Solution:
    def characterReplacement(self, s: str, k: int) -> int:
        num = [0] * 26
        n = len(s)
        ans = left = right = 0
        while right < n:
            num[ord(s[right]) - ord("A")] += 1
            maxn = max(num)
            while right - left + 1 - maxn > k:
                num[ord(s[left]) - ord("A")] -= 1
                maxn = max(num)
                left += 1
            ans = max(ans, right - left + 1)
            right += 1

        return ans

239. 滑动窗口最大值

最后来看这道经典的滑动窗口题目,239. 滑动窗口最大值

给你一个整数数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。

返回滑动窗口中的最大值。

示例 1:

输入:nums = [1,3,-1,-3,5,3,6,7], k = 3
输出:[3,3,5,5,6,7]
解释:
滑动窗口的位置                最大值
---------------               -----
[1  3  -1] -3  5  3  6  7       3
 1 [3  -1  -3] 5  3  6  7       3
 1  3 [-1  -3  5] 3  6  7       5
 1  3  -1 [-3  5  3] 6  7       5
 1  3  -1  -3 [5  3  6] 7       6
 1  3  -1  -3  5 [3  6  7]      7

这道题因为窗口是固定的,跟我们前面的几道题稍微有所区别。比较简洁的做法,利用一个双端队列来存储滑动窗口,并且每次放入元素的时候,保证最左边的元素永远是滑动窗口的最大值。

class Solution:
    def maxSlidingWindow(self, nums: List[int], k: int) -> List[int]:
        size = len(nums)

        # 特判
        if size == 0:
            return []
        # 结果集
        res = []
        # 滑动窗口,注意:保存的是索引值
        window = deque()

        for i in range(size):
            # 当元素从左边界滑出的时候,如果它恰恰好是滑动窗口的最大值
            # 那么将它弹出
            if i >= k and i - k == window[0]:
                window.popleft()

            # 如果滑动窗口非空,新进来的数比队列里已经存在的数还要大
            # 则说明已经存在数一定不会是滑动窗口的最大值(它们毫无出头之日)
            # 将它们弹出
            while window and nums[window[-1]] <= nums[i]:
                window.pop()
            window.append(i)

            # 队首一定是滑动窗口的最大值的索引
            if i >= k - 1:
                res.append(nums[window[0]])
        return res