本文同時發在個人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的方式加速查詢