LeetCode 26:双指针原地去重,吃透经典数组算法题
2026/9/24 19:55:49 网站建设 项目流程

做了这么多年算法题,LeetCode 26这道“删除有序数组中的重复项”是我觉得最值得反复琢磨的简单题之一。它表面上就是让你去掉数组里重复的数,但真动起手来,涉及到的原地修改、双指针、边界处理……每一处都是面试官最爱挖坑的地方。无论你是刚开始刷题的萌新,还是准备跳槽想快速过一遍热题的老手,把这道题吃透,性价比特别高。

很多人在第一次写这道题时会本能地想到“那我碰到重复的就删掉一个”,结果发现数组是连续内存结构,删除一个元素后面所有元素都要往前挪,效率低不说,索引还特别容易搞乱。LeetCode 26真正想考察的,是你会不会用一个更优雅的方式来“原地”解决问题。这篇文章就带你从题目读懂、思路推导、代码实现到面试延伸,把这道经典题彻底嚼碎。

1. 题目到底在考什么:先看懂“有序数组”和“原地”两个限定

1.1 先用大白话翻译一遍题目

题目原文很简洁:给你一个按非递减顺序排列的数组 nums,请你原地删除重复出现的元素,使每个元素只出现一次,返回删除后数组的新长度。元素的相对顺序应该保持一致。

我习惯把这类题先翻译成大白话:数组已经从小到大排好了,重复的数字肯定是连在一起的,我需要让每个数字只留一个,多余的全部清掉,而且不能新建数组,只能在原数组上动手,最后告诉别人“新数组有多长”。

举一个最简单的例子:nums = [0,0,1,1,1,2,2,3,3,4],处理完之后要返回 5,同时数组的前 5 位要变成 [0,1,2,3,4]。注意题目对数组第 5 位之后的内容没有要求,是 0、是 5、还是原本的残留值,都不影响判题,这一点在实际编码时非常重要。

这道题属于 LeetCode 热门 100 题里的常客,也被归入简单题一档。但它作为“快慢指针原地去重”的启蒙题,后续的 27 移除元素、80 删除有序数组中的重复项 II、283 移动零,全是它的变体。把这道题的逻辑吃透,等于同时拿下了好几道题。

1.2 “有序”这个限定为什么是突破口

数组有序,这是整道题能简化的核心前提。因为有序,所有重复元素必然相邻,比如出现 1、1、1,它们一定是紧挨着的,不会出现 1、2、1 这种重复相隔的情况。所以我们只需要比较相邻的两个元素,就能判断是否有重复。

如果数组是无序的,去重就得另想办法。比如用一个哈希表记录出现过的数字,每遍历到一个新元素就查一下表,出现过就跳过,没出现过就保留。这样当然也能去重,但时间复杂度虽然是 O(n),额外空间复杂度却变成 O(n),而且数字之间的相对顺序一旦要求保持,处理起来会更麻烦。

你可能会问,那无序数组用“先排序再去重”行不行?可以,但排序本身就有 O(n log n) 的时间成本。LeetCode 26 直接给你一个排好序的数组,等于是把最重的那部分工作提前做完了,剩下要考的就是你能不能利用“相邻即重复”这个规律,写出一个只扫一遍的算法。

1.3 “原地”限制背后的工程考量

“原地”二字往往被新手忽略,但它是这道题真正的考点。原地操作意味着不能用额外数组,空间复杂度必须控制在 O(1)。为什么 LeetCode 要强调这一点?因为真实工程里,数组可能非常非常大,比如几十万条日志、几百万个 ID,如果每个操作都复制一份新数组,内存瞬间就爆了。

C++ 里的 vector 有个 erase 方法,很多第一次刷这道题的人会直接写循环加 erase。思路是没错,但 erase 操作的时间复杂度是 O(n),因为删除中间元素后,后面所有元素都要整体搬移。假如一个数组有一万个重复元素,每删一次就搬一次,最坏情况会退化到 O(n^2)。LeetCode 的判题系统给出的测试用例里就专门准备了大数组来卡这种写法,提交后直接超时。

所以“原地”这个要求,本质上是逼你思考:我不物理删除,而是“覆盖”行不行?我不动后面多余的元素,只用前面的位置来构建最终结果,行不行?这就是双指针解法的出发点。

