1. 题目拆解:难点不在一眼能看穿的地方
1.1 题目到底在考察什么
LeetCode 138“随机链表的复制”,我第一次刷到时候觉得它就是一道“复制链表”题,和普通链表的复制没啥区别。直到自己动手写才发现,真正的麻烦全在 random 这个指针上。
题目给出的节点结构大概是这样的,每个节点除了 next,还会有一个 random 指向链表中的任意一个节点或者 null。你最终要返回一个全新的链表,新链表里的每个节点都要另外创建,并且新节点的 random 要指向新链表中对应的节点,而不是老链表中的节点。
cpp复制class Node {
public:
int val;
Node* next;
Node* random;
Node(int _val) {
val = _val;
next = NULL;
random = NULL;
}
};
这个题目表面上是链表操作,实际考的其实是深拷贝。一个工程上最典型的深拷贝场景就是:两个对象结构一样、数值一样,但内部相互引用的关系也要一起搬过去,而且不能影响原来的对象。链表版本的深拷贝把这个问题浓缩得很漂亮,所以特别适合用来考察候选人对“对象引用关系”的理解。
我个人的建议是,刷这题之前先想清楚一件事:如果你只知道链表的 next 顺序,能不能完整恢复出整个结构?显然不能,因为 random 可能指向你当前遍历时还没到达的节点。这就是整道题的核心矛盾,也是为什么它值得单独拿出来写一篇。
1.2 如果只按普通链表复制,会踩到什么坑
你可以试试先只复制 val 和 next,得到一条看起来一模一样的新链表,然后回头设置 random。这时候问题就出现了:新链表里没有任何信息能告诉你“老节点 A 的 random 对应的是新链表里的哪个节点”。
举个最简单的例子,原链表有 1、2、3 三个节点,其中 1 的 random 指向 3。你按普通链表复制出了 1'、2'、3',当你想让 1' 的 random 指向 3' 时,你会发现代码根本不知道 3' 在哪儿。如果直接写 new Node(cur->random->val),那 1' 的 random 会指向一个孤零零的新节点,这个新节点的 next 和 random 全部为 null,跟整个链表的其他部分没有任何关系。
这种问题最坑的地方在于:链表本身的节点都能串成一条线,你以为复制成功了,但只要沿着 random 往深处走,就会发现存在一个“断了逻辑”的游离节点,程序运行偶尔还能通过,一旦有逻辑依赖 random 的 next 就会立刻崩。
所以说,这道题真正需要的是一个从“老节点”到“新节点”的映射关系。有了这个映射,看到老的 random 目标,你才知道该把新的 random 填给谁。顺着这个思路,最直接的办法就是用哈希表。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 哈希表解法:先建映射,再补引用,思路最稳
2.1 为什么哈希表解法看起来如此自然
哈希表解题的核心,是把“同步复制”改成“分两步走”。第一轮遍历只创建节点,不处理任何连接关系,用一个哈希表把每个老节点和对应创建出来的新节点存成一对。第二轮遍历时,你再从头走一遍,这次每个老节点都能通过哈希表立刻找到它的新副本。
这里有个很关键的体会我多说一句:第二轮设置 random 的时候,random 指向的目标节点一定已经在哈希表里了。为什么?因为第一轮已经把整条链表的节点全创建完了,不管 random 指向链表里的哪个位置,它对应的新节点都已经被创建完毕并被记录在哈希表里了。所以你可以放心大胆地查表,不需要判断目标是否已经初始化。
这其实就是工程上处理复杂对象图深拷贝的通用套路:先把整个结构里的对象全部创建出来,再回头补对象之间的相互引用。很多人一开始想不明白为什么能遍历两次,其实“先建对象、再补引用”的顺序,天然避免了在某个引用目标还没创建时就要写指针的尴尬。
2.2 C++ 和 Python 的完整实现
C++ 版本的核心逻辑很直白,两轮遍历之后返回 map[head] 即可:
cpp复制class Solution {
public:
Node* copyRandomList(Node* head) {
if (!head) return nullptr;
unordered_map<Node*, Node*> map;
Node* cur = head;
// 第一轮:创建所有新节点,建立老节点到新节点的映射
while (cur) {
map[cur] = new Node(cur->val);
cur = cur->next;
}
// 第二轮:连接 next 和 random
cur = head;
while (cur) {
if (cur->next) {
map[cur]->next = map[cur->next];
}
if (cur->random) {
map[cur]->random = map[cur->random];
}
cur = cur->next;
}
return map[head];
}
};
Python 版本用字典实现,因为 Python 的类默认就是可哈希的,直接用老节点作为 key 就可以了:
python复制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:
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]
关于 if (cur->next) 和 if (cur->random) 这两个判断,我建议保留,而不是直接写成 map[cur]->random = map[cur->random]。否则像 C++ 里 unordered_map 的 operator[] 会额外插入一个以 nullptr 为 key 的空映射,虽然运行结果大概率没错,但这个行为本身并不是你显式想要发生的。万一未来某个环境对 nullptr 作为 key 有特殊处理,排查起来会非常痛苦。从写代码的严谨度来讲,显式判空是更好的习惯。
2.3 哈希表方案在面试中的定位
哈希表方案的时间复杂度和空间复杂度都是 O(N),其中 N 是链表节点个数。这个方案最大的优点是正确性容易保证,思路几乎不会写在第二遍时卡壳。我见过很多同学第一次接触这题,第一遍就试图同步处理 next 和 random,结果把自己绕晕了。哈希表方案应该是你随时能写出来的保底解。
但从面试官视角看,哈希表方案只是一个“及格解”,不是“优秀解”。原因也很简单:题目空间复杂度 O(N) 并不算最优。如果面试官接着问“你能不能把这个额外空间去掉”,而你从来没有思考过原地做法,那这场面试在这一题上的上限就很清楚了。
所以我的经验是:这题不要只满足于哈希表,后面这个原地复制法一定要自己动手推一遍,要不然面试时很容易被追问打懵。
3. 原地复制法:O(1) 额外空间的核心是“让新节点待在老节点身后”
3.1 插一个拷贝节点到每个老节点后面
原地复制法的思路非常巧妙,简单说就是三步走:第一步,在每个老节点的后面插入一个值相同的新节点;第二步,给所有新节点设置 random;第三步,把长链表拆回原来的老链表和新的链表。
插入完之后,链表结构从原来的 A -> B -> C 变成:
text复制A -> A' -> B -> B' -> C -> C'
为什么插入这个动作能替代哈希表?因为新节点 A' 紧跟老节点 A,这就形成了一种无需额外空间的“位置映射”:想看老节点 A 对应的新节点,直接取 A->next 就行了。对 random 来说,A 的 random 指向老节点 C,那么 A' 的 random 就应该指向 C 对应的新节点 C',而 C' 就是 C->next。所以真正的赋值逻辑就一行:
text复制A'->random = A->random->next
这个设计最妙的点在于,它把哈希表里“老节点与拷贝节点的映射关系”,直接编码成了链表的相邻关系。随机指针再随机,目标节点后面一定有它的拷贝节点,所以通过两步跳转就能完成 random 的正确指向。
3.2 random 赋值环节的边界处理
第二轮在赋值 random 的时候,注意一个非常重要的边界条件:random 可能为 null。如果 A 的 random 指向空,那么 A->random 就是 NULL,此时直接访问 A->random->next 必然产生空指针异常。
所以赋值前必须先判断 cur->random 是否为 nullptr,只有非空才执行 cur->next->random = cur->random->next;。
这一轮本质上还是在跳老节点的位置,所以步长必须是“两步”:cur = cur->next->next,这样循环变量永远停留在老链表节点上。这里很容易写顺手,用了 cur = cur->next,结果下一次循环对老节点和拷贝节点各处理了一次,random 会乱掉,代码可能非常隐蔽地出错。
3.3 完整代码实现
C++ 完整实现如下,关键步骤我在注释里标了出来:
cpp复制class Solution {
public:
Node* copyRandomList(Node* head) {
if (!head) return nullptr;
// 第一轮:在原节点后面插入拷贝节点
Node* cur = head;
while (cur) {
Node* copy = new Node(cur->val);
copy->next = cur->next;
cur->next = copy;
cur = copy->next;
}
// 第二轮:为拷贝节点设置 random
cur = head;
while (cur) {
if (cur->random) {
cur->next->random = cur->random->next;
}
cur = cur->next->next;
}
// 第三轮:拆分链表
Node* dummy = new Node(0);
Node* tail = dummy;
cur = head;
while (cur) {
tail->next = cur->next;
tail = tail->next;
cur->next = cur->next->next;
cur = cur->next;
}
return dummy->next;
}
};
Python 版本思路一致,只是语法上稍微不同:
python复制class Solution:
def copyRandomList(self, head: 'Node') -> 'Node':
if not head:
return None
# 插入拷贝节点
cur = head
while cur:
copy = Node(cur.val)
copy.next = cur.next
cur.next = copy
cur = copy.next
# 设置 random
cur = head
while cur:
if cur.random:
cur.next.random = cur.random.next
cur = cur.next.next
# 拆分
new_head = head.next
old_cur = head
new_cur = new_head
while old_cur:
old_cur.next = old_cur.next.next
new_cur.next = new_cur.next.next if new_cur.next else None
old_cur = old_cur.next
new_cur = new_cur.next
return new_head
3.4 很多人会忽略的细节:要不要恢复原链表结构
网上很多题解在拆分环节里只拆出新链表,不管老链表变成了什么样。在 LeetCode 的判题环境下,通常只检查你返回的新链表是否正确,原链表是否被破坏并不影响提交结果。
但站在面试的角度,题目说的是“复制”,不是“把原链表改造成另一条新链表”。一名工程素养过关的候选人应该意识到,深拷贝操作不应该对原对象产生副作用。如果你能在拆出新链表的同时,把老链表的 next 恢复成原来的样子,这个细节会明显提升面试官对你的评价。
我的习惯是:在原地的第三轮遍历中,一边把拷贝节点摘出来串成新链表,一边把老节点之间的 next 还原回去。上面的 C++ 代码里 cur->next = cur->next->next; 这一行就是在做这件事,不要省略。
如果你在面试时只写了最简单的拆分逻辑,面试官追问“原链表被破坏了怎么办”,别慌,补上恢复逻辑并解释两句即可。但最好的表现还是主动一步到位。
4. 常见错误、边界条件与调试经验
4.1 我最容易写错的几个地方
这题我刷到 Day 12 的时候,已经不只是第一遍遇到它了,这里总结几个高频翻车点,给大家避坑。
第一个翻车点是第二轮循环步长不正确。原地法里,插入拷贝节点之后,链表长度翻倍。想遍历只处理老节点,就要保证每次从老节点跳到下一个老节点,也就是必须 cur = cur->next->next。如果误写成 cur = cur->next,就会把拷贝节点也当成处理对象,第二轮赋值时 random 会被乱七八糟地覆盖。
第二个翻车点是拆分环节对空指针的判断。链表长度是奇数个的时候,最后一个老节点的下一个节点就是它的拷贝节点,没有更多的后继节点。你需要在摘出新节点之后小心地处理 new_cur->next 是否为空,尤其是类似 Python 那种写一行三元表达式的场景,稍不留神就会对 None 取 next。
第三个翻车点是有些同学会下意识地在第一轮插入时就已经尝试处理 random,结果发现自己当前所在节点的 random 目标可能还没有被插入到它后面。这个顺序性问题其实和哈希表解法是同样的道理:你必须先完成“所有拷贝节点的存在”,再谈“random 指向谁”,一上来就急着补 random 一定是错的。
我还见过一个比较隐蔽的误区,就是误以为“原地法不需要 new 新节点,因为是原地复制”。其实这里的 O(1) 指的是额外空间不包括最终返回的新链表空间。题目要求返回一份新的链表,新节点是必须全部创建的,总空间复杂度仍然是 O(N)。
4.2 值得优先测试的几组边界用例
写算法题,边界用例就是试金石,这里有一套我建议的测试组合,足够覆盖绝大部分隐藏问题:
| 场景 | 可能暴露的问题 |
|---|---|
| 链表为空,head 为 null | 直接返回 null,不能进入后续逻辑 |
| 只有一个节点,random 指向自己 | 检查能不能正确形成自环 |
| 两个节点,random 互相指向对方 | 检查交叉引用是否正确复制 |
| random 指向 null | 检查会不会对 null 解引用 |
| 普通长链,random 指向中间的某个节点 | 检查复制后 random 是否指向新链表的对应节点,而不是老节点 |
自己写测试用例的时候,可以构造完原链表后,分别打印原链表和新链表每个节点的地址,以及它们 random 指向的地址。如果新节点的 random 指向的地址还是原链表里的节点,那说明你的 random 没有完成跨链映射,这是最直观的排查方法。
4.3 哈希表和原地法应该如何取舍
刷题阶段,我的建议是两个方案都要能默写。哈希表方案适合作为思考出发点,快速证明自己思路正确;原地方案适合在面试中展示优化能力。实际写题时,如果没有空间限制的硬性要求,哈希表方案完全够用,而且代码可读性更好。但如果题目明说“不能使用额外 O(N) 空间”,那原地复制就是标准答案。
时间和空间的取舍还可以从工程角度理解。哈希表方案的代码更容易维护和扩展,如果后续要求“只复制满足某种条件的子链表”,哈希表可以很灵活地过滤节点;原地方案则更节省内存,适合处理节点数量非常大的场景。但原地方案会临时修改原链表结构,如果业务对并发读有要求,这种操作很可能引发线程安全问题,工程上就需要额外小心。
我在自己练习时,会刻意先用哈希表做一遍,再用原地法做一遍,最后把两个版本的复杂度和优缺点口头讲一遍。能讲明白,才说明是真正理解了。
4.4 如果面试官继续追问,如何不掉链子
这道题很常见的追问是“你的哈希表解法能不能改成不用额外空间”。这时候把原地解法讲出来即可。还有可能追问“如果原链表不允许被修改,你还能用原地法吗”。注意,原地法的“插入”和“拆分”阶段都涉及修改老节点的 next 指针,所以不能说是完全不破坏原链表,除非你在拆分的最后把老链表的 next 全部恢复原状。这也是我在前文强调恢复原链表结构的原因,它是应对这类追问的完美收尾。
再有就是问复杂度,比如“在什么极端情况下两种解法性能差不多”。如果链表本身很短,哈希表额外空间开销不大,两种办法实际上差距很小。节点数达到几十万甚至百万时,原地法的内存优势才会真正显现。
5. 这个思路还能迁移到哪里去
5.1 克隆图(Clone Graph)就是同一个套路
LeetCode 133 克隆图,本质上和带 random 指针的链表是一模一样的难点。图里的每个节点会通过邻居列表指向其他节点,有些邻居也可能回头指向自己,形成环。如果按普通思路先复制一个节点,再顺着邻居去复制邻居,很容易陷入死循环,或者把同一个节点复制出很多份。
标准的解法还是那个步骤:第一遍遍历,把所有新节点先创建出来,并且用哈希表记录原图和副本的对应关系;第二遍遍历,根据原图的邻居关系,把副本的邻居列表填充完整。我在做完 LeetCode 138 之后再去看克隆图,发现几乎不需要额外学习,因为核心思想完全一致。
5.2 复杂对象深拷贝的通用思路
如果抛开题目本身,这道题背后的思想在工程里也有用武之地。现代编程里对象之间的引用关系往往是一张网,比如一个配置文件可能引用了多个子模块,子模块之间又互相引用。要对这种对象做深拷贝,无脑递归很容易爆栈或者产生重复副本,正确做法就是先创建所有空对象,再统一填充内部引用。这也是不少序列化、反序列化框架内部使用的策略。
所以我很推荐把 LeetCode 138 当作一道“深拷贝思想”的入门题来对待,不要只把它想象成链表知识。理解了“先建对象,再补引用”的套路,你以后遇到图结构的深拷贝、配置文件快照复制、对象状态备份等真实场景,思路都会清晰很多。
我在刷这题的时候反复提醒过自己一句话:所有看起来跳来跳去的指针操作,本质上都是节点间关系的翻译。老版本的结构信息已经存在了,你要做的只是用同样的关系生成一组平行版本,同时让两组结构互不干扰。想明白这一点,代码只是顺理成章的表达而已。
