滑动窗口2(java)

接之前写的,做了几道比模板题难的,又有了一点新的理解。先说点题外话:逆向思维很重要。之前面试的时候就被问过一道sql题,大致是有几张成绩表,主键是学生id,让挑出所有成绩都在90分以上的学生,我当时还想着一个主键对应多个科目,可能还要分组什么的,很麻烦,最后写的不是很理想,但是面试官一句话点醒了我“你有没有想过把低于90分的学生先挑出来”,这样确实就简单得多,只要有一门低于90就被排除,用not in语句就可以了。

包括力扣上也有很多题用逆向思维做就比较容易,比如我印象比较深的是一道贪心的题,leetcode435。这种区间的题,比较常见的操作就是根据左边界或右边界进行排序,但是现在让你去除掉某些区间,让剩下的区间互不重叠,如果就按字面意思来,可能需要考虑的还是很多的,但是如果反着来,先把不重叠区间的数量统计出来,再用总数量减不重叠区间的数量,不就是需要去掉的区间数吗?

public int eraseOverlapIntervals(int[][] intervals) {
    if (intervals == null || intervals.length == 0)
        return 0;
    Arrays.sort(intervals, Comparator.comparingInt(a -> a[1]));

    int count = 1;
    int preEnd = intervals[0][1];
    for (int i = 1; i < intervals.length; ++i) {
        if (intervals[i][0] < preEnd)
            continue;
        preEnd = intervals[i][1];
        count++;
    }
    return intervals.length - count;
}

包括下面的滑窗题,也有用逆向思维就很容易做的,言归正传,开始介绍一些稍微难一点的滑窗题。

可获得的最大点数

leetcode1423,这道题我一开始压根就没想到会是滑窗的题,当时觉得每次只有两个选择,从头拿和从尾拿,所以想都没想写了个dfs,测试用例也过了,提交果然超时,因为数组长度是1e5级别的,搜索空间太大。这时可以拐过头来好好想一想,应该怎么解决,因为固定是拿k个数的,也就意味着剩下的数也是固定的,即剩下length - k个数,并且是连续的。数组的sum是固定的,既然想让k个数的和最大,那让剩下的length - k个数最小,不就解决问题了吗?

现在问题简化为给你一个数组,让你求固定长度子数组的最小和,这不就和上一篇讲的模板题一样了!当然这道题不是只有逆向思维这一种做法,也可以模拟把数组复制一份,然后首尾拼一块,按窗口大小是k来滑,求最大值,也是行得通的,感兴趣可以去力扣评论区翻一下,解法非常多。

public int maxScore(int[] cardPoints, int k) {
    int sum = 0;
    for (int cardPoint : cardPoints) {
        sum += cardPoint;
    }
    int len = cardPoints.length - k;
    // 如果k == length 就不用滑窗了。
    if (len == 0)
        return sum;
    int left = 0, right = 0;
    int min = Integer.MAX_VALUE;
    int tmp = 0;
    while (right < cardPoints.length) {
        tmp += cardPoints[right];
        if (right >= len - 1) {
            min = Math.min(min, tmp);
            tmp -= cardPoints[left];
            left++;
        }
        right++;
    }

    return sum - min;
}

这种固定窗口长度的滑窗,用while写其实是不太容易理解的,我也是看了很多人的写法发现大家固定窗口长度的时候更喜欢用两次for,第一次for初始化窗口,然后第二次for从初始窗口的后面开始,让窗口内的值减一个(踢出左边界)再加一个(加入右边界),看起来也更直观,这里贴一下官方题解,代码可读性也更好一些:

public int maxScore(int[] cardPoints, int k) {
    int n = cardPoints.length;
    // 滑动窗口大小为 n-k
    int windowSize = n - k;
    // 选前 n-k 个作为初始值
    int sum = 0;
    for (int i = 0; i < windowSize; ++i) {
        sum += cardPoints[i];
    }
    int minSum = sum;
    for (int i = windowSize; i < n; ++i) {
        // 滑动窗口每向右移动一格,增加从右侧进入窗口的元素值,并减少从左侧离开窗口的元素值
        // 这里其实和while里让left++,再让right++,是一个道理
        sum += cardPoints[i] - cardPoints[i - windowSize];
        minSum = Math.min(minSum, sum);
    }
    return Arrays.stream(cardPoints).sum() - minSum;
}

