本文同时发在个人github博客上,欢迎stargit
在百度或者Google搜索的时候,有时会小手一抖,打错了个别字母,好比咱们想搜索apple
,错打成了appel
,但神奇的是,即便咱们敲下回车,搜索引擎也会自动搜索apple
而不是appel
,这是怎么实现的呢?本文就将从头实现一个JavaScript版的拼写检查器github
首先,咱们要肯定如何量化敲错单词的几率,咱们将本来想打出的单词设为origin(O),错打的单词设为error(E)数组
由贝叶斯定理
咱们可知:P(O|E)=P(O)*P(E|O)/P(E)
app
P(O|E)是咱们须要的结果,也就是在打出错误单词E的状况下,本来想打的单词是O的几率函数
P(O)咱们能够看做是O出现的几率,是先验几率,这个咱们能够从大量的语料环境中获取测试
P(E|O)是本来想打单词O却打成了E的几率,这个能够用最短编辑距离模拟几率,好比本来想打的单词是apple
,打成applee
(最短编辑距离为1)的几率比appleee
(最短编辑距离为2)天然要大优化
P(E)因为咱们已知E,这个概念是固定的,而咱们须要对比的是P(O1|E)、P(O2|E)...P(On|E)的几率,不须要精确的计算值,咱们能够不用管它this
这部分的实现我参考了natural
的代码,传送门搜索引擎
首先是构造函数:prototype
function SpellCheck(priorList) { //to do trie this.priorList = priorList; this.priorHash = {}; priorList.forEach(item => { !this.priorHash[item] && (this.priorHash[item] = 0); this.priorHash[item]++; }); }
priorList
是语料库,在构造函数中咱们对priorList中的单词进行了出现次数的统计,这也就能够被咱们看做是先验几率P(O)
接下来是check函数,用来检测这个单词是否在语料库中出现
SpellCheck.prototype.check = function(word) { return this.priorList.indexOf(word) !== -1; };
而后咱们须要获取单词指定编辑距离内的全部可能性:
SpellCheck.prototype.getWordsByMaxDistance = function(wordList, maxDistance) { if (maxDistance === 0) { return wordList; } const listLength = wordList.length; wordList[listLength] = []; wordList[listLength - 1].forEach(item => { wordList[listLength].push(...this.getWordsByOneDistance(item)); }); return this.getWordsByMaxDistance(wordList, maxDistance - 1); }; SpellCheck.prototype.getWordsByOneDistance = function(word) { const alphabet = "abcdefghijklmnopqrstuvwxyz"; let result = []; for (let i = 0; i < word.length + 1; i++) { for (let j = 0; j < alphabet.length; j++) { //插入 result.push( word.slice(0, i) + alphabet[j] + word.slice(i, word.length) ); //替换 if (i > 0) { result.push( word.slice(0, i - 1) + alphabet[j] + word.slice(i, word.length) ); } } if (i > 0) { //删除 result.push(word.slice(0, i - 1) + word.slice(i, word.length)); //先后替换 if (i < word.length) { result.push( word.slice(0, i - 1) + word[i] + word[i - 1] + word.slice(i + 1, word.length) ); } } } return result.filter((item, index) => { return index === result.indexOf(item); }); };
wordList是一个数组,它的第一项是只有原始单词的数组,第二项是存放距离原始单词编辑距离为1的单词数组,以此类推,直到到达了指定的最大编辑距离maxDistance
如下四种状况被视为编辑距离为1:
ab
->abc
ab
->ac
ab
->a
ab
->ba
获取了全部在指定编辑距离的单词候选集,再比较它们的先验几率:
SpellCheck.prototype.getCorrections = function(word, maxDistance = 1) { const candidate = this.getWordsByMaxDistance([[word]], maxDistance); let result = []; candidate .map(candidateList => { return candidateList .filter(item => this.check(item)) .map(item => { return [item, this.priorHash[item]]; }) .sort((item1, item2) => item2[1] - item1[1]) .map(item => item[0]); }) .forEach(item => { result.push(...item); }); return result.filter((item, index) => { return index === result.indexOf(item); }); };
最后获得的就是修正后的单词
咱们来测试一下:
const spellCheck = new SpellCheck([ "apple", "apples", "pear", "grape", "banana" ]); spellCheck.getCorrectionsByCalcDistance("appel", 1); //[ 'apple' ] spellCheck.getCorrectionsByCalcDistance("appel", 2); //[ 'apple', 'apples' ]
能够看到,在第一次测试的时候,咱们指定了最大编辑距离为1,输入了错误的单词appel
,最后返回修正项apple
;而在第二次测试时,将最大编辑距离设为2,则返回了两个修正项
上面的实现方法是先获取了单词全部指定编辑距离内的候选项,而在语料库单词较少的状况下,这种方法比较耗费时间,咱们能够改为先获取语料库中符合指定最短编辑距离的单词
计算最短编辑距离是一种比较经典的动态规划(leetcode:72),dp便可。这里的计算最短编辑距离与leetcode的状况略有不一样,须要多考虑一层临近字母左右替换的状况
leetcode状况下的状态转换方程:
dp[i][j]=0
i===0,j===0
dp[i][j]=j
i===0,j>0
dp[i][j]=i
j===0,i>0
min(dp[i-1][j-1]+cost,dp[i-1][j]+1,dp[i][j-1]+1)
i,j>0
其中当word1[i-1]===word2[j-1]
时,cost为0,不然为1
考虑临近字母左右替换的状况,则须要在i>1,j>1且word1[i - 2] === word2[j - 1]&&word1[i - 1] === word2[j - 2]
为true的条件下,再做min(dp[i-1][j-1]+cost,dp[i-1][j]+1,dp[i][j-1]+1,dp[i-2][j-2]+1)
拿到语料库中符合指定最短编辑距离的单词在对先验几率做比较,代码以下:
SpellCheck.prototype.getCorrectionsByCalcDistance = function( word, maxDistance = 1 ) { const candidate = []; for (let key in this.priorHash) { this.calcDistance(key, word) <= maxDistance && candidate.push(key); } return candidate .map(item => { return [item, this.priorHash[item]]; }) .sort((item1, item2) => item2[1] - item1[1]) .map(item => item[0]); }; SpellCheck.prototype.calcDistance = function(word1, word2) { const length1 = word1.length; const length2 = word2.length; let dp = []; for (let i = 0; i <= length1; i++) { dp[i] = []; for (let j = 0; j <= length2; j++) { if (i === 0) { dp[i][j] = j; continue; } if (j === 0) { dp[i][j] = i; continue; } const replaceCost = dp[i - 1][j - 1] + (word1[i - 1] === word2[j - 1] ? 0 : 1); let transposeCost = Infinity; if ( i > 1 && j > 1 && word1[i - 2] === word2[j - 1] && word1[i - 1] === word2[j - 2] ) { transposeCost = dp[i - 2][i - 2] + 1; } dp[i][j] = Math.min( replaceCost, transposeCost, dp[i - 1][j] + 1, dp[i][j - 1] + 1 ); } } return dp[length1][length2]; };
这份代码还有不少能够优化的地方,好比check函数使用的是indexOf判断单词是否在语料库中出现,咱们能够改用单词查找树(Trie)或者hash的方式加速查询