一個提升英文單詞拼寫檢測性能 1000 倍的算法? 序言 單詞拼寫算法思路 對稱刪除拼寫糾正(SymSpell) 代碼實現 小結

序言

小明同學上一次在產品經理的忽悠下,寫好了一箇中英文拼寫糾正工具:https://github.com/houbb/word-checker

本來以爲可以一勞永逸了,直到昨天閒來無事,發現了另一個開源項目,描述簡要如下:

Spelling correction & Fuzzy search: 1 million times faster through Symmetric Delete spelling correction algorithm

The Symmetric Delete spelling correction algorithm reduces the complexity of edit candidate generation and dictionary lookup for a given Damerau-Levenshtein distance. 

It is six orders of magnitude faster (than the standard approach with deletes + transposes + replaces + inserts) and language independent.

小明以爲自己眼睛花了,100W 倍,這牛吹得厲害了。

秉着不信謠,不傳謠的原則,小明開始了算法的學習之旅。

單詞拼寫算法思路

針對英文單詞拼寫,有下面幾種算法:

實時計算編輯距離

給定兩個字符串 s_1s_2,它們之間的編輯距離是將 s_1 轉換爲 s_2 所需的最小編輯操作次數。

最常見的,爲此目的所允許的編輯操作是: (i) 在字符串中插入一個字符; (ii) 從字符串中刪除一個字符和 (iii) 用另一個字符替換字符串中的一個字符;對於這些操作,編輯距離有時稱爲 Levenshtein 距離。

這種算法是最容易想到的,但是實時計算的代價非常昂貴。一般不作爲工業實現。

Peter Norvig 的拼寫算法

從查詢詞生成具有編輯距離(刪除 + 轉置 + 替換 + 插入)的所有可能詞,並在字典中搜索它們。

對於長度爲n的單詞,字母大小爲a,編輯距離d=1,會有n次刪除,n-1次換位,a*n 次改變,a*(n+1) 次插入,總共2n次 搜索時 2n+2an+a-1 個詞。

這種算法就是小明一開始選擇的算法,性能比上一個算法要好很多。

但在搜索時仍然很昂貴(n=9、a=36、d=2 的 114,324 個術語)和語言相關(因爲字母表用於生成術語,這在許多方面都不同)。

性能提升的一種思路

如果讓小明來提升這個算法,那有一個思路就是用空間換時間。

把一個正確單詞的刪除 + 轉置 + 替換 + 插入提前全部生成好,然後插入到字典中。

但是這裏存在一個很大的問題,這個預處理生成的字典數太大了,有些不能接受。

那麼,世間安得雙全法?

讓我們一起來看一下本篇的主角。

對稱刪除拼寫糾正(SymSpell)

算法描述

從每個字典術語生成具有編輯距離(僅刪除)的術語,並將它們與原始術語一起添加到字典中。

這必須在預計算步驟中僅執行一次。

生成與輸入術語具有編輯距離(僅刪除)的術語並在字典中搜索它們。

對於長度爲 n 的單詞、字母大小爲 a、編輯距離爲 1 的單詞,將只有 n 個刪除,在搜索時總共有 n 個術語。

這種方法的代價是每個原始字典條目x次刪除的預計算時間和存儲空間,這在大多數情況下是可以接受的。

單個字典條目的刪除次數 x 取決於最大編輯距離。

對稱刪除拼寫校正算法通過僅使用刪除而不是刪除 + 轉置 + 替換 + 插入來降低編輯候選生成和字典查找的複雜性。 它快了六個數量級(編輯距離=3)並且與語言無關。

一些備註

爲了便於大家理解,原作者還寫了一點備註。

備註1:在預計算過程中,字典中不同的詞可能會導致相同的刪除詞:delete(sun,1)==delete(sin,1)==sn。

雖然我們只生成一個新的字典條目 (sn),但在內部我們需要將兩個原始術語存儲爲拼寫更正建議 (sun,sin)

備註 2:有四種不同的比較對類型:

dictionary entry==input entry,
delete(dictionary entry,p1)==input entry  // 預處理
dictionary entry==delete(input entry,p2)
delete(dictionary entry,p1)==delete(input entry,p2)

僅替換和轉置需要最後一個比較類型。

但是我們需要檢查建議的字典術語是否真的是輸入術語的替換或相鄰轉置,以防止更高編輯距離的誤報(bank==bnak 和bank==bink,但是bank!=kanb 和bank!= xban 和銀行!=baxn)。

備註 3:我們使用的是搜索引擎索引本身,而不是專用的拼寫字典。

這有幾個好處:

它是動態更新的。 每個新索引的詞,其頻率超過某個閾值,也會自動用於拼寫校正。

由於我們無論如何都需要搜索索引,因此拼寫更正幾乎不需要額外的成本。

當索引拼寫錯誤的術語時(即未在索引中標記爲正確),我們會即時進行拼寫更正,併爲正確的術語索引頁面。

