Leetcode刷题总结(二):滑动窗口

127 阅读10分钟

导语

前面的笔记基本将Leetcode上主要的内容进行了一刷,现在进行回顾总结,本文主要介绍几个滑动窗口相关的题目,并进行总结整理。

所涉及的题目如下:

题目列表难度
209. 长度最小的子数组中等
487. 最大连续1的个数 II中等
1004. 最大连续1的个数 III中等
340. 至多包含 K 个不同字符的最长子串中等
424. 替换后的最长重复字符中等
3. 无重复字符的最长子串中等
438. 找到字符串中所有字母异位词中等
30. 串联所有单词的子串困难
76. 最小覆盖子串困难

滑动窗口

滑动窗口是一种常用的算法设计技巧,尤其在处理数组或字符串等序列型数据结构时特别有用。滑动窗口通常用于解决子数组或子序列问题,例如找出给定数组中和为特定值的连续子数组,或找出数组中的最长连续子数组等。

滑动窗口的基本思想

假设有一个数组或字符串,你可以想象有一个窗口在这个数组或字符串上从左至右滑动。窗口的大小可以是固定的,也可以是可变的。在窗口滑动的过程中,你可以进行各种计算,以解决特定问题。

一般步骤

  1. 定义窗口的左右边界,通常用两个指针表示。
  2. 根据问题需求初始化一些其他变量,例如 maxSumminLen 等。
  3. 移动右边界来扩大窗口,并更新相关变量。
  4. 检查是否需要缩小窗口(即移动左边界)。缩小窗口通常发生在窗口内的数据已经满足了某个条件(例如和超过了给定值)。
  5. 在滑动的过程中,根据问题需求,进行必要的计算或记录。

模板

参考labuladong.github.io/algo/di-lin… ,实际上滑动窗口作为一类特殊的双指针,解题套路相对固定。

def slidingWindow(s: str):
    # 用合适的数据结构记录窗口中的数据,如set或者dict之类的
    counter = {}
    
    left, right = 0, 0
    val = ……  # 要求的最大或者最小长度、序列之类的
    
    while right < len(s):
        # c 是将移入窗口的字符、元素
        c = s[right]
        counter[c] += 1
            
        # 增大窗口
        right += 1
        
        # 进行窗口内数据的一系列更新
        # ...
        
        # 判断左侧窗口是否要收缩,这里可以为一行代码逻辑,也可以单独写一个判别函数
        while left < right and window needs shrink:
            # d 是将移出窗口的字符、元素
            d = s[left]
            
            # 缩小窗口
            left += 1
            
            # 进行窗口内数据的一系列更新
            # ...

209. 长度最小的子数组

题目描述可以参考我之前的博客Leetcode刷题笔记2:数组基础2,这里不再赘述。这里我们可以套用这个模板,最外层循环也可以使用for循环,这样就不需要手动更新right。解题代码如下:

class Solution:
    def minSubArrayLen(self, target: int, nums: List[int]) -> int:
        # 初始化 start 指针为 0,用于标识子数组的起始位置
        start = 0
        # 初始化 ans 为 0,用于保存子数组的和
        ans = 0
        # 初始化 min_length 为数组长度+1,用于保存满足条件的最短子数组长度
        min_length = len(nums) + 1

        # 使用 end 指针遍历整个数组
        for end in range(len(nums)):
            # 计算从 start 到 end 的子数组的和
            ans += nums[end]

            # 如果子数组的和大于等于目标值
            while ans >= target:
                # 更新 min_length
                min_length = min(end - start + 1, min_length)
                # 从子数组的和中减去 start 指针指向的元素
                ans -= nums[start]
                # 将 start 指针向右移动一位,以缩小子数组的大小
                start += 1
                
        # 如果 min_length 没有被更新(即仍然是初始值),说明没有找到满足条件的子数组,返回 0
        if min_length == len(nums) + 1:
            return 0
        # 否则,返回找到的最短子数组的长度
        else:
            return min_length

这里的end就是模板中的right,只需要O(N)的复杂度我们就可以解答。

487. 最大连续1的个数 II

题目描述

给定一个二进制数组 nums ,如果最多可以翻转一个 0 ,则返回数组中连续 1 的最大个数。 提示:1 <= nums.length <= 105;nums[i] 不是 0 就是 1.

进阶: 如果输入的数字是作为 无限流 逐个输入如何处理?换句话说,内存不能存储下所有从流中输入的数字。您可以有效地解决吗?

解答

直接套用模板,我们这里维护的窗口中只能最多反转1个零,所以当flip_count大于1时,需要移动left,当遇到0时我们再将flip_count恢复就可以了。

from typing import List

