中文分詞算法 之 基於詞典的逆向最大匹配算法

在以前的博文中介紹了基於詞典的正向最大匹配算法,用了不到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大的緣由之一。

 

看一下改進的算法和原來的算法的對比:

 

正向最大匹配算法:

 

逆向最大匹配算法:

 

 

 

代碼託管於GITHUB

 

參考資料:

一、中文分詞十年回顧

二、中文信息處理中的分詞問題

三、漢語自動分詞詞典機制的實驗研究

四、由字構詞_中文分詞新方法

五、漢語自動分詞研究評述

 

NUTCH/HADOOP視頻教程

相關文章
相關標籤/搜索