动态规划——实践-理论-再实践

  • 先实践
  1.  0-1背包问题

对于一组物品,重量不同,不可分割,需要选择一些放入背包,在不超过背包最大重量前提下,求这个最大值?

先用回溯算法实现一遍: 

#include <iostream>

using namespace std;

int maxW = 0;
int n = 5;                        //物品个数
int weight[] = {2, 2, 4, 6, 3};  //物品重量
int w = 9;                        //背包承受的最大重量

void f(int i, int cw)            //i 表示第i个物品  cw 表示当前背包中物品的重量
{
        if(i == n || cw == w)    //cw==w 表示装满了  i==n 表示考察完了
        {
                if(cw > maxW)
                        maxW = cw;
                return;
        }
        f(i+1, cw);                //选择不装第 i 个物品  
        if(cw + weight[i] <= w)
                f(i+1, cw+weight[i]);   //选择装第 i 个物品
}

int main()
{
        f(0,0);
        cout << maxW << endl;
        return 0;
}

如果觉得规律不好找,可以用递归树将回溯算法的求解过程画出来:

                                            

递归树的每个节点表示一种状态,用(i, cw)表示。如:(2, 2) 表示我们将要决策第2个物品是否装入背包,在决策前,背包中物品重量是 2。另外,在递归树中,有些子问题的求解是重复的,如f(2, 2) 和 f(3, 4) 都被重复计算两次。这里我们可以使用备忘录,记录已经计算好的f(i, cw),当再次计算f(i, cw),可以直接从备忘录中取出来,这样可以避免冗余计算。

bool mem[5][10] = {false};  //备忘录,默认false

void f2(int i, int cw)
{
        if(i == n || cw == w)
        {
                if(cw > maxW)
                        maxW = cw;
                return;
        }
        if(mem[i][cw])
                return;
        mem[i][cw] = true;
        f2(i+1, cw);
        if(cw + weight[i] <= w)
                f2(i+1, cw+weight[i]);
}

实际上,优化后的代码,它跟动态规划的执行效率基本上没差别。接下来,我们看看动态规划是怎么做的。

首先,我们把整个求解过程分为 n 个阶段,每个阶段会决策一个物品是否放到背包中。决策完之后,背包中物品重量会有多种情况,也就是说,会达到多种不同状态,对应到递归树中,就是不同节点。

其次,把每一层重复状态(节点)合并,只记录不同状态,再基于上一层的状态集合,来推导下一层的状态集合。可以通过合并每一层重复状态,就能保证每一层状态的个数不超过 w 个,就能成功避免每层状态个数的指数级增长。我们用一个二维数组states[n][w+1],来记录每层可以达到的不同状态。

第0个(下标从0开始编号)物品重量是2,要么装入,要么不装入背包,决策完后会有两种状态,背包中物品重量是0或2.即states[0][0] = true 和 states[0][2] = true。

第1个物品重量也是2,基于之前背包状态,在这个物品决策完之后,有3个不同状态,0(0+0),2(0+2 or 2+0),4(2+2)。即states[1][0]=true,states[1][2]=true,states[1][4]=true。

以此类推,直到考察完所有物品。整个states状态数组就计算好了。这里用图画了出来。

                                                          

int knapsack()
{
        bool states[5][10] = {false};
        states[0][0] = true;
        if(weight[0] <= w)
                states[0][weight[0]] = true;
        for(int i=1; i<n; i++)
        {
                for(int j=0; j<=w; j++)            //不把第i个物品放入背包
                        if(states[i-1][j] == true)
                                states[i][j] = true;
                for(int j=0; j<=w-weight[i]; j++)  //把第i个物品放入背包
                        if(states[i-1][j] == true)
                                states[i][j+weight[i]] = true;
        }
        for(int i=w; i>=0; i--)                   //输出结果
                if(states[n-1][i] == true)
                        return i;
        return 0;
}

  从上面代码可以轻松得到,动态规划的时间复杂度 O(n*w),回溯算法代码的时间复杂度是O(2^n),所以动态规划的执行效率高很多。但是我们需要额外申请 n乘以w+1 的二维数组,空间消耗较多。有时候我们可以说,动态规划是一种空间换时间的解决思路。这里,其实我们还可以降低空间消耗,只需要一个 w+1 的一位数组姐可以解决问题。动态规划状态转移的过程,都可以基于一位数组操作。

