LeetCode Hot100刷到第 58 题,也就是138. 随机链表的复制时,我一度怀疑这道题的难度是不是标错了。复制链表这种题,遇到的次数不算少:遍历原链表,每经过一个节点就 new 一个节点,然后把 next 接上,结束。但这题里的节点除了 next,还有一个 random 指针,它可能指向链表里的任意一个节点,也可能指向 null。正是这个多余指针,让“复制”从手感上的简单题变成了真正容易卡壳的中等题。如果你也被 random 指针绕晕过,这篇复盘应该能给你一条清晰的行动路径:先分析难点在哪,再给出哈希表正解和空间 O(1) 的进阶解,最后把刷题和面试里容易踩的坑一并排掉。
1. 直觉失效的一题:多了一个 random,深拷贝就不再是遍历
1.1 普通链表复制和深拷贝之间隔着一堵墙
先说概念。LeetCode 138 要求的是“深拷贝”。所谓深拷贝,指的是不能只是让新变量指向同一个链表,比如下面这种写法根本不叫拷贝:
java复制Node copy = head; // 这只是一个新引用,修改 copy 会直接影响原链表
这种是浅拷贝。真正的深拷贝要求 new 出同样数量的 Node,并且所有字段都指向新链表里对应的节点。对于普通单链表,做到这点很轻松:每次 new Node(cur.val),然后 cur 往前走即可。原因是 next 是有序的,你可以先创建当前节点,再创建下一个节点;当前节点的 next 先置为 null 也不影响,后面可以再补。
但随机链表不行,random 不讲顺序。比如一个节点指向链表最后一个节点,当你遍历到第一个节点时,最后一个节点的拷贝还没产生;如果你在那一刻就把 random 字段占了,后面又怎么补救?这个反直觉的点,正是很多人第一次写这道题卡住的原因:单纯按顺序复制,无法处理“随机指向将来时”的节点。
1.2 本质问题:如何找到“被指向节点的对应拷贝”
如果把 138 看成图,每个节点的出度最多为 2,一条边是 next,一条边是 random,它本质上就是一张“出度 ≤ 2”的有向图。复制问题就是在问:如何在新图中保持这种引用结构。它与克隆图的差异只是固定只有两条边、入口是 head。
一旦抽离成这个层面,解法思路也就统一了:我们需要一种方式,给定原链表中的任意节点,能够在 O(1) 时间内找到这个节点在拷贝链表里的对应节点。哈希表,就是实现这种映射最直接的工具。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 哈希表正解:新旧节点的映射关系就是最直接的答案
2.1 核心思路:第一遍只“生”节点,第二遍再“接”指针
这里建议大家养成一个习惯:看到复杂结构复制,先别急着在同一个循环里把所有事干完。哈希表法的通用步骤是:
- 第一遍遍历原链表,用 HashMap 保存原节点到新节点的映射。
- 第二遍再从头遍历原链表,通过 map 找到每个新节点,再按原链表的关系设置新节点的 next 和 random。
写这道题时,最容易犯的错误是“想一遍遍历搞定”:一边 new 新节点,一边设置 random。如果 random 指向的节点已经创建,算运气好;如果指向还没创建的节点,就无从设置。所以必须分成两步走。这个思路可以类比成生活中先建好一堆空白档案,再统一填写档案之间的关联关系,而不是每建一份档案就急着把所有关系线画完。
2.2 最小例子走查
用一个极端简单但覆盖全面的例子来验证:假设原链表有两个节点 A 和 B,其中 A.val = 1,B.val = 2,并且:
- A.random = B
- B.random = A
第一轮后,A 的拷贝 A' 和 B 的拷贝 B' 都创建完成,map 中记录 A -> A',B -> B'。第二轮处理时:
- A'.next = map.get(B) = B'
- A'.random = map.get(B) = B'
- B'.next = map.get(null) = null
- B'.random = map.get(A) = A'
结果 A' 的 next 和 random 都指向 B',B' 的 random 指向 A',和原链表关系完全一致。这个例子同时覆盖了 random 指向后继、指向前驱、以及 null 三种情况,而且代码几乎没有分支,原因就是把“找对应节点”这件事全部交给了 HashMap。
2.3 完整实现与复杂度分析
LeetCode 的节点定义自带 val、next、random 三个字段,Java 代码如下:
java复制class Solution {
public Node copyRandomList(Node head) {
if (head == null) {
return null;
}
Map<Node, Node> map = new HashMap<>();
// 第一轮:创建所有新节点,建立映射
Node cur = head;
while (cur != null) {
map.put(cur, new Node(cur.val));
cur = cur.next;
}
// 第二轮:连接新节点的 next 和 random
cur = head;
while (cur != null) {
map.get(cur).next = map.get(cur.next);
map.get(cur).random = map.get(cur.random);
cur = cur.next;
}
return map.get(head);
}
}
Python 版本同样简洁:
python复制class Solution:
def copyRandomList(self, head: 'Optional[Node]') -> 'Optional[Node]':
if not head:
return None
mapping = {}
cur = head
while cur:
mapping[cur] = Node(cur.val)
cur = cur.next
cur = head
while cur:
mapping[cur].next = mapping.get(cur.next)
mapping[cur].random = mapping.get(cur.random)
cur = cur.next
return mapping[head]
时间复杂度和空间复杂度都是 O(n)。空间来自哈希表;时间来自两轮完整遍历。
2.4 为什么不能换成数组或者用 val 做 key
有些同学会想,既然要建立映射,能不能用数组按下标逐个对应。这个方案不可行,因为链表的节点在内存里不是连续地址,无法像数组那样通过下标直接定位。还有人想用 val 作为 key,这更危险,因为链表中可能出现重复 val。只要两个不同节点的 val 相同,映射就会互相覆盖。
正确的 key 应该是节点本身,也就是内存引用。Java 的 HashMap 使用 hashCode 和 equals 判断 key,而 Node 默认基于引用地址,所以两个 val 相同的节点也能被区分开。这一步理解透了,后面看递归写法和克隆图的题都会顺很多。
3. 面试官一句“能不能 O(1) 空间”:原地复制后拆链表的妙处
3.1 从“外挂哈希表”到“用 next 自带映射”
哈希表解法虽然清晰,但面试官很可能会追加一句:额外空间能不能做到 O(1)?能,思路很巧妙:不需要外部哈希表,而是把新节点插在原节点的后面。这样,给定任意原节点 cur,它的复制节点天然就是 cur.next。映射关系被编码进了链表本身,自然省掉了 HashMap。
这类操作在不少链表题里出现过,属于很典型的“原地标记”技巧。我第一次看到这个方案时,第一反应是“这也行?”,但走查一遍后会发现每一步都很严谨。
3.2 第一步:插入拷贝节点,得到交错链表
假设原链表是 A -> B -> C,第一步的目标是把链表变成 A -> A' -> B -> B' -> C -> C'。
java复制Node cur = head;
while (cur != null) {
Node copy = new Node(cur.val);
copy.next = cur.next;
cur.next = copy;
cur = copy.next;
}
这里的循环跳法很关键:cur = copy.next。copy.next 保存的是原链表的下一个节点,所以这一步实际上是在让 cur 回到原链表的下一个节点,继续处理下一个原始节点。如果你写成 cur = cur.next,就会走到刚创建的 copy 上,再次插入一个拷贝,链表会迅速膨胀成 A -> A' -> A'' -> ...,死循环且逻辑完全崩掉。
为什么 copy 的 random 可以先不设置?因为 copy 的 random 字段在构造时默认就是 null,后续补上即可。第一步只复制 val,不碰 random。
3.3 第二步:给拷贝节点补 random
有了交错链表之后,对于任意原始节点 cur:
- cur 的拷贝节点是 cur.next;
- cur.random 指向某个原始节点 target,那么 target 的拷贝节点是 target.next;
- 所以 cur.next.random = cur.random.next。
代码如下:
java复制cur = head;
while (cur != null) {
if (cur.random != null) {
cur.next.random = cur.random.next;
}
cur = cur.next.next;
}
注意这里必须先判断 cur.random != null,否则直接访问 cur.random.next 会抛空指针。如果 cur.random 为 null,那拷贝节点的 random 保持默认 null 即可。
同时注意循环条件是 cur = cur.next.next,每次跳两个节点。如果也写成 cur = cur.next,会把拷贝节点当成原始节点再处理一次,相当于把刚才的“插入一个拷贝”动作重复执行,链表就乱了。
这一步走查一下:如果 A.random = C,那么当前 cur = A 时,A' 的 random 应该指向 C'。由于 C 的原节点后面已经插入了 C',C.random.next 就是 C',赋值后关系正确。
3.4 第三步:一分为二,同时还原原始链表
交错链表里,原节点和拷贝节点是交替排列的。现在要把它拆成两条独立链表。拆的时候,还需要把原链表的 next 指针还原,否则你的函数就改变了传入链表的结构,这在真实工程里是不可接受的副作用。
一个稳妥的实现是引入 dummy 节点,避免拆第一个节点时做特殊判断:
java复制Node dummy = new Node(0);
Node tail = dummy;
cur = head;
while (cur != null) {
Node copy = cur.next;
tail.next = copy;
tail = copy;
// 还原原始链表的 next
cur.next = copy.next;
cur = cur.next;
}
return dummy.next;
这里仔细看一下循环内部发生了什么。假设当前 cur = A,copy = A':
tail.next = copy,把 A' 挂到结果链表上;tail = copy,结果链表的尾部移动到 A';cur.next = copy.next,此时 copy.next 是原链表的下一个节点 B,所以 A.next 被还原成 B;cur = cur.next,cur 移动到 B。
这样每个原节点都被访问一次,每个拷贝节点也都恰好被串进新链表一次。最后原链表恢复成 A -> B -> C,新链表从 dummy.next 开始是 A' -> B' -> C'。
原地法的时间复杂度同样是 O(n),但只遍历了链表三次,额外空间为 O(1)。这在理论上已经是最优空间了。
4. 递归、环和边界条件:把这些细节补齐才敢说掌握
4.1 一种常见但容易忽略细节的“递归+哈希表”写法
除了两轮迭代,还可以用递归写,核心仍然是哈希表,只不过把创建和连接放在调用栈上完成:
java复制class Solution {
private Map<Node, Node> map = new HashMap<>();
public Node copyRandomList(Node head) {
if (head == null) {
return null;
}
if (map.containsKey(head)) {
return map.get(head);
}
Node copy = new Node(head.val);
map.put(head, copy);
copy.next = copyRandomList(head.next);
copy.random = copyRandomList(head.random);
return copy;
}
}
这个递归版本为什么不会死循环?因为每次进入一个原节点时,先检查哈希表里有没有它的拷贝。如果没有,才创建并放入 map;之后无论通过 next 还是 random 再次访问到这个节点,都会直接返回已有拷贝。
但我个人在面试中更推荐迭代法,原因有两个:第一,递归深度可能达到链表长度,极端情况下大链表会栈溢出;第二,递归同时走 next 和 random 两条分支,如果面试官不是对递归很熟悉,讲解起来要额外解释“记忆化”的作用。迭代法用先建节点、后接指针的两轮遍历,逻辑更直白,也更不容易翻车。
4.2 random 指向自身时,三种解法会不会翻车
这道题的一个常见边界条件是:某个节点的 random 指向它自己。三种解法都做了验证,结论是都不会出问题:
- 哈希表迭代法:第一轮创建节点时先把自身的拷贝放进 map;第二轮设置 random 时,map.get(cur) 返回自身拷贝,正确。
- 递归法:进入节点后先创建并放入 map,再递归处理 random 时发现当前节点已存在,直接返回自身拷贝,终止递归,正确。
- 原地法:如果 cur.random == cur,则执行
cur.next.random = cur.random.next,也就是 A'.random = A.next = A',A' 的 random 指向自己,也正确。
这个边界条件平时不怎么被提到,但面试官偶尔会顺手问一句“如果 random 成环怎么办”。上面三种解法都能处理,恰好说明思路没选错。
4.3 map.get(null) 的隐藏技巧
在 Java 版本里,第二轮可以放心写 map.get(cur.next) 和 map.get(cur.random),因为当 key 为 null 时,HashMap 的 get 会返回 null。新 Node 的 next 和 random 默认就是 null,赋值后依然正确。
但换到 C++ 的 unordered_map 或其他语言时,要注意“字典里没有这个键”和“键对应的值为 null”可能是两种语义。有些语言的字典允许 null 作为 key,有些不允许;如果直接对 null 做查询可能报错。稳妥的写法是显式判断:
java复制if (cur.next != null) {
map.get(cur).next = map.get(cur.next);
}
if (cur.random != null) {
map.get(cur).random = map.get(cur.random);
}
这样写虽然代码变长,但能避开不同语言对 null key 的差异,也更容易向面试官解释。如果你想展示对语言机制的熟悉,可以先写简洁版,再主动说明 null key 的底层行为。
4.4 一个容易被忽略的副标题:是否要还原原链表
原地法的第三步会重新连接原链表的 next。如果你只是为了通过 LeetCode,不还原原链表也可能拿到 AC,因为判题只检查返回的新链表是否拷贝正确。但在真实开发和面试场景里,函数不应破坏外部传入的数据结构。你传入一个链表,调用完复制函数后,原链表应该和调用前完全一样。
所以我强烈建议练习时始终把“还原原链表”这一步写上。现场面试时,即使题目没有明确要求,还原原链表也是一个很好的加分点,能证明你在设计函数时考虑到了副作用。
5. 两种解法对比,以及面试中的答题顺序
5.1 直观对比
| 维度 | 哈希表法 | 原地穿插法 |
|---|---|---|
| 时间复杂度 | O(n) | O(n) |
| 额外空间 | O(n) | O(1) |
| 实现风险 | 低,逻辑清晰 | 中高,三步循环中指针容易写错 |
| 是否改动原链表 | 否 | 需要还原,还原后保持不变 |
| 代码可读性 | 高 | 中 |
| 适用场景 | 优先写的解法 | 面试追问优化时展示 |
从工程可维护性角度看,哈希表法明显更好。如果需求只是复制链表,额外增加一个哈希表不算什么大开销,但代码出错概率低很多。原地法最大的价值在于“常数空间”这几个字,这经常是面试官区分候选人的关键点。
5.2 推荐的答题节奏
刷题和面试不太一样。面试时建议分四步:
- 先和面试官确认题意:这里的复制是深拷贝吗?随机指针可能为空吗?
- 提出哈希表解法,说明第一轮“生节点”、第二轮“接指针”的原因。
- 如果面试官追问“能否省掉额外空间”,再抛出原地穿插法。
- 最后主动提到边界条件,比如 null、random 指向自身、原链表的还原操作。
这套节奏既展示了你掌握了基础解,又证明你理解进阶优化,而不是只会背代码。日常如果时间有限,把哈希表法写熟练完全够用;但准备大厂面试的话,原地法的思想值得多花半小时推演。
6. 联想与收尾经验分享
6.1 和 LeetCode 133 克隆图的关系
如果你刷过克隆图那道题,会发现 138 和它高度相似。克隆图的节点有 neighbors 列表,需要深拷贝整张图;解决方式也是用一个 HashMap 保存原节点到克隆节点的映射,先创建节点,再补邻居关系。138 的 random 指针其实可以看作一条特殊的“边”,所以它更像是图的退化版本。
理解了这层关系,你就不会再把 138 当成一道孤立的链表题。它真正教你的是:当面对带任意引用关系的结构时,用哈希表暂存“原始对象到新对象”的映射是最通用、最稳妥的工具。以后遇到多叉树复制、图复制、复杂对象深拷贝之类的需求,这套思路都能迁移过去。
6.2 链表类题目里反复出现的几种套路
Hot100 链表题刷多了会发现,常见解法不外乎三类:
- 双指针:解决找环、找中间节点、找倒数第 k 个节点等问题;
- 哈希表:解决依赖映射、复杂指针复制等问题;
- 递归或栈:解决反转、成对交换、倒序访问等问题。
138 最能代表的是第二类。它不会要求你写出特别高级的数据结构,但很考验建模能力:你能不能在“需要按引用找到对应拷贝”时,第一时间想到 HashMap。把这个模型建立起来,比多背十道题更有用。
6.3 给刷题人的一句实在话
个人刷这题最大的体会是:遇到深拷贝,第一反应应该是“建映射”,而不是“模拟指针”。哈希表版本真正厉害的地方不是省空间,而是把逻辑层次拆得清清楚楚:先复制全部节点,再统一处理引用关系。这种“分阶段处理”的思路,放到真实系统里也很常见,比如分布式任务里先创建实体再建立关联关系,就是同一套思想。
如果你在面试现场只能写一种解法,我建议先写哈希表法。它最容易做到一遍 AC,也是后续所有延伸解法的基础。等你把两轮遍历的节奏刻进肌肉记忆,再去看原地插入法,会发现它不过是用“空间换时间”的另一种编码方式而已。
