1. 问题背景与核心挑战
在算法面试和日常编程中,处理字符串或数组中的子序列问题非常常见。最长连续不重复子序列(Longest Substring Without Repeating Characters)是一个经典的滑动窗口应用场景,也是大厂面试中的高频考题。这个问题看似简单,但要在O(n)时间复杂度内优雅解决,需要深入理解滑动窗口算法的精髓。
这个问题的核心是:给定一个字符串或数组,找出其中最长的连续子序列,且这个子序列中不包含重复元素。例如对于字符串"abcabcbb",最长不重复子序列是"abc",长度为3;对于数组[1,2,3,2,1],最长不重复子序列是[1,2,3]或[2,3,1],长度为3。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 暴力解法与性能瓶颈
2.1 直观的暴力解法
最直观的解法是使用双重循环检查所有可能的子序列:
java复制public int lengthOfLongestSubstring(String s) {
int n = s.length();
int max = 0;
for (int i = 0; i < n; i++) {
for (int j = i; j < n; j++) {
if (checkDuplicate(s, i, j)) {
max = Math.max(max, j - i + 1);
}
}
}
return max;
}
private boolean checkDuplicate(String s, int start, int end) {
Set<Character> set = new HashSet<>();
for (int i = start; i <= end; i++) {
if (set.contains(s.charAt(i))) {
return false;
}
set.add(s.charAt(i));
}
return true;
}
这种解法的时间复杂度是O(n³),因为有两层循环,再加上每次检查重复的O(n)操作。对于较长的输入(比如长度超过1000),这种解法会非常慢。
2.2 暴力解法的优化空间
我们可以稍微优化暴力解法,使用HashSet来记录当前子序列中的字符:
java复制public int lengthOfLongestSubstring(String s) {
int n = s.length();
int max = 0;
for (int i = 0; i < n; i++) {
Set<Character> set = new HashSet<>();
for (int j = i; j < n; j++) {
if (set.contains(s.charAt(j))) {
break;
}
set.add(s.charAt(j));
max = Math.max(max, j - i + 1);
}
}
return max;
}
这样优化后,时间复杂度降为O(n²),但仍然不够高效。我们需要寻找更优的解法。
3. 滑动窗口算法详解
3.1 滑动窗口的基本思想
滑动窗口算法通过维护一个窗口(通常用两个指针表示左右边界),在满足特定条件的情况下调整窗口大小。对于这个问题:
- 使用两个指针left和right表示当前窗口的左右边界
- 使用一个HashSet记录窗口中的字符
- 移动right指针扩展窗口,直到遇到重复字符
- 移动left指针收缩窗口,直到去除重复字符
- 在整个过程中记录窗口的最大长度
3.2 Java实现代码
java复制public int lengthOfLongestSubstring(String s) {
Set<Character> set = new HashSet<>();
int left = 0, right = 0;
int max = 0;
while (right < s.length()) {
if (!set.contains(s.charAt(right))) {
set.add(s.charAt(right));
right++;
max = Math.max(max, right - left);
} else {
set.remove(s.charAt(left));
left++;
}
}
return max;
}
这个实现的时间复杂度是O(n),因为每个字符最多被left和right指针各访问一次。
3.3 算法正确性分析
让我们以字符串"abcabcbb"为例,逐步分析算法的执行过程:
- 初始状态:left=0, right=0, set={}, max=0
- right=0: 'a'不在set中,加入set,right++ → set={'a'}, max=1
- right=1: 'b'不在set中,加入set,right++ → set={'a','b'}, max=2
- right=2: 'c'不在set中,加入set,right++ → set={'a','b','c'}, max=3
- right=3: 'a'在set中,移除s.charAt(left)即'a',left++ → set={'b','c'}, left=1
- right=3: 'a'不在set中,加入set,right++ → set={'b','c','a'}, max=3
- right=4: 'b'在set中,移除'b',left++ → set={'c','a'}, left=2
- right=4: 'b'不在set中,加入set,right++ → set={'c','a','b'}, max=3
- right=5: 'c'在set中,移除'c',left++ → set={'a','b'}, left=3
- right=5: 'c'不在set中,加入set,right++ → set={'a','b','c'}, max=3
- right=6: 'b'在set中,移除'a',left++ → set={'b','c'}, left=4
- right=6: 'b'在set中,移除'b',left++ → set={'c'}, left=5
- right=6: 'b'不在set中,加入set,right++ → set={'c','b'}, max=3
- right=7: 'b'在set中,移除'c',left++ → set={'b'}, left=6
- right=7: 'b'在set中,移除'b',left++ → set={}, left=7
- right=7: 'b'不在set中,加入set,right++ → set={'b'}, max=3
最终返回max=3,与预期结果一致。
4. 优化滑动窗口实现
4.1 使用HashMap记录字符位置
上面的实现中,当发现重复字符时,left指针需要逐个移动,直到移除重复字符。我们可以用HashMap记录字符及其最新位置,这样可以直接跳转到正确的位置:
java复制public int lengthOfLongestSubstring(String s) {
Map<Character, Integer> map = new HashMap<>();
int left = 0, max = 0;
for (int right = 0; right < s.length(); right++) {
char c = s.charAt(right);
if (map.containsKey(c)) {
left = Math.max(left, map.get(c) + 1);
}
map.put(c, right);
max = Math.max(max, right - left + 1);
}
return max;
}
这种实现更加高效,left指针可以直接跳到重复字符的下一个位置,而不是逐步移动。
4.2 使用数组替代HashMap
当字符集较小(比如ASCII字符)时,可以用数组替代HashMap,进一步提高性能:
java复制public int lengthOfLongestSubstring(String s) {
int[] index = new int[128]; // ASCII字符集
int left = 0, max = 0;
for (int right = 0; right < s.length(); right++) {
char c = s.charAt(right);
left = Math.max(left, index[c]);
index[c] = right + 1;
max = Math.max(max, right - left + 1);
}
return max;
}
这种实现的时间复杂度仍然是O(n),但空间复杂度是O(1)(因为数组大小固定),且常数因子更小,实际运行更快。
5. 处理数组版本的问题
如果输入是数组而不是字符串,算法思想完全一致,只是数据结构稍有不同。以下是处理整数数组的版本:
java复制public int lengthOfLongestSubarray(int[] nums) {
Map<Integer, Integer> map = new HashMap<>();
int left = 0, max = 0;
for (int right = 0; right < nums.length; right++) {
int num = nums[right];
if (map.containsKey(num)) {
left = Math.max(left, map.get(num) + 1);
}
map.put(num, right);
max = Math.max(max, right - left + 1);
}
return max;
}
对于数值范围有限的数组(比如0到1000),同样可以用数组替代HashMap:
java复制public int lengthOfLongestSubarray(int[] nums) {
int[] index = new int[1001]; // 假设数值范围是0-1000
Arrays.fill(index, -1);
int left = 0, max = 0;
for (int right = 0; right < nums.length; right++) {
int num = nums[right];
if (index[num] >= left) {
left = index[num] + 1;
}
index[num] = right;
max = Math.max(max, right - left + 1);
}
return max;
}
6. 边界条件与测试用例
6.1 常见边界情况
- 空字符串或空数组:应返回0
- 所有字符都相同:如"aaaa"应返回1
- 没有重复字符:如"abcd"应返回字符串长度
- 重复字符出现在不同位置:如"abba"需要正确处理
6.2 测试用例示例
java复制@Test
public void testLengthOfLongestSubstring() {
Solution solution = new Solution();
assertEquals(0, solution.lengthOfLongestSubstring("")); // 空字符串
assertEquals(1, solution.lengthOfLongestSubstring("a")); // 单个字符
assertEquals(1, solution.lengthOfLongestSubstring("aaaa")); // 全部相同
assertEquals(3, solution.lengthOfLongestSubstring("abcabcbb")); // 典型例子
assertEquals(3, solution.lengthOfLongestSubstring("pwwkew")); // 重复在不同位置
assertEquals(5, solution.lengthOfLongestSubstring("tmmzuxt")); // 需要正确处理left跳跃
}
7. 算法复杂度分析
7.1 时间复杂度
所有优化后的滑动窗口实现都是O(n)时间复杂度,因为每个字符最多被访问两次(由left和right指针各一次)。
7.2 空间复杂度
- HashSet/HashMap实现:O(min(m,n)),其中m是字符集大小,n是字符串长度
- 数组实现:O(m),m是字符集大小(ASCII为128,扩展ASCII为256等)
对于固定字符集(如ASCII),数组实现的空间复杂度可以视为O(1)。
8. 实际应用与变种问题
8.1 实际应用场景
- 文本编辑器中查找最长不重复单词序列
- 数据分析中寻找最长连续唯一值序列
- 生物信息学中DNA序列分析
- 网络协议中处理数据包序列
8.2 常见变种问题
- 最长连续重复子序列(与本题相反)
- 允许最多k次重复的最长子序列
- 寻找所有最长不重复子序列而不仅是长度
- 二维矩阵中的最长不重复路径
9. 编码风格与最佳实践
9.1 代码可读性建议
- 使用有意义的变量名(如left/right比i/j更清晰)
- 添加必要的注释说明算法关键步骤
- 将复杂逻辑拆分为辅助方法
- 保持一致的代码风格(大括号位置、缩进等)
9.2 性能优化技巧
- 对于已知字符集的问题,优先使用数组而非HashMap
- 避免在循环中进行不必要的对象创建
- 对于非常长的输入,考虑使用更节省空间的数据结构
- 在竞争性编程中,可以使用静态数组避免重复分配
10. 常见错误与调试技巧
10.1 常见错误类型
- 边界条件处理不当(空输入、单个字符等)
- left指针更新逻辑错误(如没有使用Math.max)
- 哈希表更新时机不正确
- 窗口长度计算错误(是否加1)
10.2 调试方法
- 打印关键变量(left, right, max)的变化过程
- 使用小型测试用例手动模拟算法执行
- 比较暴力解法与优化解法的结果
- 使用单元测试覆盖各种边界情况
在实际面试中,建议先讨论暴力解法,然后逐步优化到滑动窗口解法,并分析时间/空间复杂度。同时要注意代码的整洁性和可读性,这些都是面试官评估的重点。
