☰
LeetCode 26双指针解法:有序数组去重核心是覆盖而非删除
2026/10/12 2:48:35 网站建设 项目流程

新手刷题绕不开的一道题就是LeetCode 26,删除有序数组中的重复项。这道题在“新手友好题解与思路解析”这个标签下经常出现,但真正动手做的时候,很多人反而栽在了一个看似简单的要求上:原地修改。我第一次刷这道题也翻了车,下意识想的是把重复元素“删掉”,然后新建一个数组存结果,结果越写越复杂。后来才意识到,这道题考的其实不是删除,而是数组元素的“覆盖”和“指针推进”。这篇就围绕这道题,把思路、推导、代码、易错点和扩展一起讲透,适合刚接触数组和双指针的读者,也适合想把这个经典套路彻底吃透的进阶选手。

1. 题目到底在考什么——从描述里挖出隐藏约束

LeetCode 26的题目描述本身不长,但每个字都埋着信息:给你一个有序数组,要求原地删除重复出现的元素,使每个元素只出现一次,然后返回删除后数组的新长度。注意,这里说的是“原地”,也就是不能用额外的数组空间,必须在传入的那个数组上直接操作。

1.1 “有序”这两个字的价值

很多新手忽略了这个前提——数组是有序的。有序意味着什么?意味着所有重复的元素一定是紧挨在一起的,不会出现“1 2 1”这种重复元素中间隔着别的数的情况。正因为重复项连续排列,我们判断“当前元素是否需要保留”,只需要跟它前一个保留下来的元素比较就行,不需要回头去遍历整段数组。

对比一下:如果数组是无序的,同样的去重需求,你大概率会想到用哈希表记录“哪些元素已经出现过”。但哈希表带来的额外空间是O(n),这在“原地修改+O(1)额外空间”的约束下直接被否决。所以“有序”这个前提条件,基本上就是出题人在提示你:去重这件事,可以做得比“查表”更轻量。

1.2 原地修改的限制意味着什么

“原地”两个字的约束力很强。它排除了两种看起来很自然的做法:

  • 新建一个数组,把去重后的元素依次放进去,再拷回来——额外空间是O(n),不合格。
  • 用列表类的数据结构(比如动态数组、链表)来辅助去重——同样不符合原地要求。

能做到原地修改的工具其实只有一个:在数组本身做覆盖写入。你要把那些“应该保留”的元素,按照顺序重新写到数组前面的位置,最后把数组逻辑上的长度截断。题目不要求你物理上把元素从内存里抹掉,只要求你返回一个新长度,并且保证新的长度范围内不包含重复元素。理解到这一步,整道题的解法基本就浮出来了。

1.3 换个角度看“删除”

“删除”这个词容易让人产生误解——好像要把元素真的移除,后面的元素还得整体往前挪。数组删除一个元素确实是这个代价,挪完后面的元素位置都变了,而且要维持有序性,这就是O(n)的搬移。如果对每个重复元素都做一次搬移,最坏情况下整体复杂度会变得很糟糕。

实际上题目根本不要求你“保持数组后面的部分有意义”。LeetCode的评测只检查新长度范围内的元素是否正确,新长度之后留着什么内容,完全不管。所以正确的姿势不是“删除”,而是“前移”:把不重复的元素依次往前写,用覆盖的方式把旧值冲掉。这个视角一旦转变,重复元素自然就不会出现在前面的有效区间内了。

2. 双指针思路从哪来——从“覆盖写入”到“读写分离”

既然确定了要用覆盖写入,那么问题就变成:怎么知道哪些元素该往前写、写到哪个位置上去?这需要两个角色配合——一个指针负责在数组里往前探索,看当前元素跟上一个保留的元素是不是重复;另一个指针负责记录下一个不重复元素应该落位的位置。这就是双指针。

2.1 快慢指针的经典角色分工

在LeetCode 26里,双指针通常叫快指针和慢指针,或者读指针和写指针。我更习惯叫它们读指针和写指针,因为这样语义特别清晰:

  • 读指针(快指针)负责“读”数组,每轮向后走一步,检查每个元素。
  • 写指针(慢指针)负责“写”数组,只有遇到不重复元素时,才把读指针指向的值写到写指针指向的位置,然后写指针向后挪一位。

