先简单说说问题吧,我们在使用Word的时候经常会发现有些单词比如school,一不小心给敲成了shcool或者shool,这个时候Word会很体贴地提示我们这个英语单词错了(很简单shcool,shool在Word的字典库中都没有出现过,所以肯定错了),接下来Word给出了好几个单词都长得特别像shcool,让我们最快地修正错误,今天真正要讲的就是Word怎么判断两个字符串的相似程度(长的像)的(相似程度越高就越要给你推荐纠正)
下面涉及的算法有Spelling suggestion(拼写纠正算法)、Spell checker(拼写检查算法)、Bloom Filter(布隆过滤器)、longest common substring(最大公共子串),Levenshtein distance(我不知道怎么恰当的翻译,大概意思是计算一个字符串变到另一个字符串所需要的步骤吧,也就是两个字符串的相似程度,步骤越短当然越相似)
第一步Word肯定要能判断这个单词正确与否的,自然最合适的是使用布隆过滤器了(Bloom Filter),其实布隆过滤器的原理很简单:通过Hash将所有正确的单词都记下来,然后再来了一个字符串的话,对它进行Hash,然后检查Hash出来的地址上有没有记号,没有的话表示这个字符串在我们的正确单词库中没有任何单词能够和他匹配,那肯定就错了,这就是布隆过滤器的思想,但是你一想的话发现在我们的这个需求中不需要在Hash表中存储正确单词的内容了(所以不需要能装下整个词典那么大的空间了),最简单用一个Bit表示(0表示没有,1表示有)这个位置上有没有正确的单词就可以了,这样就节省了大量空间!毕竟这个需求比我们传统的使用Hash的时候要简单,所以没有理由为Key付出代价:),具体还可以参考Google的吴军的数学之美系列文章,这样就达到了一个字符串是否为一个正确的单词了,不是的话就要接下来找一个最有可能的单词来推荐我们修正他。(到这里就完成了拼写检查的功能,当然Bloom Filter还有很多其他用处)
接下来在讲述如何判断两个字符窜的相似程度的时候我们先来看看另外一个问题(下面的内容是摘录自http://www.5do8.com/blog/doc/569/index.aspx):LCS(longest common substring)算法,即最大公共子串,它是求两个字符串最长公共子串的问题(就是两个字符串中最长的公共部分)。大体解法是用一个矩阵来记录两个字符串中所有位置的两个字符之间的匹配情况,若是匹配则为1,否则为0。然后求出对角线(确切来说是矩阵斜线)最长的1序列,其对应的位置就是最长匹配子串的位置.
例如,有两个字符串:
A= I MISS MY CODE HI
B= One Like MY Code
这里,先忽略掉大小写,通吃,在C#或者PHP中,String.ToLower()或者lower()可以考虑.对于其中的特殊字符,例如空格,可以在比较的时候直接删除.另外,该算法比较与顺序无关,可以随意的翻转字符串.接下来比较.
i | m | i | s | s | m | y | c | o | d | e | h | i | |
o | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 |
n | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
e | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 |
l | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
i | 1 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 |
k | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
e | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 |
m | 0 | 1 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
y | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 |
c | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 |
o | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 |
d | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 |
e | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 |
在上面的矩阵图中,其中的红色(最长的1串)就可以看成匹配的字符串. (摘录完毕)
或者这样标示矩阵会更方便:
i | m | i | s | s | m | y | c | o | d | e | h | i | |
o | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 |
n | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
e | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 |
l | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
i | 1 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 |
k | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
e | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 |
m | 0 | 1 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
y | 0 | 0 | 0 | 0 | 0 | 0 | 2 | 0 | 0 | 0 | 0 | 0 | 0 |
c | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 3 | 0 | 0 | 0 | 0 | 0 |
o | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 4 | 0 | 0 | 0 | 0 |
d | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 5 | 0 | 0 | 0 |
e | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 6 | 0 | 0 |
好了这个问题到这里是完美优美地解决了,非常好理解,我当时反正感觉非常是受震撼(其实我每次看到好算法都是这样的)然后就琢磨了这个算法背后的原理、思想是什么它还能应用在哪些方面,正好我以前做个一个算法这么样计算两个字符串的匹配程度(当时是用匹配度不断加权计算出来的相似度,并进行了一些特殊处理才马马虎虎对特殊情况非常合适,呵呵太弱了吧),感觉这两个问题特别相似,既然这个矩阵记录了所有的匹配情况,那么我可以这样想所有斜线(平行对角线)上的数字加起来平方,这样得到一个数字,越大的话应该是两个字符串在不同的地方匹配整体匹配度越高(画图实在不方便,我不知道大家理解我的意思了没有,呵呵,当时我是这样想的)。因为虽然LCS算法解决的只是最长公共子串的问题,但是这个算法还帮我们得到了所有其他地方匹配的公共子串(不是最长的那些,当然也要在相似度中纳入计算的),所以我觉得这个想法是在LCS原理上的一个拓展,同样是成立的。
接下来我们再来看看拼写纠正的时候先进行的相似度判断:Levenshtein distance (下面的例子和伪代码摘自Wiki:Levenshtein distance,非常简单我就偷懒了,要我画出这个图形简直是太不可能了,好麻烦的):
A commonly-used bottom-up dynamic programming algorithm for computing the Levenshtein distance involves the use of an (n + 1) × (m + 1) matrix, where n and m are the lengths of the two strings. This algorithm is based on the Wagner-Fischer algorithm for edit distance. Here is pseudocode for a function LevenshteinDistance that takes two strings, s of length m, and t of length n, and computes the Levenshtein distance between them:
int LevenshteinDistance(char s[1..m], char t[1..n]) // d is a table with m+1 rows and n+1 columns declare int d[0..m, 0..n] for i from 0 to m d[i, 0] := i for j from 1 to n d[0, j] := j for i from 1 to m for j from 1 to n if s[i] = t[j] then cost := 0 else cost := 1 d[i, j] := minimum( d[i-1, j] + 1, // deletion d[i, j-1] + 1, // insertion d[i-1, j-1] + cost // substitution ) return d[m, n]
--------------------------------代码结束----------------------------------
Two examples of the resulting matrix (the minimum steps to be taken are highlighted):
|
|
The invariant maintained throughout the algorithm is that we can transform the initial segment s[1..i]
into t[1..j]
using a minimum of d[i,j]
operations. At the end, the bottom-right element of the array contains the answer.
This algorithm is essentially part of a solution to the Longest common subsequence problem (LCS), in the particular case of 2 input lists.
这里使用动态规划不断计算他们间的距离,特别注意一下这里就可以了:
d[i, j] := minimum(
d[i-1, j] + 1, // deletion
d[i, j-1] + 1, // insertion
d[i-1, j-1] + cost // substitution
)
三种情况代表了:,和前一个相等,和后一个相等,匹配(权衡三种情况代价最小的那种),最后矩阵右下角的数字就是两个字符串的距离,越小越相似!(这里加权的时候都是简单处理加1了事,实际上还可以细分处理,这种方法也不能很好地对两个字符刚好错位的情况进行处理)
这种思路就是最大公共子串的一个稍微不一样的扩展,背后的本质思想还是差不多的,所以我前面所说的不断计算矩阵斜线上连续数字的平方和也是一个意思,可以达到相同的效果。