【LeetCode算法题精讲】滑动窗口—无重复字符的最长子串 + 最小覆盖子串

0 阅读14分钟

一、引言

🤔 思考:给你一个字符串,找出不包含重复字符的最长子串——你会怎么做?

如果你第一反应是"枚举所有子串,检查每个是否有重复字符",那么恭喜你,你已经会了暴力解法,但面试官不会满意。

暴力枚举所有子串的时间复杂度是 O(n²) 或 O(n³),当字符串长度达到 10⁵ 时,程序会跑到天荒地老。

滑动窗口(Sliding Window) 正是为了解决这类"子串/子数组"问题而生的高级双指针技巧。它的核心思想是:

不是暴力枚举所有子串,而是用两个指针维护一个窗口,让窗口始终代表一个"可行解",通过扩张和收缩窗口来寻找最优解。

滑动窗口的本质是以空间换时间——通过维护窗口内的状态信息,避免重复计算。

本文将围绕两个经典题目展开:

  1. 3. 无重复字符的最长子串(Medium)—— 滑动窗口入门

  2. 76. 最小覆盖子串(Hard)—— 滑动窗口进阶模板


二、滑动窗口通用模板

2.1 核心概念

滑动窗口算法涉及以下几个核心变量:

| 变量 | 含义 | 说明 | | --- | --- | --- | | left | 窗口左边界 | 指向窗口起始位置 | | right | 窗口右边界 | 指向窗口结束位置 | | need | 需求字典 | 记录目标条件(如 t 中字符的计数) | | window | 窗口字典 | 记录当前窗口中各字符的计数 | | valid | 满足条件数 | 记录已满足需求的字符种类数 |

2.2 算法框架

滑动窗口的通用框架可以概括为四个步骤:

初始化 left = 0, right = 0, valid = 0
初始化 need 和 window 字典

while right < len(s):
    # 1. 扩大窗口:将 s[right] 加入窗口
    char = s[right]
    right++
    # 更新 window 字典
    
    # 2. 判断是否需要收缩
    while (需要收缩):
        # 3. 更新结果(在收缩前更新,保证结果有效)
        
        # 4. 收缩窗口:将 s[left] 移出窗口
        left_char = s[left]
        left++
        # 更新 window 字典

2.3 模板代码

Python 模板
def sliding_window(s: str, t: str) -> str:
    need = {}
    window = {}
    for c in t:
        need[c] = need.get(c, 0) + 1
    
    left = 0
    right = 0
    valid = 0
    start = 0          # 记录最优解起始位置
    length = float('inf')  # 记录最优解长度
    
    while right < len(s):
        # 扩大窗口
        c = s[right]
        right += 1
        
        if c in need:
            window[c] = window.get(c, 0) + 1
            if window[c] == need[c]:
                valid += 1
        
        # 判断是否需要收缩
        while valid == len(need):
            # 更新最优解
            if right - left < length:
                start = left
                length = right - left
            
            # 收缩窗口
            d = s[left]
            left += 1
            if d in need:
                if window[d] == need[d]:
                    valid -= 1
                window[d] -= 1
    
    return "" if length == float('inf') else s[start:start + length]
