LeetCode 209. 长度最小的子数组(附:有负数时的解决方式)

题目描述

给定一个含有若干个正整数的数组,和一个正整数target,找出该数组中,满足其和>= target且长度最小的连续子数组,返回其长度。

思路

又是连续子数组,又是和,首先想到前缀和。其次就是求一个连续区间,而由于全都是正整数,和具有单调递增性,那么想到滑动窗口。

(可以先想暴力法,两层循环,外层循环将每个位置j,作为子数组的右边界,内层循环枚举所有子数组的左边界i ∈ [0,j],求解最大的连续和。可以观察到有单调性,就是内层循环的指针,和外层循环的指针,只会向着同一个方向走。若[i,j]这个区间的和是满足>= target,那么对于以j + 1(所有大于j的都一样)作为结尾的子数组,其左边界不可能取到i的左边)

所以直接用滑动窗口来做。

class Solution {
    public int minSubArrayLen(int target, int[] nums) {
        // 前缀和 + 滑动窗口
        int[] preSum = new int[nums.length + 1];
        for (int i = 1; i <= nums.length; i++) {
            preSum[i] = preSum[i - 1] + nums[i - 1];
        }
        // 从第一个位置开始滑动, l窗口左边界, r窗口右边界
        int l = 1, r = 1, ans = Integer.MAX_VALUE;
        while (r <= nums.length) {
            while (preSum[r] - preSum[l - 1] >= target) {
                // 当前窗口的和大于等于target, 可尝试缩小窗口
                ans = Math.min(ans, r - l + 1); // 更新答案
                l++; // 左端点可以往右移动
            }
            r++;
        }
        return ans == Integer.MAX_VALUE ? 0 : ans;
    }
}

可以优化为 O ( 1 ) O(1) O(1)的空间复杂度

class Solution {
    public int minSubArrayLen(int target, int[] nums) {
        // 前缀和 + 滑动窗口
        // 从第一个位置开始滑动
        int l = 0, r = 0, ans = Integer.MAX_VALUE;
        int sum = 0;
        while (r < nums.length) {
            sum += nums[r]; // 当前位置先纳入窗口
            while (sum >= target) {
                ans = Math.min(ans, r - l + 1); // 更新答案
                sum -= nums[l];
                l++; // 左端点可以往右移动
            }
            r++;
        }
        return ans == Integer.MAX_VALUE ? 0 : ans;
    }
}

扩展

如果数组中存在负数怎么办?

第一版想法

由于负数对和的贡献是负,只会把和拉低,并且纳入负数进来,还会增大子数组的长度。这种吃力不讨好的事,肯定是不会做的。也就是说,作为答案的子数组中,不会包含负数。所以只需要根据负数,把数组切开,分别对其余部分用上面的方式求解即可。

事实证明,这种想法是有问题的,作为答案的子数组中,是可能包含负数的。比如[3,4,-1,5,2]target = 8,如果分别对负数两边进行求解,答案是0(找不到满足条件的子数组),但实际应该是3,中间的[4,-1,5]

推了一下,上面的代码,原封不动就能解决[4,-1,5]这个用例。

那么在引入负数时,原先的解法是否不需要改动呢?答案是否定的。比如这个用例

[-1,-1,8]target=5,答案应该是1

原先的做法中,滑动窗口的左边界l没有机会往右移,导致计算出错误答案3

第二版想法

那么只需要,在滑动窗口的过程中,判断左边界l,当左边界的数是负数时,则将其右移(l++),来提高sum,并减少子数组长度。

即。在滑动窗口的循环过程中,增加一个while循环,当l <= r并且nums[l] < 0时,进行l++,并判断是否满足条件