长度最小的子数组

leetcode209,这个题没什么太难的,但是和之前讲的模板不太一样,不同点在于判断左边界收缩条件时用的不再是if而是while了,再回忆一下上一篇说的模板:

枚举右边界,即right++:
	把右边界的值加入当前计算,不同的题计算逻辑不同
	if (满足某条件,左边界需要收缩)
		收缩之前更改记录,即把左边界的值从当前记录中去掉
		左边界收缩,即left++

可以先打个问号,自己好好思考一下为什么这里不能用if,如果想通了对滑窗的理解会更深一些,文章最后也会分析一下。除了这个while,还有一个坑,就是不存在的时候需要返回0,写的时候没注意,提交的时候有用例不过,比如target = 11,nums = {1,1,1,1},这时候根本没进里面的while,ans还是MAX_VALUE。

public int minSubArrayLen(int target, int[] nums) {
    int length = nums.length;
    int left = 0, right = 0;
    int ans = Integer.MAX_VALUE;
    int sum = 0;
    while (right <length) {
        sum += nums[right];
        while (sum >= target) {
            ans = Math.min(ans, right - left + 1);
            sum -= nums[left];
            left++;
        }
        right++;
    }
    return ans == Integer.MAX_VALUE ? 0 : ans;
}

水果成篮

leetcode904,这道题首先就很难读懂,我愣是看了好几遍才知道题目到底让干嘛,用评论区的高赞解释一下:这道题目可以理解为求只包含两种元素的最长连续子序列。因为只有两个篮子,每个篮子只装一种水果,对应这句话就能理解什么意思了。这道题相当于比上一篇最开始讲的例子复杂了一些,那道题是求最长连续子序列,也就是只有一种元素,现在变成两种了。那基本思路还是和之前一样,用哈希表统计不同种类苹果的数量,只是还需要再引入一个变量来记录当前窗口内有几种苹果。做题之前养成习惯,看一下条件限制,所以map哈希表大小开tree.length就可以。

1 <= tree.length <= 40000
0 <= tree[i] < tree.length
public int totalFruit(int[] tree) {
    int length = tree.length;
    int[] map = new int[length];
    int left = 0, right = 0;
    int count = 0;
    int ans = 0;
    while (right < length) {
        if (map[tree[right]] == 0)
            count++;
        map[tree[right]]++;
        if (count > 2) {
            map[tree[left]]--;
            if (map[tree[left]] == 0)
                count--;
            left++;
        }
        ans = Math.max(ans, right - left + 1);
        right++;
    }

    return ans;
}

这里的判断条件又变成if了,再想一下为什么,用while可以吗?

乘积小于K的子数组

leetcode713,这道题就已经有点难了,通过率也只有三十多,当然也和好多人忽略K的取值有关系(比如我)。做题之前还是要养成习惯,看一下限制:

0 < nums.length <= 50000
0 < nums[i] < 1000
0 <= k < 10^6

K最大只有1e6,不用担心乘法溢出;但是K是能取0和1的,nums[i]最小又是1,所以怎么乘都不会小于1或0。

先看代码,然后再讲这道题的难点:

public int numSubarrayProductLessThanK(int[] nums, int k) {
    if (k <= 1)
        return 0;
    int length = nums.length;
    int left = 0, right = 0;
    int ans = 0;
    int tmp = 1;
    while (right < length) {
        tmp *= nums[right];
        while (tmp >= k) {
            tmp /= nums[left];
            left++;
        }
        // 这里比较难理解
        ans += right - left + 1;
        right++;
    }

    return ans;
}

(这里又变while了)之前的题都是把ans更新成最大值或最小值,这里怎么直接加窗口大小呢?就不画图了,也比较好解释。

比如现在有数组[1, 2, 3, 4, 5],K = 7:

  • right = 0,tmp = 1 * 1 = 1,ans = 0 + 1 = 1,初始情况,满足的子数组只有[1]。
  • right = 1,tmp = 1 * 2 = 2,ans = 1 + 2 = 3,满足的子数组有第一次的[1],第二次的[1, 2]和[2]。
  • right = 2,tmp = 2 * 3 = 6,ans = 3 + 3 = 6,满足的子数组有前两次的加上第三次的[1, 2, 3], [2, 3]和[3],是不是有点眉目了?
  • right = 3,tmp = 6 * 4 = 24,满足while的条件,需要踢出左边的元素:tmp = 24 / 1 = 24,并不满足条件;tmp = 24 / 2 = 12,还不满足;tmp = 12 / 3 = 4,满足了。此时left = 3,right = 3,窗口大小是1,所以ans = 6 + 1 = 7,满足的子数组除了上面的6个,还有这次的[4]
  • ……

