跳转至

算法与数据结构基础

一句话说明:算法是解决问题的步骤,数据结构是组织数据的方式——掌握它们,你就能写出高效代码、看懂生信工具原理、通过技术面试。


为什么要学算法与数据结构?

面试常考:几乎所有技术岗面试都会考算法题,生信岗也不例外。面试官通过算法题判断你的逻辑思维和编程能力。

写高效代码:同样的任务,用对数据结构可能从跑 10 小时变成 10 分钟。比如用哈希表做 k-mer 计数,比逐一比对快几万倍。

理解工具原理:BWA 用后缀数组做序列比对、SPAdes 用 de Bruijn 图做基因组组装、BLAST 用动态规划做序列比对——不懂算法,这些工具对你就是黑箱。

白话说:算法就像做菜的菜谱,数据结构就像你厨房里的锅碗瓢盆。菜谱再好,没有合适的锅也做不了菜;锅再好,没有菜谱也不知道怎么做。


一、时间复杂度与空间复杂度

在讲数据结构之前,先搞懂"快慢"怎么衡量。

白话解释

时间复杂度 = 数据量变大时,程序运行时间怎么增长。
空间复杂度 = 数据量变大时,程序占用内存怎么增长。

用大 O 表示法(Big O):

复杂度 白话解释 例子
O(1) 不管数据多大,时间固定。就像查字典直接翻到那一页 字典按 key 取值
O(log n) 数据翻倍,时间只多一步。就像猜数字每次猜中间 二分查找
O(n) 数据翻倍,时间也翻倍。就像挨个数一排人 遍历列表
O(n log n) 比 O(n) 慢一点,大多数高效排序的速度 快速排序、归并排序
O(n²) 数据翻倍,时间变 4 倍。就像所有人互相握手 冒泡排序、两层循环
O(2ⁿ) 每多一个数据,时间翻倍。指数爆炸 暴力穷举所有子集

生信实际感受:处理 100 万条序列时—— - O(n) 算法:100 万步,秒级完成 - O(n²) 算法:1 万亿步,可能跑几天 - O(n log n) 算法:2000 万步,几秒搞定

# === 复杂度直观演示 ===
import time  # 导入时间模块

def demo_o1(data):
    """O(1):常数时间——不管列表多长,只取第一个"""
    return data[0]  # 直接按索引取值,一步到位

def demo_on(data):
    """O(n):线性时间——数据翻倍,时间翻倍"""
    total = 0  # 初始化累加器
    for x in data:  # 遍历每一个元素
        total += x  # 累加
    return total  # 返回总和

def demo_on2(data):
    """O(n²):平方时间——两层循环,数据翻倍时间变4倍"""
    count = 0  # 初始化计数器
    for i in data:  # 外层循环
        for j in data:  # 内层循环(对每个i都遍历全部j)
            count += 1  # 每一对(i,j)都计数
    return count  # 返回总配对数

# 测试不同规模
for size in [1000, 5000, 10000]:  # 逐步增大数据量
    data = list(range(size))  # 生成测试数据

    start = time.time()  # 记录开始时间
    demo_on(data)  # 运行O(n)
    t_n = time.time() - start  # 计算耗时

    start = time.time()  # 记录开始时间
    demo_on2(data)  # 运行O(n²)
    t_n2 = time.time() - start  # 计算耗时

    print(f"n={size:>6}: O(n)={t_n:.6f}s, O(n²)={t_n2:.6f}s")
    # 你会看到 n 翻 5 倍时,O(n) 耗时翻 5 倍,O(n²) 耗时翻 25 倍

二、核心数据结构

2.1 列表 / 数组(List / Array)

白话:一排编了号的格子,按编号(索引)存取东西。就像一排储物柜,每个柜子有编号。

时间复杂度: - 按索引访问:O(1) - 末尾添加:O(1)(均摊) - 插入/删除中间元素:O(n)(后面的都要挪位) - 查找某个值:O(n)

# === 列表基本操作 ===
genes = ["BRCA1", "TP53", "EGFR", "KRAS"]  # 创建基因列表

# 按索引访问——O(1),直接跳到第2个位置
print(genes[1])  # 输出: TP53

# 末尾添加——O(1)
genes.append("MYC")  # 在最后面加一个基因
print(genes)  # ['BRCA1', 'TP53', 'EGFR', 'KRAS', 'MYC']

# 中间插入——O(n),后面的元素都要往后挪
genes.insert(2, "PIK3CA")  # 在索引2的位置插入
print(genes)  # ['BRCA1', 'TP53', 'PIK3CA', 'EGFR', 'KRAS', 'MYC']

# 遍历——O(n)
for i, gene in enumerate(genes):  # enumerate 同时给出索引和值
    print(f"索引{i}: {gene}")  # 打印每个基因及其位置

# 列表推导式——Python特色的高效写法
lengths = [len(g) for g in genes]  # 计算每个基因名的长度
print(lengths)  # [5, 4, 6, 4, 4, 3]

# 切片操作——取子列表
top3 = genes[:3]  # 取前3个元素
print(top3)  # ['BRCA1', 'TP53', 'PIK3CA']

