动态规划(四)——如何实现搜索引擎中的拼写纠错功能?
??如何量化两个字符串之间的相似程度呢?有一个非常著名的量化方法,那就是编辑距离(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]; } 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 不高,我们可以部署多台机器,每台机器运行一个独立的纠错功能。当有一个纠错请求的时候,我们通过负载均衡,分配到其中一台机器,来计算编辑距离,得到纠错单词。
如果纠错系统的响应时间太长,也就是,每个纠错请求处理时间过长,我们可以将纠错的词库,分割到很多台机器。当有一个纠错请求的时候,我们就将这个拼写错误的单词,同时发送到这多台机器,让多台机器并行处理,分别得到编辑距离最小的单词,然后再比对合并,最终决定出一个最优的纠错单词。
??真正的搜索引擎的拼写纠错优化,肯定不止这么简单,但是万变不离其宗。掌握了核心原理,就是掌握了解决问题的方法。