2. 双指针解法拆解:一个向前探路,一个驻守写入

2.1 为什么说双指针是“最优解”

解决这类数组原地问题,双指针是教科书级别的答案。时间复杂度 O(n),空间复杂度 O(1),只扫描一遍数组,不申请额外空间,从复杂度上已经到头了。

双指针按移动方向分为很多种:同向快慢指针、左右对撞指针、滑动窗口指针等。本题用的是同向快慢指针,也叫快慢指针。它的核心思想是:让两个指针同时从左往右走,其中一个指针走得快,负责“探路”,另一个指针走得慢,负责“写入”。快指针在前面发现了一个新元素,就把这个元素“告诉”慢指针,慢指针在它所在的位置把这个值记下来,然后前进一位。

你可以把它想象成两个人一起整理一排货架:慢的人站在货架前负责摆放。快的人从第一个货位开始往后走,看到商品编号变了,就喊一声“这个编号没出现过,可以上架”。慢的人听到后就把这个商品放到自己面前的位置,然后往前走一步,等待下一次指令。整个过程货架没有被搬空重排,只是在局部做了覆盖。

2.2 指针语义与移动规则详解

双指针的实现有好几种写法,我推荐一种最不容易出错、也最适合用来向面试官讲解的方式。先定义两个变量:

  • 慢指针 slow:指向下一个可以写入新元素的位置,同时它前面所有元素已经是去重后的结果。
  • 快指针 fast:遍历整个数组,找到每个“第一次出现”的元素。

遍历时,我们从索引 1 开始让 fast 走。为什么不是 0?因为第一个元素无论重复与否,都该被保留,nums[0] 一定是最终结果的第一个元素。慢指针从 1 开始,因为它前面已经有了 nums[0] 这个“保底元素”,下一个要写入的位置是索引 1。

快指针每到一个位置,我们比较 nums[fast] 是否等于 nums[slow - 1]。这里 nums[slow - 1] 的含义是什么?它是慢指针已经写入的最后一个元素,也就是当前去重后数组的最后一个值。如果 nums[fast] 和它不一样,说明这个元素是第一次出现,值得保留,于是我们把它写到 nums[slow],然后 slow++。

比较 nums[fast] 和 nums[slow-1],而不是和 nums[fast-1],这个细节很多人没想明白。用 nums[fast-1] 也可以判断相邻是否重复,因为数组有序,但用 nums[slow-1] 有一个额外好处:即使脑子里残留着“物理删除每个重复项”的思路,这种写法也能保证慢指针之前的区间是严格不重复的,语义更清晰。两种写法都能通过,我习惯用 slow-1,因为它和返回 slow 这个逻辑浑然一体。

2.3 两个容易翻车的边界细节

第一个边界是空数组。如果 nums.length 为 0,直接返回 0。不处理这一步,后面访问 nums[0] 就会越界。很多第一次写的人会忘,导致提交后在空数组用例上报错。

第二个边界是数组长度为 1。这种时候根本不需要做任何操作,因为一个元素必然没有重复,直接返回 1 即可。其实你按统一逻辑写,slow 初始为 1,fast 从 1 开始,循环体根本进不去,最后返回 slow 就是 1,不会错。真正需要注意的是,返回的是 slow 而不是 slow + 1。slow 记录的是“已经写入元素的下一个位置”,又因为写入是从索引 0 开始的,所以 slow 本身就等于结果数组的长度。比如原数组 [1,1,2],最终 [1,2] 占两个位置,slow 恰好为 2,返回 2 就是正确答案。这里最容易写错成 slow+1,一加就多了一个。

还有一类情况是数组中所有元素都不重复,比如 [1,2,3,4,5]。这种情况下 fast 每走一步,nums[fast] 都不等于 nums[slow-1],于是每步都会写入和移动 slow,最终 slow 等于 n,返回 n,等价于什么都没删,完全正确。

3. 三种语言的完整实现与逐行注释

3.1 C++ 实现与防坑要点

C++ 是刷 LeetCode 最主流的语言之一,写这道题时可以完全避开 vector 的 erase 操作,直接用索引下标来维护双指针。

