今天做了一道困难题:最大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;
}
这道题做得我汗流浃背了,收获也不小,不说了不说了,我要成为算法糕手!