滑动窗口:基本思路与四道 LeetCode 经典题

1. 滑动窗口的基本思路

滑动窗口适合处理一部分连续子串或连续子数组的问题,尤其是需要寻找满足特定条件的区间时。

最直接的方法是枚举所有可能的子串。对于长度为 n 的字符串,仅起点和终点的组合就有 O(n2)O(n^2) 个,如果还要逐个检查子串内部的元素,时间开销就会更大。

不过,仔细想想,相邻的两个子串可能有大量重复的元素。如果每次都重新遍历整个区间,就会产生许多不必要的计算。

滑动窗口的思路就是:通过移动左右指针来调整区间,并在移动过程中维护窗口内的数据,避免反复检查整个子串。

一般用 left 和 right 两个指针表示窗口边界:

  • right 向右移动,将新元素加入窗口。
  • left 向右移动,将左侧元素移出窗口。
  • 每次移动时,同步更新窗口中的数据,例如字符出现次数、元素之和等。

这里采用左闭右开的区间 [left, right),因此当前窗口的长度就是:

right - left

虽然滑动窗口的代码经常会出现嵌套的 while 循环,但不一定意味着时间复杂度是 O(n2)O(n^2)。

因为左右指针都只向前移动,不会回头,每个指针最多移动 n 次。如果每次更新窗口数据的开销都是 O(1)O(1),那么整个窗口维护过程的时间复杂度就可以达到 O(n)O(n)。

当然,并不是所有子串问题都适合滑动窗口。还需要根据题目条件判断,能否通过单向移动指针来维护所需的窗口状态。

1.1 通用代码框架

下面是我整理的滑动窗口伪代码框架。具体使用什么数据结构、什么时候收缩窗口,都需要根据实际题目进行调整。

Python
# 滑动窗口算法伪码框架
def slidingWindow(s: str):
    # 用合适的数据结构记录窗口中的数据,根据具体场景变通
    # 比如说,我想记录窗口中元素出现的次数,就用 map
    # 如果我想记录窗口中的元素和,就可以只用一个 int
    window = ...

    left, right = 0, 0
    while right < len(s):
        # c 是将移入窗口的字符
        c = s[right]
        window.add(c)
        # 增大窗口
        right += 1
        # 进行窗口内数据的一系列更新
        ...

        # *** debug 输出的位置 ***
        # 注意在最终的解法代码中不要 print
        # 因为 IO 操作很耗时,可能导致超时
        # print(f"window: [{left}, {right})")
        # ***********************

        # 判断左侧窗口是否要收缩
        while left < right and window needs shrink:
            # d 是将移出窗口的字符
            d = s[left]
            window.remove(d)
            # 缩小窗口
            left += 1
            # 进行窗口内数据的一系列更新
            ...

这里的 window.add()、window.remove() 和 window needs shrink 都只是伪代码,表示加入元素、移除元素以及判断窗口是否需要收缩,并不是可以直接运行的 Python 语句。

实际写题时,我会先考虑三个问题:

  1. 什么时候扩大窗口? right 移动后,新加入的字符会对窗口中的数据产生什么影响?
  2. 什么时候缩小窗口? 需要满足什么条件才能移动 left?移除字符后又该更新哪些数据?
  3. 什么时候更新答案? 是在窗口满足条件时更新,还是在收缩完成后更新?

把这三个问题想清楚,滑动窗口的整体逻辑通常就比较明确了。

接下来通过几道题,看看不同情况下应该如何维护窗口。


2. LeetCode 76. 最小覆盖子串

这道题要求在字符串 s 中,找到一个长度最短的子串,使它包含字符串 t 中的所有字符,包括重复出现的字符。

例如:

纯文本
s = "ADOBECODEBANC"
t = "ABC"

答案:"BANC"

我们可以使用两个字典来维护字符出现的次数:

  • need:记录目标字符串 t 中每种字符需要出现多少次。
  • window:记录当前窗口中相关字符已经出现了多少次。

此外,还需要一个变量 vaild,用来统计有多少种字符的出现次数已经达到要求。

这里统计的是满足要求的字符种类数,而不是满足要求的字符总数。

例如,假设 t = "AABC",那么 need 中有三种字符,只有当 A、B、C 的出现次数都达到各自要求时,vaild 才会等于 len(need)。

2.1 窗口如何移动?

先不断移动 right,扩大窗口。

每加入一个字符,就更新 window 中对应的计数。如果某个字符的出现次数刚好达到 need 中要求的次数,就让 vaild 加一。

当:

Python
vaild == len(need)

说明当前窗口已经包含了目标字符串需要的全部字符。

但我们要求的是最短覆盖子串,因此仅仅找到一个满足条件的窗口还不够。

此时应该尝试移动 left,逐渐缩小窗口。

