433. 最小基因变化

433. 最小基因变化

✨核心逻辑

本题采用 BFS(广度优先搜索) 的策略,用于在无权图中寻找最短路径(最少变异次数):

  1. 预处理基因库:将合法的基因序列 bank 存储到哈希集合 set 中,以便在 O(1) 的时间内判断变异后的基因是否合法。
  2. 快速剪枝:如果目标基因 endGene 本身就不在基因库 set 中,说明无论怎么变异都无法到达终点,直接返回 -1
  3. 逐层遍历(BFS)
    • 使用队列 queue 存放当前一层的基因序列。
    • 每次从队列中弹出一个基因序列,尝试将其 8 个位置上的字符,分别替换为 ACGT 中的另外三个字符。
    • 如果生成的新的基因序列已经访问过(在 set 中),则将新序列放入队列,并从 set 中移除(避免后续重复访问和死循环)。
    • 如果在尝试替换的过程中,刚好生成了目标基因 endGene,则说明找到了最短路径,直接返回当前步数 + 1。
  4. 层数统计:每次处理完队列中的一层元素后,步数 step 加 1。

🔥代码实现(含详细变量注释)

class Solution {
    public int minMutation(String startGene, String endGene, String[] bank) {
        // set:用于存储基因库中所有有效的基因序列,提供 O(1) 的快速查找和删除能力
        Set<String> set = new HashSet<>();
        // 将 bank 数组中的所有合法基因序列加入到 set 中
        for (String string : bank) {
            set.add(string);
        }
        
        // 极端条件:如果目标基因 endGene 不包含在基因库中,根本无法达成,直接返回 -1
        if (!set.contains(endGene)) {
            return -1;
        }
        
        // step:记录当前搜索的层数(即基因变化的最少次数)
        int step = 0;
        // xyz:定义基因突变中仅允许出现的 4 个有效字符
        char[] xyz = {'A', 'C', 'G', 'T'};
        // queue:用于 BFS 遍历的队列,存放当前层待尝试变异的基因序列
        Queue<String> queue = new LinkedList<>();
        // 将起始基因序列加入队列
        queue.offer(startGene);

        // BFS 核心循环:只要队列不为空,就持续向外扩散探索
        while (!queue.isEmpty()) {
            // size:记录当前队列中属于同一层级的所有序列数量
            int size = queue.size();
            
            // 遍历当前层的所有节点(按层遍历,保证步数最小)
            for (int i = 0; i < size; i++) {
                // poll:从队列中弹出一个当前的基因序列
                String poll = queue.poll();
                // 将字符串转为字符数组(字符串是不可变的,转为字符数组方便修改单个字符)
                char[] ch = poll.toCharArray();
                
                // 遍历 8 个位置的基因字符
                for (int j = 0; j < 8; j++) {
                    // originalChar:记录当前位的原始字符,用于后续尝试完其他选项后恢复原状
                    char originalChar = ch[j];
                    
                    // 尝试将第 j 位替换为 xyz 中的 4 个字符
                    for (char current : xyz) {
                        // 如果替换的字符和原来一样,则跳过
                        if (current == originalChar) {
                            continue;
                        }
                        
                        // 修改当前位的字符
                        ch[j] = current;
                        // now:将修改后的字符数组转回新的基因序列字符串
                        String now = new String(ch);
                        
                        // 如果经过一次变异,直接得到了目标基因 endGene
                        if (now.equals(endGene)) {
                            return step + 1;
                        }
                        
                        // 如果新生成的基因序列在基因库 set 中,说明这是合法的一步
                        if (set.contains(now)) {
                            // 将其加入到 BFS 的下一层队列中
                            queue.offer(now);
                            // 从 set 中删除该序列,防止后续其他路径重复访问,形成死循环
                            set.remove(now);
                        }
                        
                        // 回溯:恢复第 j 位的原始字符,准备尝试下一个不同的突变字符
                        ch[j] = originalChar;
                    }
                }
            }
            // 当前层的所有节点都探索完毕后,表示完成了一次基因变化,步数加 1
            step++;
        }
        // 如果 BFS 遍历完所有可能都没有找到 endGene,说明无法完成变异,返回 -1
        return -1;
    }
}
  • ⏱️复杂度分析
    • 时间复杂度:O(N * 8 * 4) = O(N),其中 N 是基因库 bank 的长度。每一次 BFS 扩展,我们要尝试 8 个位置的替换,每个位置有 4 种可能的字符(排除自身),属于常数级操作(32 次),所有有效的基因序列在队列中最多会被访问一次。

    • 空间复杂度:O(N)。主要消耗在于存储基因库的哈希集合 set 和维护 BFS 队列 queue,最多需要存储 N 个基因序列。

🔍总结一下:

遇到了很多困惑:

  1. 这道题的“图“没有直接显示出来,是隐式的:是对 start 操作的过程中,如果符合 bank的元素,那么二者就有一条边,节点就是字符串
  1. set的作用:我们都知道,边在两个节点之间是可逆的,A-B 也是 B-A,这也就是 set 的作用,避免此情况的发生,记录已访问过的节点

3.为什么不直接 poll 一个元素之后进行操作,而是先遍历队列的 size 呢?因为是 BFS ,也就是当前层处理完之后,我才能让 step + 1,去处理下一层,如果没有外层 size 循环,就是poll 一个元素,操作之后 + 1,那就变成了操作的节点数量而不是一层一层的step,附件: image-Uuik.png