class Solution:
    def findMaxConsecutiveOnes(self, nums: List[int]) -> int:
        left, right = 0, 0  # 定义滑动窗口的左右边界
        max_count = 0  # 初始化最大连续1的个数为0
        flip_count = 0  # 初始化翻转0的计数为0

        while right < len(nums):  # 通过移动右边界来遍历数组
            if nums[right] == 0:  # 如果当前元素是0,则增加翻转计数
                flip_count += 1
            
            # 如果翻转计数大于1(即窗口内有多于一个的0)
            # 则需要缩小窗口(通过移动左边界)
            while flip_count > 1:
                if nums[left] == 0:  # 如果左边界元素是0,则减少翻转计数
                    flip_count -= 1
                left += 1  # 移动左边界来缩小窗口
            
            # 更新最大连续1的个数
            max_count = max(max_count, right - left + 1)
            
            right += 1  # 移动右边界来扩大窗口

        return max_count  # 返回最大连续1的个数

1004. 最大连续1的个数 III

这道题目将1变为了k,实际上只是滑动窗口中的含义变化了,也就是窗口中最多只能反转k个零,当flip_count大于k时,需要移动left并将flip_count-=1.

[left, right] 中永远是一个合法的区间。

class Solution:
    def longestOnes(self, nums: List[int], k: int) -> int:
        left, right = 0, 0  # 初始化滑动窗口的左右边界
        max_count = 0  # 初始化最大连续1的个数为0
        flip_count = 0  # 初始化翻转的0的数量为0

        # 遍历数组(通过移动右边界来扩大窗口)
        while right < len(nums):
            # 如果当前元素是0,则增加翻转计数
            if nums[right] == 0:
                flip_count += 1
            
            # 如果翻转计数大于k(即窗口内的0数量超过了允许翻转的数量)
            # 需要缩小窗口(通过移动左边界)
            while flip_count > k:
                # 如果左边界处的元素是0,则减少翻转计数
                if nums[left] == 0:
                    flip_count -= 1
                left += 1  # 移动左边界来缩小窗口
            
            # 更新最大连续1的个数
            max_count = max(max_count, right - left + 1)
            
            right += 1  # 移动右边界来扩大窗口

        return max_count  # 返回最大连续1的个数

340. 至多包含 K 个不同字符的最长子串

题目描述

给你一个字符串 s 和一个整数 k ,请你找出 至多 包含 k 个 不同 字符的最长子串,并返回该子串的长度。

示例 1:

输入: s = "eceba", k = 2
输出: 3
解释: 满足题目要求的子串是 "ece" ,长度为 3 。

提示:

  • 1 <= s.length <= 5 * 104
  • 0 <= k <= 50

解法

class Solution:
    def lengthOfLongestSubstringKDistinct(self, s: str, k: int) -> int:
        if k == 0 or len(s) == 0:
            return 0
        
        left = 0
        max_length = 0
        char_frequency = defaultdict(int)  # 哈希表用于记录窗口内每个字符的出现次数
        
        for right in range(len(s)):
            char_frequency[s[right]] += 1  # 更新右边界字符的出现次数
            
            # 当窗口内不同字符的数量超过k时,移动左边界
            while len(char_frequency) > k:
                char_frequency[s[left]] -= 1  # 减少左边界字符的出现次数
                
                # 如果某个字符的出现次数变为0,从哈希表中移除它
                if char_frequency[s[left]] == 0:
                    del char_frequency[s[left]]
                
                left += 1  # 移动左边界
            
            max_length = max(max_length, right - left + 1)  # 更新最大子串长度

        return max_length

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

题目描述

给你一个字符串 s 和一个整数 k 。你可以选择字符串中的任一字符,并将其更改为任何其他大写英文字符。该操作最多可执行 k 次。在执行上述操作后,返回包含相同字母的最长子字符串的长度。

示例 1:

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

提示:

  • 1 <= s.length <= 105
  • s 仅由大写英文字母组成
  • 0 <= k <= s.length

解法

这里我们使用Counter来记录每个字符的出现次数。我们可以写一个函数判断窗口内的字符串是否符合要求:

from collections import Counter

class Solution:
    def characterReplacement(self, s: str, k: int) -> int:
        left, right = 0, 0
        max_length = 0
        char_freq = Counter()

        def is_valid(char_freq, right, left, k):
            max_freq = max(char_freq.values())
            res = right - left + 1 - max_freq
            return res <= k

        for right in range(len(s)):
            char_freq[s[right]] += 1
            while not is_valid(char_freq, right, left, k):
                char_freq[s[left]] -= 1
                left += 1

            max_length = max(max_length, right-left+1)
        
        return max_length

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

