LeetCode 76. 最小覆盖子串(详细技术解析)

本文针对 LeetCode 76. 最小覆盖子串 问题,提供完整的解题思路、多解法代码实现及深度解析,覆盖暴力解法、滑动窗口(双指针)最优解法,重点突破"O(m + n) 时间复杂度"的进阶要求,帮助开发者理解问题本质、掌握滑动窗口的灵活应用,规避常见误区。本题核心考点为滑动窗口的动态维护、哈希表的计数应用,是面试高频中等难度题目,适配算法进阶练习。

一、题目核心解读

1.1 题目描述(精简版)

给定两个字符串 s(长度 m)和 t(长度 n),返回 s 中包含 t 所有字符(包括重复字符)的最短窗口子串;若不存在这样的子串,返回空字符串 ""。测试用例保证答案唯一。

核心约束与进阶要求:

  • 数据范围:1 ≤ m, n ≤ 10⁵,s 和 t 仅由英文字母组成(区分大小写,如 'A' 和 'a' 视为不同字符)。

  • 关键要求:子串需"覆盖 t 的所有字符",包括重复出现的字符(如 t = "aa",子串需包含至少两个 'a')。

  • 进阶要求:设计 O(m + n) 时间复杂度的算法(核心考点,也是实际面试中重点考察的方向)。

1.2 示例解析(直观理解)

  • 示例 1:输入 s = "ADOBECODEBANC", t = "ABC"

解析:t 包含 'A'、'B'、'C' 各1个,s 中包含这三个字符的子串有 "ADOBEC"(长度6)、"BECODEBA"(长度7)、"BANC"(长度4),其中最短的是 "BANC",输出 "BANC"。

  • 示例 2:输入 s = "a", t = "a"

解析:s 本身就是包含 t 所有字符的子串,且长度最短,输出 "a"。

  • 示例 3:输入 s = "a", t = "aa"

解析:t 要求包含两个 'a',但 s 只有一个 'a',无法满足,输出 ""。

关键观察:本题的核心是"找到最短的子串,满足子串中 t 的每个字符(含重复)的出现次数 ≥ t 中对应字符的出现次数"。暴力解法会因时间复杂度过高超时,滑动窗口是最优思路,可实现 O(m + n) 时间复杂度。

二、解题思路深度剖析(2种解法,从暴力到最优)

本题的核心矛盾是"时间复杂度"与"子串有效性判断":暴力解法通过枚举所有子串判断有效性,时间复杂度过高;滑动窗口通过双指针动态维护子串范围,结合哈希表计数判断有效性,实现高效求解。以下逐一解析每种解法的思路、优缺点及适用场景。

2.1 解法1:暴力解法(基础思路,适合理解问题本质)

2.1.1 思路核心

枚举 s 中所有可能的子串,判断每个子串是否包含 t 的所有字符(含重复),记录满足条件的最短子串。

具体步骤:

  1. 用哈希表(字典)统计 t 中每个字符的出现次数(记为 t_count),作为判断子串有效性的标准。

  2. 枚举所有子串:遍历 s 的所有起始索引 i,从 i 开始向后扩展终点索引 j,得到子串 s[i...j]。

  3. 判断子串有效性:用哈希表统计子串 s[i...j] 中每个字符的出现次数(记为 window_count),若 window_count 中所有 t 中的字符的出现次数 ≥ t_count 中的对应次数,则该子串有效。

  4. 记录最短有效子串:不断更新满足条件的子串长度,保留最短的子串。

2.1.2 优缺点

  • 优点:思路简单、逻辑直观,适合新手理解"覆盖子串"的核心要求,无需复杂的数据结构技巧。

  • 缺点:时间复杂度极高,为 O(m² × n)。枚举所有子串的时间为 O(m²),每个子串的有效性判断需要遍历子串和 t(O(m + n)),当 m=10⁵ 时,运算量远超时间限制,必然超时,仅适合 m ≤ 100 的小数据量。

2.2 解法2:滑动窗口(双指针)解法(最优解法,满足进阶要求)

2.2.1 思路核心

利用"滑动窗口"(双指针 left、right 界定窗口范围)动态维护子串,结合两个哈希表(t_count 统计 t 的字符计数,window_count 统计当前窗口的字符计数),通过"扩大窗口找有效子串、缩小窗口找最短子串"的思路,实现 O(m + n) 时间复杂度。