生信应用:存储序列、质量分数(FASTQ 的 quality scores)、基因表达矩阵的一行数据。


2.2 字典 / 哈希表(Dict / Hash Table)

白话:一本"索引册",通过名字(key)直接查到内容(value)。就像通讯录,知道名字就能查到电话号码,不需要从头翻。

时间复杂度: - 查找/插入/删除:O(1)(平均) - 遍历所有:O(n)

# === 字典基本操作 ===

# 基因 -> 功能的映射
gene_function = {  # 创建字典
    "BRCA1": "DNA修复",  # key: value 对
    "TP53": "肿瘤抑制",
    "EGFR": "细胞增殖信号",
}

# 查找——O(1),直接通过key定位
print(gene_function["TP53"])  # 输出: 肿瘤抑制

# 添加——O(1)
gene_function["KRAS"] = "信号转导"  # 新增一个键值对

# 安全查找——key不存在时不报错
result = gene_function.get("BRAF", "未知功能")  # 找不到就返回默认值
print(result)  # 输出: 未知功能

# 遍历所有键值对
for gene, func in gene_function.items():  # .items() 返回键值对
    print(f"{gene}: {func}")

# === 生信实战:k-mer 计数 ===
def count_kmers(sequence, k):
    """统计序列中所有长度为k的子串出现次数"""
    kmer_counts = {}  # 空字典,用来存计数结果
    for i in range(len(sequence) - k + 1):  # 滑动窗口遍历
        kmer = sequence[i:i+k]  # 截取长度为k的子串
        # 如果这个kmer已经见过,计数+1;没见过就初始化为1
        kmer_counts[kmer] = kmer_counts.get(kmer, 0) + 1
    return kmer_counts  # 返回计数字典

seq = "ATCGATCGATCG"  # 测试序列
result = count_kmers(seq, 3)  # 统计3-mer
print(result)  # {'ATC': 3, 'TCG': 3, 'CGA': 2, 'GAT': 2}
# 哈希表让每次查找和更新都是O(1),处理百万序列也很快

生信应用:k-mer 计数、基因 ID 映射、GO 注释查询、样本元数据存储。这是生信中最常用的数据结构。


2.3 集合(Set)

白话:一个"去重袋子",扔东西进去自动去重,查某个东西在不在也特别快。就像签到表,只记名字不记顺序,来过就标记了。

时间复杂度: - 查找/添加/删除:O(1)(平均) - 求交集:O(min(m, n)),并集:O(m+n),差集:O(m)(m 为被减集合大小)

# === 集合基本操作 ===

# 两个样本中检测到的基因集合
sample_a = {"BRCA1", "TP53", "EGFR", "KRAS", "MYC"}  # 样本A的基因
sample_b = {"TP53", "EGFR", "PIK3CA", "BRAF", "MYC"}  # 样本B的基因

# 交集——两个样本共有的基因
common = sample_a & sample_b  # & 是交集运算符
print(f"共有基因: {common}")  # {'TP53', 'EGFR', 'MYC'}

# 并集——两个样本所有出现过的基因
all_genes = sample_a | sample_b  # | 是并集运算符
print(f"所有基因: {all_genes}")

# 差集——样本A有但B没有的基因
a_only = sample_a - sample_b  # - 是差集运算符
print(f"A独有: {a_only}")  # {'BRCA1', 'KRAS'}

# 成员判断——O(1),非常快
print("BRCA1" in sample_a)  # True——在集合中查找是O(1)
print("BRCA1" in ["BRCA1", "TP53"])  # True——但在列表中查找是O(n)

# === 实战:从大列表中去重 ===
raw_reads = ["ATCG", "GCTA", "ATCG", "TTTT", "GCTA", "ATCG"]  # 原始reads,有重复
unique_reads = set(raw_reads)  # 一行去重
print(f"去重前: {len(raw_reads)}, 去重后: {len(unique_reads)}")
# 去重前: 6, 去重后: 3

生信应用:基因列表去重、差异基因集合的韦恩图运算、快速判断某基因是否在已知基因库中。


2.4 栈和队列(Stack & Queue)

白话: - (Stack):像叠盘子,后放的先拿(后进先出,LIFO) - 队列(Queue):像排队买饭,先来的先走(先进先出,FIFO)

时间复杂度:入栈/出栈、入队/出队都是 O(1)。

# === 栈——用列表实现 ===
stack = []  # 空栈
stack.append("第1步: 读取FASTQ")  # 压入栈(push)
stack.append("第2步: 质控")  # 压入栈
stack.append("第3步: 比对")  # 压入栈

print(stack.pop())  # 弹出栈顶(pop): "第3步: 比对"
print(stack.pop())  # 弹出: "第2步: 质控"
# 栈的应用:函数调用栈、撤销操作、括号匹配

# === 队列——用 collections.deque 实现 ===
from collections import deque  # deque 是双端队列,两头操作都是O(1)

task_queue = deque()  # 创建空队列
task_queue.append("样本001")  # 入队(队尾加入)
task_queue.append("样本002")  # 入队
task_queue.append("样本003")  # 入队

print(task_queue.popleft())  # 出队(队首弹出): "样本001"
print(task_queue.popleft())  # 出队: "样本002"
# 队列的应用:任务调度、BFS搜索、流水线处理

