中文博客

Minimum Window Substring 面试题:不变式与最小窗口讲解

2026年5月10日4 分钟阅读
Minimum Window Substring 面试题:不变式与最小窗口讲解

掌握 Minimum Window Substring 的核心不变式,学会用双指针、频次表和 matched 机制证明最小有效窗口,面试解释更稳,点击查看完整思路。

在面试里对这道题一头空白的大多数候选人,并不是因为忘了算法。他们空白,是因为从来没有把这道题真正问的两个问题分开:你怎么知道一个窗口是有效的,以及你怎么知道它是最小的有效窗口。这是两个不同的问题,把它们混为一谈,就是为什么 minimum window substring 的面试讲解常常在面试官刚开始追问时就只剩下含糊其辞。

好消息是,一旦你能用自然语言说清楚窗口有效性的不变式,整道题就不再像是死记硬背的技巧,而会变成你已经知道的规则所必然推出的结果。这个指南就是围绕这个转变展开的。

Minimum Window Substring 实际上要你证明什么

这道题不是“找一个子串”——而是“证明这个子串是最小的有效子串”

标准表述是:给定字符串 `s` 和字符串 `t`,找出 `s` 中包含 `t` 所有字符的最短子串。读起来很简单。大多数候选人犯的结构性错误,是把它当成一个搜索问题,但它其实是一个证明问题。找到一个有效窗口很容易——任何覆盖了 `t` 的窗口都算。难的是证明你返回的窗口是最小的,而在写第一行代码之前,你就必须先对“有效”给出一个正式定义。

那些一上来就写双指针循环、却没有先定义有效性的人,讲解时会显得摇摇晃晃。他们能描述代码做了什么,却说不清为什么可以收缩左指针,也说不清为什么找到的最小值真的就是最小值。面试官听到的就是这个漏洞。

这在实践中是什么样子

看经典例子:`s = "ADOBECODEBANC"`,`t = "ABC"`。这个字符串里有多个有效窗口——`"ADOBEC"` 是有效的,`"DOBECODEBA"` 是有效的,`"BANC"` 也是有效的。题目不是在这些窗口里随便找一个。题目是最终到达 `"BANC"`,并且有把握地说:没有更小的了。这个结论需要的是结构性的论证,而不只是扫一遍字符串。算法通过先扩张直到有效,再尽可能收缩,同时在每个有效状态记录当前最优来获得最小值。这个循环里的每一步,都来自那个不变式——这正是面试官等着你说出来的东西。

在碰代码之前,先说清楚窗口有效性不变式

贯穿整道题的唯一不变式

不变式可以这样直接说:当且仅当对于 `t` 中的每个字符 `c`,当前窗口中 `c` 出现的次数大于等于 `t` 要求的次数时,这个窗口才是有效的。不是只要这个字符出现了。也不是只要这些字符都在窗口里某处出现了。对于每个字符,出现次数都必须同时满足或超过要求。

这句话值得你先写在白板上。它把“有效性”变成了一个二值、可检查的状态,而不是一种模糊感觉。它是算法所有其他部分的基础。

这在实践中是什么样子

标准实现通常用两个频次表来追踪:`need` 用来存储 `t` 中每个字符所需的次数,`window` 用来存储当前窗口中各字符的实际出现次数。还有一个变量——比如叫 `matched`——用来记录 `t` 中有多少种不同字符已经完全满足要求(也就是 `window[c] >= need[c]`)。当且仅当 `matched == len(need)` 时,窗口才是有效的。

这一点很重要,因为有效性会随着指针移动而进出变化。当你向右加入一个字符,让 `window[c]` 从 `need[c] - 1` 变成 `need[c]` 时,`matched` 加一。当你从左边移除一个字符,让 `window[c]` 掉到 `need[c]` 以下时,`matched` 减一。这个不变式始终要么成立,要么不成立——没有歧义,而这正是算法可信的原因。

为什么面试官更在意这个,而不是你的代码长什么样

从面试官的角度,一个能背出双指针循环、却说不清为什么正确的人,基本上就是背题的人;而一个先说出不变式、再从不变式推导循环的人,才是真正理解题目的人。实现的语法在压力下是可以补出来的,但推理补不出来——除非你真的掌握了它。

不变式就是正确性的故事。当面试官问“为什么这里可以安全地收缩左指针?”时,答案是:因为窗口仍然有效,所以你没有丢掉任何必需字符。这个回答只有在你先从不变式出发时才会自然而然说出来,而不是从代码出发。根据 Introduction to Algorithms (CLRS),基于不变式的推理,正是正确算法和“碰巧在你跑过的测试里正确”的算法之间的区别。

