目录
- [LeetCode 3. 无重复字符的最长子串|不定长滑动窗口:什么时候更新最大值?](#LeetCode 3. 无重复字符的最长子串|不定长滑动窗口:什么时候更新最大值?)
-
- 一、先确定思路:窗口什么时候扩大,什么时候缩小?
- 二、怎么判断窗口有没有重复字符?
- 三、出现重复字符以后,怎么恢复合法窗口?
- 四、什么时候记录最大长度?
- 五、窗口长度需要额外维护吗?
- 六、完整代码
- 七、代码中的几个细节
-
- [1. freq.get(sright, 0) + 1 是什么意思?](#1. freq.get(s[right], 0) + 1 是什么意思?)
- [2. 为什么删除次数为 0 的键?](#2. 为什么删除次数为 0 的键?)
- [3. 为什么更新答案放在 while 后面?](#3. 为什么更新答案放在 while 后面?)
- 八、复杂度分析
- 九、最后复盘
LeetCode 3. 无重复字符的最长子串|不定长滑动窗口:什么时候更新最大值?
题目链接:3. 无重复字符的最长子串
PS:弄了个网站,可以看代码是怎么一步步运行、得到最终结果的:过程可视化
上一篇做了 209. 长度最小的子数组,第一次接触了不定长滑动窗口。
这是209的题解 这道题 要求的是满足条件的最短子数组,所以当窗口满足条件时,我们会尝试缩小窗口,寻找更短的答案。
但这次的 LeetCode 3,要求的是没有重复字符的最长子串。
同样是不定长滑动窗口,问题却反过来了:我们要尽可能扩大窗口,但又不能让窗口中出现重复字符。
那么,什么时候应该缩小窗口?又应该在什么时候记录最大长度?
一、先确定思路:窗口什么时候扩大,什么时候缩小?
题目要求:给定一个字符串 s,找出其中不含重复字符的最长连续子串的长度。
例如:
python
s = "abcabcbb"
答案是 3,因为 "abc" 是一个没有重复字符的子串,长度为 3。
这里需要注意,子串必须连续,不能随意跳过中间的字符。
既然要求连续,而且长度不固定,我们就继续用不定长滑动窗口。
先设置两个指针:
left:窗口左边界。right:窗口右边界。
例如:
text
s = "abcabcbb"
[a, b, c] a b c b b
L R
当前窗口 "abc" 没有重复字符,长度为 3。
因为我们要找的是最长子串,所以只要窗口合法,就希望它能够继续扩大。
于是让 right 向右移动,加入下一个字符。
但加入之后,窗口可能变成:
text
[a, b, c, a] b c b b
L R
现在出现了两个 a。
这个窗口已经不满足题目要求了。
所以我们可以先确定一个基本方向:
text
右指针向右移动
↓
加入一个新字符
↓
窗口出现重复了吗?
│
├── 没有 → 窗口合法,可以记录长度
│
└── 出现 → 移动 left,缩小窗口
↓
直到没有重复
不过,这里有两个问题:
- 怎么知道窗口中出现了重复字符?
- 出现重复以后,怎么知道要把左边的字符移出去多少个?
这就需要维护窗口中的字符信息了。
二、怎么判断窗口有没有重复字符?
最直接的想法,就是记录当前窗口中出现过哪些字符。
这里可以使用一个字典 freq,记录每个字符出现的次数。
例如:
text
当前窗口:[a, b, c]
freq = {
'a': 1,
'b': 1,
'c': 1
}
每个字符都只出现了一次,所以没有重复。
但如果右指针继续移动,又加入一个 a:
text
当前窗口:[a, b, c, a]
freq = {
'a': 2,
'b': 1,
'c': 1
}
现在 a 出现了两次,说明窗口已经不合法。
但是,逐个检查字典中的次数,代码写起来也很复杂,还有没有更简单的判断方式?
这个时候,咱们可以观察两个数字:
第一个:当前窗口中一共有多少个字符?
因为 left 和 right 分别指向窗口的左右边界,所以:
python
right - left + 1
就是窗口长度。
第二个:当前窗口中有多少种不同字符?
由于字典的每个键对应一种字符,所以:
python
len(freq)
就是不同字符的数量。
我们对比一下:
text
窗口:[a, b, c]
窗口长度 = 3
不同字符数量 = 3
没有重复
再看:
text
窗口:[a, b, c, a]
窗口长度 = 4
不同字符数量 = 3
出现重复
因此:如果窗口长度等于不同字符数量,说明窗口没有重复字符。
反过来:
python
len(freq) != right - left + 1
就说明窗口中存在重复字符。
🔖 这里有个前提:freq 必须准确维护当前窗口中的字符,而且当字符出现次数减到 0 时,一定要记得把对应的键删除。。。
否则,len(freq) 就不能代表当前窗口中不同字符的数量。
三、出现重复字符以后,怎么恢复合法窗口?
知道怎么判断重复以后,下一步就是处理重复。
继续看刚才的例子:
text
s = "abcabcbb"
当前窗口:
[a, b, c, a] b c b b
L R
此时窗口 "abca" 中出现了两个 a。
因为右指针刚刚加入了新的字符,而我们希望尽可能去寻找多个组合,那就需要优先保留右边的新内容,所以可以尝试从左边移出字符。
移出最左边的 a:
text
[a, b, c, a] b c b b
L R
当前窗口:[b, c, a]
现在窗口重新变成了 "bca"。
没有重复字符,长度为 3。
所以,恢复合法窗口的方式就是:不断移出左边的字符,直到窗口中不再出现重复。
🔖 但为什么要不断移出,而不是只移出一次?
例如:
text
s = "abcb"
加入最后一个 b 后:
[a, b, c, b]
L R
窗口中出现了两个 b。
如果只移出最左边的 a:
text
[b, c, b]
L R
还是有两个 b!
所以还得继续移出左边的 b:
text
[c, b]
L R
现在才恢复合法。
因此,这里需要的是循环收缩:
python
while len(freq) != right - left + 1:
只要窗口存在重复字符,就继续缩小。
直到条件不成立,也就是:
text
len(freq) == right - left + 1
说明窗口重新合法,才停止收缩。
这和上一道 209 有一个明显区别:
- 209:窗口满足条件时收缩,是为了寻找更短的合法窗口。
- 3:窗口不满足条件时收缩,是为了消除重复、恢复合法窗口。
所以,不定长滑动窗口的收缩条件,并不是固定的,而是取决于题目要求。
四、什么时候记录最大长度?
这是我做这道题时比较容易混乱的地方。
这里需要一个变量 max_len,记录目前找到的最长无重复子串长度。
python
max_len = 0
但问题是:什么时候更新它???
一开始我想的是:当窗口第一次出现重复字符时,是不是就意味着前面那个无重复子串结束了,这时候就该记录最大值?
但仔细想想,这里有两个问题。
-
- 加入重复字符后的窗口已经不合法了,不能直接拿它的长度更新答案。
-
- 如果整个字符串都没有重复字符呢?
例如:
python
s = "abcdef"
右指针可以一直向右移动,窗口始终合法,根本不需要收缩。
如果只在出现重复时更新最大值,就会漏掉这种情况。
所以,更新最大值 需要关注的并不是什么时候出现重复 ,而是:当前窗口是否合法?只要合法,就可以尝试更新最大长度。
结合前面恢复合法窗口的思路,我们可以把过程整理成:
text
加入新字符
↓
窗口有重复吗?
│
├── 有 → 不断缩小窗口,直到恢复合法
│ ↓
└── 没有 ─────────────────┘
↓
更新最大长度
这样,无论当前窗口原本就没有重复,还是经过收缩才恢复合法,最后都能统一更新答案。
对应到代码,就是把更新最大值的操作放在 while 后面:
python
while len(freq) != right - left + 1:
# 移出左边字符,直到没有重复
...
max_len = max(max_len, right - left + 1)
同时,需要注意:while 并不是每次都会执行。
- 如果窗口没有重复,直接跳过
while,更新最大长度。 - 如果窗口出现重复,就不断收缩,直到恢复合法,再更新最大长度。
因此,当程序执行到:
python
max_len = max(max_len, right - left + 1)
时,当前窗口一定是合法的。
而 right - left + 1 为什么能用于更新最大窗口长度,我们下一节再具体解释。
五、窗口长度需要额外维护吗?
这道题还有一个容易想复杂的地方:窗口长度。
我们当然可以设置一个 current,每次扩大窗口就加 1,每次缩小窗口就减 1。
但仔细想想,其实没必要。
因为我们已经有两个指针:
text
下标: 0 1 2 3 4
字符: a b c d e
L R
当前窗口是 "bcd"。
text
left = 1
right = 3
窗口长度 = 3 - 1 + 1 = 3
所以直接使用:
python
right - left + 1
就能得到当前窗口长度。
因此,我们不需要再维护一个额外的长度变量。
这样也避免了每次移动指针时,还得同步修改 current 的麻烦。
到这里,整道题的思路就已经完整了:
text
用 left、right 维护窗口
↓
用 freq 记录字符出现次数
↓
right 向右移动,加入新字符
↓
当前窗口有重复吗?
│
├── 有 → 移动 left,缩小窗口
│ ↓
│ 直到没有重复
│ ↓
└── 没有 ────────┘
↓
更新最大长度
↓
继续移动 right
接下来就可以根据这套思路写代码了。
六、完整代码
python
class Solution:
def lengthOfLongestSubstring(self, s: str) -> int:
freq = {}
left = 0
right = 0
max_len = 0
while right < len(s):
# 右指针扩大窗口,记录新字符
freq[s[right]] = freq.get(s[right], 0) + 1
# 出现重复时,不断缩小窗口
while len(freq) != right - left + 1:
freq[s[left]] -= 1
if freq[s[left]] == 0:
del freq[s[left]]
left += 1
# 窗口恢复合法后,更新最大长度
max_len = max(max_len, right - left + 1)
right += 1
return max_len
七、代码中的几个细节
前面已经推导完思路,这里再解释一下具体代码为什么这么写。
1. freq.get(sright, 0) + 1 是什么意思?
python
freq[s[right]] = freq.get(s[right], 0) + 1
freq.get(key, default) 是字典的一个方法。
它的意思是:
- 如果字典中存在这个键,就获取对应的值。
- 如果不存在,就返回给定的默认值
default
例如:
python
freq = {'a': 1}
freq.get('a', 0) # 1
freq.get('b', 0) # 0
因此:
python
freq[s[right]] = freq.get(s[right], 0) + 1
就是把当前字符的出现次数加 1。
如果字符第一次出现,就从 0 加到 1;如果之前出现过,就在原来的次数上加 1。
2. 为什么删除次数为 0 的键?
收缩窗口时,我们需要把左边的字符移出去:
python
freq[s[left]] -= 1
例如:
python
freq = {
'a': 1,
'b': 1,
'c': 1
}
如果移出 a,它的次数就会变成 0:
python
freq = {
'a': 0,
'b': 1,
'c': 1
}
但注意:字典中的键即使对应的值是 0,仍然会被 len(freq) 统计进去。
也就是说:
python
len(freq) # 仍然是 3
可实际窗口中只剩下 b 和 c 两种字符。
因此,必须在次数减到 0 时删除对应的键:
python
if freq[s[left]] == 0:
del freq[s[left]]
del 是 Python 的删除语句,这里用于删除字典中指定的键值对。
删除后:
python
freq = {
'b': 1,
'c': 1
}
这样 len(freq) 才能准确表示当前窗口中不同字符的数量。
3. 为什么更新答案放在 while 后面?
之前解释过了, 这里就从代码简单讲一下:
python
while len(freq) != right - left + 1:
...
max_len = max(max_len, right - left + 1)
因为内层 while 的作用是恢复窗口合法性。
- 如果存在重复,就一直缩小。
- 当
while结束时,窗口已经没有重复字符,此时才适合更新最大长度。
- 当
- 如果本来就没有重复,
while会直接跳过,同样可以更新答案。
所以无论本轮是否发生收缩,都不会漏掉合法窗口。
八、复杂度分析
-
时间复杂度:
O(n)虽然代码使用了嵌套循环:
pythonwhile right < len(s): while len(freq) != right - left + 1:但这里并不是每次外层循环都重新遍历整个字符串。
right:从左到右移动,每个字符最多加入窗口一次,总共移动n次。left:同样只向右移动,每个字符最多移出窗口一次,总共最多移动n次。freq:字典的查询、插入和删除操作,平均时间复杂度为O(1)。
因此,两个指针总共最多移动约
2n次,整体时间复杂度为O(n),而不是O(n²)。 -
空间复杂度:
O(min(n, K))额外空间主要来自记录字符出现次数的字典
freq。n:字符串的长度。K:字符串所属字符集的大小。- 字典最多记录
min(n, K)种不同字符,因此空间复杂度为O(min(n, K))。
如果字符集大小固定,例如只考虑 ASCII 字符,那么
K可以视为常数,空间复杂度也可以记为O(1)。
九、最后复盘
这道题最开始容易混乱的地方,除去指针的移动方式 ,还在于:什么时候窗口才算合法,以及什么时候应该更新答案。
整个思路可以总结成:
text
目标:最长无重复子串
↓
使用左右指针维护连续窗口
↓
使用 freq 记录字符出现次数
↓
右指针不断加入新字符
↓
出现重复?
│
├── 是 → 左指针不断右移
│ ↓
│ 恢复合法
│
└── 否
↓
更新当前最大长度
↓
继续扩大窗口
和上一道 209 对比,会发现两道题虽然都使用不定长滑动窗口,但处理方式恰好不同。
| 209. 长度最小的子数组 | 3. 无重复字符的最长子串 | |
|---|---|---|
| 目标 | 最短合法窗口 | 最长合法窗口 |
| 收缩条件 | 总和达到 target |
窗口出现重复 |
| 收缩目的 | 寻找更短的合法窗口 | 恢复窗口合法性 |
| 更新答案 | 收缩之前 | 收缩之后 |
对于 LeetCode 3:
先扩大窗口,出现重复就收缩,恢复合法后再更新最大长度。
而窗口长度本身已经可以通过 right - left + 1 得到,不需要再额外维护一个变量。