[略——开始正文]
先说我为什么要把“算法题-27”单独拎出来复盘。它不是一道特别难的题,但回顾它的过程比当初 AC 的瞬间更有价值:一道连续子数组求和的问题,串起了暴力枚举、前缀和加二分查找、滑动窗口三条优化路径。这篇文章会给你完整可跑的代码、提交时我真实踩过的坑,以及面试时怎么把“为什么这样优化”讲清楚。适合刷题新手、准备算法面试的人,也适合想系统理解经典题型的人。
1. 这道题到底在考什么:读题比动手更值钱
我把题目原样贴在这里:给定一个只包含正整数的数组nums和一个正整数target,返回满足“子数组和 ≥ target”的最短连续子数组长度;如果不存在,返回 0。
这类题有个特点:题干特别短,限制条件只有“正整数”三个字,但恰恰是这三个字决定了解法的走向。我见过不少人在这一步栽跟头,忽略掉它,直接套前缀和和二分,然后被负数数据搞挂。
1.1 拿到题先做三件事
第一件事,确认数据规模。如果n ≤ 2000,双层循环暴力枚举可以直接过;如果n = 10^5,就必须想O(n log n)甚至O(n)的做法。几乎所有算法题的第一道坎都是数据规模,不是思路本身。
第二件事,确认输出语义。这题要求“不存在返回 0”,不是返回 -1,也不返回数组长度。空数组也返回 0。这种细节在面试时特别容易被追问。
第三件事,确认“正整数”这个条件不是摆设。它保证了前缀和数组严格递增,也保证了后面滑动窗口一定能用。如果把数组换成有负数的情况,同样的解题思路会瞬间失效。
1.2 为什么这类题高频
连续子数组问题在面试里出现频率极高,变体也很多:求和等于某个值的子数组数量、和不超过 target 的最长长度、和大于等于 target 的最短长度、乘积最大或最小的子数组……剥开外壳,内核都是同一件事:维护一段连续区间,并快速判断这段区间是否满足条件。
把这一题吃透,等于把一整个题型都串起来了。这也是我不建议只背题解的原因,曡题不如曡“模式”。
2. 先写暴力解法:用 O(n²) 换来“完全确定正确性”
2.1 暴力代码与第一次复杂度判断
先写最直觉的版本:枚举左端点i,然后从i开始向右扩展右端点j,边扩展边累加,一旦累加和达到target,就记录长度并跳出内层循环。
int minSubArrayLen(int target, vector<int>& nums) { int n = nums.size(); int ans = n + 1; for (int i = 0; i < n; ++i) { int sum = 0; for (int j = i; j < n; ++j) { sum += nums[j]; if (sum >= target) { ans = min(ans, j - i + 1); break; } } } return ans == n + 1 ? 0 : ans; }这里有一个细节:因为数组全是正整数,sum随着j变大只会越来越大,所以一旦满足sum >= target,当前i就不用继续往后扫了。这个break能剪掉不少内层循环,但最坏情况下(比如整个数组加起来都不到target),每个i都会一路扫到数组末尾,所以时间复杂度依然是O(n²)。
有人会问:为什么不用三重循环枚举所有区间?因为三重循环会反复做大量无意义的求和运算。我们只需要在扩展右端点时把sum累加进去,就能一次性覆盖所有以i开头的区间,根本不需要第三层循环重新求和。这是暴力解法里最值得记住的一个小优化思路。
2.2 复杂度计算:什么时候用 O,什么时候用 Θ
聊复杂度之前,先厘清一个概念:O表示上界,Θ表示紧密界。面试里我们习惯说“这个算法是 O(n²)”,严格来讲这句话只说了“它不会比 n² 慢太多”,并没有说明它到底是不是一定在 n² 这个量级。
拿暴力代码举例:内层循环因为有break,并不是每个i都会跑满n - i次。最好情况下,第一个元素就满足条件,很快结束;最坏情况下,整个数组和小于target,每个i都扫到末尾。所以严格说,这段代码是O(n²)上界,下限是Ω(n),两者不相等,因此不能直接说它是Θ(n²)。而滑动窗口版本,因为每个元素最多被left和right各访问一次,所以既有O(n)上界又有Ω(n)下界,才能放心地说是Θ(n)。
这个区分在面试里是加分项。面试官问到“这个算法复杂度是多少”时,能主动说出“最坏 O(n²),但因为有 break 实际平均会好一些”,比干巴巴一句“O(n²)”可信得多。
重要的是用数据说服自己必须优化:n = 10^5时,O(n²)是10^10次累加,按每秒10^8次运算估算,需要 100 秒,这在竞赛或面试现场都完全不可接受。
3. 前缀和 + 二分:先学会把“区间和”变成“两个端点的差”
3.1 关键推导
暴力解法慢在每次都要重新累加一段区间。如果能提前算好前缀和,那就把“区间和”转换成了“两个端点的差”:定义prefix[i]表示nums[0..i-1]的和,那么子数组nums[i..j-1]的和就是prefix[j] - prefix[i]。
题目要求区间和 ≥ target,也就是prefix[j] - prefix[i] >= target,移项后变成prefix[j] >= prefix[i] + target。由于数组全是正整数,prefix严格递增,我们可以用二分查找,快速找到第一个满足prefix[j] >= prefix[i] + target的j。这就是前缀和+二分的优化逻辑。
3.2 代码实现与边界注意
int minSubArrayLen(int target, vector<int>& nums) { int n = nums.size(); vector<long long> prefix(n + 1, 0); for (int i = 0; i < n; ++i) { prefix[i + 1] = prefix[i] + nums[i]; } int ans = n + 1; for (int i = 0; i < n; ++i) { long long need = prefix[i] + target; auto it = lower_bound(prefix.begin() + i + 1, prefix.end(), need); if (it != prefix.end()) { int j = it - prefix.begin(); ans = min(ans, j - i); } } return ans == n + 1 ? 0 : ans; }这里有两个容易出问题的细节。第一,prefix的长度是n + 1,prefix[0] = 0必须保留,它表示空区间的前缀和。如果不保留,从下标 0 开始的子数组就找不到了。第二,内层循环里lower_bound的起点必须从i + 1开始,保证子数组非空,否则可能找到一个长度为 0 的“空区间”当作答案。
3.3 大多数人会忽略的整数溢出
我最初用int存prefix,本地小样本测试一切正常,提交后一个大用例直接失败。原因是n = 10^5,每个元素值达到10^9,prefix[n]超过int上限。这种问题不是逻辑错误,而是类型选择错误,特别隐蔽,往往只在极限数据下触发。
建议:涉及累加和、乘积这类数值的题目,一律优先考虑long long。这不是过度设计,是提前排雷。很多在线判题系统给出的边界数据就是专门敲打这种粗心大意的。
4. 滑动窗口 O(n):把左右指针当成动态区间
4.1 双指针为什么能到 O(n)
前缀和+二分已经是O(n log n),但对这道题来说还不是终点。仔细观察会发现,两个指针left和right在整个过程中只会各自向右移动,每个元素最多被left访问一次、被right访问一次,所以整体是严格的O(n)。
核心思路就两句话:右指针负责扩展窗口,让窗口和变大;一旦窗口和满足条件,左指针负责收缩窗口,尝试让窗口变短。每次满足条件时记录一下当前窗口长度,最后就能得到最短长度。
4.2 滑动窗口代码
int minSubArrayLen(int target, vector<int>& nums) { int n = nums.size(); int left = 0, ans = n + 1; long long sum = 0; for (int right = 0; right < n; ++right) { sum += nums[right]; while (sum >= target) { ans = min(ans, right - left + 1); sum -= nums[left]; ++left; } } return ans == n + 1 ? 0 : ans; }如果你更习惯 Python,逻辑完全一致:
def min_sub_array_len(target, nums): n = len(nums) left = 0 ans = n + 1 total = 0 for right in range(n): total += nums[right] while total >= target: ans = min(ans, right - left + 1) total -= nums[left] left += 1 return 0 if ans == n + 1 else ans注意ans的初始值我设成了n + 1。这是一个常见的技巧:因为最短长度不可能超过n,所以用一个“比所有可能答案都大”的初始值,最后判断ans是否仍是n + 1,就能知道到底有没有找到合法区间。如果初始值设成0或者INT_MAX,要么没法做“是否存在”的判断,要么多一层比较逻辑,容易混乱。
4.3 为什么这个题必须是正整数
滑动窗口能工作的前提,是窗口在“收缩”时不会出现异常:当左指针向右移动,窗口区间变短,即使移出一个负数导致窗口和变大,窗口的逻辑就崩了,因为“更短的区间”可能反而满足条件,双指针就没法保证当前窗口是候选最短区间。
一旦数组包含负数,正确的做法一般是前缀和配合哈希表或平衡树,去查找满足条件的prefix[j] - prefix[i],思路会复杂一个档次。因此,题目限定“正整数”不是在为难你,而是在给你发一个“滑动窗口可用”的信号。面试时主动说出这一点,说明你真的理解了这个算法,而不是背下来的模板。
4.4 三种解法对比
| 解法 | 时间复杂度 | 空间复杂度 | 适用条件 |
|---|---|---|---|
| 暴力枚举 | O(n²) | O(1) | 数据规模很小,适合验证正确性 |
| 前缀和+二分 | O(n log n) | O(n) | 数组元素非负,前缀和单调递增 |
| 滑动窗口 | O(n) | O(1) | 数组元素非负,且需要找最短/最长连续子数组 |
从表格可以清楚看到,滑动窗口在时间、空间上都占优,但它的适用条件是三者中最苛刻的。实际面试中,先分析条件、再选算法,永远比上来就写最优解安全。
5. 提交后的踩坑复盘:不是一次就能 AC
5.1 我的完整排查链路
这道题我并没有一次通过。复盘时的顺序大概是这样的,你可以直接参考这条思路来排查自己的代码。
第一次,暴力版本地测试通过。用小的随机数组和后手验证几个用例,一切正常,但提交直接超时。这一步说明逻辑没错,瓶颈在复杂度。
第二次,我尝试在内层循环加判断条件提前退出,比如当sum + 剩余元素和 < target时直接跳过。这种剪枝对大数据有一定效果,但最坏情况依然超时。说明这类优化只是“治标”,并没有改变复杂度级别。
第三次,改用前缀和+二分后,小数据过了,大数据挂掉。我一开始以为是二分边界写错,后来把前缀和数组打印出来才发现,prefix[n]已经溢出了int。把prefix改成long long后,这个版本的用例全部通过。
第四次,滑动窗口版本,逻辑改好后又踩了一个“无解返回值”的坑。空数组时ans保持n + 1 = 1,我不小心直接返回了1,正确结果应该返回0。这个 bug 让我意识到:任何边界条件下,都要想清楚“不存在”和“存在但长度为 1”两种情况。
5.2 调试时打印什么
如果滑动窗口出了问题,我建议先打印right、left、sum和ans四个关键变量。比如在while循环收缩窗口前加一行输出:
printf("right=%d left=%d sum=%lld new_len=%d\n", right, left, sum, right - left + 1);你会看到窗口状态的变化过程:right一直在递增,left在满足条件后追赶right,ans不断被更小的值覆盖。如果某个用例下left突然超过了right,说明while的收缩条件写错了,或者数组里有不符合预期的负数值。
5.3 边界用例清单
我自己整理了一份针对这道题的边界用例表,每次写完代码都用它验证一遍:
| 输入 | target | 期望输出 | 说明 |
|---|---|---|---|
[] | 5 | 0 | 空数组直接返回 0 |
[10] | 5 | 1 | 单个元素就满足条件 |
[1, 2, 3] | 100 | 0 | 所有元素和不够,不存在合法区间 |
[1, 2, 3] | 6 | 3 | 整个数组刚好满足 |
[2, 3, 1, 2, 4, 3] | 7 | 2 | 标准示例,答案是[4,3] |
[1, 1, 1, 1] | 1 | 1 | 第一个元素就满足,最短长度是 1 |
这些用例覆盖了空输入、单元素、无解、刚好满足、标准示例几种情况,能拦住大部分低级错误。
6. 一道题铺开一张网:算法工程师该掌握的知识脉络
6.1 从这道题往外扩展的算法地图
刷题最忌讳孤立地做题。把“算法题-27”放在整个算法体系里看,它至少能牵引出这些方向:
| 方向 | 代表算法 | 典型应用场景 |
|---|---|---|
| 排序 | 归并排序、堆排序、快速排序 | TOP K、前 K 大、逆序对 |
| 字符串 | KMP、字典树 | 模式匹配、前缀统计 |
| 图论 | Tarjan、匈牙利算法 | 强连通分量、二分图匹配 |
| 搜索 | DFS、BFS、剪枝、A* | 暴力枚举的进阶形态 |
| 动态规划 | 背包问题、区间 DP | 贪心无法保证的场景 |
| 机器学习 | 聚类、随机森林、深度学习 | 特征工程、模型训练 |
你会发现,很多看起来毫无关联的算法,底层思维是相通的。比如 KMP 强调的是“已经匹配过的信息不要重复计算”,滑动窗口强调的是“已经计算过的区间信息不要重复扫描”,前缀和强调的是“区间信息提前预处理”,本质上都是同一个思路:用空间换时间,消除重复计算。
日常工程里也是如此。嵌入式领域常见滑动平均滤波、PID 控制,机器学习领域常见聚类和随机森林,这些都不是算法题里凭空冒出来的概念,而是同一套复杂度分析思想在不同领域的落地。
6.2 面试官视角:代码能 AC 只是起点
我面试过不少候选人,最怕的不是算法题不会做,而是上来就背模板式写代码。代码 AC 只能证明他记住了这道题,不能证明他理解这个算法。
作为面试者,正确的表现方式是先确认数据规模和边界条件,然后说“我先给一个暴力解法验证正确性”,再逐步分析瓶颈在哪里,最后落到最优解。哪怕最终解法不是最优,你完整展示了思考链路,面试官反而更认可。
如果你要准备算法面试,我建议你练完一道题后,多问自己三个问题:这个解法为什么在这个数据规模下可行?它的局限性是什么?如果去掉题目的某个限制条件,解法会怎么变化?这三个问题很多候选人答不上来,但恰恰是面试官最爱问的。
我在“算法题-27”上最大的收获,不是代码通过了一个 case,而是终于把“为什么滑动窗口必须配正整数数组”这种以前半懂不懂的问题彻底想明白了。如果你也困在某个瓶颈期,建议把你最近做的每道题都用同样的方式复盘一遍:暴力解法写出来、优化路径讲出来、边界用例列出来。不用贪多,一周彻底吃透两三道,比囫囵吞五十道有效得多。