处理 t 里的重复字符时,别把计数搞坏

为什么“有没有这个字符”是错误判断

minimum substring 问题里最常见的 bug 不是语法错误,而是概念错误:把字符是否存在当成覆盖是否完成。如果 `t = "AABC"`,一个只包含一个 `A`、一个 `B`、一个 `C` 的窗口并不有效。它需要两个 `A`。如果候选人检查的是 `'A' in window`,而不是 `window['A'] >= need['A']`,那他写出来的解法会在简单测试里通过,一旦出现重复字符就会失败。

面试官最爱在这里追问。他们会让你手动跑一个 `t` 含重复字符的例子。如果你的心智模型是“窗口里有没有 `t` 的所有字符”,你就会答错。

这在实践中是什么样子

假设 `t = "AABC"`。`need` 映射就是 `{'A': 2, 'B': 1, 'C': 1}`。一个包含 `"ABC"` 的窗口会有 `window = {'A': 1, 'B': 1, 'C': 1}`。`A` 的计数是 1,但要求是 2。不变式没有满足——只有当 `window['A']` 达到 2 时,`matched` 才应该对 `A` 加一,而不是在它达到 1 时。第二个 `A` 进入窗口之前,不管其他字符覆盖得多全,这个窗口都无效。

`matched` 计数器能把这件事处理得很干净:只有当 `window[c]` 从 `need[c] - 1` 变到 בדיוק `need[c]` 时,才加一。一个 `A` 不会触发加一,第二个才会。这就是为什么基于计数的方法不是优化,而是正确性要求。

面试里最容易说出口的表述方式

口头上的区分很简单:所需频率 vs 观察到的频率。如果每个字符的观察频率都达到或超过所需频率,窗口就有效;如果有任何字符的观察频率低于所需频率,窗口就无效。这样说,重复字符的逻辑就很自然,而不是一个特殊情况。虽然 GeeksforGeeks sliding window explanation 讲了频次表的设置,但它很少把这个不变式说得这么明确。

先向右扩,再只在窗口仍有效时向左收

为什么收缩是安全的,以及什么时候不再安全

这是大多数候选人会跳过的正确性关键动作。左指针之所以可以收缩,只有一个原因:收缩之后窗口仍然有效。窗口一旦变得无效——也就是 `matched` 低于 `len(need)`——你就必须停止收缩。继续往左缩会丢掉一个必需字符,窗口就不再包含 `t` 的全部字符。你在最后一个有效状态记录下来的最优值,才是那个右指针位置下真正的最小值。

这不是启发式做法。这是由不变式直接推出的结论。不变式成立,收缩就安全;不成立,收缩就不安全。算法之所以正确,就是因为它每次都尊重了这条边界。

这在实践中是什么样子

控制流程可以直接这样说:先向右移动右指针,直到窗口有效(`matched == len(need)`)。一旦有效,就把当前窗口大小记为一个候选最优。然后开始向左收缩——一路更新 `window` 和 `matched`——只要窗口仍然有效,就继续收缩,并且每次都记录新的候选最优。当窗口变得无效时,停止收缩,再继续向右扩张。重复这个过程,直到右指针遍历完整个 `s`。

这个 sliding window 面试题模式本质上是两个嵌套阶段:先扩张到有效,再收缩到最小。这两个阶段都不是可选项。扩张阶段负责找到有效性,收缩阶段负责找到最小性。要正确解题,两者都必须存在。

大家最容易含糊过去、也最容易被坑的部分

失败模式是:遇到第一个有效窗口就停下,而不是继续收缩。只记录第一个有效窗口然后往前走的候选人,在简单例子上可能答对,但在那些最小窗口出现在多轮扩张和收缩之后的字符串里就会挂掉。`while matched == len(need)` 这个循环不是细节,而是让你找到“最小窗口”而不仅仅是“有效窗口”的机制。跳过它,得到的解法看起来对,实际上是坏的。

把 ADOBECODEBANC 手工跑一遍,直到模式不再像随机字符串

为什么 ADOBECODEBANC 是最值得背的例子

这个字符串之所以是标准例子,是有原因的:它能把算法的两个阶段都清清楚楚地展示出来。它足够长,扩张阶段会产生多个有效窗口,而且收缩阶段真的会改变答案。如果你能顺着它把每个状态变化讲明白,你就能解释这道算法。根据 LeetCode problem statement for Minimum Window Substring (Problem 76),当 `s = "ADOBECODEBANC"`、`t = "ABC"` 时,期望输出是 `"BANC"`——而要到达这个结果,你必须理解为什么前面那些有效窗口都不是最小的。

