java 實現中英文拼寫檢查和錯誤糾正?可我只會寫 CRUD 啊!

老馬嘯西風發表於2021-07-21

簡單的需求

臨近下班,小明忙完了今天的任務,正準備下班回家。

一條訊息閃爍了起來。

“最近發現公眾號的拼寫檢查功能不錯,幫助使用者發現錯別字,體驗不錯。給我們系統也做一個。”

看著這條訊息,小明在內心默默問候了一句。

“我 TND 的會做這個,就直接去人家總部上班了,在這受你的氣。”

“好的”,小明回覆到,“我先看看”

今天,天王老子來了我也得下班,耶穌也留不住。

小明想著,就回家了。

耶穌也留不住

冷靜分析

說到這個拼寫檢查,小明其實是知道的。

自己沒吃過豬肉,還是見過豬跑的。

平時看過一些公眾號大佬分享,說是公眾號推出了拼寫檢查功能,以後再也不會有錯別字了。

後來,小明還是在他們的文章中看到了不少錯別字。後來,就沒有後來了。

為什麼不去問一問萬能的 github 呢?

小明開啟了 github 發現好像沒有成熟的 java 相關的開源專案,有的幾顆星,用起來不太放心。

估計 NLP 是搞 python 的比較多吧,java 實現中英文拼寫檢查和錯誤糾正?可我只會寫 CRUD 啊!

小明默默地點起了一根華子……

窗外的夜色如水,不禁陷入了沉思,我來自何方?去往何處?人生的意義又是什麼?

哲學三問

尚有餘熱的菸灰落在了小明某東買的拖鞋上,把他腦海中脫韁的野馬燙的一機靈。

沒有任何思路,沒有任何頭緒,還是先洗洗睡吧。

那一夜,小明做了一個長長的美夢。夢裡沒有任何的錯別字,所有的字句都坐落在正確的位置上……

轉機

第二天,小明開啟了搜尋框,輸入 spelling correct。

可喜的是,找到了一篇英文拼寫糾正演算法講解。

吾嘗終日而思矣,不如須臾之所學也。小明嘆了一句,就看了起來。

演算法思路

英文單詞主要有 26 個英文字母組成,所以拼寫的時候可能出現錯誤。

首先可以獲取正確的英文單詞,節選如下:

apple,16192
applecart,41
applecarts,1
appledrain,1
appledrains,1
applejack,571
applejacks,4
appleringie,1
appleringies,1
apples,5914
applesauce,378
applesauces,1
applet,2
複製程式碼

每一行用逗號分隔,後面是這個單詞出現的頻率。

以使用者輸入 appl 的為例,如果這個單詞不存在,則可以對其進行 insert/delete/replace 等操作,找到最接近的單詞。(本質上就是找到編輯距離最小的單詞)

如果輸入的單詞存在,則說明正確,不用處理。

詞庫的獲取

那麼英文詞庫去哪裡獲得呢?

小明想了想,於是去各個地方查了一圈,最後找到了一個比較完善的英文單詞頻率詞庫,共計 27W+ 的單詞。

節選如下:

aa,1831
aah,45774
aahed,1
aahing,30
aahs,23
...
zythums,1
zyzzyva,2
zyzzyvas,1
zzz,76
zzzs,2
複製程式碼

在這裡插入圖片描述

核心程式碼

獲取使用者當前輸入的所有可能情況,核心程式碼如下:

/**
 * 構建出當前單詞的所有可能錯誤情況
 *
 * @param word 輸入單詞
 * @return 返回結果
 * @since 0.0.1
 * @author 老馬嘯西風
 */
private List<String> edits(String word) {
    List<String> result = new LinkedList<>();
    for (int i = 0; i < word.length(); ++i) {
        result.add(word.substring(0, i) + word.substring(i + 1));
    }
    for (int i = 0; i < word.length() - 1; ++i) {
        result.add(word.substring(0, i) + word.substring(i + 1, i + 2) + word.substring(i, i + 1) + word.substring(i + 2));
    }
    for (int i = 0; i < word.length(); ++i) {
        for (char c = 'a'; c <= 'z'; ++c) {
            result.add(word.substring(0, i) + c + word.substring(i + 1));
        }
    }
    for (int i = 0; i <= word.length(); ++i) {
        for (char c = 'a'; c <= 'z'; ++c) {
            result.add(word.substring(0, i) + c + word.substring(i));
        }
    }
    return result;
}
複製程式碼

然後和詞庫中正確的單詞進行對比:

List<String> options = edits(formatWord);
List<CandidateDto> candidateDtos = new LinkedList<>();
for (String option : options) {
    if (wordDataMap.containsKey(option)) {
        CandidateDto dto = CandidateDto.builder()
                .word(option).count(wordDataMap.get(option)).build();
        candidateDtos.add(dto);
    }
}
複製程式碼

最後返回的結果,需要根據單詞出現的頻率進行對比,整體來說還是比較簡單的。

中文拼寫

失之毫釐

中文的拼寫初看起來和英文差不多,但是中文有個很特殊的地方。

因為所有的漢字拼寫本身都是固定的,使用者在輸入的時候不存在錯字,只存在別字。

單獨說一個字是別字是毫無意義的,必須要有詞,或者上下文。

這一點就讓糾正的難度上升了很多。

小明無奈的搖了搖頭,中華文化,博大精深。

演算法思路

針對中文別字的糾正,方式比較多:

(1)困惑集。

比如常用的別字,萬變不離其宗 錯寫為 萬變不離其中

(2)N-Gram

也就是一次字對應的上下文,使用比較廣泛的是 2-gram。對應的語料,sougou 實驗室是有的。

也就是當第一個詞固定,第二次出現的會有對應的概率,概率越高的,肯定越可能是使用者本意想要輸入的。

比如 跑的飛快,實際上 跑地飛快 可能才是正確的。

糾錯

當然,中文還有一個難點就是,無法直接通過 insert/delete/replace 把一個字變成另一個字。

不過類似的,還是有許多方法:

(1)同音字/諧音字

(2)形近字

(3)同義詞

(4)字詞亂序、字詞增刪

在這裡插入圖片描述

演算法實現

迫於實現的難度,小明選擇了最簡單的困惑集。

首先找到常見別字的字典,節選如下:

一丘之鶴 一丘之貉
一仍舊慣 一仍舊貫
一付中藥 一服中藥
...
黯然消魂 黯然銷魂
鼎立相助 鼎力相助
鼓躁而進 鼓譟而進
龍盤虎據 龍盤虎踞
複製程式碼

前面的是別字,後面的是正確用法。

以別字作為字典,然後對中文文字進行 fast-forward 分詞,獲取對應的正確形式。

當然一開始我們可以簡單點,讓使用者固定輸入一個片語,實現就是直接解析對應的 map 即可

public List<String> correctList(String word, int limit, IWordCheckerContext context) {
    final Map<String, List<String>> wordData = context.wordData().correctData();
    // 判斷是否錯誤
    if(isCorrect(word, context)) {
        return Collections.singletonList(word);
    }
    List<String> allList = wordData.get(word);
    final int minLimit = Math.min(allList.size(), limit);
    List<String> resultList = Guavas.newArrayList(minLimit);
    for(int i = 0; i < minLimit; i++) {
        resultList.add(allList.get(i));
    }
    return resultList;
}
複製程式碼

中英文混合長文字

演算法思路

實際的文章,一般是中英文混合的。

要想讓使用者使用起來更加方便,肯定不能每次只輸入一個片語。

那要怎麼辦呢?

答案是分詞,把輸入的句子,分詞為一個個詞。然後區分中英文,進行對應的處理。

關於分詞,推薦開源專案:

github.com/houbb/segme…

演算法實現

修正的核心演算法,可以複用中英文的實現。

@Override
public String correct(String text) {
    if(StringUtil.isEnglish(text)) {
        return text;
    }

    StringBuilder stringBuilder = new StringBuilder();
    final IWordCheckerContext zhContext = buildChineseContext();
    final IWordCheckerContext enContext = buildEnglishContext();

    // 第一步執行分詞
    List<String> segments = commonSegment.segment(text);
    // 全部為真,才認為是正確。
    for(String segment : segments) {
        // 如果是英文
        if(StringUtil.isEnglish(segment)) {
            String correct = enWordChecker.correct(segment, enContext);
            stringBuilder.append(correct);
        } else if(StringUtil.isChinese(segment)) {
            String correct = zhWordChecker.correct(segment, zhContext);
            stringBuilder.append(correct);
        } else {
            // 其他忽略
            stringBuilder.append(segment);
        }
    }

    return stringBuilder.toString();
}
複製程式碼

其中分詞的預設實現如下:

import com.github.houbb.heaven.util.util.CollectionUtil;
import com.github.houbb.nlp.common.segment.ICommonSegment;
import com.github.houbb.nlp.common.segment.impl.CommonSegments;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

/**
 * 預設的混合分詞,支援中文和英文。
 *
 * @author binbin.hou
 * @since 0.0.8
 */
public class DefaultSegment implements ICommonSegment {

