LeetCode重复元素题型全总结:五大解法模型助你秒杀面试
2026/9/18 12:17:52 网站建设 项目流程

刷 LeetCode 刷到一定量之后,你会慢慢发现一个很有意思的现象:凡是跟“重复元素”沾边的题,不管它挂在哪个标签下面,底层解法就那么几板斧——哈希表、排序、双指针、位运算、龟兔赛跑。这周我集中过了一遍 LeetCode 100 Hot 里的相关题目,从“存在重复元素”到“寻找重复数”,难度从简单一路到偏难,但解法模型高度统一。这篇文章就把这些题串起来讲,重点不是我贴几份能提交的代码,而是帮你建立一个“看到重复元素就知道往哪个方向想”的解题框架。

适合谁看?一是刚开始刷题、被 217、26、83 这种简单题搞蒙的新手;二是刷到中等题想总结套路、准备面试手撕代码的人。文里会涉及数组、链表、哈希表、位运算这些基础数据结构,但不会讲太高深的理论,所有内容都可以直接在你的编辑器里跑起来验证。

1. 重复元素题型的核心脉络:一个主题,五种解法模型

1.1 为什么“重复元素”值得专门写一篇总结

你仔细观察 LeetCode 的题单会发现,“重复元素”不是某个特定标签下的题目,而是横跨数组、链表、哈希表、排序、位运算、二分查找多个分类的题型。这种分散性恰恰是它值得系统总结的原因:面试官想考察的往往不是某一个孤立知识点,而是你能不能把一个数据结构问题抽象成若干种通用解法。

从题目本身看,重复元素问题有一个非常明显的特征——几乎所有题目都围绕着“唯一性”做文章。有的题只问“是否存在重复”,有的题要求“把重复的删掉”,有的题要求“找出那个重复的”,还有的题更进一步限定“最多保留 k 个”。这些问题从表面看差异巨大,但底层考察的都是同一个能力:你能不能高效判断和处理集合中元素的唯一性。

我刷完这一批题后最大的感受是,重复元素题真正想训练的是两件事:第一,理解哈希表是解决唯一性问题的第一直觉;第二,在空间受限或数据有序的特殊条件下,怎么用更巧的解法替代哈希表。这两点贯穿了所有重复元素题目,也是面试官层层加码考察的核心。

1.2 五种解法模型和它们的分工

我把重复元素相关题目归纳为六种解法,它们在时间复杂度和空间复杂度上有明显差异,适用场景也不同。先给你一张速览表,后面逐一展开:

解法模型典型题目时间复杂度空间复杂度核心适用条件
哈希表/哈希集合217、219O(n)O(n)最通用,最简单的保底方案
排序后扫描217 的解法二O(n log n)O(1)可以修改原数组,不要求保序
双指针26、80O(n)O(1)数组已有序,需要原地修改
链表哑节点 + 值比较83、82O(n)O(1)排序链表场景
位运算(异或)136O(n)O(1)恰好存在一个出现奇数次的元素
正负号标记442、448O(n)O(1)数组元素范围是 1 到 n
龟兔赛跑 / 二分计数287O(n) / O(n log n)O(1)不能修改数组,且数值范围明确

这张表你可以打印出来贴在显示器边上。实际刷题时,看到题目先往表里套,大概率能快速锁定方向。接下来我会按常见程度逐个讲清楚,其中双指针和链表是面试手撕代码的高频考法,篇幅会多一些。

2. 数组去重:双指针是面试官真正想看的答案

2.1 第 26 题“删除有序数组中的重复项”:快慢指针的完整拆解

这道题是 LeetCode 的简单题,也是面试中出现频率极高的一道。题目描述很直接:给你一个升序排列的数组,要求原地删除重复元素,让每个元素只出现一次,返回删除后数组的新长度。注意两个约束:原地升序——这基本就是在提示你双指针解法。

先想一个问题:为什么不能用简单的“遍历 + 删除”来做?因为数组的删除操作是 O(n) 的,你每删一个元素,后面的所有元素都要往前挪,最坏情况下整体复杂度会退化到 O(n²)。而且题目要求“原地”,意味着你不能新建一个数组再拷贝回去。

