從零開(kāi)始實(shí)現(xiàn)中文分詞器(1)

前言

前陣子面試的到時(shí)候有個(gè)面試官問(wèn)到,你知不知道分詞器怎么實(shí)現(xiàn)的?當(dāng)時(shí)老實(shí)回答,確實(shí)不知道。隨后面試官就說(shuō)有空的時(shí)候可以看看。

不過(guò)看歸看,總感覺(jué)如果不自己實(shí)現(xiàn)一下的話還是很難達(dá)到掌握的程度,于是有個(gè)想法,從零開(kāi)始實(shí)現(xiàn)一下分詞器吧。

分詞器介紹

一直以來(lái)中文分詞都是比較頭痛的事情,因?yàn)椴幌裼⒄Z(yǔ)那樣,詞語(yǔ)之間有空格隔開(kāi)。(其實(shí)英文也有詞組分割問(wèn)題)

最早的中文分詞方法就是查字典:把一個(gè)句子從左到右掃描一遍,遇到字典里有的詞就標(biāo)識(shí)出來(lái),遇到復(fù)合詞(比如“上海大學(xué)”)就找最長(zhǎng)的詞匹配,遇到不認(rèn)識(shí)的字串分割成單詞。

比如(從左到右遍歷):

今天天氣很好(首先分隔符在 "今") ->
今/天天氣很好(繼續(xù)遍歷發(fā)現(xiàn) "今天"詞典中有,就把分隔符往右挪) -> 
今天/天氣很好(詞典不存在“今天天”這個(gè)詞,于是在第二個(gè)“天”后面新設(shè)一個(gè)分割符) ->
今天/天/氣很好(如此類(lèi)推) ->
... ->
今天/天氣/很好(完成了分詞)

存在問(wèn)題:

  1. 復(fù)雜度太高
  2. 二義性問(wèn)題("發(fā)展中國(guó)家",應(yīng)該分詞成"發(fā)展/中/國(guó)家",而采用從左到右查字典會(huì)被分割成"發(fā)展/中國(guó)/家")

隨后有一些學(xué)者開(kāi)始注意到統(tǒng)計(jì)信息的作用,假定一個(gè)句子S可以有幾種分詞方法(假定為3種):

A1,A2,A3,...,Ak
B1,B2,B3,...,Bm
C1,C2,C3,...,Cn

其中Ai, Bi, Ci都是漢語(yǔ)中的詞,那么從統(tǒng)計(jì)學(xué)的角度看,最好的分詞方法那么這個(gè)句子出現(xiàn)的概率應(yīng)該是最大的。即如果A1,A2,A3,...,Ak是最好的分詞方法,那么其概率應(yīng)該滿足:

P(A1,A2,A3,...,Ak) > P(B1,B2,B3,...,Bm)` 且`P(A1,A2,A3,...,Ak) > P(C1,C2,C3,...,Cn)

用窮舉法計(jì)算概率計(jì)算量是相當(dāng)巨大的,可以使用動(dòng)態(tài)規(guī)劃進(jìn)行優(yōu)化,算法過(guò)程大致如下:

image.png

通過(guò)統(tǒng)計(jì)模型能夠很好地解決分詞的二義性問(wèn)題(也叫歧義性)。

分詞器實(shí)現(xiàn)思路

上面已經(jīng)介紹了中文分詞的原理:根據(jù)已有詞典形成各種組合使得句子概率最大化但是具體怎么實(shí)現(xiàn)的還是不清楚。下面就從兩個(gè)問(wèn)題入手,逐漸認(rèn)識(shí)分詞器。

  1. 詞典加載進(jìn)內(nèi)存是怎么樣的,用什么數(shù)據(jù)結(jié)構(gòu)?
  2. 從輸入一個(gè)文本到輸出分詞結(jié)果的完整步驟是怎么樣的?

先來(lái)看看詞典,下面是github當(dāng)前比較熱門(mén)的兩個(gè)分詞器(前者是ES的中文分詞插件,后者是一個(gè)python中文分詞模塊)中的部分詞典內(nèi)容。

ik詞典

一一列舉
一一對(duì)應(yīng)
一一道來(lái)
一丁
一丁不識(shí)
一丁點(diǎn)
一丁點(diǎn)兒
一七八不
...

