☰
长度最小的子数组:滑动窗口经典入门题解析
2026/10/3 3:51:53 网站建设 项目流程

刷算法题刷到一定阶段,你会发现有一类题特别有意思:题目本身看起来非常简单,甚至暴力解法一行就能说清楚,但偏偏在面试里被反复问,而且每次问都能挖出不同的层次。LeetCode 209题——长度最小的子数组,就是这样的题目。它表面上是一个求子数组长度的问题,实际上是滑动窗口技巧的经典入门题目,同时也是考察边界处理、时间复杂度分析和思路演进的好素材。

第一次做这道题的时候,我的第一反应是“这么简单也能上209号题?”结果写完暴力解法提交,看到超时提示的那一瞬间,才意识到这道题的考点根本不在于会不会求子数组和,而在于能不能想到比暴力更优的解法。今天这篇文章就围绕这道题,从暴力解法逐步演进到滑动窗口,再补充前缀和加二分查找的进阶思路,以及我刷这道题时踩过的几个典型坑。无论你是刚开始刷题的新手,还是准备面试的老手,这篇文章都会有一些值得看的内容。

1. 把题目翻译成人话:题目表面在问什么,实际又在问什么

1.1 题面拆解:什么是长度最小的子数组

题目原文是这样的:给定一个含有 n 个正整数的数组和一个正整数 target,找出该数组中满足其和大于等于 target 的长度最小的连续子数组,并返回其长度。如果不存在符合条件的子数组,返回 0。

这里有几个关键信息需要拆开来看:

  • 子数组必须是连续的,不是子序列。这意味着你不能跳着选元素,选定区间[i, j]后,里面的所有元素都得算上。
  • 正整数这个条件非常关键。如果数组里有负数或零,这道题的解法会完全不同,因为窗口内累加和就不是单调的了。正因为全是正整数,才可以用滑动窗口——窗口扩大时和一定增加,窗口缩小时和一定减小,这个单调性是整套算法的基石。
  • 大于等于 target,不是等于 target。这个细节决定了窗口收缩的判断条件。

举个例子,nums = [2,3,1,2,4,3],target = 7,那么满足条件的子数组有[3,1,2,4](长度为4)、[4,3](长度为2)、[2,3,1,2](长度为4)等,其中最短的是[4,3],长度是2。答案就是2。

1.2 为什么这道题被归类为“滑动窗口”入门题

滑动窗口这个技巧,本质上是对暴力解法中大量重复计算的一种优化。暴力解法里,我们要枚举所有可能的子数组起点和终点,然后对每个子数组求和——这个求和过程是重复的。滑动窗口的核心思想是维护一个窗口,通过不断调整左右边界,在遍历过程中动态更新窗口内的和,从而避免重复计算。

从数据结构的角度看,滑动窗口其实是一个双端队列的简化用法:左端负责收缩,右端负责扩张。而从思想层面看,它是“单调性”在算法设计中的一次典型应用——因为有正整数这个约束,窗口内的和才会随着窗口扩张而单调增加,我们才能放心地收缩窗口。

我个人的理解是,这道题的价值不在于它本身有多难,而在于它是理解后续一系列滑动窗口题目的“母题”。后面你遇到的字符串无重复字符的最长子串、水果成篮、最小覆盖子串,本质上都是这个模板的变体。

2. 从暴力解法出发:知其慢,才能知其所以快

2.1 暴力枚举的思路与实现

很多教程会直接跳过暴力解法,直接讲滑动窗口。但我觉得,理解暴力解法是理解滑动窗口必不可少的一步——你得先知道暴力解法慢在哪里,才能体会滑动窗口到底优化了什么。

暴力解法的思路非常直接:枚举每个子数组的起点i,然后从i开始逐步增加终点j,每增加一个终点就计算一次子数组[i, j]的和,一旦发现某个[i, j]的和大于等于 target,就记录下当前长度并跳出内层循环。因为数组里全是正整数,内层循环可以提前终止——从i往后,和只会越加越大,一旦满足条件,继续往后加只会让长度更长,没有意义。

def minSubArrayLen(target, nums): n = len(nums) ans = n + 1 # 初始化为一个不可能更大的值 for i in range(n): s = 0 for j in range(i, n): s += nums[j] if s >= target: ans = min(ans, j - i + 1) break return ans if ans != n + 1 else 0

其实这里还有一个小优化:每轮内层循环不必从i重新累加。我们可以先计算前缀和数组prefix,然后用prefix[j] - prefix[i-1]来快速求出子数组和。这样内层循环的求和操作是常数的,整体时间复杂度还是O(n^2),只是常数小了一些。

2.2 暴力解法的性能瓶颈在哪里

