LeetCode 中等难度题目「138. 随机链表的复制」,这道题是链表操作里的经典题型,核心难点在于「随机指针」的复制------常规链表复制只需按顺序处理 next 指针,但随机指针可指向任意节点(包括自身、null 或链表中其他节点),很容易出现"复制节点的随机指针指向原链表节点"的错误,或是陷入重复创建、指针混乱的坑。
先明确题目要求,避免踩坑:
-
深拷贝:复制出 n 个全新节点,不能复用原链表的任何节点
-
指针对应:新节点的 next、random 指针,必须指向复制链表中的节点(而非原链表)
-
状态一致:原链表中 X.random → Y,复制链表中对应 x.random → y
-
输入输出:仅传入原链表头节点,返回复制链表头节点(题目中 [val, random_index] 是输入输出的便捷表示,代码中无需处理索引,直接操作节点)
先贴出题目给出的节点定义(TypeScript 版本),后续两种解法均基于此:
typescript
class _Node {
val: number
next: _Node | null
random: _Node | null
constructor(val?: number, next?: _Node, random?: _Node) {
this.val = (val === undefined ? 0 : val)
this.next = (next === undefined ? null : next)
this.random = (random === undefined ? null : random)
}
}
下面分享两种工业界常用的最优解法,分别是「哈希表映射法」和「原地插入拆分法」,各有优劣,大家可根据场景选择。
解法一:哈希表映射法(直观易懂,空间换时间)
核心思路
核心是用「哈希表(Map)」建立「原节点 → 复制节点」的映射关系,解决"复制节点找不到对应随机指针指向"的问题。
两步走:
-
第一次遍历原链表:只创建复制节点,不处理指针,将原节点作为 key、复制节点作为 value 存入 Map,此时复制节点仅初始化了 val 值。
-
第二次遍历原链表:根据 Map 中的映射关系,给复制节点赋值 next 和 random 指针------原节点的 next 对应复制节点的 next,原节点的 random 对应复制节点的 random(均从 Map 中取出)。
举个简单例子:原链表 A → B(A.random → B),第一次遍历存 Map(A: A', B: B');第二次遍历,A'.next = Map.get(A.next) = B',A'.random = Map.get(A.random) = B',完美对应原链表状态。
完整代码
typescript
function copyRandomList_1(head: _Node | null): _Node | null {
// 边界处理:空链表直接返回null
if (!head) {
return null
}
// 建立原节点到复制节点的映射
const map = new Map()
let curr: _Node | null = head
// 第一步:遍历原链表,创建复制节点并存入Map
while (curr) {
map.set(curr, new _Node(curr.val))
curr = curr.next
}
// 第二步:遍历原链表,给复制节点赋值next和random
const dummy = new _Node() // 虚拟头节点,简化边界处理
curr = head
let prev = dummy // 记录复制链表的前驱节点
while (curr) {
const node = map.get(curr) // 取出当前原节点对应的复制节点
prev.next = node; // 前驱节点指向当前复制节点
// 处理random指针:原节点random存在,则复制节点random取Map中对应的值,否则为null
if (curr.random) {
node.random = map.get(curr.random)
}
// 移动指针,继续遍历
prev = node;
curr = curr.next;
}
// 虚拟头节点的next就是复制链表的头节点
return dummy.next;
};
优劣势分析
✅ 优势:思路直观,代码简洁,容易理解和调试,无需操作原链表结构,不易出错。
❌ 劣势:使用了额外的哈希表,空间复杂度 O(n)(n 为链表长度),需要存储所有原节点和复制节点的映射关系。
适用场景:日常开发中,空间复杂度要求不高,优先追求代码可读性和开发效率的场景。
解法二:原地插入拆分法(空间最优,无额外哈希表)
核心思路
如果要求空间复杂度 O(1)(不使用额外哈希表),就需要用「原地插入+拆分」的思路------将复制节点插入到原节点的后面,利用原链表的指针关系,直接找到复制节点的 random 指向,最后再将原链表和复制链表拆分。
三步走(关键在于"插入"和"拆分"的边界处理):
-
原地插入复制节点:遍历原链表,在每个原节点 curr 后面插入一个复制节点 newNode(val 与 curr 一致),此时原链表变为「curr → newNode → curr.next(原next)」,不处理 random 指针。
-
给复制节点赋值 random:再次遍历原链表,当前原节点 curr 的复制节点是 curr.next,而 curr.random 的复制节点是 curr.random.next(因为第一步已在所有原节点后插入了复制节点),直接赋值即可。
-
拆分两个链表:最后遍历原链表,将原节点和复制节点拆分------原节点的 next 指向自身的下下个节点(跳过复制节点),复制节点的 next 指向自身的下下个节点(复制链表的下一个节点),最终得到独立的复制链表。
关键细节:拆分时要注意链表尾部的边界(当 next 为 null 时,复制节点的 next 也需设为 null),避免出现空指针错误。
完整代码
typescript
function copyRandomList_2(head: _Node | null): _Node | null {
// 边界处理:空链表直接返回null
if (!head) {
return null;
}
// 第一步:原地插入复制节点(每个原节点后面插一个复制节点)
let curr: _Node | null = head;
while (curr) {
const newNode = new _Node(curr.val); // 创建复制节点
const nextNode: _Node | null = curr.next; // 保存原节点的next
curr.next = newNode; // 原节点指向复制节点
newNode.next = nextNode; // 复制节点指向原节点的原next
curr = nextNode; // 移动到下一个原节点
}
// 第二步:给复制节点赋值random指针
curr = head;
while (curr) {
const newNode: _Node | null = curr.next; // 当前原节点对应的复制节点
if (newNode) {
// 原节点random存在 → 复制节点random = 原random的复制节点(原random.next)
if (curr.random) {
newNode.random = curr.random.next;
} else {
newNode.random = null; // 原random为null,复制节点也为null
}
curr = newNode.next; // 移动到下一个原节点(跳过复制节点)
}
}
// 第三步:拆分原链表和复制链表
curr = head;
const copyHead = head.next; // 复制链表的头节点(原头节点的下一个)
while (curr) {
const newNode: _Node | null = curr.next; // 当前原节点对应的复制节点
if (newNode) {
const nextOldNode: _Node | null = newNode.next; // 保存下一个原节点
curr.next = nextOldNode; // 原节点指向自身的下下个节点(拆分原链表)
// 复制节点指向自身的下下个节点(拆分复制链表)
if (nextOldNode) {
newNode.next = nextOldNode.next;
} else {
newNode.next = null; // 链表尾部,复制节点next设为null
}
curr = nextOldNode; // 移动到下一个原节点
}
}
return copyHead; // 返回复制链表的头节点
};
优劣势分析
✅ 优势:空间复杂度 O(1)(仅使用几个指针变量),无额外哈希表,空间最优。
❌ 劣势:思路相对复杂,需要三次遍历,且涉及原链表结构的修改,边界处理容易出错(尤其是拆分环节)。
适用场景:面试中要求优化空间复杂度,或内存资源紧张的场景(如嵌入式开发)。
两种解法对比总结
| 解法 | 时间复杂度 | 空间复杂度 | 核心优势 | 适用场景 |
|---|---|---|---|---|
| 哈希表映射法 | O(n)(两次遍历) | O(n)(哈希表存储映射) | 直观易懂,调试方便 | 日常开发,优先可读性 |
| 原地插入拆分法 | O(n)(三次遍历) | O(1)(无额外空间) | 空间最优 | 面试优化,内存紧张场景 |
常见踩坑点提醒
-
踩坑1:复制节点的 random 指针直接指向原链表节点 → 违反深拷贝要求,需通过映射或原地插入找到复制节点。
-
踩坑2:边界处理遗漏 → 空链表、链表尾部(next 为 null)、random 为 null 的情况,需单独判断。
-
踩坑3:原地拆分时,原链表的 next 指针未恢复 → 虽然题目不要求恢复原链表,但会导致原链表结构混乱(严谨开发中需注意)。
-
踩坑4:创建复制节点时,未初始化 next 和 random 为 null → 虽然构造函数有默认值,但建议明确赋值,避免隐式错误。
最后总结
「随机链表的复制」的核心是解决「随机指针的定位」问题,两种解法分别从"空间换时间"和"时间换空间"两个角度给出了最优解:
-
新手入门优先掌握「哈希表映射法」,代码简洁、思路清晰,能快速解决问题,应对日常开发场景;
-
面试进阶需掌握「原地插入拆分法」,理解其指针操作逻辑和边界处理,体现对链表操作的深度掌握。