LeetCode算法小抄--滑动窗口算法
LeetCode算法小抄--滑动窗口算法
- 滑动窗口算法
- 思路
- 框架
- [76. 最小覆盖子串](https://leetcode.cn/problems/minimum-window-substring/)[hard]
- [567. 字符串的排列](https://leetcode.cn/problems/permutation-in-string/)
- [438. 找到字符串中所有字母异位词](https://leetcode.cn/problems/find-all-anagrams-in-a-string/)
- [3. 无重复字符的最长子串](https://leetcode.cn/problems/longest-substring-without-repeating-characters/)
⚠申明: 未经许可,禁止以任何形式转载,若要引用,请标注链接地址。 全文共计6244字,阅读大概需要3分钟
🌈更多学习内容, 欢迎👏关注👀文末我的个人微信公众号:不懂开发的程序猿
个人网站:https://jerry-jy.co/
滑动窗口算法
思路
1、我们在字符串 S
中使用双指针中的左右指针技巧,初始化 left = right = 0
,把索引左闭右开区间 [left, right)
称为一个「窗口」。
理论上你可以设计两端都开或者两端都闭的区间,但设计为左闭右开区间是最方便处理的。因为这样初始化 left = right = 0 时区间 [0, 0) 中没有元素,但只要让 right 向右移动(扩大)一位,区间 [0, 1) 就包含一个元素 0 了。如果你设置为两端都开的区间,那么让 right 向右移动一位后开区间 (0, 1) 仍然没有元素;如果你设置为两端都闭的区间,那么初始区间 [0, 0] 就包含了一个元素。这两种情况都会给边界处理带来不必要的麻烦。
2、我们先不断地增加 right
指针扩大窗口 [left, right)
,直到窗口中的字符串符合要求(包含了 T
中的所有字符)。
3、此时,我们停止增加 right
,转而不断增加 left
指针缩小窗口 [left, right)
,直到窗口中的字符串不再符合要求(不包含 T
中的所有字符了)。同时,每次增加 left
,我们都要更新一轮结果。
4、重复第 2 和第 3 步,直到 right
到达字符串 S
的尽头。
这个思路其实也不难,第 2 步相当于在寻找一个「可行解」,然后第 3 步在优化这个「可行解」,最终找到最优解
框架
/* 滑动窗口算法框架 */
void slidingWindow(string s) {
// 用合适的数据结构记录窗口中的数据
unordered_map<char, int> window, need;
int left = 0, right = 0;
while (right < s.size()) {
// c 是将移入窗口的字符
char c = s[right];
// 增大窗口
right++;
// 进行窗口内数据的一系列更新
...
/*** debug 输出的位置 ***/
// 注意在最终的解法代码中不要 print
// 因为 IO 操作很耗时,可能导致超时
printf("window: [%d, %d)\n", left, right);
/********************/
// 判断左侧窗口是否要收缩
while (window needs shrink) {
// d 是将移出窗口的字符
char d = s[left];
// 缩小窗口
left++;
// 进行窗口内数据的一系列更新
...
}
}
}
Java 处理字符串不方便,所以代码为 C++ 实现。
unordered_map
就是哈希表(字典),相当于 Java 的 HashMap
,它的一个方法 count(key)
相当于 Java 的 containsKey(key)
可以判断键 key 是否存在。
可以使用方括号访问键对应的值 map[key]
。需要注意的是,如果该 key
不存在,C++ 会自动创建这个 key,并把 map[key]
赋值为 0。所以代码中多次出现的 map[key]++
相当于 Java 的 map.put(key, map.getOrDefault(key, 0) + 1)
。
另外,Java 中的 Integer 和 String 这种包装类不能直接用 ==
进行相等判断,而应该使用类的 equals
方法。
需要思考以下几个问题:
1、什么时候应该移动 right
扩大窗口?窗口加入字符时,应该更新哪些数据?
2、什么时候窗口应该暂停扩大,开始移动 left
缩小窗口?从窗口移出字符时,应该更新哪些数据?
3、我们要的结果应该在扩大窗口时还是缩小窗口时进行更新?
如果一个字符进入窗口,应该增加 window
计数器;如果一个字符将移出窗口的时候,应该减少 window
计数器;当 valid
满足 need
时应该收缩窗口;应该在收缩窗口的时候更新最终结果。
其中 valid
变量表示窗口中满足 need
条件的字符个数,如果 valid
和 need.size
的大小相同,则说明窗口已满足条件,已经完全覆盖了串 T
。
76. 最小覆盖子串[hard]
给你一个字符串 s
、一个字符串 t
。返回 s
中涵盖 t
所有字符的最小子串。如果 s
中不存在涵盖 t
所有字符的子串,则返回空字符串 ""
。
/**
* 求字符串 s 中包含字符串 t 所有字符的最小子串
* @param s 源字符串
* @param t 给定字符串
* @return 满足条件的最小子串
*/
class Solution {
public String minWindow(String s, String t) {
// 用于记录需要的字符和窗口中的字符及其出现的次数
Map<Character, Integer> need = new HashMap<>();
Map<Character, Integer> window = new HashMap<>();
// 统计 t 中各字符出现次数
for (char c : t.toCharArray())
need.put(c, need.getOrDefault(c, 0) + 1);
int left = 0, right = 0;
int valid = 0; // 窗口中满足需要的字符个数
// 记录最小覆盖子串的起始索引及长度
int start = 0, len = Integer.MAX_VALUE;
while (right < s.length()) {
// c 是将移入窗口的字符
char c = s.charAt(right);
// 扩大窗口
right++;
// 进行窗口内数据的一系列更新
if (need.containsKey(c)) {
window.put(c, window.getOrDefault(c, 0) + 1);
if (window.get(c).equals(need.get(c)))
valid++; // 只有当 window[c] 和 need[c] 对应的出现次数一致时,才能满足条件,valid 才能 +1
}
// 判断左侧窗口是否要收缩
while (valid == need.size()) {
// 更新最小覆盖子串
if (right - left < len) {
start = left;
len = right - left;
}
// d 是将移出窗口的字符
char d = s.charAt(left);
// 缩小窗口
left++;
// 进行窗口内数据的一系列更新
if (need.containsKey(d)) {
if (window.get(d).equals(need.get(d)))
valid--; // 只有当 window[d] 内的出现次数和 need[d] 相等时,才能 -1
window.put(d, window.get(d) - 1);
}
}
}
// 返回最小覆盖子串
return len == Integer.MAX_VALUE ? "" : s.substring(start, start + len);
}
}
使用 Java 的读者要尤其警惕语言特性的陷阱。Java 的 Integer,String 等类型判定相等应该用
equals
方法而不能直接用等号==
,这是 Java 包装类的一个隐晦细节。所以在缩小窗口更新数据的时候,不能直接改写为window.get(d) == need.get(d)
,而要用window.get(d).equals(need.get(d))
,之后的题目代码同理。
567. 字符串的排列
给你两个字符串 s1
和 s2
,写一个函数来判断 s2
是否包含 s1
的排列。如果是,返回 true
;否则,返回 false
。
换句话说,s1
的排列之一是 s2
的 子串 。
思路:这种题目,是明显的滑动窗口算法,相当给你一个 S
和一个 T
,请问你 S
中是否存在一个子串,包含 T
中所有字符且不包含其他字符?
class Solution {
public boolean checkInclusion(String s1, String s2) {
// 用于记录需要的字符和窗口中的字符及其出现的次数
Map<Character, Integer> need = new HashMap<>();
Map<Character, Integer> window = new HashMap<>();
// 统计 s1 中各字符出现次数
for(int i = 0; i < s1.length(); i++){
char c = s1.charAt(i);
need.put(c, need.getOrDefault(c, 0) + 1);
}
int left = 0, right = 0;
int valid = 0; // 窗口中满足需要的字符个数
// 记录最小覆盖子串的起始索引及长度
//int start = 0, len = Integer.MAX_VALUE;
while (right < s2.length()) {
// c 是将移入窗口的字符
char c = s2.charAt(right);
// 扩大窗口
right++;
// 进行窗口内数据的一系列更新
if (need.containsKey(c)) {
window.put(c, window.getOrDefault(c, 0) + 1);
if (window.get(c).equals(need.get(c)))
valid++; // 只有当 window[c] 和 need[c] 对应的出现次数一致时,才能满足条件,valid 才能 +1
}
// 判断左侧窗口是否要收缩
while (right - left >= s1.length()) {
// 在这里判断是否找到了合法的子串,窗口中有一个合法的排列,就立即返回
if (valid == need.size()) {
return true;
}
// d 是将移出窗口的字符
char d = s2.charAt(left);
// 缩小窗口
left++;
// 进行窗口内数据的一系列更新
if (need.containsKey(d)) {
if (window.get(d).equals(need.get(d)))
valid--; // 只有当 window[d] 内的出现次数和 need[d] 相等时,才能 -1
window.put(d, window.get(d) - 1);
}
}
}
// 未找到符合条件的子串
return false;
}
}
注意哦,输入的 s1
是可以包含重复字符的,所以这个题难度不小
总结:对于这道题的解法代码,基本上和最小覆盖子串一模一样,只需要改变几个地方:
1、本题移动 left
缩小窗口的时机是窗口大小大于 t.size()
时,因为排列嘛,显然长度应该是一样的。
2、当发现 valid == need.size()
时,就说明窗口中就是一个合法的排列,所以立即返回 true
。
至于如何处理窗口的扩大和缩小,和最小覆盖子串完全相同。
由于这道题中
[left, right)
其实维护的是一个定长的窗口,窗口大小为t.size()
。因为定长窗口每次向前滑动时只会移出一个字符,所以可以把内层的 while 改成 if,效果是一样的。
438. 找到字符串中所有字母异位词
给定两个字符串 s
和 p
,找到 s
中所有 p
的 异位词 的子串,返回这些子串的起始索引。不考虑答案输出的顺序。
异位词 指由相同字母重排列形成的字符串(包括相同的字符串)。
class Solution {
public List<Integer> findAnagrams(String s, String p) {
// 用于记录需要的字符和窗口中的字符及其出现的次数
Map<Character, Integer> need = new HashMap<>();
Map<Character, Integer> window = new HashMap<>();
// 统计 p 中各字符出现次数
for(int i = 0; i < p.length(); i++){
char c = p.charAt(i);
need.put(c, need.getOrDefault(c, 0) + 1);
}
int left = 0, right = 0;
int valid = 0; // 窗口中满足需要的字符个数
// 记录保存符合条件索引位置
List<Integer> res = new ArrayList<>();
while (right < s.length()) {
// c 是将移入窗口的字符
char c = s.charAt(right);
// 扩大窗口
right++;
// 进行窗口内数据的一系列更新
if (need.containsKey(c)) {
window.put(c, window.getOrDefault(c, 0) + 1);
if (window.get(c).equals(need.get(c)))
valid++; // 只有当 window[c] 和 need[c] 对应的出现次数一致时,才能满足条件,valid 才能 +1
}
// 判断左侧窗口是否要收缩
while (right - left >= p.length()) {
// 在这里判断是否找到了合法的子串,窗口中有一个合法的排列,就立即将索引加入list中
if (valid == need.size()) {
res.add(left);
}
// d 是将移出窗口的字符
char d = s.charAt(left);
// 缩小窗口
left++;
// 进行窗口内数据的一系列更新
if (need.containsKey(d)) {
if (window.get(d).equals(need.get(d)))
valid--; // 只有当 window[d] 内的出现次数和 need[d] 相等时,才能 -1
window.put(d, window.get(d) - 1);
}
}
}
// 未找到符合条件的子串
return res;
}
}
跟寻找字符串的排列一样,只是找到一个合法异位词(排列)之后将起始索引加入 res
即可。
3. 无重复字符的最长子串
给定一个字符串 s
,请你找出其中不含有重复字符的 最长子串 的长度。
class Solution {
public int lengthOfLongestSubstring(String s) {
Map<Character, Integer> window = new HashMap<>();
int left = 0, right = 0;
// 记录结果
int res = 0;
while (right < s.length()) {
// c 是将移入窗口的字符
char c = s.charAt(right);
// 扩大窗口
right++;
// 进行窗口内数据的一系列更新
window.put(c, window.getOrDefault(c, 0) + 1);
// 判断左侧窗口是否要收缩
while (window.get(c) > 1) {
// d 是将移出窗口的字符
char d = s.charAt(left);
// 缩小窗口
left++;
// 进行窗口内数据的一系列更新
window.put(d, window.get(d) - 1);
}
// 在这里更新答案
res = Math.max(res, right - left);
}
// 未找到符合条件的子串
return res;
}
}
–end–