class Solution {
    public int minSubArrayLen(int target, int[] nums) {
        // 前缀和 + 滑动窗口
        int[] preSum = new int[nums.length + 1];
        for (int i = 1; i <= nums.length; i++) {
            preSum[i] = preSum[i - 1] + nums[i - 1];
        }
        // 从第一个位置开始滑动
        int l = 1, r = 1, ans = Integer.MAX_VALUE;
        while (r <= nums.length) {
            while (preSum[r] - preSum[l - 1] >= target) {
                ans = Math.min(ans, r - l + 1); // 更新答案
                l++; // 左端点可以往右移动
            }
            // 当左边界是负数时, 将滑动窗口的左边界右移
            while (l <= r && nums[l] < 0) {
                l++;
                if (preSum[r] - preSum[l - 1] >= target) ans = Math.min(ans, r - l + 1);
            }
            r++;
        }
        return ans == Integer.MAX_VALUE ? 0 : ans;
    }
}

但实际这样的做法也不行。比如负数位于窗口的中间,窗口边界是正数。比如下面这个用例

[1,1,-9,3,4]target=5

左边界l被数字1挡住了,无法往右移动,导致-9一直位于窗口中。

第三版想法

为了解决上面的负数位于中间,被左侧的正数挡住,而导致窗口左边界l无法右移的情况。经过和朋友反复的讨论和列举特殊的测试用例,我们发现:

对于位置i,我们期望知道,在i左侧的最大连续和,是否是负数,若是负数,说明左侧一段连续区间,对和的贡献为负,则可以将左边界往右移动,跨过这段对和的贡献为负的区间。从而来增加当前子数组的和,那么对于i我们判断一下i-1的最大连续和,若为负,则右移l直接到当前位置(l = r

class Solution {
    public int minSubArrayLen(int target, int[] nums) {
        int[] preSum = new int[nums.length + 1]; // 前缀和
        int[] f = new int[nums.length + 1]; // f[i] 表示以 i 结尾的最大连续和, 求解f的过程有点动态规划的感觉
        int ans = Integer.MAX_VALUE;

        // 预处理
        for (int i = 1; i <= nums.length; i++) {
            preSum[i] = preSum[i - 1] + nums[i - 1]; // 前缀和
            f[i] = nums[i - 1];
            if (f[i - 1] > 0) f[i] += f[i - 1];
        }

        int l = 1, r = 1;
        while (r <= nums.length) {
            while (preSum[r] - preSum[l - 1] >= target) {
                ans = Math.min(ans, r - l + 1);
                l++;
            }
            // 直接把左边界挪过来
            if (f[r - 1] <= 0) l = r;
            r++;
        }

        return ans == Integer.MAX_VALUE ? 0 : ans;
    }
}

用这个可以处理负数的代码,去提交一下

在这里插入图片描述

在想到上面这个方法之前,我自己想的是:考虑对于每个位置i,求出以i结尾的连续子数组中,最大的和,并且记录这个最大和的起始位置。只要遍历以每个位置作为结尾的最大可能的连续和,并将其起始位置作为左边界l,尝试右移l找到一个最短长度,即可。

class Solution {
    public int minSubArrayLen(int target, int[] nums) {
        int[] preSum = new int[nums.length + 1]; // 前缀和
        int[] f = new int[nums.length + 1]; // f[i] 表示以 i 结尾的最大连续和, 求解f的过程有点动态规划的感觉
        int[] index = new int[nums.length + 1]; // index[i] 表示以 i 结尾的最大连续和的起始位置
        int ans = Integer.MAX_VALUE;

        // 预处理
        for (int i = 1; i <= nums.length; i++) {
            preSum[i] = preSum[i - 1] + nums[i - 1]; // 前缀和
            if (f[i - 1] > 0) {
                f[i] = f[i - 1] + nums[i - 1];
                index[i] = index[i - 1];
            } else {
                f[i] = nums[i - 1];
                index[i] = i;
            }
        }

        // 遍历每个位置, 若以当前位置结尾的最大连续和 >= target , 则取出其起始位置, 作为滑动窗口的左边界l, 一直l++ 直到不满足条件即可
        for (int i = 1; i <= nums.length ; i++) {
            if (f[i] >= target) {
                int l = index[i], r = i;
                while (preSum[r] - preSum[l - 1] >= target) {
                    ans = Math.min(ans, r - l + 1);
                    l++;
                }
            }
        }

        return ans == Integer.MAX_VALUE ? 0 : ans;
    }
}

这其实是暴力法了,提交了一下,发现耗时很高。

在这里插入图片描述

  • 4
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值