这段实现里,最值得注意的坑有两个:第一个,千万不要在循环里对 nums 做 erase 操作,否则会因为元素搬移导致下标错乱,我能贴出的代码里连看都看不到 erase,这就对了。第二个,注意 i 从 1 开始遍历,不是从 0,否则会把 nums[0] 和 nums[0] 比较一次,虽然结果没错,但白白多走一轮。复杂度方面,遍历了一轮数组,所以是 O(n),只用了两个 int 型变量,空间是 O(1)。

3.2 Python 实现与“原地修改”的坑

Python 的写法最贴近伪代码,逻辑非常清晰。但 Python 有一个其他语言不太一样的点:列表是可变对象,函数里直接对 nums 做 nums[write] = ... 操作会影响到外部变量,这点恰好符合题目要求的“原地修改”。

比较常见的错误写法是:for 循环里拿到重复元素后,用 nums.remove(value) 或 del nums[i] 去删。这样的确能在 Python 中实现“物理删除”,但 remove 本身是 O(n) 操作,因为要找到目标元素并搬移后续元素;del 也会触发列表的重新整理。在 LeetCode 的大数据量测试用例下,这种写法很容易超时。更隐蔽的问题是,边遍历边删除会导致元素索引变化,for 循环的 i 还可能越界或跳过一个元素,调试起来非常痛苦。

还有部分人会用 nums[:] = new_list。这种方式确实实现了原地替换,因为切片赋值是在原列表对象上做整体修改,外部引用依然指向同一个对象。但它需要一个新列表,额外空间是 O(n),不符合题目的“原地”初衷。面试时这样写,面试官大概率会追问一句“你额外用了多少空间”。

3.3 Java 实现与复杂度论证

Java 的 int[] 数组长度固定,没法真正“变短”,但题目只要求你返回新长度,数组后面的多余位置是否保留不重要,所以写法同样很直接。

Java 和 C++ 的这段代码逻辑完全一致,唯一的细微差别是 Java 的数组没有 length() 方法,只有 length 属性。另外,Java 里如果面试官要求你“真正缩减数组”,你可以用 Arrays.copyOf 截断,但在本题目下,直接返回 slow 即可。后面如果写 LeetCode 80(每个元素最多保留两次),只需要把比较逻辑从 nums[fast] != nums[write - 1] 改成 nums[fast] != nums[write - 2],其他完全不用动。

4. 高频问题、面试追问与同型题目

4.1 一张表查完常见报错与边界值

我整理了一个速查表,覆盖我在实际刷题和辅导别人时遇到过的高频问题,对照着排查会快很多。

问题现象常见原因解决办法
空数组越界没判断 nums.length == 0 就访问 nums[0]第一步先处理空数组,直接返回 0
返回长度比预期大 1误写成 slow + 1 或 write + 1理清语义:write 指向下一个写入位置,等于当前长度
返回长度比预期小 1误把 write 初始化为 0write 初始化为 1,因为首个元素必保留
数组前几位结果不对覆盖写入时没有用慢指针位置确保写入位置是 nums[write] 或 nums[slow],不是 nums[fast]
使用 erase/remove 导致超时每次删除都是 O(n) 搬移,最坏退化为 O(n^2)改用覆盖写入,不物理删除
边遍历边删除,索引越界删除元素后 i 应回退,但漏了回退放弃删除思路,用双指针原地覆盖

这些坑里,最不值得踩的就是“边遍历边删除”。记住一个核心认知:数组的删除成本非常高,除非数据量极小,否则能用覆盖就不用删除。

4.2 面试官最常追问的四个变体

面试官基本不会只满足于让你把这道题写出来,他一定会追问变体,考察你是不是真的理解了题目本质。

第一个变体是:如果数组是无序的怎么办?这时“比较相邻元素”的前提就失效了,你可以先排序再套双指针,时间复杂度 O(n log n);或者用一个哈希集合记录出现过的元素,遍历一遍即可,时间复杂度 O(n),额外空间 O(n)。面试官会希望你说出时间和空间的 trade-off。

第二个变体是:每个元素最多可以保留两次,怎么做?这就是 LeetCode 80。核心改动只在一个地方:把快慢指针的比较对象从 nums[write-1] 改为 nums[write-2]。因为 write-2 是“已保留结果的倒数第二个位置”,如果当前元素和它相等,说明当前元素已经出现了至少两次,需要跳过;如果不相等,说明当前元素最多只出现了一次或两次,可以保留。这个改动非常小,但体现出你对双指针的理解深度。

