☰
LeetCode第80题:双指针通用模板解决数组去重保留两次问题
2026/10/14 3:58:06 网站建设 项目流程

刷题刷到LeetCode第80题的时候,很多人会有一种“这题我好像会,但又差点意思”的感觉。题目本身不难:给一个有序数组,让你原地删除重复出现的元素,使每个元素最多出现两次,然后返回新数组的长度。可一旦上手写,判断条件到底用nums[j - 1]还是nums[j - 2],初始指针应该放1还是2,一下就容易绕进去。尤其是有第26题“最多保留一次”的经验之后,这道题最坑的地方恰恰在于:你以为只是把数字从1改成2,实际上整个“比较基准”都变了。

这篇文章就从读题、双指针模板、边界验证到面试追问,把这道题完整拆开讲清楚。看完之后,你不仅能写对第80题,还能顺手把第26题、第27题以及“最多保留K个元素”的通用模板一并拿下。

1. 读题与思路破局

1.1 题目到底在考什么

先看原题的关键约束条件。

第一,数组是有序的。这个“有序”两个字是整个解法的基石。因为有序,所有相同的值必然是连续排在一起的,所以才谈得上“删除重复项”。如果数组无序,同一个值分散在不同位置,双指针这种线性扫描的原地写法就不成立了,需要先排序或者用哈希表计数。

第二,必须原地修改。你不能新建一个数组来装结果,只能在nums这个数组上直接覆盖。题目最后判断的是新长度k,并且只看数组前k个位置是否满足要求,后面多出来的值是什么无所谓。

第三,每个元素最多出现两次。注意这里说的是“最多”,也就是说出现一次或两次都可以,出现三次及以上才需要删除。这个限制条件决定了判断逻辑不是简单的“见重复就删”,而是要数一下当前这个值已经保留了几个。

第四,返回值是长度,不是数组本身。很多人写完之后不检查数组内容,导致返回长度对、结果数组前k个位置却是错的,这种属于“半对”,面试时依然会被挂。

把这几点压缩一下,就是三个关键词:有序数组、原地覆盖、频次上限为2。所有解法都是围绕这三个词展开的。

1.2 最直接的计数方案,为什么不够“漂亮”

看到这道题,第一时间能想到的朴素做法是:遍历数组,用一个计数器记录当前值已经出现了几次,超过2次就跳过,否则保留。

int removeDuplicates(vector<int>& nums) { int n = nums.size(); int j = 0; int cnt = 0; for (int i = 0; i < n; ++i) { if (i > 0 && nums[i] == nums[i - 1]) { ++cnt; } else { cnt = 1; } if (cnt <= 2) { nums[j++] = nums[i]; } } return j; }

这段代码能跑通,空间复杂度也是O(1),从结果上看完全不违规。但它有个隐患:你在覆盖nums[j]的同时,还在读nums[i - 1]做比较。一旦j和i交错,nums[i - 1]就可能不再是原始数组里的值,而是之前覆盖进去的结果。虽然在这个特定题目里因为覆盖的都是相同值,通常不会出错,但脑子里要同时维护“原始数组值”和“已覆盖数组值”两套状态,很容易把自己绕晕。

更重要的是,这种写法没有体现出这道题想考的“双指针”思维。面试官看这道题,大概率希望你写出的是双指针模板,而不是计数器版本。这就引出了下一节要讲的核心解法。

2. 双指针:从朴素版本到通用模板

2.1 通用解法:一次遍历,一个条件判断

我先直接给出我认为最值得记忆的解法,然后逐步解释它为什么对。

int removeDuplicates(vector<int>& nums) { int n = nums.size(); if (n <= 2) { return n; } int j = 2; for (int i = 2; i < n; ++i) { if (nums[i] != nums[j - 2]) { nums[j++] = nums[i]; } } return j; }

核心只有三句话。j是“已确认保留区域的长度”,同时也是“下一个写入位置”。初始值为2,是因为不管数组前两个元素是什么,它们最多两个相同,一定满足题目要求,可以直接信任。

从i = 2开始遍历。每次比较nums[i]和nums[j - 2]。如果不相等,就说明nums[i]可以保留,写入nums[j]并让j加1;如果相等,就跳过。

