1. 题目到底在考什么
刷LeetCode热门100题,刷到这道“随机链表的复制”时,如果你是第一次见,大概率会愣一下。题目本身没什么弯弯绕:给你一个长度为n的链表,每个节点除了next指针,还多了一个random指针,random可以指向链表中任意一个节点,也可以指向null。要求你返回一个深拷贝,也就是new出来的全新链表,和原链表结构完全一样,但每一个节点都是独立的新对象。
为什么单独拿出来说?因为这道题入围了力扣的hot 100,绝不是一道简单题的水平。它考察的不只是“会不会遍历链表”,而是两个关键能力:第一,能不能处理“复制过程中引用关系依赖顺序”的问题;第二,有没有空间换时间、或者通过巧妙的结构设计把辅助空间省下来的意识。前者对应回溯+哈希,后者对应迭代+节点拆分。两种解法在LeetCode官方题解里分别是“回溯+哈希表”和“迭代+节点拆分”,这也是标题里那串括号的来历。
适合谁来读?我建议这几类人都细看一遍:准备面试、正在刷hot 100的朋友,尤其要理解两种解法的取舍;学过链表但总被指针绕晕的新手,这道题能把“新旧节点映射”这个概念彻底讲透;还有一类是做代码评审或想提升代码设计感的同学,节点拆分那种“原地织毛衣”的思路,对锻炼结构化思维非常有帮助。
1.1 一个看起来“很简单”的复制题
先把直觉版的思路摆出来:普通链表的复制很简单,遍历一次,每遇到一个原节点,就new一个值相同的新节点,然后让上一个新节点的next指向当前新节点,完事。
但随机链表多了一个random指针,麻烦就来了。你没办法在第一遍遍历的时候就准确填好random指向,因为random指向的那个节点,很可能还没有被创建。举个例子:原链表的第1个节点random指向第5个节点,你从头开始复制,当你复制第1个节点时,第5个节点还没new出来,你手里根本没有一个“新第5节点”可以指。
有人会说:那我先遍历两遍,第一遍把所有节点全部new出来存好,第二遍再逐一设置next和random,不就行了?思路方向是对的,但需要一个Map来记录“原节点 -> 新节点”的对应关系,不然你在第二遍设置random时,拿到一个原节点引用,还是不知道对应的新节点是哪个。所以最朴素的正确解法,就是哈希表映射。
1.2 真正的难点:random指针打破顺序假设
这道题最核心的难点,在于random让“复制顺序”这件事变得不可预测。链表天然是一个线性结构,next指针决定了你只需要按顺序处理。但random像是一个隐藏的“跳线”,它随时可能指向一个你还没访问到的未来节点,也可能指回一个已经处理过的历史节点,甚至指向自己。这个特性让简单的迭代复制失效。
更深一层,它考察的是“深拷贝”的概念。所谓深拷贝,不只是值相同,而是新链表里每一个节点的地址都和原链表不同,并且所有指针关系都在新链表的节点之间建立。很多人在LeetCode上跑测试,发现返回结果打印出来“好像一样”,但用node1 == node2一比较,或者修改新链表的值导致原链表也跟着变,就立刻露馅了。
1.3 解法全景图:哈希映射与节点拆分两条路线
针对这道题,主流的两个方向必须都在脑子里有清晰画像:
- 方案一:回溯+哈希表。用递归去模拟“需要哪个节点就现场创建哪个节点”,哈希表记录原节点和新节点的对应关系,避免重复创建。这是最容易想到、也最好解释清楚的方案。
- 方案二:迭代+节点拆分。把新节点先一个个插到原节点后面,构造
原1 -> 新1 -> 原2 -> 新2 -> ...的交替链条,然后在原链表上原地设置新节点的random,最后再把整个链表拆成两条。空间复杂度从O(n)降到O(1),思路极其精巧。
两条路各有优劣,后面我分别拆开讲。而且这道题非常适合当作面试题去练“题后复盘”——把两种解法都写一遍,你对“引用关系”“指针操作”“递归记忆化”的理解会上一个台阶。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 回溯 + 哈希:最符合直觉的思路
先说我个人刷题时的习惯:遇到这种存在“循环依赖”或者“未知引用”的场景,第一步想到的往往是递归加记忆化。这道题就是教科书级别的回溯应用。
你回头看看标题里的“回溯+哈希”,其实可以拆成两部分理解:回溯解决的是“顺序不确定”的问题,哈希解决的是“重复创建”的问题。两个合在一起,就是一套非常成熟的解法模板。这个模板不止能用在链表上,克隆图(LeetCode 133)、克隆树、带random指针的数据结构复制,全都是同一个套路。
2.1 为什么回溯法和哈希表能组队
我们把问题换一种问法:如果你要复制一个节点A,你需要知道什么?你需要知道A的val,需要知道A的next指向哪个“新节点”,还需要知道A的random指向哪个“新节点”。而A的next和random指向的那个节点,可能已经复制过了,也可能还没复制。如果已经复制过,直接复用即可;如果没复制过,就先递归去复制它——这就是回溯过程。
但这里有一个致命问题:如果链表中存在环形的引用链,比如A的random指向B,B的next又指回A,递归就会无限套娃下去。解决这个问题的办法叫“记笔记”:
- 每次创建一个新节点,立刻把
原节点 -> 新节点的对应关系存进哈希表; - 下一次不管是通过next还是random访问到同一个原节点,先查哈希表,查到了就直接返回已经复制过的新节点,不再递归。
哈希表在这里的角色,既是映射表,又是“备忘录”,同时避免了重复创建和死循环。用大白话说:递归负责“按需生产”,哈希负责“防止生产重复”。这跟动态规划里的记忆化搜索逻辑如出一辙。
2.2 Python递归实现:代码逐段拆解
直接上代码。我用Python写,因为它表达指针关系时最干净,但思路可以平移到你熟悉的任何语言。
python复制class Node:
def __init__(self, x, next=None, random=None):
self.val = int(x)
self.next = next
self.random = random
class Solution:
def __init__(self):
# 哈希表:原链表节点 -> 新链表节点
self.cached = {}
def copyRandomList(self, head):
if head is None:
return None
# 如果已经复制过当前节点,直接返回新节点
if head in self.cached:
return self.cached[head]
# 第一次遇到该节点,创建新节点,并立刻登记到哈希表
new_node = Node(head.val)
self.cached[head] = new_node
# 递归复制 next 和 random
new_node.next = self.copyRandomList(head.next)
new_node.random = self.copyRandomList(head.random)
return new_node
这段代码最精妙的地方,是把new_node.next和new_node.random的赋值放在哈希登记之后。这个顺序是必须的,不是随手写的。我来解释一下:如果先递归head.next,在递归过程中一旦遇到head的random指向某个节点又指回head本身,那时如果head没有出现在哈希表里,就会重复创建,导致新链表里出现两个“同一个节点的副本”,random关系就错乱了。先把当前节点登记好,再递归处理子问题,才能保证“无论什么时候回头找我,都能找到同一个我”。
另一个容易困惑的点是:为什么递归到head.random时,不需要判断head.random是否为null?因为copyRandomList()函数开头就有if head is None: return None,遇到空指针会自然返回None,不需要额外判断。这也是把边界情况收敛到递归基里,代码会非常简洁。
2.3 迭代版的哈希解法:不递归也能写
有人天生对递归过敏,或者担心Python递归深度不够(虽然链表最长1000,实际没问题),那可以把递归改成显式的迭代。思路同样简单:第一遍遍历创建所有新节点并填哈希表,第二遍遍历设置next和random。
python复制class Solution:
def copyRandomList(self, head):
if head is None:
return None
old_to_new = {}
cur = head
# 第一遍:创建所有新节点,建立映射
while cur:
old_to_new[cur] = Node(cur.val)
cur = cur.next
cur = head
# 第二遍:设置 next 和 random
while cur:
if cur.next:
old_to_new[cur].next = old_to_new[cur.next]
if cur.random:
old_to_new[cur].random = old_to_new[cur.random]
cur = cur.next
return old_to_new[head]
这段代码更好理解,没有递归,纯遍历。哈希表的key用的是原节点对象本身,而不是val,这点非常关键——因为val可能重复,但节点对象唯一。Python里对象默认hashable,直接把cur当key丢进字典就行。
对比一下两版代码:递归版不需要先创建全部节点,属于“用到哪个创建哪个”;迭代版先一次性建好所有节点,再统一连指针。两个的复杂度都是O(n)时间、O(n)空间,只是代码风格不同,面试时看哪个更顺手就写哪个。
提示:写递归版本时,记得把cached哈希表放在类成员变量里,而不是每次递归都新建一个字典。否则你传参传得累,还容易出错。
3. 迭代 + 节点拆分:把空间复杂度压到O(1)
哈希解法最大的问题,就是额外开了O(n)的空间。如果面试官接着问一句:“能不能不用哈希表,把空间复杂度降到O(1)?”哈希解法就哑火了。这时候,节点拆分法登场。
我第一次看到这个解法时,感觉有点像小时候玩的那种“翻花绳”——在原链表上穿插着织入新节点,让它长成双倍长度,处理完random再拆开。思路确实不常规,但这恰恰是面试官想看到的亮点。
3.1 节点拆分的整体思路:三步走
节点拆分法的核心思想是:不再用一个外部哈希表去记录“原节点对应哪个新节点”,而是让新节点物理上就待在自己的原节点旁边。具体三步:
第一步,遍历原链表,对每个原节点cur,创建一个新节点copy,把copy插在cur和cur.next之间。插完以后,链表长度翻倍,结构变成这样:
code复制原1 -> 新1 -> 原2 -> 新2 -> 原3 -> 新3 -> ... -> null
第二步,再次遍历这个交替链表,专门处理新节点的random。由于新节点就在原节点旁边,原节点cur的random指向某个原节点r,那copy.random就一定等于cur.random.next。为什么?因为cur.random对应的新节点,就是cur.random后面紧挨着的那一个。这个性质简直不要太爽,相当于你有了一个O(1)时间的定位方式。
第三步,把交替链表拆成两条链。遍历一次,让原节点的next跳过新节点,新节点的next指向下一个新节点,最终恢复原链表,并得到一条全新拷贝链表。
这个方案的空间复杂度是O(1)(不算返回结果占用的空间),时间复杂度是O(n),而且完全不需要哈希表。代价是思路理解成本高,代码实现时容易在指针交接处写错,特别是第三步拆分,很多人在这一步翻车。
3.2 Python实现:完整可跑的代码
直接上代码,再逐段解释。
python复制class Solution:
def copyRandomList(self, head):
if head is None:
return None
# 第一步:在原链表的每个节点后面插入一个拷贝节点
cur = head
while cur:
copy = Node(cur.val)
copy.next = cur.next
cur.next = copy
cur = cur.next.next # 跳过刚插入的 copy,到达下一个原节点
# 第二步:设置拷贝节点的 random
cur = head
while cur:
copy = cur.next
if cur.random:
copy.random = cur.random.next
else:
copy.random = None
cur = cur.next.next # 当前 cur 移到下一个原节点
# 第三步:拆分链表,恢复原链表并取出新链表
cur = head
new_head = head.next
while cur:
copy = cur.next
origin_next = cur.next.next
# 恢复原链表的 next
cur.next = origin_next
# 连接新链表的 next
if origin_next:
copy.next = origin_next.next
else:
copy.next = None
cur = origin_next
return new_head
第三步的指针交接是灵魂所在,我建议你拿纸笔画一画,或者本地加断点单步走一遍。我当时第一次手写,就是第三步写错了,恢复原链表时把copy.next指到了原节点的next上,结果新链表和原链表串在一起,LeetCode报错“cycle detected”。
有一个小细节值得注意:第二步里cur.random为空时,要显式地给copy.random赋None。其实新创建的Node默认random就是None,所以这个else分支不写也能过,但写出来语义更清晰,别人读代码时一眼能看懂。如果你是写给别人看或面试讲题,推荐保留。
3.3 边界条件与容易写错的地方
这道题用节点拆分法,踩坑点比哈希法多得多。我把自己踩过的、以及帮别人review时见过的几类典型错误列一下:
第一个坑:插入拷贝节点后,遍历原链表时,不能写cur = cur.next,而要写cur = cur.next.next。因为当前cur的next已经被替换成拷贝节点了,你再往前走一步就落到了拷贝节点上,而不是下一个原节点。这个错误在第一次写的人身上几乎必现。
第二个坑:第三步拆分时,要特别注意最后一次循环。当cur指向最后一个原节点时,origin_next是null,此时如果不判断origin_next,直接执行copy.next = origin_next.next,就会空指针异常。上面代码里用if origin_next做了保护,这是必须写的。
第三个坑:有人喜欢用dummy = Node(0)作为新链表头,但在这个场景下完全没必要,head.next在插入完成后已经确定就是新链表的头节点。注意,如果原链表为空,第一步循环不会执行,head.next会报错,所以函数开头必须判空。
提示:第三步拆分时,必须先保存
origin_next,再修改cur.next。如果先改了cur.next,你就找不到下一步要处理的原节点了。顺序反了的话,循环直接断链崩溃。
3.4 Java版本参考
怕只给Python有人迁移困难,附一个Java版核心代码,结构完全一样。尤其面试官如果要求你写Java,可以对照看。
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;
}
cur = head;
while (cur != null) {
if (cur.random != null) {
cur.next.random = cur.random.next;
}
cur = cur.next.next;
}
cur = head;
Node newHead = head.next;
while (cur != null) {
Node copy = cur.next;
Node originNext = copy.next;
cur.next = originNext;
copy.next = (originNext == null) ? null : originNext.next;
cur = originNext;
}
return newHead;
}
}
Java版和Python版逻辑一一对应。注意Java中Node类的定义需要random字段,我这里假设你已经按题目要求定义好了。如果是在LeetCode上做题,标准Node类已经内置,不需要重复定义。
4. 两种方案怎么选:面试、性能、代码可读性综合对比
两种解法代码都跑通了以后,自然会面临一个问题:那我到底该用哪种?如果只是为了AC,哈希迭代版就够了——代码直观,不容易写错,时间复杂度也是O(n),绝大多数场景下没有问题。但既然这道题在热门100题里,面试被追问的概率相当高,光会一种不够,得能说清楚两种的取舍。
4.1 复杂度与实现难度对照
先列一个表,方便对照:
| 方案 | 时间复杂度 | 空间复杂度 | 代码量 | 出错概率 | 是否需要额外结构 |
|---|---|---|---|---|---|
| 回溯+哈希(递归) | O(n) | O(n) | 少 | 中 | 哈希表 |
| 迭代+哈希 | O(n) | O(n) | 少 | 低 | 哈希表 |
| 迭代+节点拆分 | O(n) | O(1) | 中 | 高 | 无 |
从表里能看到,哈希类方案胜在稳,节点拆分胜在空间。在LeetCode的判题系统里,时间上两个差不多,因为O(n)和O(n)的差距并不明显,但内存上节点拆分明显好看不少。不过在日常工程里,单条链表的长度通常不会大到O(n)空间成为瓶颈,所以“哈希表优先”并不是什么丢人的选择。
4.2 面试时我建议这样答
我见过不少候选人,一上来就闷头写节点拆分,结果写了半天指针还绕错了,反而给自己挖坑。如果你是面试,我建议分两步展示:
第一步,先答哈希方案。讲清楚为什么要哈希表:“因为random指针指向的节点可能尚未创建,所以我需要一个映射关系,在原节点和新节点之间建立一一对应。”然后写出代码。这个方案讲清楚了,已经证明你理解深拷贝核心问题,能拿基础分。
第二步,在代码写完后主动补充:“如果面试官希望优化空间,我还可以用节点拆分法,把空间降到O(1)。思路是……”然后简单画图说明三步。这样一来,既展示了基础功底,又展示了你对优化方向有认知,比闷头写一种解法效果好得多。
关键点在于:先讲清思路再动手,不要边写边想。面试官考察的很大程度是“你是否能把自己的方案讲清楚”,而不是“代码能不能一次跑通”。这道题尤其如此,因为它算法本身不复杂,复杂的是指针关系。
4.3 面试官可能追问的变体问题
这道题被问变体的概率很高。以下几个追问方向,大家可以提前准备:
-
如果random指向的可能是任意节点,包括自己,你的解法还能工作吗?答案:都能。哈希方案因为key是原节点对象,不管指向谁都能在map里查到自己对应的新节点;节点拆分方案因为目标新节点永远紧跟在目标原节点后面,自己指向自己时
cur.random.next也是成立的。 -
如果链表中存在环(next指针成环),你的解法还成立吗?这是个陷阱。原题默认是普通无环单向链表,但如果真的成环,哈希迭代法依靠哈希表不会死循环,可以完成任务;回溯法因为有cached防重复也不会死循环;但节点拆分法第三步拆分时,如果next成环,拆分会非常麻烦,甚至死循环。所以遇到环的变体,老老实实用哈希法。
-
如果要求不修改原链表结构,节点拆分法还合法吗?节点拆分法的实现过程中确实临时修改了原链表的next指向,虽然最后恢复了,但严格说“中途修改过”。面官如果真的抠这个点,哈希方案更稳妥。
注意:这一类问题本质是在考“深拷贝的实现策略”。请一定在代码注释或讲解中强调,返回值必须是一整条独立的新链表,不能有任何节点与原链表共享。
5. 本地调试与举一反三
很多读者在LeetCode上做题习惯直接点“Run”,跑过了就不管了。但像随机链表这种指针密集型的题,我特别建议你在本地搭一个最小demo去打断点走一遍。这一步对理解节点拆分法尤其有效。
5.1 自己构造测试用例的方法
构造一个带random的链表,并不复杂。下面是Python的示例,可以用来验证你写的任何解法:
python复制# 构造链表:1 -> 2 -> 3
# random 指向:1.random -> 3, 2.random -> 1, 3.random -> 3(自指)
n1 = Node(1)
n2 = Node(2)
n3 = Node(3)
n1.next = n2
n2.next = n3
n3.next = None
n1.random = n3
n2.random = n1
n3.random = n3
s = Solution()
new_head = s.copyRandomList(n1)
# 验证:结构一致
cur1, cur2 = n1, new_head
while cur1 and cur2:
print(cur1.val, cur2.val)
cur1 = cur1.next
cur2 = cur2.next
# 验证:是深拷贝,不是同一个对象
print(n1 is new_head) # False
print(n1.next is new_head.next) # False
这个用例覆盖了三种random情况:指向尾部节点、指回头部节点、指向自身。能通过这三个指向的验证,基本说明你的实现没毛病。
如果你在本地断点调试节点拆分法,重点观察第三步循环里每个循环结束后的cur指向哪里,以及cur.next是否被正确恢复成原链表。我当时就是断点加打印,才真正弄明白copy.next = origin_next.next和cur.next = origin_next这两行代码为什么顺序不能颠倒。
5.2 常见报错与解决方案速查
我把大家在实际写代码时最常遇到的几个报错整理成一张表,方便对照排查:
| 报错提示 | 可能原因 | 解决办法 |
|---|---|---|
AttributeError: 'NoneType' object has no attribute 'next' |
第三步未判空就访问了origin_next.next |
加if origin_next保护 |
| Time Limit Exceeded | 递归回溯时cached哈希表为空或未使用,递归陷入死循环 | 检查是否每次创建节点后立即写入哈希表 |
| Wrong Answer(结果缺节点) | 第一步的遍历用了cur = cur.next而不是cur = cur.next.next |
插入copy后要跳两个节点 |
| Wrong Answer(random指向错误) | 第二步把copy.random = cur.random.next写成了cur.random |
记住copy节点在cur后面,所以指向的目标新节点也要“后移一位” |
| cycle detected | 第三步拆分恢复链表不干净,新链表的某个节点next指回了原链表 | 检查第三步中copy.next的赋值,确认每步都在两条链上正确跳跃 |
5.3 同一种套路能用在哪些题上
当你把这道题的“回溯+哈希”套路吃透以后,会发现很多题都是它的变体。最典型的是LeetCode 133克隆图——图的每个节点除了存值,还有邻居列表,复制时要保证“原图中的引用关系”在新图中一一映射。解法完全就是克隆随机链表的亲戚:一个原节点到新节点的哈希表,递归处理所有邻居,遇到已克隆的直接返回。
另一个相关的场景是“带随机指针的二叉树复制”这类自定义题,本质也一样。所以不要把这道题当一道孤立题来刷,而是把它当作“深拷贝复杂结构”这个知识点的代表。你甚至可以这样归纳:凡是复制一个带有多个引用关系的结构体,都可以考虑三步——第一,建立原对象到新对象的映射;第二,逐一遍历需要复制的字段;第三,如果存在循环引用,在映射建立后再赋值引用字段。
再有就是回溯思想本身的应用场景。哈希表解法里的“先登记再递归”,看似是为了防重,其实是一套通用的处理循环依赖模板。你在刷N皇后、单词搜索时遇到回溯,它在做试错回退;在克隆问题上,回溯配合备忘录做的则是“按需展开引用图”。这种“递归展开+哈希防重”的组合模型,值得深深刻在脑子里。
提示:强烈建议把两种解法都写在同一个本地工程里,互相跑同一个测试用例,对比结果。我刷这道题时,一开始只懂了哈希法,后来隔了一周重新写节点拆分法,第一次就写错了。这种题真的需要“隔一段时间再写一遍”,才能彻底内化成自己的东西。
