LeetCode 第 76 题:「最小覆盖子串」
字数 4519 2025-10-25 17:17:35

好的,我们这次来详细讲解 LeetCode 第 76 题:「最小覆盖子串」

这道题是滑动窗口类问题中非常经典且有一定难度的题目,非常适合用来深入理解滑动窗口算法的思想。


1. 题目描述

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

注意

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

示例 1

输入:s = "ADOBECODEBANC", t = "ABC"
输出:"BANC"
解释:最小覆盖子串 "BANC" 包含 'A', 'B', 'C' 各一个,并且它是满足条件的最短子串。

示例 2

输入:s = "a", t = "a"
输出:"a"

示例 3

输入:s = "a", t = "aa"
输出:""
解释t 中有两个 'a',但 s 中只有一个 'a',所以无法覆盖,返回空字符串。


2. 问题分析与核心思路

我们先来一步步拆解这个问题,理解其中的难点。

1. 目标是什么?
我们要在长字符串 s 中找到一个连续的子串,这个子串必须包含字符串 t 中的所有字符(包括重复的字符,并且数量要足够)。在所有满足条件的子串中,我们要找到长度最短的那个。

2. 关键难点是什么?

  • 字符数量要求:不仅仅是字符种类要覆盖,数量也要覆盖。例如 t = "AAB",那么子串中至少需要包含两个 'A' 和一个 'B'
  • 效率要求:如果使用暴力解法,枚举 s 的所有子串,再检查每个子串是否包含 t 的所有字符,时间复杂度会达到 O(n²) 甚至更高,对于较长的字符串会超时。

3. 核心思路:滑动窗口
滑动窗口算法是解决这类「连续子串」问题的利器。它的基本思想是:

  • 我们使用两个指针 leftright 来表示窗口的左右边界,初始时都在 s 的最左侧。
  • 我们不断扩大 right 指针,从而扩展窗口,直到窗口包含了 t 的所有字符。
  • 此时,我们开始收缩 left 指针,从而缩小窗口,以找到能满足条件的最小窗口。在收缩的过程中,我们不断记录当前的最小窗口长度和起始位置。
  • 当窗口不再满足条件时,我们再次扩大 right 指针,如此循环,直到 right 到达 s 的末尾。

4. 如何判断窗口是否包含了 t 的所有字符?
这是算法的核心。我们需要一个高效的数据结构来记录和比较字符需求。通常使用哈希表(在 Python 中可以用字典或 collections.Counter):

  • need:记录 t 中每个字符需要的数量。例如 t = "AAB",则 need['A'] = 2, need['B'] = 1
  • window:记录当前窗口中,属于 t 的字符的当前数量

但我们不能每次都遍历两个哈希表来比较,那样效率很低。我们引入一个关键变量:valid

  • valid:计数器,表示当前窗口中已经满足数量要求的字符种类数
    • window 中某个字符的数量 等于 need 中该字符的需求数量时,valid 就加 1。
    • valid 等于 need不同字符的种类数(即 len(need))时,就说明当前窗口已经完全覆盖了 t

3. 详细解题步骤

我们以 s = "ADOBECODEBANC", t = "ABC" 为例,一步步走一遍。

步骤 0:初始化

  • 创建 need 字典:{'A': 1, 'B': 1, 'C': 1}
  • 创建 window 字典:初始为空 {}
  • valid = 0 (因为还没有任何字符满足数量)
  • left = 0, right = 0
  • start = 0 (记录最小覆盖子串的起始索引)
  • min_len = float('inf') (记录最小长度,初始为一个极大值)

步骤 1:扩大窗口(移动 right)
循环条件:right < len(s)

  1. c = s[right] 是将要进入窗口的字符('A')。
  2. right 右移。
  3. 如果 ct 中需要的字符(即 cneed 中),则更新 window[c] 的数量。
  4. 检查 window[c] 是否已经等于 need[c]。如果相等,说明字符 c 的数量要求已满足,valid++

