「前端算法面试实战」 第三篇:无重复字符的最长子串——滑动窗口的魅力

86 阅读7分钟

一、问题描述

给定一个字符串 ss,请你找出其中不含有重复字符的 最长子串 的长度。

注意区分:

  • 子串 (Substring) :必须是原字符串中连续的字符序列。例如 "abc""abcabcbb" 的子串。
  • 子序列 (Subsequence) :不必是连续的,但必须保持原有的先后顺序。例如 "pwke""pwwkew" 的子序列,但 "pke" 也是。

示例:

  1. 输入: s="abcabcbb"s="abcabcbb" 输出: 33 解释: 因为无重复字符的最长子串是 "abc",所以其长度为 33。
  2. 输入: s="bbbbb"s="bbbbb" 输出: 11 解释: 因为无重复字符的最长子串是 "b",所以其长度为 11。
  3. 输入: s="pwwkew"s="pwwkew" 输出: 33 解释: 因为无重复字符的最长子串是 "wke",所以其长度为 33。 请注意,你的答案必须是 子串 的长度,"pwke" 是一个子序列,不是子串。
  4. 输入: s=""s="" 输出: 00

二、思路分析与解法

1. 暴力解法 (Brute Force)

最直观的想法是找出所有的子串,然后逐个判断它们是否含有重复字符,并记录下最长的那个无重复字符子串的长度。

  • 如何找出所有子串? 可以使用两层循环。外层循环确定子串的起始位置 ii,内层循环确定子串的结束位置 jj (其中 j≥ij≥i)。这样 s[i…j]s[i…j] 就是一个子串。
  • 如何判断子串是否有重复字符? 对于每个子串,可以再用一个循环或者使用一个哈希集合 (Set) 来检查是否有重复字符。如果用哈希集合,遍历子串的字符,尝试将每个字符加入集合。如果某个字符已经存在于集合中,则说明该子串有重复字符。

伪代码思路:

function lengthOfLongestSubstringBruteForce(s):
  n = s.length
  if n == 0:
    return 0
  
  maxLength = 0
  
  for i from 0 to n-1:      // 起始位置
    for j from i to n-1:    // 结束位置
      substring = s.substring(i, j + 1)
      if hasNoRepeatingCharacters(substring):
        maxLength = max(maxLength, substring.length)
        
  return maxLength

function hasNoRepeatingCharacters(str):
  charSet = new Set()
  for char in str:
    if charSet.has(char):
      return false
    charSet.add(char)
  return true

复杂度分析:

  • 时间复杂度O(n3)O(n3)

    • 两层循环生成子串的起点和终点,这部分是 O(n2)O(n2)
    • 对于每个子串,hasNoRepeatingCharacters 函数需要遍历该子串,其平均长度约为 O(n)O(n),内部 Set 操作平均 O(1)O(1)
    • 所以总体是 O(n2⋅n)=O(n3)O(n2⋅n)=O(n3)
    • (如果 hasNoRepeatingCharacters 函数在生成子串时同步检查,可以优化到 O(n2)O(n2),但思路本质不变,仍然不够高效。)
  • 空间复杂度O(k)O(k) 或 O(min⁡(n,∣Σ∣))O(min(n,∣Σ∣)),其中 kk 是子串的长度,用于存储 charSet。∣Σ∣∣Σ∣ 是字符集的大小(例如ASCII是128或256)。

缺点:

  • 效率低下,当字符串长度 nn 较大时, O(n3)O(n3) 或 O(n2)O(n2) 的时间复杂度很容易超时。

2. 优化解法:滑动窗口 (Sliding Window)

暴力解法中存在大量重复计算。例如,当我们检查 "abc" 是无重复的,然后检查 "abca",我们实际上不需要从头开始构建一个新的集合。

滑动窗口思想:

我们可以使用两个指针,leftright,来定义一个“窗口” [left,right][left,right] (或者 [left,right)[left,right),取决于具体实现中 rightright 是包含还是不包含)。这个窗口代表我们当前正在考察的子串。

  1. 窗口扩张:不断移动 right 指针向右扩展窗口,将新的字符 s[right]s[right] 加入窗口内。
  2. 窗口收缩:如果新加入的字符 s[right]s[right] 导致窗口内出现重复字符,那么我们就需要移动 left 指针向右收缩窗口,直到窗口内不再有重复字符为止。
  3. 记录结果:在每一步窗口状态合法(即无重复字符)时,我们都更新当前无重复子串的最大长度。

