📖 第16课:最小覆盖子串

0 阅读23分钟

想系统提升编程能力、查看更完整的学习路线,欢迎访问 AI Compass:github.com/tingaicompa… 仓库持续更新刷题题解、Python 基础和 AI 实战内容,适合想高效进阶的你。

📖 第16课:最小覆盖子串

模块:滑动窗口 | 难度:Hard ⭐⭐⭐ LeetCode 链接leetcode.cn/problems/mi… 前置知识:第14课 - 无重复字符的最长子串、第15课 - 长度最小的子数组 预计学习时间:35分钟


🎯 题目描述

给你一个字符串 s 和一个字符串 t,请你在 s 中找出包含 t 所有字符最小子串。如果 s 中不存在这样的子串,则返回空字符串 ""

注意

  • t 中可能有重复字符,你的子串必须包含 t每个字符的相应数量
  • 答案保证唯一(如果存在)

示例 1:

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

示例 2:

输入:s = "a", t = "a"
输出:"a"
解释:整个字符串 s 是最小覆盖子串

示例 3:

输入:s = "a", t = "aa"
输出:""
解释:t 中两个 'a' 需要在 s 的子串中,但 s 只有一个 'a',无解

约束条件:

  • 1 <= s.length, t.length <= 10^5
  • st 由英文字母组成(大小写敏感)
  • t 中可能有重复字符

🧪 边界用例(面试必考)

用例类型输入期望输出考察点
最小输入s="a", t="a""a"s 和 t 都是单字符
s < ts="a", t="aa"""s 长度小于 t,无解
t 有重复s="aa", t="aa""aa"t 中字符重复,需要数量匹配
多个解s="cabwefgewcwaefgcf", t="cae""cwae"多个窗口满足,取最短
完全匹配s="abc", t="abc""abc"整个 s 就是答案
包含无关字符s="ADOBECODEBANC", t="ABC""BANC"经典用例,窗口中有冗余字符

💡 思路引导

生活化比喻

想象你在一个超长的货架(字符串 s)上找商品,你有一张购物清单(字符串 t),上面写着"需要 2 个苹果、1 个香蕉、1 个橙子"。你要找出货架上最短的一段,能凑齐购物清单上的所有商品。

🐌 笨办法:你从货架的每个位置开始,一个个向右数,看看从这个位置到哪里能凑齐购物清单。每个起点都要重新数一遍,累死人!这就是暴力法,时间复杂度 O(n²) 或 O(n³)。

🚀 聪明办法:你用一个可伸缩的购物车(滑动窗口)来扫货架:

  • 右边的手不断往右推车,把商品加入购物车(扩大窗口)
  • 左边的手在购物车里检查:购物清单上的东西都有了吗?
    • 如果还没凑齐,右手继续推车往右
    • 如果已经凑齐了,左手尝试从左边扔掉多余的商品(缩小窗口),看能不能用更短的货架段凑齐
  • 每次凑齐购物清单时,记录当前购物车的长度,最后返回最短的

这样你的购物车只需要从左到右扫一遍货架,每个商品最多被加入/移出各一次,效率极高!

关键洞察

"包含所有字符" + "最小子串" → 用滑动窗口 + 计数器跟踪字符匹配情况 → 右扩找到覆盖,左缩找到最小


🧠 解题思维链

这一节模拟你在面试中"从零开始思考"的过程。

Step 1:理解题目 → 锁定输入输出

  • 输入:字符串 s(被搜索的字符串),字符串 t(目标字符串)
  • 输出:s 中包含 t 所有字符的最小子串(如果无解返回 ""
  • 限制
    • 必须是子串(连续)
    • 必须包含 t 中每个字符的相应数量t="aa" 就需要 2 个 'a')
    • 大小写敏感

Step 2:先想笨办法(暴力法)