为什么是和nums[j - 2]比,而不是和nums[j - 1]比?这是整道题最关键的细节。j - 2是已经保留的序列中倒数第二个位置。数组是有序的,如果nums[i]和nums[j - 2]相等,那就意味着当前值和已保留的倒数两个值都相同,也就是这个值已经出现两次了,当前的nums[i]是第三个,必须跳过。如果nums[i]和nums[j - 2]不相等,因为数组有序,说明当前值在已保留的序列中最多只出现了两次末尾位置中的一次,保留它不会导致某个值出现第三次。

举个例子,数组是[1, 1, 1, 2, 2, 2, 2, 3]。当扫描到第三个1时,j = 2,nums[j - 2] = nums[0] = 1,相等,跳过。扫描到第一个2时,j仍为2,nums[j - 2] = nums[0] = 1,不等,写入,j变成3。扫描到第二个2时,j = 3,nums[j - 2] = nums[1] = 1,与当前值2不等,写入,j变成4。扫描到第三个2时,j = 4,nums[j - 2] = nums[2] = 2,与当前值相等,跳过。第四个2同理跳过。整个过程干净利落。

如果你换成nums[j - 1]来判断,就会出现问题。还拿[1, 1, 2, 2, 2, 3]举例:扫描第一个2时,j = 2,nums[j - 1] = nums[1] = 1,不等,写入,j = 3。扫描第二个2时,nums[j - 1] = nums[2],此时nums[2]已经被覆盖成了2,和当前值相等,跳过。扫描第三个2时,nums[j - 1]还是2,相等,跳过。扫描3时,nums[j - 1] = 2,不等,写入,j = 4。最终数组前4位变成[1, 1, 2, 3],但正确结果应该是[1, 1, 2, 2, 3],少了一个2。这就是用j - 1踩的坑。

2.2 为什么这个模板可以推广到“最多K个”

上面这个写法最巧妙的地方在于:它一点都不依赖上限“2”的特殊性。如果把题目改成“每个元素最多出现三次”,你只需要把所有的2改成3;改成“最多出现K次”,就改成K。

int removeDuplicatesK(vector<int>& nums, int k) { int n = nums.size(); if (n <= k) { return n; } int j = k; for (int i = k; i < n; ++i) { if (nums[i] != nums[j - k]) { nums[j++] = nums[i]; } } return j; }

原理是一样的:j - k是已保留序列中倒数第K个位置。如果当前元素和它不相同,说明当前元素和已保留的末尾K个元素不全相同,换句话说,当前值的出现次数还没到第K+1次,可以保留。如果相同,说明已经保留了K个,当前这个是多余的。

拿这个模板回看第26题。第26题是“每个元素最多出现一次”,K等于1。于是j = 1,从i = 1开始扫,判断条件变成nums[i] != nums[j - 1]。这就是绝大多数人背过的那个解法。

int removeDuplicates(vector<int>& nums) { int n = nums.size(); if (n <= 1) { return n; } int j = 1; for (int i = 1; i < n; ++i) { if (nums[i] != nums[j - 1]) { nums[j++] = nums[i]; } } return j; }

所以第26题和第80题本质上是同一个题的两个参数版本。把K从1换成2,很多人的第一反应是猛一下没转过弯,就是因为没有意识到模板的普适性。一旦接受了j - K这个比较基准,后面不管遇到“最多保留3个”还是“最多保留5个”,都是秒解。

2.3 另一种双指针写法:计数与覆盖分离

除了上面这种“跳着比”的模板,还有一种靠计数器辅助的双指针版本,也值得一看。

int removeDuplicates(vector<int>& nums) { int n = nums.size(); int j = 0; for (int i = 0; i < n; ++i) { int cnt = 1; while (i + 1 < n && nums[i + 1] == nums[i]) { ++cnt; ++i; } int keep = min(cnt, 2); while (keep--) { nums[j++] = nums[i]; } } return j; }

这个思路是把连续相同的一段值整体处理:先数出这一段出现了几次,如果超过2次就只保留2个,否则全保留。逻辑很直观,代码也容易理解,适合作为面试时的“方案二”讲给面试官听。它的问题在于嵌套循环,看起来不如nums[j - 2]那种写法简洁,而且没有体现出“有序数组+双指针”的精髓。我自己的习惯是:讲思路时用计数器版本,让听的人秒懂;写代码时用通用模板,让代码最精简。