双指针的思路其实很生活化:想象你手里有两个人,一个慢指针 slow 负责在数组前面“圈地”,一个快指针 fast 负责在前面探路。slow 记录的是“下一个不重复元素应该放到的位置”,fast 则从头到尾扫描整个数组。因为数组有序,相同的元素一定连续排列,所以只要发现 fast 指向的元素和 slow 位置的元素不同,就说明遇到了一个新的不重复元素,把它挪到 slow 位置,然后 slow 前进一位。

def removeDuplicates(nums): if not nums: return 0 slow = 0 for fast in range(1, len(nums)): if nums[fast] != nums[slow]: slow += 1 nums[slow] = nums[fast] return slow + 1

注意几个容易出错的地方。第一,slow 从 0 开始,因为第一个元素无论如何都会保留;第二,比较的是nums[fast]nums[slow],而不是nums[fast]nums[fast - 1]——虽然这两种写法在有序数组上结果一样,但前者更好推广到“最多保留 k 个重复项”的通用场景;第三,返回值是slow + 1,因为 slow 是最后一个不重复元素的下标,长度是下标加一。

我第一次写这道题的时候,在返回长度上栽过跟头——如果 slow 初始化为 1,返回的就是 slow,如果初始化为 0,返回的就是 slow + 1。这个细节看起来不起眼,但在面试手撕代码时,很容易因为紧张在边界上翻车。建议你固定一种写法,比如都从 0 开始,最后返回 slow + 1,这样就只需要记一种模式。

2.2 第 80 题“删除有序数组中的重复项 II”:从“留一个”到“留 K 个”的通用模板

第 80 题是 26 题的升级版:这次要求每个元素最多出现两次,而不是一次。如果你已经理解 26 题的快慢指针,这题其实只改一个条件,但很多人就是卡在这个“差一点”上。

26 题比较的是nums[fast]nums[slow],因为 slow 指向的是最后一个保留元素,新元素和它不同就说明是最多出现一次。到了 80 题,我们需要知道当前元素在前面是不是已经出现两次了,这就不能只看nums[slow],而要看nums[slow - 1]——如果nums[fast]nums[slow - 1]相同,说明 fast 指向的元素已经在数组中出现了至少两次(因为 slow - 1 和 slow 是两个相同位置的元素),所以这个新元素不能再加入。

def removeDuplicates(nums): if not nums: return 0 slow = 1 for fast in range(2, len(nums)): if nums[fast] != nums[slow - 1]: slow += 1 nums[slow] = nums[fast] return slow + 1

到这里,敏锐的读者可能已经发现规律了:如果把“最多保留 k 个”作为一个通用问题,那么判断条件就是nums[fast] != nums[slow - k]。当 k=1 时,就是 26 题;当 k=2 时,就是 80 题。这个通用模板是我私藏的刷题利器,因为它把两道题合并成了一句话:

def removeDuplicatesK(nums, k): if not nums: return 0 slow = 0 for fast in range(len(nums)): if slow < k or nums[fast] != nums[slow - k]: nums[slow] = nums[fast] slow += 1 return slow

这个模板的巧妙之处在于,slow < k保证了数组前 k 个元素不管相不相等都可以直接保留,后面再遇到新元素时,只要它跟slow - k位置的元素不相等,就说明它还没有出现满 k 次,可以加入。这个写法我实测过,能顺便通过 26、80 两道题,面试时如果被问到“扩展到 k 个怎么办”,直接甩出这个模板,印象分会高很多。

2.3 第 217 题“存在重复元素”和 219 题的哈希解法

讲完双指针,回到基础一点的哈希表。第 217 题是重复元素题的“hello world”——给你一个数组,判断是否存在重复元素。最直接的思路当然是哈希集合:遍历数组,把元素一个一个丢进集合,如果发现某个元素已经在集合里,就说明有重复。

def containsDuplicate(nums): seen = set() for num in nums: if num in seen: return True seen.add(num) return False

这题的进阶版是 219 题,要求不仅存在重复,还要求两个重复元素的下标差不超过 k。如果直接套 217 的哈希集合,你会发现没法判断下标差。正确做法是哈希表存“元素最后一次出现的下标”,遍历时检查当前下标和存储下标的差值是否小于等于 k。

