☰
LeetCode 26 删除有序数组中的重复项:双指针原地去重详解
2026/10/3 3:18:30 网站建设 项目流程

刷 LeetCode 的朋友应该都有这种感觉:简单题常常不是真的"简单",更像是一层窗户纸。删除有序数组中的重复项(题库里第 26 题)就是典型的窗户纸题目——所有解法摆出来也就十来行代码,但如果你没想明白"为什么用双指针""为什么覆盖不会丢数据",看答案以为自己懂了,合上答案自己写又卡住。这篇文章我想把这道题从题意、思路演进、代码细节到边界情况完整拆一遍,给你一份能直接拿去复盘和讲给别人听的完整笔记。

这道题适合三类人看:刚开始刷题、想打好数组基础的新手;准备面试、需要把双指针思想讲清楚的求职者;还有刷过一遍但想整理通用套路的进阶玩家。核心价值就一句话:搞懂它,你就同时掌握了"原地修改"和"快慢指针"这两个高频考点的标准打开方式。

1. 题目到底在考什么:有序数组的原地去重

1.1 先说清楚输入输出长什么样

题目给你的条件很明确:一个按非递减顺序排列的整数数组nums,也就是说数组整体是升序的,允许重复值存在,比如[0,0,1,1,1,2,2,3,3,4]。要求你做两件事:

  • 原地删除重复出现的元素,让每个元素只出现一次。
  • 返回删除后数组的新长度。

"原地"这个词是重点。你不能另外开一个新数组再把结果装进去,必须直接在原数组上操作。判题系统最终会检查你返回的长度k,并且验证数组的前k个元素确实是无重复的、相对顺序保持不变。

这里有个很多新手会忽略的细节:LeetCode 的判题机制只关心数组前k个位置的内容,k之后的位置随便是什么值都无所谓。这意味着你写代码时不需要、也不应该去把后面的元素清空,你只需要保证前k个位置正确即可。

1.2 为什么有序是这道题最大的突破口

如果数组是乱序的,去重通常得借助哈希表来记录"哪些值见过";如果数组有序,重复的元素必然紧挨在一起。这个性质直接决定了最优解可以做到只遍历一遍数组、且不用额外空间。

打个比方:有序数组去重就像整理一叠已经按编号排好的文件,重复的编号都在相邻位置;而无序数组去重就像从一堆乱放的卡片里挑出不同编号,你不用小本子记一下是没办法确认某个编号到底见没见过。题目特意强调"有序",就是在暗示你:别用哈希表,用指针就够了。

1.3 所谓 O(1) 额外空间究竟是什么约束

题目要求使用 O(1) 额外空间完成,也就是说除了函数调用栈和几个整型变量,你不能再申请跟数组规模相关的存储。这个约束直接排除了"复制到新数组"和"哈希表"两条路,逼迫你在数组内部想办法挪数据。

我第一次做这道题时其实走过弯路:想着先统计每个元素的出现次数,再按次数把数组重构一遍。这个思路本身没问题,但统计需要哈希表,空间复杂度就变成 O(n),不符合题目要求。后来我才意识到,题目要的不是"聪明的统计方法",而是"如何用最朴素的方式在数组内部腾挪"。这个认知转变很重要,它会直接影响你后面做一系列数组题目的思维方式。

2. 暴力解法为什么能过但必须淘汰

2.1 最直觉的做法:一边遍历一边删除

很多人拿到题的第一反应是:遍历数组,如果发现nums[i] == nums[i-1],就把nums[i]删掉。这个想法很自然,但在数组上做删除操作意味着要移动后面所有元素,时间复杂度是 O(n²)。而且如果用 C++ 的vector::erase或 Python 的list.pop,删除过程中迭代器/索引会失效,还要小心翼翼地回退索引,代码写起来很别扭。

如果你用的是 Python,确实可以写出看起来很简洁的版本:

def removeDuplicates(nums): i = 1 while i < len(nums): if nums[i] == nums[i-1]: nums.pop(i) else: i += 1 return len(nums)

这段代码逻辑没问题,也能通过 LeetCode 的测试。但它有两个隐患:第一,pop操作在列表中间执行是 O(n) 的,最坏情况下整体复杂度 O(n²);第二,它没有体现"原地覆盖"的思想,只是利用了编程语言提供的便利。在面试中如果只写出这个版本,面试官多半会追问一句:"你能不能只遍历一遍就完成?"

2.2 暴力法的瓶颈本质是"位置移动的成本"

