news 2026/10/22 6:28:28

滑动窗口算法实战:从最小覆盖子串到变体题通解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
滑动窗口算法实战:从最小覆盖子串到变体题通解

1. 这是一道让我刷了三遍才真正懂的题

先说个题外话。我在某公司带过几个实习生,每次聊到算法面试,十个有八个会栽在滑动窗口上。不是没刷过题,而是刷完之后过两周再遇到变体——“字符串排列”“最长无重复子串”“找到所有字母异位词”——又像没见过一样。我一直觉得,问题就出在很多人只记住了代码模板,没理解窗口本质上在做什么。

今天想用“最小覆盖子串”这道题,把滑动窗口这件事尽可能讲透。

题目背景是经典的LeetCode第76题:给你一个字符串s,一个字符串t,要求在s中找出包含t所有字符的最短子串。注意,t中重复字符也要被覆盖。比如t="ABC",那子串必须同时有A、B、C各至少一个;如果t="AABC",那子串必须至少有两个A、一个B、一个C。

这道题考的不只是“双指针”三个字。它背后是一整套窗口管理的思路:什么时候扩右边界、什么时候收缩左边界、用什么数据结构统计字符、如何判断窗口是否“覆盖”了目标串、如何在收缩过程中保持最优解。任何一个环节想得不够清楚,代码就很容易写出那种“测试一过、换组数据就崩”的状态。

这篇文章不会只丢一段标准答案。我会把这道题从暴力解开始推演,讲清楚为什么必须用滑动窗口,再一步步拆解窗口的实现细节,最后给出完整代码、调试过程、常见坑点,以及面试时怎么讲才能让面试官觉得你是真的理解,而不是背题。

2. 为什么暴力解法撑不住,滑动窗口到底解决了什么

2.1 暴力解是怎么做的,以及它慢在哪

先用最直接的方式想:要找到s中包含t所有字符的最短子串,最简单的方法当然是枚举s中所有子串。

假设s的长度是n。s中总共有n(n+1)/2个不同子串,这个数量级是O(n²)。对每个子串,你要统计它里面是否包含t的全部字符,这一步如果也用遍历计数的方式做,那就是O(n)。整体复杂度瞬间变成O(n³)。稍微长一点的字符串,比如几百个字符,这个算法就已经肉眼可见地卡顿了。如果你没听说过O(n³)意味着什么,简单感受一下:n是1000的时候,理论上要做10亿次级别的字符统计操作,这已经不是你本地跑着玩能忍受的级别了。

当然,你可以优化一下判断覆盖的过程,比如提前把t的字符统计存好,每个子串用哈希表增量统计,但即使这样,你仍然要枚举所有子串,整体复杂度还是O(n²)起步,而且每换一个起点都要重新开始统计,之前的统计结果完全废掉,浪费了大量重复计算。

这里有个很关键的反省点:暴力解到底浪费在哪里?答案是,它把每一个子串都当成一个孤立的对象,没有利用相邻子串之间的重叠关系。举个例子,s="ADOBECODEBANC",当你已经统计了子串s[0:5] = "ADOBEC"的字符分布,想去判断s[1:6] = "DOBECO"时,理论上只需要把A拿掉、把O加上就行。但暴力解会把整个“DOBECO”重新数一遍。这种重复劳动,是滑动窗口要消灭的核心浪费。

2.2 双指针窗口如何把复杂度压到O(n)

滑动窗口的思路,其实一句话就能说清楚:用一个连续区间,从s的左边逐步向右移动,区间右端负责“扩”,左端负责“缩”,全程保证区间内的字符统计是动态维护的。每当窗口覆盖了t的所有字符,就尝试左移左边界来缩短窗口,记录最短长度;一旦收缩到不满足覆盖条件,就继续右移右边界,循环往复。

这样做的妙处在于:每个字符至多被右边界扫描一次、被左边界扫描一次,所以总体的字符访问次数是O(n)级别,窗口内的字符计数也不需要重复计算——右边界扩进一个字符,计数器加一;左边界缩出一个字符,计数器减一。整个过程是“增量式”维护,而不是每次重新统计。

