力扣hot100-3.无重复字符的最长子串-滑动窗口详解

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. 要求最长、最短或数量。

如果满足这些特征,滑动窗口通常就是非常值得优先考虑的方向。