news 2026/10/19 17:27:01

有序数组去重必学:双指针原地修改算法详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
有序数组去重必学:双指针原地修改算法详解

这道题在刷题圈里几乎无人不知:删除有序数组中的重复项。它是经典中的经典,不少人第一次在题库里刷它,是在刚接触数据结构的阶段,好多人以为两分钟就写完了,结果一提交就被边界用例教做人。也有不少人背下了答案,但面试官随口问一句“你这个慢指针为什么从 1 开始”,就愣住了。这篇想把这题从头到尾拆开讲清楚,包括题目到底在考什么、双指针怎么想出来、代码为什么要那样写、以及它背后真正通用的套路。适合刷题新人看,也适合准备面试、想把这题讲明白的人参考。

1. 先别急着写代码:这题到底在问什么

很多同学拿到题目,第一反应就是“我会去重”,然后开始想怎么把重复的删掉。但这件事在数组上实现,并没有想象中那么直接。

1.1 “有序”两个字是整个解法的命脉

题目明确说了“有序数组”。这四个字不是白给的,它意味着所有重复元素都连续排在一起。比如[1, 2, 2, 3, 4, 4, 4, 5],重复的 2 都连在一起,重复的 4 也都连在一起。

这个性质有多重要呢?它让你判断“当前这个元素是不是重复的”这件事变成了只需要和前一个元素比较。如果数组是无序的,一个元素可能在任意位置再次出现,你只能靠哈希表去记录“见过谁”,空间复杂度立刻就上去了。但有序数组不需要,你永远只需要回头看一个位置。这个看似简单的条件,是整个双指针解法成立的大前提。

有些人写代码的时候没意识到这一点,拿着双指针去硬套无序数组去重题,结果发现漏删或者错删。本质问题就出在这里:你没有用上“有序”这个信息。

1.2 “原地”和 O(1) 的真正含义

再看题目的两个硬性要求:原地修改数组,额外空间使用 O(1)。

原地修改(in-place)的意思是:你不能新建一个数组,把去重后的结果放进去再返回。你只能在传入的这个nums数组上动手。这其实就是说,结果数据必须覆盖回原来的数组里,而不是另起炉灶。

O(1) 额外空间的含义更严格:你可以用几个变量(整数、指针、循环变量),但不能用和数组长度相关的容器,比如哈希表、新数组、栈、列表。为什么很多人的第一版解法会挂在这?因为他们很容易写着写着就ans = []、seen = set(),这些操作单独看都挺自然,但在这个题目约束下全部不允许。

还有一个容易忽略的细节:返回值和最终数组的关系。题目要求返回去重后的新长度,比如去重后数组应该是[1, 2, 3],你就返回 3。至于原数组中索引 3 以及之后的位置上还留着什么旧值,没人关心,也不需要你去“清理干净”——这在后面第 4 章我会再展开说。

2. 从暴力法到双指针:完整推导过程

别小看这道题,它的解法不是一拍脑袋就有的。从最直观的暴力做法,到最优的双指针方案,中间其实有一条清晰的推导路径。

2.1 暴力解法的问题出在哪

最容易想到的做法是:从左到右扫描,发现某个元素和前一个重复了,就把它之后的所有元素整体往前挪一格。比如[1, 2, 2, 3],扫描到第二个 2 时,把后面的 3 往前覆盖,变成[1, 2, 3, 3],然后继续往后扫。

这种做法逻辑上完全正确,但有一个致命问题:每一次删除都可能引发一次大面积的元素移动。极端情况比如数组里有一万个元素,后面九千个都是重复的,那你每到一个位置就可能要移动一次剩余元素,整体时间复杂度会退化到 O(n^2)。虽然这个题在 LeetCode 上数据量不一定大到卡死你,但从算法设计的角度来说,这是不可接受的。

暴力思路还有一个更隐蔽的坑:你在扫描过程中不断修改数组,这会导致下标管理变得非常麻烦。用 Python 的del或者pop更不行,那会触发列表内部的内存移动,当时看着没问题,但你需要处理的边界条件会让你怀疑人生。

2.2 双指针的精髓:把逻辑上的“删除”改成“覆盖”

真正的突破口在于视角转换。数组的“删除”在底层并不是真的把这块内存抹掉,而是用后面的元素把前面的元素覆盖掉。既然最终我们只关心去重后的元素按顺序排在数组最前面,那我们完全不需要一个一个“删”,而是派一个指针去“收集”不重复的元素,把它们往前面写。

这就是双指针的雏形:

  • 一个指针负责向前遍历整个数组,检查每一个元素是不是“新遇到的、不重复的元素”。这个指针我们叫它快指针fast。
  • 另一个指针负责标记“下一个不重复元素应该写入的位置”。这个指针我们叫它慢指针slow。

