“无重复字符的最长子串”:从O(n²)哈希优化到滑动窗口封神,再到DP降维打击!


💼 面试真实场景还原:你以为的"聪明解法",其实是半吊子陷阱

你信心满满地坐在会议室里,面试官微微一笑:

"来,实现一个函数 lengthOfLongestSubstring(s),找出字符串中最长无重复字符的子串长度。"

你心里一喜:这题我会!不是有重复就用哈希表吗?

于是你提笔就写:

js 复制代码
function lengthOfLongestSubstring(s) {
  let maxLen = 0;
  for (let i = 0; i < s.length; i++) {
    const seen = new Set();
    for (let j = i; j < s.length; j++) {
      if (seen.has(s[j])) break;
      seen.add(s[j]);
      maxLen = Math.max(maxLen, j - i + 1);
    }
  }
  return maxLen;
}

写完还补充一句:"我用了 Set 来去重,时间复杂度是 O(n²),空间 O(k),比三重循环快多了!"

面试官点点头:"嗯,能跑。那......有没有更优的?或者换个思路?"

你愣住了。

👉 其实你不知道的是:虽然滑动窗口是这道题的主流解法,但这个问题竟然也能用 DP(动态规划)优雅解决!

今天我们就来 补全最后一块拼图 ------ 揭秘"无重复字符的最长子串"的 第三种解法:动态规划,并全面对比三种主流方法,带你从"只会背模板"进化到"真正理解状态转移"。


🔥 问题本质:不是找"前任列表",是找"连续恋爱期"

首先搞清楚一件事:子串 ≠ 子序列

  • 子序列:像回忆录,可以跳着看,中间断几年都行。
  • 子串:像同居生活,必须天天见面,还得住在一起(连续)!

题目要求我们找一个字符串中最长的一段连续区间,里面每个字符都不重复。比如:

👉 字符串 "abcabcbb"

可能的答案有:"abc"、"bca"、"cab" ------ 长度都是 3 ✅

但你要是说 "abcb",对不起,'b' 出现两次,属于"感情劈腿",直接出局 ❌

🎯 目标明确:找一段最持久且专一的连续关系 😂

而你之前的 O(n²) 解法,就像一次次重新谈恋爱:

  • 从第一个人开始谈 → 谈到重复就分手
  • 再从第二个人开始谈 → 又谈一遍......

能不能别这么累?能不能"边走边调整",而不是每次都重头再来?

当然能!这就引出我们的终极武器------


🛠️ 方法一:暴力 + 哈希优化(O(n²))------ 初级选手的舒适区

✅ 思路回顾:

枚举所有起点 i,从该点出发向右扩展,直到遇到重复字符为止。

使用 Set 快速判断是否重复,避免内层再遍历一次。

✅ 代码实现:

js 复制代码
function lengthOfLongestSubstring_O_n2(s) {
  let maxLen = 0;
  for (let i = 0; i < s.length; i++) {
    const seen = new Set();
    for (let j = i; j < s.length; j++) {
      if (seen.has(s[j])) break;
      seen.add(s[j]);
      maxLen = Math.max(maxLen, j - i + 1);
    }
  }
  return maxLen;
}

⚠️ 缺点分析:

  • 时间复杂度仍是 O(n²),在长字符串下会超时(如 LeetCode 测试用例)
  • 虽然用了 Set 优化,但本质上还是"重复劳动":很多子串被反复扫描

💬 类比:每次失恋后都要重新相亲,不能吸取教训。


🧩 方法二:滑动窗口 + Map(O(n))------ 主流王者方案

✅ 核心思想:

维护一个"动态窗口" [left, i],只允许无重复字符存在。

右指针 i 不断扩张,左指针 left 在发现重复时收缩。

利用 Map 记录每个字符最近出现的位置,实现 O(1) 查找。

✅ 关键逻辑:

js 复制代码
if (map.has(char) && map.get(char) >= left) {
  left = map.get(char) + 1; // 移动左边界
}

只有当重复字符位于当前窗口内时才调整 left,防止"回退"。

✅ 完整代码:

js 复制代码
function lengthOfLongestSubstring_SlidingWindow(s) {
  const map = new Map();
  let left = 0, res = 0;

  for (let i = 0; i < s.length; i++) {
    const char = s[i];
    if (map.has(char) && map.get(char) >= left) {
      left = map.get(char) + 1;
    }
    map.set(char, i);
    res = Math.max(res, i - left + 1);
  }

  return res;
}

✅ 优点:

  • 时间复杂度 O(n),每个字符仅访问一次
  • 空间 O(k),k 为字符集大小
  • 逻辑清晰,易于迁移至其他子串问题

🎯 方法三:动态规划 DP(O(n))------ 高阶玩家的秘密武器

你以为 DP 只能做背包和爬楼梯?错!它也能优雅解决本题!

✅ 核心定义:

dp[i] 表示 以第 i 个字符结尾的最长无重复子串的长度

最终答案:max(dp[0], dp[1], ..., dp[n-1])


🔍 状态转移方程推导:

我们要回答:dp[i] 如何由 dp[i-1] 推出?

分两种情况:

情况 条件 转移方式
✅ 当前字符未出现过 lastIndex == -1 dp[i] = dp[i-1] + 1
✅ 当前字符曾出现过 lastIndex >= 0 dp[i] = min(dp[i-1] + 1, i - lastIndex)

💡 解释:新子串不能包含上次相同的字符,所以最长只能从 lastIndex + 1 开始


📌 DP 解法详细步骤拆解(以 "abcb" 为例)

i s[i] dp[i-1] prevIndex(上次位置) 可选长度1: dp[i-1]+1 可选长度2: i - prevIndex dp[i] = min(两者) 当前最长子串
0 'a' - -1 1 0 - (-1) = 1 1 "a"
1 'b' 1 -1 2 1 - (-1) = 2 2 "ab"
2 'c' 2 -1 3 2 - (-1) = 3 3 "abc"
3 'b' 3 1 4 3 - 1 = 2 2 "cb"

✅ 最终结果:max(dp) = 3


✅ 完整代码实现(DP 版本)

js 复制代码
function lengthOfLongestSubstring_DP(s) {
  if (s.length === 0) return 0;

  const dp = Array(s.length).fill(1);  // 初始化 dp 数组
  const charMap = new Map();           // 记录字符最后出现位置
  let res = 1;

  charMap.set(s[0], 0);  // 初始化第一个字符

  for (let i = 1; i < s.length; i++) {
    const prevIndex = charMap.has(s[i]) ? charMap.get(s[i]) : -1;

    // 状态转移:取两个限制中的较小值
    dp[i] = Math.min(
      dp[i - 1] + 1,     // 最多比前一个多一个
      i - prevIndex      // 不能超过与上次重复的距离
    );

    res = Math.max(res, dp[i]);         // 更新全局最大值
    charMap.set(s[i], i);              // 更新字符最新位置
  }

  return res;
}

✅ 优点:

  • 同样 O(n) 时间,逻辑数学感强
  • 易于扩展到"记录具体子串"等变种问题
  • 展示你对 DP 的深刻理解,面试加分项!

⚠️ 缺点:

  • 空间复杂度略高:需要 O(n) 的 dp 数组(可优化为 O(1))
  • 理解门槛较高,不适合初学者快速掌握

🔄 三种方法横向对比大表格

方法 时间复杂度 空间复杂度 是否推荐 适用人群 特点
暴力 + 哈希 O(n²) O(k) ❌ 不推荐 新手练习 容易写出,但性能差
滑动窗口 + Map O(n) O(k) ✅ 强烈推荐 所有人 主流解法,高效简洁
动态规划 DP O(n) O(n) / 可优化为 O(k) ✅ 进阶推荐 中高级开发者 展示思维深度,适合深入探讨

💡 小技巧:你可以先写滑动窗口作为主解法,然后补充一句:"其实这题也可以用 DP 解决",瞬间提升逼格!


🧠 如何选择?一句话总结

  • 想通过面试?→ 写滑动窗口(稳定、高效、易讲)
  • 想惊艳面试官?→ 提一句 DP 思路(展现多角度思考)
  • 还在写双重循环?→ 是时候升级了!

🎯 总结升华:不只是做题,是思维跃迁

解决"无重复字符的最长子串",本质上是一场算法认知的升级

层次 思维方式 典型表现
新手 三重循环 O(n³),CPU 哭晕
进阶 哈希+双重循环 O(n²),看似聪明实则笨
高手 滑动窗口 + Map O(n),优雅高效
大师 滑动窗口 + DP 双视角 面试封神,offer 自带 BGM

掌握这三种方法,你就拿到了打开高频面试题大门的万能钥匙 + 备用钥匙 + 钥匙串上的幸运符


🌟 结语:愿你写的不是代码,而是艺术

下次面试官再问这道题,你可以微微一笑:

"这题啊,我不仅会做,还能讲三种解法。"

因为你知道:

  • 滑动窗口是舞蹈,
  • Map 是记忆,
  • DP 是哲学,
  • 而你,是那个掌控节奏的编舞师 + 记忆管理者 + 哲学家。

💻 写代码,也可以很浪漫。


相关推荐
xhxxx2 小时前
不用 Set,只用两个布尔值:如何用标志位将矩阵置零的空间复杂度压到 O(1)
javascript·算法·面试
鹿鹿鹿鹿isNotDefined2 小时前
Antd5.x 在 Next.js14.x 项目中,初次渲染样式丢失
前端·react.js·next.js
梨子同志2 小时前
Node.js 工具模块详解
前端
有意义2 小时前
斐波那契数列:从递归到优化的完整指南
javascript·算法·面试
谷歌开发者2 小时前
Web 开发指向标|AI 辅助功能在性能面板中的使用与功能
前端·人工智能
OpenTiny社区2 小时前
TinyEngine2.9版本发布:更智能,更灵活,更开放!
前端·vue.js·低代码
被考核重击3 小时前
浏览器原理
前端·笔记·学习
网络研究院3 小时前
Firefox 146 为 Windows 用户引入了加密本地备份功能
前端·windows·firefox
Mr.Jessy3 小时前
JavaScript高级:深入对象与内置构造函数
开发语言·前端·javascript·ecmascript