在LeetCode的字符串类题目中,「3. 无重复字符的最长子串」是经典的入门级难题,核心考察对「滑动窗口」与「哈希表」结合用法的理解。本题的关键在于如何从暴力解法的O(n²)时间复杂度,优化到O(n)的最优解。本文将通过两段功能一致但风格不同的代码,拆解滑动窗口的优化逻辑,对比代码细节差异,帮你彻底吃透这道题。
一、题目回顾
给定一个字符串 s,请找出其中不含有重复字符的最长子串的长度。
示例:
-
输入:
s = "abcabcbb",输出:3(最长子串为"abc") -
输入:
s = "bbbbb",输出:1(最长子串为"b") -
输入:
s = "pwwkew",输出:3(最长子串为"wke"或"kew")
二、核心解法:滑动窗口+哈希表
本题的最优解法是「滑动窗口+哈希表」组合,核心思路是用两个指针(left、right)维护一个无重复字符的窗口,通过哈希表记录字符最新位置,实现窗口的动态调整,最终找到最长窗口长度。
核心逻辑拆解:
-
left 为窗口左边界,right 为窗口右边界,初始时窗口为空;
-
right 指针遍历字符串,将当前字符存入哈希表(key为字符,value为最新索引);
-
若当前字符已在哈希表中,且其索引在left右侧(说明在当前窗口内重复),则将left跳至重复字符的下一个位置,收缩窗口;
-
每次遍历后计算当前窗口长度(right - left + 1),更新最长长度记录res;
-
遍历结束后,res即为无重复字符的最长子串长度。
该思路的关键优化的是:无需删除哈希表中的历史数据,仅通过判断字符索引与left的位置关系,就能确定是否在当前窗口内重复,避免了暴力删除带来的O(n)开销,最终实现O(n)时间复杂度(每个字符仅被right遍历一次)。
三、两段代码的对比与解析
以下两段代码均实现了上述核心逻辑,功能完全一致、返回结果无差异,仅在循环结构、初始化方式上存在细节不同,代表了从「过渡版」到「简洁版」的优化过程。
版本1:while循环过渡版(lengthOfLongestSubstring_1)
typescript
function lengthOfLongestSubstring_1(s: string): number {
const sL = s.length;
const map = new Map<string, number>();
if (sL === 0) {
return 0;
}
let left = 0;
let right = 1;
let res = 1;
map.set(s[0], 0);
while (right < sL && left < sL) {
if (map.has(s[right]) && map.get(s[right])! >= left) {
left = map.get(s[right])! + 1;
}
res = Math.max(res, right - left + 1);
map.set(s[right], right);
right++;
}
return res;
};
版本2:for循环简洁版(lengthOfLongestSubstring_2)
typescript
function lengthOfLongestSubstring_2(s: string): number {
const map = new Map<string, number>(); // 存储字符 -> 字符最新出现的索引
let left = 0; // 滑动窗口左边界(左闭)
let res = 0; // 记录最长无重复子串长度
const sL = s.length;
// 右指针right遍历字符串,作为滑动窗口右边界(右闭)
for (let right = 0; right < sL; right++) {
const currentChar = s[right];
// 关键:如果当前字符已存在,且其索引在左边界右侧(说明在当前窗口内重复)
if (map.has(currentChar) && map.get(currentChar)! >= left) {
// 直接将左边界跳到重复字符的下一个位置,无需删除map中的旧数据
left = map.get(currentChar)! + 1;
}
// 更新当前字符的最新索引(无论是否重复,都要更新,保证后续判断准确)
map.set(currentChar, right);
// 计算当前窗口长度,更新最大值(每次循环都计算,避免else分支的遗漏)
res = Math.max(res, right - left + 1);
}
return res;
}
细节差异对比
| 对比维度 | lengthOfLongestSubstring_1 | lengthOfLongestSubstring_2 |
|---|---|---|
| 循环结构 | while循环,手动初始化right=1并递增right++ | for循环,自动管理right(从0开始到sL-1) |
| 初始化逻辑 | 提前处理s[0],map预存s[0]的索引,res初始为1 | 无预处理,所有字符统一在循环内处理,res初始为0 |
| 空字符串处理 | 单独判断sL===0,返回0 | 无需单独处理,for循环不执行,res直接返回0 |
| 代码简洁度 | 稍繁琐,存在冗余初始化和条件 | 更简洁,逻辑统一,可读性更强 |
关键共性说明
两段代码的核心逻辑完全一致,均规避了原始解法中「暴力删除哈希表数据」的问题,通过「索引判断+left跳转」实现O(1)窗口调整。同时,两者都放弃了「仅在无重复时更新res」的错误逻辑,改为每次遍历后都计算窗口长度,避免遗漏场景(如调整right后窗口长度成为新最大值)。
四、代码优化建议与注意事项
1. 优先选择版本2的原因
版本2的for循环更贴合「右指针完整遍历字符串」的逻辑直觉,无需手动管理right的递增,减少了冗余代码和潜在bug(如while循环中冗余的left < sL条件,实际可删除)。同时,统一的初始化逻辑让代码更易读,也更符合行业内对该题的标准解法写法。
2. 易踩坑点提醒
-
哈希表必须存储「字符最新索引」:无论字符是否重复,每次都要更新map中的值,否则会因旧索引导致left跳转错误。
-
left跳转需加判断条件:必须确保map中重复字符的索引≥left,否则会误跳(如历史字符不在当前窗口内,无需调整left)。
-
res更新时机:需在每次调整完left、更新完map后执行,确保覆盖所有窗口状态。
3. 进一步优化方向
若想进一步提升性能,可将Map替换为数组(因字符串由ASCII字符组成,可使用长度为128的数组存储索引,访问速度比Map更快),优化后的代码如下:
typescript
function lengthOfLongestSubstring(s: string): number {
const arr = new Array(128).fill(-1); // 存储字符ASCII码对应的最新索引
let left = 0, res = 0;
for (let right = 0; right < s.length; right++) {
const charCode = s.charCodeAt(right);
// 若字符已存在且在当前窗口内,调整left
if (arr[charCode] >= left) {
left = arr[charCode] + 1;
}
arr[charCode] = right;
res = Math.max(res, right - left + 1);
}
return res;
}
五、总结
LeetCode 3题的核心是「滑动窗口+哈希表」的优化思路,两段代码的差异仅为细节风格,无本质功能区别。版本2作为简洁版,更适合实际开发和面试答题场景。掌握本题的关键在于理解「无需删除历史数据,仅通过索引判断调整窗口」的优化逻辑,这一思路也可迁移到其他滑动窗口类题目(如最长子串、子数组相关问题)。
建议在练习时,先理解版本1的过渡逻辑,再优化到版本2的简洁写法,最后尝试数组替代Map的性能优化,逐步吃透滑动窗口的核心用法。