用生活化的例子来说,就像你拿到一摞已经按时间排好序的照片,想要挑出不重复的时刻,然后把挑出来的照片按顺序摆到一个新相册里。只不过这里的新相册就是这本相册自己的前几页,你把有价值的照片往前放,后面的旧内容被盖掉也无所谓。

2.2 为什么不是删除而是覆盖

假设数组是 [1,1,2],如果采用“删除式”思路:发现第二个1跟前面重复,把它删掉,后面的2要往前挪一位,得到 [1,2],操作一次还行。但如果数组一长,比如 [1,1,1,1,2,2,3,3,3],每删一个重复项就要搬移一批元素,频繁搬移会让耗时明显上升。

覆盖式思路完全不一样:读指针从头到尾扫一遍,发现第一个1直接写入位置0,第二个1跟写入区的最后一个元素比较,发现重复就直接跳过,不产生任何搬移;遇到2时跟写入区最后的1比较,不同,就写到位置1。整个过程数组的总长度没变,元素的物理位置也没大动,只是前面几个位置上被重新写成了“有意义的”元素。这种操作的代价每次都是O(1),整体就是一次线性扫描。

2.3 为什么写指针的初始位置有讲究

常见写法里有两种初始化:

  • 写指针从0开始,第一个元素总是要保留的,所以可以先写入再移动。
  • 写指针从1开始,默认第一个元素已经保留在原位,从第二个位置开始准备接收后续的不重复元素。

两种写法都能得到正确答案,但第二种写起来更自然。因为它暗含了一个断言:第一个元素永远需要保留。不管你数组里有多少重复项,第一个元素肯定不是重复的(重复的定义是“已出现过”,而它是第一个,没出现过)。所以慢指针从1开始,直接跳过对第一个元素的无意义判断,代码会清爽很多。这也是一种常见的边界优化,新手写代码时意识不到,但其实很关键。

3. 核心解法分步推导——从伪代码到逐行实现

思路理清了,代码就水到渠成。这一节我们一步步推导,先处理边界情况,再走一个具体例子,最后给出完整代码。

3.1 边界条件先处理掉

做题先看边界,这是习惯问题。对这道题来说,最容易想到的就是空数组:一个元素都没有,长度就是0,也不需要做任何去重,直接返回0。另一个值得一提但仍能自然处理的场景是长度为1的数组:它没有重复元素,按流程走,慢指针初始是1,循环里读指针从1开始,跟写入区最后一个元素比较,相同的话跳过,最终返回的长度就是1,正好符合预期。

边界处理得干净,后面主循环就能少很多空指针和越界的隐患。

3.2 走一遍例子:从 [0,0,1,1,1,2,2,3,3,4] 看指针变化

说概念容易,走一遍就真实了。假设输入是 [0,0,1,1,1,2,2,3,3,4],有序数组,重复项都连续。

  • 初始化:慢指针 slow = 1,快指针 fast = 1。
  • fast = 1,读到的值是0,跟 nums[slow - 1],也就是 nums[0] = 0 比较,发现相等,说明是重复项。slow不动,fast继续走。
  • fast = 2,读到1,跟 nums[0] = 0 比较,不相等。执行写入:nums[slow] = nums[fast],即 nums[1] = 1。slow变为2。此时数组状态暂时是 [0,1,1,1,1,2,2,3,3,4],前两位已经是有效区。
  • fast = 3,读到1,跟 nums[slow - 1] = nums[1] = 1 比较,相等,跳过。
  • fast = 4,读到1,跟 nums[1] = 1 比较,相等,跳过。
  • fast = 5,读到2,跟 nums[1] = 1 比较,不相等,写入:nums[2] = 2,slow变为3。
  • fast = 6,读到2,跟 nums[2] = 2 比较,相等,跳过。
  • fast = 7,读到3,跟 nums[2] = 2 比较,不相等,写入:nums[3] = 3,slow变为4。
  • fast = 8,读到3,跟 nums[3] = 3 比较,相等,跳过。
  • fast = 9,读到4,跟 nums[3] = 3 比较,不相等,写入:nums[4] = 4,slow变为5。

