第12天的刷题计划,安排到了LeetCode 138:随机链表的复制。刚扫一眼题面时,我觉得这题最多就是个链表遍历,把每个节点重新new一遍就完事。真动手以后才发现,random指针才是整道题的“主菜”——它可以让每个节点随机指向链表里的任意位置,甚至指向空节点,这会让复制逻辑一下子从“顺序拷贝”变成了“结构重建”。如果你正准备面试,或者在系统刷链表类题目,这道题值得专门花一天时间吃透,因为它是理解深拷贝、哈希表映射和原地链表操作的极好样本。
这篇文章不打算只贴一份能通过的代码,我会把我从读题、画图、写哈希表法,再到优化成O(1)空间原地克隆的完整过程,以及中途踩过的空指针、死循环、链表成环这些坑,全部复盘一遍。两种主流解法都会给出完整可运行的Java实现,并且解释每一步为什么那么写。
1. 先弄懂Leetcode 138到底在考什么
1.1 为什么随机指针让复制不再简单
先看节点的定义。LeetCode 138的链表节点不是普通的单链表节点,它长这样:
java复制class Node {
int val;
Node next;
Node random;
public Node(int val) {
this.val = val;
this.next = null;
this.random = null;
}
}
val很好办,复制值就行;next也容易,顺着原链表往后走,一个一个new新节点就串起来了。问题出在random上:每个节点除了指向下一个节点,还可以额外指向链表里的任意一个节点,甚至指向null。
这就带来一个核心矛盾:假设原链表的第二个节点的random指向第五个节点,当你复制到第二个新节点时,你要给新节点的random赋值,但第五个新节点可能还没有被创建出来。哪怕你先遍历一遍把所有新节点都创建好,另一个麻烦又出现了:新节点里的random不应该指向原链表里的旧节点,而应该指向和旧节点一一对应的那个新节点。也就是说,新链表必须是一套完全独立的新节点,但节点之间的相对指向关系要和原链表完全一致。
我一开始老想省事,先直接复制val,然后用一个临时数组记录每个节点的random位置,再通过下标去关联。对这道题来说确实可行,但代码写起来丑,而且遇到random指向null的时候需要特殊判断,下标处理也很容易乱。这道题真正想考察的,不是你会不会遍历链表,而是你能否在“新旧节点之间搭建起一个可靠的映射关系”。
1.2 深拷贝到底比浅拷贝多做了哪些事
很多人看题会忽略一个关键词:深拷贝。如果只是浅拷贝,那简单得有点过分——直接把第一个节点的引用返回去,所有指针天然“相同”,因为根本就是同一个对象。但LeetCode要求每个新节点的val都要等于原节点的val,next和random要指向复制链表里的新节点,而且复制链表里的指针不能指向原链表里的任何节点。
你可以拿“搬家”来想象这个场景:原链表是一套旧房子,现在要给你造一套全新的同户型新房。里面的每扇门、每个房间的连通关系都要复刻过来,但新房里的门必须通向新房里的其他房间,绝对不能通向旧房子里的房间。如果你在复制节点时,直接把random指向了旧节点,等于新房子里有一扇门通向旧房子,那整个结构就是错的。
“深拷贝”这个概念在很多语言里都容易搞混淆,尤其是遇到对象引用类型的字段时。平时我们写普通链表的复制,因为只有next一个方向,顺序new一遍就算是深拷贝了;但random字段让每个节点的出度从1变成了最多2,链表在逻辑上不再只是一条线,更像一张图。所以这道题也被很多人看作“图的深拷贝”的前置练习。理解到这一层,后续解法自然就清晰了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 解法一:用哈希表映射复制随机链表
2.1 先建立旧节点到新节点的对应关系
哈希表法是我最先想到,也是我认为最不容易出错的解法。核心思路只有一句话:第一遍遍历原链表,为每一个旧节点new一个只包含val的新节点,然后把“旧节点 -> 新节点”的对应关系存进HashMap;第二遍再遍历原链表,把新节点的next和random接上。
为什么要分成两次循环?因为连接随机指针时,目标节点可能还没创建。哈希表从根本上解决了“旧节点对应的新节点在哪里”这个问题:不管当前遍历到哪个节点,只要拿到旧节点的引用,map.get(旧节点)就能以O(1)时间找到它的复制品。
你可以把HashMap想象成一本“新旧对照手册”。第一遍走过去,所有新房子都已经建好了,门牌号(映射关系)记录在手册里。第二遍再走一遍旧房子,每遇到一扇门,就去手册里查它应该通向新房子的哪个房间,然后直接接线。这种方式把“先创建完整节点集合”和“再连接关系”两件事彻底解耦,逻辑上非常干净。
这里有个细节值得注意:HashMap的key用的是旧节点的对象引用,而不是节点里的val。因为不同节点的val允许重复,值本身不能作为唯一标识,但对象引用一定不会重复。这一点如果写反了,代码会在隐藏用例上报错,而且非常难排查。
2.2 copyRandomList Java实现与代码解读
直接看完整代码:
java复制import java.util.HashMap;
import java.util.Map;
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);
}
第二遍循环是最值得反复看的地方。map.get(cur)拿到的就是当前旧节点对应的新节点;map.get(cur.next)拿到的是“下一个旧节点对应的新节点”,把它赋给新节点的next,就完成了链表主干线的连接。random同理,map.get(cur.random)会返回random指向的那个旧节点对应的新节点。
有人可能会担心,cur.next为null时,map.get(null)会不会报错?在Java的HashMap中,get(null)是合法操作,当key为null时会去查找null对应的value,如果不存在则返回null,所以这里并不需要额外判断。不过这个行为依赖语言实现,如果你写的是C++的unordered_map,那就得先判断是否为空再访问,习惯上还是建议在脑子里过一遍这条边界。
从时间上看,每个节点被访问两次,时间复杂度是O(n)。空间上,HashMap存了所有新旧节点的对应关系,额外空间是O(n)。这是标准的“空间换时间”解法,也是面试时最容易在短时间内写对、讲清楚的一种方案。
2.3 哈希表解法的时间和空间复杂度
说到复杂度,很多人背答案似的说一句“时间O(n),空间O(n)”就结束了,但我建议你多问自己一句:为什么必须是O(n)空间?因为你要在任意时刻回答“旧节点的复制品是哪一个新节点”这个问题。如果不用哈希表,最笨的做法是每次遇到random指针时,都从原链表头出发找到目标旧节点的下标,再沿着新链表走相同步数找到对应新节点,这样单次查询就是O(n),整体会退化到O(n^2)。
哈希表本质上是把“位置查找”这个容易重复计算的操作,预先缓存起来。对于抽象成图的链表复制,这种模式非常重要。后续如果做到LeetCode 133克隆图,你会发现思路一模一样:要复制一个任意两个节点都可能相连的结构,必须先建映射,再递归或迭代地复制邻居。
所以哈希表解法的意义不只是AC这道题,更是在训练一种思维:当你需要在“旧结构”和“新结构”之间频繁建立引用对应关系时,用一张映射表兜底,是想清楚问题的第一步。
3. 解法二:原地克隆三步法,空间优化到O(1)
3.1 把每个复制节点先塞进原链表
哈希表解法已经能正确过题,但我刷题时往往会继续追问一个问题:能不能把额外空间也省掉?因为面试官很可能在你写完哈希表法之后加一句:“如果要求空间复杂度必须是O(1),你怎么做?”
这里有一个非常聪明的思路:既然问题的难点在于建立“旧节点 -> 新节点”的映射,那我们可以直接把这个映射关系“编码”到链表自身结构里。具体做法是:遍历原链表,在每个原节点的后面插入一个val相同的新节点。比如原链表是A -> B -> C,插入后变成A -> A' -> B -> B' -> C -> C'。
这一步执行完之后,链表的长度翻倍,而且每个旧节点的next,恰好就是它对应的新节点。也就是说,原来需要用HashMap存的关系,现在通过next指针就天然表达了:旧节点cur对应的新节点就是cur.next。这种用链表物理位置代替哈希表存储的方法,是原地算法的精髓。
代码实现如下:
java复制Node cur = head;
while (cur != null) {
Node clone = new Node(cur.val);
clone.next = cur.next;
cur.next = clone;
cur = clone.next;
}
这段循环里,我第一次写的时候犯过一个低级错误:循环里cur = cur.next。但插入A'之后,cur.next已经变成A'了,如果继续cur = cur.next,下一次循环拿到的其实是刚刚创建的克隆节点,而不是原来的B。正确的写法应该是先保存原链表的下一个节点,或者利用clone.next跳到原来的下一个节点。上面代码里clone.next在赋值前已经指向原链表cur原来的next,所以循环末尾cur = clone.next就可以回到原链表的下一个位置。用一张纸画一下A -> A' -> B,这步就很直观了。
3.2 如何正确安装每个复制节点的random指针
复制节点插入完成后,所有新节点都还没有设置random。这一步需要再遍历一次链表,给每个克隆节点补齐random指向。为什么这次可以一次遍历搞定?因为对于任何一个原节点cur,它的克隆节点是cur.next,而cur.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一定报NullPointerException,所以必须先做判空处理。有些写法会省略if,直接写cur.next.random = cur.random == null ? null : cur.random.next,效果一样,看个人风格。
还有一个细节是循环步长。因为现在链表是“旧节点、新节点、旧节点、新节点”交替排列,如果循环里写cur = cur.next,走一步只能到新节点,再下一次循环访问cur.random时拿到的就是克隆节点,原关系就全乱套了。必须一次跳两步,也就是cur = cur.next.next,才能始终保证cur指向的都是原链表节点。
这一步完成后,新节点的random字段已经指向正确的新节点了。举个例子,原链表A的random指向C,那么A'的random应该指向C'。由于插入后C的next就是C',所以代码里A.next.random = A.random.next,翻译成人话就是“A的克隆节点的random,等于A的random指向的那个原节点的克隆节点”。这个对应关系很优雅,但如果不画图,很容易在指针绕来绕去时把自己绕晕。
3.3 拆链时的顺序:先保旧next,再接新链表
最后一步是要把新旧两条链表分开。因为此时原链表的next已经被改得乱七八糟了,我们不能直接return head.next了事,必须重新恢复原链表,并串起新链表。
我的写法是用一个虚拟头节点dummy来收集克隆节点,同时把原链表恢复原样:
java复制Node dummy = new Node(0);
Node copyTail = dummy;
cur = head;
while (cur != null) {
Node clone = cur.next;
Node originalNext = clone.next;
// 把克隆节点挂到新链表尾部
copyTail.next = clone;
copyTail = clone;
// 恢复原链表的 next 指向
cur.next = originalNext;
cur = originalNext;
}
return dummy.next;
这段代码的精髓在于:每次循环开始时,先把两个关键引用保存下来——clone是当前原节点对应的克隆节点,originalNext是克隆节点后面跟着的“下一个原节点”。接下来先让新链表的尾部链上clone,再让当前原节点的next绕过clone直接指回originalNext,完成原链表的恢复。
有人会问,为什么要做dummy节点?直接用一个headClone变量不好吗?其实也可以,但dummy能省掉对第一轮循环的特殊处理,让代码更统一。类似的技巧在处理链表头节点可能变化的问题时很常见,建议养成使用dummy的习惯。
等整个循环结束,cur会经过所有原节点,最后变成null;原链表的所有next引用也已经全部恢复成原始顺序;dummy.next恰好是新链表的头。这里返回的每一个节点都是new出来的新节点,不包含任何旧链表的节点引用,完全符合题目的深拷贝要求。
完整代码合并在一起就是:
java复制public Node copyRandomList(Node head) {
if (head == null) {
return null;
}
// 第一步:在每个原节点后面插入克隆节点
Node cur = head;
while (cur != null) {
Node clone = new Node(cur.val);
clone.next = cur.next;
cur.next = clone;
cur = clone.next;
}
// 第二步:设置克隆节点的 random
cur = head;
while (cur != null) {
if (cur.random != null) {
cur.next.random = cur.random.next;
}
cur = cur.next.next;
}
// 第三步:拆分原链表和新链表
Node dummy = new Node(0);
Node copyTail = dummy;
cur = head;
while (cur != null) {
Node clone = cur.next;
Node originalNext = clone.next;
copyTail.next = clone;
copyTail = clone;
cur.next = originalNext;
cur = originalNext;
}
return dummy.next;
}
空间上除了结果链表本身,只用了dummy和几个临时指针,额外空间是O(1)。时间上仍然是O(n),因为每个节点在三个阶段中分别被访问一次。这种解法的代价是代码的边界条件比哈希表法更敏感,对“链表倒来倒去”不熟的人,很容易写出死循环或者成环的链表。
4. 实现随机链表复制时的常见坑和实战排查
4.1 两个边界条件:空链表和random为null
我先说最基础的边界:输入head为null时,不论哪种解法,都要直接返回null。LeetCode的测试用例里空链表很常见,如果你忘了这一步,后续所有调用cur.next都会直接抛空指针,白白浪费一次提交。
第二个边界就是random指向null。这个坑在哈希表解法里其实不怎么明显,因为map.get(null)在Java里返回null,代码看起来很正常;但在原地克隆法里,第二步判断必须写成:
java复制if (cur.random != null) {
cur.next.random = cur.random.next;
}
如果漏掉这个if,当某个节点的random为null时,代码会尝试访问null.next,整个程序直接崩溃。我在第一次写原地法时就没注意,本地测试用例中random都为非空,提交后才被隐藏用例打脸。排查方式也很简单,报错栈会直接指向cur.random.next这一行,看到之后第一反应就应该是去检查random是否为null。
这种边界条件其实不难处理,难的是一些人不习惯在编码前就想到这些情况。我的习惯是每道链表题先写下三个测试用例:空链表、单节点链表、多节点但random指向null的链表。把最特殊的情况先跑到,再把通用逻辑补上,能省非常多调试时间。
4.2 拆链操作最容易形成循环,怎么排查
拆链看着只有几行,实际上是最容易写出环的地方。我见过一种很典型的错误写法,绕来绕去图省事,没有先保存originalNext,而是边拆边改next:
java复制// 错误示范
while (cur != null) {
Node clone = cur.next;
cur.next = clone.next;
cur = clone.next;
clone.next = cur == null ? null : cur.next;
}
这段代码问题很大。第二行把cur.next从clone改成clone.next之后,第三行cur = clone.next看似能跳到下一个原节点,但此时clone.next还是原来的值,需要分情况讨论。如果幸运的话,它指向的是下一个原节点;可一旦在第二行或后续操作中提前修改了clone.next,链表的原始顺序就会丢失,循环条件cur != null可能永远成立,最终输出一个成环的链表。
如果你提交后遇到类似问题,最直接的排查方式是打印链表走过的节点数。假设原链表长度是n,如果遍历过程中发现走过的节点数超过了2n还没结束,几乎可以断定链表里出现环了。另一个办法是打印每个节点的内存标识或者val序列,看有没有重复出现。
拆链的正确姿势就是我前面代码展示的三段式:先保存clone和originalNext;再处理新链表连接;最后恢复原链表,并把指针移动到originalNext。核心原则是:任何可能被后续操作覆盖的引用,都要先备份一份再动手。这个原则在链表操作里放之四海而皆准,不只是这道题。
4.3 这道题的扩展:克隆图与面试追问方向
把LeetCode 138的random指针看成一条任意边,链表就升级成了图。面试官经常在答完这题后追问一道LeetCode 133克隆图,或者直接把两者放在同一轮里考。图版本的难点在于一个节点可能有多个邻居,但解法内核是一样的:用HashMap记录“原节点 -> 新节点”的映射,遇到邻居就递归去复制,已经复制过的节点直接返回映射结果。
我在刷题群里看到不少人只记138的解法,懒得深想,结果做到133时又从头开始想。实际上,只要理解了这道题的HashMap解法,图复制基本就是加一个邻居数组遍历的问题。面试时把138讲透,顺手提起“这个思路可以迁移到克隆图”,会是很加分的行为。
另外,面试官还可能在原地解法上追加条件:要求不能修改原链表结构,或者在复制完成后必须把原链表恢复原样。哈希表法天然满足“不修改原链表”,而原地法在拆链阶段如果不恢复原链表,虽然也能AC很多在线评测,但严格意义上不是好的实现。因为调用方可能还持有原链表的头节点,如果原链表的next顺序被改乱,后续对原链表的操作通通会出错。所以我的代码里特意保留了恢复原链表这一步,这不仅是为了规范,也是面试中值得主动提起的加分点。
5. Day 12刷题后我的一些体会
5.1 两种解法其实是在做同一件事
刷完这道题,我最大的感受是:哈希表法和原地克隆法表面上差异巨大,本质上都在解决同一个问题——如何建立旧节点和新节点的一一对应关系。哈希表把映射关系放在一个独立的数据结构里,清晰直观;原地克隆法把映射关系藏在了链表的next域中,空间更省,但逻辑更绕。
如果你刚开始刷链表题,我建议先死磕哈希表法,因为它好理解、好写、不容易出错,可以作为面试时的保底方案。等代码能一遍过之后,再画图推导原地法,感受一下如何用结构本身来表达对应关系。两种解法都掌握了,才算真正吃透这道题,而不是背下了一份答案。
有个小技巧是:每次做链表复制类的题,不要只在脑子里想,打开编辑器把它画成A -> B -> C这种结构图,把新旧节点用不同颜色标出来,random指针单独画成一条虚线。图画清楚之后,代码里很多绕来绕去的指针操作,会突然变得顺理成章。
5.2 对后续链表和递归题目的帮助
这道题覆盖的知识点密度相当高,涉及深拷贝、哈希映射、指针交替、空指针边界、链表成环排查,还顺带连接到了图复制。刷完它再去做普通链表的复制、删除倒数第N个节点、反转链表II,都会有底很多,因为你对“保存前驱后继引用”这件事的敏感度已经被训练过了。
更关键的是,它帮我养成了一个习惯:遇到带next之外额外指针的链表题,先问自己要不要建映射、能不能利用已有空间表示映射,而不是上来就无脑遍历。
如果你也正在Day 12这个阶段,我的建议是把这道题放进二刷清单。过一段时间再回来写一遍,尽量不要看之前的代码,如果能在十五分钟内把两个解法都干净地写出来,这块内容就算真正变成你自己的了。
