刷题群里隔三差五就会有人问起这道《将x减到0的最少操作数》:给定一个数组 nums 和一个整数 x,每次只能从数组最左边或最右边拿掉一个元素,并从 x 里减去这个元素的值,问最少操作几次能把 x 正好减到 0,如果做不到就返回 -1。很多人第一眼看到它,第一反应是“这得用搜索吧”“是不是要动态规划”——但老手一眼就能认出,这是滑动窗口里最经典的“反过来想”题型。
如果你刷过几道滑动窗口题目,会知道这套路无非就是“右边界扩张、左边界收缩、窗口内维护某种状态”。但这道题真正的难点不在窗口怎么写,而在于你怎么把“从两端删元素”这个看似无法用连续窗口描述的操作,转化成“找一个最长连续子数组”的标准模型。这篇就围绕这个转换过程完整拆一遍,从题意、等价变换、代码实现到边界条件和踩坑记录,通通讲清楚。无论你是刚开始学滑动窗口,还是已经刷过一些题想补漏,这篇都能给你一点可复用的思考方式。
1. 先把题意吃透:删除两端到底在留什么
1.1 规则翻译:每一次操作都是“剥洋葱”
先别急着写代码,把规则在脑子里过一遍。给定数组 nums 和整数 x,比如nums = [1,1,4,2,3],x = 5。每一轮操作,你可以选择拿走当前数组最左边那个数,或者最右边那个数,拿完之后这个数从数组中消失,同时 x 减去它的值。
举个具体过程:第一轮拿右边最后一个3,x 变成2;第二轮再拿右边现在最后一个2,x 正好变成0。总共 2 次操作。这就是题目要的最小答案。
注意一个关键设定:题目里的 nums 元素全部是正整数。这个设定非常非常关键,后面讲滑动窗口成立的原因时会反复提到它。只要元素是正数,x 每扣一次都在变小,窗口和的变化才具有单调性。
还有一个容易误解的地方:你拿走的元素不一定全来自左边或者全来自右边,可以左边拿几个、右边拿几个,中间剩下的那一段保持原样。拿走的顺序也不要求连续,但你最后从 x 里扣掉的,必然是“原数组左边某几个元素 + 原数组右边某几个元素”的和,这两部分加起来要正好等于 x。
1.2 关键转换:剩余部分一定是连续子数组
如果从两头剥,剥到最后原数组会剩下什么?只会剩下中间“一整块”连续的元素。
你可以把数组想象成一条排好队的队伍,每次只能从队头或队尾把人叫走,剩下的队员在队伍里的相对顺序永远不变,而且始终是原来队伍中连续的一段。同理,数组两端被删掉若干元素之后,中间剩余的部分一定是一个连续子数组。
这一点是整个题目的题眼。
题目问的是“删除的最少次数”,删除多少个元素,就是操作了多少次。反过来想,删除的越少,意味着中间剩余的那一段越长。所以题目可以等价成:我想让剩余那一段尽可能长,但同时它必须满足某个和的条件。
那这个和是多少?很简单,原始数组的总和是 total,从两端删掉的那些元素之和是 x,剩下的中间段之和自然就是total - x。记这个值为target。
也就是说:现在的问题变成了,在数组里找一个最长的连续子数组,让它的和正好等于total - x。如果找到,答案就是数组长度 - 这个子数组的长度;如果找不到,说明无法通过删两端元素把 x 减到 0,返回 -1。
1.3 把“最少删除”换成“最长保留”
为什么这个等价的威力这么大?因为“从两端删”这个动作原本是动态变化的,每一步删除都在改变数组边界,如果直接模拟,最坏情况下要考虑左右两个方向各自取多少个,组合方式太多。但一旦你站在“剩余部分”的视角,问题就变成了在一个静态数组里找一个固定和的连续区间。连续区间正是滑动窗口的主场。
用一个生活化的类比:你有一整根法棍面包,顾客说要吃总长 x 厘米,而且只能从两端切。你要让切的刀数最少,等价于什么?等价于你尽量保留中间一大段完整的、没被切到的面包。只要你决定保留中间哪一段,两端要切掉的部分就自动确定了。这道题只是把“长度”换成了“元素和”。
不过这里要额外注意一点:剩余段的和是固定值 target,但剩余段的长度是我们要最大化的目标。因为每次操作删掉一个元素,删除次数 = 被删元素个数 = 总元素个数 - 剩余段长度。所以剩余段越长,操作次数越少。这个“固定和”与“最长”的组合,就是滑动窗口最适合解决的“可变窗口最值”问题。
2. 滑动窗口为什么是这道题的最优解
2.1 从暴力枚举到双指针的思考过程
最朴素的做法是枚举所有可能的连续子数组,检查它的和是不是 target,然后取最长的。连续子数组一共 O(n^2) 个,借助前缀和可以 O(1) 得到任意区间和,但枚举本身依旧 O(n^2)。当 n 到 10^5 级别,这个方案直接超时。
还能怎么优化?注意到数组元素全是正整数,窗口的右边界向右移动时,窗口和只增不减;窗口左边界向右移动时,窗口和只减不增。这种“随边界移动单调变化”的性质,正是双指针能派上用场的前提。
于是滑动窗口的思路就很自然了:让右边界一路向右走,把新元素纳入窗口;一旦窗口和超过了 target,就把左边界向右推,把多余的元素从窗口里踢出去;窗口和刚好等于 target 的时候,记录当前窗口长度,并尝试更新最大值。因为右边界不回退、左边界也只向右移动,整体是线性扫描,复杂度降到 O(n)。
2.2 滑动窗口能用的前提:正整数带来的单调性
我在前面反复强调“正整数”,不是没有原因的。滑动窗口的收缩逻辑本质上依赖一个性质:窗口的右边界扩展会让总和变大,左边界收缩会让总和变小。这个性质只有在所有元素非负时成立。
如果数组里有负数,右边界塞进一个负数,窗口和反而变小了,那窗口的“大了就缩”策略就会失效。比如当前窗口和已经大于 target,按理说该缩左边界,但如果右边界再扩展一个很大的负数,窗口和又低于 target,形态就乱了。这种情况下滑动窗口不能保证正确,需要换用前缀和加哈希表的思路。
所以拿到题目先别急着套模板,看一眼数据约束。这道题nums[i]全是正整数,滑动窗口才能成为最优解。面试时主动说出“因为元素为正,所以窗口和具有单调性,可以用双指针”,会显得你对算法适用条件的理解很到位。
2.3 窗口伸缩的完整决策过程
标准滑动窗口的代码骨架其实就三件事:右边界扩张、左边界收缩、窗口状态更新。针对这道题,我把每一步的决策逻辑拆开讲。
初始时left = 0,right从 0 开始往右扫描。每轮把nums[right]加进windowSum,这一步相当于“右边的门永远只管进”。
加完之后,需要检查窗口和是不是超了。只要windowSum > target,就说明当前窗口里的元素多了,必须从左边往外丢元素。这里用的是while而不是if,因为丢一个元素可能还不够,可能丢完一个仍然大于 target,要一直丢到windowSum <= target为止。每次丢元素时把nums[left]从 windowSum 里减掉,同时left++。
等窗口和稳定到不超过 target 之后,再判断windowSum == target。如果相等,说明当前窗口恰好就是“和等于 target”的候选子数组,于是用right - left + 1得到窗口长度,更新历史最大值。如果不相等,说明窗口和还小于 target,那就没必要记录,等下一轮 right 继续向右扩展。
为什么要“先收缩再判断相等”?因为窗口塞入新元素之后,和很可能超过 target,必须先靠收缩把它压回 target 附近,否则你拿一个超标的窗口去做判断没有意义。而收缩之后如果正好等于 target,这个窗口才是合法的候选。整个过程 windowSum 始终在 target 附近的区间内“波动”,一旦等于就捕捉一次。
3. 三种语言实现:代码与逐行讲解
3.1 Java 版本:最完整的注释版
class Solution { public int minOperations(int[] nums, int x) { int n = nums.length; int total = 0; for (int num : nums) { total += num; } // 总和都不够 x,根本不可能减到 0 if (total < x) { return -1; } int target = total - x; // target 为 0,说明 x 等于整个数组总和 // 必须把全部元素都移除,操作次数就是 n if (target == 0) { return n; } int left = 0; // 窗口左边界 int windowSum = 0; // 当前窗口内元素和 int maxLen = -1; // 满足和 = target 的最长窗口长度 for (int right = 0; right < n; right++) { windowSum += nums[right]; // 右边界扩张 while (windowSum > target) { // 和超了就收缩 windowSum -= nums[left]; left++; } if (windowSum == target) { maxLen = Math.max(maxLen, right - left + 1); } } // maxLen 仍是 -1,说明找不到和 = target 的子数组 return maxLen == -1 ? -1 : n - maxLen; } }这段代码里最容易被忽略的是maxLen = -1的初始化。为什么不用 0?因为如果 target 大于 0 且数组中不存在一个和为 target 的连续子数组,那么 maxLen 应该保持“未找到”状态,返回 -1。如果初始化为 0,最后会返回n - 0 = n,错误地表示“移除所有元素”。这个坑我在下一节还会专门展开。
另一个关键是target == 0的特判。target 等于 0 时,意味着剩下的中间段必须和为 0。由于 nums 全是正整数,唯一和为 0 的“连续段”是空段,它的长度是 0。可滑动窗口循环里不会主动记录空窗口,因为窗口至少包含nums[right]这个正数。所以需要单独处理:直接返回 n,表示把整个数组都移走。
3.2 Python 与 C++ 版本
Python 的实现逻辑和 Java 完全一致,只是语法更简洁:
class Solution: def minOperations(self, nums, x): n = len(nums) total = sum(nums) if total < x: return -1 target = total - x if target == 0: return n left = 0 window_sum = 0 max_len = -1 for right, val in enumerate(nums): window_sum += val while window_sum > target: window_sum -= nums[left] left += 1 if window_sum == target: max_len = max(max_len, right - left + 1) return -1 if max_len == -1 else n - max_lenC++ 版本同样直接,只是要把累加函数写好:
class Solution { public: int minOperations(vector<int>& nums, int x) { int n = nums.size(); int total = accumulate(nums.begin(), nums.end(), 0); if (total < x) return -1; int target = total - x; if (target == 0) return n; int left = 0, windowSum = 0, maxLen = -1; for (int right = 0; right < n; ++right) { windowSum += nums[right]; while (windowSum > target) { windowSum -= nums[left++]; } if (windowSum == target) { maxLen = max(maxLen, right - left + 1); } } return maxLen == -1 ? -1 : n - maxLen; } };三个版本没有本质区别,你熟哪门语言就用哪门。重点是理解前面那套“收缩再判断”的顺序,而不是背代码。
3.3 复杂度分析与正确性验证
复杂度上,right指针从 0 走到 n-1,left指针最坏情况下也从 0 走到 n-1,每个元素最多被right加入一次、被left移出一次,所以整体是 O(n) 的均摊时间。空间上只用了几个变量,是 O(1)。
用最开始的例子验证一下。nums = [1,1,4,2,3],x = 5,total = 11,target = 6。
| right | 窗口 | windowSum | 收缩后窗口 | 收缩后和 | 是否记录 |
|---|---|---|---|---|---|
| 0 | [0,0] = [1] | 1 | 无需收缩 | 1 | 否 |
| 1 | [0,1] = [1,1] | 2 | 无需收缩 | 2 | 否 |
| 2 | [0,2] = [1,1,4] | 6 | 无需收缩 | 6 | maxLen = 3 |
| 3 | [0,3] = [1,1,4,2] | 8 | 收缩到 [2,3] = [4,2] | 6 | maxLen = 3 |
| 4 | [3,4] = [2,3] | 5 | 无需收缩 | 5 | 否 |
最终 maxLen = 3,答案是5 - 3 = 2,和题目示例一致。你可以自己拿另一个示例[3,2,20,1,1,3],x = 10验证,total = 30,target = 20,最长和为 20 的连续段是[20],长度 1,答案是6 - 1 = 5,也是对的。
4. 刷题踩坑实录:边界条件与常见问题
4.1 经典错误:maxLen 初始为 0 导致误判
这个坑我见过很多人踩。如果你把 maxLen 初始化为 0,遇到这种数据就会出错:nums = [1,2,3],x = 1。此时 total = 6,target = 5。数组中不存在和为 5 的连续子数组,滑动窗口跑完,maxLen 仍然是初始值 0,最后返回n - 0 = 3,意思是要删掉全部 3 个元素。但这显然不对,因为你无法通过删两端元素让 x 减到 0。
正确的姿势是让 maxLen 初始化为一个“未找到”的哨兵值,比如 -1。这样跑完如果 maxLen 还是 -1,就能明确判断出“不存在这种子数组”,返回 -1。也就是说,maxLen 不只是在记录一个长度,它还肩负着“有没有找到答案”的标记责任。想清楚这一点,比死记“写 -1 不写 0”更重要。
如果你确实喜欢用 0 初始化,那也可以在循环里加一个布尔变量found,找到候选时置为 true。最后如果!found就返回 -1。两种方案等价,但都要把“未找到”这个状态和“找到了但长度是 0”区分开。
4.2 target 等于 0 时,窗口里其实不装元素
target = total - x。当 x 恰好等于数组所有元素之和时,target = 0。这种情况下,想要让 x 减到 0,你只能移除全部的 n 个元素,因为只要还剩任何一个正整数,移除的元素总和就不可能是 total。中间剩余的部分是空数组,长度为 0。
问题在于,滑动窗口的循环体至少会让 right 扫描到第一个元素,windowSum 立刻变成正数,永远不可能等于 0,所以 maxLen 永远不会被更新。如果你不特判,最终会返回 -1,但正确答案是 n。所以在进循环前加一句if (target == 0) return n;是最省心的处理方式。
另一种理解方式:窗口里装的是“我们要保留的中间段”,target 为 0 时这段为空,保留长度为 0,那么删除长度就是 n。这样想,特判的合理性就非常清晰了。
4.3 total < x 的提前返回
这个边界条件最简单,但也最容易漏。比如nums = [5,6,7,8,9],x = 4。整个数组总和是 35,你要从数组里拿走元素让 x 减到 0,可 x 本身只有 4,而每个元素最小都是 5,随便拿一个都超了。就算把整个数组全拿走,x 也减不到 0,反而会变成负数。方向完全不对。
所以先算 total,如果total < x,直接返回 -1。这一步放在最前面,避免后面计算 target 时出现负数,也避免滑动窗口做无用功。如果把total < x和target == 0两个特判都写全,这道题的主要边界就基本守住了。
4.4 面试与调试中的实用小技巧
如果你是在面试或机考里遇到这道题,我建议你的思考顺序是:先讲“从两端删 = 保留中间连续段”的转换,再讲 target 的计算方式,最后再提滑动窗口的单调性前提。面试官听到你能主动说出“因为数组元素是正整数,所以窗口和单调,可以双指针”,通常会比较满意,因为这说明你不是在背模板,而是真的理解适用条件。
调试的时候,如果答案不对,我一般会打印每个 right 对应的 left、windowSum、maxLen 三个值,重点看收缩那一步是否把左边界推过头了。常见的错误之一是把windowSum > target写成windowSum >= target,这会导致窗口和刚好等于 target 时也强行收缩,白白错过正确答案。另一个常见错误是收缩之后没有在循环内继续判断left <= right,虽然这道题因为窗口和收缩后会小于等于 target,left 不会超过 right,但如果你改写成其他场景,这个越界问题就会浮出来。
还有一个小技巧:如果一时没转过弯,先在纸上写两个极端例子。第一个是“要找的子数组正好是整个数组”,比如nums = [1,2,3],x = 0,那 target = 6,窗口最长长度为 3,答案 0,说明一次都不用删。第二个是“要找的子数组为空”,比如x = total,答案 n。把这两个极端想明白,代码里的边界分支你自然而然就能写出来。
我个人做这类题的习惯是,先不动手写窗口,先在纸上把total - x算出来,看剩余部分允许的最大和是多少。然后心里默念一句话:窗口里装的不是要删的,而是要留下的。这个角度一换,很多“从两端删元素”的题目都能一眼看穿。
最后再分享一个扩展想法:如果这道题把数组元素改成允许负数,滑动窗口就没法直接用了,但你可以用前缀和加哈希表来求“和为 target 的最长子数组”。思路是遍历每个位置作为右端点,通过哈希表查之前有没有前缀和等于当前前缀和减 target。你先把这道题吃透,再顺手推演一遍那个版本的解法,滑动窗口和前缀和两条路线就都贯通了。