目录

题目描述

LCR 008. 长度最小的子数组

题意分析

给一个含 $n$ 个正整数的数组 nums 和一个正整数 target,找出其中和大于等于 target最短连续子数组,返回它的长度;如果不存在这样的子数组,返回 $0$。

「连续子数组」这四个字把候选解限制成了 $O(n^2)$ 个区间,而不是 $2^n$ 个子序列。要求的是长度最小值,说明我们只关心区间的两个端点,不关心里面具体是哪些数。

最关键的约束是所有元素都是正整数。它带来一条极强的性质:区间和关于区间的包含关系是严格单调的——区间往右扩,和只增不减;往左收,和只减不增。没有这条性质(比如允许负数),下面所有的收缩推理都会失效。这条约束就是本题的算法信号。

数据规模是 $n \le 10^5$,$O(n^2)$ 的枚举约 $10^{10}$ 次,必然超时;$O(n \log n)$ 和 $O(n)$ 都能过。题目通常还会附加进阶要求:如果你已经实现了 $O(n)$ 的解法,请尝试 $O(n \log n)$ 的版本——反过来说,$O(n)$ 才是这道题真正的目标。

边界方面:整个数组的和可能都小于 target,此时必须返回 $0$ 而不是某个哨兵值;也可能单个元素就大于等于 target,此时答案是 $1$。这两种情况都应由「答案变量的初值 + 结尾的一次转换」自然覆盖。

解法:二分查找判定答案

核心思路

暴力做法是枚举左端点,再向右累加直到和达标,$O(n^2)$。瓶颈在于:当左端点从 i 移到 i + 1 时,右端点被无脑重置回了 i + 1,前一轮已经算过的和整个作废。

观察点是元素全为正带来的单调性:设 f(i) 表示以 i 为左端点、使区间和首次达标的最小右端点。当左端点右移一格时,区间和只会变小,因此为了重新达标,右端点绝不可能左移,即 f(i) 关于 i 单调不减。既然两个端点都只向右走、各自最多走 $n$ 步,就可以用一个同向移动的窗口把总代价压到 $O(n)$。

于是状态设计为:[i, j] 是当前窗口,s 恒等于 nums[i..j] 的和。外层让 j 逐格右移并把 nums[j] 计入 s(扩张);内层在 s >= target 时不断把 nums[i] 移出并右移 i(收缩)。要维持的不变量是:每次内层循环开始时,[i, j] 是以 j 为右端点的、仍然达标的窗口中最短的那一批候选之一;内层退出后,[i, j] 是以 j 为右端点的所有达标窗口中最短的那个再收缩一步的结果(即已不再达标)。所以答案必须在收缩之前s >= target 仍成立时更新。

这样每个 j 都在退出内层前贡献过它的最优答案,而每个下标至多被 j 访问一次、被 i 访问一次,总共 $O(n)$。

另一条思路是前缀和 + 二分:由于元素全正,前缀和数组严格递增,对每个右端点 j 二分找最大的 i 使 pre[j] - pre[i] >= target,总代价 $O(n \log n)$。它正是题目进阶里提到的那个版本,思路同样依赖「全为正数」这一条件,只是把「同向双指针」换成了「在单调数组上折半」。窗口版更短、更快,也更适合白板手写,所以下面的实现选它。

无解的处理靠初值:answer 初始化为一个大于任何合法长度的哨兵(这里用 1 << 30),若循环结束它没被更新过,说明整个数组的和都不够,返回 $0$。

解题步骤

  • 初始化 answer 为哨兵大值、s = 0i = 0。哨兵必须大于任何可能的长度 $n$,用 1 << 30 而不是 Integer.MAX_VALUE 是为了后续若有加法也不会溢出。
  • 外层 j 从 $0$ 扫到 $n-1$,先执行 s += nums[j]。先扩张再判断,保证进入内层时 s 与窗口 [i, j] 严格对应。
  • 内层用 while (s >= target) 而不是 if。一次扩张后可能需要连续收缩多格(比如刚加入的元素本身就远大于 target),if 只收一格会漏掉更短的答案。
  • 在收缩之前更新答案answer = min(answer, j - i + 1)。此刻 s >= target 成立,[i, j] 是合法窗口;一旦执行了 s -= nums[i],窗口就可能不再达标,那时再取长度就是错的。
  • 收缩时同步减和与移指针s -= nums[i++]。两者必须成对出现,否则 s 与窗口的对应关系(不变量)立刻被破坏。
  • 内层的收缩条件不需要额外的 i <= j 保护。因为元素全为正且 target 为正,s 在窗口收缩到空时必然变成 $0$,$0 < target$ 会自动终止循环。
  • 结尾把哨兵转成 $0$return answer == inf ? 0 : answer;。这是题目对无解情形的约定。

target = 7nums = [2, 3, 1, 2, 4, 3] 走一遍,期望答案 2。初始 i = 0, s = 0, answer = infj = 0s = 2,不达标。j = 1s = 5,不达标。j = 2s = 6,不达标。j = 3s = 8 >= 7,更新 answer = 3 - 0 + 1 = 4,收缩掉 nums[0] = 2s = 6i = 1,不再达标。j = 4s = 10 >= 7,更新 answer = min(4, 4 - 1 + 1) = 4,收缩掉 nums[1] = 3s = 7i = 2;仍 >= 7,更新 answer = min(4, 4 - 2 + 1) = 3,收缩掉 nums[2] = 1s = 6i = 3,退出。j = 5s = 9 >= 7,更新 answer = min(3, 5 - 3 + 1) = 3,收缩掉 nums[3] = 2s = 7i = 4;仍 >= 7,更新 answer = min(3, 5 - 4 + 1) = 2,收缩掉 nums[4] = 4s = 3i = 5,退出。循环结束,answer = 2,对应子数组 [4, 3]。注意 j = 4j = 5 都发生了连续两次收缩——这正是内层必须写 while 的直接证据。