此时状态:
s:A D O B E C O D E B A N C
^
l, r (初始)

  • r 指向 'A' -> window = {'A': 1}window['A'] == need['A'] (1==1) -> valid = 1
  • r 右移,指向 'D' -> 'D' 不在 need 中,忽略。valid=1
  • r 指向 'O' -> 忽略。valid=1
  • r 指向 'B' -> window = {'A':1, 'B':1}window['B'] == need['B'] (1==1) -> valid = 2
  • r 指向 'E' -> 忽略。valid=2
  • r 指向 'C' -> window = {'A':1, 'B':1, 'C':1}window['C'] == need['C'] (1==1) -> valid = 3

关键点:此时 valid (3) == len(need) (3),说明窗口 [A, D, O, B, E, C] 即 "ADOBEC" 已经包含了 t 的所有字符。现在进入收缩阶段。

步骤 2:收缩窗口(移动 left)
循环条件:valid == len(need) (当窗口仍然满足条件时,继续收缩以寻找更小的窗口)

  1. 检查当前窗口 [left, right) 的长度是否小于 min_len。如果是,更新 min_lenstart

    • 当前窗口长度 = right - left = 6 - 0 = 6。
    • min_len 从无穷大更新为 6。
    • start 更新为 left,即 0。
  2. d = s[left] 是将要移出窗口的字符('A')。

  3. left 左移。

  4. 如果 dt 中需要的字符:

    • 需要检查:在移出 d 之前,window[d] 是否等于 need[d]?如果是,那么移出后就不满足了,所以 valid 要减 1。
    • 然后更新 window[d] 的数量(减 1)。

此时状态:

  • 当前 d = 'A'。移出前 window['A'](1) == need['A'](1),所以 valid 减 1,变为 2。
  • 然后 window['A'] 减 1,变为 0。
  • left 右移,指向 'D'

现在 valid (2) != len(need) (3),窗口 [D, O, B, E, C] 不再满足条件(缺少了 'A')。收缩停止,回到步骤 1,继续扩大 right

步骤 3:继续扩大窗口

  • r 指向 'O' -> 忽略。
  • r 指向 'D' -> 忽略。
  • r 指向 'E' -> 忽略。
  • r 指向 'B' -> window = {'A':0, 'B':2, 'C':1}window['B'](2) > need['B'](1)valid 不变(仍然是2,因为 'A' 不满足,'B''C' 满足)。
  • r 指向 'A' -> window = {'A':1, 'B':2, 'C':1}window['A'](1) == need['A'](1) -> valid = 3

关键点valid 再次等于 3,窗口 [D, O, B, E, C, O, D, E, B, A] 即 "DOBECODEBA" 又满足了条件。再次进入收缩阶段。

步骤 4:再次收缩窗口

  • 当前窗口长度 = 11 - 1 = 10。大于 min_len=6,不更新。
  • 移出 'D'(不在 need 中),left 右移。
  • 移出 'O'(不在 need 中),left 右移。
  • ... 持续移出,直到遇到一个 need 中的字符 'B'
  • 在移出 'B' 之前,检查 window['B'](2) > need['B'](1),移出后 window['B']=1,仍然满足,所以 valid 不变(还是3)。
  • 继续收缩,移出 'E''C''O''D''E'
  • 当移出下一个 'B' 时,检查:移出前 window['B'](1) == need['B'](1),移出后 window['B']=0,不满足了,所以 valid 减 1,变为 2。
  • 收缩停止。

步骤 5:最后一段

  • 继续扩大 r,指向 'N'(忽略),最后指向 'C'
  • window['C'] 变为 2,但 valid 没变(因为 'B' 不满足)。
  • r 到达末尾。循环结束。

最终

  • 我们记录的最小窗口是 start=9, min_len=4
  • 所以结果是 s[9:9+4],即 "BANC"。

4. 代码实现(Python)

import collections

