最小覆盖子串:欠债表上的滑窗
t 里每个字符是一笔债。右端进窗还债,全部还清就吐左端;吐到重新欠债,再往右借。每次仍还清时记下更短窗口。
先把 t 写入欠债表 need,needCnt 是不同字符种数。右指针吃进 c,need[c]--,减到 0 则 matched++。matched 等于 needCnt 后收缩:记下当前窗口,吐出左端 d 并 need[d]++,加到大于 0 则 matched--。时间 O(|s|+|t|),空间 O(字符集)。
这是 LeetCode 76. Minimum Window Substring。大白话:给字符串 s 和 t,在 s 里找一个最短的连续子串,使得 t 里每种字符的出现次数,这个子串里都不少于 t。找不到返回空串。字符区分大小写。
主例 s = "ADOBECODEBANC",t = "ABC"。"ADOBEC" 覆盖 A、B、C,长度 6;更短的 "BANC" 也覆盖,长度 4。答案是 "BANC"。"ABC" 三个字母在 s 里不是挨在一起的那段最短覆盖,最短覆盖不必是 t 的一个排列。
枚举左右端再数字符是平方级。LC209 那种「和够了就缩」这里对不上:约束不是一个数字,而是一张欠债表。缺口是用欠债种数 matched 代替「和」,右端还债、左端欠债,仍然一对只前进的指针。
先把 t 写成欠债表
覆盖不是「A、B、C 都露过面」这么弱。t 若是 "AABC",窗口里只给一个 A 就不算还清。正确口径:对每个字符,窗口里的个数 ≥ t 里的个数。把 t 的计数当作初始欠债:主例 A、B、C 各欠 1。演示这一帧就是这张表,matched 还是 0。
need[c] > 0 表示这种字符还欠;= 0 表示刚好还清;< 0 表示窗口里多拿了,是盈余。matched 数的是「已经还清的种类」,不是已经吃进的字符个数。t 里三种不同字母,needCnt=3,matched 到 3 才叫覆盖。多余的 D、O、E 会把对应 need 打成负数,但不增加 matched。
还清一种字符的债从 1 降到 0,matched 才加一。再吃同种字符只是盈余,matched 不加。这才能处理 t 里的重复字符。
右端还债,还清一种 matched 加一
右指针从 0 往右。每吃进 s[right]=c,执行 need[c]--。若减完恰好等于 0,这种字符刚还清,matched++。减成负数是盈余,matched 不动。s 里 t 不需要的字母,need 从 0 减到负,也不会让 matched 增加。
用手走主例前半段。right=0 吃 A,need[A] 从 1 到 0,matched=1。right=1、2 吃 D、O,只增加盈余。演示扩张第一帧:窗口 ADO,只有 A 还清。right=3 吃 B,matched=2。right=4 吃 E。right=5 吃 C,need[C] 到 0,matched=3,第一次覆盖,窗口 ADOBEC。演示第二帧 need 三项都是 0。从这里开始才允许收缩。
还清之后吐左端,吐到重新欠债
matched 一旦等于 needCnt,当前窗口合法,先用长度挑战最短,再吐 s[left]。吐出 d 时 need[d]++:若加完大于 0,这种字符重新欠债,matched--,内层收缩停止,右端继续还债。吐掉的是盈余(need 加完仍 ≤ 0),matched 不变,窗口更短,还要再记一次长度。
主例第一次覆盖是下标 [0,5] 的 ADOBEC,长度 6,先记下。左端 A 是还清项,吐掉它 need[A] 回到 1,matched 掉到 2,覆盖被破坏,左端停在 1。之后右端继续往右,途中会再次还清。后半段窗口收到 BANC,也就是下标 [9,12],长度 4,刷新最短。演示收缩两帧给出「更短的中间窗」和最终 BANC。答案是 BANC,不是第一次覆盖的 ADOBEC。
每个下标进窗一次、出窗一次,O(|s|)。t 只在开头扫一遍建表。找不到任何覆盖时 best 仍是初值,返回空串。
Go:双指针 + 计数器
func minWindow(s, t string) string {need := [128]int{}needCnt := 0for _, c := range t { if need[c]++; need[c] == 1 { needCnt++ } }left, matched := 0, 0bestL, bestLen := 0, math.MaxInt32for right := 0; right < len(s); right++ {c := s[right]if need[c]--; need[c] == 0 { matched++ }for matched == needCnt {if right-left+1 < bestLen { bestLen, bestL = right-left+1, left }d := s[left]; left++if need[d]++; need[d] > 0 { matched-- }}}if bestLen == math.MaxInt32 { return "" }return s[bestL : bestL+bestLen]}
1need 初始是 t 的欠债。needCnt 计不同种类。主例三种,不是 s 的长度。
2右端 need[c]-- 后等于 0,才 matched++。主例吃进 C 的那一拍 matched 到 3。
3覆盖时先记窗口再吐左端。主例第一次记下 ADOBEC,吐 A 后 matched 掉到 2。
4need[d]++ 后大于 0 才算重新欠债。吐盈余字母 matched 不变,窗口继续变短。
5bestLen 仍是 MaxInt32 就返回空串。主例最终切片是 BANC。
总结
欠债表右还左欠:还清就记最短再吐,吐到重新欠债。主例最短 BANC。
- 覆盖看每种字符的个数,不是看字母有没有露过面。matched 计的是还清的种类。
- 主例第一次还清是 ADOBEC,答案却是更靠后的 BANC。长度要在每次仍覆盖时更新。
- t 有重复时,债要从那一笔的个数还到 0,不能只还一次。