每次收缩前,先判断当前窗口是否比之前记录的答案更短,如果更短,就更新结果。

随后移除左侧字符,并更新相关计数。当某个字符的数量从满足要求变成不足时,vaild 就需要减一。

这样不断重复“扩大窗口 → 满足条件 → 尝试收缩”的过程,就能逐步找到最短的覆盖子串。

2.2 Python 代码

Python
class Solution:
    def minWindow(self, s: str, t: str) -> str:
        window = dict()
        need = dict()
# 把目标串存到目标窗口
        for i in t:
            need[i] = need.get(i, 0) + 1
        vaild = 0
        left = right = 0
        length = float("inf")
        res = ""
        while right < len(s):
            c = s[right]
            right += 1
# 如果字符在need窗口里面就让计数器+1
            if c in need:
                window[c] = window.get(c, 0) + 1
# 如果这个字符满足条件了,就让vaild+1
                if window[c] == need[c]:
                    vaild += 1
# 当vaild和目标窗口长度相同,就代表所有元素都满足条件了,尝试缩小窗口
            while vaild == len(need):
# 此时的子串就是一个符合条件的子串,尝试更新答案
                if length > right - left:
                    res = s[left:right]
                    length = right - left
                c = s[left]
                left += 1
# 缩小窗口,更新相关的数据。
                if c in need:
                    if window[c] == need[c]:
                        vaild -= 1
                    window[c] -= 1
        return res

这道题最需要注意的是更新答案的时机。

当窗口已经满足所有字符要求时,我们并不是立刻停止,而是继续收缩左边界,直到窗口不再满足条件。

每次收缩之前,都有机会得到一个更短的答案。

因此,这道题使用的是可变长度的滑动窗口,内层通过 while 不断缩小窗口。


3. LeetCode 567. 字符串的排列

这道题需要判断:字符串 s2 中,是否存在某个子串,恰好是字符串 s1 的一种排列。

例如:

纯文本
s1 = "ab"
s2 = "eidbaooo"

结果:True

因为 s2 中存在子串 "ba",它恰好是 "ab" 的一种排列。

这道题和上一题有些相似,同样可以使用 need 和 window 两个字典统计字符次数。

不过,这里不需要寻找最短子串,而是判断是否存在一个长度固定的子串。

既然要求找到 s1 的某种排列,那么目标子串的长度必然等于 len(s1)。

因此,我们只需要维护一个长度为 len(s1) 的窗口。

3.1 定长窗口的处理

首先移动 right,不断将字符加入窗口。

每次加入字符后,更新 window 和 vaild。

当窗口长度达到 len(s1) 时,就检查当前窗口中的字符数量是否全部满足要求。

如果:

Python
vaild == len(need)

说明找到了符合条件的排列,直接返回 True。

如果不满足,就移除窗口最左侧的字符,再继续向右移动。

这里我使用的是 if,而不是上一题的 while。

原因很简单:窗口每次只加入一个字符,长度最多只会达到目标长度,检查后移出一个字符即可。

因此,不需要像最小覆盖子串那样,反复移动左指针来寻找最短窗口。

3.2 Python 代码

Python
class Solution:
    def checkInclusion(self, s1: str, s2: str) -> bool:
        need = dict()
        window = dict()
        for i in s1:
            need[i] = need.get(i, 0) + 1
        vaild = left = right = 0
        while right < len(s2):
            c = s2[right]
            right += 1
            if c in need:
                window[c] = window.get(c, 0) + 1
                if window[c] == need[c]:
                    vaild += 1
            if right - left >= len(s1):
                if vaild == len(need):
                    return True
                c = s2[left]
                left += 1
                if c in need:
                    if window[c] == need[c]:
                        vaild -= 1
                    window[c] -= 1
        return False

这里的窗口在达到目标长度后,先判断是否满足条件,再移出最左侧字符。

由于每轮循环只加入一个字符,移出一个字符后,窗口就会恢复到比目标长度小一的位置,方便下一轮继续检查。

如果遍历完整个 s2 都没有找到满足要求的窗口,就返回 False。


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

这道题的思路与第 567 题基本相同。

我们需要在字符串 s 中,找到所有与 p 互为字母异位词的子串,并返回它们的起始索引。

例如:

纯文本
s = "cbaebabacd"
p = "abc"

答案:[0, 6]

因为 "cba" 和 "bac" 都是 "abc" 的字母异位词,分别从索引 0 和 6 开始。

由于字母异位词的长度一定相同,所以仍然可以使用长度为 len(p) 的定长滑动窗口。

与上一题的区别主要在于答案的处理方式。

第 567 题只需要判断是否存在符合条件的子串,找到后就能直接返回 True。

但这道题要求返回所有满足条件的位置,因此不能提前结束,而是要继续向右滑动窗口。

