最小覆盖子串 图解题解
在 s 里找包含 t 所有字母的最短子串,听起来难,其实一根伸缩橡皮筋就够了。
像用一根橡皮筋圈住货架找齐购物清单:右端不停往右扩,直到清单上所有商品都圈进来;然后左端往右收,把多余的边角料挤掉,圈到最小合法状态。再记下这段最短位置,右端继续前进——橡皮筋两端都只往右走,扫一遍找出最短覆盖段。
这道题到底在问什么
- 输入
- s="ADOBECBAC", t="ABC"
- 输出
- "CBA"
最优解:为什么这么做
一句话答案:LeetCode 76 最小覆盖子串的标准解法是变长滑动窗口:右指针不断扩张把字符吃进窗口,一旦窗口覆盖了 t 的全部字符(含重复次数),就转为收缩左边界压短窗口并记录长度,破坏覆盖后再继续右扩。左右指针各扫一遍 s,时间 O(n)、空间 O(k),k 是 t 中不同字符数。
这道题真正在问什么
给两个字符串 s 和 t,在 s 中找出最短的连续子串,使它包含 t 的全部字符——注意是「包含」而非「等于」,窗口里允许夹杂无关字符;且 t 里的重复字符要按次数算,t = "AAB" 就必须凑够两个 A。比如 s = "ADOBECODEBANC"、t = "ABC",答案是 "BANC"。找不到时返回空串。
为什么暴力枚举子串行不通
最直觉的做法是枚举所有子串、逐个检查是否覆盖 t:起点终点组合有 O(n²) 个,每次检查还要 O(n) 扫一遍统计字符,加起来 O(n³),s 稍长就完全跑不动。
破局的观察有两条。第一,覆盖性有单调性:一个窗口若已覆盖 t,再往右扩只会多不会少,检查更长的窗口毫无意义——该做的是收缩,看它还能多短。第二,窗口每次只在两端进出一个字符,覆盖状态完全可以增量维护,不必每次从头重数。这两条合起来,就把问题逼向了「双指针一趟扫完」的滑动窗口(也叫双指针窗口)。
怎么高效判断窗口已覆盖 t
先用计数表 need 记下 t 中每个字符需要的个数,再用 win 记录当前窗口内各字符的实际个数。判断覆盖不必逐项对比整张表:额外维护一个变量 formed,表示「已经凑够次数的字符种数」,当 formed 等于 t 中不同字符的总种数 required 时,窗口即完成覆盖。
formed 的维护是增量的:右端吃进字符 c 后,若 win[c] 恰好追平 need[c],formed 加一;左端挤出字符后,若该字符的计数跌破需求,formed 减一。每次进出只花 O(1),这正是整个算法能保持线性的关键一环。
为什么覆盖后必须立刻收缩左边界
算法的节奏是「右扩补齐、左缩压短」交替:右指针一路吃字符,直到 formed == required;随即进入收缩循环,每一步先用当前窗口长度 r - l + 1 更新最优答案,再把 s[l] 挤出窗口、l 右移,直到覆盖被破坏,然后回到右扩。
这样做不漏最优解的理由是:对每一个可能的右端点 r,收缩过程恰好找到了以 r 结尾的最短覆盖窗口——继续缩就不覆盖了,说明已经缩到底。所有右端点各自的最短窗口都被比较过,全局最短自然在其中。另外「先记录再移动 l」的顺序不能反,先移再记会把当前这个合法窗口漏掉。
复杂度怎么算,哪些细节容易错
时间 O(n):右指针扫一遍 s,左指针也只向右、累计最多走 n 步,每步的计数增减都是 O(1);空间 O(k),两张计数表最多存 t 中不同字符数个键。
两个高频翻车点:一是只比字符种类、不比出现次数——t 里的重复字符必须按 need[c] 的计数凑够,只用集合判断会把 "AAB" 当成只要一个 A;二是拿到第一个覆盖窗口就直接返回——第一个只是「最早」不是「最短」,必须让窗口继续滑到 s 末尾,把每个右端点的收缩结果都比完才能收工。
▶ 动画逐步走查(共 19 步)——想跟着动画一帧帧对照就展开
- 3右扩补齐、左缩压短,记住这两个动作,下面每帧都在做其一。
- 4上方是 s(长度固定)。右表是「还差几个」——要让它们全部归 0,窗口才覆盖 t。
- 5右指针扩到 0,把 'A' 纳入窗口,继续往右补齐缺的字符。
- 6右指针扩到 1,把 'D' 纳入窗口,继续往右补齐缺的字符。
- 7右指针扩到 2,把 'O' 纳入窗口,继续往右补齐缺的字符。
- 8右指针扩到 3,把 'B' 纳入窗口,继续往右补齐缺的字符。
- 9右指针扩到 4,把 'E' 纳入窗口,继续往右补齐缺的字符。
- 10右指针扩到 5,吃进 'C',t 的字符全部凑齐——接下来要收缩左边。
- 11挤掉 'A' 后窗口缺字符了,左指针停在此,记录刚才的最小窗口,再继续右扩。
- 12右指针扩到 6,把 'B' 纳入窗口,继续往右补齐缺的字符。
- 13右指针扩到 7,吃进 'A',t 的字符全部凑齐——接下来要收缩左边。
- 14左指针右移,挤掉 'D' 后窗口依然覆盖 t,说明还能更短,继续缩。
- 15左指针右移,挤掉 'O' 后窗口依然覆盖 t,说明还能更短,继续缩。
- 16左指针右移,挤掉 'B' 后窗口依然覆盖 t,说明还能更短,继续缩。
- 17左指针右移,挤掉 'E' 后窗口依然覆盖 t,说明还能更短,继续缩。
- 18挤掉 'C' 后窗口缺字符了,左指针停在此,记录刚才的最小窗口,再继续右扩。
- 19右指针扩到 8,吃进 'C',t 的字符全部凑齐——接下来要收缩左边。
- 20挤掉 'B' 后窗口缺字符了,左指针停在此,记录刚才的最小窗口,再继续右扩。
- 21所有右扩+左缩比较下来,最短的覆盖窗口就是 "CBA"。
⚠️ 容易写错的地方
✗ 错:只比种类、不比次数
✓ 对:用计数 need[c],凑够次数才算齐
t 里字符可能重复
✗ 错:覆盖后不收缩
✓ 对:formed==required 时立刻缩 l
不缩永远拿不到最短
✗ 错:记录时机错
✓ 对:收缩前先比长度再移 l
移完才比会漏掉当前窗口
完整代码(Python / C++ / Java)
Python
from collections import Counter
def minWindow(s, t):
need = Counter(t)
required = len(need)
win = {}
formed = 0
l = 0; best = (float("inf"), 0, 0)
for r, c in enumerate(s):
win[c] = win.get(c, 0) + 1
if c in need and win[c] == need[c]:
formed += 1
while l <= r and formed == required:
if r - l + 1 < best[0]:
best = (r - l + 1, l, r)
win[s[l]] -= 1
if s[l] in need and win[s[l]] < need[s[l]]:
formed -= 1
l += 1
return "" if best[0] == float("inf") else s[best[1]:best[2]+1]C++
string minWindow(string s, string t){
unordered_map<char,int> need, win;
for(char c : t) need[c]++;
int required = need.size(), formed = 0, l = 0;
int bestLen = INT_MAX, bl = 0;
for(int r = 0; r < (int)s.size(); r++){
char c = s[r]; win[c]++;
if(need.count(c) && win[c] == need[c]) formed++;
while(l <= r && formed == required){
if(r - l + 1 < bestLen){ bestLen = r - l + 1; bl = l; }
char d = s[l]; win[d]--;
if(need.count(d) && win[d] < need[d]) formed--;
l++;
}
}
return bestLen == INT_MAX ? "" : s.substr(bl, bestLen);
}Java
public String minWindow(String s, String t){
int[] need = new int[128], win = new int[128];
int required = 0;
for(char c : t.toCharArray()){ if(need[c]++ == 0) required++; }
int formed = 0, l = 0, bestLen = Integer.MAX_VALUE, bl = 0;
for(int r = 0; r < s.length(); r++){
char c = s.charAt(r); win[c]++;
if(need[c] > 0 && win[c] == need[c]) formed++;
while(l <= r && formed == required){
if(r - l + 1 < bestLen){ bestLen = r - l + 1; bl = l; }
char d = s.charAt(l); win[d]--;
if(need[d] > 0 && win[d] < need[d]) formed--;
l++;
}
}
return bestLen == Integer.MAX_VALUE ? "" : s.substring(bl, bl + bestLen);
}复杂度
时间
O(n)
左右指针各扫一遍 s
空间
O(k)
k = t 中不同字符数
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 最小覆盖子串 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
为什么用 formed 计数而不是每次重算?+
维护「已凑齐次数的字符种数」,进出窗口时 O(1) 增减,避免每步重扫整个窗口。
need/win 能不能用数组替代哈希?+
字符集有限(如 ASCII 128)时用定长数组更快,见 Java 版。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 最小覆盖子串 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。