int knapsack2()
{
        bool states[10] = {false};
        states[0] = true;
        if(weight[0] <= w)
                states[weight[0]] = true;

        for(int i=1; i<n; i++)
                for(int j=w-weight[i]; j>=0; j--)
                        if(states[j] == true)
                                states[j+weight[i]] = true;
        for(int i=w; i>=0; i--)
                if(states[i] = true)
                        return i;
        return 0;
}

      切记,如果 j 从小到大处理,会出现for循环重复计算问题。

    2. 0-1背包问题升级版   

在上述题干的基础上引入物品价值的变量,在满足不超过背包最大重量的条件,是背包内物品总价值最大化。

首先来试试回溯算法解决问题:

int n = 5;
int weight[] = {2, 2, 4, 6, 3};
int w = 9;
int maxV = 0;
int value[] = {3, 4, 8, 9,6};
void f3(int i, int cw, int cv)
{
        if(i == n || cw == w)
        {
                if(cv > maxV)
                        maxV = cv;
                return;
        }
        f3(i+1, cw, cv);
        if(cw + weight[i] <= w)
                f3(i+1, cw+weight[i], cv+value[i]);
}

画出递归树,每个节点表示一个状态,用3个变量(i, cw, cv)表示一个状态,从下图可以看出,有几个节点的 i 和 cw是完全相同的,比如分f(2, 2, 4) 和 f(2, 2, 3),在背包总重量一样的情况,选择更大的总价值,即状态f(2, 2, 4)。即对于(i, cw)相同的不同状态,保留cv值更大的那个状态,继续递归处理,不考虑其他状态。             

                                          

我们来看看动态规划怎么解决这种问题?将整个求解过程分为 n 个阶段,用一个二维数组来表示 states[n][w+1] 来记录每层可以达到的不同状态,不过这里数组存储的值是当前状态的最大价值。把每一层 (i, cw) 重复的节点合并,只记录 cv 值最大的那个状态,然后基于这些状态来推导下一层状态。

int knapsack3()
{       
        int states[5][10] = {-1};
        states[0][0] = 0;
        if(weight[0] <= w)
                states[0][weight[0]] = value[0];
        for(int i=1; i<n; i++)
        {       
                for(int j=0; j<= w; j++)       //不选择第 i 个物品
                        if(states[i-1][j] >= 0)
                                states[i][j] = states[i-1][j];
                for(int j=0; j<= w-weight[i]; j++)  //选择第 i 个物品
                {       
                        if(states[i-1][j] >= 0)
                        {       
                                int v = states[i-1][j] + value[i];
                                if(v > states[i][j+weight[i]]) 
                                        states[i][j+weight[i]] = v;
                        }
                }
        }
        int maxvalue = -1;
        for(int j=0; j<= w; j++)         //找出最大值
                if(states[n-1][j] > maxvalue)
                        maxvalue = states[n-1][j];
        return maxvalue;
}

3. 双十一活动 购物

我们再来看一下生活中的例子,淘宝“双十一”,某促销活动满200减50,购物车里有n(n>100)个商品,在凑够满减条件的前提下,选出来的商品总价格最大程度地接近满减条件(200元),可以极大程度地“薅羊毛”。怎样动态规划来解决?

可以先考虑回溯算法,穷举所有的排列组合,然后看大于等于200并且最接近200的组合是哪个?但是这样效率很低,时间复杂度是指数级的。当n很大时,可能“双十一”结束了,你的程序还没跑完。实际上,这个问题跟0-1背包问题很类似,只不过把重量换成价格。购物车中n个商品,每个商品都决策是否购买,用二维数组states[n][x]记录,每次决策之后可达的状态集合。

0-1背包问题中,我们要找的是,小于等于w的最大值,这里我们找的是大于等于200中的最小值,所以 x 就不能设置200+1。如果要购买的商品总价格超过200太多,比如说1000, 就没用“薅羊毛”的实际意义了。我们可以设置x为1001。另外,我们不仅要求这个最小的价格,还要列出购买的商品是那些,可以用states数组倒推出,被选择商品的序列。好了,上代码:

void double11_shopping(int[] items, int n, int w) ////items 价格 n 个数 w 满减条件
{
        bool **states = new bool* [n];
        for(int i=0; i<n; i++)
                states[i] = new bool[3*w+1];

        states[0][0] = true;
        if(items[0] <= 3*w)
                states[0][items[0]] = true;
        for(int i=0; i<n; i++)
        {
                for(int j=0; j<=3*w; j++)  //不购买第i个商品
                        if(states[i-1][j] == true)
                                states[i][j] = true;
                for(int j=0; j<=3*w; j++)  //不购买第i个商品
                        if(states[i-1][j] == true)
                                states[i][j+items[i]] = true;
        }
        int j;
        for(j=w; j<3*w+1; j++)    //输出结果大于等于w的最小值
                if(states[n-1][j] == true)
                        break;
        if(j = 3*w+1)                //没有可行解
                return;  
        for(int i=n-1; i>=1; i--)
                if(j-items[i] >= 0 && states[i-1][j-items[i]] == true)
                {
                        cout << items[i] << " " << endl;        //购买这个商品
                        j = j - items[i];
                }    //else 没有购买这个商品, j不变
        if(j != 0)
                cout << items[0] << endl;

        for(int i=0; i<n; i++)
                delete[] states[i];
        delete[] states;
}

状态(i, j),只能从状态(i-1, j)或者(i-1, j-value[i])推导过来,我们就检查这两个状态是否可达,即是否为true。如果状态(i-1

, j)可达,说明没有购买该商品;如果(i-1, j-value[i])可达,说明该商品购买了。

  • 理论

通过上面的实践练习,我们对动态规划有了初步的认识,接下来深入理解动态规划的理论知识点。

开门见山,先将它的理论简要概括为,“一个模型三个特征”。

“一个模型”,指的是动态规划适合解决什么模型,这个模型,我们称之为“多阶段决策最优解”模型。

我们一般是用动态规划解决最优问题,而过程中需要经过多个决策阶段,每个决策阶段对应一组状态。需要找出这样一组状态的决策序列,能够得到最终期望求解的最优解。

“三个特征”,分别是最优子结构、无后效性、重复子问题。

最优子结构:问题的最优解包含子问题的最优解,即通过子问题的最优解推导出问题的最优解,或者说后面阶段的状态可以通过前面阶段的状态推导出来。

无后效性:第一层含义,推到后面阶段状态的时候,只关心前面阶段的状态值,而不关心这个值是怎么推导出来的;第二层含义,某阶段状态一旦确定,就不受之后阶段决策的影响。其实,只要满足前面提到的动态规划问题模型,就基本都会满足无后效性。

重复子问题:不同的决策序列,到达相同的阶段时,可能产生重复状态。

实例剖析:

假设有一个 n * n 的矩阵w[n][n]。棋子其实位置在左上角,终点位置在右下角,每次只能向右或者向下移动一位,这样会经过很多不同的路径,把每条路径经过的数字加起来看作路径的长度。来计算一下最短路径长度?

                                                  

从(0, 0) 走到 (i-1, i-1),总共要走2*(n-1)步,也就对应着2*(n-1)个阶段,每个阶段都有向下或者向右走两种决策,每个阶段都会对应一个状态集合。把状态定义为min_dst(i, j),min_dst的值表示从(0, 0) 到 (i, j) 的最短路径长度,所以这个问题符合多阶段决策最优解模型。

接下来看看是否符合“三个特征”?

首先,我们这里可以用回溯算法解决这个问题。可以自己写一下代码,画一下递归树,发现递归树中有重复的节点。这里重复的节点表示,从左上角到节点对应的位置,有多种路线,这也能说明问题中存在重复子问题。

其次,我们这里走到位置(i, j),只能通过(i-1, j),(i, j-1)两个位置移动过来,也就是说,我们想要计算位置(i, j)的状态,只需要关心(i-1, j) 和 (i, j-1)两个位置对应的状态,不关心是通过什么样的路线到达这个位置。而且,只允许往下或者向右移动,不允许后退,所以前面状态确定以后,不会被后面阶段的决策所改变,这个问题符合“无后效性”特征。

最后,我们知道到达位置(i,  j),要么经过(i, j-1),要么经过(i-1, j),而且到达(i, j)的最短路径必然包含到达这两个位置的最短路径之一。换句话说,min_dist(i, j)可以通过min_dst(i, j-1) 和min_dst(i-1, j)两个状态推导过来。这个问题符合“最优子结构”特征。

min_dst(i, j) = w[i][j] + min(min_dst(i-1, j), min_dst(i, j-1));

“一个模型三个特征”已经讲完,接下来看看解决动态规划问题两种思路:状态转移表法和状态转移方程法。

状态转移表法:

我们画一个二维状态表,表中数值表示从起点到这个位置的最短路径,按照决策过程,不断状态递推演进,将表填好。弄懂了填表过程,代码实现那就简单了。

注:这里要根据上面的初始表,作累加值。

  

int n = 4;
int matrix[4][4] = {{1,3,5,9}, {2,1,3,4}, {5,2,6,7}, {6,8,4,3}}; 

int mindst_DP()
{
    int states[4][4] = {0};
    int sum = 0;
    for(int j=0; j<n; j++)        //初始化states的第一行数据
    {
        sum += matrix[0][j];
        states[0][j] = sum;
    }
    sum = 0;
    for(int i=0; i<n; i++)      //初始化states的第一列数据
    {
        sum += matrix[i][0];
        states[i][0] = sum;
    }
    for(int i=0; i<n; i++)
        for(int j=0; j<n;j++)
            states[i][j] = matrix[i][j] + ( states[i][j-1] > states[i-1][j] ? states[i-1][j] : states[i][j-1] );

    return states[n-1][n-1];
}

状态转移方程法:

比较类似于递归的解题思路,某个问题如何通过子问题来递归求解,也就是所谓的最优子结构。如何根据最优子结构,写出递推公式,也就是所谓的状态转移方程。一般情况下,有两种代码实现方法:递推公式加“备忘录”和迭代递推。上面例子状态转移方程已经列出来,这里再写一遍,方便查看。

min_dst(i, j) = w[i][j] + min(min_dst(i-1, j), min_dst(i, j-1));

状态转移方程是解决动态规划的关键,如果能写出状态转移方程,那动态规划就解决了一大半,翻译代码也很简单了。而很多动态规划问题的状态本身就很难定义,状态转移方程就更不好想到。下面是递归加“备忘录”的方式的代码,另一种实现方式,跟状态转移表法的代码实现一样,只是思路不同。

int matrix[4][4] = {{1,3,5,9}, {2,1,3,4}, {5,2,6,7}, {6,8,4,3}};
int n = 4;
int mem[4][4] = {0};

int min_Dist(int i, int j)
{
    if(i=0 && j==0)
        return matrix[0][0];
    if(mem[i][j] > 0)
        return mem[i][j];
    int minleft=0, minup=0;
    if(j-1 >= 0)
        minleft = min_Dist(i, j-1);
    if(i-1 >= 0)
        minup = min_Dist(i-1, j);
    int curMinDist = matrix[i][j] + minleft>minup ? minup : minleft;
    mem[i][j] = curMinDist;
    return curMinDist;
}

两种动态规划解题思路讲完了,这里强调一点,不是每个问题都同时适合两解题思路,我们要因题而异地去选择某一种解题思路。

  • 再实战

搜索引擎在用户体验方面的优化有很多,其中就有经常会用到的拼写纠错功能。当你在搜索框中,一不小心输错单词时,搜索引擎会检测出你的拼写错误,并且用对应的正确单词来进行搜索。这个功能该如何实现?

1. 量化两个字符串的相似度

编辑距离:指的就是将一个字符串转化成另一个字符串,需要的最少编辑操作次数(增加、删除、替换一个字符)。编辑距离越大,说明两个字符串的相似程度越小,反之,两个字符串的相似程度越大。对于两个完全相同的字符串,编辑距离为0。

根据所包含编辑操作种类的不同,编辑距离有多种不同的计算方式,莱文斯坦距离 和 最长公共子串长度。莱文斯坦距离允许增加、删除、替换字符三个编辑操作,最长公共子串长度允许增加、删除字符两个操作。它们是从两个截然相反的角度,分析字符串的相似程度。莱文斯坦距离的大小,表示两个字符串的差异的大小;而最长公共子串长度的大小,表示两个字符串的相似度的大小。从下图可以看出,这两个字符串的莱文斯坦距离和最长公共子串长度分别是 3 和 4。

                                            

2. 编程计算莱文斯坦距离

这个问题是求吧一个字符串变成另一个字符串,需要的最少编辑次数。整个求解过程,涉及多个决策阶段,需要依次考察一个字符串中的每个字符,跟另一个字符串的字符是否匹配,匹配的话如何处理,不匹配的话又如何处理。所以这个这个问题符合“多阶段决策最优解”模型。

一般的,贪心、回溯、动态规划可以解决的问题,都能抽象成这样的模型。先来看看回溯算法是如何解决的。

