双指针算法入门:移除元素、去重与合并有序数组的经典解法

发布时间:2026/9/16 2:23:07
双指针算法入门:移除元素、去重与合并有序数组的经典解法 作为一个刷了三百多道算法题、也在面试中反复被双指针拷打的过来人我可以很负责任地说双指针算法是 LeetCode 上性价比最高的一类套路。它上手快、代码短、思路清晰而且覆盖的场景极广——从数组操作到链表判断、从滑动窗口到有序序列合并全都离不开它。这篇文章想和你聊透双指针最经典的几个入门题目移除元素、删除有序数组中的重复项、合并两个有序数组。这三个题看起来很基础但它们恰好对应了双指针的三种不同玩法——快慢指针覆盖、快慢指针去重、逆向双指针合并。把这三道题吃透你就掌握了双指针最核心的骨架之后再遇到变体题比如移动零、有序数组的平方、甚至三数之和都会有熟悉的感觉。这篇文章适合谁看刚开始刷题、对数组原地操作比较懵的新手或者刷过一些题但遇到双指针总是边界条件写错的同学都值得认真过一遍。我不只给你代码还会把每一步的思考过程、为什么这么写、有哪些坑都讲清楚。毕竟面试官真正想看的不是你背下了答案而是你能不能讲明白每一步的逻辑。1. 双指针算法的核心设计思路先搞懂指针到底在“指”什么很多人一开始学双指针容易被“指针”这两个字吓住觉得这是什么高深的链表操作。其实在数组题里指针就是一个数组下标而已。双指针说白了就是用两个下标变量去扫描同一个数组通过控制这两个下标的移动节奏把原本需要两层循环才能解决的问题优化成单层循环就能搞定。这背后隐藏的思路是让一个循环干两件事。1.1 双指针的三种基本形态我在实际刷题中习惯把双指针分成三种形态理解了这个分类后面做题会轻松很多。**第一种是快慢指针。**快指针在前面“探路”慢指针在后面“写结果”。最典型的场景就是“原地去重”和“移除指定值”。快指针逐格移动检查每个元素是否符合保留条件慢指针指向的位置就是下一个要写入的位置。这种模式通常用来处理“原地修改数组”类的题目空间复杂度能做到 O(1)。**第二种是左右对撞指针。**一个指针放在数组头部另一个放在尾部根据条件判断让左边指针往右移或者右边指针往左移两个指针相向而行直到相遇。这种模式常见于“有序数组里找两个数满足某个条件”的题比如两数之和、三数之和、回文判断、反转数组等。**第三种是逆向双指针。**从数组的末尾开始往前移动通常用于处理合并类问题因为从后往前写可以避免覆盖掉还没被处理的元素。合并两个有序数组就是这种模式的经典代表。两种指针一个指向第一个数组的有效内容尾部一个指向第二个数组的尾部然后从整个数组的末尾开始填充结果。这三种形态不是互相排斥的有些题甚至会用两种以上的指针思路组合。但作为入门先把这三种模式各自吃透再谈组合。1.2 怎么判断一道题能不能用双指针有一个非常朴素的判断标准如果一道题要求你“原地”修改数组并且最终的答案顺序和原数组的相对顺序有关那么大概率可以用快慢指针。如果题目给出的是有序数组并且要求查找满足某种条件的一对元素那左右对撞指针往往是首选。如果题目需要把两个有序数组合并而且允许你在较大数组上操作那逆向双指针就非常合适。这些都是我在做题中总结的“手感”。说实话算法这东西刷题前期需要的是“背套路”刷多了以后就会形成条件反射。就好比你学做饭最开始照着菜谱一步一步来做多了以后看到食材就知道该切丝还是切块。双指针也是一样前期把经典题目吃透后面关键是识别题目结构而不是重新发明解法。2. 移除元素LeetCode 27快慢指针最经典的入门场景先来看第一道题题目要求是这样的给你一个数组nums和一个值val你需要原地移除所有数值等于val的元素并返回移除后数组的新长度。不需要考虑数组中超出新长度后面的元素也不允许使用额外的数组空间只能使用 O(1) 的额外空间。注意审题它说的“移除”其实不是真的删除内存中的元素而是让你把要保留的元素覆盖到数组前面然后返回一个新的长度k表示前k个位置是有效数据。数组后面是什么内容并不重要。2.1 为什么暴力解法不行看到这道题新手的第一反应往往是遍历数组遇到等于val的元素就把它删掉——但数组删除元素要“搬移”后续元素这本身就涉及循环更麻烦的是删除后索引会变化需要维护一个偏移量。如果你用erase之类的操作在 C 里时间复杂度会退化在 Python 里虽然可以remove循环但效率也很差。暴力解法的典型写法是遍历数组碰到目标值就把它后面的所有元素整体前移一位同时让总长度减一。这个操作的时间复杂度是 O(n^2)空间复杂度虽然是 O(1)但对于长度较大的测试用例会超时。更深层的麻烦在于当数组里有大量等于 val 的连续元素时每次删除都要移动后续全部元素而这个“移动”本身大部分是冗余的——因为目标值后面的元素可能马上又会被删除。我自己刚刷题时写过这种“看似没错一跑就超时”的代码。后来才意识到数组原地操作的核心思想不是“删”而是“覆盖”。你不需要真的把元素从数组里弄出去只要让不需要的元素被需要元素覆盖掉再声明数组的有效长度变了就行。2.2 快慢指针实现用“覆盖”代替“删除”快慢指针的思路非常直观定义两个变量slow和fast初始都指向 0。fast负责遍历整个数组判断当前位置的元素是否等于valslow负责记录“下一个应该写入的位置”。当fast指向的元素不等于val时就把nums[fast]赋给nums[slow]然后slow和fast都加一。当fast指向的元素等于val时slow不动只有fast加一相当于跳过了这个元素。我用 C 写是这样class Solution { public: int removeElement(vectorint nums, int val) { int slow 0; for (int fast 0; fast nums.size(); fast) { if (nums[fast] ! val) { nums[slow] nums[fast]; slow; } } return slow; } };这段代码看起来短但每一步都对应着一个关键决策为什么slow要指向“下一个写入位置”而不是“当前元素位置”因为在遍历时slow前面的区域都是已经处理好的“有效区”slow本身是有效区末尾的下一个空位。这样做的好处是每次覆盖都发生在有效区的末尾不会破坏已经处理好的部分。为什么当fast指向val时只需要fast因为等于val的元素不需要保留它该占的那个“有效位置”不应该给它。slow停在原地就是为了等下一个不等于val的元素来填这个洞。走一遍示例nums [3,2,2,3], val 3初始slow 0, fast 0nums[0] 3等于val跳过fast 1fast 1nums[1] 2不等于valnums[0] 2slow 1, fast 2fast 2nums[2] 2不等于valnums[1] 2slow 2, fast 3fast 3nums[3] 3等于val跳过fast 4循环结束返回slow 2最终前两位是[2, 2]数组后面原本是什么还留着不影响结果。这个“覆盖”的思想贯穿了所有数组原地操作类题目务必记住。2.3 这道题的边界条件与易错点我踩过的坑主要有三个**第一个坑忽视了空数组的情况。**如果nums长度为 0上面的循环压根不会执行直接返回slow 0就对了。这个在逻辑上是天然正确的所以你只要别一开始就手动处理void的情况写错分支就行。有些同学喜欢先特判if (nums.size() 0) return 0;虽然不影响结果但其实是多余的双指针写法天然兼容边界。**第二个坑slow最后是否要加一。**注意slow维护的是“下一个写入位置”所以当处理完所有元素后slow正好等于有效元素的个数也就是应该返回的长度。举个例子你就明白了有效区里有 3 个元素写入完第三个时slow从 0 加到了 3所以slow就是长度不需要再额外加一。**第三个坑面试时回答“为什么这样可以保证前 k 个元素都是有效元素”。**因为fast每发现一个不等于val的元素就把它放到slow指向的位置然后slow前进一格。所以从位置 0 到slow-1每一个格子都被有效元素填满不可能出现空位。这个点我在面试时被连续追问过后来总结了一个更好的说法快指针负责选择慢指针负责落位。3. 删除有序数组中的重复项LeetCode 26把“去重”变成“原地覆盖”第二题是删除有序数组中的重复项。你看题名就知道它和移除元素是“亲兄弟”。题目要求是给你一个非严格递增排列的数组nums请你原地删除重复出现的元素使每个元素只出现一次返回删除后数组的新长度。元素的相对顺序应该保持一致。这道题和上一题唯一的区别是上一题我们要“移除指定值”这道题我们要“移除所有重复值”。但它们本质上做的是同一件事——用快慢指针遍历把符合条件的元素一个接一个写在数组前面。3.1 为什么“有序”是解题的大前提强调一下题目里“非严格递增”这个条件。如果你拿到的是一个无序数组要求去重且保持相对顺序直接套双指针是不行的你得先排序或者用哈希表。而这道题恰好给的是有序数组所以相邻元素如果有重复它们一定是紧挨着的。这一个特性让双指针解法变得极其简洁。我在做题时经常提醒自己很多题目不是靠算法硬解而是靠题目条件“送分”的。有序数组的重复元素相邻就意味着我可以只比较相邻元素而不是把每个元素和前面所有元素都比较一遍。这相当于是双指针能用的根本原因。如果题目改成无序数组那复杂度就不是 O(n) 而是 O(n^2) 甚至更差了。3.2 双指针去重的标准写法定义slow 1fast 1。为什么是从 1 开始而不是从 0因为第一个元素无论如何都会被保留不需要比较它和它自己。slow在这里依然表示“下一个需要写入的位置”而fast从第二个元素开始往后扫描。当nums[fast] ! nums[slow - 1]时说明遇到了新元素就把它写到nums[slow]然后slow。如果不相等则不满足就让fast继续往前探索。C 代码class Solution { public: int removeDuplicates(vectorint nums) { if (nums.empty()) return 0; int slow 1; for (int fast 1; fast nums.size(); fast) { if (nums[fast] ! nums[slow - 1]) { nums[slow] nums[fast]; slow; } } return slow; } };这里有一个非常精妙的点比较对象不是nums[fast]和nums[fast - 1]而是nums[fast]和nums[slow - 1]。为什么举个具体例子nums [1, 1, 1, 2, 2, 3]。如果比较的是nums[fast]和nums[fast-1]那当fast 3时nums[3] 2nums[2] 1虽然发现了新元素但这个 2 应该写到哪个位置你不知道前面有多少个重复的 1。而和nums[slow - 1]比较就完全不一样了slow - 1指向的是“已经处理好的有效区最后一位”nums[slow-1]就是有效区的当前最后一个值。只要nums[fast]和它不一样就说明fast遇到了一个全新的值把它放到slow位置准没错。拿上面的例子走一遍slow 1, fast 1nums[1] 1nums[0] 1相等跳过fast 2fast 2nums[2] 1nums[0] 1相等跳过fast 3fast 3nums[3] 2nums[0] 1不相等nums[1] 2slow 2, fast 4fast 4nums[4] 2nums[1] 2相等跳过fast 5fast 5nums[5] 3nums[1] 2不相等nums[2] 3slow 3返回slow 3前三位为[1, 2, 3]站在面试官的角度他其实特别希望听到你解释清楚为什么要和nums[slow-1]比较而不是nums[fast-1]。这个问题一答出来说明你是真的理解了慢指针的含义而不是背的模板。3.3 推广如果允许每个元素最多出现两次怎么办这是 LeetCode 80 题的变体也是面试中喜欢追问的方向。思路很简单把比较对象从nums[slow - 1]改成nums[slow - 2]。为什么因为slow - 1只能保证有序数组里相邻元素不重复无法保证某个值最多出现两次而nums[slow-2]表示有效区倒数第二个元素的位置。当新元素和它不相等时说明这个元素在有效区里出现的次数还没有达到 2 次可以写入。这背后的通用逻辑是如果你允许每个元素出现 k 次就把比较对象改成nums[slow - k]。这个规律我从刷题中总结出来后后面遇到“最多出现 k 次”的变体题直接套模板就行。4. 合并两个有序数组LeetCode 88逆向双指针的艺术第三道题是合并两个有序数组。题目的描述是给你两个按非递减顺序排列的整数数组nums1和nums2另有两个整数m和n分别表示nums1和nums2中的元素数目。请你合并nums2到nums1中使合并后的数组同样按非递减顺序排列。注意nums1的初始长度为m n其中前m个元素代表应合并的元素后n个元素为 0应忽略。这道题和前两题的区别在于前两题是“一个数组内原地修改”这一题是“把第二个数组合并进第一个数组”而且要求结果仍然保存在nums1里不能额外开数组。4.1 正向合并最大的坑会覆盖还没处理的元素很多新手拿到这道题第一反应是“这不就是归并排序的合并过程吗”于是新建一个临时数组把nums1的前m个元素和nums2的n个元素按从小到大合并到临时数组里最后再拷回nums1。这个思路完全正确逻辑也没毛病时间复杂度 O(mn)空间复杂度 O(mn)。但题目要求额外空间必须为 O(1)所以这种做法过不了。那能不能直接在nums1上从前往后合并举个反例nums1 [1, 2, 3, 0, 0, 0]m 3nums2 [2, 5, 6]n 3。如果你从前往后比较nums1[0] 1比nums2[0] 2小于是你把1保留在原地接着nums1[1] 2和nums2[0] 2比较也保留原值到了nums1[2] 3发现 3 大于 2需要把 2 插入到 3 前面这时候你就得把 3 往右移动一位——但nums1[3]的位置原本是 0是预留的空位移动之后确实没问题。然而如果nums1[3]不是空位而是另一个待合并的有效元素呢在更复杂的情况下正向移动会覆盖掉还没参与比较的有效元素。核心教训是当两个数组合并到第一个数组中、且第一个数组后面有预留空间时从前往后合并会有覆盖风险从后往前合并才是最安全的。4.2 逆向双指针从后往前填空既然正向有覆盖问题我们反过来想nums1末尾有 n 个空位这 n 个空位正是为合并结果预留的。如果从后往前填比较nums1和nums2中当前最大的有效元素谁大就把谁放到nums1的末尾这样永远不会覆盖还没处理的元素因为末尾的位置总是“空”的或者已经填好的。需要三个指针p1 m - 1指向nums1有效部分的最后一个元素p2 n - 1指向nums2的最后一个元素p m n - 1指向nums1数组的最后一个位置即待填充位置每一步操作比较nums1[p1]和nums2[p2]把大的那个放到nums1[p]同时对应的指针往前移动一格p也往前移动一格。C 代码class Solution { public: void merge(vectorint nums1, int m, vectorint nums2, int n) { int p1 m - 1; int p2 n - 1; int p m n - 1; while (p2 0) { if (p1 0 nums1[p1] nums2[p2]) { nums1[p] nums1[p1]; p1--; } else { nums1[p] nums2[p2]; p2--; } p--; } } };注意我这里的循环条件是p2 0而不是p 0。这里有一个很妙的逻辑如果p2 0说明nums2已经全部合并完毕而nums1剩余的元素本来就在正确位置上不需要再动。反过来如果p1 0而p2 0那就说明nums1的有效部分已经用完了剩下的位置全部由nums2来填而循环会继续执行else分支把nums2剩余元素依次放到前面的位置。很多初学时看不懂为什么循环里还带p1 0这个判断。我当时也卡了很久。其实它是为了防止p1变成 -1 时仍然去访问nums1[-1]导致越界。一旦p1 0说明nums1原始有效元素已经全部被比较过且放入更靠后的位置了此时剩下的空位理所当然应该全部填入nums2中剩余的元素。4.3 边界条件的精细化处理这道题最容易出错的地方在于当p1和p2其中一个先耗尽时循环如何正确收尾。用nums1 [1, 2, 3, 0, 0, 0]、nums2 [2, 5, 6]来走一遍p1 2, p2 2, p 5nums1[2] 3nums2[2] 66 更大nums1[5] 6p2 1, p 4p1 2, p2 1, p 4nums1[2] 3nums2[1] 55 更大nums1[4] 5p2 0, p 3p1 2, p2 0, p 3nums1[2] 3nums2[0] 23 更大nums1[3] 3p1 1, p 2p1 1, p2 0, p 2nums1[1] 2nums2[0] 2这里条件是相等时走 elsenums1[2] 2p2 -1, p 1循环结束nums1为[1, 2, 2, 3, 5, 6]再考虑一个特殊情况nums1 [0]m 0nums2 [1]n 1。这时p1 -1, p2 0, p 0。循环条件p2 0满足进入循环后p1 0为 false所以走 else 分支nums1[0] 1。完美。另一个特殊情况nums1 [2, 0]m 1nums2 [1]n 1。p1 0, p2 0, p 1nums1[0] 2大于nums2[0] 1nums1[1] 2p1 -1, p 0。进入下一轮p1 0为 false走 elsenums1[0] 1。结果[1, 2]正确。可以看到这个else分支天然承担了“照看剩余元素”的任务设计得非常优雅。从后往前的思路不仅省空间还让边界处理变得异常干净。4.4 为什么很多教材推荐先写循环条件while (p2 0)我见过不少人把循环写成while (p1 0 || p2 0)然后里面还要分四种情况讨论代码长且容易出错。其实不需要。因为nums1的前 m 个元素始终保留在nums1中并且一旦自己的位置确定下来以后它不需要再移动。所以正确的思路是只关注nums2有没有合并完。如果nums2合并完了整个合并就完成了。这句看似简单的话是这道题真正考察的思维深度。理解了这个循环条件和内部判断就都顺理成章了。5. 三题串讲从一道题到一类题的双指针套路总结这三个题目放一起学价值远大于单独刷三五遍。因为它们的解题思路完全是一脉相承的都是通过两个指针协同移动在一个循环里完成“扫描”和“写入”两个动作从而避免不必要的元素移动。5.1 三种指针移动模式对比很多时候面试官会追问这三个题有什么区别和联系用表格整理一下会非常清晰题目指针类型起始位置写入位置比较对象核心思想移除元素快慢指针slow0, fast0slownums[fast] 与 val跳过所有不等于 val 的元素删除有序数组中的重复项快慢指针slow1, fast1slownums[fast] 与 nums[slow-1]遇到新值就写入合并两个有序数组逆向双指针p1m-1, p2n-1mn-1 从后往前nums1[p1] 与 nums2[p2]谁大谁放末尾避免覆盖注意前两个题的slow起始位置不同是因为第一个元素或前 k 个元素要保留所以从 1 开始移除元素因为每个位置都可能被移除所以从 0 开始。如果把这三个题的代码放在一起对比你会发现都遵循一个通用模板初始化指针 - 循环扫描 - 根据条件写入 - 移动指针 - 返回或处理结果。所谓算法能力其实就是在这些模板的基础上根据题目条件做微调。5.2 面试答题的推荐节奏与代码规范面试时遇到这三类题我建议按下面的节奏来首先审题时要明确一个关键问题允不允许使用额外空间有的题目明确要求 O(1) 空间那就是没有商量余地必须用原地覆盖但有的版本没有限制额外空间那你可以先提一个简单方案再优化到最优解。我面试时习惯先把这两个方案都说出来让面试官看到我考虑了不同的取舍。其次动手前先口头描述一遍思路比如”我准备用快慢指针fast 负责遍历slow 记录下一个写入位置当 fast 指向的元素满足条件时写入并移动 slow最后返回 slow。“ 这句话一说完面试官基本就知道你懂了。很多时候刷题没过不是代码写不对而是不会表达。最后写代码时要注意变量命名。不要用i、j这种没有含义的命名建议用slow、fast、p1、p2、p或者注释标明每个指针的含义。在面试高压环境下清晰的命名能帮你避免很多低级错误。5.3 我踩过的一些坑和调试技巧说几个我在刷题中真正踩过的坑你们写代码时留意一下。坑一用for循环的时候在循环体里误改动了fast。移除元素这么简单的题我也见过有人写成for (int fast 0; fast nums.size(); fast)然后在等于 val 的时候执行fast导致一次循环跳了两个位置。正确做法是等于 val 时不操作fast交给循环自己递增就行。坑二数组为空时直接访问nums[slow-1]。在去重那道题里如果你没加if (nums.empty()) return 0;的特判当数组为空时slow - 1 -1访问就会越界。虽然 LeetCode 的测试用例大概率不会给空数组但面试官可能会故意问所以特判一定不要忘。坑三合并两个有序数组时忘记处理nums2剩余元素。我在白板上写代码时经常出现一种情况nums2的元素比nums1的某些元素小所以它一直没被填入循环结束时p2却还没小于 0。如果循环条件是while (p1 0)那nums2中较小的元素就无缘无故丢了。我后来养成了一个习惯合并题写完第一个 while 后一定要顺手加一个处理剩余元素的 while。调试技巧方面我强烈建议你在刷题时养成“画表格”的习惯。拿一张纸画三行分别表示slow、fast、数组内容每一步都更新一次。虽然看起来慢但对于理清指针的移动逻辑非常有效。尤其是边界条件复杂的时候干瞪眼是瞪不出来的动手画一遍就通了。另外一个小建议这三道题在 LeetCode 上都有非常多的测试用例提交代码后如果报错别急着看答案先用错误的那个用例手动走一遍你的代码找到是哪一步覆盖错了。这个排查过程本身就是算法能力提升最快的方式。我刷这三道题时曾经各提交过四五次才过每错一次对指针的理解就加深一层。5.4 后续可以继续挑战的变体题把这三道题吃透以后你会惊喜地发现双指针的变形题非常多而且很多都是面试高频题移动零把数组里所有 0 移到末尾其实就是在“移除元素”的基础上把“不等于 val 的元素”改成“不等于 0 的元素”而这道题的写法甚至可以直接在移除元素的思路上加一步末尾补 0。有序数组的平方给你一个有序数组返回每个数字的平方组成的新数组要求也按非递减排序。因为有负数存在平方后最大的数一定在两端所以用左右对撞指针从两端向中间填。三数之和固定一个数再用左右对撞指针在剩余区间寻找两个数。这里用到的就是对撞指针思想。盛最多水的容器左右对撞指针每次移动较矮的那一侧时间复杂度 O(n)。长度最小的子数组快慢指针维护一个滑动窗口寻找满足和大于等于 target 的最短子数组。你会发现这些题的核心很多都是这三道题的思路延伸。我经常跟身边的朋友说基础题不刷透难题很难真正理解。双指针这个专题最有价值的入门组合恰恰就是这三道题别看它们难度低其中的思维含量一点都不低——快慢指针的“覆盖”思想、逆向指针的“避覆盖”思想理解了这两点以后数组类题目的路就宽了很多。提示刷题时别只满足于“通过了”试着问自己三个问题为什么 slow 从 0 开始或从 1 开始为什么比较对象是 slow-1 而不是 fast-1为什么从后往前合并这三个问题答清楚了才算真正吃透了双指针。我个人在实际操作中的体会是双指针算法的代码往往不超过十五行但每一行的位置、每一个比较的先后顺序都极其讲究。刷这三道题时如果你能写到不看题解、独立给出正确的边界处理那面试考到同类问题基本就稳了。它们就像是算法世界里的“基本功”练好之后后面的路会顺很多。