0
点赞
收藏
分享

微信扫一扫

LeetCode 3. 无重复字符的最长子串 -> follow up 395. 424. 1004. 1208. 1493. 2024. (滑动窗口系列)



文章目录

  • ​​题目描述​​
  • ​​思路​​
  • ​​follow up​​
  • ​​395. 至少有K个重复字符的最长子串​​
  • ​​424. 替换K个字符后的最长子串​​
  • ​​1004.最大连续1的个数​​
  • ​​1208.尽可能使字符串相等​​
  • ​​1493.删掉一个元素后全为1的最长数组​​
  • ​​2024.考试的最大困扰度​​
  • ​​小结​​

题目描述

给定一个字符串 s ,请你找出其中不含有重复字符的 最长子串 的长度。

思路

如果区间​​[l,r]​​​内有重复字符,则以​​l​​​作为左端点的不含重复字符的子串,不可能伸到​​r​​以右的位置。则用滑动窗口即可。

写法一

(由于字符只包含字母,数字,符号,空格。即是ASCII码内的字符,可以直接开一个大小为128的数组进行计数,因为ASCII码不会超过128)

class Solution {
public int lengthOfLongestSubstring(String s) {
int[] cnt = new int[128]; // 计数
int ans = 0;
for (int r = 0, l = 0; r < s.length(); r++) {
// 尝试将r纳入窗口, 先判断其是否已存在
while (l < r && cnt[s.charAt(r)] > 0) {
cnt[s.charAt(l)]--;
l++; // 若已存在, 则左端点右移
}
cnt[s.charAt(r)]++; // 纳入
ans = Math.max(ans, r - l + 1); // 更新答案
}
return ans;
}
}

写法二

用一个​​Set​​来记录已经出现的字符

class Solution {
public int lengthOfLongestSubstring(String s) {
Set<Character> set = new HashSet<>();
int ans = 0;
for (int r = 0, l = 0; r < s.length(); r++) {
while (l < r && set.contains(s.charAt(r))) {
set.remove(s.charAt(l));
l++;
}
set.add(s.charAt(r));
ans = Math.max(ans, r - l + 1);
}
return ans;
}
}

写法三

其实可以当需要移动左端点时,不需要一位一位的移动,直接移动到重复字符的下一个位置即可,所以可以用一个​​Map​​来保存出现过的字符以及其下标。

由于更新左端点时,一次性将窗口移动到了某个位置,有多个字符被移出去,这些被移出去的字符,其在​​Map​​​里存储的下标,可以不用删除。只需要在判断​​Map​​里存在某个字符,并取这个字符的下标时,注意与当前的左端点取一个最大值即可。

class Solution {
public int lengthOfLongestSubstring(String s) {
Map<Character, Integer> map = new HashMap<>();
int ans = 0;
for (int r = 0, l = 0; r < s.length(); r++) {
// 直接if即可, 若r这个位置的字符存在重复, 则左边界直接挪到下一个位置
if (map.containsKey(s.charAt(r))) l = Math.max(l, map.get(s.charAt(r)) + 1);
map.put(s.charAt(r), r); // 将右端点, 以及其下标, 更新进去
ans = Math.max(ans, r - l + 1);
}
return ans;
}
}

follow up

395. 至少有K个重复字符的最长子串

滑动窗口解法

无法直接用滑动窗口,因为不具备二段性。当固定了字符种类的数目后,重新具备二段性,可以使用滑窗。

class Solution {
public int longestSubstring(String s, int k) {
// 固定字符种类, 总共可能有1 - 26
int ans = 0;
int[] cnt = new int[26]; // 计数
for (int kind = 1; kind <= 26; kind++) {
Arrays.fill(cnt, 0); // 每一轮开始, 重置 cnt 数组
int totalKind = 0, matchKind = 0; // 出现过的字符种类数目, 重复超过k个字符的种类数目
for (int r = 0, l = 0; r < s.length(); r++) {
cnt[s.charAt(r) - 'a']++; // 计数++
if (cnt[s.charAt(r) - 'a'] == 1) totalKind++; // 第一次出现该字符, 种类+1
if (cnt[s.charAt(r) - 'a'] == k) matchKind++; // 达到k次重复, 种类+1
while (l < r && totalKind > kind) {
if (cnt[s.charAt(l) - 'a'] == 1) totalKind--;
if (cnt[s.charAt(l) - 'a'] == k) matchKind--;
cnt[s.charAt(l) - 'a']--;
l++;
}
if (totalKind == matchKind) ans = Math.max(ans, r - l + 1);
}
}
return ans;
}
}

