面试题35:复杂链表的复制
把克隆节点交织到原节点后,用紧邻关系常数时间定位随机指针目标,最后同步恢复原链并拆出独立深拷贝。
学习目标
- 能说出三阶段法:复制节点插入原后、复制随机指针、拆分两链
- 能解释"紧邻关系"如何常数时间定位随机指针目标
- 能区分深拷贝与浅拷贝并验证克隆独立性
从“复制值以后,sibling该指向谁”开始
先预测:原节点A的sibling指向原节点C。新建A′后,若直接令A′.sibling等于A.sibling,它会指回原C,克隆链仍依赖原链,并不是真正复制。目标必须是与原C对应的新节点C′。
“复杂链表的复制”中每个节点有m_pNext和m_pSibling,后者也常叫random。值和next只能确定主链,随机指针可能向前、向后、指自己、为空或形成环。核心任务是建立↡。
哈希表可以显式保存原地址到克隆地址;作者则把映射编码进临时链表结构,让满足原节点.next就是它的克隆。
阶段一:复制节点插入原节点后面
遍历原next链。对每个pNode新建pCloned,复制value;令pCloned.next等于原pNode.next,再令pNode.next等于pCloned。随后从pCloned.next跳到下一个原节点,避免把刚插入的克隆再次复制。
原A→原B→原C变为原A→克隆A′→原B→克隆B′→原C→克隆C′。原节点的相对顺序没变,克隆节点也按相同顺序出现,只是两条链暂时交错。
“复制节点插入原节点后面”带来:clone(x)等于x.next。阶段二和阶段三都依赖它,不能提前恢复next。
阶段二:复制↡
遍历每个原节点pNode,克隆就是pNode.next。若原sibling为空,克隆保持空;否则原目标s的克隆是s.next,因此赋值pCloned.sibling等于pNode.sibling.next。
| 原关系 | 克隆赋值 | 结论 |
|---|---|---|
| 原节点sibling为空 | 克隆sibling保持nullptr | 无目标 |
| 原A.sibling=原C | 克隆A′.sibling=原C.next | 原C.next恰为克隆C′ |
| 原B.sibling=原B | 克隆B′.sibling=原B.next | 得到克隆B′自指 |
| 原B与原D互指 | B′指D′,D′指B′ | sibling环被同构复制 |
| sibling指向链外节点 | 公式不再成立 | 违反作者输入契约 |
这一步“复制随机指针”不沿sibling遍历,只读取一次目标的next,因此sibling形成环也不会无限循环。Test3中原2与原4互指,克隆2′和4′会自然互指;Test2与Test4的自指也自然变为克隆自指。
算法隐含约束:每个非空sibling必须指向同一条next主链中的某个原节点,因为只有这些节点后面插入了克隆。若指向链外对象,目标.next不是其克隆,公式失效。一般对象图复制应使用哈希映射。
阶段三:拆分两个链表
完成sibling后,交织结构不再需要。拆分必须同时做两件事:把每个原节点next恢复到下一个原节点;把每个克隆节点next连接到下一个克隆节点。
| 阶段 | 原链next | 克隆链next | 不变量 |
|---|---|---|---|
| 交织前 | A→B→C | 无 | 原链完整 |
| 交织后 | A→A′→B→B′→C→C′ | 尚未独立 | 映射可局部读取 |
| 先取克隆头 | A.next=A′ | cloneHead=A′ | 保存返回入口 |
| 每轮恢复原next | A→B→C | A′→B′→C′ | 两条链同步前进 |
| 完成 | 与输入地址和值关系一致 | 全新节点与同构引用 | 原链恢复、克隆独立 |
作者先单独处理首对,保存pClonedHead并恢复原头next。循环中令当前克隆next等于下一个原节点next,也就是下一个克隆;随后恢复该原节点next。最终原链与调用前相同,返回克隆头。
不是只移动返回指针。如果忘记恢复原next,调用方原链仍夹着克隆;如果忘记连接克隆next,返回链只有头或仍穿过原节点。
忠实还原作者三函数实现
作者Clone无条件依次调用三个阶段;空头时前两阶段循环不执行,ReconnectNodes返回nullptr。
struct ComplexListNode {
int m_nValue;
ComplexListNode* m_pNext;
ComplexListNode* m_pSibling;
};
void CloneNodes(ComplexListNode* pHead) {
ComplexListNode* pNode = pHead;
while (pNode != nullptr) {
auto* pCloned = new ComplexListNode();
pCloned->m_nValue = pNode->m_nValue;
pCloned->m_pNext = pNode->m_pNext;
pCloned->m_pSibling = nullptr;
pNode->m_pNext = pCloned;
pNode = pCloned->m_pNext;
}
}
void ConnectSiblingNodes(ComplexListNode* pHead) {
ComplexListNode* pNode = pHead;
while (pNode != nullptr) {
ComplexListNode* pCloned = pNode->m_pNext;
if (pNode->m_pSibling != nullptr) {
pCloned->m_pSibling =
pNode->m_pSibling->m_pNext;
}
pNode = pCloned->m_pNext;
}
}
ComplexListNode* ReconnectNodes(ComplexListNode* pHead) {
ComplexListNode* pNode = pHead;
ComplexListNode* pClonedHead = nullptr;
ComplexListNode* pClonedNode = nullptr;
if (pNode != nullptr) {
pClonedHead = pClonedNode = pNode->m_pNext;
pNode->m_pNext = pClonedNode->m_pNext;
pNode = pNode->m_pNext;
}
while (pNode != nullptr) {
pClonedNode->m_pNext = pNode->m_pNext;
pClonedNode = pClonedNode->m_pNext;
pNode->m_pNext = pClonedNode->m_pNext;
pNode = pNode->m_pNext;
}
return pClonedHead;
}
ComplexListNode* Clone(ComplexListNode* pHead) {
CloneNodes(pHead);
ConnectSiblingNodes(pHead);
return ReconnectNodes(pHead);
}三个遍历都沿原节点推进:交织后每次跨过克隆到下一个原节点;拆链后原与克隆指针同步前进。任何阶段若把pNode简单设为pNode.next,就会落到克隆节点并破坏奇偶角色。
↡应验证什么
不只是打印值相同。对第i个原节点和克隆节点,应满足:地址不同、值相同;next为空状态相同且克隆next对应原next;sibling为空状态相同且克隆sibling对应原sibling。
还要验证原链被完全恢复。克隆后再次遍历原next应得到相同地址序列;原节点next和sibling都应与调用前快照一致。修改克隆节点值或sibling不应影响原节点,反之亦然。
若节点值重复,不能按value匹配对应关系;必须按next位置或测试前建立的地址索引。Test输出两条链供人工观察,但没有自动证明地址独立,也没有释放原链和克隆链,生产测试应补上。
哈希映射是更稳妥的替代
交织法O(1)辅助空间,但会临时修改输入,不适合只读、并发共享或链外sibling。哈希法第一遍为每个原节点创建克隆并建立map,第二遍按map连接next与sibling,原链始终不变。
#include <memory>
#include <unordered_map>
#include <vector>
struct Node {
int value;
Node* next = nullptr;
Node* sibling = nullptr;
};
Node* cloneWithMap(const Node* head) {
if (head == nullptr) return nullptr;
std::unordered_map<const Node*, Node*> cloneOf;
std::vector<std::unique_ptr<Node>> owned;
for (const Node* node = head;
node != nullptr; node = node->next) {
owned.push_back(std::make_unique<Node>());
owned.back()->value = node->value;
cloneOf.emplace(node, owned.back().get());
}
for (const Node* node = head;
node != nullptr; node = node->next) {
Node* copy = cloneOf.at(node);
copy->next =
node->next == nullptr ? nullptr : cloneOf.at(node->next);
copy->sibling =
node->sibling == nullptr
? nullptr
: cloneOf.at(node->sibling);
}
for (auto& node : owned) node.release();
return cloneOf.at(head);
}unique_ptr暂时拥有新节点;分配或map操作抛异常时已创建节点自动释放。全部关系连接成功后才release所有权。真实库更适合返回拥有整条链的RAII类型,而不是裸头指针;这里保持与题目接口接近。
哈希法时间O(n)、映射辅助空间O(n)。交织法也是O(n)时间,辅助指针O(1),但新建的n个克隆节点是必要输出,不计入“额外工作空间”并不代表总分配O(1)。
临时修改与异常安全
作者CloneNodes每创建一个克隆就修改原next。若第k次new抛出,前k减1个节点已交织,原链处于半转换状态,克隆也无人释放;函数没有回滚,异常安全不足。
可预先分配全部节点再交织,或用哈希RAII方案。若坚持O(1)辅助空间与裸指针,可捕获异常并从头识别已交织前缀、恢复原next、删除克隆,但边界复杂,且异常处理本身必须不再抛。
遍历期间其他线程会看到A→A′→B的临时结构,可能把克隆当业务节点;必须独占访问或使用不可变哈希法。即使单线程,回调、日志钩子或析构器也不应在交织窗口遍历原链。
作者五组测试覆盖什么
Test1主链1到5,sibling为1→3、2→5、4→2,其余空,覆盖向前与向后跨节点。Test2为2→5、3→自身、4→2,覆盖自指。Test3让2→4且4→2,形成sibling环。
Test4只有节点1,next为空、sibling指向自己;克隆后1′必须指向自身而非原1。Test5传nullptr,返回nullptr。源码总计五组。
#include <cassert>
#include <unordered_map>
#include <vector>
void assertDeepClone(const Node* original, const Node* copy) {
std::vector<const Node*> oldNodes;
std::vector<const Node*> newNodes;
for (auto* p = original; p != nullptr; p = p->next)
oldNodes.push_back(p);
for (auto* p = copy; p != nullptr; p = p->next)
newNodes.push_back(p);
assert(oldNodes.size() == newNodes.size());
std::unordered_map<const Node*, std::size_t> oldIndex;
for (std::size_t i = 0; i < oldNodes.size(); ++i) {
oldIndex[oldNodes[i]] = i;
assert(oldNodes[i] != newNodes[i]);
assert(oldNodes[i]->value == newNodes[i]->value);
}
for (std::size_t i = 0; i < oldNodes.size(); ++i) {
const Node* sibling = oldNodes[i]->sibling;
if (sibling == nullptr) {
assert(newNodes[i]->sibling == nullptr);
} else {
assert(newNodes[i]->sibling ==
newNodes[oldIndex.at(sibling)]);
}
}
}测试前还应保存原节点next地址序列,调用后确认完全恢复;分别销毁两条链并使用内存检测工具验证无泄漏、无双重释放。对作者Test1到Test4,克隆后修改任一新节点值,原值必须保持不变。
正确性证明
阶段一结束后,对每个原节点x,x.next是唯一克隆x′,而x′.next是原来的下一个原节点;此为交织不变量。阶段二读取原x.sibling=s,按不变量s.next=s′,因此赋给x′.sibling得到正确克隆关系,空指针也保持空。
阶段三沿交织序列恢复x.next为x′.next,并令x′.next指向下一原节点的next,也就是下一克隆。归纳每轮后,已处理前缀的原链与克隆链都正确独立,未处理后缀仍交织可定位。
结束时所有原next恢复,所有克隆next按原顺序连接;值在创建时复制,sibling在阶段二同构连接,所以返回结构是独立深拷贝。
输入契约与复杂度
next必须形成有限无环单链。next成环会让三个while无限循环;共享next前驱则不再是链。sibling可成环,但必须为空或指向同一主链节点。
每阶段扫描O(n),总时间O(n)。交织法除克隆输出外只用常数个指针,辅助空间O(1);递归未使用,调用栈O(1)。哈希法辅助空间O(n),但不修改原链且更易获得异常安全。
若克隆由垃圾回收语言实现,交织期间原与克隆互相可达,不会被回收;拆分后仍需确保返回头持有全部新节点。弱引用sibling、所有权sibling或外部引用会改变复制语义,不能只照搬裸指针模型。
本章练习
练习
问题 1: 三阶段法的三个阶段各做什么?
问题 2: 为什么"紧邻关系"能 O(1) 定位随机指针目标?
问题 3: 深拷贝应验证哪些性质?
概念说明
本章核心概念:拆分两个链表。理解这些概念是掌握解题方法的关键,具体实现与边界条件请参考上文对应小节。
本章回顾
- 复杂链表复制要保持值、next和sibling拓扑同构且地址独立。
- 阶段一把每个克隆节点插在对应原节点后,建立局部映射。
- 克隆sibling等于原sibling.next,前提是目标属于同一主链。
- sibling自指或成环不会导致额外遍历,仍可常数时间映射。
- 拆链同时恢复原next并连接克隆next,最终得到两条独立链。
- 交织法O(n)时间、O(1)辅助空间,但会临时修改输入。
- 作者版本在new中途失败时缺少回滚;哈希加RAII更稳健。
- 五组官方测试覆盖跨引用、自指、sibling环、单点与空链。
名词解释
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 节点映射
- 原节点到其克隆节点的对应关系,三阶段法用"紧邻"隐式建立。
- 随机指针
- 指向链中任意节点的额外指针(sibling),可能成环。
- 深拷贝
- 复制全部结构,克隆链不共享原节点,完全独立。