jieba詞典

# 詞語(yǔ) 詞頻 詞性
AT&T 3 nz
B超 3 n
c# 3 nz
C# 3 nz
c++ 3 nz
C++ 3 nz
T恤 4 n
A座 3 n
A股 3 n
A型 3 n
...

從上面摘抄的部分詞典中可以看出,詞典基本是按字典順序排,即相鄰的詞很可能有相同的前綴,聰明的同學(xué)可能很快就get到,這用前綴樹(shù)(Trie)來(lái)存儲(chǔ)不就很合適嗎?(如果你以前不知道前綴樹(shù)是什么東西,那看下面就知道了)

Trie樹(shù),又稱(chēng)字典樹(shù),單詞查找樹(shù)或者前綴樹(shù),是一種用于快速檢索的多叉樹(shù)結(jié)構(gòu),如英文字母的字典樹(shù)是一個(gè)26叉樹(shù),數(shù)字的字典樹(shù)是一個(gè)10叉樹(shù)。

一個(gè)節(jié)點(diǎn)的所有子孫都有相同的前綴(prefix),Trie樹(shù)利用字符串的公共前綴來(lái)節(jié)約存儲(chǔ)空間。如下圖所示,該Trie樹(shù)用11個(gè)節(jié)點(diǎn)保存了8個(gè)字符串tea,ted,ten,to,A,i,in,inn。

img

圖片來(lái)源:

https://www.cnblogs.com/rush/archive/2012/12/30/2839996.html(這篇文章也解釋了怎么實(shí)現(xiàn)前綴樹(shù))

接下來(lái)會(huì)先從Trie的實(shí)現(xiàn)開(kāi)始逐步介紹怎么實(shí)現(xiàn)一個(gè)分詞器,會(huì)比較啰嗦,如果沒(méi)耐心的話可以直接看上面的文章的TrieTree的實(shí)現(xiàn)

相信通過(guò)上面的圖你可以清楚前綴樹(shù)的物理結(jié)構(gòu),那么我們就先把前綴樹(shù)需要的一些屬性列出來(lái):

Trie樹(shù)所需要的屬性

這里順便說(shuō)一下java的基礎(chǔ)知識(shí): Java中char的使用的是UTF-8,所以任意一個(gè)中文其實(shí)是可以用一個(gè)單位的char來(lái)存儲(chǔ)的。

首先我們從一個(gè)樹(shù)節(jié)點(diǎn)入手,一個(gè)節(jié)點(diǎn)中必須會(huì)有對(duì)應(yīng)的 value 吧,然后還可能有下層子節(jié)點(diǎn) children ,由于子節(jié)點(diǎn)可能不止一個(gè),為了方便就使用HashMap來(lái)存儲(chǔ)所有的子節(jié)點(diǎn):

// 最初可以確定有以下兩個(gè)屬性
char value;
Map<Character, TrieNode> childrenMap;

接下來(lái)思考,是不是缺少了點(diǎn)什么?假如給定任意一個(gè)節(jié)點(diǎn),那如何確定怎么到達(dá)這個(gè)節(jié)點(diǎn)的路徑呢(即這個(gè)節(jié)點(diǎn)的前綴是什么)?

于是我們?cè)僖?parent 屬性來(lái)存儲(chǔ)當(dāng)前節(jié)點(diǎn)的父節(jié)點(diǎn)的引用,emmm,順便再引入當(dāng)前節(jié)點(diǎn)的深度 deep

如果這里想不到引入這兩個(gè)屬性的話其實(shí)后續(xù)遇到打印節(jié)點(diǎn)的方法時(shí)也會(huì)想到

TrieNode parent;
int deep;

Trie樹(shù)的構(gòu)造方法

主要就是考慮父節(jié)點(diǎn)和深度的計(jì)算(這里把上面的屬性也一起列出來(lái))

public class TrieNode {

    char value;
    Map<Character, TrieNode> childrenMap;
    TrieNode parent;
    int deep;


    public TrieNode(TrieNode parent, char value) {
        this.parent = parent;
        this.value = value;
        // 假定根節(jié)點(diǎn)不存儲(chǔ)有意義的值,深度為0
        if (parent == null)
            deep = 0;
        else
            deep = parent.deep + 1;
    }
}

