leetcode滑动窗口问题总结 Python

目录

目录

一、理论

二、例题

1. 最长无重复字符串

2. 长度最小的子数组

3. 字符串的排列

4. 最小覆盖子串

5. 最大连续 1 的个数

6. 滑动窗口最大值


一、理论

滑动窗口是一类比较重要的解题思路,一般来说我们面对的都是非定长窗口,所以一般需要定义两个指针 left 和 right,分别用来限制窗口的左边界和右边界。在解题时一般需要设定两个嵌套的循环,外循环不设定条件,完全是遍历模式,驱动右指针的移动;内循环需要设定条件,在满足条件的情况下,驱动左指针的移动。整体实现滑动窗口的向右移动。外循环虽然没有设定条件,但其实存在隐藏的条件就是不满足内循环所设定的条件时运行。理论上,滑动窗口框架在如下 “位置1” 处只能处理满足设定条件的最短长度问题,如例题 1;在如下 “位置2” 处只能处理不满足设定条件的最长长度问题,如例题 2。在这个框架下,可以增加对窗口长度和数据的记录,进而实现丰富的功能。注意该框架的搜索策略是按照右指针递增顺序遍历搜索,所以首先确定右指针,然后再确定左指针,但左指针不需要遍历所有可能,只需要遍历部分可能的范围,在大部分右指针下只需要检查一个左指针值即可。不需要遍历所有可能,这正是该框架能减少时间和空间使用量的原因。

其实,该框架并不能解决所有最短和最长长度问题,只能解决满足特定先验条件下的最长和最短长度问题。只有满足以下条件时,该框架下的搜索才是完备的,不会遗漏结果,没有被检查的子串经过分析一定不包含所要结果,具体可以见如下例题分析。

  1. 对于最长长度问题,当问题满足“只要当前字符串满足判断条件,则该字符串右边缘向右扩展延长字符串时,新字符串依然满足判断条件”时,该框架下的搜索才是完备的。例如例题 1 中,当右指针确定了,遍历左指针时,只要代码运行到位置 2,则此时左指针没必要再往右移了,因为当前左指针就是不满足判断条件的,再右移左指针即使依然不满足判断条件,那长度也小于当前长度。最难考虑清楚的是,为何此时不能左移左指针,原因就是左 - 1 指针与右 -1 指针组成的字符串经过上一轮的验证肯定是满足判断条件的,当前如果左指针向左移动一位,则变为由左 -1 指针和右指针组成的字符串,对于例题 1,只要左 -1 和右 -1 指针组成的字符串满足判断条件,那左 -1 和右指针组成的字符串也一定满足判断条件,所以此时也没有必要左移左指针。综上所述,当右指针确定时,在位置 2 只需要检查一次即可,不需要再左移和右移左指针,便可保证右指针搜索的完备性。
  2. 对于最短长度问题,当问题满足“只要当前字符串不满足判断条件,则该字符串左边缘向右扩展缩短字符串时,新字符串依然不满足判断条件”时,该框架下的搜索才是完备的。例如例题 2 中,当右指针确定了,遍历左指针时,只要代码运行到位置 1,则此时左指针没必要再往左移了,因为当前左指针就是满足判断条件的,再左移左指针依然满足判断条件,那长度也大于当前长度。最难考虑清楚的是,为何当左指针右移至第一次不满足判断条件时,不能再继续右移左指针,原因就是当前左指针与右指针组成的字符串不满足判断条件,如果左指针向右移动一位,则变为由左 +1 指针和右指针组成的字符串,对于例题 2,只要左和右指针组成的字符串不满足判断条件,那左 +1 和右指针组成的字符串也一定满足判断条件,所以此时也没有必要右移左指针。综上所述,当右指针确定时,在位置 2 只需要检查至第一次不满足判断条件即可,不需要再左移和右移左指针,便可保证右指针搜索的完备性。对于例题 2,如果字符串中的数字有正有负,则不满足先验条件,不能使用以上框架解题。
