BFS 与并查集实战总结:从基础框架到刷题落地

BFS 与并查集实战总结:从基础框架到刷题落地

在算法刷题与面试中,BFS(广度优先搜索)和并查集是两大高频核心工具,它们各自有明确的适用场景,也能结合使用解决复杂问题。本文将结合大量实战例题,梳理 BFS 与并查集的核心逻辑、应用场景、标准模板,以及刷题时的关键技巧,所有代码均保留原始实现和注释,方便直接参考使用,助力快速掌握并灵活运用这两大算法。

一、BFS 核心解析:层序遍历的本质与应用

BFS 的本质是「层序遍历」,遵循"先进先出(FIFO)"的原则,核心优势是能高效求解「无权图/树的最短路径」,因为它能保证首次添加到队列到目标节点时,所经过的路径是最短的。

无论是二叉树、多叉树还是图,BFS 的核心逻辑一致,唯一的区别在于:图需要增加「添加到队列的标记」(seen),避免重复入队和死循环;而树的结构天然无环,无需额外标记(除非有特殊场景)。

1.1 BFS 核心思想与适用场景

BFS 的核心思路可以概括为:

  1. 初始化队列,将起点节点加入队列,并标记为已添加到队列;

  2. 循环遍历队列,每次取出当前层的所有节点,处理每个节点的业务逻辑;

  3. 遍历当前节点的所有邻接节点,未添加到队列过的节点标记后加入队列;

  4. 直到队列为空,或达到目标条件,结束遍历。

适用场景(面试高频):

  • 树的层序遍历、求树的最小深度、填充节点的右侧指针等;

  • 图的最短路径(无权),如迷宫出口、单词接龙、二进制矩阵最短路径等;

  • 多源扩散问题,如腐烂的橘子、01 矩阵等;

  • 场景抽象类问题(将具体场景转化为图/树),如滑动谜题、打开转盘锁等。

1.2 BFS 标准模板(无权图最短路径)

以下是 BFS 求解无权图两点之间最短路径的标准模板,适配大多数图类场景,可直接复用修改:

js 复制代码
/**
 * BFS 层序遍历 - 求无权图两点之间的最短路径长度
 * @param {Object} graph 图(必须支持 size() 获取节点数、neighbors(curId) 获取邻接点)
 * @param {Number} startId 起点编号
 * @param {Number} targetId 终点编号
 * @return {Number} 最短路径的层数(步数)
 */
function bfs(graph, startId, targetId) {
  // 1. 获取图中总节点数量
  const n = graph.size();

  // 2.  seen 数组:标记节点是否被添加到队列过,避免重复入队、死循环
  const seen = new Array(n).fill(false);

  // 3. BFS 核心队列:存储【待遍历的节点】
  // 队列遵循:先进先出(FIFO),保证层序遍历
  const q = [startId];
  // ✅ 关键:入队时立刻标记为已添加到队列!防止重复入队
  seen[startId] = true;

  // 4. 记录当前层数(步数),起点算第 0 层
  let level = -1;

  // 5. 队列不为空就继续遍历
  while (q.length) {
    // 每进入一层,层数 +1
    level++;

    // 🔥 关键:记录当前层有多少个节点(必须先保存,不能直接用 q.length)
    const levelNodesCount = q.length;

    // 遍历当前层的所有节点
    for (let i = 0; i < levelNodesCount; i++) {
      // 队首出队
      const curId = q.shift();

      // 6. 如果当前节点就是目标,直接返回当前层数 = 最短路径
      if (curId === targetId) return level;

      // 7. 遍历当前节点的所有邻居
      for (const [to, weight] of graph.neighbors(curId)) {
        // 没添加到队列过才处理
        if (!seen[to]) {
          seen[to] = true; // 入队前标记添加到队列
          q.push(to); // 加入下一层
        }
      }
    }
  }

  // 8. 没找到目标(图不连通)
  return -1;
}

1.3 BFS 实战例题(含完整代码)

以下例题均保留原始代码和注释,覆盖不同场景,可直接复制提交,适配 LeetCode 对应题目,重点体会场景抽象和 BFS 逻辑的复用。

例题 1:滑动谜题(773 题)

核心:将棋盘的每一种排列抽象为「状态节点」,0 的移动对应状态之间的切换,求从初始状态到目标状态的最短步数,典型 BFS 应用。

js 复制代码
/**
 * 773. 滑动谜题
 * 解题思路:
 * 1. 棋盘的每一种排列 = 一个【状态】
 * 2. 0(空格)可以和相邻数字交换 = 状态之间可以切换
 * 3. 求【最少移动步数】= 典型 BFS 问题
 */
var slidingPuzzle = function (board) {
  // ===================== 1. 初始化 =====================
  // 把输入的 2x3 二维数组 转换成 字符串(方便 BFS 存储与比较)
  const start = board[0].join('') + board[1].join('');
  // 目标状态:我们需要拼成的正确结果
  const target = '123450';

  // 记录已经添加到队列过的状态,防止重复走(回头路)
  const seen = new Set();
  // BFS 队列:存储每一步的棋盘状态
  const queue = [start];

  // 【入队时立刻标记为已添加到队列】这是 BFS 关键规则
  seen.add(start);

  // 记录步数(层数),初始为 -1,进入第一层后变成 0
  let step = -1;

  // ===================== 2. BFS 核心循环 =====================
  // 队列不为空,就继续搜索
  while (queue.length) {
    // 每进入一层(每走一步),步数 +1
    step++;
    // 记录当前层有多少个状态(必须先保存,队列长度会变化)
    const stepNodeCount = queue.length;

    // 遍历当前层的所有状态
    for (let i = 0; i < stepNodeCount; i++) {
      // 队首出队:拿出当前要处理的棋盘状态
      const cur = queue.shift();

      // ===================== 终止条件 =====================
      // 如果当前状态就是目标状态,直接返回当前步数(最短路径)
      if (cur === target) {
        return step;
      }

      // ===================== 扩展子状态 =====================
      // 获取当前状态通过移动 0 能得到的所有下一步状态
      const neighbors = getNeighbors(cur);

      // 遍历所有邻居
      for (let nei of neighbors) {
        // 只处理没添加到队列过的状态
        if (!seen.has(nei)) {
          seen.add(nei); // 标记已添加到队列
          queue.push(nei); // 加入队列,作为下一层
        }
      }
    }
  }

  // 队列空了都没找到 → 无解,返回 -1
  return -1;
};

// ===================== 工具函数 =====================
/**
 * 根据当前棋盘字符串,获取 0 移动后能生成的所有状态
 * @param {string} cur 当前棋盘字符串
 * @return {string[]} 所有下一步状态数组
 */
function getNeighbors(cur) {
  // 2x3 棋盘一维索引对应的【可交换邻居】
  // 索引位置:
  // 0 1 2
  // 3 4 5
  const indexToNeighbors = [
    [1, 3], // 0
    [0, 2, 4], // 1
    [1, 5], // 2
    [0, 4], // 3
    [1, 3, 5], // 4
    [2, 4], // 5
  ];

  // 找到 0 在字符串中的位置
  const index0 = cur.indexOf('0');

  // 生成所有交换后的新状态并返回
  return indexToNeighbors[index0].map(nei => {
    const arr = cur.split('');
    [arr[index0], arr[nei]] = [arr[nei], arr[index0]];
    return arr.join('');
  });
}

例题 2:打开转盘锁(752 题)

核心:将锁的每一种状态(4位数字)抽象为节点,每转动一位数字对应状态切换,同时需要避开"死亡密码",用 BFS 求最短转动步数,可优化为双向 BFS 提升效率。

js 复制代码
var openLock = function (deadends, target) {
  const start = '0000';

  // 边界判断:起点就是死亡点 / 起点就是终点
  if (deadends.includes(start)) return -1;
  if (start === target) return 0;

  // 记录已经添加到队列过的状态,防止重复走
  const visitedSet = new Set();
  // BFS 队列:存储每一步的锁状态
  const queue = [];

  // 初始状态入队 + 标记添加到队列
  queue.push(start);
  visitedSet.add(start);

  // 记录步数(层数)
  let level = -1;

  // ===================== BFS 核心 =====================
  while (queue.length) {
    level++;
    const levelNodesCount = queue.length; // 当前层节点数

    for (let i = 0; i < levelNodesCount; i++) {
      const cur = queue.shift(); // 取出当前状态

      // 找到目标,返回当前步数
      if (cur === target) return level;

      // 获取所有能转动到的下一个状态
      const neighbors = getNeighbors(cur);

      for (let nei of neighbors) {
        // 条件:不是死亡点 + 没添加到队列过
        if (!deadends.includes(nei) && !visitedSet.has(nei)) {
          queue.push(nei);
          visitedSet.add(nei);
        }
      }
    }
  }

  // 遍历完没找到 → 无解
  return -1;
};

// ===================== 工具函数:转动一位数字,生成 8 个邻居 =====================
function getNeighbors(str) {
  const res = [];
  // 4 位密码,每一位都可以 +1 或 -1
  for (let i = 0; i < 4; i++) {
    const num = Number(str[i]);
    const arr = str.split('');

    // 数字 -1(9→0)
    arr[i] = (num - 1 + 10) % 10;
    res.push(arr.join(''));

    // 数字 +1(0→9)
    arr[i] = (num + 1) % 10;
    res.push(arr.join(''));
  }
  return res;
}

例题 3:腐烂的橘子(994 题)

核心:多源 BFS 应用,所有初始腐烂的橘子作为起点,同时向四周扩散,统计扩散所需时间,注意边界条件(无橘子、有橘子无法全部腐烂)。

js 复制代码
var orangesRotting = function (grid) {
  // 定义网格状态常量,代码更易读
  const EMPTY = 0;
  const FRESH = 1;
  const ROTTED = 2;

  const rows = grid.length;
  const cols = grid[0].length;

  // 统计【所有橘子】的总数:新鲜 + 腐烂
  let countOrange = 0;

  // const seen = new Set(); // 标记已入队的位置(入队=被污染)
  const seen = Array.from({ length: rows }, () => new Array(cols).fill(false));
  const queue = []; // 多源BFS队列,存储所有初始腐烂橘子

  // 第一次遍历:初始化队列 + 统计总橘子数
  for (let i = 0; i < rows; i++) {
    for (let j = 0; j < cols; j++) {
      const cur = grid[i][j];
      if (cur !== EMPTY) countOrange++;

      // 初始腐烂橘子入队,作为BFS起点
      if (cur === ROTTED) {
        queue.push([i, j]);
        seen[i][j] = true;
        // seen.add(`${i},${j}`);
      }
    }
  }

  // ✅ 边界情况:一开始就没有任何橘子,直接返回0
  if (countOrange === 0) return 0;

  let level = -1; // 时间分钟数

  // ================= BFS 扩散主逻辑 =================
  while (queue.length) {
    level++; // 进入新一层,时间 +1
    const levelSize = queue.length; // 当前层要处理的节点数量

    // ✅ 神优化:批量减去当前层的橘子数量,替代循环--,效率更高
    countOrange -= levelSize;

    // ✅ 所有橘子都处理完毕(全部腐烂),返回当前时间
    if (countOrange === 0) return level;

    // 遍历当前层所有腐烂橘子,向四个方向扩散
    for (let i = 0; i < levelSize; i++) {
      const [row, col] = queue.shift();

      // 遍历上下左右邻居
      for (let nei of getNeighbors(row, col)) {
        const [nRow, nCol] = nei;
        // const key = nei.join(',');

        // 只处理:新鲜橘子 + 没入队过的
        if (grid[nRow][nCol] !== FRESH || seen[nRow][nCol]) continue;

        // 感染,加入下一层队列
        queue.push(nei);
        seen[nRow][nCol] = true;
        // seen.add(key);
      }
    }
  }

  // BFS结束仍有橘子剩余 → 永远无法腐烂
  return -1;

  // 工具函数:获取上下左右四个合法方向
  function getNeighbors(row, col) {
    const res = [];
    const dirs = [
      [0, 1],
      [0, -1],
      [1, 0],
      [-1, 0],
    ];
    for (const [dr, dc] of dirs) {
      const r = row + dr;
      const c = col + dc;
      if (r >= 0 && r < rows && c >= 0 && c < cols) {
        res.push([r, c]);
      }
    }
    return res;
  }
};

例题 4:01 矩阵(542 题)

核心:多源 BFS 反向应用,将所有 0 作为起点,向四周扩散,扩散的层数即为每个 1 到最近 0 的最短距离,首次添加到队列即最短距离,无需重复处理。

js 复制代码
/**
 * @param {number[][]} mat
 * @return {number[][]}
 * 全零入队做起点,层序扩散算距离,首次添加到队列即最短,遍历完成出答案
 */
var updateMatrix = function (mat) {
  // 矩阵行列数
  const rows = mat.length;
  const cols = mat[0].length;

  // 标记是否添加到队列过(防止重复入队)
  const seen = Array.from({ length: rows }, () => new Array(cols).fill(false));
  // 结果矩阵:存储每个位置到最近 0 的距离,默认先填充0 ,只有1的位置需要重新赋值
  const res = Array.from({ length: rows }, () => new Array(cols).fill(0));

  // 统计已处理的格子总数(用于提前退出优化)
  let count = 0;
  // BFS 队列:存储所有 0 的坐标(多源 BFS)
  const queue = [];

  // 第一步:遍历矩阵,把所有 0 加入队列作为起点
  for (let i = 0; i < rows; i++) {
    for (let j = 0; j < cols; j++) {
      const cur = mat[i][j];
      if (cur === 0) {
        queue.push([i, j]); // 0 是 BFS 起点
        seen[i][j] = true; // 标记已添加到队列
      }
    }
  }

  // 记录距离层级(0 所在层为 0,向外扩散每层 +1)
  let level = -1;

  // ================= BFS 扩散主逻辑 =================
  while (queue.length) {
    level++; // 进入新一层,距离 +1
    const levelSize = queue.length; // 当前层节点数

    // 遍历当前层所有节点
    for (let i = 0; i < levelSize; i++) {
      const curPos = queue.shift(); // 取出队首
      const [row, col] = curPos;

      // 如果当前位置是 1,记录当前层级 = 最短距离
      if (mat[row][col] === 1) {
        res[row][col] = level;
      }

      // 遍历四个方向(上下左右)
      for (let nei of getNeighbors(row, col)) {
        const [nRow, nCol] = nei;
        if (seen[nRow][nCol]) continue; // 已添加到队列过,跳过

        // 未添加到队列的节点加入下一层
        queue.push([nRow, nCol]);
        seen[nRow][nCol] = true;
      }
    }

    // 累计已处理格子数
    count += levelSize;
    // 所有格子都处理完了,提前退出
    if (count === cols * rows) break;
  }

  // 返回距离矩阵
  return res;

  // 工具函数:获取当前坐标上下左右四个合法邻居
  function getNeighbors(row, col) {
    const res = [];
    // 四个方向
    const dirs = [
      [0, 1],
      [0, -1],
      [-1, 0],
      [1, 0],
    ];
    for (let [rowDiff, colDiff] of dirs) {
      const newRow = row + rowDiff;
      const newCol = col + colDiff;
      // 确保在矩阵范围内
      if (newRow >= 0 && newRow < rows && newCol >= 0 && newCol < cols) {
        res.push([newRow, newCol]);
      }
    }
    return res;
  }
};

1.4 双向 BFS 优化技巧

当起点和终点明确时,双向 BFS 比传统 BFS 效率更高,核心思路是"从起点和终点同时扩散,直到相遇",每次扩散节点数更少的一侧,进一步提升效率。

以下是打开转盘锁的双向 BFS 实现,保留完整代码和注释:

js 复制代码
var openLock = function (deadends, target) {
  // 锁的初始状态:0000
  const start = '0000';
  // 死亡数组转成 Set,判断速度更快 O(1)
  const deadSet = new Set(deadends);

  // 边界条件 1:起点就是死亡密码 → 直接无解
  if (deadSet.has(start)) return -1;
  // 边界条件 2:起点就是终点 → 0 步
  if (start === target) return 0;

  // ==================== 【双向 BFS 核心结构】 ====================
  // 用两个队列:
  // queues[0] → 从起点 0000 开始扩散
  // queues[1] → 从终点 target 反向扩散
  let queues = [[start], [target]];

  // 两个 seen 集合:
  // 分别记录 正向 / 反向 已经走过的位置(最标准、最安全写法)
  let seen = [
    new Set([start]), // 正向走过的
    new Set([target]), // 反向走过的
  ];

  // 两个方向各自走的步数
  let steps = [0, 0];

  // ==================== 开始 BFS 循环 ====================
  // 两个队列都不为空,才继续搜索
  while (queues[0].length && queues[1].length) {
    // ==================== 【核心优化】 ====================
    // 永远让【节点更少】的队列去扩散(速度更快)
    // 如果左边比右边大,就交换左右,保证每次都扩散小的
    if (queues[0].length > queues[1].length) {
      [queues[0], queues[1]] = [queues[1], queues[0]]; // 交换队列
      [seen[0], seen[1]] = [seen[1], seen[0]]; // 交换添加到队列记录
      [steps[0], steps[1]] = [steps[1], steps[0]]; // 交换步数
    }

    // 当前要扩散的队列(永远是小队列)
    const curQueue = queues[0];
    // 存储下一层的新节点
    const nextQueue = [];

    // 遍历当前队列的【所有节点】(一层)
    for (let i = 0; i < curQueue.length; i++) {
      const cur = curQueue[i]; // 取出当前密码

      // ==================== 【相遇判断:最重要!】 ====================
      // 如果当前节点,已经被【对面】添加到队列过
      // 说明 正向 和 反向 相遇了!
      // 总最短步数 = 正向步数 + 反向步数
      if (seen[1].has(cur)) {
        return steps[0] + steps[1];
      }

      // 获取当前密码转动一位能得到的 8 个邻居
      const neighbors = getNeighbors(cur);

      // 遍历所有邻居
      for (const nei of neighbors) {
        // 过滤:不能是死亡点 + 不能被当前方向添加到队列过
        if (deadSet.has(nei) || seen[0].has(nei)) {
          continue;
        }
        // 标记当前方向已添加到队列
        seen[0].add(nei);
        // 加入下一层
        nextQueue.push(nei);
      }
    }

    // 更新队列:当前层扩散完,换成下一层
    queues[0] = nextQueue;
    // 当前方向步数 +1
    steps[0]++;
  }

  // 循环结束没找到 → 无解
  return -1;
};

// ==================== 工具函数 ====================
// 输入一个4位密码,返回转动一位能生成的8种状态
function getNeighbors(str) {
  const res = [];
  for (let i = 0; i < 4; i++) {
    const num = Number(str[i]);
    const arr = str.split('');

    // 第 i 位数字 -1(9 → 0)
    arr[i] = (num - 1 + 10) % 10;
    res.push(arr.join(''));

    // 第 i 位数字 +1(0 → 9)
    arr[i] = (num + 1) % 10;
    res.push(arr.join(''));
  }
  return res;
}

二、并查集核心解析:连通块的高效管理

并查集(Union-Find)是一种用于管理「动态连通性」的数据结构,核心优势是高效实现「合并集合」和「判断两个元素是否在同一集合」两个操作,时间复杂度接近 O(1)(带路径压缩和按秩合并优化)。

并查集的应用场景非常明确,只要题目涉及「连通块」「分组归类」「合并集合」等关键词,优先使用并查集,几乎都是最优解。

2.1 并查集核心思想与适用场景

并查集的核心思路可以概括为:

  1. 初始化:每个元素各自为一个集合,父节点指向自身,连通块大小为 1;

  2. 查找(find):找到元素的根节点,同时进行路径压缩(将元素直接挂到根节点,减少后续查找时间);

  3. 合并(union):将两个元素所在的集合合并,通常按连通块大小合并(小的挂到大的上面,减少树的高度);

  4. 查询:判断两个元素的根节点是否相同,相同则在同一集合。

适用场景(面试高频):

  • 无向图的连通块统计、判断两点是否连通;

  • 分组归类问题,如朋友圈、账户合并、相似字符串分组;

  • 感染扩散问题(无向),如恶意软件传播;

  • 带权连通问题(可扩展为带权并查集),如除法求值。

2.2 并查集标准模板(带优化)

以下是并查集的标准版模板,包含路径压缩和按秩(连通块大小)合并优化,可直接复用:

js 复制代码
class UF {
  constructor(n) {
    this.treeCount = n; // 连通块总数
    this.parent = new Array(n).fill(0).map((_, index) => index); // 父节点数组
    this.treeSize = new Array(n).fill(1); // 每个连通块的大小
  }

  // 查找x的根节点 + 路径压缩
  find(x) {
    if (x === this.parent[x]) return x;
    const root = this.find(this.parent[x]);
    this.parent[x] = root; // 路径压缩
    return root;
  }

  // 合并p和q所在的集合
  union(p, q) {
    const rootP = this.find(p);
    const rootQ = this.find(q);
    if (rootP === rootQ) return; // 已在同一集合,无需合并
    // 小集合挂到大集合上,减少树高
    this.parent[rootP] = rootQ;
    this.treeSize[rootQ] += this.treeSize[rootP];
    this.treeCount--; // 连通块总数减少
  }

  // 判断p和q是否连通
  connected(p, q) {
    return this.find(p) === this.find(q);
  }

  // 获取x所在连通块的大小
  getTreeSize(x) {
    return this.treeSize[this.find(x)];
  }

  // 获取连通块总数
  getCount() {
    return this.treeCount;
  }
}

2.3 并查集实战例题(含完整代码)

以下例题覆盖并查集的基础应用和进阶应用(带权并查集),保留原始代码和注释,重点体会并查集在连通块管理、分组归类中的核心作用。

例题 1:账户合并(721 题)

核心:将每个邮箱作为节点,同一账户下的邮箱合并为一个集合,最终按连通块分组,组装成账户信息,典型的「分组归类」场景。

js 复制代码
class UF {
  constructor(n) {
    this.treeCount = n;
    this.parent = new Array(n).fill(0).map((_, index) => index);
    this.treeSize = new Array(n).fill(1);
  }

  find(x) {
    if (x === this.parent[x]) return x;
    const root = this.find(this.parent[x]);
    this.parent[x] = root;
    return root;
  }

  union(p, q) {
    const rootP = this.find(p);
    const rootQ = this.find(q);
    if (rootP === rootQ) return;
    this.parent[rootP] = rootQ;
    this.treeSize[rootQ] += this.treeSize[rootP];
    this.treeCount--;
  }
}

var accountsMerge = function (accounts) {
  // ====================== 第一步:给每个邮箱分配唯一 ID & 建立映射关系 ======================
  // 核心:邮箱是唯一标识,相同邮箱无论在哪个账户,都属于同一个人
  const idToEmailAndName = new Map(); // Map<id, [email, name]>:通过ID查 邮箱+用户名
  const emailToId = new Map(); // Map<email, id>:通过邮箱查 ID
  let id = 0; // 自增ID,每个邮箱对应唯一ID

  // 遍历所有账户,建立ID、邮箱、名字的映射
  for (let account of accounts) {
    const name = account[0]; // 账户的用户名(第一个元素)
    // 遍历当前账户的所有邮箱
    for (let i = 1; i < account.length; i++) {
      const email = account[i];
      if (emailToId.has(email)) continue; // 邮箱已分配ID,跳过

      // 给新邮箱分配ID,并保存映射关系
      emailToId.set(email, id);
      idToEmailAndName.set(id, [email, name]);
      id++; // ID自增,准备给下一个新邮箱
    }
  }

  // ====================== 第二步:使用并查集,合并同一个账户下的所有邮箱 ======================
  const uf = new UF(id); // 初始化并查集,总节点数 = 所有不同邮箱的数量

  // 遍历所有账户,将同一个账户内的所有邮箱互相连通
  for (let account of accounts) {
    const firstId = emailToId.get(account[1]); // 取当前账户第一个邮箱的ID作为代表
    // 把当前账户下所有邮箱 都和 第一个邮箱 合并
    for (let i = 1; i < account.length; i++) {
      const emailId = emailToId.get(account[i]);
      uf.union(firstId, emailId);
    }
  }

  // ====================== 第三步:根据根节点,对邮箱进行分组 ======================
  const rootIdToEmailList = new Map(); // Map<根ID, 邮箱数组>:同一个根 = 同一个人

  // 遍历所有ID,找到每个邮箱对应的根节点,按根分组
  for (let [id, [email]] of idToEmailAndName.entries()) {
    const rootId = uf.find(id); // 找到当前邮箱的根ID

    // 如果根ID不存在,创建空数组
    if (!rootIdToEmailList.has(rootId)) {
      rootIdToEmailList.set(rootId, []);
    }
    // 把当前邮箱加入对应根的分组
    rootIdToEmailList.get(rootId).push(email);
  }

  // ====================== 第四步:组装结果,格式化输出 ======================
  const res = [];
  // 遍历每个分组(每个人)
  for (let [rootId, emailList] of rootIdToEmailList.entries()) {
    const name = idToEmailAndName.get(rootId)[1]; // 通过根ID拿到用户名
    emailList.sort(); // 邮箱按字典序排序(题目要求)
    res.push([name, ...emailList]); // 拼接成 [名字, 邮箱1, 邮箱2...]
  }

  return res;
};

例题 2:尽量减少恶意软件的传播(924 题)

核心:用并查集合并无向图的连通块,统计每个连通块内的初始感染节点数,只有"连通块内只有1个感染节点"时,删除该节点才能拯救整个连通块,最终选择拯救节点最多、索引最小的节点。

js 复制代码
// ====================== 并查集模板(标准版,带连通块大小统计)======================
class UF {
  constructor(n) {
    this.treeCount = n; // 连通块总数(本题不用,但模板必备)
    this.parent = new Array(n).fill(0).map((_, index) => index); // 父节点数组,初始自己指向自己
    this.treeSize = new Array(n).fill(1); // 每个连通块的大小,初始每个节点自己是一块
  }

  // 查找节点x的根节点 + 路径压缩(加速)
  find(x) {
    if (x === this.parent[x]) return x;
    const root = this.find(this.parent[x]);
    this.parent[x] = root; // 路径压缩:直接挂到根节点
    return root;
  }

  // 合并两个节点所在的连通块
  union(p, q) {
    const rootP = this.find(p);
    const rootQ = this.find(q);
    if (rootP === rootQ) return; //  already in same set

    // 把p所在树挂到q所在树上
    this.parent[rootP] = rootQ;
    this.treeSize[rootQ] += this.treeSize[rootP]; // 合并后更新连通块大小
    this.treeCount--; // 连通块数量-1
  }

  // 获取节点x所在连通块的总大小
  getTreeSize(x) {
    return this.treeSize[this.find(x)];
  }
}

// ====================== 主函数:LeetCode 924 解题核心 ======================
/**
 * 题目:尽量减少恶意软件的传播
 * 核心:删除一个节点,让最终感染数最小 → 返回应该删除的节点
 * @param {number[][]} graph 无向图邻接矩阵
 * @param {number[]} initial 初始感染节点列表
 * @return {number} 返回删除的节点索引
 */
var minMalwareSpread = function (graph, initial) {
  const n = graph.length; // 总节点数
  const uf = new UF(n); // 初始化并查集

  // ====================== 步骤1:用并查集合并所有连通块 ======================
  // 无向图,只遍历 i < j 的情况,避免重复合并,效率最高
  for (let i = 0; i < n; i++) {
    for (let j = i + 1; j < n; j++) {
      // 如果 i 和 j 相连,就合并
      if (graph[i][j] === 1) {
        uf.union(i, j);
      }
    }
  }

  // ====================== 步骤2:按【连通块根节点】分组初始感染节点 ======================
  // key:连通块根节点
  // value:这个块里的【初始感染节点列表】
  const rootToNodeList = new Map();
  for (let node of initial) {
    const root = uf.find(node); // 找到当前感染节点属于哪个连通块
    if (!rootToNodeList.has(root)) rootToNodeList.set(root, []);
    rootToNodeList.get(root).push(node); // 把感染节点加入对应列表
  }

  // ====================== 步骤3:遍历找最优删除节点 ======================
  let maxSave = 0; // 记录最多能拯救多少个节点
  let bestNode = Math.min(...initial); // 答案默认:初始列表中最小索引(边界情况)

  // 遍历每个连通块
  for (let [root, nodeList] of rootToNodeList) {
    // 核心规则:
    // 只有当一个连通块里【只有1个初始感染点】时,删除它才能拯救整个块
    // 如果块里 ≥2 个感染点,删一个没用,照样感染
    if (nodeList.length === 1) {
      const curNode = nodeList[0]; // 当前唯一感染节点(删它就能救整个块)
      const blockSize = uf.getTreeSize(root); // 当前连通块大小(能拯救的数量)

      // 情况1:能拯救更多节点 → 更新
      if (blockSize > maxSave) {
        maxSave = blockSize;
        bestNode = curNode;
      }

      // 情况2:拯救数量一样 → 选【索引更小】的节点
      if (blockSize === maxSave && curNode < bestNode) {
        bestNode = curNode;
      }
    }
  }

  // 返回最优删除节点
  return bestNode;
};

例题 3:除法求值(399 题)

核心:带权并查集的应用,将每个变量作为节点,等式关系作为边的权重(如 a/b = value,权重为 value),通过并查集维护变量间的比例关系,最终快速求解查询的比例。

js 复制代码
class UFW {
  // 初始化 n 个节点
  constructor(n) {
    // 父节点数组:parent[i] 表示 i 的父亲是谁
    this.parent = new Array(n).fill(0).map((_, index) => index);

    // 权重数组 🔥 核心定义:
    // weight[i] = i / parent[i]
    // 意思是:当前节点 ÷ 它的父亲 = 这个值
    this.weight = new Array(n).fill(1);

    // 连通分量的数量(可选)
    this.count = n;
  }

  // 查找 x 的根节点 + 路径压缩 + 自动更新权重
  find(x) {
    // 如果自己就是根节点,直接返回
    if (x === this.parent[x]) {
      return x;
    }

    // 保存 x 原来的父亲(路径压缩前的父亲)
    const oldP = this.parent[x];

    // 递归找到最顶层根节点
    const root = this.find(this.parent[x]);

    // 路径压缩:把 x 直接挂到根节点上
    this.parent[x] = root;

    // 🔥 权重更新(最关键)
    // this.weight[x] 原来:x / oldP
    // this.weight[oldP] 原来:oldP / root
    // 现在 x 直接指向 root,所以 x / root = (x / oldP) * (oldP / root)
    this.weight[x] = this.weight[x] * this.weight[oldP];

    return root;
  }

  // 判断 x 和 y 是否在同一个集合里(连通)
  connected(x, y) {
    return this.find(x) === this.find(y);
  }

  // 🔥 合并操作
  // 传入关系:x / y = value
  union(x, y, value) {
    // 先找到 x 和 y 各自的根
    const rootX = this.find(x);
    const rootY = this.find(y);

    // 如果已经连通,不需要合并
    if (rootX === rootY) return;

    // 把 rootX 挂到 rootY 下面(合并两个集合)
    this.parent[rootX] = rootY;

    // 🔥🔥🔥 核心公式(推导好的结果,直接用)
    // find之后,weight[x] = x / rootX
    // find之后,weight[y] = y / rootY
    // 已知 x/y = value
    // 推导得:rootX/rootY = value * weight[y]/weight[x]
    this.weight[rootX] = (value * this.weight[y]) / this.weight[x];

    // 两个集合合并成一个,连通分量总数-1
    this.count--;
  }

  // 🔥 获取 x / y 的结果
  getValue(x, y) {
    // 如果不连通 → 返回 -1
    if (!this.connected(x, y)) return -1.0;

    // 🔥 最终公式:
    // x / y = (x / root) / (y / root)
    // 也就是 weight[x] / weight[y]
    return this.weight[x] / this.weight[y];
  }

  // 获取连通分量数量(可选)
  getCount() {
    return this.count;
  }
}

/**
 * @param {string[][]} equations
 * @param {number[]} values
 * @param {string[][]} queries
 * @return {number[]}
 */
var calcEquation = function (equations, values, queries) {
  // 字符串变量 -> 数字ID 映射(因为并查集用数字数组最快)
  const strToId = new Map();
  let id = -1;

  // 1. 给所有字符串变量分配唯一数字ID
  for (let i = 0; i < equations.length; i++) {
    const [a, b] = equations[i];
    if (!strToId.has(a)) strToId.set(a, ++id);
    if (!strToId.has(b)) strToId.set(b, ++id);
  }

  // 2. 创建并查集
  const uf = new UFW(id + 1);

  // 3. 合并所有等式关系 a / b = value
  for (let i = 0; i < equations.length; i++) {
    const [a, b] = equations[i];
    const aId = strToId.get(a);
    const bId = strToId.get(b);
    uf.union(aId, bId, values[i]);
  }

  const res = [];
  // 4. 处理每个查询
  for (let [aStr, bStr] of queries) {
    // 变量不存在 → -1
    if (!strToId.has(aStr) || !strToId.has(bStr)) {
      res.push(-1.0);
      continue;
    }
    const aId = strToId.get(aStr);
    const bId = strToId.get(bStr);
    // 直接调用并查集获取 a/b 的结果
    res.push(uf.getValue(aId, bId));
  }

  return res;
};

例题 4:引爆炸弹(2101 题)

核心:先通过双重循环建图(判断炸弹之间能否互相引爆),再用并查集合并连通块,最后遍历每个炸弹作为起点,用 BFS 统计能引爆的最大数量,结合了并查集和 BFS 的优势。

js 复制代码
/**
 * LeetCode 2101 引爆最多炸弹
 * 题目:炸弹之间可以连锁引爆,求最多能引爆多少个
 * 核心思想:建图 + BFS 遍历每个起点求最大连通数
 * 图关系:如果 A 能引爆 B,则 A → B 有一条有向边
 */
var maximumDetonation = function (bombs) {
  const n = bombs.length;

  // ==================== 1. 建图(邻接表)====================
  // indexToNeighbors[i]:表示炸弹 i 能直接引爆的所有炸弹编号
  // 必须用 map 创建独立数组,避免引用共享
  const indexToNeighbors = new Array(n).fill(0).map(() => []);

  // 双层循环:i 从 0~n,j 从 i~n,只算一半,效率更高
  // 同时判断 i→j 和 j→i 两个方向
  for (let i = 0; i < n; i++) {
    for (let j = i + 1; j < n; j++) {
      // 取出两个炸弹的坐标与半径
      const [cx, cy, cr] = bombs[i];
      const [nx, ny, nr] = bombs[j];

      // 计算两点距离的平方(不开根号,避免浮点误差)
      const d = (cx - nx) * (cx - nx) + (cy - ny) * (cy - ny);

      // 判断:炸弹 i 能否引爆 j
      if (cr * cr >= d) {
        indexToNeighbors[i].push(j);
      }
      // 判断:炸弹 j 能否引爆 i
      if (nr * nr >= d) {
        indexToNeighbors[j].push(i);
      }
    }
  }

  // ==================== 2. BFS 遍历所有起点 ====================
  let maxCount = 0; // 记录全局最大数量

  // 每个炸弹都当一次起点,做 BFS
  for (let i = 0; i < n; i++) {
    const queue = [i]; // BFS 队列,存炸弹索引
    const seen = new Array(n).fill(false); // 每次 BFS 独立 seen
    let breakCount = 0; // 当前起点能引爆的总数
    seen[i] = true; // 标记起点已添加到队列

    // BFS 核心循环
    while (queue.length) {
      const levelSize = queue.length; // 层序遍历(可不用,但逻辑更清晰)

      for (let j = 0; j < levelSize; j++) {
        const curIdx = queue.shift(); // 取出当前炸弹
        breakCount++; // 计数+1

        // 遍历当前炸弹能引爆的所有邻居
        for (const nei of indexToNeighbors[curIdx]) {
          if (seen[nei]) continue; // 已炸过,跳过
          seen[nei] = true; // 标记为已炸
          queue.push(nei); // 加入队列,继续扩散
        }
      }
    }

    // 更新最大值
    maxCount = Math.max(maxCount, breakCount);
  }

  // 返回能引爆的最大数量
  return maxCount;
};

三、BFS 与并查集对比及选型技巧

BFS 和并查集都能处理图的连通性问题,但适用场景各有侧重,选择对的工具能大幅提升解题效率,避免走弯路。

3.1 核心区别

特性 BFS 并查集
核心用途 求最短路径、层序遍历、多源扩散 连通块管理、合并集合、判断连通性
时间复杂度 O(V+E)(V为节点数,E为边数) 接近 O(1)(带路径压缩和按秩合并)
适用场景 无权图最短路径、层序相关、扩散类问题 分组归类、连通块统计、动态合并集合
优势 能快速求解最短路径,适配层序类需求 合并、查询效率极高,代码简洁
劣势 处理连通块合并时效率低于并查集 无法求解最短路径,不适用于层序遍历

3.2 选型技巧(面试必记)

  • 看到「最短路径」「最少步数」「层序」「扩散」→ 优先用 BFS;

  • 看到「连通块」「分组」「合并」「是否连通」→ 优先用并查集;

  • 复杂场景(如引爆炸弹):可结合两者,先用并查集合并连通块,再用 BFS 统计结果;

  • 图为有向图:优先用 BFS(并查集更适合无向图的连通性);

相关推荐
casual~2 小时前
第?个质数(埃氏筛算法)
数据结构·c++·算法
小彭努力中2 小时前
191.Vue3 + OpenLayers 实战:可控化版权信息(Attribution)详解与完整示例
前端·javascript·vue.js·#地图开发·#cesium
无限大62 小时前
数字生存02:如何在信息爆炸的时代保持清醒,不被算法控制
后端
无限大62 小时前
AI实战02:一个万能提示词模板,搞定90%的文案/设计/分析需求
前端·后端
仰泳的熊猫2 小时前
题目2308:蓝桥杯2019年第十届省赛真题-旋转
数据结构·c++·算法·蓝桥杯
朝阳5813 小时前
控制 Nuxt 页面的渲染模式:客户端 vs 服务端渲染
前端·javascript
hssfscv3 小时前
力扣练习训练2(java)——二叉树的中序遍历、对称二叉树、二叉树的最大深度、买卖股票的最佳时机
java·数据结构·算法
青柠代码录3 小时前
【Linux】脚本:console.log 日志定期备份清理
后端
陈随易3 小时前
站在普通开发者的角度,我觉得 RollCode 更像是“把 H5 交付这件事重新捋顺了”
前端·后端·程序员