前言
前陣子面試的到時(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)題:
- 復(fù)雜度太高
- 二義性問(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ò)程大致如下:
通過(guò)統(tǒng)計(jì)模型能夠很好地解決分詞的二義性問(wèn)題(也叫歧義性)。
分詞器實(shí)現(xiàn)思路
上面已經(jīng)介紹了中文分詞的原理:根據(jù)已有詞典形成各種組合使得句子概率最大化但是具體怎么實(shí)現(xiàn)的還是不清楚。下面就從兩個(gè)問(wèn)題入手,逐漸認(rèn)識(shí)分詞器。
- 詞典加載進(jìn)內(nèi)存是怎么樣的,用什么數(shù)據(jù)結(jié)構(gòu)?
- 從輸入一個(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)兒
一七八不
...
# 詞語(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。
圖片來(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)然是加載詞典。
- 為了處理方便,把每個(gè)傳入的字符串(詞語(yǔ))轉(zhuǎn)化成隊(duì)列(這樣能夠減少subString的開(kāi)銷(xiāo))
- 加載一個(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模式
可以看到,內(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ù)深入
參考
<<數(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);
}
}
}