刷力扣138题的人,十有八九第一眼是被"随机指针"四个字搞懵的。复制一个普通链表很简单,逐个new节点、连next就完事了,但random这个指针可以指向链表里的任意一个节点甚至指向自己,这就让"复制"这件事从"照着画一遍"变成了"如何在复制后的新链表里,还原每个random的对应关系"。而这恰恰就是深拷贝最核心的考验:你拷贝的不只是节点值,而是节点之间的拓扑关系。这篇文章我会从深拷贝的本质讲起,把哈希表法、O(1)空间的节点拆分法、递归法三种解法逐一拆开,最后再聊聊我在实际刷题和面试过程中踩过的坑,希望看完你能真正理解这题背后的设计意图,而不是单纯背下代码。
1. 这题到底在考什么:别被random指针唬住
1.1 一道题戳破多少人的"伪深拷贝"
先看题目描述:给定一个长度为n的链表,每个节点除了next指针,还多了一个random指针,它可能指向链表中任意一个节点,也可能指向null。要求你返回一个与原链表结构完全相同的深拷贝。
很多人的第一反应是:我先遍历一遍链表,把每个节点的val复制出来,生成一串新节点,然后用next把它们串起来。这时候问题来了——新节点的random指向谁?
如果你在原链表里看到一个节点A的random指向节点B,你当然知道在新链表里,A的拷贝节点要指向B的拷贝节点。但问题是:B的拷贝节点在哪个内存地址?如果你没有建立"原节点→新节点"的映射关系,你根本找不到。这就是这题和普通链表复制之间最本质的差异:普通的next是线性推进的,复制的时候顺着走就行;而random是任意跳转的,它可能是前面的节点,也可能是后面的节点,甚至是一个还没创建出来的节点。
你可以先创建所有节点,再回头设置random,但前提是你得记住每个原节点对应哪个新节点。这其实就是"索引表"或"映射表"的雏形。所以这道题表面上考链表操作,实际上考的是两个数据结构基本功:一是哈希表作为映射工具的使用,二是指针操作和链表拆分的精细度。
1.2 深拷贝与浅拷贝的本质边界
要理解这题的满分答案,第一步不是写代码,而是把深拷贝和浅拷贝的边界彻底掰扯清楚。
浅拷贝(shallow copy):新对象拿到的是原对象里字段的拷贝,但如果字段是引用类型,那拷贝的是引用本身。放到链表场景里,如果你只是复制了头节点,然后让它的next指向原链表的第二个节点,那这个"新链表"和原链表共享了大量节点。你在新链表上修改节点,原链表也跟着变。
深拷贝(deep copy):新对象不仅复制了最外层的字段,连内部所有引用指向的对象也全部新建一份。放到本题里,就是新链表里的每一个节点都是独立new出来的,且新节点的next和random必须指向新链表里的节点,而不是原链表的节点。
你可能觉得这个区别太简单了,但真正的考验在于:深拷贝要求"结构等价、内存隔离"。结构等价指的是next和random的指向关系在新链表里完全复刻;内存隔离指的是新旧两个链表没有任何共享节点。
这题里最容易翻车的地方,就是有些初学者在设置拷贝节点的random时,直接把原节点的random引用赋给了新节点。这是典型的浅拷贝,代码跑起来可能部分测试用例能过,但只要检查是否共享节点,立刻暴露。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 哈希表解法:先把映射关系攥在手里
2.1 为什么必须分两步走
哈希表解法是最容易理解的版本,也是面试时最先应该给出的方案。
核心思路一句话:遍历原链表,创建新节点,同时用哈希表存下"原节点→新节点"的映射关系。全部节点创建完之后,再遍历一遍原链表,根据映射关系设置新节点的next和random。
为什么不能在第一遍遍历的时候同时设置random?因为random指向的节点可能还没有被创建出来。比如链表第三个节点的random指向最后一个节点,当你处理第三个节点时,最后一个节点对应的新节点还没生成,你拿什么去赋给random?就算你强行用map.get(原节点random)去查,也会因为键不存在而返回null,逻辑上就错了。
所以两步走是必然选择:
- 第一遍:只创建节点,建立映射。此时每个原节点都能找到自己对应的新节点,即使random指向后面的节点,那个节点在map里也已经存在了。
- 第二遍:遍历原链表,用map取出对应的新节点,分别设置next和random。
这个思路的关键点是:哈希表本质上是"原节点地址→新节点地址"的翻译器。没有这个翻译器,你无法在两条链表之间做地址跳转。
2.2 完整代码与细节注解
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)在cur.next为null时返回null,正好也是新链表的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.put(cur, new Node(cur.val))是在创建节点头的同时建立映射,键是原节点的对象引用,值是新节点。- 第二遍里,
map.get(cur.next)的妙处在于:当cur.next为null时,HashMap会返回null,而新节点的next本身就应该是null,语义完全一致。random同理。 - 返回的是
map.get(head),也就是原头节点对应的新头节点。不要试图另起一个变量去记录新头,直接用map取是最稳妥的。
在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]
Python里的dict.get(key)在键不存在时返回None,这正好把空指针的情况一并处理了,代码非常干净。
2.3 复杂度分析与适用场景
时间复杂度和额外空间复杂度都是O(n)。哈希表需要存储n个键值对,额外空间不可避免。
这个方法的最大优势是思路直白、逻辑清晰,面试时解释起来不费劲。而且它是后两种解法的基础:递归法本质上也是在用哈希表做映射,只是把创建和设置的过程揉进了递归调用里。所以无论如何,把哈希表法写熟、讲透,是必须的基本功。
如果面试官进一步追问"能不能写出O(1)额外空间的解法",这时候就要把节点拆分法亮出来了。
3. O(1)空间的节点拆分法:最惊艳的解法
3.1 三步走的思路推演
节点拆分法,也叫"插入拷贝节点法"或者"A-B-A'法",是这道题最经典的优化解法。它不用哈希表,额外空间降到O(1),时间复杂度依然是O(n)。
核心思想非常巧妙:既然random指针需要在两条链表之间"对号入座",而哈希表解决的问题是"原节点→新节点"的映射,那我不如把新节点直接插在原节点的后面。这样一来,原节点的next指向新节点,我只要看原节点的random指向谁,它的next就一定是我要找的新节点。
整个算法分三步:
第一步:遍历原链表,对每个原节点cur,创建一个新节点copy,把copy插在cur和cur.next之间。也就是说,原链表从"1→2→3"变成"1→1'→2→2'→3→3'"。
第二步:再次遍历。这次针对每个原节点cur,它的拷贝节点是cur.next。如果cur.random不为null,那么cur.next.random应该指向cur.random.next。为什么是cur.random.next?因为cur.random指向的是某个原节点,而这个原节点的next就是它对应的拷贝节点。
第三步:拆分链表。把奇数位置的节点串起来是原链表,把偶数位置的节点串起来就是新链表。这一步要仔细处理指针,先把新链表的头保存好,再逐个断开。
这个方法的精妙之处在于:它用空间换地址映射的思路被反过来了——用插入位置本身来编码映射关系。不需要额外存储,原链表的next指针就承载了映射信息。
3.2 代码实现与拆链细节
java复制class Solution {
public Node copyRandomList(Node head) {
if (head == null) {
return null;
}
// 第一步:在每个原节点后面插入拷贝节点
Node cur = head;
while (cur != null) {
Node copy = new Node(cur.val);
copy.next = cur.next;
cur.next = copy;
cur = copy.next;
}
// 第二步:设置拷贝节点的random
cur = head;
while (cur != null) {
if (cur.random != null) {
// cur.next是拷贝节点,cur.random.next是random指向节点的拷贝节点
cur.next.random = cur.random.next;
}
// 跳两步:原节点 -> 拷贝节点 -> 下一个原节点
cur = cur.next.next;
}
// 第三步:拆分链表,还原原链表,拆出新链表
Node dummy = new Node(0);
Node prev = dummy;
cur = head;
while (cur != null) {
Node copy = cur.next; // 拷贝节点
prev.next = copy; // 接入新链表尾部
prev = copy;
cur.next = copy.next; // 恢复原链表的next
cur = cur.next; // 移动到下一个原节点
}
return dummy.next;
}
}
拆链这步是很多人容易写错的地方,我详细说一下:
- 在连接新链表时,需要先保存
copy = cur.next,因为接下来cur.next会被修改。 copy.next指向的是下一个原节点,所以在恢复原链表时,要让cur.next = copy.next,这相当于把插入的拷贝节点从原链表中剔除。- 然后把cur移动到下一个原节点,即
cur = cur.next——注意此时cur.next已经通过上一步恢复为下一个原节点了,所以直接赋值即可。
这里有一个非常经典的坑:如果先执行cur.next = copy.next,再去取copy = cur.next,那么copy拿到的是下一个原节点而不是拷贝节点,整个逻辑就全乱了。顺序不能错:先取copy,再接新链表,再恢复原链表,最后移动cur。
3.3 为什么它能省掉哈希表
哈希表法需要O(n)的额外空间,是因为它要额外存储一份"映射关系"。而节点拆分法把新节点物理上紧贴在原节点后面,通过位置关系天然建立了映射:任何一个原节点node,它的拷贝节点就是node.next。当需要找"原节点random指向节点的拷贝"时,直接取random节点的next即可。
这一招的本质是"用链表的物理结构替代哈希表的逻辑映射"。理解了这一点,你以后遇到类似的"复制带任意指针的结构"题目,都能想到类似的优化方向——比如复制一个带random的二叉树,也可以在原树节点旁边挂拷贝节点,再拆出来。
空间复杂度上要注意:严格来说,三步遍历产生的临时指针变量是O(1)的,但如果题目要求不能修改原链表,这种方法就不适用了,因为第一步会改变原链表的next结构。好在力扣138的原题没有这个限制,所以节点拆分法是官方认可的进阶解法。
4. 递归解法:用回溯解决指针乱指的困局
4.1 递归的天然优势与隐患
递归解法其实和哈希表法共享同一个核心思想——都需要映射表,区别在于递归把"创建节点"和"设置指针"两个动作通过函数调用栈天然地串联起来。
我们观察一个节点需要干什么:
- 如果这个节点还没被拷贝过,就创建它的拷贝节点。
- 递归拷贝它的next指向的节点。
- 递归拷贝它的random指向的节点。
但是有个问题:如果链表中存在环状引用(比如节点A的random指向B,节点B的next指向A),递归就会无限循环。所以必须引入一个"记忆化"的机制:在创建节点后,立刻把"原节点→新节点"的映射存入哈希表,每次递归开始时先查表,如果发现已经拷贝过,直接返回之前创建的新节点。
这个思路在很多看似复杂的链表题里都能用。它的好处是代码极其简洁,逻辑天然自洽;坏处是如果链表很长(比如10万个节点),递归深度可能导致栈溢出,而且递归调用本身有函数调用开销。面试时如果给出了递归解法,最好主动补充一句:如果链表特别长,可以考虑改成迭代式哈希表法。
4.2 代码实现与防重复拷贝
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);
}
// 先创建当前节点的拷贝,并立刻放入map,防止后续递归出现环时重复创建
Node node = new Node(head.val);
map.put(head, node);
// 递归拷贝next和random
node.next = copyRandomList(head.next);
node.random = copyRandomList(head.random);
return node;
}
}
这里最关键的细节是:map.put(head, node)必须发生在递归调用之前。如果先递归head.next和head.random,再put,遇到环时就会在递归深处回到当前节点,发现map里还没有它,于是又创建了一个新节点,导致同一原节点对应两个拷贝节点,逻辑彻底崩盘。
很多人在写递归版时栽在这个顺序上,这其实是个非常好的思维练习:深拷贝的问题域天然包含"循环引用",所以"先标记再展开"是处理循环引用的通用范式。你以后做JSON深拷贝、图结构深拷贝,都会遇到同样的设计决策。
5. 我在实际刷题和面试中踩过的坑
5.1 random指向自身的节点
测试用例里经常出现一个节点random指向它自己的情况。看起来很简单,但实际写代码时,如果你用递归法,在拷贝random递归调用时,传入的head.random正是当前节点本身,此时map里已经存在当前节点的拷贝,所以能直接返回;如果是哈希表法,第二遍遍历时map.get(cur.random)返回的就是cur对应的新节点,也没问题。但如果你用的是"先复制next,再回头处理random"的朴素思路,且没有建映射,遇到self-loop时就会陷入死循环或者拿到原节点引用。这个case用来检验你是不是真的理解了深拷贝,非常好使。
5.2 拆分链表时的断链问题
节点拆分法里我最容易写岔的地方就是第三步。有一次我在拆分时先执行了cur.next = copy.next,然后再去prev.next = cur.next,结果新链表的尾巴直接指向了原链表的后半段,整个结构变成了一条串在一起的怪链表。后来我把第三步拆成四个动作,每一步都确认当前指针的指向:取拷贝、接新链表、恢复原链表、移动原链表指针。跑测试时再加一句打印校验,再也没有错过。
5.3 调试利器:打印函数怎么写
刷链表题,Debugger当然可以用,但有时候一行打印函数比断点更直观。我习惯写一个工具函数,打印出每个节点的val、当前节点地址、next指向的val、random指向的val,比如:
java复制public static void printList(Node head) {
Node cur = head;
while (cur != null) {
String randomVal = cur.random == null ? "null" : String.valueOf(cur.random.val);
System.out.println("val=" + cur.val + ", next=" +
(cur.next == null ? "null" : cur.next.val) +
", random=" + randomVal);
cur = cur.next;
}
}
刷完复制函数后,分别打印原链表和结果链表,逐行对比random指向的val是否一致。如果题目用例里存在两个val相同的节点,仅凭val不够,我还习惯打印节点自身的hashCode(System.identityHashCode),确认新链表的所有节点都是新对象,而不是复用了原链表的节点。这种方法在验证深拷贝时特别有用,因为力扣判题会检查每个random是否指向拷贝链表的节点,如果指向原链表节点就会判错,打印函数能提前帮你发现问题。
6. 一道题串起的工作经验:深拷贝在真实场景中的位置
很多人刷这题时只是当一道链表题处理,但我后来在业务开发里多次碰到类似场景,才发现这题真的很典型。
举个例子:你在做前端状态管理时,需要把一份配置对象进行深拷贝,然后让用户编辑,改乱了还能恢复原始配置。如果直接用浅拷贝或者手工拷贝了一半的字段,那么用户改配置时会不知不觉改到原始数据,线上事故就是这么来的。随机链表的复制本质上就是"带引用关系的结构体深拷贝"的最简模型,链表的random指针换成对象里的引用字段,逻辑一模一样。
还有一个场景是图结构或者多叉树的序列化与反序列化。比如一个社交网络里,每个用户节点除了知道自己的好友列表,还可能有指向"特别关注用户"的引用。复制这样一份数据时,如果只拷贝了next(好友列表的顺序),遗漏了random(特别关注关系),那复刻出来的数据就是残缺的。力扣138教会我们的事,在处理任何带交叉引用的结构时,第一步永远是"建立映射关系",而不是急着复制内容。
哈希表法在这个场景里的直接翻译是:遍历原始结构,创建新对象,用Map记录"原始对象→新对象",然后第二遍遍历设置所有引用字段。你在Java里用clone方法做深拷贝或手写JSON深拷贝时,核心思路完全一致。等你在业务里写过一次工具类,再回头看这题,会有更深的体感。
按照我个人的建议,这题的刷题顺序是:先用朴素思路交一次错(看看自己能不能踩到random指向未创建节点的坑),再写哈希表法,然后推导节点拆分法,最后再用递归法复盘一遍。四种写法都过一遍,你才算把深拷贝这件事吃透了。