核心原则(滑动窗口维护规则):

  1. 初始化:用 t_count 统计 t 中每个字符的出现次数;window_count 初始为空,用于统计当前窗口内的字符次数;left 指针初始为 0,用于界定窗口左边界;min_len 记录最短有效子串的长度(初始为无穷大);start 记录最短有效子串的起始索引(初始为 0)。

  2. 扩大窗口(right 指针右移):从 s 的起始位置开始,right 指针逐步右移,将当前字符 s[right] 加入 window_count(若字符在 t_count 中,才更新 window_count,否则无需统计,因为不影响子串有效性)。

  3. 判断窗口有效性:当 window_count 中所有 t 的字符的出现次数 ≥ t_count 中的对应次数时,说明当前窗口是有效子串,此时尝试缩小窗口,寻找更短的有效子串。

  4. 缩小窗口(left 指针右移):在窗口有效的前提下,逐步右移 left 指针,缩小窗口范围,同时更新 window_count(若当前字符在 t_count 中,减少其计数);每缩小一次,判断窗口是否仍有效,若有效则更新 min_len 和 start,直到窗口无效。

  5. 重复步骤 2-4,直到 right 指针遍历完 s,最终根据 min_len 是否为无穷大,返回最短子串或空字符串。

2.2.2 关键优化:有效性判断的高效实现

若每次判断窗口有效性都遍历 t_count 对比 window_count,会导致时间复杂度上升至 O(m × n),无法满足进阶要求。因此引入"匹配计数器"(match),优化判断逻辑:

  • match 初始为 0,代表当前窗口中,满足"出现次数 ≥ t_count 对应次数"的字符个数。

  • 当 window_count 中某字符的计数达到 t_count 中该字符的计数时,match 加 1(仅加 1 一次,避免重复计数)。

  • 当 match 等于 t_count 的长度(即 t 中所有不同字符都满足计数要求)时,窗口有效,无需遍历对比,直接进入缩小窗口阶段。

该优化将有效性判断的时间从 O(n) 降至 O(1),确保整体时间复杂度为 O(m + n)。

2.2.3 思路验证(结合示例1)

s = "ADOBECODEBANC", t = "ABC",t_count = {'A':1, 'B':1, 'C':1},match 初始为 0,min_len = 无穷大,start = 0,left = 0。

  • right=0(s[0]='A'):window_count['A']=1,等于 t_count['A'],match=1 → 窗口无效(match≠3),继续扩大。

  • right=1(s[1]='D'):不在 t_count 中,不更新 window_count → 窗口无效。

  • right=2(s[2]='O'):不在 t_count 中 → 窗口无效。

  • right=3(s[3]='B'):window_count['B']=1,等于 t_count['B'],match=2 → 窗口无效。

  • right=4(s[4]='E'):不在 t_count 中 → 窗口无效。

  • right=5(s[5]='C'):window_count['C']=1,等于 t_count['C'],match=3 → 窗口有效(ADOBEC,长度6)。更新 min_len=6,start=0。开始缩小窗口:

left=0(s[0]='A'):window_count['A']=0,小于 t_count['A'],match=2 → 窗口无效,停止缩小,left=1。

  • 继续扩大 right 至 10(s[10]='B')、right=11(s[11]='A')、right=12(s[12]='N')、right=13(s[13]='C'),过程中不断维护 window_count 和 match,当 right=13 时,窗口为 "BANC"(left=10),match=3,有效,长度4,更新 min_len=4,start=10。

  • 最终最短子串为 s[10:14] = "BANC",与示例一致。

2.2.4 优缺点

  • 优点:时间复杂度 O(m + n),t_count 统计 t 的字符需 O(n),right 和 left 指针各遍历 s 一次需 O(m),整体高效,满足进阶要求;空间复杂度 O(n),哈希表存储 t 的字符计数,最多存储 t 中所有不同字符(不超过26×2=52个,因为英文字母分大小写),空间开销固定。

  • 缺点:思路相对复杂,需同时维护双指针、两个哈希表和匹配计数器,容易出现细节错误(如窗口缩小时机、match 计数逻辑、字符是否在 t 中的判断)。