题目描述

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

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

提示:

  • 0 <= s.length <= 5 * 104
  • s 由英文字母、数字、符号和空格组成

解法

from collections import defaultdict

class Solution:
    def lengthOfLongestSubstring(self, s: str) -> int:
        left, right = 0, 0
        max_length = 0
        # char_set = set()
        char_freq = defaultdict(int)

        while right<len(s):       
            char_freq[s[right]] += 1
            while char_freq[s[right]] > 1:
                char_freq[s[left]] -= 1
                left += 1
            max_length = max(max_length, right-left+1)
            right += 1
        
        return max_length
            

这里的一个巧妙之处在于我们维护的窗口都是没有重复字符的,所以当加入s[right]后,打破这一约束的唯一可能就是char_freq[s[right]]。所以,第二个while只需判断char_freq[s[right]] > 1即可。

438. 找到字符串中所有字母异位词

题目描述

给定两个字符串 s 和 p,找到 s ****中所有 p ****的 异位词 的子串,返回这些子串的起始索引。不考虑答案输出的顺序。异位词 指由相同字母重排列形成的字符串(包括相同的字符串)。

示例 1:

输入: s = "cbaebabacd", p = "abc"
输出: [0,6]
解释:
起始索引等于 0 的子串是 "cba", 它是 "abc" 的异位词。
起始索引等于 6 的子串是 "bac", 它是 "abc" 的异位词。

提示:

  • 1 <= s.length, p.length <= 3 * 104
  • s 和 p 仅包含小写字母

解法

from collections import Counter

class Solution:
    def findAnagrams(self, s: str, p: str) -> List[int]:
        left, right = 0, 0
        result = []

        p_counter = Counter(p)
        s_counter = Counter()

        while right<len(s):
            s_counter[s[right]] += 1
            while right - left + 1 == len(p):
                if s_counter == p_counter:
                    result.append(left)
                
                s_counter[s[left]] -= 1
                if s_counter[s[left]] == 0:
                    del s_counter[s[left]]
                left += 1
            right += 1
        
        return result

这道题目的不同之处在于求所有符合条件的结果,所以这里使用一个列表来保存结果。

30. 串联所有单词的子串

题目描述

给定一个字符串 s ****和一个字符串数组 words  words 中所有字符串 长度相同。 s ****中的 串联子串 是指一个包含  words 中所有字符串以任意顺序排列连接起来的子串。

例如,如果 words = ["ab","cd","ef"], 那么 "abcdef", "abefcd""cdabef", "cdefab""efabcd", 和 "efcdab" 都是串联子串。 "acdbef" 不是串联子串,因为他不是任何 words 排列的连接。返回所有串联子串在 s ****中的开始索引。你可以以 任意顺序 返回答案。

示例 1:

输入: s = "barfoothefoobarman", words = ["foo","bar"]
输出: [0,9]
解释: 因为 words.length == 2 同时 words[i].length == 3,连接的子字符串的长度必须为 6。
子串 "barfoo" 开始位置是 0。它是 words 中以 ["bar","foo"] 顺序排列的连接。
子串 "foobar" 开始位置是 9。它是 words 中以 ["foo","bar"] 顺序排列的连接。
输出顺序无关紧要。返回 [9,0] 也是可以的。

提示:

  • 1 <= s.length <= 104
  • 1 <= words.length <= 5000
  • 1 <= words[i].length <= 30
  • words[i] 和 s 由小写英文字母组成

解法

本以为这道题目就是438. 找到字符串中所有字母异位词的简单变形,结果抄过来改了一下怎么样都不对,最后debug了一下才找到原因,先看看我写过来的原始代码:

from collections import Counter

class Solution:
    def findSubstring(self, s: str, words: List[str]) -> List[int]:
        result = []
        word_length = len(words[0])
        w_counter = Counter(words)

        for offset in range(len(words[0])):
            strings =s[offset:]
            left, right = 0, 0
            s_counter = Counter()
            while right<len(strings):
                word = strings[right: right+word_length]
                s_counter[word] += 1
                while right-left == len(words)*len(words[0]):
                    start_word = strings[left: left+word_length]
                    # print("===========================")
                    # print("s[left: right]: ", strings[left: right])
                    # print("start_word: ", start_word)
                    # print("s_counter: ", s_counter)
                    # print("w_counter: ", w_counter)
                    # print("===========================")
                    if s_counter == w_counter:
                        result.append(left+offset)
                    s_counter[start_word] -= 1
                    if s_counter[start_word] == 0:
                        del s_counter[start_word]
                    left += word_length
                right += word_length

        return result

这里犯了好几个错误:

