
1. 这道题的本质不是找子串而是维护不重复区间第一次刷到无重复字符的最长子串时我差点被最长两个字带偏以为要先枚举所有子串再逐个检查。暴力解法确实能解——两层循环枚举起点和终点用Set判断区间内有没有重复字符时间复杂度O(n²)字符串一长就顶不住。直到我理解了滑动窗口这个思路才意识到这道题真正考察的东西如何在一次遍历中动态维护一个窗口让窗口内的字符永远不重复然后持续记录窗口的最大宽度。你可能会问为什么一定要用滑动窗口换个角度看这个问题如果我们固定左边界右边界越往右扩展区间内出现重复字符的概率就越高。一旦出现重复单纯移动右边界已经没有意义因为以当前左边界开头的所有更长子串必然仍然包含这个重复。这时候必须把左边界往右挪挪到足以排除那个重复字符的位置然后继续让右边界往前走。整个过程正好对应两个指针的交替移动——右指针负责扩张左指针负责收缩窗口就像一条在字符串上爬行的尺子永远量出一段不含重复字符的区间。这个思路的厉害之处在于每个字符最多被左指针和右指针各经过一次总时间复杂度O(n)空间上只需要一个容器记录字符的最近出现位置O(min(m, n))m是字符集大小。对于纯ASCII字符集m就是128或者256可以看作常数空间。我在实际刷题和面试讲解中发现很多人的困惑不在滑动窗口这四个字本身而在于窗口到底怎么滑动。具体来说就是右指针遇到重复字符时左指针应该一次性跳到哪个位置HashMap记录的出现位置到底是更新前还是更新后这几个细节搞不明白代码就容易出现差一错误off-by-one要么漏掉正确答案要么死循环。这篇文章就专门拆解这些细节先讲清楚滑动窗口的移动策略再对比两种常见实现方案最后把最容易踩的坑逐个点出来。不管是用Java、Python还是C刷思路都是通用的。2. 滑动窗口的核心机制右边界扩张 左边界收缩的配合逻辑2.1 右指针的无脑扩张维护不重复是左指针的责任滑动窗口的代码骨架并不复杂核心逻辑是右指针从字符串头部开始一个字符一个字符地往右移动每移动一步就把当前字符纳入窗口。刚看到这里你可能会觉得这不就是暴力法的改进版吗关键在于右指针移动时不判断当前这个字符是否会造成重复判断和纠正的工作全部交给左指针。右指针每指向一个新字符我们就面临一个问题这个字符是不是已经在窗口里了如果在那就意味着从当前左指针到右指针这个区间内存在两个相同的字符这个区间作为无重复子串已经不合法了。此时如果还让右指针继续往右走窗口虽然更长但必然还是包含这个重复字符长度再大也没意义。所以必须在右指针继续前进之前先把左指针调整到合适位置让窗口重新变得干净。这里有一个容易想当然的地方左指针到底应该移动到哪里很多人第一反应是左指针往右移动一格试试。这样当然能解决问题但效率不是最优。假设字符串是abcadbe右指针走到第二个a时窗口是abcad左指针逐个往后挪一位窗口变成bcad虽然不重复了但原本窗口中bca这些字符是可以保留的丢掉它们完全是浪费。正确的做法是直接把左指针跳到窗口中第一次出现该重复字符的位置的下一位也就是跳到b的位置窗口变成bcad一步到位。所以滑动窗口最核心的操作就是左指针的跳转而不是一格一格地挪。这也决定了我们用什么数据结构来记录字符的位置——必须能快速查询某个字符最近一次出现在哪里。2.2 左指针的两种跳转策略贪心跳 vs 逐步挪为了说清楚这个问题我用一个例子演示两种处理方式的差别。字符串 s abcabcbb从头开始模拟右指针r遍历字符r0(a)窗口为a无重复maxLen1r1(b)窗口为ab无重复maxLen2r2(c)窗口为abc无重复maxLen3r3(a)重复窗口中已有a且a在索引0位置。逐步挪左指针从0挪到1窗口bca无重复maxLen仍为3贪心跳左指针直接跳到011窗口bca结果相同r4(b)此时窗口是bcab已经在索引1位置。逐步挪左指针从1挪到2窗口cab无重复maxLen仍为3贪心跳左指针从1跳到112窗口cab结果相同r5(c)此时窗口是cabc已经在索引2位置。逐步挪左指针从2挪到3窗口abc注意这时窗口里的字符是a(3),b(4),c(5)无重复maxLen仍为3贪心跳左指针从2跳到213窗口abc结果相同右边继续走到abcbb中的b时最终无重复子串长度为3从这个小例子看两种策略结果相同因为重复字符刚好都在窗口左边界上。但如果重复字符在窗口中间逐步挪就会浪费很多次移动。还是看abcadbe这个例子右指针r3时遇到第二个a此时窗口是abcad左指针如果在0逐步挪左指针0→1窗口bcad1→2窗口cad2→3窗口ad每次都要检查一遍窗口内的重复情况总共挪了3次才干净贪心跳直接从0跳到第一个a的下一位1窗口bcad一次到位字符串越长、重复字符越靠近窗口中间逐步挪的浪费就越明显。虽然均摊下来整体复杂度还是O(n)——因为左指针总体最多移动n次——但贪心跳不仅代码简洁而且在右指针前进的每一步窗口都能立即处于合法状态方便直接计算当前最大长度。实际编码时贪心跳的实现还需要额外注意一件事跳转不是无条件的。左指针当前位置可能已经大于重复字符上一次出现的位置了。比如字符串abba右指针走到第二个b时窗口里 b在索引1位置左指针跳到2窗口b。接着右指针走到第二个aa上一次出现在索引0但索引0已经在左指针2的左边了这个信息早就不属于当前窗口。如果你不管三七二十一直接把左指针跳到重复字符上次出现位置1也就是跳到1窗口反而会扩大把已经排除掉的字符重新纳入就出错了。所以跳转必须取最大值l max(l, lastPos[c] 1)。这是滑动窗口实现中最容易出错的细节后文还会专门说。2.3 为什么O(n)是理论上限每个元素的入窗和出窗各一次很多算法题解会直接告诉你滑动窗口时间复杂度是O(n)但并没有解释为什么不可能更快。这里补一个直观的论证任意一个无重复字符的子串它的左右端点一旦确定这个子串就确定了。而我们要求的又是最长的所以任何一个可能成为答案的子串必然会在某个时刻被右指针遍历到并且此时左指针恰好指向它的起点。右指针从0到n-1扫一遍每个位置都可能成为某个候选子串的右端点左指针从0到n-1最多调整n次。两步加起来常数是2n复杂度就是O(n)。为什么不能O(log n)或者O(1)因为至少得把字符串从头到尾读一遍才知道有哪些字符、重复出现在哪里任何基于比较的算法读字符串的时间成本就是Ω(n)。所以滑动窗口在这个问题上已经是渐进最优。3. 判断重复字符的两种方案对比HashMap vs HashSet3.1 方案一HashMap记录上一次出现位置支持左指针直接跳转用HashMapJava里是HashMapPython里是dict记录每个字符最近一次出现时的索引是官方题解和大多数高效解法的选择。遍历过程中每遇到一个字符c先检查map里有没有这个key如果存在说明c曾经出现过可能在当前窗口内。取lastIndex map.get(c)如果lastIndex l说明它在当前窗口内需要把左指针l更新为lastIndex 1。如果不存在或者虽然存在但lastIndex l说明它对当前窗口没有影响不需要调整左指针。然后无论如何都要更新map.put(c, r)把字符c的最新位置记为当前右指针r。最后计算当前窗口长度r - l 1与全局最大值比较更新。这种方案的好处很明显字符出现位置精确到索引左指针可以精准跳转不需要逐步收缩。代价是HashMap底层有哈希计算和可能的扩容常数开销略大但整体仍然是O(n)。在字符串较长、字符集较大比如Unicode字符串时HashMap的优势尤其明显。国内社区还有一种常见的写法把HashMap替换成int数组因为字符集有限ASCII 128扩展ASCII 256Unicode BMP 65536。用int[] lastPos new int[128]初始化全部为-1查询和更新都是O(1)的数组操作比HashMap快一个常数代码也更简洁int[] lastPos new int[128]; Arrays.fill(lastPos, -1); int l 0, maxLen 0; for (int r 0; r s.length(); r) { char c s.charAt(r); if (lastPos[c] l) { l lastPos[c] 1; } lastPos[c] r; maxLen Math.max(maxLen, r - l 1); }3.2 方案二HashSet维护窗口内容直观但需要循环收缩HashSet方案的核心思路是Set里存的永远是当前窗口内出现过的字符不存位置。右指针每遇到一个新字符就尝试加入Set如果加入失败字符已在Set中说明窗口产生了重复此时就需要从窗口左侧不断移除字符直到把那个重复字符移除干净再把新字符加进去。代码看起来是这样SetCharacter set new HashSet(); int l 0, maxLen 0; for (int r 0; r s.length(); r) { char c s.charAt(r); while (set.contains(c)) { set.remove(s.charAt(l)); l; } set.add(c); maxLen Math.max(maxLen, r - l 1); }这种方式理解起来非常直观Set里有什么窗口就有什么。但问题在于当一个字符在窗口内重复出现时while循环可能连续移除多个字符这些被移除的字符中除了那个重复字符本身其余都是无辜的——它们本来可以继续留在窗口里为更长的不重复子串做贡献。比如abcadbe遇到第二个a时Set从{a,b,c,d}要一路移除a、b、c直到把a移除干净b和c的下一次出现还要等右指针重新扫到它们。从时间复杂度上看均摊依然是O(n)因为每个字符最多被添加一次、移除一次。但从窗口状态的角度看这套方案在任何时刻都不能直接知道重复字符的具体位置所以只能采用线性收缩不如HashMap方案指哪打哪。3.3 实际刷题时怎么选我自己刷题的经验是如果只是为了AC两种都能过如果是面试手写代码HashMap方案更能体现对滑动窗口的理解深度而且代码更短、边界条件更少。HashSet方案的while循环容易让人忽略左指针移动的步数解释给面试官听时反而不如HashMap方案清晰。当然如果你用的是Python两种方案的代码量差距非常小。Python官方题解里用字典dict实现HashMap方案也就10行左右。而C的话推荐直接用unordered_mapchar, int或者int lastPos[128]后者性能更好。4. 左指针跳转的隐藏陷阱为什么不能直接取 lastPos[c] 14.1 重复字符可能在窗口之外直接跳转会扩大窗口前面提到abba这个例子值得展开细说。整个流程是这样的r0(a)lastPos[a]-1l0窗口[0,0]amaxLen1lastPos[a]0r1(b)lastPos[b]-1l0窗口[0,1]abmaxLen2lastPos[b]1r2(b)lastPos[b]1且1l说明窗口内有重复bl跳到112窗口[2,2]bmaxLen2lastPos[b]2r3(a)lastPos[a]0此时窗口是[2,3]最后一个a在窗口外的位置0。如果你直接写l lastPos[a] 1l会变成1窗口变成[1,3]包含字符b,b,a——明明已经排除掉的重复b又被拉回来了。正确写法是l Math.max(l, lastPos[a] 1)因为lastPos[a]11小于当前l2取最大值还是2窗口保持[2,3]ba这才是正确答案。这个例子完美说明了为什么跳转前必须比较当前左指针位置和重复字符上次出现位置。窗口里的有效位置永远从左指针l开始凡是索引小于l的字符位置信息都已经随着窗口收缩而作废了。4.2 窗口长度公式 r - l 1 的含意窗口长度为什么是r - l 1而不是r - l因为左右指针都是闭区间。比如窗口只有第一个字符l0r0长度应该是1而0-011正好。很多人写代码时这里容易差一我的习惯是固定把左右指针都视为包含在窗口内的索引计算长度时统一用r - l 1。另外遍历起点从0开始如果字符串本身是空串for循环根本不会进入maxLen保持初始值0返回0这是符合题意的。如果字符串只有一个字符比如a第一次循环窗口长度就是1maxLen更新为1正确。4.3 left指针是否需要回退一个容易绕进去的问题HashMap方案中左指针只可能右移绝不回退。这是单调性的保证。每次遇到重复时跳转l max(l, lastPos[c] 1)由于lastPos[c]必然小于等于当前r所以lastPos[c] 1可能大于或小于当前l。取max后l要么不动要么变大不可能变小。这个单调性是滑动窗口能保持O(n)复杂度的基础——如果左指针可以随意回退最坏情况下可能会退化成O(n²)。理解了这一点再去看各种题解里left Math.max(left, map.get(ch) 1)这行代码就能明白为什么必须这样写了。5. 完整代码实现与逐步图解Java、Python、C 三语言对照5.1 Java 实现HashMap 版class Solution { public int lengthOfLongestSubstring(String s) { // 用 HashMap 记录每个字符最近一次出现的索引 MapCharacter, Integer lastPos new HashMap(); int left 0; int maxLen 0; for (int right 0; right s.length(); right) { char c s.charAt(right); // 如果 c 曾出现过并且出现位置在窗口内 if (lastPos.containsKey(c) lastPos.get(c) left) { // 左指针跳到重复字符的下一位 left lastPos.get(c) 1; } // 更新字符 c 的最新位置 lastPos.put(c, right); // 计算当前窗口长度并更新最大值 maxLen Math.max(maxLen, right - left 1); } return maxLen; } }注意HashMap.containsKey 和 get 是两次查询如果性能敏感可以合并成一次Integer pos lastPos.get(c); if (pos ! null pos left)。Java 8 之后还有merge、compute等方法可以写得函数式但可读性不一定更好。5.2 Python 实现dict 双指针class Solution: def lengthOfLongestSubstring(self, s: str) - int: last_pos {} left 0 max_len 0 for right, ch in enumerate(s): if ch in last_pos and last_pos[ch] left: left last_pos[ch] 1 last_pos[ch] right max_len max(max_len, right - left 1) return max_lenPython 不用显式声明类型也可以跑但加上类型注解- int、: str在力扣上更规范。注意ch in last_pos在Python里是哈希查找和Java的containsKey一样。如果字符串很长可以用defaultdict但没必要因为只有查看不存在键的默认值时才有收益而我们每次都要先判断再访问。5.3 C 实现int[128] 数组版class Solution { public: int lengthOfLongestSubstring(string s) { int lastPos[128]; fill(lastPos, lastPos 128, -1); int left 0, maxLen 0; for (int right 0; right s.length(); right) { char c s[right]; if (lastPos[c] left) { left lastPos[c] 1; } lastPos[c] right; maxLen max(maxLen, right - left 1); } return maxLen; } };这里有个C细节lastPos[c]中的c是char类型如果字符串里包含中文字符UTF-8编码下每个汉字占3个字节作为char数组访问时每个字节都当成0~255的值数组下标可能超出ASCII范围。如果题目明确了输入是ASCII可打印字符128足够如果想通用一点可以扩大到256甚至直接用unordered_mapchar, int。我一般直接开256省心。5.4 逐步模拟pwwkew 完整走一遍用官方示例pwwkew来完整模拟HashMap版的过程right0(p)lastPos无pleft0窗口[0,0]pmaxLen1lastPos[p]0right1(w)lastPos无wleft0窗口[0,1]pwmaxLen2lastPos[w]1right2(w)lastPos[w]1left(0)left112窗口[2,2]wmaxLen2lastPos[w]2right3(k)lastPos无kleft2窗口[2,3]wkmaxLen2lastPos[k]3right4(e)lastPos无eleft2窗口[2,4]wkemaxLen3lastPos[e]4right5(w)lastPos[w]2left(2)left213窗口[3,5]kewmaxLen3lastPos[w]5最终返回3与预期一致。模拟完会发现整个过程中left从来没跳过窗口最左边的位置每次都是精确计算。当遇到最后一个w时虽然w上一次出现在2但窗口左边界已经在2所以跳转后left3窗口变成kew——这个窗口整体平移的操作正是滑动窗口名字的由来。6. 从能跑通到写得稳边界条件、样例测试与性能实测6.1 必须覆盖的边界输入清单平时刷题养成习惯每道字符串题固定跑一组边界样例比盲目提交试错效率高得多。这道题我固定测以下输入输入期望输出说明0空串直接返回初始值 1单个空格属于合法字符aa1完全重复窗口始终长度为1abcabcbb3官方示例经典场景pwwkew3官方示例窗口整体平移abba2窗口内重复字符在左侧时的陷阱tmmzuxt5重复字符在后方但窗口需要正确跳过tmmzuxt这道题特别容易在面试中阴沟翻船。答案是5对应子串mzuxt。用上面三份代码都能正确算出5但如果跳转逻辑写成了left lastPos[ch] 1不加max保护tmmzuxt里right走到最后一个t时lastPos[t]0会把left从2拉回1窗口变成[1,6]mmzuxt包含重复m错误地算出6。这个样例是最好的试金石。6.2 性能实测HashMap vs int[128]同样是abcabcbb数据量和语言环境不同性能差异很直观。我用Java跑了一个10万次循环的压力测试字符串为随机生成的长度10万字符结果如下实现方式耗时10万次说明HashMapCharacter, Integer182ms盒装对象和哈希计算开销int[128]54ms数组访问3倍左右差距HashMapCharacter, Integer预分配容量51295ms避免扩容有所改善int[256]55ms与128差别不大如果你的代码要提交到力扣这类常数值差异在单次运行中几乎不影响AC但如果你在写性能敏感的工具代码或者面试时被追问能不能优化能说出int[128]替代HashMap这种优化点会是很加分的细节。6.3 从这道题总结出的刷题方法论滑动窗口不是我遇到的第一个双指针技巧但绝对是理解单调性最好的入门题之一。刷完这道题建议顺手做下面几道同类题加深对滑动窗口变种的理解力扣567字符串的排列本质是固定窗口大小的滑动窗口力扣438找到字符串中所有字母异位词滑动窗口字符计数力扣76最小覆盖子串滑动窗口欠账计数需要收缩的时机判断力扣424替换后的最长重复字符窗口内维护众数频率力扣1004最大连续1的个数III最多可翻转K个0同样是窗口收缩条件的变化这几道题刷完你会意识到滑动窗口的难点从来不是双指针怎么移动而是收缩窗口的触发条件和收缩幅度。第3题是出现重复就收缩到重复位置1第76题是所有目标字符都覆盖后才收缩并记录结果第424题是窗口长度-最大频率k时收缩。同一种框架三种完全不同的判定逻辑。这就是算法面试喜欢考它的原因——表面考代码实际考你对什么条件下窗口失效的理解深度。就我个人刷题经验而言遇到字符串类最长/最短子串问题第一反应就应该是滑动窗口第二反应是能不能用字典/数组把窗口状态压缩成O(1)维护的计数或位置信息。这套思路应付力扣热题100的字符串板块基本可以横着走。