def fun():

    left = 0     # 定义左指针
    right = 0    # 定义右指针
    length = 0   # 记录窗口长度
    # 可以定义数据结构记录窗口元素,例如set(), set().add(), set.remove()

    while right < len(s):
        length += 1           # 每移动一次右指针,窗口长度加一
        # 可以做一下数据操作,例如set().add(),数据操作也有可能会放到最后
        while check():        # 满足某个条件下移动左指针
            # 可以做一下数据操作,例如set().remove()
            left += 1         # 然后移动左指针
            length -= 1       # 每移动一次左指针,窗口长度减一
            ### 位置1:如果题目是满足check()条件的最短长度,则应该将处理逻辑放在这里 !!!前提是满足先验条件
        ### 位置2:如果题目是不满足check()条件的最长长度,则应该将处理逻辑放在这里 !!!前提是满足先验条件
        right += 1            # 移动右指针

    return 

二、例题

1. 最长无重复字符串

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

示例:

输入: s = "abcabcbb"

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

该问题可以使用动态规划的思想来解,具体可以看leetcode动态规划问题总结 Python,本题还可以使用变长滑动窗口的思路来解。定义两个指针 left 和 right,以及一个集合 lookup,lookup 表示以左指针 left 为开始以右指针 right 为结尾的最长无重复字符串集合,实时更新 left、right 和 lookup。向右移动右指针(隐藏条件是 s[right] 不存在于 lookup),直至 s[right] 存在于 lookup,然后固定右指针,右移左指针,直至 s[right] 不存在于 lookup。该双指针移动的结果是完备的,外层控制遍历右指针,内层有针对性的遍历部分左指针。当右指针固定,右移左指针是完备的,此时如果左移左指针则不会生成新的无重复字符串,所以没必要。但当右移左指针至 s[right] 不存在于 lookup 时,还继续右移左指针,会造成无重复字符串变短,并小于已找到的无重复字符串,所以也没有必要继续右移。思路确定好了以后,就可以通过滑动窗口的两个循环嵌套实现编程,外层循环控制右指针递增右移,不用设定判断条件(其实存在隐含判断条件,就是与内层判断条件相反),内层循环需要设定判断条件,内层循环还需要控制左指针的右移。

def lengthOfLongestSubstring(s):
    if not s: return 0
    cur_len = 0
    max_len = 0
    lookup = set()
    left = 0
    for i in range(len(s)):
        cur_len += 1
        while s[i] in lookup:
            lookup.remove(s[left])
            left += 1
            cur_len -= 1
        if cur_len > max_len: max_len = cur_len
        lookup.add(s[i])
    return max_len

2. 长度最小的子数组

给定一个含有 n 个正整数的数组和一个正整数 target 。找出该数组中满足其总和大于等于 target 的长度最小的连续子数组 [numsl, numsl+1, ..., numsr-1, numsr] ,并返回其长度。如果不存在符合条件的子数组,返回 0 。

示例:

输入:target = 7, nums = [2,3,1,2,4,3]

输出:2    解释:子数组 [4,3] 是该条件下的长度最小的子数组。

该问题属于变长滑动窗口问题,定义两个指针 start 和 end。在子串求和小于 target 时,end 向右移动,直至子串求和大于等于 target,然后固定 end,右移 start,直至子串求和小于 target,然后再次移动 start,循环往复。该种移动方式的解是完备的,只是缩小了解的搜索范围,所以时间复杂度更优。在右移 end 且子串求和小于 target 时,如果左移 start,虽然有可能得到求和大于 target 的子串,但是这些子串的长度一定大于已找到的子串,所以并不属于所求解的范围,亦或者根本无法左移 start(=0),此时如果右移 start,那所有子串的和都小于 target,所以也不属于求解的范围。在 end 固定时,如果左移 start,依然不属于求解范围,因为窗口长度会增加,超过了现有解的长度,只能右移 start,右移至子串求和小于 target 后就不能再右移了,因为继续右移不可能找到子串求和大于 target 的解。编程思路是两层循环嵌套,外层循环控制 end 的移动,隐藏条件是子串求和小于 target,内层循环控制 start 的移动,判断条件是子串求和大于等于 target。