数组这种数据结构的特点是:随机访问 O(1),但中间插入或删除需要搬动后续所有元素。所以凡是涉及数组删除的题目,只要数据规模大一点,O(n²) 就无法接受。LeetCode 的测试用例里数组长度可以到 3 万甚至更大,O(n²) 虽然不至于超时,但也已经处在危险边缘。

真正应该学会的思维方式是转换目标:不追求"物理删除"重复元素,而是追求"把不重复的元素依次放到数组前部"。后者的代价是 O(n),因为每个元素最多被移动一次。这就是双指针方案的核心思想。

2.3 从删除思维切换到覆盖思维

覆盖思维听起来可能有点反直觉——直接往原数组上写值,不会弄丢后面还没处理的数据吗?答案是:不会,因为快指针永远走在慢指针前面,慢指针写的位置一定是快指针已经扫描过的位置。快指针才是"探索者",慢指针是"记账员",记账员永远不会写到探索者还没去过的地方。

这个思维切换成功后,你会发现很多数组题都变简单了:删除元素、移动零、去除重复项,本质上都是同一套"覆盖前移"逻辑。所以这道题的价值不只是解一道题,而是帮你建立一种解决数组类问题的通用策略。

3. 双指针解法:从推导到代码的完整链路

3.1 快慢指针的语义设计

双指针解法里,两个指针的角色必须非常清晰:

  • 慢指针slow:指向下一个不重复元素应该存放的位置。初始时slow = 0,因为第一个元素无论如何都要保留,它是基准。
  • 快指针fast:负责遍历整个数组,寻找和当前保留元素不同的新值。初始时fast = 1,直接从第二个元素开始探索。

两个指针的初始值设定是有讲究的。slow从 0 开始,是因为下标 0 的元素一定是不重复的,不需要比较就知道它要被保留。fast从 1 开始,是因为我们要从第二个元素起逐个判断"这个元素是否和上一个保留的元素重复"。

3.2 核心判断逻辑:什么时候覆盖,什么时候跳过

遍历过程中,永远拿nums[fast]和nums[slow]比较,而不是和nums[fast-1]比较。这点很多人写错,两种比较方式在大多数情况下结果一样,但在某些场景下会有微妙差异,后面我会单独说明。先记住:和nums[slow]比较,语义是"当前快指针发现的值,是不是一个新的、和已保留集合末尾不同的值"。

判断逻辑只有两种情况:

  • nums[fast] == nums[slow]:说明遇到了重复值,不需要保留,fast继续往前走。
  • nums[fast] != nums[slow]:说明找到了新的不重复值。此时先把slow加 1(给新值腾出位置),再把nums[fast]的值写到nums[slow]上,最后fast继续往前。

整个过程结束后,slow的值加 1 就是新数组长度,因为它指向的是最后一个被保留元素的位置,位置编号加 1 就是元素个数。

3.3 手动走一遍完整流程

拿[0,0,1,1,1,2,2,3,3,4]举例:

  • 初始slow = 0, fast = 1,nums = [0,0,1,1,1,2,2,3,3,4]
  • fast=1,nums[1]=0,等于nums[0]=0,跳过,fast=2
  • fast=2,nums[2]=1,不等于nums[0]=0,slow变为 1,nums[1]=nums[2]=1,数组变为[0,1,1,1,1,2,2,3,3,4],fast=3
  • fast=3,nums[3]=1,等于nums[1]=1,跳过,fast=4
  • fast=4,nums[4]=1,等于nums[1]=1,跳过,fast=5
  • fast=5,nums[5]=2,不等于nums[1]=1,slow变为 2,nums[2]=nums[5]=2,数组变为[0,1,2,1,1,2,2,3,3,4],fast=6
  • 后续同理,fast发现 3 时填入nums[3],发现第二个 3 时跳过,发现 4 时填入nums[4]

最终slow = 4,返回slow + 1 = 5。数组前 5 位是[0,1,2,3,4],正确。

注意一个细节:在覆盖过程中,数组后面残留的旧值(比如步骤 6 中nums[3]仍然是 3)完全不用管,因为判题只看前k个位置。

3.4 完整代码与复杂度分析

下面是几种主流语言的实现,逻辑完全一致:

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
var removeDuplicates = function(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; };
int removeDuplicates(vector<int>& nums) { if (nums.empty()) return 0; int slow = 0; for (int fast = 1; fast < nums.size(); fast++) { if (nums[fast] != nums[slow]) { slow++; nums[slow] = nums[fast]; } } return slow + 1; }

复杂度分析:

  • 时间复杂度:O(n),快指针遍历数组一遍,每个元素访问一次。
  • 空间复杂度:O(1),只用了两个整型变量,没有额外数据结构。

这个方案已经是最优解,因为至少需要遍历一遍数组才能知道有哪些元素,不可能做到比 O(n) 更快。

