资讯动态

动态规划实现全局与局部序列比对:Needleman-Wunsch与Smith-Waterman算法详解

发布时间:2026/10/6 10:05:32 来源:尧图企业网站定制
序列比对这件事我在刚接触生物信息的时候踩过一个很典型的坑拿到两条序列脑子里第一反应是这还不简单逐位比较不就完了结果真跑起来才发现两条序列长度不一样、中间还夹着插入缺失逐位比较根本对不齐。后来才明白序列比对的核心不是比较而是对齐——在什么位置插入空位、插入多少空位、空位怎么罚分这一整套决策过程才是算法的灵魂。而支撑这套决策的底层工具就是动态规划。这篇内容我想把 Global Alignment全局比对和 Local Alignment局部比对这两类最基础的序列比对算法从头到尾讲透包括它们各自的动态规划递推式怎么来的、空位罚分为什么要有仿射形式、代码怎么落地、跑出来的结果怎么解读。适合刚学生物信息、算法课需要交作业、或者工作中偶尔要处理序列对齐问题的朋友。不需要你有很深的生物背景只要会写一点 Python、能看懂二维表格就能跟着走完。1. 先搞清楚序列比对到底在解决什么问题1.1 从两条序列长得像不像说起假设你手里有两条 DNA 序列一条是GATTACA另一条是GCATGCU。肉眼扫一眼能看出它们有些位置一样有些不一样但到底像到什么程度光靠看是给不出量化答案的。序列比对要做的就是给这两条序列找到一个最优的对应关系让对应上的字符尽量多、错配和空位尽量少最后用一个分数把这个像的程度表达出来。这里有个关键点容易被忽略比对结果不是唯一的。同样两条序列你可以选择在某个位置插一个空位也可以选择在另一个位置插甚至插两个空位每一种选择都会得到不同的分数。算法要做的是在所有可能的对齐方案里找到分数最高或代价最低的那一个。这就是为什么它天然适合用动态规划来解——因为最优解可以从子问题的最优解推导出来不用暴力枚举所有可能。我习惯把序列比对类比成给两段文字做逐字对照的排版你要决定哪些字对齐、哪些字之间要空一格。空格的插入位置不同整段文字的对齐质量就不同。Global Alignment 要求你把两段文字从头到尾全部对齐一个字符都不能落下Local Alignment 则允许你只挑两段文字里最相似的那一小段来对齐其余部分直接忽略。1.2 Global 和 Local 的本质区别在哪很多人初学时会把这两个概念混在一起觉得不就是比对吗能差多少。差别其实很大而且直接决定了算法递推式的写法。Global Alignment也叫全局比对代表算法是 Needleman-Wunsch。它要求两条序列的全部字符都参与比对从第一个字符比到最后一个字符。哪怕开头完全不一样、结尾完全不一样也得硬着头皮对齐该罚分就罚分。它适合的场景是你确信两条序列整体上是同源的长度也差不多比如两个物种的同一个基因。Local Alignment也叫局部比对代表算法是 Smith-Waterman。它不要求全部字符参与只找两条序列里最相似的那一段局部区域。如果两条序列只有中间一小段像其余部分八竿子打不着Local Alignment 会聪明地把两头不相关的部分丢掉只输出中间那段高分对齐。它适合的场景是你怀疑两条序列里只有某个功能域或某个片段是同源的整体并不相似。用一个不太严谨但好记的说法Global 是整体凑合着对齐Local 是只挑最好的那段对齐。这个区别体现在递推式上就是 Local 多了一个分数不能为负为负就归零重来的操作。1.3 为什么非得用动态规划暴力解法理论上可行枚举所有可能的对齐方式算分取最高。但问题是组合爆炸。两条长度分别为 m 和 n 的序列可能的对齐方式数量随长度指数级增长。长度 100 的两条序列暴力枚举的对齐方案数量已经大到天文数字根本算不完。动态规划的价值在于它把对齐前 i 个字符和前 j 个字符当成一个子问题用一张 (m1)×(n1) 的表格记录每个子问题的最优分数。填表的时候每个格子只需要看它左边、上边、左上边三个格子的值取最优。这样时间复杂度降到 O(mn)空间复杂度也是 O(mn)。对于长度几千的序列现代计算机跑起来毫无压力。提示动态规划能成立的前提是最优子结构——整条序列的最优对齐一定由前缀的最优对齐组合而成。序列比对恰好满足这个性质所以 DP 是它的天然解法。2. 全局比对Needleman-Wunsch 的递推式是怎么推出来的2.1 打分矩阵的物理含义Needleman-Wunsch 的核心是一张二维打分矩阵我习惯叫它 DP 表。假设序列 A 长度为 m序列 B 长度为 n那么这张表就是 (m1) 行、(n1) 列。多出来的那一行一列是初始边界代表其中一条序列还没开始比的情况。表里每个格子F(i, j)的含义是序列 A 的前 i 个字符和序列 B 的前 j 个字符在全局比对下的最优分数。注意是前 i 个和前 j 个不是第 i 个和第 j 个。这个前缀的定义非常关键是理解整个递推的钥匙。初始化的时候第一行F(0, j)表示 A 的前 0 个字符也就是空和 B 的前 j 个字符比对。空序列和长度为 j 的序列比对只能全部插入空位所以分数是 j 乘以空位罚分。同理第一列F(i, 0)是 i 乘以空位罚分。如果空位罚分是 -2那么F(0, 3) -6。2.2 三种来源的取舍逻辑填F(i, j)的时候只有三种可能的来源对应三种对齐动作对角线来源F(i-1, j-1) s(A[i], B[j])把 A 的第 i 个字符和 B 的第 j 个字符对齐。如果两个字符相同s是匹配得分比如 1不同则是错配罚分比如 -1。上方来源F(i-1, j) gapA 的第 i 个字符和空位对齐相当于在 B 里插入一个空位。左方来源F(i, j-1) gapB 的第 j 个字符和空位对齐相当于在 A 里插入一个空位。取这三者的最大值就是F(i, j)。写成公式F(i, j) max( F(i-1, j-1) s(A[i], B[j]), F(i-1, j) gap, F(i, j-1) gap )这个递推式的直觉是要到达(i, j)这个状态你最后一步只可能从这三个方向走过来。既然要最优那就看从哪个方向走过来分数最高。2.3 一个手算例子把过程走一遍拿两条短序列来手算比看公式直观得多。设 A GATTACAB GCATGCU匹配 1错配 -1空位 -1。先初始化第一行第一列。F(0, 0) 0F(0, 1) -1F(0, 2) -2以此类推。第一列同理。然后逐格填。以F(1, 1)为例A[1] GB[1] G相同对角线F(0,0) 1 1上方F(0,1) - 1 -2左方F(1,0) - 1 -2取最大是 1所以F(1,1) 1。再算F(1, 2)A[1] GB[2] C不同对角线F(0,1) (-1) -2上方F(0,2) - 1 -3左方F(1,1) - 1 0取最大是 0所以F(1,2) 0。这样一格一格填下去最后右下角F(m, n)就是全局比对的最优分数。填完之后还要做回溯traceback从右下角往回走每次看当前格子是从哪个方向来的记录下来就能还原出具体的对齐方式。我实测下来手算一遍 7×7 的表大概要十几分钟但算完一遍之后递推式的每个细节都会变得非常清晰比看十遍公式都管用。建议初学的朋友一定亲手算一次。3. 局部比对Smith-Waterman 多出来的那一步归零3.1 从必须对齐到可以放弃Smith-Waterman 和 Needleman-Wunsch 的骨架几乎一样都是填一张 DP 表都看三个方向。唯一的、也是最关键的区别是Smith-Waterman 在取最大值的时候多了一个候选值——0。F(i, j) max( 0, F(i-1, j-1) s(A[i], B[j]), F(i-1, j) gap, F(i, j-1) gap )这个 0 的含义是如果前面怎么对齐分数都是负的那不如从这里重新开始把之前的全部丢掉。换句话说局部比对允许半路重启。这就是它能自动忽略不相关片段的原因——那些不相关的部分会让分数变负一旦变负就被 0 截断不会污染后面的高分区域。初始化也不一样。全局比对第一行第一列要填累加的罚分局部比对则把第一行第一列全部填 0。因为局部比对可以从任意位置开始边界不存在必须对齐的约束。3.2 回溯的起点和终点都变了全局比对回溯时从右下角F(m, n)出发一路走回左上角F(0, 0)路径是完整的。局部比对回溯时起点不是右下角而是整张表里分数最大的那个格子。从那个格子出发往回走什么时候走到 0什么时候停。因为 0 代表这里就是局部比对的起点。所以局部比对的结果是两条序列中间的一段而不是全部。这个差异在实际使用中影响很大。举个例子两条序列整体相似度很低但中间有一段 20 个字符完全一致。全局比对会把两头的不相似也算进去最后分数可能很低甚至为负局部比对则会精准地把那 20 个字符的对齐揪出来分数很高。做同源域搜索、找保守片段的时候局部比对是更合适的选择。3.3 空位罚分为什么需要仿射形式前面用的都是每个空位罚固定分的模型叫线性空位罚分linear gap penalty。它简单但有个明显的问题不区分一个长度为 5 的空位和五个长度为 1 的空位。在真实的生物序列里这两种情况的意义完全不同——一段连续的空位往往是一次插入或缺失事件造成的而分散的多个空位更可能是多次独立事件。前者应该罚得轻一些后者应该罚得重一些。仿射空位罚分affine gap penalty就是来解决这个问题的。它把空位罚分拆成两部分打开空位的罚分gap open和延伸空位的罚分gap extend。一个长度为 L 的空位总罚分是gap_open (L-1) * gap_extend。通常gap_open远大于gap_extend比如 open -5extend -1。这样一段长度为 5 的连续空位罚分是 -5 4×(-1) -9而五个分散的长度为 1 的空位罚分是 5×(-5) -25差距一下就出来了。实现仿射罚分需要三张 DP 表分别记录以匹配/错配结尾以 A 中空位结尾以 B 中空位结尾三种状态递推时在三张表之间转移。这是序列比对里一个经典的进阶话题Gotoh 算法就是专门做这件事的。如果只是学习基础比对线性罚分足够但要做真正有生物学意义的比对仿射罚分几乎是标配。4. 用 Python 把两套算法落地4.1 数据结构的选择与初始化我用 Python 实现的时候DP 表直接用二维列表。行数是len(A) 1列数是len(B) 1。为了回溯方便我还会额外维护一张方向表记录每个格子是从哪个方向来的。def init_matrix(rows, cols, fill0): return [[fill for _ in range(cols)] for _ in range(rows)]这里有个小细节Python 里[[0] * cols] * rows这种写法是错的因为它创建的是 rows 个指向同一个列表的引用改一个格子会连带改一整列。必须用列表推导式每个子列表独立创建。这个坑我见过太多人踩调试半天找不到原因。打分函数单独抽出来方便替换不同的打分方案def score(a, b, match1, mismatch-1): return match if a b else mismatch4.2 全局比对的完整实现def global_align(A, B, match1, mismatch-1, gap-1): m, n len(A), len(B) F init_matrix(m 1, n 1) trace init_matrix(m 1, n 1, fillNone) for i in range(1, m 1): F[i][0] i * gap trace[i][0] up for j in range(1, n 1): F[0][j] j * gap trace[0][j] left for i in range(1, m 1): for j in range(1, n 1): diag F[i-1][j-1] score(A[i-1], B[j-1], match, mismatch) up F[i-1][j] gap left F[i][j-1] gap best max(diag, up, left) F[i][j] best if best diag: trace[i][j] diag elif best up: trace[i][j] up else: trace[i][j] left return F, trace注意索引序列 A 的第 i 个字符在 Python 里是A[i-1]因为 DP 表多了一行一列。这个 off-by-one 是初学阶段最容易出错的地方写的时候一定要在脑子里把表索引和序列索引分开。4.3 局部比对的实现差异局部比对只需要改三处初始化全填 0、递推时加 0 候选、回溯时从最大值格子出发。def local_align(A, B, match1, mismatch-1, gap-1): m, n len(A), len(B) F init_matrix(m 1, n 1) trace init_matrix(m 1, n 1, fillNone) max_score 0 max_pos (0, 0) for i in range(1, m 1): for j in range(1, n 1): diag F[i-1][j-1] score(A[i-1], B[j-1], match, mismatch) up F[i-1][j] gap left F[i][j-1] gap best max(0, diag, up, left) F[i][j] best if best 0: trace[i][j] None elif best diag: trace[i][j] diag elif best up: trace[i][j] up else: trace[i][j] left if best max_score: max_score best max_pos (i, j) return F, trace, max_pos对比一下就能看出两套算法的代码骨架几乎一模一样差异全在初始化和那个 0 上。这也是为什么我建议先学 Global 再学 Local理解了前者后者只是加一个约束。4.4 回溯还原对齐结果回溯的逻辑是从起点格子出发根据方向表一步步往回走每走一步就往对齐结果里加一个字符对。def traceback(A, B, trace, start, stop_condition): i, j start align_a, align_b [], [] while i 0 or j 0: if stop_condition(i, j, trace): break direction trace[i][j] if direction diag: align_a.append(A[i-1]) align_b.append(B[j-1]) i - 1 j - 1 elif direction up: align_a.append(A[i-1]) align_b.append(-) i - 1 elif direction left: align_a.append(-) align_b.append(B[j-1]) j - 1 else: break return .join(reversed(align_a)), .join(reversed(align_b))全局比对调用时起点是(m, n)停止条件是i 0 and j 0。局部比对调用时起点是max_pos停止条件是当前格子分数为 0。回溯出来的字符串是反的最后要 reverse 一下。5. 实测中那些文档不会告诉你的坑5.1 打分参数不同结果可能天差地别我做过一组对比实验同样的两条序列只改打分参数最优对齐方式就变了。匹配 1、错配 -1、空位 -1 的时候算法倾向于多插空位来换取更多匹配把空位罚分改成 -3算法就变得吝啬宁可错配也不轻易插空位。这意味着什么意味着没有一组放之四海皆准的打分参数。做 DNA 比对、蛋白质比对、不同物种之间的比对参数都应该调整。DNA 常用匹配 1、错配 -1 到 -3、空位 -2 到 -4蛋白质因为氨基酸替换有更复杂的相似性矩阵比如 BLOSUM62打分不能简单用 1/-1。如果你只是跑个 demo随便设参数没问题但要做真实分析参数选择本身就是一门学问。注意不要拿默认参数去跑所有任务。我见过有人用同一套参数比对 DNA 和蛋白质结果当然是一塌糊涂。先想清楚你的序列是什么类型、比对目的是什么再定参数。5.2 多条最优路径的平局问题DP 表里经常出现三个方向分数一样的情况这时候选哪个方向会得到不同的对齐结果但分数相同。标准实现通常按固定优先级比如优先对角线来打破平局但这不代表其他路径是错的。这个现象在生物学上其实有意义的多条等分路径可能对应多种合理的比对假设。有些工具会输出所有最优路径有些只输出一条。如果你发现自己的实现和别人的结果不完全一样先别急着怀疑代码错了很可能只是平局处理策略不同。5.3 内存和时间的实际表现O(mn) 的空间复杂度对于长度 1000 的两条序列就是 100 万个格子。Python 里一个整数对象加上列表开销大概几十 MB还能接受。但如果序列长度上万内存就会吃紧。这时候可以用 Hirschberg 算法把空间降到 O(min(m, n))代价是实现复杂度上升。时间上纯 Python 双层循环跑 1000×1000 大概几秒到十几秒取决于机器。如果要做大批量比对建议用 NumPy 向量化或者直接调用成熟的库比如 Biopython 的pairwise2模块。自己实现的价值在于理解原理生产环境没必要重复造轮子。5.4 边界条件的处理空序列、单字符序列、完全相同的序列、完全不同的序列这几种边界情况一定要测。我踩过的坑是空序列输入时初始化循环没处理好直接数组越界。还有完全相同的序列如果打分参数设置不当比如错配罚分比匹配得分还高算法可能给出奇怪的结果。建议写完之后至少跑这几组测试测试用例预期行为A 为空B 非空全部插入空位分数为 n×gapA 与 B 完全相同全对角线对齐分数为 m×matchA 与 B 完全不同全局比对大量错配局部比对分数为 0单字符序列正常返回不越界6. 从基础比对到更实用的变体6.1 仿射罚分的 Gotoh 实现思路前面提过仿射罚分这里说下实现思路。维护三张表M表记录以匹配/错配结尾的最优分数Ix表记录以 A 中空位即 B 插入结尾的最优分数Iy表记录以 B 中空位结尾的最优分数。递推时M(i,j) max(M(i-1,j-1), Ix(i-1,j-1), Iy(i-1,j-1)) s(A[i], B[j]) Ix(i,j) max(M(i-1,j) gap_open, Ix(i-1,j) gap_extend) Iy(i,j) max(M(i,j-1) gap_open, Iy(i,j-1) gap_extend) F(i,j) max(M(i,j), Ix(i,j), Iy(i,j))关键在Ix和Iy的递推从M表进入空位状态要付gap_open在空位状态里继续延伸只付gap_extend。这样就实现了开空位贵、延伸空位便宜的效果。回溯时也要在三张表之间跳转比单表复杂但逻辑是一致的。6.2 半全局比对一个常被忽略的中间选项Global 要求两头都对齐Local 允许两头都丢弃中间还有个半全局比对semi-global允许一头或两头自由。典型场景是你要把一条短序列比对到一条长序列上短序列应该完整参与长序列两头可以不管。这时候初始化第一行第一列为 0、但回溯仍从右下角出发就能实现。这个变体在实际工具里很常见比如做引物定位、短读段比对的时候。理解了 Global 和 Local半全局只是改几行初始化代码的事。6.3 什么时候该用哪个算法给个我自己的判断标准两条序列长度接近、确信整体同源用 Global。只关心局部相似区域、整体可能不相关用 Local。短序列比对到长序列、短的要完整用半全局。需要生物学上更合理的空位模型上仿射罚分。序列很长、内存吃紧考虑 Hirschberg 或分块策略。这套判断不是绝对的但能覆盖大部分日常场景。真正做研究的时候往往还要结合具体工具和数据库的推荐参数。7. 我个人的几点实操体会序列比对算法看起来是教科书里的经典内容但真正动手实现过一遍和只看懂公式理解深度完全不是一个层次。我最大的体会是动态规划的精髓不在递推式本身而在状态定义和边界处理。递推式网上一搜一大把但状态怎么定义、边界怎么初始化、回溯怎么走这些细节才是决定代码能不能跑对的关键。另外一个体会是不要迷信最优解。DP 给出的最优对齐是在你给定的打分模型下的最优换一套参数就是另一个最优。生物序列的真实演化过程我们并不知道算法给出的只是一个在特定假设下最合理的猜测。理解这一点就不会对着结果钻牛角尖。最后分享一个学习技巧把 Global 和 Local 的代码写在一起用同一个测试用例跑对比两张 DP 表的差异。你会直观地看到那个 0 是怎么把负分区域截断的也会看到局部比对的最大值格子为什么不在右下角。这种对比带来的理解比单独看任何一个算法都深刻。等你把这两个基础算法吃透再去看 BLAST、Smith-Waterman 的加速版本、多序列比对会发现底层逻辑都是相通的。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