class Solution:
    def minWindow(self, s: str, t: str) -> str:
        # 初始化 need 和 window 字典
        need = collections.Counter(t)
        window = collections.Counter()
        
        left, right = 0, 0
        valid = 0  # 满足数量要求的字符种类数
        
        # 记录最小覆盖子串的起始索引和长度
        start = 0
        min_len = float('inf')
        
        while right < len(s):
            # c 是将移入窗口的字符
            c = s[right]
            # 右移窗口
            right += 1
            
            # 进行窗口内数据的一系列更新
            if c in need:
                window[c] += 1
                if window[c] == need[c]:
                    valid += 1
            
            # 判断左侧窗口是否要收缩
            while valid == len(need):
                # 在这里更新最小覆盖子串
                if right - left < min_len:
                    start = left
                    min_len = right - left
                
                # d 是将移出窗口的字符
                d = s[left]
                # 左移窗口
                left += 1
                
                # 进行窗口内数据的一系列更新
                if d in need:
                    # 注意:这里要先判断,再减少 window[d]
                    if window[d] == need[d]:
                        valid -= 1
                    window[d] -= 1
        
        # 返回最小覆盖子串
        return "" if min_len == float('inf') else s[start:start+min_len]

# 测试
solution = Solution()
print(solution.minWindow("ADOBECODEBANC", "ABC")) # 输出 "BANC"
print(solution.minWindow("a", "a"))              # 输出 "a"
print(solution.minWindow("a", "aa"))             # 输出 ""

5. 复杂度分析

  • 时间复杂度:O(n)。虽然有嵌套的 while 循环,但字符串 s 中的每个字符最多只会被 leftright 指针各访问一次,所以时间复杂度是线性的。
  • 空间复杂度:O(k)。其中 k 是字符串 t 的字符集大小。我们使用了两个哈希表来存储字符计数,空间消耗取决于 t 中不同字符的数量。

6. 总结

「最小覆盖子串」是滑动窗口算法的巅峰应用之一。它巧妙地通过 valid 变量来高效判断窗口的满足条件,避免了每次检查整个 t 的复杂度。

核心要点回顾

  1. needwindow 两个哈希表分别记录需求和当前状态
  2. 引入 valid 计数器,只有当某个字符的数量恰好满足时才增加,当它变得不满足时才减少。
  3. 算法流程:先扩大,满足后收缩,不满足再扩大,循环往复。
  4. 在收缩过程中更新答案,因为收缩时窗口是满足条件的,并且我们是在寻找最小的满足条件的窗口。

希望这个从零开始的详细讲解能帮助你彻底理解这道题!

