剑指 Offer(第2版)面试题 48:最长不含重复字符的子字符串
剑指 Offer(第2版)面试题 48:最长不含重复字符的子字符串
题目来源:
解法1:滑动窗口 + 哈希
以字符串 abcabcbb 为例,找出从每一个字符开始的,不包含重复字符的最长子串,那么其中最长的那个字符串即为答案。对于该字符串,我们列举出这些结果,其中括号中表示选中的字符以及最长的字符串:
- 以 (a)bcabcbb 开始的最长字符串为 (abc)abcbb;
- 以 a(b)cabcbb 开始的最长字符串为 a(bca)bcbb;
- 以 ab(c)abcbb 开始的最长字符串为 ab(cab)cbb;
- 以 abc(a)bcbb 开始的最长字符串为 abc(abc)bb;
- 以 abca(b)cbb 开始的最长字符串为 abca(bc)bb;
- 以 abcab(c)bb 开始的最长字符串为 abcab(cb)b;
- 以 abcabc(b)b 开始的最长字符串为 abcabc(b)b;
- 以 abcabcb(b) 开始的最长字符串为 abcabcb(b)。
发现规律:如果我们依次递增地枚举子串的起始位置,那么子串的结束位置也是递增的。
这样一来,我们就可以使用「滑动窗口」来解决这个问题了:
遍历字符串的元素,加入「滑动窗口」。如果发现当前字符重复,一直把窗口左端的字符移出,直至「滑动窗口」内的字符不再重复,在这个过程中更新不含重复字符的子字符串的最大长度。
在上面的流程中,我们还需要使用一种数据结构来判断 是否有重复的字符,常用的数据结构为哈希集合,即 C++ 中的 std::unordered_set。在左指针向右移动的时候,我们从哈希集合中移除一个字符,在遍历字符串 s 的时候,我们往哈希集合中添加一个字符。
代码:
class Solution {
public:
int longestSubstringWithoutDuplication(string s) {
if (s.empty())
return 0;
int n = s.size(), maxLen = 0, left = 0;
unordered_set<char> occurred;
for (int i = 0; i < n; i++)
{
while (occurred.count(s[i])) // s[i] 在哈希表中存在
{
occurred.erase(s[left]);
left++;
}
// 更新最大子串长度
maxLen = max(maxLen, i - left + 1);
occurred.insert(s[i]);
}
return maxLen;
}
};
复杂度分析:
时间复杂度:O(n),其中 n 是字符串 s 的长度。
空间复杂度:O(∣Σ∣),其中 Σ 表示字符集(即字符串中可以出现的字符),∣Σ∣ 表示字符集的大小。AcWing 中假设字符串中只包含从 a 到 z 的字符,所以 ∣Σ∣ = 26。
解法2:动态规划
令 dp[i] 表示以下标 i 为末尾的不含重复字符的子字符串的最大长度。
哈希集合 unordered_map<char, int> charMap 存储键-值对:<字符,最大下标 + 1>。
初始化:dp[0] = 1,charMap[s[0]] = 1。
状态转移:
- 如果第 i 个字符没有出现过(charMap[s[i]] == 0),dp[i] = dp[i - 1] + 1。
- 如果第 i 个字符出现过,设上一次出现该字符的下标为 lastIndex = charMap[s[i]] - 1,该字符与上一次出现该字符的距离 distance = i - lastIndex。
- 如果 distance <= dp[i - 1],说明第 i 个字符出现在 dp[i - 1] 对应的最长子串中,我们需要删掉前一次出现字符,再加上 s[i],最终的子串长度为 distance -1 + 1 = distance,所以 dp[i] = distance。
- 否则,第 i 个字符不在 dp[i - 1] 对应的最长子串中,仍然有 dp[i] = dp[i - 1] + 1。
与此同时,每遍历一个字符 s[i],就要:
3. 更新哈希集合:charMap[s[i]] = i + 1。
4. 更新最长子串长度:max_len = max(max_len, dp[i])。
代码:
class Solution {
public:
int longestSubstringWithoutDuplication(string s) {
if (s.empty())
return 0;
int n = s.size();
if (n <= 1)
return n;
int max_len = 0;
unordered_map<char, int> charMap; //<字符,最大下标>
vector<int> dp(n, 0);
// 初始化
dp[0] = 1;
charMap[s[0]] = 1;
// 状态转移
for (int i = 1; i < n; i++)
{
if (charMap[s[i]] == 0)
{
dp[i] = dp[i - 1] + 1;
}
else
{
int lastIndex = charMap[s[i]] - 1;
int distance = i - lastIndex;
if (distance <= dp[i - 1])
dp[i] = distance;
else
dp[i] = dp[i - 1] + 1;
}
charMap[s[i]] = i + 1;
max_len = max(max_len, dp[i]);
}
return max_len;
}
};
复杂度分析:
时间复杂度:O(n),其中 n 是字符串 s 的长度。
空间复杂度:O(n + ∣Σ∣),其中 n 是字符串 s 的长度, Σ 表示字符集(即字符串中可以出现的字符),∣Σ∣ 表示字符集的大小。AcWing 中假设字符串中只包含从 a 到 z 的字符,所以 ∣Σ∣ = 26。
AcWing 中假设字符串中只包含从 a 到 z 的字符,我们可以用一个长度为 26 的下标数组代替之前的哈希集合。
代码:
class Solution
{
public:
int longestSubstringWithoutDuplication(string s)
{
if (s.empty())
return 0;
int n = s.length();
if (n <= 1)
return n;
int curLength = 0, maxLength = 0;
vector<int> lastPositon(26, -1);
for (int i = 0; i < n; i++)
{
int preIndex = lastPositon[s[i] - 'a'];
if (preIndex == -1 || i - preIndex > curLength)
curLength++;
else
{
if (curLength > maxLength)
maxLength = curLength;
curLength = i - preIndex;
}
lastPositon[s[i] - 'a'] = i;
}
maxLength = max(maxLength, curLength);
return maxLength;
}
};
更改一下状态转移:
class Solution
{
public:
int longestSubstringWithoutDuplication(string s)
{
if (s.empty())
return 0;
int n = s.length();
if (n <= 1)
return n;
int curLength = 0, maxLength = 0;
vector<int> lastPositon(26, -1);
for (int i = 0; i < n; i++)
{
int preIndex = lastPositon[s[i] - 'a'];
if (preIndex == -1)
curLength++;
else
{
if (i - preIndex > curLength)
curLength++;
else
curLength = i - preIndex;
}
if (curLength > maxLength)
maxLength = curLength;
lastPositon[s[i] - 'a'] = i;
}
maxLength = max(maxLength, curLength);
return maxLength;
}
};
复杂度分析:
时间复杂度:O(n),其中 n 是字符串 s 的长度。
空间复杂度:O(∣Σ∣),其中 Σ 表示字符集(即字符串中可以出现的字符),∣Σ∣ 表示字符集的大小。AcWing 中假设字符串中只包含从 a 到 z 的字符,所以 ∣Σ∣ = 26。
对比:
动态规划空间开销大,代码量也大,还要对下标做特殊处理,评价是不如滑动窗口。