离了滑动窗口还真不行?——很多人在算法面试里遇到“寻找和为K的子数组”这道题,第一反应就是套双指针。我第一次认真做这道题是在一次模拟面试里,原题说给一个整数数组 nums 和一个整数 k,要统计满足 nums[i..j](i <= j)的和恰好等于 k 的连续子数组个数。我脱口而出“滑动窗口”,结果被面试官一句话堵回来:“数组里有负数,你怎么滑?”这题核心不是窗口,而是“前缀和 + 哈希表”。今天我把整个解题过程、原理推导、常见翻车点一次性说清楚。
1. 先看懂题:为什么不能无脑套滑动窗口
1.1 题面拆解与输出口径
这题在算法题库里通常长这样:给定整数数组 nums 和一个整数 k,请你返回数组中和为 k 的连续子数组的个数。这里有几个容易被忽略的细节:
- “子数组”指的是连续的区间,不是随便从数组里挑几个数;
- 区间长度至少是 1,也就是说空子数组不算数;
nums里可能有正数、负数,还可能包含 0;- 返回值是满足条件的区间数量,不是返回区间本身。
举个例子,nums = [1, 2, 3, -2, 2],k = 3。把所有和为 3 的连续子数组列出来:
[1, 2],下标 0 到 1;[3],下标 2 到 2;[2, 3, -2],下标 1 到 3;[3, -2, 2],下标 2 到 4。
总共有 4 个,答案就是 4。
很多人会混淆“子数组”和“子序列”,子序列可以不连续,子数组不行。如果题目改成“和为 K 的子序列”,那就是另一类完全不同的题,很可能要走动态规划或背包思路。所以拿到题第一步,先把“连续区间”这个定义钉死。
1.2 为什么负数的出现让很多常见模板失效
滑动窗口通常用于求解“满足某种条件的连续子数组”问题,比如无重复字符的最长子串、长度最小的子数组。这个模板通用成立的前提是:窗口向右扩张时,窗口内的累加和单调不减;窗口左边界向右收缩时,窗口累加和单调不增。如果数组里全是非负数,这个前提成立,双指针就能用。
但是一旦数组里出现负数,窗口内累加和就不是单调的了。我举个最直白的例子:nums = [3, 1, -2, 4],k = 2。按滑动窗口的思维,右指针一路往前走,一旦窗口和超过 2 就收缩左边界,你会发现无论如何都很难覆盖到 [3, 1, -2] 这样的区间,因为它在窗口扩到 -2 时总和才从 4 降到 2,你根本没法预测“前面多加的负数会不会把总和拉回来”。负数让窗口失去了单调性,滑动窗口模板在负数数组面前基本是废的。
这也是面试官喜欢考这题的原因之一:它考察的不是“你背了多少模板”,而是你能不能跳出固定套路,在一个看似可以用双指针的场景里识别出真正的瓶颈,然后自己构造出一个能用哈希表优化的数学模型。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从三层循环到前缀和:解法演进的关键一步
2.1 最直接的三重枚举能不能过
面对区间和问题,最朴素的想法是枚举左右端点:外层循环固定左端点 i,内层循环枚举右端点 j,再写一层循环把 i 到 j 之间的元素全部加起来,判断是否等于 k。
用代码描述大概是这样:
cpp复制int count = 0;
for (int i = 0; i < nums.size(); ++i) {
for (int j = i; j < nums.size(); ++j) {
int sum = 0;
for (int p = i; p <= j; ++p) sum += nums[p];
if (sum == k) ++count;
}
}
三层循环的时间复杂度是 O(n^3)。n 稍微大一点,比如 10^4 级别,直接跑几亿到几万亿次运算,超时是必然的。别笑,我在很多人提交的代码里见过这种写法,多半是刚学数组时留下的思维惯性。
稍微优化一下,可以在枚举右端点 j 时,用一个累加变量 sum 承接当前区间的和,每次 j 往后扩一格就把新元素加进去,省掉最内层循环,变成 O(n^2):
cpp复制int count = 0;
for (int i = 0; i < nums.size(); ++i) {
int sum = 0;
for (int j = i; j < nums.size(); ++j) {
sum += nums[j];
if (sum == k) ++count;
}
}
这个版本可以用,但面试官往往不会满意。只要数组长度到 10^5 以上,O(n^2) 依然会超时。必须找出 O(n) 的办法。
2.2 用前缀和把区间和变成两个数相减
“前缀和”是解决连续区间和问题的一把通用钥匙。定义一个数组 pre,其中 pre[i] 表示 nums[0..i-1] 的前缀和,边界上让 pre[0] = 0,代表空前缀的和。比如 nums = [1, 2, 3, -2, 2],对应的前缀和数组是:
text复制pre[0] = 0
pre[1] = 1
pre[2] = 3
pre[3] = 6
pre[4] = 4
pre[5] = 6
这样从下标 i 到下标 j(假设都从 0 开始)的子数组和,就能直接写成:
text复制nums[i..j] 的和 = pre[j+1] - pre[i]
为什么?因为 pre[j+1] 是 nums[0..j] 的和,pre[i] 是 nums[0..i-1] 的和,两者一减,中间重叠的前缀部分全部抵消,剩下的正好是 nums[i..j]。这就像记流水账:你把从第一天开始累计花的钱都记下来,想知道某几天花了多少,只需要拿后面某天的累计数减去前几天某天的累计数,中间那几天的开销自然就出来了。
有了这个公式,原问题就等价于:有多少对下标 (i, j) 满足 i < j,且 pre[j] - pre[i] == k。注意,这里的边界在下标写法上略有区别,但本质是一样的。
2.3 谁和谁配对,才是优化真正的突破口
如果只知道前缀和,还是得双重循环枚举所有 i < j 的配对,复杂度仍然是 O(n^2)。真正的转折在于:我们可以把等式改写一下,变成:
text复制pre[i] == pre[j] - k
也就是说,当我站在位置 j 上,手里拿着当前的 pre[j],我关心的不是“前面所有前缀和都和我比一遍”,而是“前面有没有出现过值等于 pre[j] - k 的前缀和,出现了几次”。
这个视角一换,问题就从“两两配对”变成了“查表”。我只需要一边从左往右扫描数组,一边维护一个动态的哈希表,记录已经扫过的前缀和值各出现过多少次。每到一个新位置,先查一下 pre[j] - k 在不在表里,如果在,说明之前存在某些起点,能让从那个起点到当前位置这一段的和正好等于 k;然后把当前的 pre[j] 也加入哈希表,继续往后走。
这就是“前缀和 + 哈希表”能够把时间复杂度压到 O(n) 的真正原因,也是整道题最核心的思维跃迁。
3. “前缀和 + 哈希表”的标准实现与完整推演
3.1 算法流程一句话概括
维护两个东西:一个变量 current 记录当前前缀和,一个哈希表 map 记录每个前缀和值出现的次数。每遍历到一个数,就把它加到 current 上,然后查 map 里有多少个 current - k,有多少就让答案加上多少;最后再把当前的 current 放进 map。
这个流程写成伪代码:
text复制初始化 map,并让 map[0] = 1
current = 0
answer = 0
for num in nums:
current += num
answer += map[current - k]
map[current] += 1
return answer
最关键的一步是初始化时一定要写入 map[0] = 1。这一步代表“空前缀”出现了 1 次。否则,当 current - k == 0 时,也就是整个从数组开头到当前位置的区间和刚好等于 k 的情况,会因为查不到 0 而被漏掉。
3.2 用一个小例子跑完整张哈希表
还是用 nums = [1, 2, 3, -2, 2],k = 3。我们一步步把哈希表的内容写出来,看答案是怎么累加的:
| 下标 | 当前元素 | current | 查询 current - k | 哈希表中该值的出现次数 | 累加后 answer | 更新后的 map |
|---|---|---|---|---|---|---|
| 初始化 | - | 0 | - | - | 0 | |
| 0 | 1 | 1 | -2 | 0 | 0 | |
| 1 | 2 | 3 | 0 | 1 | 1 | |
| 2 | 3 | 6 | 3 | 1 | 2 | 再加 |
| 3 | -2 | 4 | 1 | 1 | 3 | 再加 |
| 4 | 2 | 6 | 3 | 1 | 4 | 再加 6 的计数 |
走到下标 1 时,current = 3,查询 current - k = 0,哈希表里 0 出现 1 次,答案变成 1,对应的是子数组 [1, 2]。
走到下标 2 时,current = 6,查询 3,哈希表里 3 出现 1 次,答案变成 2,对应子数组 [3]。
走到下标 3 时,current = 4,查询 1,哈希表里 1 出现 1 次,答案变成 3,对应子数组 [2, 3, -2]。
走到下标 4 时,current = 6,查询 3,哈希表里 3 出现 1 次,答案变成 4,对应子数组 [3, -2, 2]。
最终答案是 4,和手算一致。这个推演过程很有价值,如果你能把每一行的“查询值”和“命中的是哪个历史起点”对应上,就说明你真的吃透了这道题,而不是背下了一段模板。
3.3 两种语言的参考实现
C++ 版本:
cpp复制class Solution {
public:
int subarraySum(vector<int>& nums, int k) {
unordered_map<long long, int> cnt;
cnt[0] = 1;
long long current = 0;
int answer = 0;
for (int num : nums) {
current += num;
// 先查表,再更新当前前缀和的计数
if (cnt.find(current - k) != cnt.end()) {
answer += cnt[current - k];
}
cnt[current]++;
}
return answer;
}
};
Python 版本:
python复制def subarray_sum(nums, k):
cnt = {0: 1}
current = 0
answer = 0
for num in nums:
current += num
answer += cnt.get(current - k, 0)
cnt[current] = cnt.get(current, 0) + 1
return answer
两种写法结构完全一致。代码短不代表容易,真正容易错的地方藏在“先查后更新”和“初始化 0:1”这两个细节里,我在下一节展开说。
3.4 三个必须刻进脑海的细节
第一个细节:必须先查表,再把当前 current 的频率加进哈希表。如果反过来,先执行 cnt[current]++ 再查 cnt[current - k],当 k = 0 的时候,当前这个位置自身产生的前缀和会被当成“历史前缀”查出来,等于把长度为 0 的空区间也算进去了。题目要求子数组至少包含一个元素,所以必须保证查询时哈希表里只有严格早于当前位置的前缀和。顺序一旦写反,nums = [0, 0, 0]、k = 0 这种样例很容易算错。
第二个细节:哈希表的 value 存的是出现次数,不是布尔值。因为同一前缀和可能来自多个不同的起点。比如数组 [0, 0, 0],前缀和 0 出现过很多次,如果 value 只存“出现过”,很多组合会被漏掉。这就像你查地图时,某个公交站有多条线路经过,只记住“有车来”不够,你得记住有多少条线,才知道有多少种上车方式。
第三个细节:前缀和要用更大的整数类型保存。C++ 用 long long,Java 用 long,避免极端数据下累加溢出。很多人在力扣这类平台上用 int 也能通过,是因为测试数据不够极端,但面试官追问边界时,这个点往往是加分项。
4. 面试题不是只有这一种考法
4.1 变体一:只要返回一个有效子数组
有些题目不要求统计数量,只要求返回任意一个和为 k 的连续子数组的起止下标。这时候哈希表不用记录“出现次数”,改成记录“某个前缀和第一次出现的最早下标”。因为只求一个可行解,最早下标带来的区间一定有效。
核心思路是:扫描到位置 i 时,如果 current - k 已经在哈希表里,并且对应的位置是 start,那么 [start + 1, i] 就是一个满足条件的区间。
代码片段如下:
cpp复制pair<int, int> findSubarray(vector<int>& nums, int k) {
unordered_map<long long, int> firstPos;
firstPos[0] = -1;
long long current = 0;
for (int i = 0; i < nums.size(); ++i) {
current += nums[i];
if (firstPos.count(current - k)) {
return {firstPos[current - k] + 1, i};
}
if (!firstPos.count(current)) {
firstPos[current] = i;
}
}
return {-1, -1}; // 找不到
}
这个版本里,我记录的是最早出现位置,并且只在第一次出现时写入,这样才能保证返回的区间尽量短且下标可用。如果数组全是正整数,其实也可以用滑动窗口找第一个可行解,但为了兼容负数,哈希表方法最稳。
4.2 变体二:求最长的和为 K 的子数组长度
这类题在笔试题里也经常变着法出现,比如“求和为 K 的最长连续子数组长度”。思路和上面很像,还是用哈希表记录“前缀和第一次出现的下标”,然后每到一个位置就尝试更新最大长度。
代码片段如下:
cpp复制int maxSubarrayLength(vector<int>& nums, int k) {
unordered_map<long long, int> firstPos;
firstPos[0] = -1;
long long current = 0;
int maxLen = 0;
for (int i = 0; i < nums.size(); ++i) {
current += nums[i];
if (firstPos.count(current - k)) {
maxLen = max(maxLen, i - firstPos[current - k]);
}
if (!firstPos.count(current)) {
firstPos[current] = i;
}
}
return maxLen;
}
注意这里有个不同点:哈希表记录的不是频次而是最早下标,因为求最长时我当然希望起点越靠前越好,最早下标能天然给出最长距离。如果记录最新下标,长度就会被压缩,可能丢掉最优解。
4.3 变体三:如果数组全为正数,滑动窗口才是 O(1) 空间的最优解
当题目额外限定“数组元素均为正整数”时,滑动窗口重新变成可选方案,而且空间复杂度能降到 O(1)。因为此时窗口和具备单调性,右指针扩张时和只增不减,左指针收缩时和只减不增。
思路是用两个指针维护当前窗口,右指针逐步扩大范围,一旦窗口和超过 k,就移动左指针,直到窗口和不大于 k;如果窗口和刚好等于 k,计数加一。
cpp复制int subarraySumPositiveOnly(vector<int>& nums, int k) {
int left = 0;
long long windowSum = 0;
int answer = 0;
for (int right = 0; right < nums.size(); ++right) {
windowSum += nums[right];
while (windowSum > k) {
windowSum -= nums[left];
++left;
}
if (windowSum == k) {
++answer;
}
}
return answer;
}
但这个方案有一个非常明显的前提:所有元素必须是正数。只要数组里允许出现 0 或负数,窗口和就不具备单调性,上面的写法要么漏解,要么无限循环或逻辑混乱。我建议你在面试时先向面试官确认数组元素范围,再决定要不要提滑动窗口。否则原题场景下你答滑动窗口,大概率会被认为审题不清。
4.4 变体四:前缀和有序的陷阱
还有一种很容易踩坑的变体:有人觉得前缀和可以预处理出来,然后排个序,再用二分法找等于某个值的“前缀和配对”。这种做法在数学上很诱人,但实际是错的。因为子数组要求左端点在右端点之前,也就是说配对的前缀和存在先后顺序。一旦你把前缀和数组排序,下标顺序就全部打乱了,哪怕你二分查到了某个值,也无法保证这个值对应的位置早于当前终点。
举个例子,前缀和数组可能是 [0, 3, 1],排序后变成 [0, 1, 3],原来的相邻关系被破坏了。排序后的配对极有可能把“后面的前缀和”当成“前面的前缀和”来用,从而错误地构造出根本不存在的区间。所以遇到区间和问题,前缀和只能和哈希表结合使用,不能拍脑袋排序后二分。
5. 常见错误与脑裂时刻:我把这些坑都踩过一遍
5.1 高频错误速查表
下面这些错误,是我实际看过大量代码后总结出来的高频翻车点,很多看起来只是差一行代码,结果却天差地别。
| 错误类型 | 典型表现 | 原因分析 | 正确做法 |
|---|---|---|---|
忘了初始化 map[0] = 1 |
漏掉从数组开头到某个下标区间和为 k 的情况 |
current - k = 0 时查不到空前缀 |
在循环开始前写入 map[0] = 1 |
| 先更新频次再查表 | k = 0 时多算区间,把空子数组也算进去 |
当前前缀和自己被当成历史前缀 | 严格先执行查询,再执行 map[current]++ |
| 哈希表 value 用布尔值 | 相同前缀和多次出现时漏算 | 多个起点可能对应同一个前缀和值 | 用整数记录出现次数 |
| 前缀和用 int 存储 | 极端数据下溢出,答案错误 | 累加和可能超过 int 范围 | 使用 long long 或 long |
| 原题场景用滑动窗口 | 负数出现导致窗口和失去单调性,结果错得隐蔽 | 窗口模板的适用前提不成立 | 改用“前缀和 + 哈希表” |
| 子数组起点判断错误 | 把当前遍历到的元素也纳入历史前缀 | 对 i <= j 的边界理解不到位 |
查询必须在更新前发生 |
对 k = 0 的测试数据跑不对 |
答案等于或小于正确结果 | 空区间混入,或前缀和为 0 的历史起点没被正确计数 | 单独用 [0,0,0] 这类样例验证 |
5.2 边界条件和测试用例清单
写算法题最怕“看着对,一测就挂”,本质是边界没覆盖全。我每次写完这道题,都会用下面这些用例过一遍:
- 空数组:
nums = [],任何k都应该返回 0; - 单元素数组恰好命中:
nums = [5], k = 5,应返回 1; - 单元素数组不命中:
nums = [3], k = 5,应返回 0; - 全 0 数组:
nums = [0, 0, 0], k = 0,应返回 6; - 全负数:
nums = [-1, -2, -3], k = -3,子数组[-1, -2]和[-3]共 2 个; - 前缀和多次重复:
nums = [1, -1, 1, -1], k = 0,答案需要手工推演,很容易测出“用布尔值当 value”的问题; - 极大数据:
nums中包含10^9级别的数,验证前缀和会不会溢出。
[0, 0, 0] 为什么答案会是 6?连续子数组包括:长度为 1 的 3 个,长度为 2 的 2 个,长度为 3 的 1 个,总共 6 个。用哈希表法跑一遍,初始化 map[0] = 1,每扫过一个 0,前缀和仍然为 0,查询到的历史 0 的个数会从 1 变成 2 变成 3,最后累加 1 + 2 + 3 = 6,分毫不差。
5.3 如何快速验证自己的写法正确
最有效的验证方式不是只跑平台上的测试用例,而是写一个简单的 O(n^2) 暴力版本,再用随机数组对拍。数组长度设小一点,比如从 1 到 20,元素范围在 -10 到 10 之间,随机生成几千组,对比两个版本的结果。
我自己的习惯是写一个小的 Python 脚本,暴力版本长这样:
python复制def brute(nums, k):
n = len(nums)
ans = 0
for i in range(n):
s = 0
for j in range(i, n):
s += nums[j]
if s == k:
ans += 1
return ans
再写一个哈希版本:
python复制def fast(nums, k):
cnt = {0: 1}
cur = 0
ans = 0
for num in nums:
cur += num
ans += cnt.get(cur - k, 0)
cnt[cur] = cnt.get(cur, 0) + 1
return ans
然后随机生成测试数据,如果两个函数对拍结果完全一致,基本可以确定实现没问题。这个过程比单纯背答案有用得多,因为它能逼着你发现那些“看起来合理但其实是错的”的隐性问题。
6. 从一维数组到二叉树路径:这套模型能延伸到哪
6.1 换一层包装:统计二叉树里和为目标值的路径条数
“前缀和 + 哈希表”这套模型并不局限在一维数组。很多读者可能已经想到了,它就是经典题“路径总和 III”的底层思路:给定一棵二叉树和一个目标值 targetSum,统计树中路径和等于目标值的路径数量。这里的路径要求是从某个节点向下到另一个节点,方向只能从父节点到子节点,不需要一定经过根节点。
在二叉树里,从根节点出发到当前节点的过程中,我们可以维护一个“根到当前节点”的累计和,这其实就是一条纵向的前缀和链。递归进入左子树或右子树前,先查一下当前累计和减去 targetSum 在哈希表里出现过多少次,再把当前累计和加进哈希表;退出这个节点时,把计数减回去,防止影响另一条分支。
C++ 递归版本可以这样写:
cpp复制class Solution {
public:
unordered_map<long long, int> cnt;
int answer = 0;
int pathSum(TreeNode* root, int targetSum) {
cnt[0] = 1;
dfs(root, 0, targetSum);
return answer;
}
void dfs(TreeNode* node, long long current, int targetSum) {
if (!node) return;
current += node->val;
if (cnt.count(current - targetSum)) {
answer += cnt[current - targetSum];
}
cnt[current]++;
dfs(node->left, current, targetSum);
dfs(node->right, current, targetSum);
// 回溯,避免把当前路径的信息带到兄弟分支
if (--cnt[current] == 0) {
cnt.erase(current);
}
}
};
这里有个细节和数组版不同:二叉树场景里,哈希表记录的是“从根到某个祖先节点的累计和出现了几次”。因为路径必须是从父节点向下走的,祖先节点的前缀和只能来自搜索栈里的当前路径,所以退出当前节点时要把贡献撤销,否则左子树的路径信息会污染右子树的判断。
6.2 为什么“前缀和 + 计数”是一种能迁移的建模思路
如果把数组也看成一种特殊的“链表”,那树路径问题本质上和数组子数组问题是同一个模型:在一段需要保持“先后顺序”的节点序列中,寻找某一段的区间和等于目标值。数组里的顺序是线性的,树里的顺序是纵向祖先链。两者都利用了前缀和的差来消除中间无关元素。
一旦把这个模型刻在脑子里,看到新题时你的第一反应就会从“枚举哪些元素相加等于 k”变成“维护一个动态前缀和,并用哈希表完成历史查询”。这套思路还能继续延伸到很多其他场景,比如“前缀和对 K 取模后相同的区间可以被 K 整除”“二维矩阵中求和为 K 的矩形区域”等。核心一点都没变:区间和问题,在能维护前缀顺序的前提下,优先考虑用做差 + 哈希表把 O(n^2) 的枚举降成 O(n) 的查询。
按照我自己带人的经验,这道题最忌讳的并不是不会推导,而是看着题解觉得自己懂了,关上编辑器就写不出来。我会建议你准备一张纸,拿一个长度在六左右、带负数的数组,把每走一步的 current、current - k、哈希表内容全部手写一遍。等你亲手把这张表推通,再遇到任何“区间和”变体题,第一反应就不会是暴力循环,而是先去想能不能用前缀和做差。这个条件反射,比记住某个题号值钱得多。
