这道题是滑动窗口里非常经典的一道题。
题目要求:
给定一个字符串
s,找出其中不含重复字符的最长子串长度。
例如:
s = "abcabcbb"
最长的不重复子串可以是:
"abc"
"bca"
"cab"
所以答案是:
3
这道题为什么适合滑动窗口?
因为题目要求的是:
一段连续区间里的最长结果。
这种题很适合用两个指针维护一个窗口:
left
↓
a b c a b c b b
↑
right
left 表示窗口左边界,right 表示窗口右边界。
窗口就是:
s[left ... right]
核心思想:
right不断向右扩大窗口,发现重复字符时,就移动left缩小窗口。
什么是滑动窗口?
滑动窗口本质上就是:
用两个指针维护一个连续区间。
例如:
a b c a b c b b
↑ ↑
L R
当前窗口:
abc
没有重复字符,所以窗口合法。
于是 right 继续向右:
a b c a
↑ ↑
L R
窗口变成:
abca
出现了两个 a,不合法。
这时候就移动左指针:
a b c a
↑ ↑
L R
窗口变成:
bca
重新满足"没有重复字符"。
所以滑动窗口的核心就一句话:
右指针负责扩张,左指针负责缩小。
JavaScript 代码
javascript
var lengthOfLongestSubstring = function(s) {
let left = 0
let maxLength = 0
const set = new Set()
for (let right = 0; right < s.length; right++) {
while (set.has(s[right])) {
set.delete(s[left])
left++
}
set.add(s[right])
maxLength = Math.max(
maxLength,
right - left + 1
)
}
return maxLength
}
这段代码就是标准的滑动窗口写法。
五、代码逐步讲解
先初始化:
let left = 0
let maxLength = 0
const set = new Set()
含义:
left
→ 窗口左边界
maxLength
→ 当前找到的最大长度
set
→ 当前窗口里的字符
六、right 为什么一直往右走?
for (let right = 0; right < s.length; right++)
right 表示:
当前准备加入窗口的字符位置。
例如:
abcabcbb
↑
R
abcabcbb
↑
R
abcabcbb
↑
R
每一轮:
right 往右走一步
就是不断扩大窗口。
七、为什么这里要用 while?
核心代码:
while (set.has(s[right])) {
set.delete(s[left])
left++
}
意思是:
如果当前字符已经存在于窗口中,就不断移动
left,直到重复字符消失。
例如:
abc
当前:
set = {a, b, c}
现在 right 又遇到:
a
窗口变成:
abca
发现:
set.has("a")
// true
说明重复。
于是:
set.delete(s[left])
left++
删掉左边的 a。
窗口变成:
bca
现在不重复了。
八、为什么不是 if,而是 while?
因为删除一个字符以后,不一定马上满足条件。
滑动窗口常见模板就是:
while (窗口不合法) {
缩小窗口
}
意思是:
只要窗口还不满足要求,就一直缩。
这也是滑动窗口题里非常常见的写法。
九、什么时候把新字符加入窗口?
当重复已经被处理完之后:
set.add(s[right])
这时候可以保证:
当前窗口内没有重复字符。
于是就可以计算当前窗口长度。
十、为什么窗口长度是 right - left + 1?
例如:
a b c
0 1 2
如果:
left = 0
right = 2
窗口长度应该是:
3
计算:
right - left + 1
就是:
2 - 0 + 1
// 3
所以:
maxLength = Math.max(
maxLength,
right - left + 1
)
表示:
当前窗口长度和历史最大长度比较,保存更大的那个。
十一、用 abcabcbb 走一遍
字符串:
a b c a b c b b
开始:
left = 0
set = {}
maxLength = 0
right = 0
字符:
a
加入:
a
长度:
1
right = 1
字符:
b
窗口:
ab
长度:
2
right = 2
字符:
c
窗口:
abc
长度:
3
所以:
maxLength = 3
right = 3
字符又是:
a
当前:
abca
重复。
移动 left:
abc a
↑ ↑
L R
窗口重新变成:
bca
长度还是:
3
后面继续重复这个过程。
最终答案:
3
十二、这道题的核心思路
这道题可以压缩成四步:
1. right 向右扩大窗口
2. 如果出现重复字符
→ left 向右缩小窗口
3. 保证窗口始终没有重复字符
4. 不断记录最大窗口长度
这就是滑动窗口。
十三、滑动窗口通用模板
很多字符串和数组题都可以套这个思路:
let left = 0
for (let right = 0; right < nums.length; right++) {
// 1. 把 right 对应元素加入窗口
while (窗口不满足条件) {
// 2. 移除 left 对应元素
left++
}
// 3. 此时窗口合法
// 4. 更新答案
}
核心规律:
right
→ 扩大窗口
left
→ 缩小窗口
十四、为什么时间复杂度是 O(n)?
虽然代码里有:
for
里面还有:
while
但并不是 O(n²)。
因为:
right 只向右移动
left 也只向右移动
每个字符最多:
进入窗口一次
离开窗口一次
所以总操作次数大约是:
2n
时间复杂度:
O(n)
空间复杂度:
O(n)
因为最坏情况下,Set 可能保存整个字符串的字符。
十五、Set 版和 Map 版有什么区别?
这道题常见两种写法。
Set
记录:
当前窗口里有哪些字符
遇到重复字符时:
left 一个一个向右移动
优点:
思路简单,非常适合学习滑动窗口。
Map
记录:
字符最后一次出现的位置
遇到重复字符时,可以直接:
跳过重复位置
例如:
var lengthOfLongestSubstring = function(s) {
const map = new Map()
let left = 0
let maxLength = 0
for (let right = 0; right < s.length; right++) {
if (
map.has(s[right]) &&
map.get(s[right]) >= left
) {
left = map.get(s[right]) + 1
}
map.set(s[right], right)
maxLength = Math.max(
maxLength,
right - left + 1
)
}
return maxLength
}
不过第一次学习建议先掌握 Set 版。
十六、滑动窗口适合解决什么题?
滑动窗口特别适合:
连续子数组 / 连续子串 + 最长、最短、满足某种条件
例如:
- 无重复字符的最长子串
- 至多包含 K 个不同字符的最长子串
- 最小覆盖子串
- 长度最小的子数组
- 固定长度窗口求最大值/平均值
它们的核心套路基本都是:
扩大窗口
↓
判断条件
↓
不满足就缩小窗口
↓
满足后更新答案
总结
这道题的核心不是 Set,而是:
滑动窗口 + 双指针。
最核心的代码只有:
for (let right = 0; right < s.length; right++) {
while (set.has(s[right])) {
set.delete(s[left])
left++
}
set.add(s[right])
maxLength = Math.max(
maxLength,
right - left + 1
)
}
把它翻译成人话就是:
right 不断往右扩
↓
如果出现重复
↓
left 不断往右缩
↓
直到窗口重新合法
↓
记录当前窗口长度
↓
继续向右
一句话记住滑动窗口:
右指针负责扩,左指针负责缩,始终维护一个满足条件的连续区间。