    @Override
    public List<String> segment(String s) {
        //根據空格分隔
        List<String> strings = CommonSegments.defaults().segment(s);
        if(CollectionUtil.isEmpty(strings)) {
            return Collections.emptyList();
        }

        List<String> results = new ArrayList<>();
        ICommonSegment chineseSegment = InnerCommonSegments.defaultChinese();
        for(String text : strings) {
            // 進行中文分詞
            List<String> segments = chineseSegment.segment(text);

            results.addAll(segments);
        }


        return results;
    }

}
複製程式碼

首先是針對空格進行分詞,然後對中文以困惑集的別字做 fast-forward 分詞。

當然,這些說起來也不難。

真的實現起來還是比較麻煩的,小明把完整的實現已經開源:

github.com/houbb/word-…

覺得有幫助的小夥伴可以 fork/star 一波~

快速開始

word-checker 用於單詞拼寫檢查。支援英文單詞拼寫檢測,和中文拼寫檢測。

話不多說,我們來直接體驗一下這個工具類的使用體驗。

特性說明

  • 可以迅速判斷當前單詞是否拼寫錯誤

  • 可以返回最佳匹配結果

  • 可以返回糾正匹配列表,支援指定返回列表的大小

  • 錯誤提示支援 i18n

  • 支援大小寫、全形半形格式化處理

  • 支援自定義詞庫

  • 內建 27W+ 的英文詞庫

  • 支援基本的中文拼寫檢測

快速開始

maven 引入

<dependency>
     <groupId>com.github.houbb</groupId>
     <artifactId>word-checker</artifactId>
    <version>0.0.8</version>
</dependency>
複製程式碼

測試案例

會根據輸入,自動返回最佳糾正結果。

final String speling = "speling";
Assert.assertEquals("spelling", EnWordCheckers.correct(speling));
複製程式碼

核心 api 介紹

核心 api 在 EnWordCheckers 工具類下。

功能方法引數返回值備註
判斷單詞拼寫是否正確isCorrect(string)待檢測的單詞boolean
返回最佳糾正結果correct(string)待檢測的單詞String如果沒有找到可以糾正的單詞,則返回其本身
判斷單詞拼寫是否正確correctList(string)待檢測的單詞List返回所有匹配的糾正列表
判斷單詞拼寫是否正確correctList(string, int limit)待檢測的單詞, 返回列表的大小返回指定大小的的糾正列表列表大小 小於等於 limit

測試例子

參見 EnWordCheckerTest.java

是否拼寫正確

final String hello = "hello";
final String speling = "speling";
Assert.assertTrue(EnWordCheckers.isCorrect(hello));
Assert.assertFalse(EnWordCheckers.isCorrect(speling));
複製程式碼

返回最佳匹配結果

final String hello = "hello";
final String speling = "speling";
Assert.assertEquals("hello", EnWordCheckers.correct(hello));
Assert.assertEquals("spelling", EnWordCheckers.correct(speling));
複製程式碼

預設糾正匹配列表

final String word = "goox";
List<String> stringList = EnWordCheckers.correctList(word);
Assert.assertEquals("[good, goo, goon, goof, gook, goop, goos, gox, goog, gool, goor]", stringList.toString());
複製程式碼

指定糾正匹配列表大小

final String word = "goox";
final int limit = 2;
List<String> stringList = EnWordCheckers.correctList(word, limit);
Assert.assertEquals("[good, goo]", stringList.toString());
複製程式碼

中文拼寫糾正

核心 api

為降低學習成本,核心 api 和 ZhWordCheckers 中,和英文拼寫檢測保持一致。

是否拼寫正確

final String right = "正確";
final String error = "萬變不離其中";

Assert.assertTrue(ZhWordCheckers.isCorrect(right));
Assert.assertFalse(ZhWordCheckers.isCorrect(error));
複製程式碼

返回最佳匹配結果

final String right = "正確";
final String error = "萬變不離其中";

Assert.assertEquals("正確", ZhWordCheckers.correct(right));
Assert.assertEquals("萬變不離其宗", ZhWordCheckers.correct(error));
複製程式碼

預設糾正匹配列表

final String word = "萬變不離其中";

List<String> stringList = ZhWordCheckers.correctList(word);
Assert.assertEquals("[萬變不離其宗]", stringList.toString());
複製程式碼

指定糾正匹配列表大小

final String word = "萬變不離其中";
final int limit = 1;

List<String> stringList = ZhWordCheckers.correctList(word, limit);
Assert.assertEquals("[萬變不離其宗]", stringList.toString());
複製程式碼

長文字中英文混合

情景

實際拼寫糾正的話,最佳的使用體驗是使用者輸入一個長文字,並且可能是中英文混合的。

然後實現上述對應的功能。