def containsNearbyDuplicate(nums, k): pos_map = {} for i, num in enumerate(nums): if num in pos_map and i - pos_map[num] <= k: return True pos_map[num] = i return False

这里有个细节值得注意:哈希表保存的始终是元素最后一次出现的下标。因为最后一次出现一定是最接近当前位置的,如果用最早出现的下标判断,可能会漏掉中间新出现的重复。我第一次写 219 时,用的是if num in pos_map: return True,结果发现即使距离超过 k 也会误判,后来才意识到要更新下标。这个坑很典型,建议你自己跑一遍体会一下。

那么问题来了:217 能不能不用哈希表?当然可以,排序后相邻比较就行。排序的时间复杂度是 O(n log n),空间是 O(1)。哈希表是 O(n) 时间、O(n) 空间。在面试中如果追问空间复杂度,你就要能从哈希表切换到排序解法,并说明两者取舍。

3. 链表上的重复元素:哑节点和值比较的细节

3.1 第 83 题“删除排序链表中的重复元素”:保留一个的简单逻辑

数组说完,来看链表。链表版本的重复元素题和数组版本有个核心差异:链表不能按下标随机访问,只能从头节点开始一个一个走。但好在题目明确说了是“排序链表”——这意味着相同的节点也是连续排列的,所以解法比无序链表简单得多。

第 83 题要求删除排序链表中重复的元素,每个值保留一个。思路就是用一个指针从头遍历,只要当前节点的值和下一个节点的值相同,就把下一个节点跳过(让当前节点的 next 指向下下个节点);否则指针前移,继续判断。这个操作不需要哑节点,因为头节点本身一定被保留。

def deleteDuplicates(head): cur = head while cur and cur.next: if cur.val == cur.next.val: cur.next = cur.next.next else: cur = cur.next return head

注意这里有一个非常容易写错的点:当发生删除时,cur 不能前移。因为删除后,新的cur.next可能还是和当前节点相同的重复节点,需要再比较一轮;只有当cur.val != cur.next.val时,cur 才移动到下一个节点。我第一次写的时候在 else 分支里忘了这个逻辑,把 cur 无条件后移,结果遇到连续三个相同节点时就漏删了一个。这个细节在面试时是很容易被面试官抓住的。

3.2 第 82 题“删除排序链表中的重复元素 II”:重复元素一个不留

82 题是 83 题的变体,要求把所有重复出现过的元素全部删掉,一个都不保留。比如1 -> 2 -> 2 -> 3,最后要变成1 -> 3。这道题的难度明显上升,核心原因在于头节点可能是重复元素,如果头节点也被删了,你没法直接返回原来的 head。

解决思路是引入哑节点(dummy node)。哑节点的 next 指向头节点,这样即使头节点被删,我们也能通过 dummy.next 找到新的头节点。然后维护一个 prev 指针,它指向“已处理区域的最后一个不重复节点”。遍历时,如果当前节点和它的下一个节点值相同,就继续往后走,把所有相同值的节点全部跳过,最后让 prev.next 指向第一个不重复的节点;如果当前节点不重复,prev 直接前移。

def deleteDuplicates(head): dummy = ListNode(0, head) prev = dummy while head: if head.next and head.val == head.next.val: while head.next and head.val == head.next.val: head = head.next prev.next = head.next else: prev = prev.next head = head.next return dummy.next

这段代码里最容易踩的坑是head = head.next这行。在“跳过重复段”之后,head 已经指向了重复段最后一个节点,此时再执行head = head.next,就正好指向了第一个“不重复的新节点”,这个设计是连贯的。但如果你在跳过重复段之后忘了 head 已经指向最后一个重复节点,直接写prev.next = head.next又写head = head.next,逻辑就会错乱。建议你把这段代码手动模拟一遍,画出每个指针的移动轨迹,比任何口头解释都直观。

3.3 链表题三连坑:空链表、单节点、头节点重复

链表相关的题目,坑往往不在算法本身,而在边界。我总结了自己反复踩的三个坑:

第一,空链表和单节点链表。85% 的链表题都能用while head and head.next这种条件天然规避空指针,但如果你提前访问了head.next.val就会直接抛异常。写之前先问自己:链表为空时,我的代码会不会访问空指针?链表只有一个节点时,循环会不会进入?