首先,回溯是一个递归处理的过程。如果 a[i] 和 b[j] 匹配,我们递归考察 a[i+1] 和 b[j+1] 。如果不匹配,那我们有多种处理方式可选:

a) 可以删除a[i],然后递归考察 a[i+1] 和 b[j];

b) 可以删除b[j],然后递归考察 a[i] 和 b[j+1];

c) 在a[i] 前面添加跟 b[j] 相同的字符, 然后递归考察a[i] 和 b[j+1];

d) 在b[j] 前面添加跟 a[i] 相同的字符, 然后递归考察a[i+1] 和 b[j];

e) 将 a[i] 替换成 b[j] 或者将 b[j] 替换成 a[i],然后递归考察a[i+1] 和 b[j+1]。

看代码:

char a[] = "mitcmu";
char b[] = "mtacnu";
int m, n, minDist = 0;  //minDist 存储结果
m = n = 6;
void lwstBT(int i, int j, inr edist) //调用方式lwstBT(0, 0, 0)
{
    if(i==n || j == m)
    {
        if(i<n)
            edist += n-i;
        if(j<m)
            edist += m-j;
        if(edist < minDist)
            minDist = edist;
        return;
    }
    if(a[i] == b[j])
        lwstBT(i+1, j+1, edist);
    else          //当不匹配的时候,以下三种分别对应上面叙述的情况
    {
        lwstBT(i+1, j, edist+1);
        lwstBT(i, j+1, edist+1);
        lwstBT(i+1, j+1, edist+1);
    }
}

根据回溯算法代码的实现,可以画出递归树,看是否存在重复子问题。若存在,就可以考虑用动态规划来解决;若不存在,那么回溯算法就是最好的解决方法。

                               ,

在递归树中,每个节点代表一种状态,状态包含三个变量(i, j, edist),其中edist表示处理 a[i] 和 b[j] 时,已经执行的编辑操作次数。对于(i, j)相同的节点,只需保留edist最小的,继续递归处理就可以了,剩下的节点可以舍弃。所以,(i, j, edist) 就变成了(i, j, min_edist),其中min_edist表示处理到a[i] 和 b[j] 时,已经执行的最少编辑次数。

我们可以写出状态转移方程:

如果:a[i] != b[j],那么:

   min_edist(i, j) = min( min_edist(i-1, j)+1, min_edist(i, j-1)+1, min_edist(i-1, j-1)+1 )

如果:a[i] == b[j],那么:

   min_edist(i, j) = min( min_edist(i-1, j)+1, min_edist(i, j-1)+1, min_edist(i-1, j-1) )

了解了状态间的递推关系,我们画出一个二维的状态表,按行依次填充表中的值。

                                    

代码如下:

int lwstDP()
{
    int minDist[6][6] = {0};
    for(int j=0; j<m; j++)
    {
        if(a[0] == b[j])
            minDist[0][j] = j;
        else if(j != 0)
            minDist[0][j] = minDist[0][j-1] + 1;  //a[0] != b[0] 这个时候j=0,但是minDist=1,所以这种情况要除外,用下面的else 来表达
        else 
            minDist[0][j] = 1;
    }
    for(int i=0; i<n; i++)
    {
        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(itn j=1; j<m; j++)
        {
            if( a[i] == b[j] )
                minDIst = min( minDist[i-1][j]+1, minDist[i][j-1]+1, minDist[i-1][j-1] );
            else 
                minDIst = min( minDist[i-1][j]+1, minDist[i][j-1]+1, minDist[i-1][j-1]+1 );
        }

    return minDist[n-1][m-1];
}

3. 编程计算最长公共子串长度

最长公共子串长度作为编辑距离的一种,只允许增加、删除字符两种编辑操作。从本质上,表征的也是两个字符串之间的相似程度。这个问题的解决思路,跟莱文斯坦距离类似,也可以用动态规划解决。解决思路前面已经讲得非常详细,这里就直接来定义状态,,在写状态转移方程。

每个状态还是包括三个变量(i, j, max_lcs),max_lcs表示 a[0...i] 和 b[0...j] 的最长公共子串长度,那 (i, j) 这个状态是从那么哪些状态转移过来的呢?

先来卡回溯的回溯的处理思路。从 a[0] 和 b[0]开始,依次考察两个字符串的字符是否匹配:

a) 如果a[i] 和 b[j] 互相匹配,我们将最长公共子串长度+1,继续考察 a[i+1] 和 b[j+1]。