核心方法

WordCheckers 工具類提供了長文字中英文混合的自動糾正功能。

功能方法引數返回值備註
文字拼寫是否正確isCorrect(string)待檢測的文字boolean全部正確,才會返回 true
返回最佳糾正結果correct(string)待檢測的單詞String如果沒有找到可以糾正的文字,則返回其本身
判斷文字拼寫是否正確correctMap(string)待檢測的單詞Map返回所有匹配的糾正列表
判斷文字拼寫是否正確correctMap(string, int limit)待檢測的文字, 返回列表的大小返回指定大小的的糾正列表列表大小 小於等於 limit

拼寫是否正確

final String hello = "hello 你好";
final String speling = "speling 你好 以毒功毒";
Assert.assertTrue(WordCheckers.isCorrect(hello));
Assert.assertFalse(WordCheckers.isCorrect(speling));
複製程式碼

返回最佳糾正結果

final String hello = "hello 你好";
final String speling = "speling 你好以毒功毒";
Assert.assertEquals("hello 你好", WordCheckers.correct(hello));
Assert.assertEquals("spelling 你好以毒攻毒", WordCheckers.correct(speling));
複製程式碼

判斷文字拼寫是否正確

每一個詞,對應的糾正結果。

final String hello = "hello 你好";
final String speling = "speling 你好以毒功毒";
Assert.assertEquals("{hello=[hello],  =[ ], 你=[你], 好=[好]}", WordCheckers.correctMap(hello).toString());
Assert.assertEquals("{ =[ ], speling=[spelling, spewing, sperling, seeling, spieling, spiling, speeling, speiling, spelding], 你=[你], 好=[好], 以毒功毒=[以毒攻毒]}", WordCheckers.correctMap(speling).toString());
複製程式碼

判斷文字拼寫是否正確

同上,指定最多返回的個數。

final String hello = "hello 你好";
final String speling = "speling 你好以毒功毒";

Assert.assertEquals("{hello=[hello],  =[ ], 你=[你], 好=[好]}", WordCheckers.correctMap(hello, 2).toString());
Assert.assertEquals("{ =[ ], speling=[spelling, spewing], 你=[你], 好=[好], 以毒功毒=[以毒攻毒]}", WordCheckers.correctMap(speling, 2).toString());
複製程式碼

格式化處理

有時候使用者的輸入是各式各樣的,本工具支援對於格式化的處理。

大小寫

大寫會被統一格式化為小寫。

final String word = "stRing";

Assert.assertTrue(EnWordCheckers.isCorrect(word));
複製程式碼

全形半形

全形會被統一格式化為半形。

final String word = "string";

Assert.assertTrue(EnWordCheckers.isCorrect(word));
複製程式碼

自定義英文詞庫

檔案配置

你可以在專案資源目錄建立檔案 resources/data/define_word_checker_en.txt

內容如下:

my-long-long-define-word,2
my-long-long-define-word-two
複製程式碼

不同的詞獨立一行。

每一行第一列代表單詞,第二列代表出現的次數,二者用逗號 , 隔開。

次數越大,在糾正的時候返回優先順序就越高,預設值為 1。

使用者自定義的詞庫優先順序高於系統內建詞庫。

測試程式碼

我們在指定了對應的單詞之後,拼寫檢測的時候就會生效。

final String word = "my-long-long-define-word";
final String word2 = "my-long-long-define-word-two";

Assert.assertTrue(EnWordCheckers.isCorrect(word));
Assert.assertTrue(EnWordCheckers.isCorrect(word2));
複製程式碼

自定義中文詞庫

檔案配置

你可以在專案資源目錄建立檔案 resources/data/define_word_checker_zh.txt

內容如下:

默守成規 墨守成規
複製程式碼

使用英文空格分隔,前面是錯誤,後面是正確。

小結

中英文拼寫的糾正一直是比較熱門,也比較難的話題。

近些年,因為 NLP 和人工智慧的進步,在商業上的應用也逐漸成功。

本次主要實現是基於傳統的演算法,核心在詞庫。

小明把完整的實現已經開源:

github.com/houbb/word-…

覺得有幫助的小夥伴歡迎 fork/star 一波~

後續

在經歷了幾天的努力之後,小明終於完成了一個最簡單的拼寫檢查工具。

“上次和我說的公眾號的拼寫檢查功能還要嗎?”

“不要了,你不說我都忘記了。”,產品顯得有些驚訝。"那個需求做不做也無所謂,我們最近擠壓了一堆業務需求,你優先看看。"

“……”

"我最近又看到 xxx 上有一個功能也非常不錯,你給我們系統也做一個。"

“……”

相關文章