跳转至

算法与数据结构基础

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


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

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

写高效代码:同样的任务,用对数据结构可能从跑 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)

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

进阶(Medium)

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

六、常见面试算法题(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,坚持两周就能应付大部分面试了。