word = strings[right: right+word_length] s_counter[word] += 1 之后,导致s_counter里实际上是left到right+word_legth里的内容,而下面while还是判断的left到right! right实际上是区间的后面的下标+1,所以外层的while应该为right<=len(strings), 修改后的代码为:

from collections import Counter
from typing import List  # 导入List类型,用于类型注解

class Solution:
    def findSubstring(self, s: str, words: List[str]) -> List[int]:
        result = []  # 存储结果的列表
        word_length = len(words[0])  # 单个单词的长度
        w_counter = Counter(words)  # 统计words列表中单词的出现频率

        # 由于所有单词长度相同,可以从s的不同偏移量开始检查
        # 这样可以处理所有可能的子串
        for offset in range(word_length):  
            strings = s[offset:]  # 获得从偏移量开始的字符串
            left, right = 0, 0  # 初始化左右指针
            s_counter = Counter()  # 存储当前检查的子串中单词的计数器

            # 当右指针没有越界时继续
            while right <= len(strings):
                # 检查当前窗口大小是否与目标单词列表的总长度相同
                while right - left == len(words) * word_length:
                    start_word = strings[left: left + word_length]  # 当前窗口最左边的单词

                    # 如果计数器匹配,则找到一个结果
                    if s_counter == w_counter:
                        result.append(left + offset)

                    # 移动左指针以缩小窗口,并更新计数器
                    s_counter[start_word] -= 1  
                    if s_counter[start_word] == 0:
                        del s_counter[start_word]
                    left += word_length

                # 从当前右指针位置取出一个单词,并更新计数器和右指针
                word = strings[right: right + word_length]
                s_counter[word] += 1
                right += word_length

        return result

整体的思路就是最外层for循环进行一个单词长度的位置,相当于便利各种字符串起始位置的情况(因为s并不总是word长度的倍数),之后套用之前的438. 找到字符串中所有字母异位词代码,做一些修改即可。

76. 最小覆盖子串

题目描述

给你一个字符串 s 、一个字符串 t 。返回 s 中涵盖 t 所有字符的最小子串。如果 s 中不存在涵盖 t 所有字符的子串,则返回空字符串 "" 。

注意:

  • 对于 t 中重复字符,我们寻找的子字符串中该字符数量必须不少于 t 中该字符数量。
  • 如果 s 中存在这样的子串,我们保证它是唯一的答案。

 

示例 1:

输入: s = "ADOBECODEBANC", t = "ABC"
输出: "BANC"
解释: 最小覆盖子串 "BANC" 包含来自字符串 t 的 'A''B''C'

提示:

  • m == s.length
  • n == t.length
  • 1 <= m, n <= 105
  • s 和 t 由英文字母组成

进阶: 你能设计一个在 o(m+n) 时间内解决此问题的算法吗?

解法

整体思路与424. 替换后的最长重复字符类似,我们需要使用一个函数来统计当前窗口是否包含 t 的所有字符(包括重复字符)。

from collections import defaultdict, Counter  # 导入所需的库

class Solution:
    def minWindow(self, s: str, t: str) -> str:
        start = 0  # 初始化滑动窗口的起始索引
        char2count = defaultdict(int)  # 创建一个默认字典来存储 s 中各字符的计数
        t_count = Counter(t)  # 使用 Counter 对象计算 t 中各字符的计数
        
        # 初始化最小窗口长度和最小窗口的起始和结束索引
        min_length = len(s) + 1  
        min_start, min_end = -1, -1

        # 定义一个内部函数用于检查当前窗口是否包含 t 的所有字符(包括重复字符)
        def check(char2count, t_count):
            for char, count in t_count.items():  # 遍历 t 中的每个字符及其出现次数
                if char2count[char] < count:  # 如果 s 中该字符的计数小于 t 中的计数,则返回 False
                    return False
            return True  # 如果所有字符的计数都符合要求,则返回 True

        # 遍历 s 中的每个字符
        for end in range(len(s)):
            char2count[s[end]] += 1  # 增加当前字符的计数

            # 使用 while 循环不断缩小窗口,直到窗口不再包含 t 的所有字符
            while check(char2count, t_count):
                # 检查当前窗口是否比已找到的最小窗口还要小
                if end - start + 1 < min_length:
                    min_length = end - start + 1
                    min_start, min_end = start, end  # 更新最小窗口的起始和结束索引
                
                # 缩小窗口(即移动窗口的起始索引)
                char2count[s[start]] -= 1
                start += 1

        # 根据最小窗口的长度判断是否找到了符合条件的子串
        if min_length == len(s) + 1:
            return ""
        else:
            return s[min_start: min_end + 1]  # 返回找到的最小窗口子串