这也是为什么滑动窗口类题目几乎都有一个共性:题目问的是“连续子串/子数组”,并且涉及“满足某个条件的最小/最大长度”时,优先考虑双指针维护动态窗口。你以后做变体题时,把“连续”“最值”“条件覆盖”这三个关键词抓住,基本可以快速定位到位方法。

提示:滑动窗口不是银弹。如果题目不要求连续,或者条件满足后不能通过单方向移动来判断收缩,那这种双指针结构就不适用。判断时要注意题目里的“连续”二字。

2.3 覆盖条件的本质:别只看“有”,还要看“够”

很多人第一次写这道题的判断逻辑时,会想得很简单:把t的字符放进一个集合,窗口内只要包含集合里的所有字符就行。写成代码可能是“for c in t: if c not in window: return false”。这种写法在t没有重复字符的测试用例上能跑对,但一旦t="AABC",局面就尴尬了——窗口里有A、B、C各一个时,你的集合判断会认为已经覆盖了,实际上A还缺一个。

根子在于,覆盖的本质不是一个“有没有”的问题,而是一个“计数够不够”的问题。t中每个字符都有一个需求量,窗口内对应字符的数量必须不小于这个需求量,才算覆盖。

所以正确的做法是维护两个计数结构:一个记录t中每个字符的需求量(need),另一个记录当前窗口内每个字符的实际数量(window)。判断覆盖时,比较的是每个相关字符在两个结构中的数量大小,而不是单纯看存在性。

那怎么高效判断“每个字符都够”,又不需要每次遍历所有字符类型?这里就用上了“满足类型数计数器”——一般记为matched或valid或count。它统计的是:当前窗口里,有多少种字符已经达到了需求量。比如t="AABC",字符类型有A、B、C三种,当窗口的计数在某一时刻变成A≥2、B≥1、C≥1,valid就等于3,此时窗口满足覆盖条件。

这个优化很关键。如果没有valid计数器,每次判断覆盖都要遍历一遍need表的所有键,效率会退化;有了valid,判断覆盖变成了O(1)的等值比较。

3. 滑动窗口的完整设计:从数据结构到收缩时机

3.1 用什么结构存字符统计:哈希表还是数组

这道题里的字符范围如果只是大小写字母,那你完全可以用长度为128的数组来做计数,字符的ASCII码直接当下标。用数组的好处是写起来直观、访问速度快,而且不用处理哈希表里那些“键不存在”的边界。比如c++风格的写法,可以直接int need[128] = {0},window[128] = {0},然后window[s[right]]++,这种操作在哈希表里还得先判断键存不存在,在数组里完全不用纠结。

如果你面对的是更复杂的字符集(比如Unicode全量字符,或者题目明确说字符范围很大),那就用哈希表。语言不同写法略有差异,Java是HashMap<Character, Integer>,Python是dict。核心思路一样,实现上多几个getOrDefault、containsKey的调用。

我的建议是:笔试或面试时,如果题目没明确说字符范围,先用代码里方便的方式实现——大多数情况下用数组就够了。但你要能说出“如果字符集会很大,我会改用哈希表”这句话,这会让面试官觉得你考虑了扩展性,而不是只会套模板。

3.2 窗口的循环结构:先扩后缩,并且每一步都问“还满不满足”

完整窗口管理的骨架如下:

  1. 初始化left=0,right=0,窗口区间记作[left, right),这里推荐左闭右开。
  2. 不断把right向右移动,每次移动都把s[right]纳入窗口并更新window计数。
  3. 如果某个字符在窗口中的计数恰好等于need中的需求量,valid加1。
  4. 只要valid等于需要满足的字符种类数(need中值大于0的key数量),说明窗口已经覆盖了t。
  5. 在满足覆盖条件时,尝试收缩:先把当前窗口长度记录下来,如果比已有答案短,就更新答案;然后把left向右移动,每移出一个字符,就更新window计数,并判断移出后是否导致该字符不再满足需求量,如果是,valid减1。
  6. 重复2~5,直到right遍历完整个s。

