【算法】无重复字符的最长子串 & 每个字符最多出现两次的最长子字符串------不定长滑动窗口进阶
-
- [3. 无重复字符的最长子串](#3. 无重复字符的最长子串)
-
- [1. 题目链接](#1. 题目链接)
- [2. 题目描述](#2. 题目描述)
- [3. 题目示例](#3. 题目示例)
- [4. 算法思路](#4. 算法思路)
-
- 解法一:暴力枚举
- [解法二:滑动窗口 + 哈希表(推荐)](#解法二:滑动窗口 + 哈希表(推荐))
- [5. 核心代码](#5. 核心代码)
- [6. 示例测试(总代码)](#6. 示例测试(总代码))
- [3090. 每个字符最多出现两次的最长子字符串](#3090. 每个字符最多出现两次的最长子字符串)
-
- [1. 题目链接](#1. 题目链接)
- [2. 题目描述](#2. 题目描述)
- [3. 题目示例](#3. 题目示例)
- [4. 算法思路](#4. 算法思路)
- [5. 核心代码](#5. 核心代码)
- [6. 示例测试(总代码)](#6. 示例测试(总代码))
- 总结
-
- [第 3 题 vs 第 3090 题 对比](#第 3 题 vs 第 3090 题 对比)
- 不定长滑动窗口通用模板
- 核心要点

🎬 博主名称: 超级苦力怕
🔥 个人专栏: 《LeetCode 题解》
🚀 每一次思考都是突破的前奏,每一次复盘都是精进的开始!
本篇文章讲解的是 LeetCode 第 3 题------无重复字符的最长子串 和 第 3090 题------每个字符最多出现两次的最长子字符串 。两道题同属 不定长滑动窗口 ,核心都是维护一个"满足字符频次约束"的窗口:第 3 题要求窗口内字符全部不重复(频次 ≤ 1),第 3090 题则放宽为每个字符最多出现两次(频次 ≤ 2)。
不定长滑动窗口是字符串问题的通用解法,时间复杂度 O(n),空间复杂度 O(字符集大小)。在实际业务中,它广泛应用于 日志时间窗口分析 (如"最近 5 分钟内最多允许 3 次失败登录")、网络流量控制 (TCP 拥塞窗口的动态调整)、基因序列匹配 (寻找满足特定碱基频次约束的最长片段)等场景。
本文将使用 Java 进行讲解,从暴力枚举逐步过渡到滑动窗口,帮助你掌握不定长滑窗的核心框架------右指针扩张 + 左指针收缩。
3. 无重复字符的最长子串
1. 题目链接
直达链接:LeetCode 3
2. 题目描述
给定一个字符串 s,请你找出其中不含有重复字符的 最长子串 的长度。

提示:
0 <= s.length <= 5 * 10^4s由英文字母、数字、符号和空格组成
3. 题目示例
示例 1:
输入: s = "abcabcbb"
输出: 3
解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。
示例 2:
输入: s = "bbbbb"
输出: 1
解释: 因为无重复字符的最长子串是 "b",所以其长度为 1。
示例 3:
输入: s = "pwwkew"
输出: 3
解释: 因为无重复字符的最长子串是 "wke",所以其长度为 3。
请注意,你的答案必须是 子串 的长度,"pwke" 是一个子序列,不是子串。
4. 算法思路
这道题是不定长滑动窗口的入门经典题。窗口内需要满足的约束是:所有字符互不相同(即每个字符的出现次数 ≤ 1)。
解法一:暴力枚举
算法思想:
- 枚举所有可能的子串起点
i和终点j - 对每个子串,用哈希集合检查是否有重复字符
- 若无重复,更新最大长度
复杂度分析:
- 时间复杂度:O(n²),枚举所有子串 O(n²),每次判重 O(n),总计 O(n³),实际可通过提前 break 优化到 O(n²)
- 空间复杂度:O(n),哈希集合存储子串字符
解法二:滑动窗口 + 哈希表(推荐)
核心框架:右指针扩张,左指针收缩。
维护一个可变长度的窗口 [left, right]:
- 右指针
right不断向右扩张 ,将s[right]加入窗口,更新频次表 - 当窗口内出现重复字符时 (即
s[right]的频次 > 1),左指针left不断右移,同时减少对应字符的频次,直到重复消除 - 窗口重新合法后,用当前窗口长度
right - left + 1更新答案
示例推演 (s = "abcabcbb")
初始:left = 0, right = 0, cnt = {}, ans = 0
right=0 'a': cnt={a:1}, window="a", 合法, ans=1
right=1 'b': cnt={a:1,b:1}, window="ab", 合法, ans=2
right=2 'c': cnt={a:1,b:1,c:1}, window="abc", 合法, ans=3
right=3 'a': cnt={a:2,b:1,c:1} → 重复!
left=0 移出'a': cnt={a:1,b:1,c:1}, left=1
window="bca", 合法, ans=3
right=4 'b': cnt={a:1,b:2,c:1} → 重复!
left=1 移出'b': cnt={a:1,b:1,c:1}, left=2
window="cab", 合法, ans=3
right=5 'c': cnt={a:1,b:1,c:2} → 重复!
left=2 移出'c': cnt={a:1,b:1,c:1}, left=3
window="abc", 合法, ans=3
right=6 'b': cnt={a:1,b:2,c:1} → 重复!
left=3 移出'a': cnt={a:0,b:2,c:1}, left=4 (a 被移除)
仍重复!left=4 移出'b': cnt={b:1,c:1}, left=5
window="cb", 合法, ans=3
right=7 'b': cnt={b:2,c:1} → 重复!
left=5 移出'c': cnt={b:2}, left=6 (c 被移除)
仍重复!left=6 移出'b': cnt={b:1}, left=7
window="b", 合法, ans=3
最终答案:3
易错点: 收缩左指针时可能需要多次移动,要用 while 循环而非 if。例如 s = "abca" 中遇到第二个 'a' 时,移出最左边的 'a' 即可;但 s = "abcc" 中遇到第二个 'c' 时,需要连续移出 'a' 和 'b' 和第一个 'c'。
复杂度分析:
- 时间复杂度:O(n),每个字符最多被左右指针各访问一次
- 空间复杂度:O(∣Σ∣),其中 ∣Σ∣ 为字符集大小
5. 核心代码
java
class Solution {
public int lengthOfLongestSubstring(String s) {
int n = s.length();
int[] cnt = new int[128];
int left = 0, ans = 0;
for (int right = 0; right < n; right++) {
char c = s.charAt(right);
cnt[c]++;
while (cnt[c] > 1) {
cnt[s.charAt(left)]--;
left++;
}
ans = Math.max(ans, right - left + 1);
}
return ans;
}
}
6. 示例测试(总代码)
java
public class Main {
public static void main(String[] args) {
Solution sol = new Solution();
// 示例1测试
String s1 = "abcabcbb";
System.out.println("示例1输出:" + sol.lengthOfLongestSubstring(s1)); // 预期输出3
// 示例2测试
String s2 = "bbbbb";
System.out.println("示例2输出:" + sol.lengthOfLongestSubstring(s2)); // 预期输出1
// 示例3测试
String s3 = "pwwkew";
System.out.println("示例3输出:" + sol.lengthOfLongestSubstring(s3)); // 预期输出3
}
}
3090. 每个字符最多出现两次的最长子字符串
1. 题目链接
直达链接:LeetCode 3090
2. 题目描述
给你一个字符串 s,请返回满足每个字符最多出现两次的最长子字符串的长度。

提示:
1 <= s.length <= 100s仅由小写英文字母组成
3. 题目示例
示例 1:
输入:s = "bcbbbcba"
输出:4
解释:以下子字符串长度为 4,并且每个字符最多出现两次:
- "bcba"(b 出现 2 次,c 出现 1 次,a 出现 1 次)
- "cbbc"(c 出现 2 次,b 出现 2 次)
- "cbba"(c 出现 1 次,b 出现 2 次,a 出现 1 次)
示例 2:
输入:s = "aaaa"
输出:2
解释:以下子字符串长度为 2,并且每个字符最多出现两次:
- "aa"
不存在更长的满足条件的子字符串。
4. 算法思路
这道题与第 3 题一脉相承,唯一的区别是约束条件放宽了------从"每个字符最多出现 1 次"变为"每个字符最多出现 2 次"。
滑动窗口框架完全一致,只需修改收缩条件:
- 右指针
right不断向右扩张 ,将s[right]加入窗口,频次 +1 - 当
s[right]的频次 > 2 时,左指针不断右移,直到该字符频次降回 2 - 窗口合法后,用
right - left + 1更新答案
解法一:暴力枚举
算法思想:
- 枚举所有子串
[i, j],统计每个子串中各字符的频次 - 若所有字符频次 ≤ 2,更新最大长度
复杂度分析:
- 时间复杂度:O(n²),n 最大 100,完全可行
- 空间复杂度:O(1),固定 26 个小写字母
解法二:滑动窗口(推荐)
由于 s 仅由小写字母组成,可以用 int[26] 数组代替哈希表,效率更高。
示例推演 (s = "bcbbbcba")
初始:left = 0, ans = 0, cnt[26] = {}
right=0 'b': cnt[b]=1, window="b", 合法, ans=1
right=1 'c': cnt[b]=1,c=1, window="bc", 合法, ans=2
right=2 'b': cnt[b]=2,c=1, window="bcb", 合法, ans=3
right=3 'b': cnt[b]=3,c=1 → b 超限!
移出 left=0 'b': cnt[b]=2,c=1, left=1
window="cbb", 合法, ans=3
right=4 'b': cnt[b]=3,c=1 → b 超限!
移出 left=1 'c': cnt[b]=3,c=0, left=2
仍超限!移出 left=2 'b': cnt[b]=2,c=0, left=3
window="bb", 合法, ans=3
right=5 'c': cnt[b]=2,c=1, window="bbc", 合法, ans=3
right=6 'b': cnt[b]=3,c=1 → b 超限!
移出 left=3 'b': cnt[b]=2,c=1, left=4
window="bcb", 合法, ans=3
right=7 'a': cnt[b]=2,c=1,a=1, window="bcba", 合法, ans=4
最终答案:4
复杂度分析:
- 时间复杂度:O(n),每个字符最多被左右指针各访问一次
- 空间复杂度:O(1),固定大小数组
int[26]
5. 核心代码
java
class Solution {
public int maximumLengthSubstring(String s) {
int n = s.length();
int[] cnt = new int[26];
int left = 0, ans = 0;
for (int right = 0; right < n; right++) {
int idx = s.charAt(right) - 'a';
cnt[idx]++;
while (cnt[idx] > 2) {
cnt[s.charAt(left) - 'a']--;
left++;
}
ans = Math.max(ans, right - left + 1);
}
return ans;
}
}
6. 示例测试(总代码)
java
public class Main {
public static void main(String[] args) {
Solution sol = new Solution();
// 示例1测试
String s1 = "bcbbbcba";
System.out.println("示例1输出:" + sol.maximumLengthSubstring(s1)); // 预期输出4
// 示例2测试
String s2 = "aaaa";
System.out.println("示例2输出:" + sol.maximumLengthSubstring(s2)); // 预期输出2
}
}
总结
第 3 题 vs 第 3090 题 对比
| 维度 | 3. 无重复字符的最长子串 | 3090. 每个字符最多出现两次的最长子字符串 |
|---|---|---|
| 难度 | 中等 | 简单 |
| 约束条件 | 每个字符频次 ≤ 1 | 每个字符频次 ≤ 2 |
| 字符集 | 英文字母、数字、符号、空格 | 仅小写英文字母 |
| 频次存储 | int[128] 或 HashMap |
int[26] |
| 收缩条件 | cnt[c] > 1 |
cnt[idx] > 2 |
| 核心技巧 | 不定长滑动窗口 + 频次约束 | 同第 3 题,仅阈值不同 |
不定长滑动窗口通用模板
两道题共享同一个算法框架,差异仅在于频次阈值的不同:
右指针扩张 → 更新频次
↓
while (窗口不合法) {
左指针收缩 → 更新频次
}
↓
更新答案
这个模板可以推广到任意"每个字符最多出现 k 次"的问题------只需将阈值从 1 或 2 改为 k 即可。
核心要点
- 滑动窗口的核心是单调性:当右指针固定时,左指针只会向右移动、不会回退,这保证了 O(n) 的时间复杂度
- 第 3 题中,
while (cnt[c] > 1)的循环条件保证了窗口内无重复字符;第 3090 题中改为while (cnt[idx] > 2),约束放宽后窗口可以更长 - 第 3 题字符集包含符号和空格,使用
int[128]覆盖 ASCII 全集;第 3090 题仅小写字母,使用int[26]更高效 - 收缩左指针时用
while而非if------因为可能需要连续移出多个字符才能恢复窗口合法性 - 两道题都只需一次遍历,时间复杂度 O(n),空间复杂度 O(1)(固定大小数组)