3. 实操验证与细节打磨

3.1 用一组用例完整体验指针移动过程

理解模板之后,最忌讳的就是“眼睛会了,手没会”。我建议你拿一组混合数据,老老实实把每一步的i、j、nums[j - 2]列出来,手动推一遍。下面以[0, 0, 1, 1, 1, 1, 2, 3, 3]为例,初始j = 2。

步骤inums[i]nums[j - 2]是否相等动作j
121nums[0] = 0否nums[2] = 1,j加13
231nums[1] = 0否nums[3] = 1,j加14
341nums[2] = 1是跳过4
451nums[2] = 1是跳过4
562nums[2] = 1否nums[4] = 2,j加15
673nums[3] = 1否nums[5] = 3,j加16
783nums[4] = 2否nums[6] = 3,j加17

最终j = 7,数组前7个元素依次是[0, 0, 1, 1, 2, 3, 3]。手动推完这一步,你就会发现一个规律:j始终指向“已确认合法序列的下一格”,而nums[j - 2]始终是这个合法序列里的倒数第二个元素。这个规律比背代码更值得记住,因为它是判断逻辑的几何意义。

3.2 边界条件与LeetCode提交细节

光会推主流程不够,边界条件才是第一次提交能否通过的关键。

空数组[]。n = 0,直接返回0。代码里n <= 2的判断会覆盖这种情况。

长度为1的数组[1]。数组里最多只有一个元素,无论如何都不会出现重复三次,直接返回1。

长度为2的数组[1, 1]。直接返回2,因为两个相同的元素恰好符合“最多两次”。

全部相同的数组[1, 1, 1, 1, 1]。j一直停在2,扫描完返回2,数组前两位保持[1, 1],正确。

全部不相同的数组[1, 2, 3, 4, 5]。从i = 2开始,每次nums[i]都和新序列的倒数第二个元素不同,所以每个元素都保留,返回5。这一步说明一个问题:在完全无重复的数组上,这个算法不会做任何覆盖操作,只是让i和j同步前进。

还有一类特殊用例:[1, 1, 2, 2, 2, 3, 3, 3, 3]。这里既有两个连着的1,又有超过两个的2和3。推一遍会发现,两个1被完整保留,两个2保留,两个3保留,最终长度6,结果[1, 1, 2, 2, 3, 3]。这类混合用例最容易检验你对“最多两次”的理解有没有偏差。

3.3 与第26题、第27题的联动复习

刷题不能一题一题孤立地刷,尤其是双指针这一大类,很多题共用同一个底层模型。第26题是去掉有序数组中的重复项,每个元素最多保留一次。第80题是它的升级版,每次最多保留两次。第27题是移除数组中所有等于某个值的元素,维护一个不等于目标值的序列。

把这三道题放在一起看,会发现它们的框架完全一致:

// 第26题:最多一次 int j = 1; for (int i = 1; i < n; ++i) { if (nums[i] != nums[j - 1]) { nums[j++] = nums[i]; } } // 第80题:最多两次 int j = 2; for (int i = 2; i < n; ++i) { if (nums[i] != nums[j - 2]) { nums[j++] = nums[i]; } } // 第27题:移除val int j = 0; for (int i = 0; i < n; ++i) { if (nums[i] != val) { nums[j++] = nums[i]; } }

三者的区别只在于两点:j的初始值,以及判断条件。第27题比较特殊,它不看“前一个数”,而看“是不是目标值”,因为这里的保留条件是值本身,不是出现次数。但指针模型一样,都是“快指针负责探索,慢指针负责记录合法序列的末端”。

建议你按这个顺序复习:先刷第27题,理解“保留/丢弃”双指针模型;再刷第26题,加入“最多一次”的约束;最后做第80题,把约束改成“最多两次”。这样一条线串下来,比单独背第80题的代码要扎实得多。

4. 面试官常问的延伸题与避坑清单

4.1 三个高频追问

追问一:如果数组是无序的,怎么做?