第二,头节点重复无法直接返回。83 题不需要担心这个,但 82 题必须用哑节点。这是两道题解法分叉的关键——面试时如果从 83 追问到 82,面试官想考察的就是你能不能意识到头节点可能被删除。

第三,值比较和引用比较混为一谈。链表节点比较的是val还是节点本身?在去重场景下当然是 val,但如果你写了if head == head.next,这在某些语言里比较的是引用,结果永远为 false,程序就会陷入死循环或漏删。我见过不少人在面试现场被这个低级错误卡住,非常尴尬。

4. 位运算和正负号标记:两种“不开额外空间”的巧解

4.1 异或运算解决“只出现一次的数字”

聊完了通用的哈希表和双指针,来说两个“秀操作”性质的解法。第一个是第 136 题“只出现一次的数字”:给定一个数组,除了某个元素只出现一次,其他元素都出现两次,找出那个只出现一次的元素。要求线性时间、常数空间。

如果不限制空间,哈希表就能做。但限制常数空间后,很多人的第一反应是“排序再扫描”——排序 O(n log n) 虽然空间 O(1),但时间不满足。这时候位运算登场:异或运算有一个神奇的性质,相同数字异或为 0,0 和任何数异或等于那个数本身,而且异或满足交换律和结合律。

这意味着,把数组里所有元素全部异或一遍,成对出现的元素会两两抵消变成 0,最后剩下的就是那个只出现一次的元素:

def singleNumber(nums): res = 0 for num in nums: res ^= num return res

这个解法太优雅了,以至于我第一次看到时愣了半天。但我要提醒你,这个技巧的适用范围极其有限:它只适合“恰好一个元素出现奇数次,其他元素出现偶数次”的场景。如果把题目改成“有两个只出现一次的元素”,代码就要复杂得多;如果改成“有三个重复的”,异或就完全失效。所以位运算可以作为加分项展示,但不能作为求重复元素的通用武器。

4.2 正负号标记法:442 和 448 的套路

第二个巧解是正负号标记法,它专门解决一类特殊条件的题目:数组长度为 n,元素值在 1 到 n 的范围内。条件这么苛刻,是因为它允许我们把数组本身当作哈希表来用——用数值对应下标,用正负号作为“是否出现过”的标记。

第 442 题“数组中重复的数据”是这类题的代表。遍历数组,对于每个数 x = abs(nums[i]),我们把nums[x - 1]取负。如果某个数已经是负数,说明这个下标对应的数字之前出现过,也就是重复了。为什么要用 abs?因为数组元素在遍历过程中可能已经被改成了负数,直接用原始值访问下标会出错。

def findDuplicates(nums): res = [] for num in nums: idx = abs(num) - 1 if nums[idx] < 0: res.append(abs(num)) nums[idx] = -nums[idx] return res

第 448 题“找到所有数组中消失的数字”是同一套路的反向操作:先同样做正负号标记,然后再次遍历数组,找到哪些位置的值仍然为正,那些位置的下标加一就是没出现过的数字。

这种解法的精妙之处在于把空间复杂度压到了 O(1),但也带来了两个硬前提:数组元素必须都在 1 到 n 之间,且允许修改原数组。面试时如果你用了这个方法,面试官大概率会追问“如果元素范围是 0 到 n-1 呢?”——这时候你需要意识到,0 作为哨兵值会导致正负号标记失效,解法要重新设计。我建议你记住这个方法的适用边界,不要机械套用。

4.3 正负号标记法在面试中的展示技巧

如果你在面试中用到正负号标记法,建议你主动说清楚它的前提条件,这比默默写出代码效果好得多。我当时面一家公司时,面试官出了 442 的变体,我写出正负号标记后主动说:“这个解法适用于元素范围 1 到 n,并且会修改原数组,如果题目要求不能修改数组,需要改用其他方法。”面试官明显态度不一样,还追问了我二分计数的解法。

在面试中,展示边界意识和方案权衡,比单纯写出正确答案更重要。这也侧面说明,刷题不只是为了 AC,更是为了理解每个解法在什么条件下成立、什么条件下失效。

