DNA序列血缘分析(中)、最大UCC子串(难) | 豆包MarsCode AI刷题

92 阅读8分钟

今天做了一道困难题:最大UCC子串计算, 这让我联想到另一道类似题目:古生物DNA血缘分析,两道题有共通之处,一并分享大家。

古生物DNA血缘分析

题目链接:www.marscode.cn/practice/jn…

题目

小U是一位古生物学家,正在研究不同物种之间的血缘关系。为了分析两种古生物的血缘远近,她需要比较它们的DNA序列。
DNA由四种核苷酸A、C、G、T组成,并且可能通过三种方式发生变异:添加一个核苷酸、删除一个核苷酸或替换一个核苷酸。
小U认为两条DNA序列之间的最小变异次数可以反映它们之间的血缘关系:变异次数越少,血缘关系越近。
你的任务是编写一个算法,帮助小U计算两条DNA序列之间所需的最小变异次数。

  • dna1: 第一条DNA序列。
  • dna2: 第二条DNA序列。

这道题看上去很长,本质是一句话:字符串 s 通过插入、删除、替换三种操作,变为字符串 t 的最少操作次数。

一眼可以看到该问题存在最右子结构性质,即可以拆分成子问题,典型的动态规划了,有点类似于最长公共子序列问题。

闲言少叙,结合代码来分析一下:

public static int solution(String dna1, String dna2) {
    // Please write your code here
    int m = dna1.length();
    int n = dna2.length();

    // dp[i][j] 表示将 dna1 的前 i 个字符变为 dna2 的前 j 个字符所需的最小操作次数
    int[][] dp = new int[m + 1][n + 1];
    for (int i = 0; i <= m; i++) {
        dp[i][0] = i;
    }
    for (int j = 0; j <= n; j++) {
        dp[0][j] = j;
    }

    for (int i = 1; i <= m; i++) {
        for (int j = 1; j <= n; j++) {
            if (dna1.charAt(i - 1) == dna2.charAt(j - 1)) {
                dp[i][j] = dp[i - 1][j - 1];
            } else {
                // 当前字符不相等,替换或者增删使其相等
                // 这里增加和删除是一样的
                dp[i][j] = Math.min(dp[i - 1][j - 1], Math.min(dp[i - 1][j], dp[i][j - 1])) + 1;
            }
        }
    }

    return dp[m][n];
}

看完代码别跑,我们还没分析思路呢。联想一下最长公共子序列问题可以想到,对于 dna1 的前 i 个字符和 dna2 的前 j 个字符,构造出子问题,在迭代更新dp数组,最终得到原问题的解。

那么此时我们只需考虑 dna1 的第 i 个字符(记为 c1 )和 dna2 的第 j 个字符(记为 c2 )。

  • c1 == c2
    • 无需操作dp[i][j] = dp[i - 1][j - 1]
  • c1 != c2
    • 替换操作:将这两个字符随便一个替换为另一个即可完成状态转移,得到 dp[i][j] = dp[i - 1][j - 1] + 1
    • 删除操作:删除 c1 得到: dp[i][j] = dp[i - 1][j] ,或者删除 c2 得到: dp[i][j] = dp[i][j - 1] + 1
    • 插入操作:在 c1 前插入 c2 得到: dp[i][j] = dp[i - 1][j] ,或者在 c2 前插入 c1 得到: dp[i][j] = dp[i][j - 1] + 1

可以看到插入操作和删除操作是一样的。本题顺利结束!重头戏开始:

最大UCC子串计算

题目链接:www.marscode.cn/practice/jn…

题目

小S有一个由字符 'U' 和 'C' 组成的字符串 SS,并希望在编辑距离不超过给定值 m 的条件下,尽可能多地在字符串中找到 "UCC" 子串。
编辑距离定义为将字符串 S 转化为其他字符串时所需的最少编辑操作次数。允许的每次编辑操作是插入、删除或替换单个字符。你需要计算在给定的编辑距离限制 m 下,能够包含最多 "UCC" 子串的字符串可能包含多少个这样的子串。
例如,对于字符串"UCUUCCCCC"和编辑距离限制 m = 3 ,可以通过编辑字符串生成最多包含3个 "UCC" 子串的序列。