枚举 s 的所有子串,检查每个子串是否包含 t 的所有字符,找出满足条件的最短的。

  • 外层循环枚举起点 i
  • 内层循环枚举终点 j
  • 每次检查 s[i:j+1] 是否包含 t 的所有字符(需要比较字符计数)
  • 时间复杂度:O(n² × m) 或 O(n³)(n 是 s 的长度,m 是 t 的长度)
  • 瓶颈在哪:大量重复计算字符计数

Step 3:瓶颈分析 → 优化方向

暴力法的核心问题:

  • 每次都要重新统计子串的字符计数
  • 没有利用"子串是连续的"这个特性

优化思路:能不能维护一个动态窗口,通过增删字符来动态更新计数,而不是每次都重新统计?

Step 4:选择武器

  • 选用:滑动窗口 + 哈希表计数
  • 理由:
    • 窗口右扩(加字符)→ 更新计数器
    • 窗口左缩(减字符)→ 更新计数器
    • 用一个变量 matched 记录"已经匹配的字符种类数",当 matched == len(need) 时就是找到了覆盖
    • 时间复杂度降为 O(n + m)

🔑 模式识别提示:当题目出现"子串包含"+"所有字符"+"最短/最长",优先考虑"滑动窗口 + 计数器"


🔑 解法一:暴力枚举(直觉法)

思路

枚举 s 的所有子串 [i, j],检查每个子串是否包含 t 的所有字符(用 Counter 比较),找出满足条件的最短子串。

图解过程

示例:s = "ADOBECODEBANC", t = "ABC"

从 i=0 开始枚举所有终点 j:
  s[0:1] = "A"      → Counter: {A:1}        vs need: {A:1,B:1,C:1} ❌ 缺 B,C
  s[0:2] = "AD"     → Counter: {A:1,D:1}    vs need: {A:1,B:1,C:1} ❌ 缺 B,C
  s[0:3] = "ADO"    → Counter: {A:1,D:1,O:1} vs need: {A:1,B:1,C:1} ❌ 缺 B,C
  ...
  s[0:6] = "ADOBEC" → Counter: {A:1,D:1,O:1,B:1,E:1,C:1} ✅ 满足!长度=6i=1 开始枚举...
从 i=2 开始枚举...
...
从 i=9 开始枚举:
  s[9:12] = "BAN"   → Counter: {B:1,A:1,N:1} ❌ 缺 C
  s[9:13] = "BANC"  → Counter: {B:1,A:1,N:1,C:1} ✅ 满足!长度=4 ← 最短!

最小覆盖子串 = "BANC"

Python代码

from collections import Counter


def min_window_brute(s: str, t: str) -> str:
    """
    解法一:暴力枚举所有子串
    思路:双层循环枚举起点和终点,检查每个子串是否包含 t 的所有字符
    """
    if len(s) < len(t):
        return ""

    need = Counter(t)  # t 中每个字符的需求数量
    min_len = float('inf')
    result = ""

    for i in range(len(s)):
        for j in range(i, len(s)):
            substring = s[i:j+1]
            window = Counter(substring)

            # 检查 window 是否包含 need 的所有字符
            if all(window[char] >= need[char] for char in need):
                if j - i + 1 < min_len:
                    min_len = j - i + 1
                    result = substring
                break  # 从 i 开始的最短已找到,不需要继续扩展 j

    return result


# ✅ 测试
print(min_window_brute("ADOBECODEBANC", "ABC"))  # 期望输出:"BANC"
print(min_window_brute("a", "a"))                # 期望输出:"a"
print(min_window_brute("a", "aa"))               # 期望输出:""

复杂度分析

  • 时间复杂度:O(n² × m) — 两层循环 O(n²),每次 Counter 比较 O(m)
    • 如果 n=100000,约需 10^10 次操作,会超时
  • 空间复杂度:O(m) — Counter 需要存储 t 的字符计数

优缺点

  • ✅ 思路直观,容易理解
  • ✅ 代码简洁(利用 Counter)
  • ❌ 时间复杂度 O(n² × m),必然超时
  • ❌ 每次都要重新创建 Counter,浪费计算