備註 4:我們以類似的方式實現了查詢建議/完成。

這是首先防止拼寫錯誤的好方法。

每個新索引的單詞,其頻率超過某個閾值,都被存儲爲對其所有前綴的建議(如果它們尚不存在,則會在索引中創建)。

由於我們提供了即時搜索功能,因此查找建議也幾乎不需要額外費用。 多個術語按存儲在索引中的結果數排序。

推理

SymSpell 算法利用了兩個術語之間的編輯距離對稱的事實:

我們可以生成與查詢詞條編輯距離 < 2 的所有詞條(試圖扭轉查詢詞條錯誤)並根據所有字典詞條檢查它們,

我們可以生成與每個字典術語的編輯距離 < 2 的所有術語(嘗試創建查詢術語錯誤),並根據它們檢查查詢術語。

通過將正確的字典術語轉換爲錯誤的字符串,並將錯誤的輸入術語轉換爲正確的字符串,我們可以將兩者結合起來並在中間相遇。

因爲在字典上添加一個字符相當於從輸入字符串中刪除一個字符,反之亦然,所以我們可以在兩邊都限制轉換爲僅刪除。

例子

這一段讀的讓小明有些雲裏霧裏,於是這裏舉一個例子,便於大家理解。

比如用戶輸入的是:goox

正確的詞庫只有:good

對應的編輯距離爲 1。

那麼通過刪除,good 預處理存儲就會變成:{good = good, ood=good; god=good; goo=good;}

判斷用戶輸入的時候:

(1)goox 不存在

(2)對 goox 進行刪除操作

oox gox goo

可以找到 goo 對應的是 good。

讀到這裏,小夥伴肯定已經發現了這個算法的巧妙之處。

[圖片上傳失敗...(image-c09f0c-1627187608521)]

通過對原有字典的刪除處理,實際上基本已經達到了原來算法中 刪除+添加+修改 的效果。

編輯距離

我們正在使用變體 3,因爲僅刪除轉換與語言無關,並且成本低三個數量級。

速度從何而來?

預計算,即生成可能的拼寫錯誤變體(僅刪除)並在索引時存儲它們是第一個前提條件。

通過使用平均搜索時間複雜度爲 O(1) 的哈希表在搜索時進行快速索引訪問是第二個前提條件。

但是,只有在此之上的對稱刪除拼寫糾正才能將這種 O(1) 速度帶到拼寫檢查中,因爲它可以極大地減少要預先計算(生成和索引)的拼寫錯誤候選者的數量。

將預計算應用於 Norvig 的方法是不可行的,因爲預計算所有可能的刪除 + 轉置 + 替換 + 插入所有術語的候選將導致巨大的時間和空間消耗。

計算複雜度

SymSpell 算法是常數時間(O(1) 時間),即獨立於字典大小(但取決於平均術語長度和最大編輯距離),因爲我們的索引基於具有平均搜索時間複雜度的哈希表 的 O(1)。

代碼實現

光說不練假把式。

看完之後,小明就連夜把自己原來的算法實現進行了調整。

詞庫預處理

以前針對下面的詞庫:

the,23135851162
of,13151942776
and,12997637966

只需要構建一個單詞,和對應的頻率 freqMap 即可。

現在我們需要對單詞進行編輯距離=1的刪除操作:

/**
 * 對稱刪除拼寫糾正詞庫
 * <p>
 * 1. 如果單詞長度小於1,則不作處理。
 * 2. 對單詞的長度減去1,依次移除一個字母,把餘下的部分作爲 key,
 * value 是一個原始的 CandidateDto 列表。
 * 3. 如何去重比較優雅?
 * 4. 如何排序比較優雅?
 * <p>
 * 如果不考慮自定義詞庫,是可以直接把詞庫預處理好的,但是隻是減少了初始化的時間,意義不大。
 *
 * @param freqMap    頻率 Map
 * @param resultsMap 結果 map
 * @since 0.1.0
 */
static synchronized void initSymSpellMap(Map<String, Long> freqMap,
                                         Map<String, List<CandidateDto>> resultsMap) {
    if (MapUtil.isEmpty(freqMap)) {
        return;
    }

    for (Map.Entry<String, Long> entry : freqMap.entrySet()) {
        String key = entry.getKey();
        Long count = entry.getValue();
        // 長度判斷
        int len = key.length();
        // 後續可以根據編輯距離進行調整
        if (len <= 1) {
            continue;
        }
        char[] chars = key.toCharArray();
        Set<String> tempSet = new HashSet<>(chars.length);
        for (int i = 0; i < chars.length; i++) {
            String text = buildString(chars, i);
            // 跳過重複的單詞
            if (tempSet.contains(text)) {
                continue;
            }
            List<CandidateDto> candidateDtos = resultsMap.get(text);
            if (candidateDtos == null) {
                candidateDtos = new ArrayList<>();
            }
            // 把原始的 key 作爲值
            candidateDtos.add(new CandidateDto(key, count));
            // 刪減後的文本作爲 key
            resultsMap.put(text, candidateDtos);
            tempSet.add(text);
        }
    }
    // 統一排序
    for (Map.Entry<String, List<CandidateDto>> entry : resultsMap.entrySet()) {
        String key = entry.getKey();
        List<CandidateDto> list = entry.getValue();
        if (list.size() > 1) {
            // 排序
            Collections.sort(list);
            resultsMap.put(key, list);
        }
    }
}

