中文分詞指的是將一段文本拆分為一系列單詞的過程,這些單詞順序拼接后等於原文本。中文分詞算法大致分為基於詞典規則與基於機器學習這兩大派別。本章先從簡單的規則入手,為讀者介紹一些高效的詞典匹配算法。
詞典分詞 是最簡單、最常見的分詞算法,僅需一部詞典和一套查詞典的規則即可,適合初學者入門。給定一部詞典,詞典分詞就是一個確定的查詞與輸出的規則系統。詞典分詞的重點不在於分詞本身,而在於支撐詞典的數據結構。
本章先介紹詞的定義與性質,然后給出一部詞典。
2.1 什么是詞
2.1.1 詞的定義
在基於詞典的中文分詞中,詞的定義要現實得多:詞典中的字符串就是詞。根據此定義,詞典之外的字符串就不是詞了。這個推論或許不符合讀者的期望,但這就是詞典分詞故有的弱點。事實上,語言中的詞匯數量是無窮的,無法用任何詞典完整收錄,
2.1.2 詞的性質----齊夫定律
齊夫定律:一個單詞的詞頻與它的詞頻排名成反比。就是說,雖然存在很多生詞,但生詞的詞頻較小,趨近於0,平時很難碰到。至少在常見的單詞的切分上,可以放心地試一試詞典分詞。
實現詞典分詞的第一個步驟,當然是准備一份詞典了。
2.2 詞典
互聯網上有許多公開的中文詞庫,
比如搜狗實驗室發布的互聯網詞庫(SogouW,其中有15萬個詞條) https://www.sogou.com/labs/resource/w.php,
清華大學開放中文詞庫(THUOCL),http://thunlp.org
何晗發布的千萬級巨型漢語詞庫(千萬級詞條):http://www.hankcs.com/nlp/corpus/tens-of-millions-of-giant-chinese-word-library-share.html
2.2.1 HanLP詞典
CoreNatureDictionary.mini.txt文件

第一列是單詞本身,之后每兩列分別表示詞性與相應的詞頻。希望這個詞以動詞出現了386次,以名詞的身份出現了96次。
2.2.2 詞典的加載
利用HanLP,讀取CoreNatureDictionary.mini.txt文件,只需一行代碼
TreeMap<String, CoreDictionary.Attribute> dictionary =
IOUtil.loadDictionary("data/dictionary/CoreNatureDictionary.mini.txt");
得了一個TreeMap,它的鍵宿舍單詞本身,而值是CoreDictionary.Attribute
查看這份詞典的大小,以及按照字典序排列的第一個單詞:
System.out.printf("詞典大小:%d個詞條\n", dictionary.size());
System.out.println(dictionary.keySet().iterator().next());

2.3 切分算法
2.3.1 完全切分
完全切分指的是,找出一段文本中的所有單詞。朴素的完全切分算法其實非常簡單,只要遍歷文本中的連續序列,查詢該序列是否在詞典中即可。定義詞典為dic,文本為text,當前的處理位置為i,完全切分的python算法如下:
def fully_segment(text, dic): word_list = [] for i in range(len(text)): # i 從 0 到text的最后一個字的下標遍歷 for j in range(i + 1, len(text) + 1): # j 遍歷[i + 1, len(text)]區間 word = text[i:j] # 取出連續區間[i, j]對應的字符串 if word in dic: # 如果在詞典中,則認為是一個詞 word_list.append(word) return word_list
代碼詳見tests/book/ch02/fully_segment.py
主函數
if __name__ == '__main__': dic = load_dictionary() print(fully_segment('商品和服務', dic))
運行結果:

Java代碼
/** * 完全切分式的中文分詞算法 * * @param text 待分詞的文本 * @param dictionary 詞典 * @return 單詞列表 */ public static List<String> segmentFully(String text, Map<String, CoreDictionary.Attribute> dictionary) { List<String> wordList = new LinkedList<String>(); for (int i = 0; i < text.length(); ++i) { for (int j = i + 1; j <= text.length(); ++j) { String word = text.substring(i, j); if (dictionary.containsKey(word)) { wordList.add(word); } } } return wordList; }
// 完全切分
System.out.println(segmentFully("就讀北京大學", dictionary));
結果:

2.3.2 正向最長匹配
完全切分的結果比較沒有意義,我們更需要那種有意義的詞語序列,而不是所有出現在詞典中的單詞所構成的鏈表。 所以需要完善一下處理規則,考慮到越長的單詞表達的意義越豐富,於是我們定義單詞越長優先級越高。具體說來,就是在以某個下標為起點遞增查詞的過程中,優先輸出更長的單詞,這種規則被稱為最長匹配算法。掃描順序從前往后,則稱為正向最長匹配,反之則為逆向最長匹配。
Python代碼
def forward_segment(text, dic): word_list = [] i = 0 while i < len(text): longest_word = text[i] # 當前掃描位置的單字 for j in range(i + 1, len(text) + 1): # 所有可能的結尾 word = text[i:j] # 從當前位置到結尾的連續字符串 if word in dic: # 在詞典中 if len(word) > len(longest_word): # 並且更長 longest_word = word # 則更優先輸出 word_list.append(longest_word) # 輸出最長詞 i += len(longest_word) # 正向掃描 return word_list
調用
if __name__ == '__main__': dic = load_dictionary() print(forward_segment('就讀北京大學', dic)) print(forward_segment('研究生命起源', dic))

Java代碼
/** * 正向最長匹配的中文分詞算法 * * @param text 待分詞的文本 * @param dictionary 詞典 * @return 單詞列表 */ public static List<String> segmentForwardLongest(String text, Map<String, CoreDictionary.Attribute> dictionary) { List<String> wordList = new LinkedList<String>(); for (int i = 0; i < text.length(); ) { String longestWord = text.substring(i, i + 1); for (int j = i + 1; j <= text.length(); ++j) { String word = text.substring(i, j); if (dictionary.containsKey(word)) { if (word.length() > longestWord.length()) { longestWord = word; } } } wordList.add(longestWord); i += longestWord.length(); } return wordList; }

2.3.3 逆向最長匹配
Python代碼
def backward_segment(text, dic): word_list = [] i = len(text) - 1 while i >= 0: # 掃描位置作為終點 longest_word = text[i] # 掃描位置的單字 for j in range(0, i): # 遍歷[0, i]區間作為待查詢詞語的起點 word = text[j: i + 1] # 取出[j, i]區間作為待查詢單詞 if word in dic: if len(word) > len(longest_word): # 越長優先級越高 longest_word = word word_list.insert(0, longest_word) # 逆向掃描,所以越先查出的單詞在位置上越靠后 i -= len(longest_word) return word_list
Java代碼
/** * 逆向最長匹配的中文分詞算法 * * @param text 待分詞的文本 * @param dictionary 詞典 * @return 單詞列表 */ public static List<String> segmentBackwardLongest(String text, Map<String, CoreDictionary.Attribute> dictionary) { List<String> wordList = new LinkedList<String>(); for (int i = text.length() - 1; i >= 0; ) { String longestWord = text.substring(i, i + 1); for (int j = 0; j <= i; ++j) { String word = text.substring(j, i + 1); if (dictionary.containsKey(word)) { if (word.length() > longestWord.length()) { longestWord = word; } } } wordList.add(0, longestWord); i -= longestWord.length(); } return wordList; }

結果還是出現問題,因此有人提出綜合兩種規則,期待它們取長補短,稱為雙向最長匹配。
2.3.4 雙向最長匹配
統計顯示,正向匹配錯誤而逆向匹配正確的句子占9.24%。
雙向最長匹配規則集,流程如下:
(1)同時執行正向和逆向最長匹配,若兩者的詞數不同,則返回詞數更少的那一個。
(2)否則,返回兩者中單字更少的那一個。當單字數也相同時,優先返回逆向最長匹配的結果。
Python代碼
from backward_segment import backward_segment
from forward_segment import forward_segment
from utility import load_dictionary
def count_single_char(word_list: list): # 統計單字成詞的個數
return sum(1 for word in word_list if len(word) == 1)
def bidirectional_segment(text, dic):
f = forward_segment(text, dic)
b = backward_segment(text, dic)
if len(f) < len(b): # 詞數更少優先級更高
return f
elif len(f) > len(b):
return b
else:
if count_single_char(f) < count_single_char(b): # 單字更少優先級更高
return f
else:
return b # 都相等時逆向匹配優先級更高
if __name__ == '__main__':
dic = load_dictionary()
print(bidirectional_segment('研究生命起源', dic))
Java版本
/** * 雙向最長匹配的中文分詞算法 * * @param text 待分詞的文本 * @param dictionary 詞典 * @return 單詞列表 */ public static List<String> segmentBidirectional(String text, Map<String, CoreDictionary.Attribute> dictionary) { List<String> forwardLongest = segmentForwardLongest(text, dictionary); List<String> backwardLongest = segmentBackwardLongest(text, dictionary); if (forwardLongest.size() < backwardLongest.size()) return forwardLongest; else if (forwardLongest.size() > backwardLongest.size()) return backwardLongest; else { if (countSingleChar(forwardLongest) < countSingleChar(backwardLongest)) return forwardLongest; else return backwardLongest; } }
主函數調用部分代碼
// 雙向最長匹配 String[] text = new String[]{ "項目的研究", "商品和服務", "研究生命起源", "當下雨天地面積水", "結婚的和尚未結婚的", "歡迎新老師生前來就餐", }; for (int i = 0; i < text.length; i++) { System.out.printf("| %d | %s | %s | %s | %s |\n", i + 1, text[i], segmentForwardLongest(text[i], dictionary), segmentBackwardLongest(text[i], dictionary), segmentBidirectional(text[i], dictionary) ); }

比較之后發現,雙向最長匹配在2、3、5這3種情況下選擇出了最好的結果,但在4號句子上選擇了錯誤的結果,使得最終正確率3/6反而小於逆向最長匹配的4/6。由此,規則系統的脆弱可見一斑。規則集的維護有時是拆東牆補西牆,有時是幫倒忙。
2.3.5 速度評測
詞典分詞的規則沒有技術含量,消除歧義的效果不好。詞典分詞的核心價值不在於精度,而在於速度。
Python
def evaluate_speed(segment, text, dic): start_time = time.time() for i in range(pressure): segment(text, dic) elapsed_time = time.time() - start_time print('%.2f 萬字/秒' % (len(text) * pressure / 10000 / elapsed_time)) if __name__ == '__main__': text = "江西鄱陽湖干枯,中國最大淡水湖變成大草原" pressure = 10000 dic = load_dictionary() print('由於JPype調用開銷巨大,以下速度顯著慢於原生Java') evaluate_speed(forward_segment, text, dic) evaluate_speed(backward_segment, text, dic) evaluate_speed(bidirectional_segment, text, dic)

Java
public static void evaluateSpeed(Map<String, CoreDictionary.Attribute> dictionary) { String text = "江西鄱陽湖干枯,中國最大淡水湖變成大草原"; long start; double costTime; final int pressure = 10000; System.out.println("正向最長"); start = System.currentTimeMillis(); for (int i = 0; i < pressure; ++i) { segmentForwardLongest(text, dictionary); } costTime = (System.currentTimeMillis() - start) / (double) 1000; System.out.printf("%.2f萬字/秒\n", text.length() * pressure / 10000 / costTime); System.out.println("逆向最長"); start = System.currentTimeMillis(); for (int i = 0; i < pressure; ++i) { segmentBackwardLongest(text, dictionary); } costTime = (System.currentTimeMillis() - start) / (double) 1000; System.out.printf("%.2f萬字/秒\n", text.length() * pressure / 10000 / costTime); System.out.println("雙向最長"); start = System.currentTimeMillis(); for (int i = 0; i < pressure; ++i) { segmentBidirectional(text, dictionary); } costTime = (System.currentTimeMillis() - start) / (double) 1000; System.out.printf("%.2f萬字/秒\n", text.length() * pressure / 10000 / costTime); }