每当窗口中的字符数量全部满足要求时,就将当前窗口的左端点 left 加入结果列表。

4.1 Python 代码

Python
class Solution:
    def findAnagrams(self, s: str, p: str) -> List[int]:
        need = dict()
        windows = dict()
        vaild = left = right = 0
        res = []
        for i in p:
            need[i] = need.get(i, 0) + 1
        while right < len(s):
            c = s[right]
            right += 1
            if c in need:
                windows[c] = windows.get(c, 0) + 1
                if windows[c] == need[c]:
                    vaild += 1
            if right - left >= len(p):
                if vaild == len(need):
                    res.append(left)
                c = s[left]
                left += 1
                if c in need:
                    if windows[c] == need[c]:
                        vaild -= 1
                    windows[c] -= 1
        return res

可以发现,这份代码与第 567 题非常接近。

窗口的扩大、缩小以及字符计数方式都没有变化,只是将找到答案时的:

Python
return True

改成了:

Python
res.append(left)

最后再返回整个结果列表。

这两道题放在一起理解比较方便,因为它们本质上使用的是同一种定长窗口,只是对答案的要求不同。


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

最后这道题要求找到字符串中不包含重复字符的最长连续子串,并返回它的长度。

例如:

纯文本
s = "abcabcbb"

答案:3

因为 "abc" 是一个不含重复字符的子串,长度为 3,并且不存在更长的合法子串。

与前面的题目相比,这次不需要 need 字典,因为没有指定必须包含哪些字符。

我们只需要维护一个 window,记录当前窗口中每个字符出现的次数。

5.1 遇到重复字符时收缩窗口

首先移动右指针,将新字符加入窗口,并更新它的出现次数。

如果加入之后,当前字符出现了两次或更多次,就说明窗口中产生了重复字符。

此时需要移动 left,不断移除窗口左侧的字符,直到重复被消除。

例如:

纯文本
s = "abca"

当右指针移动到最后一个 a 时,窗口中包含:

纯文本
a b c a

由于 a 重复出现,需要将左侧第一个 a 移出窗口。

窗口就变成了:

纯文本
b c a

此时不再包含重复字符,可以继续向右移动。

因为每次加入新字符之前,窗口都已经没有重复字符,所以只需要处理这次新加入的字符是否重复。

等内层 while 循环结束后,当前窗口一定是一个不含重复字符的合法子串。

此时就可以用窗口长度更新答案:

Python
if right - left > length:
    length = right - left

5.2 Python 代码

Python
class Solution:
    def lengthOfLongestSubstring(self, s: str) -> int:
        left = right = 0
        window = dict()
        length = 0
        while right < len(s):
            c = s[right]
            right += 1
            window[c] = window.get(c, 0) + 1
            while window[c] >= 2:
                new_c = s[left]
                left += 1
                window[new_c] -= 1
            if right - left > length:
                length = right - left
        return length

这道题和第 76 题一样,都需要通过 while 动态收缩窗口。

不过,两道题的收缩条件不同:

  • 最小覆盖子串:窗口已经满足要求时,尝试缩小窗口,寻找更短的答案。
  • 无重复字符的最长子串:窗口出现重复字符时,必须缩小窗口,直到重新满足要求。

因此,两道题虽然都使用可变长度的滑动窗口,但更新答案的时机恰好不同。

第 76 题是在窗口满足覆盖条件时更新最短答案,而第 3 题是在消除重复字符之后,更新最长答案。


6. 总结

通过这四道题,可以发现滑动窗口虽然有相对固定的代码框架,但具体什么时候收缩窗口、什么时候更新答案,仍然需要根据题目调整。

题目窗口类型收缩条件更新答案的时机
76. 最小覆盖子串可变窗口满足覆盖条件时尝试收缩收缩前记录更短的窗口
567. 字符串的排列定长窗口达到目标长度时移出一个字符检查到符合条件的排列
438. 找到所有字母异位词定长窗口达到目标长度时移出一个字符记录所有合法窗口的起点
3. 无重复字符的最长子串可变窗口出现重复字符时收缩消除重复后记录最长长度

对于这几道题,在哈希表操作平均为常数时间的前提下,左右指针都只会向前移动,因此窗口维护本身具有线性的时间复杂度。

不过,实际代码中的其他操作也需要考虑。例如第 76 题使用字符串切片保存答案,切片会产生额外的复制开销。

我觉得写滑动窗口时,最重要的不是记住某一份固定模板,而是先想清楚:

窗口中需要维护什么数据?什么时候扩大或缩小窗口?什么时候更新答案?

只要明确这三个问题,再结合题目要求调整代码,就能更容易地理解不同滑动窗口题目的解法。

谢谢你读到这里。 ✧

聊两句

评论审核后显示

有疑问、补充,或者想交流一下,都可以写在这里。

评论区将在滚动到这里时加载。