在以前的博文中介紹了基於詞典的正向最大匹配算法,用了不到50行代碼就實現了,而後分析了詞典查找算法的時空複雜性,最後使用前綴樹來實現詞典查找算法,並作了3次優化。java
下面咱們看看基於詞典的逆向最大匹配算法的實現,實驗代表,對於漢語來講,逆向最大匹配算法比(正向)最大匹配算法更有效,以下代碼所示:git
public static List<String> segReverse(String text){ Stack<String> result = new Stack<>(); while(text.length()>0){ int len=MAX_LENGTH; if(text.length()<len){ len=text.length(); } //取指定的最大長度的文本去詞典裏面匹配 String tryWord = text.substring(text.length() - len); while(!DIC.contains(tryWord)){ //若是長度爲一且在詞典中未找到匹配,則按長度爲一切分 if(tryWord.length()==1){ break; } //若是匹配不到,則長度減一繼續匹配 tryWord=tryWord.substring(1); } result.push(tryWord); //從待分詞文本中去除已經分詞的文本 text=text.substring(0, text.length()-tryWord.length()); } int len=result.size(); List<String> list = new ArrayList<>(len); for(int i=0;i<len;i++){ list.add(result.pop()); } return list; }
算法跟正向相差不大,重點是使用Stack來存儲分詞結果,具體差別以下圖所示:github
下面看看正向和逆向的分詞效果,使用以下代碼:算法
public static void main(String[] args){ List<String> sentences = new ArrayList<>(); sentences.add("楊尚川是APDPlat應用級產品開發平臺的做者"); sentences.add("研究生命的起源"); sentences.add("長春市長春節致辭"); sentences.add("他從立刻下來"); sentences.add("乒乓球拍賣完了"); sentences.add("咬死獵人的狗"); sentences.add("大學生活象白紙"); sentences.add("他有各類才能"); sentences.add("有意見分歧"); for(String sentence : sentences){ System.out.println("正向最大匹配: "+seg(sentence)); System.out.println("逆向最大匹配: "+segReverse(sentence)); } }
運行結果以下:數組
開始初始化詞典 完成初始化詞典,詞數目:427452 最大分詞長度:16 正向最大匹配: [楊尚川, 是, APDPlat, 應用, 級, 產品開發, 平臺, 的, 做者] 逆向最大匹配: [楊尚川, 是, APDPlat, 應用, 級, 產品開發, 平臺, 的, 做者] 正向最大匹配: [研究生, 命, 的, 起源] 逆向最大匹配: [研究, 生命, 的, 起源] 正向最大匹配: [長春市, 長春, 節, 致辭] 逆向最大匹配: [長春, 市長, 春節, 致辭] 正向最大匹配: [他, 從, 立刻, 下來] 逆向最大匹配: [他, 從, 立刻, 下來] 正向最大匹配: [乒乓球拍, 賣完, 了] 逆向最大匹配: [乒乓球拍, 賣完, 了] 正向最大匹配: [咬, 死, 獵人, 的, 狗] 逆向最大匹配: [咬, 死, 獵人, 的, 狗] 正向最大匹配: [大學生, 活象, 白紙] 逆向最大匹配: [大學生, 活象, 白紙] 正向最大匹配: [他, 有, 各類, 才能] 逆向最大匹配: [他, 有, 各類, 才能] 正向最大匹配: [有意, 見, 分歧] 逆向最大匹配: [有, 意見分歧]
下面看看實際的分詞性能如何,對輸入文件進行分詞,而後將分詞結果保存到輸出文件,輸入文本文件從這裏下載,解壓後大小爲69M,詞典文件從這裏下載,解壓後大小爲4.5M,項目源代碼託管在GITHUB:安全
/** * 將一個文件分詞後保存到另外一個文件 * @author 楊尚川 */ public class SegFile { public static void main(String[] args) throws Exception{ String input = "input.txt"; String output = "output.txt"; if(args.length == 2){ input = args[0]; output = args[1]; } long start = System.currentTimeMillis(); segFile(input, output); long cost = System.currentTimeMillis()-start; System.out.println("cost time:"+cost+" ms"); } public static void segFile(String input, String output) throws Exception{ float max=(float)Runtime.getRuntime().maxMemory()/1000000; float total=(float)Runtime.getRuntime().totalMemory()/1000000; float free=(float)Runtime.getRuntime().freeMemory()/1000000; String pre="執行以前剩餘內存:"+max+"-"+total+"+"+free+"="+(max-total+free); try(BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream(input),"utf-8")); BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(output),"utf-8"))){ int textLength=0; long start = System.currentTimeMillis(); String line = reader.readLine(); while(line != null){ textLength += line.length(); writer.write(WordSeg.seg(line).toString()+"\n"); line = reader.readLine(); } long cost = System.currentTimeMillis() - start; float rate = textLength/cost; System.out.println("文本字符:"+textLength); System.out.println("分詞耗時:"+cost+" 毫秒"); System.out.println("分詞速度:"+rate+" 字符/毫秒"); } max=(float)Runtime.getRuntime().maxMemory()/1000000; total=(float)Runtime.getRuntime().totalMemory()/1000000; free=(float)Runtime.getRuntime().freeMemory()/1000000; String post="執行以後剩餘內存:"+max+"-"+total+"+"+free+"="+(max-total+free); System.out.println(pre); System.out.println(post); } }
測試結果以下(對比TrieV3和HashSet的表現):數據結構
開始初始化詞典 dic.class=org.apdplat.word.dictionary.impl.TrieV3 dic.path=dic.txt 完成初始化詞典,耗時695 毫秒,詞數目:427452 詞典最大詞長:16 詞長 0 的詞數爲:1 詞長 1 的詞數爲:11581 詞長 2 的詞數爲:146497 詞長 3 的詞數爲:162776 詞長 4 的詞數爲:90855 詞長 5 的詞數爲:6132 詞長 6 的詞數爲:3744 詞長 7 的詞數爲:2206 詞長 8 的詞數爲:1321 詞長 9 的詞數爲:797 詞長 10 的詞數爲:632 詞長 11 的詞數爲:312 詞長 12 的詞數爲:282 詞長 13 的詞數爲:124 詞長 14 的詞數爲:116 詞長 15 的詞數爲:51 詞長 16 的詞數爲:25 詞典平均詞長:2.94809 字符數目:24960301 分詞耗時:64014 毫秒 分詞速度:389.0 字符/毫秒 執行以前剩餘內存:2423.3901-61.14509+60.505272=2422.7505 執行以後剩餘內存:2423.3901-961.08545+203.32925=1665.6339 cost time:64029 ms
開始初始化詞典 dic.class=org.apdplat.word.dictionary.impl.HashSet dic.path=dic.txt 完成初始化詞典,耗時293 毫秒,詞數目:427452 詞典最大詞長:16 詞長 0 的詞數爲:1 詞長 1 的詞數爲:11581 詞長 2 的詞數爲:146497 詞長 3 的詞數爲:162776 詞長 4 的詞數爲:90855 詞長 5 的詞數爲:6132 詞長 6 的詞數爲:3744 詞長 7 的詞數爲:2206 詞長 8 的詞數爲:1321 詞長 9 的詞數爲:797 詞長 10 的詞數爲:632 詞長 11 的詞數爲:312 詞長 12 的詞數爲:282 詞長 13 的詞數爲:124 詞長 14 的詞數爲:116 詞長 15 的詞數爲:51 詞長 16 的詞數爲:25 詞典平均詞長:2.94809 字符數目:24960301 分詞耗時:77254 毫秒 分詞速度:323.0 字符/毫秒 執行以前剩餘內存:2423.3901-61.14509+60.505295=2422.7505 執行以後剩餘內存:2423.3901-900.46466+726.91455=2249.84 cost time:77271 ms
在上篇文章基於詞典的正向最大匹配算法中,咱們已經優化了詞典查找算法(DIC.contains(tryWord))的性能(百萬次查詢只要一秒左右的時間),即便通過優化後TrieV3仍然比HashSet慢4倍,也不影響它在分詞算法中的做用,從上面的數據能夠看到,TrieV3的總體分詞性能領先HashSet十五個百分點(15%),並且內存佔用只有HashSet的80%。ide
如何來優化分詞算法呢?分詞算法有什麼問題嗎?post
回顧一下代碼:性能
public static List<String> seg(String text){ List<String> result = new ArrayList<>(); while(text.length()>0){ int len=MAX_LENGTH; if(text.length()<len){ len=text.length(); } //取指定的最大長度的文本去詞典裏面匹配 String tryWord = text.substring(0, 0+len); while(!DIC.contains(tryWord)){ //若是長度爲一且在詞典中未找到匹配,則按長度爲一切分 if(tryWord.length()==1){ break; } //若是匹配不到,則長度減一繼續匹配 tryWord=tryWord.substring(0, tryWord.length()-1); } result.add(tryWord); //從待分詞文本中去除已經分詞的文本 text=text.substring(tryWord.length()); } return result; }
分析一下算法複雜性,最壞狀況爲切分出來的每一個詞的長度都爲一(即DIC.contains(tryWord)始終爲false),所以算法的複雜度約爲外層循環數*內層循環數(即 文本長度*最大詞長)=25025017*16=400400272,以TrieV3的查找性能來講,4億次查詢花費的時間大約8分鐘左右。
進一步查看算法,發現外層循環有2個substring方法調用,內層循環有1個substring方法調用,substring方法內部new了一個String對象,構造String對象的時候又調用了System.arraycopy來拷貝數組。
最壞狀況下,25025017*2+25025017*16=50050034+400400272=450450306,須要構造4.5億個String對象和拷貝4.5億次數組。
怎麼來優化呢?
除了咱們不得不把切分出來的詞加入result中外,其餘的兩個substring是能夠去掉的。這樣,最壞狀況下咱們須要構造的String對象個數和拷貝數組的次數就從4.5億次下降爲25025017次,只有原來的5.6%。
看看改進後的代碼:
public static List<String> seg(String text){ List<String> result = new ArrayList<>(); //文本長度 final int textLen=text.length(); //從未分詞的文本中截取的長度 int len=DIC.getMaxLength(); //剩下未分詞的文本的索引 int start=0; //只要有詞未切分完就一直繼續 while(start<textLen){ if(len>textLen-start){ //若是未分詞的文本的長度小於截取的長度 //則縮短截取的長度 len=textLen-start; } //用長爲len的字符串查詞典 while(!DIC.contains(text, start, len)){ //若是長度爲一且在詞典中未找到匹配 //則按長度爲一切分 if(len==1){ break; } //若是查不到,則長度減一後繼續 len--; } result.add(text.substring(start, start+len)); //從待分詞文本中向後移動索引,滑過已經分詞的文本 start+=len; //每一次成功切詞後都要重置截取長度 len=DIC.getMaxLength(); } return result; } public static List<String> segReverse(String text){ Stack<String> result = new Stack<>(); //文本長度 final int textLen=text.length(); //從未分詞的文本中截取的長度 int len=DIC.getMaxLength(); //剩下未分詞的文本的索引 int start=textLen-len; //處理文本長度小於最大詞長的狀況 if(start<0){ start=0; } if(len>textLen-start){ //若是未分詞的文本的長度小於截取的長度 //則縮短截取的長度 len=textLen-start; } //只要有詞未切分完就一直繼續 while(start>=0 && len>0){ //用長爲len的字符串查詞典 while(!DIC.contains(text, start, len)){ //若是長度爲一且在詞典中未找到匹配 //則按長度爲一切分 if(len==1){ break; } //若是查不到,則長度減一 //索引向後移動一個字,而後繼續 len--; start++; } result.push(text.substring(start, start+len)); //每一次成功切詞後都要重置截取長度 len=DIC.getMaxLength(); if(len>start){ //若是未分詞的文本的長度小於截取的長度 //則縮短截取的長度 len=start; } //每一次成功切詞後都要重置開始索引位置 //從待分詞文本中向前移動最大詞長個索引 //將未分詞的文本歸入下次分詞的範圍 start-=len; } len=result.size(); List<String> list = new ArrayList<>(len); for(int i=0;i<len;i++){ list.add(result.pop()); } return list; }
對於正向最大匹配算法,代碼行數從23增長爲33,對於逆向最大匹配算法,代碼行數從28增長爲51,除了代碼行數的增長,代碼更復雜,可讀性和可維護性也更差,這就是性能的代價!因此,不要過早優化,不要作不成熟的優化,由於不是全部的場合都須要高性能,在數據規模未達到必定程度的時候,各類算法和數據結構的差別表現不大,至少那個差別對你無任何影響。你可能會說,要考慮到明天,要考慮未來,你有你本身的道理,不過,我仍是堅持不過分設計,不過早設計,經過單元測試和持續重構來應對變化,不爲高不可攀的未來浪費今天,下一秒會發生什麼誰知道呢?更不用說明天!由於有單元測試這張安全防禦網,因此在出現性能問題的時候,咱們能夠放心、大膽、迅速地重構來優化性能。
下面看看改進以後的性能(對比TrieV3和HashSet的表現):
開始初始化詞典 dic.class=org.apdplat.word.dictionary.impl.TrieV3 dic.path=dic.txt 完成初始化詞典,耗時689 毫秒,詞數目:427452 詞典最大詞長:16 詞長 0 的詞數爲:1 詞長 1 的詞數爲:11581 詞長 2 的詞數爲:146497 詞長 3 的詞數爲:162776 詞長 4 的詞數爲:90855 詞長 5 的詞數爲:6132 詞長 6 的詞數爲:3744 詞長 7 的詞數爲:2206 詞長 8 的詞數爲:1321 詞長 9 的詞數爲:797 詞長 10 的詞數爲:632 詞長 11 的詞數爲:312 詞長 12 的詞數爲:282 詞長 13 的詞數爲:124 詞長 14 的詞數爲:116 詞長 15 的詞數爲:51 詞長 16 的詞數爲:25 詞典平均詞長:2.94809 字符數目:24960301 分詞耗時:24782 毫秒 分詞速度:1007.0 字符/毫秒 執行以前剩餘內存:2423.3901-61.14509+60.505272=2422.7505 執行以後剩餘內存:2423.3901-732.0371+308.87476=2000.2278 cost time:25007 ms
開始初始化詞典 dic.class=org.apdplat.word.dictionary.impl.HashSet dic.path=dic.txt 完成初始化詞典,耗時293 毫秒,詞數目:427452 詞典最大詞長:16 詞長 0 的詞數爲:1 詞長 1 的詞數爲:11581 詞長 2 的詞數爲:146497 詞長 3 的詞數爲:162776 詞長 4 的詞數爲:90855 詞長 5 的詞數爲:6132 詞長 6 的詞數爲:3744 詞長 7 的詞數爲:2206 詞長 8 的詞數爲:1321 詞長 9 的詞數爲:797 詞長 10 的詞數爲:632 詞長 11 的詞數爲:312 詞長 12 的詞數爲:282 詞長 13 的詞數爲:124 詞長 14 的詞數爲:116 詞長 15 的詞數爲:51 詞長 16 的詞數爲:25 詞典平均詞長:2.94809 字符數目:24960301 分詞耗時:40913 毫秒 分詞速度:610.0 字符/毫秒 執行以前剩餘內存:907.8702-61.14509+60.505295=907.2304 執行以後剩餘內存:907.8702-165.4784+123.30369=865.6955 cost time:40928 ms
能夠看到分詞算法優化的效果很明顯,對於TrieV3來講,提高了2.5倍,對於HashSet來講,提高了1.9倍。咱們看看HashSet的實現:
public class HashSet implements Dictionary{ private Set<String> set = new java.util.HashSet<>(); private int maxLength; @Override public int getMaxLength() { return maxLength; } @Override public boolean contains(String item, int start, int length) { return set.contains(item.substring(start, start+length)); } @Override public boolean contains(String item) { return set.contains(item); } @Override public void addAll(List<String> items) { for(String item : items){ add(item); } } @Override public void add(String item) { //去掉首尾空白字符 item=item.trim(); int len = item.length(); if(len < 1){ //長度小於1則忽略 return; } if(len>maxLength){ maxLength=len; } set.add(item); } }
JDK的HashSet沒有這裏優化所使用的contains(String item, int start, int length)方法,因此用了substring,這是HashSet提速沒有TrieV3大的緣由之一。
看一下改進的算法和原來的算法的對比:
正向最大匹配算法:
逆向最大匹配算法:
參考資料:
一、中文分詞十年回顧