第三个变体是:不只是返回长度,还要返回去重后的数组。这个问题在 Python 里可以用 nums[:] 截断,在 C++ 里可以用 vector 的 resize,或者干脆直接使用结果数组的前 slow 个元素。只要你自己心里清楚,多余位置的元素不影响最终结果,这个问题就不难。

第四个变体是:如果数组是 Java 的 ArrayList 或 C++ 的 list,会有差别吗?ArrayList 底层是数组,本质上和 int[] 差不多,用 get 和 set 双指针即可;list 是链表结构,删除操作是 O(1),但随机访问不如数组方便,双指针策略要调整成迭代器写法。

4.3 跳出题目:原地覆盖思想在工程里的应用

这道题里的覆盖写入思想,在真实工程里处处可见。最常见的场景是日志清洗:系统每天产生海量的有序日志,需要去除重复的告警记录,但又不能频繁申请新内存,于是可以维护一个写入游标,遍历原始日志流时只把“首次出现的告警类型”写入保留区。这种模式在数据处理管道、数据库压缩、文件去重工具里都有体现。

再比如做视频帧抽稀,要求每秒钟最多保留一帧关键帧,视频帧序列本身按时间戳排序,处理逻辑跟 LeetCode 80 几乎一模一样,只是判断条件从“最多出现两次”变成“间隔至少超过多少毫秒”。当年我刚开始接触流式处理时,看到这类需求总觉得要开一个临时文件或者新数组,后来意识到这种“覆盖写入 + 游标”的思想能省一大笔内存,才真正体会到算法题不只是刷题,而是工程经验的提前预演。

5. 刷完这道题后,我总结的三个习惯

5.1 先处理空集和单元素边界

我见过太多人写算法题,主逻辑写得飞快,一到边界就翻车。处理数组题,第一步永远是问自己:数组为空怎么办?长度为 1 怎么办?这两个问题不想清楚,后面代码写起来很容易越界或直接返回错误结果。

我个人的习惯是:不管题目多简单,先写一个 if 判断长度的分支,或者确保算法天然兼容长度 1 的情况。LeetCode 26 这道题只要把 write 初始化为 1,从索引 1 开始遍历,就能天然兼容空集(空集直接返回 0)和单元素数组(循环不进,返回 1)。但“天然兼容”不等于“不用思考”,你在写之前最好明确说出来:空数组返回 0,单元素返回 1,这样代码 review 时也更有说服力。

5.2 手写一段测试用例,别直接提交

写完代码不要着急点提交,先在草稿纸上或者本地跑一遍小例子。比如 [1,1,2]、[0,0,1,1,1,2,2,3,3,4] 这种标准用例,再想想 [1] 和 [] 这种边界。

我在实际练习时会故意构造一个全是重复元素的数组,比如 [7,7,7,7,7],手动模拟双指针移动过程,看看 write 最终停在哪里。这种手写模拟的过程虽然慢,但对训练思维非常有效。你跑多了之后会形成一种直觉:看到原地数组题目,第一时间想到快慢指针,想到 write 指向下一个可写位置,想到 nums[write - 1] 或 nums[write - 2] 的比较逻辑。

5.3 记录复杂度推导过程

刷题时我会在代码注释里写上时间复杂度和空间复杂度推导过程,不是为了应付考试,而是为了日后复习。LeetCode 26 的复杂度分析很简单,但你要有能力正式地讲出来:快指针遍历整个数组,总操作次数是 n,所以时间复杂度 O(n);额外只用了两个整数变量,不随输入规模变化,所以空间复杂度 O(1)。一旦你能把这种复杂度论证变成刷题的本能反应,遇到任何新题就都不会怵。

最后再说一个小技巧,每次刷完一道题,顺手看一下讨论区里别人有没有写出更简洁的版本。LeetCode 26 的题解区里,有些答案是单指针加哨兵值,有些是用元组解包做交换,这些不同写法能帮你拓展思维边界。但核心永远是:先理解原理,再追求简洁,这样才能真正把知识内化成自己的能力。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询