5道题,教你参破滑动窗口的解法
作者:互联网
前言
所谓窗口,就是一个连续的封闭集合,一般是用left与right指针来表示,但是也会根据题意变化,比如下面这道题
187. 重复的DNA序列
所有 DNA 都由一系列缩写为 ‘A’,‘C’,‘G’ 和 ‘T’ 的核苷酸组成,例如:“ACGAATTCCG”。在研究 DNA 时,识别 DNA 中的重复序列有时会对研究非常有帮助。
编写一个函数来找出所有目标子串,目标子串的长度为 10,且在 DNA 字符串 s 中出现次数超过一次。
简单说就是字符串中找到长度为10的,多次出现过的子串
class Solution {
public List<String> findRepeatedDnaSequences(String s) {
List<String> ans = new ArrayList<>();
int n = s.length();
Map<String, Integer> map = new HashMap<>();
for (int i = 0; i + 10 <= n; i++) {
String cur = s.substring(i, i + 10);
int cnt = map.getOrDefault(cur, 0);
if (cnt == 1) ans.add(cur);
map.put(cur, cnt + 1);
}
return ans;
}
}
这里的窗口就是固定大小为10,非常简单,所以,滑动窗口的核心与难点,是滑动,是把握扩大与缩小窗口的时机今天我们就来讨论一下滑动窗口的解法
首先看套路,我们需要left与right指针去保存窗口的边界
// 选择某种记忆化集合
HashMap<Integer, Integer>map
while(right < size){
//扩大窗口
while(){
right ++;
}
//题目要求
doSth……
//缩小窗口
left = right;
}
上面架构比较简单,因为越复杂的模板,除了记起来越困难之外,可移植性也越差,
这三行注释,就是我们的三板斧,接下来,我们来看一些题目
我们关键要在意的是,扩大窗口的常规情况,与缩小窗口的特殊情况,也就是这两个时机的把握,接下来我们通过四道题来进行阐述
普通滑动窗口
3. 无重复字符的最长子串
这道题,可以说是面试频率最高的滑动窗口题之一了,不贴题干我想大家也知道啥题,既然说是无重复字符,那么只要没出现重复字符我们就可以扩大窗口,只要出现一个重复,就是缩小的时机
我们可以使用map存储,套用框架,当不重复时,不断put,right ++,当遇到重复,跳出循环,left更新
class Solution {
public int lengthOfLongestSubstring(String s) {
int left = 0;
int right = 0;
int size = s.length();
char [] chars = s.toCharArray();
HashMap<Character, Integer>map = new HashMap<>();
int maxNumber = 0;
while(right < size){
// 扩大窗口
while(right < size){
if(!map.containsKey(chars[right])){
map.put(chars[right], right);
right++;
}else break;
}
//缩小窗口,right已经重复,作为开区间,取right - left
maxNumber = Math.max(maxNumber, right - left);
map.remove(chars[left++]);
}
return maxNumber;
}
}
在上题中,map所起的作用是记忆化,map在很多题目中都很有用,核心就是因为,具有记忆化功能,所以,但凡涉及到【重复】,【计数】这类关键词,都可以用map,这道题就是典型的滑动窗口 + map
接下来这道是剑指offer中的题目,一毛一样,但是解法可以改一改,那就是交换扩大和缩小的位置,也可以更改数据结构为set,set也可以承担部分【重复】的功能解决
剑指 Offer 48. 最长不含重复字符的子字符串
class Solution {
public int lengthOfLongestSubstring(String s) {
int resMax = 0;
HashSet<Character>hashSet = new HashSet<>();
int l = 0;
for(int r = 0; r < s.length(); r++){
char c = s.charAt(r);
// 缩小窗口
while(hashSet.contains(c)){
hashSet.remove(s.charAt(l++));
}
// 扩大窗口
hashSet.add(c);
// 题目要求
resMax = Math.max(resMax, hashSet.size());
}
return resMax;
}
}
这就是我强调的,重要的是滑动,是扩大与缩小的时机把握,而map,set和后面即将出现的数组,list,排序等等,都是为了正确的滑动而需要的辅助数据结构
所以,当出现一道滑动窗口题目,我们要思考两件事,一是扩大缩小窗口的时机,二是为了第一条,我们需要怎样的数据结构去记录已经遍历的数据
再来看这道
1838. 最高频元素的频数
元素的 频数 是该元素在一个数组中出现的次数。
给你一个整数数组 nums 和一个整数 k 。在一步操作中,你可以选择 nums 的一个下标,并将该下标对应元素的值增加 1 。
执行最多 k 次操作后,返回数组中最高频元素的 最大可能频数 。
示例 1:
输入:nums = [1,2,4], k = 5
输出:3
解释:对第一个元素执行 3 次递增操作,对第二个元素执 2 次递增操作,此时 nums = [4,4,4] 。
4 是数组中最高频元素,频数是 3 。
这道题简单说就是你有k块钱,如何分配让大家共同富裕的故事
滑动窗口是个框,什么都能往里装,我们可以给数组增加排序,
为什么要排序?这题的难点在于如何理解扩大窗口的情况,实际上,只要k次操作能保证在滑动窗口范围内将所有数都增加到最大值(也就是排序后,最右边的值),就可以不断扩大窗口,直到无法保证,计算式子为
total += (nums[r] - nums[r - 1]) * (r - l);
也就是每新纳入一个值到窗口中,就要消耗掉(nums[r] - nums[r - 1]) * (r - l)次操作,因为新纳入的值一定比原来值大,所以原来窗口的所有数必须统一增加大出的部分
你可能会问,为啥原来的所有数统一增加(nums[r] - nums[r - 1]),难道原来的数一样吗??
是的,很简单,这类似于dp,从第一个最小的滑动窗口开始,我们就保证了滑动窗口内的所有数必须相等
接下里就是缩小窗口,减掉(nums[r] - nums[l])即可
排序 + 滑动窗口
class Solution {
public int maxFrequency(int[] nums, int k) {
// 排序
Arrays.sort(nums);
int res = 1;
int total = 0;
int l = 0;
for(int r = 1; r < nums.length; r++){
// 扩大窗口,记录需要的资源
total += (nums[r] - nums[r - 1]) * (r - l);
// 不够就缩小窗口
while(total > k){
total -= nums[r] - nums[l];
l ++;
}
// 题目要求:取最大值
res = Math.max(res, r - l + 1);
}
return res;
}
}
进阶滑动窗口
接下来这道会难一些,同样非常经典
239. 滑动窗口最大值
给你一个整数数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。
返回滑动窗口中的最大值。
示例 1:
输入:nums = [1,3,-1,-3,5,3,6,7], k = 3
输出:[3,3,5,5,6,7]
解释:
滑动窗口的位置 最大值
[1 3 -1] -3 5 3 6 7 3
1 [3 -1 -3] 5 3 6 7 3
1 3 [-1 -3 5] 3 6 7 5
1 3 -1 [-3 5 3] 6 7 5
1 3 -1 -3 [5 3 6] 7 6
1 3 -1 -3 5 [3 6 7] 7
这道题难,本质上还是难在缩小窗口的时机把握
之前缩小窗口,都是left指针右移,在这道题中,缩小窗口的任务应该交给窗口自己,因为我们要找的是窗口中的最大值,而其他的值不用在意,所以我们只需要将最大值放在窗口一侧,其他值可以全部去掉
所以,除了常规的缩小窗口外,还额外有一次缩小,就是当新加入的值超过的原窗口最大值,那么原窗口所有值都没有作用了,可以全部去掉
class Solution {
public int[] maxSlidingWindow(int[] nums, int k) {
if(nums == null || nums.length < 2) return nums;
// 双向队列 保存当前窗口最大值的数组位置 保证队列中数组位置的数按从大到小排序
LinkedList<Integer> list = new LinkedList();
int[] result = new int[nums.length-k+1];
for(int i=0;i<nums.length;i++){
// 额外缩小窗口 保证从大到小 如果前面数小 弹出
while(!list.isEmpty() && nums[list.peekLast()] <= nums[i]){
list.pollLast();
}
// 扩大窗口添加当前值对应的数组下标
list.addLast(i);
// 缩小窗口 等到窗口长度为k时 下次移动在删除过期数值
if(list.peek() <= i - k){
list.poll();
}
// 题目要求:窗口长度为k时 再保存当前窗口中最大值
if(i - k + 1 >= 0){
result[i - k + 1] = nums[list.peek()];
}
}
return result;
}
}
接下里的题目,更有点难度,但是我相信你已经胸中有数,即使没有做过,也知道我们把框架简单写好后,就应该讨论扩大与缩小窗口的时机
76. 最小覆盖子串
给你一个字符串
s
、一个字符串t
。返回s
中涵盖t
所有字符的最小子串。如果s
中不存在涵盖t
所有字符的子串,则返回空字符串""
。
简单说就是找到s中最短的能够包含t中所有字母的子串
扩大窗口有两种情况,一是s中的字符不是需要的,是多余的,那就正常扩大
第二种是,字符是需要的,但是还没有达到覆盖t的程度,所以需要继续扩大
对于第二种,除了扩大窗口外,显然,我们还需要记录当前到底覆盖了t的多少字符,所以需要一个have数组,以及对应的need数组
对于缩小窗口,只有一种情况,那就是达到了覆盖t的程度,可以直观的知道,只要have数组所有字符的频率都超过了need数组,就可以,但是这样略显麻烦,我们可以额外定义一个count记录满足的字符,当count == t.length()即可
当掌握了缩小的时机,接下来就是如何缩小,除了left++之外,count,have数组都要同步更新,还有为了最后输出子字符串,需要一个start标记索引,记录最小覆盖子串的位置,而left继续往前滑动
class Solution {
public String minWindow(String s, String t) {
// 特殊情况
if(s == null || s == "" || t == null || t == "" || s.length() < t.length())return "";
// 定义数组
int [] have = new int[128];
int [] need = new int[128];
for(char c : t.toCharArray()){
need[c]++;
}
// 定义窗口
int right = 0, left = 0;
int min = s.length() + 1;
int count = 0; // 符合的字符总数
int start = 0; // 最小字符串的起始位置
char [] chars = s.toCharArray();
while(right < s.length()){
char c = chars[right];
// 扩大窗口 两种情况
if(need[c] == 0){ // 不需要的情况
right++;
continue;
}
if(have[c] < need[c]){ // 已有的不够
count++;
}
have[c]++;
right++; // right最后加,表示左闭右开
// 缩小窗口
while(count == t.length()){
//更新最小窗口
if(right - left < min){
min = right - left;
start = left;
}
char l = chars[left];
// 更新left
if(need[l] == 0){
left++;
continue;
}
if(have[l] == need[l])count--;
have[l]--;
left ++;
}
}
return min == s.length() + 1? "":s.substring(start, start + min);
}
}
总结
上面的题都是我根据面试频率找出的非常常考的经典题,总结一下,对于滑动窗口,核心在于滑动的时机,也就是扩大和缩小窗口的时机,一般针对数组,按照需要我们可以提前对数组进行排序
窗口是用left 和right指针更新的,遇到需要记忆化的操作,遍历的数据可以根据需要放在map,set或者list中,甚至多个集合,框架如下
// 选择某种记忆化集合
HashMap<Integer, Integer>map
while(right < size){
//扩大窗口
while(){
right ++;
}
//题目要求
doSth……
//缩小窗口
left = right;
}
接下来给大家两道同样很经典的思考题,一道easy,一道medium,有兴趣的小伙伴可以做一做哦,检验一下自己
219. 存在重复元素 II
给你一个整数数组 nums 和一个整数 k ,判断数组中是否存在两个 不同的索引 i 和 j ,满足 nums[i] == nums[j] 且 abs(i - j) <= k 。如果存在,返回 true ;否则,返回 false 。
示例 1:
输入:nums = [1,2,3,1], k = 3
输出:true
220. 存在重复元素 III
给你一个整数数组 nums 和两个整数 k 和 t 。请你判断是否存在 两个不同下标 i 和 j,使得 abs(nums[i] - nums[j]) <= t ,同时又满足 abs(i - j) <= k 。
如果存在则返回 true,不存在返回 false。
标签:道题,窗口,nums,int,参破,right,滑动,解法,left 来源: https://blog.csdn.net/qq_37465638/article/details/122546917