在算法面试和刷题这个圈子里,滑动窗口和子串专题一直是让人又爱又恨的存在。说爱,是因为这类题一旦掌握了套路,往往就是一套模板反复用,性价比极高;说恨,是因为很多人看题解时觉得懂了,合上书一写就各种边界错漏。LeetCode hot100里这个专题的题目不算多,但涵盖了无重复字符最长子串、最小覆盖子串、字母异位词分组、和为K的子数组、滑动窗口最大值这些经典中的经典。这期内容我打算把这些题拆开揉碎,把背后的窗口维护逻辑、哈希表配合技巧、以及调试时最容易踩的坑全部讲清楚,帮你彻底拿下这一专题。
这个专题适合正在刷LeetCode准备面试的同学,也适合那些已经把基础算法过了一遍、但碰到字符串和数组区间类题目还是容易懵的开发者。我尽量按照从原理到实战、从简单到复杂的顺序来组织,每道题都会给出可以直接上手使用的代码模板,并且解释清楚每个步骤的设计意图。
1. 内容整体设计与思路拆解
这个专题之所以被各大公司反复考察,核心原因在于它考察了三种非常基础的算法能力:一是对连续区间状态的动态维护能力,二是对哈希表计数与查找的灵活运用,三是对边界条件的缜密判断。我刷了这么多遍hot100,最大的感受是滑动窗口和子串专题不是靠背题解能解决的,真正重要的是建立一套统一的思维模型。
先看一个本质问题:什么时候该用滑动窗口,什么时候该用前缀和+哈希表?我自己的分类方法是看区间是否具有"滑动的单调性"。如果题目要求子串内部满足某个约束条件,且约束条件在窗口扩大时是单向变化的,那么大概率滑动窗口可解。什么意思?比如"无重复字符的最长子串",窗口每纳入一个字符,重复情况只会变多不会变少,这样当窗口内出现重复字符时,左指针就可以放心收缩。这个"单调性"是滑动窗口能成立的前提。
而像"和为K的子数组"这类题目,虽然也是子串,但窗口内数字有正有负时,窗口扩大不一定让总和单向增加,这时候滑动窗口就失效了。需要换成前缀和加哈希表的思路,把问题转化为"两个前缀和的差等于K"的查找问题。这就是为什么hot100里滑动窗口和哈希表经常结伴出现——它们是解决子串问题的两条腿,单靠任何一条都走不远。
再聊聊为什么这个专题对面试很重要。我观察到一个现象,很多候选人能写出二叉树的递归、能默写排序算法,但一遇到字符串子串问题就开始慌。因为这类问题的边界条件实在太碎了,左指针右指针的移动时机错一个就全盘皆输。而面试官恰恰喜欢用这类题来考察候选人的代码基本功和问题拆解能力。通过这个专题的训练,你不仅能应付考试,在日常开发中处理日志解析、文本匹配、数据流统计这些场景时,思路也会清晰很多。
1.1 核心需求解析
把hot100里这个专题的题目放在一起看,需求其实非常集中。一大类是"求满足某条件的最大/最小窗口",比如无重复字符最长子串、最小覆盖子串;另一大类是"统计满足某条件的子串数量",比如和为K的子数组、找到字符串中所有字母异位词;还有一类是"窗口内状态的高效维护",比如滑动窗口最大值。这几类需求的解题模型虽然不同,但底层的观察视角是统一的——把子串看作左边界和右边界围成的区间,问题就是在所有可能的区间里寻找最优解。
我常用的一个思维方法是把"枚举所有子串"作为暴力基线。任意一个子串可以用左右两个下标确定,暴力枚举所有左右下标需要O(n²)的复杂度,而滑动窗口的聪明之处在于,它利用状态转移的连续性,把很多无效的区间直接跳过,从而把复杂度降到O(n)。这是一切优化的出发点,理解了这一点你就明白了为什么滑动窗口这种"看似简单"的解法能成为面试的宠儿。
1.2 滑动窗口与子串的关系
滑动窗口本质上是两指针法的进阶形态。两指针法里有一类是同向双指针,左右指针都只朝一个方向移动,滑动窗口就是同向双指针的典型应用。但滑动窗口比朴素双指针多了一层"窗口状态"的概念——我们需要维护窗口内元素的一个计数表、一个累加值,或者一个最大值集合,并在指针移动时同步更新这个状态。
子串(substring)与子序列(subsequence)的差别在于子串必须是连续的。正因为连续性,子串天然对应了"窗口"这个几何意象。所以这个专题的隐藏主线其实是:给你一个连续的区间搜索问题,如何通过状态复用和单调移动来避免重复计算。我在给模拟项目X做代码评审时经常说一句话:能用O(n)解决的问题,就别整O(n²)的暴力。滑动窗口就是这个"别整暴力"的最直观答案。
2. 滑动窗口三种模式与场景判断
我把滑动窗口分成三种模式,在实战中先判断属于哪种模式,再套对应的代码骨架,准确率会高很多。第一种是寻找最长类窗口,比如无重复字符最长子串;第二种是寻找最短/最小类窗口,比如最小覆盖子串;第三种是固定长度窗口,比如找到字符串中所有字母异位词。每种模式在指针移动策略和状态更新时机上略有差异。
2.1 可变窗口:右指针扩张、左指针收缩
最长类窗口的模板是:右指针不断向后走,每走一步就往窗口里加入新元素,同时检查窗口是否还满足约束条件;不满足时,左指针右移直到窗口重新合法。这个过程中需要不断记录窗口长度并更新答案。最短类窗口则相反,窗口一开始满足条件后,我们尝试收缩左指针,找到以当前右指针为结尾的最短合法窗口,然后记录答案。收缩到不满足条件为止,再继续扩展右指针。
这里有一个初学者最容易犯的错误:最长类窗口在收缩时左指针可能一路移很多步,所以要用while循环而不是if判断。我见过太多人把while写成if,结果左指针只挪了一步,窗口内依然不合法,后面的逻辑全乱套。这个教训我当时是在无重复字符最长子串这道题上踩的,后来也在一场模拟面试里见过候选人现场踩同样的坑,所以特别提一句。
2.2 固定窗口:先初始化再平移
固定长度窗口的思路是在原始序列上先建立一个长度为k的窗口,然后整体向右平移。每移动一步,剔除最左边的元素,加入新的右边元素,再更新窗口状态。这个模式在"找到字符串中所有字母异位词"里很典型,窗口长度固定为模式串的长度,我们只需要比较窗口内各字符的计数和模式串中字符计数是否完全一致。
实现细节上,固定窗口的右指针和左指针是同步移动的,代码写起来往往用两个带偏移的下标控制,也可以直接用两个下标索引。需要注意的点是在初始化第一个窗口时,别漏了"先建立窗口再移动"的顺序,我曾经因为先移动后初始化导致结果里永远少了头几个位置的判断,排查了很久才找到问题所在。
2.3 什么时候不能用滑动窗口
没有"滑动的单调性"时,再漂亮的窗口模板也无能为力。最典型的例子就是和为K的子数组:如果数组里同时有正数和负数,右指针扩展一个元素,窗口总和既可能变大也可能变小,左指针也没有明确的移动依据。这时强行用滑动窗口会漏掉合法答案,唯一的出路就是前缀和+哈希表。
还有一种场景需要注意:如果题目要求窗口内元素顺序无关(比如字母异位词),窗口内部的计数状态就够了;但如果题目对元素顺序敏感(比如最长回文子串),滑动窗口就不太适合,需要考虑其他策略。判断能不能用滑动窗口,我一般会问自己两个问题:窗口扩大/缩小时,状态变化是不是可控的?答案的更新是不是只需要窗口边界信息?两个都是"是",就用。
3. 子串统计的哈希优化套路
子串类的另一大分支是统计数量,这类题的正确姿势是前缀和配合哈希表。核心思想是:如果我们要数有多少个子数组的和等于K,与其枚举每个子数组的左右端点,不如换个角度——对于每个右端点,我们回头看有多少个左端点,使得从该左端点到当前右端点的区间和为K。用数学语言说,设前缀和数组为P[i]表示序列前i个元素之和,那么子数组(i, j]的和等于K等价于P[j] - P[i] = K,也就是P[i] = P[j] - K。于是问题转化为:对每个j,统计之前出现过多少个前缀和等于P[j] - K。
这个思路的关键是边遍历边记录前缀和出现的次数,用哈希表完成O(1)的查询。我特别提醒一点:初始化哈希表时一定要放一个"前缀和为0"的条目,否则从序列开头就满足条件的子数组会被漏掉。这个细节我在实际写和为K的子数组时吃过亏,后来养成了"凡是用前缀和必先初始化map[0]=1"的习惯,相当有效。
3.1 前缀和与哈希表组合:和为K的子数组
把前面的逻辑落实到具体代码上,需要维护一个当前的累计和curSum,以及一个哈希表countMap记录各种前缀和值出现的次数。遍历数组的每个元素,先把curSum更新为加上当前元素后的值,然后去countMap里查curSum - K出现的次数,这个次数就是"以当前元素为右端点且和为K的子数组个数"。累加到答案后,再把curSum在countMap里的次数加一,为后续元素做准备。这里有个顺序问题:必须"先查后更新",否则同一个位置的前缀和会被重复计数,导致结果偏大。
这张哈希表的键是前缀和的值,值是出现次数,本质上是在做"历史状态查询"。因为数组里可以有负数,前缀和的值可能相同多次出现,所以用次数而不是布尔值。这是前缀和哈希和普通哈希查找的一个显著差异,也是初学者比较容易忽略的点。
3.2 字母异位词:字符集计数与窗口比较
找异位词这类题是滑动窗口+计数表的典型融合。对于字符串s中的每个固定长度窗口,我们维护一个长度为26或根据字符集大小定的计数数组,窗口滑动时,减少移出字符的计数,增加移入字符的计数,然后和模式串p对应的计数数组比较是否相等。比较相等这一步如果每次都重新遍历26个位置,窗口有n个,复杂度就是O(26n),常系数较大但量级还是O(n)。如果想优化,可以维护一个diff变量,统计当前窗口里与模式串计数不同的字符数,diff为0时即为命中。这个优化在模式串很长、字符集很大时收益显著。如果字符集不只是字母而是Unicode,计数表换成HashMap的方式更通用,但核心逻辑不变。我在面试时通常会先说基础版本,再提到diff优化,展示自己有复杂度意识。
3.3 窗口状态设计速查表
为了帮助快速判断某个题需要维护什么窗口状态,我整理了这样一个速查表,平时刷题时放在手边非常管用。
| 题目类型 | 窗口内需要维护的状态 | 状态更新方式 | 答案记录时机 |
|---|---|---|---|
| 无重复字符最长子串 | 字符最后出现的位置或字符计数 | 右指针加入时更新,左指针跳过重复 | 每次右指针移动后 |
| 最小覆盖子串 | 窗口内各字符计数 vs 目标字符计数 | 加入/移出时同步更新计数和已覆盖数 | 窗口合法且左指针收缩时 |
| 找到所有字母异位词 | 窗口内字符计数数组 | 固定步长的加入和移出 | 计数数组与目标完全一致时 |
| 和为K的子数组 | 当前前缀和+历史前缀和计数表 | 累加并更新哈希表 | 每次查询哈希表后 |
| 滑动窗口最大值 | 单调递减双端队列 | 入队时淘汰队尾较小值,出窗时检查队首 | 窗口完整后每次记录队首 |
这张表里的每一行都对应了hot100里的一道经典题,记住这些状态设计思路,遇到变体题时照着状态去分析,方向就不会跑偏。
4. 核心代码模板与实操过程
现在到实战环节。我用五道hot100的经典题作为载体,把滑动窗口和子串的代码模板逐个过一遍。每道题的代码都给出完整的可运行版本,并且标注关键步骤的注释。环境以Java为例,因为面试手写代码时Java的写法最通用,但逻辑同样适用于Python和C++,你只需要换个语法外壳。
4.1 无重复字符的最长子串(可变窗口经典)
这题的思路是维护窗口内不重复的子串。我们用哈希表记录每个字符出现的位置,右指针不断右移,当发现某个字符之前出现过且出现在当前窗口内时,直接把左指针跳到该字符上一次出现位置的下一个位置。注意是"下一个位置",不是上一次出现位置本身,这是一个常见的差一错误。然后用右下标减左下标加一更新长度。
public int lengthOfLongestSubstring(String s) { Map<Character, Integer> lastIndex = new HashMap<>(); int left = 0; int maxLen = 0; for (int right = 0; right < s.length(); right++) { char c = s.charAt(right); if (lastIndex.containsKey(c) && lastIndex.get(c) >= left) { left = lastIndex.get(c) + 1; } lastIndex.put(c, right); maxLen = Math.max(maxLen, right - left + 1); } return maxLen; }这里面lastIndex.get(c)表示字符c最近一次出现的位置,如果这个位置大于等于当前左指针,说明窗口内确实已经有重复了,需要移动左指针。这个判断"是否还在窗口内"是必须的,因为字符可能出现在窗口左侧之外的老位置,那种情况不用管。右指针每次扩展后都更新最长长度,因为此时窗口必然是合法的,不需要额外的合法性检查循环。
4.2 找到字符串中所有字母异位词(固定窗口模板)
异位词要求的是"长度相同且字符种类和数量完全相同",所以我们建一个和模式串p长度相同的窗口。核心是利用一个计数数组,判断数组是否全零。一种做法是:先把p中的字符计数,然后滑动窗口时,进入窗口的字符计数减一,离开窗口的字符计数加一。如果某个瞬间所有计数都为零,则窗口内字符与p是异位关系。
public List<Integer> findAnagrams(String s, String p) { List<Integer> result = new ArrayList<>(); if (s.length() < p.length()) return result; int[] count = new int[26]; for (char c : p.toCharArray()) count[c - 'a']++; int left = 0, right = 0; while (right < s.length()) { char rc = s.charAt(right); count[rc - 'a']--; while (count[rc - 'a'] < 0) { char lc = s.charAt(left); count[lc - 'a']++; left++; } if (right - left + 1 == p.length()) { result.add(left); } right++; } return result; }这段代码用了一个非常优雅的技巧:一旦窗口内某个字符数量超了,就移动左指针直到恢复正常。当窗口长度正好等于p的长度,且所有计数都不为负,那么必然所有字符数量恰好匹配。这个写法比每次重新比较两个数组更简洁,也更快。如果你理解了这一段,实际上也就理解了"零一窗口"的动态平衡思想。
4.3 最小覆盖子串(最短类窗口的集大成者)
这题是hot100里难度偏高的一道,考察点在于如何在窗口滑动的过程中判断"当前窗口是否已经覆盖了t中的所有字符"。做法是用一个need哈希表记录t中各字符的需求量,同时维护一个needCount表示当前还没满足需求的字符种类数。右指针扩展时,把窗口内出现的字符对应的需求减一;如果某个字符的需求从正数变成零,说明该字符种类已经全部匹配,needCount减一。当needCount为零时,开始收缩左指针,同时把从窗口移出的字符对应的需求加一,如果某个字符的需求从零变正数,needCount加一。
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 needCount = need.size(); int left = 0, right = 0; int minStart = 0, minLen = Integer.MAX_VALUE; while (right < s.length()) { char rc = s.charAt(right); if (need.containsKey(rc)) { need.put(rc, need.get(rc) - 1); if (need.get(rc) == 0) needCount--; } right++; while (needCount == 0) { if (right - left < minLen) { minLen = right - left; minStart = left; } char lc = s.charAt(left); if (need.containsKey(lc)) { need.put(lc, need.get(lc) + 1); if (need.get(lc) > 0) needCount++; } left++; } } return minLen == Integer.MAX_VALUE ? "" : s.substring(minStart, minStart + minLen); }需要注意,needCount统计的是"字符种类数"而不是"字符总数",这是这个解法里最重要的一个概念。如果用总数来代表覆盖程度,那么"a"出现两次和"a""b"各出现一次会被混淆,导致判断错误。用种类数配合每个字符的需求量变化,才能准确判断覆盖状态。
4.4 和为K的子数组(前缀和哈希模板)
这道题在前面已经把原理讲透了,直接看代码实现。核心就是维护一个curSum和countMap,先查countMap[curSum - K],再更新curSum的计数。
public int subarraySum(int[] nums, int k) { Map<Integer, Integer> countMap = new HashMap<>(); countMap.put(0, 1); int curSum = 0; int result = 0; for (int num : nums) { curSum += num; result += countMap.getOrDefault(curSum - k, 0); countMap.put(curSum, countMap.getOrDefault(curSum, 0) + 1); } return result; }读这段代码时我建议你跟着一组小数据走一遍,比如[1, -1, 0], k=0,你会发现输出是3,对应的三个子数组是[1,-1]、[0]、[1,-1,0]。走一遍用例就能建立起"前缀和出现的次数就是可选左边界数量"的直觉。这种直觉比背代码重要得多,因为面试时稍微变形一下,比如求被K整除的子数组数量,核心思路完全一样,但哈希表的键要换成余数。彼时如果只有代码记忆没有思路理解,现场会很被动。
4.5 滑动窗口最大值(双端队列优化)
这题如果直接用优先队列,复杂度是O(n log k);如果要上O(n),必须用到单调双端队列。队列里从队首到队尾,元素对应的值单调递减。每次窗口右移,新的元素从队尾入队前,先弹出队尾所有比新元素小的值;同时,如果队首元素已经滑出窗口左边界,就把它从队首弹出。这样队首始终是当前窗口最大值。
public int[] maxSlidingWindow(int[] nums, int k) { int n = nums.length; int[] ans = new int[n - k + 1]; Deque<Integer> deque = new ArrayDeque<>(); for (int i = 0; i < n; i++) { while (!deque.isEmpty() && nums[deque.peekLast()] <= nums[i]) { deque.pollLast(); } deque.offerLast(i); if (deque.peekFirst() <= i - k) { deque.pollFirst(); } if (i >= k - 1) { ans[i - k + 1] = nums[deque.peekFirst()]; } } return ans; }队列里存的是数组下标而不是值,这一点很关键。因为只有存储下标才能判断元素是否已经滑出窗口,且在弹出队首时直接用下标和i - k比较即可,不需要额外数据结构。这里的小细节是弹出条件用的是<=而不是<,确保重复最大值时新的下标能留下来,这样在后续滑动中不会被误判为过期元素。
5. 常见问题与排查技巧实录
这部分我把自己刷题和陪跑模拟面试过程中遇到的经典问题整理一下,很多都是查半天才能发现的隐性坑。
5.1 Integer缓存引发的哈希表判断错误
在Java里,使用HashMap时如果键是Integer类型,比较值相等一定用equals或者直接比较intValue。有个非常经典的坑:当Integer的值在-128到127之间时,有缓存机制,==可能返回true;但超过这个范围就返回false了。如果你用getOrDefault拿到Integer后直接和某个int比较,会自动拆箱没问题。但如果你写的是if (map.get(key) == 0)这种代码,遇到的值如果在缓存范围内还能侥幸通过,范围外就逻辑错乱。建议养成习惯:从Map取值后立即拆箱成int再比较,不要用对象直接做==判断。
5.2 窗口左右指针的边界差一错误
计算窗口长度这个操作,看似简单,但左闭右开还是左闭右闭的写法不同,结果差1。我统一用"左闭右闭"的表示法:窗口覆盖下标left到right时,长度等于right - left + 1。当右指针右移后再更新答案,不会遗漏任何窗口;当左指针收缩时,left++之后窗口长度同步变化。另外,初始化left=0, right=0时,窗口包含第一个元素;如果初始化right=-1,那首次循环要记得先right++再处理,两种写法别混用。
5.3 计数HashMap的键清理问题
在最小覆盖子串这类题目中,如果用need这个HashMap来记录需求,字符移出窗口时需求会加一,如果需求恢复成正数还好,如果一直维持零或负数,这个键会一直存在。如果后续需要统计need.size()作为"还有多少种字符没满足",就要求我们绝对不能去删除值为0的键,否则needCount会错。正确做法是把needCount单独维护,永远不要依赖need.size()做动态判断。当时我在这块卡了很久,被误导后一直以为size在变化,实际应该看的是"需求从正变零"和"需求从零变正"这两个事件。
5.4 滑动窗口、双指针与普通嵌套循环的分辨
很多人在概念上把滑动窗口等同于双指针,这没错,但实操中有一个区别:滑动窗口的左右指针不需要回溯,整体复杂度O(n);而像是"最长回文子串"这类题,两指针需要从中心向外扩展,每个中心都要重新计算,那是O(n²)的思路,虽然也叫双指针,但不是滑动窗口。面试时如果能把这个区别主动讲给面试官听,会显得你不仅会套模板,还真的理解了方法的适用边界。
6. 本专题的面试应对策略与优先级建议
很多人把hot100题目刷了一遍就觉得自己掌握了,但真到面试现场,被问一个变体就懵了。我建议在复习这个专题时,不只是刷题,而是把每道题目的"不可变核心"与"可变因子"分开理解。不可变核心就是状态维护方式和指针移动逻辑,可变因子是题目在长度、字符集、数组值域、顺序约束上做的调整。比如"无重复字符的最长子串"修改为"最多包含两个不同字符的最长子串",核心没变,只是窗口收缩的条件从"出现重复"变成"种类数超过2"。如果你平时就是这么拆解题目,这类变体在面试时基本就是送分题。
关于优先级,我的建议是无重复字符最长子串、和为K的子数组、最小覆盖子串必须能默写,这三道覆盖了三种最重要的解法模型;字母异位词和滑动窗口最大值建议能手撕,因为它们分别涉及到固定窗口和单调队列的进阶技巧。面试被问这个专题的概率极高,建议每周至少手写一遍这几道经典题,特别是维护状态那几行,一定要形成肌肉记忆。
在面试现场讲滑动窗口的思路时,有一个常用的表达框架:先说暴力枚举的复杂度,然后说窗口维护如何消除无效枚举;接着说明窗口满足什么条件时需要移动左指针、如何移动;最后分析复杂度为O(n)。这个框架既能展示你的思考过程,也能让面试官顺着你的逻辑追问,掌握节奏的主动权。切忌一上来就甩代码,那样很难展现算法设计能力。
我个人在实际操作中的体会是,滑动窗口和子串专题值得反复刷,每刷一遍都能发现新的理解层次。第一遍你可能只是照着模板写,第二遍你会开始思考为什么左指针可以跳着移动,到了第三遍,你看到一道新题时会自然而然地先判断它属于哪种子串模型。这个从“背模板”到“建模子”的转变,正是刷这个专题最大的收获。最后再分享一个小技巧:刷完每道题后,自己试着改写一个变体条件,比如把固定窗口改成可变长度、把哈希表改成数组计数,再用同样的思路去解,这会比多刷十道同质化题目更能提升你的内功。