递归解法

对于字符串中,出现次数小于k的字符,其一定不会被包含在任意一个满足条件的子串中。所以只要找出一个出现次数小于k的字符,根据这个字符将字符串进行切分,对切分后的子串,分别递归求解即可。

class Solution {
public int longestSubstring(String s, int k) {
return get(s, 0, s.length() - 1, k);
}

// 返回[l, r]区间内, 至少有K个重复字符
private int get(String s, int l, int r, int k) {
if (r - l + 1 < k) return 0;
int[] cnt = new int[26];
for (int i = l; i <= r; i++) {
cnt[s.charAt(i) - 'a']++;
}
char c = 0; // 尝试找到一个出现次数小于k的字符
for (int i = 0; i < 26; i++) {
if (cnt[i] > 0 && cnt[i] < k) {
// 出现过, 且重复次数小于k
c = (char) (i + 'a');
break;
}
}
if (c == 0) return r - l + 1; // 整个区间的所有字符的出现次数都大于等于k

// 进行递归求解
int i = l;
int ans = 0;
while (i <= r) {
while (i <= r && s.charAt(i) == c) i++;
int iBegin = i;
while (i <= r && s.charAt(i) != c) i++;
int iEnd = i - 1;
ans = Math.max(ans, get(s, iBegin, iEnd, k));
}

return ans;
}
}

424. 替换K个字符后的最长子串

给你一个字符串 s 和一个整数 k 。你可以选择字符串中的任一字符,并将其更改为任何其他大写英文字符。该操作最多可执行 k 次。

在执行上述操作后,返回包含相同字母的最长子字符串的长度。

​​参考题解​​

先想暴力解法:枚举所有可能的子串(),判断每个子串是否能替换成全部相同的字母(),总时间复杂度 。

  • 若子串中出现了至少两种字符,要想替换后字母全部相同,且以最小的代价替换(每次尝试花最小的代价,则能使得到的子串更长),则应该替换出现次数最多的字符以外的全部字符
  • 若已经找到一个长度为L且替换K个字符后,仍然不满足条件的子串,则左端点相同,长度更长的子串,一定不满足要求
    (可证明:假设这个长度为L的子串中,出现次数最多的字符为x,出现的次数为n,则剩余的字符数量为m,n + m = L,并且容易得知,出现次数第二多的字符y的数量,一定小于n。由于无法满足条件,说明 k < m,无法完全替换掉剩余字符。假设往右继续延申,填入的字符恰好是之前出现次数最多的x,则仍然无法全部替换;若填入的字符不是x,假设是字符y,则待替换的字符数,仍然超过k)

我自己的解法

由于替换操作后,整个子串只包含一个字母,而字符串只由大写字母组成,所以枚举可能作为结果的全部26个字母,依次做滑动窗口。

class Solution {
public int characterReplacement(String s, int k) {
int ans = 0;
for (int i = 0; i < 26; i++) {
char c = (char) (i + 'A'); // 选择这个字母作为重复字母
int cnt = k;
for (int r = 0, l = 0; r < s.length(); r++) {
if (s.charAt(r) != c) {
// 需要替换
while (l <= r && cnt <= 0) {
// 可用次数不够了, 则左端点右移
if (s.charAt(l) != c) cnt++; // 移除一个不是c的, 则可用次数+1
l++;
}
cnt--; // 替换当前字符, 可用次数-1
}
// 当k=0时, l可能在r的后面一位, 此时滑动窗口的长度为0
ans = Math.max(ans, r - l + 1);
}
}
return ans;
}
}

一次遍历滑窗

其实可以直接使用滑动窗口,一次遍历求解出答案。不过这个滑动窗口的过程和普通的不太一样。

根据前面的分析,我们知道,左端点固定,长度为L的子串若不满足条件,则更长的子串一定不满足条件。且我们每次对一个区间内的字符进行替换,总是先找到这个区间内,出现次数最多的字符,然后尝试对剩余的字符进行替换。

由于我们要找最长的长度。所以我们在找的过程中,更新答案时,长度是只增不减的。

