一、引言
🤔 思考:给你一个字符串,找出不包含重复字符的最长子串——你会怎么做?
如果你第一反应是"枚举所有子串,检查每个是否有重复字符",那么恭喜你,你已经会了暴力解法,但面试官不会满意。
暴力枚举所有子串的时间复杂度是 O(n²) 或 O(n³),当字符串长度达到 10⁵ 时,程序会跑到天荒地老。
滑动窗口(Sliding Window) 正是为了解决这类"子串/子数组"问题而生的高级双指针技巧。它的核心思想是:
不是暴力枚举所有子串,而是用两个指针维护一个窗口,让窗口始终代表一个"可行解",通过扩张和收缩窗口来寻找最优解。
滑动窗口的本质是以空间换时间——通过维护窗口内的状态信息,避免重复计算。
本文将围绕两个经典题目展开:
-
3. 无重复字符的最长子串(Medium)—— 滑动窗口入门
-
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 解法思路
核心思想:维护一个无重复字符的窗口,当窗口内出现重复字符时,收缩左边界直到无重复。
两种实现方式:
-
使用 Set:窗口内字符全部存入 Set,当
s[right]已在 Set 中时,不断移除s[left]并移动 left -
使用 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 易错点
-
左边界收缩时忘记移除字符(Set 解法):使用
remove移除s[left]后,left 才自增 -
Map 解法中 left 跳转条件:需要判断
char_map[s[right]] >= left,确保重复字符在当前窗口内,而不是历史记录 -
空字符串:输入
s = ""时,应返回 0 -
单个字符:输入
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 变量跟踪已满足条件的字符种类数。
算法步骤:
-
初始化
need字典,统计 t 中每个字符的频次 -
移动右指针
right,将字符加入窗口 -
如果某个字符在
window中的计数达到need中的需求,valid++ -
当
valid == len(need)时,窗口已覆盖 t,尝试收缩左边界 -
收缩时,如果某个字符的计数低于需求,
valid-- -
记录所有有效窗口中的最小值
🤔 思考:为什么用
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 为字符集大小。need 和 window 两个字典的开销
|
4.5 易错点
-
t 中包含重复字符:
need字典的计数要累加,而不是简单标记存在。例如t = "aa",需要两个'a' -
valid 变量的更新时机:
-
扩大窗口时:
window[c] == need[c]才valid++ -
收缩窗口时:
window[d] == need[d]才valid-- -
注意:不能简单地用
>=或<=判断,必须精确相等
-
-
收缩窗口时 valid 的更新顺序:必须先判断
window[d] == need[d],再window[d]--。如果先减再判断,就会漏掉 -
返回空字符串:当
length == float('inf')/Integer.MAX_VALUE/INT_MAX时,说明没有找到覆盖子串,返回"" -
字符比较: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) 的线性扫描。它通过维护窗口内的状态信息,让每个字符只被访问常数次,极大提升了效率。
三个关键记忆点:
-
双指针 + 字典:滑动窗口 = 双指针维护窗口边界 + 字典记录窗口状态
-
扩张 + 收缩:右指针扩张直到窗口"可行",左指针收缩找到"最优"
-
valid 变量:用 O(1) 的 valid 替代 O(k) 的遍历,是性能优化的关键
八、参考文献
-
LeetCode 3. Longest Substring Without Repeating Characters — Discussion
-
LeetCode 76. Minimum Window Substring — Discussion