LeetCode 133. 克隆图:两种解法(BFS+DFS)详细解析

0 阅读7分钟

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)」记录「原节点 → 克隆节点」的映射关系。这样做有两个作用:

  1. 判断某个原节点是否已经被克隆:若 Map 中存在该原节点,直接返回对应的克隆节点,避免重复创建。

  2. 快速找到克隆节点的邻接节点:当给克隆节点添加邻居时,可通过 Map 快速获取原邻居对应的克隆节点,无需重新遍历。

基于这个核心思路,我们可以用两种经典的图遍历方式实现:BFS(广度优先搜索)和 DFS(深度优先搜索)。

三、解法一:BFS 实现(迭代式)

3.1 思路拆解

BFS 适合「逐层遍历」图,步骤如下:

  1. 边界处理:若输入节点为 null(空图),直接返回 null。

  2. 初始化:创建哈希表 visited,用于存储原节点与克隆节点的映射;创建队列 queue,用于存储待处理的原节点(从输入节点开始)。

  3. 先克隆起始节点:将起始节点的克隆节点存入 visited,同时将起始节点加入队列。

  4. 队列循环处理:取出队列头部的原节点,遍历其所有邻接节点。

  5. 处理邻接节点:若邻接节点未被克隆(未在 visited 中),则克隆该节点并加入 visited 和队列;无论是否已克隆,都将邻接节点对应的克隆节点,加入当前原节点对应克隆节点的 neighbors 列表。

  6. 循环结束后,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 适合「深度遍历」图,通过递归深入每个节点的邻接节点,直到所有节点被克隆,步骤如下:

  1. 边界处理:若输入节点为 null,直接返回 null。

  2. 初始化哈希表 visited,存储原节点与克隆节点的映射。

  3. 定义递归函数 cloneNode:接收一个原节点,返回其对应的克隆节点。

  4. 递归函数逻辑:

    • 若原节点已在 visited 中,直接返回对应的克隆节点(终止递归,避免循环)。

    • 若未克隆,创建克隆节点,存入 visited。

    • 遍历原节点的所有邻接节点,递归调用 cloneNode 得到邻接节点的克隆节点,加入当前克隆节点的 neighbors 列表。

    • 返回当前克隆节点。

  5. 调用递归函数,传入起始节点,返回克隆图的入口节点。

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 关键注意事项

  1. 必须用哈希表记录映射:这是避免重复克隆和循环的核心,缺一不可。如果没有哈希表,遇到环会陷入无限递归(DFS)或无限循环(BFS)。

  2. 邻接关系的建立时机:BFS 中,无论邻接节点是否已克隆,都要给当前克隆节点添加邻居(已克隆则直接取,未克隆则先克隆再添加);DFS 中,通过递归自然完成邻接关系的建立。

  3. 边界处理:输入节点为 null 的情况(空图),必须直接返回 null,否则会报错。

  4. 节点 val 的唯一性:题目隐含「每个节点的 val 是唯一的」,但我们的解法没有依赖这一点(用原节点引用作为 Map 的 key),即使 val 重复也能正确克隆,兼容性更强。

六、总结

克隆图的核心是「哈希表映射 + 图的遍历」,两种解法(BFS、DFS)本质上都是通过遍历所有节点,用哈希表记录原节点与克隆节点的对应关系,再逐步建立邻接关系。

实际刷题或面试中,可根据个人习惯选择解法:偏爱简洁代码选 DFS,担心栈溢出选 BFS。掌握这两种解法,不仅能解决这道题,还能巩固图的遍历思想,应对同类的图拷贝、图遍历题目。