第5步是很多人的盲区。请注意收缩窗口的时机是“满足覆盖条件”时,而不是“找到一个之后停一下再继续”。因为我们要的是最短长度,所以满足条件时就应该尽可能收缩,收缩到刚好无法覆盖,然后再继续右移,寻找下一个可能的更短答案。这个“满足即收缩”的模式,是这类题的通法。

还有一种容易犯迷糊的写法:在right扩展的过程中也检查是否覆盖,一旦覆盖就立即更新答案。这种写法不是不行,但如果你在扩展过程中更新,那可能漏掉“扩展后窗口覆盖、但收缩一下仍然覆盖且更短”的情形——因为你并没有在收缩路径上去记录更短答案。所以标准做法是,每次覆盖后进入收缩循环,在收缩的每一轮都记录一次答案,这样保证答案是覆盖状态下最靠右压缩的窗口。

3.3 窗口区间用左闭右开还是左闭右闭

我很推荐在写这种窗口题时统一使用左闭右开区间[left, right),理由有两点。

第一,初始化时窗口为空,left=0、right=0正好表示空区间,不需要额外赋初值,漏讨论“空窗口”的边界。第二,right表示“下一个待加入的字符下标”,所以右移时先加字符再right++;left表示“当前窗口最左字符下标”,所以左移时先移除s[left]再left++。两个边界的操作逻辑在语义上不会打架。

如果你习惯左闭右闭[left, right],也不是不能写,但要注意:初始化left=0, right=-1才是空区间;右移时是right++后加入s[right];左移时是移除s[left]后left++。这种写法容易在“空区间”和“左右交换”这种边界上出错,很多人写着写着就懵了。我的建议是,如果你还在初学阶段,直接统一成左闭右开,能省不少内耗。

注意:无论用什么区间定义,必须前后一致。最怕的是代码里一会儿用right - left + 1算长度,一会儿又用right - left,这种前后不一致的错误非常难排查。

4. 手把手跑通完整代码:Python与Java双版本

4.1 Python完整实现与逐行解释

先说实现整体思路。我们用need字典记录t中每个字符的需求量,用window字典记录当前窗口内字符的实际数量。整个代码骨架可以分为外层右移循环、内层收缩循环、判断和更新答案三大部分。下面这段我直接贴完整代码,你可以照着跑:

def minWindow(s: str, t: str) -> str: from collections import defaultdict need = defaultdict(int) for c in t: need[c] += 1 # 需要覆盖的字符类型总数 need_cnt = len(need) window = defaultdict(int) # valid 表示当前窗口中有多少种字符已经达到需求量 valid = 0 left = 0 right = 0 # 答案初始化:最小长度设为一个很大的数 min_len = float('inf') ans_start = 0 while right < len(s): # 右边进入窗口的字符 c = s[right] right += 1 # 只有当前字符在 need 中,才更新 window 和 valid if c in need: window[c] += 1 if window[c] == need[c]: valid += 1 # 当 valid 等于 need_cnt,说明所有字符都已满足需求量 while valid == need_cnt: # 当前窗口长度 cur_len = right - left if cur_len < min_len: min_len = cur_len ans_start = left # 准备收缩,移除左边字符 d = s[left] left += 1 if d in need: if window[d] == need[d]: valid -= 1 window[d] -= 1 return s[ans_start: ans_start + min_len] if min_len != float('inf') else ""

几个关键点我拆开说。

一是字符进入窗口时,只有c存在于need中,才需要更新window和valid。为什么?因为如果c不在t里,它对覆盖条件没有任何贡献,它进入窗口只会增加窗口长度,不可能让valid增长。我们最终寻找的是最短覆盖子串,那些无关字符当然是越少越好——它们会让窗口变长,但对覆盖没有正向作用。所以代码里用“if c in need”直接跳过,能省下不必要的哈希操作。

二是valid增加的条件是window[c] == need[c],而不是window[c] >= need[c]。因为valid记录的是“已经达到需求量的字符种类数”,一旦等于,之后就算再加,也不应该再次增加valid。比如窗口里A有3个,need里A需要2个,在window[A]从2变3的过程中,valid已经加过了,就不能再加。同理,收缩时window[d] == need[d]时valid减1,说明这字符刚好从满足变成不满足。

