滑动窗口的艺术:如何优雅地解决「长度最小的子数组」问题

122 阅读4分钟

前言

各位掘友们好!今天我们来聊聊一个经典的算法问题——LeetCode 209题「长度最小的子数组」。这道题就像是算法界的"减肥神器",教你如何用最短的子数组达到目标和,简直是数组界的"断舍离"哲学!

题目回顾

根据 leetcode209 的描述,题目要求我们:

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

简单来说,就是要找到一个"最短路径"来达成目标,有点像找最优解的感觉。

示例分析:

  • 输入:target = 7, nums = [2,3,1,2,4,3]
  • 输出:2(子数组 [4,3] 的和为7,长度为2)

解法一:暴力破解(新手村装备)

最直观的想法就是暴力枚举所有可能的子数组,就像是"地毯式搜索":

// 暴力解法(时间复杂度 O(n²))
var minSubArrayLenBrute = function(target, nums) {
    let minLength = Infinity;
    
    for (let i = 0; i < nums.length; i++) {
        let sum = 0;
        for (let j = i; j < nums.length; j++) {
            sum += nums[j];
            if (sum >= target) {
                minLength = Math.min(minLength, j - i + 1);
                break; // 找到就可以跳出了
            }
        }
    }
    
    return minLength === Infinity ? 0 : minLength;
};

这种方法虽然能解决问题,但效率堪比"用筷子吃汤"——能行,但不优雅。

解法二:滑动窗口(高手进阶装备)

接下来我们看看滑动窗口解法,这才是真正的"神器":

var minSubArrayLen = function(target, nums) {
    let left = 0; // 左指针,初始指向数组起始位置
    let sum = 0;  // 当前窗口的和
    let minLength = Infinity; // 记录最小长度,初始化为无穷大
    
    for (let right = 0; right < nums.length; right++) {
        sum += nums[right]; // 扩展右边界,累加当前元素到窗口和
        
        // 当窗口和大于等于目标值时,尝试收缩左边界以找到更短的子数组
        while (sum >= target) {
            // 更新最小长度
            minLength = Math.min(minLength, right - left + 1);
            // 收缩左边界,减去左指针指向的元素值,并移动左指针
            sum -= nums[left];
            left++;
        }
    }
    
    // 如果minLength仍为无穷大,说明没有符合条件的子数组,返回0;否则返回minLength
    return minLength === Infinity ? 0 : minLength;
};

滑动窗口的精髓

滑动窗口就像是一个"智能橡皮筋":

  1. 右指针扩展:不断向右移动,增加窗口大小,就像拉伸橡皮筋
  2. 左指针收缩:当条件满足时,尝试缩小窗口,寻找最优解
  3. 动态调整:根据当前状态决定是扩展还是收缩

这种方法的时间复杂度是 O(n),因为每个元素最多被访问两次(一次被右指针访问,一次被左指针访问)。

问题代码分析

让我们看看下面这段代码,这里有一些需要改进的地方:

var minSubArrayLen = function(target, nums) {
    let sum = 0, arr = [];
    for (let i = 0; i < nums.length; i++) {
        arr.push(nums[i]);
        sum += nums[i];
        while(sum >= target) {
            if (sum - arr[0] >= target) {
                arr.shift(); // 这里有问题!
            }
        }
    }
    if (sum >= target) return arr.length;
    else return 0;
};

问题分析:

  1. 逻辑错误while 循环中缺少对 sum 的更新
  2. 效率问题:使用 arr.shift() 的时间复杂度是 O(n)
  3. 结果错误:最后只检查了最终状态,没有记录过程中的最小值

修正版本:

var minSubArrayLenFixed = function(target, nums) {
    let sum = 0, arr = [];
    let minLength = Infinity;
    
    for (let i = 0; i < nums.length; i++) {
        arr.push(nums[i]);
        sum += nums[i];
        
        while(sum >= target) {
            minLength = Math.min(minLength, arr.length);
            sum -= arr.shift(); // 修正:更新sum
        }
    }
    
    return minLength === Infinity ? 0 : minLength;
};

算法复杂度对比

方法时间复杂度空间复杂度优缺点
暴力解法O(n²)O(1)简单直观,但效率低
滑动窗口O(n)O(1)高效优雅,推荐使用
数组模拟O(n²)O(n)直观但效率和空间都不佳

滑动窗口的应用场景

滑动窗口不仅仅适用于这道题,它是解决"连续子数组/子字符串"问题的万能钥匙:

  • 最长无重复字符子串
  • 字符串匹配问题
  • 固定窗口大小的最值问题
  • 可变窗口大小的优化问题

实战技巧

  1. 双指针维护:左右指针分工明确,右指针负责扩展,左指针负责收缩
  2. 状态维护:及时更新窗口内的状态(如和、计数等)
  3. 边界处理:注意空数组、单元素等边界情况
  4. 优化目标:明确是求最大值、最小值还是计数

总结

滑动窗口算法就像是程序员的"瑞士军刀",看似简单,但威力无穷。它教会我们一个道理:有时候最优解不是通过复杂的算法得到的,而是通过巧妙的思维方式。

记住滑动窗口的核心思想:"能进则进,该退则退",就像人生一样,知进退,懂取舍,方能游刃有余。

希望这篇文章能帮助大家更好地理解滑动窗口算法。如果你觉得有用,别忘了点赞收藏哦!毕竟,好的算法就像好的朋友一样,值得珍藏。