def minSubArrayLen(s, nums):
    if not nums:
        return 0
    
    ans = n + 1
    start, end = 0, 0
    total = 0
    while end < len(nums):
        total += nums[end]
        while total >= s:
            ans = min(ans, end - start + 1)
            total -= nums[start]
            start += 1
        end += 1
    
    return 0 if ans == n + 1 else ans

3. 字符串的排列

给你两个字符串 s1 和 s2 ,写一个函数来判断 s2 是否包含 s1 的排列。如果是,返回 true ;否则,返回 false 。换句话说,s1 的排列之一是 s2 的子串 。

示例:

输入:s1 = "ab" s2 = "eidbaooo"

输出:true    解释:s2 包含 s1 的排列之一 ("ba").

该问题属于定长滑动窗口问题,以一个固定长度 len(s1) 进行滑动,所以该问题的核心问题在于,怎么判断一个长度为 len(s1) 的子集是否为 s1 的排列,这里采用的方式是通过字典统计每个字符出现的频率,然后比较两个字典是否相等,如果相等,则两个字符串互为排列。当窗口滑动时,只需要将新加入的字符添加到字典中,同步将剔除的字符从字典中剔除即可。

def checkInclusion(s1, s2):
    l1 = len(s1)
    l2 = len(s2)
    if l2 < l1:                  # 若字符串s2的长度小于s1,则返回false
        return False
    
    s = 'abcdefghijklmnopqrstuvwxyz'
    dict1 = {}
    dict2 = {}
    for char in s:
        dict1[char] = 0          # 初始化字典,key为字母,value为字母出现的次数,都初始化为0
        dict2[char] = 0
    for i in range(l1):
        dict1[s1[i]] += 1        # 首先计算前l1长度的不同字母出现次数
        dict2[s2[i]] += 1
    if dict1 == dict2:           # 若两个字典相等,则说明字符串的[0:l1]是字符串l1的不同排列
        return True

    for i in range(l2-l1):       # 开始往后查找,每次移动一个位置
        dict2[s2[i]] -= 1        # 减去滑动比较得前一个字母出现的次数
        dict2[s2[i+l1]] += 1     # 加上滑动后加进来的字母出现的次数
        if dict1 == dict2:       # 若相等,则返回true
            return True

    return False

4. 最小覆盖子串

给你一个字符串 s 、一个字符串 t 。返回 s 中涵盖 t 所有字符的最小子串。如果 s 中不存在涵盖 t 所有字符的子串,则返回空字符串 " " 。注意:对于 t 中重复字符,我们寻找的子字符串中该字符数量必须不少于 t 中该字符数量。如果 s 中存在这样的子串,我们保证它是唯一的答案。

示例:

输入:s = "ADOBECODEBANC", t = "ABC"

输出:"BANC"     解释:最小覆盖子串 "BANC" 包含来自字符串 t 的 'A'、'B' 和 'C'。

该问题采用滑动窗口的思路来解,定义左右两个指针,均起于 0 位置,右指针在滑动窗口不包含 t 字符串的情况下向右移动,当右指针使得滑动窗口包含 t 时,固定右指针,右移左指针,直至滑动窗口不包含 t 字符串,然后固定左指针,再次右移右指针,循环往复,直至右指针达到最右侧。该逻辑是完备的,当右指针右移时,起初滑动窗口并不包含 t 字符串,所以没必要检查每个路过的字符以及每个字符对应的所有可能字符串,此时如果左移左指针,会造成字符串长度增加,也不可能被使用,所以右指针右移时并没有错过满足条件的解。当滑动窗口包含 t 字符串时,左指针右移,也是完备的,左指针没必要左移,因为左移会增加子集的长度,而我们已经有更短的满足条件的解,所以左指针只能右移,当右移至滑动窗口不包含 t 字符串时,停止右移,因为继续右移只会得到一堆不满足条件的子集。所以实际右指针针对大部分字符仅仅只是检查了一次,针对剩余的小批量字符也只是检查了很少一部分对应字符串,整体算下来,左右指针的时间复杂度都是O(N)级别,如果是暴力法,那仅仅获取所有子集的时间复杂度就是O(N^2)。具体编程思路采用嵌套两个循环的方式进行,外循环对应右指针,内循环对应左指针,外循环直接无条件遍历,内循环基于判断条件遍历,其实外循环存在隐藏条件,条件为内循环判断条件的对立面。最后就剩余一个问题,如何判断一个字符串是否存在于另一个字符串中,这里采用的是字典统计法,通过字典统一字符串中每个字符的出现频率,通过比较两个字典进行判断。