三是最短的答案记录在收缩的每一轮里。注意我用的窗口长度是right - left,因为在左闭右开区间下,这正好表示[left, right)的元素个数。比如left=0, right=6,那区间长度是6。这个细节我曾经看到不止一个人写成right - left + 1,导致长度恒多1,输出会截多一个字符。

四是最短长度初始化为inf。这里有个小坑:如果用0初始化,那任何窗口长度都比0大,永远得不到更新,结果会一直输出空串。这个细节笔试时容易翻车,建议直接用浮点inf或者很大的数。

4.2 Java版本与哈希表细节处理

Java版本思路完全一致,主要差别在哈希表操作上。HashMap的getOrDefault是高频操作,很多时候你忘了判断键是否存在就直接get(),会返回null,然后你试图对null做加法,直接NPE。我写了不少遍之后,习惯了统一使用getOrDefault。

class Solution { public String minWindow(String s, String t) { Map<Character, Integer> need = new HashMap<>(); for (char c : t.toCharArray()) { need.put(c, need.getOrDefault(c, 0) + 1); } int needCnt = need.size(); Map<Character, Integer> window = new HashMap<>(); int valid = 0; int left = 0, right = 0; int minLen = Integer.MAX_VALUE; int ansStart = 0; 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).intValue() == need.get(c).intValue()) { valid++; } } while (valid == needCnt) { if (right - left < minLen) { minLen = right - left; ansStart = left; } char d = s.charAt(left); left++; if (need.containsKey(d)) { if (window.get(d).intValue() == need.get(d).intValue()) { valid--; } window.put(d, window.get(d) - 1); } } } return minLen == Integer.MAX_VALUE ? "" : s.substring(ansStart, ansStart + minLen); } }

这里有几个Java特有的注意点。

一是window.get(c).intValue() == need.get(c).intValue()比较的是int值而非Integer引用。因为Java中Integer在-128到127之间有缓存,超出这个范围的两个Integer对象用==比较可能为false,造成valid更新错误。虽然本题中字符计数大概率不会超过127,但养成用intValue()或equals判断的习惯没有坏处。

二是每次收缩后window.put(d, window.get(d) - 1)。有人会写window.put(d, window.getOrDefault(d, 0) - 1),这其实也没问题,只是语义上略显不明。更重要的时不能忘了put回去。Map里的值并不是引用型对象,put之后你必须把更新后的值塞回去,否则下次get到的还是旧值。

三是String.substring的参数区间是[start, end),用ansStart和ansStart + minLen来切割正好。

4.3 用一个具体例子完整走一遍窗口流程

纸上得来终觉浅,我们用s = "ADOBECODEBANC",t = "ABC"完整过一遍算法流程。这个例子恰好是题目自带的经典样例,非常能说明问题。

初始化:need = {'A':1, 'B':1, 'C':1},needCnt=3,window为空,valid=0,left=0,right=0,minLen=inf。

第一轮:right指向s[0]='A'。A在need中,window['A']=1,正好等于need['A'],valid变成1。此时valid=1,不等于3,不进入收缩循环。窗口为"A"。

第二轮:right指向s[1]='D'。D不在need中,跳过,valid仍为1。窗口为"AD"。

第三轮:right指向s[2]='O'。同上,窗口变为"ADO"。

第四轮:right指向s[3]='B'。B在need中,window['B']=1,等于need['B'],valid变成2。窗口变为"ADOB",此时已包含A、B,缺C。

第五轮:right指向s[4]='E'。窗口变为"ADOBE"。

第六轮:right指向s[5]='C'。C在need中,window['C']=1,等于need['C'],valid变成3。注意此刻valid == needCnt,进入收缩循环。

收缩循环开始:当前区间left=0, right=6,窗口"ADOBEC"长度为6,记录答案ansStart=0,minLen=6。然后移除左边s[0]='A'。A在need中,window['A']本来是1,等于need['A'],于是valid减1变成2,然后window['A']减为0。收缩后valid=2,不满足覆盖了,退出循环。

第七轮:right指向s[6]='O'。O不在need中,窗口"DOBECO",valid保持2。

第八轮:right指向s[7]='D'。D不在need中,窗口变为"DOBECOD",valid仍2。

