本文分析:HanLP版本1.5.3中二元核心詞典的存儲與查找。當詞典文件沒有被緩存時,會從文本文件CoreNatureDictionary.ngram.txt中解析出來存儲到TreeMap中,而後構造start和pair數組,並基於這兩個數組實現詞共現頻率的二分查找。當已經有緩存bin文件時,那直接讀取構建start和pair數組,速度超快。java
源碼實現node
二元核心詞典的加載算法
二元核心詞典在文件:CoreNatureDictionary.ngram.txt,約有46.3 MB。程序啓動時先嚐試加載CoreNatureDictionary.ngram.txt.table.bin 緩存文件,大約22.9 MB。這個緩存文件是序列化保存起來的。數組
ObjectInputStream in = new ObjectInputStream(IOUtil.newInputStream(path));緩存
start = (int[]) in.readObject();ide
pair = (int[]) in.readObject();oop
當緩存文件不存在時,拋出異常:警告: 嘗試載入緩存文件E:/idea/hanlp/HanLP/data/dictionary/CoreNatureDictionary.ngram.txt.table.bin發生異常[java.io.FileNotFoundException: 而後解析CoreNatureDictionary.ngram.txtthis
br = new BufferedReader(new InputStreamReader(IOUtil.newInputStream(path), "UTF-8"));idea
while ((line = br.readLine()) != null){spa
String[] params = line.split("\\s");
String[] twoWord = params[0].split("@", 2);
...
}
而後,使用一個TreeMap<Integer, TreeMap<Integer, Integer>> map來保存解析的每一行二元核心詞典條目。
TreeMap<Integer, TreeMap<Integer, Integer>> map = new TreeMap<Integer, TreeMap<Integer, Integer>>();
int idA = CoreDictionary.trie.exactMatchSearch(a);//二元接續的 @ 前的內容
int idB = CoreDictionary.trie.exactMatchSearch(b);//@ 後的內容
TreeMap<Integer, Integer> biMap = map.get(idA);
if (biMap == null){
biMap = new TreeMap<Integer, Integer>();
map.put(idA, biMap);//
}
biMap.put(idB, freq);
好比二元接續:「一 一@中」,@ 前的內容是:「一 一」,@後的內容是 「中」。因爲同一個前綴能夠有多個後續,好比:
一一@中 1
一一@爲 6
一一@交談 1
全部以 '一 一' 開頭的 @ 後的後綴 以及對應的頻率 都保存到 相應的biMap中:biMap.put(idB, freq);。注意:biMap和map是不一樣的,map保存整個二元核心詞典,而biMap保存某個詞對應的全部後綴(這個詞 @ 後的全部條目)
map中保存二元核心詞典示意圖以下:
二元核心詞典主要由CoreBiGramTableDictionary.java 實現。這個類中有兩個整型數組 支撐 二元核心詞典的快速二分查找。
/**
* 描述了詞在pair中的範圍,具體說來<br>
* 給定一個詞idA,從pair[start[idA]]開始的start[idA + 1] - start[idA]描述了一些接續的頻次
*/
static int start[];//支持快速地二分查找
/**
* pair[偶數n]表示key,pair[n+1]表示frequency
*/
static int pair[];
start 數組
首先初始化一個與一元核心詞典Trie樹 size 同樣大小 的start 數組:
int maxWordId = CoreDictionary.trie.size();
...
start = new int[maxWordId + 1];
而後,遍歷一元核心詞典中的詞,尋找這些詞是 是否有二階共現(或者說:這些詞是否存在 二元接續)
for (int i = 0; i < maxWordId; ++i){
TreeMap<Integer, Integer> bMap = map.get(i);
if (bMap != null){
for (Map.Entry<Integer, Integer> entry : bMap.entrySet()){
//省略其餘代碼
++offset;//統計以 這個詞 爲前綴的全部二階共現的個數
}
}//end if
start[i + 1] = offset;
}// end outer for loop
if (bMap != null)表示 第 i 個詞(i從下標0開始)在二元詞典中有二階共現,因而 統計以 這個詞 爲前綴的全部二階共現的個數,將之保存到 start 數組中。下面來具體舉例,start數組中前37個詞的值以下:
其中start[32]=0,start[33]=0,相應的 一元核心詞典中的詞爲 ( )。即,一個左括號、一個右括號。而這個 左括號 和 右括號 在二元核心詞典中是不存在詞共現的(接續)。也就是說在二元核心詞典中 沒有 (@xxx這樣的條目,也沒有 )@xxx 這個條目(xxx 表示任意以 ( 或者 ) 爲前綴 的後綴接續)。所以,這也是start[32] 和 start[33]=0 都等於0的緣由。
部分詞的一元核心詞典以下:
再來看 start[34]=22,start[35]=23。在一元核心詞典中,第34個詞是"一 一",而在二元核心詞典中 '一 一'的詞共現共有22個,以下:
在一元核心詞典中,第35個詞是 "一 一列舉",如上圖所示,"一 一列舉" 在二元核心中只有一個詞共現:「一 一列舉@芒果臺」。所以,start[35]=22+1=23。從這裏也能夠看出:
給定一個詞idA,從pair[start[idA]]開始的start[idA + 1] - start[idA]描述了一些接續的頻次
好比,idA=35,對應詞「一 一列舉」,它的接續頻次爲1,即:23-22=1
這樣作的好處是什麼呢?自問自答一下:^~^,就是大大減小了二分查找的範圍。
pair 數組
pair數組的長度是二元核心詞典行數的兩倍
int total = 0;
while ((line = br.readLine()) != null){
//省略其餘代碼
total += 2;
}
pair數組 偶數 下標 存儲 保存的是 一元核心詞典中的詞 的下標,而對應的偶數加1 處的下標 存儲 這個詞的共現頻率。即: pair[偶數n]表示key,pair[n+1]表示frequency
pair = new int[total]; // total是接續的個數*2
for (int i = 0; i < maxWordId; ++i)
{
TreeMap<Integer, Integer> bMap = map.get(i);//i==0?
if (bMap != null)//某個詞在一元核心詞典中, 可是並無出如今二元核心詞典中(這個詞沒有二元核心詞共現)
{
for (Map.Entry<Integer, Integer> entry : bMap.entrySet())
{
int index = offset << 1;
pair[index] = entry.getKey();//詞 在一元核心詞典中的id
pair[index + 1] = entry.getValue();//頻率
}
}
}
舉例來講:對於 '一 一@中',pair數組是如何保存這對詞的詞共現頻率的呢?
'一 一'在 map 中第0號位置處,它是一元核心詞典中的第34個詞。 共有22個共現詞。以下:
其中,第一個共現詞是 '一 一 @中',就是'一 一'與 '中' 共同出現,出現的頻率爲1。而 ''中'' 在一元核心詞典中的 4124行,以下圖所示:
所以,'一 一@中'的pair數組存儲以下:
0=4123 (‘中’在一元核心詞典中的位置(從下標0開始計算))
1=1 ('一 一@中'的詞共現頻率)
2=5106 ('爲' 在一元核心詞典中的位置) 【爲 p 65723】
3=6 ('一 一@爲'的詞共現頻率)
由此可知,對於二元核心詞典共現詞而言,共同前綴的後續詞 在 pair數組中是順序存儲的,好比說:前綴'一 一'的全部後綴:中、爲、交談……按順序依次在 pair 數組中存儲。而這也是可以對 pair 數組進行二分查找的基礎。
一 一@中 1
一 一@爲 6
一 一@交談 1
一 一@介紹 1
一 一@做 1
一 一@分析
.......//省略其餘
二分查找
如今來看看 二分查找是幹什麼用的?爲何減小了二分查找的範圍。爲了獲取某 兩個詞(idA 和 idB) 的詞共現頻率,須要進行二分查找:
public static int getBiFrequency(int idA, int idB){
//省略其餘代碼
int index = binarySearch(pair, start[idA], start[idA + 1] - start[idA], idB);
return pair[index + 1];
}
根據前面介紹,start[idA + 1] - start[idA]就是以 idA 爲前綴的 全部詞的 詞共現頻率。好比,以 '一 一' 爲前綴的詞一共有22個,假設我要查找 '一 一@向' 的詞共現頻率是多少?在覈心二元詞典文件CoreNatureDictionary.ngram.txt中,咱們知道 '一 一@向' 的詞共現頻率爲2,可是:如何用程序快速地實現查找呢?
二元核心詞典的總個數仍是不少的,好比在HanLP1.5.3大約有290萬個二元核心詞條,若是每查詢一次 idA@idB 的詞共現頻率就要從290萬個詞條裏面查詢,顯然效率很低。若先定位出 全部以 idA 爲前綴的共現詞:idA@xx1,idA@xx2,idA@xx3……,而後再從從這些 以idA爲前綴的共現詞中進行二分查找,來查找 idA@idB,這樣查找的效率就快了許多。
而start 數組保存了一元詞典中每一個詞 在二元詞典中的詞共現狀況: start[idA] 表明 idA在 pair 數組中共現詞的起始位置,而start[idA + 1] - start[idA]表明 以idA 爲前綴的共現詞一共有多少個,這樣二分查找的範圍就只在 start[idA] 和 start[idA] + (start[idA + 1] - start[idA]) - 1之間了。
private static int binarySearch(int[] a, int fromIndex, int length, int key)
{
int low = fromIndex;
int high = fromIndex + length - 1;
//省略其餘代碼
說到這裏,再多說一點:二元核心詞典的二分查找 是爲了獲取 idA@idB 的詞共現頻率,而這個詞共現頻率的用處之一就是最短路徑分詞算法(維特比分詞),用來計算最短路徑的權重。關於最短路徑分詞,可參考這篇解析:
//只列出關鍵代碼
List<Vertex> vertexList = viterbi(wordNetAll);//求解詞網的最短路徑
to.updateFrom(node);//更新權重
double weight = from.weight + MathTools.calculateWeight(from, this);//計算兩個頂點(idA->idB)的權重
int nTwoWordsFreq = CoreBiGramTableDictionary.getBiFrequency(from.wordID, to.wordID);//查覈心二元詞典
int index = binarySearch(pair, start[idA], start[idA + 1] - start[idA], idB);//二分查找 idA@idB共現頻率
總結
有時候因爲特定項目須要,須要修改核心詞典。好比添加一個新的二元詞共現詞條 到 二元核心詞典中去,這時就須要注意:添加的新詞條須要存在於一元核心詞典中,不然添加無效。另外,添加到CoreNatureDictionary.ngram.txt裏面的二元共現詞的位置不過重要,由於相同的前綴 共現詞 都會保存到 同一個TreeMap中,可是最好也是連續放在一塊兒,這樣二元核心詞典就不會太混亂。
文章來源 hapjin的博客