文章目录
- 1. 字符串相似度
- 1.1 莱文斯坦距离
- 1.2 最长公共子串长度
- 2. 计算编辑距离
- 2.1 莱文斯坦距离
- 2.2 最长公共子串长度
- 3. 搜索引擎拼写纠错
- 4. 练习题
在 Trie树那节讲过,利用Trie可以进行关键词提示,节省输入时间。
在搜索框中你不小心打错了字,它也会智能提醒你是不是要搜XXX
1. 字符串相似度
- 如何量化两个字符串的相似度?有一个非常著名方法,编辑距离(Edit Distance)。
- 编辑距离,将一个字符串转化成另一个字符串,需要的最少编辑操作次数(增加一个字符、删除一个字符、替换一个字符)。
- 编辑距离越大,两个字符串相似度越小;编辑距离越小,两个字符串相似度越大。两个完全相同的字符串,编辑距离是0。
1.1 莱文斯坦距离
- 莱文斯坦距离(Levenshtein distance)允许增加、删除、替换字符这三个编辑操作
- 莱文斯坦距离的大小,表示两个字符串差异的大小
1.2 最长公共子串长度
- 最长公共子串长度(Longest common substring length)只允许增加、删除字符这两个编辑操作
- 表示两个字符串相似程度的大小
两个字符串 mitcmu 和 mtacnu 的莱文斯坦距离是3,最长公共子串长度是4。
2. 计算编辑距离
这个问题是求把一个字符串变成另一个字符串,需要的最少编辑次数。整个求解过程,涉及多个决策阶段,需要依次考察一个字符串中的每个字符,跟另一个字符串中的字符是否匹配,所以,问题符合多阶段决策最优解模型。
2.1 莱文斯坦距离
回溯解法:
回溯是一个递归处理的过程。如果a[i]与b[j]匹配,我们递归考察a[i+1]和b[j+1]。如果a[i]与b[j]不匹配,那我们有多种处理方式可选:
- 可以删除a[i],然后递归考察a[i+1]和b[j];
- 可以删除b[j],然后递归考察a[i]和b[j+1];
- 可以在a[i]前面添加一个跟b[j]相同的字符,然后递归考察a[i]和b[j+1];
- 可以在b[j]前面添加一个跟a[i]相同的字符,然后递归考察a[i+1]和b[j];
- 可以将a[i]替换成b[j],或者将b[j]替换成a[i],然后递归考察a[i+1]和b[j+1]。
/*** @description: 莱文斯坦距离,回溯* @author: michael ming* @date: 2019/7/25 1:25* @modified by: */
#include <string>
#include <iostream>
using namespace std;
void lwstBT(string &a, string &b, int i, int j, int dist, int &minDist)
{if(i == a.size() || j == b.size()){if(i < a.size())dist += (a.size()-i);if(j < b.size())dist += (b.size()-j);if(dist < minDist)minDist = dist;return;}if(a[i] == b[j])// 两个字符匹配{lwstBT(a,b,i+1,j+1,dist,minDist);}else// 两个字符不匹配{lwstBT(a,b,i+1,j,dist+1,minDist);// 删除 a[i] 或者 b[j] 前添加一个字符lwstBT(a,b,i,j+1,dist+1,minDist);// 删除 b[j] 或者 a[i] 前添加一个字符lwstBT(a,b,i+1,j+1,dist+1,minDist);// 将 a[i] 和 b[j] 替换为相同字符}
}
int main()
{int minDist = INT_MAX;string a = "mitcmu", b = "mtacnu";lwstBT(a,b,0,0,0,minDist);cout << "莱文斯坦距离:" << minDist << endl;
}
-
在递归树中,每个节点状态包含三个变量(i,j,dist),dist表示处理到a[i]和b[j]时,已经执行的编辑次数。
-
在递归树中,(i,j)两个变量重复的节点很多,比如(3,2)和(2,3)。对于(i,j)相同的节点,我们只保留 dist最小的,继续递归处理,剩下的舍弃。所以,状态就从(i,j,dist)变成了(i,j,min_dist),其中min_dist 表示处理到a[i]和b[j],已经执行的最少编辑次数。
-
这个问题的状态转移方式,要比之前两节课中讲到的例子要复杂很多。上一节我们讲的矩阵最短路径问题中,到达状态(i,j)只能通过(i-1,j)或(i,j-1)两个状态转移过来,而今天这个问题,状态(i,j)可能从(i-1,j),(i,j-1),(i-1,j-1)三个状态中的任意一个转移过来。
写状态转移方程如果:a[i] != b[j],那么:min_dist(i, j) 就等于: min{min_dist(i-1,j)+1, min_dist(i,j-1)+1, min_dist(i-1,j-1)+1}如果:a[i] == b[j],那么:min_dist(i, j) 就等于: min{min_dist(i-1,j)+1, min_dist(i,j-1)+1, min_dist(i-1,j-1)}其中,min 表示求三数中的最小值。
根据状态转移方程,填充状态表
/*** @description: 莱文斯坦距离,动态规划* @author: michael ming* @date: 2019/7/25 20:40* @modified by: */
#include <string>
#include <iostream>
using namespace std;
int min(int x, int y, int z)
{int m = INT_MAX;if(x < m) m = x;if(y < m) m = y;if(z < m) m = z;return m;
}
int lwstDP(string &a, const int lenA, string &b, const int lenB)
{int i, j;int minDist[lenA][lenB];for(j = 0; j < lenB; ++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;elseminDist[0][j] = 1;}for(i = 0; i < lenA; ++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;elseminDist[i][0] = 1;}for(i = 1; i < lenA; ++i)//按行填状态表for(j = 1; j < lenB; ++j){if(a[i] == b[j])minDist[i][j] = min(minDist[i-1][j]+1, minDist[i][j-1]+1, minDist[i-1][j-1]);elseminDist[i][j] = min(minDist[i-1][j]+1, minDist[i][j-1]+1, minDist[i-1][j-1]+1);}return minDist[lenA-1][lenB-1];
}
int main()
{string a = "mitcmu", b = "mtacnu";cout << "莱文斯坦距离:" << lwstDP(a,a.size(),b,b.size()) << endl;
}
2.2 最长公共子串长度
最长公共子串作为编辑距离的一种,只允许增加、删除字符两种操作。表征的也是两个字符串之间的相似程度。
- 每个状态包括三个变量(i,j,max_lcs),max_lcs表示a[0…i]和b[0…j]的最长公共子串长度。那(i,j)这个状态都是由哪些状态转移过来的呢?
- 先来看回溯的处理思路。我们从a[0]和b[0]开始,依次考察两个字符串中的字符是否匹配。
- 如果a[i]与b[j]匹配,最大公共子串长度+1,继续考察a[i+1]和b[j+1]。
- 如果a[i]与b[j]不匹配,最长公共子串长度不变,这个时候,有两个不同的决策路线:
1 删除a[i],或者在b[j]前面加上一个字符a[i],然后继续考察a[i+1]和b[j];
2 删除b[j],或者在a[i]前面加上一个字符b[j],然后继续考察a[i]和b[j+1]。
a[0…i]和b[0…j]的最长公共长度max_lcs(i,j),只有可能通过下面三个状态转移过来:
(i-1,j-1,max_lcs),max_Ics表示 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_Ics表示 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)};其中 max 表示求三数中的最大值。
/*** @description: 最长公共子串长度, DP* @author: michael ming* @date: 2019/7/25 21:40* @modified by: */
#include <string>
#include <iostream>
using namespace std;
int max(int x, int y, int z)
{int m = INT_MIN;if(x > m) m = x;if(y > m) m = y;if(z > m) m = z;return m;
}
int lcsDP(string &a, const int lenA, string &b, const int lenB)
{int i, j;int maxlcs[lenA][lenB];for(j = 0; j < lenB; ++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];elsemaxlcs[0][j] = 0;}for(i = 0; i < lenA; ++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];elsemaxlcs[i][0] = 0;}for(i = 1; i < lenA; ++i)//按行填状态表for(j = 1; j < lenB; ++j){if(a[i] == b[j])maxlcs[i][j] = max(maxlcs[i-1][j], maxlcs[i][j-1], maxlcs[i-1][j-1]+1);elsemaxlcs[i][j] = max(maxlcs[i-1][j], maxlcs[i][j-1], maxlcs[i-1][j-1]);}return maxlcs[lenA-1][lenB-1];
}
int main()
{string a = "mitcmu", b = "mtacnu";cout << "最大公共子串长度:" << lcsDP(a,a.size(),b,b.size()) << endl;
}
最大公共子串长度:4
3. 搜索引擎拼写纠错
-
当用户在搜索框内,输入一个拼写错误的单词时,拿这个单词跟词库中的单词一—进行比较,计算编辑距离,将编辑距离最小,作为纠正之后的单词,提示用户。
-
这就是拼写纠错最基本的原理。真正商用的搜索引擎,拼写纠错功能不会这么简单。一方面,单纯利用编辑距离来纠错,效果并不一定好;另一方面,词库中的数据量可能很大,对纠错的性能要求很高。
-
针对纠错效果不好,有很多种优化思路。
a. 取出编辑距离最小的TOP10,然后根据其他参数决策选择。比如使用搜索热门程度来决定。
b. 可以用多种编辑距离计算方法,比如今天讲到的两种,分别计算编辑距离最小的TOP10,求交集,用交集的结果,再继续优化处理。
c. 可以统计用户的搜索日志,得到最常拼错的单词列表,以及对应的拼写正确的单词。纠错时,首先在这个最常拼错单词列表中查找。一旦找到,直接返回对应的正确的单词。这样纠错的效果非常好。
d. 引入个性化因素。针对每个用户,维护这个用户特有的搜索喜好,也就是常用的搜索关键词。当用户输入错误时,先在这个用户常用的搜索关键词中,计算编辑距离,查找编辑距离最小的单词。 -
针对纠错性能方面,讲两种分治的优化思路。
a. 如果纠错功能的TPS(每秒事务处理量(TransactionPerSecond))不高,可以部署多台机器,每台机器运行一个独立的纠错功能。当有一个纠错请求的时候,通过负载均衡,分配到其中一台机器,来计算编辑距离,得到纠错单词。
b. 如果纠错系统的响应时间太长,也就是,每个纠错请求处理时间过长,可以将纠错的词库,分割到很多台机器。当有一个纠错请求的时候,将这个拼写错误的单词,同时发送到多台机器,并行处理,分别得到编辑距离最小的单词,然后再比对合并,最终决定出一个最优的纠错单词。
4. 练习题
LeetCode 72. 编辑距离(DP)
LeetCode 1143. 最长公共子序列(动态规划)