如何获取两词间Damerau–Levenshtein距离的全部最优回溯路径?
看起来你遇到的核心问题是标准Wagner-Fischer算法的回溯逻辑是贪心的,只会选择一条最优路径,而没有遍历所有等价代价的分支——这就是为什么你只能得到"goto"和"go to home"的其中一种最优对齐结果。下面我拆解下问题根源和具体的修复方案:
问题根源:贪心回溯的局限性
标准的Wagner-Fischer回溯是从矩阵右下角(对应两个字符串的末尾)往左上角(起始点)走,每次只会选择一个能到达当前最小代价的方向(比如优先匹配/替换,再删除,再插入)。但当多个方向的操作代价都等于当前单元格的最小代价时,每个方向都对应一条独立的最优对齐路径,贪心逻辑会直接漏掉这些分支。
比如你举的例子,"goto"和"go to home"的两种最优对齐,本质是在"go"匹配完成后,存在两种等价的操作路径:
- 直接匹配后续的"t"和"o",忽略后面的" home"插入
- 先插入一个空格,再匹配"t"和"o",同样忽略后续插入
这两条路径的总代价是一样的,但贪心回溯只会走你预设的优先级方向,所以只能拿到其中一种。
具体修复步骤
1. 改造Wagner-Fischer矩阵:记录所有最优方向
原来的矩阵只存储每个单元格的最小代价,现在需要让每个单元格同时存储所有能到达该最小代价的操作方向(比如匹配、删除、插入、换位)。
举个代码结构的例子(适配你的spellCheck.hpp):
// 定义操作方向的枚举 enum class AlignmentDir { MATCH, DELETE, INSERT, TRANSPOSE }; // 改造矩阵元素,存代价+方向列表 struct MatrixCell { int cost; std::vector<AlignmentDir> optimalDirs; }; // 在计算距离的方法中,填充矩阵时记录所有最优方向 void optimalStringAlignementDistance(const std::string& s1, const std::string& s2) { int len1 = s1.size(), len2 = s2.size(); std::vector<std::vector<MatrixCell>> dp(len1 + 1, std::vector<MatrixCell>(len2 + 1)); // 初始化边界(省略) // ... for (int i = 1; i <= len1; ++i) { for (int j = 1; j <= len2; ++j) { // 计算四种操作的代价 int matchCost = dp[i-1][j-1].cost + (s1[i-1] == s2[j-1] ? 0 : 1); int deleteCost = dp[i-1][j].cost + 1; int insertCost = dp[i][j-1].cost + 1; int transposeCost = INT_MAX; if (i > 1 && j > 1 && s1[i-1] == s2[j-2] && s1[i-2] == s2[j-1]) { transposeCost = dp[i-2][j-2].cost + 1; } // 找到最小代价 int minCost = std::min({matchCost, deleteCost, insertCost, transposeCost}); dp[i][j].cost = minCost; // 把所有等于最小代价的方向加入列表 if (matchCost == minCost) dp[i][j].optimalDirs.push_back(AlignmentDir::MATCH); if (deleteCost == minCost) dp[i][j].optimalDirs.push_back(AlignmentDir::DELETE); if (insertCost == minCost) dp[i][j].optimalDirs.push_back(AlignmentDir::INSERT); if (transposeCost == minCost) dp[i][j].optimalDirs.push_back(AlignmentDir::TRANSPOSE); } } // 后续可以把dp矩阵保存下来给回溯方法用 }
2. 回溯时遍历所有分支,收集所有最优对齐
把原来的简单回溯改成递归或迭代的分支遍历,每次遇到有多个最优方向的单元格,就为每个方向生成一条新的回溯路径,直到走到矩阵左上角(0,0)。
示例回溯逻辑:
// 定义操作类型,用于保存对齐结果 struct AlignmentOp { char c1; // s1的字符(删除/匹配时有效) char c2; // s2的字符(插入/匹配时有效) AlignmentDir type; }; void optimalStringAlignmentBacktrace(const std::string& s1, const std::string& s2, const std::vector<std::vector<MatrixCell>>& dp, std::vector<std::vector<AlignmentOp>>& allAlignments) { std::vector<AlignmentOp> currentPath; backtraceHelper(s1, s2, dp, s1.size(), s2.size(), currentPath, allAlignments); } void backtraceHelper(const std::string& s1, const std::string& s2, const std::vector<std::vector<MatrixCell>>& dp, int i, int j, std::vector<AlignmentOp>& currentPath, std::vector<std::vector<AlignmentOp>>& allAlignments) { // 到达起点,保存当前路径(注意要反转,因为是从末尾往回走的) if (i == 0 && j == 0) { std::reverse(currentPath.begin(), currentPath.end()); allAlignments.push_back(currentPath); std::reverse(currentPath.begin(), currentPath.end()); return; } // 遍历当前单元格的所有最优方向 for (auto dir : dp[i][j].optimalDirs) { switch (dir) { case AlignmentDir::MATCH: currentPath.push_back({s1[i-1], s2[j-1], AlignmentDir::MATCH}); backtraceHelper(s1, s2, dp, i-1, j-1, currentPath, allAlignments); currentPath.pop_back(); break; case AlignmentDir::DELETE: currentPath.push_back({s1[i-1], '\0', AlignmentDir::DELETE}); backtraceHelper(s1, s2, dp, i-1, j, currentPath, allAlignments); currentPath.pop_back(); break; case AlignmentDir::INSERT: currentPath.push_back({' \0', s2[j-1], AlignmentDir::INSERT}); backtraceHelper(s1, s2, dp, i, j-1, currentPath, allAlignments); currentPath.pop_back(); break; case AlignmentDir::TRANSPOSE: currentPath.push_back({s1[i-2], s1[i-1], AlignmentDir::TRANSPOSE}); backtraceHelper(s1, s2, dp, i-2, j-2, currentPath, allAlignments); currentPath.pop_back(); break; } } }
3. 加入需求过滤:忽略首个单词末尾后的插入操作
在收集到所有最优对齐路径后,需要对每条路径进行过滤:
- 先确定首个单词的结束位置(比如以空格为分隔,找到原字符串或目标字符串中第一个单词的最后一个字符的对齐位置)
- 过滤掉该位置之后所有的插入操作,只保留之前的对齐步骤
为什么你之前的A*/BFS没生效?
你尝试的A*或BFS思路是对的,但问题出在代价矩阵只存储了最小代价,没有记录所有能到达该代价的路径分支。如果要基于BFS实现,需要把每个(i,j)状态的所有前驱状态(即能以最优代价到达当前状态的(i-1,j-1)、(i-1,j)等)都加入队列,而不是只加入一个。
内容的提问来源于stack exchange,提问作者Victor Istomin

