问题描述
基因序列可以表示为一条由 8 个字符组成的字符串,其中每个字符都是 ‘A’、‘C’、‘G’ 和 ‘T’ 之一。
假设我们需要调查从基因序列 startGene
变为 endGene
所发生的基因变化。一次基因变化就意味着这个基因序列中的一个字符发生了变化。
- 例如,
"AACCGGTT" --> "AACCGGTA"
就是一次基因变化。
另有一个基因库 bank
记录了所有有效的基因变化,只有基因库中的基因才是有效的基因序列。(变化后的基因必须位于基因库 bank
中)
给你两个基因序列 startGene
和 endGene
,以及一个基因库 bank
,请你找出并返回能够使 startGene
变化为 endGene
所需的 最少变化次数。如果无法完成此基因变化,返回 -1 。
注意: 起始基因序列 startGene
默认是有效的,但是它并不一定会出现在基因库中。
示例 1:
1 | 输入:startGene = "AACCGGTT", endGene = "AACCGGTA", bank = ["AACCGGTA"] |
示例 2:
1 | 输入:startGene = "AACCGGTT", endGene = "AAACGGTA", bank = ["AACCGGTA","AACCGCTA","AAACGGTA"] |
示例 3:
1 | 输入:startGene = "AAAAACCC", endGene = "AACCCCCC", bank = ["AAAACCCC","AAACCCCC","AACCCCCC"] |
约束条件:
startGene.length == 8
endGene.length == 8
0 <= bank.length <= 10
bank[i].length == 8
startGene
、endGene
和bank[i]
仅由字符['A', 'C', 'G', 'T']
组成
解题思路
这个问题要求解"最少变化次数",这是一个典型的最短路径问题。我们可以将每个基因序列看作一个节点,如果两个基因序列之间可以通过一次变化(即一个字符不同)相互转换,并且目标序列在基因库 bank
中,那么这两个节点之间就存在一条边。问题的目标就是找到从 startGene
到 endGene
的最短路径。
思路一:建图 + Dijkstra 算法
这种方法将问题显式地转换为图的最短路径问题。
- 节点定义:将
startGene
以及bank
中所有不重复的基因序列作为图的节点。 - 边的定义:如果两个基因序列(节点)之间只有一个字符不同,则在它们之间连接一条权重为 1 的边。
- 构建邻接矩阵:我们可以创建一个邻接矩阵
graph
来存储图的结构。graph[i][j] = 1
表示节点i
和节点j
之间可以相互转换。 - 最短路径算法:在构建好的图上,从
startGene
对应的节点开始,使用 Dijkstra 算法计算到endGene
对应节点的最短路径。
实现细节
Dijkstra 算法是一种经典的解决单源最短路径问题的算法,它适用于边权为非负数的情况。虽然在这里所有边权都为 1,使用它也是完全正确的。
代码实现 (Go - Dijkstra)
1 | package main |
思路二:广度优先搜索 (BFS)
对于边权全部为 1 的图,广度优先搜索(BFS)是求解最短路径问题的更直接、更高效的选择。BFS 按层级遍历图,第一次到达目标节点时所经过的层数(步数)就是最短路径的长度。
- 数据结构:
- 一个队列
queue
用于存储待访问的基因序列。 - 一个哈希集合
visited
(或map
) 用于记录已经访问过的序列,防止重复搜索和陷入循环。 - 一个哈希集合
bankSet
用于快速查询一个基因序列是否在基因库bank
中。
- 一个队列
- 算法流程:
- 将
startGene
和初始步数 0 加入队列。 - 将
startGene
加入visited
集合。 - 当队列不为空时,取出队头元素(当前基因序列
currentGene
和步数steps
)。 - 如果
currentGene
等于endGene
,则steps
就是最短路径,返回steps
。 - 否则,生成
currentGene
的所有可能的一次变化。对于每个新的基因序列nextGene
:- 如果
nextGene
存在于bankSet
中且未被访问过,则将其加入队列和visited
集合,步数加 1。
- 如果
- 如果队列为空仍未找到
endGene
,说明无法转换,返回 -1。
- 将
代码实现 (Go - BFS)
1 | import "container/list" |
方法比较
方面 | 方法一 (Dijkstra) | 方法二 (BFS) |
---|---|---|
时间复杂度 | O(N² + L*N²) or O(N²logN) | O(CLN) |
空间复杂度 | O(N²) | O(L*N) |
核心思想 | 通用的单源最短路径算法 | 针对无权图的最短路径优化 |
优点 | 思路通用,可处理带权图 | 实现更简洁,效率更高 |
缺点 | 对于无权图来说过于复杂 | 仅适用于无权图 |
推荐度 | ★★★☆☆ | ★★★★★ |
- N:
bank
的长度。 - L: 基因序列的长度 (这里是 8)。
- C: 基因字符集的大小 (这里是 4)。
- Dijkstra 的复杂度取决于其具体实现(邻接矩阵为 O(N²),优先队列为 O(E log V),在此图中 E 可能达到 O(N²))。
- BFS 的复杂度中,
C*L
代表从一个节点可以扩展出的邻居数量,N 是节点总数。
复杂度分析
Dijkstra
- 时间复杂度:
O(N^2)
,其中N
是图中节点的总数 (len(bank) + 1
)。构建邻接矩阵需要O(N^2 * L)
的时间(L=8),Dijkstra 算法在使用邻接矩阵和线性扫描找最小距离节点时的时间复杂度是O(N^2)
。 - 空间复杂度:
O(N^2)
,主要用于存储邻接矩阵。
BFS
- 时间复杂度:
O(C * L * N)
。N
是bank
的长度,L
是基因序列长度 (8),C
是字符集大小 (4)。对于队列中的每个元素,我们都需要遍历其所有可能的C*L
种变化,并检查其是否在bank
中。由于bank
的大小N
很小,这种方法非常高效。 - 空间复杂度:
O(L * N)
,用于存储bankSet
、visited
集合和队列。
关键收获
- 问题建模: 识别出 “最少次数”、“最短转换” 这类问题可以建模为图的最短路径问题是解决关键。
- 算法选择: 在无权图(所有边的权重都为 1 或相同)中,BFS 是求解最短路径的首选算法。它的实现简单且时间复杂度优于通用的 Dijkstra 算法。
- 隐式图 vs 显式图: Dijkstra 的解法是先 显式 地把图的完整结构(邻接矩阵)构建出来,然后再运行算法。而 BFS 的解法是在搜索过程中 隐式 地探索图的节点和边,无需预先构建整个图,从而节省了空间和预处理时间。对于节点连接规则明确但图可能很大的情况,隐式图搜索是更优的选择。