
1. 这个“复制”到底难在哪随机链表的核心矛盾1.1 题目到底在说什么LeetCode 第 138 题中文名“随机链表的复制”原题英文叫 Copy List with Random Pointer。题目给了一个单链表每个节点除了有 next 指向下一个节点还有一个 random 指针可以指向链表中任意一个节点也可以指向 null。要求你返回一个深拷贝的新链表新链表里每个节点都是 new 出来的新对象但整个结构和原链表完全一致包括那根看似毫无规律的 random 指针。节点定义通常是这样的class Node { public: int val; Node* next; Node* random; Node(int _val) { val _val; next NULL; random NULL; } };可能有人一看到题目就说这不就复制一遍链表嘛。但等你真正动手写会发现 random 指针这根线特别拧巴。普通链表的复制只要顺着 next 走下去一个个 new 节点再接起来就够了因为所有节点之间的关系是线性确定的。可 random 是乱指的你复制到第 3 个节点的时候它的 random 很可能指向第 100 个还没复制到的节点当时你根本不知道“第 100 个新节点”住在哪块内存里。这就引出了整个题目的核心矛盾在复制过程中你必须建立“旧节点 - 新节点”的映射关系。记住这句话它是所有解法的基础也是很多面试官希望从你嘴里听到的第一句话。1.2 为什么不能像普通链表那样直接复制我们来推演一个最朴素的错误做法。你先顺着 next 把新链表复制出来此时每个新节点的 random 还没有设置。然后你从头再走一遍每到一个旧节点就想去找到“旧节点 random 指向的那个节点”对应的新节点。问题来了你手上只有一个旧节点地址怎么在 O(1) 时间内知道它对应的新节点地址如果你没有任何映射表就只能从头遍历新链表逐个比较 val 或者地址直到找到对应节点。这就是 O(n^2) 算法链表稍微长一点就特别难看。所以结论很明确要么花 O(n) 空间存下映射关系哈希表法要么花点心思把映射关系藏在链表结构本身里原地穿插法。此题没有任何第三种符合“深拷贝”语义的更优做法你能想到的第三方案基本都会退化成 O(n^2)。顺便说一下这道题非常适合用来检验一个人对“深拷贝”的理解程度。你新建的节点如果只是复制了 val但 random 指针还指向原链表的旧节点那这就叫浅拷贝拷贝一份之后两边数据互相干扰工程里这种 bug 极其隐蔽不是立刻崩溃而是等你改了某一个节点另一个人读取时发现数据变成了你改过的值。随机链表复制本质上就在逼迫你把“引用关系”彻底映射到新地址空间。1.3 谁需要认真搞定这题如果你是准备大厂笔试或者校招面试这题属于链表专题里的高频题出现频率相当高。它不像 LRU 那种长得很吓人的题目代码量很少但考察的点很集中有没有映射意识、会不会处理空指针、能不能控制空间复杂度。面试官很爱在一道小链表题里连续追问如果你能从容地把哈希表方案和原地方案都讲清楚这题就能成为一个很稳的加分项。如果你已经在做工程这题同样有现实意义。比如你写一个配置管理工具要对一份包含嵌套对象、循环引用的配置做深拷贝或者你做一个数据导出模块需要把一张带外键关系的数据库表复制到另一张表外键就是这里的 random 指针。懂得了随机链表复制的核心思路你在面对这些更宏大的工程问题时至少知道第一步永远是“先把映射关系建立起来”。2. 哈希表映射法最直白也最不容易出错2.1 核心思路拿一张“新老对照表”锁死映射哈希表方案的做法非常直接。第一遍遍历原链表把所有节点都 new 出来并记录下“原节点地址 - 新节点地址”的对应关系。此时新节点的 next 和 random 都先不管只把 val 复制进去。第二遍遍历原链表这时候你手里已经有了完整的映射表不管是设置 next 还是设置 random都能在 O(1) 时间内查到“旧地址对应的新地址”。以下是这个思路的伪代码级别描述遍历原链表为每个旧节点创建一个新节点存入哈希表 old_to_new[old] new。再次遍历原链表根据映射表设置每个新节点的 next 和 random。返回映射表中 head 对应的新节点。第一遍遍历结束后映射表已经把“所有新节点”都准备齐全所以第二遍遍历时无论旧节点的 next 或 random 指向哪个节点对应的新节点都已经存在你只负责接线就行。这就解决了我在前面说的那个拧巴问题random 指向还没创建的节点不存在的因为第一遍你把它创建完了。2.2 完整代码与逐行解读这里我用 C 写一个完整实现核心代码不超过二十行Node* copyRandomList(Node* head) { if (!head) return nullptr; unordered_mapNode*, 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]; }几个值得注意的细节。第一为什么要判断 cur-next 和 cur-random 是否为 null因为 unordered_map 不处理 nullptr 作为 key如果你直接写 map[cur-next]当 cur-next 为 null 时这个操作会出问题。其实原则上可以让新节点的 next 和 random 默认就是 null所以只对非空的情况赋值即可。第二返回 map[head] 而不是自己另存一个头节点因为映射表已经保存了 head 对应的新地址。第三这段代码把一个关键的工程习惯体现得特别明显先创建全部节点再统一接线。这个顺序能避免很多“接线时发现目标节点还不存在”的问题。很多语言里这个解法也都有对应的写法。Python 用 dictJava 用 HashMap写起来几乎一模一样。思路不变换语言只是换语法。2.3 时间与空间复杂度O(n) 换 O(n)哈希表法的时间复杂度是 O(n)第一遍和第二遍都是线性扫描空间复杂度是 O(n)因为存的映射项数和链表节点数一样多。对于单链表来说n 就是节点个数这个额外空间很小尤其是在现代 64 位系统上一个指针 8 个字节10 万个节点也就 80 万字节完全在可接受范围内。但注意哈希表本身是有开销的。unordered_map 底层是哈希桶插入和查询平均 O(1)但存在哈希冲突时会有额外成本还会动态扩容。如果链表长度只有几十几百这些都不重要如果链表有几百万节点哈希表内存占用会被放大到不止节点数乘 8 字节因为每个桶还要存储状态信息。这也是为什么面试场景里经常追加一句“能不能把空间优化到 O(1)”而原地穿插法的出现就是冲着这个点去的。我个人的建议是笔试做题时如果没有特殊限制优先用哈希表方案因为它正确率高、代码短、调试成本低。原地穿插法虽然空间更优但指针操作复杂一旦写错排查时间比省下的那点空间划算得多。不过面试时面试官就是想看你能否写出原地方案所以我们下一节认真拆解它。3. 原地穿插法把空间优化到 O(1)3.1 三步走穿插克隆节点、设置随机指针、拆分链表原地法的思路比哈希表法多一个“转折”但理解了之后会觉得非常巧妙。核心思想是既然需要查找“旧节点的克隆节点”那我干脆让每个旧节点的 next 指向它的克隆节点这样不需要额外哈希表通过 old-next 就能直接找到克隆节点。代价是链表暂时变长一倍然后再拆开恢复原状。具体分为三步。第一步遍历原链表在每个旧节点后面插入一个新节点。比如原链表是 A - B - C插入后变成 A - A - B - B - C - C。A 是 A 的克隆B 是 B 的克隆以此类推。第二步遍历这个被“拉长”的链表设置克隆节点的 random。对于每个旧节点 cur它的克隆节点是 cur-next。旧节点的 random 如果指向某个节点 R那么克隆节点 cur-next 的 random 应该指向 R 的克隆节点也就是 R-next。所以代码是 cur-next-random cur-random-next。如果 cur-random 为 null就不处理克隆节点 random 保持 null。第三步把这条穿插链表拆成两条独立链表奇数位节点组成原链表偶数位节点组成克隆链表。重点在于边拆边把原链表恢复原样而不是只取偶数位就完事。因为原链表如果未来还要用它的 next 必须还是原来的 A - B - C不能变成 A - A - B。这三步里最容易翻车的是第三步。你会发现拆分过程不仅要正确连接克隆链表的 next还要把原链表的 next 找回原来的样子。如果不小心在某个环节丢了一个指针轻则链表断裂重则形成环程序直接卡死。3.2 完整代码穿插、配置、拆分离不开了下面这是完整可运行的 C 代码Node* copyRandomList(Node* head) { if (!head) return nullptr; // 第一步在原始链表中穿插克隆节点 Node* cur head; while (cur) { Node* clone new Node(cur-val); clone-next cur-next; cur-next clone; cur clone-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) { Node* clone cur-next; tail-next clone; tail clone; cur-next clone-next; // 恢复原链表的 next cur clone-next; } Node* newHead dummy-next; delete dummy; return newHead; }我们逐行走查一次。穿插阶段cur 初始为 head假设 head 是 A。创建 AA-next 指向原来的 B也就是 A-next然后 A-next 被改成 A。此时 cur 怎么移动cur clone-next也就是 A-next即原来的 B。这样下一轮 while 就会正确处理 B。注意这里如果把 cur 写成 cur-next-next其实也行因为 clone-next 就是 cur-next-next两者等价。第二步里cur-next-random 就是克隆节点 A 的 randomcur-random 是 A 的 random。如果 A 的 random 指向 R那么 R 的克隆节点是 R-next所以 cur-random-next 就是正确的目标。这里有一个小坑cur-random 可能是一个在穿插后已经被改过 next 的旧节点但这个不影响因为我们要的正是 R-next它永远指向 R 的克隆。整个第二步必须保证 cur 只走旧节点所以 cur cur-next-next。第三步拆分cur 初始为 Aclone 是 A。先把 A 接到新链表的尾节点上然后把 A-next 恢复成 A-next也就是 B。最后 cur clone-next也就是 B。如此循环下去原链表逐步恢复克隆链表逐步生成。dummy 节点的作用是为了避免处理头节点时写额外分支这在链表题目里非常常见。3.3 为什么这样能省掉 O(n) 空间面试时如果你能把这个思路讲清楚就已经很加分了。我来帮你组织一下讲解语言原本哈希表存储映射关系每个旧节点都要有一个对应的新节点地址原地法把新节点直接“贴”在旧节点后面那么 old-next 天然就是 new不需要额外的数据结构去记录。random 的设置也不需要查表因为任意旧节点的克隆都站在它自己的 next 上。这样就把空间复杂度从 O(n) 降到了 O(1)唯一的“额外空间”只是创建新节点本身需要的 O(n) 内存——但这本来就是答案的一部分不能算额外空间。打个比方你就理解了。哈希表法相当于你建了一座“档案室”存了每个员工的工号和对应名字查信息都要去档案室。原地法相当于你把员工的工牌直接挂在工位上看到工位就看到工牌不需要跑档案室。前者查询方便但占地儿后者省地方但要把工牌一个个挂好再拆下来流程更复杂。这个方案在算法竞赛和面试里特别受欢迎因为它看起来像是“从题目条件里抠出的巧劲”。不过我得说句实话为了这 O(n) 空间省下来你引入的指针操作复杂度明显更高一旦写错调 bug 的时间往往超过了省下来的那点空间收益。所以在实际做题时先跑通哈希表法再冷静地写一遍原地法把它当作一次专项练习这是最稳妥的学习路径。4. 实操中的坑边界条件与易错点全记录4.1 空指针、自引用、单节点三个最关键的用例边界条件永远是链表题的隐形杀手。第一个就是空链表。head 为 nullptr 时直接返回 nullptr很多解法开头就写这一句但有些人会忘记结果后面的 while 循环直接对 nullptr 编译器报错或者运行崩掉。不管哪种解法开头这个 if 都不能少。第二个是 random 指向 null 的情况。哈希表法里如果不判断就查 map[nullptr]C 会直接出问题原地法里如果不判断就在 cur-random-next 上做解引用一样崩。random 为 null 必须单独处理最方便的做法是保持克隆节点的 random 为 null什么都不做因为 new Node() 出来 random 默认就是 null。第三个是自引用和单节点。比如链表只有一个节点它自己的 random 指向自己这在测试用例里非常常见。哈希表法里 map[cur]-random map[cur-random]正好等于 map[cur]也就是克隆节点自己指向自己逻辑闭环。原地法里 cur headclone head-nextcur-random headcur-next-random cur-random-next head-next clone同样指向克隆节点自己。如果这里没想明白可以在纸上画一个单节点的图马上就能理清。除了这些还有一种很隐蔽的场景链表可能形成环。题目默认没有环但如果你把这份代码用在工程环境里碰到循环引用的对象图就必须额外加防环机制。我做过一个真实的复制工具对象之间互相引用直接跑链表复制逻辑结果 while 循环跑了三分钟没结束就是没有环检测。工程上的解决办法是用递归 visited 集合或者迭代 集合记录已访问节点。面试时如果面试官追问“不借助额外空间怎么检测环”那是另一个经典问题但如果要复制环链表哈希表法天然能处理原地法会麻烦很多这个对比也值得你记住。4.2 三个高频错误与修复对照表我把平时帮别人 review 代码、以及自己在 LeetCode 上提交出错时积累的高频错误整理成一个表格如果你做题当场卡住直接对照查错误现象根本原因正确做法新链表的 random 指向了旧节点地址没有在映射关系上做转换直接用了旧节点的 random哈希表法用 map[cur-random]原地法用 cur-random-next拆分后原链表乱了甚至丢节点先改了旧链表的 next导致后续无法访问先用变量保存 clone cur-next再恢复 cur-next clone-next原地法出现死循环第二步遍历时没有跨过克隆节点走了 cur-next 而不是 cur-next-next保证 cur 每次跳到下一个旧节点单节点用例返回错误对循环遍历的终止条件把握不准单节点自引用单独画图走查一遍哈希表访问空的 random 崩溃把 nullptr 当作 key 访问 unordered_map先判断 cur-random 再访问null 则跳过其中“random 指向旧节点”这个错误最容易蒙混过关。因为你的测试用例如果只检查 val往往会发现新链表的 random 值恰好是旧节点某个 val测试看起来通过了但地址是旧的不是真深拷贝。这种 bug 在工程里特别危险因为它能通过一半的测试用例却在某种特殊数据下产生数据错乱。4.3 四条调试心法实测真的很管用第一条打印节点时把地址也打出来。只看 val 你根本分辨不出这个 random 指向的是旧还是新地址是唯一的。我常用一个简短 debug 函数把 val、next 指向的 val、random 指向的 val、next 的地址、random 的地址都打出来。看到新链表里所有地址都在新内存区域内心里才有底。第二条构造“自己指向自己”的用例。随便构造一个长度为 3 的小链表让第一个节点 random 指向第二个第二个指向第二个自己第三个指向 null。这个用例能覆盖自引用、空指针、跨节点引用三种情况跑一次基本就能定位问题。第三条原地法写完额外验证原链表恢复原状。你可以在拆分前把原链表头节点存一下拆分后从头遍历一遍检查每个旧节点的 next 是否跟原始链表一致。为什么一定要检查因为拆分过程里一行代码写错原链表就废了。如果原链表是你另外一段代码的输入这一废整个程序都会崩。第四条跑 LeetCode 之前先在本地构造示例别只靠在线判题。我把这题的所有边界用例都写在一个 main 函数里每次修改代码都本地跑一遍再粘贴到答题区这样能节省大量时间。在线判题平台偶尔会因为你访问空指针直接报红但你很难直接在编辑器里看崩溃堆栈本地调试会舒服很多。5. 现场决策笔试、面试和工程里的最优选择5.1 拿到题目先别急着写想清楚这三点在面对“随机链表的复制”这道题时我建议你给自己五秒钟先确认三件事。第一题目有没有限制 O(1) 空间如果原题明确要求或者面试官口头强调了直接考虑原地法如果没有限制哈希表法更划算。第二链表是否可能为空这个一眼就能确定但能帮你决定要不要在开头加 if。第三是否需要保留原链表结构如果题目只是要求返回一个深拷贝的新的头节点没有说原链表后续还要使用那你的注意力可以集中在克隆链表上但如果你在一个更大项目里调用这段代码原链表往往还要继续用就必须保证拆完之后原链表恢复原状。我见过不少人在面试现场一上来就埋头写原地法结果写到第三步拆分时卡住指针转来转去把自己绕晕最后在面试官面前越改越乱。反过来那些先讲“我可以用哈希表做到 O(n) 时间和 O(n) 空间如果需求是 O(1) 空间我再优化成穿插法”的人通常能拿到一个很好的印象分。因为面试官看重的不是你是不是背下了最优解而是你有没有能力从简单方案进到复杂方案遇到问题能不能自己纠正。实践里还有一个很实用的技巧口述解法顺序。你可以先说“第一遍建立新旧节点映射第二遍用映射接线”如果面试官点头你就继续写如果面试官皱眉他会主动告诉你“需要 O(1) 空间”你再切换到原地法。这种策略避免了“在一开始就拿出最复杂方案结果写崩”的尴尬也给了你自己更多缓冲时间。5.2 面试追问清单这些坑我都替你踩过了面试官大概率会在你写完基础解法后追加几个问题我提前帮你列出来。“为什么需要两遍遍历”答第一遍确保所有新节点都创建出来第二遍设置 random 时才能 O(1) 访问目标节点。如果只遍历一遍遇到 random 指向后续未创建的节点就抓瞎了。“两个方法的时间复杂度都是 O(n)为什么面试官还喜欢问原地法”答原地法在时间不变的情况下把额外空间从 O(n) 降到 O(1)这展示了你能根据资源限制调整算法结构的能力。有些低内存设备上几百万个节点的链表再配一张哈希表可能内存吃紧原地法就是针对这类场景的优化。“原地法会不会修改原链表”答会短暂穿插拆分后恢复。重点讲清楚拆分的顺序先取出克隆节点再恢复原链表指针。这是整个方案里最容易出错的地方你自己写的时候要反复练。“用递归能不能做”答能用递归 哈希表做 memo 化也可以但递归会占用调用栈空间链长了容易栈溢出。这题标准解法都是迭代。如果你听到别人提递归先意识到递归栈也算额外空间这正好是一个展示你空间意识的切口。5.3 从链表复制延伸到现实中的复制难题借“随机链表,复制”这个关键词再多说一点。你可能会觉得这题离实际生活很远但其实“复制”这个动作到处都是。文件复制慢可能是一边读一边写没有用缓冲 IO复制后格式变了可能是剪切板处理了富文本但目标程序只认纯文本复制提示“请去掉写保护”是因为磁盘只读属性或者存储介质没有关闭开关。这些现象背后都指向同一个本质复制不只是把数据搬一遍还要保证目标环境能正确解析新环境里的引用关系。链表里的 next 和 random 可以类比成配置文件里的层级路径和引用 ID。你要把一个配置文件从一个项目复制到另一个项目如果引用 ID 没有跟着迁移复制完配置就是一堆失效的外链。这就是为什么我说随机链表复制是“带随机引用的深拷贝”的最小模型你把最小模型吃透了工程里那些复杂得多的复制逻辑你至少会有清晰的入手点。我个人练习这题时的体会是第一次看题觉得哈希表方案就够了第二次看题觉得原地法真巧妙第三次自己手写原地法被拆分步骤坑到憋屈。但正是那次被坑让我彻底记住了“先存后拆”的顺序。如果你也打算练这题别满足于看懂一定亲自动手写一遍然后把边界用例跑一遍。能静下心把一张链表从穿插到拆分完整画清楚你对链表指针的掌控感会上一个台阶。