这在实践中是什么样子

下面是关键轨迹,使用双指针和频次表状态来描述:

  • 右指针扩张:`A` → `D` → `O` → `B` → `E` → `C`。到 `C` 时,窗口是 `"ADOBEC"`,`ABC` 全部覆盖。`matched = 3`。第一个有效窗口,长度 6。
  • 左指针收缩:移除 `A`。`window['A']` 降到 0,低于 `need['A'] = 1`。`matched` 降到 2。窗口无效。停止收缩。
  • 右指针继续扩张:`O` → `D` → `E` → `B`。窗口是 `"DOBECODEB"`。仍然缺 `A`。继续扩张。
  • 右指针到达 `A`。窗口是 `"DOBECODEBA"`。`matched = 3` 再次成立。有效。长度 10——比 6 更差。
  • 左指针收缩:移除 `D`、`O`、`B`、`E`、`C`、`O`、`D`、`E`。每次移除都安全,因为 `A`、`B`、`C` 仍然被覆盖。窗口缩到 `"BA"`——但 `C` 不见了。无效。
  • 右指针继续扩张:`N` → `C`。窗口是 `"BANC"`。`matched = 3`。有效。长度 4——新的最小值。
  • 左指针再收缩:移除 `B`。`window['B']` 降到 `need['B']` 以下。无效。停止。
  • 右指针用尽。返回 `"BANC"`。

窗口变得更小也更聪明的那个瞬间

关键转折点,是算法第二次扩张后到达 `"BANC"` 的时刻。那时窗口有效,长度 4,已经优于之前最好的 6。算法把它记录下来。然后它试图继续收缩,立刻丢掉 `B`,于是停止。这个序列——有效、记录、收缩直到无效——就是不变式在运作。答案 `"BANC"` 不是猜出来的,而是规则一致执行后的结果。

用 60 秒把答案讲得像是你真的懂了

一个听起来有把握,而不是背诵感的版本

当候选人真正掌握了不变式时,minimum window substring 的面试解释会像这样:

“目标是找出 `s` 中最短的子串,使它以至少所需频率包含 `t` 的每个字符。我会用双指针和频次表。关键不变式是:当窗口里 `t` 中每个字符的实际出现次数都达到或超过所需次数时,窗口才有效。我用 `matched` 计数器来追踪这一点——当某个字符的计数刚好达到要求时它加一,当计数降到要求以下时它减一。

循环分两阶段:先向右扩张直到窗口有效,然后在窗口仍有效时向左收缩,并且每次都记录窗口大小。所有有效状态中的最小值就是答案。时间复杂度是 O(n + m)——每个指针最多向前移动 n 次,而且频次表的大小受字符集大小限制。空间复杂度是 O(m) 用于映射表,或者如果用固定的 128 长度数组表示 ASCII,则是 O(1)。”

这在实践中是什么样子

注意这个解释做了什么:先说目标,再明确说出不变式,再描述状态变量,再解释两阶段循环,最后给出复杂度。它不会一上来就说“我用滑动窗口”,然后指望面试官帮你补全。这里不变式是第二句话,而不是事后补充。这种顺序会让人感觉你理解的是正确性,而不只是实现。

要知道哪些 bug 会让看起来很好的解法在面试里翻车

经典错误:下标偏一、计数失效,以及假装重复字符不存在

在实践中,minimum substring 问题的大多数失败,都来自三个 bug。

用成员关系代替计数。 用 `c in window` 去判断有效性,而不是 `window[c] >= need[c]`。遇到任何含重复字符的 `t`,都会立刻失败。

在收缩时忘记递减 `matched`。 从左侧移除字符,更新了 `window[c]`,却忘了检查 `window[c]` 是否掉到 `need[c]` 以下,也忘了相应地递减 `matched`。结果窗口看起来比实际更久地保持有效,最小值也被错误记录。

在无效状态下记录最优窗口。 在 `matched < len(need)` 时更新最优答案——通常是因为候选人在循环中选错了更新时机,还没确认有效就先判断最小值。

这在实践中是什么样子

