刷 LeetCode 刷到一定量之后,你会慢慢发现一个很有意思的现象:凡是跟“重复元素”沾边的题,不管它挂在哪个标签下面,底层解法就那么几板斧——哈希表、排序、双指针、位运算、龟兔赛跑。这周我集中过了一遍 LeetCode 100 Hot 里的相关题目,从“存在重复元素”到“寻找重复数”,难度从简单一路到偏难,但解法模型高度统一。这篇文章就把这些题串起来讲,重点不是我贴几份能提交的代码,而是帮你建立一个“看到重复元素就知道往哪个方向想”的解题框架。
适合谁看?一是刚开始刷题、被 217、26、83 这种简单题搞蒙的新手;二是刷到中等题想总结套路、准备面试手撕代码的人。文里会涉及数组、链表、哈希表、位运算这些基础数据结构,但不会讲太高深的理论,所有内容都可以直接在你的编辑器里跑起来验证。
1. 重复元素题型的核心脉络:一个主题,五种解法模型
1.1 为什么“重复元素”值得专门写一篇总结
你仔细观察 LeetCode 的题单会发现,“重复元素”不是某个特定标签下的题目,而是横跨数组、链表、哈希表、排序、位运算、二分查找多个分类的题型。这种分散性恰恰是它值得系统总结的原因:面试官想考察的往往不是某一个孤立知识点,而是你能不能把一个数据结构问题抽象成若干种通用解法。
从题目本身看,重复元素问题有一个非常明显的特征——几乎所有题目都围绕着“唯一性”做文章。有的题只问“是否存在重复”,有的题要求“把重复的删掉”,有的题要求“找出那个重复的”,还有的题更进一步限定“最多保留 k 个”。这些问题从表面看差异巨大,但底层考察的都是同一个能力:你能不能高效判断和处理集合中元素的唯一性。
我刷完这一批题后最大的感受是,重复元素题真正想训练的是两件事:第一,理解哈希表是解决唯一性问题的第一直觉;第二,在空间受限或数据有序的特殊条件下,怎么用更巧的解法替代哈希表。这两点贯穿了所有重复元素题目,也是面试官层层加码考察的核心。
1.2 五种解法模型和它们的分工
我把重复元素相关题目归纳为六种解法,它们在时间复杂度和空间复杂度上有明显差异,适用场景也不同。先给你一张速览表,后面逐一展开:
| 解法模型 | 典型题目 | 时间复杂度 | 空间复杂度 | 核心适用条件 |
|---|---|---|---|---|
| 哈希表/哈希集合 | 217、219 | O(n) | O(n) | 最通用,最简单的保底方案 |
| 排序后扫描 | 217 的解法二 | O(n log n) | O(1) | 可以修改原数组,不要求保序 |
| 双指针 | 26、80 | O(n) | O(1) | 数组已有序,需要原地修改 |
| 链表哑节点 + 值比较 | 83、82 | O(n) | O(1) | 排序链表场景 |
| 位运算(异或) | 136 | O(n) | O(1) | 恰好存在一个出现奇数次的元素 |
| 正负号标记 | 442、448 | O(n) | O(1) | 数组元素范围是 1 到 n |
| 龟兔赛跑 / 二分计数 | 287 | O(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、442 | nums[0]直接越界 | 先判空,返回 0 或空列表 |
| 数组长度为 1 | 26、80、136 | 循环条件设置错误 | 直接返回 1 或对应值 |
| 链表为空 / 单节点 | 83、82 | 访问head.next.val空指针 | 用while head and head.next兜底 |
| 头节点就是重复元素 | 82 | return head把头节点也删了 | 用哑节点 dummy,最后返回 dummy.next |
| 连续重复超过 2 次 | 83 | 删除后 cur 不判断新 next | 发生删除时 cur 不要前移 |
| 元素可能为负数 | 220、442 | 取负号时漏加 abs | 判断和修改时都用 abs |
| 值域上界接近 int 上限 | 220 | t+1 溢出 int | 用 long 类型 |
这张表是从我自己的提交记录里翻出来的,每一条都是真实踩过的坑。我最惨的一次是在 220 题上,因为t + 1溢出导致桶 id 计算错误,提交了四次才反应过来。这类问题往往在本地小用例上测不出来,因为小数据的 t 很小,但 LeetCode 的隐藏用例会把边界值拉满。
6.2 第 220 题:桶排序的溢出和负数处理
220 题“存在重复元素 III”是前文提到的三步曲里难度最高的:要求找出是否满足abs(nums[i] - nums[j]) <= t且abs(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 题的删除过程,比盯着代码空想高效得多。我身边几乎所有算法强的同事,面试手撕链表题时都会先画图再写码,这个习惯值得你刻意练习。
最后分享一点个人体会。重复元素题看起来多而杂,但本质上就是“唯一性问题”的不同考法。把哈希表作为第一直觉,把双指针、位运算、正负号标记、龟兔赛跑作为特殊条件下的优化手段,整个题型的脉络就清晰了。我刷完这一轮的最大收获,不是记住了每道题的代码,而是养成了一个习惯:拿到题目先问自己三个问题——数据是否有序?空间是否受限?能否修改原数组?这三个问题的答案,基本就决定了该用哪种解法。你之后做这类题,也可以试试这个思路,应该能少走不少弯路。