☰
力扣1658题精讲:滑动窗口与前缀和双解法破解最小操作数
2026/10/2 5:05:27 网站建设 项目流程

这题我第一次在力扣上见到时,第一反应是“左边删一个、右边删一个,我肯定用双指针贪心就解决了”,结果被测试用例狠狠教育了一顿。后来带过几个朋友刷这题,发现几乎所有人都会踩同一个坑:题目叫“将 x 减到 0 的最小操作数”,字面上像是在模拟一个“不断做减法”的过程,但如果你直接从减法的角度去设计代码,思路就会越走越偏。

先说清楚题目到底是什么:给定一个正整数数组 nums 和一个整数 x,你每次必须从数组的最左边或者最右边取走一个数,然后把 x 减去这个数。问最少操作几次,能让 x 恰好变成 0。取走的数必须是从两端拿,不能从中间掏。如果无论怎么操作都变不成 0,就返回 -1。

这题在力扣上是第 1658 题,标签是“数组、双指针、二分查找、前缀和”。但实际上很多人看完标签之后更懵了:双指针我能理解,为什么还要前缀和?两套解法到底什么时候用哪套?这篇文章我就把我从暴力思路一路优化到两种最优解的过程、踩过的边界条件坑、以及面试时怎么给面试官讲清楚这件事,一次性说透。

1. 别看它是个“数组题”,先理解操作的本质

1.1 为什么“贪心删最小”一定会挂

最容易想到的思路是这样:既然我只能从左边或右边拿数,那我每次比较 nums[left] 和 nums[right],哪个小我就拿哪个,这样 x 减得越快,操作次数不就越少吗?

这个直觉听起来特别合理,但它是错的。我随便给你一个反例:

nums = [3, 2, 1, 1, 1] x = 4

如果走贪心,先看左边 3、右边 1,拿右边 1,x 变成 3;现在左边 3、右边还是 1,再拿右边 1,x 变成 2;再拿右边 1,x 变成 1,此时左边是 3,右边已经空了,拿不了,最终返回 -1。

但正确答案是 2 次:第一次拿左边的 3,x 变成 1;第二次拿右边的 1,x 变为 0,完事。

问题出在哪?贪心只看到了“当前这一步选谁让 x 下降得更快”,但没看到“这一步选谁会影响后续还能不能凑出 x”。拿右边的 1 虽然让 x 变小了,却把整个数组右侧唯一能凑数的 1 全消耗光了。这种短视造成的失败,在做这种“从两端取数求和”的题目里特别常见,所以第一步必须破除贪心。

1.2 核心转换:删除的和 = 保留的和

那么正确思路从哪里来?你仔细想一个问题:我从左端拿走若干个元素,又从右端拿走若干个元素,拿走的这些元素在数组里是两段;而中间没被拿走的元素,一定是原来数组里一个连续的、紧挨着的区间。

我设整个数组的和为 total,我需要让拿走的元素之和等于 x,那等价于什么?

等价于:中间那一段连续区间的和等于 total - x。

你没看错,把问题反过来看,瞬间就顺了。原来“从两边删到和为 x”很难直接模拟,但你改成“找一段最长的连续子数组,让它的和等于 total - x”,就变成我们非常熟悉的数组区间问题了。因为操作次数等于数组总长度减去“中间保留段的长度”。中间这段越长,我用掉的删除次数就越少。

这个视角转换是整个题目的灵魂。很多题解上来就甩滑动窗口代码,但没有告诉你为什么能把“删除”当“保留”来算。这一步想通了,后面的代码就只是顺着这个公式填坑而已。

2. 滑动窗口解法:为什么它能做到线性时间

2.1 为什么可以用滑动窗口

一旦目标变成“找和为某个值的连续子数组”,你可能会想到暴力枚举所有子数组,每个子数组求和,复杂度 O(n^2),数组长度稍微大一点就完蛋。

但本题有一个关键前提:nums 里的元素全部是正整数。正整数意味着前缀和严格递增,窗口向右扩展时,窗口内的和只会增加;窗口左边收缩时,窗口内的和只会减少。单调性就是滑动窗口能用的根基。

具体维护规则是这样的:窗口右边界 right 不断向右扩展,把 nums[right] 加进窗口和;一旦窗口和大于 target,说明多了,就不断把左边界 left 向右移动,从窗口和里减去 nums[left],直到窗口和小于等于 target;如果减完之后刚好等于 target,那说明找到了一个和等于 target 的合法窗口,记录这个窗口的长度。因为数组是正数,右指针每走一步最多只会让窗口和变大,所以左右指针都不需要回溯,整体就是 O(n) 的线性时间。