第九轮:right指向s[8]='E'。E不在need中,窗口"DOBECODE"。

第十轮:right指向s[9]='B'。B在need中,window['B']原来是1,现在加1变成2。注意window 'B' 不等于need 'B' ,所以valid不变,仍是2。此时窗口"DOBECODEB"中B有两个,但A和C还是不够。

第十一轮:right指向s[10]='A'。A在need中,window['A']从0变成1,等于need['A'],valid变成3。再次进入收缩循环。

此刻状态:left=1, right=11,窗口区间是s[1:11]="OBECODEBA"。长度为10,比已有的6大,不更新答案。然后尝试收缩:移除s[left]=s[1]='O'。O不在need中,valid不变,left变成2。

继续收缩:移除s[2]='B'。B在need中,window['B']当前为2,不等于need['B'],所以valid不变,window['B']减为1,left变成3。

继续收缩:移除s[3]='E',E不在need中,left变成4。

继续收缩:移除s[4]='C'。C在need中,window['C']当前为1,等于need['C'],valid减1变成2,window['C']减为0,left变成5。此时valid=2,不满足覆盖,退出收缩循环。

第十二轮:right指向s[11]='N'。N不在need中,窗口继续向右扩。

第十三轮:right指向s[12]='C'。C在need中,window['C']从0变成1,等于need['C'],valid变成3。此时窗口区间是s[5:13]="BANC",长度4,比之前的6小,更新答案为ansStart=5,minLen=4。

然后收缩:移除s[5]='B'。B在need中,window['B']当前1,等于need['B'],valid减1变成2,window['B']减为0。退出循环。此时right=13等于s长度,循环结束。

最终返回s[5:5+4] = "BANC"。流程跑完,答案确实正确。

这里有一个细节值得再说一次:第二次扩张到满足条件时,为什么答案是“BANC”而不是“OBECODEBA”或者中间的某个区间?因为收缩循环中我们每次都先判断当前长度,再移除左边字符。第一次进入收缩循环时left=1,right=11,长度是10,大于已有最小6,所以不更新。移除到left=5时,区间"CODEBA"已经不足覆盖(缺C)。直到再次扩到right=13、窗口"CODEBANC"覆盖后,从left=5收缩到left=6时,区间"BANC"长度4,更新答案。整个过程中,答案永远是覆盖状态下的最左侧位置。

5. 这套思路如何延伸到五类高频变体题

5.1 变体一:找到字符串中所有字母异位词

LeetCode第438题。给定两个字符串s和p,返回s中所有p的异位词的起始索引。比如p="abc",那么在s里找所有包含一个a、一个b、一个c的等长子串位置。

这道题和最小覆盖子串的区别在于:不再需要“最短”和“收缩”,而是要求窗口长度固定等于p的长度。思路几乎相同,只需要在valid == needCnt时判断窗口长度是否正好等于p的长度,如果是,记录left,然后无论如何都收缩一次(把left右移一格),保持窗口长度恒定滑动。

换句话说,最小覆盖子串的收缩条件是“满足覆盖且尽量缩”,而异位词题目的收缩条件是“固定长度强制缩”。理解了两者的差异后,代码改动其实只差几行。

5.2 变体二:无重复字符的最长子串

LeetCode第3题。这个变体不走“覆盖”路线,判断条件变成了“窗口内是否没有重复字符”。

实现那题时,很多人直接用HashSet记录窗口内字符,但更通用的思路其实是维护一个窗口计数器,当某个字符的计数超过1时,说明窗口内有重复,开始收缩。这里甚至不需要need字典,valid概念变成了“有重复就收缩直到恢复无重复”。本质上还是一个“满足条件时尽量扩、不满足条件时收缩”的滑动窗口框架。

如果你已经理解最小覆盖子串的valid机制,再看这道题会感觉非常轻松——无非就是valid的触发条件从“所有字符都达到需求量”变成了“某个字符计数不大于1”。

5.3 变体三:字符串的排列

LeetCode第567题。判断s2中是否存在s1的某个排列。比如s1="ab",s2="eidbaooo",返回true,因为"ba"出现在s2中。

