📌 题目链接:138. 随机链表的复制 - 力扣(LeetCode) 🔍 难度:中等 | 🏷️ 标签:链表、哈希表、原地操作、深拷贝
⏱️ 目标时间复杂度:O(n)
💾 空间复杂度:O(n)(方法一)或 O(1)(方法二,不计返回值)
在 LeetCode Hot100 中,「随机链表的复制」 是一道非常经典的链表深拷贝问题。它不仅考察你对链表结构的理解,还深入测试了你在指针操作、内存管理、算法空间优化等方面的综合能力。
这道题看似简单,实则暗藏玄机——因为每个节点除了 next 指针外,还有一个 random 指针,可能指向任意位置(包括 null 或自身)。如何在不知道后续节点是否已创建的情况下,正确建立 random 指向? 这就是本题的核心难点。
面试中,此题常被用于考察:
- 对“深拷贝”与“浅拷贝”的理解;
- 递归思维与哈希缓存的结合;
- 空间优化技巧(原地修改链表) —— 这是区分普通选手和高手的关键!
🧩 题目分析
给定一个带有 random 指针的链表,要求返回其完全独立的深拷贝。关键约束:
- 新链表必须由 全新节点 构成;
- 所有指针(
next和random)必须指向新链表中的节点; - 不能引用原链表的任何节点;
random可能为null,也可能形成环或自指。
💡 注意:题目输入以
[val, random_index]形式给出,但函数参数只传入head节点,因此我们无法直接通过索引访问,必须依赖指针遍历。
🔑 核心算法及代码讲解
本题有两种主流解法,各有千秋:
✅ 方法一:回溯 + 哈希表(推荐理解)
核心思想:
使用 递归 + 哈希缓存 实现“按需创建”。每当访问一个节点,若其拷贝未创建,则新建;否则直接返回已有拷贝。这样可避免重复创建,同时自然处理 random 指向尚未遍历到的节点的问题。
为什么可行?
因为递归天然具有“延迟求值”特性:即使 random 指向后面的节点,递归调用会自动创建它,并通过哈希表缓存结果。
📜 代码详解(含行注释)
// 哈希表:记录原节点 → 新节点的映射
unordered_map<Node*, Node*> cachedNode;
Node* copyRandomList(Node* head) {
// 基线条件:空节点直接返回
if (head == nullptr) {
return nullptr;
}
// 若当前节点尚未拷贝
if (!cachedNode.count(head)) {
// 创建新节点(仅赋值 val)
Node* headNew = new Node(head->val);
// 立即缓存,防止后续递归重复创建(关键!)
cachedNode[head] = headNew;
// 递归构建 next 和 random(即使它们还没创建也没关系)
headNew->next = copyRandomList(head->next);
headNew->random = copyRandomList(head->random);
}
// 返回已缓存的新节点
return cachedNode[head];
}
✅ 优点:逻辑清晰,易于理解,天然处理任意 random 指向。
⚠️ 缺点:需要 O(n) 额外空间存储哈希表。
✅ 方法二:迭代 + 原地拆分(空间最优)
核心思想:
不使用额外空间,通过“穿插复制”将新旧节点交织在一起,利用结构关系间接建立 random 映射。
三步走策略:
- 穿插复制:对每个原节点 A,插入其拷贝 A' 在 A 后面 → A → A' → B → B' ...
- 设置 random:A'.random = A.random ? A.random.next : nullptr(因为 A.random 的拷贝就在其后!)
- 拆分链表:将原链表与新链表分离,恢复原链表结构,返回新链表头。
📜 代码详解(含行注释)
Node* copyRandomList(Node* head) {
if (head == nullptr) return nullptr;
// Step 1: 穿插复制 —— 每个原节点后插入其拷贝
for (Node* node = head; node != nullptr; node = node->next->next) {
Node* nodeNew = new Node(node->val); // 创建拷贝
nodeNew->next = node->next; // 新节点指向原节点的下一个
node->next = nodeNew; // 原节点指向新节点
}
// Step 2: 设置 random 指针
for (Node* node = head; node != nullptr; node = node->next->next) {
Node* nodeNew = node->next; // 获取拷贝节点
// 原节点的 random 若存在,则其拷贝是 random->next
nodeNew->random = (node->random != nullptr) ? node->random->next : nullptr;
}
// Step 3: 拆分链表
Node* headNew = head->next; // 新链表头
for (Node* node = head; node != nullptr; node = node->next) {
Node* nodeNew = node->next; // 当前拷贝节点
node->next = node->next->next; // 恢复原链表的 next
// 设置新链表的 next:跳过原节点
nodeNew->next = (nodeNew->next != nullptr) ? nodeNew->next->next : nullptr;
}
return headNew;
}
✅ 优点:空间复杂度 O(1)(不计返回值),极致优化!
⚠️ 缺点:代码较复杂,需小心处理指针边界(如 nullptr)。
💬 面试加分点:能手写方法二,说明你对链表操作非常熟练,且具备空间优化意识!
🧭 解题思路(分步拆解)
方法一(哈希回溯)步骤:
- 定义全局哈希表
cachedNode,记录原节点到新节点的映射。 - 递归函数入口:若
head == nullptr,返回nullptr。 - 若当前
head未被拷贝:- 创建新节点
headNew,值为head->val; - 立即存入哈希表(防止循环引用或重复创建);
- 递归设置
headNew->next和headNew->random;
- 创建新节点
- 返回
cachedNode[head]。
方法二(原地拆分)步骤:
- 第一遍遍历:在每个原节点后插入其拷贝,形成交织链表。
- 第二遍遍历:利用
原节点.random.next即为拷贝节点.random的特性,设置所有random。 - 第三遍遍历:将交织链表拆分为原链表和新链表,恢复原链表结构,返回新链表头。
📊 算法分析
| 方法 | 时间复杂度 | 空间复杂度 | 是否修改原链表 | 面试推荐度 |
|---|---|---|---|---|
| 哈希回溯 | O(n) | O(n) | 否 | ⭐⭐⭐⭐(易理解) |
| 原地拆分 | O(n) | O(1) | 临时修改,最终恢复 | ⭐⭐⭐⭐⭐(高阶技巧) |
📌 注意:方法二虽然“修改”了原链表,但在最后一步完全恢复了原始结构,因此符合题目“只读输入”的隐含要求。
💻 完整代码(保留你的模板)
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
// Definition for a Node.
class Node {
public:
int val;
Node* next;
Node* random;
Node(int _val) {
val = _val;
next = NULL;
random = NULL;
}
};
// 方法一:哈希回溯
class Solution1 {
public:
unordered_map<Node*, Node*> cachedNode;
Node* copyRandomList(Node* head) {
if (head == nullptr) {
return nullptr;
}
if (!cachedNode.count(head)) {
Node* headNew = new Node(head->val);
cachedNode[head] = headNew;
headNew->next = copyRandomList(head->next);
headNew->random = copyRandomList(head->random);
}
return cachedNode[head];
}
};
// 方法二:原地拆分(空间最优)
class Solution2 {
public:
Node* copyRandomList(Node* head) {
if (head == nullptr) {
return nullptr;
}
for (Node* node = head; node != nullptr; node = node->next->next) {
Node* nodeNew = new Node(node->val);
nodeNew->next = node->next;
node->next = nodeNew;
}
for (Node* node = head; node != nullptr; node = node->next->next) {
Node* nodeNew = node->next;
nodeNew->random = (node->random != nullptr) ? node->random->next : nullptr;
}
Node* headNew = head->next;
for (Node* node = head; node != nullptr; node = node->next) {
Node* nodeNew = node->next;
node->next = node->next->next;
nodeNew->next = (nodeNew->next != nullptr) ? nodeNew->next->next : nullptr;
}
return headNew;
}
};
// 测试
signed main(){
ios::sync_with_stdio(false);
cin.tie(nullptr);
cout.tie(nullptr);
// 构建示例1: [[7,null],[13,0],[11,4],[10,2],[1,0]]
Node* n1 = new Node(7);
Node* n2 = new Node(13);
Node* n3 = new Node(11);
Node* n4 = new Node(10);
Node* n5 = new Node(1);
n1->next = n2; n2->next = n3; n3->next = n4; n4->next = n5;
n1->random = nullptr;
n2->random = n1;
n3->random = n5;
n4->random = n3;
n5->random = n1;
Solution2 sol;
Node* copied = sol.copyRandomList(n1);
// 简单验证:输出 val 和 random->val(若存在)
Node* cur = copied;
while (cur) {
cout << "Val: " << cur->val << ", Random: ";
if (cur->random) cout << cur->random->val;
else cout << "null";
cout << "\n";
cur = cur->next;
}
return 0;
}
🌟 本期完结,下期见!🔥
👉 点赞收藏加关注,新文更新不迷路。关注专栏【算法】LeetCode Hot100刷题日记,持续为你拆解每一道热题的底层逻辑与面试技巧!
💬 欢迎留言交流你的解法或疑问!一起进步,冲向 Offer!💪
📣 下一期预告:LeetCode 热题 100 第33题 —— 39.合并两个有序链表(简单)
🔹 题目:将两个升序链表合并为一个新的升序链表并返回。
🔹 核心思路:虚拟头节点 + 双指针迭代 / 递归合并。
🔹 考点:链表基础操作、递归思维、哨兵技巧。
🔹 难度:简单,但高频出现在链表面试题开头,务必手写无 bug!
💡 提示:不要新建节点!直接重连指针,实现 O(1) 空间合并!
📌 记住:当你在刷题时,不要只看答案,要像写这篇文章一样,深入思考每一步背后的原理、优化空间和面试价值。这才是真正提升算法能力的方式!