资讯详情

滑动窗口反向思考:将x减到0的最小操作数

📅 2026/9/28 13:28:03 | 华诺云谱 👁 阅读
滑动窗口反向思考:将x减到0的最小操作数
聊到滑动窗口很多刚开始刷算法题的朋友第一反应是“双指针嘛左右指针维护一个区间很简单”。但真正遇到“将x减到0的最小操作数”这道题时绝大多数人都会被卡住因为它不是让你找一个连续子数组的和而是让你从两端反复拿元素拿到刚好等于x为止。这个“从两端拿”的描述很容易把人带偏到DFS或者前缀和加二分的思路上我也一样。这道题我前后认真复盘过四遍标题里那个“(4)”其实是我自己的刷题笔记序号——第四轮总结。这一轮我决定把它彻底拆开从题目本质、滑动窗口原理、多语言实现到各种报错场景全部过一遍希望能帮正在纠结这道题的朋友少走点弯路。1. 题目理解与核心思路最开始真的被它骗了1.1 题目到底在问什么先把题目翻译成人话给你一个整数数组nums和一个整数x你每次操作只能从数组的最左边或者最右边取走一个元素取走的元素值会从x里扣掉。问最少需要多少次操作能让x正好变成0。如果无论怎么操作都没办法让x归零就返回-1。这里有个特别容易忽略的设定每次只能拿“最左边或最右边”的元素不能从数组中间掏。举个例子nums [1, 1, 4, 2, 3]x 5你可以先拿左边的1再拿右边的3最后拿右边的2一共三次操作1 3 2 6不对那就得换别的组合。这种“两端拿”的规则让题目看起来像搜索题很多人一上来就跑去写回溯。我一开始也是这么想的每次有两种选择拿左还是拿右拿完之后x变小数组边界也变了。最简单粗暴的方案就是深度优先搜索把整棵选择树遍历一遍。这个思路在数组只有几个元素时没问题但一旦nums的长度达到10^5级别分支数量直接指数级爆炸根本跑不完。1.2 为什么暴力解会挂除了DFS另一个常见的错误思路是“前缀和枚举”。你可能会想每次从左边拿若干个、从右边拿若干个那左边拿走的前缀和加上右边拿走的后缀和只要等于x就行。于是可以用双重循环枚举左边拿几个、右边拿几个计算两个部分的和。但这么做的时间复杂度是O(n^2)在n 10^5这种规模下同样会超时。还有更隐蔽的问题即便用二分优化也只能把复杂度降到O(n log n)但如果左右两边拿取的数量是“此消彼长”的关系二分的边界条件很容易写错。我第二轮复盘时试过“前缀和 哈希表”的解法思路是枚举左边拿i个右边需要拿多少个才能凑够x - prefix[i]用哈希表快速查找后缀和。这个做法能过但代码写起来其实不短而且对于“求最小操作数”这个目标它需要额外维护很多细节。所以当你看到这题的主流解法是“滑动窗口”的时候第一反应可能是滑动窗口不是处理连续子数组的吗这题明明是从两端拿元素跟连续子数组有什么关系这里就是整道题的关键转折点——你把问题倒过来看一切就通了。1.3 核心结论移除部分难算保留部分好算既然每次操作拿掉的是数组两端的元素那么经过若干次操作之后数组中没被拿走的那些元素一定是中间紧挨着的一段连续子数组。举个例子数组长度为7你从左边拿掉前2个从右边拿掉后3个剩下的就是nums[2]到nums[3]这一段它必然是连续的。如果拿掉的所有元素之和等于x那么剩下的连续子数组之和就等于sum(nums) - x。设这个目标值为target。题目要求“最少操作次数”等价于“拿走的元素个数最少”也就是“留下来的连续子数组长度最长”。于是这道题就变成了在数组nums中寻找一个最长的连续子数组使得该子数组的和正好等于target。答案就是n - maxLen其中maxLen是满足条件的最长子数组长度。如果找不到这样的子数组返回-1。这就是滑动窗口能派上用场的根本原因移除的元素分布在两端剩下的元素必然连续而滑动窗口恰恰就是处理连续子数组问题的利器。2. 滑动窗口核心原理窗口里装的是“保留部分”2.1 窗口的物理意义很多讲这题的文章一上来就说“用滑动窗口找和为 target 的最长子数组”但没有解释为什么可以这么做。这里我用自己的话再说一遍滑动窗口里的元素在物理意义上就是你最后没被移除的那些元素。窗口的左边界对应左边被移除部分的结束位置右边界对应右边被移除部分的起始位置。窗口越长说明保留的元素越多被移除的元素越少操作次数自然越少。所以我们要找的就是“和恰好等于target的最长窗口”。这个窗口的长度一旦确定答案就是总数减去窗口长度。理解了这个对应关系你在写代码的时候就不会把变量名搞混。2.2 左右指针的移动规则标准的滑动窗口模板有两根指针左指针left和右指针right。整个过程只有三步右指针right从0开始逐步向右移动每次移动都把nums[right]加进窗口和windowSum。只要windowSum target就说明当前窗口太大需要把左指针left向右移动同时从windowSum里减去nums[left]直到windowSum target。如果windowSum target说明找到了一个合法窗口用right - left 1更新最大长度。这个规则之所以成立依赖于一个隐藏前提数组元素都是正整数nums[i] 0。因为只有元素为正窗口向右扩展时窗口和才会单调递增左指针移动时窗口和才会单调递减。如果数组里有负数滑动窗口就失效了因为右指针移动可能会让窗口和变小、左指针移动可能会让窗口和变大指针移动的“单调性”被打破整个滑动逻辑就不成立了。本题里nums的元素是正整数正好满足这个条件。2.3 特殊边界情况要先处理我第三遍刷这题的时候就是在边界条件上翻车的。归纳起来有几个特殊场景必须在一开始就处理掉sum(nums) x所有元素加起来都不够x无论如何都拿不到x直接返回-1。sum(nums) x所有元素全部移除才能让x归零返回n。target 0说明sum(nums) x其实已经被上一条覆盖了只不过单独写成if (target 0) return n会让逻辑更直观。maxLen一直没更新说明没有找到和等于target的连续子数组最终返回-1。很多网上的题解会把前两条合并成一段判断然后初始化maxLen 0最后用if (maxLen 0) return -1做收尾。但这里有个漏洞如果target本身是0那么空窗口的长度也算合法返回值应该是n直接用maxLen 0判断会误伤。所以我在实现里选择把maxLen初始化为-1既能区分“没找到”和“找到了空窗口”又不容易在边界上翻车。3. 完整实现与代码详解三种语言一次讲透3.1 C 版本最贴近模板的写法滑动窗口这套逻辑C 写起来最直白几乎没有语法糖干扰。下面是我在实际提交里用过的版本class Solution { public: int minOperations(vectorint nums, int x) { int n nums.size(); long long total 0; for (int v : nums) total v; long long target total - x; if (target 0) return -1; if (target 0) return n; long long windowSum 0; int left 0; int maxLen -1; for (int right 0; right n; right) { windowSum nums[right]; while (windowSum target) { windowSum - nums[left]; left; } if (windowSum target) { maxLen max(maxLen, right - left 1); } } return maxLen -1 ? -1 : n - maxLen; } };这里有几个细节想提醒你。第一total和target我用的是long long因为nums的长度可以到10^5元素值最大到10^4总和最大就是10^9虽然int通常也能存下但谁敢保证出题人不会加个极端用例提前用long long可以省掉一次提交才知道的烦恼。第二maxLen初始化为-1而不是0就是为了区分“找不到合法窗口”和“窗口长度为0”的情况。第三while (windowSum target)用的是while而不是if因为左指针可能连续移动好几次才能让窗口和回到target附近。3.2 Java 版本注意整数溢出和变量声明位置Java 版本的逻辑完全一致唯一要多留个心眼的是整数溢出问题。Java 的int是 32 位有符号整数最大值2147483647如果total超过这个值就会溢出变成负数。所以求和变量同样要用longclass Solution { public int minOperations(int[] nums, int x) { int n nums.length; long total 0; for (int v : nums) total v; long target total - x; if (target 0) return -1; if (target 0) return n; long windowSum 0; int left 0; int maxLen -1; 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); } } return maxLen -1 ? -1 : n - maxLen; } }Java 这版其实没什么额外的坑Math.max跟 C 的std::max一个意思。唯一要注意的是nums[left]在减法强制转换时可能被隐式提升为long这是 Java 自动帮你做的不用手动处理。如果你之前写的是int windowSum在极端用例下就会出问题所以从一开始就用long是最省心的选择。3.3 Python 版本写起来最简洁但要注意“大整数”的隐式优势Python 的int不限长度所以理论上不会溢出这让代码看起来更清爽def minOperations(nums, x): n len(nums) total sum(nums) target total - x if target 0: return -1 if target 0: return n left 0 window_sum 0 max_len -1 for right in range(n): window_sum nums[right] 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_lenPython 写这道题的优势是干净劣势是如果你不熟悉for right in range(n)和while的配合可能会在缩进上犯迷糊。记住一个原则while收缩左指针的代码必须跟右指针扩展的代码在同一层缩进里不要缩进到if window_sum target下面那样逻辑就全乱了。还有max_len用-1初始化的原因和 C 一样Python 里也可以写成max_len float(-inf)但那样最后的返回值判断要额外处理不如-1直接。3.4 复杂度分析为什么这个解法能跑到 O(n)整个滑动窗口过程里右指针right从0走到n-1一共移动n次左指针left虽然也会移动但它最多也只从0走到n-1绝不会回头。也就是说两个指针各自移动的次数加起来不超过2n所以总时间复杂度是O(n)。空间上只用了几个变量没有额外的数组或哈希表空间复杂度是O(1)。这里有个很经典的对比如果不做“反向思考”直接枚举左边拿几个、右边拿几个用哈希表记录后缀和时间复杂度是O(n)也还行但空间复杂度会变成O(n)而且代码可读性差不少。滑动窗口的思路之所以被推崇就是因为它在时间最优的前提下把空间也压到了常数级而且代码短到让人怀疑“这题真的这么简单吗”——对就是这么简单。4. 常见错误与问题排查实录我自己掉过的坑4.1 找不到子数组时到底返回 -1 还是 n这个错误我犯过两次。第一次我把maxLen初始化为0然后写一个if (maxLen 0) return -1。问题在于如果target 0那么空窗口是合法窗口maxLen应该记为0但此时最终结果应该是n也就是“一个元素都不用移除”。按我当时的写法maxLen是0直接返回-1就错了。所以后来我统一改成maxLen -1用负值表示“没找到”这样target 0的情况在一开始就被if (target 0) return n拦截了后面窗口正常滑动即可。4.2 外层循环用 while 还是 for其实都行但 for 更稳有人喜欢这么写外层循环int left 0, right 0; while (right n) { // 扩展右边界 windowSum nums[right]; // 收缩左边界 while (windowSum target) { windowSum - nums[left]; left; } // 更新答案 if (windowSum target) { ... } right; }这样写完全没问题。但我更推荐for (int right 0; right n; right)因为right的递增被自动处理不容易漏掉。我之前用while版本时漏写过循环末尾的right结果陷入死循环排查了半天才发现是右指针没动。for版本则没有这个烦恼。4.3 更新窗口长度的位置错一个缩进就是另一个 bug更新答案的代码必须放在“收缩完左指针之后”因为我们要保证windowSum target的前提下检查是否相等。如果你把if (windowSum target)放在while收缩之前那么窗口和可能还大于target你就拿这个窗口长度去更新答案了得到的是一个“超额”窗口结果自然不对。这个问题的隐蔽性在于当数据比较小的时候可能碰巧不出错一旦窗口和反复超过target答案就乱了。我的习惯是右指针扩展 → 左指针收缩 → 更新答案三步顺序绝不动摇。4.4 数组里有负数时滑动窗口直接失效这一点值得单独提醒。滑动窗口能工作的前提是窗口和随右指针单调不减、随左指针单调不增。只要数组里有负数窗口扩展时和反而可能变小左指针收缩时和反而可能变大指针就无法按规则单向移动了。本题明确说了数组元素是正整数所以安全。如果哪天你遇到变体题里包含负数就不要硬套滑动窗口应该考虑前缀和加哈希表或者单调队列的方案。这是题目和数据范围共同决定的不是模板的问题。4.5 排查问题速查表表现可能原因解决办法结果比预期大windowSum target判断放在收缩之前把更新答案移到while收缩之后结果始终是 -1用maxLen 0判断找不到改用-1初始化maxLen数字大的用例报错int溢出求和变量改为long long/long死循环外层用while但漏掉right改成for循环或补上自增边界用例错误sum x或sum x未处理开头加上两个if判断窗口和一直不对target total - x的符号算错手动模拟一遍sum - x的数值5. 滑动窗口的工程化思考一道题带出一整套模板5.1 通用滑动窗口模板语言可以换骨架不变这题刷完之后我最大的收获不是记住了代码而是理解了滑动窗口的通用骨架。它适用于所有“连续子数组/子串”相关问题核心就四步扩展右边界、收缩左边界、更新答案、移动右指针。不管你是写 C、Java、Python还是很多人问到的 JavaScript 版本模板骨架都是一样的function minOperations(nums, x) { const n nums.length; const total nums.reduce((a, b) a b, 0); const target total - x; if (target 0) return -1; if (target 0) return n; let left 0; let windowSum 0; let maxLen -1; for (let 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); } } return maxLen -1 ? -1 : n - maxLen; }JS 版其实就是 C 版换个壳区别只是reduce求和、const声明变量、严格相等。这里我想强调的是你不需要为每种语言单独记一套“滑动窗口算法”只需要记住“扩展-收缩-更新”这三个动词任何语言都能写出来。5.2 相关变体题从“窗口和”到“窗口统计”刷完这道题我顺手把几道同类题放在一起对比发现滑动窗口在不同题目里的“窗口状态”差别很大题目窗口维护的状态收缩条件将x减到0的最小操作数窗口内元素之和和超过 target 时收缩无重复字符的最长子串字符出现次数的哈希表出现重复字符时收缩最小覆盖子串目标字符的覆盖计数已覆盖全部目标字符时收缩滑动窗口最大值单调递减队列队首滑出窗口时弹出滑动窗口中位数有序结构或双堆窗口长度固定时左右同步移动这个表不是我随便列出来的而是想说明一个关键点滑动窗口并不是只能维护“和”这一种状态它可以维护哈希表、队列、堆、有序集合等任何数据结构只要这个结构能在窗口滑动的过程中快速更新和查询。理解了这一点你再遇到新的变体题就不会觉得“又是新题型”而是会想“它到底要在窗口里维护什么状态”。5.3 题外话工程领域的“滑动窗口”到底指什么算法题里聊完我想顺带说点题外话。很多人在搜索引擎里输入“滑动窗口滤波”“滑动窗口滤波模型”“滑动窗口滤波 verilog”这些词其实它们在工程领域跟算法题里的滑动窗口是同一套思想在不同场景的落地。在信号处理领域滑动窗口滤波指的是用一个固定长度的窗口扫描信号序列窗口内做某种运算比如平均、中位数、加权平均然后每次向后滑动一个采样点输出一个新值。它和算法题的共同点在于“固定区间 单向滑动”这个核心结构不同点在于窗口长度固定窗口内计算的是数值而不是总和。用 Verilog 做硬件实现时通常会用一个移位寄存器组来模拟窗口每来一个时钟周期就移入一个新采样值、移出一个旧采样值这和代码里的双指针滑动本质上是一回事。如果窗口长度是L输出相对输入会有(L-1)/2个采样点的延迟这也是很多人搜“滑动窗口滤波器延迟”时想搞清楚的指标。在流量控制和服务端限流场景里滑动窗口也经常出现把时间轴切成若干小格维护最近一个时间窗口内的请求计数窗口随时间滚动。这跟刷题时维护一个区间然后右指针右移、左指针跟进完完全全是同一个思维模型。所以我一直觉得算法题练的不是“背题型”而是练一种通用的区间抽象能力。你今天能把数组里的连续子数组想成窗口明天就能把一段时间内的请求计数想成窗口后天就能把硬件里的采样序列想成窗口。5.4 复盘总结把这道题变成你的“模板题”我个人刷题的习惯是每道经典题不止刷一遍而是隔一周再回来写一遍每次只做一点点改动。比如这题第一遍我写的是 C 版第二遍换成 Java第三遍写了 Python第四遍试着在纸上只写模板骨架不写具体代码。这个过程的收益比看十篇题解都大因为你最终发现语言之间的差异会被“扩展-收缩-更新”这个统一骨架彻底抹平。如果你现在还在为这题纠结我建议你按这个顺序做先在纸上把sum(nums) - x这个公式推明白再用[-1]或者是[1]这种简单用例手动走一遍窗口滑动过程最后再动手写代码。相信我手动跑一遍用例后你对左指针什么时候动、右指针什么时候动、答案在哪里更新会有完全不一样的体感。这题不难难的是跨过“从两端移除”这个表面描述看到“保留中间连续子数组”这个本质。想通这一层滑动窗口自然水到渠成。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