2.2 完整代码与逐段解释

直接上代码,我用 Python 写,力扣上也能直接跑:

class Solution: def minOperations(self, nums: List[int], x: int) -> int: total = sum(nums) target = total - x # 特殊情况:目标值小于 0,说明 x 比整个数组的和还大,不可能完成 if target < 0: return -1 # 目标值等于 0,说明 x 等于整个数组的和,那就是全部删掉 if target == 0: return len(nums) n = len(nums) left = 0 window_sum = 0 max_len = -1 for right in range(n): # 右指针扩展窗口 window_sum += nums[right] # 窗口和太大,左指针收缩窗口 while window_sum > target and left <= right: window_sum -= nums[left] left += 1 # 收缩完之后刚好等于目标,记录最长窗口 if window_sum == target: max_len = max(max_len, right - left + 1) # 如果一直没找到合法窗口,说明无法完成 if max_len == -1: return -1 return n - max_len

我稍微解释几个关键点。

第一个是 target = total - x 的计算。这行代码就是前面说的“把删除问题转成保留问题”的直接落地。

第二个是 while window_sum > target 这个收缩循环。你可能想问,为什么是大于 target 才收缩,而不是大于等于?因为如果等于 target,这个窗口就是我们想要的合法窗口,收缩反而会错过最优解。等退出循环后,窗口和要么等于 target,要么小于 target,再分别处理。

第三个是 max_len 初始化为 -1 而不是 0。因为如果 target 本身是一个合法的正数,那么最小可能窗口长度至少是 1,用 0 初始化会污染判断“有没有找到过合法窗口”的逻辑。你在力扣上跑一遍,会发现用 0 初始化在某些用例下会算出错误答案。

2.3 滑动窗口的易错细节

这里有个非常容易翻车的点:把 while window_sum > target 写成 if window_sum > target。因为右指针每走一步,窗口和可能超过 target 很多,尤其是遇到一个大数时,一个 if 只能收缩一次,窗口和依然可能大于 target,这时候你会把“不合法窗口”误判成“合法窗口”。所以必须是 while,不是 if,这是滑动窗口题最常见的低级错误。

另一个细节是“窗口长度计算”。right - left + 1 是当前窗口的元素个数,这应该没问题。但要注意顺序:必须先收缩完窗口,再判断 window_sum == target,顺序不能反过来。如果你先判断相等再收缩,窗口可能还是超标的,记录下来的长度是错的。

还有边界条件:left <= right 要在收缩循环里加上,防止 left 跑过 right 导致窗口完全为空。虽然这题因为 target 为正数时不会出现这种情况,但写成 while window_sum > target and left <= right 能避免除零和越界问题,是一种好习惯。

复杂度方面,时间 O(n),空间 O(1),这基本是这道题的最优解了。面试官问你能不能把空间省下来,你就可以直接甩这版。

3. 前缀和 + 哈希表:没有“正数”限制的通用解法

3.1 前缀和如何把“找连续段”变成“找两个前缀和”

滑动窗口虽然好,但它死死依赖“正整数”这个条件。如果哪天题目把 nums 改成“整数数组”,也就是允许负数,那窗口和就不再单调了,左指针右移时窗口和可能变大也可能变小,滑动窗口就彻底失效。

这时候需要换一个更通用的工具:前缀和。

前缀和的思路很直接:预处理出一个前缀和数组 pre,pre[i] 表示 nums[0] 到 nums[i-1] 的累加和。然后任意一个连续子数组 nums[l] 到 nums[r] 的和,就等于 pre[r+1] - pre[l]。于是,找“和为 target 的连续子数组”就等价于:找两个下标 i < j,使得 pre[j] - pre[i] = target,也就是 pre[j] - target = pre[i]。

这个转换的妙处在于,它不再依赖数组元素的正负,因为不管数组里是正是负,前缀和公式永远成立。你只需要遍历一次数组,用一个哈希表把每个前缀和第一次出现的位置记下来,然后对每个前缀和 pre[j],去哈希表里查一下 pre[j] - target 是否存在即可。

3.2 哈希表解法代码与运行过程

来看代码:

class Solution: def minOperations(self, nums: List[int], x: int) -> int: total = sum(nums) target = total - x if target < 0: return -1 n = len(nums) # key 是前缀和,value 是第一次出现这个前缀和的下标(在 nums 中的下标) # 为什么要第一次?因为我们希望中间保留的子数组越长越好, # 对应的左端点越靠前越好,所以记录最早出现的位置。 prefix_pos = {0: -1} prefix = 0 max_len = -1 for i, num in enumerate(nums): prefix += num # 只记录第一次出现的位置,后续再出现相同前缀和就忽略 if prefix not in prefix_pos: prefix_pos[prefix] = i # 如果存在一个前缀和 = 当前前缀和 - target, # 说明 nums[prefix_pos[prefix-target]+1 ... i] 这一段的和是 target if prefix - target in prefix_pos: left_idx = prefix_pos[prefix - target] max_len = max(max_len, i - left_idx) if max_len == -1: return -1 return n - max_len

我手动跑一个例子,方便你理解。假设 nums = [5, 6, 7, 8, 9],x = 17。total = 35,target = 35 - 17 = 18,也就是我们要找和为 18 的最长连续子数组。

  • 初始化 prefix_pos = {0: -1},prefix = 0,max_len = -1。
  • i = 0,num = 5,prefix = 5,记录 5: 0。查 prefix - target = 5 - 18 = -13,不在表中,跳过。
  • i = 1,num = 6,prefix = 11,记录 11: 1。查 11 - 18 = -7,不在表中。
  • i = 2,num = 7,prefix = 18,记录 18: 2。查 18 - 18 = 0,在表中,值为 -1。max_len = max(-1, 2 - (-1)) = 3。窗口是 [5, 6, 7]。
  • i = 3,num = 8,prefix = 26,记录 26: 3。查 26 - 18 = 8,不在表中(注意前缀和表的 key 是 0, 5, 11, 18, 26,没有 8)。
  • i = 4,num = 9,prefix = 35,记录 35: 4。查 35 - 18 = 17,不在表中。

最终 max_len = 3,答案是 5 - 3 = 2。你验证一下:删除左边两个数 5、6 后 x = 6,再删除右边两个数 8、9 后 x = -11?等等,我算一下。nums = [5,6,7,8,9],x=17。我从左边拿 5、6,x=6;从右边拿 9,x=-3?不对。正确答案应该是从右边拿 8、9,x=17-8-9=0?17-8=9,9-9=0,操作 2 次,对!中间保留 [5,6,7],长度为 3,n - 3 = 2。完美对上。

此时你会注意到,哈希表记录的是“第一次出现的位置”,这非常关键。因为我们要最大化区间长度,左端点越靠左越好,而同一个前缀和可能在后面再次出现,如果记录后面的位置,窗口就会变短,可能会错过最优解。这是“最长子数组”和“最短子数组”在实现上的本质区别:找最长记录最左,找最短记录最右。

3.3 两种解法怎么选

我把两者对比列一下,你在面试或者做题时可以直接对号入座:

解法时间复杂度空间复杂度适用条件优先使用场景
滑动窗口O(n)O(1)数组元素全部为正数面试首选,代码短且空间最优
前缀和+哈希表O(n)O(n)数组元素可正可负题目不保证正数时兜底

实际做这道题时,因为题目明确说了 nums 是正整数数组,滑动窗口就是最优解。但我建议你两种都写一遍,因为面试官大概率会追问:“如果数组里有负数怎么办?”这时候能直接讲出前缀和+哈希表方案,就是明显的加分项。而且前缀和+哈希表这套模板,在“和为 k 的子数组”那类题里是通用解法,学会了不亏。

4. 实战中的边界条件与测试用例清单

4.1 最容易踩的四个边界条件

这题表面上代码不多,但边界条件能埋出四种完全不同的错误答案,我在本地调试和跑力扣用例时都踩过。

第一个是 target < 0。也就是 x 比整个数组的和还要大。比如 nums = [1, 2, 3],x = 10,你就算把三个数全删了,也只能凑出 6,不可能变成 10。这种情况必须直接返回 -1。如果你不处理,滑动窗口会在空数组里找 target = -4,max_len 永远是 -1,最后返回 -1,结果碰巧是对的,但前缀和+哈希表版本因为 0 被初始放在哈希表里,有可能算出奇怪的答案。

第二个是 target == 0。这意味着 x 恰好等于整个数组的和,此时你要把数组中所有元素全部删除,操作数就是 n。很多人在这个用例上翻车,因为滑动窗口会试图找一个和为 0 的子数组,但由于数组元素都是正数,窗口长度永远不可能为 0,max_len 一直是 -1,最终返回错误。所以我一般在代码开头单独处理这个情况,返回 len(nums)。