代码实现

class Solution {
    public int minSubArrayLen(int target, int[] nums) {
        final int inf = 1 << 30;
        int answer = inf;
        int s = 0;
        for (int i = 0, j = 0; j < nums.length; ++j) {
            s += nums[j];
            // 元素全为正,达标后继续收缩仍可能达标,必须用 while。
            while (s >= target) {
                // 先在窗口仍合法时更新答案,再收缩。
                answer = Math.min(answer, j - i + 1);
                s -= nums[i++];
            }
        }
        return answer == inf ? 0 : answer;
    }
}
func minSubArrayLen(target int, nums []int) int {
    const inf = 1 << 30
    answer := inf
    s, i := 0, 0
    for j, x := range nums {
        s += x
        for s >= target {
            answer = min(answer, j-i+1)
            s -= nums[i]
            i++
        }
    }
    if answer == inf {
        return 0
    }
    return answer
}

复杂度分析

  • 时间复杂度:$O(n)$。左右两个下标都只向右移动、各自至多走 $n$ 步,所以内层 while 的总执行次数被 i 的总位移量摊还成 $O(n)$,而不是每轮各跑一遍。凭的是元素全正带来的单调性,它保证了 i 永远不需要回退。
  • 空间复杂度:$O(1)$。只用了 answersij 四个标量,没有前缀和数组也没有任何容器。

关键点总结

  • 「连续子数组 + 全为正数 + 求最短/最长」是滑动窗口最典型的触发组合;一旦允许负数,区间和的单调性消失,就必须换成前缀和加单调队列(对应 862 题)。
  • 滑动窗口的正确性论证要落在「左端点单调不回退」上:证明了左端点不需要回退,才有 $O(n)$ 的摊还复杂度。
  • 答案的更新时机由窗口的合法性决定:求「最短且满足条件」时在收缩前更新(此刻合法),求「最长且满足条件」时在收缩后更新(收缩后才恢复合法)。这一条是区分两类窗口题的分水岭。
  • 循环变量 s 必须与窗口范围保持严格对应,任何一次指针移动都要配一次和的增减,把它当作不变量来维护。
  • 用「哨兵初值 + 结尾转换」代替「先判断是否有解」的前置特判,能让主逻辑保持单一出口。
  • 面试视角:面试官期待的主解法就是这份 $O(n)$ 窗口。写完后主动补一句「因为元素全为正,左端点单调不减,所以是 $O(n)$ 而不是 $O(n^2)$」是关键得分点;若被追问进阶的 $O(n \log n)$,就给出前缀和 + 二分的版本,并说明它的价值在于「当题目改成求恰好等于某值或数据需要离线处理时更容易推广」。

易错点总结

  • 错误写法:内层用 if (s >= target) 而不是 while。输入 target = 7, nums = [2, 3, 1, 2, 4, 3]j = 5 只收缩一格就退出,答案停在 3,正确值是 2
  • 错误写法:先 s -= nums[i++] 再更新答案。输入 target = 4, nums = [1, 4, 4] 时,j = 1 处收缩后窗口已变成空,长度算成 1 - 1 + 1 = 1 看似碰巧对,但 target = 11, nums = [1, 2, 3, 4, 5] 会算出比真实值更小的长度,返回 2 而不是 3
  • 错误写法:answer 初始化为 $0$Math.min(0, ...) 恒为 $0$,任何输入都返回 0
  • 错误写法:answer 初始化为 Integer.MAX_VALUE 且结尾忘记转 $0$。输入 target = 11, nums = [1, 1, 1] 会返回 2147483647 而不是 0
  • 错误写法:收缩时只写 s -= nums[i] 忘记 ++ii 永远停在 $0$,窗口长度再也不会变短,输入 target = 7, nums = [2, 3, 1, 2, 4, 3] 会返回 4 而不是 2
  • 错误写法:把窗口长度算成 j - i。少算一格,输入 target = 4, nums = [4] 会返回 0(长度算成 $0$,又恰好等于「无解」的返回值),正确答案是 1
  • 错误写法:内层条件写成 s > target。输入 target = 4, nums = [4]s == target 不进内层,返回 0 而不是 1;题目要的是「大于等于」。
  • 错误写法:把这套窗口直接用在含负数的变体上。输入 target = 3, nums = [1, -1, 3] 会返回 3,而正确答案是 1(子数组 [3]):负数让区间和不再随扩张单调递增,收缩到 s < target 就停下的判据彻底失效——这类题必须改用前缀和加单调队列。

相似题目

题目 难度 考察点
209. 长度最小的子数组 中等 与本题同题,可直接套用同一份窗口
713. 乘积小于 K 的子数组 中等 求方案数而非长度,命中时一次性加上 j - i + 1 个子数组
LCR 009. 乘积小于 K 的子数组 中等 与 713 同题,把和换成积,收缩条件与溢出处理是新增难点
1658. 将 x 减到 0 的最小操作数 中等 需先把「删两端最少」反转成「留中间最长且和为定值」再滑窗
3. 无重复字符的最长子串 中等 求最长,答案要在收缩完成、窗口恢复合法之后更新
424. 替换后的最长重复字符 中等 窗口合法性依赖「窗口长度减最高频字符数」,需额外维护频次最大值
1004. 最大连续1的个数 III 中等 收缩依据换成窗口内 $0$ 的个数是否超过 k
862. 和至少为 K 的最短子数组 困难 允许负数,单调性失效,必须改用前缀和 + 单调队列