LeetCode 433. 最小基因变化:BFS求解最短路径问题

0 阅读6分钟

在LeetCode的中等难度题目中,433. 最小基因变化是一道典型的“最短路径”问题,核心考察对广度优先搜索(BFS)的理解和应用。这道题看似是基因序列的变化问题,本质上可以转化为无向图的最短路径求解——每一个基因序列是图中的节点,两个仅相差一个碱基的基因序列之间存在一条边,我们需要找到从起始节点(startGene)到目标节点(endGene)的最短路径长度。

今天就来详细拆解这道题,从题目分析、代码思路、易错点修正到优化方向,一步步带你吃透这道题,同时结合我最终调试好的完整代码,帮你避开刷题路上的“坑”。

一、题目回顾(清晰理解题意)

先明确题目核心要求,避免理解偏差:

  • 基因序列由8个字符组成,每个字符只能是 'A'、'C'、'G'、'T' 之一(固定长度,简化了比较逻辑)。

  • 一次基因变化:仅改变基因序列中的一个字符(即两个基因序列的差异数为1)。

  • 有效变化:变化后的基因序列必须在基因库 bank 中(startGene 本身有效,但不一定在 bank 中)。

  • 求解目标:从 startGene 变为 endGene 所需的最少变化次数;若无法实现,返回 -1。

举个简单例子:startGene = "AACCGGTT",endGene = "AACCGGTA",bank = ["AACCGGTA"],此时仅需1次变化(将最后一个 'T' 改为 'A'),所以返回1。

二、解题思路(BFS为什么是最优解?)

这道题的核心需求是“最少变化次数”,而 BFS 的核心特性就是「层序遍历」——每一层代表一次变化,遍历到目标节点时,所处的层数就是最短路径长度(最少变化次数),这也是我们选择 BFS 而不是 DFS 的原因(DFS 可能会走弯路,需要回溯,效率更低)。

具体解题思路分为3步,对应代码的核心逻辑:

1. 构建基因库的邻接表(无向图)

基因库 bank 中的每一个基因序列都是图中的一个节点,我们需要找到所有“仅相差一个碱基”的节点对,为它们建立边(互相添加到对方的邻接列表中)。同时,记录目标基因 endGene 在 bank 中的索引(如果 endGene 不在 bank 中,直接返回 -1,因为无法完成有效变化)。

2. 初始化BFS队列

找到所有与 startGene 仅相差一个碱基的基因(这些是 startGene 能一步到达的有效节点),将它们加入队列,并标记为已访问(避免重复访问,防止死循环)。此时步数 step 初始化为1(因为从 startGene 到这些节点已经完成1次变化)。

3. BFS层序遍历,寻找最短路径

按层遍历队列中的节点,每遍历完一层,步数 step 加1(代表完成一次批量变化)。对于每个节点,遍历其邻接节点,若邻接节点是目标节点(endGene),直接返回当前步数;若未访问过,则标记为已访问并加入队列。若队列遍历完毕仍未找到目标节点,返回 -1。

三、完整可运行代码(已修正所有易错点)

结合上面的思路,我整理了完整的 TypeScript 代码,并且修正了之前遇到的“索引0被误判”“类型不匹配”等问题,可直接复制到 LeetCode 提交通过:

