青训营X豆包MarsCode 技术训练营 | DNA序列编辑距离

1 阅读8分钟

问题描述

小R正在研究DNA序列,他需要一个函数来计算将一个受损DNA序列(dna1)转换成一个未受损序列(dna2)所需的最少编辑步骤。编辑步骤包括:增加一个碱基、删除一个碱基或替换一个碱基。

测试样例

样例1:

输入:dna1 = "AGT",dna2 = "AGCT"
输出:1

样例2:

输入:dna1 = "AACCGGTT",dna2 = "AACCTTGG"
输出:4

样例3:

输入:dna1 = "ACGT",dna2 = "TGC"
输出:3

样例4:

输入:dna1 = "A",dna2 = "T"
输出:1

样例5:

输入:dna1 = "GGGG",dna2 = "TTTT"
输出:4

代码

def solution(dna1, dna2):
    m, n = len(dna1), len(dna2)
    dp = [[0] * (n + 1) for _ in range(m + 1)]
    # m 和 n 分别是两个DNA序列的长度。
    for i in range(1, m+1):
        dp[i][0] = i
    for j in range(1, n+1):
        dp[0][j] = j
    # 边界条件
    # dp[i][0] = i 表示将 dna1 的前 i 个字符转换为空字符串需要 i 次删除操作。
    # dp[0][j] = j 表示将空字符串转换为 dna2 的前 j 个字符需要 j 次插入操作。
    dp[0][0] = 0
    for i in range(1, m+1):
        for j in range(1, n+1):
            if dna1[i-1] == dna2[j-1]:
                dp[i][j] = dp[i-1][j-1]
            else:
                dp[i][j] = min(dp[i-1][j] + 1, dp[i][j-1] + 1, dp[i-1][j-1] + 1)

    return dp[m][n]
    # 最终返回 dp[m][n],即将整个 dna1 转换为 dna2 的最小编辑距离。

if __name__ == "__main__":
    #  You can add more test cases here
    print(solution("AGCTTAGC", "AGCTAGCT") == 2 )
    print(solution("AGCCGAGC", "GCTAGCT") == 4)

思路分析

本题需要我们找出将一个字符串(受损的DNA序列)转换成另一个字符串(未受损的DNA序列)所需的最少编辑步骤,而这个过程可以被分解成更小的子问题。在计算两个字符串的编辑距离时,能够将一个问题的最优解(即从完整的 dna1 转换到完整的 dna2 的最小编辑步骤)表达为其子问题的最优解。

  1. 编辑距离是指将一个字符串转换成另一个字符串所需的最少操作次数。可以进行的操作包括插入一个字符、删除一个字符、替换一个字符。而动态规划这一解题思路非常适合处理“最优子结构”和“重叠子问题”的问题。具体来说,我们可以使用动态规划来构建一个二维数组,记录从子串 dna1[0..i] 转换到子串 dna2[0..j] 所需的最少编辑步骤。

  2. 构建动态规划表(dp表),用一个二维列表存储子问题的解。dp[i][j] 表示将 dna1 的前 i 个字符转换为 dna2 的前 j 个字符所需的最小编辑距离。

  3. 初始化边界条件在编辑距离的动态规划计算中,我们使用一个二维表 dp 来存储子问题的解。初始化边界条件是为了建立一个基础,使得从边界开始逐步扩展,最终计算出完整字符串的编辑距离。

dp[i][0] = i 表示将 dna1 的前 i 个字符转换为空字符串需要 i 次删除操作。

  • 如果 dna1 = "ACGT"(假设长度为 4),那么:

    • 将 "A" 删除变成 "" 需要 1 次删除。
    • 将 "AC" 删除变成 "" 需要 2 次删除。
    • 将 "ACG" 删除变成 "" 需要 3 次删除。
    • 将 "ACGT" 删除变成 "" 需要 4 次删除。
    • 这样就得到了 dp[1][0] = 1dp[2][0] = 2dp[3][0] = 3dp[4][0] = 4

dp[0][j] = j 表示将空字符串转换为 dna2 的前 j 个字符需要 j 次插入操作。

  • 如果 dna2 = "TGCA"(假设长度为 4),那么:

    • 将 "" 插入一个 "T" 需要 1 次插入。
    • 将 "" 插入 "TG" 需要 2 次插入。
    • 将 "" 插入 "TGC" 需要 3 次插入。
    • 将 "" 插入 "TGCA" 需要 4 次插入。

因此得到了 dp[0][1] = 1dp[0][2] = 2dp[0][3] = 3dp[0][4] = 4

  1. 了解整个动态规划算法的状态转移是理解编辑距离计算的关键。接下来,将详细解释动态规划的状态转移过程和如何填充整个 dp 表。

dp[i][j]:表示将 dna1 的前 i 个字符转换为 dna2 的前 j 个字符所需的最小编辑距离。 动态规划状态转移开始填充整个 dp 表,取到最小值作为 dp[i][j] 的值。

for i in range(1, m + 1):
    for j in range(1, n + 1):
        if dna1[i - 1] == dna2[j - 1]:
            dp[i][j] = dp[i - 1][j - 1]  # 字符相同,直接继承上一个状态
        else:
            dp[i][j] = min(dp[i - 1][j] + 1,  # 删除操作
                           dp[i][j - 1] + 1,  # 插入操作
                           dp[i - 1][j - 1] + 1)  # 替换操作

