Problem: 3. 无重复字符的最长子串
题目:给定一个字符串 s ,请你找出其中不含有重复字符的 最长 子串 的长度。
整体思路
采用 滑动窗口 的方法来解决 “无重复字符的最长子串” 问题。核心目标是在给定的字符串 s
中,找到一个不包含任何重复字符的、最长的子字符串,并返回其长度。
算法的整体思路可以分解为以下几个核心步骤:
-
数据结构与指针初始化:
- 滑动窗口:算法在逻辑上维护一个“窗口”,该窗口由两个指针
left
和right
界定,[left, right]
区间代表当前的子字符串。left
是窗口的左边界,right
是窗口的右边界。 - 哈希表 (HashMap):代码使用一个
HashMap
(map
) 来存储当前窗口内每个字符及其出现的次数(频率)。这使得我们能以 O(1) 的平均时间复杂度快速判断一个字符是否在窗口内以及出现了多少次。 - 变量初始化:
left
初始化为 0,ans
(最终结果)初始化为 0。
- 滑动窗口:算法在逻辑上维护一个“窗口”,该窗口由两个指针
-
窗口的扩展(遍历):
- 代码通过一个
for
循环来移动right
指针,从字符串的开始到结束,一次移动一个位置。 - 每当
right
指针移动时,就相当于将一个新的字符c[right]
“移入”窗口。 - 同时,更新
map
中该字符的频率。
- 代码通过一个
-
窗口的收缩(处理重复):
- 触发条件:在将新字符
c[right]
加入窗口后,代码会立即检查该字符在map
中的频率是否大于 1。如果大于 1,则说明当前窗口内出现了重复字符,窗口变得“不合法”。 - 收缩逻辑:为了使窗口重新“合法”,代码进入一个
while
循环,开始从左侧收缩窗口。- 它会减少窗口最左侧字符
c[left]
在map
中的频率。 - 然后将
left
指针向右移动一位 (left++
)。
- 它会减少窗口最左侧字符
- 这个收缩过程会一直持续,直到
c[right]
这个重复字符在窗口内的频率降回到 1 为止。
- 触发条件:在将新字符
-
结果更新:
- 在
for
循环的每一次迭代中,在窗口调整(可能收缩)完毕后,此时的窗口[left, right]
必然是一个不含重复字符的有效子串。 - 代码会计算当前有效窗口的长度
right - left + 1
,并与已记录的最大长度ans
进行比较,取较大者更新ans
。
- 在
-
返回结果:
- 当
right
指针遍历完整个字符串后,ans
中存储的就是整个过程中出现过的最长的、无重复字符的子串的长度。
- 当
总而言之,该算法通过 right
指针不断扩展窗口,并通过 left
指针在必要时收缩窗口,确保窗口内始终没有重复字符,同时动态更新找到的最大长度。
完整代码
import java.util.HashMap;
import java.util.Map;
class Solution {
public int lengthOfLongestSubstring(String s) {
// 将字符串转换为字符数组,可以略微提升后续字符访问的效率。
char[] c = s.toCharArray();
// 使用哈希表来存储当前滑动窗口内每个字符及其出现的次数(频率)。
// Key: 字符, Value: 出现的次数。
Map<Character, Integer> map = new HashMap<>();
// a. 滑动窗口的左边界索引,初始时指向字符串的开头。
int left = 0;
// b. 用于记录和返回最长无重复子串的长度,初始化为0。
int ans = 0;
// c. for循环驱动滑动窗口的右边界`right`向右移动,遍历整个字符串。
for (int right = 0; right < c.length; right++) {
// 将右边界指向的字符加入窗口,并更新其在map中的频率。
// getOrDefault(key, defaultValue)方法可以优雅地处理字符首次出现的情况。
map.put(c[right], map.getOrDefault(c[right], 0) + 1);
// 核心判断:检查窗口是否有效。
// 如果新加入的字符 c[right] 在窗口中的数量大于1,说明窗口内出现了重复字符。
// 此时需要从左侧收缩窗口,直到窗口再次变为有效(即 c[right] 的数量变回1)。
while (map.get(c[right]) > 1) {
// 将左边界指向的字符的频率减1,相当于将其移出窗口。
map.put(c[left], map.get(c[left]) - 1);
// 将左边界向右移动一位,即收缩窗口。
left++;
}
// 在每次循环的最后(窗口调整完毕后),当前窗口 [left, right] 是一个有效的无重复子串。
// 计算当前有效窗口的长度 (right - left + 1),并与已知的最大长度 ans 比较,更新为较大值。
ans = Math.max(ans, right - left + 1);
}
// 遍历结束后,ans中存储的就是最终结果。
return ans;
}
}
时空复杂度
时间复杂度:O(N)
- 指针移动分析:算法使用了
left
和right
两个指针。right
指针从0
线性扫描到N-1
,其中 N 是字符串s
的长度。它总共移动了 N 次。left
指针也从0
开始移动,并且它 只向右移动,从不后退。它移动的总次数也不会超过 N 次。
- 摊还分析:每个字符
c[i]
最多被right
指针访问一次,也最多被left
指针访问一次。HashMap
的put
和get
操作的平均时间复杂度是 O(1)。因此,尽管存在嵌套的while
循环,但整个过程的总操作数与 N 呈线性关系。
综合分析:两个指针共同完成了一次对字符串的遍历。因此,总的时间复杂度为 O(N)。
空间复杂度:O(k)
- 主要存储开销:算法的额外空间主要来自于
HashMap
(map
)。 - 空间大小分析:这个
map
用来存储窗口内的字符及其频率。- 在最坏情况下,字符串中的所有字符都不同(例如 “abcdefg”),此时窗口会扩展到整个字符串,
map
中会存储 N 个键值对。 - 然而,
map
中存储的键是字符,其数量受限于字符集的大小。例如,如果字符串只包含小写英文字母,map
的大小最多为 26。如果字符集是 ASCII,大小最多为 128 或 256。
- 在最坏情况下,字符串中的所有字符都不同(例如 “abcdefg”),此时窗口会扩展到整个字符串,
- 结论:因此,空间复杂度取决于字符串中字符集的大小。我们可以表示为 O(k),其中
k
是字符集的大小(例如 ASCII 字符集大小为 128)。在很多情况下,k
是一个远小于 N 的常数,此时空间复杂度也可以看作是 O(1)。更严谨的表示是 O(min(N, k))。
综合分析:考虑到通用性,空间复杂度为 O(k),其中 k
是字符集的大小。
参考灵神