成员关系 bug 会在 `t = "AABC"` 时,把一个 `"ABC"` 窗口叫成有效。计数失效 bug 会让收缩循环在窗口已经无效后还继续往左缩,丢掉一个必需字符,并记录一个其实并不包含全部 `t` 的最小值。更新位置错误的 bug 则会在控制流的错误时刻更新答案,导致记录的窗口过大或过小。

这些都是结构性错误,不是手滑打错字。它们来自对“有效性”定义模糊,而不是粗心编码。

你最需要的那点安心

如果你犯过这些错,那不是因为你算法差,而是因为你先想了代码形状,而不是不变式。一旦不变式清楚了——有效意味着满足所有所需频率——这些 bug 在你运行代码之前就会显得明显错误。

不要像背模板一样讲复杂度

为什么它虽然指针动得很多,却仍然是线性

时间复杂度是 O(n + m),其中 n 是 `s` 的长度,m 是 `t` 的长度。理由很简单:左指针和右指针都最多向前移动 n 次,而且它们不会回退。所有指针操作总数被限制在 2n 以内。初始化 `need` 映射需要 O(m)。合起来就是 O(n + m)。算法之所以是线性的,不是因为有什么巧妙技巧,而是因为它有一个结构性性质:双指针配合频次表状态,而且每个指针都是单调不减的。

这在实践中是什么样子

空间复杂度是 O(|Σ|),其中 Σ 是字符集。若频次表用哈希映射实现,空间上界由 `s` 和 `t` 中不同字符的数量决定。实际中,对于区分大小写的字母输入,最多大概是 52;对于完整 ASCII,则是 128。

什么时候固定数组比哈希表更好

如果输入保证是 ASCII,那么长度为 128 的固定数组在实践中比哈希表更快:不用哈希,不用处理冲突,可以直接按下标访问。如果输入可能是 Unicode——任意码点——那就必须用哈希表,因为字符空间太大,不能用固定数组。能清楚地说明这一点,说明你考虑过真实实现中的权衡,而不只是理论复杂度。

把同一个模式用在对的题上,别用在错的题上

什么时候适用,什么时候不适用

滑动窗口面试题模式适用于以下情况:有效性条件依赖于一个连续窗口里字符的频率,窗口可以通过两个指针前进来扩张和收缩,并且最优答案是在满足有效性条件下最小化或最大化窗口大小时得到的。Minimum Window Substring 正好满足这三点。

以下情况则不适用:窗口内字符的顺序很重要(子序列问题),有效性条件要求非连续匹配,或者约束条件不是字符频率。把 substring 问题和 subsequence 问题混为一谈,是很常见的模式匹配错误。

这在实践中是什么样子

字符串中的 anagram 检测(找出 `t` 的某个排列在 `s` 中出现的所有起始位置)使用同样的频次表和双指针结构,但有效性条件更严格:要求的是精确计数,而不是至少计数,而且窗口大小固定。Minimum Window Subsequence 表面上相似,但本质不同:它要求字符按顺序出现,这会彻底破坏左侧收缩逻辑,需要完全不同的思路。

边界一旦问对问题就很清楚:有效性依赖的是连续窗口中的频率,还是窗口内的顺序?如果顺序重要,这个模式就不适用。

面试官真正关心的可迁移能力

真正被测试的能力,不是你有没有背下 minimum window substring 算法,而是你能不能识别出某道题的支配性不变式,围绕它搭建状态机,并从这个基础上推理正确性。这个能力可以迁移到每一道滑动窗口题,也能迁移到更大范围的一类题:在写任何代码之前,先定义什么叫“有效”。根据 Skiena's Algorithm Design Manual,能清楚表达问题不变式的人,往往比只会解题的人更稳定、更有区分度。

Verve AI 如何帮助你准备这道 Minimum Window Substring 面试题

这道题最难的部分,不是写代码,而是在现场压力下、对着一个会不断追问漏洞的人,把不变式清清楚楚地说出来。这是一种表现型技能,而表现型技能需要现场练习,而不是继续阅读。

Verve AI Interview Copilot 就是为这种差距设计的。它会 实时听取 你的口头解释,并且根据你实际说了什么来回应,而不是给你一段预设提示。当你只说“我用滑动窗口”然后停住时,Verve AI Interview Copilot 可以像真实面试官一样追问:“为什么这里可以安全地收缩左指针?”——而你就得当场用那个你是否真正掌握的不变式来回答。这样的练习才有意义。Verve AI Interview Copilot 还可以在屏幕共享时从 OS 层面 保持隐形,所以如果你在和同伴或教练做 mock interview,它会在场,但不会分散注意力。第 6 节里的 60 秒讲解,面试前至少值得你大声练五遍。Verve AI Interview Copilot 是最快的方式,帮你立刻得到诚实反馈:你听起来像是在理解,还是在背诵。