生信应用:分析流水线的任务调度(先提交的样本先处理)、BFS 遍历基因调控网络、递归调用栈。


2.5 链表(Linked List)

白话:一串珠子,每颗珠子知道下一颗在哪。不像数组那样连续存放,而是"串"起来的。好处是插入删除快(O(1)),坏处是不能直接跳到第 N 个(得从头数,O(n))。

时间复杂度: - 头部插入/删除:O(1) - 按索引访问:O(n) - 查找:O(n)

# === 简单链表实现 ===
class Node:
    """链表节点:每个节点存一个值和指向下一个节点的指针"""
    def __init__(self, data):
        self.data = data  # 节点存储的数据
        self.next = None  # 指向下一个节点的指针,初始为空

class LinkedList:
    """单向链表"""
    def __init__(self):
        self.head = None  # 链表头部,初始为空

    def prepend(self, data):
        """在头部插入——O(1)"""
        new_node = Node(data)  # 创建新节点
        new_node.next = self.head  # 新节点指向原来的头
        self.head = new_node  # 更新头部为新节点

    def display(self):
        """遍历打印——O(n)"""
        current = self.head  # 从头开始
        items = []  # 收集所有值
        while current:  # 一直走到末尾(None)
            items.append(current.data)  # 记录当前值
            current = current.next  # 移动到下一个
        print(" -> ".join(items))  # 用箭头连接打印

# 使用链表
pipeline = LinkedList()  # 创建空链表
pipeline.prepend("比对")  # 插入到头部
pipeline.prepend("质控")  # 插入到头部(变成第一个)
pipeline.prepend("读取")  # 插入到头部(变成第一个)
pipeline.display()  # 输出: 读取 -> 质控 -> 比对

生信应用:基因组上的变异位点链表、流水线步骤的链式调用。实际开发中 Python 直接用 list 更多,但面试常考链表操作。


2.6 树(Tree)

白话:倒过来的树——最上面是根,往下分叉。每个节点可以有子节点。

  • 二叉树:每个节点最多 2 个孩子(左、右)
  • 二叉搜索树(BST):左孩子比自己小,右孩子比自己大,查找很快
  • B 树 / B+ 树:数据库索引用的多叉树,磁盘读取效率高
# === 二叉搜索树实现 ===
class TreeNode:
    """树节点"""
    def __init__(self, val):
        self.val = val  # 节点值
        self.left = None  # 左子节点
        self.right = None  # 右子节点

class BST:
    """二叉搜索树:左小右大"""
    def __init__(self):
        self.root = None  # 根节点

    def insert(self, val):
        """插入值——O(log n) 平均"""
        if not self.root:  # 空树,直接设为根
            self.root = TreeNode(val)
            return
        self._insert(self.root, val)  # 递归插入

    def _insert(self, node, val):
        """递归找到合适位置插入"""
        if val < node.val:  # 比当前节点小,往左走
            if node.left is None:  # 左边为空,就插在这里
                node.left = TreeNode(val)
            else:
                self._insert(node.left, val)  # 左边不空,继续往左找
        else:  # 比当前节点大或等于,往右走
            if node.right is None:  # 右边为空,就插在这里
                node.right = TreeNode(val)
            else:
                self._insert(node.right, val)  # 右边不空,继续往右找

    def search(self, val):
        """查找值——O(log n) 平均"""
        return self._search(self.root, val)

    def _search(self, node, val):
        """递归查找"""
        if node is None:  # 走到空了,没找到
            return False
        if val == node.val:  # 找到了
            return True
        elif val < node.val:  # 目标比当前小,往左找
            return self._search(node.left, val)
        else:  # 目标比当前大,往右找
            return self._search(node.right, val)

    def inorder(self, node, result=None):
        """中序遍历——会得到从小到大的排序结果"""
        if result is None:
            result = []  # 初始化结果列表
        if node:
            self.inorder(node.left, result)  # 先遍历左子树
            result.append(node.val)  # 再访问当前节点
            self.inorder(node.right, result)  # 最后遍历右子树
        return result

# 使用二叉搜索树
bst = BST()
expression_values = [5.2, 3.1, 7.8, 1.0, 4.5, 6.3, 9.9]  # 基因表达量
for val in expression_values:  # 逐个插入
    bst.insert(val)

print(bst.search(4.5))  # True——O(log n) 查找
print(bst.inorder(bst.root))  # [1.0, 3.1, 4.5, 5.2, 6.3, 7.8, 9.9]——排好序了

生信应用:物种进化树(系统发育树)本质就是树结构;数据库索引用 B+ 树加速查询;决策树/随机森林也是树。


2.7 图(Graph)

白话:一堆点(节点)和连接它们的线(边)。比树更自由——任意两个节点之间都可以连线,还可以有方向和权重。

# === 用字典表示图(邻接表) ===

# 蛋白质互作网络(PPI Network)
ppi_network = {  # 每个蛋白质 -> 与它互作的蛋白质列表
    "TP53": ["MDM2", "BRCA1", "ATM"],  # TP53 和这3个蛋白质有互作
    "BRCA1": ["TP53", "RAD51", "BARD1"],
    "MDM2": ["TP53"],
    "ATM": ["TP53", "BRCA1"],
    "RAD51": ["BRCA1"],
    "BARD1": ["BRCA1"],
}

