LeetCode第26题“删除有序数组中的重复项”是几乎所有刷题人都会遇到的一道经典题。题目本身很简单,但背后的双指针思想、原地修改约束、以及对“有序”这个条件的利用,却是一堆进阶题的地基。网上讲这道题的教程很多,但这篇我想换个方式,不只讲AC代码,而是从题目设计意图出发,把为什么这么解、边界在哪、以及它和真实开发中SQL去重、对象数组去重、数据清洗怎么关联起来,一次性说透。无论你是刚准备刷题的初学者,还是想在面试中把这道题讲出深度的人,这篇都值得看完。
1. 题目解读与问题本质
1.1 题目到底在考什么
原题描述很简短:给你一个升序排列的数组,要求原地删除重复元素,使每个元素只出现一次,返回删除后数组的新长度。注意“原地”两个字,意味着不能新建一个数组来装结果,必须直接修改原数组。额外空间复杂度被限制在O(1)。末尾多出来的元素不参与判断,只要保证前length个元素是去重后的结果就行。
很多人上来就想到用Set,先把数组元素塞进集合,再倒出来。这种做法在LeetCode的判题环境里确实能输出正确长度,但在面试里只要说出“用哈希表”,基本就输了。原因很简单:Set需要O(n)的额外空间,违反了题目“原地”的约束。这道题真正想考的是,你能不能在一个有序数组上,用有限的几个变量完成去重。更进一步说,它考察的是你对“快慢指针”模型的理解,以及你处理边界条件的能力。
LeetCode把这道题放在入门序列,并不是因为它难,而是因为它足够典型。我能想到的最贴切的比喻,就像整理一排书架:书已经按高矮排好了,有一些重复的版本混在里面,你不能把书全部搬出来放到另一个书架上再搬回来,只能在现有书架上腾位置,把不重复的书推到前面去。这个过程的关键就是“记住当前不重复书排到哪了”,和双指针的慢指针一模一样。
1.2 为什么“有序”是解题关键
这道题能只用O(1)空间解决,核心依赖一个条件:数组有序。当数组有序时,所有重复元素都是紧挨着的。有了这个性质,你才能确定“跳过一段连续相等的值之后,遇到的一定是新的不重复元素”。如果数组无序,比如[3,1,2,1,3],两个3一个在头一个在尾,不靠哈希表根本没法高效判断,空间复杂度一下就上去了。
这个差别很值得品味。同样是去重,有序数组可以把问题简化成“单次扫描 + 局部覆盖”,无序数组就得靠哈希表做全局记录。这和SQL里DISTINCT的实现逻辑有异曲同工之处:数据库在处理去重时,要么走排序聚合,要么走哈希聚合,核心就是看数据有没有可利用的顺序。如果你能把这道题想明白,日后看数据库执行计划里的去重算子,也会更有感觉。
另外要注意,题目说的是“有序”,通常默认升序。其实降序也一样能解,只是比较方向反过来,但力扣题面明确给的是非严格递增序列,我们按升序处理即可。还有一个隐藏信息:数组长度可能是0,也可能是1,这两个边界在实现时都要覆盖到。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 双指针解法拆解
2.1 双指针的思路从哪来
我第一次做这道题时,脑子里冒出过一种笨办法:每次遇到一个重复元素,就把后面的所有元素往前搬一格。这个想法直观,但仔细一算复杂度是O(n^2),一旦数组里有大量重复元素,性能会很难看。而双指针的做法,本质上只用了一次遍历,代价是O(n),空间上只用了两个int变量,这就是它优雅的地方。
双指针具体怎么设计?快指针负责“在前面找新元素”,慢指针负责“告诉快指针新元素该放到哪”。慢指针初始为1,因为下标0的元素一定会被保留,不用比较。快指针从1开始遍历。每到一个位置,就拿nums[fast]和nums[slow - 1]比较。
这里有个细节值得多说几句。为什么不拿nums[fast]和nums[fast - 1]比较?我当时学的时候就有这个困惑。实际上nums[fast - 1]只是快指针的前一个元素,它可能是一个已经被判为重复而跳过的元素;而nums[slow - 1]是“答案区最后一个被保留的元素”。我们要判断的是“快指针遇到的这个新元素,和已保留的最后一个元素是否相同”,所以和nums[slow - 1]比较才是语义正确的做法。在有序数组上,两种写法结果碰巧一样,但换到后面的第80题,也就是允许每个元素最多出现两次的变体里,用nums[fast - 1]就会出错,用nums[slow - k]才是通用解法。
2.2 代码实现与手把手走查
先给出最经典的实现,Python版本:
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
同样逻辑的Java版本:
java复制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];
}
}
return slow;
}
拿一个例子完整走查一遍,比如nums = [0, 0, 1, 1, 1, 2, 2, 3, 3, 4]。
初始状态:slow = 1,fast = 1。
- fast=1,nums[1]=0,nums[slow-1]=nums[0]=0,相等,跳过。
- fast=2,nums[2]=1,nums[0]=0,不等,执行nums[1]=1,此时数组前几位变成[0,1,1,1,1,2,2,3,3,4],slow变为2。
- fast=3,nums[3]=1,nums[slow-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。
- fast=8,nums[8]=3,nums[3]=3,相等,跳过。
- fast=9,nums[9]=4,nums[3]=3,不等,执行nums[4]=4,slow变为5。
最终返回5。数组前5个元素依次是0、1、2、3、4,正好是去重后的结果。数组后面留着4、4这样的垃圾值,但题目并不关心。
观察这个过程你会发现,快指针永远走在慢指针前面,所以“把nums[fast]写进nums[slow]”不会覆盖掉还没遍历到的元素。这一点也是双指针原地操作能成立的逻辑基础。
2.3 用一个通用模板理解快慢指针
当你理解了上面这段代码,可以顺势把它抽象成更通用的模板:
python复制def process(nums, k):
if len(nums) <= k:
return len(nums)
slow = k
for fast in range(k, len(nums)):
if nums[fast] != nums[slow - k]:
nums[slow] = nums[fast]
slow += 1
return slow
k=1时,就是第26题;k=2时,就是LeetCode第80题“删除有序数组中的重复项 II”。这个模板我愿称为“有序数组去重全家桶”。它背后的语义是:答案区允许保留k个相同元素,慢指针从k开始,因为前k个元素无论如何都该保留;每次拿nums[fast]和nums[slow - k]比较,后者表示答案区倒数第k个位置的元素。如果不相等,说明fast处的元素在答案区里最多只会造成k个相同的,可以接纳;如果相等,说明再加进来就超过k个了,必须跳过。
很多讲解不会把这层关系点出来,但你一旦看穿这层窗户纸,做变体题就基本不用再费脑子。
3. 变体与扩展:从数组去重到真实场景
3.1 保留K个重复项的通用写法
第80题在原题基础上加了一个条件:每个元素最多出现两次。如果用我上面说的通用模板,改一个参数就行。
python复制def removeDuplicates2(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
试着走一遍[1, 1, 1, 2, 2, 3]:
- slow=2,fast=2,nums[2]=1,nums[slow-2]=nums[0]=1,相等,跳过。
- fast=3,nums[3]=2,nums[0]=1,不等,执行nums[2]=2,slow=3。
- fast=4,nums[4]=2,nums[1]=1(slow-2=1),不等,执行nums[3]=2,slow=4。
- fast=5,nums[5]=3,nums[2]=2(slow-2=2),不等,执行nums[4]=3,slow=5。
最终返回5,数组前5个是[1, 1, 2, 2, 3],三个1被削掉了一个,正确。如果你愿意,k可以继续改成3、4……题目限制是常数次重复,但那不影响模板。这个泛化能力就是算法的魅力。你看一道题,不是记一条逻辑,而是记一类问题的共同结构。
3.2 SQL去重、对象数组去重,和这道题有什么关联
这道题表面上是纯算法,但去重的思想在实际开发里到处都是,热词里出现的“sql语句去重”“对象数组去重”就和它有内在关联。
先看SQL。SELECT DISTINCT user_id FROM orders,数据库内部实现去重往往有两种路径:第一种是hash聚合,也就是扫描时用一个哈希表记录已经见过的值;第二种是排序聚合,先按user_id排序,然后顺序扫一遍,遇到和上一个相同的就跳过。后面这种思路,和这道题的双指针几乎是同一个逻辑。你可以把有序数组想象成“已经排好序的SQL结果集”,双指针去掉重复项,对应SQL执行流程里的Stream Aggregate算子。
再看对象数组去重,比如JavaScript中:
javascript复制const users = [
{ id: 1, name: '张三' },
{ id: 2, name: '李四' },
{ id: 1, name: '张三' }
];
const seen = new Map();
const uniqueUsers = users.filter(user => {
if (seen.has(user.id)) {
return false;
}
seen.set(user.id, true);
return true;
});
为什么这里用Map而不是Set?因为对象的比较是引用比较,两个内容相同的对象在JS里并不相等,你不能直接放进Set里去重,必须指定一个唯一的业务键(比如id)。这跟算法题里“数组元素是数字所以可以直接比较”形成了鲜明对比。换句话说,去重的前提是“能定义什么是重复”。在有序数字数组里,重复就是值相等;在对象数组里,重复通常指某个关键字段相同。
我用这个例子想说的是,你在LeetCode上练习的双指针,并不是只能用来做题。它代表了一种“利用已有顺序减少空间消耗”的通用策略。数据清洗时如果拿到的是有序日志序列,你完全可以写一个和上面几乎一样的指针逻辑,单次遍历把重复记录压缩掉;如果数据无序,才需要考虑哈希表或者先排序。这种选型判断力,才是刷题真正带到工作里的东西。
4. 常见错误与性能细节
4.1 新人最容易踩的坑
这道题出错主要集中在三处:边界处理、返回值理解、以及修改数组的方式。
第一次写的时候,最容易犯的错是忘记判空。数组长度为0时,直接访问nums[0]会抛异常。第二个常见问题是返回值理解偏差。题目只要求返回新长度,数组本身允许残留后面那些元素。很多人会下意识想“我要不要真的把后面的多余元素删除”,这就把简单问题想复杂了,还可能引发O(n^2)的删除操作。第三个问题是边遍历边用splice或pop来删元素。Python里边for边del、JS里边forEach边splice,都会造成索引错乱或漏元素,属于经典翻车现场。
我用一个表格总结一下:
| 常见错误 | 后果 | 正确做法 |
|---|---|---|
| 忘记判空数组 | nums[0]越界异常 | 开头加if not nums判断 |
| 返回nums.length | 输出的长度包含重复数据 | 返回slow指针,它才是去重后长度 |
| 用splice/remove删除元素 | 数组长度变化导致索引错乱,且复杂度O(n^2) | 只覆盖前slow位,不真正删除 |
| 快指针从0开始 | 和自己比较一次,纯属多余 | 快指针从1开始即可 |
| 用nums[fast-1]做比较 | 第26题碰巧能过,但换到第80题必挂 | 和nums[slow-k]比较,语义才是对的 |
4.2 复杂度分析与为什么不能更快
双指针解法时间复杂度O(n),空间复杂度O(1),这是这道题的最优解。为什么不能更快?因为任何去重算法都必须至少看一遍所有元素,否则最后一个元素是重复的还是全新的,你根本不知道。哪怕数组是有序的,二分查找也只能帮你跳过重复段,但最坏情况下数组恰好没有重复,你还是得逐个访问一遍。
有些细心的读者可能想优化:既然数组有序,能不能用二分查找“跳到下一个不同元素”的位置,一次跨过很长一段重复区间?确实可以,极端情况下比如100万个1后面紧跟2,这种跳跃能省不少比较。实现也简单,内层加一个while跳过相等元素即可:
python复制def removeDuplicatesJump(nums):
if not nums:
return 0
slow = 0
fast = 0
n = len(nums)
while fast < n:
nums[slow] = nums[fast]
slow += 1
cur = nums[fast]
while fast < n and nums[fast] == cur:
fast += 1
return slow
但这只是常数级别的优化,最坏复杂度依然是O(n),而且代码可读性比经典双指针差一些。面试时你可以把这个思路作为加分项提一嘴,但平时写代码我更推荐经典写法,因为它的可变性更强,往第80题迁移时也更顺。
5. 实操感悟与刷题建议
5.1 面试和刷题的正确打开方式
我见过不少同学这道题能AC,但面试官追问“为什么慢指针初始值是1”就答不上来。这说明他只是背了模板,没有真正理解指针的含义。面试时更推荐的演示节奏是:先说明两个指针各自负责什么,慢指针指向“答案区下一个可写入位置”,快指针负责“扫描未知区域”;然后举一个短例子手动走一遍;最后再写代码。哪怕代码里有个别bug,逻辑说的是清楚的,面试官通常也会给过。
刷题层面,我建议把这题和它的直接变体一起练习。先做第26题,再做第80题,然后试试第27题“移除元素”,你会发现它们共享同一个骨架,只是比较对象和写入位置略有不同。连续做三题,比分散刷十题更能建立联想。
5.2 一个价值被低估的细节
最后想夸一下这个细节:始终和答案区边界元素比较,而不是和快指针前一个元素比较。这个习惯在开头看起来没什么差别,但到了“每个元素最多出现两次”这种变体里,它会直接决定你能不能一次写对代码。我自己的经验是,凡是这类原地压缩数组的题目,写之前先在草稿纸上标出slow和fast的位置,模拟三个元素,基本就不会错。整套逻辑想明白之后,你会发现这道题不只是“去重”,它是在教你如何用最少的资源维护一个结果区。这个思路用到日志压缩、缓存淘汰、数据清洗里,都同样成立。
