LeetCode 3. 无重复字符的最长子串:滑动窗口详解
1. 这道题到底在问什么
LeetCode 3「无重复字符的最长子串」要求我们:
text
给定一个字符串 s,找出其中不含重复字符的最长子串的长度。
这里最重要的是两个词:
text
1. 子串
2. 无重复字符
子串必须是连续的。比如:
text
s = "pwwkew"
"wke" 是子串,因为它在原字符串中连续出现。
但是 "pwke" 不是子串,因为它跳过了中间的一个 w,它只是子序列,不符合题意。
所以这个例子的答案是:
text
3
对应的最长无重复子串可以是:
text
"wke"
这道题真正要解决的问题可以换一种说法:
text
在字符串中找一段尽可能长的连续区间,并且这段区间内不能有重复字符。
一旦看到"连续区间"加"满足某种限制条件"加"求最长",就可以优先考虑滑动窗口。
2. 先从暴力思路看问题
假设字符串是:
text
s = "abcabcbb"
如果用最直接的暴力思路,我们可以从每个位置开始,向右枚举子串,直到出现重复字符为止。
从下标 0 开始:
text
a
ab
abc
abca 出现重复 a,停止
所以以 0 为起点的最长无重复子串是:
text
"abc"
长度是 3。
从下标 1 开始:
text
b
bc
bca
bcab 出现重复 b,停止
所以以 1 为起点的最长无重复子串是:
text
"bca"
长度也是 3。
从下标 2 开始:
text
c
ca
cab
cabc 出现重复 c,停止
所以以 2 为起点的最长无重复子串是:
text
"cab"
长度也是 3。
暴力思路当然可以理解题目,但它有明显的问题:很多字符会被反复检查。
比如我们已经知道从 0 开始可以扩到 2,窗口是 "abc"。当起点变成 1 时,其实 "bc" 这一段仍然是无重复的,没有必要重新从 1 开始一点点检查。
滑动窗口就是利用了这个事实:
text
已经维护好的无重复区间,在左边界右移后,仍然不会因为删除字符而变得重复。
这句话是理解本题滑动窗口的核心。
3. 滑动窗口的核心直觉
我们维护一个窗口:
text
s[i..rk]
其中:
text
i 表示窗口左边界
rk 表示窗口右边界
这个窗口始终满足一个条件:
text
窗口里的字符没有重复。
例如:
text
s = "abcabcbb"
当前窗口:
[a b c]
i rk
为了快速判断某个字符是否已经在窗口里,我们用一个集合 Set 保存窗口中出现过的字符。
java
Set<Character> occ = new HashSet<Character>();
occ 可以理解成:
text
当前窗口里已经有哪些字符。
如果下一个字符不在 occ 里,说明它可以加入窗口。
如果下一个字符已经在 occ 里,说明加入后会产生重复,窗口不能继续向右扩展。
所以滑动窗口的动作只有两个:
text
1. 右边界 rk 向右扩展,把新字符加入窗口。
2. 左边界 i 向右移动,把旧字符移出窗口。
官方解法采用的是这种叙事:
text
固定左边界 i,让右边界 rk 尽可能向右扩。
扩不动时,当前窗口就是以 i 为左边界的最长无重复子串。
然后 i 向右移动一格,继续处理下一个左边界。
4. 官方代码完整注释
下面是官方滑动窗口写法的 Java 版本,并加上完整注释。
java
import java.util.HashSet;
import java.util.Set;
class Solution {
public int lengthOfLongestSubstring(String s) {
// occ 表示当前窗口中已经出现过的字符。
// 窗口范围是 [i, rk]。
// 在整个算法过程中,我们始终维护:窗口内没有重复字符。
Set<Character> occ = new HashSet<Character>();
// 字符串长度。
int n = s.length();
// rk 是右指针,表示当前窗口的右边界。
// 初始值为 -1,表示窗口一开始是空的。
// 如果左边界 i = 0,那么空窗口可以看作 [0, -1]。
int rk = -1;
// ans 记录目前找到的最长无重复子串长度。
int ans = 0;
// i 是左指针,表示当前窗口的左边界。
// 外层循环枚举每一个可能的左边界 i。
for (int i = 0; i < n; ++i) {
// 当 i 不等于 0 时,说明左边界从 i - 1 移动到了 i。
// 原来的 s.charAt(i - 1) 已经不属于当前窗口了,
// 所以需要把它从 occ 中移除。
if (i != 0) {
occ.remove(s.charAt(i - 1));
}
// 尝试不断向右扩展窗口。
//
// rk + 1 < n:
// 表示右边还有字符可以尝试加入。
//
// !occ.contains(s.charAt(rk + 1)):
// 表示下一个字符还没有在当前窗口中出现过,
// 加入后不会产生重复。
while (rk + 1 < n && !occ.contains(s.charAt(rk + 1))) {
// 把下一个字符加入窗口。
occ.add(s.charAt(rk + 1));
// 右指针向右移动一位。
++rk;
}
// while 循环结束时,说明出现了两种情况之一:
// 1. rk 已经到达字符串末尾;
// 2. s.charAt(rk + 1) 已经在窗口中,不能继续加入。
//
// 此时窗口 [i, rk] 就是"以 i 为左边界"的最长无重复子串。
// 窗口长度是 rk - i + 1。
ans = Math.max(ans, rk - i + 1);
}
// 返回所有窗口中出现过的最大长度。
return ans;
}
}
这段代码里最关键的三个变量是:
text
i 左边界
rk 右边界
occ 当前窗口中的字符集合
只要这三个变量的含义清楚,代码整体就不难理解。
5. 为什么 rk 一开始是 -1
rk = -1 是很多人第一次看官方题解时会困惑的地方。
其实它只是为了表示:
text
窗口一开始是空的。
如果左边界是:
text
i = 0
那么空窗口可以看成:
text
[0, -1]
这个区间没有任何字符。
代码中每次准备扩展时,看的是:
java
s.charAt(rk + 1)
当 rk = -1 时,第一次看的就是:
java
s.charAt(0)
这刚好是字符串的第一个字符。
所以 rk = -1 不是特殊技巧,它只是空窗口的一种自然表示。
可以把它记成:
text
rk 表示已经加入窗口的最右位置。
一开始还没有任何字符加入窗口,所以 rk 在 0 的左边,也就是 -1。
6. 用 abcabcbb 手动模拟一遍
现在用示例完整走一遍。
text
s = "abcabcbb"
初始状态:
text
i = 0
rk = -1
occ = {}
ans = 0
第一轮:i = 0
当前窗口为空。
看 rk + 1 = 0,字符是 a。
a 不在 occ 中,可以加入。
text
occ = {a}
rk = 0
继续看 rk + 1 = 1,字符是 b。
b 不在 occ 中,可以加入。
text
occ = {a, b}
rk = 1
继续看 rk + 1 = 2,字符是 c。
c 不在 occ 中,可以加入。
text
occ = {a, b, c}
rk = 2
继续看 rk + 1 = 3,字符是 a。
但是 a 已经在 occ 中了,如果加入就会重复。
所以停止扩展。
此时窗口是:
text
s[0..2] = "abc"
长度是:
text
rk - i + 1 = 2 - 0 + 1 = 3
更新答案:
text
ans = 3
第二轮:i = 1
左边界从 0 移动到 1。
所以要删除原来的左边字符:
text
s[0] = a
删除后:
text
occ = {b, c}
此时窗口实际是:
text
s[1..2] = "bc"
继续尝试扩展右边界。
看 rk + 1 = 3,字符是 a。
a 不在 occ 中,可以加入。
text
occ = {b, c, a}
rk = 3
继续看 rk + 1 = 4,字符是 b。
b 已经在 occ 中,不能加入。
所以停止扩展。
当前窗口是:
text
s[1..3] = "bca"
长度是:
text
3
答案仍然是:
text
ans = 3
第三轮:i = 2
左边界从 1 移动到 2。
删除原来的左边字符:
text
s[1] = b
删除后:
text
occ = {c, a}
此时窗口是:
text
s[2..3] = "ca"
继续扩展右边界。
看 rk + 1 = 4,字符是 b。
b 不在 occ 中,可以加入。
text
occ = {c, a, b}
rk = 4
继续看 rk + 1 = 5,字符是 c。
c 已经在 occ 中,不能加入。
所以停止扩展。
当前窗口是:
text
s[2..4] = "cab"
长度还是:
text
3
后面继续移动,最长长度不会超过 3。
所以最终答案是:
text
3
7. 为什么右指针不需要回退
这是滑动窗口比暴力枚举快的根本原因。
假设当前窗口是:
text
s[i..rk]
并且这个窗口没有重复字符。
当左边界从 i 移动到 i + 1 时,新窗口变成:
text
s[i + 1..rk]
这一步只是删除了最左边的一个字符。
删除字符不会制造重复。
也就是说,如果原窗口 s[i..rk] 是合法的,那么删除一个字符后的窗口 s[i + 1..rk] 一定还是合法的。
所以右指针 rk 没必要回退。
它只需要继续从当前的位置往右尝试扩展即可。
这也是滑动窗口的本质优化:
text
左指针只向右走,右指针也只向右走。
每个字符最多被加入窗口一次,也最多被移出窗口一次。
因此整个过程不会反复扫描同一段字符串。
8. 为什么不会漏掉答案
外层循环会枚举每一个左边界 i:
java
for (int i = 0; i < n; ++i)
对于每一个固定的 i,内层 while 会让 rk 尽可能向右扩展,直到再扩就会出现重复字符。
所以每一轮结束时,窗口 [i, rk] 都代表:
text
以 i 为左边界的最长无重复子串。
比如对 "abcabcbb" 来说:
text
i = 0 找到 "abc"
i = 1 找到 "bca"
i = 2 找到 "cab"
每个左边界对应的最优结果都被考虑到了。
最终答案就是这些结果中的最大值。
这就是为什么滑动窗口不会漏掉真正的最长子串。
9. 复杂度分析
时间复杂度是:
text
O(n)
原因是:
text
i 从左到右最多走 n 次。
rk 从左到右最多走 n 次。
虽然代码里有一个 while 循环,但 rk 不会回退,所以整体不是 O(n^2)。
空间复杂度是:
text
O(字符集大小)
因为 occ 中保存的是当前窗口里的字符。
如果只考虑常见 ASCII 字符,可以近似认为空间是常数级;如果按一般字符串理解,最多也不会超过字符串长度。
10. 总结
这道题的第一性原理是:
text
我要找一个尽可能长的连续区间,并且这个区间里不能有重复字符。
为了做到这一点,我们维护一个滑动窗口:
text
窗口 [i, rk] 始终不包含重复字符。
然后每一轮做两件事:
text
1. 固定左边界 i,让右边界 rk 尽可能向右扩展。
2. 扩不动时,用当前窗口长度更新答案,然后左边界右移。
Set 的作用是判断一个字符是否已经在当前窗口里。
rk = -1 的作用是表示初始窗口为空。
右指针不需要回退,是因为左边界右移只会删除字符,而删除字符不会让一个无重复窗口变成有重复窗口。
以后遇到类似问题时,可以优先观察它是不是满足这几个特征:
text
1. 处理的是连续子串或连续子数组。
2. 需要满足某种限制条件。
3. 要求最长、最短或数量。
如果满足这些特征,滑动窗口通常就是非常值得优先考虑的方向。