为了快速判断窗口内是否有重复字符,以及快速移除 left 指针指向的字符,我们可以使用一个哈希集合 (Set) 来存储窗口内的字符。

步骤详解:

  1. 初始化:

    • left = 0 (窗口左边界)
    • right = 0 (窗口右边界,或者说下一个要考察的字符的索引)
    • maxLength = 0 (记录最大长度)
    • windowChars = new Set() (存储当前窗口内的字符)
  2. 循环,当 right 指针未到达字符串末尾时:

    • 获取当前 right 指针指向的字符 char = s[right]

    • 检查重复

      • 如果 windowChars 中不包含 char

        • 说明当前字符不会引起重复。
        • 将 char 加入 windowChars
        • right 指针右移一位,扩大窗口 (right++)。
        • 更新 maxLength = max(maxLength, windowChars.size) (或者 maxLength = max(maxLength, right - left))。
      • 如果 windowChars 中已包含 char

        • 说明字符 char 在窗口 [left, right-1] 中已经存在,加入 s[right]s[right] 会导致重复。
        • 我们需要从窗口左边开始移除字符,直到 char 不再重复(或者说,直到把窗口中第一次出现的那个 char 移出去)。
        • 从 windowChars 中移除 s[left]
        • left 指针右移一位,缩小窗口 (left++)。
        • 这个收缩过程需要持续,直到 s[right]s[right] 可以被安全地加入窗口。

JavaScript 代码实现:

function lengthOfLongestSubstring(s) {
    const n = s.length;
    if (n === 0) {
        return 0;
    }

    let left = 0;
    let right = 0;
    let maxLength = 0;
    const windowChars = new Set();

    while (right < n) {
        const charRight = s[right];
        if (!windowChars.has(charRight)) {
            // 如果当前字符不在窗口中,则将其加入,并扩展窗口
            windowChars.add(charRight);
            right++;
            maxLength = Math.max(maxLength, windowChars.size); // 或者 Math.max(maxLength, right - left);
        } else {
            // 如果当前字符已在窗口中,则从左边开始收缩窗口,直到重复字符被移除
            const charLeft = s[left];
            windowChars.delete(charLeft);
            left++;
        }
    }
    return maxLength;
}

复杂度分析:

  • 时间复杂度O(n)O(n)

    • left 指针和 right 指针都最多遍历字符串一次。left 指针的移动次数不会超过 right 指针,因此总的移动次数是 O(n)O(n)。
    • Set 的 addhasdelete 操作平均时间复杂度为 O(1)O(1)
    • 因此,总体时间复杂度是 O(n)O(n)
  • 空间复杂度O(min⁡(n,∣Σ∣))O(min(n,∣Σ∣))

    • windowChars 集合中最多存储 nn 个字符(如果所有字符都不同)。
    • 或者最多存储字符集大小 ∣Σ∣∣Σ∣ 个字符(例如,ASCII 字符集大小为 128 或 256)。
    • 所以空间复杂度是两者中的较小者。

三、总结与对比

特性暴力解法滑动窗口解法
核心思想枚举所有子串,逐个检查维护一个动态窗口,避免重复检查
数据结构可能需要 Set (检查重复时)必须使用 Set/Map (维护窗口内字符)
指针无特定指针技巧 (或用于生成子串)双指针 (left, right) 定义窗口
时间复杂度O(n3)O(n3) 或 O(n2)O(n2)O(n)O(n)
空间复杂度$O(\min(n,\Sigma

显然,滑动窗口 方法在时间和效率上远超暴力解法,是解决此类问题的标准方案。

四、关键点回顾

  1. 滑动窗口的本质:通过动态调整子区间的左右边界,来优化对子区间的遍历和计算。
  2. 何时扩展窗口:当新元素加入后,窗口仍然满足题目条件时(本题中是“无重复字符”)。
  3. 何时收缩窗口:当新元素加入后,窗口不再满足题目条件时,需要从左边界开始收缩,直到再次满足条件。
  4. 辅助数据结构:通常需要一个哈希表(如 Set 或 Map)来高效地维护窗口内的状态(例如,字符的出现情况、频率等)。

掌握滑动窗口技巧对于解决一系列的数组和字符串子区间问题非常有帮助,例如“最小覆盖子串”、“字符串的排列”等。

希望这篇详解能帮助你理解“无重复字符的最长子串”问题以及滑动窗口的巧妙之处!我们下一篇再见!