当我们在某个区间内,找到一个出现次数最多的字符,假设其出现次数为maxCnt,并且能成功将剩余部分进行替换,则我们尝试将右端点继续往右延申,直到全部的k次机会都被使用掉。当不满足条件时,我们将右端点往右移一次,左端点也往右移一次,保持左右端点的相对位置不变(最长的长度由左右两个端点维护)。由于我们要找更长的子串,则更长的子串只可能出现在,找到另一个出现次数大于maxCnt的字符为止。

所以我们只需要维护滑动窗口内每个字符的出现次数,并每次尝试用更大的出现次数,去更新maxCnt。可见,当前窗口内出现次数最大的字符的出现次数maxCnt,可以不被正确维护(maxCnt只需要不断变大,才能找到比当前长度更长的子串)。只需要正确维护窗口内每个字符的出现次数即可。

class Solution {
public int characterReplacement(String s, int k) {
int l = 0, r = 0, maxCnt = 0;
int[] cnt = new int[26];
while (r < s.length()) {
cnt[s.charAt(r) - 'A']++;
maxCnt = Math.max(maxCnt, cnt[s.charAt(r) - 'A']);
if (r - l + 1 > maxCnt + k) {
cnt[s.charAt(l) - 'A']--;
l++;
}
r++;
}
return r - l;
}
}

1004.最大连续1的个数

给定一个二进制数组 nums 和一个整数 k,如果可以翻转最多 k 个 0 ,则返回 数组中连续 1 的最大个数 。

可以用和424相同的思路来做(用窗口的左右端点,保留住历史出现过的最大值,然后对1进行计数):

class Solution {
public int longestOnes(int[] nums, int k) {
int l = 0, r = 0, cnt = 0;
while (r < nums.length) {
if (nums[r] == 1) cnt++; // 1的个数+1
if (r - l + 1 > cnt + k) {
if (nums[l] == 1) cnt--;
l++;
}
r++;
}
return r - l;
}
}

当然,这道题也可以用标准的滑动窗口来做

class Solution {
public int longestOnes(int[] nums, int k) {
int ans = 0, remain = k;
for (int r = 0, l = 0; r < nums.length; r++) {
// 当前准备纳入的右端点是0, 但可用的翻转次数又不够
while (nums[r] == 0 && l <= r && remain <= 0) {
if (nums[l] == 0) remain++; // 准备移除的左端点是0, 可用次数+1
l++;
}
if (nums[r] == 0) remain--;
ans = Math.max(ans, r - l + 1);
}
return ans;
}
}

1208.尽可能使字符串相等

给你两个长度相等的字符串s和t,将s中某个字符变化为t中的某个字符,代价是​​s[i] - t[i]​​ 的绝对值。在最多花费maxCost的代价下,求能转换的最大子串的长度。

同样的,可以用424的思路来做,左右两个端点hold住历史最大值。

class Solution {
public int equalSubstring(String s, String t, int maxCost) {
int n = s.length();
int l = 0, r = 0;
while (r < n) {
char sc = s.charAt(r);
char tc = t.charAt(r);
maxCost -= Math.abs(sc - tc);
if (maxCost < 0) {
sc = s.charAt(l);
tc = t.charAt(l);
maxCost += Math.abs(sc - tc);
l++;
}
r++;
}
return r - l;
}
}

也可以用标准的滑动窗口

class Solution {
public int equalSubstring(String s, String t, int maxCost) {
int ans = 0;
for (int r = 0, l = 0; r < s.length(); r++) {
char sc = s.charAt(r);
char tc = t.charAt(r);
maxCost -= Math.abs(sc - tc); // 纳入
while (l <= r && maxCost < 0) {
// 代价已超
sc = s.charAt(l);
tc = t.charAt(l);
maxCost += Math.abs(sc - tc);
l++;
}
ans = Math.max(ans, r - l + 1);
}
return ans;
}
}

1493.删掉一个元素后全为1的最长数组

一个只包含0和1的数组 nums ,你需要从中删掉一个元素。

请你在删掉元素的结果数组中,返回最长的且只包含 1 的非空子数组的长度。

与前面题目是一样的,只是这里的k次操作变成了1次,即只能删除一次。用424的思路解:

class Solution {
public int longestSubarray(int[] nums) {
int l = 0, r = 0, c = 1; // 可用次数1
while (r < nums.length) {
if (nums[r] == 0) c--;
if (c < 0) {
// 当可用次数小于0, 用超了, 左端点右移一位
if (nums[l] == 0) c++;
l++;
}
r++;
}
return r - l - 1; // 因为移除了中间的一个0, 所以长度要再减去1
}
}

