动态规划(四)——如何实现搜索引擎中的拼写纠错功能?

??如何量化两个字符串之间的相似程度呢?有一个非常著名的量化方法,那就是编辑距离(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 不高,我们可以部署多台机器,每台机器运行一个独立的纠错功能。当有一个纠错请求的时候,我们通过负载均衡,分配到其中一台机器,来计算编辑距离,得到纠错单词。

  • 如果纠错系统的响应时间太长,也就是,每个纠错请求处理时间过长,我们可以将纠错的词库,分割到很多台机器。当有一个纠错请求的时候,我们就将这个拼写错误的单词,同时发送到这多台机器,让多台机器并行处理,分别得到编辑距离最小的单词,然后再比对合并,最终决定出一个最优的纠错单词。

??真正的搜索引擎的拼写纠错优化,肯定不止这么简单,但是万变不离其宗。掌握了核心原理,就是掌握了解决问题的方法。

相关推荐