滑动窗口这个专题,我在LeetCode Top100里刷完又反复咀嚼了好几轮,也在面试中作为候选人被问过、作为面试官问过别人。很多人对这类题有种“看着简单,一写就乱”的感受——无非就是两个指针加一个哈希表,但真到面试现场,指针移动的时机、窗口收缩的条件、边界的处理,三句话讲不清楚就露怯了。这篇博文我不打算把Top100里所有滑动窗口题贴一遍答案,而是想把这类题从原理到模板、从模板到套用、从套用到面试表达,完整梳理一遍,给你一套拿到题就能稳定输出的体系。
先说清楚本文适合谁:正在准备Java后端岗位面试的候选人,不管你是刚刷题的新手还是刷了几百题但总觉得不扎实的进阶选手,都能从中拿到可复用的东西。我会围绕Top100中最高频的几道滑动窗口题展开,拆解它们的共同套路,分析每道题的特殊之处,再把我踩过的坑和面试中的表达经验一并交代清楚。内容不追求把每道题的所有解法都铺开,只讲那些面试能加分、写代码能省时间的核心思路。
1. 滑动窗口到底在面试中考察什么
1.1 为什么Top100里滑动窗口密度这么高
如果你把LeetCode Top100的题目按算法标签统计一下,会发现滑动窗口相关题目占比相当可观。这背后有明确的逻辑:Top100是各大公司面试题的浓缩,面试官喜欢出这类题,不是因为它们怎么难,而是因为它们能够在一次代码书写中同时考察三个核心能力——对连续子数组(子串)问题的敏感度、对双指针边界条件的掌控能力、对数据结构的熟练运用。
滑动窗口题还有一个隐性优势:它天然具备“从暴力解法逐步优化到高效解法”的梯度。面试官可以先让你给出暴力解,再引导你优化到O(n)解法,这个过程能非常直观地看到一个人的思维路径。所以不是刷题机构把这类题炒热的,是面试官真的爱用,Top100自然就把它们收进去了。
1.2 滑动窗口和暴力解的本质差异
很多初学者对滑动窗口的理解停留在“两个指针维护一段区间”这种层面,这种理解没错,但不够本质。滑动窗口的真正价值在于它把“重复扫描”变成了“增量更新”。
给你一张长纸条,纸条上写着一串数字,要求找出所有连续子数组中满足某个条件的最短/最长区间。暴力解法是先固定左端点,然后右端点一路扫过去,每扫一个位置就统计一次区间内的情况。假设数组长度是n,暴力解法需要枚举O(n^2)个子数组,每个子数组还要花时间统计,总复杂度往往到O(n^2)甚至更高。
滑动窗口的做法是:左右指针都只朝一个方向移动,每次只更新因为指针移动而产生的增量变化。左指针向右移动时,将一个元素移出窗口;右指针向右移动时,将一个元素加入窗口。每次移动的代价是O(1)而不是把整个窗口重新计算一遍。这样总的时间复杂度降为O(n),空间复杂度看统计手段,通常O(1)或O(字符集大小)。
这里有个很容易混淆的点:滑动窗口的O(n)前提是“窗口的扩展和收缩都是O(1)更新”。你如果每次扩展都用循环重新扫描窗口内所有元素,那复杂度就又回去了。后面讲模板时我会强调这一点。
1.3 什么时候该用滑动窗口:三个触发信号
我在面试中常被问到一个问题:“你什么时候会想到用滑动窗口?”这个问题比“请你写一道滑动窗口算法”更能考察对模型的理解。根据我的经验,触发信号有三个:
第一,题目涉及的是连续区间、连续子串、连续子数组,而不是任意组合的子序列。滑动窗口天然只适合连续区间,遇到子序列问题请立刻转向动态规划或回溯。
第二,题目要求的答案是“满足某个条件的最长/最短区间,或某个定长区间内的统计值”。比如最长无重复子串、最短覆盖子串、定长窗口的最大值、包含所有目标字符的最小子串,这些都属于滑动窗口的标准业务场景。
第三,窗口的内层统计可以通过增量方式维护。也就是说,当窗口变化时,我们能以O(1)或O(logn)的成本更新当前窗口的信息。如果窗口每次变化都要重新计算大量内容,滑动窗口就不一定是最优解。
这三条信号像雷达一样,一但全部命中,你就可以快速锁定滑动窗口方案。我建议你在平时的训练中有意识地用这三个条件去分析每道连续区间题目,两个月后你的题感会有明显提升。
2. 一套通用的Java滑动窗口模板,所有题都能套
2.1 模板的完整代码结构
先明确一个观点:滑动窗口题虽然看起来五花八门,但绝大多数都逃不出下面这个模板。你可以把它当作一套流程,先按模板写,再根据题目要求调整细节。
public int slidingWindowTemplate(String s) { // 1. 数据结构准备 Map<Character, Integer> window = new HashMap<>(); int left = 0, right = 0; int ans = 0; int n = s.length(); // 2. 扩展窗口 while (right < n) { char c = s.charAt(right); window.put(c, window.getOrDefault(c, 0) + 1); right++; // 3. 不满足条件时收缩窗口 while (需要收缩窗口的条件判断) { char d = s.charAt(left); window.put(d, window.getOrDefault(d, 0) - 1); if (window.get(d) == 0) { window.remove(d); } left++; } // 4. 更新答案 ans = Math.max(ans, right - left); } return ans; }这个模板的骨架是:右指针负责扩展,左指针负责收缩,收缩到满足某种条件后更新答案。几乎所有滑动窗口题都能在这个骨架上微调出解,区别只在于“需要收缩的条件”和“更新答案的位置”。
2.2 模板里每个指针移动的时机怎么理解
理解模板不能只看代码,得把指针移动的时机想透。
右指针的移动时机最简单:只要窗口还没扫到数组末尾,就不断向右扩展。每扩展一次,就把新加入的元素更新到统计结构中。这里有新手经常犯糊涂的地方——right++这一行代码放在哪里。我在模板里是先更新右指针指向的字符,再让右指针右移,这样后续窗口区间统一用[left, right)这个前闭后开的区间表示。在写代码时固定一种区间表示法非常重要,能显著减少边界bug。
左指针的移动时机是所有滑动窗口题的核心分歧点。有的题目要求窗口在“不满足条件”时收缩,比如无重复字符的最长子串;有的题目要求窗口在“满足条件”时收缩,比如长度最小的子数组。判断标准很简单:看题目求的是“最大窗口”还是“最小窗口”。
求最大窗口时,我们希望窗口尽量大,所以当窗口内出现违规元素时收缩左指针,直到窗口回到合法状态;求最小窗口时,我们希望窗口尽量小,所以一旦窗口内的条件被满足,就尝试收缩左指针,直到条件刚刚不再满足为止,然后记录这个“临界点”的窗口大小。这两个方向搞反了,代码写出来总是差那么几个测试用例过不了。
更新答案的位置也是同理:求最大窗口通常在收缩后更新(此时窗口处于合法且尽量大的状态),求最小窗口通常在收缩过程中更新(每次收缩都记录一下当前窗口大小,最终取最小)。
2.3 为什么HashMap计数是首选而不是别的结构
在Java实现里,最常用的统计结构是HashMap<Character, Integer>。有人会问,为什么不用数组?这里其实有个性能与通用性的权衡。如果题目限定了字符集,比如只有小写英文字母,那么用int[26]数组作为计数器会更省空间也更快。但如果字符集不确定,比如Unicode字符或者数字和其他字符混合出现,数组方式就不好办了,HashMap更通用。
我在面试中会更倾向于先和面试官确认字符集范围。如果面试官说“字符串只包含小写字母”,我会毫不犹豫用int[26],代码更简洁;如果没限制,就直接用HashMap,稳妥。这种小细节在面试中是加分项,说明你注意到问题的边界条件而不是闷头写代码。
另一个需要特别说明的是window.remove(d)这一步。有些模板不会移除计数降为0的键,而是保留0值。这样做的风险在于后续如果要判断“窗口内是否包含字符x”,用window.containsKey(x)就会误判。所以我在收缩时会将计数降为0的键从Map中移除,保持Map的键集合始终是窗口内实际存在的字符集合。
3. Top100滑动窗口六道题逐个拆解
3.1 无重复字符的最长子串:最经典的入门题
题目编号3,这是几乎所有刷题人的滑动窗口入门题。题目要求:给定一个字符串,找出其中不含有重复字符的最长子串的长度。
这道题完美适配上文模板,收缩条件是“窗口内出现重复字符”。为了检测重复,我维护一个Map<Character, Integer>记录窗口内每个字符出现的次数。当右指针新加入的字符在窗口内已经存在时,说明窗口不合法了,此时左指针不断右移,直到把重复字符移出窗口为止。
public int lengthOfLongestSubstring(String s) { Map<Character, Integer> window = new HashMap<>(); int left = 0, right = 0; int ans = 0; int n = s.length(); while (right < n) { char c = s.charAt(right); window.put(c, window.getOrDefault(c, 0) + 1); right++; while (window.getOrDefault(c, 0) > 1) { char d = s.charAt(left); window.put(d, window.getOrDefault(d, 0) - 1); if (window.get(d) == 0) { window.remove(d); } left++; } ans = Math.max(ans, right - left); } return ans; }这里有个细节容易写错:收缩条件写成window.get(c) > 1还是window.containsValue(2)。后者时间复杂度O(n)只在窗口内字符种类较多时不会出错但效率差,所以要锁定“当前加入的字符出现了重复”这个条件即可。因为重复发生时,必然是刚加入的字符触发的,检查它就行。
这道题还有一种更讨巧的解法:用Map<Character, Integer>记录每个字符最后出现的下标,左指针直接跳到重复字符上一次出现的后一位,不需要逐格收缩。但如果你要训练滑动窗口思维,建议还是先把基础收缩模板吃透。
3.2 找到字符串中所有字母异位词:窗口大小固定的套路
题目编号438。给定两个字符串s和p,在s中找到所有p的异位词的起始索引。所谓异位词,就是字符相同但排列不同。
这道题和上一道最大的区别在于窗口大小固定为p.length()。你可以把它理解为“定长滑动窗口”的标准题。定长窗口的常见写法是先初始化一个长度为p.length()的窗口,然后窗口整体向右滑动,每次加入一个新的字符、移出一个旧的字符,比较窗口的字符计数与p的字符计数。
public List<Integer> findAnagrams(String s, String p) { List<Integer> result = new ArrayList<>(); if (s.length() < p.length()) { return result; } int[] pCount = new int[26]; int[] windowCount = new int[26]; for (int i = 0; i < p.length(); i++) { pCount[p.charAt(i) - 'a']++; windowCount[s.charAt(i) - 'a']++; } if (Arrays.equals(pCount, windowCount)) { result.add(0); } for (int i = p.length(); i < s.length(); i++) { windowCount[s.charAt(i) - 'a']++; windowCount[s.charAt(i - p.length()) - 'a']--; if (Arrays.equals(pCount, windowCount)) { result.add(i - p.length() + 1); } } return result; }注意这里因为明确说明是小写字母,所以用int[26]比较方便。这个解法的时间复杂度是O(n),但每次比较数组相等是O(26),可以视为常数级。
这道题比无重复字符串更好懂,但有个新考点:连续的窗口滑动中,如何在加入新字符和移除旧字符之间保持计数同步。这里有一个非常容易错的点:先加再减和先减再加没有本质区别,因为这两个操作针对的是不同位置的字符,顺序不会互相影响。但你不能先查看windowCount[s.charAt(i)]再更新,容易导致新窗口的计数不完整。
3.3 滑动窗口最大值:单调队列的用法
题目编号239,这是整个滑动窗口系列中最特殊的一道题。前面几道题统计窗口内容用的是计数类结构,这道题要求的是窗口内最大值,计数结构解决不了。它需要引入一个非常经典的数据结构:单调队列。
题目给定数组nums和窗口大小k,窗口从数组左端滑到右端,每次返回窗口内的最大值。
思路是:维护一个双端队列,队列中存的是数组下标。下标对应的数组元素值在队列中保持单调递减,队首永远是当前窗口内的最大值。窗口滑动时,如果队首下标已经不在窗口范围内,就将其弹出;新元素入队时,从队尾依次弹出所有比新元素小的元素,再将其入队。
public int[] maxSlidingWindow(int[] nums, int k) { int n = nums.length; int[] result = new int[n - k + 1]; Deque<Integer> deque = new ArrayDeque<>(); for (int i = 0; i < n; i++) { // 移除窗口外的元素 while (!deque.isEmpty() && deque.peekFirst() < i - k + 1) { deque.pollFirst(); } // 保持单调递减 while (!deque.isEmpty() && nums[deque.peekLast()] < nums[i]) { deque.pollLast(); } deque.offerLast(i); // 从窗口形成时开始记录 if (i >= k - 1) { result[i - k + 1] = nums[deque.peekFirst()]; } } return result; }单调队列的精髓在于:维护队列时,那些“又老又小”的元素可以放心丢弃,因为它们既不在窗口最左端(会被淘汰),也不是最大值候选(有更大的新元素)。这就是单调队列高效的根本原因——每个元素最多入队一次出队一次,整体O(n)。
这道题在面试中出现的频率极高,而且很多候选人能写出模板但讲不清为什么弹掉比新元素小的元素是对的。面试官一追问就卡住了。所以你要准备好那套“贡献度“的表达:对于任意两个下标i<j,如果nums[i]<=nums[j],那么nums[i]在窗口滑动过程中对结果的贡献永远不会超过nums[j],可以先淘汰。
3.4 最小覆盖子串:最综合的一道Hard题
题目编号76,这道题是滑动窗口系列里综合度最高的一道,也是Top100中标记为Hard但用标准模板很容易解的题。题目要求:给定字符串s和t,在s中找到包含t所有字符(包括重复字符)的最短子串,并返回该子串。
关键点在于如何判断“窗口已覆盖t”。用两个map:一个记录t中每个字符的需求量need,一个记录窗口中对应字符的数量have。再用一个变量matched记录当前窗口有多少种字符已经达到需求数量。当matched == need.size()时,窗口就是一个可行覆盖。
public String minWindow(String s, String t) { if (s.length() < t.length()) { return ""; } Map<Character, Integer> need = new HashMap<>(); Map<Character, Integer> window = new HashMap<>(); for (char c : t.toCharArray()) { need.put(c, need.getOrDefault(c, 0) + 1); } int left = 0, right = 0; int matched = 0; int minLen = Integer.MAX_VALUE; int start = 0; int n = s.length(); while (right < n) { char c = s.charAt(right); window.put(c, window.getOrDefault(c, 0) + 1); if (need.containsKey(c) && window.get(c).intValue() == need.get(c).intValue()) { matched++; } right++; while (matched == need.size()) { if (right - left < minLen) { minLen = right - left; start = left; } char d = s.charAt(left); if (need.containsKey(d) && window.get(d).intValue() == need.get(d).intValue()) { matched--; } window.put(d, window.get(d) - 1); left++; } } return minLen == Integer.MAX_VALUE ? "" : s.substring(start, start + minLen); }我用这个模板的时候碰到过一个特别隐蔽的坑:window.get(c).intValue() == need.get(c).intValue()这个判断里,如果直接写window.get(c) == need.get(c),就有大问题。因为Integer在-128到127之间有缓存,超过这个范围的整数比较会走equals而不是==。你在写比较逻辑时如果用了==,在测试时可能因为数据范围小没问题,但实际上线后某些场景直接肉眼可见地出bug。写力扣题虽然一般不会触发,但我建议你在写代码时就从良好习惯上做好——包装类型比较一律用equals或.intValue()。
这个解法中,我用了matched变量来避免每次判断窗口状态都遍历整个map,这是一个重要的优化思路。很多候选人能写出两层循环但忽略了判断窗口是否覆盖t也是需要优化的一环,导致提交后超时。这也是滑动窗口“增量更新”思想的延伸——不只是窗口内字符的数量要增量更新,窗口的“合法状态”也要增量维护。
3.5 买卖股票的最佳时机:窗口思想的另类应用
题目编号121。严格来说,这道题不算滑动窗口的标准题型,但Top100里它常被归到滑动窗口或双指针标签下,而且面试中经常出现。题目要求:给定一个数组prices,其中prices[i]表示第i天的股票价格,你只能买卖一次,求最大利润。
这道题的最优解是维护一个变量minPrice记录历史最低价,然后遍历每天的价格,用当天价格减去minPrice得到当天卖出的潜在利润,取最大值。如果你把这个过程理解为“左指针记录窗口内的最小值,右指针不断扫描价格”,它其实就是一个简化版的滑动窗口模型。
public int maxProfit(int[] prices) { int minPrice = Integer.MAX_VALUE; int maxProfit = 0; for (int price : prices) { if (price < minPrice) { minPrice = price; } else if (price - minPrice > maxProfit) { maxProfit = price - minPrice; } } return maxProfit; }这道题思路简单,但它是面试官测试你“是否真的理解了问题本质”的试金石。因为很多人上来就背“双指针模板”,却不知道这里只需要一个左指针的“影子”即可——窗口的左边界在逻辑上会跳着走,但代码上用一个最小值变量就足够。
逻辑很简单,但你千万别小看它。在面试中,这道题一般会作为热身题或压轴题的铺垫。面试官可能会追问:“如果允许你无限次买入卖出呢?”这时你就要从滑动窗口切换到贪心算法。这个追问环节考察的是你对不同算法模型的理解深度。
3.6 长度最小的子数组:前缀和加窗口
题目编号209。给定一个正整数数组nums和一个正整数target,找出数组中满足其和大于等于target的长度最小的连续子数组,返回其长度。如果不存在则返回0。
这道题表面上是求“最小窗口”,实际上有一个更隐蔽的前缀和解法。由于数组中全是正整数,可以维护一个前缀和数组prefixSum,然后对每个右端点用二分查找找到满足prefixSum[right] - prefixSum[left] >= target的最左左端点,复杂度从O(n)变成O(nlogn)。但在面试中我更推荐标准的滑动窗口解法,因为思路更简单且O(n)更优。
public int minSubArrayLen(int target, int[] nums) { int left = 0; int sum = 0; int ans = Integer.MAX_VALUE; for (int right = 0; right < nums.length; right++) { sum += nums[right]; while (sum >= target) { ans = Math.min(ans, right - left + 1); sum -= nums[left]; left++; } } return ans == Integer.MAX_VALUE ? 0 : ans; }这道题的收缩条件与前面最大窗口题不同:每当窗口的和大于等于target时,就尝试收缩左边界,并且在收缩过程中维护最小长度。注意sum -= nums[left]这行代码必须在left++之前执行,顺序不能反。很多新手先移动left再减去元素,结果减的是错误的值,测试用例一跑就露馅。这个顺序本质上对应的是“窗口左边界还没移动时,左指针指的元素还在窗口内,应该先把它移出统计,再移动指针”。
4. 面试现场最容易翻车的细节盘点
4.1 循环边界和区间表示法:千万要统一
滑动窗口题中,最常见的bug来源就是边界不一致。建议你从第一天起就统一采用[left, right)左闭右开区间表示法,在这个规则下,窗口长度是right - left,有效元素是left到right-1。
相应地,外层循环条件就应该是while (right < n),而不是while (right <= n)。如果你不小心写成了后者,就会在right==n时访问charAt(n),抛出StringIndexOutOfBoundsException。另一个容易混淆的场景是定长窗口题目里记录结果的位置:如果窗口区间是[left, right),那么在right >= k之后,每次更新结果时,窗口左端点就是right - k。
我特意在第二章的两套代码里都用右开区间风格书写,就是为了让你养成固定的习惯。平时训练时,哪怕题目和模板不完全一致,也要保持区间风格统一,避免在面试高压状态下左右端点混乱。
4.2 收缩时机:while还是if
这个点看似简单,却在实战里坑了无数人。在收缩窗口时,有的题目只需要收缩一格就可以停下来(比如定长窗口),有的题目需要连续收缩直到满足/不满足某个条件。
核心判断标准是:收缩动作是否会立刻让目标条件逆转。比如“无重复字符的最长子串”中,左指针需要一直右移直到重复元素被彻底移出窗口,这时必须用while;而如果只是判断窗口大小是否超过k,用一个if就够了。写错的话,一个是用if导致窗口还处于非法状态就更新答案,一个是用while导致窗口收缩过度,漏掉最优解。
这里我给你一个经验判断办法:如果你不确定该用while还是if,就在脑子里跑一个额外干扰项的例子。比如字符串“abca”这个场景,右指针扫到第二个a时,如果只用if收缩一次,窗口变成“bca”,里面仍然有重复吗?没有,因为重复的a被移出去了——这种例子会导致你误以为if就够了。但换成“abbca”,右指针扫到最后一个a时,重复的b和a都要处理,你只用if缩一次还是会有重复。所以不安全。只要存在“收缩一次可能仍然不满足条件”的可能,就必须用while。
4.3 Integer缓存问题:什么时候用==什么时候用equals
如果你在滑动窗口题中用HashMap统计字符频次,并且用window.get(c) == need.get(c)来判断两个Integer是否相等,那你就找到通往bug的捷径了。
Java中Integer类型在-128到127之间的值会走缓存,直接用==比较这个范围内的数值,结果通常是true;一旦数值超过127,==比较的是引用地址,返回false。面试题里如果字符串恰好比较短,比如t中某个字符出现了130次,这个bug就极有可能触发。
所以我在代码里统一使用window.get(c).intValue() == need.get(c).intValue()或者window.get(c).equals(need.get(c))。你可能会觉得这跟算法无关,但面试官就是喜欢在这种地方埋坑,观察你对语言细节的敏感度。
4.4 删除计数为0的键:保持Map状态干净
在HashMap版本的滑动窗口模板中,当字符计数减到0时从Map中移除该键,这个习惯非常值得养成。表面上,保留键值对也不会影响结果,因为后续判断窗口是否包含某字符时可以检查count != 0。但问题在于,如果你同时用window.containsKey(c)来判断字符是否存在,保留0值键就会出现逻辑错误。
你可能会说:“那我在判断时不使用containsKey就行了。”这当然可以,但滑动窗口模板往往要处理多个判断条件,你很难保证自己每次写代码时都记得排除0值的情况。最稳妥的做法是:收缩窗口时一旦某个字符计数变为0,就立即从Map中remove掉。这个习惯在多道题之间迁移时能帮你减少大量debug时间。
5. 把滑动窗口练成肌肉记忆的刷题路线
5.1 推荐题单和刷题顺序
如果你时间有限,不想把所有滑动窗口题都刷一遍,我建议你按以下顺序刷题,按难度和类型递进,10道以内就能建立起比较完整的知识框架。
第一优先级是基础题:LeetCode 3(无重复字符的最长子串)、LeetCode 209(长度最小的子数组)、LeetCode 76(最小覆盖子串)。这三道题覆盖了“滑动窗口最长”、“滑动窗口最短”和“带条件目的匹配”三种最核心的场景。
第二优先级是定长窗口和数据结构综合题:LeetCode 438(找到字符串中所有字母异位词)、LeetCode 567(字符串的排列)、LeetCode 239(滑动窗口最大值)。这些题目在前三类基础上增加了定长窗口和单调队列等新特性。
第三优先级是变体题和延伸题:LeetCode 424(替换后的最长重复字符)、LeetCode 1004(最大连续1的个数 III)、LeetCode 480(滑动窗口中位数)。这些题能帮你检验自己对模板的掌握程度,因为它们往往需要一点额外的数据结构或思维技巧。
刷题时要注意,同一道题至少刷两遍。第一遍是理解解法并默写模板,第二遍是隔一段时间后不看答案复现代码,并且要求自己在十分钟内完成主体框架。这样到了面试现场,你才能在心理压力下稳定输出。
5.2 面试中如何向面试官讲清楚你的思路
很多候选人代码写得没错,但面试讲解环节扣分严重。这一环节考察的是你的逻辑表达能力和对代码的理解深度。我建议你按照这个顺序来讲:
第一步,概述题目并点明题型。用一句话说明问题本质:“这是一个在连续子串中求满足特定条件的最大长度问题,适合用滑动窗口求解。”
第二步,说明窗口的维护逻辑。明确告诉面试官:“我定义左右指针维护窗口区间,右指针负责扩展窗口,当窗口内出现不合法状态时,左指针收缩窗口。”
第三步,说明状态更新的细节。这一步最容易被忽视:你要明确指出你用了什么数据结构来维护窗口状态,为什么选它,以及如何增量更新。比如:“我用HashMap来记录窗口内每个字符出现的次数,因为题目要求的是字符级别的计数统计;当我判断窗口覆盖了目标串的所有字符时,用一个matched变量记录当前已有多少种字符达到需求数量,它随着窗口变化同步更新。”
第四步,分析时间复杂度。一句话讲清楚为什么是O(n):因为每个元素最多进入窗口一次、离开窗口一次,每次进出的更新操作是O(1)。
这套表达结构只要练熟了,面试官基本能平稳理解你的思路,很少会再追问特别刁钻的问题。
5.3 做完一道题后,如何系统复盘
复盘是整个刷题过程中增值最大的环节。我给自己制定了四个固定问题,每做完一道题都强制问一遍。
第一问:这道题和哪道已做过的题相似?相似在哪?区别在哪?比如你做完LeetCode 76后,再去看LeetCode 438,会发现后者就是“固定窗口长度版本的76”,这样你就能把题目串成知识网络而不是一个个孤立答案。
第二问:如果用暴力解法做,瓶颈在哪里?滑动窗口是怎么绕过这个瓶颈的?这个问题帮助我把算法本质理解得更深一层。
第三问:如果改变条件,解法会怎么变?比如把字符集从小写字母扩展到Unicode字符,代码哪里要改?这种变式训练让你对模板的边界非常敏感。
第四问:这道题能不能用其他算法解?比如209除了滑动窗口还能用前缀和加二分搜索,两种解法的适用场景有什么区别?这样你会跳出机械记忆,理解算法选择的权衡。
我发现很多刷了三百道题的人,面试时仍然一遇到陌生题就卡住,原因就是复盘不够。刷题数量不等于掌握程度,真正拉开差距的是每道题之后的花式提问和主动延展。
我自己带过不少实习生,见过太多一上来就套模板,结果题目一变就懵的案例。滑动窗口这个专题其实非常适合用来锻炼“识别题目模型”的能力,因为它的识别信号非常明确——连续区间、最值条件、可增量维护的统计信息。你只要把这三条雷达立起来,再配合统一的代码模板、严格的边界习惯、系统的复盘方法,这类题在面试中基本就属于送分题了。
最后再给一个实操层面的小建议:如果你用的是Java刷题,强烈建议你在练习时就养成封装好各种常用数据结构的习惯,比如用Deque而不是Stack,用HashMap的getOrDefault而不是先判空再赋值。这些语言细节在面试现场会不自觉地流露出来,它们不会直接决定你的面试结果,但一定能帮助你更游刃有余地应对那些出其不意的追问。