长度最小的子数组

数组 滑动窗口 双指针

题目描述

给定一个含有 n 个正整数的数组和一个正整数 target,找出该数组中满足其和 ≥ target 的长度最小的连续子数组,并返回其长度。

如果不存在符合条件的子数组,返回 0

示例:

输入:target = 7, nums = [2,3,1,2,4,3]
输出:2
解释:子数组 [4,3] 是该条件下的长度最小的子数组。
输入:target = 11, nums = [1,2,3,4,5]
输出:3
解释:子数组 [3,4,5] 是该条件下的长度最小的子数组。
输入:target = 100, nums = [1,2,3,4,5]
输出:0
解释:没有符合条件的子数组。

思路分析

这道题的核心是找"连续子数组",暴力解法是枚举所有子数组并计算和,时间复杂度 O(n²)O(n³)。而滑动窗口(双指针)能将复杂度降低到 O(n)

滑动窗口的工作原理

滑动窗口是双指针的一种应用形式,维护一个可变长度的"窗口"在数组上滑动:

  • 右指针(right):不断向右扩展窗口,将新元素加入窗口。
  • 左指针(left):当窗口内的和满足条件时,尝试收缩窗口左边界,寻找更小的满足条件的子数组。

这种"扩展-收缩"的策略能够高效地遍历所有可能的子数组,而不需要重复计算。

为什么滑动窗口有效?

关键在于单调性:当窗口和 sum ≥ target 时,继续扩展右边界只会让和更大,此时应该收缩左边界;而当 sum < target 时,必须扩展右边界才能让和增大。

这种单调关系保证了:

  • 每个元素最多被左指针和右指针各访问一次。
  • 不会遗漏任何可能的最小子数组。

算法步骤

  1. 初始化左指针 left = 0,右指针 right = 0,窗口和 sum = 0,最小长度 minLen = Infinity
  2. 右指针从 0 遍历到 n-1,每次循环:
    • nums[right] 加入窗口,更新 sum
    • sum ≥ target 时,进入内层循环:
      • 记录当前窗口长度 right - left + 1,更新 minLen
      • nums[left] 从窗口移除,更新 sum
      • 左指针右移 left++,继续尝试收缩窗口。
  3. 循环结束后,如果 minLen 仍是 Infinity,说明没有满足条件的子数组,返回 0;否则返回 minLen

边界情况处理

  • 空数组:直接返回 0
  • target 非正数:题目要求为正整数,非正数情况下返回 0
  • 无解情况:所有元素之和都小于 targetminLen 保持 Infinity,最终返回 0

示例代码

滑动窗口
/**
 * 找出数组中和大于等于目标值的最小连续子数组长度
 * 
 * 使用滑动窗口(双指针)算法:
 * 1. 右指针不断向右扩展窗口,累加元素
 * 2. 当窗口内的和满足条件时,左指针向右收缩窗口,寻找最小长度
 * 3. 重复上述过程直到遍历完整个数组
 * 
 * @param target - 目标和的值
 * @param nums - 正整数数组
 * @returns 满足条件的最小子数组长度,如果不存在则返回 0
 * 
 * @example
 * minSubArrayLen(7, [2,3,1,2,4,3]) // 返回 2,子数组 [4,3]
 * minSubArrayLen(11, [1,2,3,4,5]) // 返回 3,子数组 [3,4,5]
 * minSubArrayLen(100, [1,2,3]) // 返回 0,不存在满足条件的子数组
 * 
 * 时间复杂度:O(n) - 每个元素最多被访问两次(一次右指针,一次左指针)
 * 空间复杂度:O(1) - 只使用固定的额外变量
 */
export function minSubArrayLen(target: number, nums: number[]): number {
    // 获取数组长度,后续频繁使用
    const n = nums.length;
    
    // 边界条件检查:如果数组为空或目标值无效,直接返回 0
    // 避免后续不必要的计算
    if (n === 0 || target <= 0) {
        return 0;
    }

    // 左指针,表示滑动窗口的左边界
    // 初始值为 0,指向数组第一个元素
    let left = 0;
    
    // 记录找到的最小子数组长度
    // 初始化为 Infinity(无穷大),方便后续比较
    let minLen = Infinity;
    
    // 记录当前滑动窗口内所有元素的和
    // 初始值为 0,随着窗口扩展和收缩而动态变化
    let sum = 0;

    // 外层循环:右指针从 0 遍历到 n-1,不断扩展窗口右边界
    // right 既是循环变量,也代表滑动窗口的右边界
    for (let right = 0; right < n; right++) {
        // 将右指针指向的元素加入窗口,更新窗口内的和
        // 这一步相当于扩展窗口
        sum += nums[right];

        // 内层循环:当窗口内的和满足条件(≥ target)时
        // 尝试收缩左边界以寻找更小的满足条件的子数组
        // 使用 while 是因为可能需要多次收缩才能找到最小值
        while (sum >= target) {
            // 计算当前窗口的长度,并与已记录的最小长度比较
            // right - left + 1 是因为索引从 0 开始,需要加 1
            // 例如:left=1, right=3,长度为 3-1+1=3
            minLen = Math.min(minLen, right - left + 1);
            
            // 从窗口中移除左指针指向的元素,准备收缩窗口
            // 先减去该元素的值,更新窗口和
            sum -= nums[left];
            
            // 左指针向右移动一位,收缩窗口左边界
            // 继续检查收缩后的窗口是否仍然满足条件
            left++;
        }
    }

    // 返回结果:
    // 如果 minLen 仍是 Infinity,说明没有找到满足条件的子数组,返回 0
    // 否则返回找到的最小长度值
    return minLen === Infinity ? 0 : minLen;
}

复杂度分析

  • 时间复杂度O(n),其中 n 是数组长度。虽然有两层循环,但左右指针各最多移动 n 次,每个元素最多被访问两次。
  • 空间复杂度O(1),只使用了固定数量的变量(leftrightsumminLen)。

示例演示

target = 7, nums = [2,3,1,2,4,3] 为例:

窗口滑动过程

轮次rightnums[right]sumsum ≥ 7?窗口窗口长度minLenleft 移动
1022[2]1-
2135[2,3]2-
3216[2,3,1]3-
4328[2,3,1,2]440→1 (sum=6)
54410[3,1,2,4]441→2 (sum=7)→3 (sum=4)
6537[2,4,3]333→4 (sum=7)→5 (sum=3)

详细解释:

  • 轮次 1-3:右指针不断扩展,但 sum < 7,无法收缩。
  • 轮次 4sum = 8 ≥ 7,记录长度 4,移除 nums[0]=2,左指针移到 1。
  • 轮次 5:加入 4,sum = 10,持续收缩:
    • 移除 nums[1]=3,sum=7,记录长度 4。
    • 移除 nums[2]=1,sum=6 < 7,停止收缩。
    • 移除 nums[3]=2,sum=4 < 7,停止收缩。
  • 轮次 6:加入 3,sum = 7,持续收缩:
    • 当前窗口 [2,4,3],长度 3,更新 minLen = 3。
    • 移除 nums[3]=2,sum=7,窗口 [4,3],长度 2,更新 minLen = 2。
    • 移除 nums[4]=4,sum=3 < 7,停止收缩。

最终返回 minLen = 2,对应子数组 [4,3]

关键点总结

  • 扩展阶段:右指针每次移动都会将新元素加入窗口。
  • 收缩阶段:当满足条件时,左指针持续移动直到不再满足条件,这个过程中不断更新最小长度。
  • 时间复杂度保证:左右指针都是单向移动,不会回退,因此总体时间复杂度为 O(n)