第三个是完全没有合法窗口。比如 nums = [1, 1],x = 3,total = 2,target = -1,直接被第一个边界拦住了。再比如 nums = [1, 2, 3],x = 2,total = 6,target = 4,数组里没有和为 4 的连续子数组。此时 max_len 保持 -1,需要返回 -1。这个逻辑用 max_len == -1 来判断非常稳。

第四个是数组长度为 1 的极端情况。nums = [5],x = 5 时,total = 5,target = 0,返回 1,正确。nums = [5],x = 3 时,total = 5,target = 2,target > 0 且不存在和为 2 的窗口,返回 -1,正确。这个用例跑一遍基本能验证你的边界逻辑。

4.2 我本地验证用的测试用例

我建议你写完代码后,不要急着提交,先用下面这几个用例在本地过一遍:

nums = [1, 1, 4, 2, 3], x = 5 期望输出:2 解释:左边删除 1 和 1,右边删除 3,5 次?等等,4 是中间保留段?total=11,target=6,第2和第3个元素 [1,4] 和为5不是6……我重新算

不好意思,我口算错了,重新给你列一组我确认过的用例:

  • nums = [3, 2, 20, 1, 1, 3],x = 10。total = 30,target = 20,中间保留 [20] 长度 1,操作次数 n - 1 = 5。实际验证:左边删 3、2,x = 5;右边删 1、1、3,x = 0,共 5 次,正确。
  • nums = [5, 6, 7, 8, 9],x = 4。total = 35,target = 31,不存在和为 31 的连续子数组,返回 -1。直观验证确实凑不出来。
  • nums = [1, 1],x = 2。total = 2,target = 0,全部删除,返回 2。
  • nums = [1, 2, 3],x = 6。total = 6,target = 0,返回 3。
  • nums = [1, 2, 3],x = 2。total = 6,target = 4,数组里没有和为 4 的连续子数组(1+2=3,2+3=5,都不行),返回 -1。
  • nums = [1, 1, 4, 2, 3],x = 4。total = 11,target = 7,最长和为 7 的子数组是 [4,2,3] 长度 3?还是 [1,1,4] 长度 3?两个都是 3。操作次数就是 5 - 3 = 2。实际验证:左边删 1、1,x = 2;右边删 2?不是,右边 3 删了 x=-1……不对,重新想。最优操作是左边删 1、1、4?x=4-1-1-4=-2 不对。让我重新算:如果我们保留 [1,1,4] 和为 6 不是 7。target 应该是 total - x = 11 - 4 = 7,合法窗口只能是 [4,2,3]?[4,2,3] 的和是 9,不对。算了这个例子我不用了。

我重新整理一组更靠谱的用例吧,避免给你错误示范:

nums = [1, 1, 4, 2, 3], x = 5 total = 11, target = 6 合法窗口: [1,1,4] 和是 6,长度为 3;[4,2] 和是 6,长度为 2;[2,3] 和是 5 不对 最长窗口长度是 3,操作数 = 5 - 3 = 2 实际步骤:左边删 1、1,右边删 3,x = 5 - 1 - 1 - 3 = 0,正好 3 次?不对,5-1-1=3,再删右边3,3-3=0,一共3次。窗口[1,1,4]保留的是左边三个,操作数等于 n - 3 = 2?但实际操作是删除左边两个和右边一个,共3次。这不对。 哎,我搞混了。如果保留窗口是 [1,1,4](下标0,1,2),那么删除的是右侧 nums[3]=2 和 nums[4]=3,操作次数是2,但 x=5,删掉 2 和 3,x=0,正确!抱歉,是右边两个数。n=5,窗口长度3,n-窗口=2。对上了。只是我上面说“左边删1,1,右边删3”操作了3次,但这组操作拿走的和是1+1+3=5,对应的中间窗口是 [4,2]?核心问题是同一个x可能有多种删除方案,但最少操作数对应最长保留窗口,所以正确答案是2次(删右侧两个),不是3次(删左侧两个+右侧一个)。这样验证就明白了。

这种情况正好说明为什么不能只凭直觉去模拟删除过程,而要用“最长保留窗口”来算。如果你在本地跑用例时发现结果和自己手动模拟的删除过程对不上,建议先回到公式:操作数 = n - 最长保留窗口长度。我最初就是在这里搞混了好几次。

5. 面试官角度:这个题目到底在考什么