4. 三个隐藏细节:和 nums[slow] 比较 vs 和 nums[fast-1] 比较的差别

4.1 两种比较方式真的等价吗?

网上很多题解写的是if (nums[fast] != nums[fast - 1]),然后nums[++slow] = nums[fast]。乍看之下,用nums[fast-1]和用nums[slow]作为比较基准,在"有序数组去重"这个场景下结果几乎总是相同的。这是因为当fast递增时,如果中间跳过了重复值,nums[fast-1]很可能仍然是那个重复值;而nums[slow]是指向最后一个已保留的唯一值,它和nums[fast-1]在很多情况下值相同。

但有一个微妙区别值得注意:直接用nums[fast-1]比较,隐含了"上一个元素就是已保留集合的末尾"这一假设。在去重场景下这个假设通常成立,因为重复值都挤在一起,只要数组有序,fast-1位置上的值要么和slow指向的值相同,要么就是刚刚被跳过的重复段里的值。可如果题目变形为"删除无序数组中的重复项",用nums[fast-1]就完全错了,因为无序时上一个元素不代表已保留的末尾。所以从代码的可迁移性角度,我建议你养成和nums[slow]比较的习惯,这是一个更稳健、更能应对变式的写法。

4.2 为什么覆盖操作不会弄丢未处理的数据

这是数组双指针最核心的一个疑问。覆盖操作是nums[slow] = nums[fast],slow永远小于或等于fast。当slow < fast时,nums[slow]是已经被快指针扫描过、不再需要保留的旧值;当slow == fast时,说明从开头到现在完全没有重复,此时nums[slow] = nums[fast]等于原地赋值,什么都没变。

换句话说,慢指针永远在快指针的身后或同位置,它写的位置必然是"已经被快指针看过且判定为不需要保留"的位置。所以覆盖是安全的,不会破坏尚未扫描的数据。这个性质值得自己推导一遍,理解了它,你对双指针的信心会完全不一样。

4.3 边界条件测试清单

面试或做题时,边界条件是最容易扣分的地方。我建议你提交前用下面这些用例过一遍代码:

测试用例期望输出说明
[]0空数组,直接返回 0
[1]1单元素数组,直接返回 1
[1,1,1]1全部重复,只保留一个
[1,2,3,4]4完全无重复,数组不变
[0,0,1,1,1,2,2,3,3,4]5混合场景,经典用例
[-3,-3,-2,-1,-1,0]4负数场景,逻辑同样适用

如果你写了if not nums: return 0这个保护,空数组就不会出问题;如果没有这个保护,直接在nums[0]上操作就会越界崩溃。单元素数组也要注意,循环体内range(1, 1)不会执行,直接返回slow + 1 = 1,逻辑天然正确。

4.4 从"删除重复项"到"删除指定值"的迁移

双指针方法不仅限于去重。LeetCode 第 27 题"移除元素"、第 283 题"移动零"本质上都是同一个套路,只是比较逻辑和写入逻辑稍作变化。

  • 移除元素:给定一个值val,要求原地移除所有等于val的元素。比较基准变成"是否等于 val",快指针扫描,遇到不等于 val 的值就写入慢指针位置。
  • 移动零:要求把数组里所有 0 移到末尾,同时保持非零元素相对顺序。快指针扫描,遇到非零值就写入慢指针位置,结束后慢指针后面的位置补 0。

如果你真正理解了"慢指针指向下一个应该写入的位置,快指针寻找有效值"这个抽象模型,这三道题就是一通百通的关系。我建议你按"删除重复项 → 移除元素 → 移动零"这个顺序连续刷,体会同一套框架在不同题目里的变体。

5. 一道题背后:双指针技巧的通用框架

5.1 双指针的两大类:快慢指针与左右指针

双指针技巧在算法题里是个大家族,主要分两类:

  • 快慢指针:两个指针同向移动,通常一个快一个慢。适用于链表判环、数组去重、链表找中点、移动零等场景。
  • 左右指针:两个指针从两端向中间移动。适用于有序数组两数之和、反转数组、回文判断、盛水最多的容器等场景。

本题属于前者,而且是最经典的入门载体。因为它的逻辑足够简单,没有复杂的数学推导,却完整展示了快慢指针的协作模式:一个负责探索,一个负责记录。把这个例子吃透,后面遇到链表的快慢指针题目时,你会有一种似曾相识的感觉,学习成本会降低很多。

5.2 双指针为什么能把 O(n²) 降成 O(n)

暴力解法的问题在于:每次删除一个元素都要把后面所有元素往前搬,搬移次数和数组长度相关。双指针的核心优化是:通过一次遍历,把每个元素最多移动一次。

