力扣解题-82. 删除排序链表中的重复元素 II
给定一个已排序的链表的头 head , 删除原始链表中所有重复数字的节点,只留下不同的数字 。返回 已排序的链表 。
示例 1:
输入:head = [1,2,3,3,4,4,5]
输出:[1,2,5]
示例 2:
输入:head = [1,1,1,2,3]
输出:[2,3]
提示:
链表中节点数目在范围 [0, 300] 内
-100 <= Node.val <= 100
题目数据保证链表已经按升序 排列
Related Topics
链表、双指针
第一次解答
解题思路
核心方法:前驱指针+跳过重复段法,通过哑节点(dummy)处理头节点重复的边界问题,利用前驱指针pre定位非重复节点,当发现重复值时,跳过所有连续重复的节点,仅保留无重复的节点,时间复杂度O(n)、空间复杂度O(1),是本题的经典最优解法。
核心逻辑拆解
删除排序链表中所有重复节点的核心是“识别连续重复段并整体跳过”(而非仅删除单个重复节点):
- 边界处理:若原链表为空(
head == null),直接返回null; - 哑节点初始化:创建
dummy哑节点(dummy.next = head),pre指针初始指向dummy(作为当前非重复段的尾节点); - 遍历检测重复:
- 循环条件:
pre.next != null && pre.next.next != null(确保有至少两个节点可比较); - 检测重复:若
pre.next.val == pre.next.next.val(发现重复值):- 记录重复值
val = pre.next.val; - 定义
temp指针从pre.next开始,向后遍历跳过所有值为val的节点; - 将
pre.next指向temp(跳过整个重复段,直接连接到第一个非重复节点);
- 记录重复值
- 无重复:
pre指针后移一位(pre = pre.next),继续检测下一段;
- 循环条件:
- 返回结果:返回
dummy.next(跳过哑节点,得到仅含唯一值的链表)。
具体步骤(以示例1 head=[1,2,3,3,4,4,5]为例)
| 步骤 | 操作 | pre指向 | pre.next指向 | 链表状态 | 说明 |
|---|---|---|---|---|---|
| 1 | 初始化dummy→1→2→3→3→4→4→5 | dummy | 1 | - | 哑节点避免头节点边界问题 |
| 2 | 检测1≠2 | dummy | 1 | - | 无重复,pre后移 |
| 3 | pre=1,检测2≠3 | 1 | 2 | - | 无重复,pre后移 |
| 4 | pre=2,检测3=3 | 2 | 3 | - | 发现重复,记录val=3 |
| 5 | temp跳过所有3,指向4 | 2 | 3 | - | 跳过重复段3→3 |
| 6 | pre.next=4 | 2 | 4 | dummy→1→2→4→4→5 | 连接非重复节点 |
| 7 | 检测4=4 | 2 | 4 | - | 发现重复,记录val=4 |
| 8 | temp跳过所有4,指向5 | 2 | 4 | - | 跳过重复段4→4 |
| 9 | pre.next=5 | 2 | 5 | dummy→1→2→5 | 连接非重复节点 |
| 10 | 循环终止(pre.next.next=null) | 2 | 5 | - | 遍历完成 |
| 最终链表:1→2→5,与示例结果一致。 |
性能说明
- 时间复杂度:O(n)(每个节点仅被访问一次,跳过重复段时的遍历是线性的,无嵌套);
- 空间复杂度:O(1)(仅使用几个指针变量,无额外存储);
- 优势:
- 一次遍历完成所有操作,跳过重复段而非逐个删除,效率高;
- 哑节点完美解决“头节点全重复”的边界问题(如示例2中1→1→1→2→3);
- 逻辑清晰,通过“定位重复值→跳过重复段”的思路,精准删除所有重复节点。
public ListNode deleteDuplicates(ListNode head) {
if (head == null) return null;
ListNode dummy=new ListNode(0);
dummy.next=head;
ListNode pre=dummy;
while(pre.next!=null&&pre.next.next!=null){
if(pre.next.val==pre.next.next.val){
int val=pre.next.val;
ListNode temp=pre.next;
while(temp!=null&&temp.val==val){
temp=temp.next;
}
pre.next=temp;
}else {
pre=pre.next;
}
}
return dummy.next;
}
示例解答
解题思路
解法1:双指针优化版(更直观的指针命名)
核心方法:哨兵节点+快慢指针,将pre命名为sentinel(哨兵),curr命名为current(当前检测节点),逻辑与原解法一致,但指针命名更贴合语义,代码可读性更高,时间/空间复杂度仍为O(n)/O(1)。
代码实现
public ListNode deleteDuplicates(ListNode head) {
if (head == null) {
return null;
}
// 哨兵节点(哑节点)
ListNode sentinel = new ListNode(0);
sentinel.next = head;
// 慢指针:指向当前无重复的最后一个节点
ListNode slow = sentinel;
while (slow.next != null && slow.next.next != null) {
// 快指针:检测重复的起始节点
ListNode fast = slow.next;
// 发现重复值
if (fast.val == fast.next.val) {
int duplicateVal = fast.val;
// 跳过所有重复节点
while (fast != null && fast.val == duplicateVal) {
fast = fast.next;
}
// 连接到第一个非重复节点
slow.next = fast;
} else {
// 无重复,慢指针后移
slow = slow.next;
}
}
return sentinel.next;
}
优势说明
- 指针命名更语义化:
slow(慢指针)对应原解法的pre,fast(快指针)对应原解法的temp,新手更容易理解“慢指针守边界,快指针探重复”的逻辑; - 代码结构更清晰:将
temp的初始化和遍历整合到fast指针中,减少临时变量的定义; - 性能与原解法完全一致,仅代码风格优化。
解法2:递归法(思路拓展)
核心方法:递归缩小问题规模,递归判断当前节点是否与下一个节点重复:
- 若重复:跳过所有重复节点,递归处理剩余链表;
- 若无重复:当前节点的next指向递归处理后的链表,返回当前节点; 逻辑优雅但递归深度最多为n(无栈溢出风险,n≤300)。
代码实现
public ListNode deleteDuplicates(ListNode head) {
// 递归终止:空链表或单节点链表
if (head == null || head.next == null) {
return head;
}
// 检测当前节点是否重复
if (head.val == head.next.val) {
// 跳过所有重复节点
ListNode curr = head;
while (curr != null && curr.val == head.val) {
curr = curr.next;
}
// 递归处理剩余链表
return deleteDuplicates(curr);
} else {
// 无重复,递归处理下一个节点
head.next = deleteDuplicates(head.next);
return head;
}
}
适用场景说明
- 时间复杂度:O(n)(每个节点仅被访问一次);
- 空间复杂度:O(n)(递归调用栈深度,最坏情况链表无重复,递归深度=n);
- 优势:代码极简,符合分治思想,无需维护复杂的指针关系;
- 局限性:递归栈有额外空间开销,执行效率略低于迭代法,且无法处理“头节点重复”的边界问题(需依赖递归终止条件自然跳过)。
总结
- 前驱指针跳过重复段法(第一次解答):O(n)时间+O(1)空间,逻辑清晰、边界处理完善,是工程首选的经典迭代解法;
- 双指针优化版:指针命名更语义化,代码可读性更高,性能与原解法一致;
- 递归法:思路优雅、代码简洁,但有栈空间开销,适合理解递归处理链表的逻辑;
- 关键技巧:
- 核心思想:排序链表的重复节点是连续的,只需“识别重复值→跳过整个重复段”,而非逐个删除;
- 边界处理:哑节点是解决“头节点全重复”的关键,所有迭代解法均需优先使用;
- 效率优化:跳过重复段的遍历是线性的,避免嵌套循环,保证O(n)的时间复杂度。