好的,我们这次来详细讲解 LeetCode 第 76 题:「最小覆盖子串」 。 这道题是滑动窗口类问题中非常经典且有一定难度的题目,非常适合用来深入理解滑动窗口算法的思想。 1. 题目描述 题目 :给你一个字符串 s 、一个字符串 t 。返回 s 中涵盖 t 所有字符的最小子串。如果 s 中不存在涵盖 t 所有字符的子串,则返回空字符串 "" 。 注意 : 对于 t 中重复的字符,在子串中该字符的数量必须不少于 t 中该字符的数量。 如果存在这样的子串,我们保证它是唯一的答案。 示例 1 : 输入 :s = "ADOBECODEBANC", t = "ABC" 输出 :"BANC" 解释 :最小覆盖子串 "BANC" 包含 'A', 'B', 'C' 各一个,并且它是满足条件的最短子串。 示例 2 : 输入 :s = "a", t = "a" 输出 :"a" 示例 3 : 输入 :s = "a", t = "aa" 输出 :"" 解释 : t 中有两个 'a',但 s 中只有一个 'a',所以无法覆盖,返回空字符串。 2. 问题分析与核心思路 我们先来一步步拆解这个问题,理解其中的难点。 1. 目标是什么? 我们要在长字符串 s 中找到一个 连续的子串 ,这个子串必须包含字符串 t 中的所有字符(包括重复的字符,并且数量要足够)。在所有满足条件的子串中,我们要找到 长度最短 的那个。 2. 关键难点是什么? 字符数量要求 :不仅仅是字符种类要覆盖,数量也要覆盖。例如 t = "AAB" ,那么子串中至少需要包含两个 'A' 和一个 'B' 。 效率要求 :如果使用暴力解法,枚举 s 的所有子串,再检查每个子串是否包含 t 的所有字符,时间复杂度会达到 O(n²) 甚至更高,对于较长的字符串会超时。 3. 核心思路:滑动窗口 滑动窗口算法是解决这类「连续子串」问题的利器。它的基本思想是: 我们使用两个指针 left 和 right 来表示窗口的左右边界,初始时都在 s 的最左侧。 我们 不断扩大 right 指针 ,从而 扩展窗口 ,直到窗口包含了 t 的所有字符。 此时,我们 开始收缩 left 指针 ,从而 缩小窗口 ,以找到能满足条件的最小窗口。在收缩的过程中,我们不断记录当前的最小窗口长度和起始位置。 当窗口不再满足条件时,我们再次扩大 right 指针,如此循环,直到 right 到达 s 的末尾。 4. 如何判断窗口是否包含了 t 的所有字符? 这是算法的核心。我们需要一个高效的数据结构来记录和比较字符需求。通常使用 哈希表 (在 Python 中可以用字典或 collections.Counter ): need :记录 t 中每个字符 需要的数量 。例如 t = "AAB" ,则 need['A'] = 2 , need['B'] = 1 。 window :记录当前窗口中,属于 t 的字符的 当前数量 。 但我们不能每次都遍历两个哈希表来比较,那样效率很低。我们引入一个关键变量: valid 。 valid :计数器,表示当前窗口中 已经满足数量要求的字符种类数 。 当 window 中某个字符的数量 等于 need 中该字符的需求数量时, valid 就加 1。 当 valid 等于 need 中 不同字符的种类数 (即 len(need) )时,就说明当前窗口已经完全覆盖了 t 。 3. 详细解题步骤 我们以 s = "ADOBECODEBANC" , t = "ABC" 为例,一步步走一遍。 步骤 0:初始化 创建 need 字典: {'A': 1, 'B': 1, 'C': 1} 创建 window 字典:初始为空 {} valid = 0 (因为还没有任何字符满足数量) left = 0 , right = 0 start = 0 (记录最小覆盖子串的起始索引) min_len = float('inf') (记录最小长度,初始为一个极大值) 步骤 1:扩大窗口(移动 right) 循环条件: right < len(s) c = s[right] 是将要进入窗口的字符( 'A' )。 right 右移。 如果 c 是 t 中需要的字符(即 c 在 need 中),则更新 window[c] 的数量。 检查 window[c] 是否已经 等于 need[c] 。如果相等,说明字符 c 的数量要求已满足, valid++ 。 此时状态: s :A D O B E C O D E B A N C ^ l, r (初始) r 指向 'A' -> window = {'A': 1} 。 window['A'] == need['A'] (1==1) -> valid = 1 。 r 右移,指向 'D' -> 'D' 不在 need 中,忽略。 valid=1 。 r 指向 'O' -> 忽略。 valid=1 。 r 指向 'B' -> window = {'A':1, 'B':1} 。 window['B'] == need['B'] (1==1) -> valid = 2 。 r 指向 'E' -> 忽略。 valid=2 。 r 指向 'C' -> window = {'A':1, 'B':1, 'C':1} 。 window['C'] == need['C'] (1==1) -> valid = 3 。 关键点 :此时 valid (3) == len(need) (3) ,说明窗口 [A, D, O, B, E, C] 即 "ADOBEC" 已经包含了 t 的所有字符。现在进入收缩阶段。 步骤 2:收缩窗口(移动 left) 循环条件: valid == len(need) (当窗口仍然满足条件时,继续收缩以寻找更小的窗口) 检查当前窗口 [left, right) 的长度是否小于 min_len 。如果是,更新 min_len 和 start 。 当前窗口长度 = right - left = 6 - 0 = 6。 min_len 从无穷大更新为 6。 start 更新为 left ,即 0。 d = s[left] 是将要移出窗口的字符( 'A' )。 left 左移。 如果 d 是 t 中需要的字符: 需要检查:在移出 d 之前, window[d] 是否 等于 need[d] ?如果是,那么移出后就不满足了,所以 valid 要减 1。 然后更新 window[d] 的数量(减 1)。 此时状态: 当前 d = 'A' 。移出前 window['A'](1) == need['A'](1) ,所以 valid 减 1,变为 2。 然后 window['A'] 减 1,变为 0。 left 右移,指向 'D' 。 现在 valid (2) != len(need) (3) ,窗口 [D, O, B, E, C] 不再满足条件(缺少了 'A' )。收缩停止,回到步骤 1,继续扩大 right 。 步骤 3:继续扩大窗口 r 指向 'O' -> 忽略。 r 指向 'D' -> 忽略。 r 指向 'E' -> 忽略。 r 指向 'B' -> window = {'A':0, 'B':2, 'C':1} 。 window['B'](2) > need['B'](1) , valid 不变(仍然是2,因为 'A' 不满足, 'B' 和 'C' 满足)。 r 指向 'A' -> window = {'A':1, 'B':2, 'C':1} 。 window['A'](1) == need['A'](1) -> valid = 3 。 关键点 : valid 再次等于 3,窗口 [D, O, B, E, C, O, D, E, B, A] 即 "DOBECODEBA" 又满足了条件。再次进入收缩阶段。 步骤 4:再次收缩窗口 当前窗口长度 = 11 - 1 = 10。大于 min_len=6 ,不更新。 移出 'D' (不在 need 中), left 右移。 移出 'O' (不在 need 中), left 右移。 ... 持续移出,直到遇到一个 need 中的字符 'B' 。 在移出 'B' 之前,检查 window['B'](2) > need['B'](1) ,移出后 window['B']=1 ,仍然满足,所以 valid 不变(还是3)。 继续收缩,移出 'E' , 'C' , 'O' , 'D' , 'E' 。 当移出下一个 'B' 时,检查:移出前 window['B'](1) == need['B'](1) ,移出后 window['B']=0 ,不满足了,所以 valid 减 1,变为 2。 收缩停止。 步骤 5:最后一段 继续扩大 r ,指向 'N' (忽略),最后指向 'C' 。 window['C'] 变为 2,但 valid 没变(因为 'B' 不满足)。 r 到达末尾。循环结束。 最终 : 我们记录的最小窗口是 start=9 , min_len=4 。 所以结果是 s[9:9+4] ,即 "BANC"。 4. 代码实现(Python) 5. 复杂度分析 时间复杂度 :O(n)。虽然有嵌套的 while 循环,但字符串 s 中的每个字符最多只会被 left 和 right 指针各访问一次,所以时间复杂度是线性的。 空间复杂度 :O(k)。其中 k 是字符串 t 的字符集大小。我们使用了两个哈希表来存储字符计数,空间消耗取决于 t 中不同字符的数量。 6. 总结 「最小覆盖子串」是滑动窗口算法的巅峰应用之一。它巧妙地通过 valid 变量来高效判断窗口的满足条件,避免了每次检查整个 t 的复杂度。 核心要点回顾 : 用 need 和 window 两个哈希表分别记录需求和当前状态 。 引入 valid 计数器,只有当某个字符的数量 恰好满足 时才增加,当它 变得不满足 时才减少。 算法流程: 先扩大,满足后收缩,不满足再扩大 ,循环往复。 在收缩过程中更新答案,因为收缩时窗口是满足条件的,并且我们是在寻找最小的满足条件的窗口。 希望这个从零开始的详细讲解能帮助你彻底理解这道题!