有序这个条件一旦去掉,双指针模板就失效了。因为相同的值不再连续,nums[i]和nums[j - k]比较没有意义。一种做法是先排序,然后转成有序数组再套模板,时间复杂度变成O(n log n),空间复杂度看排序算法。另一种做法是借助哈希表统计每个值的出现次数,然后再遍历一次数组,只保留出现次数不超过2的值,但这样额外空间是O(n),不符合原题的空间要求。所以原题给“有序”不是随意的,它是整个O(n)原地解法成立的前提。

追问二:如果“最多两次”改成“最多K次”,怎么写?

直接套2.2节的通用模板,把常量2换成参数K。面试官如果这么问,大概率是想看你是不是真的理解了模板,而不是背了答案。这时候你最好主动说一句:“这个模板对任意K都成立,因为nums[j - K]始终指向已保留序列的倒数第K个位置,当前元素只要和它不同,就说明重复次数还没到上限。”

追问三:返回的长度到底怎么理解?

题目要求返回新长度k,并且调用方只会检查nums的前k个元素。面试时写完之后,建议主动解释一下:j的值同时就是新数组长度,因为j从0或2开始,每次决定保留时只加1,它始终等于已保留元素的数量。这一点虽然简单,但很多候选人写代码时并不清楚“为什么返回j就是对的值”,被追问后反而卡壳。

4.2 常见错误速查表

错误写法错误原因正确做法
if (nums[i] != nums[j - 1])只比较倒数第一个,导致出现三次时判断失效应该比较nums[j - 2]
int j = 1; for (int i = 1; ...)直接从1开始,初始状态就不满足“最多两次”的信任前提j初始化为2,i也从2开始
未处理n <= 2的情况数组长度不足2时,j - 2会访问负下标开头加上if (n <= 2) return n;
忘记在原数组上覆盖,直接新建数组违背原地修改要求必须通过nums[j++] = nums[i]覆盖
用nums[i + 1]与nums[i]比较来统计次数容易越界,且无法和“已保留区域”联动用双指针维护合法区域,不要靠前看一位来数数
返回i而不是ji是扫描指针,不是长度必须返回慢指针j

排序下来你会发现,80%的错误都出在“比较基准选错”和“初始化指针位置不对”这两类。我自己刷这道题时也在这两个地方各栽过一次,后来把j - 2看成“合法区域的倒数第二个位置”,才算真正记住了。

4.3 刷题之外:这种覆盖技巧在工程里的影子

有人会问,这种“原地覆盖数组”的写法,实际工作中真的用得到吗?说实话,直接用到的场景不算多,但类似的思路其实很常见。比如日志模块里要保留最近N份相同级别的记录,比如数据清洗时要把超过阈值的重复项在数组里就地滤掉,再比如协议栈里处理环形缓冲区中的数据覆盖。这些场景的共同点是:你不想每次处理都新建一块内存,也不想引入太多临时变量,于是“快指针负责读,慢指针负责写”就成了最自然的选择。

退一步说,即使工作中用不到这行代码,面试时这道题仍然是检验候选人“能不能把约束条件转化为代码逻辑”的好题目。它不需要任何奇技淫巧,纯粹考你理不理解有序性带来的简化,以及指针含义的准确表达。

5. 一点个人经验

这道题我前前后后给别人讲过很多次。每次讲的时候,我发现最容易出问题的不是双指针这个概念,而是“我究竟在和谁比较”。第26题和j - 1比,第80题和j - 2比,如果只记结论不记原理,下次遇到K = 3又会卡住。

所以我的建议是:以后遇到这题,不要急着写代码,先问自己三个问题——合法区域的长度由哪个指针维护?初始状态能信任几个元素?当前扫描值和合法区域的哪个位置比较,才能保证不超上限?把这三个问题想清楚,代码自然就写出来了。

如果你正在准备面试,最好把第26题、第80题、第27题放在同一天刷掉,用同一个双指针模型串起来。能讲清楚三者的异同,比单独背一百道题的答案都管用。刷完之后,还可以顺手把“最多K个”这个模板写一遍,实测一下K从1到3分别怎么判断,花不了一个小时,但对理解深度的提升非常明显。

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

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

立即咨询