删除链表中的重复元素,在算法中算是一道非常经典的题了,它的解法千千万,今天,本文将带你深入剖析 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;
}
算法步骤:
- 初始化指针:初始化一个
cur指针,并让它从链表头开始进行遍历 - 检查重复:通过比较
cur.val和cur.next.val,若相等,则跳过重复节点(即修改cur.next) - 移动指针:若不相等,则使
cur向后移动,指向下一个元素 - 循环终止:最终,当
cur或cur.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
注意点:
- 指针移动错误:在删除重复节点后,
cur不能立即后移(因为新cur.next可能还是重复的) - 空指针异常:循环条件必须同时检查
cur和cur.next是否为空 - 头节点处理:当链表为空时直接返回 null
- 尾节点处理:当
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;
}
算法步骤:
- 基准情况:在开始时,判断链表为空或单节点的情况,则直接返回
- 递归处理:先处理
head.next开始的子链表 - 判断重复:比较当前节点与子链表头节点的值
- 跳过重复:若相等则丢弃当前节点(返回子链表头)
时间复杂度:O(n)
空间复杂度:O(n),递归调用栈空间
示例过程演示:
初始链表:1 -> 1 -> 2 -> 3 -> 3
步骤1:处理尾部 [3,3] --> 比较 3 == 3 --> 返回 [3]
步骤2:处理 [2,3] --> 比较 2 ≠ 3 --> 返回 [2->3]
步骤3:处理 [1,2] --> 比较 1 ≠ 2 --> 返回 [1->2->3]
步骤4:处理 [1,1] --> 比较 1 == 1 --> 返回 [1->2->3](头节点更新)
最终:1 -> 2 -> 3
注意点:
- 递归终止条件缺失:不要忘记处理空链表或单节点情况
- 指针操作错误:递归返回后未正确连接链表(
head.next = ...) - 重复判断逻辑:必须在递归返回后比较当前节点与后续节点
- 内存消耗:链表过长时可能导致栈溢出
解法 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;
}
算法步骤:
- 初始化指针:初始化
slow指针指向头节点,fast指向第二个节点 - 快指针扫描:通过
fast指针遍历整个链表 - 值比较:当
slow.val != fast.val时:- 连接非重复节点(
slow.next = fast) - 移动
slow到新位置
- 连接非重复节点(
- 截断尾部:在循环结束后断开
slow后的所有节点
优势:逻辑更清晰,避免在删除节点时混淆指针关系
注意点:
-
指针移动时机:
// 错误示例:删除后立即移动指针 while (cur && cur.next) { if (cur.val === cur.next.val) { cur.next = cur.next.next; cur = cur.next; // 可能导致跳过后续重复节点! } else { cur = cur.next; } } -
空指针访问:
// 错误示例:未检查cur.next while (cur) { if (cur.val === cur.next.val) { // 当cur.next为null时报错 -
头节点特殊处理:
// 必要检查:空链表或单节点链表 if (head === null || head.next === null) return head; -
递归空间消耗:链表过长时避免使用递归解法
解题核心思想
- 利用有序特性:已排序链表中的重复元素必然相邻,只需比较相邻节点
- 双指针技巧:
- 标准解法:单指针+原地修改
- 明确职责:快指针扫描,慢指针标记非重复尾端
- 链表操作原则:
- 删除节点:修改前驱节点的
next指针 - 边界处理:头节点和尾节点的特殊情况
- 删除节点:修改前驱节点的
- 递归思想:将问题分解为头节点+子链表问题
总结
- 推荐解法:直接迭代法(解法1),时间复杂度 O(n),空间复杂度 O(1)
- 代码简洁:递归解法(解法2),但需注意栈空间开销
- 工程实践:快慢指针(解法3)更易维护,适合复杂链表操作