当前位置: 首页 > news >正文

无重复字符的最长子串的解题分析

无重复字符的最长子串

一、问题描述

  • 难度:中等
  • 标签:哈希表、字符串、滑动窗口

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

你可能见过一个类似的益智题:在下方的矩阵中找出 8

B B B B B B B B B B B B B B
B B B B B B B B B B B B B B
B B B B B B B B B B B B B B
B B B B B B B B B B B B B B
B B B B B B B B B B B B B B
B B B B B B B B B B B B B B
B B B B B B B B B B B 8 B B
B B B B B B B B B B B B B B

你会怎么找呢?

第一种方法,你通过检查每一个横排或者每一个竖列,找到 8,这是大多数人可以想到的思路,操作起来也比较简单。

第二种方法,把视野缩小,用纸挡住一部分,迫使你自己只能看到一小部分,少了干扰自然也就好找了。

这两个方法其实看上去差不多,都是尽可能集中注意力。但是第一种方法的效率远低于第二种方法。在后文中,第一种方法称为暴力循环,第二种方法称为滑动窗口。

二、示例

案例一

输入: s = "abcabcbb"
输出: 3
解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。

案例二

输入: s = "bbbbb"
输出: 1
解释: 因为无重复字符的最长子串是 "b",所以其长度为 1。

案例三

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

三、提示

  • 0 <= s.length <= 5 * 10^4
  • s 由英文字母、数字、符号和空格组成

四、解题

方法一:暴力循环

思路

最直观的想法是枚举所有可能的子串,检查每个子串是否包含重复字符,记录最长的无重复子串长度。

实现

  1. 使用两层循环枚举所有子串的起始和结束位置
  2. 对每个子串,使用集合或数组检查是否有重复字符
  3. 更新最大长度
