生信面试算法与数据结构¶
一句话概述:生信面试中算法题不追求 LeetCode 难度,但需要掌握哈希表、排序、二分查找、动态规划等基础算法,以及它们在序列比对、变异检测、基因组组装中的实际应用。
核心知识点速查表¶
| 概念 | 白话解释 | 生信应用 |
|---|---|---|
| 哈希表(Hash Table) | 键值对存储,查找O(1) | K-mer计数、序列索引 |
| 二分查找 | 有序数组中快速查找,O(log n) | BED区间查询、BAM索引 |
| 排序 | 把数据按顺序排列 | VCF按位置排序、TopN基因 |
| 动态规划(DP) | 把大问题拆成小问题 | 序列比对(Smith-Waterman) |
| 后缀数组/BWT | 字符串高效索引 | BWA/Bowtie2比对算法 |
| de Bruijn图 | K-mer构建的图结构 | 基因组从头组装 |
| 贪心算法 | 每步选当前最优 | 序列拼接、贪心比对 |
| 图算法 | 节点+边的网络 | PPI网络、代谢通路 |
一、哈希表(最常用)¶
# ========== 哈希表:生信中最重要的数据结构 ==========
# 场景1:基因ID映射(O(1)查找)
gene_map = {} # 空字典
gene_map["ENSG00000141510"] = "TP53" # 建立映射
gene_map["ENSG00000012048"] = "BRCA1"
print(gene_map.get("ENSG00000141510", "Unknown")) # O(1)查找
# 场景2:K-mer计数
from collections import Counter
def count_kmers(seq, k=21):
"""
统计序列中所有k-mer的频率
时间复杂度: O(n), n=序列长度
空间复杂度: O(4^k), k-mer种类数
"""
kmers = [seq[i:i+k] for i in range(len(seq)-k+1)] # 生成所有k-mer
return Counter(kmers) # 哈希计数
# 应用:通过k-mer频率判断测序错误
# 正确的k-mer出现频率高,测序错误导致的k-mer出现频率低(1-2次)
# 场景3:两个基因列表求交集
set_a = set(["TP53", "BRCA1", "EGFR", "KRAS"]) # 集合A
set_b = set(["BRCA1", "EGFR", "MYC", "ALK"]) # 集合B
intersection = set_a & set_b # 交集:O(min(m,n))
union = set_a | set_b # 并集
diff = set_a - set_b # 差集(A有B没有)
print(f"交集: {intersection}") # {'BRCA1', 'EGFR'}
# 面试要点:set底层也是哈希表,in操作O(1)
# list的in操作是O(n),查找大量元素时set比list快很多
二、排序算法¶
# ========== 排序:基础但重要 ==========
# Python内置排序:Timsort,O(n log n)
genes = [("TP53", 0.001), ("BRCA1", 0.05), ("EGFR", 0.0001)]
# 按p值排序
sorted_genes = sorted(genes, key=lambda x: x[1]) # 按第2个元素升序
print(sorted_genes) # EGFR, TP53, BRCA1
# 按p值降序
sorted_genes = sorted(genes, key=lambda x: x[1], reverse=True)
# 多级排序:先按染色体,再按位置
variants = [
("chr1", 1000, "A", "T"),
("chr2", 500, "G", "C"),
("chr1", 500, "C", "G"),
]
sorted_variants = sorted(variants, key=lambda x: (x[0], x[1])) # 元组排序
# 结果:chr1:500, chr1:1000, chr2:500
# 面试题:找出表达量最高的Top 10基因
import heapq
expression = {"TP53": 1500, "BRCA1": 800, "EGFR": 3000, "MYC": 2500}
# 方法1:全排序 O(n log n)
top10 = sorted(expression.items(), key=lambda x: x[1], reverse=True)[:10]
# 方法2:堆 O(n log k),k=10,大数据更快
top10 = heapq.nlargest(10, expression.items(), key=lambda x: x[1])
# 面试要点:
# 稳定排序(stable sort): 相等元素保持原始顺序 → Python的sorted()是稳定的
# 不稳定排序: 相等元素顺序不确定 → 快速排序
三、二分查找¶
# ========== 二分查找:有序数据的快速查找 ==========
import bisect
def binary_search(arr, target):
"""
在有序数组中查找target
时间复杂度: O(log n)
"""
left, right = 0, len(arr) - 1 # 左右边界
while left <= right:
mid = (left + right) // 2 # 中间位置
if arr[mid] == target:
return mid # 找到了
elif arr[mid] < target:
left = mid + 1 # 目标在右半边
else:
right = mid - 1 # 目标在左半边
return -1 # 没找到
# 生信应用:BED文件区间查询
# 问题:给定一个基因组位置,快速找到它在哪个基因/外显子区间
def find_region(positions, query):
"""
在排序的位置列表中找到query应该插入的位置
应用:判断变异是否在目标区间内
"""
idx = bisect.bisect_left(positions, query) # 二分查找插入位置
return idx
# 示例:exon起始位置已排序
exon_starts = [100, 500, 1000, 2000, 5000, 8000]
query_pos = 750 # 变异位置
idx = bisect.bisect_right(exon_starts, query_pos)
print(f"位置{query_pos}在第{idx}个区间之后") # 在第2个之后(500-1000之间)
# 面试要点:
# BAM文件的索引(.bai)使用类似二分查找来快速定位基因组区域
# tabix索引也是基于排序+二分查找
四、动态规划(序列比对的核心)¶
# ========== 动态规划:序列比对的数学基础 ==========
# 全局比对(Needleman-Wunsch)
def needleman_wunsch(seq1, seq2, match=1, mismatch=-1, gap=-2):
"""
全局序列比对(Needleman-Wunsch算法)
时间复杂度: O(m*n)
空间复杂度: O(m*n)
"""
m, n = len(seq1), len(seq2)
# 初始化DP矩阵
dp = [[0]*(n+1) for _ in range(m+1)] # (m+1) x (n+1) 矩阵
for i in range(m+1):
dp[i][0] = i * gap # 第一列:gap累加
for j in range(n+1):
dp[0][j] = j * gap # 第一行:gap累加
# 填充DP矩阵
for i in range(1, m+1):
for j in range(1, n+1):
if seq1[i-1] == seq2[j-1]: # 碱基匹配
diag = dp[i-1][j-1] + match
else: # 碱基不匹配
diag = dp[i-1][j-1] + mismatch
up = dp[i-1][j] + gap # 从上方(seq1加gap)
left = dp[i][j-1] + gap # 从左方(seq2加gap)
dp[i][j] = max(diag, up, left) # 取最大值
# 回溯
align1, align2 = [], []
i, j = m, n
while i > 0 or j > 0:
if i > 0 and j > 0:
score = match if seq1[i-1] == seq2[j-1] else mismatch
if dp[i][j] == dp[i-1][j-1] + score: # 对角线
align1.append(seq1[i-1])
align2.append(seq2[j-1])
i -= 1
j -= 1
continue
if i > 0 and dp[i][j] == dp[i-1][j] + gap: # 上方
align1.append(seq1[i-1])
align2.append('-')
i -= 1
else: # 左方
align1.append('-')
align2.append(seq2[j-1])
j -= 1
return ''.join(reversed(align1)), ''.join(reversed(align2)), dp[m][n]
# 测试
a1, a2, score = needleman_wunsch("ATCGTAC", "ATGTAC")
print(f"Seq1: {a1}") # ATC-GTAC 或 ATCGTAC
print(f"Seq2: {a2}") # AT-GTAC 或 AT-GTAC
print(f"Score: {score}")
# 局部比对(Smith-Waterman)
# 与NW的区别:
# 1. DP矩阵最小值为0(不允许负分)
# 2. 从最高分处开始回溯(不是右下角)
# 3. 回溯到分数为0时停止
# 面试要点:
# BLAST 不是Smith-Waterman,而是启发式的种子-延伸算法(更快但不保证最优)
# BWA-MEM 使用 BWT+后缀数组做精确匹配种子,再用Smith-Waterman做延伸
五、BWT 与 FM-index(BWA的核心)¶
# ========== BWT变换:BWA/Bowtie2比对的核心算法 ==========
def bwt_transform(text):
"""
Burrows-Wheeler Transform
将文本变换为更容易压缩和搜索的形式
"""
text = text + '$' # 添加终止符
n = len(text)
rotations = [text[i:] + text[:i] for i in range(n)] # 所有旋转
rotations.sort() # 字典序排序
bwt = ''.join(r[-1] for r in rotations) # 取每个旋转的最后一个字符
return bwt
# 示例
seq = "ATCGATCG"
bwt = bwt_transform(seq)
print(f"原始序列: {seq}")
print(f"BWT变换: {bwt}")
# 面试要点:
# BWT的神奇之处:
# 1. 变换是可逆的(可以从BWT恢复原始序列)
# 2. 相同字符聚在一起,利于压缩
# 3. 配合FM-index可以O(m)时间精确匹配长度为m的模式
# 4. 参考基因组3GB,BWT索引约3-4GB,比后缀树(~45GB)小很多
# BWA建索引(bwa index)就是在构建BWT+FM-index
六、de Bruijn 图(基因组组装)¶
# ========== de Bruijn图:基因组组装的核心 ==========
from collections import defaultdict
def build_debruijn(reads, k=3):
"""
从reads构建de Bruijn图
节点 = (k-1)-mer
边 = k-mer
"""
graph = defaultdict(list) # 邻接表
for read in reads:
for i in range(len(read) - k + 1): # 遍历所有k-mer
kmer = read[i:i+k]
prefix = kmer[:-1] # 前(k-1)个字符
suffix = kmer[1:] # 后(k-1)个字符
graph[prefix].append(suffix) # prefix → suffix
return dict(graph)
# 示例
reads = ["ATCGA", "TCGAT", "CGATC"]
graph = build_debruijn(reads, k=3)
print("de Bruijn图:")
for node, edges in sorted(graph.items()):
print(f" {node} → {edges}")
# 面试要点:
# 理想情况:de Bruijn图有欧拉路径 → 组装出完整序列
# 实际问题:重复序列导致图有分支(bubble)、错误导致tip
# 组装工具(如SPAdes、MEGAHIT)就是在解决这些图结构问题
# k值选择:k太小→图太复杂(重复多),k太大→图不连通(覆盖度不够)
七、图算法(PPI网络分析)¶
# ========== 图算法:生信网络分析基础 ==========
# BFS(广度优先搜索)— 找最短路径
from collections import deque
def bfs_shortest_path(graph, start, end):
"""
BFS找两个基因在PPI网络中的最短路径
应用:药物靶点预测、基因功能推断
"""
visited = {start} # 已访问节点
queue = deque([(start, [start])]) # 队列:(节点, 路径)
while queue:
node, path = queue.popleft() # 取出队首
if node == end:
return path # 找到目标
for neighbor in graph.get(node, []): # 遍历邻居
if neighbor not in visited:
visited.add(neighbor)
queue.append((neighbor, path + [neighbor]))
return None # 不可达
# PPI网络示例
ppi_network = {
"TP53": ["MDM2", "BRCA1", "ATM"],
"BRCA1": ["TP53", "RAD51", "PALB2"],
"MDM2": ["TP53"],
"ATM": ["TP53", "CHK2"],
"CHK2": ["ATM", "BRCA1"],
"RAD51": ["BRCA1"],
"PALB2": ["BRCA1"],
}
path = bfs_shortest_path(ppi_network, "MDM2", "RAD51")
print(f"MDM2到RAD51最短路径: {' → '.join(path)}")
# MDM2 → TP53 → BRCA1 → RAD51
# 节点度(degree)— 连接数
degrees = {gene: len(partners) for gene, partners in ppi_network.items()}
hub_genes = sorted(degrees.items(), key=lambda x: -x[1]) # 按度排序
print(f"Hub基因: {hub_genes[0][0]}(度={hub_genes[0][1]})")
# Hub基因通常是重要的调控因子
八、时间/空间复杂度(面试必问)¶
# ========== 复杂度分析速查 ==========
# 时间复杂度(从快到慢):
# O(1) — 常数时间(哈希查找)
# O(log n) — 对数时间(二分查找、BAM索引查询)
# O(n) — 线性时间(遍历一遍、grep搜索)
# O(n log n)— 排序时间(sort、samtools sort)
# O(n²) — 平方时间(简单比对、嵌套循环)
# O(n*m) — Smith-Waterman比对(n和m是两条序列长度)
# O(2^n) — 指数时间(暴力搜索,不可接受)
# 常见生信操作的复杂度:
operations = {
"FASTA读取": "O(n) 时间, O(n) 空间",
"K-mer计数": "O(n) 时间, O(4^k) 空间",
"BLAST搜索": "O(n*m) 最坏, 启发式约O(n)",
"BWA比对": "O(m) 查找种子 + O(m*w) 延伸",
"samtools sort": "O(n log n) 时间",
"VCF过滤(grep)": "O(n) 时间",
"BED区间查询": "O(log n) 用tabix索引",
"NW全局比对": "O(n*m) 时间, O(n*m) 空间",
"SW局部比对": "O(n*m) 时间, O(n*m) 空间",
}
for op, complexity in operations.items():
print(f" {op}: {complexity}")
九、常见报错与解决¶
| 问题 | 原因 | 解决方法 |
|---|---|---|
| 递归超过最大深度 | 递归太深或无终止条件 | 改用迭代或增大sys.setrecursionlimit() |
| 内存不足 | 数据结构太大 | 用生成器、分块处理、减少k值 |
| 时间超限 | 算法复杂度太高 | 用更好的算法(如哈希替换嵌套循环) |
| 排序结果不对 | key函数写错 | 打印中间结果调试 |
十、面试高频问题¶
Q1:BLAST 和 Smith-Waterman 的区别?
Smith-Waterman 是精确算法(保证最优解),时间O(n*m),适合短序列。BLAST 是启发式算法(不保证最优但很快),先找种子匹配再延伸,适合在大数据库中搜索。BWA-MEM 结合了 BWT 精确种子查找和 SW 延伸。
Q2:BWA 为什么比 BLAST 快?
BWA 用 BWT+FM-index 索引参考基因组,查找种子的时间与参考基因组大小无关(O(m),m=read长度)。BLAST 每次查询都需要遍历数据库。BWA 适合短读长比对,BLAST 适合相似序列搜索。
Q3:基因组组装为什么用 de Bruijn 图而不是 OLC?
二代测序 reads 多(亿条)且短(150bp)。OLC(Overlap-Layout-Consensus)需要 O(n²) 时间做全对全比对,太慢。de Bruijn 图把 reads 分解为 k-mer,构图 O(n),更高效。三代测序长读长用 OLC 更好(reads 少但长)。
Q4:哈希表查找为什么是 O(1)?
哈希函数把 key 直接映射到数组下标,不需要遍历。冲突时用链表或开放定址,平均仍是 O(1)。最坏情况(所有 key 哈希冲突)O(n),但好的哈希函数几乎不会发生。
十一、速查表¶
# === 生信算法速查 ===
# 数据结构选择
# 查找多 → 哈希表(dict/set)
# 有序数据 → 二分查找(bisect)
# 网络关系 → 图(邻接表)
# 序列比对 → 动态规划(DP矩阵)
# 复杂度口诀
# 一次遍历 O(n),排序 O(n log n)
# 嵌套循环 O(n²),二分 O(log n)
# 哈希查找 O(1),BFS/DFS O(V+E)
# 生信算法对应
# BWA → BWT + FM-index + SW延伸
# BLAST → 种子(word) + 延伸(gapped alignment)
# SPAdes → de Bruijn图 + 欧拉路径
# GATK → Pair-HMM + 贝叶斯
# samtools sort → 外部归并排序
参考资料:Algorithms (Sedgewick 2011)、Bioinformatics Algorithms (Compeau & Pevzner 2015)、BWA论文 (Li & Durbin 2009)