快指针走过的地方,只要是第一次遇到的元素(因为有序,也就是跟前一个保留的元素不同),就把它丢给慢指针当前位置,慢指针再往前进一格。如果快指针发现当前元素和上一个保留的元素相同,就不管它,直接跳过。

你仔细品一下这个过程:快指针像是一个筛选员,负责判断“这个值是不是重复的”;慢指针像一个收件员,只负责站在下一个空位置上,等待筛选员把合格元素递过来。

2.3 慢指针到底指向哪里,代码才不会乱

双指针的写法在网上能看到很多版本,但最流行的有两类,区别就在慢指针的含义上。

一种写法让slow指向“已经归位的不重复序列的最后一个元素”,初始化在 0,快指针从 1 开始。当发现nums[fast] != nums[slow]时,先把slow加 1,再把nums[fast]写进nums[slow]。这种写法判断条件写起来很自然,但新手容易绕不明白“为什么要先加 1 再赋值”。

另一种写法让slow指向“下一个可以写入的位置”,初始化在 1,快指针也从 1 开始。此时slow - 1就是“已经归位序列的最后一个元素”,比较nums[fast]和nums[slow - 1]即可。如果不同,直接把nums[fast]赋值给nums[slow],然后slow自增。这种写法我个人更推荐,因为slow的角色极其明确,后续遇到变体题时也更容易推广。

两种写法最后返回的数值也不一样,第一种返回slow + 1,第二种返回slow。记住这个,不然很容易在边界测试上翻车。

3. 代码实现与逐行注释

思路清楚了,代码就变得非常机械。我直接给出推荐版本,再附上注释和不同语言的注意点。

3.1 Python 参考实现

先看我日常最常用、也最容易讲明白的版本:

def removeDuplicates(nums): if not nums: return 0 slow = 1 # 下一个不重复元素应该写入的位置,同时表示当前不重复序列的长度 for fast in range(1, len(nums)): if nums[fast] != nums[slow - 1]: nums[slow] = nums[fast] slow += 1 return slow

逐个解释关键点:

  • 空数组直接返回 0,这是第一个边界条件。
  • slow = 1的初始含义是:数组第一个元素一定是不重复的,所以它天然是去重结果的第一位,下一个写入位置从索引 1 开始。
  • fast从 1 开始遍历,没必要从 0 看,因为第一个元素不可能靠自己判断重复。
  • 比较的是nums[fast]和nums[slow - 1]。slow - 1是当前最后一个已经保留的元素。在有序数组里,新元素如果和它相同,就说明是重复的;如果不同,就是新值。
  • 一旦发现新值,就写入nums[slow],并让slow加 1。这个slow自增本身就是在“记录新长度”。

举个例子走一遍:nums = [0, 0, 1, 1, 1, 2, 2, 3]。

  • 初始slow = 1。
  • fast = 1,nums[1] = 0,和nums[0] = 0相同,跳过。
  • fast = 2,nums[2] = 1,和nums[0] = 0不同,执行nums[1] = 1,数组变成[0, 1, 1, 1, 1, 2, 2, 3],slow = 2。
  • fast = 3,nums[3] = 1,和nums[1] = 1相同,跳过。
  • fast = 4,nums[4] = 1,和nums[1] = 1相同,跳过。
  • fast = 5,nums[5] = 2,和nums[1] = 1不同,执行nums[2] = 2,slow = 3。
  • fast = 6,nums[6] = 2,和nums[2] = 2相同,跳过。
  • fast = 7,nums[7] = 3,和nums[2] = 2不同,执行nums[3] = 3,slow = 4。

最终返回 4,数组前四个元素是[0, 1, 2, 3],完全正确。而且整个过程只遍历了一遍数组,额外空间只有两个变量,真正的 O(n) 时间和 O(1) 空间。

3.2 不同语言下的细节差异

同一个逻辑,不同语言写起来需要注意的细节不太一样。

JavaScript 版本:

var removeDuplicates = function(nums) { if (nums.length === 0) return 0; let slow = 1; for (let fast = 1; fast < nums.length; fast++) { if (nums[fast] !== nums[slow - 1]) { nums[slow] = nums[fast]; slow++; } } return slow; };

JavaScript 里数组的长度是可变的,但这里你不要去修改nums.length,也不需要splice删除多余元素。力扣判定时会只看返回长度对应的前几个位置,手动缩小数组反而浪费时间。

Java 版本:

class Solution { public int removeDuplicates(int[] nums) { if (nums.length == 0) return 0; int slow = 1; for (int fast = 1; fast < nums.length; fast++) { if (nums[fast] != nums[slow - 1]) { nums[slow] = nums[fast]; slow++; } } return slow; } }

Java 的数组长度固定,你根本没法“删除”元素,所以更能体现“覆盖”的思路。这里返回slow后,调用方只需要遍历前slow个元素。

