滑动窗口算法
滑动窗口算法
滑动窗口算法可以看成是双指针的一类应用,常用于字符匹配。
典型应用如下:
序号 | 题目 |
---|---|
1 | 76. 最小覆盖子串 |
2 | 567. 字符串的排列 |
3 | 438. 找到字符串中所有字母异位词 |
4 | 395. 至少有 K 个重复字符的最长子串 |
5 | 424. 替换后的最长重复字符 |
6 | 1234. 替换子串得到平衡字符串 |
思路
用两个指针 \(left\) ,\(right\) 分别作为窗口的左右边界,同时,需要用到两个\(hash\)表:
-
\(windows\) 用于记录窗口中每个字符的个数;
-
\(need\) 用于记录目标字符串中的每个字符个数。
同时,我们用 \(valid\) 记录窗口中的有效字符长度,当 \(valid\) 的值等于目标字符串的长度时,说明窗口中的内容是满足条件的。
代码模板
这里我们直接给出代码模板:
from collections import defaultdict
def minWindow(self, string: str, target: str):
need = defaultdict(int)
window = defaultdict(int)
for _char in target:
need[_char] += 1
# 用双指针遍历源字符串
left, right = 0, 0
# 有效长度:记录窗口中目标字符的数量
valid = 0
# 记录窗口的右侧位置
start = 0
n = len(string) + 1
# 右指针遍历字符串的每一个位置:作为窗口右侧结束位置
while right < len(string):
# right_char 是移入窗口的字符
right_char = string[right]
# 窗口左指针右移:扩大窗口
right += 1
# 对窗口内的数据进行更新
""" ... """
# 当前窗口的位置
print("window[%s:%s]" % (left, right))
# 判断窗口是否需要收缩
while valid == len(need):
# left_char是即将移出窗口的字符
left_char = string[left]
# 窗口左指针右移:缩小窗口
left += 1
# 对窗口内的数据进行更新
""" ... """
return
应用
应用1:Leetcode 76. 最小覆盖子串
题目
分析
我们使用两个指针 \(left\) ,\(right\) 分别作为窗口的左右边界,同时,需要用到两个\(hash\)表:
-
\(windows\) :用于记录窗口中每个字符的个数;
-
\(need\) :用于记录目标字符串中的每个字符个数。
同时,使用三个变量:
-
用 \(category\) 记录窗口中满足条件的目标字符的种类;
-
如果某个字符移入窗口内时,窗口中该字符的个数等于目标字符串中该字符的数量相等,就将其数量加一;
-
如果某个字符移出窗口外时,窗口中该字符的个数等于目标字符串中该字符的数量相等,就将其数量减一;
-
-
用 \(start\) 记录的左侧起始位置。
-
用 \(windowLength\) 记录窗口的最小长度;
思路
遍历源字符串,利用双指针,记录窗口的位置,当窗口中每个目标字符的个数与目标字符串中对应字符的个数都相等时,就停止移动右侧指针,开始缩小左侧指针。
缩小左侧指针时,不断更新窗口长度,并更新窗口中每个目标字符的个数,当窗口中目标字符的个数与目标串中的对应字符的个数相等时,就减小有效长度,缩小窗口。
注意:
这里,为了避免边界条件的讨论,我们将双指针的查找区间设置为[left, right)
,即左闭右开的查找区间。
那么,初始化查找区间就是[0, 0)
,最后一个查找区间就是[left, n)
。
这种情况下初始查找区间[0, 0)
,窗口长度为零,只要右侧指针移动一步,窗口中就包含一个元素,同时,窗口的长度就是length = right - left
,比较方便。
代码实现
class Solution {
public String minWindow(String s, String t) {
Map<Character, Integer> need = new HashMap<>();
Map<Character, Integer> window = new HashMap<>();
for (int i = 0; i < t.length(); i++) {
char c = t.charAt(i);
need.put(c, need.getOrDefault(c, 0) + 1);
}
int left = 0, right = 0;
int category = 0; // 记录窗口中符合条件的目标字符的种类
int start = 0;
int windowLength = s.length() + 1;
while (right < s.length()) {
char rightChar = s.charAt(right);
right++;
// 如果当前字符是一个目标字符
if (need.containsKey(rightChar)) {
// 窗口中的对应的字符个数加
window.put(rightChar, window.getOrDefault(rightChar, 0) + 1);
// 如果窗口中该字符的数量等于目标字符串中的字符数量,就将符合条件的字符种类加一
if (need.getOrDefault(rightChar, 0).equals(window.get(rightChar))) {
category++;
}
}
// 只要符合条件的字符等于目标字符的种类,就要不断缩小窗口
while (category == need.size()) {
// 找到一个更小的窗口,就记录起始位置、窗口长度
if (right - left < windowLength) {
start = left;
windowLength = right - left;
}
char leftChar = s.charAt(left);
left++;
// 如果即将移除窗口的字符是一个目标字符
if (need.containsKey(leftChar)) {
// 移除前如果窗口中该字符的个数与目标字符个数相等,就将种类减一
if (window.get(leftChar).equals(need.get(leftChar))) {
category--;
}
// 更新窗口中对应的字符的个数
window.put(leftChar, window.getOrDefault(leftChar, 0) - 1);
}
}
}
// 如果窗口的长度大于 s 的长度,说明没有找到匹配的子串,否则就返回最小子串
return windowLength == s.length() + 1 ? "" : s.substring(start, start + windowLength);
}
}
应用2:Leetcode 567. 字符串的排列
题目
给你两个字符串 s1 和 s2 ,写一个函数来判断 s2 是否包含 s1 的排列。如果是,返回 true ;否则,返回 false 。
换句话说,s1 的排列之一是 s2 的 子串 。
示例 1:
输入:s1 = "ab" s2 = "eidbaooo"
输出:true
解释:s2 包含 s1 的排列之一 ("ba").
示例 2:
输入:s1= "ab" s2 = "eidboaoo"
输出:false
提示:
1 <= s1.length, s2.length <= 104
s1 和 s2 仅包含小写字母s1 和 s2 仅包含小写字母
分析
注意,子串一定是连续的字符串,这里直接使用滑动窗口的模板即可。
代码实现
class Solution {
public boolean checkInclusion(String s1, String s2) {
Map<Character, Integer> need = new HashMap<>();
Map<Character, Integer> window = new HashMap<>();
for (int i = 0; i < s1.length(); i++) {
need.put(s1.charAt(i), need.getOrDefault(s1.charAt(i), 0) + 1);
}
int left = 0, right = 0;
boolean valid = false;
while (right < s2.length()) {
Character tail = s2.charAt(right);
if (need.containsKey(tail)) {
window.put(tail, window.getOrDefault(tail, 0) + 1);
}
if (need.equals(window)) {
valid = true;
break;
}
// 窗口长度固定,只要窗口长度满足目标值,左指针就要跟随右指针移动,所以用 if
if (right >= s1.length() - 1) {
Character head = s2.charAt(left);
if (need.containsKey(head)) {
window.put(head, window.get(head) - 1);
if (window.get(head).equals(0)) {
window.remove(head);
}
}
left++;
}
right++;
}
return valid;
}
}
应用3:Leetcode 438. 找到字符串中所有字母异位词
题目
给定两个字符串 s 和 p,找到 s 中所有 p 的 异位词 的子串,返回这些子串的起始索引。不考虑答案输出的顺序。
异位词 指由相同字母重排列形成的字符串(包括相同的字符串)。
示例 1:
输入: s = "cbaebabacd", p = "abc"
输出: [0,6]
解释:
起始索引等于 0 的子串是 "cba", 它是 "abc" 的异位词。
起始索引等于 6 的子串是 "bac", 它是 "abc" 的异位词。
解题思路
使用一个哈希表 \(window\) 记录窗口中每个数字出现的次数,如果窗口的长度大于哈希表中的 \(key\) 的个数,则说明窗口中存在重复的数字。
代码实现
class Solution {
public List<Integer> findAnagrams(String s, String p) {
Map<Character, Integer> window = new HashMap<>();
Map<Character, Integer> need = new HashMap<>();
for (int i = 0; i < p.length(); i++) {
need.put(p.charAt(i), need.getOrDefault(p.charAt(i), 0) + 1);
}
int left = 0, right = 0;
List<Integer> result = new ArrayList<>();
while (right < s.length()) {
Character tail = s.charAt(right);
window.put(tail, window.getOrDefault(tail, 0) + 1);
if (need.equals(window)) {
result.add(left);
}
// 窗口中子串长度等于目标字符串时,开始移动左指针
if (right >= p.length() - 1) {
Character head = s.charAt(left);
window.put(head, window.get(head) - 1);
if (window.get(head).equals(0)) {
window.remove(head);
}
left++;
}
right++;
}
return result;
}
}
应用4:Leetcode 395. 至少有 K 个重复字符的最长子串
题目
给你一个字符串 s 和一个整数 k ,请你找出 s 中的最长子串, 要求该子串中的每一字符出现次数都不少于 k 。返回这一子串的长度。
示例 1:
输入:s = "aaabb", k = 3
输出:3
解释:最长子串为 "aaa" ,其中 'a' 重复了 3 次。
解题思路
方法一:双指针
这里,对于每一个子串,由于窗口长度无法固定,而字母种类是固定的,所以,我们可以直接枚举窗口内的字符种类数量:\([0,26]\),这样就可以计算每种情况下满足条件的最长子串。
为了方便记录窗口内是否有小于 \(k\) 的字符,我们需要维护一个变量 \(less\),用于记录窗口内出现次数小于 \(k\) 的字符。
方法二:分治
假设在字符串 \(s\) 中,出现次数小于 \(k\) 的字符为 \(\alpha\),那么,任何包含 \(\alpha\) 子串都不满足题目要求,需要排除掉。
因此,我们可以将字符串分割成若干个子串,满足要求的最长子串一定会出现在某个被切分的区间内。
代码实现
方法一
class Solution {
public int longestSubstring(String s, int k) {
int n = s.length();
int maxWindowSize = 0;
// 枚举窗口内的字符种类
for (int category = 1; category <= 26; category++) {
// 使用滑动窗口计算满足条件的子串
int less = 0 ; // 记录窗口内小于k的字符个数
int left = 0, right = 0;
Map<Character, Integer> window = new HashMap<>();
while (right < n) {
Character tail = s.charAt(right);
window.put(tail, window.getOrDefault(tail, 0) + 1);
if (window.get(tail).equals(1)) {
less++;
}
if (window.get(tail).equals(k)) {
less--;
}
if (less == 0) {
maxWindowSize = Math.max(maxWindowSize, right - left + 1);
}
// 窗口内的字符种类大于category说明需要移动左指针
while (left < right && window.size() > category) {
Character head = s.charAt(left);
window.put(head, window.getOrDefault(head, 0) - 1);
if (window.get(head).equals(k - 1)) {
less++;
}
if (window.get(head).equals(0)) {
less--;
window.remove(head);
}
left++;
}
right++;
}
}
return maxWindowSize;
}
}
方法二
class Solution:
def longestSubstring(self, s: str, k: int) -> int:
n = len(s)
return self.dfs(s, 0, n - 1, k)
def dfs(self, s: str, left: int, right: int, k: int):
count = [0] * 26
# 统计子串s[left...right]中每个字符出现的次数
for i in range(left, right + 1):
count[ord(s[i]) - ord("a")] += 1
split = 0
# 找到子串中某个字符出现的次数小于k的字符
for i in range(26):
if 0 < count[i] < k:
split = i + ord("a")
break
if split == 0:
return right - left + 1
i = left
result = 0
while i <= right:
# 如果子串的左边界是出现的次数小于k的字符,则缩小左侧区间
while i <= right and s[i] == chr(split):
i += 1
# 如果左侧边界溢出,则退出
if i > right:
break
# 记录新的左侧边界
start = i
# 确保当前子串中没有出现的次数小于k的字符
while i <= right and s[i] != chr(split):
i += 1
# 继续查找
length = self.dfs(s, start, i - 1, k)
result = max(result, length)
return result
应用5:Leetcode 424. 替换后的最长重复字符
题目
给你一个字符串 s 和一个整数 k 。你可以选择字符串中的任一字符,并将其更改为任何其他大写英文字符。该操作最多可执行 k 次。
在执行上述操作后,返回包含相同字母的最长子字符串的长度。
示例 1:
输入:s = "ABAB", k = 2
输出:4
解释:用两个'A'替换为两个'B',反之亦然。
解题思路
维护两个指针 \(left\) 和 \(right\),同时,一个长度为 \(26\) 的数组 \(frequent\),用于记录窗口 \(s[left \cdots right]\) 中每个字符出现的次数。
我们使用 \(maxCount\) 记录窗口中出现次数最多的字符的次数。
滑动窗口:
-
右指针移动过程中,记录每个字符出现的次数,并更新最大值 \(maxCount\)。
-
只要区间长度大于 \(maxCount + k\),即区间里面的元素太多,就需要缩小区间,移动左侧指针 \(left\),同时,移出区间的元素对应的出现次数减 1;
-
更新最大的区间长度;
-
右指针向右移动;
代码实现
class Solution:
def characterReplacement(self, s: str, k: int) -> int:
n = len(s)
if n < 2:
return n
left, right = 0, 0
result = 0
max_count = 0
frequent = [0] * 26
while right < n:
order = ord(s[right]) - ord("A")
frequent[order] += 1
max_count = max(max_count, frequent[order])
while right - left + 1 > max_count + k:
order = ord(s[left]) - ord("A")
frequent[order] -= 1
left += 1
result = max(result, right - left + 1)
right += 1
return result
【实现2】
class Solution:
def characterReplacement(self, s: str, k: int) -> int:
window = defaultdict(int)
left, right = 0, 0
result = 0
while right < len(s):
right_char = s[right]
window[right_char] += 1
# 窗口中出现次数最多的字符的个数
max_count = max(window.values())
while right - left + 1 > max_count + k:
left_char = s[left]
window[left_char] -= 1
left += 1
result = max(result, right - left + 1)
right += 1
return result
扩展
与本题类似的题目有:
应用6:1234. 替换子串得到平衡字符串
题目
有一个只含有
'Q', 'W', 'E', 'R'
四种字符,且长度为n
的字符串。假如在该字符串中,这四个字符都恰好出现n/4
次,那么它就是一个「平衡字符串」。
给你一个这样的字符串s
,请通过「替换一个子串」的方式,使原字符串s
变成一个「平衡字符串」。你可以用和「待替换子串」长度相同的 任何 其他字符串来完成替换。
请返回待替换子串的最小可能长度。如果原字符串自身就是一个平衡字符串,则返回0
。
示例 1:
输入:
s = "QQQQ"
输出:3
解释:我们可以替换后 3 个'Q'
,使s = "QWER"
。
解题思路
假设字符串 \(s\) 的长度为 \(n\),同时,令 \(average = \frac{n}{4}\)。
我们维护两个指针 \(left\)、\(right\),用于维护一个窗口,窗口中的子串表示 待替换的子串。
同时,我们使用 \(Hash\) 表 \(counter\) 来统计所有窗口之外的子序列中字符的出现次数,当窗口移动到某个位置,满足 \(counter\) 中的最大字符出现次数小于 \(average\) 即可。
算法步骤:
-
初始状态,\(counter\) 中保存 \(s\) 中所有字符的出现次数。
-
我们使用左指针 \(left\) 从零开始枚举 \(s\) 中的所有字符,只要窗口之外的子序列有字符出现次数大于 \(average\),就使当前字符的出现次数减 \(1\),并移动右指针。
此时,说明我们需要将消耗右指针指向的当前字符,直到满足所有的字符都不大于 \(average\)。
-
如果 \(right\) 移动到最右侧,都存在字符出现次数都大于 \(average\) ,则直接退出。
-
右指针移动的同时,将窗口的长度作为待替换的子串,并更新最小子串的长度。
注意,我们在枚举窗口的时候,右指针不需要回到 \(left + 1\) 的起始位置,因为,假如完成一轮枚举之后,子串 \(s[left \cdots right]\) 必然是刚好满足其余字符串时一个平衡串的条件的。
而 \(left\) 移动一个位置之后,对于除子串 \(s[left + 1 \cdots right]\) 外的字符串必然不是一个平衡串的(因为多加了一个字符进来),同时,对于 \(s[left + 1 \cdots right]\) 的子串也肯不满足条件,因此,完成一轮枚举之后,\(right\) 不需要重新回到本轮枚举 \(right\) 起始的位置, 只需要从 \(right\) 当前位置继续向后枚举即可。
代码实现
from collections import Counter
class Solution:
def balancedString(self, s: str) -> int:
n = len(s)
average = n // 4
counter = Counter(s)
if max(counter.values()) == average:
return 0
result = n
right = 0
# 枚举窗口左指针:统计窗口之外的所有字符
for left, char in enumerate(s):
# 如果窗口之外的序列,存在字符出现次数大于partial时,就使当前字符的出现次数减1
while right < n and max(counter.values()) > average:
counter[s[right]] -= 1
right += 1
# 如果right移动到最右侧,都存在字符出现次数都大于partial,则直接退出
if max(counter.values()) > average:
break
result = min(result, right - left)
counter[char] += 1
return result
参考: