LeetCode 中等难度题目「133. 克隆图」,这道题是图的深拷贝经典题型,核心考察对图的遍历(BFS、DFS)和节点映射的理解,也是面试中常考的基础图论题,适合巩固图的核心操作。
先明确题目核心需求:给定无向连通图中一个节点的引用,返回该图的深拷贝。这里的关键是「深拷贝」—— 不仅要复制每个节点的 val 值,还要完整复制节点之间的邻接关系,且新图的节点的是全新的,不能复用原图的节点引用。
一、题目前置知识
题目中给出的节点结构定义如下(TS 版本),每个节点包含两个核心属性:
class _Node {
val: number
neighbors: _Node[]
constructor(val?: number, neighbors?: _Node[]) {
this.val = (val === undefined ? 0 : val) // 节点值,唯一(题目隐含,可用于区分节点)
this.neighbors = (neighbors === undefined ? [] : neighbors) // 邻接节点列表
}
}
补充说明:题目明确是「无向连通图」,意味着从给定节点出发,能遍历到图中所有节点;无向图的邻接关系是双向的,比如节点 A 是节点 B 的邻居,那么节点 B 也一定是节点 A 的邻居,这一点在克隆时需要注意(两种解法会自然处理这一特性)。
二、核心解题思路
克隆图的核心难点的是「避免重复克隆节点」和「正确建立邻接关系」。因为图存在环(比如 A→B→C→A),如果不记录已克隆的节点,会陷入无限循环,且会重复创建同一个节点,导致邻接关系混乱。
解决思路:用一个「哈希表(Map)」记录「原节点 → 克隆节点」的映射关系。这样做有两个作用:
-
判断某个原节点是否已经被克隆:若 Map 中存在该原节点,直接返回对应的克隆节点,避免重复创建。
-
快速找到克隆节点的邻接节点:当给克隆节点添加邻居时,可通过 Map 快速获取原邻居对应的克隆节点,无需重新遍历。
基于这个核心思路,我们可以用两种经典的图遍历方式实现:BFS(广度优先搜索)和 DFS(深度优先搜索)。
三、解法一:BFS 实现(迭代式)
3.1 思路拆解
BFS 适合「逐层遍历」图,步骤如下:
-
边界处理:若输入节点为 null(空图),直接返回 null。
-
初始化:创建哈希表 visited,用于存储原节点与克隆节点的映射;创建队列 queue,用于存储待处理的原节点(从输入节点开始)。
-
先克隆起始节点:将起始节点的克隆节点存入 visited,同时将起始节点加入队列。
-
队列循环处理:取出队列头部的原节点,遍历其所有邻接节点。
-
处理邻接节点:若邻接节点未被克隆(未在 visited 中),则克隆该节点并加入 visited 和队列;无论是否已克隆,都将邻接节点对应的克隆节点,加入当前原节点对应克隆节点的 neighbors 列表。
-
循环结束后,visited 中已存储所有原节点的克隆节点,返回起始节点对应的克隆节点即可。
3.2 完整代码(带详细注释)
class _Node {
val: number
neighbors: _Node[]
constructor(val?: number, neighbors?: _Node[]) {
this.val = (val === undefined ? 0 : val)
this.neighbors = (neighbors === undefined ? [] : neighbors)
}
}
// BFS 解法
function cloneGraph_1(node: _Node | null): _Node | null {
// 边界处理:空图直接返回null
if (!node) {
return null;
}
// visited: 存储原节点 → 克隆节点的映射,避免重复克隆
const visited = new Map();
// 队列:存储待处理的原节点,实现逐层遍历
const queue = [node];
// 先克隆起始节点,存入visited(此时克隆节点的neighbors为空)
visited.set(node, new _Node(node.val));
// 队列不为空,继续处理节点
while (queue.length) {
// 取出当前待处理的原节点(shift() 取出队列头部,适合BFS)
const curr = queue.shift()!; // ! 表示非null断言,因队列中都是有效节点
// 遍历当前原节点的所有邻接节点
for (const neighbor of curr.neighbors) {
// 若邻接节点未被克隆,先克隆并加入队列
if (!visited.has(neighbor)) {
visited.set(neighbor, new _Node(neighbor.val));
queue.push(neighbor); // 后续处理该邻接节点的邻居
}
// 给当前克隆节点,添加邻接节点对应的克隆节点(建立邻接关系)
visited.get(curr)!.neighbors.push(visited.get(neighbor)!);
}
}
// 返回起始节点对应的克隆节点,即整个克隆图的入口
return visited.get(node);
};
3.3 复杂度分析
-
时间复杂度:O(N),其中 N 是图中节点的数量。每个节点只会被处理一次(入队一次、遍历一次邻居),每个边也会被处理一次(无向图每条边被两个节点各遍历一次,总体还是 O(N) 级)。
-
空间复杂度:O(N)。哈希表 visited 存储所有节点,队列最多存储 N 个节点(最坏情况,比如线性图),两者都是 O(N)。
四、解法二:DFS 实现(递归式)
4.1 思路拆解
DFS 适合「深度遍历」图,通过递归深入每个节点的邻接节点,直到所有节点被克隆,步骤如下:
-
边界处理:若输入节点为 null,直接返回 null。
-
初始化哈希表 visited,存储原节点与克隆节点的映射。
-
定义递归函数 cloneNode:接收一个原节点,返回其对应的克隆节点。
-
递归函数逻辑:
-
若原节点已在 visited 中,直接返回对应的克隆节点(终止递归,避免循环)。
-
若未克隆,创建克隆节点,存入 visited。
-
遍历原节点的所有邻接节点,递归调用 cloneNode 得到邻接节点的克隆节点,加入当前克隆节点的 neighbors 列表。
-
返回当前克隆节点。
-
-
调用递归函数,传入起始节点,返回克隆图的入口节点。
4.2 完整代码(带详细注释)
class _Node {
val: number
neighbors: _Node[]
constructor(val?: number, neighbors?: _Node[]) {
this.val = (val === undefined ? 0 : val)
this.neighbors = (neighbors === undefined ? [] : neighbors)
}
}
// DFS 解法(递归)
function cloneGraph_2(node: _Node | null): _Node | null {
// 边界处理:空图直接返回null
if (!node) {
return null;
}
// visited: 存储原节点 → 克隆节点的映射,避免重复克隆和循环递归
const visited = new Map();
// 递归函数:克隆当前节点,并递归克隆其所有邻居
const cloneNode = (node: _Node): _Node => {
// 若当前节点已克隆,直接返回克隆节点(终止递归)
if (visited.has(node)) {
return visited.get(node)!;
}
// 克隆当前节点,存入visited(此时neighbors为空)
const clone = new _Node(node.val);
visited.set(node, clone);
// 递归克隆每个邻接节点,并添加到当前克隆节点的neighbors中
for (const neighbor of node.neighbors) {
clone.neighbors.push(cloneNode(neighbor));
}
// 返回当前克隆节点,供上层节点添加邻居
return clone;
}
// 从起始节点开始克隆,返回克隆图的入口
return cloneNode(node);
};
4.3 复杂度分析
-
时间复杂度:O(N),与 BFS 一致,每个节点和每条边都只处理一次。
-
空间复杂度:O(N)。哈希表 visited 存储所有节点;递归调用栈的深度最坏情况下为 O(N)(比如线性图,递归会深入到最后一个节点)。
五、两种解法对比与注意事项
5.1 解法对比
| 解法 | 实现方式 | 核心优势 | 适用场景 |
|---|---|---|---|
| BFS | 迭代(队列) | 避免递归栈溢出,适合节点数量极多的图 | 图的节点多、深度大,怕递归栈溢出 |
| DFS | 递归(函数调用栈) | 代码简洁,逻辑直观,容易理解 | 图的深度不大,节点数量适中 |
5.2 关键注意事项
-
必须用哈希表记录映射:这是避免重复克隆和循环的核心,缺一不可。如果没有哈希表,遇到环会陷入无限递归(DFS)或无限循环(BFS)。
-
邻接关系的建立时机:BFS 中,无论邻接节点是否已克隆,都要给当前克隆节点添加邻居(已克隆则直接取,未克隆则先克隆再添加);DFS 中,通过递归自然完成邻接关系的建立。
-
边界处理:输入节点为 null 的情况(空图),必须直接返回 null,否则会报错。
-
节点 val 的唯一性:题目隐含「每个节点的 val 是唯一的」,但我们的解法没有依赖这一点(用原节点引用作为 Map 的 key),即使 val 重复也能正确克隆,兼容性更强。
六、总结
克隆图的核心是「哈希表映射 + 图的遍历」,两种解法(BFS、DFS)本质上都是通过遍历所有节点,用哈希表记录原节点与克隆节点的对应关系,再逐步建立邻接关系。
实际刷题或面试中,可根据个人习惯选择解法:偏爱简洁代码选 DFS,担心栈溢出选 BFS。掌握这两种解法,不仅能解决这道题,还能巩固图的遍历思想,应对同类的图拷贝、图遍历题目。