第一次做这道题的时候,我差点没被random指针绕进去。题目本身看起来就是普通的链表复制——值、next指针、一直往下走就完了,但多了一个random指针,情况就完全变了。这道题就是LeetCode Hot 100里的第138题:随机链表的复制。
它考的东西其实很纯粹:你有没有真正理解"深拷贝"的含义。很多刷题的人复制链表的时候,直接new一个节点,然后用next指针串起来,就以为完事了。但random指针的存在意味着节点之间的引用关系不再仅仅局限于线性方向,你必须站在"图"的视角去看问题。这篇题解我会从三种解法出发,把哈希表映射法、递归写法还有O(1)空间的原地拼接法全部过一遍,最后再聊一聊我在面试和实际项目里和深拷贝打交道的经历。适合正在刷Hot 100、准备面试,或者想系统理解深拷贝问题的人。
1. 这道题在Hot 100里算什么水平:先看懂题目本质
1.1 题目到底长什么样
138题的描述可以用一句话概括:给你一个普通单向链表,但每个节点除了value和next之外,还有一个random指针,你的任务是把整条链表原样复制一份,让复制品中的所有指针都指向复制品中的节点。
普通链表复制很简单,遍历一遍,new节点,用next连起来就好。但random指针的存在让"复制"变成了一件引用层面的问题。我举个例子:原链表上,A节点的random指向B节点。如果你只是复制了A节点,复制出的A'的random还指向原来的B,这就不对了。深拷贝要求A'.random指向B',也就是原节点在复制链表中的对应节点。
题目给的结构是这个样子:
code复制class Node:
def __init__(self, x: int, next: 'Node' = None, random: 'Node' = None):
self.val = int(x)
self.next = next
self.random = random
注意random字段是一个引用类型,它可以指向任意位置的节点,也可以指向null。这个"任意位置"四个字,就是整道题的题眼。
1.2 random指针为什么让人头疼
random指针可以指向任意位置,甚至指向null。这意味着,你不能一边复制节点一边立即设置random,因为当你复制到第i个节点时,第j个节点(random指向的那个)可能还没被创建出来。好,那有人会说,先创建所有节点,再设置指针。这就引出了第二个问题:怎么知道"原链表中的节点x"对应"新链表中的哪个节点"?这就是唯一的关键问题。
前面两种方法都是围绕这个对应关系展开的。哈希表直接用映射解决,原地拼接则利用插入位置来隐式保存关系。本质上,这道题考的就是一件事:如何在两个结构相同、但节点完全不同的链表之间维护映射关系。
不少人第一反应是"我复制值不就行了",复制完整个链表才想起来random还指着一个陌生的节点,然后开始怀疑人生。这个坑我当年也踩过,所以我想先用一种最直观的方式来拆解。
1.3 这道题在面试中扮演的角色
138题在Hot 100里属于高频常客,原因是它不像很多链表题那样只需要背模板。这道题要求面试者理解引用、深拷贝、映射关系,又可以通过不同解法来区分候选人水平。
如果你只能写出哈希表解法,说明你掌握了基本解法;如果你能提出O(1)空间的原地解法,面试官会觉得你对链表的操作足够熟练;如果你还能把思路抽象到深拷贝任意图结构,那基本就是加分项。这题在面试里的定位很像一个"分层筛选器",从暴力到最优解,每一层都能看出候选人的思维深度。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 两种常规解法:哈希表和递归,先把分拿到手
2.1 哈希表映射法:两次遍历解决随机指针
先讲最容易想到的写法。既然问题核心是"怎么知道原节点对应哪个新节点",那最直接的办法就是建立一个映射表。
第一次遍历原链表,不管next和random,只做一件事:为每一个原节点创建一个新节点,然后用哈希表把它们对应起来。第二次遍历原链表,这一次我们来设置新链表每个节点的next和random。因为此时所有新节点都已经创建好了,哈希表可以随时查到任意原节点对应的新节点。
code复制class Solution:
def copyRandomList(self, head: 'Node') -> 'Node':
if not head:
return None
old_to_new = {}
cur = head
# 第一遍:创建新节点,建立映射
while cur:
old_to_new[cur] = Node(cur.val)
cur = cur.next
# 第二遍:设置 next 和 random
cur = head
while cur:
old_to_new[cur].next = old_to_new.get(cur.next)
old_to_new[cur].random = old_to_new.get(cur.random)
cur = cur.next
return old_to_new[head]
这里有个细节值得注意:我用的是old_to_new.get(cur.next),而不是直接old_to_new[cur.next]。为什么要用get?因为random和next都可能指向null,而null不会出现在哈希表里。如果直接用中括号取值,遇到null时就会抛KeyError。用get可以在找不到key时返回None,然后代码里的next和random就会被赋值为None,正好符合要求。
哈希表法的时间复杂度是O(n),空间复杂度是O(n),因为额外用了长度n的哈希表。这个解法的优点是逻辑清晰、不容易出错,面试时先写这个,基本能保证拿到及格分。
2.2 递归加备忘录:让系统栈替我们干活
除了哈希表迭代法,还有一种思路更贴合人脑的直觉:递归。
想象一下:要复制一个节点,我需要复制它的next指向的节点,也需要复制它的random指向的节点。这两个复制任务本质上是独立的,所以可以递归地做。为了避免重复创建同一个节点(比如两个节点的random都指向同一个节点),需要一个备忘录来记录已经创建过的节点。
code复制class Solution:
def copyRandomList(self, head: 'Node') -> 'Node':
memo = {}
def dfs(node):
if not node:
return None
if node in memo:
return memo[node]
new_node = Node(node.val)
memo[node] = new_node
new_node.next = dfs(node.next)
new_node.random = dfs(node.random)
return new_node
return dfs(head)
这段代码的核心在于memo[node] = new_node这行放在递归之前。如果先递归后记录,遇到链表成环的情况就会死循环。138题的链表虽然没有环形next,但random可能互相指向,比如A.random指向B,B.random指向A,如果不提前记录,递归会无限套娃。
备忘录法的时间和空间复杂度同样是O(n)。递归的优点是代码短、可读性好,缺点也很明显,如果链表非常长,递归深度太大可能导致栈溢出。面试时可以用它展示思路的多样性,但工程上我更倾向于迭代写法。
2.3 这两种方案的取舍
哈希表法和递归法本质是同一套思路,只是实现形式不同。哈希表法用显式的循环和字典,递归法用系统栈和闭包。两者都需要额外O(n)的空间,性能上几乎没有差别。
但在面试场景里,我建议优先说哈希表法,因为它把"映射关系"这四个字体现得最直白。面试官问"还有没有更优解"的时候,再引出下一章的原地拼接法。
3. 原地拼接法:O(1)空间的答案,为什么面试官更看重这一步
3.1 为什么要把新节点插在原节点后面
哈希表法已经能解决问题,面试官却往往会追问一句:"能不能不用额外空间?"
不额外存映射关系,就意味着必须有一种方式,让我们看到原节点A时,能立刻知道它对应的新节点A'在哪里。有一种很取巧的思路:把每个新节点直接插在原节点的后面。这样一来,任何一个原节点cur,它的next就是它对应的新节点cur'。
链表从1 -> 2 -> 3变成1 -> 1' -> 2 -> 2' -> 3 -> 3'。此时原节点和新节点交错排列,关系就藏在了next指针里,不需要额外用哈希表记录。这就是原地拼接法的核心思想。
3.2 三步走的完整代码
整个算法分三步:
第一步,遍历原链表,在每个节点后面插入一个值相同的新节点。
第二步,再次遍历原链表,设置每个新节点的random指针。原节点cur的random如果指向某个节点R,那么cur'的random就应该指向R后面的那个R'。用代码表达就是cur.next.random = cur.random.next。
第三步,把交错链表拆成两条独立链表。奇数位置是原链表,偶数位置是复制链表。
code复制class Solution:
def copyRandomList(self, head: 'Node') -> 'Node':
if not head:
return None
# 第一步:在每个原节点后插入复制节点
cur = head
while cur:
new_node = Node(cur.val)
new_node.next = cur.next
cur.next = new_node
cur = cur.next.next
# 第二步:设置复制节点的 random
cur = head
while cur:
if cur.random:
cur.next.random = cur.random.next
cur = cur.next.next
# 第三步:拆分链表
new_head = head.next
cur = head
while cur:
copy_node = cur.next
cur.next = copy_node.next
if copy_node.next:
copy_node.next = copy_node.next.next
cur = cur.next
return new_head
第三步这步我第一次写的时候卡了很久。拆分操作里,cur.next = copy_node.next先把原链表的next恢复,然后copy_node.next = copy_node.next.next把复制链表的next接上。注意要判断copy_node.next是否为空,否则最后一步会访问None.next报空指针。我在这里漏判过,LeetCode直接报AttributeError。
3.3 原地法的边界条件和细节
边界条件主要在三个方面:
空链表:直接返回None。完整的链表在第三步拆分时,最后一个节点要特别小心。假设链表长度为原链表长度n,交错后长度为2n。当cur移到倒数第二个节点时,copy_node是最后一个节点,此时copy_node.next为None,不能继续访问。上面代码里加了判断,就是为了应对这个情况。
random为null:第二步里我只判断了if cur.random,只有不为空才设置。如果cur.random为null,那么cur.next.random也应该保持null。新建节点时random默认就是null,所以不操作就是正确的。
链表只有一个节点时:head为单独节点,第一步插入后变为node -> node' -> null,第二步设置random,第三步拆分后原链表和新链表都是单节点,返回new_head即可。这个边界情况跑一遍流程就能确认没问。
我建议刷这道题的时候,至少手动跑三个用例:普通链表、random指向null的链表、两个节点random互相指向的链表。手动跑完,代码里那些边界条件就会记得特别牢。
3.4 拆分链表时最容易写错的地方
拆分阶段有几种常见写法,但都容易踩坑。我见过有人把第三步写成这样:
code复制new_head = head.next
cur = head
while cur and cur.next:
copy_node = cur.next
cur.next = copy_node.next
copy_node.next = cur.next.next
cur = cur.next
这版其实也能跑,但copy_node.next = cur.next.next这种写法不够直观,一旦cur为None就会出问题。我建议采用更保守的写法:每次都先取出copy_node,然后分别处理原链表的next和复制链表的next,再移动cur。这种写法虽然多几行,但逻辑清楚,不容易在边界处翻车。
另外还有一点,拆分完成后原链表应该恢复原样。很多刚学的人只记得返回新链表,忘记修复原链表。虽然在LeetCode的判题系统里不修复原链表也能通过,但面试的时候,面试官很可能会检查你的代码是否破坏了输入链表。养成"复制完毕后不修改原数据"的习惯,在工程里同样重要。
4. 把几种解法放一起比较:面试现场到底先说什么
4.1 时间和空间的硬指标对比
| 解法 | 时间复杂度 | 空间复杂度 | 核心思路 |
|---|---|---|---|
| 哈希表法 | O(n) | O(n) | 用字典维护原节点到新节点的映射 |
| 递归法 | O(n) | O(n) | 递归+备忘录,防止重复创建 |
| 原地拼接法 | O(n) | O(1) | 新节点插在原节点后,隐式保存映射 |
三种解法的时间复杂度都是O(n),差别主要在空间。哈希表法最直观,原地拼接法最省空间,递归法代码最简洁但是有栈溢出风险。
这里多说一句,时间复杂度O(n)是每个解法都要遍历链表若干次的结果,常数因子不同。哈希表法遍历2次,原地拼接法遍历3次,但都是线性级别,在面试分析里面通常不会刻意区分常数,只要不是O(n²)就合格。
4.2 面试官最常追问的路径
面试官通常不会满足于一个解法,他们会沿着这套逻辑往下问:
先是"怎么写"——你写哈希表法,考察编码基本功。然后是"能不能优化空间"——你写原地拼接法,考察对链表结构的理解。接着可能问"如果random指向的节点还没被创建呢"——用递归+备忘录或者两次遍历都可以覆盖。最后还可能问"如果链表变成了图呢"——这就是我们下一章要说的内容。
回看这些追问,你会发现每个问题都是在考察你对"引用关系"的理解深度,而不是在考背题。所以刷138题的时候别只背代码,要把每一步的意义讲清楚。
4.3 实现中值得注意的代码习惯
我见过很多人在写哈希表法时,习惯用if nxt in old_to_new来判断,然后再赋值。这在功能上没问题,但代码会更长,而且容易在遍历过程中漏掉某个分支。用old_to_new.get(cur.next)一行就搞定,简洁且避免KeyError。这种小技巧在面试中能帮你节省不少时间,也能让代码显得更老练。
另一个建议是变量命名。面试时写old_to_new就比写map清晰得多,copy_node比temp更直观。可读性好的代码在面试中天然占优势,因为面试官不一定有时间跑你的代码,他靠读来判断你的思路,清晰命名就是在帮面试官降低阅读成本。
5. 从"复制链表"到"深拷贝任意图结构":一种通用的解题模型
5.1 链表复制是图复制的特例
把链表看成图的话,每个节点最多有两个邻居:next和random。整个复制过程可以抽象成"遍历原图的所有节点,为每个节点建立副本,再建立副本之间的边关系"。这个描述对链表、树、图都适用。
所以138题并不只是一道链表题,它其实是深拷贝问题的入门。理解了它,你就理解了深拷贝的核心套路:建立原节点到副本节点的映射,然后根据原节点的邻居关系,把副本节点的邻居也连好。
5.2 克隆图的解题思路
LeetCode 133题"克隆图"就是同一个模型的直接应用。题目给了你一个无向连通图,每个节点的定义是这样的:
code复制class Node:
def __init__(self, val = 0, neighbors = None):
self.val = val
self.neighbors = neighbors if neighbors is not None else []
这题和138题唯一的区别在于:每个节点不再只有next和random两个邻居,而是有一个neighbors列表。但核心思路完全一样,先建映射,再根据原图关系设置副本节点的邻居。
如果你看过138题的哈希表解法,再看133题的代码,会发现几乎是同一个模板:
code复制class Solution:
def cloneGraph(self, node: 'Node') -> 'Node':
if not node:
return None
old_to_new = {}
def dfs(node):
if node in old_to_new:
return old_to_new[node]
copy = Node(node.val)
old_to_new[node] = copy
for neighbor in node.neighbors:
copy.neighbors.append(dfs(neighbor))
return copy
return dfs(node)
这就是为什么我强烈建议把138题搞懂,它积累的解题模型能直接迁移到图论题目上。
5.3 深拷贝在工程里的现实场景
深拷贝不只是面试题,工程里到处都有它。比如你在做一个文档编辑器,用户在草稿箱里编辑了一份文档,你要生成一份快照,避免后续操作污染原文档;又比如你在做游戏里的存档系统,角色状态树、物品列表这些数据都需要深拷贝之后才能安全地修改和试算;再比如数据库的读写分离架构中,从库的数据快照本质上也是一棵巨大的对象树的复制。
在这些场景里,对象之间往往存在循环引用和复杂的交叉引用。如果只用Python的copy.deepcopy,当数据量很大时会非常慢;如果只写浅拷贝,又会发生两个对象共享子节点的诡异问题。这时候,138题里建的映射思想就派上了用场:先复制节点,再处理引用,确保每个对象只被真正复制一次,而不是复制多个副本。
我自己在做数据同步工具的时候,就遇到过类似的场景。两个服务之间的对象图结构几乎一样,但每个对象的id不同,直接赋值会导致下游拿到错误的引用。当时我用了一个全局字典来保存"原对象到新对象"的映射,遍历完再根据映射修正所有引用,本质上就是138题的哈希表解法。刷题时觉得这种写法太平常,真到工程里用起来,才知道这个模式有多重要。
所以这道题我刷了很多遍,每次刷都有新收获。起初只是纠结代码怎么写才能通过测试用例,后来开始想怎么把空间优化到O(1),再后来看到它和图复制题的共同点。如果你能沿着这条路径去理解138题,它对你的价值就远远不止一道Hot 100题解那么简单。