⚡ 解法二:滑动窗口 + 计数器(最优解)

优化思路

用一个可伸缩的窗口 [left, right] 维护当前子串,同时用两个哈希表:

  • need:记录 t 中每个字符的需求数量
  • window:记录当前窗口中每个字符的数量

再用一个变量 matched 记录"已经匹配的字符种类数":

  • window[char] == need[char] 时,matched += 1
  • matched == len(need) 时,说明窗口包含了 t 的所有字符
  • 此时尝试收缩左边界,找最小窗口

💡 关键想法

  • 右指针扩展:不断加入新字符,更新 windowmatched
  • 左指针收缩:当 matched == len(need) 时(已覆盖),尝试移除左边字符,找最小窗口
  • 每个字符最多被 left 和 right 各访问一次,总时间 O(n)

图解过程

示例:s = "ADOBECODEBANC", t = "ABC"
need = {A:1, B:1, C:1}  (需要 3 种字符)

初始状态:left=0, right=0, window={}, matched=0, min_len=∞

Step 1: right=0, 加入 'A'
  [A]DOBECODEBANC
   ↑
  L,R
  window={A:1}, window['A']==need['A'] → matched=1 ✅
  matched < 3,继续右扩

Step 2-4: right=1~3, 加入 'D','O','B'
  [ADOB]ECODEBANC
   ↑  ↑
   L  R
  window={A:1,D:1,O:1,B:1}, window['B']==need['B'] → matched=2
  matched < 3,继续右扩

Step 5: right=5, 加入 'C'
  [ADOBEC]ODEBANC
   ↑    ↑
   L    R
  window={A:1,D:1,O:1,B:1,E:1,C:1}, window['C']==need['C'] → matched=3matched == 3,找到覆盖!长度=6,记录结果 "ADOBEC"

  → 尝试左缩:移除 'A'

Step 6: left=1, 移除 'A'
   A[DOBEC]ODEBANC
     ↑   ↑
     L   R
  window={D:1,O:1,B:1,E:1,C:1}, window['A']=0 < need['A'] → matched=2
  matched < 3,无法继续缩,右扩

Step 7-9: right=6~8, 加入 'O','D','E'
  (窗口一直不满足 matched==3)

Step 10: right=9, 加入 'B'
   A[DOBECODE B]ANC
     ↑       ↑
     L       R
  window={D:2,O:2,B:2,E:2,C:1}, window['B']=2 > need['B'] → matched 仍为 2
  (缺 'A')继续右扩

Step 11: right=10, 加入 'A'
   A[DOBE CODE BA]NC
     ↑         ↑
     L         R
  window={D:2,O:2,B:2,E:2,C:1,A:1}, window['A']==need['A'] → matched=3 ✅
  找到覆盖!长度=10 > 6,不更新

  → 尝试左缩:连续移除 'D','O','B','E','C','O','D','E'
     (这些字符要么不在 need 中,要么数量 > need,可以安全移除)

Step 12: left=9, 移除多余字符后
   ADOBECODE[BA]NC
             ↑ ↑
             L R
  window={B:1,A:1}, matched=2 < 3 (缺 'C')
  继续右扩

Step 13: right=11, 加入 'N'
  window={B:1,A:1,N:1}, matched 仍为 2,继续右扩

Step 14: right=12, 加入 'C'
   ADOBECODE[BANC]
             ↑   ↑
             L   R
  window={B:1,A:1,N:1,C:1}, window['C']==need['C'] → matched=3 ✅
  找到覆盖!长度=4 < 6,更新结果 "BANC" ← 最优解!

  → 尝试左缩:移除 'B'
     window['B']=0 < need['B']matched=2,停止

right 到达末尾,循环结束
最小覆盖子串 = "BANC"

Python代码

from collections import Counter


