题目描述

✅ 剑指 Offer 48. 最长不含重复字符的子字符串

image-20261001230752573

image-20260928181226016

题意分析

在字符串中找一个连续片段,使其中每个字符最多出现一次,返回它的最大长度。题面限定字符为 a 到 z,可以直接记录每个字母出现的位置。

解法:滑动窗口记录最近位置

核心思路

[!blue]

用 [left, right] 表示当前窗口,last[ch] 记录字符 ch 在已扫描前缀中最后一次出现的位置。向右加入新字符前,旧窗口已经没有重复,所以新冲突只可能由当前字符 ch 引起。

设 ch 的上次位置为 p。若 p >= left,旧 ch 还在窗口中,新的左边界至少要到 p + 1 才能排除它;若 p < left,旧记录已经在窗口外,不会造成重复,左边界保持不变。两种情况合并为 left = max(left, p + 1);从未出现过的字符不需要移动左边界。

每次只排除必须舍弃的前缀,保留下来的就是以 right 结尾的最长无重复子串。扫描每个右端点并取最大长度,就覆盖了全局答案。last 可以保留窗口外的记录,因为比较 p 与 left 就能判断它是否仍然有效,无需逐个删除。

解题步骤

  1. 初始化左边界 left = 0、答案 ans = 0 和最近位置表 last。
  2. 枚举右边界 right,读取新字符 ch = s[right]。
  3. 若 ch 出现过,用 max(left, last[ch] + 1) 更新左边界,排除窗口内的旧 ch,同时忽略窗口外的旧记录。
  4. 记录 last[ch] = right。此时 [left, right] 恢复为无重复窗口,用其长度更新 ans。
  5. 扫描结束后返回 ans。空字符串不进入循环,返回初始值 $0$。

代码实现

class Solution {
    public int lengthOfLongestSubstring(String s) {
        Map<Character, Integer> last = new HashMap<>();
        int left = 0;
        int ans = 0;

        for (int right = 0; right < s.length(); right++) {
            char ch = s.charAt(right);

            if (last.containsKey(ch)) {
                // 旧出现可能已经离开窗口,左边界只能向右而不能回退。
                left = Math.max(left, last.get(ch) + 1);
            }

            // 先用旧位置修复窗口,再登记当前出现。
            last.put(ch, right);
            ans = Math.max(ans, right - left + 1);
        }

        return ans;
    }
}
func lengthOfLongestSubstring(s string) int {
    last := make(map[byte]int)
    left := 0
    ans := 0

    for right := 0; right < len(s); right++ {
        ch := s[right]
        // 旧出现可能已经离开窗口,只有越过当前左界才需要跳转。
        if idx, exists := last[ch]; exists && idx+1 > left {
            left = idx + 1
        }
        // 先用旧位置修复窗口,再登记当前出现。
        last[ch] = right
        if right-left+1 > ans {
            ans = right - left + 1
        }
    }
    return ans
}

复杂度分析

  • 时间复杂度:$O(n)$,其中 $n$ 为字符串长度。右边界只扫描一次,哈希表查询与更新的均摊时间为 $O(1)$。
  • 空间复杂度:$O(1)$。题面只含 $26$ 个小写字母,哈希表每种字母最多保存一条最近位置记录。

关键点总结

[!green]

  • 窗口不变量是 [left, right] 内字符互不重复。
  • 最近位置表让左边界一步跳过重复字符,不需要逐个删除窗口元素。
  • left 必须单调不减,max 正是为了解决旧记录已经离开窗口的情况。

易错点总结

[!yellow]

  • 直接赋值 left = last[ch] + 1,可能被窗口外的旧记录拉回已经排除的位置;应取 max。
  • 最近位置必须先读取再覆盖,否则会把当前位置误认为上一次出现的位置。
  • 跳转目标是旧位置的下一格,漏写 +1 后重复字符仍在窗口内。
  • 必须先恢复窗口不变量,再计算 right - left + 1。

相似题目

题目 难度 关联与区别
159. 至多包含两个不同字符的最长子串 中等 同样维护可扩张再收缩的字符窗口,原题允许两种字符重复,本题每种字符最多出现一次。
424. 替换后的最长重复字符 中等 同样追踪窗口内字符频次,原题允许替换预算,本题遇重复就必须调整左边界。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2025/94004328
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!