LeetCode 82题——删除排序链表中的重复元素 II,这道题比同系列的83题(保留重复元素的第一个)稍难一点,核心难点在于“只要出现重复,就全部删除”,而非保留单个副本。下面结合题目要求、给出的代码,逐行拆解逻辑,同时重点分析可优化点。
一、题目解读(清晰版)
给定一个已排序的链表的头 head ,删除原始链表中所有重复数字的节点,只留下不同的数字。返回排序后的链表。
核心前提
链表是已排序的!这是解题的核心突破口——排序链表中,重复元素一定是连续出现的,无需额外判断非连续的重复情况,极大简化了遍历和判断逻辑。
示例辅助理解
-
输入:1 → 2 → 2 → 3 → 4 → 4 → 5
-
输出:1 → 3 → 5
-
解释:2和4均出现重复,需删除所有值为2、所有值为4的节点,仅保留唯一出现的1、3、5;且返回的链表仍需保持排序(因原链表有序,删除后无需额外排序)。
链表节点定义(题目给出)
/**
* Definition for singly-linked list.
* class ListNode {
* val: number
* next: ListNode | null
* constructor(val?: number, next?: ListNode | null) {
* this.val = (val===undefined ? 0 : val)
* this.next = (next===undefined ? null : next)
* }
* }
*/
二、给定代码逐行解析(易懂不跳步)
先贴出完整代码,再逐行拆解每一步的作用,搞懂“为什么要这么写”,避免死记硬背:
class ListNode {
val: number
next: ListNode | null
constructor(val?: number, next?: ListNode | null) {
this.val = (val === undefined ? 0 : val)
this.next = (next === undefined ? null : next)
}
}
function deleteDuplicates(head: ListNode | null): ListNode | null {
// 边界处理:如果链表为空,直接返回null
if (!head) {
return null
}
// 技巧1:创建虚拟头节点,避免处理头节点本身重复的特殊情况
let dummy = new ListNode(0, head);
// 前驱指针:指向“当前无重复的最后一个节点”,用于跳过重复区间
let prev: ListNode | null = dummy;
// 当前指针:用于遍历链表,判断当前节点是否与下一个节点重复
let curr: ListNode | null = head;
// 遍历条件:当前节点和当前节点的下一个节点都不为空(才有判断重复的意义)
while (curr && curr.next) {
// 临时指针:指向当前节点的下一个节点,用于定位重复区间的末尾
let next: ListNode | null = curr.next;
// 核心判断:如果当前节点和下一个节点值相等,说明进入重复区间
if (curr.val === next.val) {
// 循环移动next指针,直到找到“与curr.val不相等”的节点(定位重复区间的末尾)
while (next && curr.val === next.val) {
next = next.next;
}
// 关键操作:跳过整个重复区间,让prev的next直接指向next(相当于删除重复区间)
prev!.next = next;
// 更新curr指针,从重复区间的下一个节点开始,继续遍历
curr = next;
} else {
// 无重复:prev和curr同步后移,保持prev始终指向“无重复的最后一个节点”
prev = curr;
curr = curr.next;
}
}
// 最终返回虚拟头节点的next,即处理后的链表头
return dummy.next;
};
代码核心逻辑总结
用「虚拟头节点」规避头节点重复的特殊情况 → 用「prev(前驱指针)+ curr(当前指针)+ next(临时指针)」三个指针配合 → 遍历链表时,一旦发现重复区间,就用prev跳过整个重复区间 → 最终返回处理后的链表头(dummy.next)。
整个逻辑的核心是「定位重复区间、整体跳过」,得益于链表有序的前提,无需回头遍历,时间复杂度初步为 O(n)(n为链表长度)。
三、重点:代码可优化点(实用干货)
给定的代码已经能正确解题,但在可读性、健壮性、效率上还有3个可优化的点,优化后代码更简洁、更不易出错,适合实际面试或项目中使用,逐一拆解:
优化点1:去除prev指针的非空断言(!),提升代码健壮性
问题分析
原代码中 prev!.next = next; 使用了非空断言(!),强制告诉TS“prev一定不为null”。虽然逻辑上prev确实不会为null(dummy节点初始化后,prev从dummy开始,且只有curr移动时prev才移动,始终指向有效节点),但非空断言会降低代码健壮性(若后续逻辑修改失误,可能导致prev为null,断言会掩盖错误)。
优化方案
调整prev的初始类型和遍历条件,确保prev始终不为null,无需使用非空断言:
// 优化前:prev可能为null,需用!断言
let prev: ListNode | null = dummy;
prev!.next = next;
// 优化后:prev始终为ListNode类型,无需断言
let prev: ListNode = dummy; // 直接定义为非null
prev.next = next; // 无需加!
优化理由
dummy是新创建的ListNode实例,一定不为null;后续prev的更新的逻辑(prev = curr)中,curr只有在非null时才会赋值给prev(遍历条件是curr && curr.next,else分支中curr非null,才会执行prev = curr),因此prev始终是非null的,可直接定义为ListNode类型,去除断言。
优化点2:合并指针定义,简化代码(提升可读性)
问题分析
原代码中,next指针在while循环内部定义,每次循环都会重新创建,虽然开销不大,但可简化;同时,curr和prev的初始化可结合逻辑,让代码更简洁。
优化方案
// 优化后核心遍历逻辑(简化指针定义)
function deleteDuplicates(head: ListNode | null): ListNode | null {
if (!head) return null;
const dummy = new ListNode(0, head);
let prev: ListNode = dummy;
let curr: ListNode | null = head;
while (curr && curr.next) {
// 无需单独定义next,直接用curr.next判断,重复时再移动
if (curr.val === curr.next.val) {
const duplicateVal = curr.val; // 记录重复值,避免多次获取curr.val
// 循环跳过所有等于duplicateVal的节点(简化next指针操作)
while (curr && curr.val === duplicateVal) {
curr = curr.next;
}
prev.next = curr; // 跳过重复区间
} else {
prev = curr;
curr = curr.next;
}
}
return dummy.next;
}
优化理由
-
去除单独的next指针,直接用curr.next判断重复,减少指针数量,逻辑更直观;
-
用duplicateVal记录重复值,避免在循环中多次获取curr.val(虽然性能提升微小,但可读性和规范性更好);
-
简化重复区间的跳过逻辑,直接移动curr到非重复节点,再让prev.next指向curr,代码更简洁。
优化点3:处理极端场景,提升代码鲁棒性(面试加分项)
问题分析
原代码已处理“链表为空”的场景,但还有2个极端场景未明确优化(虽然原代码也能覆盖,但优化后更清晰,面试时能体现考虑周全):
-
链表所有节点都重复(如:1→1→1),此时应返回null;
-
链表只有两个重复节点(如:1→1),此时也应返回null。
优化方案
无需额外加判断,通过上述“优化点2”的逻辑即可完美覆盖——当所有节点重复时,curr会遍历到null,prev.next = null,最终返回dummy.next = null,符合预期;但可在代码注释中明确说明,或补充测试用例提示,提升可读性。
同时,可将ListNode的定义与函数分离(若题目已给出,可省略重复定义),避免代码冗余(原代码中重复定义了ListNode,实际刷题时无需重复书写)。
四、优化后完整代码(最终版)
整合以上3个优化点,最终代码更简洁、健壮、易读,适合面试书写:
// 题目已给出ListNode定义,实际刷题时无需重复书写
class ListNode {
val: number
next: ListNode | null
constructor(val?: number, next?: ListNode | null) {
this.val = (val === undefined ? 0 : val)
this.next = (next === undefined ? null : next)
}
}
function deleteDuplicates(head: ListNode | null): ListNode | null {
// 边界处理:链表为空,直接返回null
if (!head) return null;
// 虚拟头节点:规避头节点重复的特殊情况
const dummy = new ListNode(0, head);
let prev: ListNode = dummy; // 前驱指针,始终非null
let curr: ListNode | null = head; // 当前遍历指针
while (curr && curr.next) {
// 判断当前节点与下一个节点是否重复
if (curr.val === curr.next.val) {
const duplicateVal = curr.val;
// 跳过所有重复节点,定位到第一个非重复节点
while (curr && curr.val === duplicateVal) {
curr = curr.next;
}
prev.next = curr; // 跳过重复区间
} else {
// 无重复,指针同步后移
prev = curr;
curr = curr.next;
}
}
return dummy.next;
}
五、刷题总结(易错点+关键点)
-
易错点1:忘记处理“头节点重复”的情况(如输入1→1→2),此时必须用虚拟头节点,否则无法正确返回新的头节点;
-
易错点2:只删除重复元素的单个副本(而非全部删除),核心是“找到重复区间的末尾,整体跳过”,而非只跳过一个节点;
-
关键点1:利用“链表有序”的前提,重复元素连续,无需回头遍历,保证时间效率;
-
关键点2:指针分工明确(prev负责定位非重复节点、curr负责遍历判断、duplicateVal记录重复值),避免指针混乱;
-
优化思路:优先保证代码健壮性(去除非空断言),再简化逻辑(合并指针),最后考虑极端场景(面试加分)。
这道题本质是“链表指针操作”的进阶应用,掌握虚拟头节点的用法、指针的分工,再理解优化点的逻辑,就能轻松解决了。