我第一次带零基础的朋友刷力扣,一般会先扔出这道题:删除有序数组中的重复项。四个字,没有链表、没有递归、没有动态规划,双指针法在这里出现得毫无攻击性。力扣题库里双指针题目不少,但多数新手的第一道双指针题都会是它,原因很直白——它是少数几道你只靠“for 循环 + 一个 if”就能写完并提交通过的题,但想真正把它讲透,又确实值得写一整篇。
很多人刷完这题只是记住了代码,碰到变体又懵了:什么时候用 slow 和 fast?为什么 slow 初始是 1?为什么题目说“删除”,但原数组的最后几位根本没变?这篇文章就顺着这条线,从题面一步步推到代码,再把双指针的通用模板抽出来,让你以后看到同类题能直接套。零基础不用怕,全文不堆术语,每个细节都掰开讲。
1. 为什么我把这道题排在带刷清单的第一位
1.1 前置知识约等于零,却能立刻建立“我能行”的正反馈
很多新手一上来就刷“两数之和”“最长回文子串”,结果被哈希表、动态规划劝退。第 26 题不一样,它只需要三样东西:数组怎么存取、for 循环怎么写、函数怎么返回值。任何一个学过一两种编程基础语法的人,都能在半小时内把代码写出来。
这种“第一次刷题就能提交通过”的感觉特别重要。我自己刚开始刷题时,前几道题全是红叉,一度怀疑自己不适合写代码。直到碰到删除有序数组中的重复项,照着双指针的思路推了一遍,提交通过的那一刻,才真正建立起继续刷下去的信心。所以如果让我给零基础的人列清单,这一题必定排第一,它不只是教一个算法,更是帮你跨过“不敢动手”的心理门槛。
1.2 双指针的“最小可运行模型”,复杂度刚好能看透
双指针在力扣里是个大家族:有对撞指针、快慢指针、滑动窗口,还有链表里的双指针。第 26 题用的是其中最容易理解的快慢指针,而且它把快慢指针的完整结构压缩到一个数组、一个循环里。
你可以把这道题看成双指针的“最小模型”:两个指针都在数组上从左往右移动,一个负责找新元素,一个负责记录写入位置。没有链表指针的跳跃,没有窗口边界的收缩,条件也简单到只有一个if。把它吃透,后面遇到“移除元素”“移动零”“删除排序链表中的重复元素”这些题,你会发现自己已经会了一半。
1.3 出错反馈非常快,适合反复试错
算法学习最怕的就是思路错了但代码能跑,最后结果全错又不知道错在哪。第 26 题的判题逻辑很直观:返回一个数字 k,然后判题系统会检查原数组前 k 位是否是不重复且有序的。你一旦想错,立刻能看到“数组越界”“返回值不对”“前 k 位顺序错误”这些明确的报错。
这种即时反馈对零基础是极大的帮助。我自己带人时发现,新手最需要的不是一次性讲完所有正确步骤,而是允许他先写错,再根据报错信息一步步修正。第 26 题的报错信息足够友好,非常适合用来练习“读报错、找问题”的基本功。
2. 题面拆解:有序、重复、原地修改分别意味着什么
2.1 用一组样例把题意还原出来
题目给的是一个“非严格递增排列”的数组,比如:
nums = [0,0,1,1,1,2,2,3,3,4]
要求你原地修改,然后返回新长度。这里的新长度是 5,因为去重后只剩[0,1,2,3,4]。同时,修改之后的 nums 前 5 位必须是[0,1,2,3,4],至于后 5 位是什么,题目不关心,可能是[0,0,1,1,1]这样的残留,也可能被覆盖成了别的值。
我见过不少人把“原地修改”理解成“必须让数组物理上变短”,于是在 JavaScript 里用 splice 删除,在 Python 里用 list.remove。这其实是走偏了。数组作为底层数据结构,长度在很多语言里是固定的,题目要的是你把“有效的元素”挪到前面,而不是真的把后面的存储空间抹掉。
2.2 “有序”是整道题的题眼,不是背景板
如果数组是无序的,比如[3,1,2,1,4],你要去重,最直接的方法是把所有元素放进一个哈希集合,或者先排序再处理,复杂度至少是 O(n) 起步,而且哈希集合需要额外 O(n) 空间。
但这道题明确说了“有序数组”,这意味着所有相同的值必然相邻排列。我常用的类比是查字典:字典里同一个字的所有页码一定是连在一起的,不可能这个字出现在第 10 页又出现在第 300 页中间隔着别的字。既然相等的元素排在一起,去重问题就简化为“只要某个元素和它前面一个元素不同,它就是不重复的”。
这一条性质,是整个双指针解法的根基。没有“有序”两个字,后面的代码一概不成立。
2.3 “原地修改”到底考的是什么
算法题里的“原地”,翻译成人话就是:不额外申请一个长度和原数组差不多的容器。你可以用几个临时变量,但不能再开一个数组来装结果。很多新手不理解为什么非要这样限制,觉得“重新开个数组多简单”。
其实这是工程里的真实需求。想象一下你操作的是一个几十 GB 的大文件或者一块内存里的海量数据,每去一次重就复制一份,内存直接爆掉。第 26 题的“原地修改”就是在模拟这种场景:数据量极大时,我们必须学会在原有空间上做覆盖,而不是动不动就开辟新空间。这也是为什么题目明确禁止返回新数组,必须原地把前 k 位变成正确答案。
3. 从“开新数组”到“快慢指针”:一次完整的思路推导
3.1 大多数人第一反应是新建数组
零基础看到这个题,很自然的思路是这样:准备一个空数组 result,遍历原数组,如果当前元素不在 result 里,就把它加进去,最后返回 result。这个思路没有错,但它违反“原地”的限制,额外用了 O(n) 空间。
更关键的是,这种思路没有利用“有序”这个条件。你在 result 里查找当前元素是否存在,如果还用线性查找,整体会到 O(n²);就算用哈希表优化,也还是一个“通用去重”方案,完全没有吃到有序数组的红利。
3.2 关键反转:把“新数组”想成“原数组的前半段”
既然不能开新数组,那能不能借原数组自己来当那个“result”?去重后元素个数一定小于等于原数组长度,所以原数组前面一定空得出位置。比如原数组有 10 个元素,去重后只有 5 个,那前 5 个位置足够放下所有结果。
于是问题变成了:我们需要在遍历数组的同时,把“不重复的元素”逐个写到前面的空位里。前面快慢指针的雏形就出来了——一个指针负责在旧数组里向前探索,另一个指针负责记录已经写到了哪个位置。
3.3 快慢指针的身份定义
在讲解里,我习惯把两个指针叫作 fast 和 slow,也有很多人叫 right 和 left。名字不重要,身份必须清楚:
- fast 是“勘探员”,它一路向右走,负责检查每个元素是不是一个新的不重复元素。
- slow 是“记录员”,它指向“下一个不重复元素应该写入的位置”。
核心逻辑只有一句话:当 fast 发现当前元素和上一个元素不同时,就说明遇到了一个新的值,把这个值写到 slow 指向的位置,然后 slow 前进一步。如果 fast 发现当前元素和上一个元素相同,说明是重复值,直接跳过,继续往前走。
3.4 为什么两个指针都从 1 开始,而不是 0
第一个元素无论如何都会保留,因为它前面没有元素可以和它比较,它天然就是不重复的。所以在代码里,slow 的初始值设为 1,意思是“第 0 位已经确定保留,下一个待写入的位置是下标 1”。fast 也从 1 开始,从第二个元素开始和它前一个元素比较。
有些新手会把 slow 初始成 0,然后想“从 0 开始写”,结果发现第一个元素被覆盖了。这里一定要记住:下标 0 的位置不需要动,它是最终答案的第一个元素。慢指针真正要操心的是“从下标 1 开始往后摆第二个、第三个……不重复元素”。
如果还不放心,可以拿[1,1,2]自己走一遍:
- 初始:slow=1,fast=1,数组是 [1,1,2]。
- fast=1 时,nums[1]=1,和 nums[0]=1 相同,说明重复,slow 不动,fast 到 2。
- fast=2 时,nums[2]=2,和 nums[1]=1 不同,说明来了新值,把 nums[2] 写入 nums[slow],也就是把 2 写到下标 1,数组变成 [1,2,2],slow 变为 2。
- 结束,返回 slow=2,前 2 位是 [1,2],正确。
手动走这一遍,比看十遍代码都管用。
4. 手把手写代码:Python、JavaScript、Java 逐行拆解
4.1 先把伪代码骨架立起来
在贴正式代码之前,我建议你先在脑子里或者纸上建立这个骨架:
如果数组为空,返回 0 slow = 1 for fast in 从 1 到数组末尾: 如果 nums[fast] 不等于 nums[fast - 1]: 把 nums[fast] 写入 nums[slow] slow 加 1 返回 slow这个骨架就是整道题的灵魂。三种语言的差异只是语法层面的,算法逻辑完全一致。
4.2 Python 实现:零基础最友好
def removeDuplicates(nums): # 空数组直接返回 0,防止后面访问 nums[-1] 出错 if not nums: return 0 slow = 1 for fast in range(1, len(nums)): # 当前元素和前一个元素不同,说明遇到了一个新的不重复元素 if nums[fast] != nums[fast - 1]: nums[slow] = nums[fast] slow += 1 return slow逐行解释一下:if not nums是 Python 里判断空列表的常见写法,空列表等价于 False。range(1, len(nums))让 fast 从 1 遍历到最后一个下标。nums[fast] != nums[fast - 1]是比较当前元素和紧挨着它前一个元素。这里为什么要比fast - 1,而不是比slow - 1,后面我会单独讲,先按这个写法理解。
4.3 JavaScript 实现:和 Python 几乎一一对应
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[fast - 1]) { nums[slow] = nums[fast]; slow++; } } return slow; };JavaScript 的数组长度是动态的,但这里我们完全没有用 push、splice 这类方法,只是通过下标赋值来覆盖。注意slow++是先使用 slow 再自增,所以nums[slow] = nums[fast]会先把值写到当前位置,再让 slow 指向下一个空位。这个细节在 JavaScript 里和 Python 里效果一致,只是写法不同。
4.4 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[fast - 1]) { nums[slow] = nums[fast]; slow++; } } return slow; } }Java 的数组一旦创建,长度就固定了。所以外面判题时只看你返回的 slow,以及原数组的前 slow 位。很多 Java 新手会纠结“数组后面多出来的旧值怎么办”,答案是:不用管。判题系统只检查前 slow 个位置,后面的残留不影响结果。
4.5 另一种常见写法:比较 nums[fast] 和 nums[slow - 1]
我在上面用的条件是nums[fast] != nums[fast - 1],但在很多题解里你会看到另一种写法:
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区别在于:第一种写法是“相邻元素比较”,第二种写法是“当前元素和已保留部分的最后一个元素比较”。由于 fast 永远大于等于 slow,nums[fast - 1]和nums[slow - 1]是两个不同的位置。
但为什么两种写法都能通过?因为在有序数组里,slow 之前的最后一个元素,就是上一个被保留的不重复元素;而 fast 往前看一个位置,如果那个位置是重复的,那 fast 上一个位置的值其实也和 slow-1 位置的值相同。所以两种条件在有序数组上最终等价。我的建议是:先用第一种,逻辑更贴近“扫描数组”的直觉;等完全理解了,再切换到第二种,因为第二种写法可以直接套用到“移除元素”那道题上。
5. 边界条件测试与新手最容易犯的三个错
5.1 四组用例把代码钉死
写完后不要急着提交,先在本地或者脑子里跑这几组用例:
| 输入 | 返回 | 前 k 位期望结果 |
|---|---|---|
[] | 0 | 无 |
[1] | 1 | [1] |
[1,1,1,1,1] | 1 | [1] |
[1,2,3,4,5] | 5 | [1,2,3,4,5] |
[0,0,1,1,1,2,2,3,3,4] | 5 | [0,1,2,3,4] |
空数组是最容易漏判的边界条件。for 循环从 1 开始,如果数组为空,range(1, 0)不会执行,但如果你直接访问nums[0]就会越界,所以开头那个空数组判断不能省。
5.2 错误一:真的调用删除方法去删
最常见的新手错误是用splice、remove、pop之类的方法把重复元素物理删除。这么做的结果是数组长度一直在变,循环边界乱掉,轻则下标越界,重则逻辑完全错乱。而且删除一个元素的时间复杂度是 O(n),整体会退化到 O(n²)。
正确做法永远是“覆盖”,不是“删除”。理解这一点,整个代码就顺畅了。覆盖思想的本质是:用后面的新值把前面已经没用的旧值替换掉,数组长度不变,全靠 slow 来标记有效范围。
5.3 错误二:返回值习惯性写成 nums.length
如果你最后写的是return nums.length,那你完全没理解 slow 的含义。nums.length 是原始长度,而题目要的是“去重后的长度”,也就是 slow 最后停在哪里,前面就有效到哪里。
我自己刚开始也犯过这个错,因为很多语言里函数都会返回数组本身,下意识觉得“返回数组长度”就对了。判题系统会在你返回 k 之后,单独检查 nums 的前 k 位。如果返回的是原长度,即便你把前 k 位改好了,判题也只会认为你输出了一大堆错误数据。
5.4 错误三:循环里访问了 nums[-1]
如果你用for i in range(len(nums))然后判断nums[i] != nums[i-1],在 i=0 时访问的是nums[-1],在 Python 里这不会报错,而是取到了最后一个元素,很多诡异的结果就是这么来的。所以循环必须从 1 开始,或者单独处理第 0 个元素。
有些语言比如 Java,数组下标为负会直接抛异常。这就提醒我们,凡是涉及i-1的循环,边界一定要想清楚从几开始。第 26 题的所有细节,几乎都藏在起点的选择上。
6. 把双指针解法沉淀成一套可迁移的模板
6.1 同一套骨架的三种变体
第 26 题的代码写完,不应该就此打住。我建议你把骨架抽象出来:
slow = 初始位置 for fast in 从某个位置到末尾: if 满足保留条件: 把 nums[fast] 放到 slow 指向的位置 slow += 1 return slow这个骨架可以演变成很多题:
- 移除元素:给定一个值 val,删除所有等于 val 的元素。条件是
nums[fast] != val,slow 从 0 开始,因为第一个元素也可能被移除。 - 移动零:把所有 0 移到末尾,保持非零元素相对顺序。条件是
nums[fast] != 0,找到非零元素就和 slow 位置交换。 - 删除排序链表中的重复元素:同样是快慢指针思想,只不过指针从一个数组元素变成了链表节点。
你会发现,这三道题和删除有序数组中的重复项是同一个内脏,只是穿的“外衣”不同。真正学会第 26 题,等于一次学会了四五道题。
6.2 为什么这三个变体的初始值不同
这里有个很容易踩的细节:第 26 题 slow 从 1 开始,移除元素和移动零的 slow 从 0 开始。原因是“第一个元素是否需要保留”。第 26 题第一个元素一定保留,所以 slow=1;移除元素时,如果第一个元素刚好等于 val,它也要被移除,所以 slow 必须从 0 开始,允许第一个位置被覆盖。
很多新手拿到变体题直接照抄 slow=1,结果发现第一个元素永远没被移除。这就是没有理解 slow 的含义,只背了代码。我在带刷的时候,要求所有人必须能把“slow 代表什么”这句话写出来,再动手写代码。
6.3 复杂度推算:为什么是 O(n) 时间、O(1) 空间
时间复杂度看循环:fast 从 1 遍历到 n-1,一共走了 n-1 步,每一步只做常数次比较和赋值,所以是 O(n),n 是数组长度。
空间复杂度看额外变量:我们只用了 slow 和 fast 两个变量,没有开新数组,所以是 O(1)。这里的“空间”指额外空间,不是原数组本身。很多人把原数组也算进去,说是 O(n),这是概念混淆。题目要求原地修改时,原数组的空间是“免费”的,不算额外开销。
6.4 什么时候不能用这套模板
有三类情况不能硬套。第一,无序数组去重:相等的元素不一定相邻,你无法通过“比较相邻元素”判断重复,必须用哈希表记录出现过的元素。第二,需要返回一个新数组而不是原地修改:比如你在某个业务里必须保留原数组不变,那就不能用覆盖方案,要另开空间。第三,要求保持所有元素相对位置且不允许覆盖:比如去重的同时还要继续用原数组做别的操作,这时候覆盖可能会破坏后续数据。
模板只是起点,不是万能钥匙。判断一道题能不能用双指针,核心就看:能不能通过两个游标在一次遍历里完成筛选和写入。不能的话,就别硬套。
7. 这道题真正教会零基础选手的,是“覆盖”而非“删除”
7.1 误区:双指针就是两个指针一左一右夹逼
一提到双指针,不少人脑子里是“一个从头、一个从尾,往中间走”,比如两数之和 II、反转字符串。但第 26 题是快慢指针,两个指针同向移动,fast 永远在 slow 前面。这个区别非常关键。
对撞指针和快慢指针虽然都叫双指针,但解决的问题完全不同。对撞指针一般利用有序性从两侧逼近目标;快慢指针则是一个负责探索、一个负责记录。如果混为一谈,后面写“移动零”这类题会特别别扭。建议你脑子里维护两张图:一张是左右夹逼的箭头,一张是同向快慢的箭头。
7.2 误区:以为必须要保持数组物理长度不变
有朋友问我:“最后 nums 后面不是还有重复的旧值吗?看起来好难受。”其实力扣判题只看前 k 个位置,后面的旧值是允许残留的。这个设计在工程里很常见:你清理了一批无效数据,但并没有让整个存储空间立刻缩减,只是标记了“有效长度是多少”。以后遍历时只走前 k 位,后面的空间等将来被新数据覆盖,或者由更大范围的机制统一回收。
理解了这一点,你就不会再纠结“有没有真正删干净”。
7.3 误区:只背模板,不亲手推演
我看到太多人刷第 26 题,代码能默写出来,但问他“如果数组是 [1,1,1],slow 和 fast 分别走到哪一步”就答不上来。这是典型的背题,不是学算法。
我的建议是拿纸笔,把[0,0,1,1,1,2,2,3,3,4]画成一个格子图,每执行一次循环,就在纸上把 slow 和 fast 的位置标出来,把被覆盖的数字圈出来。走完一遍之后,你才能真正体会到为什么 slow 的最终值就是答案。这个过程大概只需要十分钟,但效果比刷十遍题都扎实。
7.4 我自己的体会:覆盖思想比这道题本身更有用
第 26 题刷完很久之后,具体代码我会忘,但“覆盖”这个思想一直留着。后来我在很多场景里都会想到它:处理日志文件时用游标标记有效行,处理缓存时用旧空间覆盖失效数据,甚至写全栈项目时在内存里复用对象池,本质上都是同一套逻辑。
所以如果有人问我,第一道刷什么题,我一定会推荐删除有序数组中的重复项。它不炫技,不玩概念,老老实实地教会你两件事:一是双指针的快慢分工,二是原地覆盖的思维方式。这两件事,值得任何一个零基础选手花一晚上慢慢嚼。
最后再分享一个小技巧:提交通过之后,别急着 next 题。回到题解区,把“比较 nums[fast] 和 nums[slow-1]”的写法也自己实现一遍,再改成 while 循环写一遍。同一道题用三种写法各做一次,你对双指针的理解会立刻上一个台阶。这是我觉得刷这题最划算的收尾方式。