2.3 常见误区提醒

  • 误区1:忽略 t 中重复字符的要求(如示例3,t="aa",子串需包含至少两个 'a'),仅判断字符是否存在,不判断出现次数,导致错误。

  • 误区2:有效性判断未优化,每次都遍历 t_count 对比 window_count,导致时间复杂度过高,无法通过大数据量测试。

  • 误区3:缩小窗口时,未判断当前字符是否在 t_count 中,就更新 window_count,导致计数混乱,影响 match 判断。

  • 误区4:未记录最短子串的起始索引,仅记录长度,最终无法正确截取子串;或初始 min_len 设为 0,导致无法区分"无有效子串"和"有效子串长度为0"(实际不可能)。

  • 误区5:区分大小写错误(如将 'A' 和 'a' 视为同一字符),导致测试用例失败。

三、代码实现(Python,2种解法,重点最优解法)

严格按照题目要求的类和方法名格式编写(注:题目要求方法名为 findKthBit,此处按题目实际需求调整为 minWindow,贴合题目场景,避免混淆),代码简洁高效,附带详细注释,适配 Python 语法规范。

3.1 解法1:暴力解法(仅供理解,不推荐提交)

python 复制代码
from collections import defaultdict

class Solution:
    def minWindow(self, s: str, t: str) -> str:
        m, n = len(s), len(t)
        # 若t长度大于s,直接返回空字符串(不可能有覆盖子串)
        if n > m:
            return ""
        # 统计t中每个字符的出现次数
        t_count = defaultdict(int)
        for c in t:
            t_count[c] += 1
        # 初始化最短子串相关参数
        min_len = float('inf')
        result = ""
        
        # 枚举所有子串的起始索引i
        for i in range(m):
            # 统计当前子串的字符出现次数
            window_count = defaultdict(int)
            # 扩展子串终点索引j
            for j in range(i, m):
                c = s[j]
                if c in t_count:
                    window_count[c] += 1
                # 判断当前子串是否有效
                valid = True
                for key in t_count:
                    if window_count.get(key, 0) < t_count[key]:
                        valid = False
                        break
                # 若有效,更新最短子串
                if valid:
                    current_len = j - i + 1
                    if current_len < min_len:
                        min_len = current_len
                        result = s[i:j+1]
                    # 找到当前i对应的最短有效子串,无需继续扩展j(j越大,子串越长)
                    break
        return result

3.2 解法2:滑动窗口(双指针)解法(最优,满足进阶要求,推荐面试使用)

python 复制代码
from collections import defaultdict

class Solution:
    def minWindow(self, s: str, t: str) -> str:
        m, n = len(s), len(t)
        # 边界判断:t比s长,直接返回空
        if n > m:
            return ""
        
        # 1. 统计t中每个字符的出现次数
        t_count = defaultdict(int)
        for c in t:
            t_count[c] += 1
        # 2. 初始化滑动窗口相关参数
        window_count = defaultdict(int)  # 当前窗口的字符计数
        match = 0  # 匹配计数器:满足计数要求的字符个数
        left = 0  # 窗口左边界
        min_len = float('inf')  # 最短有效子串长度
        start = 0  # 最短有效子串的起始索引
        
        # 3. 扩大窗口(right指针右移)
        for right in range(m):
            c = s[right]
            # 仅统计t中存在的字符
            if c in t_count:
                window_count[c] += 1
                # 当该字符的计数达到t中的要求时,match加1(仅加一次)
                if window_count[c] == t_count[c]:
                    match += 1
            
            # 4. 窗口有效(match等于t中不同字符的个数),缩小窗口
            while match == len(t_count):
                # 更新最短有效子串
                current_len = right - left + 1
                if current_len< min_len:
                    min_len = current_len
                    start = left
                
                # 左指针右移,缩小窗口
                left_c = s[left]
                if left_c in t_count:
                    # 若当前字符是t中的字符,更新window_count
                    window_count[left_c] -= 1
                    # 若计数低于t中的要求,match减1(窗口变为无效)
                    if window_count[left_c] < t_count[left_c]:
                        match -= 1
                # 左指针右移
                left += 1
        
        # 5. 判断是否存在有效子串,返回结果
        return s[start:start+min_len] if min_len != float('inf') else ""

四、代码逐行解析(重点讲解最优解法)

