刷 LeetCode 的时候,很多题一看就会,一写就废,“无重复字符的最长字串的长度”就是典型代表。这道题在面试里出现频率极高,既能考你对字符串操作的熟练度,也能考你对时间复杂度的敏感度。我第一次做的时候,脑子里直接冒出三层循环的暴力解法,写出来之后自己都笑了——不是不对,是太笨了。后来弄懂滑动窗口,才明白这道题真正想考察的东西。
这篇文章不是我抄官方题解,而是把从暴力到优化的完整思考链路、写代码时容易踩的坑、以及面试官可能追加的变体问题都串起来讲一遍。无论你是刚开始刷题的新手,还是准备跳槽需要快速过一遍经典题的同学,这篇都能给你一点不一样的参考。
1. 从暴力解到滑动窗口:先弄清“子串”和“子序列”的区别
1.1 为什么“子串长度”这个表述让很多人翻车
“无重复字符的最长子串”这个描述,看起来每个字都认识,但真正下手时很多人直接理解为“找一串不重复的字符”,然后就开始数。问题在于“子串”这个词有严格定义:在原始字符串中连续出现的一段字符序列。换句话说,你选出来的字符必须在原串里是紧挨着的,不能跳着选。
举个特别容易混淆的例子。字符串abcabcbb,如果问“无重复字符的最长子序列”,你可以选abc(取第1、2、3个字符),也可以选后面的abc,甚至能选acb这种非连续顺序吗?不能,因为子序列还要求保持原顺序,但不要求连续。可这道题问的是“子串”,那abc后面再接一个a就变成了abca,里面有重复的a,所以最长无重复子串只能是abc,长度是 3。
这个区分为什么重要?因为很多人刷题刷多了,容易把子序列的思维惯性带进来,想着“我只要统计每个字符最后出现的位置,然后随便组合”,这样算出来的长度会虚高。我之前就见过有同学把pwwkew的答案写成 4,因为觉得pwwk里只重复了一个w,去掉一个w再补上后面的e就是pwke。但pwke在原串里根本不是连续的,它是子序列,不是子串。正确的答案是 3,也就是wke或kew。
所以拿到这道题,第一件事不是写代码,而是在心里明确:我要在一个连续窗口内,保证所有字符都不重复,然后让这个窗口尽可能长。窗口的左右边界都来自原字符串的下标,窗口内字符串就是原串的一个连续切片。
1.2 暴力枚举的直观思路与时间复杂度代价
暴力解法是大部分人能立刻想到的:枚举所有可能的子串起点i和终点j,然后判断s[i..j]里有没有重复字符,如果有重复就跳过,如果没有就更新答案。
判断重复最简单的办法是再用一个循环遍历子串内部,或者用一个哈希集合把子串里的字符装进去,装的时候发现某个字符已经存在就说明重复。枚举起点和终点本身是 O(n^2),每个子串判断又需要 O(子串长度),最坏情况下总复杂度 O(n^3)。这个复杂度在 LeetCode 上遇到长字符串基本是超时的命。
我自己写过一次 O(n^2) 的优化版本:固定起点i,让j从i一直往右走,同时维护一个哈希集合,边走边往集合里加字符,一旦发现重复,就记录当前窗口长度,然后i加一,换下一个起点重新开始。这样每个起点只扫描一遍右边字符,整体降到了 O(n^2)。但这样依然有大量重复扫描:比如起点i=0时刚走到j=200发现重复,起点i=1时又要从1重新走到差不多远的位置,前面扫过的字符全被浪费了。
这个时候就应该想到:能不能让左右指针都只往前走,不回头?右指针负责扩展,左指针负责在遇到重复时收缩,这种“一个窗口跟着右边界前进、左边界按需追赶”的思路,就是滑动窗口。滑动窗口能把这个题的时间复杂度压到 O(n),这才是面试官想看到的答案。
2. 窗口怎么滑:核心数据结构与左右指针的移动逻辑
2.1 用哈希集合记录窗口内字符的依赖
滑动窗口的第一步,是确定用什么数据结构来维护“当前窗口内有哪些字符”。最常见的两种选择:HashSet和HashMap。
如果用HashSet,做法是:右指针每遇到一个新字符,先检查集合里有没有,如果没有就加进去,窗口长度就是右指针减左指针再加一;如果有重复,就不断从集合里删除左指针指向的字符,同时左指针右移,直到把那个重复字符删掉为止,然后再把新字符加进集合。这种思路下,左指针可能在一轮里移动多次。
如果用HashMap,记录的是“字符 -> 它最近一次出现的位置(下标)”。每次右指针遇到字符c,先查map里c上一次出现的位置lastIndex,如果lastIndex存在且大于等于当前左指针的位置,说明在这个窗口内确实有重复,这时候可以直接把左指针跳到lastIndex + 1,而不需要像 HashSet 那样“一格一格地挪”。这种跳跃式收缩更快,也更优雅。
这两种数据结构在思路上略有不同,但最终效果都是确保窗口内无重复。我个人更推荐在面试中先说清逻辑,然后用 HashMap 版本演示代码,因为它在讲时间复杂度时能很自然地说明“每个字符平均被访问两次,右指针一次,左指针一次”,如果用了 HashMap 的跳跃,左指针实际访问次数更少,但整体仍然是 O(n)。
2.2 左指针收缩的触发条件与正确时机
这里有一个非常关键的细节:什么时候触发左指针收缩?答案是“当新字符已经在当前窗口内出现过时”。什么叫“当前窗口内”?不是“在整个字符串中曾经出现过”,而是“在左右指针夹住的这个区间里出现过”。
举个例子,字符串abba。用 HashMap 法,假设一开始左指针left=0,右指针遍历:
right=0,字符a,map 里没有,记录a->0,当前窗口是[0,0]。right=1,字符b,map 里没有,记录b->1,窗口[0,1],长度 2。right=2,字符b,map 里b上一次位置是 1,且 1 >= left=0,所以窗口内确实有重复b。这时应该把 left 跳到 2,更新b->2。窗口变成[2,2],长度 1。right=3,字符a,map 里a上一次位置是 0,但 0 < left=2,说明虽然a曾经出现过,但不在当前窗口内,所以不构成重复。于是不需要收缩,直接更新a->3,窗口变成[2,3],字符是ba,长度 2。
如果这里不检查lastIndex >= left就会出问题:你会因为a曾经出现过而把 left 跳到 1,窗口变成[1,3],字符是bba,反而包含了重复的b。所以千万记住:HashMap 里旧位置小于 left 时,就相当于那个字符已经被“滑出窗口”了,不用管它。
2.3 窗口长度更新的位置选择
更新最大长度的时机也有讲究。我见过不少人习惯在每次右指针移动后都计算一次right - left + 1并更新答案,这在收缩完成后计算是正确的。但要注意,如果用 HashSet 法,必须先收缩后计算,否则会算进去重复字符。如果用 HashMap 法,收缩是在发现重复的瞬间完成的(left 直接跳过去),所以可以在右指针循环体内、在 left 更新完之后直接计算窗口长度。
还有一个细节:如果右指针移动后,当前窗口长度其实变小了(比如 left 跳了很远),也要执行maxLen = max(maxLen, right - left + 1)吗?可以,但没必要,因为max会保留历史最大值。不过为了代码简洁,直接在每次循环末尾更新没有任何问题。
我自己更喜欢在“字符处理完成之后”统一更新,因为这样逻辑清晰:每一步循环结束时,窗口都是合法的无重复状态,此时计算长度并更新答案,不会因为位置不同产生诡异 bug。
3. 我的第一次 AC 代码与两次典型翻车现场
3.1 翻车一:忘记在收缩时删除过期字符
用 HashSet 法时,最容易犯的错误就是左指针移动时只改了 left 的下标,但忘了从集合里删除“滑出去”的字符。
想象这个场景:窗口内已经有a, b, c,新字符是a,你发现a已经存在。于是你写了一个 while 循环,让 left 从 0 开始右移,直到遇到a才停。如果你在循环里只写了left++,没有删除s[left],那么窗口内始终保留着那个旧的a,后面无论怎么走,新字符进来总会和旧a冲突,导致窗口越缩越短,最终结果比正确答案小。
正确做法是,在左指针每次右移前,先把当前s[left]从集合里移除。这个过程像是在排队:队伍里混进来一个重复的人,你要从头开始一个一个把人放出去,直到把那个造成拥堵的人放走,新来的人才能进来。
下面这个错误版本,就是典型的“差一行代码”:
// 错误示例,仅用于演示 Set<Character> set = new HashSet<>(); int left = 0, maxLen = 0; for (int right = 0; right < s.length(); right++) { char c = s.charAt(right); while (set.contains(c)) { // 忘记写 set.remove(s.charAt(left)); left++; } set.add(c); maxLen = Math.max(maxLen, right - left + 1); }这个代码会陷入死循环吗?不会,因为left会一直增加直到超出字符串长度,但set.contains(c)永远为 true,因为旧的c一直没被删,结果就是 while 循环一直走到底,最终 left 变成s.length(),右边right还在继续,窗口长度变成负数。运行结果当然全错。
3.2 翻车二:用数组代替哈希时的索引陷阱
有些追求性能的人会用int[128]数组模拟哈希表,因为 ASCII 字符最多 128 个。数组下标是字符的 ASCII 码,数组的值存字符最近一次出现的位置。这个方法本身没问题,但很容易在初始化时踩坑。
常见错误是把数组初始化为0,然后用“0 表示从未出现”,但0本身也是有效下标。比如字符串第一个字符是a,记录last['a'] = 0,等下再遇到a时,你检查last['a'] >= left,发现0 >= 0,于是以为重复了,实际上这是它第一次出现。
更隐蔽的是当 left 移动到 1 时,你判断last['a']=0,发现0 >= left不成立了,这没问题。但如果第一个重复字符发生在下标 0 和下标 2,left=0,last=a=0,确实应该收缩。问题在于:你不能用 0 表示“没出现过”,因为下标 0 是合法位置。正确做法是初始化数组为-1,或者给每个下标加偏移。
我自己因为这个问题 debug 了很久,后来干脆统一用HashMap,虽然理论上数组更快,但代码里的心智负担小很多。刷题阶段,清晰比微优化重要。
3.3 一份可直接通过的实现(C++ 和 Python)
下面给两个我测试过的可行版本。C++ 用数组存“最近出现位置”,但初始化为-1;Python 用字典,逻辑一致。
C++ 版本:
#include <string> #include <vector> #include <algorithm> using namespace std; class Solution { public: int lengthOfLongestSubstring(string s) { vector<int> last(128, -1); int left = 0; int maxLen = 0; for (int right = 0; right < s.size(); right++) { char c = s[right]; if (last[c] >= left) { left = last[c] + 1; } last[c] = right; maxLen = max(maxLen, right - left + 1); } return maxLen; } };Python 版本:
def lengthOfLongestSubstring(s: str) -> int: last = {} left = 0 max_len = 0 for right, ch in enumerate(s): if ch in last and last[ch] >= left: left = last[ch] + 1 last[ch] = right max_len = max(max_len, right - left + 1) return max_len这里的关键点是if last[c] >= left而不是if last[c] != -1,因为字符可能在窗口外出现过,不影响当前窗口。Python 的ch in last同样要配合last[ch] >= left判断,缺一不可。
这段代码为什么正确?因为它确保每次更新last[c]之前,窗口[left, right]内没有重复字符。遇到新字符时,如果它上次出现的位置在当前窗口内,就说明会形成重复,直接把左边界跳到上次出现位置的下一个,这样就把那个旧的重复字符排除在窗口外了。然后无论是否收缩,都把当前字符的最新位置更新进去,再计算长度。
4. 进阶:如果字符集不是 ASCII 而是 Unicode / 多语言怎么办
4.1 字符集扩展对哈希和数组方案的影响
C++ 的vector<int> last(128, -1)只能处理 ASCII 字符。LeetCode 的测试用例通常只包含 ASCII,但实际业务中,字符串可能包含中文、日文、emoji 等 Unicode 字符。在 C++ 里,char类型通常只有 1 字节,对于多字节编码(如 UTF-8),直接处理char会出错。
有一种思路是直接改用unordered_map<char, int>或unordered_set<char>,C++ 的char在某些编译器里可以存下单个码点吗?实际上对于 UTF-8 编码的中文,一个汉字占 3 个字节,char一次只能处理一个字节,所以按字节处理会把一个汉字拆成三个字符,这显然不对。
更合理的做法是使用 C++ 的wchar_t或直接使用 Python 这类天然处理 Unicode 的语言。Python 的str是按 Unicode 码点存储的,len(s)得到的是字符数量而不是字节数量,遍历得到的ch就是完整的字符(包括 emoji)。所以在 Python 中,上面的代码不需要任何修改,天然支持 Unicode。但要注意,Python 中的len对 emoji 的处理也依赖 Unicode 规范化,比如一些复合 emoji 可能由多个码点组成,这类极端情况在面试里不会考,但你要知道存在。
如果面试官非得让你用 C++ 处理宽字符,可以这样:先把string转为wstring,但要注意wchar_t在不同平台长度不一样,Windows 下 2 字节(UTF-16),Linux 下 4 字节(UTF-32)。这个问题很麻烦,一般在面试中你可以直接表明“用 C++ 的话,按 UTF-8 解码成 Unicode 码点再做”,然后讨论思路,面试官不会傻到让你现场写一个 UTF-8 解码器。
4.2 复杂度分析:时间 O(n)、空间 O(min(m, n)) 怎么说清楚
很多人能写出正确代码但是说不清复杂度,面试时会被追问。这道题的时间复杂度容易理解:左指针和右指针都只往右移动,每个字符最多被左指针访问一次、右指针访问一次,总操作次数不超过 2n,所以 O(n)。但空间复杂度有个很妙的地方。
如果用HashSet或HashMap,里面存的是窗口内的字符。窗口最大能有多大?最多不会超过字符集的大小m(比如 ASCII 就是 128),同时也不会超过字符串长度n。所以空间复杂度是O(min(m, n))。这个说法比单纯说O(n)更精确,也更能体现你对复杂度的理解。
当字符集大小固定且很小(比如字母表只有 26 个字母),空间可以视作 O(1)。这就是很多题解里写“空间复杂度 O(1)”的依据。如果你使用固定大小的数组int[128],那就更直接了:无论输入多长,数组大小恒为 128,所以空间复杂度严格 O(1)。但如果字符集非常庞大,比如 Unicode 包含超过 14 万个字符,你不可能开一个这么大的数组,这时候用哈希表,空间和窗口长度成正比,也就是 O(min(m, n))。
所以我通常回答:使用固定数组实现时空间 O(1),使用哈希表实现时空间 O(min(m, n)),其中 m 是字符集大小。这样既准确又显得有深度。
5. 面试官还能怎么追问:变体题的举一反三
5.1 返回最长无重复子串本身而不是长度
有些面试官会把问题升级成“不只是求长度,还要返回子串内容”。这时候你只需要在滑动窗口更新最大长度时,同时记录对应的 left 和 right,等循环结束后用s.substr(left, maxLen)截取即可。关键点在于:更新maxLen的那一刻,left和right对应的窗口就是当前最长合法窗口。
如果是 Python,就用s[left:right+1]切片。要注意,用 HashMap 法时,left可能会被跳变,所以记录下来的 left 可能不是 0,但截出来的一定是正确子串。下面是一个返回子串的 Python 版本:
def longest_unique_substring(s: str) -> str: last = {} left = 0 max_len = 0 start = 0 for right, ch in enumerate(s): if ch in last and last[ch] >= left: left = last[ch] + 1 last[ch] = right if right - left + 1 > max_len: max_len = right - left + 1 start = left return s[start:start + max_len]这个例子能考察你是否理解窗口的“起止”状态,而不是只会套模板。
5.2 最多允许 k 个重复字符的最长子串
进阶题经常是“至多包含 k 个不同字符的最长子串”,或者“至多包含两个不同字符的最长子串”(这个其实是 LeetCode 的 159 题)。这个变体和本题思路类似,但维护的约束变了:本题要求“所有字符都不重复”,所以一旦发现某个字符出现过就得收缩;而“至多 k 个不同字符”要求的是窗口内的“不同字符数量”不超过 k。
这种情况下,哈希集合就不够用了,得用HashMap<char, int>记录每个字符在窗口内出现的次数。右指针扩展时,把新字符计数加一,如果这个字符之前没出现过,窗口内的不同字符数distinct就加一。当distinct > k时,左指针收缩,把滑出窗口的字符计数减一,如果计数减到 0,就从 map 中移除,同时distinct减一。直到distinct <= k为止。这个模式和“无重复字符”的收缩逻辑很相似,但判别条件从“字符是否存在”换成了“不同字符数量是否超限”。
理解这个变体之后,你会发现滑动窗口的核心思想是:在一个动态区间内维护某个约束条件,约束不满足时通过收缩左边界来修复。无重复字符只是约束的一种特殊形式。
5.3 与最长不重复子数组、最长无重复字符流的联系
如果把字符串换成数组,比如“最长无重复元素的子数组长度”,解法几乎一模一样,只是比较对象从字符变成了整数。用HashMap<Integer, Integer>存元素最近出现的位置即可,代码框架完全不用变。你甚至可以直接把上面的 Python 代码里ch改为num来用。
还有一种流式场景:数据像流水一样不断到来,要求实时维护当前窗口内最长无重复字符长度。这时候不能用静态数组的substr思路,但滑动窗口依然有效,因为每次新来一个字符,只需要检查它在窗口内是否重复,然后决定是否移动左边界。在一个实时流里,左边界只会单调增大,这种算法天然适合流式处理。所以别再觉得 LeetCode 只是刷题工具,这道题的思路在日志解析、序列检测、协议解析里都很常见。
我自己在做一个简单的数据清洗工具时,就遇到过类似问题:要在一段日志里找到连续且不包含重复关键字的子段长度。当时直接套了滑动窗口的模板,修改一下“字符”为“关键字 ID”,效率非常高。这就是刷算法题的意义——不是背题,而是把思想迁移到实际场景。
6. 刷题路上的三个实操建议
6.1 先用小例子手推一遍,再写代码
很多同学一上来就撸代码,写到一半发现逻辑有问题,又回去改。对于滑动窗口这种题,强烈建议先拿一个短字符串,比如abcabcbb,在纸上把 left 和 right 每一步的位置变化写出来,用一组示例验证“收缩条件”和“长度更新时机”。手推一遍之后,你会对代码有一个全局认识,写的时候不容易犯“忘记删集合”的低级错误。
6.2 面试时先讲暴力解,再讲优化
面试官通常不希望你上来就写最优解,而是想看你如何分析问题。你可以先说“最直观的办法是枚举所有子串,判断是否有重复,这是 O(n^3),能不能优化?我们发现当右边界固定时,如果某个子串已经有重复字符,那么再往右扩展只会更差,所以左边界可以移动,这样就得到了滑动窗口”。这个过程展示了你的思考深度,比直接背模板加分得多。
6.3 延伸思考:为什么左指针可以跳跃
HashMap 版本里,左指针是直接跳到last[c] + 1的,而不是像 HashSet 版本那样一步步移动。这是因为你记录了每个字符最近一次出现的位置,当你发现当前字符c在窗口内重复时,窗口内[left, last[c]]这一段的所有字符,其实都可以直接丢掉了——即便其中某些字符可能在窗口内只出现了一次,留着它们也没用,因为c已经重复,而窗口是连续的,你要想继续扩展,必须把c的旧位置排除,那么在c旧位置左边的一切字符也就一并被排除了。
这个跳跃并非让人不舒服的“技巧”,而是逻辑推演的自然结果。理解了这一点,遇到更复杂的滑动窗口题时会更容易举一反三。
最后分享一个我自己的小习惯:每做完一道经典题,我会在笔记里写下“这道题让我错在哪一步”和“能不能把解法用到别的题上”。无重复字符这道题,我的笔记里写的是“不要用 0 当未出现标记”和“检查 lastIndex >= left”。后来做“至多含 k 个不同字符”时,发现只需要改动维护的变量和收缩条件,思路瞬间通透。希望这篇内容对你也有类似的帮助。