# 查找某个蛋白质的互作伙伴——O(1)
print(f"TP53的互作: {ppi_network['TP53']}")

# 计算每个蛋白质的度(degree = 连接数)
for protein, partners in ppi_network.items():
    print(f"{protein}: degree = {len(partners)}")
# TP53 degree=3,是这个网络中的 hub 蛋白(连接最多)

# 判断两个蛋白质是否有直接互作
def has_interaction(network, protein_a, protein_b):
    """判断两个蛋白质是否直接互作"""
    if protein_a in network:  # 先确保蛋白质a在网络中
        return protein_b in network[protein_a]  # 判断b是否在a的邻居中
    return False

print(has_interaction(ppi_network, "TP53", "MDM2"))  # True
print(has_interaction(ppi_network, "RAD51", "MDM2"))  # False

生信应用:蛋白质互作网络(PPI)、基因调控网络(GRN)、代谢通路网络、de Bruijn 图(基因组组装的核心)。


三、核心算法

3.1 排序算法

快速排序(Quick Sort)

白话:选一个基准值,比它小的放左边,比它大的放右边,然后对左右两边分别再排。像打牌时按大小分堆再整理。

时间复杂度:平均 O(n log n),最坏 O(n²)

def quick_sort(arr):
    """快速排序——分而治之"""
    if len(arr) <= 1:  # 0或1个元素不用排,直接返回
        return arr
    pivot = arr[len(arr) // 2]  # 选中间的元素作为基准
    left = [x for x in arr if x < pivot]  # 比基准小的放左边
    middle = [x for x in arr if x == pivot]  # 等于基准的放中间
    right = [x for x in arr if x > pivot]  # 比基准大的放右边
    return quick_sort(left) + middle + quick_sort(right)  # 递归排序后拼接

# 测试:按表达量排序基因
expression = [5.2, 1.1, 8.7, 3.3, 6.6, 2.2]  # 基因表达量
sorted_exp = quick_sort(expression)  # 排序
print(sorted_exp)  # [1.1, 2.2, 3.3, 5.2, 6.6, 8.7]

归并排序(Merge Sort)

白话:先把数组拆成两半,分别排好序,再合并。像两副已排好的扑克牌,按大小交替合在一起。

时间复杂度:始终 O(n log n),稳定排序

def merge_sort(arr):
    """归并排序——先拆后合"""
    if len(arr) <= 1:  # 一个元素不用排
        return arr
    mid = len(arr) // 2  # 找中间位置
    left = merge_sort(arr[:mid])  # 递归排序左半部分
    right = merge_sort(arr[mid:])  # 递归排序右半部分
    return merge(left, right)  # 合并两个有序数组

def merge(left, right):
    """合并两个有序数组"""
    result = []  # 存放合并结果
    i = j = 0  # 两个指针分别指向left和right的开头
    while i < len(left) and j < len(right):  # 两边都还有元素
        if left[i] <= right[j]:  # 左边当前元素更小
            result.append(left[i])  # 放入结果
            i += 1  # 左指针前进
        else:  # 右边当前元素更小
            result.append(right[j])  # 放入结果
            j += 1  # 右指针前进
    result.extend(left[i:])  # 左边剩余的全部放入
    result.extend(right[j:])  # 右边剩余的全部放入
    return result

# 测试
data = [38, 27, 43, 3, 9, 82, 10]  # 待排序数据
print(merge_sort(data))  # [3, 9, 10, 27, 38, 43, 82]

生信应用:SAMtools sort 对 BAM 文件按坐标排序、对差异基因按 p-value 排序。


白话:在一个已排好序的列表中,每次比较中间的元素,如果目标比中间小就去左半边找,比中间大就去右半边找。每一步排除一半,非常快。

时间复杂度:O(log n)

def binary_search(arr, target):
    """二分查找——前提是数组已排序"""
    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  # 没找到

# 测试:在排好序的染色体位置中查找
positions = [100, 250, 500, 750, 1000, 1500, 2000, 3000]  # 已排序的变异位点
idx = binary_search(positions, 750)  # 查找位置750
print(f"找到位置750在索引: {idx}")  # 输出: 3
print(f"查找999: {binary_search(positions, 999)}")  # 输出: -1(没找到)

生信应用:在排好序的 BAM 文件中按坐标快速定位 reads、tabix 索引查询。


3.3 双指针(Two Pointers)

白话:用两个指针从不同位置出发,协同移动来解决问题。就像两个人从绳子两头往中间走。

def two_sum_sorted(arr, target):
    """在有序数组中找两个数的和等于target"""
    left, right = 0, len(arr) - 1  # 左右两个指针
    while left < right:  # 两个指针没相遇
        current_sum = arr[left] + arr[right]  # 当前两数之和
        if current_sum == target:  # 找到了
            return (left, right)  # 返回两个索引
        elif current_sum < target:  # 和太小,左指针右移让和变大
            left += 1
        else:  # 和太大,右指针左移让和变小
            right -= 1
    return None  # 没找到

# 测试:找两个表达量之和等于10
values = [1.5, 2.3, 3.7, 4.8, 6.2, 7.5, 8.1]  # 已排序
result = two_sum_sorted(values, 10.0)  # 找和为10的两个数
if result:
    i, j = result
    print(f"values[{i}]={values[i]} + values[{j}]={values[j]} = {values[i]+values[j]}")

生信应用:合并有序的基因组区间、对已排序的 reads 做配对分析。


3.4 滑动窗口(Sliding Window)

白话:在序列上放一个固定大小的"窗口",每次往右滑动一格。就像火车窗户——你看到的风景是固定宽度的一段,火车动一格你就看到新的一格风景。

def gc_content_windows(sequence, window_size):
    """用滑动窗口计算序列每个区域的GC含量"""
    results = []  # 存放每个窗口的GC含量
    # 先算第一个窗口的GC数
    gc_count = sum(1 for c in sequence[:window_size] if c in "GC")
    results.append(gc_count / window_size)  # 第一个窗口的GC含量

    # 窗口滑动:每次减去离开窗口的碱基,加上进入窗口的碱基
    for i in range(1, len(sequence) - window_size + 1):
        if sequence[i - 1] in "GC":  # 离开窗口的碱基是GC
            gc_count -= 1  # GC计数减1
        if sequence[i + window_size - 1] in "GC":  # 进入窗口的碱基是GC
            gc_count += 1  # GC计数加1
        results.append(gc_count / window_size)  # 记录当前窗口GC含量

    return results

# 测试
seq = "ATCGGCTAGCTTAAGCGCTA"  # 测试序列
gc_profile = gc_content_windows(seq, 5)  # 窗口大小5
for i, gc in enumerate(gc_profile):
    print(f"位置{i}-{i+4}: GC={gc:.1%}")  # 打印每个窗口的GC含量
# 滑动窗口让每个窗口的计算是O(1),总时间O(n)
# 如果每个窗口都重新数一遍就是O(n*k),窗口越大越慢

生信应用:GC 含量分析、序列质量滑动窗口评估(Trimmomatic 的 SLIDINGWINDOW)、CpG 岛检测。


3.5 递归(Recursion)

白话:函数自己调用自己。就像俄罗斯套娃——打开一个娃娃,里面还有一个更小的娃娃,直到最小的那个(基线条件)为止。

def fibonacci(n, memo={}):
    """斐波那契数列——带记忆化的递归
    注意:memo={} 利用了 Python 可变默认参数在函数调用间共享的特性来做缓存。
    面试中如果被问到可变默认参数的陷阱,要能解释这里是故意利用这个行为。
    更规范的写法是用 functools.lru_cache 装饰器。
    """
    if n <= 1:  # 基线条件:不再递归
        return n
    if n in memo:  # 已经算过了,直接返回(避免重复计算)
        return memo[n]
    memo[n] = fibonacci(n - 1) + fibonacci(n - 2)  # 递归调用
    return memo[n]

# 测试
for i in range(10):
    print(f"F({i}) = {fibonacci(i)}", end="  ")
# F(0)=0  F(1)=1  F(2)=1  F(3)=2  F(4)=3  F(5)=5 ...
print()

# === 递归遍历进化树 ===
def count_leaves(tree):
    """递归计算树的叶子节点数(物种数)"""
    if isinstance(tree, str):  # 叶子节点是字符串(物种名)
        return 1  # 基线条件
    total = 0  # 初始化计数
    for subtree in tree:  # 遍历每个子树
        total += count_leaves(subtree)  # 递归计算
    return total

# 一个简单的进化树结构(嵌套列表表示)
phylo_tree = [["人类", "黑猩猩"], ["大猩猩", ["猩猩", "长臂猿"]]]
print(f"物种数: {count_leaves(phylo_tree)}")  # 输出: 5

生信应用:系统发育树遍历、分治算法(归并排序、快排)、递归解析嵌套的文件格式(如 Newick 树格式)。


3.6 动态规划入门(Dynamic Programming)

白话:把大问题拆成小问题,记住小问题的答案(不重复计算),用小问题的答案推出大问题的答案。就像爬楼梯——到第 10 级的方法数 = 到第 9 级的方法数 + 到第 8 级的方法数。

这是生信中最重要的算法思想——序列比对的核心就是动态规划。

# === 经典动态规划:爬楼梯 ===
def climb_stairs(n):
    """爬n级楼梯,每次爬1或2级,有多少种方法?"""
    if n <= 2:  # 1级有1种,2级有2种
        return n
    dp = [0] * (n + 1)  # dp[i] = 到第i级的方法数
    dp[1] = 1  # 到第1级只有1种方法
    dp[2] = 2  # 到第2级有2种方法(1+1 或 2)
    for i in range(3, n + 1):  # 从第3级开始推导
        dp[i] = dp[i-1] + dp[i-2]  # 从i-1跨1步 或 从i-2跨2步
    return dp[n]

print(f"爬10级楼梯: {climb_stairs(10)}种方法")  # 89

# === 生信核心:简化版序列比对(编辑距离) ===
def edit_distance(seq1, seq2):
    """计算两条序列的编辑距离(最少需要多少次操作变成一样)
    操作包括:插入、删除、替换
    这就是序列比对的简化版原理!
    """
    m, n = len(seq1), len(seq2)  # 两条序列的长度
    # dp[i][j] = seq1前i个字符 变成 seq2前j个字符 需要的最少操作数
    dp = [[0] * (n + 1) for _ in range(m + 1)]  # 创建 (m+1) x (n+1) 的表

    for i in range(m + 1):  # 第一列:seq2为空,需要删除i次
        dp[i][0] = i
    for j in range(n + 1):  # 第一行:seq1为空,需要插入j次
        dp[0][j] = j

    for i in range(1, m + 1):  # 逐行填表
        for j in range(1, n + 1):  # 逐列填表
            if seq1[i-1] == seq2[j-1]:  # 当前字符相同,不需要操作
                dp[i][j] = dp[i-1][j-1]  # 直接继承左上角的值
            else:  # 当前字符不同,取三种操作的最小值 + 1
                dp[i][j] = 1 + min(
                    dp[i-1][j],    # 删除(从上方来)
                    dp[i][j-1],    # 插入(从左方来)
                    dp[i-1][j-1]   # 替换(从左上方来)
                )
    return dp[m][n]  # 右下角就是最终答案

# 测试
seq1 = "ATCGTAC"  # 序列1
seq2 = "ATGCTAC"  # 序列2
dist = edit_distance(seq1, seq2)
print(f"'{seq1}' 和 '{seq2}' 的编辑距离: {dist}")  # 2
# BLAST、Smith-Waterman、Needleman-Wunsch 都基于类似的DP思想

生信应用:Needleman-Wunsch 全局比对、Smith-Waterman 局部比对、多序列比对、隐马尔可夫模型(HMM)的 Viterbi 算法。


3.7 BFS 与 DFS(图/树遍历)

白话: - BFS(广度优先):像水波扩散,一圈一圈往外走。先访问所有近邻,再访问近邻的近邻。 - DFS(深度优先):像走迷宫,一条路走到底,走不通了再回头试另一条。

from collections import deque  # BFS需要队列

# 基因调控网络
gene_network = {
    "TP53": ["MDM2", "CDKN1A", "BAX"],  # TP53 调控这3个基因
    "MDM2": ["TP53"],  # 负反馈
    "CDKN1A": ["CDK2"],
    "BAX": ["BCL2"],
    "CDK2": [],
    "BCL2": [],
}

def bfs(graph, start):
    """BFS广度优先——一层一层往外扩展"""
    visited = set()  # 记录已访问的节点(避免重复)
    queue = deque([start])  # 初始化队列,放入起始节点
    visited.add(start)  # 标记起始节点为已访问
    order = []  # 记录访问顺序

    while queue:  # 队列不为空就继续
        node = queue.popleft()  # 取出队首节点
        order.append(node)  # 记录访问顺序
        for neighbor in graph.get(node, []):  # 遍历所有邻居
            if neighbor not in visited:  # 没访问过的才加入
                visited.add(neighbor)  # 标记为已访问
                queue.append(neighbor)  # 加入队尾
    return order

def dfs(graph, start, visited=None):
    """DFS深度优先——一条路走到底再回头"""
    if visited is None:
        visited = set()  # 初始化已访问集合
    visited.add(start)  # 标记当前节点为已访问
    order = [start]  # 记录当前节点
    for neighbor in graph.get(start, []):  # 遍历邻居
        if neighbor not in visited:  # 没访问过的
            order.extend(dfs(graph, neighbor, visited))  # 递归深入
    return order

print(f"BFS从TP53出发: {bfs(gene_network, 'TP53')}")
# ['TP53', 'MDM2', 'CDKN1A', 'BAX', 'CDK2', 'BCL2']——一层一层
print(f"DFS从TP53出发: {dfs(gene_network, 'TP53')}")
# ['TP53', 'MDM2', 'CDKN1A', 'CDK2', 'BAX', 'BCL2']——一条路走到底

生信应用:基因调控网络分析、蛋白质互作网络的连通分量检测、代谢通路搜索、系统发育树遍历。


四、生信中的算法应用

4.1 动态规划 -> 序列比对

BLAST、BWA-MEM 的核心。Needleman-Wunsch(全局比对)和 Smith-Waterman(局部比对)都是经典 DP:构建得分矩阵,通过回溯找到最优比对路径。上面的编辑距离就是简化版。

4.2 de Bruijn 图 -> 基因组组装

SPAdes、MEGAHIT 把 reads 打断成 k-mer,每个 k-mer 是图的一条边,通过找欧拉路径(经过每条边恰好一次)来组装基因组。白话说:把拼图碎片按重叠部分连成网络,然后找一条路把所有碎片串起来。

4.3 后缀数组 / BWT -> 序列索引

BWA 用 Burrows-Wheeler Transform 建立参考基因组索引,让比对速度从 O(m*n) 降到接近 O(m)。白话说:对基因组做特殊的"排序变换",让查找任意子串变得极快。

4.4 哈希表 -> k-mer 计数

Jellyfish、KMC 用哈希表存储 k-mer 及其出现次数。处理几十 GB 的测序数据时,O(1) 的查找和更新速度至关重要。

4.5 隐马尔可夫模型(HMM)

Prodigal 基因预测、HMMER 蛋白质家族搜索都用 HMM。核心是 Viterbi 算法(也是动态规划),在隐藏状态序列中找最优路径。


五、LeetCode 推荐入门题目

按难度递进,优先刷这些:

入门(Easy)

题号 题目 考点 生信联系
1 Two Sum 哈希表 快速查找配对
20 Valid Parentheses 括号/标签匹配
21 Merge Two Sorted Lists 链表 合并有序数据
70 Climbing Stairs 动态规划 DP入门
104 Maximum Depth of Binary Tree 树/递归 进化树遍历
141 Linked List Cycle 快慢指针 环检测
206 Reverse Linked List 链表 面试必考
704 Binary Search 二分查找 坐标索引查询

进阶(Medium)

题号 题目 考点 生信联系
3 Longest Substring Without Repeating 滑动窗口 序列特征扫描
15 3Sum 双指针 多条件组合查找
53 Maximum Subarray 动态规划 信号峰值区段检测
72 Edit Distance DP 序列比对核心
102 Binary Tree Level Order BFS 层级遍历
200 Number of Islands DFS/BFS 连通分量
322 Coin Change DP 资源优化分配

六、常见面试算法题(6 道 + Python 解法)

题 1:两数之和(LeetCode #1)

def two_sum(nums, target):
    """给定数组和目标值,找出和为目标值的两个数的索引
    思路:用哈希表记录"需要的配对值"
    时间O(n),空间O(n)
    """
    seen = {}  # 哈希表:值 -> 索引
    for i, num in enumerate(nums):  # 遍历每个数
        complement = target - num  # 计算需要的配对值
        if complement in seen:  # 配对值之前见过
            return [seen[complement], i]  # 返回两个索引
        seen[num] = i  # 记录当前值和索引
    return []  # 没找到

print(two_sum([2, 7, 11, 15], 9))  # [0, 1]——因为 2+7=9

题 2:有效括号(LeetCode #20)

def is_valid_parentheses(s):
    """判断括号字符串是否有效
    思路:左括号压栈,右括号弹栈匹配
    """
    stack = []  # 用列表模拟栈
    pairs = {')': '(', ']': '[', '}': '{'}  # 右括号 -> 对应的左括号
    for char in s:  # 遍历每个字符
        if char in pairs:  # 是右括号
            if not stack or stack[-1] != pairs[char]:  # 栈空或不匹配
                return False  # 无效
            stack.pop()  # 匹配成功,弹出栈顶
        else:  # 是左括号
            stack.append(char)  # 压入栈
    return len(stack) == 0  # 最后栈空才是有效的

print(is_valid_parentheses("()[]{}"))  # True
print(is_valid_parentheses("(]"))  # False

题 3:反转链表(LeetCode #206)

class ListNode:
    """链表节点"""
    def __init__(self, val=0, next=None):
        self.val = val  # 值
        self.next = next  # 下一个节点

def reverse_list(head):
    """反转链表
    思路:三指针法,逐个翻转指向
    """
    prev = None  # 前一个节点,初始为空
    curr = head  # 当前节点,从头开始
    while curr:  # 还没到末尾
        next_temp = curr.next  # 先保存下一个节点(不然翻转后就丢了)
        curr.next = prev  # 当前节点指向前一个(翻转!)
        prev = curr  # 前指针前进
        curr = next_temp  # 当前指针前进
    return prev  # prev 现在是新的头节点

# 构建测试链表: 1 -> 2 -> 3 -> 4
head = ListNode(1, ListNode(2, ListNode(3, ListNode(4))))
new_head = reverse_list(head)  # 反转
# 打印结果
node = new_head
while node:
    print(node.val, end=" -> " if node.next else "\n")  # 4 -> 3 -> 2 -> 1
    node = node.next

题 4:最大子数组和(LeetCode #53)

def max_subarray(nums):
    """找到数组中连续子数组的最大和
    思路:Kadane算法——维护当前最大和,如果加上当前数反而变小就重新开始
    生信联系:找基因组上信号最强的连续区段
    """
    max_sum = nums[0]  # 全局最大和,初始为第一个元素
    current_sum = nums[0]  # 当前连续和
    for i in range(1, len(nums)):  # 从第二个元素开始
        # 要么加上当前数继续延伸,要么从当前数重新开始
        current_sum = max(nums[i], current_sum + nums[i])
        max_sum = max(max_sum, current_sum)  # 更新全局最大
    return max_sum

print(max_subarray([-2, 1, -3, 4, -1, 2, 1, -5, 4]))  # 6(子数组[4,-1,2,1])

题 5:二叉树最大深度(LeetCode #104)

class TreeNode:
    """二叉树节点"""
    def __init__(self, val=0, left=None, right=None):
        self.val = val
        self.left = left
        self.right = right

def max_depth(root):
    """计算二叉树最大深度
    思路:递归——树的深度 = max(左子树深度, 右子树深度) + 1
    生信联系:计算进化树的最大进化距离
    """
    if root is None:  # 空节点深度为0(基线条件)
        return 0
    left_depth = max_depth(root.left)  # 递归算左子树深度
    right_depth = max_depth(root.right)  # 递归算右子树深度
    return max(left_depth, right_depth) + 1  # 取较大的+1(加上自己这一层)

# 构建测试树:     3
#               / \
#              9   20
#                 / \
#                15   7
root = TreeNode(3, TreeNode(9), TreeNode(20, TreeNode(15), TreeNode(7)))
print(f"最大深度: {max_depth(root)}")  # 3

题 6:编辑距离(LeetCode #72)

def min_distance(word1, word2):
    """计算将word1转换为word2所需的最少操作次数
    操作:插入、删除、替换
    这道题就是序列比对的核心算法!
    """
    m, n = len(word1), len(word2)
    dp = [[0] * (n + 1) for _ in range(m + 1)]  # DP表

    # 初始化:空串到另一个串需要的操作数
    for i in range(m + 1):
        dp[i][0] = i  # word1前i个字符全删除
    for j in range(n + 1):
        dp[0][j] = j  # 空串插入j个字符

    # 填表
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if word1[i-1] == word2[j-1]:  # 字符相同
                dp[i][j] = dp[i-1][j-1]  # 不需要操作
            else:  # 字符不同,三种操作取最小
                dp[i][j] = 1 + min(
                    dp[i-1][j],    # 删除word1[i]
                    dp[i][j-1],    # 在word1中插入word2[j]
                    dp[i-1][j-1]   # 替换word1[i]为word2[j]
                )
    return dp[m][n]

# 生信示例:两条DNA序列的编辑距离
print(min_distance("ATCGTAC", "ATGCTAC"))  # 2
print(min_distance("GATTACA", "GCATGCU"))  # 4

七、速查表

7.1 数据结构对比

数据结构 查找 插入 删除 空间 适用场景
列表/数组 O(n) O(1)末尾 / O(n)中间 O(n) O(n) 有序数据、按索引访问
字典/哈希表 O(1) O(1) O(1) O(n) 键值映射、快速查找
集合 O(1) O(1) O(1) O(n) 去重、成员判断
O(n) O(1) O(1) O(n) 后进先出场景
队列 O(n) O(1) O(1) O(n) 先进先出场景
链表 O(n) O(1)头部 O(1)已知位置 O(n) 频繁插入删除
二叉搜索树 O(log n) O(log n) O(log n) O(n) 有序数据动态维护
- O(1) O(1) O(V+E) 网络关系建模

注:哈希表的 O(1) 是平均情况,最坏可能退化为 O(n)

7.2 排序算法对比

算法 平均时间 最坏时间 空间 稳定性 特点
快速排序 O(n log n) O(n²) O(log n) 不稳定 实际最快,但最坏情况差
归并排序 O(n log n) O(n log n) O(n) 稳定 时间稳定,适合链表排序
堆排序 O(n log n) O(n log n) O(1) 不稳定 空间最省
冒泡排序 O(n²) O(n²) O(1) 稳定 最简单但最慢,仅供学习
Python sorted() O(n log n) O(n log n) O(n) 稳定 Timsort,实际开发直接用这个

稳定性:相等的元素排序后是否保持原来的相对顺序。生信中按多个字段排序时很重要。

7.3 算法复杂度速查

操作 时间复杂度 常见算法/场景
哈希表增删查 O(1) 字典操作、k-mer 计数
二分查找 O(log n) 有序数据查找、tabix
单次遍历 O(n) 求和、计数、GC 含量
高效排序 O(n log n) 快排、归并、BAM 排序
两层循环 O(n²) 冒泡排序、简单比对
DP 矩阵填表 O(m*n) 序列比对、编辑距离
子集枚举 O(2ⁿ) 特征选择暴力搜索

八、延伸资源

书籍

  • 《算法图解》(Aditya Bhargava):图文并茂,非常适合入门,Python 代码示例
  • 《数据结构与算法 Python 语言实现》:系统学习用

在线课程

  • LeetCode:刷题平台,从 Easy 开始刷,每天 1-2 题
  • 力扣(中文版)leetcode.cn,题目有中文翻译
  • Visualgovisualgo.net,算法可视化,看动画理解排序、搜索过程

生信相关

  • Rosalindrosalind.info,用算法题学生物信息学,题目结合 DNA/蛋白质
  • 《Bioinformatics Algorithms》(Compeau & Pevzner):生信算法教材

刷题建议

  1. 先按类型刷:先把哈希表的题做 5 道,再做链表 5 道,不要随机跳
  2. 每道题做 3 遍:第一遍看思路写,第二遍独立写,第三遍优化
  3. 控制时间:Easy 20 分钟、Medium 40 分钟想不出来就看题解
  4. 重点刷:哈希表 > 双指针 > 二分查找 > 动态规划 > 树/图——这是面试频率排名

最后总结:算法不需要你从零发明,需要你认识问题类型 -> 选对数据结构和算法 -> 写出正确代码。生信面试不会考很难的算法题,但基础的哈希表、排序、二分查找、动态规划一定要会。每天刷 1-2 道 LeetCode,坚持两周就能应付大部分面试了。