4.1 边界判断与初始化

python 复制代码
m, n = len(s), len(t)
if n > m:
    return ""

t_count = defaultdict(int)
for c in t:
    t_count[c] += 1

window_count = defaultdict(int)
match = 0
left = 0
min_len = float('inf')
start = 0

解释:

  • m、n 分别存储 s 和 t 的长度,若 t 比 s 长,不可能存在覆盖子串,直接返回空字符串。

  • t_count:用默认字典统计 t 中每个字符的出现次数(如 t="ABC",t_count = {'A':1, 'B':1, 'C':1})。

  • window_count:统计当前滑动窗口内,t 中存在的字符的出现次数(无关字符不统计)。

  • match:匹配计数器,记录当前窗口中"出现次数 ≥ t_count 对应次数"的字符个数,当 match == len(t_count) 时,窗口有效。

  • left:滑动窗口的左边界,初始为 0。

  • min_len:记录最短有效子串的长度,初始为无穷大(方便后续更新)。

  • start:记录最短有效子串的起始索引,用于最终截取子串。

4.2 扩大窗口(right指针右移)

python 复制代码
for right in range(m):
    c = s[right]
    if c in t_count:
        window_count[c] += 1
        if window_count[c] == t_count[c]:
            match += 1

解释:

  • right 指针从 0 遍历到 m-1,逐步扩大窗口范围,每次获取当前字符 c = s[right]。

  • 若 c 是 t 中的字符(存在于 t_count 中),才更新 window_count,避免统计无关字符,节省空间和时间。

  • 当 window_count[c] 等于 t_count[c] 时,说明该字符的计数满足要求,match 加 1(仅加一次,避免重复计数,例如 t 中 'A' 出现2次,window_count['A'] 从1变为2时,match 才加1)。

4.3 缩小窗口(left指针右移)

python 复制代码
while match == len(t_count):
    current_len = right - left + 1
    if current_len < min_len:
        min_len = current_len
        start = left
    
    left_c = s[left]
    if left_c in t_count:
        window_count[left_c] -= 1
        if window_count[left_c] < t_count[left_c]:
            match -= 1
    left += 1

解释:

  • while 循环条件:match == len(t_count),即当前窗口有效(包含 t 所有字符,且满足重复次数要求)。

  • 更新最短子串:计算当前窗口长度 current_len,若比 min_len 小,更新 min_len 和 start(记录新的最短子串起始位置)。

  • left_c = s[left]:获取当前窗口左边界的字符,准备右移左边界,缩小窗口。

  • 若 left_c 是 t 中的字符,更新 window_count[left_c](减1),因为该字符将离开窗口。

  • 若 window_count[left_c] 减1后小于 t_count[left_c],说明该字符的计数不再满足要求,match 减1,窗口变为无效,退出 while 循环,继续扩大窗口。

  • left 指针右移,缩小窗口范围,尝试寻找更短的有效子串。

4.4 结果返回

python 复制代码
return s[start:start+min_len] if min_len != float('inf') else ""

解释:

  • 若 min_len 仍为无穷大,说明没有找到有效子串,返回空字符串 ""。

  • 否则,根据 start 和 min_len 截取 s 中的最短有效子串,返回该子串。

五、性能分析与扩展

5.1 两种解法性能对比

解法 时间复杂度 空间复杂度 适用场景
暴力解法 O(m² × n) O(n)(哈希表存储 t 字符计数) 小数据量(m ≤ 100),适合理解问题本质
滑动窗口解法 O(m + n) O(n)(哈希表存储 t 字符计数) 大数据量(m ≤ 10⁵),面试最优选择,满足进阶要求

5.2 边界场景适配

本题需重点适配4种边界场景,确保代码健壮性:

  • 场景1:t 长度大于 s → 返回 ""(代码已适配,开头直接判断)。

  • 场景2:s 与 t 长度相等 → 若 s == t,返回 s;否则返回 ""(代码会正常判断,窗口扩大到 right=m-1 时,若有效则返回 s,否则返回 "")。

  • 场景3:t 中存在重复字符(如 t="aa") → 代码通过 t_count 统计重复次数,window_count 需满足计数要求,避免遗漏重复字符。

  • 场景4:s 中存在多个有效子串,且答案唯一(测试用例保证) → 代码通过不断更新 min_len 和 start,确保返回最短的有效子串。

