刚入门算法的时候,这道题真的给我整懵过。暴力法写出来直接超时,想优化又不知道从哪下手,看滑动窗口的题解总觉得 "道理我都懂,怎么写出来就不对"。今天把我刷了三遍总结出来的两种滑动窗口写法分享给你,从好理解的入门版到更高效的优化版,连我踩过的坑都给你标出来了。
题目说啥
题目很简单,给你一个字符串 s,找出里面连续且没有重复字符的最长子串,返回它的长度。划重点:是子串(必须连续),不是子序列,别搞混了。
入门版:计数数组滑动窗口
我最开始理解滑动窗口,就是从这个版本开始的。
思路其实很朴素:我们用两个指针框出一个窗口,右指针负责往右 "扩张",每遇到一个字符就给它记个数。一旦发现当前字符出现次数超过 1 了,说明窗口里有重复,那就移动左指针 "收缩" 窗口,直到当前字符不重复为止。全程记录窗口的最大长度就行。
拿示例 abcabcbb 走两步你就懂了:
- right 走到 0(字符 a):a 计数 1,窗口长度 1,最大长度 1
- right 走到 1(字符 b):b 计数 1,窗口长度 2,最大长度 2
- right 走到 2(字符 c):c 计数 1,窗口长度 3,最大长度 3
- right 走到 3(字符 a):a 计数变成 2,重复了!左指针往右走一步,a 计数减 1,left 变成 1,窗口变成 "bca",长度还是 3
- 以此类推,最后最大长度就是 3
这个版本的好处是逻辑直白,不容易写错,新手特别友好。
javascript
var lengthOfLongestSubstring = function(s) {
const strLen = s.length;
// 用数组存每个字符出现次数,ASCII共128个,覆盖字母、数字、符号完全够用
const charCount = new Array(128).fill(0);
let left = 0; // 窗口左边界
let maxLen = 0; // 记录最长无重复子串长度
for (let right = 0; right < strLen; right++) {
// 右指针字符进入窗口,计数+1
const charCode = s.charCodeAt(right);
charCount[charCode]++;
// 当前字符重复了,收缩左边界直到不重复
while (charCount[charCode] > 1) {
charCount[s.charCodeAt(left)]--;
left++;
}
// 每次都更新一下最大长度
maxLen = Math.max(maxLen, right - left + 1);
}
return maxLen;
};
提个小细节:用数组比用 Map / 对象快一点,字符的 ASCII 码直接当下标用,寻址更快。
优化版:记录位置的滑动窗口
写完上面那个版本我就在想,遇到重复的时候,左指针非要一步步挪吗?比如我已经知道这个字符上次出现在哪个位置了,那左指针直接跳到 "上次位置 + 1" 不就好了?一步到位,省掉内层的 while 循环。
思路升级一下:我们不用计数了,改成记录每个字符上一次出现的索引位置。右指针每走一步,就查一下这个字符之前有没有出现过:
- 如果出现过,并且在当前窗口里,那就把左边界直接更新为「上次出现位置 + 1」
- 不管有没有出现,都更新这个字符的最新位置
- 同时更新最大长度
还是拿 abcabcbb 举例,当 right 走到索引 3(第二个 a):
- 查到 a 上次出现在索引 0
- 左边界直接跳到 0+1 = 1,窗口瞬间变成 1,3,也就是 "bca"
- 不用一步步减计数挪左指针,一步到位
这个写法我一开始写翻车了,就是数组初始值的坑。一开始我把数组初始化成 0,结果第一个字符进来的时候,lastPos[charCode]+1 就变成 1,左边界直接错了。正确的初始值应该是 -1,这样第一次遇到字符时,-1 + 1 = 0,左边界刚好不动,完美对应。
javascript
var lengthOfLongestSubstring = function(s) {
// 记录每个字符上一次出现的索引,初始值 -1(坑点:别写成0!我在这里卡了半小时)
const lastPos = new Array(256).fill(-1);
let maxLen = 0;
let left = 0; // 窗口左边界
for (let right = 0; right < s.length; right++) {
const charCode = s.charCodeAt(right);
// 更新左边界:如果当前字符在窗口内出现过,直接跳到上次位置+1
// Math.max 很关键!防止左边界往回走
left = Math.max(left, lastPos[charCode] + 1);
// 更新最大长度
maxLen = Math.max(maxLen, right - left + 1);
// 记录当前字符这次的位置,给下一次遇到它时用
lastPos[charCode] = right;
}
return maxLen;
};
这里的 Math.max 千万别漏。因为字符上次出现的位置可能在当前窗口外面,这时候左边界不用动,所以要取较大值。我一开始漏了这个,各种边界 case 过不了。
复杂度唠两句
两种写法时间复杂度都是 O (n),每个字符最多被访问一次。优化版没有内层循环,实际运行会更快一点。空间都是 O (1),因为数组大小是固定的,和字符串长度没关系。
最后碎碎念
这道题是滑动窗口的入门神题,吃透了它,很多中等难度的滑动窗口题都能举一反三。
- 入门版好理解,不容易写错,面试紧张的时候写这个稳
- 优化版更高效,写熟了之后这题基本就是秒杀
我自己踩过的坑再给你们提个醒:
- 优化版数组初始值别写 0,写 - 1
- 左边界更新一定要加 Math.max,不然会回退
- 长度计算是 right - left + 1,别漏了 + 1
你第一次刷这道题的时候,是一遍就过还是卡了好久?或者你有更巧妙的写法?评论区聊聊呗~觉得有帮助的话点个赞,下次刷题不踩同款坑~