LeetCode LCR 016. 无重复字符的最长子串
目录
题目描述
题意分析
给定字符串
s,求其中不含重复字符的最长连续子串的长度,返回长度而不是子串本身。两个关键词决定了算法形态。“连续”说明答案对应原串上的一段区间
[left, right],可以用两个下标刻画;“无重复”则是一个对区间单调的性质:若[left, right]内无重复,它的任意子区间也无重复;反过来,一旦区间内出现重复,继续向右扩张永远不可能重新合法,只能先收缩左边界。这正是可变长滑动窗口成立的条件。于是问题可以改写成:对每个右端点
right,求出使[left, right]无重复的最小left,答案就是所有right - left + 1的最大值。这样枚举右端点一遍即可,不必枚举所有子串。边界情况:
s为空时答案是 0;s全部字符相同(如"bbbbb")时答案是 1;s本身就无重复时答案是整串长度。
解法一:滑动窗口 + 最近出现位置
核心思路
暴力做法枚举 $O(n^2)$ 个子串再逐个判重,总代价 $O(n^3)$。优化的入手点在于:右端点右移一位时,上一轮窗口的合法性信息完全可以复用,不需要从头判重。
用哈希表
lastIndex记录每个字符最近一次出现的下标。当s[right]加入窗口时,只有一种情况会破坏合法性——这个字符上次出现的位置preIndex仍落在窗口内,即preIndex >= left。此时窗口里恰好只有这一对重复,把left一步跳到preIndex + 1就重新合法,而且这是能容纳right的最小左边界。正确性来自一个不变量:每次更新答案前,
[left, right]内的字符互不相同。归纳地看,上一轮结束时窗口合法,本轮只新增了s[right]一个字符,所以只需排除它与窗口内旧字符的冲突,而lastIndex恰好给出了唯一可能的冲突位置。
left只增不减:跳转的前提是preIndex >= left,因此preIndex + 1 > left恒成立。这个单调性是线性复杂度的来源。
解题步骤
- 初始化:
lastIndex为空表,left = 0指向窗口左端点,ans = 0。窗口用左闭右闭区间[left, right]表示。- 枚举右端点:
right从 0 到n - 1,取ch = s[right],把它看作本轮新加入窗口的字符。- 判断冲突:取出
ch的最近位置preIndex。只有preIndex存在且preIndex >= left才说明重复发生在窗口内;若preIndex < left,它早已被窗口甩在身后,属于过期信息,left不能动。- 修复窗口:冲突时令
left = preIndex + 1,一步跨过那个重复字符。这里不需要循环,因为合法窗口内该字符最多出现一次。- 更新最近位置:
lastIndex[ch] = right。这一步必须排在读取preIndex之后,否则会读到自己。- 更新答案:此刻窗口已经合法,用
right - left + 1取最大值。这个顺序不能颠倒。- 返回
ans。以
s = "abcabcbb"走一遍:a入窗得[0,0],ans=1;b得[0,1],ans=2;c得[0,2],ans=3;下标 3 的a的preIndex = 0 >= left = 0,left跳到 1,窗口[1,3],ans 保持 3;之后每轮都恰好跳一位,窗口长度稳定在 3;末尾两个b相邻,left被推到 7,窗口收成[7,7]。最终ans = 3。再看
s = "abba"这个专门卡人的用例:处理下标 3 的a时lastIndex['a'] = 0,但left已因为下标 2 的b重复被推到 2。0 < 2是过期位置,left必须保持 2,窗口为[2,3],答案 2。若漏掉preIndex >= left判断而把left回退到 1,就会错答成 3。
代码实现
class Solution {
public int lengthOfLongestSubstring(String s) {
Map<Character, Integer> lastIndex = new HashMap<>();
int left = 0;
int ans = 0;
for (int right = 0; right < s.length(); right++) {
char ch = s.charAt(right);
Integer preIndex = lastIndex.get(ch);
if (preIndex != null && preIndex >= left) {
// left 只越过当前窗口内的重复字符,保证窗口边界单调右移。
left = preIndex + 1;
}
lastIndex.put(ch, right);
ans = Math.max(ans, right - left + 1);
}
return ans;
}
}
func lengthOfLongestSubstring(s string) int {
lastIndex := make(map[byte]int)
left := 0
ans := 0
for right := 0; right < len(s); right++ {
ch := s[right]
if preIndex, ok := lastIndex[ch]; ok && preIndex >= left {
// left 只越过当前窗口内的重复字符,保证窗口边界单调右移。
left = preIndex + 1
}
lastIndex[ch] = right
if right-left+1 > ans {
ans = right - left + 1
}
}
return ans
}
复杂度分析
- 时间复杂度:$O(n)$,
right单调扫描 n 次,left只增不减、总位移不超过 n,哈希查询与写入均摊 $O(1)$。- 空间复杂度:$O(\min(n, k))$,
k为字符集大小,哈希表最多保存k个不同字符,也不会超过串长。
关键点总结
- 窗口能用双指针,依赖的是“无重复”对区间的单调性:合法区间的子区间一定合法。
- 哈希表存的是全局历史最近位置,而
left决定哪些历史仍然有效,两者靠preIndex >= left衔接。- “先修窗口,再记答案”是所有可变窗口题的固定次序。
- 一步跳跃能替代逐格收缩,前提是能直接算出新的左边界。
解法二:滑动窗口 + 字符计数
核心思路
换一个视角:不记位置,而是维护窗口内每个字符的出现次数
count。
s[right]入窗后令其计数加一,若count[ch] > 1说明窗口非法,就反复移出s[left](计数减一、left右移),直到count[ch]回到 1。收缩一定会终止,最坏情况是左端点推进到与right重合,窗口只剩一个字符。它比“最近位置”写法多走了收缩循环,换来的是通用性——把判非法的条件从“某字符出现超过 1 次”换成“窗口内字符种类超过 k”或“窗口内 0 的个数超过 k”,同一套骨架就能解 340、424、1004 等题。这就是通用可变窗口模板的形状:扩张 → while(非法) 收缩 → 统计。
左右指针都只向右移动,每个下标最多入窗一次、出窗一次,所以仍然是线性。
解题步骤
- 初始化:
count计数表、left = 0、ans = 0。要牢记count描述的是当前窗口,不是整串的字符频次。- 扩张:
right右移一位,count[s[right]]++。- 判非法:本轮只有
ch = s[right]的计数可能升到 2,所以只检查count[ch] > 1就够了,不必扫描整张表。- 收缩:
while (count[ch] > 1),取出s[left],把它的计数减一,left++。循环退出时ch在窗口中只剩一个。- 统计:窗口合法后用
right - left + 1更新ans。- 返回
ans。以
s = "pwwkew"走一遍:p得[0,0];w得[0,1],ans=2;下标 2 的w使count['w'] = 2,收缩两次(移出p、移出下标 1 的w),窗口变[2,2];k得[2,3],ans=2;e得[2,4],ans=3;下标 5 的w再次超标,收缩到[3,5],长度仍是 3。最终ans = 3,对应子串"wke"。注意"pwke"是子序列不是子串,这也是本题常见的读题陷阱。
代码实现
class Solution {
public int lengthOfLongestSubstring(String s) {
Map<Character, Integer> count = new HashMap<>();
int left = 0;
int ans = 0;
for (int right = 0; right < s.length(); right++) {
char ch = s.charAt(right);
count.put(ch, count.getOrDefault(ch, 0) + 1);
while (count.get(ch) > 1) {
// 只收缩到本轮新增字符不重复,窗口就恢复合法。
char removed = s.charAt(left);
count.put(removed, count.get(removed) - 1);
left++;
}
ans = Math.max(ans, right - left + 1);
}
return ans;
}
}
func lengthOfLongestSubstring(s string) int {
count := make(map[byte]int)
left := 0
ans := 0
for right := 0; right < len(s); right++ {
ch := s[right]
count[ch]++
for count[ch] > 1 {
// 只收缩到本轮新增字符不重复,窗口就恢复合法。
removed := s[left]
count[removed]--
left++
}
if right-left+1 > ans {
ans = right - left + 1
}
}
return ans
}
复杂度分析
- 时间复杂度:$O(n)$,每个下标最多被
right加入一次、被left移出一次,收缩总次数不超过 n。- 空间复杂度:$O(\min(n, k))$,计数表规模不超过窗口内出现过的字符种类数。
关键点总结
- 只检查“本轮新增字符”的计数,是把判非法从 $O(k)$ 降到 $O(1)$ 的关键。
count的语义严格绑定当前窗口,移出左端点时必须同步减一,漏减会让窗口永远非法。- 本题首推最近位置写法,更短且没有内层循环;计数写法的价值在于模板可迁移。
解法对比
最近出现位置:$O(n)$ 时间,一步跳过所有无效左边界,没有内层循环,代码最短,是本题面试主解。代价是必须理解“过期历史位置”,写漏
preIndex >= left就会 WA。字符计数:同样 $O(n)$,多了收缩循环、常数略大,但结构就是通用可变窗口模板。当约束换成“至多 k 种字符”“至多 k 个可替换字符”时跳跃法失效,计数法只需改一行判非法条件。
面试建议:先用最近位置版本拿下本题,再补一句“如果约束推广到至多 k 种字符,我会换成计数收缩的窗口模板”,可以同时体现熟练度和迁移能力。
常见面试追问:为什么代码里有内层
while仍是 $O(n)$?因为left只向右走,每个字符最多入窗、出窗各一次;如果要求返回最长子串本身,只需在刷新ans时同步记录起点;如果输入可能包含多字节 Unicode,Go 版本要把byte窗口改成[]rune,否则一个字符会被拆成多个字节。
易错点总结
- 漏掉
preIndex >= left判断:直接left = preIndex + 1会让left回退。用"abba"一测即错,返回 3 而正确答案是 2。- 先更新
lastIndex再取preIndex:读到的是当前下标自己,preIndex >= left恒成立,left每轮都被推到right + 1,窗口长度恒为 0,任何输入都返回 0。- 在修复窗口之前更新答案:会把含重复字符的非法窗口长度计入,结果偏大。
- 把
ans初始化为 1:空串会返回 1,正确初值是 0。- 计数写法忘记同步减一:移出左端点时只做
left++而不减计数,while条件永远成立,直接死循环。- 把子序列当成子串:
"pwwkew"的答案是 3 而不是 4,"pwke"并不连续。- 字符集假设:Java 的
char与 Go 的byte处理常规 ASCII 没问题;若输入含多字节 Unicode,Go 按byte遍历会切开码点,需要改成[]rune。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 159. 至多包含两个不同字符的最长子串 | 中等 | 判非法条件换成窗口内字符种类超过 2 |
| 340. 至多包含 K 个不同字符的最长子串 | 中等 | 种类上限推广为 k,计数写法可直接复用 |
| 424. 替换后的最长重复字符 | 中等 | 合法条件变为「窗口长度 - 最高频次 ≤ k」 |
| 1004. 最大连续 1 的个数 III | 中等 | 只对 0 计数,窗口内 0 的个数不超过 k |
| 992. K 个不同整数的子数组 | 困难 | 恰好 k 种,拆成「至多 k」减「至多 k-1」 |
| 剑指 Offer 48. 最长不含重复字符的子字符串 | 中等 | 与本题同题,另可用「以 i 结尾」的 dp 视角推导 |