系列文章目录
刷题笔记(一)–数组类型:二分法
刷题笔记(二)–数组类型:双指针法
题录
209.长度最小的子数组
链接:209.长度最小的子数组
对应题目截图如下:
这个题目有两种解法,第一种就是暴力解法,我直接遍历整个数组,然后得出每一个下标对应的大于target的窗口长度是多少,然后取最小的那一个。
class Solution {
public static int minSubArrayLen(int target, int[] nums) {
int left = 0;//定义左指针下标
int right = 0;//定义右指针下标
int lengthRs = target;//定义最小的长度
boolean flagMe = false;
for (left = 0; left < nums.length; left++) {
int res = nums[left];//记录当前的和
int lengthNow = 1;//定义当前的长度
right = left + 1;//右指针不能和左指针同一个下标
//如果说当前和小于target或者说右指针越界了就不能循环了
while(res < target && right < nums.length){
res += nums[right];
lengthNow++;
right++;
}
boolean flag = false;
if(res >= target){
//进行一个标志位判断,看一下最后结果是不是大于target
flag = true;
flagMe = true;
}
//进行判断,看一下当前的长度和当前记录的最小长度那个小
if(flag && lengthRs > lengthNow){
lengthRs = lengthNow;
}
}
if(flagMe){
return lengthRs;
}
return 0;
}
}
但是可以发现,这种解法的效率不是很高
所以这里我们就延伸出了,解这个题的另外一种方式–滑动窗口法,注意看下图指针移动的过程。
刚开始都是起始位置:
然后j下标到2时,窗口和就大于7
这个时候我们就移动i指针往前一位
然后此时窗口和小于7,那就继续移动j指针
这个时候和又大于7,就移动i指针。
所以当大于target,就移动i指针,如果小于target就移动right指针。一直到j遍历完整个数组。所以对应解法如下:
public int minSubArrayLen(int target, int[] nums) {
int left = 0;
int sum = 0;//定义当前的窗口内的值的和
int lengthRs = Integer.MAX_VALUE;//定义窗口长度,这里取最大值
for (int right = 0; right < nums.length; right++) {//定义右指针为right,然后开始遍历
//每次都把结果加到sum上
sum += nums[right];
while(sum >= target){//如果窗口内的和大于target,就不能再继续移动right指针了,就要准备移动left指针
lengthRs = Integer.min(lengthRs,right - left + 1);//这里更新下标长度
sum -= nums[left];//当前窗口值大于target,所以减去左边指针的值
left++;//更新left指针
}
}
//如果是最后长度是Integer.MAX_VALUE,那就证明窗口所有的和加起来都小于target
return lengthRs == Integer.MAX_VALUE ? 0 : lengthRs;
}
暴力解法的时间复杂度是O(n^2),因为最差情况就是一个等差数列的和。
而滑动窗口时间复杂度是O(n),因为每一个元素都最多被遍历两次,也就是O(2n),去掉常数就是O(n)。
904. 水果成篮
链接:904.水果成篮
对应题目截图如下:
这道题是什么意思呢?
一句话总结一下:求只出现两个元素的最大子区间。
这一题和上一题相比,增加了对区间元素种类的控制。这题属于滑动窗口当中的计数问题,用arr数组来记录两个篮子中出现的水果数目,count来记录水果的种类,用count来控制窗口的滑动。如果说count <= 2,那么right就可以一直往右走;如果说count > 2,那就是left指针往右走。一边移动一变更新arr数组中对应的水果数目。
class Solution {
public int totalFruit(int[] fruits) {
int left = 0;
int n = fruits.length;
if(n < 2){
return n;
}
int max = 2;//这里设置最长区间的下标,2就是最小值了
int[] arr = new int[n];//创建一个数组用来记录每个数组出现的次数
int count = 0;//用count来控制数组里面元素不为0的个数,也就是只能用两个篮子来装水果
for (int right = 0; right < n; right++) {
if (arr[fruits[right]] == 0){
count++;//如果这个水果之前没有出现过,那么证明就是第一次入篮,count就+1
}
arr[fruits[right]] += 1;//更新每个水果对应出现次数
while(left < n && count > 2){
arr[fruits[left]]--;
//这里注意了,因为我们这里还要用left来判断count,所以不能后面不能直接跟left++
if(arr[fruits[left]] == 0) count--;
left++;
}
max = Math.max(max,right - left + 1);//取连续下标区间最长的
}
return max;
}
}
76. 最小覆盖子串
链接:76. 最小覆盖子串
截屏如下:
这个题,怎么说呢,和上面的题一样的思路,但是不一样的就是对count值的控制,说一下整体的思路吧:
<1>怎么确保s的最小子串覆盖了目标字符串t
<2>确保了最小子串之后怎么滑动窗口
<3>滑动了窗口之后,怎么确定那个子串最小
一个一个来
前置知识说明:对应的指针的意义看我代码中的注释。这里想说的就是,
<1>使用一个哈希表map来保存字符t中每个字符出现的次数,然后开始遍历字符s,s总每出现一个t中的字符的时候,就把map中保存的次数-1,当map中任何一个键的value值为0的时候,就证明这个字符已经被完全的包含在了s的子串中,这个时候count就+1。(count我用来记录当前出现的t的字符完全被s的字符串覆盖的个数)
<2>上面的问题解决了之后,就要开始滑动窗口了,滑动窗口就要使用我们的count值了,如果一个count值小于map的长度,那就证明t没有被子串完全涵盖进去,反之就是完全涵盖了。如果count < map.size(),那么就移动右指针,反之移动左指针。
<3>最小子串的确定,我们用一个minsize来确定,在count >= map.size()的时候就要进行判断,如果minsize大于当前left和right的长度,那么就要更新子串的位置。
这就是整体思路,最后判断一下特殊情况就好(看return行)
public class 最小覆盖子串 {
public String minWindow(String s, String t) {
int left = 0;//定义最后的左指针下标
int right = 0;//定义最后的右指针下标
int leftNow = 0;//当前的当前的左指针下标,用来求最小子串
int count = 0;//用来控制窗口的滑动
int minSize = Integer.MAX_VALUE;//记录最小子串的长度
Map<Character,Integer> map = new HashMap<>();//用一个map表来映射对应的字符出现次数
for(Character c:t.toCharArray()){
//统计字符出现的次数
map.put(c,map.getOrDefault(c,0) + 1);
}
for (int rightNow = 0; rightNow < s.length(); rightNow++) {
Character c = s.charAt(rightNow);
if(map.containsKey(c)){
//记录当前字符对应的value值
int cnt = map.get(c);
map.put(c,cnt - 1);
//如果cnt - 1 == 0,那就证明当前这个字符已经完全的在子串当中
if(cnt == 1) count++;
}
//能进入while循环,就证明当前字符已经完全在子串中了
while(count >= map.size()){
//这个时候就要判断一下要不要更新子串
if(minSize > (rightNow - leftNow + 1)){
right = rightNow;
left = leftNow;
minSize = right - left + 1;
}
Character rightC = s.charAt(leftNow);
if(map.containsKey(rightC)){
int cnt = map.get(rightC);
map.put(rightC,cnt + 1);
//如果说cnt == 0,那就意味着什么呢?意味着原先字符是完全在子串中的,经过下面的leftNow++后就不在了
if(cnt == 0){
count--;
}
}
leftNow++;
}
}
return minSize == Integer.MAX_VALUE ? "" : s.substring(left,right+1);
}
}
总结
有没有发现,其实所谓的滑动窗口本质上还是一种双指针,解题思路其实大致还是可以使用我们的双指针模板,但是只是有相似之处,可以借鉴,这个和双指针还是有点区别的。
(自己用自己图不用去水印了吧~)
这里是我们之前的模板,但是这里我们这个模板就需要更换了
这里大致是需要这个模板的,但是记住了,不能太死。因为你最后实际i和j的值肯定是不在这个位置的。上面这个模板指针的位置是你最后正确答案的位置,什么意思呢?这样解释,逻辑位置和实际位置,我们的逻辑位置,就是我们要知道这个位置是正确的位置也是我们最后要返回的位置,实际地址,是我们实际上代码中i和j的位置。是不是还是有点抽象?(PS:这里好好理解一下,我对于这个概念的处理贯穿后续的内容)
我用上面76题的对应指针来进行讲解。
这两个就是逻辑指针的位置:
int left = 0;//定义最后的左指针下标
int right = 0;//定义最后的右指针下标
这两个就是实际指针的位置:
int leftNow = 0
int rightNow = 0
我们需要用实际指针位置来判断逻辑指针的位置,需要用实际指针来更新逻辑指针的位置。而且其实逻辑指针是可以不存在的!!!这里说一下,是可以不存在的!!
什么意思呢?
拿209题来说,我有使用逻辑指针嘛?没有吧?我怎么表示的呢?是不是用了一个变量来记录长度最小?那实际上这个长度最小是怎么得来的呢?是不是逻辑指针相减得来的?那我76题又用了,为什么又要用呢?可不可以不用呢?当然可以,但是这个时候你就需要用一个自定义的字符串来记录你的最短字符串了。
所以嘛,这里就要我们自己灵活的判断了。
那说完了整体的思想,来说一下准确的实现。我们写滑动窗口的题主要是要记住一下几点:
1.窗口里面我们想要的是什么
2.如果移动窗口的起始位置
3.如果移动窗口的结束位置
一点一点来解答(用209题做具体模板,以下说法参考209题)
1.窗口里面我们想要的是什么?
答:窗口是满足我们要求的最长或者最短的连续子数组
2.如果移动窗口的起始位置
如果当前窗口的值大于目标值,那么就要移动起始位置
3.如果移动窗口的结束位置
如果当前窗口的值小于目标值,就要移动结束位置
所有滑动窗口的题具体的精髓就在于如果移动这个窗口
这个玩意太暧昧了,但是就是一个我们当前逻辑指针记录的答案,和实际指针记录的答案的一个比较,然后如果说要扩充范围,就移动窗口结束位置。如果要缩小范围,就移动窗口起始位置。
下面给出代码模板
int left; //定义实际指针的位置
/*这里做对应题目的处理,1.我们要不要使用逻辑指针,如果使用我们就定义。但是你使用不
使用,你都要根据实际题目的要求来确定,如果方便就是使用。比如int变量求长度(比如209)
,比如String变量记录最短字符串(比如76)
2.确定我们中间要用哪一些处理来进行我们窗口滑动的判断
*/
for(int right = 0;;){//遍历右指针
//动态更新实际窗口数据,只针对右指针
while(实际答案已经得到,满足了窗口滑动条件,准备和我们的逻辑答案进行判断){
//接着就是判断语句,是否要更新(位置不固定,根据题目自己选择)
//更新当前实际窗口的值,逻辑上移动left指针,也就是left++。(为什么是逻辑上?因为这个时候可能判断语句在中当前这条语句的下面)
//实际上在移动left,指针,也就是left++
}
}
return 正确答案;
所以其实也不难总结,我们实际上难点在下面
1.确定窗口滑动条件。以及我们要动态维护的数据
2.更新逻辑指针位置的时机
3.在更新实际指针的时候,我们窗口内的需要的值的变动