5.3 扩展思考(面试延伸)

  1. 如何优化空间复杂度?

答:由于 s 和 t 仅由英文字母组成(区分大小写),可使用数组替代哈希表(如用大小为 128 的数组,对应 ASCII 码),减少哈希表的开销,空间复杂度仍为 O(1)(固定大小数组)。例如:t_count = [0] * 128,t_count[ord©] += 1(ord© 得到字符的 ASCII 码)。

  1. 若 s 和 t 包含非英文字符(如中文、符号),如何调整解法?

答:核心思路不变,只需将哈希表(defaultdict)保留,无需改为数组,因为非英文字符的 ASCII 码范围不确定,数组无法覆盖,哈希表可灵活存储任意字符的计数。

  1. 若题目要求"找到所有最短覆盖子串"(而非唯一答案),如何修改代码?

答:将 start 改为列表,存储所有最短有效子串的起始索引,当 current_len == min_len 时,将 start 加入列表;最终根据列表中的起始索引,截取所有最短子串并返回。

  1. 滑动窗口的核心思想是什么?

答:滑动窗口的核心是"用双指针界定窗口范围,动态维护窗口内的信息,避免重复计算",适用于"子串/子数组相关的最值、匹配问题",可将暴力解法的 O(m²) 时间复杂度优化至 O(m)。

六、总结与思考

6.1 核心知识点

  • 滑动窗口(双指针)的动态维护:通过 right 指针扩大窗口找有效子串,left 指针缩小窗口找最短子串,实现高效遍历。

  • 哈希表的计数应用:用两个哈希表分别统计 t 和当前窗口的字符计数,结合匹配计数器,高效判断窗口有效性。

  • 时间复杂度优化:从暴力解法的 O(m² × n) 优化至 O(m + n),核心是"避免重复判断子串有效性"和"双指针仅遍历一次 s"。

6.2 解题启示

本题是"滑动窗口 + 哈希表"的经典结合,也是面试中高频考察的中等难度题目,解题时需注意:

  • 先明确问题核心:不仅要包含 t 的所有字符,还要满足重复字符的计数要求,这是区别于"包含所有不同字符"的关键。

  • 滑动窗口的维护逻辑是重点:牢记"扩大窗口找有效,缩小窗口找最短",避免出现"缩小窗口时遗漏字符计数更新"的错误。

  • 细节决定成败:边界判断(t 比 s 长)、匹配计数器的更新逻辑、最短子串的起始索引记录,这些细节直接影响代码的正确性。

  • 进阶要求的实现:通过匹配计数器优化有效性判断,避免遍历对比,确保时间复杂度达到 O(m + n),这是面试中加分项。

6.3 测试用例验证

补充4组测试用例,确保代码覆盖所有场景:

  • 测试用例1:s = "a", t = "a" → 输出 "a"(边界场景,s 和 t 长度相等)。

  • 测试用例2:s = "a", t = "aa" → 输出 ""(t 有重复字符,s 无法满足)。

  • 测试用例3:s = "aa", t = "aa" → 输出 "aa"(t 重复字符,s 刚好满足)。

  • 测试用例4:s = "abac", t = "abc" → 输出 "bac"(最短有效子串)。

将上述用例代入滑动窗口解法代码,均能得到正确结果,验证代码的正确性和健壮性。

相关推荐
guygg882 小时前
基于ADMM的MRI-PET高质量图像重建算法MATLAB实现
开发语言·算法·matlab
李昊哲小课2 小时前
Python itertools模块详细教程
数据结构·python·散列表
moonlight03042 小时前
类加载子系统
java·jvm·算法
baivfhpwxf20232 小时前
ACS X轴回零程序 项目实战版
网络·数据库·算法
一叶落4382 小时前
LeetCode 219. 存在重复元素 II(C语言详解)
算法·哈希算法·散列表
小猪弟2 小时前
【app逆向】某壳逆向的wll-kgsa参数,signature参数
python·逆向·wll-kgsa·signature·
像污秽一样2 小时前
算法设计与分析-习题2.4
数据结构·算法·排序算法
不想看见4042 小时前
Reverse Bits位运算基础问题--力扣101算法题解笔记
笔记·算法·leetcode