Java 模板
class Solution {
    public String slidingWindow(String s, String t) {
        Map<Character, Integer> need = new HashMap<>();
        Map<Character, Integer> window = new HashMap<>();
        for (char c : t.toCharArray()) {
            need.put(c, need.getOrDefault(c, 0) + 1);
        }
        
        int left = 0, right = 0;
        int valid = 0;
        int start = 0, length = Integer.MAX_VALUE;
        
        while (right < s.length()) {
            // 扩大窗口
            char c = s.charAt(right);
            right++;
            
            if (need.containsKey(c)) {
                window.put(c, window.getOrDefault(c, 0) + 1);
                if (window.get(c).equals(need.get(c))) {
                    valid++;
                }
            }
            
            // 判断是否需要收缩
            while (valid == need.size()) {
                // 更新最优解
                if (right - left < length) {
                    start = left;
                    length = right - left;
                }
                
                // 收缩窗口
                char d = s.charAt(left);
                left++;
                if (need.containsKey(d)) {
                    if (window.get(d).equals(need.get(d))) {
                        valid--;
                    }
                    window.put(d, window.get(d) - 1);
                }
            }
        }
        
        return length == Integer.MAX_VALUE ? "" : s.substring(start, start + length);
    }
}
C++ 模板
class Solution {
public:
    string slidingWindow(string s, string t) {
        unordered_map<char, int> need, window;
        for (char c : t) need[c]++;
        
        int left = 0, right = 0;
        int valid = 0;
        int start = 0, length = INT_MAX;
        
        while (right < s.size()) {
            // 扩大窗口
            char c = s[right];
            right++;
            
            if (need.count(c)) {
                window[c]++;
                if (window[c] == need[c]) {
                    valid++;
                }
            }
            
            // 判断是否需要收缩
            while (valid == need.size()) {
                // 更新最优解
                if (right - left < length) {
                    start = left;
                    length = right - left;
                }
                
                // 收缩窗口
                char d = s[left];
                left++;
                if (need.count(d)) {
                    if (window[d] == need[d]) {
                        valid--;
                    }
                    window[d]--;
                }
            }
        }
        
        return length == INT_MAX ? "" : s.substr(start, length);
    }
};

2.4 滑动窗口过程图解

flowchart TD
    A[初始化 left=0, right=0] --> B{right < n?}
    B -->|是| C[扩大右边界<br>将 s[right] 加入窗口]
    C --> D[更新 window 字典]
    D --> E{需要收缩?}
    E -->|是| F[收缩左边界<br>将 s[left] 移出窗口]
    F --> G[更新 window 字典]
    G --> E
    E -->|否| H[更新结果]
    H --> B
    B -->|否| I[返回结果]
    
    style A fill:#4A90D9,color:#fff
    style C fill:#4A90D9,color:#fff
    style F fill:#E8833A,color:#fff
    style I fill:#27AE60,color:#fff

三、3. 无重复字符的最长子串(Medium)

3.1 题目描述

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

示例 1:

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

示例 2:

输入: s = "bbbbb"
输出: 1
解释: 因为无重复字符的最长子串是 "b",所以其长度为 1。

示例 3:

输入: s = "pwwkew"
输出: 3
解释: 因为无重复字符的最长子串是 "wke",所以其长度为 3。
     请注意,你的答案必须是 子串 的长度,"pwke" 是一个子序列,不是子串。

3.2 解法思路

核心思想:维护一个无重复字符的窗口,当窗口内出现重复字符时,收缩左边界直到无重复。

两种实现方式

  1. 使用 Set:窗口内字符全部存入 Set,当 s[right] 已在 Set 中时,不断移除 s[left] 并移动 left

  2. 使用 Map:记录每个字符最后出现的位置,当 s[right] 已存在时,直接将 left 跳转到上次出现位置的下一个

🤔 思考:为什么用 Map 比 Set 更高效?因为 Map 可以直接定位重复字符的位置,不需要逐格收缩左边界。

3.3 代码实现

解法一:滑动窗口 + Set

Python

class Solution:
    def lengthOfLongestSubstring(self, s: str) -> int:
        char_set = set()
        left = 0
        max_len = 0
        
        for right in range(len(s)):
            # 如果 s[right] 重复,收缩左边界
            while s[right] in char_set:
                char_set.remove(s[left])
                left += 1
            # 将 s[right] 加入窗口
            char_set.add(s[right])
            # 更新最大长度
            max_len = max(max_len, right - left + 1)
        
        return max_len

Java

class Solution {
    public int lengthOfLongestSubstring(String s) {
        Set<Character> charSet = new HashSet<>();
        int left = 0, maxLen = 0;
        
        for (int right = 0; right < s.length(); right++) {
            char c = s.charAt(right);
            // 如果 s[right] 重复,收缩左边界
            while (charSet.contains(c)) {
                charSet.remove(s.charAt(left));
                left++;
            }
            // 将 s[right] 加入窗口
            charSet.add(c);
            // 更新最大长度
            maxLen = Math.max(maxLen, right - left + 1);
        }
        
        return maxLen;
    }
}

C++