也就是说,每次移动右边界,增加的满足条件的子数组的个数就是窗口的大小,理解了这个,这个题就没什么难的了。

K个不同整数的子数组

leetcode992,如果904和713这两个题完全理解了,这个题其实就已经会做了,就是把这两个题综合起来了。但是仔细读题,会发现不同点,这里要求的是恰好有K个不同的元素,而之前是最多两种(并没有要求必须装满两个篮子,代码里也没限制count必须等于2)。猛一想可能会觉得复杂很多,但是其实只要用最多K个不同元素的结果减去最多K-1个不同元素的结果,就是恰好K个不同元素的结果。

public int subarraysWithKDistinct(int[] A, int K) {
    return helper(A, K) - helper(A, K - 1);
}

private int helper(int[] A, int K) {
    int n = A.length;
    int left = 0, right = 0;
    int[] map = new int[n + 1];
    int count = 0;
    int ans = 0;
    while (right < n) {
        if (map[A[right]] == 0)
            count++;
        map[A[right]]++;
        while (count > K) {
            map[A[left]]--;
            if (map[A[left]] == 0)
                count--;
            left++;
        }
        // 比如:现在右边界更新后,窗口内是[2,4,3,5]满足条件,要累加的其实就是
        // [2,4,3,5],[4,3,5],[3,5],[5]四个元素,加的就是窗口的长度
        ans += right - left + 1;
        right++;
    }

    return ans;
}

到底用if还是while?

其实经过这几个题,应该多少有点感觉了。再看一下209这道题,假如把这里的while换成if:

public int minSubArrayLen(int target, int[] nums) {
    int length = nums.length;
    int left = 0, right = 0;
    int ans = Integer.MAX_VALUE;
    int sum = 0;
    while (right <length) {
        sum += nums[right];
        if (sum >= target) {
            ans = Math.min(ans, right - left + 1);
            sum -= nums[left];
            left++;
        }
        right++;
    }
    return ans == Integer.MAX_VALUE ? 0 : ans;
}

对于例子[1, 1, 1, 20, 1, 1, 1],target = 3,会发现窗口大小一直都是3,根本得不到1这个答案,因为if做的动作就是踢出一个left,跳出if后,right又向右移动,做的是窗口滑动的动作,窗口是不会收缩的,窗口大小自然不可能从3变成1。但是现在这道题要求的是最小值,必须要收缩窗口,所以要用while来收缩左边界

但是713这道题也不是求最小值啊,为什么也用while呢?可以看到这道题既不是求最小值也不是求最大值,但是它要记录所有合法的子数组,既然要合法,肯定不可能只让窗口扩张和滑动,因为滑动的时候窗口内的值不一定合法,所以需要收缩左边界,让窗口内的值合法,然后进行记录。

下面比较一下这两种写法:

while (right < length) {
    ...
    if () {
        ...
        left++;
    }
    ...
    right++;
}

while (right < length) {
    ...
    while () {
       ...
       left++;
    }
    ...
    right++;
}
  • if:left每右移一次,都尽可能让right向右移,尝试找到最大窗口
  • while:right每右移一次,都尽可能让left右移,尝试找到最小窗口/合法窗口

这里最小窗口和合法窗口可不是一个概念,别搞混了,合法的窗口不一定是最小窗口,主要还是看判断条件写的什么,还是713这个题,假如现在移动到合法窗口[2, 3, 4, 5]了,有4种不同元素,那[3, 4, 5]也是合法窗口,我们并没有让left接着右移,因为不需要找到最小窗口,所以主要还是看判断条件。

其实用if的地方,大概率都可以用while(不敢百分百肯定,至少原理上是可以的),甚至看官方题解,前面提到的有些题用的也是while,因为找最大值的时候缩左边界也无所谓,反正最大值已经记录过了。所以如果真的搞不清区别,就无脑用while判断条件就可以了。

目前做到的题能总结的只有这些,后续如果再碰到别的题再补,不断学习不断进步吧。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值