每日LeetCode : 删除排序链表中的重复元素

143 阅读4分钟

删除链表中的重复元素,在算法中算是一道非常经典的题了,它的解法千千万,今天,本文将带你深入剖析 LeetCode 经典题目「删除排序链表中的重复元素」,并逐步优化解法。

问题描述

给定一个已排序的链表的头 head , 删除所有重复的元素,使每个元素只出现一次,返回 已排序的链表

 示例 1:

输入: head = [1,1,2]
输出: [1,2]

示例 2:

输入: head = [1,1,2,3,3]
输出: [1,2,3]

解法 1:直接迭代法

该解法的核心思路是:利用链表已排序的特性,通过双指针遍历链表,在遇到重复元素时,删除其后续的重复节点。

function deleteDuplicates(head) {
    let cur = head;
    while (cur !== null && cur.next !== null) {
        if (cur.val === cur.next.val) {
            cur.next = cur.next.next; 
        } else {
            cur = cur.next; 
        }
    }
    return head;
}

算法步骤

  1. 初始化指针:初始化一个cur 指针,并让它从链表头开始进行遍历
  2. 检查重复:通过比较 cur.valcur.next.val,若相等,则跳过重复节点(即修改 cur.next
  3. 移动指针:若不相等,则使cur 向后移动,指向下一个元素
  4. 循环终止:最终,当 curcur.next 为 null 时,循环结束

时间复杂度:O(n),仅遍历链表一次
空间复杂度:O(1),无需额外空间

示例过程演示:

初始链表:1 -> 1 -> 2 -> 3 -> 3
步骤1:cur=1, cur.next=1 → 相等 → 删除 → 1 -> 2 -> 3 -> 3
步骤2:cur=1, cur.next=2 → 不等 → 移动cur
步骤3:cur=2, cur.next=3 → 不等 → 移动cur
步骤4:cur=3, cur.next=3 → 相等 → 删除 → 1 -> 2 -> 3

注意点

  1. 指针移动错误:在删除重复节点后,cur 不能立即后移(因为新 cur.next 可能还是重复的)
  2. 空指针异常:循环条件必须同时检查 curcur.next 是否为空
  3. 头节点处理:当链表为空时直接返回 null
  4. 尾节点处理:当 cur 指向最后一个节点时自动终止循环

解法 2:递归

该解法的核心思路是:利用递归隐式栈处理链表,先处理后续节点,再判断当前节点是否重复。

function deleteDuplicates(head) {
    if (head === null || head.next === null) return head;
    
    head.next = deleteDuplicates(head.next); 
    
    if (head.val === head.next.val) {
        return head.next; // 跳过当前重复节点
    }
    return head;
}

算法步骤

  1. 基准情况:在开始时,判断链表为空或单节点的情况,则直接返回
  2. 递归处理:先处理 head.next 开始的子链表
  3. 判断重复:比较当前节点与子链表头节点的值
  4. 跳过重复:若相等则丢弃当前节点(返回子链表头)

时间复杂度:O(n)
空间复杂度:O(n),递归调用栈空间

示例过程演示:

初始链表:1 -> 1 -> 2 -> 3 -> 3

步骤1:处理尾部 [3,3] --> 比较 3 == 3  --> 返回 [3]
步骤2:处理 [2,3]     --> 比较 23   --> 返回 [2->3]
步骤3:处理 [1,2]     --> 比较 12   --> 返回 [1->2->3]
步骤4:处理 [1,1]     --> 比较 1 == 1  --> 返回 [1->2->3](头节点更新)

最终:1 -> 2 -> 3

注意点

  1. 递归终止条件缺失:不要忘记处理空链表或单节点情况
  2. 指针操作错误:递归返回后未正确连接链表(head.next = ...
  3. 重复判断逻辑:必须在递归返回后比较当前节点与后续节点
  4. 内存消耗:链表过长时可能导致栈溢出

解法 3:优化的迭代法(快慢指针)

该解法的核心思路是:使用快慢指针明确区分当前比较节点和遍历节点。

function deleteDuplicates(head) {
    if (head === null) return null;
    
    let slow = head;
    let fast = head.next;
    
    while (fast !== null) {
        if (slow.val !== fast.val) {
            slow.next = fast; // 连接非重复节点
            slow = slow.next; // 移动慢指针
        }
        fast = fast.next; // 总是移动快指针
    }
    slow.next = null; // 截断尾部
    return head;
}

算法步骤

  1. 初始化指针:初始化slow指针指向头节点,fast 指向第二个节点
  2. 快指针扫描:通过fast指针遍历整个链表
  3. 值比较:当 slow.val != fast.val 时:
    • 连接非重复节点(slow.next = fast
    • 移动 slow 到新位置
  4. 截断尾部:在循环结束后断开 slow 后的所有节点

优势:逻辑更清晰,避免在删除节点时混淆指针关系

注意点

  1. 指针移动时机

    // 错误示例:删除后立即移动指针
    while (cur && cur.next) {
        if (cur.val === cur.next.val) {
            cur.next = cur.next.next;
            cur = cur.next; // 可能导致跳过后续重复节点!
        } else {
            cur = cur.next;
        }
    }
    
  2. 空指针访问

    // 错误示例:未检查cur.next
    while (cur) {
        if (cur.val === cur.next.val) { // 当cur.next为null时报错
    
  3. 头节点特殊处理

    // 必要检查:空链表或单节点链表
    if (head === null || head.next === null) return head;
    
  4. 递归空间消耗:链表过长时避免使用递归解法

解题核心思想

  1. 利用有序特性:已排序链表中的重复元素必然相邻,只需比较相邻节点
  2. 双指针技巧
    • 标准解法:单指针+原地修改
    • 明确职责:快指针扫描,慢指针标记非重复尾端
  3. 链表操作原则
    • 删除节点:修改前驱节点的 next 指针
    • 边界处理:头节点和尾节点的特殊情况
  4. 递归思想:将问题分解为头节点+子链表问题

总结

  • 推荐解法:直接迭代法(解法1),时间复杂度 O(n),空间复杂度 O(1)
  • 代码简洁:递归解法(解法2),但需注意栈空间开销
  • 工程实践:快慢指针(解法3)更易维护,适合复杂链表操作