🔥 LeetCode 第3题:最长无重复字符子串的三种解法(暴力 → Set → Map)

183 阅读4分钟

🎯 问题描述

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³))

🧠 思路

最直观的想法:

  1. 枚举所有可能的子串(两层循环)
  2. 对每个子串,用 Set 检查是否有重复字符
  3. 记录最长的有效子串长度

💻 代码实现

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³) —— 两层循环 + sliceSet 构造
  • 空间复杂度:O(n) —— Set 最多存 n 个字符

⚠️ 虽然能通过小数据,但效率极低,面试中不推荐。


✅ 方法二:Set + 滑动窗口(O(n))

🧠 思路

使用滑动窗口优化暴力法:

  • 用两个指针 leftright 表示当前窗口
  • 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) —— 每个字符最多被 leftright 各访问一次
  • 空间复杂度: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))✅✅✅面试/生产环境首选

🧩 常见误区

  1. 为什么用 end 循环而不是 start
    → 因为 end 是“推动者”,start 是“跟随者”。我们以 end 为主循环,动态调整 start,才能保证 O(n) 时间
  2. Set 版本为什么需要 while
    → 因为它只能一步步移动 left,直到移除重复字符,无法跳跃。

✅ 总结

  • 暴力法 是理解问题的第一步,但效率太低
  • Set 滑动窗口 是标准解法,逻辑清晰
  • Map 滑动窗口 是最优解,效率最高

🙋‍♂️ 如果你觉得这篇文章有帮助,欢迎点赞、收藏、转发!
💬 欢迎在评论区交流你的解法,我们一起进步!