暴力解法最直观的问题是:当数组很长时,枚举的子数组数量是n(n+1)/2个,在 n = 10^5 级别(这是 LeetCode 上这类题目的典型数据规模)时,需要枚举大约 50 亿个子数组。即使每个子数组的和计算是常数时间,这个规模也远远超出了 1 秒的限制。

但暴力解法还有一个更隐蔽的浪费:大量的求和是重复的。[i, j+1]的和明明可以由[i, j]的和加上nums[j+1]得到,暴力解法却没有利用这个关系,而是重新计算了窗口内的所有元素之和。

我经常跟周围刷题的朋友说,算法优化本质上就是“找重复”。暴力解法慢不是因为它笨,而是因为它没有利用题目条件带来的性质——这里有两条性质可以利用:

  1. 数组全是正整数,子数组和单调递增;
  2. 子数组的右边界移动时,和的变化是增量式的。

滑动窗口恰恰同时利用了两个性质:用单调性保证窗口收缩的正确性,用增量式计算保证和更新的效率。

2.3 从暴力到滑动窗口的思维跃迁

写暴力解法时你会有一个直观感受:内层循环的起点一直在“回退”。比如第一轮枚举以nums[0]开头的所有子数组,第二轮枚举以nums[1]开头的所有子数组——每一轮都把前面的工作推倒重来。

滑动窗口的优化思路是:既然窗口[i, j]已经满足条件了,那么我能不能固定j,只把i往右移动,直到窗口不满足条件,再继续移动j呢?这样每个元素最多被访问两次——一次作为右边界进入窗口,一次作为左边界离开窗口——总时间复杂度就是O(n)。

这个“每个元素最多进出窗口各一次”的直觉,就是滑动窗口比暴力解法快一万倍的原因。

3. 滑动窗口的核心逻辑:左右指针的动态平衡

3.1 窗口收缩的触发条件和循环不变量

滑动窗口的实现有一个核心的循环不变量:在每次循环开始时,当前窗口[left, right]是满足“窗口内元素和小于 target”的最大窗口(或者理解为在上一次循环结束后,窗口刚被收缩到不再满足条件的状态)。然后右指针不断扩张,一旦发现窗口内的和大于等于 target,就尝试收缩左指针,并在这个过程中记录最短长度。

具体逻辑是这样的:

  1. 初始时left = 0,right = 0,当前窗口和为 0;
  2. 右指针right从 0 开始遍历数组,每次把nums[right]加入窗口;
  3. 当窗口内元素和s >= target时,记录当前窗口长度right - left + 1,然后尝试移动左指针left += 1,并从窗口和中减去nums[left - 1],重复这一步直到s < target;
  4. 继续移动右指针。

这个逻辑里最关键的点是第三步:当窗口满足条件时,我们要先记录长度,再收缩窗口。有些初学者会把顺序搞反——先收缩再记录,这样会漏掉一些合法的子数组。

我在草稿纸上推演了几个例子,总结出一个很容易记忆的顺序:先记录再收缩,收缩要收缩到不满足条件为止。

3.2 代码实现与每一步的意图

def minSubArrayLen(target, nums): n = len(nums) left = 0 s = 0 ans = float('inf') for right in range(n): s += nums[right] # 右指针扩张:把新元素纳入窗口 while s >= target: ans = min(ans, right - left + 1) # 先记录当前窗口长度 s -= nums[left] # 左指针收缩:从窗口和中移除最左边元素 left += 1 # 左指针右移 return ans if ans != float('inf') else 0

有一些实现在while循环里会先把s -= nums[left],再计算长度,这其实是错的——因为移除元素后窗口已经不包含原来的左边界元素了。所以顺序上,一定要先计算当前窗口的合法长度,再收缩。

3.3 为什么每个元素只被访问两次:复杂度分析

滑动窗口的时间复杂度是O(n),这不是一句空话,它的证明很简单:right指针从 0 遍历到 n-1,一共 n 次;left指针最多也只从 0 移动到 n-1,一共 n 次。每个元素最多一次进入窗口(由right指针完成),最多一次离开窗口(由left指针完成),因此总操作次数不超过2n。

空间复杂度是O(1),因为我们只需要两个指针和一个变量记录当前窗口和,没有额外的存储结构。

这种复杂度分析的方法值得反复体会:看一个算法是不是真正达到线性效率,不要看循环嵌套的层数,而要看数据元素被访问的总次数是否与 n 呈线性关系。滑动窗口里虽然有一个 inner 的while循环,但整个算法仍然是线性的,因为left指针的总移动次数是有限的。

3.4 窗口先后顺序的正确理解:一种反直觉的直觉

有一个让我自己绕了好一阵的概念:滑动窗口的“滑”这个概念,很多人理解为“窗口在数组上连续滑动,边界一步步挪动”,但在实现里,右指针不是一步步试探性地移动,而是每次循环都果断移动一格,再通过while把左指针拉到正确位置。

