无重复字符的最长子串
1. 题目描述
给定一个字符串 s
,请你找出其中不含有重复字符的 最长子串的长度。
示例 1:
ini
输入: s = "abcabcbb"
输出: 3
解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。
示例 2:
ini
输入: s = "bbbbb"
输出: 1
解释: 因为无重复字符的最长子串是 "b",所以其长度为 1。
示例 3:
makefile
输入: s = "pwwkew"
输出: 3
解释: 因为无重复字符的最长子串是 "wke",所以其长度为 3。
请注意,你的答案必须是 子串 的长度,"pwke" 是一个子序列, 不是子串。
提示:
0 <= s.length <= 5 * 104
s
由英文字母、数字、符号和空格组成
2. 解决方案
暴力解法
- 思路:
- 枚举字符串
s
的所有子串,对于每个子串,检查其是否包含重复字符。 - 从长度为 1 的子串开始,逐渐增加子串长度,记录下不包含重复字符的最长子串的长度。
- 代码实现:
ts
function lengthOfLongestSubstringBruteForce(s: string): number {
let maxLength = 0;
const n = s.length;
for (let i = 0; i < n; i++) {
for (let j = i; j < n; j++) {
const subStr = s.slice(i, j + 1);
const charSet = new Set();
let isDuplicate = false;
for (const char of subStr) {
if (charSet.has(char)) {
isDuplicate = true;
break;
}
charSet.add(char);
}
if (!isDuplicate) {
maxLength = Math.max(maxLength, subStr.length);
}
}
}
return maxLength;
}
- 分析:
- 时间复杂度 :(O(n^3))。外层循环遍历子串的起始位置
i
,时间复杂度为 (O(n));内层循环遍历子串的结束位置j
,时间复杂度为 (O(n));对于每个子串,检查是否有重复字符需要遍历子串中的每个字符,时间复杂度为 (O(n))。所以总的时间复杂度为 (O(n \times n \times n))。 - 空间复杂度 :(O(min(n, m))),其中
n
是字符串s
的长度,m
是字符集的大小。在最坏情况下,子串中包含所有不同字符,此时空间复杂度为 (O(n));如果字符集大小有限,如 ASCII 字符集大小为 128,则空间复杂度为 (O(m))。
- 缺点:时间复杂度非常高,在处理较长字符串时,运行效率极低,会导致超时。
滑动窗口解法
- 思路:
- 使用滑动窗口来维护一个不包含重复字符的子串。
- 用一个集合(
Set
)来记录当前窗口内的字符。 - 初始化窗口的左右边界
left
和right
都为 0。 - 移动右边界
right
,将新字符加入集合。如果新字符已在集合中,说明出现重复,移动左边界left
,并从集合中移除相应字符,直到窗口内不再有重复字符。 - 每次移动后,更新最长无重复字符子串的长度。
- 代码实现:
ts
function lengthOfLongestSubstring(s: string): number {
const charSet = new Set();
let left = 0;
let maxLength = 0;
for (let right = 0; right < s.length; right++) {
while (charSet.has(s[right])) {
charSet.delete(s[left]);
left++;
}
charSet.add(s[right]);
maxLength = Math.max(maxLength, right - left + 1);
}
return maxLength;
}
- 分析:
- 时间复杂度 :(O(n))。虽然有一个嵌套的
while
循环,但left
和right
指针最多各移动n
次,所以总的时间复杂度为 (O(n))。 - 空间复杂度 :(O(min(n, m))),与暴力解法相同,取决于字符串长度
n
和字符集大小m
。
- 优点:时间复杂度大大降低,相比于暴力解法,在处理长字符串时效率有显著提升。
优化的滑动窗口解法(使用数组替代集合)
- 思路:
- 如果字符串只包含 ASCII 字符(共 128 个),可以使用一个长度为 128 的数组来替代
Set
记录字符出现的位置。 - 数组的索引对应字符的 ASCII 码值,数组的值记录该字符上次出现的位置。
- 移动右边界时,检查当前字符上次出现的位置是否在当前窗口内,如果在,则更新左边界。
- 代码实现:
ts
function lengthOfLongestSubstringOptimized(s: string): number {
const charIndex = new Array(128).fill(-1);
let left = 0;
let maxLength = 0;
for (let right = 0; right < s.length; right++) {
left = Math.max(left, charIndex[s.charCodeAt(right)] + 1);
charIndex[s.charCodeAt(right)] = right;
maxLength = Math.max(maxLength, right - left + 1);
}
return maxLength;
}
- 分析:
- 时间复杂度 :(O(n))。与普通滑动窗口解法相同,
left
和right
指针最多各移动n
次。 - 空间复杂度 :(O(m)),这里
m
是固定的 128,因为使用了一个固定大小的数组。相比使用Set
,在空间上更优。
最优解及原因
- 最优解:优化的滑动窗口解法(使用数组替代集合)是最优解。
- 原因 :它在时间复杂度上与普通滑动窗口解法相同,都是 (O(n)),但在空间复杂度上,对于只包含 ASCII 字符的字符串,它使用固定大小的数组,空间复杂度为 (O(m))(
m = 128
),优于普通滑动窗口解法的 (O(min(n, m)))。同时,数组的访问和更新操作比Set
的操作在某些情况下效率更高。
3. 拓展和题目变形
拓展:
- 找到所有不含有重复字符的最长子串。
思路:
- 在滑动窗口的基础上,每次更新最长子串长度时,将符合长度的子串记录下来。
代码实现:
ts
function findAllLongestSubstrings(s: string): string[] {
const charIndex = new Array(128).fill(-1);
let left = 0;
let maxLength = 0;
const result: string[] = [];
for (let right = 0; right < s.length; right++) {
left = Math.max(left, charIndex[s.charCodeAt(right)] + 1);
charIndex[s.charCodeAt(right)] = right;
if (right - left + 1 === maxLength) {
result.push(s.slice(left, right + 1));
} else if (right - left + 1 > maxLength) {
maxLength = right - left + 1;
result.length = 0;
result.push(s.slice(left, right + 1));
}
}
return result;
}
题目变形:
- 给定一个字符串
s
和一个整数k
,找到最长的包含不超过k
个不同字符的子串。
思路:
- 使用滑动窗口,用一个对象记录窗口内不同字符的出现次数,当窗口内不同字符数超过
k
时,移动左边界,更新最长子串长度。
代码实现:
ts
function lengthOfLongestSubstringKDistinct(s: string, k: number): number {
const charCount: { [key: string]: number } = {};
let left = 0;
let maxLength = 0;
for (let right = 0; right < s.length; right++) {
charCount[s[right]] = (charCount[s[right]] || 0) + 1;
while (Object.keys(charCount).length > k) {
charCount[s[left]]--;
if (charCount[s[left]] === 0) {
delete charCount[s[left]];
}
left++;
}
maxLength = Math.max(maxLength, right - left + 1);
}
return maxLength;
}