26.删除有序数组中的重复项,我刷过的次数一只手数不过来,每次面算法之前还是会翻出来再过一遍。别看它名字朴素,考察的点非常集中:有序、原地、双指针。题目要求给你一个非严格递增的数组,把重复元素去掉,每个元素只保留一次,最后返回去重后的新长度。很多人上来就想到 Set 或者新开一个数组,在小测试里确实能跑通,但面试官追问一句“不能开新数组,能做到 O(1) 额外空间吗”就卡壳了。这一篇我想把双指针是怎么一步步被“逼”出来的、代码里容易错在哪儿、以及它和 80 题这种变式的关系全部讲清楚,顺便聊聊我在真实数据清洗场景里对“去重”这件事的体会。
1. 题目拆解:有序数组去重到底在考什么
1.1 为什么“有序”和“原地”两个词这么关键
先看第一个关键词:有序。在原题里,数组是“非严格递增”的,意味着所有重复元素一定紧紧挨在一起。这句话看着简单,实际上直接决定了最优解法。如果去掉“有序”这个前提,输入变成 [2,1,2,3,1],你不可能指望只靠比较相邻元素就完成去重,因为重复的数字分散在不同的位置。这时候要么先排序再处理,要么用哈希表记录出现过的元素,复杂度都会明显上升。
我习惯用整理书架来类比。书架上的书如果已经按首字母排好序,想找重复的很好办,从头到尾扫一遍,看到相邻两本是一样的,就把多余的抽出来;但如果书架是乱的,你想知道哪些书重复,就不得不拿个本子把每本都记下来,或者先把书全部重新排一遍。有序数组的作用,就是帮你省掉了“本子”和“重新排序”这两件事。
再看“原地”。LeetCode 原题明确说了不要使用额外的数组空间,必须在原数组上修改。这就意味着你不能简单地把不重复元素收集到新数组里,而是要把它们“压缩”到原数组的前面。题目最终只要求返回一个整数 length,并且保证数组前 length 个元素是去重后的结果,后面的元素是什么都不管。这个“后面不管”的设计很重要,它给了我们覆盖数组前面位置的自由,也让双指针解法有了发挥空间。
1.2 双指针的直觉:一个记录已整理区,一个探索前方
最常规的双指针写法是这样:慢指针 i 指向“已经整理好的不重复序列的最后一个元素”,快指针 j 从头到尾遍历整个数组。初始时 i=0,因为第一个元素无论重复不重复都一定需要保留。j 从 1 开始,逐个检查后面的元素。
判断逻辑非常朴素:如果 nums[j] 等于 nums[i],说明当前元素和已保留的最后一个元素一样,是重复项,直接跳过;如果 nums[j] 不等于 nums[i],说明遇到了新元素,先把 i 往后移动一位,再把 nums[j] 赋值到 nums[i]。循环结束后,nums[0..i] 就是不重复的序列,新长度是 i + 1。
为什么正确?因为有序数组保证相同值只可能连续出现。一旦 j 发现当前值和上一个保留值不同,那它就一定是下一个需要保留的新值。i 始终指向已经整理好的区间的尾巴,j 每走一步,要么跳过重复项,要么搬入新值。用不变量来描述就是:在每个循环步骤开始前,nums[0..i] 已经是从扫描过的元素中提取出来的去重结果。循环结束时,这个不变量覆盖了整个数组,答案自然成立。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 完整实现与边界测试
2.1 Python 和 Java 代码逐行解读
先给 Python 写法:
python复制from typing import List
def removeDuplicates(nums: List[int]) -> int:
if not nums:
return 0
i = 0 # 已去重区间的最后一个下标
for j in range(1, len(nums)):
if nums[j] != nums[i]:
i += 1
nums[i] = nums[j]
return i + 1
逐行拆开看:特判空数组,避免访问 nums[0] 越界;i 初始化为 0,对应第一个元素已经保留;循环从 1 开始;关键判断是 nums[j] != nums[i],强烈建议用 nums[i] 而不是 nums[j-1],语义上更清晰。如果相等就什么都不做,快指针继续走;如果不等,先移动慢指针,再覆盖写入。
Java 版本更紧凑:
java复制public int removeDuplicates(int[] nums) {
if (nums.length == 0) {
return 0;
}
int i = 0;
for (int j = 1; j < nums.length; j++) {
if (nums[j] != nums[i]) {
nums[++i] = nums[j];
}
}
return i + 1;
}
nums[++i] = nums[j] 把“移动 i”和“赋值”合并成了一行,效果等同于 Python 里的写法。不过我建议新手先写成分开的两行,不要为了省代码把顺序搞混。
还有另一种常见写法,让 i 直接表示“下一个写入位置”,初始时 i = 1:
python复制def removeDuplicates(nums):
if len(nums) <= 1:
return len(nums)
i = 1
for j in range(1, len(nums)):
if nums[j] != nums[i - 1]:
nums[i] = nums[j]
i += 1
return i
这种写法里,i 的含义是“去重后的长度”,所以比较对象是 nums[i-1],也就是新数组的最后一个元素,而不是原数组的 nums[j-1]。两种都可以,面试时选择自己不容易混的那种,但一定要说清楚 i 到底代表下标还是长度。
2.2 边界用例与复杂度分析
用一张表把典型输入过一遍:
| 输入数组 | 去重后的前段 | 返回值 | 要注意的点 |
|---|---|---|---|
[] |
空 | 0 | 必须先判空 |
[1] |
[1] |
1 | 循环不会执行 |
[1,1,2] |
[1,2] |
2 | 典型的两个重复+一个新值 |
[0,0,0,0] |
[0] |
1 | 全部相同,慢指针一次都不动 |
[1,2,3] |
[1,2,3] |
3 | 全部不同,每次都要写入 |
[-3,-3,-2,1,1,2] |
[-3,-2,1,2] |
4 | 负数不影响去重逻辑 |
最容易翻车的就是空数组。少了 if not nums,代码在 nums[0] 那里会直接抛 IndexError。其次就是返回值,i 是下标,长度是下标加一,脑子里要有一个“元素个数 vs 下标”的转换意识。
复杂度这块必须脱口而出:时间 O(n),因为快指针 j 从 1 走到 n-1,慢指针 i 最多也移动 n-1 次,总体是线性;空间 O(1),因为没有开额外的数组或哈希表,只有两个整型变量。很多面试官还会追问:“赋值操作不是 O(1) 吗,为什么总时间不是 O(1)?”因为虽然单次赋值是常数时间,但对 n 个元素逐次操作,累计起来就是 O(n)。问这个问题的潜台词是看你是否真的理解复杂度的累加含义,而不只是背答案。
2.3 新手最容易写错的三处细节
第一处:先赋值还是先移动指针。如果写成 nums[i] = nums[j]; i += 1;,初始时 i=0,当 nums[1] != nums[0] 时,你实际上用 nums[1] 覆盖了本应该保留的 nums[0]。比如 [1,2] 会变成 [2,2],然后返回 2,结果就是错的。必须严格做到“先把慢指针移开,再往新位置赋值”。这里的顺序是血泪教训,我见过太多人栽在这一步。
第二处:比较对象写成了 nums[j-1]。在有序数组且数组没有被大幅修改的情况下,nums[j-1] 有时候碰巧也等于 nums[i],比如 [1,1,1,2] 这样走下来结果居然是对的。但 i 和 j-1 在出现连续重复时并不相同,语义上也解释不清。尤其是后面扩展到“每个元素最多保留 k 次”的题目时,如果你不习惯用 nums[i-k] 来比较,一定会想不明白。所以从一开始就养成习惯:比较的对象是“已去重区间的边界元素”,而不是“当前元素的上一个位置”。
第三处:把循环边界写成 range(1, len(nums)) 之外的范围。有些同学想着要覆盖整个数组,写 range(len(nums)),然后在循环里判断 j == 0 时跳过。这样做虽然也能得到正确答案,但多了一次无意义的判断,代码也不够干净。从 1 开始是最自然的,因为索引 0 天然就是第一个保留值。
3. 进阶变式与面试追问
3.1 从最多保留 1 次升级到最多保留 k 次
LeetCode 80 题是这道题的经典变式:允许每个元素最多出现两次。很多博客会单独讲一遍,但其实可以用一个统一的模板解决。假设“每个元素最多保留 k 次”,可以这样写:
python复制def remove_duplicates_k(nums: List[int], k: int) -> int:
if len(nums) <= k:
return len(nums)
i = k
for j in range(k, len(nums)):
if nums[j] != nums[i - k]:
nums[i] = nums[j]
i += 1
return i
原理是什么?i 表示“下一个可写入的位置”,初始为 k,因为前 k 个元素无论如何都不会超过保留次数。循环从 k 开始,判断当前元素 nums[j] 和 nums[i-k] 是否相等。nums[i-k] 是去重区间里倒数第 k 个元素。如果当前元素和它相等,说明前面已经有 k 个相同值了,再放进来就会变成 k+1 个,所以跳过;如果不相等,说明当前值在去重区间里还没达到 k 次,可以安全写入。
拿 k=2 举例,去重区末尾可能已经有两个相同的值,比如 [1,1]。这时候来了第三个 1,如果你选择比较 nums[i-1],得到的是 1,会误以为“前面已经有 1 了不能放”,导致每一个值最多只能保留一次。但比较 nums[i-2],看到的是更前的元素,就能判断出当前区块里已经有两个 1,达到上限,第三个 1 应该跳过。这就是为什么要看 i-k 而不是 i-1。掌握了这个通用模板,你可以很自信地告诉面试官,不止能写保留两次,保留任意次数都行。
3.2 无序数组去重、对象数组去重与 SQL 去重有什么不同
“去重”这个词在很多场景都会出现,但解法差异非常大。算法题里的有序数组去重,因为依赖有序这一强条件,才能做到 O(1) 空间;如果把数组换成无序的,双指针法就失效了。最简单的处理是借助哈希集合:第一次出现的值写入结果区,重复值跳过。但哈希集合本身需要 O(n) 的额外空间,而且如果要求保持原顺序,你还得保证遍历顺序就是你想要的顺序。
前端开发里常见的数组去重,很多人会直接写 [...new Set(arr)]。这个写法简洁,但只能去重基础类型。对象数组按某个字段去重就麻烦了,比如按 id 去重,常用的方案是:
javascript复制const uniqueById = (arr) => {
const map = new Map();
arr.forEach(item => {
if (!map.has(item.id)) {
map.set(item.id, item);
}
});
return [...map.values()];
};
这里用 Map 的 key 来做哈希标记,value 存对象本身,从而实现按 id 去重。和算法题最大的区别是:它可以自由使用额外空间,也不需要原地修改原数组。SQL 里的 SELECT DISTINCT 或 GROUP BY 又是另一个层面,它面向的是数据表,通常不需要修改源数据,还会搭配聚合函数,底层可能走排序或哈希。面试里如果有人问起来,你可以简单对比一下:有序数组原地去重是最“抠门”的方案,省时间也省空间,而实际业务里我们往往更关注保留哪一条记录,以及如何去重时不破坏原有排序。
3.3 面试引导出的三连问:链表、合并、稳定性
面试官看过你写出 26 题后,最常见的追问是:如果输入不是数组,而是有序链表,怎么去重?链表的处理方式和数组完全不同,因为你不能随机访问。核心思路是用一个指针 cur 遍历,如果 cur.next.val == cur.val,就删除 cur.next,否则 cur 向后移动。空间 O(1),时间 O(n)。它验证的是你是否抓住了“有序结构里重复项相邻”这个本质,而不是死记数组的下标移动。
第二个常见追问是:给你两个有序数组,如何合并成一个有序且不重复的数组?这就是归并排序中的“双指针合并去重”:两个指针分别从两个数组头部开始,谁小先加入结果;如果两个值相等,只加入一个,然后两个指针同时后移。这个过程与 26 题不同,但底层思路一致:有序可以让“比较相邻元素”成立,双指针可以控制写入的位置。
第三个追问:如果允许用额外空间,但要求保持原数组顺序去重,怎么做?那就更简单了,用一个哈希集合记录已经见过的元素,遍历时只保留第一次出现的值。这些追问并不要求你背出标准答案,而是希望你理解“有序+去重+原地”这三个条件各自到底带来了什么约束。能把这一层想通,面试官对你算法能力的评价会比单纯背题高很多。
4. 实际场景应用与刷题避坑经验
4.1 数据的清洗里的去重,和这道题有什么关系
我在做数据清洗的时候经常遇到重复数据。比如用户行为日志按上报时间排序,同一个 event_id 因为网络重试被记录了好几次,清洗的目标往往是“同一个事件只保留一条”。这时候有两个思路:如果数据规模不大,直接 GROUP BY event_id 取一条;如果数据量特别大,更稳妥的做法是先按 event_id 排序,再用类似双指针的思路一次扫描完成标记。你可能会觉得奇怪,SQL 里明明有 ROW_NUMBER() 这类窗口函数,但理解“排序后重复项相邻”的原理,能帮你在写复杂清洗逻辑时少走弯路。
举个例子,在 SQL 里保留每个用户最新一条登录记录:
sql复制SELECT *
FROM (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_time DESC) AS rn
FROM login_log
) t
WHERE rn = 1;
这个思路本质上也是把重复项排列在一起,再按某种规则选一条。算法题里它是“保留第一次遇到的值”,业务清洗里是“按时间取最新一条”,抽象模型都是相同的。所以在面试中聊项目时,如果在简历里写了数据清洗经验,不妨主动把 26 题和这个场景关联起来,面试官会认为你不是在背题,而是真的理解去重的本质。
4.2 调试双指针题目的三个小技巧
第一个技巧:打印关键状态,而不是打印整个数组。很多人调试时喜欢 print 出整个数组,看完之后还是一脸懵。更好的方式是在循环里打印:
python复制for j in range(1, len(nums)):
if nums[j] != nums[i]:
i += 1
nums[i] = nums[j]
print(f"j={j}, i={i}, nums[j]={nums[j]}, nums[i]={nums[i]}")
这样你能直观看到每个步骤里两个指针的相对位置。第二个技巧:把“不变量”写进注释。比如“循环开始前,nums[0..i] 已经是不重复序列”。先写注释再写代码,可以避免乱改指针顺序。第三个技巧:手动走一遍小样例。推荐大家拿 [1,1,2,2,2,3] 在纸上画两个箭头,从 j=1 开始一步步推。我每次给朋友讲这道题,都让他们先画十分钟图再来看代码,效果比直接讲要好很多。
4.3 从暴力解到双指针的思维路径,比答案更重要
如果你是第一遍做这道题,拿到题目先想到 Set 或新数组,这是非常正常的,先承认它是最简单的思路:时间 O(n),空间 O(n)。然后你再想,题目非要原地,那是不是能把不重复的元素覆盖到前面去?既然要覆盖,就需要一个“写到哪”的指针和一个“读到哪”的指针。快指针一直往前走,慢指针只在新元素出现时才移动。你看,双指针不是凭空蹦出来的,而是被“原地”这个约束逼出来的。
一旦形成这种思考路径,你会发现很多数组题都能套进这个框架:27 题“移除元素”、283 题“移动零”、844 题“比较含退格的字符串”,核心都是快慢指针。慢指针维护结果区域,快指针负责扫描原始数据。26 题作为这个系列里最简单的一道,非常适合用来建立这种模式。我个人刷题时最大的体会是:算法题背解法是最低效的方式,真正有效的是持续追问“为什么这里要这样设计”、把不变量写到代码里、再动手画一遍流程。做到这三点,这道题才算真正过了。