def min_window(s: str, t: str) -> str:
    """
    解法二:滑动窗口 + 计数器
    思路:右指针扩展窗口直到包含所有字符,左指针收缩窗口找最小长度
    """
    if len(s) < len(t):
        return ""

    # 1. 初始化 need(目标字符计数)和 window(窗口字符计数)
    need = Counter(t)
    window = {}
    matched = 0  # 已经匹配的字符种类数

    left = 0
    min_len = float('inf')
    start = 0  # 记录最小窗口的起始位置

    # 2. 右指针遍历 s
    for right in range(len(s)):
        char = s[right]

        # 将右边界字符加入窗口
        if char in need:
            window[char] = window.get(char, 0) + 1
            # 当前字符的数量刚好满足需求时,matched++
            if window[char] == need[char]:
                matched += 1

        # 3. 当窗口包含了所有字符时,尝试收缩左边界
        while matched == len(need):
            # 更新最小窗口
            if right - left + 1 < min_len:
                min_len = right - left + 1
                start = left

            # 移除左边界字符
            left_char = s[left]
            if left_char in need:
                # 移除前刚好满足需求,移除后就不满足了
                if window[left_char] == need[left_char]:
                    matched -= 1
                window[left_char] -= 1
            left += 1

    # 4. 返回结果
    return "" if min_len == float('inf') else s[start:start + min_len]


# ✅ 测试
print(min_window("ADOBECODEBANC", "ABC"))  # 期望输出:"BANC"
print(min_window("a", "a"))                # 期望输出:"a"
print(min_window("a", "aa"))               # 期望输出:""
print(min_window("ab", "b"))               # 期望输出:"b"

复杂度分析

  • 时间复杂度:O(n + m) — 右指针遍历 s 一次 O(n),左指针最多遍历一次 O(n),构建 need 需要 O(m)
    • 具体地说:如果 n=100000,只需要约 20 万次操作,比暴力法快 50000 倍!
  • 空间复杂度:O(m + k) — need 和 window 最多存储 O(m + k) 个字符(k 是字符集大小,英文字母最多 52)

🚀 解法三:滑动窗口优化版(代码更简洁)

优化思路

解法二的代码已经很优秀了,但还可以进一步简化:

  • 不需要单独的 matched 变量,可以直接检查 window 是否包含 need 的所有键值对
  • 使用 defaultdict 简化初始化

但这样会让代码可读性下降,且时间复杂度没有本质提升,所以解法二已经是最推荐的写法

Python代码(仅供参考)

from collections import Counter, defaultdict


def min_window_v3(s: str, t: str) -> str:
    """
    解法三:滑动窗口(代码简化版)
    思路:用辅助函数检查窗口是否覆盖目标
    """
    if len(s) < len(t):
        return ""

    need = Counter(t)
    window = defaultdict(int)
    left = 0
    min_len = float('inf')
    start = 0

    def is_covered():
        """检查当前窗口是否覆盖了 need"""
        return all(window[char] >= need[char] for char in need)

    for right in range(len(s)):
        window[s[right]] += 1

        while is_covered():
            if right - left + 1 < min_len:
                min_len = right - left + 1
                start = left
            window[s[left]] -= 1
            left += 1

    return "" if min_len == float('inf') else s[start:start + min_len]


# ✅ 测试
print(min_window_v3("ADOBECODEBANC", "ABC"))  # 期望输出:"BANC"

注意:这个写法虽然代码更短,但 is_covered() 每次都要遍历 need 的所有键,实际时间复杂度是 O(n × m)(m 是 t 中不同字符的数量)。解法二用 matched 变量将这个检查优化到 O(1),所以解法二更优


🐍 Pythonic 写法

利用 Python 的 collections.Counter 简化代码:

from collections import Counter