这题几乎是字母异位词题目的换皮版本,只不过返回类型从“所有起始索引列表”变成了布尔值。核心逻辑还是固定窗口长度等于s1的长度,滑动过程中判断valid是否等于needCnt。

如果你把最小覆盖子串的代码改成固定窗口版本,这题只需要再改返回值即可,基本上五分钟内就能写完。

5.4 变体四:最长覆盖子串(手动反向思考)

有人会问,既然76题是最短覆盖子串,那如果题目改成“最长覆盖子串”呢?

其实最长覆盖问题通常没有唯一解——因为一旦窗口覆盖了t,窗口往右扩任意长度,它仍然覆盖t,最长会变成整个s。所以这类题往往会加额外条件,比如“不含多余重复”“不超过k种字符”等,让它变成另一种约束下的最值问题。理解了这一点,你就会明白,最短和最长虽然听起来对称,但解题策略完全不同:最短需要“满足即收缩”,最长往往需要“不满足再收缩”。

5.5 变体五:最多包含k种字符的最长子串

这题也是经典面试题,思路是窗口内维护不同字符的种类数。当种类数超过k时,收缩左边界;否则右移扩大窗口。本质上还是“什么时候收缩”的选择题。

从最小覆盖子串到这些变体,你会发现核心代码骨架都长得差不多:右移right、更新计数器、判断条件、决定收缩时机、记录答案。真正需要训练的,是看到不同题目后快速确定三件事——窗口维护的数据是什么、满足条件是什么、收缩的时机是什么。

6. 常见错误与调试实录:这些坑我全都踩过

6.1 坑一:valid的加减时机写反了

这是我最初写这题时最常犯的错误。最容易出错的场景是扩展字符时,写成if window[c] >= need[c]: valid++。这样会导致同一个字符在超过需求量后继续触发valid增长,valid会大于needCnt,最终收缩条件失效。

正确的时机是window[c] == need[c]时valid++,因为这是该字符从“不够”变成“刚好够”的临界点。同理,收缩时必须是window[d] == need[d]时valid--,因为那是它从“刚好够”变成“不够”的临界点。很多人写成window[d] <= need[d]或者window[d] < need[d],结果valid跳来跳去,调试到怀疑人生。

6.2 坑二:窗口长度计算错误

左闭右开区间下,窗口长度是right - left。我曾经在代码里写成了right - left + 1,然后在测试用例上跑了很久,发现输出老是多一个字符。排查方式也很简单:打印每一轮left和right的值,以及当前窗口内容,很快就发现了。

如果你也遇到输出长度不对劲,第一件事就看你的区间定义和长度计算是否自洽。左闭右闭就处处用right - left + 1,左闭右开就处处用right - left,千万不要混用。

6.3 坑三:忘记处理“s中不存在覆盖子串”的情况

如果遍历完整个s都没能得到一个覆盖窗口,minLen还是初始的inf,代码如果直接substring就会出问题。标准做法是最后判断一下minLen是否还是inf,如果是,返回空字符串。

这个错误在绝大多数人能跑过的测试用例里不会触发,所以你很可能写完样例测试就交上去了。但面试官或在线判题系统一旦给了s="a", t="aa"这种用例,就直接暴露问题。提前加一行判断,能堵住这种边界漏洞。

6.4 坑四:把无关字符也加进了window统计

有些初学者为了让“窗口统计”逻辑统一,对所有进入窗口的字符都执行window[c]++,包括那些不在need里的字符。这样虽然不会导致valid计算错误(因为那些字符永远不会触发valid++),但会白白增加哈希表的大小,并且代码里到处需要判断need.containsKey,逻辑变得更乱。

规范做法是只在字符属于need时更新window。有人担心这样会丢掉“无关字符”的信息——但想一想,覆盖条件只关心t里的字符,无关字符数量多少都不影响判断,所以根本没有维护的必要。

6.5 调试实录:我用一个极简样例把valid变化画了出来

当初我自己彻底想明白这题,是拿一个极简单的例子手推了五遍:s="ABA",t="AA"。那时候我还没画过valid变化表,总是觉得valid像玄学。后来我列了一张表,每轮记录right、left、window、valid、minLen,走完三遍之后,整个逻辑就刻在脑子里了。