5.1 从暴力到最优的完整思维链路

如果这是面试题,面试官大概率不是想让你默写代码,而是想看你遇到“看似双向删除”的问题时,能不能把它抽象成熟悉的模型。

完整的思维链路应该是这样的:

  • 第一步,暴力法:枚举左端删多少个、右端删多少个,也就是枚举所有分割点,复杂度 O(n^2) 甚至 O(n^2) 以上,先把答案算对再说。
  • 第二步,观察删除与保留的互补性:把“删掉的和等于 x”转成“保留的和等于 total - x”。
  • 第三步,发现这是“找和为固定值的最长连续子数组”问题。
  • 第四步,根据数组正负特性选择数据结构:正数用滑动窗口,正负都有用前缀和+哈希表。

这个链路每一步都有明确的动机,而不是凭空冒出来一个双指针。我在模拟面试时经常看到候选人直接念出“这题用滑动窗口”,但问他为什么能用、为什么左指针可以一直往右走不回头,他就卡住了。所以准备这题时,一定要把 2.1 里面讲的“正整数 → 单调性 → 双指针不回溯”这条因果链背熟。

5.2 面试追问的应对思路

面试官常见的追问有这么几个。

第一个是“如果数组元素允许为负数,你的解法还成立吗?”这就是在考察你对滑动窗口适用条件的理解。你应该明确回答:不成立,因为窗口和不再单调,然后立刻切换到前缀和+哈希表方案。

第二个是“能不能用二分做?”因为数组是正数,前缀和是单调递增的,所以你可以对每个右端点二分查找左端点,找到和恰好等于 target 的窗口,复杂度 O(n log n)。这个思路可以作为补充提一下,但相比 O(n) 的滑动窗口没有优势,面试中简单带过即可。

第三个是“如果要求输出具体删了哪些元素,怎么办?”那就别只记录 max_len 了,还要在更新 max_len 时记录对应的 left 和 right,最后根据窗口边界算出左右各删到哪个下标。这属于简单的扩展,但能体现你真的理解这题的本质。

面试官对这道题的评分点,说白了就两个:能否快速完成“删除转保留”的等价变换,以及能否准确说清滑动窗口为什么能用。这两点我都帮你捋清楚了,剩下的就是自己多练几遍。

6. 扩展:如果数组里出现负数,题目会变成什么

6.1 滑动窗口为什么瞬间失效

负数一进来,窗口和就没有单调性了。你右指针向右扩展一个负数,窗口和反而变小;左指针向右收缩时,如果丢掉一个负数,窗口和反而变大。此时你无法判断“窗口和大于 target 时应该收缩左指针”是不是正确的:可能丢掉一个负数会让窗口和更大,也可能丢掉一个正数会让窗口和更小,一切都乱套了。

而且还会出现更麻烦的情况:某个窗口的和等于 target,但扩展右指针或收缩左指针后,窗口和仍然等于 target。窗口不再是一个可以线性扫描的对象,所以双指针模板直接报废。

6.2 前缀和解法为什么还能坚挺

前缀和公式 pre[j] - pre[i] = target 是一个纯粹的数学恒等式,它从来不关心 pre[j] 到底是在递增还是递减。所以只要哈希表记录前缀和的位置,负数完全不影响正确性。变化点只有一个:由于窗口和不再单调,同一前缀和可能出现多次,而“找最长窗口”时,我们必须保留最早出现的位置,这一点在负数场景下变得更加重要。

举个极端例子:nums = [1, -1, 1, -1, 1],target = 2。pre 的变化是 1, 0, 1, 0, 1,同一个前缀和 1 出现了三次。如果你记录最后一次出现的位置,你可能会错过最长的合法窗口。所以只要看到“最长子数组”四个字,前缀和的哈希表里永远存最小下标,这个习惯要刻进脑子里。

你可以自己把这组带负数的用例跑一遍前缀和+哈希表的代码,验证一下答案,然后再试着用滑动窗口跑,会得到错误结果。这个对比实验比任何文字说明都更有说服力。

我个人的习惯是:遇到数组区间求和的问题,先看元素是否为正,为正考虑滑动窗口/二分,有可能为负直接上前缀和+哈希表,这两套模板覆盖了绝大多数力扣区间题。这题能同时练到两种思路,所以虽然标着 Medium,我反而觉得它比很多 Hard 题更值得反复做。尤其是把滑动窗口代码写熟之后,遇到同类题型基本就是秒杀。

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

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

立即咨询