如果你非要用生活化的比喻来理解,可以这样想:你有一根绳子,左边绳头是left,右边绳头是right。你不停地从右边拽绳子进来(扩张),直到绳子上积攒的“重量”(窗口和)超过目标值,这时候你开始从左边收绳子(收缩),每次收一点就看重量是否还达标,一直收到重量刚好低于目标,再继续从右边拽。

“先拽右边,再收左边,收紧了再拽右边”这个循环,就是滑动窗口的全部精神内核。你写的代码越多,越会感觉到这个循环的节奏感——它是一种“呼吸式”的推进方式。

4. 换个思路:前缀和加二分查找的玩法

4.1 前缀和的预处理与等式变形

滑动窗口是这道题的主流解法,但算法题永远不只有一个解。如果你对“查找”这个操作敏感,会发现这道题还可以用二分查找来优化。

首先构造前缀和数组pre,其中pre[i] = nums[0] + nums[1] + ... + nums[i](令pre[-1] = 0,或者用pre[i]表示前 i 个元素的和,即pre[0]=0空前缀,pre[i]表示前 i 个元素的和)。任意子数组[i, j]的和可以表示为:

sum(i, j) = pre[j] - pre[i-1] // 如果用 pre[k] 表示前 k+1 个元素的和

换一种更顺手的表示,令pre[0] = 0,pre[t] = sum(nums[0:t]),则子数组[i, j)(左闭右开)的和是pre[j] - pre[i]。

题目要求找到pre[j] - pre[i] >= target且j - i最小的区间。变形一下:

pre[j] >= pre[i] + target

对于每个固定的i,我需要找到一个最小的j,使得pre[j]大于等于pre[i] + target。由于nums全是正整数,前缀和数组pre是严格递增的,所以可以在这个递增数组上二分查找满足条件的最小位置。

4.2 用bisect_left实现二分查找

Python 里实现这个思路非常简洁——用bisect_left直接找到第一个不小于目标值的位置。

import bisect def minSubArrayLen(target, nums): n = len(nums) pre = [0] * (n + 1) for i in range(1, n + 1): pre[i] = pre[i - 1] + nums[i - 1] ans = float('inf') for i in range(n + 1): # 需要找到最小的 j 使得 pre[j] >= pre[i] + target need = pre[i] + target j = bisect.bisect_left(pre, need) if j <= n: # 区间 [i, j) 的长度是 j - i,但 j 必须大于 i if j - i > 0: ans = min(ans, j - i) return ans if ans != float('inf') else 0

这里有个边界细节值得注意:bisect_left返回的j有可能等于i——比如pre[i] + target恰好等于pre[i]时(即target = 0,但题目限定 target 是正整数,所以不会出现)。不过在实际代码中仍需检查j - i > 0,防止出现长度为 0 的区间。另外,bisect_left返回的j如果等于n+1,说明没有找到满足条件的位置,跳过即可。

4.3 两种方案的适用场景对比

滑动窗口和前綏和加二分都能在O(n)或O(n log n)内解决问题,那么什么时候用哪种?

方案时间复杂度空间复杂度核心优势核心局限
滑动窗口O(n)O(1)线性效率,空间最优,实现简单要求数组元素全为正数(和单调)
前缀和 + 二分O(n log n)O(n)思路更具一般性,后续可扩展到带负数的变体多一个 log n 因子,需要额外空间存前缀和

实际面试中,我认为优先答滑动窗口是稳妥的选择,因为它效率更高且代码量少。但如果你能随口说出前缀和加二分的思路,并指出两者的适用边界,面试官通常会更认可——这体现了你不是只会背模板,而是真正理解了单调性这个前提条件。

5. 这道题最容易踩的坑:边界、初始化与极端用例

5.1 不存在满足条件的子数组时返回 0

题目明确要求:如果不存在符合条件的子数组,返回 0。这是最容易被新手忽略的边界条件。

我见过不少代码,初始化答案时直接把ans设为 0,然后在更新时用min(ans, length)——这样会一直得到 0,因为min(0, length)永远是 0。正确的做法是把ans初始化为一个不可能更大的值,比如float('inf')或者n + 1,最后再判断是否被更新过。

# 错误示范:ans = 0 会导致 min 永远取 0 # 正确做法:初始化为无穷大或 n+1

还有一种技巧性的写法:初始化ans = n + 1,这样如果最终ans仍然是n + 1,就说明没有找到任何合法子数组,返回 0。这种方法不用引入浮点数,在整型环境下特别方便。

5.2 while 循环和 if 判断的细微差别

滑动窗口收缩时用的是while s >= target,而不是if s >= target。为什么必须循环收缩?