function minMutation(startGene: string, endGene: string, bank: string[]): number {
  const m = startGene.length; // 基因序列长度(固定为8)
  const n = bank.length;      // 基因库中基因的数量
  // 构建邻接表:存储每个基因(索引)的相邻基因(索引)
  const adj = new Array(n).fill(0).map(() => new Array());
  let endIndex = -1;          // 目标基因在bank中的索引

  // 1. 构建邻接表 + 查找目标基因的索引
  for (let i = 0; i < n; i++) {
    // 记录目标基因在bank中的位置
    if (bank[i] === endGene) {
      endIndex = i;
    }
    // 比较当前基因与后续所有基因,判断是否仅相差1个碱基
    for (let j = i + 1; j < n; j++) {
      let mutations = 0; // 碱基差异数
      for (let k = 0; k < m; k++) {
        if (bank[i][k] !== bank[j][k]) {
          mutations++;
        }
        if (mutations > 1) { // 差异超过1个,无需继续比较
          break;
        }
      }
      // 差异为1,建立双向边(无向图)
      if (mutations === 1) {
        adj[i].push(j);
        adj[j].push(i);
      }
    }
  }

  // 目标基因不在基因库中,直接返回-1
  if (endIndex === -1) {
    return -1;
  }

  // 2. 初始化BFS队列和访问标记
  const queue: number[] = []; // 存储待访问的基因索引
  const visited = new Array(n).fill(false); // 标记基因是否已访问(布尔值更严谨)
  let step = 1; // 初始步数为1(start到第一个有效基因算1步)

  // 找到所有与startGene仅相差1个碱基的基因,加入队列
  for (let i = 0; i < n; i++) {
    let mutations = 0;
    for (let k = 0; k < m; k++) {
      if (startGene[k] !== bank[i][k]) {
        mutations++;
      }
      if (mutations > 1) {
        break;
      }
    }
    if (mutations === 1) {
      queue.push(i);
      visited[i] = true;
    }
  }

  // 3. BFS层序遍历
  while (queue.length) {
    const sz = queue.length; // 当前层的节点数量
    // 遍历当前层的所有节点
    for (let i = 0; i < sz; i++) {
      const curr = queue.shift();
      // 仅过滤undefined(避免误判索引0)
      if (curr === undefined) continue;
      // 找到目标基因,返回当前步数
      if (curr === endIndex) {
        return step;
      }
      // 遍历当前节点的所有相邻基因
      for (const next of adj[curr]) {
        if (!visited[next]) {
          visited[next] = true;
          queue.push(next);
        }
      }
    }
    step++; // 遍历完一层,步数加1
  }

  // 遍历完毕仍未找到目标基因,返回-1
  return -1;
};

四、关键易错点修正(避坑重点!)

这道题的代码思路不难,但很容易因为一些细节出错,我之前就踩过这些坑,整理出来帮大家避坑:

易错点1:误判索引0为无效值(最致命)

最初代码中写了 if (!curr) continue;,但当 curr = 0(目标基因在 bank[0])时,!0 会被判定为 true,直接跳过目标节点,导致返回 -1(比如题目中的测试用例)。

修正方案:改为 if (curr === undefined) continue;,仅过滤队列空时的无效值,不影响合法索引0。

易错点2:visited数组类型不匹配

一开始用 new Array(n).fill(0) 初始化 visited(数字0),但后续用 visited[i] = true(布尔值)赋值,语义混乱,可能导致判断异常。

修正方案:用 new Array(n).fill(false) 初始化,全程用布尔值标记访问状态,更严谨。

易错点3:邻接表构建时重复比较

如果用双重循环 for (let i = 0; i < n; i++) for (let j = 0; j < n; j++),会重复比较 i 和 j、j 和 i,浪费性能。

优化方案:j 从 i+1 开始遍历,只比较一次 i 和 j,建立双向边即可(adj[i].push(j)、adj[j].push(i))。

五、题目延伸与总结

这道题的本质是「无向图的最短路径问题」,BFS 是这类问题的标准解法,类似的题目还有:

  • LeetCode 127. 单词接龙(和本题思路完全一致,只是将基因序列换成了单词)

  • LeetCode 752. 打开转盘锁(层序遍历求解最短解锁步数)

总结一下核心要点:

  1. 遇到“最少步骤”“最短路径”类问题,优先考虑 BFS 层序遍历。

  2. 图的构建是关键:明确节点(基因序列)和边(单碱基差异),用邻接表存储图更高效。

  3. 细节决定成败:避免误判合法索引、类型不匹配等小问题,才能确保代码正确运行。