LeetCode第26题“删除有序数组中的重复项”是我见过最典型的“简单题困难写”的例子。做这道题之前,先回答我一个问题:如果面试官给你 [1,1,2],要你在不新建数组的前提下原地去重,你能一次写对吗?我见过不少人最后返回了长度 2,但数组还是 [1,1,2] 一点没变——他们只理解了返回值,没理解“数组前 k 位也要被修改”。
这道题很适合收进刷题日记里反复看:它看起来只考一个数组去重,实际上考的是对“有序”这个前提条件的敏感度,以及双指针到底在维护什么。哪怕面试时没有直接考原题,面试官也很容易把难度往“最多保留两个重复项”“合并两个有序数组”“链表去重”这些方向延伸。所以我这篇笔记不准备只给一段能 AC 的代码,而是想把为什么要用双指针、代码里每一行在干什么、哪些细节最容易写错,一次讲透。
1. 把题目读透:“有序”和“原地删除”并不是修饰词
很多人拿到这道题就直接背双指针模板,背完也不理解为什么自己用 HashSet 会超空间。我建议先把题干里的限定条件拆开看,因为面试官后面追问的所有变体,本质上都在这些限定条件上做文章。
1.1 “有序数组”隐藏的语义
题目给的是“有序数组”,这是全题最大的礼物。数组有序意味着所有相同的值只会连续出现,不会出现 [1, 2, 1, 1] 这种交错情况。每当扫描到数组中的某个值,它要么和当前已经保留的最后一个值相等,那这一步就是重复;要么比它大,那就是一个新值。
这一点直接决定了我们不需要额外的哈希表来记住“历史上出现过哪些值”。如果数组无序,比如 [3,1,2,1,3],想不借用额外空间做去重,那就只能对每个元素往前找一遍,时间复杂度会退化成 O(n^2),或者你先排序再继续走——都会破坏“保持相对顺序”的要求。所以面试官一旦听到“有序”,你就应该立刻往指针扫描的方向想。这也是为什么那些“看起来会用双指针的人”代码不一定写得对,但“为什么用双指针”一定要能答出来的人更容易过关。
还有一个容易被忽略的语义:题目要求数组的“相对顺序”保持一致。不能因为某个数重复了,就把它取出来放到数组尾部去“凑数”,那样会打乱顺序。所以正确做法必须是把不重复的值稳定地往数组前面搬。
1.2 “原地删除”背后的空间契约
数组的物理长度在 Java 里是固定的,你不可能像一个真正的 List 一样 remove 掉某个槽位,让数组长度缩短。所以题目真正要求你做的事情有两件:
- 把去重后的有效元素放到
nums的前k个位置; - 返回这个
k。
换句话说,“删除”在这里体现为“覆盖”,而不是“移除”。你只需要保证数组前 k 个下标里存的是完整、有序、不重复的结果。下标 k 之后到底残留什么旧值,题目不关心,判题机也不会去读。
我当年第一次做这题时有个误区:总想把数组尾部那些多余的值“清掉”,甚至写循环把后面赋成 0 或 -1。结果不仅多余,还容易把已经保留的前缀弄乱。你只要记住一句话:这道题的结果由“返回值 k + 数组前 k 位”共同定义,数组后半段是所谓的“合法垃圾区”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 双指针的推演与标准写法
既然不能新建数组,又要把不重复的元素往前搬,自然就需要两个位置标记:一个用于表示“当前有效区已经整理到哪里”,另一个用于“扫描原数组中还没处理的值”。这就是快慢指针。
2.1 为什么双指针能把这道题写成 O(n)
先看如果是暴力解法你会怎么做:遇到一个重复值,为了“删除”它,把后面所有元素往前挪一位,最坏情况下 [1,1,1,...,1,2] 这种输入,每次遇到重复都要搬运大量数据,复杂度是 O(n^2)。
双指针的做法完全不同:
- 慢指针
i表示“已经去重后的有效区间的最后一个元素下标”; - 快指针
j负责向前探索,逐个查看后面的元素; - 如果
nums[j]和nums[i]不相等,说明发现了一个新值,先把慢指针向前移动一位,再把新值放到该位置; - 如果相等,说明
nums[j]是重复项,直接跳过,继续让快指针走。
整个过程每个元素最多被访问一次,数组元素最多被原地赋值一次,时间 O(n),额外空间 O(1)。因为数组有序,所以这种比较方式不会漏掉任何情况:新值一定比旧值大,和旧值比较是否相等就够了。
用一句话概括核心逻辑:快指针负责认新,慢指针负责留旧。
2.2 标准实现与关键注释
我推荐直接记住下面这个版本,变量语义清晰,面试手写也不容易乱:
java复制public int removeDuplicates(int[] nums) {
// 工程上防御一下,LeetCode 的测试一般不会传 null
if (nums == null || nums.length == 0) {
return 0;
}
// i 指向已经去重后的有效区间的最后一个元素下标
// 初始时 nums[0] 一定保留
int i = 0;
// j 从 1 开始,逐个扫描后面的元素
for (int j = 1; j < nums.length; j++) {
// nums[j] 如果和当前有效区最后一个元素不相等
// 说明遇到了新的不重复值
if (nums[j] != nums[i]) {
i++;
// 把这个新值搬到有效区末尾
nums[i] = nums[j];
}
// 如果相等,说明重复,什么都不用做
}
// i 是下标,个数需要 +1
return i + 1;
}
这里有几个非常关键的变量语义:
i一开始是0,因为第一个元素一定是保留的;- 比较对象始终是
nums[i],也就是“当前有效区最后一个元素”,不是随便一个什么基准值; - 每次写入前必须先
i++,否则会把已经保留的旧值覆盖掉。
有小部分人会写另一种风格:
java复制int i = 0;
for (int num : nums) {
if (i == 0 || num != nums[i - 1]) {
nums[i++] = num;
}
}
return i;
这个写法也正确,但它的变量语义完全不同:这里 i 不是“有效区最后一个下标”,而是“下一个待写入的下标”,所以比较对象是 nums[i - 1]。两种风格都能 AC,但千万别混着用。很多人写着写着,一会儿用 i 表示末尾下标,一会儿又当成待写入下标,就会产出非常隐蔽的 bug。
2.3 用完整案例走一遍状态变化
只看代码可能还不够,我用一个带重复的长例子手动走一遍。
输入:
text复制nums = [0, 0, 1, 1, 1, 2, 2, 3, 3, 4]
初始:i = 0,有效区内容为 [0]。
| 步骤 j | nums[j] | 和 nums[i] 比较 | 操作 | 有效区变化 |
|---|---|---|---|---|
| 1 | 0 | 0 == 0 | 跳过 | [0] |
| 2 | 1 | 1 != 0 | i 变 1,nums[1] = 1 | [0, 1] |
| 3 | 1 | 1 == 1 | 跳过 | [0, 1] |
| 4 | 1 | 1 == 1 | 跳过 | [0, 1] |
| 5 | 2 | 2 != 1 | i 变 2,nums[2] = 2 | [0, 1, 2] |
| 6 | 2 | 2 == 2 | 跳过 | [0, 1, 2] |
| 7 | 3 | 3 != 2 | i 变 3,nums[3] = 3 | [0, 1, 2, 3] |
| 8 | 3 | 3 == 3 | 跳过 | [0, 1, 2, 3] |
| 9 | 4 | 4 != 3 | i 变 4,nums[4] = 4 | [0, 1, 2, 3, 4] |
最后返回 i + 1 = 5。你如果打印整个数组,会