b) 如果a[i] 和 b[j] 不匹配,最长公共子串长度不变,有两种不同的决策路线:

    删除a[i],或者在 b[j] 前面加上a[i],继续考察a[i+1] 和 b[j];

    删除b[j],或者在 a[i] 前面加上b[j],继续考察a[i] 和 b[j+1]。

所以,状态(i, j)是从下面三个状态转移过来:

(i-1, j-1, max_lcs),其中max_lcs 表示a[0...i-1] 和 b[0...j-1] 的最长公共子串长度;

(i-1, j, max_lcs), 其中max_lcs 表示a[0...i-1] 和 b[0...j] 的最长公共子串长度;

(i, j-1, max_lcs),其中max_lcs 表示a[0...i] 和 b[0...j-1] 的最长公共子串长度。

状态转移方程就可以写出来:

如果a[i] == b[j],那么:
max_lcs(i, j) = max( max_lcs(i-1, j-1)+1, max_lcs(i-1, j), max_lcs(i, j-1) );

如果a[i] != b[j], 那么:
max_lcs(i, j) = max( max_lcs(i-1, j-1), max_lcs(i-1, j), max_lcs(i, j-1) );

看代码:


int lcsDP(void)
{
    char a[] = "mitcmu";
    char b[] = "mtacnu";
    int maxlcs[6][6] = {0};
    int n, m;
    n = m = 6;

    for(int j=0; j<m; j++)
    {
        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++)
    {
        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=0; i<n; i++)
        for(int j=0; j<m; j++)
        {
            if(a[i] == b[j])
                maxlcs[i][j] = max( max[i-1][j-1]+1, max[i-1][j], max[i][j-1] );
            else
                maxlcs[i][j] = max( max[i-1][j-1], max[i-1][j], max[i][j-1] );
         }

    return maxlcs[n-1][m-1];     
}

所以,当用户在搜索框内,输入一个拼写错误的单词时,我们就拿这个单词跟词库中的单词一一进行比较,计算编辑距离,将编辑距离最小的单词,作为纠正之后的单词,提示给用户。

上述是拼写纠错的最基本原理。不过,真正用于商用的搜索引擎,拼写纠错功能显然不会就这么简单。一方面,单纯利用编辑距离来纠错,效果并不一定好;另一方面,词库 中的数据量可能很大,手欧引擎每天支持海量的搜索,所以对纠错的性能要求很高。

针对纠错效果不好的问题,我们有很多的优化思路,介绍几种。

(1) 我们不仅仅取出编辑距离最小的那个单词,二是取出编辑距离最小的TOP 10,然后根据其他参数,决策选择哪个单词作为拼写纠错单词。比如使用搜索热门程度来决策。

(2) 我们还可以使用多种编辑距离计算方法,比如这里介绍的两种,然后分别求编辑距离最小的TOP 10,然后求交集,再对交集的结果,继续优化处理。

(3) 还可以通过统计用户的搜索日志,得到最常被拼错的单词列表,以及对应的拼写正确的单词,搜索引擎在拼写纠错的时候,首先在这个最常被拼错单词列表中查找。如果一旦找到,直接返回正确的单词。

(4)还有一种更高级的做法,引入个性因素。针对每个用户,维护这个用户特有的搜索喜好,也就是常用的关键词。当用户输入错误的单词时,首先在这个用户常用的搜索关键词中,计算编辑距离,查找编辑距离最小的单词。

针对纠错性能方面,也有相应的优化方式,将两种分治的优化思路。

(1) 如果纠错的TPS不高,我们可以部署多台机器,每台机器运行一个独立的纠错功能。当有一个纠错请求的时候,通过负载均衡,分配到其中一台机器,来计算编辑距离,得到纠错单词。

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

真正的搜索引擎拼写纠错优化,肯定不止这里介绍的这么简单,但是万变不离其宗。掌握了核心原理,就是掌握了解决问题的方法剩下就只灵活应用和实战操练了。

通过这篇文章的学习,相信一定能在动态规划方面达到入门级了。

以上所述,是本人最近在极客时间上学习数据结构和算法相关课程做的笔记吧。

具体参照: https://time.geekbang.org/column/intro/126

发布了37 篇原创文章 · 获赞 20 · 访问量 4968

猜你喜欢

转载自blog.csdn.net/qq_24436765/article/details/93890929