def min_window_pythonic(s: str, t: str) -> str:
    """
    Pythonic 写法:利用 Counter 的特性
    """
    if len(s) < len(t):
        return ""

    need = Counter(t)
    window = Counter()
    left = matched = 0
    min_len, start = float('inf'), 0

    for right, char in enumerate(s):
        if char in need:
            window[char] += 1
            if window[char] == need[char]:
                matched += 1

        while matched == len(need):
            if right - left + 1 < min_len:
                min_len, start = right - left + 1, left

            left_char = s[left]
            if left_char in need:
                if window[left_char] == need[left_char]:
                    matched -= 1
                window[left_char] -= 1
            left += 1

    return s[start:start + min_len] if min_len != float('inf') else ""


# ✅ 测试
print(min_window_pythonic("ADOBECODEBANC", "ABC"))  # 期望输出:"BANC"

这个写法用到了:

  • enumerate():同时获取下标和值
  • Counter():自动初始化计数为 0,不需要 get(char, 0)
  • 多重赋值min_len, start = ... 简化变量更新

⚠️ 面试建议:推荐使用解法二(带 matched 变量的版本),逻辑最清晰,性能最优。面试时先写清晰版本展示思路,再提一嘴"也可以用 Counter 简化初始化"来展示语言功底。


📊 解法对比

维度解法一:暴力枚举解法二:滑动窗口解法三:简化版
时间复杂度O(n² × m)O(n + m)O(n × m)
空间复杂度O(m)O(m + k)O(m + k)
代码难度简单中等中等
面试推荐⭐⭐⭐⭐⭐
适用场景小规模数据或说明思路面试首选,高效且清晰快速编码,但性能略差

面试建议:先用 30 秒口述暴力法思路和复杂度(展示你能想到基本解法),然后重点讲解滑动窗口 + matched 变量的优化(展示优化能力)。关键点在于说清楚"如何用 matched 变量 O(1) 判断窗口是否覆盖目标"。


🎤 面试现场

模拟面试中的完整对话流程,帮你练习"边想边说"。

面试官:给你两个字符串 s 和 t,找出 s 中包含 t 所有字符的最小子串。

:(审题 30 秒)好的,让我确认一下——输入是两个字符串,输出是 s 中的一个子串(连续),这个子串必须包含 t 中每个字符的相应数量,对吧?比如 t="aa",子串中也必须有至少 2 个 'a'?

面试官:没错。

:好的。我先想一个最直接的办法:枚举 s 的所有子串,用 Counter 检查每个子串是否包含 t 的所有字符,找出满足条件的最短的。时间 O(n² × m),空间 O(m)。不过这个显然会超时。

让我想想怎么优化……这道题的关键特征是:

  1. 要求"子串包含所有字符"
  2. 找"最短长度"
  3. 需要跟踪字符数量

这是一个经典的滑动窗口 + 计数器问题!

具体做法:

  • 用一个 need 哈希表记录 t 中每个字符的需求数量
  • 用一个 window 哈希表记录当前窗口中每个字符的数量
  • 用一个 matched 变量记录"已经匹配的字符种类数"
  • 右指针不断扩展窗口,当 matched == len(need) 时说明找到了覆盖
  • 此时尝试收缩左指针,找最小窗口

时间 O(n + m),空间 O(m)。

面试官:思路清晰,请写代码吧。

:好的。(边写边说)

def minWindow(self, s, t):
    from collections import Counter

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

    need = Counter(t)        # 目标字符计数
    window = {}              # 窗口字符计数
    matched = 0              # 已匹配的字符种类数

    left = 0
    min_len = float('inf')
    start = 0

    for right in range(len(s)):
        char = s[right]

        # 右边界字符加入窗口
        if char in need:
            window[char] = window.get(char, 0) + 1
            # 刚好满足需求时,matched++
            if window[char] == need[char]:
                matched += 1

        # 当窗口覆盖目标时,尝试收缩左边界
        while matched == len(need):
            # 更新最小窗口
            if right - left + 1 < min_len:
                min_len = right - left + 1
                start = left

            # 移除左边界字符
            left_char = s[left]
            if left_char in need:
                if window[left_char] == need[left_char]:
                    matched -= 1
                window[left_char] -= 1
            left += 1

    return "" if min_len == float('inf') else s[start:start + min_len]

