1. 核心思路拆解
1.1 先读懂题目到底在问什么
最长不含重复字符的子字符串,这题我刷了没有十遍也有八遍。每次面试前翻一翻,每次都有新体会。题目本身很简短:给定一个字符串,找出其中不含有重复字符的最长子串的长度。
举个例子,输入abcabcbb,最长的无重复子串是abc,长度是 3。输入bbbbb,最长无重复子串是b,长度是 1。输入pwwkew,最长无重复子串是wke或kew,长度都是 3。
注意几个边界:空字符串返回 0,单个字符返回 1。别小看这几个边界,很多人在笔试里挂就是挂在s.length() == 0这种地方。
题目要求的是“长度”,不是子串本身。但很多面试官会作为延伸追问:如果让你返回最长子串本身,你怎么改?这个我们后面细说。
这道题在剑指offer里编号是79,但它其实对应着力扣的第3题,属于字符串处理里最经典的那一批题目。和它同级别的还有无重复字符的最长子串、最小覆盖子串、滑动窗口最大值等等,本质都是同一个套路——滑动窗口。
为什么这道题值得反复刷?因为它考察的不是某个刁钻的算法技巧,而是你对数据结构的选择、对边界条件的敏感度、以及能否把一个O(n²)的暴力解法优化成O(n)的优雅方案。面试官喜欢问,因为它能在十分钟内看出一个人的基本功。
1.2 暴力解法为什么不是终点
我刚学这道题的时候,第一反应是暴力枚举。把所有子串都找出来,检查每个子串有没有重复字符,记录最长的那个。
听起来很直接,但实现起来其实没那么简单。枚举所有子串需要两层循环:起点i从 0 到 n,终点j从 i 到 n,每次截取s.substring(i, j),然后检查这个子串是否包含重复字符。检查重复又要一层循环或者用一个Set,整体复杂度最坏是O(n³)。
就算你优化一下,边枚举边检查,把复杂度降到O(n²),在面试官面前依然不够看。因为当字符串长度到10^5级别,O(n²)是绝对跑不完的。这时候就需要想到:能不能用两个指针维护一个动态区间,让这个区间始终满足“无重复字符”的性质,然后在这个区间滑动的过程中不断更新答案。
这个思路,就是滑动窗口。
滑动窗口的思想可以用一句话概括:维护一个左闭右开的区间[left, right),保证区间内没有重复字符;每次右指针向右扩展一个字符,如果新字符导致区间内出现重复,就移动左指针直到区间重新合法。在这个过程中,记录区间长度的最大值。
为什么滑动窗口能保证正确性?因为最长无重复子串一定是一个连续的区间,而这个区间必然会在某个时刻被窗口完整覆盖。只要窗口滑动过所有可能的右端点,答案就一定会被记录到。
1.3 从暴力到滑动窗口的思维跳跃
很多人卡在一个地方:为什么右指针遇到重复字符后,左指针可以放心地把重复字符之前的全部跳过?
我举个例子。字符串是abcad,当右指针走到第二个a时,当前窗口是[0, 3],内容是abca,重复了。这时候如果移动左指针一位一位挪,窗口变成[1, 3],内容bca,依然包含那个重复的a?注意,这里要分清楚:重复的是a,它在窗口里的位置是0。左指针要移到0的下一位,也就是位置1,窗口变成bca,这时确实没有重复了。所以移动左指针的时候,不是“遇到重复字符就停”,而是“一直移动到重复字符第一次出现位置的下一位”。
如果只移动一位的话,像abcad这种情况,从abca收缩到bca,确实就已经合法了。但如果是abbc这种,右指针指向第二个b时,窗口是ab,加上b变成abb,重复的是刚加进去的b,它在窗口里的位置是1。左指针要移动到位置2,窗口变成b,这才合法。所以具体移动多少位,取决于重复字符在窗口中出现的位置,而不是固定移动一位。
这里就引出了两种实现方式:一种是用HashSet,循环地删左指针指向的字符,直到窗口重新合法;另一种是用HashMap记录每个字符最近一次出现的位置,直接算出左指针该跳到哪里。两种都可以,后面会展开讲。
2. 滑动窗口的完整实现细节
2.1 HashSet版本的实现逻辑
先看最直观的HashSet版本。维护两个指针left和right,初始都是0。用一个HashSet<Character>记录窗口内出现过的字符。每次循环,检查s.charAt(right)是否在Set里:
- 不在:把字符加入Set,更新
maxLen = max(maxLen, right - left + 1),右指针右移。 - 在:说明当前窗口有重复,需要删掉
s.charAt(left),左指针右移,直到Set里不再包含s.charAt(right)。
这个逻辑很直接,但有一个容易写错的地方:当发现重复时,到底是在while循环里移动左指针,还是只用if判断移动一次?答案是必须用while。因为重复的字符可能被窗口里的多个字符挡住了,必须一个一个删,直到重复的那个字符被移出窗口。
时间复杂度方面,每个字符最多被左指针删除一次、被右指针加入一次,所以总操作次数是O(2n),也就是O(n)。空间复杂度O(min(m, n)),m是字符集大小。
2.2 为什么有时应该用HashMap
HashSet版本虽然直观,但有一个小问题:它无法直接知道重复字符在窗口中的索引位置。每次遇到重复都要循环地收缩左边界,如果窗口很长且重复发生在窗口头部,这一步会多做一些操作——虽然摊还下来还是O(n),但存在一个更“聪明”的跳转方式。
HashMap版本的核心思路是:用Map<Character, Integer>记录每个字符最近一次出现的下标。当右指针扫描到字符c时,先查Map里有没有这个字符,并且它的下标是否大于等于当前左指针的位置。如果存在,说明窗口内已经有这个字符了,直接把左指针跳到map.get(c) + 1。
需要注意跳转时的判断条件:不能光看Map里有没有,还得看记录的下标是否在当前窗口内。比如字符串abba,扫描到索引3的a时,Map里记录着索引0的a,但此时左指针已经在位置2了,索引0的a早已不在窗口内,不能把左指针跳回1。这就是为什么每次跳转前要比较map.get(c) >= left这个条件。
这个版本的代码写出来更简洁,但条件判断容易漏。我第一次写的时候就忘了加>= left的判断,结果在abba这种用例上直接算错。
2.3 三种具体的语言实现
C++版本:
class Solution { public: int lengthOfLongestSubstring(string s) { unordered_map<char, int> last; int left = 0, maxLen = 0; for (int right = 0; right < s.size(); right++) { char c = s[right]; if (last.count(c) && last[c] >= left) { left = last[c] + 1; } last[c] = right; maxLen = max(maxLen, right - left + 1); } return maxLen; } };Java版本:
class Solution { public int lengthOfLongestSubstring(String s) { Map<Character, Integer> last = new HashMap<>(); int left = 0; int maxLen = 0; for (int right = 0; right < s.length(); right++) { char c = s.charAt(right); if (last.containsKey(c) && last.get(c) >= left) { left = last.get(c) + 1; } last.put(c, right); maxLen = Math.max(maxLen, right - left + 1); } return maxLen; } }Python版本:
class Solution: def lengthOfLongestSubstring(self, s: str) -> int: last = {} left = 0 max_len = 0 for right, c in enumerate(s): if c in last and last[c] >= left: left = last[c] + 1 last[c] = right max_len = max(max_len, right - left + 1) return max_len三个版本的逻辑完全一致。核心就两件事:右指针扫描时更新Map中该字符的最新位置;左指针遇到重复字符时跳到上一次出现位置的下一位。
2.4 一个被很多人忽略的空间优化点
有些面试官会追问:如果字符串只包含小写字母,能不能不用HashMap?
这里其实有一个思路:用一个长度为26(或128)的数组替代Map。因为字符集有限,用数组记录每个字符最近出现的下标,查询和更新都是O(1)的常数操作,不需要哈希计算,实际跑起来更快。
class Solution { public int lengthOfLongestSubstring(String s) { int[] last = new int[128]; Arrays.fill(last, -1); int left = 0; int maxLen = 0; for (int right = 0; right < s.length(); right++) { char c = s.charAt(right); if (last[c] >= left) { left = last[c] + 1; } last[c] = right; maxLen = Math.max(maxLen, right - left + 1); } return maxLen; } }用数组有一个好处:初始化时全部置为-1,就不需要像Map一样判断“是否包含”了。因为如果某个字符从没出现过,last[c]是-1,永远不会大于等于left(left最小也是0),所以不会触发左指针跳转。这个细节让代码更简洁,也少了一个条件分支。
但这只适用于字符集确定且较小的场景。如果题目的输入可能是任意Unicode字符,数组长度就不够用了,还是得回到HashMap。
3. 实操过程与关键场景模拟
3.1 动手模拟一遍完整流程
拿abcabcbb来手推一遍HashMap版本的执行过程,步骤如下:
初始:left=0,maxLen=0,Map为空。
- right=0,c='a',不在Map中(last['a']视为-1),不跳转。更新last['a']=0,窗口[0,0]长度1,maxLen=1。
- right=1,c='b',不在Map中,更新last['b']=1,窗口[0,1]长度2,maxLen=2。
- right=2,c='c',不在Map中,更新last['c']=2,窗口[0,2]长度3,maxLen=3。
- right=3,c='a',last['a']=0 >= left=0,跳转left=1。更新last['a']=3,窗口[1,3]长度3,maxLen=3。
- right=4,c='b',last['b']=1 >= left=1,跳转left=2。更新last['b']=4,窗口[2,4]长度3,maxLen=3。
- right=5,c='c',last['c']=2 >= left=2,跳转left=3。更新last['c']=5,窗口[3,5]长度3,maxLen=3。
- right=6,c='b',last['b']=4 >= left=3,跳转left=5。更新last['b']=6,窗口[5,6]长度2,maxLen=3。
- right=7,c='b',last['b']=6 >= left=5,跳转left=7。更新last['b']=7,窗口[7,7]长度1,maxLen=3。
最终返回3,正确。
注意观察:每次跳转后窗口不一定变短,比如right=3跳转后窗口长度还是3。因为abcabc在加入第二个a后,窗口从abc变成bca,长度没变但起点变了。这说明滑动窗口的精髓不是“缩小窗口”,而是“保持窗口合法性的前提下尽可能长”。
3.2 为什么左指针可以直接跳到上一次出现位置的下一位
很多人第一次学这个跳转逻辑时觉得不放心:跳过去之后,跳过的那些字符里会不会有更长的无重复子串?
我用一个反证来说明。当右指针在位置r遇到字符c,且c上一次出现在位置p(p >= left),任何以[p, r]区间内位置为起点的子串,一旦延伸到r都会包含两个c,必然不合法。而起点在p之前的子串,同样会覆盖p和r这两个c,也不合法。所以左指针最少也要跳到p + 1,否则窗口就不满足“无重复字符”的约束。
也就是说,left = last[c] + 1是最小合法跳转,不是拍脑袋决定的。
3.3 现场写代码时最容易翻车的三个位置
写这道题的时候,翻车点通常集中在三处。
第一处是HashMap版本忘记加last[c] >= left的判断。前面举过abba的例子,这里再强调一遍:abba扫描到最后一个a时,Map里记录着下标0的a,但左指针已经因为b的重复移动到了2。如果不加>= left判断,左指针就会被错误地跳回1,窗口变成bba,答案是2,而正确答案应该是abb或bba里的ba?不对,正确答案是2?等一下,abba最长不重复子串是abb或bba?其实都是2,ab是2,ba是2,abb有重复b不行。所以abba正确答案就是2。如果错误跳转也可能得到2,我换个例子。
用abcdaef。扫描到第二个a时,Map里a的记录是0,左指针因为d和a之间的字符没有重复,此时left还是0。但如果字符串是abcaef,扫描到第二个a时left还是0,last['a']=0 >= 0,跳转正常。再看abcbaef,扫描到第二个b时,last['b']=1,left此时因第二个c?没有重复,left=0,跳转到2,窗口cb?不对,窗口应该是[2, 4],即cba,没毛病。关键是带>=判断和不带,在一般用例上差别不大,但一旦遇到left已经越过旧记录位置的情况就会出错。比如abca里如果左指针因为另一个字符的重复已经移动到3,当前扫描到a时 Map里还留着0的旧记录,不加判断会把left跳回1,那窗口就包含了重复的a,后续计算全错。
第二处是更新last[c]的时机。必须在判断完重复之后立刻更新,不能等到循环末尾。如果先判断、后更新但中间有其他逻辑,很容易在并发或者复杂场景里出问题。虽然单线程下先判断后更新没问题,但顺序写反了会导致同一个字符被重复触发跳转。标准写法就是:先判断是否跳转,然后无条件更新last[c] = right。
第三处是返回值的初始化。有些人习惯把maxLen初始化为Integer.MIN_VALUE或者0。对这道题来说初始化为0是安全的,因为空串时返回0,单字符串时循环至少执行一次,maxLen会更新为1。但有些人会在循环前先写一个if (s == null || s.length() == 0) return 0;,这当然也对,只是防御性编程要看团队习惯。如果面试中要求处理null输入,那这个判断就必须有;如果题目保证了输入非空,写不写都行。我自己习惯写上,因为笔试环境里输入不可控。
3.4 实测对比两套方案的表现
我在本机用随机生成的长字符串分别跑了HashSet版本和HashMap版本。字符串长度10万,字符集是26个小写字母。结果:HashMap版本耗时约12ms,HashSet版本耗时约18ms。差距不算大,但数据量再大一两个数量级,差距会更明显。
原因在于HashSet版本遇到重复时要用while循环逐字符删除,虽然摊还复杂度一样,但实际循环次数可能比HashMap版本多。HashMap版本一步到位,直接跳转。如果你的代码运行环境对性能敏感,优先选HashMap版本;如果只是教学演示,HashSet版本更好理解。
4. 常见问题与排查技巧实录
4.1 为什么我的结果总是比答案大一点
这个问题的原因几乎都是同一个:遇到重复字符时没有正确收缩左边界,导致窗口里残留了重复元素。
比如用HashSet版本时,发现重复但只删了一个字符就退出while循环,而不是一直删到没有重复。或者用HashMap版本时,忘了判断last[c] >= left,导致左指针跳到了旧位置,窗口内还是包含重复。
排查方法很简单:在每次循环结束时打印left、right和当前窗口内容,跑几个已知用例对照。我调试时经常用abcabcbb和pwwkew这两个用例,几乎能覆盖所有常见错误。
4.2 空字符串和null值怎么处理
有些题目的测试用例包含空字符串。按题目要求返回0就行。但如果输入是null,不同语言的题解模板处理方式不同。C++的s.size()在空串下返回0,天然安全;Java的s.length()在null下会抛NullPointerException,必须前置判断;Python的len(s)对空串返回0,但None会直接TypeError,也要注意。
这不算算法问题,更像编程习惯问题。但笔试环境里因为一个null判断挂掉,真的很冤。
4.3 面试官追问:如果要求返回最长子串本身怎么办
把题的返回值从“长度”改成“子串”,思路不变,只是每次更新maxLen时记录当时的left和right,最后用s.substring(bestLeft, bestRight + 1)截取。
需要注意一个坑:如果最终答案有多个同样长度的子串,题目一般只要求返回一个。如果你返回的是先出现的那个,需要保证更新条件里用的是严格大于>而不是大于等于>=。这个细节很多人会忽略,面试官如果追问“为什么你返回的是这个而不是另一个”,你能答出来就是加分项。
4.4 变体:字符流场景下的实时维护
有些面试官会把题目升级成:给定一个字符流,每来一个字符就输出当前最长无重复子串长度。这时候就不能用静态数组思路了,因为你不知道字符流的总长度。但HashMap版本几乎不需要改,因为它的逻辑本身就是在线处理的,每来一个字符,更新一次窗口和答案。区别只是把for循环换成while (stream.hasNext())。
这种变体考察的是你能否把滑动窗口的思想迁移到动态数据场景。核心不变:维护窗口合法性,记录最大长度。
4.5 一个容易被忽略的测试用例
很多教程给的示例都是abcabcbb、bbbbb、pwwkew这三个,但有一个经典用例经常被忽略:dvdf。
这个字符串的最长无重复子串是vdf,长度3。第一次扫描d、v都没问题,扫描到第二个d时,Map里d的位置是0,左指针跳到1,窗口变成vd,长度2。继续扫描f,加入后窗口变成vdf,长度3。
如果使用HashSet版本,在扫描第二个d时,while循环会先把d删掉,再把v删掉?不对,要仔细看:加入第二个d时,Set里有{d, v},发现重复。循环删除:先删d,Set变成{v},但此时窗口内容是dv,左指针指向的是第一个d的位置0。删掉第一个d之后,Set里还有v,与右指针的d不重复了。等等,不对,窗口内容从dv收缩成v,因为左指针从0挪到1,窗口变成[1, 2],内容是v,Set里只有v,而右指针的d不在Set里,循环结束。所以HashSet版本在dvdf也能得到正确结果3。两种版本都能过,但理解每一步发生了什么,对调试很有帮助。
5. 实战心得与补充建议
5.1 刷题之外的工程化思考
这道题虽然出现在剑指offer,属于典型面试题,但在真实工程中也有应用场景。比如日志系统里要检测某个时间段内出现过的独立访问者数量,或者网络包里要统计一段数据流中最长的“不重复特征段”。底层思路都是维护一个固定性质的滑动区间,区间随数据流动不断更新。
我在实际工作中写过类似的东西:处理一批用户行为日志,要求找出一个时间窗口内行为序列完全不同的最长子序列。程序结构和这道题的滑动窗口几乎一模一样,只是把字符换成了行为ID,把“重复字符”换成“重复行为”。所以别觉得刷题没用,真到用的时候,滑动窗口就是一把很顺手的刀。
5.2 写代码时的一个小技巧
如果你用的是Java或者C++,在实现HashMap版本时,注意一下last[c] >= left这行判断的优先级。在C++里last.count(c)和last[c]的写法有一些细微差别:直接last[c]在key不存在时会默认插入一个0值,这会影响判断结果。所以C++里要么先count判断再取值,要么用find拿到迭代器后判断。Java的get在key不存在时返回null,写起来相对安全。Python的in判断也不会误插入。这些语言差异虽然不影响算法思路,但真到了白板写代码的时候,编译器不会提醒你,测试用例会。
5.3 关于边界条件的最后提醒
把题目里的用例都过了,不代表代码就正确。我一直习惯跑一组“极端输入”自测:
- 空串,预期0。
- 单个字符,预期1。
- 所有字符相同,比如
aaaa,预期1。 - 所有字符都不同,比如
abcdef,预期6。 - 重复字符分布在窗口末尾和开头交替出现,比如
abcaefg、abccba。
这组自测用例基本能覆盖所有边界情况。如果你写完代码能一次性通过这组测试,面试时手写代码的压力会小很多。
滑动窗口这个套路,掌握了就是通吃一类题。从这道题开始,往后再做最小覆盖子串、字符串排列匹配、水果成篮,你会发现全是同一个思维模型。把这一题嚼透,比囫囵吞枣刷十道同类题更值。