Trie樹(shù)的詞典加載方法

構(gòu)造完Trie樹(shù)之后,回到最初的需求,我們是要做一個(gè)能裝載詞典的數(shù)據(jù)結(jié)構(gòu),那么首要的功能當(dāng)然是加載詞典。

  1. 為了處理方便,把每個(gè)傳入的字符串(詞語(yǔ))轉(zhuǎn)化成隊(duì)列(這樣能夠減少subString的開(kāi)銷(xiāo))
  2. 加載一個(gè)詞其實(shí)是簡(jiǎn)單的遞歸創(chuàng)建過(guò)程:第一個(gè)字符是否已經(jīng)存在?若存在則直接進(jìn)入,若不存在則先創(chuàng)建再進(jìn)入,然后繼續(xù)判斷第二個(gè)字符串是否已經(jīng)在第一個(gè)字符串的 childrenMap 里面,如果不存在則創(chuàng)建...按照這種流程遞歸下去。(不用考慮溢出問(wèn)題,一般單個(gè)詞不會(huì)很長(zhǎng))
    /**
     * 加載字符
     * */
    public void load(Queue<Character> wordQueue) {
        if (wordQueue.isEmpty())
            return;
        // 彈出隊(duì)列中第一個(gè)字符
        char c = wordQueue.poll();
        if (childrenMap == null)
            childrenMap = new HashMap<>();
        TrieNode node = childrenMap.computeIfAbsent(c, s -> new TrieNode(this, c));
        // 如果隊(duì)列非空,繼續(xù)遞歸加載剩余字符
        if (!wordQueue.isEmpty())
            node.load(wordQueue);
    }

    /**
     * 將字符串轉(zhuǎn)化成字符隊(duì)列的靜態(tài)方法
     * */
    public static Queue<Character> string2Queue(String str) {
        Queue<Character> queue = new LinkedList<>();
        for (char c:str.toCharArray()) {
            queue.add(c);
        }
        return queue;
    }

TrieNode 類(lèi)中加入上面兩個(gè)方法,基本的詞典前綴樹(shù)就完成了,下面測(cè)試一下詞典加載:

//下面代碼在 public static void main(String[] args) 方法中執(zhí)行

// 初始化樹(shù)根節(jié)點(diǎn),置parent=null, value=' '
TrieNode node = new TrieNode(null, ' ');
node.load(TrieNode.string2Queue("北京大學(xué)"));
node.load(TrieNode.string2Queue("北京交通大學(xué)"));

進(jìn)入DEBUG模式

image.png

可以看到,內(nèi)存中兩個(gè)詞共用了"北京"前綴,且深度屬性也正常運(yùn)作

Trie樹(shù)的匹配方法

既然上面我們已經(jīng)完成了詞典的加載,接下來(lái)就應(yīng)該做詞的匹配了:

給定一串文本,如何判斷哪些詞是詞典中存在的?

再簡(jiǎn)化下問(wèn)題:給定一串文本,如何識(shí)別出文本中存在于詞典中的詞?

先來(lái)模擬一下匹配流程:

比如,之前的例子中,加載了"北京大學(xué)"和"北京交通大學(xué)"兩個(gè)詞,當(dāng)我輸入"去北京大學(xué)玩"這樣一個(gè)文本的時(shí)候,應(yīng)該要識(shí)別出其中的"北京大學(xué)"

最簡(jiǎn)單的做法其實(shí)就是遍歷:"去北京大學(xué)玩","北京大學(xué)玩","京大學(xué)玩","大學(xué)玩"...看下有沒(méi)有符合前綴的。如果有符合前綴的就開(kāi)始遍歷。這里只有"北京大學(xué)玩"是符合前綴,然后開(kāi)始遍歷這個(gè)子字符串,最終遍歷到"學(xué)"的時(shí)候發(fā)現(xiàn)存在一個(gè)滿足的詞語(yǔ)"北京大學(xué)"。