关键点是这个 matched 变量——它记录"有多少种字符的数量已经满足需求"。当 matched == len(need) 时,说明所有种类的字符都满足了,窗口就是一个合法覆盖。这样我们可以 O(1) 判断窗口是否覆盖,而不需要每次都遍历 need 检查。

面试官:能手动跑一个例子验证一下吗?

:好的,用 s="ADOBECODEBANC", t="ABC"

  • need = {A:1, B:1, C:1},需要 3 种字符
  • right=0~5: 窗口 "ADOBEC",window={A:1,D:1,O:1,B:1,E:1,C:1},matched=3 ✅
    • 找到覆盖!长度=6,记录 "ADOBEC"
  • 收缩 left=1: 移除 'A',matched=2,停止收缩
  • right=6~10: 窗口继续右扩,直到 right=10 时加入 'A',matched=3 ✅
  • 连续收缩 left 到 left=9: 窗口 "BA",matched=2 (缺 'C')
  • right=12: 加入 'C',窗口 "BANC",matched=3 ✅,长度=4 < 6
    • 更新最小窗口为 "BANC"
  • 尝试收缩 left=10: 移除 'B',matched=2,停止

最终返回 "BANC" ✅

面试官:很好。如果 t 中有重复字符呢?

:代码已经处理了。need 用 Counter 统计 t 中每个字符的数量,window 也是同样的逻辑。比如 t="AAB",need={A:2, B:1},窗口必须有至少 2 个 'A' 和 1 个 'B' 才算覆盖。matched 只有在 window[char] == need[char] 时才会增加,所以重复字符也能正确处理。

高频追问

追问应答策略
"还有更优解吗?"时间已经是 O(n + m) 最优(至少要遍历一遍 s 和 t),空间也是 O(m) 最优。这就是最优解了
"如果要找所有满足条件的子串呢?"min_lenstart 的更新逻辑改为收集所有长度等于最小长度的子串,用一个列表存储
"如果 t 非常大呢?"need 的大小取决于 t 中不同字符的数量,最多 O(m)。如果 m 很大,空间复杂度会增加,但算法思路不变
"能否用固定长度窗口?"不行。因为我们不知道最小窗口的长度是多少,必须用可变长度窗口动态查找
"实际工程中什么场景会用到?"DNA 序列分析(找包含所有特定碱基的最短片段)、日志分析(找包含所有关键词的最短日志段)、文本搜索(找包含所有查询词的最短文本片段)

🎓 知识点总结

Python技巧卡片 🐍

# Counter — 快速统计字符/元素频次
from collections import Counter
t = "AABC"
need = Counter(t)  # Counter({'A': 2, 'B': 1, 'C': 1})

# Counter 的常用操作
need['A']           # 2(如果键不存在返回 0)
'A' in need         # True(判断键是否存在)
len(need)           # 3(不同字符的种类数)

# defaultdict — 自动初始化默认值
from collections import defaultdict
window = defaultdict(int)  # 默认值为 0
window['A'] += 1           # 不需要判断键是否存在

# all() — 检查所有元素是否满足条件
need = {'A': 2, 'B': 1}
window = {'A': 3, 'B': 1, 'C': 1}
all(window[char] >= need[char] for char in need)  # True

# enumerate() — 同时拿到下标和值
for i, char in enumerate("ABC"):
    print(i, char)  # 0 A / 1 B / 2 C

💡 底层原理(选读)

为什么用 matched 变量能优化性能?

如果不用 matched,每次判断窗口是否覆盖目标,需要遍历 need 的所有键:

def is_covered():
    return all(window[char] >= need[char] for char in need)

这个操作的时间复杂度是 O(m)(m 是 t 中不同字符的数量)。

而用 matched 变量后:

  • 当某个字符的数量从不满足变为满足时,matched += 1
  • 当某个字符的数量从满足变为不满足时,matched -= 1
  • 判断窗口是否覆盖只需要检查 matched == len(need),O(1)