FAQ

Q: 什么是 minimum-window 不变式,怎么判断一个窗口是否有效?

当且仅当 `t` 中每个字符的观察到的计数都达到或超过所需计数时,窗口才有效——不是只要字符存在,而是它至少要出现 `t` 要求的次数。你可以用 `matched` 计数器来追踪这一点;当不变式完全满足时,它等于 `len(need)`。如果 `matched < len(need)`,不管窗口多大,它都是无效的。

Q: `t` 里的重复字符会怎样影响计数逻辑?

重复字符意味着你不能只做成员测试——你必须对每个字符比较观察频率和所需频率。如果 `t = "AABC"`,窗口必须有两个 `A` 才能满足 `A` 的要求。只有当 `window['A']` 达到 `need['A'] = 2` 时,`matched` 才会对 `A` 加一,而不是在它达到 1 时。窗口里有一个 `A` 并不意味着窗口有效。

Q: 怎么在不破坏正确性的前提下收缩左指针?

你只在窗口仍然有效的时候收缩——也就是 `matched == len(need)` 的时候。每次从左侧移除一个字符时,都要更新 `window[c]`,并检查它是否已经低于 `need[c]`。如果低了,就递减 `matched` 并停止收缩。不变式保证了你在收缩阶段记录的每个窗口都是真正有效的,所以其中的最小值也就是真最小值。

Q: 60 秒的面试解释应该是什么样子?

先说目标:找出 `s` 中最短的子串,使其以所需频率包含 `t` 的所有字符。再说不变式:当窗口中每个字符的观察计数都达到所需计数时,窗口才有效。然后描述机制:用 `matched` 计数器追踪有多少字符已经完全满足要求。再解释循环:先向右扩张直到有效,再在有效时向左收缩,并在每个有效状态记录最小值。最后说复杂度:时间 O(n + m),空间 O(|Σ|)。这就是完整答案,顺序也对。

Q: 这道题最常见的 bug 是什么?

三类 bug 最多:用成员关系(`c in window`)代替计数比较(`window[c] >= need[c]`);在收缩阶段当字符计数跌破要求时忘记递减 `matched`;以及在循环的错误位置记录最优窗口——也就是还没确认窗口有效就先更新答案。它们都源于对有效性定义模糊,而不是编码失误。

Q: 这个模式怎么迁移到其他滑动窗口面试题?

只要有效性依赖于连续窗口中的字符频率,而且最优答案是最小化或最大化窗口大小,这个模式就适用。anagram 检测使用同样的结构,但要求精确计数和固定窗口大小。Minimum Window Subsequence 看起来相似,但它要求按顺序匹配,这会破坏左侧收缩逻辑。真正可迁移的能力,是先问:有效性依赖于连续窗口中的频率,还是窗口内顺序也重要?

Q: 怎么一步一步手工跑 ADOBECODEBANC?

右指针扩张经过 `"ADOBEC"`——第一个有效窗口,长度 6。左指针收缩:移除 `A`,窗口变无效,停止。右指针继续扩张经过 `"DOBECODEBA"`——再次有效,长度 10,更差。只要窗口还有效,就继续向左收缩。右指针再扩张到 `"BANC"`——有效,长度 4,新的最小值。尝试收缩:移除 `B`,无效,停止。右指针用尽。返回 `"BANC"`。关键是每一步都跟踪 `matched`,确认何时进入和退出有效状态。

结论

这道题不是陷阱,从来都不是。它之所以看起来像陷阱,是因为大多数讲解都直接跳到实现,省略了不变式——于是面试官一问“为什么这能工作?”,你就无话可说。

答案就是不变式:当每个必需字符都至少出现 `t` 要求的次数时,窗口才有效。其他一切——频次表、`matched` 计数器、先扩张后收缩的循环——都只是对这个条件精确追踪后的直接结果。一旦你真正掌握了不变式,代码就跟着来了;一旦你能把不变式说出口,讲解也就跟着来了。

在下一次面试前,做两件事:把第 6 节的 60 秒讲解反复练到像是在思考,而不是背诵;再亲手把 `ADOBECODEBANC` 完整跑一遍,直到你能在每一步都不看稿地说出 `matched` 的状态。这两次练习,比你反复阅读任何材料,都更能提升你在压力下的表现。

VA

Verve AI

归档内容