🎯 问题描述
LeetCode 第 3 题:最长无重复字符子串
给你一个字符串 s,请你找出其中不含有重复字符的最长子串的长度。
示例:
输入: s = "abcabcbb"
输出: 3
解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。
输入: s = "bbbbb"
输出: 1
输入: s = "pwwkew"
输出: 3
解释: "wke" 是一个子串,长度为 3。
💡 解题思路概览
我们来一步步探索这个问题的三种解法:
| 方法 | 时间复杂度 | 空间复杂度 | 思路 |
|---|---|---|---|
| 1. 暴力枚举 | O(n³) | O(n) | 枚举所有子串,检查是否重复 |
| 2. Set 滑动窗口 | O(n) | O(min(m,n)) | 双指针 + Set 维护窗口 |
| 3. Map 滑动窗口(最优) | O(n) | O(min(m,n)) | 记录字符位置,跳过无效移动 |
✅ 方法一:暴力枚举(O(n³))
🧠 思路
最直观的想法:
- 枚举所有可能的子串(两层循环)
- 对每个子串,用
Set检查是否有重复字符 - 记录最长的有效子串长度
💻 代码实现
var lengthOfLongestSubstring = function(s) {
const n = s.length;
let maxLen = 0;
for (let i = 0; i < n; i++) {
for (let j = i; j < n; j++) {
const substring = s.slice(i, j + 1);
const set = new Set(substring);
if (set.size === substring.length) {
maxLen = Math.max(maxLen, j - i + 1);
} else {
break; // 一旦有重复,后续更长的子串也无效
}
}
}
return maxLen;
};
📊 复杂度分析
- 时间复杂度:O(n³) —— 两层循环 +
slice和Set构造 - 空间复杂度:O(n) ——
Set最多存 n 个字符
⚠️ 虽然能通过小数据,但效率极低,面试中不推荐。
✅ 方法二:Set + 滑动窗口(O(n))
🧠 思路
使用滑动窗口优化暴力法:
- 用两个指针
left和right表示当前窗口 - 用
Set存储当前窗口内的字符 right不断向右扩展,直到遇到重复字符- 遇到重复时,
left右移并删除Set中的字符,直到不再重复 - 每次更新最大长度
💡 关键点
right是主循环变量,推动窗口扩展left是被动移动,用于收缩窗口Set用于快速判断是否重复
💻 代码实现
var lengthOfLongestSubstring = function(s) {
const occ = new Set();
const n = s.length;
let right = -1, maxLen = 0;
for (let left = 0; left < n; left++) {
if (left !== 0) {
occ.delete(s[left - 1]); // 左指针右移,移除旧字符
}
while (right + 1 < n && !occ.has(s[right + 1])) {
occ.add(s[right + 1]);
right++;
}
maxLen = Math.max(maxLen, right - left + 1);
}
return maxLen;
};
📊 复杂度分析
- 时间复杂度:O(n) —— 每个字符最多被
left和right各访问一次 - 空间复杂度:O(min(m,n)) ——
Set最多存字符集大小(如 ASCII 128)
✅ 这是标准滑动窗口解法,逻辑清晰,适合理解。
✅ 方法三:Map + 滑动窗口(最优解,O(n))
🧠 思路
在 Set 基础上进一步优化:
- 使用
Map记录每个字符最后出现的位置 - 当发现重复字符时,直接跳到上次出现位置的下一个位置,避免一步步
left++ - 不需要
while循环收缩窗口,只需一次判断
💡 为什么更快?
- Set 版本需要一步步移动
left并删除字符 - Map 版本可以“跳跃式”移动
left,减少无效操作
💻 代码实现
var lengthOfLongestSubstring = function(s) {
const map = new Map(); // 字符 -> 最后出现的位置
let maxLen = 0;
let start = 0; // 当前窗口的起始位置
for (let end = 0; end < s.length; end++) {
const char = s[end];
// 如果字符已存在,且在当前窗口内
if (map.has(char) && map.get(char) >= start) {
start = map.get(char) + 1; // 跳到重复字符的下一个位置
}
map.set(char, end); // 更新字符位置
maxLen = Math.max(maxLen, end - start + 1); // 更新最大长度
}
return maxLen;
};
📊 复杂度分析
- 时间复杂度:O(n) —— 单次遍历
- 空间复杂度:O(min(m,n)) —— Map 存储字符位置
✅ 这是面试中最推荐的写法,高效且优雅。
🎯 三种方法对比
| 方法 | 时间复杂度 | 空间复杂度 | 是否推荐 | 适用场景 |
|---|---|---|---|---|
| 暴力枚举 | O(n³) | O(n) | ❌ | 仅用于理解 |
| Set 滑动窗口 | O(n) | O(min(m,n)) | ✅ | 初学者理解滑动窗口 |
| Map 滑动窗口 | O(n) | O(min(m,n)) | ✅✅✅ | 面试/生产环境首选 |
🧩 常见误区
- 为什么用
end循环而不是start?
→ 因为end是“推动者”,start是“跟随者”。我们以end为主循环,动态调整start,才能保证 O(n) 时间 - Set 版本为什么需要
while?
→ 因为它只能一步步移动left,直到移除重复字符,无法跳跃。
✅ 总结
- 暴力法 是理解问题的第一步,但效率太低
- Set 滑动窗口 是标准解法,逻辑清晰
- Map 滑动窗口 是最优解,效率最高
🙋♂️ 如果你觉得这篇文章有帮助,欢迎点赞、收藏、转发!
💬 欢迎在评论区交流你的解法,我们一起进步!