說明
中文分詞是很多文本分析的基礎。最近一個項目,輸入一個地址,需要識別出地址中包含的省市區街道等單詞。與以往的分詞技術不同。jieba/hanlp等常用的分詞技術,除了基於詞典,還有基於隱馬爾科夫/條件隨機場等機器學習技術對未登錄詞的分詞,有一定的概率性。而我們所使用的地址識別,要求必須基於詞庫進行精確的分詞。這些比較高級的分詞技術反而成為了不必要的風險。
另外還有一個原因是,流行的分詞技術對多用戶詞典和詞典的動態管理支持也不是很好。本項目就實現了一個可以多詞典間相互隔離的分詞工具。
基於前綴詞典樹的中文分詞概念簡單。其中使用到的trie樹/有向無環圖(dag)/動態規划計算最長路徑等算法,可以說是教科書一樣的例子。所以我就自己實現了一遍,測試下來效果還不錯。
Trie樹(前綴詞典樹)
trie,又稱前綴樹或字典樹,是一種有序樹,用於保存關聯數組,其中的鍵通常是字符串。與二叉查找樹不同,鍵不是直接保存在節點中,而是由節點在樹中的位置決定。一個節點的所有子孫都有相同的前綴,也就是這個節點對應的字符串,而根節點對應空字符串。一般情況下,不是所有的節點都有對應的值,只有葉子節點和部分內部節點所對應的鍵才有相關的值。
在圖示中,鍵標注在節點中,值標注在節點之下。每一個完整的單詞對應一個特定的整數。
鍵不需要被顯式地保存在節點中。圖示中標注出完整的單詞,只是為了演示trie的原理。
trie中的鍵通常是字符串,但也可以是其它的結構。trie的算法可以很容易地修改為處理其它結構的有序序列,比如一串數字或者形狀的排列。比如,bitwise trie中的鍵是一串位元,可以用於表示整數或者內存地址。
在本實現中,節點的值主要是單詞的詞頻。
初始化時,往根節點中添加一個單詞,以及對應的詞頻。根節點生成子節點遞歸調用。但是這里方法並沒有寫成遞歸,而是寫成了循環。因為,遞歸相對來說不好理解,而且debug也不顯而易見。一個基本原則是,對於java這種非函數式語言,凡是使用遞歸的地方都可以改成循環。

1 public void fillSegment(char[] charArray, int wordFrequency) { 2 int count = charArray.length; 3 CharNode charNode = this; 4 for (int i = 0; i < count; i++) { 5 CharNode child = charNode.searchOrAddChild(charArray[i]); 6 charNode = child; 7 } 8 charNode.wordFrequency += wordFrequency; 9 }
初始化后,就是搜索了。傳入一個字符串,指定從哪個字符開始搜索,找到所有可能匹配的單詞。這里就體現出了trie樹優於hashmap的地方。在trie樹中,一旦找不到子節點,就可以返回了。但hash並不具有這種特性,hash需要把字符串的所有子字符串遍歷一遍才知道。

1 public List<HitWord> match(char[] chars, int beginIndex) { 2 List<HitWord> result = new ArrayList<>(); 3 int count = chars.length; 4 if (beginIndex >= count - 1) { 5 return result; 6 } 7 CharNode charNode = this; 8 for (int i = beginIndex; i < count; i++) { 9 CharNode tmpCharNode = charNode.searchChild(chars[i]); 10 if (tmpCharNode == null) { 11 break; 12 } 13 charNode = tmpCharNode; 14 if (tmpCharNode.wordFrequency > 0) { 15 HitWord hitWord = new HitWord(i, tmpCharNode.wordFrequency); 16 result.add(hitWord); 17 } 18 } 19 return result; 20 }
這里返回結果是一個list,因為一個字符串可能匹配到多個結果。比如“上海市”去匹配,詞庫里有“上海”和“上海市”,那返回的就是List.size() == 2的結果。
DAG(有向無環圖)
一個語句根據詞頻生成一個有向無環圖。分詞的原理我懶得說了。看下這個鏈接吧。http://www.cnblogs.com/zhbzz2007/p/6084196.html。
生成dag的關鍵代碼如下。

1 private List<List<HitWord>> createDag(String sentence){ 2 List<List<HitWord>> result = new ArrayList<>(); 3 char[] chars = sentence.toCharArray(); 4 int count = chars.length; 5 for(int i = 0; i < count; i++){ 6 List<HitWord> matchList = dictionaryMgr.match(chars, i, this.dictionaryKey); 7 result.add(matchList); 8 } 9 return result; 10 }
生成的結果是一個二維List。第一維下標對應句子字符串下標。第二維下標對應圖的路徑:路徑包含路徑終點下標及路徑權重(詞頻)。
下面是基於dag尋找最優路徑。這里用到了動態規划的思想。由於每個節點都是有下標的,而且路徑的起點下標恆大於終點下標,基於這個特點,其實這個動態規划實現的還是比較簡單粗暴的。不過還好,還算非常簡單有效。

1 private Map<Integer, Integer> calculateRouter(List<List<HitWord>> dag){ 2 int count = dag.size(); 3 Map<Integer, Integer> router = new HashMap<>(count); 4 double[] frequencies = new double[count + 1]; 5 for(int i = count - 1; i >= 0; i--){ 6 List<HitWord> list = dag.get(i); 7 if(list.isEmpty()){ 8 frequencies[i] = frequencies[i + 1]; 9 continue; 10 } 11 for (HitWord hitWord : list) { 12 int endIndex = hitWord.getEndIndex(); 13 double tmpFrequency = hitWord.getFrequency() + frequencies[endIndex + 1]; 14 if(tmpFrequency > frequencies[i]){ 15 frequencies[i] = tmpFrequency; 16 router.put(i, endIndex); 17 } 18 } 19 } 20 return router; 21 }
分詞
有了路徑,就可以分詞了。路徑是一個map,key為起點,value為終點。根據路徑進行分詞,就很簡單了。

1 private List<String> cutFromRouter(String sentence, Map<Integer, Integer> router){ 2 List<String> result = new ArrayList<>(); 3 int count = sentence.length(); 4 for(int i = 0; i < count; i++){ 5 int j = i; 6 if(router.containsKey(i)){ 7 j = router.get(i); 8 } 9 String word = sentence.substring(i, j + 1); 10 i = j; 11 result.add(word); 12 } 13 return result; 14 }
最后是分詞入口,將生成dag,生成router,分詞的 三個方法連起來。

1 public List<String> cut(String sentence){ 2 List<List<HitWord>> dag = this.createDag(sentence); 3 Map<Integer, Integer> router = this.calculateRouter(dag); 4 return this.cutFromRouter(sentence, router); 5 }
附:
代碼git,https://github.com/shlugood/wordcut 如果感興趣,可下載下來。pom項目,還帶有測試詞典和UT。
看下我跑的UT吧。

1 [北京, 順義區, 李橋鎮, 馨, 港, 庄, 園, 8, 區, 8, 0, 號, 樓, 1, 2, 3, 單, 元, 2, 0, 1] 2 [浙江, 杭州市, 余杭區, 五常街道, 西, 溪, 庭, 院, 9, 8, —, 2, —, 4, 4, 4] 3 [廣東, 深圳市, 寶安區, 松崗街道, 松崗鎮, 溪, 頭, 村, 委, 西, 六, 十, 七, 巷, 一, 百, 二, 十, 三, 號]