基於詞典的前綴掃描中文分詞


說明

中文分詞是很多文本分析的基礎。最近一個項目,輸入一個地址,需要識別出地址中包含的省市區街道等單詞。與以往的分詞技術不同。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     }
View Code

初始化后,就是搜索了。傳入一個字符串,指定從哪個字符開始搜索,找到所有可能匹配的單詞。這里就體現出了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     }
View Code

這里返回結果是一個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     }
View Code

生成的結果是一個二維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     }
View Code

分詞

有了路徑,就可以分詞了。路徑是一個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     }
View Code

最后是分詞入口,將生成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     }
View Code

附:

代碼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 [廣東, 深圳市, 寶安區, 松崗街道, 松崗鎮, 溪, 頭, 村, 委, 西, 六, 十, 七, 巷, 一, 百, 二, 十, 三, 號]
View Code

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM