3. 无重复字符的最长子串#
生活中的算法#
你是否玩过”一指禅”游戏?就是沿着一串字母走,不能重复走过已经走过的字母。这个游戏的本质,其实就是在寻找无重复字符的最长子串。
在实际编程中,这个问题的应用非常广泛。比如在文本编辑器中查找不含重复字符的最长片段,或是在DNA序列分析中寻找无重复碱基对的最长序列。
问题描述#
题目目标#
给定一个字符串 s,请找出其中不含重复字符的最长子串长度。
示例 1#
输入: s = "abcabcbb"
输出: 3
窗口示意:
最长无重复子串之一: "abc"
长度: 3text说明: 虽然后面还会再次出现 abc 中的字符,但最长的不重复子串长度仍然是 3。
示例 2#
输入: s = "bbbbb"
输出: 1
窗口示意:
最长无重复子串: "b"
长度: 1text说明: 字符串中所有字符都相同,所以任意合法子串最多只包含一个字符。
补充说明#
- 这里的“子串”必须是连续的一段字符。
- 题目要求的是长度,不是具体返回子串内容。
最直观的解法:暴力枚举法#
最容易想到的方法是:枚举所有可能的子串,检查每个子串是否包含重复字符,然后找出最长的那个。
让我们用一个例子来模拟这个过程:
s = "pwwk"
检查所有子串:
"p" - 长度1,无重复
"pw" - 长度2,无重复
"pww" - 长度3,有重复
"pwwk" - 长度4,有重复
"w" - 长度1,无重复
"ww" - 长度2,有重复
"wwk" - 长度3,有重复
"w" - 长度1,无重复
"wk" - 长度2,无重复
"k" - 长度1,无重复
最长的无重复子串长度为2plaintext这种思路可以用Java代码这样实现:
public int lengthOfLongestSubstring(String s) {
int maxLength = 0; // 记录目前找到的“无重复字符子串”的最大长度
// 枚举所有可能的起点 i,表示子串从 s[i] 开始
for (int i = 0; i < s.length(); i++) {
Set<Character> charSet = new HashSet<>(); // 存放当前子串中已经出现过的字符,用于 O(1) 判断重复
int currentLength = 0; // 记录以 i 为起点时,当前无重复子串的长度
// 从起点 i 开始,尝试不断向右扩展子串的终点 j
for (int j = i; j < s.length(); j++) {
// 如果当前字符已经在集合中,说明出现重复,当前这条扩展路径结束
if (charSet.contains(s.charAt(j))) {
break; // 直接跳出内层循环,尝试下一个起点
}
charSet.add(s.charAt(j)); // 把新字符加入集合,表示它已在当前子串里
currentLength++; // 当前无重复子串长度 +1
}
maxLength = Math.max(maxLength, currentLength); // 更新全局最大长度
}
return maxLength; // 返回最终答案
}java优化解法:滑动窗口法#
仔细观察会发现,当我们遇到重复字符时,不需要完全重新开始,而是可以从上一次该字符出现位置的下一个位置继续。这就是”滑动窗口”的思想。 举个例子,对字符串”abcdce”,当我们查看到”abcd”这个子串时,子串内没有重复字符。 但是,当我们继续前进,子串变成”abcdc”,现在c重复了!由于出现了第二个c,所以,第一个c之前的字符,都没有用了。 我们需要把第一个c,以及它前面的字符全部剔除出去,以保证c不再重复。
滑动窗口法的原理#
- 使用两个指针(left和right)维护一个窗口
- 右指针不断向右移动,扩大窗口
- 当遇到重复字符时,左指针移动到上一次该字符出现位置的下一位
- 在这个过程中记录最大窗口大小
算法步骤(伪代码)#
- 初始化left = 0,right = 0,maxLength = 0
- 使用Map记录每个字符最后出现的位置
- 移动右指针,对于每个字符:
- 如果字符已在窗口中,更新left指针
- 更新字符的位置
- 更新最大长度
示例运行#
让我们用s = “abba”模拟这个过程:
初始状态:left = 0, right = 0, maxLength = 0
Map = {}
1. 处理'a':
Map = {a:0}
窗口:[a]
maxLength = 1
2. 处理'b':
Map = {a:0, b:1}
窗口:[ab]
maxLength = 2
3. 处理'b':
发现重复的'b'
left移动到上一个'b'的下一位
Map = {a:0, b:2}
窗口:[b]
maxLength = 2
4. 处理'a':
发现重复的'a'
left移动到上一个'a'的下一位
Map = {a:3, b:2}
窗口:[ba]
maxLength = 2plaintextJava代码实现#
public int lengthOfLongestSubstring(String s) {
Map<Character, Integer> charMap = new HashMap<>(); // 记录每个字符“最近一次出现”的下标
int maxLength = 0; // 记录窗口过程中出现过的最大窗口长度
// left 表示当前窗口左边界,i 表示当前窗口右边界(正在遍历到的位置)
for (int left = 0, i = 0; i < s.length(); i++) {
char currentChar = s.charAt(i); // 当前右指针指向的字符
// 如果该字符之前出现过,可能会导致窗口内重复,需要移动 left
if (charMap.containsKey(currentChar)) {
// left 只能右移不能左移,所以用 max 防止 left 回退
// charMap.get(currentChar) + 1 表示跳过上一次出现 currentChar 的位置
left = Math.max(left, charMap.get(currentChar) + 1);
}
charMap.put(currentChar, i); // 更新当前字符“最新出现位置”为 i
maxLength = Math.max(maxLength, i - left + 1); // 当前窗口长度为 i-left+1,尝试更新最大值
}
return maxLength; // 返回不含重复字符的最长子串长度
}java解法比较#
让我们比较这两种解法:
暴力枚举法:
- 时间复杂度:O(n²)
- 空间复杂度:O(min(m,n)),其中m是字符集大小
- 优点:直观易懂
- 缺点:效率低,有重复计算
滑动窗口法:
- 时间复杂度:O(n)
- 空间复杂度:O(min(m,n))
- 优点:一次遍历就能得到结果
- 缺点:需要额外空间存储字符位置
题目模式总结#
这道题体现了几个重要的算法思想:
- 滑动窗口:使用双指针维护一个符合条件的区间
- 空间换时间:使用哈希表记录信息来优化查找
- 重复利用信息:不重新开始,而是利用已知信息继续搜索
这种解题模式在很多问题中都有应用,比如:
- 最小覆盖子串
- 字符串的排列
- 找到字符串中所有字母异位词
解决此类问题的通用思路是:
- 考虑是否可以通过维护一个窗口来解决
- 确定窗口的更新条件
- 想清楚如何移动左右指针
- 考虑是否需要额外的数据结构来优化
小结#
通过这道题,我们不仅学会了如何找到最长无重复子串,更重要的是掌握了滑动窗口这个强大的算法技巧。从暴力解法到优化解法,我们看到了如何通过观察问题特点来优化算法。
记住,很多看似复杂的问题,都可以通过滑动窗口来优雅地解决。当你遇到类似的字符串处理问题时,不妨先想想是否可以用这个技巧!