这里我把那个推演过程简要说一下,供你参考:

初始化need={'A':2},needCnt=1。

第一轮right=0,字符A进窗口,window['A']=1,不等于need 'A' ,valid不变仍是0。窗口"A"不足覆盖。

第二轮right=1,字符B进窗口,B不在need中,跳过。窗口"AB",valid仍0。

第三轮right=2,字符A进窗口,window['A']变成2,等于need['A'],valid变成1,进入收缩。记录当前窗口"A B A"长度3,minLen=3。收缩:移除s[0]='A',window['A']从2减到1,因为window['A']等于need['A']是2,现在不等于2,所以valid减为0。此时left=1,窗口"BA"不覆盖,退出收缩。

循环结束,返回答案是"ABA"。

如果你亲手画过这个流程,你会发现valid的本质就是一个“覆盖状态指示器”,它只有在临界点才变化。理解了这一点,后续所有变体题都会顺很多。

6.6 面试讲解的节奏建议

这道题在面试里出现频率不低,我建议你在面试官面前讲思路时,按这个节奏走:

第一句先亮本质:“这道题是典型的滑动窗口题,我用两个指针维护窗口区间,用哈希表统计t的字符需求量和窗口内的实际字符量。”

第二句讲清楚valid的作用:“为了避免每次判断都遍历哈希表,我维护一个valid计数器,统计已经满足需求量的字符种类数。当valid等于t中出现的字符种类数时,窗口覆盖了t。”

第三句讲收缩逻辑:“每次覆盖后,我尝试收缩左边界,不断寻找更短的覆盖窗口,直到不满足覆盖为止。每轮收缩前记录一次答案。”

第四句讲复杂度:“每个字符进出窗口各一次,所以时间复杂度O(n),空间复杂度O(k),k是字符集大小。”

这套话术能让人快速抓住你确实理解了算法本质,而不是单纯背题。

7. 为什么这道题值得二刷三刷,以及我的个人建议

如果你现在正在准备算法面试,我建议这道题至少亲手写三遍。

第一遍,照抄标准答案,跑通所有测试用例。第二遍,关掉答案,手写完整代码,直到你能一次性编译通过。第三遍,换个变体题(比如字母异位词那题),用同样的框架去解,看看你能不能在上面的骨架基础上改出来。

我自己带过的人里,凡是老老实实走完这三遍的,后面遇到滑动窗口题目基本不会再慌。相反,那些只刷一遍、背下代码的,过两周连valid为什么要先加后减都说不清楚。

还有一种更牢固的练习方式:拿一个白板或者纸笔,把第4节那个推演过程自己走一遍,每一步都写下left、right、window、valid的当前值。这个过程虽然看起来很基础,但能真正帮你建立对“临界点”的直觉——什么时候valid变化,为什么变化,以及收缩条件是怎么触发的。有了这种直觉,以后写窗口类型的题,你根本不需要临时去回忆模板。

最后再分享一个小技巧:遇到这类“求连续子串最值”的题,先别急着写代码,花一分钟在纸面上确定三件事——窗口里维护什么统计信息、满足条件是什么、什么时候收缩。三件事想清楚了,代码只是翻译工作。这比直接上手敲,然后陷入debug泥潭要高效得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 6:27:51

Qt QTableWidget分页实现:从零构建高性能虚拟化表格

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/22 6:28:15

跨境独立站服务器怎么选?从节点到配置的完整清单(2026 实操版)

做跨境独立站&#xff0c;服务器选错比选贵更麻烦——访客打不开、店铺卡顿&#xff0c;直接影响转化。本文不推荐具体商家&#xff0c;只讲一套可落地的选型清单&#xff0c;照着核就行。 一、先定节点&#xff1a;访客在哪&#xff0c;服务器就在哪 跨境业务选服务器&#xf…

作者头像 李华
网站建设 2026/10/20 13:25:07

PCL三维凸包计算实战:QuickHull原理、参数详解与体积估算

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/20 17:57:56

MP2642GR-Z+PIC18F57Q43实现2S电池主动均衡:从硬件到策略

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/20 17:56:02

Python解包原理:从迭代协议到生产级安全解包实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华