眼熟吧?跟上一道题一样,都是字符的插入删除和替换,但这题绝没有那么简单!
首先比较容易想到的是:定义状态 dp[i][j] 表示前 i 个字符中,在进行 j 次操作后,能够匹配到的 "UCC" 子串最大数量。状态转移方程类似上一道题,分别考虑插入、删除和替换操作,进行状态转移,但是本题的难点在于,要匹配的不是单个字符,而是 "UCC"三个字符!此时如何进行状态转移呢?我们从其中某个字符开始匹配(假设前面的字符已经匹配完成),此时最容易想到的是:

  • 跳过该字符,从下一个字符开始匹配:dp[i][j] = dp[i - 1][j]

但是如何决定字符的替换、插入、删除呢?这里需要进行第二次动态规划!即我们需要知道从某个字符开始,所有的匹配策略,这些策略的操作次数和匹配长度。

定义 dpMatch[p][q] 表示从当前字符 s[i] 开始,已匹配的进度为 p ,且已经匹配了 q 个字符时的最小操作次数。

这里的“进度”是指匹配到"UCC"的第几个字符,范围是0~3。 此时问题转化成 从下标i开始,匹配字符串 "UCC" 需要的最小操作次数 的动态规划问题,这不就和古生物DNA血缘分析一样了吗?那么好,状态转移方程如下:

  • 若当前字符 s[i + q] 等于 "UCC".charAt(p)
    • 无需操作dpMatch[p + 1][q + 1] = dpMatch[p][q]
  • 若不等于:
    • 替换操作dpMatch[p + 1][q + 1] = dpMatch[p][q] + 1
    • 插入操作dpMatch[p + 1][q] = dpMatch[p][q] + 1
    • 删除操作dpMatch[p][q + 1] = dpMatch[p][q] + 1

这部分的代码是这样的(对于代码中 matchLen 的解释看下文):

for (int i = 0; i < n; i++) {
    int matchLen = Math.min(m + 3, n - i); // 最多匹配 m+3 长度
    // dpMatch[p][q] 表示从当前字符 s[i] 开始,已匹配的进度为p,且已经匹配了q个字符时的最小操作次数
    int[][] dpMatch = new int[4][matchLen + 1];
    for (int[] row : dpMatch) {
        Arrays.fill(row, Integer.MAX_VALUE);
    }
    dpMatch[0][0] = 0;

    // 遍历匹配过程中的所有可能操作
    for (int p = 0; p < 4; p++) {
        for (int q = 0; q <= matchLen; q++) {
            if (dpMatch[p][q] > m) {
                continue;
            }

            // 替换或保留字符
            if (p < 3 && q < matchLen) {
                int cost = (s.charAt(i + q) == "UCC".charAt(p)) ? 0 : 1;
                dpMatch[p + 1][q + 1] = Math.min(dpMatch[p + 1][q + 1], dpMatch[p][q] + cost);
            }
            // 插入操作
            if (p < 3) {
                dpMatch[p + 1][q] = Math.min(dpMatch[p + 1][q], dpMatch[p][q] + 1);
            }
            // 删除操作
            if (q < matchLen) {
                dpMatch[p][q + 1] = Math.min(dpMatch[p][q + 1], dpMatch[p][q] + 1);
            }
        }
    }
}

解释一下 matchLen :考虑从 s[i] 开始,每个字符都要删除,最多删除了 m 次,最终又能匹配到一个 "UCC" 字符串,也就是最长匹配到 m+3 。同时,为了不越界,上限为 n-i

好了,经过这n轮动态规划,我们已经得知从 s[i] 开始匹配的所有策略和匹配到的长度,我们需要将它们记录下来,方便主过程动态规划使用(相当于预处理)。