5. 龟兔赛跑和二分计数:寻找重复数的进阶玩法

5.1 第 287 题如何把数组抽象成链表找环

第 287 题“寻找重复数”是重复元素题里的天花板之一,也是面试题中的常客。题目描述:给定一个包含 n+1 个整数的数组,整数范围是 1 到 n,假设只有一个重复的数字(可能重复多次),在不修改数组且只用 O(1) 额外空间的条件下找出它。

一个很长面熟的解法是排序后找相邻相同,但题目不允许修改数组;哈希表空间是 O(n),也不行。这个时候,把数组看成链表是关键一跳。

怎么做呢?把数组的每个下标 i 当成链表的节点,nums[i]当成从节点 i 出发的 next 指针。因为数组长度为 n+1,值范围 1 到 n,所以从任意位置出发,沿着i -> nums[i]走,一定能走进一个环——环的入口恰好就是重复的那个数。这一步抽象很多人第一次想不到,但只要见过一次,后面再遇到类似题就容易触类旁通。

def findDuplicate(nums): slow = nums[0] fast = nums[nums[0]] while slow != fast: slow = nums[slow] fast = nums[nums[fast]] slow = 0 while slow != fast: slow = nums[slow] fast = nums[fast] return slow

这段代码其实是链表中“寻找环入口”的经典写法,只不过把链表访问换成了数组访问。如果你对链表找环不熟悉,建议先去做一下 142 题“环形链表 II”,把这个解法吃透,再来写 287 就顺理成章了。

这个解法最难理解的地方,是“为什么环的入口就是重复数字”。我尝试用一句话解释:因为存在重复数字 x,所以至少有两个不同的下标 i 和 j 都满足nums[i] = nums[j] = x,这意味着从这两个下标出发能到达同一个位置,形成了一个环;在“把数组当下标链表”的模型里,所有指向 x 的位置会把快慢指针引向同一点,而这个点的数值就是 x。

5.2 二分计数:另一个不修改数组的解法

287 题还有一条完全不依赖链表抽象的路线:二分计数。思路是,在 [1, n] 范围内二分猜测重复数字,每次都统计数组中“小于等于 mid”的数字个数。如果没有重复,小于等于 mid 的数字个数应该恰好等于 mid;如果实际个数大于 mid,说明重复数字在 [1, mid] 区间内,否则在 (mid, n] 区间内。

def findDuplicate(nums): left, right = 1, len(nums) - 1 while left < right: mid = (left + right) // 2 count = sum(1 for num in nums if num <= mid) if count > mid: right = mid else: left = mid + 1 return left

这个解法的时间复杂度是 O(n log n),空间 O(1),虽然时间上不如龟兔赛跑,但胜在思路直观,面试时可以作为保底方案。而且它加深了对“重复数字在值域上分布”的理解——重复数字的出现,会破坏“小于等于 mid 的数量恰好为 mid”这个规律,这是个很重要的观察。

5.3 面试官考察 287 题的真正意图

287 题在面试中往往不是孤立出现的,而是作为“考察你能不能把一个陌生模型映射到已学过的算法”的测试题。数组和链表是两种看似完全不同的数据结构,但通过i -> nums[i]的映射,它们被连通了。能想到这一层的人,说明解题时具备抽象建模能力,而不仅仅是在套模板。

我个人的建议是,刷 287 题之前先把 142 题做熟,理解快慢指针为什么能找环入口;再把 26 题的双指针搞透,理解指针移动的本质。287 题是把这些看似不相关的知识点串联起来的一个节点,值得花时间去抠细节,而不是背代码。

6. 实战踩坑记录:边界条件、溢出问题和调试习惯

6.1 边界条件系统整理:一张表避免 80% 的报错

刷重复元素题目,真正拉开差距的不是算法思路,而是边界条件的处理。我把这些题目里最常出现的边界问题整理成一张表,面试前扫一眼能省很多时间:

