第一次在 LeetCode 上遇到"删除有序数组中的重复项"这道题,是在我刷题刚起步的时候。当时我盯着"原地修改"四个字愣了很久,心想直接把数组转成 set 再去重不就完了,为什么非要在原数组上折腾。后来才明白,这道题表面上是"去重",真正考的是"在一个已排序数组里,如何用最小代价完成一次整理"。
如果你也是刚开始刷算法题的新手,或者已经对照题解把代码抄明白了、却总觉得"双指针"的思路似懂非懂,那这篇笔记应该能帮上忙。我会把整个思考过程完整拆开:题目到底在问什么、双指针的直觉从哪来、代码每一行为什么这么写、边界情况怎么处理,最后再延伸到同一套路下的其他题目。这篇文章面向的是"想真正弄懂"的人,不是"想快速抄完答案"的人,所以我会刻意讲一些题解里不写、但对理解非常重要的话。
1. 先别急着敲代码:这道题到底在问什么
很多人打开 LeetCode 26 的第一反应是:数组去重,这不是很简单吗?确实,如果允许你开一个新数组,这题的难度可能连简单都算不上。但题目加了两个条件,整个性质就变了:一是数组是有序的,二是必须原地修改。
1.1 有序数组这个前提,是整个题目的命门
题目名字叫"删除有序数组中的重复项",关键词不只有"重复项",还有"有序"。为什么这个前提这么重要?因为在一个升序数组里,所有相同的值一定是连续挨在一起的。举个例子:
code复制[1, 1, 2, 2, 2, 3, 4, 4, 5]
数字 2 连续出现了三次,数字 4 连续出现了两次,它们不会散落在数组各处。这个性质意味着:判断一个元素是不是"新的",只需要和它前面相邻的元素比较就够了。如果数组是无序的,比如 [3, 1, 2, 1, 4],想让重复的 1 靠在一起,就得先排序,排序的时间复杂度至少是 O(n log n),空间开销也不一样,整个问题就复杂多了。
这个在生活里也有类比:你整理一个按书名排序的书架,发现重复的书一定是紧挨着的,所以只需要"从左往右扫一遍,看到和上一本一样的就抽掉";但如果书架本来就是乱的,你就得先把所有书翻一遍、记下哪些已经见过,处理方式完全不同。"有序"就是题目打包送给你的红利,双指针能高效解这道题,最根本的底气就是它。
1.2 原地修改是什么?为什么出题人非要这么要求
"原地"的意思是不允许你另外开一个新数组去存储结果,所有去重操作必须在传入的那个数组上直接完成。也就是说,空间复杂度必须是 O(1) 级别的额外空间。
有的新手会不理解:我把结果复制到新数组里,最后再复制回来不也一样吗?从功能上看确实一样,但从工程角度看,当数组很大、甚至大到放不进内存的时候,任何一次整体复制都是巨大的开销。这道题考的就是你有没有"少用额外空间"的意识。实际开发中,处理一份几十 GB 的日志数据、一张超大图片的像素数组时,能原地整理就绝不多开一块同等大小的内存,这几乎是底线级别的习惯。
还有一个隐藏考点:函数签名里传进来的是一个数组引用,你在函数内部对数组做的修改,会直接影响调用方手中的那个数组。所以题目要求你用 nums 本身的空间来存结果,并把最终的有效长度返回去。提交代码时,后台会拿你返回的长度 k,去检查 nums 的前 k 个位置是否已经不重复,至于 nums[k] 到数组末尾还留着什么旧值,题目完全不在意。这一点如果你没有意识到,后面理解代码时会有很多困惑。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 双指针的直觉来源:从暴力解到快慢指针
我在给朋友讲这道题的时候,发现大家最容易卡住的地方不是代码本身,而是"凭什么想到用两个指针"。所以要讲双指针,得先从错误方案开始,看看暴力思路是怎么逼着我们走向正解的。
2.1 新手最容易想到的几个错误方案
第一个想到的一定是 set。把数组里的元素塞进哈希集合,自动就去重了,然后新建一个数组存结果。但问题很明显:第一,哈希集合是无序的,即使最后排个序,也要额外开销;第二,占用了 O(n) 的额外空间,直接违背"原地"要求。所以这条路从起点就走错了。
第二个常见想法是:每遇到一个重复元素,就把后面所有的元素整体往前挪一位。比如 [1, 1, 2, 2],发现第二个 1 重复,把 [2, 2] 往前移,数组变成 [1, 2, 2],但是此时你还得记录长度在缩减。这个思路在逻辑上没错,但每删除一个元素都要移动后面的所有元素,最坏情况下数组里全是重复值,时间复杂度会退化到 O(n^2)。用这段代码去交,大概率会超时。
第三个想法离正确答案很近了:用一个变量记住"上一个不重复的值",然后遍历数组,遇到新值就记录下来。很多人走到这一步会发现,记录下标比记录值更优雅,于是自然过渡到双指针。这就是双指针直觉的真正来源——你不是凭空设计了一个算法,而是被"空间受限、时间也不能超"这两个约束,一步步逼出来的。
2.2 快慢指针的不变量,到底怎么理解
先给结论:我们维护两个指针,slow 和 fast,它们都从数组左端向右移动。fast 负责"探索",一路往右扫描所有元素;slow 负责"写入",指向下一个可以放"不重复元素"的位置。
理解双指针的关键,是心里要有一个数组分区的画面。任意时刻,数组可以被想成三块:
[0, slow)区间:已经整理好的、没有重复元素的"成品区"[slow, fast)区间:已经路过、但被判定为重复值而跳过的"废弃区"[fast, len)区间:还没扫描过的"原料区"
当 fast 发现一个新的、没见过的元素时,就把它搬到 slow 指向的位置,然后 slow 后移一格。这个过程有一个非常重要的不变量:nums[0] 到 nums[slow-1] 始终保持着原始相对顺序,并且相互之间没有重复。你可以手动在纸上随便写一个例子,比如 [1, 1, 2, 3, 3, 4],然后带着这个分区视角模拟一遍,每一步都检查"成品区里是不是真的没有重复",你会发现这个不变量从开始到结束都成立。
新手容易漏掉的一个细节是:fast 每次循环都必然前进一步,而 slow 只在"发现新元素"时才前进。所以 slow 的移动速度永远不超过 fast,这保证了我们永远不会把还没扫过的东西覆盖掉。这个"速度快慢的不同"正是"快慢指针"这个名字的由来。
3. 代码逐行拆解:以 Python 主讲,附 Java 和 JS 版本
接下来是实操部分。我用 Python 写主版本,因为它最适合表达算法逻辑,然后给出 Java 和 JavaScript 的等价实现。我建议你哪怕平时不用 Python,也把 Python 版读清楚,因为后面讲的每一行推理都是通用的。
3.1 主版本:slow 表示下一个写入位置
python复制class Solution:
def removeDuplicates(self, nums: List[int]) -> int:
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
这段代码只有八行,但每一行背后都有讲究。
先看 if not nums。当数组为空时,去重后的长度当然是 0,如果少了这行防御,后面 slow = 1 就会越界访问 nums[0],直接报错。别小看这一行,很多新手第一次提交挂了就是因为空数组这个用例。
再看 slow = 1。为什么从 1 开始,不从 0 开始?因为无论如何,nums[0] 一定是第一个不重复的元素,它天然属于"成品区"。所以 slow 指向的"下一个写入位置"实际上是索引 1,也就是成品区末尾的下一位。这是这个版本里最重要的一步,理解了它,后面的代码就顺了。
进入循环后,fast 从 1 开始扫描。核心判断是 nums[fast] != nums[slow - 1],注意这里不是和 nums[fast - 1] 比较,而是和 nums[slow - 1] 比较。nums[slow - 1] 的含义是"成品区中最后一个元素",也就是目前已知的最后一个不重复值。当 fast 扫到的值跟它不一样,说明遇到了新元素,于是写入 nums[slow],然后 slow 加 1。
为什么不直接跟 nums[fast - 1] 比较?我拿一个例子演示。数组 [1, 1, 2, 2, 2, 3],当 fast 走到索引 5 的 3 时,nums[fast - 1] 是索引 4 的 2,拿 3 和 2 比,结果也是"不同",碰巧没问题。但看 fast 走到索引 2 的 2 时,nums[fast - 1] 是索引 1 的 1,拿 2 和 1 比,结论是"不同",于是会把 2 写入成品区,这也对。那如果改成连续重复三次的情况 [2, 2, 2, 3]:当 fast 走到索引 2 的 2 时,nums[fast - 1] 是索引 1 的 2,拿 2 和 2 比,结论是"相同",跳过;当 fast 走到索引 3 的 3 时,nums[fast - 1] 是索引 2 的 2,拿 3 和 2 比,结论是"不同",写入。看起来也对?那问题在哪?
问题出在更复杂的交错情况。想象数组里有一个元素被跳过之后,后面来了一个跟"原始前驱"相同、但跟"成品区末尾"不同的值。用一个例子:[1, 2, 2, 1],虽然这不是完全升序,但能说明问题。fast 在索引 2 时遇到重复 2,跳过;fast 到索引 3 时,值 1 和 nums[fast-1](2)不同,于是写入,变成 [1, 1, 2, 1]。成品区是 [1, 1],出现了重复 1。这说明跟 fast-1 比较会让"已经被跳过的重复值"干扰判断。而跟 slow - 1 比较,每次都在和"成品区最后一个确定值"作比较,永远不会受废弃区影响。所以 nums[slow - 1] 不是随便写的,它是这个算法的逻辑基石。
3.2 另一种等价写法:slow 表示已保留序列的末尾索引
很多题解用的是另一种写法,同样值得了解:
python复制class Solution:
def removeDuplicates(self, nums: List[int]) -> int:
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 指向的是"最后一个保留元素",而不是下一个写入位置。所以初始化为 0,判断时直接拿 nums[fast] 和 nums[slow] 比较,发现新元素时先 slow += 1,再写入。循环结束后,成品区长度是 slow + 1,所以返回 slow + 1。
两种写法的时间复杂度都是 O(n),空间复杂度都是 O(1),没有任何性能差异,纯粹是个人习惯。我给新手讲课的时候更喜欢第一个版本,因为 slow - 1 的写法把"永远和最后一个保留值比较"这个逻辑说得更直白;但第二个版本也有它的优点:slow 直接指向保留区末尾,判断条件写起来更像人类的直觉。你可以都写一遍,选一个自己觉得顺手的,但两个版本的边界处理逻辑都要能说明白,因为面试官可能会让你解释另一种写法。
3.3 Java 和 JavaScript 的等价实现
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;
}
}
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;
};
Java 和 JS 版的逻辑与 Python 版完全一致。需要注意的一点是,用 JS 写这类题时,函数接收的 nums 同样是引用类型,你在 nums[slow] = nums[fast] 里做的修改,会直接反映到调用方传入的那个数组上,这正好满足题目的"原地修改"要求。Java 因为是强类型语言,int[] 数组的空判断用 length == 0,其余没有任何坑。
3.4 复杂度分析:为什么说这就是最优解
时间上,fast 指针从 1 遍历到 n-1,每个元素恰好被访问一次,循环体内的操作都是 O(1),所以总时间复杂度是 O(n)。
空间上,整个算法只用了 slow 和 fast 两个额外变量,没有开任何和 n 相关的存储,所以额外空间复杂度是 O(1)。
为什么说这是最优解?因为在"有序数组 + 原地修改"这两个条件下,每个元素至少需要看一眼才能确定它是否重复,所以任何正确解法的下界就是 O(n);而空间上题目明确要求 O(1),双指针已经做到。既不能省时间,也不能省空间,它就是这个问题的天花板。如果你在面试中被追问"能不能再优化",可以很自信地回答:已经达到时间和空间的最优边界。
4. 提交后反复踩过的边界坑与调试技巧
代码看似只有几行,但真正提交起来,新手会在各种边界输入上栽跟头。我把常见的极端情况全部列出来,并且演示一种非常实用的手动调试方法。
4.1 空数组、单元素、全重复、全不重复四种极端输入
| 输入 | 预期输出 | 代码行为 |
|---|---|---|
[] |
0 | 被 if not nums 拦截,直接返回 0 |
[1] |
1 | 循环不执行,返回 slow = 1 |
[1,1,1,1] |
1 | 每次比较都发现相等,slow 始终停在 1 |
[1,2,3,4] |
4 | 每个元素都是新值,全部写入,最终 slow = 4 |
这里我想强调一个很多人忽略的点:全不重复的情况是算法能走到的最坏路径吗? 从时间上看,它和全重复一样,都是遍历 n 次,但全不重复时每次循环都要做一次赋值操作 nums[slow] = nums[fast]。你可能觉得多几次赋值无所谓,但在面试分析复杂度时,要清楚赋值操作也是 O(n) 的一部分,不能想当然地认为"赋值不需要时间"。这也是为什么有些细节题解会专门提一句"这个算法在元素完全不重复时,其实做满了 n 次写操作"。
另外,空数组的防御不只是这一道题的问题。我见过不少新手在 LeetCode 上因为空输入吃了大亏,然后下一道题又忘了。我的习惯是:写完主逻辑后,先看一遍题目给出的"约束条件"(constraints),把所有边界情况列成一个清单,再写代码。LeetCode 26 的约束里有 0 <= nums.length <= 3 * 10^4,也就是说空数组是合法输入,必须处理。
4.2 打印指针运动的调试方法
如果你在某个用例上跑出了错误答案,最快的定位方式不是盯着代码猜,而是手动模拟指针运动。我建议你用一个简单的例子,比如 [1, 1, 2, 3, 3],把每一轮循环的 fast、slow、nums[fast]、nums[slow-1] 都列出来:
| 轮次 | fast | nums[fast] | slow | nums[slow-1] | 是否相等 | 操作 |
|---|---|---|---|---|---|---|
| 1 | 1 | 1 | 1 | 1 | 相等 | 跳过 |
| 2 | 2 | 2 | 1 | 1 | 不相等 | 写入,slow=2 |
| 3 | 3 | 3 | 2 | 2 | 不相等 | 写入,slow=3 |
| 4 | 4 | 3 | 3 | 3 | 相等 | 跳过 |
手动模拟一轮之后,你会清楚地看到 slow 是怎么一步步"推进"的,fast 又是怎么"跳过"重复值的。这个方法对所有双指针题都适用。如果你想用代码调试,也可以临时加几行 print,不过提交前记得删掉,否则会报错(LeetCode 不会因为你 print 就判错,但会污染输出)。
4.3 题目背后的隐藏考点:数组引用与副作用
这道题还有一个很多人没意识到的考点:对数组的修改会传导到函数外面。Java、Python、JavaScript 里数组都是引用类型,传入函数后,你在函数内改 nums,调用方看到的同一个数组也变了。所以当代码里出现 nums[slow] = nums[fast] 时,表面上是在操作"局部变量",实际上是在直接修改"外部数组的内容"。
这也是为什么题目要求你"返回新长度 k 即可,而不是返回数组本身"。因为数组已经被你原地改掉了,后台只需要检查前 k 个位置是否符合要求。有的人在本地 IDE 里测试时,习惯先打印整个数组,发现 nums[k:] 一段残留着旧值,以为代码写错了,其实完全正常。只要前 k 个元素是去重后的正确结果,后面的残留值不影响判题。这一点想明白,你在本地调试时就不会自己吓自己。
5. 从本题延伸出去:一类双指针题的通用套路
LeetCode 26 最好的地方在于,它不是孤立的一道题,而是一整个"原地数组整理"题型的基础。把这道题吃透之后,你会发现后面好几道经典题都是在同一套骨架上换皮。
5.1 同一个骨架可以解移除元素和移动零
先说 LeetCode 27"移除元素"。题目要求把数组中所有等于 val 的元素删掉,返回剩余元素的新长度。你会发现它的代码几乎和 26 题一模一样:
python复制class Solution:
def removeElement(self, nums: List[int], val: int) -> int:
slow = 0
for fast in range(len(nums)):
if nums[fast] != val:
nums[slow] = nums[fast]
slow += 1
return slow
区别在哪?26 题里,slow 从 1 开始,因为 nums[0] 天然保留;27 题里,slow 从 0 开始,因为你不知道 nums[0] 是否等于 val,每个位置都要先判断再决定去留。这就是为什么我说"理解 slow 初始值比背代码更重要"——不同题目中 slow 的起点是由业务逻辑决定的,不是写死的。
再看 LeetCode 283"移动零"。把数组里所有 0 移到末尾,同时保持非零元素的相对顺序。用同一套思路:
python复制class Solution:
def moveZeroes(self, nums: List[int]) -> None:
slow = 0
for fast in range(len(nums)):
if nums[fast] != 0:
nums[slow], nums[fast] = nums[fast], nums[slow]
slow += 1
这个版本比 27 题更巧妙:slow 指向"下一个非零元素应该放的位置",遇到非零元素时不是直接覆盖,而是和 fast 交换。因为 fast 一定不会落后于 slow,所以被交换到后面的那个值一定是之前扫过的数据,不会丢失信息。如果你会做 26 和 27,再看 283 会有一种"这套路我见过"的熟悉感。
再往后还有 LeetCode 80"删除有序数组中的重复项 II",要求每个元素最多出现两次。它的框架仍然一样,只是从"比较一次"变成"需要计数或比较前两位",slow 和 fast 的相对运动关系完全不变。新手阶段我建议按 26 → 27 → 283 → 80 的顺序刷,你会发现自己的双指针能力在几天之内就有明显提升。
5.2 给新手的刷题与复习建议
最后说几句实际刷题层面的建议。
第一,做这种"几行代码"的题,最忌讳的是"看懂了就翻页"。我的经验是:关掉题解,打开编辑器,从空文件开始,自己推导一遍完整流程,包括边界情况。如果能毫无障碍地写出正确代码,才算真正会了。
第二,写完代码后,养成习惯给自己三分钟讲一遍复杂度分析:为什么时间是 O(n)?为什么空间是 O(1)?能不能做得更好?为什么不能?这三问几乎在每个算法面试里都会被问到,而简单的题正是练习讲清楚的好机会。
第三,复习节奏上,我不建议当天反复刷同一道题,因为你在"记忆"代码而不是在理解算法。更好的做法是隔一周左右回来重做,如果能顺畅做出来,说明思路已经内化;如果卡住了,正好暴露薄弱点。我甚至可以告诉你一个小技巧:重做时故意换一种语言或者换一种等价写法(比如把 slow = 1 版本改成 slow = 0 版本),这样能强制自己的大脑思考"为什么",而不是"那是哪行代码"。
我自己在实际带人刷题的过程中发现,十个人里有七八个第一次做这题都会问出同一个问题:"为什么不能用 set?"而真正理解答案的人,往后看 27、283、80 这些题时几乎不会卡太久。所以希望这篇笔记不只是让你会做一道题,而是帮你建立一种"在限制条件下设计算法"的思维方式。下次再遇到"原地操作""有序数组""去重"这类字眼,你能第一时间想到:一个指针负责遍历,一个指针负责写入,中间隔开的两段空间,就是这道题的全部秘密。
