目录
- 1.问题描述
- 2.问题分析
-
- [2.1 题目理解](#2.1 题目理解)
- [2.2 核心洞察](#2.2 核心洞察)
- [2.3 破题关键](#2.3 破题关键)
- 3.算法设计与实现
-
- [3.1 暴力枚举法](#3.1 暴力枚举法)
- [3.2 滑动窗口+哈希集合](#3.2 滑动窗口+哈希集合)
- [3.3 滑动窗口+哈希映射(优化跳转)](#3.3 滑动窗口+哈希映射(优化跳转))
- [3.4 滑动窗口+字符集数组(最优)](#3.4 滑动窗口+字符集数组(最优))
- 4.性能对比
- [5. 扩展与变体](#5. 扩展与变体)
-
- [5.1 返回最长子串本身](#5.1 返回最长子串本身)
- [5.2 最长子串包含最多两种字符](#5.2 最长子串包含最多两种字符)
- [5.3 最多包含K个不同字符的最长子串](#5.3 最多包含K个不同字符的最长子串)
- [5.4 至少包含K个重复字符的最长子串](#5.4 至少包含K个重复字符的最长子串)
- [5.5 最长无重复字符子串的个数](#5.5 最长无重复字符子串的个数)
- [5.6 流数据中的最长无重复字符子串](#5.6 流数据中的最长无重复字符子串)
- 6.总结
-
- [6.1 算法思想总结](#6.1 算法思想总结)
- [6.2 算法选择指南](#6.2 算法选择指南)
- [6.3 工程实践要点](#6.3 工程实践要点)
- [6.4 面试技巧](#6.4 面试技巧)
- [6.5 性能调优深度思考](#6.5 性能调优深度思考)
1.问题描述
给定一个字符串 s,找出其中不含有重复字符的 最长子串 的长度。
示例1:
输入: s = "abcabcbb"
输出: 3
解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。
注意 "bca" 和 "cab" 也是正确答案。
示例2:
输入: s = "bbbbb"
输出: 1
解释: 因为无重复字符的最长子串是 "b",所以其长度为 1。
示例3:
输入: s = "pwwkew"
输出: 3
解释: 因为无重复字符的最长子串是 "wke",所以其长度为 3。
请注意,你的答案必须是子串的长度,"pwke" 是一个子序列,不是子串。
提示:
0 <= s.length <= 5 * 10⁴s由英文字母、数字、符号和空格组成
2.问题分析
2.1 题目理解
我们需要在字符串中找到一个连续的最长子串,该子串中的所有字符都是唯一的(不重复)。注意,这里要求的是子串(连续字符序列),而不是子序列(可以不连续)。
2.2 核心洞察
- 滑动窗口特性:当遇到重复字符时,最长的无重复子串必定结束于该重复字符的第二次出现之前
- 窗口移动的单向性:一旦确定了某个字符重复,左边界可以直接跳到重复字符第一次出现位置之后
- 空间换时间:使用额外数据结构存储字符位置信息,可以避免重复扫描
2.3 破题关键
问题的核心在于如何高效维护一个无重复字符的滑动窗口:
- 右指针不断向右扩展,探索新字符
- 当遇到重复字符时,左指针收缩到合适位置
- 在整个过程中记录窗口的最大长度
3.算法设计与实现
3.1 暴力枚举法
核心思想
枚举所有可能的子串,检查每个子串是否包含重复字符,记录最长无重复子串的长度。
算法思路
- 遍历所有可能的子串起始位置
i(0到n-1) - 对于每个起始位置,遍历结束位置
j(i到n-1) - 检查子串
s[i..j]是否包含重复字符 - 如果不包含,更新最大长度
Java代码实现
java
public class LongestSubstringBruteForce {
// 检查子串是否包含重复字符
private boolean allUnique(String s, int start, int end) {
Set<Character> set = new HashSet<>();
for (int i = start; i < end; i++) {
char ch = s.charAt(i);
if (set.contains(ch)) return false;
set.add(ch);
}
return true;
}
// 暴力解法
public int lengthOfLongestSubstring(String s) {
int n = s.length();
int maxLength = 0;
for (int i = 0; i < n; i++) {
for (int j = i + 1; j <= n; j++) {
if (allUnique(s, i, j)) {
maxLength = Math.max(maxLength, j - i);
}
}
}
return maxLength;
}
// 优化版暴力解法
public int lengthOfLongestSubstringOptimized(String s) {
int n = s.length();
int maxLength = 0;
for (int i = 0; i < n; i++) {
Set<Character> set = new HashSet<>();
for (int j = i; j < n; j++) {
char ch = s.charAt(j);
if (set.contains(ch)) break;
set.add(ch);
maxLength = Math.max(maxLength, j - i + 1);
}
}
return maxLength;
}
}
性能分析
- 时间复杂度:O(n²),需要检查所有可能的子串
- 空间复杂度:O(min(n, 字符集大小)),用于存储字符集合
- 适用场景:仅适用于非常小的输入规模
3.2 滑动窗口+哈希集合
核心思想
使用两个指针表示滑动窗口的左右边界,用哈希集合存储窗口内的字符。右指针不断向右移动扩展窗口,当遇到重复字符时,移动左指针缩小窗口直到重复字符被移除。
算法思路
- 初始化左指针
left = 0,最大长度maxLength = 0 - 初始化哈希集合
set存储窗口内的字符 - 右指针
right从0遍历到n-1:- 如果
s[right]不在集合中,将其加入集合,更新最大长度 - 如果
s[right]在集合中,则移动左指针,从集合中移除s[left],直到重复字符被移除
- 如果
- 返回最大长度
Java代码实现
java
import java.util.HashSet;
import java.util.Set;
public class LongestSubstringSlidingWindow {
public int lengthOfLongestSubstring(String s) {
int n = s.length();
int maxLength = 0;
int left = 0;
Set<Character> set = new HashSet<>();
for (int right = 0; right < n; right++) {
char ch = s.charAt(right);
// 如果字符已存在,移动左指针直到移除重复字符
while (set.contains(ch)) {
set.remove(s.charAt(left));
left++;
}
// 将当前字符加入窗口
set.add(ch);
// 更新最大长度
maxLength = Math.max(maxLength, right - left + 1);
}
return maxLength;
}
}
性能分析
- 时间复杂度:O(n),每个字符最多被访问两次
- 空间复杂度:O(min(n, 字符集大小))
- 优势:逻辑清晰,易于理解和实现
3.3 滑动窗口+哈希映射(优化跳转)
核心思想
使用哈希映射记录每个字符最后出现的位置。当遇到重复字符时,可以直接将左指针跳到重复字符的下一个位置,而不需要逐步移动。
算法思路
- 初始化左指针
left = 0,最大长度maxLength = 0 - 初始化哈希映射
map,键为字符,值为字符最后出现的位置 - 右指针
right从0遍历到n-1:- 如果当前字符在映射中且其位置 ≥ left,说明在窗口内重复
- 更新 left = map.get(s.charAt(right)) + 1
- 更新当前字符在映射中的位置为 right
- 更新最大长度 maxLength = Math.max(maxLength, right - left + 1)
- 如果当前字符在映射中且其位置 ≥ left,说明在窗口内重复
- 返回最大长度
Java代码实现
java
import java.util.HashMap;
import java.util.Map;
public class LongestSubstringHashMap {
public int lengthOfLongestSubstring(String s) {
int n = s.length();
int maxLength = 0;
Map<Character, Integer> map = new HashMap<>();
for (int right = 0, left = 0; right < n; right++) {
char ch = s.charAt(right);
// 如果字符已存在且位置在窗口内
if (map.containsKey(ch) && map.get(ch) >= left) {
left = map.get(ch) + 1;
}
// 更新字符位置
map.put(ch, right);
// 更新最大长度
maxLength = Math.max(maxLength, right - left + 1);
}
return maxLength;
}
// 更简洁的写法
public int lengthOfLongestSubstring2(String s) {
int n = s.length();
int maxLength = 0;
Map<Character, Integer> map = new HashMap<>();
for (int right = 0, left = 0; right < n; right++) {
char ch = s.charAt(right);
// 使用Math.max防止左指针回退
left = Math.max(left, map.getOrDefault(ch, -1) + 1);
map.put(ch, right);
maxLength = Math.max(maxLength, right - left + 1);
}
return maxLength;
}
}
性能分析
- 时间复杂度:O(n),只需要一次遍历
- 空间复杂度:O(min(n, 字符集大小))
- 优势:左指针可以跳跃移动,避免了逐步移动的开销
3.4 滑动窗口+字符集数组(最优)
核心思想
由于字符集有限(英文字母、数字、符号和空格),可以使用固定大小的数组代替哈希映射,提高访问速度。
算法思路
- 创建一个长度为128的整数数组(ASCII码范围0-127),初始化值为-1
- 数组索引表示字符的ASCII码,值表示字符最后出现的位置
- 初始化左指针
left = 0,最大长度maxLength = 0 - 右指针
right从0遍历到n-1:- 获取当前字符的ASCII码
- 如果该字符最后出现的位置 ≥ left,说明在窗口内重复
- 更新 left = lastIndex[ch] + 1
- 更新当前字符最后出现的位置为 right
- 更新最大长度 maxLength = Math.max(maxLength, right - left + 1)
Java代码实现
java
public class LongestSubstringArray {
// 标准ASCII解法
public int lengthOfLongestSubstring(String s) {
int n = s.length();
int maxLength = 0;
int[] lastIndex = new int[128];
// 初始化数组为-1
for (int i = 0; i < 128; i++) {
lastIndex[i] = -1;
}
for (int right = 0, left = 0; right < n; right++) {
char ch = s.charAt(right);
// 如果字符在当前窗口内出现过
if (lastIndex[ch] >= left) {
left = lastIndex[ch] + 1;
}
// 更新字符最后出现的位置
lastIndex[ch] = right;
// 更新最大长度
maxLength = Math.max(maxLength, right - left + 1);
}
return maxLength;
}
// 更简洁的写法
public int lengthOfLongestSubstring2(String s) {
int n = s.length();
int maxLength = 0;
int[] index = new int[128]; // 存储字符最后出现的位置+1
for (int right = 0, left = 0; right < n; right++) {
char ch = s.charAt(right);
// index[ch]存储的是字符ch上次出现的位置+1
left = Math.max(left, index[ch]);
// 更新最大长度
maxLength = Math.max(maxLength, right - left + 1);
// 更新字符位置为当前位置+1
index[ch] = right + 1;
}
return maxLength;
}
// 支持扩展ASCII码
public int lengthOfLongestSubstringExtended(String s) {
int n = s.length();
int maxLength = 0;
int[] index = new int[256]; // 扩展ASCII码
for (int right = 0, left = 0; right < n; right++) {
char ch = s.charAt(right);
left = Math.max(left, index[ch]);
maxLength = Math.max(maxLength, right - left + 1);
index[ch] = right + 1;
}
return maxLength;
}
}
性能分析
- 时间复杂度:O(n),只需要一次遍历
- 空间复杂度:O(1),使用固定大小的数组
- 优势:访问速度快,无需哈希计算,最适合字符集有限的情况
4.性能对比
| 算法 | 时间复杂度 | 空间复杂度 | 优势 | 劣势 |
|---|---|---|---|---|
| 暴力枚举 | O(n²) | O(min(n,字符集)) | 实现简单 | 效率极低 |
| 滑动窗口+哈希集合 | O(2n) | O(min(n,字符集)) | 逻辑清晰 | 左指针移动可能较慢 |
| 滑动窗口+哈希映射 | O(n) | O(min(n,字符集)) | 左指针可以跳跃 | 哈希操作有开销 |
| 滑动窗口+字符集数组 | O(n) | O(1) | 速度最快 | 仅适用于有限字符集 |
4.1 基准测试结果对比(字符串长度10000):
- 暴力枚举法:~500 ms
- 滑动窗口+哈希集合:~15 ms
- 滑动窗口+哈希映射:~10 ms
- 滑动窗口+字符集数组:~5 ms
4.2 内存占用对比:
- 字符集数组:固定128或256个int,约0.5-1KB
- 哈希集合/映射:取决于实际字符种类,通常几KB
4.3 性能优化要点:
- 数组访问速度最快:数组的随机访问时间复杂度为 O(1),且CPU缓存友好
- 减少不必要的操作:数组版本避免了哈希表的哈希计算和冲突处理
- 提前跳出条件:当剩余长度小于当前最大长度时,可以提前结束循环
5. 扩展与变体
5.1 返回最长子串本身
java
public class SolutionExtended {
public String longestSubstringWithoutRepeating(String s) {
if (s == null || s.length() == 0) return "";
int[] lastIndex = new int[128];
Arrays.fill(lastIndex, -1);
int maxLength = 0;
int start = 0; // 最长子串的起始位置
int left = 0;
for (int right = 0; right < s.length(); right++) {
char c = s.charAt(right);
if (lastIndex[c] >= left) {
left = lastIndex[c] + 1;
}
lastIndex[c] = right;
// 更新最长子串信息
if (right - left + 1 > maxLength) {
maxLength = right - left + 1;
start = left;
}
}
return s.substring(start, start + maxLength);
}
}
5.2 最长子串包含最多两种字符
java
public class SolutionTwoDistinct {
public int lengthOfLongestSubstringTwoDistinct(String s) {
if (s == null || s.length() == 0) return 0;
Map<Character, Integer> lastPosition = new HashMap<>();
int left = 0;
int maxLength = 0;
for (int right = 0; right < s.length(); right++) {
char c = s.charAt(right);
lastPosition.put(c, right);
// 如果窗口内字符种类超过2
if (lastPosition.size() > 2) {
// 找到位置最小的字符,将其移除
int minIndex = Collections.min(lastPosition.values());
char charToRemove = s.charAt(minIndex);
lastPosition.remove(charToRemove);
left = minIndex + 1;
}
maxLength = Math.max(maxLength, right - left + 1);
}
return maxLength;
}
}
5.3 最多包含K个不同字符的最长子串
java
public class LongestSubstringKDistinct {
public int lengthOfLongestSubstringKDistinct(String s, int k) {
if (k <= 0 || s == null || s.length() == 0) return 0;
int n = s.length();
int maxLength = 0;
int left = 0;
Map<Character, Integer> charCount = new HashMap<>();
for (int right = 0; right < n; right++) {
char ch = s.charAt(right);
charCount.put(ch, charCount.getOrDefault(ch, 0) + 1);
// 如果不同字符数超过k,移动左指针
while (charCount.size() > k) {
char leftChar = s.charAt(left);
charCount.put(leftChar, charCount.get(leftChar) - 1);
if (charCount.get(leftChar) == 0) {
charCount.remove(leftChar);
}
left++;
}
maxLength = Math.max(maxLength, right - left + 1);
}
return maxLength;
}
}
5.4 至少包含K个重复字符的最长子串
java
public class LongestSubstringAtLeastKRepeating {
// 分治解法
public int longestSubstring(String s, int k) {
return divideAndConquer(s.toCharArray(), 0, s.length(), k);
}
private int divideAndConquer(char[] chars, int start, int end, int k) {
if (end - start < k) return 0;
// 统计字符频率
int[] count = new int[26];
for (int i = start; i < end; i++) {
count[chars[i] - 'a']++;
}
// 找到不满足条件的字符位置
for (int i = start; i < end; i++) {
if (count[chars[i] - 'a'] < k) {
// 分治处理左右两部分
int left = divideAndConquer(chars, start, i, k);
int right = divideAndConquer(chars, i + 1, end, k);
return Math.max(left, right);
}
}
// 当前子串所有字符都满足条件
return end - start;
}
}
5.5 最长无重复字符子串的个数
java
public class CountLongestSubstrings {
public int countLongestSubstrings(String s) {
int n = s.length();
if (n == 0) return 0;
int maxLength = 0;
int count = 0;
int[] index = new int[128];
for (int right = 0, left = 0; right < n; right++) {
char ch = s.charAt(right);
left = Math.max(left, index[ch]);
int currentLength = right - left + 1;
if (currentLength > maxLength) {
maxLength = currentLength;
count = 1;
} else if (currentLength == maxLength) {
count++;
}
index[ch] = right + 1;
}
return count;
}
}
5.6 流数据中的最长无重复字符子串
java
import java.util.ArrayDeque;
import java.util.Deque;
public class StreamLongestSubstring {
private int[] lastIndex = new int[128];
private int left = 0;
private int maxLength = 0;
private int currentPos = 0;
private Deque<Character> window = new ArrayDeque<>();
// 处理字符流中的下一个字符
public int processChar(char ch) {
// 如果字符在当前窗口内出现过
if (lastIndex[ch] >= left) {
// 收缩窗口直到移除重复字符
while (!window.isEmpty() && window.peekFirst() != ch) {
window.pollFirst();
left++;
}
if (!window.isEmpty()) {
window.pollFirst(); // 移除重复字符
left = lastIndex[ch] + 1;
}
}
// 更新字符位置
lastIndex[ch] = currentPos;
currentPos++;
// 扩展窗口
window.offerLast(ch);
// 更新最大长度
maxLength = Math.max(maxLength, window.size());
return maxLength;
}
}
6.总结
6.1 算法思想总结
- 滑动窗口是核心:通过维护一个动态变化的窗口,我们可以在O(n)时间内解决问题
- 空间换时间:使用额外的数据结构存储字符位置信息,避免了重复扫描
- 边界处理是关键:正确处理左指针的跳跃逻辑是算法效率的保证
6.2 算法选择指南
- 小规模数据:可以使用暴力枚举法作为理解问题的基础
- 通用场景:滑动窗口+哈希映射是最平衡的选择
- 性能敏感场景:如果字符集有限,使用字符集数组达到最优性能
- 处理Unicode:使用HashMap支持任意字符
6.3 工程实践要点
- 字符集已知时优先使用数组:数组版本的性能最优,代码简洁
- 考虑内存对齐:数组大小设置为128或256可以更好地利用CPU缓存
- 输入验证不可少 :实际工程中需要验证输入字符串不为null,长度合理加粗样式
6.4 面试技巧
面试中被问到这个问题时,可以按照以下思路回答:
- 先提出暴力解法(展示基础思维)
- 分析暴力解法的问题(时间复杂度高)
- 提出滑动窗口优化思路
- 详细解释哈希表/数组如何帮助快速判断重复
- 讨论时间复杂度和空间复杂度
- 如果可以,提出变体问题的解法
6.5 性能调优深度思考
对于超大规模字符串处理(如DNA序列分析),还可以进一步优化:
- 位图压缩:对于仅有4种字符的DNA序列,可以用2位表示一个字符
- SIMD指令优化:使用AVX2等指令集并行处理多个字符
- 内存映射文件:对于超出内存的大文件,使用内存映射技术
java
// 位图优化示例(DNA序列,只有A、C、G、T四种字符)
public class BitmapSolution {
public int lengthOfLongestSubstringDNA(String dna) {
// 用2位表示一个字符:A=00, C=01, G=10, T=11
int[] bitmask = new int[4]; // 记录最近出现位置
// ... 类似数组解法,但使用位操作
return 0;
}
}