总时间复杂度从 O(n × m) 降为 O(n + m)。

为什么是 window[char] == need[char] 而不是 >=

matched 记录的是"有多少字符已经满足需求",而不是"有多少字符"。

  • window['A'] 从 0 增加到 1,再增加到 2 时:
    • 如果 need['A'] = 2,只有在 window['A'] == 2 时才 matched += 1
    • 如果后续 window['A'] 增加到 3、4,matched 不变(因为 'A' 这种字符已经满足了)
  • window['A'] 从 3 减少到 2、1、0 时:
    • 只有在 window['A'] 从 2 减少到 1 时(即从满足变为不满足),才 matched -= 1

滑动窗口的两种模式

  1. 求最长(如"无重复字符的最长子串"):
    • while 循环条件:不满足时收缩
    • 更新答案:在 for 循环中(窗口满足条件时)
  2. 求最短(如本题):
    • while 循环条件:满足时收缩
    • 更新答案:在 while 循环中(每次收缩时)

算法模式卡片 📐

  • 模式名称:滑动窗口 + 计数器(最小覆盖子串模板)
  • 适用条件
    1. 子串/子数组包含问题
    2. 需要匹配字符/元素的数量
    3. 求最长/最短/计数
  • 识别关键词:"包含所有字符"+"最小/最大子串"+"字符频次匹配"
  • 模板代码
from collections import Counter


def sliding_window_cover(s: str, t: str) -> str:
    """
    滑动窗口 + 计数器模板(最小覆盖子串)
    适用:找包含所有目标字符的最小子串
    """
    if len(s) < len(t):
        return ""

    # 1. 初始化 need 和 window
    need = Counter(t)
    window = {}
    matched = 0  # 已匹配的字符种类数

    left = 0
    min_len = float('inf')
    start = 0

    # 2. 右指针遍历
    for right in range(len(s)):
        char = s[right]

        # 右边界字符加入窗口
        if char in need:
            window[char] = window.get(char, 0) + 1
            if window[char] == need[char]:
                matched += 1

        # 3. 当窗口满足条件时,尝试收缩左边界
        while matched == len(need):
            # 更新答案(求最小)
            if right - left + 1 < min_len:
                min_len = right - left + 1
                start = left

            # 移除左边界字符
            left_char = s[left]
            if left_char in need:
                if window[left_char] == need[left_char]:
                    matched -= 1
                window[left_char] -= 1
            left += 1

    # 4. 返回结果
    return "" if min_len == float('inf') else s[start:start + min_len]

变体:求最长窗口、求所有满足条件的窗口等,核心逻辑不变,只需调整更新答案的位置。

易错点 ⚠️

  1. matched 更新时机错误:应该在 window[char] == need[char] 时更新,而不是 >=。很多人写成 if window[char] >= need[char]: matched += 1,导致重复计数。解决:理解 matched 是"字符种类数",不是"字符个数"。

  2. 收缩窗口时忘记更新 matched:移除左边界字符时,如果 window[left_char] 从满足变为不满足,必须 matched -= 1。有的人只减少 window[left_char],忘记更新 matched,导致窗口判断错误。

  3. 无解时返回值错误:如果没有满足条件的窗口,应该返回空字符串 "",不是返回 sNone。记得在最后检查 min_len == float('inf')

  4. 字符不在 need 中时的处理:窗口中可能包含不在 need 中的字符(如示例中的 'D','O','E'),这些字符不需要加入 window,也不影响 matched。很多人所有字符都加入 window,导致空间浪费和逻辑复杂。

  5. 边界情况遗漏s 的长度小于 t 时直接返回 "",不要尝试滑动窗口。


🏗️ 工程实战(选读)

