算法刷题打卡 | 今天终于刷到了中等难度的题 ------LeetCode 3. 无重复字符的最长子串,这道题是滑动窗口的经典应用,之前做的都是链表的基础题,今天换字符串的题练练手,把 HashSet 和 HashMap 两种滑动窗口的写法都理清楚了,做个笔记记录一下。
题目回顾
给定一个字符串 s ,请你找出其中不含有重复字符的 最长子串 的长度。
示例
示例 1:
Plain
输入: s = "abcabcbb"
输出: 3
解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。
示例 2:
Plain
输入: s = "bbbbb"
输出: 1
解释: 因为无重复字符的最长子串是 "b",所以其长度为 1。
示例 3:
Plain
输入: s = "pwwkew"
输出: 3
解释: 因为无重复字符的最长子串是 "wke",所以其长度为 3。
请注意,你的答案必须是 子串 的长度,"pwke" 是一个子序列,不是子串。
示例 4:
Plain
输入: s = ""
输出: 0
核心思路:滑动窗口
这道题的核心就是滑动窗口 的思想:我们维护一个窗口 [left, right],保证这个窗口里的字符都是不重复的。然后我们不断移动右指针扩大窗口,遇到重复字符的时候,就移动左指针缩小窗口,直到窗口里没有重复字符,全程记录最大的窗口长度就可以了。
相比暴力枚举所有子串的 O (n²) 解法,滑动窗口可以把时间复杂度降到 O (n),每个字符最多被访问两次,效率高了很多。
解法一:HashSet 实现滑动窗口
这是滑动窗口的基础写法,比较直观,容易理解,适合新手入门。
步骤拆解
我自己整理的操作要点,每一步都不能乱:
-
初始化指针:左指针
left和右指针right都从 0 开始,temp记录当前窗口的长度,res记录最大的长度。 -
初始化 HashSet:注意 Set 的类型是
Character不是char,因为 Java 的泛型不能用基本类型,用来存当前窗口里的字符。 -
循环条件:只要右指针小于字符串的长度,就继续处理。
-
拿到当前右指针指向的字符
cur:
-
如果 Set 里已经包含了
cur,说明窗口里有重复了,我们要移动左指针缩小窗口:把左指针指向的字符从 Set 里删掉,左指针右移一位,当前窗口长度temp减一。 -
如果 Set 里不包含
cur,说明可以扩大窗口:把cur加入 Set,右指针右移一位,当前窗口长度temp加一,然后更新res为max(res, temp),记录最大的长度。TIP:更新 res 的操作必须放在 else 块里!不然重复的时候也会更新,会导致结果错误。
- 最后返回
res就可以了。
代码实现
java
class Solution {
public int lengthOfLongestSubstring(String s) {
int left=0,right=0,temp=0,res=0;
HashSet<Character> set = new HashSet<>();
while(right<s.length()){
char cur=s.charAt(right);
if(set.contains(cur)){
set.remove(s.charAt(left));
left++;
temp--;
}else{
set.add(cur);
right++;
temp++;
res=Math.max(temp,res);
}
}
return res;
}
}
踩坑感悟
一开始写这个的时候,我踩了两个小坑:
-
一开始把 Set 的泛型写成了
char,结果编译器直接报错,才想起来 Java 的泛型不支持基本类型,必须用包装类Character,这个细节差点卡了我半天。 -
一开始把更新 res 的操作放在了循环的最后,不管有没有重复都更新,结果遇到重复的时候,窗口缩小了,res 反而被更新成了更小的数,结果错了,后来才明白,只有扩大窗口的时候才需要更新 res,所以必须放在 else 里。
这个写法的好处是非常直观,很容易理解滑动窗口的移动过程,但是缺点是左指针是一步一步移动的,遇到重复的时候要移很多步,效率稍微低一点。
解法二:HashMap 优化滑动窗口
这个是滑动窗口的优化写法,我们可以用 HashMap 记录每个字符最后一次出现的位置,这样遇到重复的时候,左指针可以直接跳到重复字符的下一个位置,不用一步一步移了,效率更高。
步骤拆解
我整理的 HashMap 版的要点:
-
Map 的定义:Key 存储字符,Value 存储这个字符最后一次出现的下标位置。
-
初始化左指针
left从 0 开始,res记录最大长度。 -
遍历右指针
right,拿到当前的字符cur:
-
如果 Map 里已经存在
cur,说明这个字符之前出现过,那我们的左指针就要跳到这个字符上次出现的位置的下一位,这样窗口里就没有重复了。 -
注意这里要取
max(left, map.get(cur)+1),因为有可能这个字符上次出现的位置在左指针的左边,也就是不在当前窗口里,这时候我们不能把左指针往回跳,不然窗口就乱了。
-
然后把当前字符和它的下标更新到 Map 里,不管之前有没有,都要更新成最新的位置。
-
然后更新
res为max(res, right-left+1),也就是当前窗口的长度,记录最大的。
代码实现
java
class Solution {
public int lengthOfLongestSubstring(String s) {
int left=0,res=0;
HashMap<Character,Integer> map = new HashMap<>();
for(int right=0;right<s.length();right++){
char cur = s.charAt(right);
if(map.containsKey(cur)){
left = Math.max(left,map.get(cur)+1);
}
map.put(cur,right);
res = Math.max(res,right-left+1);
}
return res;
}
}
踩坑感悟
这个写法我踩了一个超级经典的坑:一开始我没加Math.max,直接写了left = map.get(cur)+1,结果测试abba这个用例的时候直接错了!
-
处理到第二个 b 的时候,left 变成了 2,没问题。
-
然后处理到第二个 a 的时候,map 里 a 的位置是 0,这时候如果直接
left=0+1=1,就把 left 往回跳了,窗口变成了[1,3],也就是bba,这时候就有重复了!后来才明白,必须用 max,保证 left 只会往右走,不会往左跳,这样才不会出错,这个坑真的太多人踩了,我调试了半天才发现问题所在。
这个写法的好处是左指针可以直接跳,不用一步一步移,所以效率比 HashSet 的版本高很多,尤其是长字符串的时候,差距很明显。
复杂度分析
两种解法的时间复杂度都是 O (n),n 是字符串的长度,每个字符最多被左右指针各访问一次,不会重复处理。
空间复杂度都是 O (min (m, n)),m 是字符集的大小,比如如果是英文字母的话,最多也就 26 个字符,所以空间其实很小,就算字符串很长,空间也不会太大。
刷题笔记与感悟
做完这道题,最大的收获就是彻底搞懂了滑动窗口的思想,之前一直觉得滑动窗口很抽象,现在做完这道题才明白,其实就是维护一个窗口,保证窗口里满足我们的条件,然后移动指针,动态调整窗口的大小,全程记录我们要的结果,这个思想真的太通用了,很多字符串、数组的题都能用。
而且这道题也让我明白,优化的思路就是:能不能把一步一步的移动,改成直接跳转到目标位置?用 HashMap 记录位置,就可以把 O (n) 的左指针移动,变成 O (1) 的跳转,虽然时间复杂度还是 O (n),但是实际运行效率高了很多。
另外边界情况也都测试了:空字符串、全重复的字符串、所有字符都不重复的字符串,两种方法都能正确处理,鲁棒性还是很好的。
总结
这道中等难度的题,核心就是滑动窗口,两种解法各有优劣:
-
HashSet 版:写法直观,容易理解,适合新手入门,帮你理解滑动窗口的移动过程。
-
HashMap 优化版:效率更高,代码更简洁,是面试的时候推荐写的版本,体现了你对优化的理解,也能体现你踩过那些经典的坑(比如 abba 的 max 问题)。
搞定了这道经典的滑动窗口题,之后的其他滑动窗口的题,就有很好的基础了!