LeetCode 82. 删除排序链表中的重复元素 II:代码解析+优化指南

0 阅读8分钟

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;
}

优化理由

  1. 去除单独的next指针,直接用curr.next判断重复,减少指针数量,逻辑更直观;

  2. 用duplicateVal记录重复值,避免在循环中多次获取curr.val(虽然性能提升微小,但可读性和规范性更好);

  3. 简化重复区间的跳过逻辑,直接移动curr到非重复节点,再让prev.next指向curr,代码更简洁。

优化点3:处理极端场景,提升代码鲁棒性(面试加分项)

问题分析

原代码已处理“链表为空”的场景,但还有2个极端场景未明确优化(虽然原代码也能覆盖,但优化后更清晰,面试时能体现考虑周全):

  1. 链表所有节点都重复(如:1→1→1),此时应返回null;

  2. 链表只有两个重复节点(如: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记录重复值),避免指针混乱;

  • 优化思路:优先保证代码健壮性(去除非空断言),再简化逻辑(合并指针),最后考虑极端场景(面试加分)。

这道题本质是“链表指针操作”的进阶应用,掌握虚拟头节点的用法、指针的分工,再理解优化点的逻辑,就能轻松解决了。