def minWindow(s, t):

    def check(dict1, dict2):                    # 定义一个函数检查dict2是否为dict1的子集
        for key, value in dict2.items():
            if dict1[key] < value:
                return False
        return True

    if len(s) < len(t): return ""

    key = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJGKLMNOPQRSTUVWXYZ"  # 初始化两个字典,分别记录t和s前部分的字符分布
    dict1 = {}                                  # 如果使用collections.Counter()效率关于更高
    dict2 = {}
    for i in range(len(key)):
        dict1[key[i]] = 0
        dict2[key[i]] = 0
    for i in range(len(t)):
        dict1[s[i]] += 1                        # 得到s前len(t)的字符分布
        dict2[t[i]] += 1                        # 得到t的字符分布
    if check(dict1, dict2): return s[0:len(t)]  # 判断右指针为len(t)-1的起始情况
    
    min_len = len(s) + 1
    min_beign = 0
    min_end = 0
    left = 0                                    # 滑动窗口的左指针
    for right in range(len(t), len(s)):         # 滑动窗口的右指针,从len(t)开始,隐藏条件:在dict2不是dict1子集的情况下移动右指针
        dict1[s[right]] += 1                    # 每移动一个右指针必须马上修改其指纹dict1,对dict1的修改必须正在check函数之前
        while check(dict1, dict2):              # 条件:在dict2是dict1子集的情况下移动左指针

            if min_len > right - left + 1:      # 此时得到满足条件的子集
                min_len = right - left + 1
                min_beign = left
                min_end = right
                
            dict1[s[left]] -= 1
            left += 1

    if min_end == 0:
        return ""
    else:
        return s[min_beign:min_end+1]

5. 最大连续 1 的个数

给定一个二进制数组 nums 和一个整数 k,如果可以翻转最多 k 个 0 ,则返回 数组中连续 1 的最大个数 。

示例:

输入:nums = [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。

该问题是最长长度问题,所以很快想到滑动窗口的解决方案,但为了应用这个方案,则需要找一个反的判断条件才可以。题干中的条件是可以翻转最多 k 个 0,则其等效于寻找最多包含 k 个 0 的最长子数组,所以反的判断条件是子数组中 0 的个数大于 k,故滑动窗口内循环的判断条件就是 zeros > k,zeros 是子数组中 0 的个数。通过以上分析可以得到如下代码。该方案是滑动窗口右指针按顺序递增的方式检查部分可能的结果,虽然没有检查所有可能结果,但该检查方式是完备的,具体分析过程与上面的案例类似,不会遗漏结果。该方案的时间复杂度是 o(N)。

def longestOnes(nums, k):
    left = 0                                    # 左指针left
    zeros = 0
    max_len = 0
    length = 0
    for right in range(len(nums)):              # 滑动窗口外循环,移动右指针right
        length += 1
        if nums[right] == 0: zeros += 1
        while zeros > k:                        # 滑动窗口内循环,因为是最长问题,所以是反的判断条件
            left += 1
            length -= 1
            if nums[left-1] == 0: zeros -= 1
        if max_len < length: max_len = length   # 最长问题的处理逻辑
    return max_len

如果再增加一个要求,不仅要输出最长长度还要输出有多少种最长长度。具体实现如下,新增一个 log 来记录最长长度和出现的次数。需要注意,因为这里遍历的方式是滑动窗口右指针按顺序递增,所以外循环中每次得到的 length 是以右指针为结尾的最长子数组,这并不符合题目的要求,题目中没有对窗口右指针做限定,例如 [1,1,1,1],当右指针为 2 时得到的最长 length 是 3,但题目实际要求的答案是 4。所以在所有 length 中,只有最大的 length 是真的翻转部分 0 后的最长子数组。如下代码只能求得最长长度的子数组数目,无法求得第二长长度的子数组数目。

def longestOnes(nums, k):
    left = 0
    zeros = 0
    length = 0
    log = [0]*(len(nums)+1)
    for right in range(len(nums)):
        length += 1
        if nums[right] == 0: zeros += 1
        while zeros > k:
            left += 1
            length -= 1
            if nums[left-1] == 0: zeros -= 1
        log[length] += 1
    for i in range(len(nums), -1, -1):
        if log[i] != 0:
            return i, log[i]   # log[i-1]对应的并不是第二长子数组的数目

如果没有想到滑动窗口的思路,而是采用遍历法,代码如下所示,不仅代码编码麻烦,需要考虑的边界条件多,最重要的是时间复杂度还是 o(N^2),不满足要求。所以方向错了,再努力都是白费。

def longestOnes(nums, k):
    n = len(nums)
    log = [0]*(n+1)
    for i in range(n):
        ii = i                                                            # 审查一个起点 i
        zeros = 1 if nums[ii] == 0 else 0
        max_len = 1 if nums[ii] == 1 or (nums[ii] == 0 and k > 0) else 0
        while zeros <= k:
            ii += 1
            if ii >= n: break
            if nums[ii] == 0:
                zeros += 1
            if zeros > k: break
            max_len += 1
        log[max_len] += 1
    for i in range(n, -1, -1):
        if log[i] != 0:
            return i, log[i]

6. 滑动窗口最大值

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

示例:

输入:nums = [1,3,-1,-3,5,3,6,7], k = 3

输出:[3,3,5,5,6,7]

该问题最朴素的想法就是固定窗口长度,向右滑动,直接找每个窗口的最大值即可,但是这样编程的时间复杂度不满足要求。本题需要借助大根堆这个完全二叉树结构(在 python 中 heapq 包对应的是小根堆,所以需要加入负号变大根堆)实现最大值的寻找,具体操作为起初用最左侧的的 k 个数值搭建一颗大根堆的完全二叉树,然后向右滑动窗口,每滑动一次,向大根堆增加一个数值,但此时的问题是如何删除滑动窗口最左侧剔除的数值,或者如何保证大根堆的最大值是在滑动窗口的当前范围内呢?其实这里不需要删除滑动窗口最左侧的值,只要保证大根堆的最大值永远落在滑动窗口内即可,具体操作就是使用 while 循环,在大根堆的最大值的索引不在滑动窗口范围内的情况下,持续将大根堆的主根剔除,在 while 循环终止后,大根堆的最大值一定落在滑动窗口的当前范围内,此时大根堆的最大值就是当前滑动窗口的最大值。此题的关键是想清楚,当滑动窗口右移时,没必要把滑动窗口左侧剔除的数据从大根堆中剔除。

import heapq

def maxSlidingWindow(nums, k):
    n = len(nums)
    # 注意 Python 默认的优先队列是小根堆
    q = [(-nums[i], i) for i in range(k)]
    heapq.heapify(q)

    ans = [-q[0][0]]
    for i in range(k, n):
        heapq.heappush(q, (-nums[i], i))  # 把新的字符放入大根堆
        while q[0][1] <= i - k:           # 没必要把所有窗口外的数据踢走,只需要让找到的最大值在窗口范围内即可
            heapq.heappop(q)
        ans.append(-q[0][0])
    
    return ans

  • 24
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值