用标准的滑动窗口解:

class Solution {
public int longestSubarray(int[] nums) {
int ans = 0, c = 1;
for (int r = 0, l = 0; r < nums.length; r++) {
while (nums[r] == 0 && l <= r && c <= 0) {
//准备纳入当前的r, 但是需要移除, 但可用次数又为0
if (nums[l] == 0) c++;
l++;
}
if (nums[r] == 0) c--; // 纳入当前右端点, 若其为0, 可用次数-1
ans = Math.max(ans, r - l); // 更新答案, 注意长度要少1
}
return ans;
}
}

2024.考试的最大困扰度

一场考试,每道题的答案为T或者F。为了增加学生的困扰,在不超过k次操作下,最大化连续的T或者F。每次操作可以将T变成F或者F变成T。

又是这种在k次操作内要怎么怎么样的题,与前面有些微不同,这里需要记录T的次数和F的次数。还是用和424相同的思路来解:

class Solution {
public int maxConsecutiveAnswers(String answerKey, int k) {
int l = 0, r = 0, cntT = 0, cntF = 0;
while (r < answerKey.length()) {
// 计数
if (answerKey.charAt(r) == 'T') cntT++;
else cntF++;
// 选出次数最多的, 并替换掉另一个
int maxCnt = Math.max(cntT, cntF);
if (r - l + 1 > maxCnt + k) {
// 不够替换, 左端点右移
if (answerKey.charAt(l) == 'T') cntT--;
else cntF--;
l++;
}
r++;
}
return r - l;
}
}

也可以用标准的滑动窗口(对T和F分别做两次滑窗):

class Solution {
public int maxConsecutiveAnswers(String answerKey, int k) {
int ans = 0, remain = k; // 剩余可用次数

// 先对T, 进行滑窗, 全改成T
for (int r = 0, l = 0; r < answerKey.length(); r++) {
while (answerKey.charAt(r) == 'F' && l <= r && remain <= 0) {
if (answerKey.charAt(l) == 'F') remain++;
l++;
}
if (answerKey.charAt(r) == 'F') remain--;
ans = Math.max(ans, r - l + 1);
}

// 对F进行滑窗
remain = k; // 重置
for (int r = 0, l = 0; r < answerKey.length(); r++) {
while (answerKey.charAt(r) == 'T' && l <= r && remain <= 0) {
if (answerKey.charAt(l) == 'T') remain++;
l++;
}
if (answerKey.charAt(r) == 'T') remain--;
ans = Math.max(ans, r - l + 1);
}
return ans;
}
}

小结

根据前面这一系列的题,我们可以进行一个简单的总结归纳。

针对滑动窗口类型的题,我们可以有2种思路:

  • 标准的滑动窗口做法
  • 变形的滑动窗口做法

标准做法,即,右端点​​r​​​从左到右依次移动,移动的过程中,若条件不满足了,​​r​​​先停下,使用​​while​​​条件将左端点​​l​​​往右移,直到条件满足,再继续移动​​r​​​,在这个过程中,记录并更新答案​​ans​​,答案往往是个最值。

变形做法,主要是从424这道题衍生出来。这类题目有个特点,往往是能够对数组做某种操作,且这种操作的次数不超过k次,问在不超过k次的操作下,能够得到的某个最大值。我们使用滑窗,在滑窗的过程中,我们是总是想要找到一个更大的答案,所以答案的更新过程,一定是递增的。所以在滑窗的过程中,我们用左右端点​​l​​​和​​r​​,来维护历史出现过的最值。

这种做法有个特点,​​r​​​仍然是从左往右移动,但是,当条件不满足时,我们只使用​​if​​​,而不是​​while​​​,只将​​l​​​右移一位。这样就能保证。若在某个位置的​​l​​​和​​r​​​得到了一个满足条件的答案时,并且在尝试将​​r​​​往右移动(尝试往右延申这个区间时),发现不满足了,则此时只将​​l​​​右移一次即可。而由于​​r​​​每次循环也会右移一次。这样就能保持​​l​​​和​​r​​​的相对位置不变,即能够通过​​l​​​和​​r​​​两个端点,hold住这个历史出现过的最大值,直到窗口内出现了下一个能够使答案更大的元素,此时​​l​​​和​​r​​​的相对位置才会出现变化,表现为​​r​​​相对​​l​​,真正往右移动了(答案变得更大)。



举报

相关推荐

0 条评论