开头:
刷 LeetCode 的朋友应该都有这种体验:一道题看名字觉得很简单,真正动手一写才发现坑全在细节里。力扣 209 题“长度最小的子数组”就是典型代表,它挂着“中等难度”的标签,但几乎每个面试算法合集里都会出现,因为它背后考察的滑动窗口思想,是处理连续子数组问题的基石。这道题的核心需求很直白:在一个正整数数组里,找到和大于等于目标值的最短连续子数组,返回它的长度。但真正难的不是读懂题,而是如何在 O(n) 时间内把答案算出来,同时把边界条件处理干净。
这篇文章我会从暴力枚举开始拆,带你理解为什么滑动窗口是正解,再给出前缀和加二分的进阶思路,最后把我在实际刷题和面试中踩过的坑、总结的排查方法全部分享出来。不管你是刚接触算法的新手,还是准备面试想快速复习的工程师,这篇都能给你一套能直接用的解题模板。
1. 题目解读与核心思路拆解
1.1 题目到底在问什么
先原封不动看下题目描述:给定一个含有 n 个正整数的数组和一个正整数 target,找出该数组中满足其和大于等于 target 的长度最小的连续子数组,并返回其长度。如果不存在符合条件的子数组,返回 0。
关键信息有三个:数组元素是正整数,要找的是连续子数组,并且要求“长度最小”。这三个条件决定了整个解题方向。正整数意味着数组单调递增求和时不会出现负数,这为滑动窗口的可行性提供了前提;连续子数组意味着我们不能像处理子序列那样随意跳选;长度最小则是一个典型的优化目标,不是“有没有”,而是“最短多少”。
很多初学者会下意识想到排序,但这里数组的原始顺序必须保留,因为子数组强调连续性,一旦排序就破坏了位置关系。所以这道题本质上是在一个固定顺序的序列上做区间搜索,所有解法都要围绕“区间”来思考。
举个例子,数组是 [2,3,1,2,4,3],target 是 7。肉眼扫一遍,[4,3] 的和是 7,长度 2,这就是答案。但程序可没有肉眼,它需要一种系统性的搜索方式,要么枚举所有区间,要么利用某种机制加速。
1.2 暴力枚举为什么慢,以及优化的方向
暴力思路很直接:用两层循环固定区间的左端点和右端点,计算区间和,如果满足条件就更新最小长度。这样做的复杂度是 O(n^2),在 n 达到 10^5 级别时就会超时。但注意,这还不是最差的,如果每次都用循环重新求和,复杂度会退化到 O(n^3)。所以至少要先用前缀和把区间和优化到 O(1) 查询,暴力才能勉强跑到 O(n^2)。
为什么暴力慢?因为它做了大量重复计算。比如左端点 i 固定时,右端点 j 从 i 一直移到末尾,每次移动都要重新计算 sum(i, j)。当你把左端点移到 i+1 时,很多区间和又要重新算一遍。这些重复正是我们可以优化的空间。
优化的核心观察是:由于所有元素都是正整数,当右端点固定时,随着左端点向右移动,区间和是递减的;当左端点固定时,随着右端点向右移动,区间和是递增的。这种单调性给了我们两种思路:
第一种思路是滑动窗口:维护一个左指针和一个右指针,右指针负责扩展区间,当区间和达标后,左指针尝试收缩窗口,不断更新最小长度。因为指针只向右移动,每个元素最多被访问两次,复杂度是 O(n)。
第二种思路是前缀和加二分:前缀和数组本身是严格递增的(因为正整数),对于每个左端点,我们可以用二分查找找到第一个使得前缀和之差大于等于 target 的右端点,复杂度是 O(n log n),虽然不如滑动窗口,但代码的数学味道更浓,面试时可以作为替代方案展示。
这两种思路的本质都是利用了“正整数数组”带来的单调性。如果数组里有负数,滑动窗口就会失效,因为窗口和不是单调变化的。这也是面试官喜欢追问的点:如果数组包含负数,你会怎么做?答案往往要转换成前缀和加哈希表,类似“和为 K 的子数组”那题的思路。不过那是后话,先把今天这题吃透。
2. 滑动窗口解法:从原理到代码实现
2.1 滑动窗口的经典套路
滑动窗口不是一个抽象的概念,它就像一个可以伸缩的尺子。你先把尺子的右端向右拉,让窗口覆盖更多元素,直到窗口内的和超过 target;然后你开始慢慢收紧尺子的左端,看能不能在保持和大于等于 target 的情况下把窗口缩短。每次收缩成功,就记录当前窗口的长度。重复这个过程,直到右端到达数组末尾。
这套流程的学名叫“双指针”,但更形象的叫法是“滑动窗口”。它要求窗口的滑动方向是一致的,即左右指针都只向右移动,不会回头。之所以能这样,是因为数组是正数,右指针向右扩展时和必然增加,左指针向右收缩时和必然减少。这种单调性保证了我们不需要回退指针,就能穷举所有可能的“最佳窗口”。
具体到实现,你可以这样想:
- 初始化左指针 left=0,窗口和 sum=0,最小长度 result=无穷大。
- 让右指针 right 从 0 到 n-1 遍历,每次把 nums[right] 加进 sum。
- 只要 sum 大于等于 target,说明当前窗口满足条件。这时尝试更新 result=min(result, right-left+1),然后把 nums[left] 从 sum 中减掉,同时 left 向右移动一位。这一步是在收缩窗口,意图是看看能不能用更短的长度也能满足条件。
- 重复步骤 3,直到 sum 再次小于 target,然后继续移动右指针。
这里有个很多人会踩的误区:在步骤 3 中,为什么用 while 而不是 if?因为收缩一次后,可能窗口仍然满足条件。比如窗口 [1,2,3] 的和是 6,target 是 5,收缩左端后变成 [2,3],和是 5,依然满足。你需要一直收缩到不满足为止,才能保证不会漏掉更短的答案。所以必须用 while。
另外,result 应该初始化为多少?常见做法是初始化为 n+1 或者一个很大的数,比如 nums.size()+1。因为最终答案最大就是 n,如果最后 result 还是 n+1,说明没找到任何满足条件的子数组,返回 0。
2.2 代码实现与细节讲解
用 C++ 写一遍,代码非常简洁:
class Solution { public: int minSubArrayLen(int target, vector<int>& nums) { int n = nums.size(); int left = 0; int sum = 0; int result = n + 1; // 初始化为不可能的最大值 for (int right = 0; right < n; ++right) { sum += nums[right]; while (sum >= target) { result = min(result, right - left + 1); sum -= nums[left]; left++; } } return result == n + 1 ? 0 : result; } };如果换成 Python,写法几乎一样:
def minSubArrayLen(self, target: int, nums: List[int]) -> int: n = len(nums) left = 0 total = 0 result = n + 1 for right in range(n): total += nums[right] while total >= target: result = min(result, right - left + 1) total -= nums[left] left += 1 return 0 if result == n + 1 else result这段代码看起来简单,但里面的细节值得逐行拆。
先看循环顺序:外层的 for 循环固定了右指针,内层的 while 处理左指针的收缩。右指针每前进一步,就尝试“消化”当前满足条件的窗口。你可能会有疑问:为什么不在 while 里也移动 right?因为 right 的移动是主流程,如果 while 里动 right,会打乱遍历顺序。滑动窗口的标准写法就是外层扩展右边界,内层收缩左边界,两者职责分明。
再看不满足条件的情况:如果整个数组的和都小于 target,那么 while 永远不会进入,result 就会停留在初始值 n+1,最后返回 0。这个逻辑写起来很方便,但不能用 result 初始化为 0 再最后判断,因为 0 也可能是一个合法答案吗?不是,题目要求最小长度至少为 1,但用 n+1 作为哨兵更安全,因为当 target 刚好等于某个元素,窗口长度可能是 1,此时 result 更新为 1,不会和哨兵混淆。
还有一个细节:sum 的类型。题目给出的是正整数数组,数组长度可能很大,比如 10^5,每个元素最大 10^5,总和可能达到 10^10,超过了 int 的表示范围(32 位 int 最大值约 2.1e9)。所以 C++ 里最好用 long long 来存 sum。虽然 LeetCode 官方测试数据有时候 int 不会爆,但自己写的时候养成好习惯,别让类型溢出成为隐患。
2.3 复杂度分析与正确性证明
时间复杂度方面,右指针从头到尾移动 n 次,左指针最多也移动 n 次(因为 left 只增不减),所以两个指针的总移动次数不超过 2n,复杂度是 O(n)。空间复杂度只用了一个 sum 变量和两个指针,O(1)。
正确性证明是面试中可能被要求的。我们需说明:为什么右指针右移、左指针右移这种方式不会漏掉最短窗口?
核心是“单调性”。假设存在一个最优窗口 [L, R],它的和正好大于等于 target,且长度最短。我们的算法会遍历所有 right 作为右端点,当 right 到达 R 时,由于右指针之前一直在移动,left 可能停在某个小于等于 L 的位置。这时窗口 [left, R] 的和一定大于等于 [L, R] 的和,因为 left <= L,窗口里多包含了 L 之前的若干正数,所以 sum >= target。于是 while 循环会触发收缩,left 会一直向右移动,直到 sum < target。在这个过程中,left 必然会经过 L 这个位置。当 left 等于 L、right 等于 R 时,窗口正好是最优窗口,result 会被更新为 R-L+1。所以算法不会漏掉最优解。
更严谨的说法是:对于任何左端点 i,右端点 j 是满足条件的最小右端点。因为窗口左端不断收缩,右端不断扩展,每个 i 最多被考虑一次,且 j 是从小到大单调的,所以能覆盖所有候选窗口。这个过程可以形象理解为“用一根橡皮筋从左到右捻过整个数组,每个位置都被橡皮筋的两端扫过一遍”。
3. 前缀和加二分查找:另一种优雅解法
3.1 前缀和数组的构造
如果你面试时已经熟练使用滑动窗口,那这题基本够用了。但有些面试官会故意让你“换个思路”,或者在追问中暗示你使用二分。这时候前缀和加二分就是你的第二张牌。
前缀和数组 preSum 的定义是:preSum[i] 表示原数组 nums 前 i 个元素的和,特别地,preSum[0]=0。这样区间 [i, j](注意这里的 i、j 是下标,区间含左端不含右端,或者用闭区间对应关系,不同写法要小心)的和可以表示为 preSum[j+1] - preSum[i]。举个例子,nums=[2,3,1,2,4,3],preSum[0]=0,preSum[1]=2,preSum[2]=5,preSum[3]=6,preSum[4]=8,preSum[5]=12,preSum[6]=15。要计算 nums[2..4] 的和,就是 preSum[5]-preSum[2]=12-5=7。
为什么要用前缀和?因为它把区间和转化为两个前缀和的差,而 preSum 数组是严格递增的(因为 nums 全是正数)。严格递增意味着我们可以用二分查找来快速定位。
具体到本题的解法:我们枚举左端点 i,希望找到一个最小的右端点 j(j >= i),使得 preSum[j+1] - preSum[i] >= target,即 preSum[j+1] >= preSum[i] + target。由于 preSum 递增,可以在 preSum 数组上用 lower_bound 找到第一个大于等于 preSum[i]+target 的位置。
这里下标映射很容易晕。假设 lower_bound 返回的位置是 pos,那么 pos 对应的是 preSum 的下标,它等于原数组右端点下标加 1,所以子数组长度就是 pos - i。因为 preSum 的下标范围是 0..n,原数组下标范围是 0..n-1。比如 preSum[4] 对应的是 nums[0..3] 的和,pos=4 表示右端点是 nums[3]。
3.2 二分查找的配合与边界处理
先看代码,C++ 版本如下:
class Solution { public: int minSubArrayLen(int target, vector<int>& nums) { int n = nums.size(); vector<long long> preSum(n + 1, 0); for (int i = 0; i < n; ++i) { preSum[i + 1] = preSum[i] + nums[i]; } int result = n + 1; for (int i = 0; i <= n; ++i) { long long need = preSum[i] + target; // 需要达到的边界 auto it = lower_bound(preSum.begin() + i, preSum.end(), need); if (it != preSum.end()) { int pos = it - preSum.begin(); result = min(result, pos - i); } } return result == n + 1 ? 0 : result; } };注意几个关键点。
第一,外层循环的 i 取值范围是 0 到 n。因为 preSum 有 n+1 个元素,我们枚举的是子数组的左边界前一个位置,即 preSum 的下标。当 i=n 时,preSum[i] 是全部元素的和,如果这个和都不够 target,那后面也没有元素了,lower_bound 也不会找到结果,所以循环到 n 没毛病。但实际可以优化到只循环 i <= n-1,因为 i=n 时子数组不存在,长度也为 0,不会更新 result。
第二,lower_bound 的搜索范围要从 preSum.begin() + i 开始,为什么不能从 begin() 开始?因为要求子数组左端点是 i,右端点必须大于等于 i,所以 preSum 的查找起点必须是 i。如果从开头找,可能会找到一个位置在 i 左边,那就构成负长度区间了,虽然 preSum[i] 是递增的,lower_bound 找到的位置天然不会小于 i(因为 need>preSum[i]),所以从 begin()+i 开始只是语义更清晰,实际上从 begin() 开始也能找到正确位置。这里建议写清楚,面试时解释起来也顺。
第三,当 lower_bound 返回 preSum.end() 时,说明从当前左端点开始,右侧所有元素加起来的和都不够 target,那么再往后的左端点更不可能够(因为丢掉了前面的正数),但循环不会提前 break,只是 end() 不会被计入,逻辑上没问题。实际代码可以加一个简单的优化:如果 last 前缀和 - preSum[i] < target,可以直接跳出循环,因为后面的更小。
Python 版用 bisect 也很方便:
from bisect import bisect_left def minSubArrayLen(self, target: int, nums: List[int]) -> int: n = len(nums) pre = [0] for x in nums: pre.append(pre[-1] + x) result = n + 1 for i in range(len(pre)): need = pre[i] + target pos = bisect_left(pre, need, i, len(pre)) if pos < len(pre): result = min(result, pos - i) return 0 if result == n + 1 else result复杂度方面,外层循环 n+1 次,每次二分 O(log n),总复杂度 O(n log n)。虽然比滑动窗口慢,但在 n 小于 10^5 时完全够用,而且这种解法更容易扩展“最小平均子数组”一类的问题,所以值得储备。
3.3 两种解法对比
从实际刷题角度看,滑动窗口是这道题的最优解,时间 O(n),代码短,面试中最推荐优先写出。前缀和加二分可以作为“如果被要求给出另一种解法”的备选,或者当你第一反应没想到滑动窗口时,用前缀和也能稳过。两者对比如下:
| 解法 | 时间复杂度 | 空间复杂度 | 代码量 | 适用场景 |
|---|---|---|---|---|
| 滑动窗口 | O(n) | O(1) | 很短 | 数组全为正数,且目标是区间和 |
| 前缀和+二分 | O(n log n) | O(n) | 中等 | 数组全为正数,需要可解释的数学思路 |
前提条件是数组元素为正数。如果数组有负数,滑动窗口的收缩条件就崩了,因为窗口和不再随着 left 移动而单调递减,right 移动时也不能保证窗口和单调递增。这时候就要考虑前缀和配合其他结构,比如“和为 K 的子数组”那题的哈希表优化。所以你会发现,同一个解法能否成立,完全取决于题目给的约束条件。刷题时多问自己一步:“这个解法依赖了什么性质?如果性质被破坏,还能用吗?”这比背模板重要得多。
4. 实操过程中的常见问题与排查技巧
4.1 边界条件翻车现场
我第一次写这题的时候,把 while 写成 if,结果在 target 较小的用例上直接崩了。比如 nums=[1,1,1,1],target=2,当 right=1 时,sum=2,if 进去更新长度 2,然后 left 加一,sum 变成 1,循环退出。但此时窗口 [1,1] 已经不满足条件了,正确做法是继续收缩?不对,这里因为 left 收缩后窗口不满足,所以 if 和 while 结果相同。但换一个用例 nums=[2,3,1,2],target=7,right=2 时 sum=6 不满足,right=3 时 sum=8 满足,if 进去更新长度 4,left 加一变为 1,sum=6,循环退出。可实际上窗口 [3,1,2] 也就是 nums[1..3] 的和是 6,还不满足,但 [1,2] 更短也不满足,看起来没有漏。再试 nums=[1,2,3,2,4],target=7。right=2 时 sum=6 <7,right=3 时 sum=8 >=7,if 更新长度 4,left=1,sum=7(窗口 [2,3,2]),此时 sum 仍 >=7!如果只用 if,就会退出,下次 right=4 时 sum=11,但窗口 [3,2,4] 的长度是 3,不是最优;最优其实是 [2,4] 长度 2(nums[3] 是 2?我写错了应该测试下)。总之,只用 if 会错过连续收缩后依然满足的情况,一旦出现,就会漏掉更短的窗口。所以必须用 while。
另一个坑是 result 的初始值。如果初始化为 0,最后判断 result==0 返回 0,会因为 target 可能等于某个单个元素,比如 nums=[1,2,3],target=3,正确答案是 1,但此时 result 会被更新为 1,不会出现 0 混淆。但如果 target 恰好没有答案,result 保持 0,返回 0 也是对的。不过用 n+1 更自然,因为 0 通常被误解为“没有答案”,而题目中长度为 0 是没有意义的。
4.2 如何快速验证正确性
刷题时我会先写完代码,再用几个极端用例自测。第一个用例是空数组,nums=[],target=5,直接返回 0。滑动窗口代码里 n=0,for 循环不执行,result 保持 1,返回 0,没问题。但要注意,如果代码里直接访问 nums[right] 时没有检查 n,下标会越界,所以一定要先处理 n==0。
第二个用例是单元素数组,nums=[5],target=5,窗口长度 1,直接 while 进入更新 result=1,然后 left 变成 1,循环结束,返回 1。如果 target=6,sum 始终不到 6,返回 0。
第三个用例是整个数组刚好满足,比如 nums=[1,2,3], target=6,正确答案是 3。代码运行 right=0,1,2 累计 6,while 进入,更新长度 3,left 移到 1,sum=5 退出,返回 3。没问题。
第四个用例是最优窗口在中间,比如前面举过的 [2,3,1,2,4,3], target=7,答案 2。手推一下:right 从 0 累计,直到 right=4 时 sum=12,while 收缩 left:先更新长度 5,left=1,sum=10;更新长度 4,left=2,sum=7;更新长度 3,left=3,sum=4(因为 nums[2]=1 被移走),sum<7 退出。right=5 时 sum=7(加上 nums[5]=3),while 进入:更新长度 3(窗口 [2,4,3]? 等一下 left=3 到 right=5 是 [2,4,3] 长度 3),然后 left=4,sum=4退出?不对我写乱了。人工验证可能麻烦,可以用打印调试。我建议刷题时写个简单的测试框架,把暴力解和滑动窗口放在一起跑随机样例,对比结果。这个习惯很管用,能快速暴露边界问题。
4.3 面试现场怎么答
面试遇到这道题,我一般会先花 20 秒和面试官确认约束:“题目说的是正整数数组,对吗?如果元素有负数,解法要变。”然后先说暴力思路,再自然过渡到滑动窗口。哪怕你一眼能看出最优解,也要把思考过程展现出来,这比直接甩代码加分。
讲滑动窗口时,我会画一个数组的简单示意图,用两个下标表示窗口,说明右指针负责“找可行解”,左指针负责“优化可行解”。然后强调单调性是能这样移动的前提:“因为全是正数,窗口和随 left 右移而减小,随 right 右移而增大,所以不存在需要回头的情况。”
代码写完后,主动分析复杂度,然后补充一句“如果面试官想听更数学的版本,我还可以用前缀和加二分,时间复杂度 O(n log n),空间 O(n)。”这能展示你的知识广度。最后别忘了说边界条件:n 为 0 返回 0,找不到返回 0。
另外有个小细节:面试官可能会追问“如果 target 非常大,超过数组总和,你的代码会怎样?”回答是:滑动窗口的 while 永远不会进入,result 保持 n+1,最终返回 0;前缀和解法 lower_bound 会返回 end(),也返回 0。这种追问的目的通常是想看你能不能说出返回条件,提前想好代码里的哨兵值就不会卡壳。
5. 延伸思考:从这道题看算法思维
5.1 滑动窗口的适用场景
滑动窗口不是一个孤立的算法,而是一类题的通用解法。它的适用条件可以总结为两条:一是求解目标与“连续区间”有关;二是窗口在扩展和收缩时,目标值具有单调性。满足这两个条件,滑动窗口往往就是最优解。
常见的变体有“无重复字符的最长子串”(LeetCode 3)、“最大连续1的个数 III”(LeetCode 1004)、“替换后的最长重复字符”(LeetCode 424)等。它们的核心都是维护一个窗口,通过调整左右指针来满足某种约束。区别在于约束的判断方式,有的用哈希表记录字符频次,有的用计数变量,但外层骨架几乎一致。
碰到这类题,我会先思考:窗口代表什么?右指针扩展会带来什么影响?左指针收缩会带来什么影响?什么时候窗口是合法的?什么时候需要收缩?把四个问题理清楚,代码自然就出来了。
5.2 类似题目串讲
如果把本题的“正整数数组”改成“有正有负的数组”,滑动窗口就不能用了。因为窗口和不再单调,收缩左边可能让和变大也可能变小。这时候求“和大于等于 target 的最短子数组”就变成一个更复杂的问题,通常需要借助前缀和并维护某种有序结构。
但如果只求“和为 target 的子数组个数”,那就是另一道经典题——LeetCode 560。那道题用前缀和加哈希表,时间复杂度 O(n),空间 O(n)。核心公式是:当遍历到位置 j 时,我们想知道有多少个 i 使得 preSum[j] - preSum[i] = target,也就是 preSum[i] = preSum[j] - target。用哈希表存前缀和出现的次数即可。
对比一下,209 的“大于等于”和 560 的“等于”,一字之差,解法天差地别。前者因为“大于等于”有单调性,可以用滑动窗口;后者因为“等于”需要精确匹配,滑动窗口无法判断收缩方向,除非数组全是正数。这个对比能帮你理解为什么题目对数组元素的限制如此关键。
还有一道“子数组最大平均数 I”(LeetCode 643),固定窗口大小,要求窗口平均值最大。那种题是定长滑动窗口,用固定大小维护窗口和,本质上也是双指针,只是窗口大小不变。滑动窗口可以根据“是否定长”分成两类,解题时先判断窗口是否定长,再决定是否需要在扩展后固定收缩。
5.3 个人经验与建议
我个人刷了三百多道题之后,最大的体会是:不要急着看题解,先自己推一遍暴力解法,再尝试找优化点。比如 209 这题,暴力很好写,写完后你自然会觉得“咦,为什么每次都要重复求和?”这时候再引入前缀和或双指针,逻辑就顺理成章了。如果一上来就背滑动窗口模板,遇到变种题很容易漏掉“单调性”这个根本条件。
在实际编码中,我还会刻意练习“边写边小声讲思路”,这能帮助我在面试时保持清晰表达。你会发现,当你能把“为什么要用 while 收缩”“为什么 left 只增不减”“为什么 result 初值设为 n+1”都讲明白时,代码已经不可能写错了。
最后分享一个我自己做题的小工具:对于这种双指针题,我会写一个自动测试脚本,用暴力解法和优化解法同时跑一批随机生成的测试数据,如果结果不一致,就打印出错时数组和 target。这样能快速定位到特殊用例,比用 LeetCode 提交一次等反馈快得多。这道题我当年就是这样验证了自己的滑动窗口写法——在一次随机数据里,因为 C++ 的 int 类型溢出,暴力解和滑动窗口结果不一样,排查了半天才发现是变量类型的问题。从那以后,只要涉及累加,我第一反应就是 long long,这也是今天文章里反复强调类型细节的原因。
顺便说一句,如果你刚开始刷题,遇到“长度最小”“最长子串”“窗口内最大值”这类关键词,脑子里可以跳出两个候选方向:一是滑动窗口,二是单调队列/哈希表辅助。先把最简单的滑动窗口练熟,再用题目中的约束条件判断它是否成立。209 题就是练手的最佳开胃菜,搞定它,后面一连串滑动窗口题都会顺畅很多。