如何量化两个字符串之间的相似程度呢?有一个非常著名的量化方法,那就是编辑距离(Edit Distance)。
编辑距离指的就是,将一个字符串转化成另一个字符串,需要的最少编辑操作次数(比如增加一个字符、删除一个字符、替换一个字符)。编辑距离越大,说明两个字符串的相似程度越小;相反,编辑距离就越小,说明两个字符串的相似程度越大。对于两个完全相同的字符串来说,编辑距离就是 0。
编辑距离有多种不同的计算方式,比较著名的有莱文斯坦距离(Levenshtein distance)和最长公共子串长度(Longest common substring length)。其中,莱文斯坦距离允许增加、删除、替换字符这三个编辑操作,最长公共子串长度只允许增加、删除字符这两个编辑操作。
莱文斯坦距离和最长公共子串长度,从两个截然相反的角度,分析字符串的相似程度:
-
莱文斯坦距离的大小,表示两个字符串差异的大小
-
最长公共子串的大小,表示两个字符串相似程度的大小
根据回溯算法的代码实现,我们可以画出递归树,看是否存在重复子问题。如果存在重复子问题,那我们就可以考虑能否用动态规划来解决;如果不存在重复子问题,那回溯就是最好的解决方法。
C++版代码如下
#include <iostream>
#include <math.h>
#include <string.h>
using namespace std;
#define MAXNUM 100010
#define DRIFT 1001
int minWeight = 9999;
int minDist = 0xFFFFFF;
// 回溯法求最短路径
void reCall(int cost[][4], int rows, int cols, int row, int col, int curS){
if((row == (rows - 1)) && (col == (cols - 1))){
//cout<<curS<<endl;
if(curS < minWeight)
minWeight = curS;
return ;
}
// 向右走
if(col < cols - 1)
reCall(cost, rows, cols, row, col + 1,curS + cost[row][col + 1]);
// 向下走
if(row < rows - 1)
reCall(cost, rows, cols, row + 1, col, curS + cost[row + 1][col]);
}
// 动态规划版
int dpRoad(int cost[][4], int n, int row, int col){
int dp[n][n];
memset(dp, -1, sizeof(dp));
// 初始化第一行、第一列
dp[0][0] = cost[0][0];
for(int i = 1; i < n; i++){
dp[0][i] = dp[0][i - 1] + cost[0][i];
dp[i][0] = dp[i - 1][0] + cost[i][0];
}
for(int i = 1; i < n; i++){
for(int j = 1; j < n; j++){
dp[i][j] = cost[i][j] + min(dp[i - 1][j], dp[i][j - 1]);
}
}
return dp[row - 1][col - 1];
}
// 回溯法求莱文斯坦编辑距离
void lwstBT(char str_1[], int n, char str_2[], int m, int i, int j, int edist){
if(i == n || j == m){
if(i < n)
edist += (n - i);
if(j < m)
edist += (m - j);
if(edist < minDist)
minDist = edist;
return ;
}
// 1、两字符相匹配
if(str_1[i] == str_2[j])
lwstBT(str_1, n, str_2, m, i + 1, j + 1, edist);
else{
// 2、删除a[i]或者b[j]前添加一个字符
lwstBT(str_1, n, str_2, m, i + 1, j, edist + 1);
// 3、删除b[j]或者a[i]前添加一个字符
lwstBT(str_1, n, str_2, m, i, j + 1, edist + 1);
// 4、替换
lwstBT(str_1, n, str_2, m, i + 1, j + 1, edist + 1);
}
}
int myMin(int x, int y, int z) {
int minv = 0x7FFFFFFF;
if (x < minv)
minv = x;
if (y < minv)
minv = y;
if (z < minv)
minv = z;
return minv;
}
int myMax(int x, int y, int z) {
int maxv = -1;
if (x > maxv) maxv = x;
if (y > maxv) maxv = y;
if (z > maxv) maxv = z;
return maxv;
}
// 动态规划法求莱文斯坦编辑距离
int lwstDP(char a[], int n, char b[], int m) {
int minDist[n][m];
memset(minDist, -1, sizeof(minDist));
for (int j = 0; j < m; ++j) { // 初始化第0行:a[0..0]与b[0..j]的编辑距离
if (a[0] == b[j])
minDist[0][j] = j;
else if (j != 0)
minDist[0][j] = minDist[0][j-1]+1;
else
minDist[0][j] = 1;
}
for (int i = 0; i < n; ++i) { // 初始化第0列:a[0..i]与b[0..0]的编辑距离
if (a[i] == b[0])
minDist[i][0] = i;
else if (i != 0)
minDist[i][0] = minDist[i-1][0]+1;
else
minDist[i][0] = 1;
}
for (int i = 1; i < n; ++i) { // 按行填表
for (int j = 1; j < m; ++j) {
if (a[i] == b[j])
minDist[i][j] = myMin(minDist[i-1][j]+1, minDist[i][j-1]+1, minDist[i-1][j-1]);
else
minDist[i][j] = myMin(minDist[i-1][j]+1, minDist[i][j-1]+1, minDist[i-1][j-1]+1);
}
}
return minDist[n-1][m-1];
}
int lcsDP(char a[], int n, char b[], int m) {
int maxlcs[n][m];
for (int j = 0; j < m; ++j) {//初始化第0行:a[0..0]与b[0..j]的maxlcs
if (a[0] == b[j])
maxlcs[0][j] = 1;
else if (j != 0)
maxlcs[0][j] = maxlcs[0][j-1];
else
maxlcs[0][j] = 0;
}
for (int i = 0; i < n; ++i) {//初始化第0列:a[0..i]与b[0..0]的maxlcs
if (a[i] == b[0])
maxlcs[i][0] = 1;
else if (i != 0)
maxlcs[i][0] = maxlcs[i-1][0];
else
maxlcs[i][0] = 0;
}
for (int i = 1; i < n; ++i) { // 填表
for (int j = 1; j < m; ++j) {
if (a[i] == b[j])
maxlcs[i][j] = myMax(maxlcs[i-1][j], maxlcs[i][j-1], maxlcs[i-1][j-1]+1);
else
maxlcs[i][j] = myMax(maxlcs[i-1][j], maxlcs[i][j-1], maxlcs[i-1][j-1]);
}
}
return maxlcs[n-1][m-1];
}
// 动态规划求 a 的最上升长子序列长度
int lengthOfLIS(int nums[], int n){
vector<int> dp(n, 1);
for(int i = 0; i < n; i++){
for(int j = 0; j < i; j++){
if(nums[j] < nums[i])
dp[i] = max(dp[i], dp[j] + 1);
}
}
int ans = INT_MIN;
for(int i = 0; i < n; i++)
ans = max(ans, dp[i]);
return ans;
}
int main()
{
int cost[4][4]={{1, 3, 5, 9},{2, 1, 3, 4},{5, 2, 6, 7},{6, 8, 4, 3}};
// 从(0, 0)开始走到(n-1, n-1),初始值路径值为1
reCall(cost, 4, 4, 0, 0, 1);
cout<<minWeight<<endl;
cout<<dpRoad(cost, 4, 4, 4)<<endl;
// 最长公共子串(从前到后)"mtcu"4个
char str_1[7] = "mitcmu";
char str_2[7] = "mtacnu";
lwstBT(str_1, 6, str_2, 6, 0, 0, 0);
cout<<minDist<<endl;
cout<<lwstDP(str_1, 6, str_2, 6)<<endl;
cout<<lcsDP(str_1, 6, str_2, 6);
return 0;
}
解答开篇
当用户在搜索框内,输入一个拼写错误的单词时,我们就拿这个单词跟词库中的单词一一进行比较,计算编辑距离,将编辑距离最小的单词,作为纠正之后的单词,提示给用户。
这就是拼写纠错最基本的原理。不过,真正用于商用的搜索引擎,拼写纠错功能显然不会就这么简单。一方面,单纯利用编辑距离来纠错,效果并不一定好;另一方面,词库中的数据量可能很大,搜索引擎每天要支持海量的搜索,所以对纠错的性能要求很高。
针对纠错效果不好的问题,我们有很多种优化思路:
-
我们并不仅仅取出编辑距离最小的那个单词,而是取出编辑距离最小的 TOP 10,然后根据其他参数,决策选择哪个单词作为拼写纠错单词。比如使用搜索热门程度来决定哪个单词作为拼写纠错单词。
-
我们还可以用多种编辑距离计算方法,比如今天讲到的两种,然后分别编辑距离最小的 TOP 10,然后求交集,用交集的结果,再继续优化处理。
-
我们还可以通过统计用户的搜索日志,得到最常被拼错的单词列表,以及对应的拼写正确的单词。搜索引擎在拼写纠错的时候,首先在这个最常被拼错单词列表中查找。如果一旦找到,直接返回对应的正确的单词。这样纠错的效果非常好。
-
我们还有更加高级一点的做法,引入个性化因素。针对每个用户,维护这个用户特有的搜索喜好,也就是常用的搜索关键词。当用户输入错误的单词的时候,我们首先在这个用户常用的搜索关键词中,计算编辑距离,查找编辑距离最小的单词。
针对纠错性能方面,我们也有相应的优化方式。我讲两种分治的优化思路。
-
如果纠错功能的 TPS 不高,我们可以部署多台机器,每台机器运行一个独立的纠错功能。当有一个纠错请求的时候,我们通过负载均衡,分配到其中一台机器,来计算编辑距离,得到纠错单词。
-
如果纠错系统的响应时间太长,也就是,每个纠错请求处理时间过长,我们可以将纠错的词库,分割到很多台机器。当有一个纠错请求的时候,我们就将这个拼写错误的单词,同时发送到这多台机器,让多台机器并行处理,分别得到编辑距离最小的单词,然后再比对合并,最终决定出一个最优的纠错单词。
真正的搜索引擎的拼写纠错优化,肯定不止这么简单,但是万变不离其宗。掌握了核心原理,就是掌握了解决问题的方法。