C/C++ 因为在系统层面更贴近数组本质,理解起来其实更顺——nums[slow] = nums[fast]就是一次内存拷贝,快指针遍历,慢指针标记写入位置,和底层内存操作完全对应。

3.3 必测的边界用例

写任何算法题,提交前都建议自己先跑几个用例。这个题至少测这些:

输入期望返回说明
[]0空数组直接返回 0
[1]1单元素数组不用处理
[1, 1, 1, 1]1所有元素都相同
[1, 2, 3, 4]4全部不重复,原样保持
[1, 1, 2, 2, 3, 3]3每个值重复两次
[0, 0, 1, 1, 1, 2, 2, 3]4常规混合情况

我习惯在本地写一个简单断言,直接打印返回值再打印修改后的数组前几位,确认是否符合预期。尤其是nums = [1, 1, 1, 1]这种用例,很多人第一次会卡在slow初始值上,输出 0 或者抛异常。

4. 实战中容易踩的坑与排查心得

这题代码短,但面试里能展开的东西并不少。不少人看完答案以为自己会了,一追问就露馅,问题大多集中在下面这几个地方。

4.1 返回值是长度,不是数组

这是新手最容易误会的地方。题目明确要求返回新长度,但很多本地调试的人会直接print(nums),发现数组后面还残留着旧值,比如[0, 1, 2, 3, 1, 2, 2, 3],立刻觉得自己写错了。

实际上,算法运行完后,数组的前slow个位置才是有效结果,后面的元素全部属于“历史残留”,题目不检查,你也不需要处理。这就好比你把桌面上的文件整理到前几个抽屉里,后面抽屉里堆着什么旧文件,根本不影响你整理完成这件事。

所以排查时,你应该打印的是nums[:slow],看这个切片是否符合预期。如果这个对,代码基本就没有逻辑问题。

4.2 快慢指针初始化的两种写法,选哪种

慢指针初始化成 0 还是 1,网上两种答案都有。比如这种:

def removeDuplicates(nums): if not nums: return 0 slow = 0 for fast in range(1, len(nums)): if nums[fast] != nums[slow]: slow += 1 nums[slow] = nums[fast] return slow + 1

这版也能跑,判断逻辑是“当前元素是否和最后一个保留元素不同”。但问题是,这种写法里slow的语义是“最后一个保留元素的位置”,当你发现新值时,必须先自增再写入,代码的每一步都需要你在脑子里多绕一层。

我更推荐慢指针从 1 开始,把slow定义为“下一个写入位置”。这样比较nums[fast]与nums[slow - 1]时,slow - 1让你一眼就知道是在和“当前结果序列最后一个元素”比较,写读操作也更自然。面试里讲解时,这种定义方式听上去也更清晰,不容易被考官追问“这里为什么先加 1”。

4.3 面试官喜欢追问的变体:如果允许重复两次?

这题有一道特别经典的延续题:给定有序数组,允许每个元素最多出现两次,原地去重,返回新长度。比如[1, 1, 1, 2, 2, 3]需要变成[1, 1, 2, 2, 3]。

很多背诵解法的人在这里会突然卡住,因为他们只记住了“比较当前元素和上一个”这个操作,却没理解背后的本质。

一旦你理解了“慢指针是下一个写入位置”这个概念,这道变体就非常顺:

def removeDuplicates_allowTwo(nums): if len(nums) <= 2: return len(nums) slow = 2 for fast in range(2, len(nums)): if nums[fast] != nums[slow - 2]: nums[slow] = nums[fast] slow += 1 return slow

为什么比较nums[fast]和nums[slow - 2]?因为slow - 2是当前“已保留序列中,从后往前数第二个位置”。如果新元素和前第二个位置都相同,说明它至少和前面两个数都相等,那就已经出现三次了,必须跳过。如果不同,说明它在当前结果序列中最多只会带来两个相同的值,可以写入。

这个思路推广一下,甚至可以直接解“最多保留 K 个重复项”的通用模板,把slow初始化为k,比较nums[fast]与nums[slow - k]。这就是搞懂了原理之后举一反三的效果。

5. 一套思路吃透同类题:双指针的通用模板

最后这一章,我想把视野拉开。如果你只把这题当成一道“要背的答案”,那就亏了。它其实代表了数组类题目里非常重要的一类模式:用快慢双指针做原地状态更新。

5.1 变体一:移除指定元素

这算是“删除有序数组中的重复项”的亲兄弟,题目要求原地移除所有数值等于val的元素,返回新长度。你可以完全套用慢指针的写法:

def removeElement(nums, val): slow = 0 for fast in range(len(nums)): if nums[fast] != val: nums[slow] = nums[fast] slow += 1 return slow

这里甚至不需要比较相邻元素,只需要把不等于val的值全部“收集”到前面。你看,slow还是“下一个写入位置”,fast负责筛选,逻辑一模一样,只是筛选条件变了。