这个算法思想在真实项目中的应用,让你知道"学了有什么用"。

  • DNA 序列分析:生物信息学中,需要在一条 DNA 序列(如 "ATCGATCGAA...")中找到包含所有特定碱基(如 "ATCG" 各至少 1 个)的最短片段,用于基因定位和分析。

  • 日志分析 - 关键词搜索:在服务器日志中搜索包含所有关键词(如 "ERROR", "timeout", "user:12345")的最短日志段,用于快速定位问题根源。

  • 文本搜索引擎 - 摘要生成:搜索引擎在返回搜索结果时,会在文档中找到包含所有查询词的最短文本片段作为摘要(snippet)显示给用户,让用户快速了解相关性。

  • 视频字幕匹配:在视频字幕中找到包含所有关键词的最短时间段,用于视频内容索引和跳转。


🏋️ 举一反三

完成本课后,试试这些同类题目来巩固知识:

题目难度相关知识点提示
LeetCode 3. 无重复字符的最长子串Medium滑动窗口 + set求最长,while 条件是"有重复时收缩"
LeetCode 438. 找到字符串中所有字母异位词Medium固定长度窗口 + Counter窗口长度固定为 len(p),比较计数
LeetCode 567. 字符串的排列Medium固定长度窗口 + Counter和 438 几乎一样,返回 True/False
LeetCode 30. 串联所有单词的子串Hard滑动窗口 + 单词计数本题的进阶版,单位是单词而非字符
LeetCode 159. 至多包含两个不同字符的最长子串Medium滑动窗口 + 计数窗口内最多 2 种字符,求最长
LeetCode 340. 至多包含 K 个不同字符的最长子串Medium滑动窗口 + 计数159 的通用版,窗口内最多 K 种字符

📝 课后小测

试试这道变体题,不要看答案,自己先想 5 分钟!

题目:给定字符串 st,找出 s 中包含 t 所有字符(不考虑顺序和数量)的最长子串

示例:s = "ADOBECODEBANC", t = "ABC""ADOBECODE"(包含 A、B、C,长度最长)

💡 提示 1(实在想不出来再点开)

这道题和原题的区别:

  • 原题:包含 t 每个字符的相应数量,求最短
  • 变体:包含 t 所有字符种类(数量可以更多),求最长

滑动窗口的收缩时机会反过来!

💡 提示 2(再给你一个线索)
  • 原题:matched == len(need)收缩窗口(求最短)
  • 变体:matched == len(need)扩展窗口(求最长),当 matched < len(need)收缩
✅ 参考答案
from collections import Counter


def max_window_cover(s: str, t: str) -> str:
    """
    变体题:包含所有字符种类的最长子串
    思路:窗口满足条件时继续右扩,不满足时左缩
    """
    if len(s) < len(t):
        return ""

    need = set(t)  # 只需要字符种类,不需要数量
    window = set()

    left = 0
    max_len = 0
    start = 0

    for right in range(len(s)):
        window.add(s[right])

        # 当窗口包含所有字符种类时,更新最大长度
        while window >= need:  # window 是 need 的超集
            if right - left + 1 > max_len:
                max_len = right - left + 1
                start = left

            # 尝试收缩左边界
            left_char = s[left]
            left += 1

            # 如果移除后窗口不再包含所有字符,需要重建 window
            # (简化版:直接重建当前窗口)
            window = set(s[left:right+1])

    return s[start:start + max_len] if max_len > 0 else ""


# 测试
print(max_window_cover("ADOBECODEBANC", "ABC"))  # 期望:"ADOBECODE"(长度9)

核心思路

  • 原题是"满足条件时收缩找最短",变体是"满足条件时扩展找最长"
  • 因为只需要字符种类,不需要数量,所以用 set 而不是 Counter
  • 滑动窗口的核心逻辑是一样的,只是收缩/扩展的时机相反

启示:滑动窗口的模板是通用的,关键在于理解"什么时候收缩、什么时候更新答案"。


如果这篇内容对你有帮助,推荐收藏 AI Compass:github.com/tingaicompa… 更多系统化题解、编程基础和 AI 学习资料都在这里,后续复习和拓展会更省时间。