class Solution {
public:
    int lengthOfLongestSubstring(string s) {
        unordered_set<char> charSet;
        int left = 0, maxLen = 0;
        
        for (int right = 0; right < s.size(); right++) {
            // 如果 s[right] 重复,收缩左边界
            while (charSet.count(s[right])) {
                charSet.erase(s[left]);
                left++;
            }
            // 将 s[right] 加入窗口
            charSet.insert(s[right]);
            // 更新最大长度
            maxLen = max(maxLen, right - left + 1);
        }
        
        return maxLen;
    }
};
解法二:滑动窗口 + Map(优化版)

Python

class Solution:
    def lengthOfLongestSubstring(self, s: str) -> int:
        char_map = {}  # 字符 -> 最后出现的索引
        left = 0
        max_len = 0
        
        for right in range(len(s)):
            # 如果 s[right] 出现过且在窗口内,跳转 left
            if s[right] in char_map and char_map[s[right]] >= left:
                left = char_map[s[right]] + 1
            # 更新字符最后出现位置
            char_map[s[right]] = right
            # 更新最大长度
            max_len = max(max_len, right - left + 1)
        
        return max_len

Java

class Solution {
    public int lengthOfLongestSubstring(String s) {
        Map<Character, Integer> charMap = new HashMap<>();
        int left = 0, maxLen = 0;
        
        for (int right = 0; right < s.length(); right++) {
            char c = s.charAt(right);
            // 如果 c 出现过且在窗口内,跳转 left
            if (charMap.containsKey(c) && charMap.get(c) >= left) {
                left = charMap.get(c) + 1;
            }
            // 更新字符最后出现位置
            charMap.put(c, right);
            // 更新最大长度
            maxLen = Math.max(maxLen, right - left + 1);
        }
        
        return maxLen;
    }
}

C++

class Solution {
public:
    int lengthOfLongestSubstring(string s) {
        unordered_map<char, int> charMap;
        int left = 0, maxLen = 0;
        
        for (int right = 0; right < s.size(); right++) {
            // 如果 s[right] 出现过且在窗口内,跳转 left
            if (charMap.count(s[right]) && charMap[s[right]] >= left) {
                left = charMap[s[right]] + 1;
            }
            // 更新字符最后出现位置
            charMap[s[right]] = right;
            // 更新最大长度
            maxLen = max(maxLen, right - left + 1);
        }
        
        return maxLen;
    }
};

3.4 复杂度分析

| 解法 | 时间复杂度 | 空间复杂度 | | --- | --- | --- | | Set 解法 | O(n) | O(min(m, n)) | | Map 优化 | O(n) | O(min(m, n)) |

  • 时间复杂度 O(n):每个字符最多被访问两次(进窗口一次,出窗口一次)

  • 空间复杂度 O(min(m, n)):m 为字符集大小(ASCII 128 / Unicode),n 为字符串长度

3.5 易错点

  1. 左边界收缩时忘记移除字符(Set 解法):使用 remove 移除 s[left] 后,left 才自增

  2. Map 解法中 left 跳转条件:需要判断 char_map[s[right]] >= left,确保重复字符在当前窗口内,而不是历史记录

  3. 空字符串:输入 s = "" 时,应返回 0

  4. 单个字符:输入 s = " " 时,应返回 1

3.6 滑动窗口过程图解

s = "abcabcbb" 为例,展示 Set 解法的滑动过程:


四、76. 最小覆盖子串(Hard)

4.1 题目描述

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

注意:

  • 对于 t 中重复字符,子串中该字符数量必须不少于 t 中该字符数量

  • 如果 s 中存在这样的子串,我们保证它是唯一的答案

示例 1:

输入: s = "ADOBECODEBANC", t = "ABC"
输出: "BANC"
解释: "BANC" 涵盖 "A""B""C" 三个字符。

示例 2:

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

示例 3:

输入: s = "a", t = "aa"
输出: ""
解释: t 中两个字符 'a' 均应包含在子串中,因此没有符合条件的子串,返回空字符串。

4.2 解法思路

核心思想:用 need 字典记录 t 中每个字符的需求量,用 window 字典记录当前窗口中各字符的数量,用 valid 变量跟踪已满足条件的字符种类数。

算法步骤

  1. 初始化 need 字典,统计 t 中每个字符的频次

  2. 移动右指针 right,将字符加入窗口

  3. 如果某个字符在 window 中的计数达到 need 中的需求,valid++

  4. valid == len(need) 时,窗口已覆盖 t,尝试收缩左边界

  5. 收缩时,如果某个字符的计数低于需求,valid--

  6. 记录所有有效窗口中的最小值