for (int i = 0; i < n; i++) {
    // 记录从位置 i 开始匹配 "UCC" 的代价和匹配长度
    for (int q = 0; q <= matchLen; q++) {
        int cost = dpMatch[3][q];
        if (cost <= m) {
            matchInfo.get(i).add(new int[]{cost, q});
        }
    }
}

在主过程中,跳过某一字符的逻辑我们已经介绍,即 dp[i][j] = dp[i - 1][j] ,或者写成 dp[i + 1][j] = dp[i][j] ,不跳过的话,我们已经预处理好了所有匹配方式,遍历所有方式匹配一个"UCC",进行状态转移即可:
dp[i + length][j + cost] = Math.max(dp[i + length][j + cost], dp[i][j] + 1)

最终代码如下:

public static int solution(int m, String s) {
    // write code here
    int n = s.length();

    // matchInfo[i] 保存从位置 i 开始匹配 "UCC" 的所有可能的方式信息(代价, 长度)
    List<List<int[]>> matchInfo = new ArrayList<>();
    for (int i = 0; i < n; i++) {
        matchInfo.add(new ArrayList<>());
    }

    // 预计算从每个位置开始匹配 "UCC" 的最小代价
    for (int i = 0; i < n; i++) {
        int matchLen = Math.min(m + 3, n - i); // 最多匹配 m+3 长度
        // dpMatch[p][q] 表示从当前字符 s[i] 开始,已匹配的进度为p,且已经匹配了q个字符时的最小操作次数
        int[][] dpMatch = new int[4][matchLen + 1];
        for (int[] row : dpMatch) {
            Arrays.fill(row, Integer.MAX_VALUE);
        }
        dpMatch[0][0] = 0;

        // 遍历匹配过程中的所有可能操作
        for (int p = 0; p < 4; p++) {
            for (int q = 0; q <= matchLen; q++) {
                if (dpMatch[p][q] >= m) {
                    continue;
                }

                // 替换或保留字符
                if (p < 3 && q < matchLen) {
                    int cost = (s.charAt(i + q) == "UCC".charAt(p)) ? 0 : 1;
                    dpMatch[p + 1][q + 1] = Math.min(dpMatch[p + 1][q + 1], dpMatch[p][q] + cost);
                }
                // 插入操作
                if (p < 3) {
                    dpMatch[p + 1][q] = Math.min(dpMatch[p + 1][q], dpMatch[p][q] + 1);
                }
                // 删除操作
                if (q < matchLen) {
                    dpMatch[p][q + 1] = Math.min(dpMatch[p][q + 1], dpMatch[p][q] + 1);
                }
            }
        }

        // 记录从位置 i 开始匹配 "UCC" 的代价和匹配长度
        for (int q = 0; q <= matchLen; q++) {
            int cost = dpMatch[3][q];
            if (cost <= m) {
                matchInfo.get(i).add(new int[]{cost, q});
            }
        }
    }

    // dp[i][j] 表示前 i 个字符中,在进行 j 次操作后,能够匹配到的 "UCC" 子串最大数量
    int[][] dp = new int[n + 1][m + 1];
    for (int i = 0; i <= n; i++) {
        for (int j = 0; j <= m; j++) {
            if (i < n) {
                // 跳过当前字符
                dp[i + 1][j] = Math.max(dp[i + 1][j], dp[i][j]);

                // 从当前字符位置开始匹配 "UCC"
                for (int[] match : matchInfo.get(i)) {
                    int cost = match[0];
                    int length = match[1];
                    if (cost + j <= m && length + i <= n) {
                        dp[i + length][j + cost] = Math.max(dp[i + length][j + cost], dp[i][j] + 1);
                    }
                }
            }
        }
    }

    int ans = 0;
    for (int i = 0; i <= m; i++) {
        ans = Math.max(ans, dp[n][i]);
    }

    return ans;
}

这道题做得我汗流浃背了,收获也不小,不说了不说了,我要成为算法糕手!