资讯动态

吃透数组:双指针、滑动窗口、前缀和与原地操作实战

发布时间:2026/10/17 3:01:11 来源:尧图企业网站定制
1. 整体思路这一篇我们把数组真正“吃透”如果你不是从前面两篇看过来的我简单交代一下背景前两篇我们聊了数组的基本定义、内存模型、增删改查以及遍历时常见的几种思维误区。很多朋友看完私信我说基础部分其实都懂真正头疼的是遇到“稍微绕一点”的数组题或者业务需求时不知道该从哪个角度下手。这篇就是冲着这个痛点来的。我不打算再讲数组的“静态结构”而是把它当作一个“动态工具”来用。整篇文章围绕四个关键词展开双指针、滑动窗口、前缀和、原地操作。这四个东西并不是什么高深莫测的算法它们本质上都是对“数组的遍历方式”做优化。你理解了这一点几乎所有中等难度的数组问题在你眼里都会变得有迹可循。适合谁看一种是刚学完数组语法、做题时只会暴力嵌套循环的初学者另一种是日常写业务代码经常处理列表、表格数据、批量更新但总觉得自己的写法不够干净的后端或前端开发者。两种身份我都经历过这篇里的每个例子我都尽量用业务场景和算法题两种形式对照着讲。2. 核心技巧详解双指针与滑动窗口2.1 双指针把两层循环压成一层先问一个问题给你一个升序排列的数组找出其中两个数使它们的和等于目标值。你会怎么写大多数人第一反应是两层嵌套循环外层固定一个数内层找另一个数。理论上没问题但时间复杂度是O(n²)。如果数组长度是10万这个方案跑起来会非常难受。双指针的思路非常朴素既然数组是有序的那我就在数组两端各放一个指针左指针指向最小值右指针指向最大值。两个指针对应的数相加如果比目标值大说明“大的那一头”太大了右指针往左移如果比目标值小说明“小的那一头”太小了左指针往右移。整个过程只需要一次遍历时间复杂度直接降到O(n)。我直接给你一段可以跑的代码function twoSumSorted(nums, target) { let left 0; let right nums.length - 1; while (left right) { const sum nums[left] nums[right]; if (sum target) { return [left, right]; } else if (sum target) { left; } else { right--; } } return []; }这段代码的巧妙之处在于它利用了“有序”这个前提让两个指针在移动过程中不会漏掉任何可能的组合。你可能会疑惑为什么左指针向右移动就代表忽略左边的更小值因为right已经指向当前最大的值了如果nums[left] nums[right]还小于目标值那nums[left]和任何其他数值相加都一定小于目标值其他数值都比nums[right]小所以nums[left]可以安全排除。我最早看这段逻辑时总觉得“不放心”总想用例子穷举一遍。后来我换了个角度理解双指针本质上是在维护一个“可能性区间”每次移动指针都在根据已确定的信息收缩这个区间而不是盲目试探。2.2 滑动窗口连续子数组问题的标准解法如果说双指针解决的是“从数组中找一对元素”的问题那么滑动窗口解决的就是“从数组中找一段连续区间”的问题。最典型的场景是给定一个数组和一个整数k找出长度为k的连续子数组的最大平均值。暴力解法就是枚举所有长度为k的子数组每个子数组求和复杂度O(n×k)。如果你处理的数据量不大这么写当然没问题但如果你在一个实时数据流的场景里比如每隔几秒就需要重新计算一批滚动指标这种复杂度就会成为瓶颈。滑动窗口的思路是第一次先老老实实算出窗口内元素的和然后每次窗口向右滑动一位新窗口的和等于旧窗口的和减去离开窗口的那个数再加上进入窗口的那个数。这样不管窗口多大每次移动只需要常数时间的计算。function maxAverage(nums, k) { let sum 0; // 先算出第一个窗口的和 for (let i 0; i k; i) { sum nums[i]; } let maxSum sum; // 窗口开始滑动 for (let i k; i nums.length; i) { sum sum - nums[i - k] nums[i]; maxSum Math.max(maxSum, sum); } return maxSum / k; }这个模式的本质是“复用计算结果”避免重复劳动。这和做报表的时候你每次新增一行数据不需要重新汇总整个月只需要在昨天的总数上加上今天的增量是一个道理。滑动窗口的难点其实不在思路而在边界。窗口的左边界是i - k右边界是i很多人在写的时候会把左边界算错一位导致结果偏大或偏小。我的建议是在你写代码之前先手动在纸上跑两个小例子一个用正常的窗口一个特意覆盖下数组尾部的边界情况。2.3 双指针的变体快慢指针双指针还有一个非常实用的变体快慢指针。它常常被用来处理“原地删除重复元素”“判断链表是否有环”这类问题用在数组上时最常见的场景是压缩重复元素。假设你有一个排序好的数组需要原地删除重复出现的元素使每个元素只出现一次返回删除后数组的新长度。不能使用额外空间。暴力做法是每发现一个重复元素就把后面的所有元素往前挪动一位。这个操作的时间复杂度是O(n²)因为最坏情况下每次删除都要移动大量元素。快慢指针的思路是慢指针指向“当前已处理好的最后一个位置”快指针负责向前探索新元素。当快指针发现一个新元素和慢指针指向的元素不同就把它复制到慢指针的下一个位置然后慢指针前进一位。快指针继续探索直到遍历完整个数组。function removeDuplicates(nums) { if (nums.length 0) return 0; let slow 0; for (let fast 1; fast nums.length; fast) { if (nums[fast] ! nums[slow]) { slow; nums[slow] nums[fast]; } } return slow 1; }这里的核心思想是“用快指针判断用慢指针记录”整个数组只遍历一次空间复杂度O(1)。我之前在维护数据清洗工具的时候就遇到过类似场景一批用户行为日志按时间排序里面有很多重复上报的事件ID需要在原数组上把重复项去掉同时保留日志顺序。当时用的就是这个思路直接把清洗耗时从几十分钟降到了几秒。3. 前缀和与差分区间问题的一次遍历方案3.1 前缀和让区间求和变成减法数组有一种很常见的问题类型频繁查询某个区间[i, j]内所有元素的和。如果你每次查询都从i遍历到j求和单次查询是O(n)查询m次就是O(m×n)。数据量一旦上来比如数组长度10万、查询次数10万那就基本跑不动了。前缀和的思路是预先计算一个数组pre其中pre[i]表示从数组开头到第i个位置的所有元素之和。这样区间[i, j]的和就等于pre[j] - pre[i-1]如果i等于0那就直接是pre[j]。单次查询的时间复杂度降为O(1)。我来解释一下为什么这里用“减法”前缀和的本质是把“区间和”转换成两个“前缀和”的差。就像你记录流水账想要知道某一段时间花了多少钱只需要用这个月累计支出减去上个月累计支出不需要每天逐项累加。你可能会觉得这个技巧好像只有在算法题里才用得上。其实不是。我做过一个运营后台的销量统计页面上每个商品都要显示“最近7天、最近30天、最近90天”的销量。如果每次切换时间范围都重新查一次数据库慢得不行。后来我在内存里维护了一个按日期排序的累计销量数组用前缀和直接做差前端切换时间范围就是即时响应。3.2 差分数组区间修改的逆操作前缀和解决的是“多次查询、几乎不修改”的问题差分数组则解决相反的问题“频繁修改区间、最后才查询”。什么叫区间修改比如你有一个长度为n的数组初始值全是0现在有m个操作每个操作是把[i, j]范围内的每个元素都加上一个值v。最后你要输出整个数组的最终结果。暴力解法很简单每个操作都去循环遍历区间内的每个元素逐个加上v。时间复杂度O(m×n)。如果m和n都是10万级别这同样不可接受。差分数组的做法是不直接修改原数组而是维护一个“差值数组”diff其中diff[i] nums[i] - nums[i-1]diff[0] nums[0]。当你要把[i, j]区间都加上v时只需要在diff[i]上加上v在diff[j1]上减去v。最后恢复原数组时从第一个元素开始累加diff得到的就是最终结果。function rangeAdd(n, operations) { const diff new Array(n 1).fill(0); for (const [i, j, v] of operations) { diff[i] v; if (j 1 n) { diff[j 1] - v; } } const result new Array(n).fill(0); let current 0; for (let k 0; k n; k) { current diff[k]; result[k] current; } return result; }我第一次看差分数组的时候总觉得它像是“魔法”。为什么只需要改两个位置就相当于改了整个区间后来我理解了差分数组记录的是“相邻元素之间的变化量”。区间内所有元素都加上同一个值意味着区间内部的相对差值完全不变只有区间的起点和终点外侧的差值发生了变化。所以只需要改两个位置非常优雅。3.3 实际业务里的区间修改差分数组在实际业务中也能用。比如你看运营后台的“用户等级积分”体系有一段规则是用户等级在指定日期范围内每天额外获得一定积分。今天是第100天你一看规则发现要处理一百万个玩家里面很大一个区间的每日加分。如果每天去更新每个人的积分数据库会撑不住。更合理的方案是把“每日加分”当成一次区间更新先用差分数组把所有规则范围内的加分量聚合好再一次性地批量更新数据库。整个过程从“逐行改”变成“批量算”性能提升非常明显。4. 实操过程数组去重、合并与原地操作全记录4.1 原地去重的完整实战流程很多语言自带去重函数比如JavaScript里有SetPython里有set。但面试题或者性能敏感场景往往不允许你开额外空间这时候原地去重就成了必考项。前面已经讲了快慢指针的核心版本这里我补充一个完整实战流程。我之前接过的某模拟项目X需要处理一批用户输入的标签数组里面混着大量重复项还要保持原有的相对顺序。第一步判断边界。数组为空或者长度为1直接返回。第二步初始化快慢指针。慢指针指向第一个元素快指针从第二个元素开始遍历。用一个中间变量记录“上一个非重复元素的值”方便做连续性判断。数组是排序过的可以直接比较相邻元素数组没排序就不能用简单的相邻比较需要先借助一个临时对象记录已经出现过的值。第三步快指针移动。每次走到一个新元素判断它是否已经出现在“已保留集合”中。如果没有就让慢指针前进一步把新元素写进慢指针位置同时把这个值记录下来如果有快指针继续跳过。第四步返回新长度慢指针加1。原数组前几个位置已经被改写为去重后的数据。这里有一个很多新手容易忽略的点如果题目明确说数组是有序的你可以用“记录上一个值”的方式如果没说有序你就必须用一个额外的记录结构去标记这本质上已经消耗了O(n)空间不算“真正意义上的原地去重”。所以做题前一定要看清题目约束不要想当然。4.2 合并有序数组的两种思路合并有序数组也是高频场景。比如两个已经排好序的数组需要合并成一个新的有序数组。最直接的办法是双指针同时扫描两个数组谁的当前元素小就放进结果数组里然后移动对应的指针。function merge(nums1, m, nums2, n) { let i m - 1; // nums1 有效元素末尾 let j n - 1; // nums2 末尾 let k m n - 1; // nums1 总末尾 while (j 0) { if (i 0 nums1[i] nums2[j]) { nums1[k--] nums1[i--]; } else { nums1[k--] nums2[j--]; } } }上面这段代码还有个变体版的坑如果题目要求把合并结果直接放进第一个数组而第一个数组预留了足够的空间后面是0你从前往后合并会出现“覆盖未处理元素”的问题。解决办法是从后往前合并从两个数组的末尾开始比较把较大的数放到第一个数组的末尾。这又是“指针思维”的一次应用。从前往后合并会覆盖掉还没比较过的原数组元素我之前在真实项目里就踩过这个坑。有个排序组件最初实现的时候从前往后归并结果数组后半部分总是丢数据。排查了半天最后发现是覆盖顺序错了。从那以后凡是往原数组里写结果我都会养成一个习惯先判断是从前往后写还是从后往前写以及为什么这样写不会覆盖未处理的数据。4.3 多维数组的遍历顺序与存储优化数组还有一个经常被忽略的维度多维数组。多数人在业务里用二维甚至三维数组的频次不高但只要接触到数据表、矩阵运算、图片像素处理就一定会遇到。二维数组在内存里是连续存储的但连续方式分两种行优先和列优先。像C语言和JavaScript大多数实现是行优先也就是一行一行地连续存放。如果你连续访问同一行的相邻列缓存命中率最高如果总是跨行访问性能会明显下降。处理一个2000×2000的矩阵时用行优先遍历和列优先遍历时间差可以达到几倍甚至十几倍。这个差异在面试里通常不会被问到但在做图像处理、数值计算时会非常明显。我的习惯是写代码前先想想数据在内存里是怎么排列的特别是在处理大矩阵的时候尽量让循环的访问顺序贴合内存布局。5. 常见问题与排查技巧实录5.1 边界条件数组操作的头号翻车现场我在带新人做算法训练时发现90%的数组代码错在边界条件而不是核心逻辑。最常见的几个坑空数组。数组长度为0时很多算法会直接崩溃或者返回错误结果。长度为1的数组。某些双指针代码里左指针和右指针一开始就相等或交错循环条件写错会漏处理。索引越界。在访问nums[i1]或者nums[j-1]时没有判断i和j是否位于合法范围内。窗口滑动漏边界。窗口从0开始滑到末尾时最后一组的左边界不合法。针对这些边界问题我常用的排查方法只有一个先把最简单的情形手算出来。如果一个算法在数组长度为0、1、2、3的情况下都能跑对那么大部分边界坑就已经避开了。千万不要一开始就拿一个长度很大的数组调试那是自己给自己找麻烦。5.2 性能陷阱解决了功能问题又冒出了时间问题数组操作第二个经常翻车的点是性能。一个看起来正确的写法可能在数据量小的时候表现正常一旦数据量上来就直接卡顿或者超时。比较典型的是频繁使用shift()或splice()。很多语言中从数组头部删除元素需要把后面所有元素往前移动时间复杂度O(n)。如果你在一个循环里反复执行这种操作整体复杂度就变成O(n²)。处理10万元素的数组时这种性能急剧下降。替代方案是“反向遍历”或“交换后出栈”把O(n)的头部删除优化为O(1)。举个例子你要删除数组中所有值为0的元素并不需要每删一个就整体移动一次而是可以用一个慢指针维护“非零元素写入位置”一次遍历完成。function moveZeroes(nums) { let writeIndex 0; for (let i 0; i nums.length; i) { if (nums[i] ! 0) { nums[writeIndex] nums[i]; } } // 剩余位置补0 while (writeIndex nums.length) { nums[writeIndex] 0; } }我处理过一个百万级数据的清洗任务最初写的就是循环里反复splice结果跑了十几分钟还没结束。后来改成这种“写入指针”的做法整个过程不到一秒。性能差距就是这么大。5.3 一次完整的排查实录记录一个最近的排查过程能给到大家一个典型样本。某天一个同事过来说他负责的某个数据报表接口偶尔会多出一行脏数据。他检查了很久认为是数据库问题其实不是。我让他把接口里处理数组的代码发过来看到这样一段逻辑他先用某种排序规则把数组排好序然后去重去掉ID相同的数据。但去重逻辑只用了一重循环比较相邻两项。问题就在于这份数据源不是严格按ID排序的ID相同的数据可能分散在数组的各个位置相邻比较根本发现不了远端重复项。排查思路很简单构造一个只有两条重复记录但ID不相邻的测试数组跑一遍他的代码果然没有去重成功。修复方案有两种要么先按ID稳定排序再去重要么用哈希表记录出现过的ID在遍历时直接判断是否重复。这个案例想说明的是数组问题往往不是“数据本身有问题”而是“你对数组顺序的假设出错了”。写代码之前一定要确认输入数据满足什么样的约束条件。6. 常见问题速查表场景表现原因处理方式数组头部删除数据量大时卡顿shift()导致O(n)移动改用写入指针或从尾部处理区间和查询频繁接口响应慢每次查询遍历整个区间前缀和数组用减法求区间和区间批量更新更新耗时长每次更新都循环每个元素差分数组首尾标记两次修改合并有序数组数据错乱从前往后覆盖了未处理数据从后往前合并去重不生效重复数据仍有残留假设数组有序但实际无序改用哈希表记录出现过的元素在数组操作这个领域“能跑”和“跑得好”之间隔着对数据结构特性的理解。大部分性能问题不是代码本身写得烂而是你选择了和数据结构特性相悖的操作方式。我个人操作下来的体会是处理数组问题时最值钱的思维习惯是“先画一遍指针移动过程”。无论双指针、滑动窗口、前缀和还是差分数组如果你能在纸上画清楚每一步指针的位置和数据的值那么写代码就是水到渠成的事。相反如果脑子里没想清楚就直接敲键盘再简单的逻辑也会出各种莫名其妙的错。这一篇的内容其实就是围绕着一个核心观点数组虽然简单但它是练习逻辑思维最好的载体。下一篇我会挑几个复合场景比如“数组加上二叉搜索树的组合问题”继续接在这系列的后面感兴趣的可以留意。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