🤔 思考:为什么用 valid 变量而不是每次遍历 need 字典判断?因为遍历字典是 O(k) 操作(k 为字符集大小),而 valid 是 O(1) 判断,在滑动窗口这种高频操作中性能差异巨大。

4.3 代码实现

Python
class Solution:
    def minWindow(self, s: str, t: str) -> str:
        need = {}
        window = {}
        for c in t:
            need[c] = need.get(c, 0) + 1
        
        left = 0
        right = 0
        valid = 0
        start = 0          # 记录最小覆盖子串的起始位置
        length = float('inf')  # 记录最小覆盖子串的长度
        
        while right < len(s):
            # 扩大窗口
            c = s[right]
            right += 1
            
            if c in need:
                window[c] = window.get(c, 0) + 1
                if window[c] == need[c]:
                    valid += 1
            
            # 窗口已覆盖 t,尝试收缩
            while valid == len(need):
                # 更新最小覆盖子串
                if right - left < length:
                    start = left
                    length = right - left
                
                # 收缩窗口
                d = s[left]
                left += 1
                if d in need:
                    if window[d] == need[d]:
                        valid -= 1
                    window[d] -= 1
        
        return "" if length == float('inf') else s[start:start + length]
Java
class Solution {
    public String minWindow(String s, String t) {
        Map<Character, Integer> need = new HashMap<>();
        Map<Character, Integer> window = new HashMap<>();
        for (char c : t.toCharArray()) {
            need.put(c, need.getOrDefault(c, 0) + 1);
        }
        
        int left = 0, right = 0;
        int valid = 0;
        int start = 0, length = Integer.MAX_VALUE;
        
        while (right < s.length()) {
            // 扩大窗口
            char c = s.charAt(right);
            right++;
            
            if (need.containsKey(c)) {
                window.put(c, window.getOrDefault(c, 0) + 1);
                if (window.get(c).equals(need.get(c))) {
                    valid++;
                }
            }
            
            // 窗口已覆盖 t,尝试收缩
            while (valid == need.size()) {
                // 更新最小覆盖子串
                if (right - left < length) {
                    start = left;
                    length = right - left;
                }
                
                // 收缩窗口
                char d = s.charAt(left);
                left++;
                if (need.containsKey(d)) {
                    if (window.get(d).equals(need.get(d))) {
                        valid--;
                    }
                    window.put(d, window.get(d) - 1);
                }
            }
        }
        
        return length == Integer.MAX_VALUE ? "" : s.substring(start, start + length);
    }
}
C++
class Solution {
public:
    string minWindow(string s, string t) {
        unordered_map<char, int> need, window;
        for (char c : t) need[c]++;
        
        int left = 0, right = 0;
        int valid = 0;
        int start = 0, length = INT_MAX;
        
        while (right < s.size()) {
            // 扩大窗口
            char c = s[right];
            right++;
            
            if (need.count(c)) {
                window[c]++;
                if (window[c] == need[c]) {
                    valid++;
                }
            }
            
            // 窗口已覆盖 t,尝试收缩
            while (valid == need.size()) {
                // 更新最小覆盖子串
                if (right - left < length) {
                    start = left;
                    length = right - left;
                }
                
                // 收缩窗口
                char d = s[left];
                left++;
                if (need.count(d)) {
                    if (window[d] == need[d]) {
                        valid--;
                    }
                    window[d]--;
                }
            }
        }
        
        return length == INT_MAX ? "" : s.substr(start, length);
    }
};

4.4 复杂度分析

| 维度 | 分析 | | --- | --- | | 时间复杂度 | O(n),n 为字符串 s 的长度。每个字符最多被访问两次(进窗口一次,出窗口一次) | | 空间复杂度 | O(m),m 为字符集大小。needwindow 两个字典的开销 |