這里會(huì)發(fā)現(xiàn)一個(gè)問(wèn)題:怎么在遍歷到"學(xué)"的時(shí)候能夠知道匹配上了一個(gè)詞?這時(shí)候其實(shí)在 TrieNode 類(lèi)中補(bǔ)充一個(gè)標(biāo)記屬性即可

// 判斷到當(dāng)前節(jié)點(diǎn)是否為一個(gè)詞
boolean isWord = false;

并且在加載詞典的時(shí)候補(bǔ)充幾行(12~14)

    public void load(Queue<Character> wordQueue) {
        if (wordQueue.isEmpty())
            return;
        // 彈出隊(duì)列中第一個(gè)字符
        char c = wordQueue.poll();
        if (childrenMap == null)
            childrenMap = new HashMap<>();
        TrieNode node = childrenMap.computeIfAbsent(c, s -> new TrieNode(this, c));
        // 如果隊(duì)列非空,繼續(xù)遞歸加載剩余字符
        if (!wordQueue.isEmpty())
            node.load(wordQueue);
        else
            // 隊(duì)列為空了,說(shuō)明當(dāng)前節(jié)點(diǎn)是最后一個(gè)字符,剛好成一個(gè)詞
            node.isWord = true;
    }

梳理清楚之后,就可以開(kāi)始寫(xiě)對(duì)應(yīng)的匹配方法了

    public static void match(TrieNode node, String word) {
        if (word == null || word.length() == 0)
            return;
        System.out.println(String.format("開(kāi)始對(duì)\"%s\"進(jìn)行匹配:", word));
        // 對(duì)輸入字符串的所有子串均進(jìn)行前綴匹配
        for (int i = 0; i < word.length(); i++)
            match(node, word, i);
    }

    private static void match(TrieNode node, String word, int index) {
        // 要考慮邊界情況
        if (index >= word.length() || node.childrenMap == null)
            return;
        // 取出當(dāng)前位置的字符進(jìn)行匹配
        char c = word.charAt(index);
        TrieNode child = node.childrenMap.get(c);
        // 子節(jié)點(diǎn)存在對(duì)應(yīng)字符才能往下遍歷/判斷
        if (child != null) {
            if (child.isWord) {
                char[] w = new char[child.deep];
                TrieNode n = child;
                while (n != null && n.deep != 0) {
                    w[n.deep - 1] = n.value;
                    n = n.parent;
                }
                // 當(dāng)找到一個(gè)匹配的詞語(yǔ)時(shí)直接打印
                System.out.println(String.valueOf(w));
            }
            match(child, word, index + 1);
        }
    }

回到main方法,在原來(lái)的基礎(chǔ)上多增加match的測(cè)試:

TrieNode node = new TrieNode(null, ' ');
node.load(TrieNode.string2Queue("北京大學(xué)"));
node.load(TrieNode.string2Queue("北京交通大學(xué)"));

TrieNode.match(node, "去北京大學(xué)玩");
TrieNode.match(node, "去北京交通大學(xué)玩");
TrieNode.match(node, "去北京交通大學(xué)玩北京大學(xué)");
// 輸出:
開(kāi)始對(duì)"去北京大學(xué)玩"進(jìn)行匹配:
北京大學(xué)
開(kāi)始對(duì)"去北京交通大學(xué)玩"進(jìn)行匹配:
北京交通大學(xué)
開(kāi)始對(duì)"去北京交通大學(xué)玩北京大學(xué)"進(jìn)行匹配:
北京交通大學(xué)
北京大學(xué)

今天就先到此為止了,后續(xù)文章再繼續(xù)深入

參考

結(jié)巴分詞

IK分詞

中文分詞原理理解+jieba分詞詳解(二)

<<數(shù)學(xué)之美>>

Trie樹(shù)和Ternary Search樹(shù)的學(xué)習(xí)總結(jié)

完整TrieNode類(lèi)實(shí)現(xiàn)

此處代碼只是到當(dāng)前文章為止所介紹到內(nèi)容的實(shí)現(xiàn),后續(xù)隨著分詞器的逐步完善會(huì)不斷修改。

package edqi.lucene.util;


import java.util.HashMap;
import java.util.LinkedList;
import java.util.Map;
import java.util.Queue;

/**
 * @Description
 * @auther edqi
 * @create 2020-05-21 23:33
 */