其中構建刪除字符串的實現比較簡單:

/**
 * 構建字符串
 *
 * @param chars        字符數組
 * @param excludeIndex 排除的索引
 * @return 字符串
 * @since 0.1.0
 */
public static String buildString(char[] chars, int excludeIndex) {
    StringBuilder stringBuilder = new StringBuilder(chars.length - 1);
    for (int i = 0; i < chars.length; i++) {
        if (i == excludeIndex) {
            continue;
        }
        stringBuilder.append(chars[i]);
    }
    return stringBuilder.toString();
}

這裏有幾個點需要注意下:

(1)單詞如果小於等於編輯距離,則不需要刪除。因爲就刪除沒了==

(2)要注意跳過重複的詞。比如 good,刪除的結果會有 2 個 god。

(3)統一排序,這個還是有必要的,可以提升實時查詢時的性能。

當然,小明心想,如果詞庫是固定的,可以直接把預處理的詞庫也處理好,大大提升加載速度。

不過這個聊勝於無,影響不是很大。

核心算法的調整

核心算法獲取備選列表,直接按照給定的 4 種情況查詢即可。

freqData 正確字典的頻率信息。

symSpellData 刪除後字典的信息。

/**
 * dictionary entry==input entry,
 * delete(dictionary entry,p1)==input entry  // 預處理
 * dictionary entry==delete(input entry,p2)
 * delete(dictionary entry,p1)==delete(input entry,p2)
 *
 * 爲了性能考慮,這裏做快速返回。後期可以考慮可以配置,暫時不做處理。
 *
 * @param word    單詞
 * @param context 上下文
 * @return 結果
 * @since 0.1.0
 */
@Override
protected List<CandidateDto> getAllCandidateList(String word, IWordCheckerContext context) {
    IWordData wordData = context.wordData();
    Map<String, Long> freqData = wordData.freqData();
    Map<String, List<CandidateDto>> symSpellData = wordData.symSpellData();

    //0. 原始字典包含
    if (freqData.containsKey(word)) {
        // 返回原始信息
        CandidateDto dto = CandidateDto.of(word, freqData.get(word));
        return Collections.singletonList(dto);
    }
    // 如果長度爲1
    if(word.length() <= 1) {
        CandidateDto dtoA = CandidateDto.of("a", 9081174698L);
        CandidateDto dtoI = CandidateDto.of("i", 3086225277L);
        return Arrays.asList(dtoA, dtoI);
    }

    List<CandidateDto> resultList = new ArrayList<>();
    //1. 對稱刪減包含輸入的單詞
    List<CandidateDto> symSpellList = symSpellData.get(word);
    if(CollectionUtil.isNotEmpty(symSpellList)) {
        resultList.addAll(symSpellList);
    }
    // 所有刪減後的數組
    Set<String> subWordSet = InnerWordDataUtil.buildStringSet(word.toCharArray());
    //2. 輸入單詞刪減後,在原始字典中存在。
    for(String subWord : subWordSet) {
        if(freqData.containsKey(subWord)) {
            CandidateDto dto = CandidateDto.of(subWord, freqData.get(subWord));
            resultList.add(dto);
        }
    }
    //3. 輸入單詞刪減後,在對稱刪除字典存在。
    for(String subWord : subWordSet) {
        if(symSpellData.containsKey(subWord)) {
            resultList.addAll(symSpellData.get(subWord));
        }
    }
    if(CollectionUtil.isNotEmpty(resultList)) {
        return resultList;
    }

    //4. 執行替換和修改(遞歸調用一次)甚至也可以不做處理。
    // 爲保證編輯距離爲1,只考慮原始字典
    List<String> edits = edits(word);
    for(String edit : edits) {
        if(freqData.containsKey(edit)) {
            CandidateDto dto = CandidateDto.of(edit, freqData.get(edit));
            resultList.add(dto);
        }
    }
    return resultList;
}

有下面幾點需要注意:

(1)如果原字典已經包含,則直接返回。說明是拼寫正確。

(2)如果長度爲1,則固定返回 I、a 即可。

(3)其他每一種場景,如果處於性能考慮的話,也可以快速返回。

你的服務器性能永遠不可能提升 1000X 的配置,但是算法可以,但是工資不可以。

小結

好的算法,對程序的提升是非常顯著的。

以後還是要持續學習。

我是老馬,期待與你的下次重逢。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章