總結:
1、Python的運行速度比Java慢,效率只有Java的一半不到
2、正向匹配與逆向匹配的速度差不多,是雙向的兩倍。因為雙向做了兩倍的工作
3、Java實現的正向匹配比逆向匹配快。
2.4 字典樹
2.4.1 什么是字典樹
字符串集合常用字典樹存儲,這是一種字符串上的樹形數據結構。字典樹中每條邊都對應一個字,從根節點往下的路徑構成一個個字符串。字典樹並不直接在節點上存儲字符串,而是將詞語視作根節點到某節點之間的一條路徑,並在終點節點上做個標記"該節點對應詞語的結尾".字符串就是一條路徑,要查詢一個單詞,只需順着這條路徑從根節點往下走。如果能走到特殊標記的節點,則說明該字符串在集合中,否則說明不存在。

藍色標記着該節點是一個詞的結尾,數字是人為的編號。這棵樹中存儲的詞典如下所示:
入門: 0--1--2
自然: 0--3--4
自然人: 0--3--4--5
2.4.2 字典樹的節點實現
約定用值為None表示節點不對應詞語,雖然這樣就不能插入值為None的鍵了,但實現起來更簡單。
節點的Python描述如下:
class Node(object): def __init__(self, value) -> None: self._children = {} self._value = value def _add_child(self, char, value, overwrite=False): child = self._children.get(char) if child is None: child = Node(value) self._children[char] = child elif overwrite: child._value = value return child
2.4.3 字典樹的增刪改查實現
"刪改查"其實是一回事,都是查詢。刪除操作就是將終點的值設為None而已,修改操作無非是將它的值設為另一個值而已。
從確定有限狀態自動機的角度來講,每個節點都是一個狀態,狀態表示當前已查詢到的前綴。
狀態 前綴
0 “(空白)
1 入
2 入門
。。。。
從父節點到子節點的移動過程可以看作一次狀態轉移。
”增加鍵值對“其實還是查詢,只不過在狀態轉移失敗的時候,則創建相應的子節點,保證轉移成功。
字典樹的完整實現如下:
class Trie(Node): def __init__(self) -> None: super().__init__(None) def __contains__(self, key): return self[key] is not None def __getitem__(self, key): state = self for char in key: state = state._children.get(char) if state is None: return None return state._value def __setitem__(self, key, value): state = self for i, char in enumerate(key): if i < len(key) - 1: state = state._add_child(char, None, False) else: state = state._add_child(char, value, True)
寫一些測試:
if __name__ == '__main__': trie = Trie() # 增 trie['自然'] = 'nature' trie['自然人'] = 'human' trie['自然語言'] = 'language' trie['自語'] = 'talk to oneself' trie['入門'] = 'introduction' assert '自然' in trie # 刪 trie['自然'] = None assert '自然' not in trie # 改 trie['自然語言'] = 'human language' assert trie['自然語言'] == 'human language' # 查 assert trie['入門'] == 'introduction'
2.4.4 首字散列其余二分的字典樹
讀者也許聽說過散列函數,它用來將對象轉換為整數。散列函數必須滿足的基本要求是:對象相同,散列值必須相同。散列函數設計不當,則散列表的內存效率和查找效率都不高。Python沒有char類型,字符被視作長度為1的字符串,所以實際調用的就是str的散列函數。在64位系統上,str的散列函數返回64位的整數。但Unicode字符總共也才136690個,遠遠小於2^64。這導致兩個字符在字符集中明明相鄰,然而散列值卻相差萬里。
Java中的字符散列函數則要友好一些,Java中字符的編碼為UTF-16。每個字符都可以映射為16位不重復的連續整數,恰好是完美散列。這個完美的散列函數輸出的是區間[0,65535]內的正整數,用來索引子節點非常合適。具體做法是創建一個長為65536的數組,將子節點按對應的字符整型值作為下標放入該數組中即可。這樣每次狀態轉移時,只需訪問對應下標就行了,這在任何編程語言中都是極快的。然而這種待遇無法讓每個節點都享受,如果詞典中的詞語最長為l,則最壞情況下字典樹第l層的數組容量之和為O(65536^l)。內存指數膨脹,不現實。一個變通的方法是僅在根節點實施散列策略。