public class TrieNode {

    char value;
    Map<Character, TrieNode> childrenMap;
    TrieNode parent;
    int deep;
    boolean isWord = false;


    public TrieNode(TrieNode parent, char value) {
        this.parent = parent;
        this.value = value;
        // 假定根節(jié)點(diǎn)不存儲(chǔ)有意義的值,深度為0
        if (parent == null)
            deep = 0;
        else
            deep = parent.deep + 1;
    }

    /**
     * 將字符串轉(zhuǎn)化成字符隊(duì)列的靜態(tài)方法
     */
    public static Queue<Character> string2Queue(String str) {
        Queue<Character> queue = new LinkedList<>();
        for (char c : str.toCharArray()) {
            queue.add(c);
        }
        return queue;
    }

    /**
     * 加載字符
     */
    public void load(Queue<Character> wordQueue) {
        if (wordQueue.isEmpty())
            return;
        // 彈出隊(duì)列中第一個(gè)字符
        char c = wordQueue.poll();
        if (childrenMap == null)
            childrenMap = new HashMap<>();
        TrieNode node = childrenMap.computeIfAbsent(c, s -> new TrieNode(this, c));
        // 如果隊(duì)列非空,繼續(xù)遞歸加載剩余字符
        if (!wordQueue.isEmpty())
            node.load(wordQueue);
        else
            // 隊(duì)列為空了,說(shuō)明當(dāng)前節(jié)點(diǎn)是最后一個(gè)字符,剛好成一個(gè)詞
            node.isWord = true;
    }


    public static void match(TrieNode node, String word) {
        if (word == null || word.length() == 0)
            return;
        System.out.println(String.format("開(kāi)始對(duì)\"%s\"進(jìn)行匹配:", word));
        // 對(duì)輸入字符串的所有子串均進(jìn)行前綴匹配
        for (int i = 0; i < word.length(); i++)
            match(node, word, i);
    }

    private static void match(TrieNode node, String word, int index) {
        // 要考慮邊界情況
        if (index >= word.length() || node.childrenMap == null)
            return;
        // 取出當(dāng)前位置的字符進(jìn)行匹配
        char c = word.charAt(index);
        TrieNode child = node.childrenMap.get(c);
        // 子節(jié)點(diǎn)存在對(duì)應(yīng)字符才能往下遍歷/判斷
        if (child != null) {
            if (child.isWord) {
                char[] w = new char[child.deep];
                TrieNode n = child;
                while (n != null && n.deep != 0) {
                    w[n.deep - 1] = n.value;
                    n = n.parent;
                }
                // 當(dāng)找到一個(gè)匹配的詞語(yǔ)時(shí)直接打印
                System.out.println(String.valueOf(w));
            }
            match(child, word, index + 1);
        }
    }


}
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,606評(píng)論 6 533
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡,警方通過(guò)查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,582評(píng)論 3 418
  • 文/潘曉璐 我一進(jìn)店門(mén),熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái),“玉大人,你說(shuō)我怎么就攤上這事。” “怎么了?”我有些...
    開(kāi)封第一講書(shū)人閱讀 176,540評(píng)論 0 376
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)。 經(jīng)常有香客問(wèn)我,道長(zhǎng),這世上最難降的妖魔是什么? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 63,028評(píng)論 1 314
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 71,801評(píng)論 6 410
  • 文/花漫 我一把揭開(kāi)白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開(kāi)封第一講書(shū)人閱讀 55,223評(píng)論 1 324
  • 那天,我揣著相機(jī)與錄音,去河邊找鬼。 笑死,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,294評(píng)論 3 442
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來(lái)了?” 一聲冷哼從身側(cè)響起,我...
    開(kāi)封第一講書(shū)人閱讀 42,442評(píng)論 0 289
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒(méi)想到半個(gè)月后,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 48,976評(píng)論 1 335
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 40,800評(píng)論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 42,996評(píng)論 1 369
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,543評(píng)論 5 360
  • 正文 年R本政府宣布,位于F島的核電站,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 44,233評(píng)論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 34,662評(píng)論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 35,926評(píng)論 1 286
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 51,702評(píng)論 3 392
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 47,991評(píng)論 2 374