4.5 易错点

  1. t 中包含重复字符need 字典的计数要累加,而不是简单标记存在。例如 t = "aa",需要两个 'a'

  2. valid 变量的更新时机

    • 扩大窗口时:window[c] == need[c]valid++

    • 收缩窗口时:window[d] == need[d]valid--

    • 注意:不能简单地用 >=<= 判断,必须精确相等

  3. 收缩窗口时 valid 的更新顺序:必须先判断 window[d] == need[d],再 window[d]--。如果先减再判断,就会漏掉

  4. 返回空字符串:当 length == float('inf') / Integer.MAX_VALUE / INT_MAX 时,说明没有找到覆盖子串,返回 ""

  5. 字符比较:Java 中 Integer 对象比较要用 equals() 而不是 ==,因为 Integer 缓存范围有限

4.6 滑动窗口过程图解

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


五、对比总结

| 对比维度 | 3. 无重复字符的最长子串 | 76. 最小覆盖子串 | | --- | --- | --- | | 难度 | ⭐ Medium | ⭐⭐⭐ Hard | | 核心问题 | 窗口内不能有重复字符 | 窗口必须覆盖 t 的所有字符 | | 收缩条件 | 出现重复字符时收缩 | 窗口已覆盖 t 时收缩(尝试更优解) | | 核心数据结构 | Set / Map | Map + Map(need + window) | | valid 变量 | ❌ 不需要 | ✅ 必需,跟踪满足条件的字符种类数 | | 返回值 | 最大长度(int) | 最短子串(string) | | 收缩方向 | 逐格收缩直到无重复 | 逐格收缩直到 valid < len(need) | | 典型场景 | 去重约束 | 覆盖约束 | | 面试频率 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |

核心感悟

  • 3 题是滑动窗口的"去重"版本,76 题是滑动窗口的"覆盖"版本

  • 76 题包含了 3 题的所有要素,还多了 need/window 双字典和 valid 变量

  • 掌握了 76 题的模板,就等于掌握了滑动窗口的通用框架


六、家族题

掌握了上述模板后,以下题目可以一通百通:

209. 长度最小的子数组(Medium)

  • 题目:在正整数数组中,找出和 ≥ target 的最短子数组

  • 解法:滑动窗口,窗口维护子数组和

  • 与模板的区别:不需要 need/window 字典,只需要一个 sum 变量

  • 关键点:当 sum >= target 时收缩左边界

438. 找到字符串中所有字母异位词(Medium)

  • 题目:在 s 中找出所有 p 的字母异位词子串的起始索引

  • 解法:滑动窗口 + 字符计数,固定窗口大小 = len(p)

  • 与模板的区别:窗口大小固定,不需要在收缩时更新结果

  • 关键点valid == len(need) 时记录起始索引

567. 字符串的排列(Medium)

  • 题目:判断 s 中是否包含 t 的排列

  • 解法:滑动窗口 + 字符计数

  • 与模板的区别:与 438 本质相同,找到第一个满足条件的窗口即可返回

  • 关键点:窗口长度超过 len(t) 时收缩左边界

424. 替换后的最长重复字符(Medium)

  • 题目:最多替换 k 个字符,使字符串中字符相同的最长子串长度

  • 解法:滑动窗口 + 历史最大频次

  • 与模板的区别:不需要 need 字典,但需要维护窗口内字符的最大频次

  • 关键点window_size - max_freq > k 时收缩左边界

家族题难度梯度


七、结语

🤔 思考:回顾一下,滑动窗口这个技巧到底解决了什么问题?

滑动窗口的核心价值在于——将 O(n²) 的暴力枚举优化为 O(n) 的线性扫描。它通过维护窗口内的状态信息,让每个字符只被访问常数次,极大提升了效率。

三个关键记忆点

  1. 双指针 + 字典:滑动窗口 = 双指针维护窗口边界 + 字典记录窗口状态

  2. 扩张 + 收缩:右指针扩张直到窗口"可行",左指针收缩找到"最优"

  3. valid 变量:用 O(1) 的 valid 替代 O(k) 的遍历,是性能优化的关键


八、参考文献

  1. 力扣官方题解 — 3. 无重复字符的最长子串

  2. 力扣官方题解 — 76. 最小覆盖子串

  3. labuladong — 滑动窗口算法框架

  4. 代码随想录 — 双指针法

  5. LeetCode 3. Longest Substring Without Repeating Characters — Discussion

  6. LeetCode 76. Minimum Window Substring — Discussion

  7. LeetCode 209. 长度最小的子数组

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