看到这题时,我第一反应是:这不就是83题的加强版嘛,把重复节点删到只剩一个改成删光。但真正动手写,提交,然后被测试用例教做人的时候,才发现这题的坑全藏在细节里。删除排序链表中的重复元素,听起来就是一个while循环加指针移动的事,但“删干净”和“删到剩一个”之间的差距,远比你想象的大。这篇文章我把自己的完整思考过程、两套解法(迭代和递归)、以及我反复踩过的边界条件全部整理出来。无论你是刚开始刷链表题,还是已经能轻松搞定83题准备进阶82,这篇内容都能给你一份可直接复用的解题框架。
1. 这题到底在考什么:先吃透题意里的三个隐藏细节
1.1 题目到底长什么样
LeetCode 82题的完整描述是:给定一个已排序的链表的头 head,删除原始链表中所有重复数字的节点,只留下不同的数字。返回已排序的链表。
举个例子:
- 输入:
1 -> 2 -> 3 -> 3 -> 4 -> 4 -> 5 - 输出:
1 -> 2 -> 5
再比如:
- 输入:
1 -> 1 -> 1 -> 2 -> 3 - 输出:
2 -> 3
注意这里和83题的本质区别:83题是“每个数字保留一个”,82题是“重复的数字一个都不留”。 只要你出现了一次以上,整个数字的所有节点全部删除。这个“删光光”的语义,直接导致我们需要重新设计指针的移动策略,而不能简单套用83题的模板。
1.2 隐藏细节一:头节点可能被删除,甚至可能被删光
因为重复的元素一个不留,所以头节点完全可能是被删的对象。比如输入 1 -> 1 -> 2,正确答案是 2,头节点被删掉了。更极端的情况是 1 -> 1 -> 1,整个链表删完,返回空指针。
这就带来一个经典问题:如果直接用指针操作头节点,返回 head 会出错。因为 head 可能已经不存在了。所以绝大多数标准解法都会引入一个虚拟头节点(dummy node),让 dummy -> next 指向真正的头节点。这样即使原来的 head 被删掉,我们也能通过 dummy.next 拿到新链表的头。这不是技巧,而是必要的工程手段。
1.3 隐藏细节三:指针什么时候移动,决定了代码的成功率
83题的解法里,我们通常维护一个 cur 指针,遇到相同节点就 cur.next = cur.next.next,不同就 cur = cur.next。这个逻辑处理“保留一个”是没问题的,因为当前节点永远是第一个出现的数字,它不会被删除。
但82题不一样。当我们发现 cur.next.val == cur.next.next.val 时,cur.next 这个节点是要删除的,不能简单地把 cur 向后移,而是要把 cur.next 指向这一段重复链表的最后一个节点的下一个节点。这里的核心差异在于:83题移动的是“当前指针”,82题移动的是“前驱指针的next指向”。很多第一次写这题的人都会在这里卡住,包括我。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心难点拆解:为什么“删干净”比“删到只剩一个”难这么多
2.1 重复段识别这一步,很多人第一步就写错了
最简单的思路是:遍历链表,如果 cur.val == cur.next.val,就说明出现重复。但问题来了——我们只能知道“相邻两个相等”,却不知道这段重复的边界在哪里。
比如链表 1 -> 2 -> 2 -> 2 -> 3,当我们扫描到第一个 2 的时候,它的 next 也是 2,所以能判断出重复。但问题是,你此刻并不知道后面还有多少个 2。如果你直接执行 pre.next = cur.next,那只是删掉了第一个 2,后面两个 2 还在,结果就变成了 1 -> 2 -> 3,这是不对的。
正确做法是:先让一个指针把连续重复的节点全部走完,走到最后一个重复节点的位置,然后再修改前驱指针的 next。
我见过一些人的代码是这么写的:
python复制if cur.val == cur.next.val:
while cur.val == cur.next.val:
cur = cur.next
这段代码第一眼看上去好像没什么问题,但仔细想:当 cur 走到最后一个 2 时,cur.next 是 3,cur.val != cur.next.val,循环退出。但此时 cur 停在最后一个重复节点上,没有任何指针把它和前面的节点断开。然后你继续移动 cur = cur.next,这个重复节点就变成了链表的一部分,被保留下来了。这就是典型的“识别重复段成功,但删除失败”。
正确的删除动作,是让前驱节点的 next 直接指向重复段的后一个节点,例如:
python复制pre.next = cur.next
也就是说,cur 的作用是探测重复边界,pre 的作用才是真正执行删除。
2.2 前驱指针:维持“安全区”和“未知区”的边界
为什么需要 pre?因为链表是单向的,当你探测到重复段时,你已经走过了前面的安全节点,不可能回头去修改它们。所以你必须有一个指针,始终停留在“已经确认安全的最后一个节点”上。
这个思路非常像你在一个走廊里查看房间,走廊前面你已经确认过的房间都是安全的,你不会再回去。你手上拿着一张标签,贴在你确认安全的最后一个房间门口。当你在前方发现一串连续的危险房间时,你只需要把标签从当前安全房间的门口,直接指向危险房间之后的那扇门。这个标签就是 pre。
有个很容易被忽略的细节:pre 不是每次循环都移动的。只有当 cur.next 和 cur.next.next 的值不同,也就是没有出现重复段时,pre 才向前移动一步。一旦发现重复段,pre 停在原地,等 cur 探测完整个重复段,然后 pre.next = cur.next,把安全区和重复段之后的节点接上。之后,pre 依然停在原地不动,继续观察新的 cur.next 和 cur.next.next。
这个“只有安全才动”的原则,是整道题的灵魂。
2.3 为什么83题的写法在82题里会挂
顺便说一嘴83题的写法:它只需要一个 cur 指针,遇到重复就 cur.next = cur.next.next,不重复就 cur = cur.next。为什么它不需要 pre?因为它的语义是“保留第一个出现的节点”,所以当前节点永远不会被删,它本身就充当了“安全区最后一个节点”的角色。它只需要把后面和它相同的节点一个个丢弃。
但在82题里,当前节点完全可能是重复段的一部分,需要和后面的重复节点一起被丢弃。如果你还让 cur 兼任“安全区边界”和“探测器”两个角色,就必然出现顾此失彼。这也是为什么82题必须分开两个指针,各司其职。
3. 迭代解法:虚拟头节点 + 双指针,一套能写进简历的解题范式
3.1 完整代码(C++)
我先把最终能通过的迭代版本完整贴出来,语言用C++,后面逐行解释。
cpp复制class Solution {
public:
ListNode* deleteDuplicates(ListNode* head) {
// 虚拟头节点,指向真正的链表头
ListNode* dummy = new ListNode(0);
dummy->next = head;
// pre 始终指向已经确认安全的最后一个节点
ListNode* pre = dummy;
// cur 用来探测当前节点和下一个节点是否重复
ListNode* cur = head;
while (cur && cur->next) {
if (cur->val == cur->next->val) {
// 发现重复段,用 val 记录重复值
int val = cur->val;
// 把 cur 走到这一整段重复节点的末尾
while (cur && cur->val == val) {
cur = cur->next;
}
// 让 pre 跳过整个重复段
pre->next = cur;
} else {
// 当前节点和下一个节点值不同,说明当前节点安全
pre = cur;
cur = cur->next;
}
}
return dummy->next;
}
};
3.2 为什么用虚拟头节点,而不是直接操作 head
你可能会想,直接用 head 来遍历,然后用一个 pre 记录前驱,不也一样吗?理论上可以,但有一个场景会让你非常难受:头节点就是重复段的开头。
比如 1 -> 1 -> 2,当你发现 head->val == head->next->val 时,你需要一个指针指向 head 之前的节点来执行删除。你没有这个节点,只能用一些额外变量来记录 head 是否被删除,非常麻烦。如果你用虚拟头节点,pre 初始就指向 dummy,而 dummy->next 指向 head。即使头节点被删,你只需要执行 pre->next = cur,其中 cur 已经跳过了所有重复节点,dummy->next 就自动更新为新的头节点。最后直接返回 dummy->next,不需要任何特殊判断。
这里有个小细节:dummy 节点一定要在堆上创建(new ListNode(0)),并且用完要 delete,避免内存泄漏。某些在线评测系统不查这个,但写工程代码时这是基本素养。
3.3 逐步模拟:拿 1 -> 2 -> 3 -> 3 -> 4 -> 4 -> 5 走一遍
初始状态:
code复制dummy -> 1 -> 2 -> 3 -> 3 -> 4 -> 4 -> 5
pre = dummy
cur = head (指向1)
第一次循环:
cur->val = 1,cur->next->val = 2,不相等。- 说明节点
1是安全的,pre = cur,cur = cur->next。
状态变为:
code复制dummy -> 1 -> 2 -> 3 -> 3 -> 4 -> 4 -> 5
↑
pre = 1
↑
cur = 2
第二次循环:
cur->val = 2,cur->next->val = 3,不相等。pre = cur,cur = cur->next。
状态变为:
code复制dummy -> 1 -> 2 -> 3 -> 3 -> 4 -> 4 -> 5
↑
pre = 2
↑
cur = 3
第三次循环:
cur->val = 3,cur->next->val = 3,相等。- 记录
val = 3。 - 内层
while (cur && cur->val == val)开始执行:cur当前指向第一个3,值等于3,cur = cur->next,指向第二个3。cur指向第二个3,值等于3,cur = cur->next,指向4。cur指向4,值不等于3,退出。
- 现在
cur指向4,执行pre->next = cur。
状态变为:
code复制dummy -> 1 -> 2 -> 4 -> 4 -> 5
↑ ↑
pre = 2 cur = 4
此时注意,pre 没有移动,它依然指向上一次确认安全的节点2。而 cur 已经来到了重复段后面的第一个节点4。
第四次循环:
cur->val = 4,cur->next->val = 4,相等。- 记录
val = 4。 - 内层循环:
cur指向第一个4,值等于4,cur = cur->next,指向第二个4。cur指向第二个4,值等于4,cur = cur->next,指向5。cur指向5,值不等于4,退出。
- 执行
pre->next = cur。
状态变为:
code复制dummy -> 1 -> 2 -> 5
↑ ↑
pre = 2
cur = 5
第五次循环:
cur->val = 5,cur->next == nullptr,循环条件cur && cur->next不成立,退出。- 返回
dummy->next,即1 -> 2 -> 5。
3.4 为什么内层循环必须用 val 暂存,而不是直接用 cur->val
我第一次写的时候,内层循环直接写成了:
cpp复制while (cur && cur->val == cur->next->val) {
cur = cur->next;
}
结果越看越不对劲。问题出在:当你把 cur 向后移动之后,cur->next 也变了,循环条件里的 cur->val 和 cur->next->val 是基于当前 cur 和 当前 cur->next 的。如果你要跳过一整段相同的节点,应该用一个固定的值来对比,否则当 cur 恰好移动到重复段的最后一个节点时,cur->next->val 已经不等于 cur->val 了,循环退出,但 cur 只走到了最后一个重复节点,而不是重复段之后的第一个节点。
使用 int val = cur->val 暂存,然后在内层循环里用 cur->val == val 判断,逻辑就清晰了:不管 cur 移动到哪里,我们始终和最初那个重复值比较,确保把一整段都走完。
3.5 为什么循环条件要写 cur && cur->next 而不是 cur
你可能会觉得,应该让 cur 从头走到尾,所以循环条件写 cur 就够了。但在82题里,我们每次循环都需要访问 cur->next->val,如果 cur->next 是空指针,访问 cur->next->val 直接崩溃。所以在循环入口处就要保证 cur 和 cur->next 都不为空。
这个写法的逻辑是:只要 cur 后面还有至少一个节点,我就要判断它和下一个节点是否相等。如果 cur 已经是最后一个节点,它没有下一个节点,也就不可能存在重复,直接退出循环,安全。
3.6 关于链表节点释放的内存管理
上面C++代码里,当 pre->next = cur 跳过一整段重复节点后,那些被跳过的节点实际上已经没有任何指针引用了。严格来说,应该把它们逐个 delete 掉,防止内存泄漏。但在LeetCode这种在线评测环境中,链表节点是评测系统统一分配的,通常不要求在算法里手动释放,因为释放后如果系统还要检查链表状态,可能产生悬挂指针问题。所以我上面的代码没有做释放,这是符合平台习惯的。但在真实工程中,你负责的链表如果由你分配,删除节点后记得释放内存,这属于基本的资源管理意识。
4. 递归解法:换个思路从尾到头处理重复段
4.1 递归的本质:把链表看成“当前节点 + 已处理好的子链表”
迭代解法是自顶向下,从前往后扫描。递归解法则是自底向上,先把后面一长串处理好,再回头处理当前节点。这是理解递归解法的关键。
对于82题,递归的逻辑可以这样描述:
- 如果链表为空,或者只有一个节点,那肯定没有重复节点,直接返回
head。 - 如果
head->val != head->next->val,说明头节点是安全的,那么头节点的next应该指向“从head->next开始处理完重复节点后的结果”,然后返回头节点。 - 如果
head->val == head->next->val,说明头节点是重复段的一部分,我们需要找到和head值不同的第一个节点,然后从那个节点开始递归处理,返回处理结果,跳过整段重复节点。
4.2 完整代码(C++)
cpp复制class Solution {
public:
ListNode* deleteDuplicates(ListNode* head) {
if (!head || !head->next) {
return head;
}
if (head->val != head->next->val) {
head->next = deleteDuplicates(head->next);
return head;
} else {
ListNode* cur = head->next;
while (cur && cur->val == head->val) {
cur = cur->next;
}
return deleteDuplicates(cur);
}
}
};
4.3 逐行拆解递归逻辑
先看边界条件:if (!head || !head->next) return head;。这很好理解,空链表或只有一个节点的链表,不存在重复。
当 head->val != head->next->val 时,说明 head 是安全的。我们递归处理 head->next 之后的链表,把处理好的结果接到 head->next 上。这一步和迭代解法里 pre = cur; cur = cur->next 有异曲同工之妙:安全节点保留,继续向后探测。
当 head->val == head->next->val 时,说明 head 不能被保留。我们要找到第一个不等于 head->val 的节点 cur。注意 cur 的初始值是 head->next,因为我们已经知道了 head->next->val == head->val,所以直接从下一个节点开始找。找到 cur 之后,递归调用 deleteDuplicates(cur),返回的结果直接作为整个链表的新头返回。这就相当于把这一整段重复节点全部丢弃了。
4.4 一个容易绕晕的点:递归返回的到底是什么
很多初学者看到 return deleteDuplicates(cur) 会困惑:这里返回的不应该是“处理完的链表头”吗?为什么直接递归调用而不是接在某个节点后面?
注意,当 head 是重复段的一部分时,head 本身要被丢弃,所以整个函数的结果就直接是“从 cur 开始处理完的链表头”。这个结果会返回给上一层调用者,由上一层决定是把它接到某个安全节点的 next 上,还是直接作为最终的链表头。递归的精髓就在这里:每一层只关心自己负责的节点是否保留,但“保留”和“丢弃”的结果都会通过返回值向上传递。
4.5 拿 1 -> 1 -> 2 -> 3 走一遍递归
调用 deleteDuplicates(head) 时,head 指向节点1。
head->val = 1,head->next->val = 1,相等。cur = head->next,即指向第二个节点1。- 循环:
cur->val = 1,等于head->val,cur = cur->next,指向节点2。 cur->val = 2,不等于1,循环退出。- 返回
deleteDuplicates(cur),也就是deleteDuplicates(节点2)。
进入下一层递归,head 指向节点2。
head->val = 2,head->next->val = 3,不相等。head->next = deleteDuplicates(head->next),即head->next = deleteDuplicates(节点3)。- 节点3的递归调用:
head指向节点3,head->next为空,直接返回节点3。 - 所以节点2的
next接上节点3,返回节点2。
回到最外层,deleteDuplicates(节点2) 的结果是 2 -> 3。这个结果作为整体返回,因为最外层的头节点1被丢弃了。最终结果就是 2 -> 3,正确。
4.6 迭代和递归怎么选:性能与代码可读性的权衡
递归写法的代码更短,逻辑更“声明式”:你只需要告诉程序“这个节点安全就保留,不安全就跳过”,具体怎么跳由递归框架负责。缺点是需要额外的函数调用栈空间,最坏情况下链表是完全逆序的(但这里是排序链表,不存在逆序问题),或者链表非常长,递归深度可能达到链表长度,导致栈溢出。LeetCode上链表长度通常不会到十万级,递归没问题。但如果处理真实场景下的超长链表,我还是推荐迭代写法,空间复杂度O(1),更稳。
另一个细节:递归解法中,当 head->val != head->next->val 时,我们执行 head->next = deleteDuplicates(head->next)。这一步是先处理后面,再接当前节点。如果你把顺序反过来,先接当前节点再处理后面,就会丢失后面的链表。这个顺序也是递归题的一个常见错误点,我自己写的时候栽过一次。
5. 边界条件与易错点复盘:那些一提交就报错的case
5.1 最容易被坑的测试用例汇总
我把实际测试中容易踩坑的输入整理成了一张表,方便你对照自查:
| 输入 | 期望输出 | 坑点 |
|---|---|---|
[] |
[] |
空链表,直接返回null,别访问head->next |
[1] |
[1] |
单节点,无重复,原样返回 |
[1,1] |
[] |
全部重复,返回空链表 |
[1,1,2] |
[2] |
头节点被删,需要虚拟头节点 |
[1,2,2] |
[1] |
重复出现在尾部,注意cur走到最后cur->next为空 |
[1,1,1,1] |
[] |
连续四个重复,内层循环要走到空指针 |
[1,1,2,2,3] |
[3] |
两段重复连续出现,pre要两次原地停留 |
[1,2,3,3,4,4,5] |
[1,2,5] |
标准样例,重复段都在中间 |
[1,1,2,2] |
[] |
全部重复,且最后一段重复后没有节点 |
5.2 易错点一:循环条件里的空指针访问
迭代解法里,最容易崩的地方是访问 cur->next->val 时 cur->next 是空。举个例子,输入 [1,2,2]:
- 第一次循环:
cur=1,cur->next->val=2,不相等,pre=cur,cur=cur->next。 - 第二次循环:
cur=2,cur->next->val=2,相等,进入内层循环。 - 内层循环把
cur一路走到末尾的空指针处。此时cur == nullptr。 - 外层
while (cur && cur->next)在下一轮判断时为 false,退出循环,程序正常结束。
问题在于,如果你在内层循环里用了 cur->next->val 这个写法,当 cur 移动到最后一个节点时,cur->next 是空指针,访问 cur->next->val 直接段错误。所以内层循环的判断条件一定要写 cur && cur->val == val,利用短路求值先判断 cur 不为空。
5.3 易错点二:pre 什么时候移动,什么时候不移动
再强调一次这个核心逻辑:
- 当
cur->val != cur->next->val,cur是安全节点,pre需要更新到cur,因为cur成为了新的“已确认安全段的最后一个节点”。 - 当
cur->val == cur->next->val,cur处于重复段中,pre不能动,它必须停留在重复段之前的最后一个安全节点上,这样才能执行pre->next = cur,跳过整段重复。
我见过有人把 pre = cur 写在了 if 外面,结果每次循环都更新 pre。当出现重复段时,pre 也跟着移动到了重复段中,导致后面的 pre->next = cur 无法正确跳过整个段,最终结果出现多删或者少删。这个逻辑必须刻在脑子里。
5.4 易错点三:内层循环跳出时 cur 指向的位置到底在哪
这是一个极度容易搞混的细节。内层循环:
cpp复制while (cur && cur->val == val) {
cur = cur->next;
}
循环结束有两种可能:
cur为空,说明整个链表后半段全是重复节点,pre->next = cur会将pre的 next 置空,正确。cur->val != val,说明cur已经指向了第一个和重复值不同的节点。
注意,此时 cur 并没有被删除,它只是被探测到了。所以 pre->next = cur 之后,cur 成为新的待检查节点,下一轮循环会继续判断它和 cur->next 是否重复。这个设计非常巧妙:探测和删除是分开的两步,探测时不修改链表结构,删除时只修改 pre 的 next 指针。
5.5 一个我在面试中踩过的坑:返回值怎么处理
当你用虚拟头节点时,最后一定要 return dummy->next,不能返回 head。因为 head 可能已经被删除了。比如 [1,1,2],head 指向第一个1,但正确答案是从2开始的链表。如果你返回 head,得到的是一个已经被跳过的野节点,输出完全错误。
我还见过一种写法:不建虚拟头节点,而是在循环前先处理头节点的重复。这种写法也能通过,但逻辑分支更多,代码更容易出错。相比之下,虚拟头节点一劳永逸,建议直接采用。
6. 从这题延伸出去的思考:链表操作的通用经验
6.1 82题和83题放在一起学,效果比单独刷好
这两道题是一对完美的对照实验。83题考察“去重保留一个”,82题考察“重复整个删除”。前者只需要一个 cur 指针,后者需要 pre 和 cur 双指针。放在一起对比,你会更深刻地理解“前驱指针”在单链表删除操作中的核心地位。单链表删除的本质,就是“让前驱节点的 next 越过被删节点”。任何单链表删除题,都可以抽象成这个动作。
82题为什么必须用 pre?因为当你发现重复时,你不能确定当前节点是否要保留,所以必须有一个指针停留在安全区。这种“双指针 + 安全区”的模型,在链表题中反复出现,比如“删除链表的倒数第N个节点”、“两两交换链表中的节点”、“反转链表II”等,都有类似的思想。学会82题,等于掌握了一类题目的解法。
6.2 如果链表不是有序的,这题还能这么做吗
如果链表无序,82题的解法需要调整。因为“排序链表”这个前提,是我们可以只比较相邻节点就判断出重复段的根基。如果链表无序,一个值可能出现在链表的不同位置,相邻比较就无法覆盖所有情况,需要用哈希表统计每个值出现的次数,然后第二遍遍历删除出现次数大于1的节点。这种解法的时间复杂度是O(n),空间复杂度是O(n),代价更高。
所以面试官如果追问“如果是无序链表怎么办”,你要能立刻给出基于哈希表的方案,并说清楚和有序场景的差异。这属于对题目本质的理解,不是背代码能应付的。
6.3 链表题的实际工程意义:为什么面试官爱考链表
很多人觉得链表题只存在于面试题里,实际开发中用得少。但实际上,链表是操作系统、数据库、网络协议栈、内存管理中的基础数据结构。比如内存池的可用块管理、操作系统的进程队列、文件系统的目录项缓存,都有链表的身影。连芯片设计里的片上网络路由节点管理、多级缓存行状态跟踪,也会用到链式结构来维护节点顺序。理解链表的操作,不仅是刷题,更是理解底层系统运作的基础。
82题这种“按值分组删除”的操作,在真实场景中对应着“清理配置表中重复的配置项”“删除日志中连续出现的异常标记”“合并哈希冲突链中的冗余副本”等需求。虽然工程上通常不会手写链表,但如果你理解这些操作的本质,排查问题时就能更快定位到bug。
6.4 再看一道变形题巩固思路:只出现一次的元素
如果题目改成“删除链表中只出现一次的元素,保留重复元素中的一个”,思路就要反过来。这道题的核心还是统计频次,但因为链表有序,我们可以用一次遍历。你会发现问题最终的落点还是“如何确定一段连续相同节点的边界”,以及“边界两侧的节点如何正确连接”。每次做这类变形,都是对原始题的一次加深理解。
6.5 刷题建议:把这些测试用例写进你的本地方案
我强烈建议你在本地写代码时,把上面那张边界测试用例表当成回归测试集。每次改完代码,跑一遍这组输入。尤其是 [1,1] 和 [1,1,1,1] 这种极端情况,能帮你快速暴露空指针和指针移动逻辑的问题。
我自己刷链表题的流程是这样的:先画图模拟整个过程,再写代码,然后用测试用例跑,最后在代码里加注释记录“为什么这么写”。这个过程看着慢,但练过几次后,链表题的通过率会明显提高。
另外提一个写代码的小技巧:在循环体里临时打印 pre->val 和 cur->val,观察每一步的指针状态。这个方法在本地调试时非常直观,比人脑硬模拟快得多。等你确认逻辑正确后再删掉打印语句提交即可。
最后再说一个我在高频面试中总结出来的规律:链表的题目,80%的难点都在于“指针边界处理”,20%才是算法思路。算法思路半小时就能懂,但指针边界的处理需要反复手写训练。82题就是一个非常好的训练样本,它把单链表常见的边界问题几乎都包含了:头节点删除、尾部删除、连续删除、全部删除。把这题吃透,你的链表基本功会上一个台阶。