class Solution(object):def lengthOfLongestSubstring(self, s):""":type s: str:rtype: int"""n = len(s)max_len = 0for i in range(n):seen = set()for j in range(i, n):# 如果当前字符已存在,跳出内层循环if s[j] in seen:breakseen.add(s[j])max_len = max(max_len, j - i + 1)return max_len
C++ 示例
class Solution {
public:int lengthOfLongestSubstring(string s) {int n = s.length();int maxLen = 0;// 枚举所有子串for (int i = 0; i < n; i++) {unordered_set<char> seen;for (int j = i; j < n; j++) {// 如果当前字符已存在,跳出内层循环if (seen.count(s[j])) {break;}seen.insert(s[j]);maxLen = max(maxLen, j - i + 1);}}return maxLen;}
};

时间复杂度: \(O(n^3)\) 两层循环 + 集合操作
空间复杂度: \(O(min(m,n))\) m 是字符集大小

可以发现,两层循环会有重复比较,是否可以优化呢?我们可以使用前文提到的滑动窗口。

方法二:滑动窗口

滑动窗口的基本思路就是使用窗口,左侧的边界和右侧的边界内是最长的不重复字符串长度,右侧不断加入新的字符,如果新加入的字符与左侧的某个字符重复,那么就把左侧的边界向右移动,直到不重复为止。

移动的过程中,保留最长的字符串长度。

class Solution {
public:int lengthOfLongestSubstring(string s) {unordered_set<char> window;int maxLen = 0;int left = 0, right = 0;while (right < s.length()) {// 扩展右边界,直到遇到重复字符while (right < s.length() && window.find(s[right]) == window.end()) {window.insert(s[right]);right++;}// 更新最大长度maxLen = max(maxLen, right - left);// 收缩左边界,直到移除重复字符if (right < s.length()) {while (left < right && s[left] != s[right]) {window.erase(s[left]);left++;}window.erase(s[left]);left++;}}return maxLen;}
};

是否还可以优化呢?

想一下,我们移动左侧的边界是为了不重复,比如 abcbdefgh,第二次出现 b 字符的时候,意味着前面一定出现过 b,我们是不是可以直接跳到首次出现 b 后面一个位置呢?

还是以 abcbdefgh 为例,可以在第二次出现 b 的时候,直接跳到滑动窗口中第一个 b 的后面一个位置,这样不就保证了后面的字符不重复了吗!

有可能出现意外的情况吗?比如跳转的时候,在前面的序列中有最长字串,但是跳过的时候,忽略了,导致不完全。

我们来证明一下:

假设我们在位置 j 发现了重复字符,需要跳转到位置 k+1(其中 k 是该字符在当前窗口中的首次出现位置)。

对于任何被跳过的区间 [i, k](其中 i 是当前左边界),以 i' (i ≤ i' ≤ k) 为起点的子串都必然包含重复字符,因为:

子串 [i', j] 必然包含位置 k 和位置 j 的字符,这两个位置的字符相同,所以任何包含这两个位置的子串都是存在重复的。

换句话说:任何包含重复字符的子串都不可能是最优解,所以可以安全地跳过它们。

方法三:滑动窗口 + 哈希表(推荐解法)

思路

使用滑动窗口,往右侧移动,维护一个无重复字符的窗口。当遇到重复字符时,更改左侧的索引为左侧重复字符的下一个字符。

每次移动都记录字符序列的数量,比较上一次和当前的值大小,返回最大值。

步骤

  1. 使用两个指针 left 和 right 表示滑动窗口的边界
  2. 使用哈希表记录每个字符最近出现的位置
  3. 右指针逐步扩展窗口,当遇到重复字符时,移动左指针
  4. 实时更新最大窗口长度

这里看不懂没关系,后面还会解释

class Solution:def lengthOfLongestSubstring(self, s: str) -> int:char_sequence = {}      # 字符序列max_len = 0left = 0             # 滑动窗口左边界for right, char in enumerate(s): # right 滑动窗口右边界,char 字符# 如果字符在窗口内已出现过,则收缩左边界# 并且忽略已经被甩出窗口的历史记录,只把真正落在当前窗口内的重复字符当作冲突来处理。if char in char_sequence and char_sequence[char] >= left:left = char_sequence[char] + 1# 更新字符最新位置char_sequence[char] = right# 更新最大长度max_len = max(max_len, right - left + 1)return max_len
C++ 代码示例
class Solution {
public:int lengthOfLongestSubstring(string s) {unordered_map<char, int> charIndex; // 字符 -> 最近出现的索引int maxLen = 0;int left = 0; // 滑动窗口左边界for (int right = 0; right < s.length(); right++) {char currentChar = s[right];// 如果当前字符在窗口内已存在,移动左指针if (charIndex.find(currentChar) != charIndex.end() &&charIndex[currentChar] >= left) {left = charIndex[currentChar] + 1;}// 更新字符最新位置charIndex[currentChar] = right;// 更新最大长度maxLen = max(maxLen, right - left + 1);}return maxLen;}
};

需要注意的是,这里比较的是 right 和 left,每次变化只是记录最近一次字符出现的位置,而不是删除左侧的重复字符。

abcabcbb 来说

right char char 序列 left 当前窗口 最大长度
0 'a' {'a':0} 0 "a" 1
1 'b' {'a':0, 'b':1} 0 "ab" 2
2 'c' {'a':0, 'b':1, 'c':2} 0 "abc" 3
3 'a' 'a' 在窗口内(0 ≥ 0),移动左侧边界 → left = 1 1 "bca" 3
4 'b' 'b' 在窗口内(1 ≥ 1),移动左侧边界 → left = 2 2 "cab" 3
5 'c' 'c' 在窗口内(2 ≥ 2),移动左侧边界 → left = 3 3 "abc" 3
6 'b' 'b' 在窗口内(4 ≥ 3),移动左侧边界 → left = 5 5 "cb" 3
7 'b' 'b' 在窗口内(6 ≥ 5),移动左侧边界 → left = 7 7 "b" 3

分步说明

我们先厘清一下这个滑动窗口移动的过程:

  1. 定义一个空的滑动窗口,然后定义滑动窗口的左侧边界,设置默认为 0;用enumerate()解析这个字符序列,返回索引和字符。
  2. 根据这个字符去检查每次读取到一个字符都放入 char_sequence 字典中。
  3. 每读取一个字符就检查之前是否有这个字符。注意,只检查当前的窗口大小,不是从索引 0 开始检查,所以需要 >= left,如果之前有这个字符,那么就更新左侧字符为滑动窗口中首次出现这个字符的下一个位置。
  4. 把当前的字符插入到 char_sequence 中,如果之前有这个字符,就更改为最新的索引。
  5. 最后计算长度的时候,由于之前 left 是在重复字符索引的下一个位置,而右侧又是在索引字符上,所以计算长度的时候,需要 right - left + 1

abcdgfckjdd 为例:

0 1 2 3 4 5 6 7 8 9 10
a b c d g f c k j d d
- 1 个无重复字符的最长子串

这个字符序列中,第一个字符为 a,索引为 0,左侧的边界为索引 0 的字符(默认边界),然后右侧边界为 a 字符的索引,索引也是 0

把当前的字符插入到 char_sequence

char_sequence{'a': 0}

现在的 max1

0 1 2 3 4 5 6 7 8 9 10
a b c d g f c k j d d
--- 2 个无重复字符的最长子串

现在 ab 都是不重复的

char_sequence{'a': 0, 'b': 1}

现在的 max2

0 1 2 3 4 5 6 7 8 9 10
a b c d g f c k j d d
----- 3 个无重复字符的最长子串

现在 abc 都是不重复的

char_sequence{'a': 0, 'b': 1, 'c': 2}

现在的 max3

0 1 2 3 4 5 6 7 8 9 10
a b c d g f c k j d d
------- 4 个无重复字符的最长子串

现在 abcd 都是不重复的

char_sequence{'a': 0, 'b': 1, 'c': 2, 'd': 3}

现在的 max4

0 1 2 3 4 5 6 7 8 9 10
a b c d g f c k j d d
--------- 5 个无重复字符的最长子串

现在 abcdg 都是不重复的

char_sequence{'a': 0, 'b': 1, 'c': 2, 'd': 3, 'g': 4}

现在的 max5

0 1 2 3 4 5 6 7 8 9 10
a b c d g f c k j d d
----------- 6 个无重复字符的最长子串

现在 abcdgf 都是不重复的。

char_sequence{'a': 0, 'b': 1, 'c': 2, 'd': 3, 'g': 4, 'f': 5}

现在的 max6

0 1 2 3 4 5 6 7 8 9 10
a b c d g f c k j d d
------------- 存在重复字符

当前的的 c (索引为 6)与索引为 2 的 c 重复

符合条件 if char in char_sequence and char_sequence[char] >= left:,所以进行偏移。

left 偏移到当前字符相同的字符后一个字符,即 char_sequence[char] + 1,也就是 3

更新字符的最新位置 char_sequence[char] = right,也就是把原来的 'c': 2 更新为 'c': 6

当前的 char_sequence{'a': 0, 'b': 1, 'c': 6, 'd': 3, 'g': 4, 'f': 5} (长度和上面一样,但是更新了 c

可以理解为:

0 1 2 3 4 5 6 7 8 9 10
a b c d g f c k j d d
----  ------- 存在重复字符

现在的因为上一次的 len6,现在的 len6 - 3 + 1 = 4,保留的 max

然后继续右侧偏移。

0 1 2 3 4 5 6 7 8 9 10
a b c d g f c k j d d
----  --------- 没有重复字符

没有重复字符。

char_sequence{'a': 0, 'b': 1, 'c': 6, 'd': 3, 'g': 4, 'f': 5, 'k': 7}

现在的因为上一次的 len6,现在的 len7 - 3 + 1 = 5

max6

0 1 2 3 4 5 6 7 8 9 10
a b c d g f c k j d d
----  ----------- 没有重复字符

char_sequence{'a': 0, 'b': 1, 'c': 6, 'd': 3, 'g': 4, 'f': 5, 'k': 7, 'j': 8}

现在的因为上一次的 len6,现在的 len8 - 3 + 1 = 6

max6

0 1 2 3 4 5 6 7 8 9 10
a b c d g f c k j d d
----  ------------- 有重复字符

更新前面的 d9

char_sequence {'a': 0, 'b': 1, 'c': 6, 'd': 9, 'g': 4, 'f': 5, 'k': 7, 'j': 8}

可以理解为

0 1 2 3 4 5 6 7 8 9 10
a b c d g f c k j d d
----  	-----------  有重复字符

现在的因为上一次的 len6,现在的 len9 - 4 + 1 = 6

max6

0 1 2 3 4 5 6 7 8 9 10
a b c d g f c k j d d
----  	---------   - 有重复字符

char_sequence {'a': 0, 'b': 1, 'c': 6, 'd': 10, 'g': 4, 'f': 5, 'k': 7, 'j': 8}

现在的因为上一次的 len6,现在的 len10 - 10 + 1 = 1

max6

  • 时间复杂度: O(n)
    • 每个字符最多被访问两次(一次被 right 访问,一次被 left 访问)
  • 空间复杂度: O(min(m,n))
    • 哈希表最多存储 min(m,n) 个字符

针对数组优化的版本

对于 ASCII 字符,可以用数组代替哈希表,提高常数因子的性能:

class Solution {
public:int lengthOfLongestSubstring(string s) {vector<int> charIndex(128, -1); // ASCII字符表int maxLen = 0;int left = 0;for (int right = 0; right < s.length(); right++) {char currentChar = s[right];// 如果字符在当前窗口内,移动左指针if (charIndex[currentChar] >= left) {left = charIndex[currentChar] + 1;}charIndex[currentChar] = right;maxLen = max(maxLen, right - left + 1);}return maxLen;}
};
  • 时间复杂度: O(n)
  • 空间复杂度: O(m),m = 128(ASCII字符集大小)

五、关键技巧

1. 滑动窗口模式

  • 核心思想: 维护一个动态窗口,通过移动左右边界来满足约束条件

  • 适用场景: 连续子数组/子串问题,特别是有约束条件的情况

  • 模板结构:

    int left = 0;
    for (int right = 0; right < n; right++) {// 扩展窗口,加入 s[right]// 当窗口不满足条件时,收缩窗口while (window_is_invalid) {// 移除 s[left]left++;}// 更新结果
    }
    

2. 哈希表优化

  • 字符位置记录: 使用哈希表记录字符最后出现的位置
  • 快速查重: O(1) 时间复杂度检查字符是否重复
  • 索引跳跃: 直接跳到重复字符的下一个位置,避免逐个移动

3. 边界处理技巧

  • 初始化: left = 0, 哈希表初始为空或 -1
  • 越界检查: 确保 charIndex[currentChar] >= left
  • 更新时机: 每次都更新字符位置,无论是否重复

六、总结

核心要点

  1. 滑动窗口是解决子串问题的利器,特别适合有约束条件的连续子串问题
  2. 哈希表提供 O(1) 的查找和更新,是滑动窗口的最佳伙伴
  3. 双指针减少不必要的重复计算,将暴力 \(O(n^3)\) 优化到 \(O(n)\)
  4. 边界条件处理至关重要,注意指针越界和哈希表状态

常见错误

  1. 忘记更新字符位置,导致左指针移动错误
  2. 边界判断遗漏,特别是 charIndex[currentChar] >= left 条件
  3. 窗口长度计算错误,注意是 right - left + 1
  4. 哈希表操作混乱,插入、删除、查找的时机把握不准
http://www.agseo.cn/news/297/

相关文章:

  • ClaudeCode实现简单需求文档分析与拆分
  • python基础——数据容器(序列、集合、字典)
  • 提取符号偏移地址
  • 11.4 类与对象的绑定方法
  • 【初赛】排序 - Slayer
  • Overpass – TryHackMe
  • nvm管理node
  • 浅拷贝和深拷贝两种不同的对象复制
  • NPU前端编译器常见的优化
  • LG11755
  • 「LAOI-9」Update
  • ABC393F
  • ABC393E
  • ABC393D
  • ZR 25 noip D1T2 题解 | 最短路
  • NOIP2024 退役记
  • LG11311
  • CF1746F
  • ABC389F
  • LG10641
  • P11068
  • scp拷贝文件报错
  • ABC150 C-F
  • 【游戏设计】五子棋设计思路
  • LG10516
  • 11.1 定义类和对象
  • C#.NET EFCore.BulkExtensions 扩展详解
  • 2025AI赋能HR新纪元,中国AI HR主流厂商大盘点
  • Linux作业及状态转换
  • C++小白修仙记_LeetCode刷题_队列