實現拼寫檢查器(spell check)

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

相關文章
相關標籤/搜索