循环结束,返回slow也就是5。数组前面的5个位置是 [0,1,2,3,4],完美。

这个推演过程值得自己在纸上画一遍。我每次带人刷题都会强调,不要只盯着代码看,脑子里要有数组的画面——哪些位置被重新写过,哪些位置是旧值残留,指针之间隔了多少距离。画过一遍之后,双指针的核心动作就再也不会忘了。

3.3 代码实现:Java版本逐行注释

给出Java版本,因为LeetCode上Java用得多。其他语言无非是语法差异,逻辑完全一致。

public int removeDuplicates(int[] nums) { // 边界处理:空数组直接返回0 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; }

几个细节值得单独说明:

  • 慢指针同时也是“新数组的有效长度”,所以最后直接返回slow,不需要slow+1之类的修正。
  • 为什么比较的是 nums[slow - 1] 而不是别的?因为slow指向的是“下一个要写入的位置”,它前面的那个位置里存的是最近一个被保留的元素。数组有序,只要当前元素跟最近保留的那个不同,就说明它不是重复项。如果你去比较 nums[fast - 1],那就错了——那只是数组物理上的前一个元素,可能是已经被覆盖的旧值,也可能是待处理的重复项,参考性不强。
  • 写入这个动作本身是安全的:因为fast一定大于等于slow,所以 nums[fast] 的位置一定不会先于 nums[slow] 被覆盖,覆盖顺序不会破坏源数据。

3.4 复杂度分析:时间O(n),空间O(1)

时间上,快指针从头到尾只扫了一遍数组,每次循环内做的事情是常数级的比较和可能的赋值,所以时间复杂度是O(n)。n是数组长度。

空间上,全程没有使用任何额外的集合、数组、哈希表,几个整数变量占用的空间是常数级的,空间复杂度O(1)。这也是这道题的根本约束——如果面试官追问你能不能优化,你可以直接说明线性时间和常数空间已经是这个约束下的最优解,因为至少要遍历一遍每个元素才能判断重复。

4. 为什么返回长度而不是返回数组——题目的隐藏约定

这道题有一个很容易让新手产生疑惑的点:明明函数签名是int removeDuplicates(int[] nums),返回的是一个整数,那数组去哪儿了?其实答案在题目描述里:“函数应该返回新的长度,并且原数组 nums 的前面部分需要被修改成新的内容。”也就是说,题目的输入输出协议是:你要原地改数组,然后告诉系统“这个数组现在有效的部分有多长”。

4.1 评测系统怎么检查你的答案

LeetCode的检测逻辑大致是这样:拿到你返回的长度k,然后检查 nums 数组前k个元素是否为去重后的有序序列。至于下标k之后还有没有旧值残留,系统根本不关心。

这也解释了一个常见现象:很多人在本地ide里运行这段代码,打印整个数组,发现后面还跟着旧数字,误以为自己写错了。其实没有,只要你返回的长度正确,并且前k个元素正确,就完全满足题目要求。记得我刚刷题时在本地调试,看到输出数组是 [0,1,2,3,4,2,2,3,3,4],一度以为自己没删干净,后来才明白这个约定。

4.2 为什么这种设计反而更合理

从工程角度看,数组的长度在创建时就是固定的,你没法真正“截断”一个数组。在大多数编程语言里,数组的长度是内在属性,不是可以随意修改的。所以这类题目只能用“逻辑长度”来模拟删除:数组还是那个数组,只是我们认为有效的部分变成了前k个元素。这其实非常贴近实际,因为很多底层系统就是用这种“维护一个有效水位线”的方式来管理定长缓冲区的。数据不用真的清空,新数据写进来覆盖掉就行。

4.3 面试中怎么回答“为什么返回长度”

面试官如果追问,你可以说:“数组本身是定长的,无法物理删除元素。这道题实际考察的是在定长结构中通过覆盖写入维护逻辑有效长度,返回长度就是在告诉调用方如何切分有效区和废弃区。”这个回答既体现了对题目约束的理解,也展示了底层数据结构的知识,比只说“因为LeetCode要求这么写”要加分得多。

5. 易错点与常见问题排查——新手翻车实录

再简单的题,踩坑的姿势也能五花八门。这一节把新手常遇到的问题整理成速查表,并且逐个分析背后的原因。

症状出错原因解决方式
空数组报错或返回奇怪结果没处理 n == 0 的边界开头加判断,空数组直接返回0
返回的数组第一个元素丢失慢指针从0开始,且先更新慢指针再写入保证写入发生在慢指针位置,再让慢指针自增;或让慢指针从1开始
数组出现未被去重的情况比较对象选错,比较了nums[fast - 1]改为比较nums[slow - 1],确保参考的是“已保留区的最后一个元素”
使用了HashMap或HashSet没注意原地修改和O(1)空间约束改用双指针覆盖方案
无限循环快指针没有正确递增,或循环条件写错确认 for 循环中 fast++ 不被跳过,循环范围是 fast < nums.length
数组后面残留旧值,怀疑自己错误不理解题目只检查新长度之前的内容无需处理残留值,只要前k个元素正确即可

5.1 踩坑实录一:慢指针起始位置写错

有同学写代码时让 slow = 0,然后在循环里先 slow++ 再写入,结果返回的数组变成从第二个元素开始,丢了第一个。正确的做法是:让慢指针指向的是“下一个要写入的位置”,所以写入时先写nums[slow],再slow++。如果你不确定,可以在第一轮循环加一个断点,观察 slow 和 fast 的初始值以及写入顺序。慢指针初始化为1,既符合“第一个元素天然保留”的逻辑,也能避开这类顺序错误。

5.2 踩坑实录二:比较对象选错,导致去重失效

这是最隐蔽的错误。有人会写成if (nums[fast] != nums[fast - 1]),在 [0,0,1,1,1,2] 这种例子上,第一次比较是0跟0相同,跳过;接着1跟前面的0比较,不同,写入;继续1跟前面的1比较,相同,跳过。看起来也能工作,但问题是它只判断“跟物理相邻的前面那个元素是否相等”,依赖的是有序数组下重复项相邻的性质。可一旦慢指针跟快指针之间差开了距离,数组物理位置前面的元素可能是已经被写入的新值,也可能是还没被处理的重复项,对比结果就不一定可靠了。右侧例子中如果数组变成 [0,1,1,1,2],用nums[fast - 1]比较,遇到第二个1时前面的元素也是1,能跳过;但遇到2时,跟前面的1比较,不同,写入;返回结果似乎也对。可一旦快指针的位置落后于“已写入区的最后一个元素”的语义,就可能在更复杂的输入下出问题。稳妥的做法只有比较写入区最后一个元素,也就是nums[slow - 1]。

5.3 踩坑实录三:本地调试误以为没通过

我之前带过一个同学,他跑完代码后打印整个数组,看到后面还有重复值,立刻觉得自己代码错了。其实这是正常现象。你要检验自己的输出,应该只看返回长度范围内的元素。用Arrays.copyOf(nums, slow)打印前slow个元素来验证,就不会被残留值误导。这算一个很小的调试技巧,但对新人来说还挺关键。

6. 从去重到“保留k个重复项”——一道题的通用化扩展

LeetCode 26的套路掌握以后,其实可以无缝迁移到一类更通用的题目:有序数组里每个元素最多保留k个。这个扩展在LeetCode 80“删除有序数组中的重复项II”里就用到了:每个元素最多出现两次,不能出现三次及以上。

6.1 通用化思路:把“比较前一个”换成“比较前k个”

LeetCode 26的本质是“每个元素最多保留1次”。为什么我们比较的是nums[slow - 1]?因为写入区最后那1个位置就代表着最近保留的元素,如果当前元素跟它相同,就意味着这个元素已经出现了,不能再保留第2次。

那如果允许出现2次呢?我们就不再关心“是否出现过”,而要关心“是否已经出现满2次”。由于数组有序,如果当前元素跟nums[slow - 2]相同,说明在保留区里已经有连续的两个当前元素了,那当前这个就是第3个,必须跳过。反过来,如果跟nums[slow - 2]不同,说明在保留区里至多只出现过1次当前元素,那就可以放心写入。

同理,如果允许出现k次,就把比较对象换成nums[slow - k]。这是这类题的通用公式。我后来刷到LeetCode 80时几乎没有额外思考,直接把26的代码改了一行:nums[fast] != nums[slow - k],k传2,就通过了。

6.2 通用代码模板

public int removeDuplicates(int[] nums, int k) { if (nums.length <= k) { return nums.length; } int slow = k; for (int fast = k; fast < nums.length; fast++) { if (nums[fast] != nums[slow - k]) { nums[slow] = nums[fast]; slow++; } } return slow; }

这个模板的边界逻辑要仔细想一下:慢指针从k开始,因为前k个元素天然可以保留(即使它们全相等,也没有超过k个)。快指针也从k开始,从第k+1个元素开始判断。比较对象是nums[slow - k],它表示写入区里“往前数k个位置”的那个元素。如果当前元素等于它,说明相同元素已经凑够k个了;不等,才允许写入。用这个模板,LeetCode 26就是k=1的特例,LeetCode 80就是k=2的特例。

6.3 举一反三:数组操作里的“读写分离”思想

双指针去重看起来只是个小技巧,但它背后是“读写分离”的思想:一个指针负责读,一个指针负责写,写永远追着读走,但写的位置不一定跟读同步。这个思想在很多场景里都会复用:比如数组原地删除指定元素(LeetCode 27)、移动零(LeetCode 283)、按奇偶排序数组(LeetCode 905),本质都是在用快慢指针做原地过滤。你甚至可以把这个套路背成一个条件反射——一旦遇到“原地处理数组元素”的题,就先想想能不能用快慢指针的读写分离来解决。

7. 实操心得:如何把这道题变成你的面试底牌

作为一个刷过不少题的过来人,我建议别急着把这道题划掉就算完。LeetCode 26虽然简单,却是一个很好的“表现型题目”——你可以用它展示代码规范、边界意识、复杂度分析能力和扩展思维,这些都在一道题里涵盖了。

7.1 面试时怎么讲才能加分

如果面试官让你做这道题,别直接闷头写代码。先说思路:“因为数组有序,重复元素一定相邻,所以可以用快慢指针。快指针负责扫描,慢指针维护有效区的末尾。当前元素跟慢指针前一个元素不同,就覆盖写入并发推进,否则快指针直接跳过。这样能保证原地、O(1)空间、O(n)时间。”

写代码时,注意先写空数组边界,再写主循环。写完代码后主动补充复杂度分析,并提一句“慢指针同时就是新数组长度,所以最后返回慢指针”。最后,如果面试官有兴趣,还可以补充“这个解法可以推广到保留k个重复项,把比较对象改成 slow - k 就行”。这种层次递进的输出方式,远比默写代码印象深刻。

7.2 练习时给自己的额外挑战

我自己刷题时,习惯在通过之后给代码做三个小改造:

  • 换成C++版本时注意引用传递和指针写法,加深对“原地修改”的理解。
  • 把代码改成“slow从0开始、先判断再决定是否写入第一个元素”的版本,对比两种写法的边界差异,从而彻底掌握慢指针的含义。
  • 尝试用while循环重写整个逻辑,做到无论用for还是while都能快速写出正确代码。

这些额外练习花不了多少时间,但对加深理解帮助很大。尤其是写指针语义这件事,代码怎么写都行,但只有真正理解“slow指向下一个写入位置”之后,遇到变体题才不会慌。

7.3 关于这道题的最后一个心得

双指针是我刷题过程中感受到最实惠的套路之一。LeetCode 26看起来平淡无奇,但它是理解“快慢指针为什么能优化原地数组操作”的最短路径。以前我总觉得数组去重非得用集合,学会了覆盖式写入以后才发现,数据结构自带的API有时候反而会限制思路。数组本身只是个连续内存,谁告诉你非要“删除”才能去重?站在内存的角度看,覆盖比删除效率高得多。这道题教给我的就是这个——处理数据之前,先想想数据在底层长什么样,很多“聪明解法”其实是顺着底层结构自然长出来的。保持这个视角,刷题才会越刷越轻松。

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

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

立即咨询