A. 字符相同的情况

  • 当 dna1[i-1] == dna2[j-1] 时,意味着这两个最后的字符相同,我们不需要进行任何操作。

  • 此时,dp[i][j] 的值与 dp[i-1][j-1] 相同。

  • 示例

    • 如果 dna1 = "ACG" 和 dna2 = "AC",当我们比较 i = 3j = 2 时,dna1[2] == dna2[1] (均为 'C'),此时 dp[3][2] = dp[2][1]

B. 字符不同的情况

  • 当 dna1[i-1] != dna2[j-1] 时,需要考虑三种操作:

    删除:从 dna1 中删除最后一个字符,转换为 dna2 的前 j 个字符,编辑距离为 dp[i-1][j] + 1

    如果 dna1 = "ACG" 和 dna2 = "AC",我们在填充 dp 表的过程中,假设当前考虑的是将 "ACG" 的前 3 个字符转换为 "AC" 的前 2 个字符,即 i = 3 和 j = 2。在比较 dna1[2](即字符 G)和 dna2[1](即字符 C)的时候,我们发现它们不相同。此时我们需要计算将 "ACG" 转换为 "AC",即我们需要计算 dp[2][2]。根据上述逻辑,我们知道 dp[3][2] 可以表示为:

    dp[3][2] = dp[2][2] + 1

则 dp[2][2] 的值实际上已经告诉我们在这 2 个字符之间的最小编辑距离(无操作,因此可以是 0),然后我们在这个最小值上加1,便得到了从 dna1 中删除一个字符所需的总编辑距离。

插入:在 dna1 的末尾插入 dna2[j-1] 的字符,转换到 dna2 的前 j 个字符编辑距离为 dp[i][j-1] + 1

如果 dna1 = "AC" 和 dna2 = "ACG",我们在填充 dp 表的时候,当前比较的是将 dna1 的前 2 个字符转换为 dna2 的前 3 个字符,即 i = 2 和 j = 3。从 dna1 的末尾插入字符 G在这样的情况下,编辑距离为 dp[2][3] = dp[2][2] + 1

这里的 dp[2][2] 表示将 dna1 的前 2 个字符("AC")转换为 dna2 的前 2 个字符("AC")的最小编辑距离。因为这两个字符完全相同,dp[2][2] 为 0,所以最终的编辑距离为 1。

替换:将 dna1[i-1] 替换为 dna2[j-1],意味着我们将 dna1 的前 i-1 个字符转换到 dna2 的前 j-1 个字符,编辑距离为 dp[i-1][j-1] + 1。 好的,让我们通过一个详尽的例子来说明替换操作是如何在编辑距离中工作的。

假设我们有以下两个字符串:

  • dna1 = "kitten"
  • dna2 = "sitting"

我们首先构建一个二维数组 dp,其大小为 (len(dna1) + 1) x (len(dna2) + 1),用来表示将 dna1 的前 i 个字符转换为 dna2 的前 j 个字符所需的最小编辑距离。

初始化第一行和第一列:

  • dp[0][j] = j 表示从空字符串转换为 dna2 的前 j 个字符需要 j 次插入操作。
  • dp[i][0] = i 表示从 dna1 的前 i 个字符转换为空字符串需要 i 次删除操作。

初始化后的表格如下:

    ""  s  i  t  t  i  n  g
""  0  1  2  3  4  5  6  7
k   1  
i   2  
t   3  
t   4  
e   5  
n   6  

接下来,我们逐步填充这个表格:

  1. 第一行(表示将空字符串转换为 dna2):

    • dp[0][1] = 1(插入 s
    • dp[0][2] = 2(插入 si
    • 以此类推...
  2. 第二行(表示将 k 转换为 dna2 的前几个字符):

    • dp[1][1] = 1ks 不同,所以替换)
    • dp[1][2] = 2k 可以插入 i,共 2 次操作)
    • dp[1][3] = 3(两者都不同,所以替换还是需要考虑)
    • 以此类推...
  3. 继续这个过程,直到填满整个表格。到最后我们可以得到:

    ""  s  i  t  t  i  n  g
""  0  1  2  3  4  5  6  7
k   1  1  2  3  4  5  6  7
i   2  2  1  2  3  4  5  6
t   3  3  2  1  2  3  4  5
t   4  4  3  2  1  2  3  4
e   5  5  4  3  2  2  3  4
n   6  6  5  4  3  3  2  3

具体替换操作的分析:

考虑位置 (3, 2)dp 值,即将 kit 转换为 si

  • ks 不同,因此我们可以选择替换 ks,这需进行 1 次操作。另外再考虑将剩余的 it 转换为 i 也是另一种情况。

结果是待填入的值为 2,其中 1 次替换和 1 次插入。

具体的编辑路径为:

  • 替换 ks

  • 插入 i

  • 最后是替换 ei 等等

  • 所以,对于不同字符的状态转移:

    dp[i][j] = min(dp[i-1][j] + 1,  # 删除操作
                   dp[i][j-1] + 1,  # 插入操作
                   dp[i-1][j-1] + 1)  # 替换操作
    

C. 最小化编辑距离

  • 上述三种操作中的最小值将被选择为 dp[i][j] 的值,表示在 dna1 的前 i 个字符与 dna2 的前 j 个字符之间所需的最小编辑距离。

 最后填充整个 dp 表

从边界条件开始,通过嵌套循环逐步填充整个 dp 表。外层循环迭代字符串 dna1 的每个字符,内层循环迭代字符串 dna2 的每个字符。对于每对字符,检查它们是否相同,并根据它们的关系更新 dp[i][j] 的值。