1. 问题背景与理解
这道题目源自华为OD机考双机位C卷,考察的是动态规划在图论中的应用。题目描述了两个字符串间的最短路径问题,这实际上是编辑距离问题的一个变种,在生物信息学中常用于DNA序列比对。
我第一次看到这个问题时,立刻联想到Levenshtein距离(编辑距离),但仔细分析后发现它更接近Needleman-Wunsch算法,因为允许对角线移动(斜边)且移动成本不同。这类问题在实际开发中很常见,比如代码差异比较、文档相似度计算等场景。
2. 问题建模与算法选择
2.1 图形化理解问题
给定字符串A="ABCABBA"和B="CBABAC",我们可以构建一个m×n的网格(m=A.length+1, n=B.length+1):
- 原点(0,0)在左上角
- 终点(m,n)在右下角
- 水平边表示使用A的字符
- 垂直边表示使用B的字符
- 斜边表示同时使用A和B的相同字符
2.2 动态规划状态定义
定义dp[i][j]表示从(0,0)到(i,j)的最短距离。状态转移需要考虑三种情况:
- 从左边过来(水平边):dp[i][j] = dp[i-1][j] + 1
- 从上边过来(垂直边):dp[i][j] = dp[i][j-1] + 1
- 从左上对角线过来(斜边):当A[i-1]==B[j-1]时,dp[i][j] = dp[i-1][j-1] + 1
2.3 边界条件处理
- dp[0][0] = 0
- 第一行:dp[0][j] = j(全部垂直边)
- 第一列:dp[i][0] = i(全部水平边)
3. Java实现详解
3.1 基础实现
java复制public class ShortestPathBetweenStrings {
public static int shortestPath(String A, String B) {
int m = A.length(), n = B.length();
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 (A.charAt(i-1) == B.charAt(j-1)) {
dp[i][j] = dp[i-1][j-1] + 1;
} else {
dp[i][j] = Math.min(dp[i-1][j], dp[i][j-1]) + 1;
}
}
}
return dp[m][n];
}
}
3.2 空间优化版本
原始实现空间复杂度O(mn),可以优化到O(min(m,n)):
java复制public static int shortestPathOptimized(String A, String B) {
if (A.length() < B.length()) {
// 确保B是较短的字符串
return shortestPathOptimized(B, A);
}
int m = A.length(), n = B.length();
int[] prev = new int[n+1];
int[] curr = new int[n+1];
// 初始化第一行
for (int j = 0; j <= n; j++) prev[j] = j;
for (int i = 1; i <= m; i++) {
curr[0] = i; // 第一列
for (int j = 1; j <= n; j++) {
if (A.charAt(i-1) == B.charAt(j-1)) {
curr[j] = prev[j-1] + 1;
} else {
curr[j] = Math.min(prev[j], curr[j-1]) + 1;
}
}
// 交换数组
int[] temp = prev;
prev = curr;
curr = temp;
}
return prev[n];
}
3.3 路径回溯实现
如果需要输出具体路径,可以增加回溯逻辑:
java复制public static void printShortestPath(String A, String B) {
int m = A.length(), n = B.length();
int[][] dp = new int[m+1][n+1];
char[][] path = new char[m+1][n+1]; // 'H'水平, 'V'垂直, 'D'对角线
// 初始化... (同上)
// 回溯路径
int i = m, j = n;
LinkedList<String> steps = new LinkedList<>();
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && A.charAt(i-1) == B.charAt(j-1) &&
dp[i][j] == dp[i-1][j-1] + 1) {
steps.addFirst("(" + A.charAt(i-1) + "," + B.charAt(j-1) + ")");
i--; j--;
} else if (i > 0 && dp[i][j] == dp[i-1][j] + 1) {
steps.addFirst("(" + A.charAt(i-1) + ", )");
i--;
} else {
steps.addFirst("( ," + B.charAt(j-1) + ")");
j--;
}
}
System.out.println("Path: " + String.join("->", steps));
System.out.println("Distance: " + dp[m][n]);
}
4. 算法分析与优化
4.1 时间复杂度分析
基础算法的时间复杂度为O(mn),这是不可避免的,因为需要填充整个DP表格。对于字符串长度在10000以内的情况,现代计算机可以在合理时间内完成计算。
4.2 实际测试数据
测试用例1:
输入:A="ABC", B="ABC"
输出:3
解释:直接走对角线 (A,A)->(B,B)->(C,C)
测试用例2:
输入:A="ABCABBA", B="CBABAC"
输出:9
路径:(A, )->( ,C)->(B,B)->(C, )->(A,A)->(B,B)->(B,B)->(A,A)->( ,C)
4.3 常见错误与调试技巧
-
索引越界:注意字符串索引从0开始,DP表从1开始
调试技巧:打印DP表检查边界值
-
斜边条件判断错误:必须在字符相等时才允许走斜边
常见错误:遗漏A.charAt(i-1) == B.charAt(j-1)判断
-
初始化错误:第一行和第一列需要正确初始化
验证方法:手动计算几个简单case
5. 扩展思考与实际应用
5.1 不同成本设置
实际问题中可能需要不同的边成本:
- 水平/垂直边成本为2
- 斜边成本为1(相同字符)或3(不同字符)
只需修改状态转移方程中的常数即可:
java复制int horizontalCost = 2;
int verticalCost = 2;
int diagonalCostSame = 1;
int diagonalCostDiff = 3;
if (A.charAt(i-1) == B.charAt(j-1)) {
dp[i][j] = dp[i-1][j-1] + diagonalCostSame;
} else {
dp[i][j] = Math.min(
dp[i-1][j] + horizontalCost,
Math.min(
dp[i][j-1] + verticalCost,
dp[i-1][j-1] + diagonalCostDiff
)
);
}
5.2 实际应用场景
- 代码差异分析:比较两个版本代码的相似度
- DNA序列比对:生物信息学中的基础算法
- 拼写检查:计算单词间的"距离"提供建议
- 语音识别:匹配语音信号与文本
5.3 算法变种
- 最长公共子序列(LCS):斜边权重为1,其他边为0
- 编辑距离:插入/删除/替换操作对应不同成本
- 带间隙罚分的序列比对:连续空位有额外惩罚
6. 性能优化实战技巧
6.1 内存优化技巧
对于超长字符串(>10000字符):
- 使用位压缩存储DP表
- 分块计算,只保留必要的行
- 使用Hirschberg算法将空间降到O(n)
6.2 并行计算优化
DP表可以斜对角线方式计算,适合并行化:
java复制// 伪代码:对角线并行
for (int d = 0; d <= m + n; d++) {
parallel_for (int i = max(1, d - n); i <= min(d, m); i++) {
int j = d - i;
// 计算dp[i][j]
}
}
6.3 剪枝策略
在某些应用中,可以设置阈值提前终止:
java复制if (dp[i][j] > threshold) {
break; // 不再继续计算这条路径
}
7. 不同语言实现对比
7.1 C++实现特点
cpp复制int shortestPath(const string& A, const string& B) {
vector<vector<int>> dp(A.size()+1, vector<int>(B.size()+1));
// ...类似Java实现
// 优势:更快的执行速度,适合超长字符串
}
7.2 Python实现技巧
python复制def shortest_path(A: str, B: str) -> int:
m, n = len(A), len(B)
prev = list(range(n+1))
for i in range(1, m+1):
curr = [i] + [0]*n
for j in range(1, n+1):
if A[i-1] == B[j-1]:
curr[j] = prev[j-1] + 1
else:
curr[j] = min(prev[j], curr[j-1]) + 1
prev = curr
return prev[n]
Python注意:
- 使用列表推导式优化
- 利用numpy数组加速计算
7.3 JavaScript实现
javascript复制function shortestPath(A, B) {
let prev = Array(B.length+1).fill(0).map((_,i)=>i);
for (let i = 1; i <= A.length; i++) {
let curr = [i];
for (let j = 1; j <= B.length; j++) {
curr[j] = A[i-1] === B[j-1]
? prev[j-1] + 1
: Math.min(prev[j], curr[j-1]) + 1;
}
prev = curr;
}
return prev[B.length];
}
8. 测试用例设计指南
8.1 基础测试用例
-
相同字符串:
A="ABC", B="ABC" → 3 -
完全不同的字符串:
A="AAA", B="BBB" → 6 -
一个字符串为空:
A="", B="ABC" → 3
8.2 边界测试用例
-
最长边界:
A=重复字符×10000, B=另一重复字符×10000
验证内存和时间是否可接受 -
特殊字符:
确保正则[A-Z]过滤正确
8.3 复杂测试用例
-
交替模式:
A="ABABAB", B="BABABA" → 6 -
部分匹配:
A="ABCDEF", B="AXCEXEX" → 7
9. 华为OD机考特别提示
-
输入处理:注意题目要求的输入格式(空格分割)
java复制Scanner sc = new Scanner(System.in); String A = sc.next(); String B = sc.next(); -
性能要求:Java提交时避免使用Scanner,改用BufferedReader
java复制BufferedReader br = new BufferedReader(new InputStreamReader(System.in)); String[] inputs = br.readLine().split(" "); String A = inputs[0], B = inputs[1]; -
输出要求:严格按要求输出,不要添加额外信息
-
代码规范:类名必须为Main,方法用static
10. 个人实战经验分享
在准备这类算法题时,我有几个实用建议:
- 白板练习:先在纸上画出DP表,手动填充几个例子
- 测试驱动:先写测试用例,再实现算法
- 空间优化:先实现基础版本,确认正确后再优化
- 调试技巧:打印中间DP表验证状态转移
一个容易忽略的细节是字符串索引与DP表索引的对应关系。我习惯在代码中添加这样的注释:
java复制// dp[i][j] 对应 A[0..i-1] 和 B[0..j-1]
// 所以当比较字符时,使用A.charAt(i-1)和B.charAt(j-1)
对于机考环境,建议提前准备模板代码,包括快速输入输出、常用算法等。例如:
java复制import java.util.*;
import java.io.*;
public class Main {
public static void main(String[] args) throws IOException {
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
String[] inputs = br.readLine().split(" ");
String A = inputs[0], B = inputs[1];
System.out.println(shortestPath(A, B));
}
static int shortestPath(String A, String B) {
// 实现同上
}
}
最后提醒,在真正的机考环境中,双机位监控会严格检查作弊行为,所以务必真正掌握算法原理,而不是死记硬背代码。这道题考察的核心是动态规划思想,理解状态定义和转移方程比记住具体代码更重要。