边界场景涉及题目易犯错误正确做法
数组为空26、217、442nums[0]直接越界先判空,返回 0 或空列表
数组长度为 126、80、136循环条件设置错误直接返回 1 或对应值
链表为空 / 单节点83、82访问head.next.val空指针while head and head.next兜底
头节点就是重复元素82return head把头节点也删了用哑节点 dummy,最后返回 dummy.next
连续重复超过 2 次83删除后 cur 不判断新 next发生删除时 cur 不要前移
元素可能为负数220、442取负号时漏加 abs判断和修改时都用 abs
值域上界接近 int 上限220t+1 溢出 int用 long 类型

这张表是从我自己的提交记录里翻出来的,每一条都是真实踩过的坑。我最惨的一次是在 220 题上,因为t + 1溢出导致桶 id 计算错误,提交了四次才反应过来。这类问题往往在本地小用例上测不出来,因为小数据的 t 很小,但 LeetCode 的隐藏用例会把边界值拉满。

6.2 第 220 题:桶排序的溢出和负数处理

220 题“存在重复元素 III”是前文提到的三步曲里难度最高的:要求找出是否满足abs(nums[i] - nums[j]) <= tabs(i - j) <= k。哈希表无法处理值差约束,有序集合(TreeSet)时间复杂度 O(n log k),但桶排序可以做到平均 O(n)。

桶排序的核心思路是,把数值按照“每 t+1 一个桶”来分组,同桶内的元素差值一定不超过 t。遍历数组时,维护一个大小为 k 的滑动窗口,窗口内元素按照桶 id 存储。如果当前元素进入的桶已经存在元素,直接返回 true;否则检查相邻两个桶中是否存在差值不超过 t 的元素。

def containsNearbyAlmostDuplicate(nums, k, t): bucket_size = t + 1 buckets = {} for i, num in enumerate(nums): bucket_id = num // bucket_size if bucket_id in buckets: return True if bucket_id - 1 in buckets and abs(num - buckets[bucket_id - 1]) <= t: return True if bucket_id + 1 in buckets and abs(num - buckets[bucket_id + 1]) <= t: return True buckets[bucket_id] = num if i >= k: old_id = nums[i - k] // bucket_size del buckets[old_id] return False

这里有两个必须处理的细节。第一,桶大小t + 1要用长整型,否则当 t 接近 int 上限时会溢出变成负数,桶 id 计算全乱套。第二,负数在整除时向下取整的问题:Python 的//是向下取整,所以负数也能得到正确的桶 id;但在 Java、C++ 里,整数除法是向零取整,负数会分错桶,需要用Math.floorDiv或调整(num - min_val) // bucket_size。这个语言差异我在跨语言刷题时踩过一次,写完 Python 再写 Java 时差点翻车。

6.3 刷重复元素题的调试习惯:先暴力再优化

最后分享一个比较实用的刷题习惯。遇到重复元素题目,我一般不直接上最优解,而是先在本地写一个暴力的双层循环版本,跑完样例确认自己理解了题意,再考虑怎么优化。这个习惯陪我过了很多中等题,原因很朴素:最优解往往是对暴力解缺陷的修补,理解缺陷才能理解优化方向

比如 26 题,暴力解法是“从后往前删除重复元素”,虽然复杂度高,但至少能帮你确认升序、原地、返回长度这三个约束分别意味着什么。确认之后,再看快慢指针怎么解决删除导致的 O(n²) 问题,就会有“原来如此”的顿悟。反过来,如果你一上来就背双指针代码,遇到变体题,比如“最多保留 k 个”,很容易卡住。

另外,链表题强烈建议你在白纸上画图。画四个节点、标上指针,手动模拟 82 题的删除过程,比盯着代码空想高效得多。我身边几乎所有算法强的同事,面试手撕链表题时都会先画图再写码,这个习惯值得你刻意练习。


最后分享一点个人体会。重复元素题看起来多而杂,但本质上就是“唯一性问题”的不同考法。把哈希表作为第一直觉,把双指针、位运算、正负号标记、龟兔赛跑作为特殊条件下的优化手段,整个题型的脉络就清晰了。我刷完这一轮的最大收获,不是记住了每道题的代码,而是养成了一个习惯:拿到题目先问自己三个问题——数据是否有序?空间是否受限?能否修改原数组?这三个问题的答案,基本就决定了该用哪种解法。你之后做这类题,也可以试试这个思路,应该能少走不少弯路。

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

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

立即咨询