更精确地说,暴力解法在"删除"时重复搬移了同一个元素多次(删一次搬一次),而双指针方案里,每个元素要么被快指针扫描一次,要么被慢指针写入一次,整体操作次数是线性的。这背后是一个更通用的思想——用覆盖代替删除。在很多数组题目中,删除是昂贵的,而覆盖是廉价的,你应该尽量把昂贵操作转化为廉价操作。

5.3 写题时的思考顺序:先抽象,再编码

看到一道数组题,我的建议是先别急着写代码,按这个顺序想:

  1. 题目要求什么?是物理删除还是覆盖即可?
  2. 输入有什么特殊性质?有序、无序、范围固定?
  3. 能否用两个指针分别承担不同职责?
  4. 指针的初始位置和移动条件分别是什么?
  5. 边界条件(空数组、单元素、全重复)怎么处理?

以本题为例:题目要求原地删除,输入有序,自然想到快慢指针;慢指针负责记录新数组的写入位置,快指针负责扫描;初始化慢指针 0、快指针 1;比较逻辑是不等则写入;边界是空数组返回 0。整个过程一旦理清,代码几乎是水到渠成的事。

5.4 一题多解:除了双指针还有别的思路吗

严格来说,针对"有序数组去重"这个具体场景,双指针已经是最优解。但如果你放宽限制,还可以考虑:

  • 利用语言特性:Python 可以用dict.fromkeys(nums)去重,但需要额外空间且不满足原地要求。
  • 二分查找边界:对每个唯一值找它在数组中的最后出现位置,然后移动元素。这个思路可以解决"删除有序数组中的重复项 II"那种允许重复两次的变体,但实现复杂。
  • 额外数组拷贝:最简单,但完全违背题目精神。

了解这些思路的意义在于:面试中面试官可能会在双指针基础上进一步变形,比如"如果每个元素最多保留两个副本呢"(LeetCode 第 80 题)。那时你就需要在双指针框架上增加一个计数器,记录当前保留了几个副本。理解了基础版的指针语义,变形题才不会被吓住。

6. 实测经验:提交、调试、复盘的最佳路径

6.1 我建议的第一次动手顺序

第一次做这道题时,不要直接抄代码。按下面的步骤来:

  1. 自己在纸上画一个数组,把slow和fast两个指针标出来,手动模拟一遍完整流程。
  2. 用你熟悉的语言写第一版,不要追求优雅,先保证逻辑正确。
  3. 提交到 LeetCode,看测试报告,重点看哪些用例没过。
  4. 如果某个用例没过,打印出每一步的数组状态和指针位置,逐个对比期望行为。
  5. 通过之后,再思考代码能否精简,是否有更直观的写法。

这套流程适用于几乎所有算法题。很多人在第 3 步就停了,然后去看题解——这其实是提升最慢的方式。亲手撞一次边界条件,比看十篇题解都有用。

6.2 我在调试中实际遇到过的坑

我第一次写这题时用的是nums[fast] != nums[fast-1]作为判断条件,提交后所有用例都过了,但后来做变形题"删除有序数组中的重复项 II"时,这个写法立刻给我带来麻烦。因为那道题需要知道"已经保留了几个相同元素",nums[fast-1]不能提供这个信息,我必须改成从nums[slow]和一个计数器共同判断。从那以后我统一改成和nums[slow]比较,避免了思维混乱。

另一个容易踩的坑是返回值弄错。有人最后会直接返回slow,但slow是下标不是长度,从 0 开始数,所以要加 1。这个错误在数组长度为 0 或 1 时都看不出来,但在混合用例下会差 1。建议最后写一行注释提醒自己"slow 是下标,长度是 slow+1"。

6.3 如何用这道题举一反三

建议你连刷以下四道题,按顺序练习:

  1. 26. 删除有序数组中的重复项(本题)
  2. 27. 移除元素:比较基准从"是否重复"变为"是否等于指定值"
  3. 283. 移动零:额外增加"末尾补零"的步骤
  4. 80. 删除有序数组中的重复项 II:允许保留两个副本,需要引入计数器

刷这四道题时,你最好维护一份自己的"双指针模板"笔记,记录通用的代码框架和每次变形时改动了哪一行。等这四道题全部吃透,你再看其他用到双指针的题,就不会有畏难情绪了。我个人实际体会是,把一道经典题彻底消化成自己的思维习惯,效果远好于囫囵吞枣刷十道题。每次刷题前先默写一遍快慢指针的语义,没过多久你就会发现,这类题已经变成肌肉记忆了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询