在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. 打开转盘锁(层序遍历求解最短解锁步数)
总结一下核心要点:
-
遇到“最少步骤”“最短路径”类问题,优先考虑 BFS 层序遍历。
-
图的构建是关键:明确节点(基因序列)和边(单碱基差异),用邻接表存储图更高效。
-
细节决定成败:避免误判合法索引、类型不匹配等小问题,才能确保代码正确运行。