CS-Notes 剑指 Offer:删除链表中重复的结点——递归与迭代两种完整解法
本篇基于 CS-Notes 仓库中《18.2 删除链表中重复的结点》一文展开,完整继承原文档的题目描述与递归解法,并结合仓库中链表题解(Leetcode 题解 - 链表、18.1 在 O(1) 时间内删除链表节点%20时间内删除链表节点.md))中的相关技法进行纵深扩充。读完后你能掌握:该题与"去重保留一份"题型的本质区别、原文档递归解法的逐步推演过程、等价的迭代(哨兵节点)实现、以及时间/空间复杂度与边界条件的完整分析。
题目描述
原始文档给出的题目是:
在一个排序的链表中,存在重复的结点。请删除该链表中的重复结点,重复的结点不保留(即全部删除)。
示例如下:
注意题目前提是链表已排序,因此所有重复值必然相邻出现,这保证了只需局部比较相邻结点即可判断重复,不需要全局统计频次。
这里有一个极易混淆的关键点:本题要求把重复值全部删除(2->2 整组消失、3->3 整组消失),而不是只保留一份。仓库中 Leetcode 题解 - 链表 收录的"4. 从有序链表中删除重复节点"(Leetcode 83 Remove Duplicates from Sorted List)是另一类题型——它保留一份:
// 仅保留一份:1->1->2 -> 1->2
public ListNode deleteDuplicates(ListNode head) {
if (head == null || head.next == null) return head;
head.next = deleteDuplicates(head.next);
return head.val == head.next.val ? head.next : head;
}
两者的判断逻辑几乎一致,唯一区别在于发现重复时"跳过整组"还是"跳过重复部分"。面试中先确认清楚这一语义,再动手写代码。
原文档解法:递归
原始文档给出的完整代码如下:
public ListNode deleteDuplication(ListNode pHead) {
if (pHead == null || pHead.next == null)
return pHead;
ListNode next = pHead.next;
if (pHead.val == next.val) {
while (next != null && pHead.val == next.val)
next = next.next;
return deleteDuplication(next);
} else {
pHead.next = deleteDuplication(pHead.next);
return pHead;
}
}
逐行拆解其执行逻辑:
- 递归出口:
pHead == null(已经删到链表外)或pHead.next == null(只剩一个结点,不可能重复),直接返回当前头结点。 - 头结点参与重复:先取
next = pHead.next,若pHead.val == next.val,说明头结点所在的重复组至少有两个。此时用 while 循环让next一路向右跳过整组同值结点,然后直接递归处理next开始的剩余链表,并让递归结果成为新的头结点——这一步天然完成了"整组摘除"。 - 头结点不重复:
pHead必须保留,于是对pHead.next递归,把去重后的子链表接回pHead.next,返回pHead。
以示例 1->2->2->3->3->4 推演调用链:
deleteDuplication(1):1 不重复,递归处理2->2->3->3->4;deleteDuplication(2):2 重复,while 跳到3->3->4,递归处理;deleteDuplication(3):3 重复,while 跳到4,递归处理;deleteDuplication(4):next == null,命中出口返回 4;- 逐层回卷后结果为
1->4,与上图一致。
由于"是否重复"只依赖相邻两结点,且每个结点最多被访问常数次,时间复杂度为 O(n);递归调用深度最大为 n,空间复杂度为 O(n)(递归栈开销)。
等价解法:迭代 + 哨兵节点
原文档的递归版本在"头结点也可能被整组删除"的场景下处理得很干净,但面试手写时迭代写法通常更不易出错。核心技巧是引入一个哨兵节点(dummy),让"头结点被删除"也统一为"中间结点被删除"的问题:
public ListNode deleteDuplication(ListNode pHead) {
// 哨兵节点,把 head 也变成普通后继
ListNode dummy = new ListNode(0);
dummy.next = pHead;
ListNode cur = dummy;
while (cur.next != null && cur.next.next != null) {
if (cur.next.val == cur.next.next.val) {
int val = cur.next.val;
// 跳过所有值为 val 的结点
while (cur.next != null && cur.next.val == val) {
cur.next = cur.next.next;
}
} else {
cur = cur.next;
}
}
return dummy.next;
}
要点:
cur始终停在"待判断结点的前驱"上,重复时不移动cur(因为cur.next已经被整组摘除,需要重新判断新的后继),不重复时才cur = cur.next前进;- 返回值是
dummy.next而非dummy,dummy 只用于统一处理边界; - 复杂度:时间 O(n)、空间 O(1)。
两种解法可对照记忆:递归版的"跳过整组"对应迭代版的内层 while,递归版的"返回新头"对应迭代版的哨兵机制。
与仓库内相关题型的技法关联
结合仓库中剑指 Offer 链表章节(见剑指 Offer 题解 - 目录),本题的"摘除结点"操作与几道经典题共享底层技法,可以对照复习:
- 18.1 在 O(1) 时间内删除链表节点%20时间内删除链表节点.md):该题利用"把后继结点的值复制过来再删后继"来绕过前驱指针,平均时间复杂度 O(1)。两者对比可以看出:当没有前驱引用时(18.1 只给 tobeDelete 本身),与只有前驱引用时(18.2 从头部顺序扫描),操作策略完全不同。18.2 的迭代解法正是靠
cur持有前驱引用,才能安全地把整组结点的后继指针改写到cur.next上。 -
- 反转链表、25. 合并两个排序的链表:同样反复使用"双指针维护前后关系 + 逐结点改指针"的模式,与本题内层 while 的指针改写是同一种基本功。
-
- 从尾到头打印链表:与原文档递归版同属"用递归栈模拟从尾到头"的思路,可以体会递归处理链表时的通用节奏:先信任"子链表已处理好",再处理当前结点与子链表的拼接。
边界条件与复杂度小结
| 输入情况 | 结果 | 说明 |
|---|---|---|
null |
null |
递归出口第一分支直接处理 |
单结点 1 |
1 |
pHead.next == null,不重复 |
全部重复 2->2 |
null |
整组被 while 跳过,next 为 null,递归返回 null |
无重复 1->2->3 |
原链表 | 每次走 else 分支,等价于原样返回 |
头结点参与重复 1->1->2 |
2 |
递归版返回 deleteDuplication(next),迭代版靠 dummy 兜底 |
复杂度结论:两种解法时间均为 O(n)(每个结点至多被跳过一次),空间上递归版 O(n)(栈深)、迭代版 O(1)。若题目规模可能使递归栈溢出(链表极长),应优先选择迭代 + 哨兵节点版本。
小结
本题(对应 原始文档)的核心考点有三个:
- 准确识别"重复值全部删除"的语义,区分于 Leetcode 83 的"保留一份";
- 掌握递归解法中"跳过整组同值结点后让递归结果作新头"的写法;
- 能独立写出带哨兵节点的迭代解法,并正确区分
cur何时前进、何时原地重查。
仓库中该文档位于剑指 Offer 链表章节,可与 18.1 在 O(1) 时间内删除链表节点%20时间内删除链表节点.md) 以及 Leetcode 题解 - 链表 中的第 4、5 题(有序去重、删除倒数第 n 个节点)一并练习,形成对"链表结点摘除"这一类操作的完整理解。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