考虑这样一个场景:nums = [1, 2, 3, 4, 5],target = 7。当右指针移动到 3(索引为 3,值 4)时,窗口[0, 3]的和是1+2+3+4 = 10 >= 7。此时如果只用if判断一次,把左指针右移一位,窗口变成[1, 3],和是2+3+4 = 9,仍然大于等于 7。而最短的满足条件的子数组其实是[3, 4](和 7,长度 2),这需要左指针连续右移两次才能达到。

所以必须用while循环,让左指针一路收缩到窗口刚小于 target 为止,并且在收缩过程中不断更新答案。每次收缩都是一个可能更短的合法窗口,错过一个都可能错过正确答案。

5.3 极端数据与边界用例的测试方法

刷算法题时,我习惯在写完代码后自己模拟几个极端用例:

  • target小于数组中的单个元素,比如nums = [3, 1, 1],target = 2。答案是 1,因为nums[0] = 3单独就满足条件。
  • 所有元素之和刚好等于 target,比如nums = [1, 2, 3],target = 6。答案是 3,因为整个数组就是满足条件的唯一子数组,不存在更短的。
  • 只有一个元素:如果该元素大于等于 target,答案 1,否则返回 0。
  • target极大,比如sum(nums) < target,此时应返回 0,窗口在整个过程中永远不会收缩。

这些边界用例不仅能帮你验证代码正确性,也能帮你在面试中展示严谨性。我一般会单独写一个测试函数循环测试这些用例,这比一步步调试更快。

6. 从209题出发:滑动窗口题型的通用套路

6.1 一个可以举一反三的框架模板

滑动窗口问题虽然变化多端,但其核心框架是高度统一的。做题做多了,我总结出一个模板化的写法,适用于绝大多数窗口类题目:

def slidingWindow(nums, k): # 1. 初始化左右指针和窗口状态 left = 0 state = ... # 窗口内的累计状态,可能是和、频率、集合等 ans = ... # 2. 主体循环:右指针扩张 for right in range(len(nums)): state = update(state, nums[right]) # 扩充窗口,更新状态 # 3. 条件不满足时,左指针收缩 while invalid(state, nums, left): state = remove(nums[left]) # 移除左指针元素,更新状态 left += 1 # 4. 更新答案:注意答案可能在收缩前、收缩中、或收缩后更新,因题而异 ans = update(ans, right - left + 1) return ans

难点在于第二步和第三步之间的“答案更新时机”——不同题目有不同的更新时机。以 209 题来说,答案在收缩过程中更新,因为每收缩一次,窗口可能仍然满足条件且变得更短。而像“无重复字符的最长子串”,答案则在收缩完成后(保证窗口无重复)更新。吃透这个答案更新时机,是滑动窗口从入门到进阶的分水岭。

6.2 类似的变式题目与思路迁移

209题做熟之后,可以顺手刷这几道关联题,帮助建立题型认知:

  • LeetCode 76 最小覆盖子串:给定一个字符串和模式串,找到包含模式串所有字符的最短子串。这个题的窗口收缩条件变成了“窗口内字符覆盖模式串的所有字符”,需要维护两个哈希表来计数。
  • LeetCode 904 水果成篮:这道题把窗口限制改成“最多只能有两种不同元素”,收缩条件是“窗口内不同元素种类大于 2”。
  • LeetCode 3 无重复字符的最长子串:收缩条件是“窗口内有重复字符”。

这些题目乍看各不相同,但本质上都是同一套模板:右指针不断扩展,左指针根据某个条件收缩,答案在某个时机更新。209题是理解这套模板的最佳起点,因为它没有任何哈希表、字符计数等附加维度,纯靠累加和就能驱动窗口滑动。

6.3 个人刷题体会和一个小技巧

这道题我第一次做的时候,用的是暴力解法,提交超时,然后看题解学到滑动窗口,又隔了一周重新自己写,结果while循环里顺序写反了(先收缩再记录长度),导致一个简单的测试用例都过不了。这次失败让我养成一个习惯:遇到窗口类题目,先在草稿纸上画三五行元素的窗口滑动过程,标注清楚“记录长度”的那一步发生在哪一个状态。

具体画法是这样:把数组写在一条数轴上,用方括号标出 left 和 right,每次右指针移动一格,画一个新状态;一旦进入 while 收缩,每收缩一格也画一个新状态;在检查每个状态时用笔在旁边圈出“此时是否满足条件?如果满足,当前窗口长度是多少?”。

这个过程看起来麻烦,但对初学者非常有效——它能把代码里的循环逻辑还原成可视化的状态转移,一旦代码行为和你的手推过程不一致,你就能立刻定位到是收缩顺序出错还是答案更新时机出错。

我现在刷题仍然保留这个习惯,甚至对一些复杂的双指针题,会在白板上画出整个状态序列再写代码。事实证明,大多数边界错误都是在画图阶段就能暴露的,而不用等到提交之后被测试用例打回。这也算是我从 209 题这道“简单题”身上,收获到的最有价值的经验。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询