5.2 变体二:移动零

再看一道高频题:把数组里所有 0 移动到末尾,同时保持非零元素的相对顺序。你能用同样的思路解:

def moveZeroes(nums): slow = 0 for fast in range(len(nums)): if nums[fast] != 0: nums[slow], nums[fast] = nums[fast], nums[slow] slow += 1

慢指针从左往右维护“非零序列的下一位置”,快指针扫到非零元素就交换过来。这样所有 0 会被自动“挤”到后面。如果你熟悉这个套路,刷题时一眼就能看出这类题本质上是一模一样的。

5.3 用“写入指针”的视角重新看待这类题

做了这些题之后,我最大的体会是:不要把它们当成独立题目去背。双指针里真正有用的思维模型是“快指针负责遍历,慢指针负责记录下一个写入位置”。

凡是遇到“需要在原地按某种规则筛选元素”的问题,你都可以往这个模型上靠:

  • 写指针维护“结果序列”的长度和写入点。
  • 读指针在原数组上逐个扫描。
  • 判定条件决定读指针扫描到的元素该不该被写入。

一旦你习惯了这种思维,像“删除排序链表中的重复元素”、“把排序数组整理成每个元素都不重复”等变体,其实都是同一套内功心法的不同招式。你不需要把一个一个题都背住,只需要知道怎么把题目翻译成“筛选条件”。

我自己的经验是,第一次接触这题时,光看答案看了半天也没懂,后来动手在纸上把数组的每一步变化画出来,才恍然大悟。如果你也觉得代码抽象,建议也试一试:拿一组短数组,手动模拟慢指针和快指针的每一次移动,写完几个用例基本就能理解为什么慢指针是从 1 开始、为什么要比较slow - 1位置的元素。这个习惯帮我后面解决了不少更复杂的数组题,也推荐给你。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 17:26:33

MySQL REPLACE() 函数:数据清洗与批量修正的实战指南

如果你在某个项目的存量数据里翻过Bug&#xff0c;大概率会被一类问题缠住&#xff1a;值本身没错&#xff0c;就是格式烂、内容脏、符号乱。最近我处理一批历史数据修正任务时&#xff0c;就撞上了这种场景——路径分隔符混用、富文本里塞满换行和全角空格、业务标识里多了一段…

作者头像 李华
网站建设 2026/10/18 8:57:23

context-mode:从隐式状态到显式上下文模式的设计与实践

1. 从"context-mode"这个词说起&#xff1a;它到底指什么第一次看到"context-mode"这个标题&#xff0c;很多人会愣一下——它不像"XX管理系统"或"XX爬虫"那样一眼能看出用途。我当初也是被这个词吸引&#xff0c;琢磨了半天才反应过来…

作者头像 李华
网站建设 2026/10/18 9:00:38

Object → Method Call:WSaiOS-SI 中方法调用的结构运行理论

Object → Method Call&#xff1a;WSaiOS-SI 中方法调用的结构运行理论作者&#xff1a;东塬一老翁单位&#xff1a;WSaiOS 多模态智能日期&#xff1a;2026 年 10 月摘要本文基于 WSaiOS-SI 中 Class、Object、Attribute、Value、Method 等基本理论&#xff0c;系统讨论“方法…

作者头像 李华
网站建设 2026/10/18 0:31:48

CoDeSys运动控制:Gantry3pi坐标变换与插补器参数调试

简介&#xff1a;CoDeSys运动控制资料包面向工业自动化领域的PLC编程、运动控制与机器人调试人员&#xff0c;系统讲解CoDeSys软PLC中的运动控制实现方案。文档以SMC_TRAFO_Gantry3pi模块和SMC_Interpolator功能块为核心&#xff0c;逐一说明目标位置向量、x/y偏移量、移动范围…

作者头像 李华
网站建设 2026/10/18 11:57:56

一次召回动辄数以亿计:QMS凭什么能在几小时内圈定影响范围?

凌晨两点&#xff0c;供应商那条原料线出问题的消息传来。作为质量负责人&#xff0c;你脑子里冒出的第一个念头往往不是“怎么查”&#xff0c;而是“要砍多少”——用这批原料做过的产品去了哪些客户&#xff1f;已经发出去多少&#xff1f;仓库里还压着多少&#xff1f;几个…

作者头像 李华
网站建设 2026/10/18 4:30:03

Java并发编程JUC实战:锁、原子类、并发容器与线程池

做Java开发这几年&#xff0c;凡是碰到高并发场景&#xff0c;最后的落脚点基本都跑不掉JUC&#xff08;java.util.concurrent&#xff09;这套工具包。它从JDK 1.5引入&#xff0c;一路演进到现在&#xff0c;几乎成了Java并发编程的事实标准。不管是多线程数据共享、任务协作…

作者头像 李华