常见编程模式之滑动窗口

时间:2022-07-23
本文章向大家介绍常见编程模式之滑动窗口,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

本系列旨在介绍编程题中最常见的 16 种模式[1]。对于每一种模式会介绍其基本原理,应用场景以及经典的例题。

1. 滑动窗口(Sliding Window)

基本原理及应用场景

滑动窗口模式指对一个给定的数组或链表以特定的窗口大小进行所需操作,例如找出只包含 1 的最长子数组。滑动窗口一般从最左边第一个元素开始,每次向右移动一个元素,并根据要解决的问题调整窗口的长度。某些情况下,窗口的大小不需要调整,而其他情况下则需要增大或减小窗口大小。

在以下场景中,我们可能会用到滑动窗口:

  • 问题的输入是一个「线性数据结构」,例如链表、数组或字符串
  • 问题的目标是找出「最长/最短」子串、子数组或是目标值
  • 普通(暴力)解法的时间复杂度相当高

经典例题

下面给出三道不同难度的通过滑动窗口求解的经典例题:

643. 子数组最大平均数 I(Easy)

给定 n 个整数,找出平均数最大且长度为 k 的连续子数组,并输出该最大平均数。

「示例」

输入: [1,12,-5,-6,50,3], k = 4
输出: 12.75
解释: 最大平均数 (12-5-6+50)/4 = 51/4 = 12.75

这道题目如果直接采用遍历所有情况,依次求和取最大的话,会超出时间限制。我们可以考虑通过滑动窗口,持续跟踪窗口内的和,以减小时间复杂度,如下图所示:

该方法对应的 python 实现如下,时间复杂度为

O(N)

class Solution:
    def findMaxAverage(self, nums: List[int], k: int) -> float:
        average = [] # 平均数的列表,最后取最大即可
        sum_, start = 0, 0 # 末尾下划线避免与关键字冲突
        for end in range(len(nums)):
            sum_ += nums[end]
            if end >= k - 1: # 达到窗口大小
                average.append(sum_ / k) # 计算平均值
                sum_ -= nums[start] # 减去窗口外的元素
                start += 1 # 滑动窗口一位
        return max(average)

904. 水果成篮(Medium)

在一排树中,第 i 棵树产生 tree[i] 型的水果。你可以从你「选择的任何树开始」,然后重复执行以下步骤: 把这棵树上的水果放进你的篮子里。如果你做不到,就停下来。移动到当前树右侧的下一棵树。如果右边没有树,就停下来。请注意,在选择一颗树后,你没有任何选择:你必须执行步骤 1,然后执行步骤 2,然后返回步骤 1,然后执行步骤 2,依此类推,直至停止。 你有两个篮子,每个篮子可以携带任何数量的水果,但你希望每个篮子只携带一种类型的水果。用这个程序你能收集的水果总量是多少?

「示例」

输入:[0,1,2,2]
输出:3
解释:我们可以收集 [1,2,2].
如果我们从第一棵树开始,我们将只能收集到 [0, 1]。

这道题本质上即求「最多包含两种元素的最长连续子序列」,可以通过滑动窗口法来求解,时间复杂度为

O(N)

class Solution:
    def totalFruit(self, tree: List[int]) -> int:
        start, res = 0, 0
        count = {} # 可以用 collections.Counter()
        for end in range(len(tree)): # 可以用enumerate直接拿到值
            if (tree[end] not in count.keys()): count[tree[end]] = 0
            count[tree[end]] += 1
            while len(count) > 2: # 统计中的序列中的水果类型超过两种
                count[tree[start]] -= 1
                if count[tree[start]] == 0:
                    del count[tree[start]] # 数量归零则去除该类型
                start += 1 # 滑动窗口左边界加一
            res = max(res, end - start + 1)
        return res

76. 最小覆盖子串(Hard)

给你一个字符串 S、一个字符串 T,请在字符串 S 里面找出:包含 T 所有字符的最小子串。

「示例」

输入: S = "ADOBECODEBANC", T = "ABC"
输出: "BANC"

本题可以通过滑动窗口的思想解决。通过滑动右边界不断扩张窗口,当窗口包含 T 全部的所需字符后,如果能收缩,就收缩窗口直到得到最小窗口。关于如何判断当前窗口包含所有 T 所需的字符,可以使用哈希表(字典)来记录 T 中的所有字符及其个数,具体的实现如下:

class Solution:
    def minWindow(self, s: str, t: str) -> str:
        need = collections.defaultdict(int) # 或 Counter(),前者更快
        for item_t in t:
            need[item_t] += 1 # 默认任意键的初始值为 0       
        start, min_cnt, need_cnt = 0, float('inf'), len(t) # 设置起始位置,最小长度(默认为无限大)和需要字符的数量
        res = "" # 默认为空字符串
        for end, item in enumerate(s):
            if need[item] > 0: # 如果字典中数量大于0,说明位于t中,将need_cnt减1(直到小于等于0)
                need_cnt -= 1
            need[item] -= 1 # 对于任意字符都需要减1,表示遍历到了(对于不在t中的字符,其将直接从0变为负数)
            if need_cnt == 0: # 如果需要的字符数量达到了,则开始考虑收缩左边界
                while True:
                    if need[s[start]] == 0: # 如果左边界字符串的需求数量达到临界值,则不能再收缩了,需要跳出循环
                        break # 注意对于不在t中的字符,其值必为负数,最多加到0停止,不可能进入此条件
                    else:
                        need[s[start]] += 1 # 未达到临界值则将字典中对应的字符加1,并左移左边界
                        start += 1       
                if end - start + 1 < min_cnt: # 判断是否为最小长度,是则更新目标序列
                    min_cnt = end - start + 1
                    res = s[start : end + 1]
                need_cnt += 1 # 由于跳出时必处于临界,所以再次右移会导致need_cnt加1
                need[s[start]] += 1 # 将左边界再右移一个,开始新的遍历
                start += 1
        return res

其他类似的题目列表:

  • LeetCode 3-「无重复字符的最长子串」(Medium)
  • LeetCode 30-「串联所有单词的子串」(Hard)
  • LeetCode 209-「长度最小的子数组」(Medium)
  • LeetCode 424-「替换后的最长重复字符」(Medium)
  • LeetCode 438-「找出字符串中的所有字母异位词」(Medium)
  • LeetCode 567-「字符串的排列」(Medium)
  • LeetCode 1004-「最大连续 1 的个数 III」(Medium)

参考资料

[1]

Educative: https://www.educative.io/courses/grokking-the-coding-interview