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

基於詞典的正向最大匹配算法最長詞優先匹配,算法會根據詞典文件自動調整最大長度,分詞的好壞徹底取決於詞典。java

 

算法流程圖以下:node

 

Java實現代碼以下:git

 

/**
 * 基於詞典的正向最大匹配算法
 * @author 楊尚川
 */
public class WordSeg {
    private static final List<String> DIC = new ArrayList<>();
    private static final int MAX_LENGTH;
    static{
        try {
            System.out.println("開始初始化詞典");
            int max=1;
            int count=0;
            List<String> lines = Files.readAllLines(Paths.get("D:/dic.txt"), Charset.forName("utf-8"));
            for(String line : lines){
                DIC.add(line);
                count++;
                if(line.length()>max){
                    max=line.length();
                }
            }
            MAX_LENGTH = max;
            System.out.println("完成初始化詞典,詞數目:"+count);
            System.out.println("最大分詞長度:"+MAX_LENGTH);
        } catch (IOException ex) {
            System.err.println("詞典裝載失敗:"+ex.getMessage());
        }
        
    }
    public static void main(String[] args){
        String text = "楊尚川是APDPlat應用級產品開發平臺的做者";  
        System.out.println(seg(text));
    }
    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.rar,簡單吧,呵呵github

 

實現功能是簡單,不過這裏的詞典中詞的數目爲:427452,咱們須要頻繁執行DIC.contains(tryWord))來判斷一個詞是否在詞典中,因此優化這行代碼可以顯著提高分詞效率(不要過早優化、不要作不成熟的優化)。算法

 

上面的代碼是利用了JDK的Collection接口的contains方法來判斷一個詞是否在詞典中,而這個方法的不一樣實現,其性能差別極大,上面的初始版本是用了ArrayList:List<String> DIC = new ArrayList<>()。那麼這個ArrayList的性能如何呢?還有更好性能的實現嗎?數組

 

一般來講,對於查找算法,在有序列表中查找比在無序列表中查找更快,分區查找全局遍歷要快。安全

 

經過查看ArrayList、LinkedList、HashSet的contains方法的源代碼,發現ArrayList和LinkedList採用全局遍歷的方式且未利用有序列表的優點,HashSet使用了分區查找,若是hash分佈均勻衝突少,則須要遍歷的列表就不多甚至不須要。理論歸理論,仍是寫個代碼來測測更直觀放心,測試代碼以下:數據結構

 

/**
 * 比較詞典查詢算法的性能
 * @author 楊尚川
 */
public class SearchTest {
    //爲了生成隨機查詢的詞列表
    private static final List<String> DIC_FOR_TEST = new ArrayList<>();
    //經過更改這裏DIC的實現來比較不一樣實現之間的性能
    private static final List<String> DIC = new ArrayList<>();
    static{
        try {
            System.out.println("開始初始化詞典");
            int count=0;
            List<String> lines = Files.readAllLines(Paths.get("D:/dic.txt"), Charset.forName("utf-8"));
            for(String line : lines){
                DIC.add(line);
                DIC_FOR_TEST.add(line);
                count++;
            }
            System.out.println("完成初始化詞典,詞數目:"+count);
        } catch (IOException ex) {
            System.err.println("詞典裝載失敗:"+ex.getMessage());
        }        
    }
    public static void main(String[] args){
        //選取隨機值
        List<String> words = new ArrayList<>();
        for(int i=0;i<100000;i++){
            words.add(DIC_FOR_TEST.get(new Random(System.nanoTime()+i).nextInt(427452)));
        }
        long start = System.currentTimeMillis();
        for(String word : words){
            DIC.contains(word);
        }
        long cost = System.currentTimeMillis()-start;
        System.out.println("cost time:"+cost+" ms");
    }
}

 

#分別運行10次測試,而後取平均值
LinkedList     10000次查詢       cost time:48812 ms
ArrayList      10000次查詢       cost time:40219 ms
HashSet        10000次查詢       cost time:8 ms
HashSet        1000000次查詢     cost time:258 ms
HashSet        100000000次查詢   cost time:28575 ms

 

咱們發現HashSet性能最好,比LinkedList和ArrayList快約3個數量級!這個測試結果跟前面的分析一致,LinkedList要比ArrayList慢一些,雖然他們都是全局遍歷,可是LinkedList須要操做下一個數據的引用,因此會多一些操做,LinkedList由於須要保存前驅後繼引用,佔用的內存也要高一些。dom

 

雖然HashSet已經有不錯的性能了,可是若是詞典愈來愈大,內存佔用愈來愈多怎麼辦?若是有一個數據結構,有接近HashSet性能的同時,又能對詞典的數據進行壓縮以減小內存佔用,那就完美了。oop

 

前綴樹(Trie)有可能能夠實現「魚與熊掌兼得」的好事,本身實現一個Trie的數據結構,代碼以下:

 

/**
 * 前綴樹的Java實現
 * 用於查找一個指定的字符串是否在詞典中
 * @author 楊尚川
 */
public class Trie {
    private final TrieNode ROOT_NODE = new TrieNode('/');

    public boolean contains(String item){
        //去掉首尾空白字符
        item=item.trim();
        int len = item.length();
        if(len < 1){
            return false;
        }
        //從根節點開始查找
        TrieNode node = ROOT_NODE;
        for(int i=0;i<len;i++){
            char character = item.charAt(i);
            TrieNode child = node.getChild(character);
            if(child == null){
                //未找到匹配節點
                return false;
            }else{
                //找到節點,繼續往下找
                node = child;
            }
        }
        if(node.isTerminal()){
            return true;
        }
        return false;
    }
    public void addAll(List<String> items){
        for(String item : items){
            add(item);
        }
    }
    public void add(String item){
        //去掉首尾空白字符
        item=item.trim();
        int len = item.length();
        if(len < 1){
            //長度小於1則忽略
            return;
        }
        //從根節點開始添加
        TrieNode node = ROOT_NODE;
        for(int i=0;i<len;i++){
            char character = item.charAt(i);
            TrieNode child = node.getChildIfNotExistThenCreate(character);
            //改變頂級節點
            node = child;
        }
        //設置終結字符,表示從根節點遍歷到此是一個合法的詞
        node.setTerminal(true);
    }
    private static class TrieNode{
        private char character;
        private boolean terminal;
        private final Map<Character,TrieNode> children = new ConcurrentHashMap<>();        
        public TrieNode(char character){
            this.character = character;
        }
        public boolean isTerminal() {
            return terminal;
        }
        public void setTerminal(boolean terminal) {
            this.terminal = terminal;
        }        
        public char getCharacter() {
            return character;
        }
        public void setCharacter(char character) {
            this.character = character;
        }
        public Collection<TrieNode> getChildren() {
            return this.children.values();
        }
        public TrieNode getChild(char character) {
            return this.children.get(character);
        }        
        public TrieNode getChildIfNotExistThenCreate(char character) {
            TrieNode child = getChild(character);
            if(child == null){
                child = new TrieNode(character);
                addChild(child);
            }
            return child;
        }
        public void addChild(TrieNode child) {
            this.children.put(child.getCharacter(), child);
        }
        public void removeChild(TrieNode child) {
            this.children.remove(child.getCharacter());
        }        
    }
    
    public void show(){
        show(ROOT_NODE,"");
    }
    private void show(TrieNode node, String indent){
        if(node.isTerminal()){
            System.out.println(indent+node.getCharacter()+"(T)");
        }else{
            System.out.println(indent+node.getCharacter());
        }
        for(TrieNode item : node.getChildren()){
            show(item,indent+"\t");
        }
    }
    public static void main(String[] args){
        Trie trie = new Trie();
        trie.add("APDPlat");
        trie.add("APP");
        trie.add("APD");
        trie.add("Nutch");
        trie.add("Lucene");
        trie.add("Hadoop");
        trie.add("Solr");
        trie.add("楊尚川");
        trie.add("楊尚昆");
        trie.add("楊尚喜");
        trie.add("中華人民共和國");
        trie.add("中華人民打太極");
        trie.add("中華");
        trie.add("中心思想");
        trie.add("楊家將");        
        trie.show();
    }
}

 

 

修改前面的測試代碼,把List<String> DIC = new ArrayList<>()改成Trie DIC = new Trie(),使用Trie來作詞典查找,最終的測試結果以下:

 

#分別運行10次測試,而後取平均值
LinkedList     10000次查詢       cost time:48812 ms
ArrayList      10000次查詢       cost time:40219 ms
HashSet        10000次查詢       cost time:8 ms
HashSet        1000000次查詢     cost time:258 ms
HashSet        100000000次查詢   cost time:28575 ms
Trie           10000次查詢       cost time:15 ms
Trie           1000000次查詢     cost time:1024 ms
Trie           100000000次查詢   cost time:104635 ms

 

能夠發現Trie和HashSet的性能差別較小,在半個數量級之內,經過jvisualvm驚奇地發現Trie佔用的內存比HashSet的大約2.6倍,以下圖所示:

 

HashSet:

 

Trie:


 

詞典中詞的數目爲427452,HashSet是基於HashMap實現的,因此咱們看到佔內存最多的是HashMap$Node、char[]和String,手動執行GC屢次,這三種類型的實例數一直在變化,固然都始終大於詞數427452。Trie是基於ConcurrentHashMap實現的,因此咱們看到佔內存最多的是ConcurrentHashMap、ConcurrentHashMap$Node[]、ConcurrentHashMap$Node、Trie$TrieNode和Character,手動執行GC屢次,發現Trie$TrieNode的實例數一直保持不變,說明427452個詞通過Trie處理後的節點數爲603141。

 

很明顯地能夠看到,這裏Trie的實現不夠好,選用ConcurrentHashMap佔用的內存至關大,那麼咱們如何來改進呢?把ConcurrentHashMap替換爲HashMap能夠嗎?HashSet不是也基於HashMap嗎?看看把ConcurrentHashMap替換爲HashMap後的效果,以下圖所示:



 

內存佔用雖然少了10M左右,但仍然是HashSet的約2.4倍,原本是打算使用Trie來節省內存,沒想反正更加佔用內存了,既然使用HashMap來實現Trie佔用內存極高,那麼試試使用數組的方式,以下代碼所示:

 

/**
 * 前綴樹的Java實現
 * 用於查找一個指定的字符串是否在詞典中
 * @author 楊尚川
 */
public class TrieV2 {
    private final TrieNode ROOT_NODE = new TrieNode('/');

    public boolean contains(String item){
        //去掉首尾空白字符
        item=item.trim();
        int len = item.length();
        if(len < 1){
            return false;
        }
        //從根節點開始查找
        TrieNode node = ROOT_NODE;
        for(int i=0;i<len;i++){
            char character = item.charAt(i);
            TrieNode child = node.getChild(character);
            if(child == null){
                //未找到匹配節點
                return false;
            }else{
                //找到節點,繼續往下找
                node = child;
            }
        }
        if(node.isTerminal()){
            return true;
        }
        return false;
    }
    public void addAll(List<String> items){
        for(String item : items){
            add(item);
        }
    }
    public void add(String item){
        //去掉首尾空白字符
        item=item.trim();
        int len = item.length();
        if(len < 1){
            //長度小於1則忽略
            return;
        }
        //從根節點開始添加
        TrieNode node = ROOT_NODE;
        for(int i=0;i<len;i++){
            char character = item.charAt(i);
            TrieNode child = node.getChildIfNotExistThenCreate(character);
            //改變頂級節點
            node = child;
        }
        //設置終結字符,表示從根節點遍歷到此是一個合法的詞
        node.setTerminal(true);
    }
    private static class TrieNode{
        private char character;
        private boolean terminal;
        private TrieNode[] children = new TrieNode[0];
        public TrieNode(char character){
            this.character = character;
        }
        public boolean isTerminal() {
            return terminal;
        }
        public void setTerminal(boolean terminal) {
            this.terminal = terminal;
        }        
        public char getCharacter() {
            return character;
        }
        public void setCharacter(char character) {
            this.character = character;
        }
        public Collection<TrieNode> getChildren() {
            return Arrays.asList(children);            
        }
        public TrieNode getChild(char character) {
            for(TrieNode child : children){
                if(child.getCharacter() == character){
                    return child;
                }
            }
            return null;
        }        
        public TrieNode getChildIfNotExistThenCreate(char character) {
            TrieNode child = getChild(character);
            if(child == null){
                child = new TrieNode(character);
                addChild(child);
            }
            return child;
        }
        public void addChild(TrieNode child) {
            children = Arrays.copyOf(children, children.length+1);
            this.children[children.length-1]=child;
        }
    }
    
    public void show(){
        show(ROOT_NODE,"");
    }
    private void show(TrieNode node, String indent){
        if(node.isTerminal()){
            System.out.println(indent+node.getCharacter()+"(T)");
        }else{
            System.out.println(indent+node.getCharacter());
        }        
        for(TrieNode item : node.getChildren()){
            show(item,indent+"\t");
        }
    }
    public static void main(String[] args){
        TrieV2 trie = new TrieV2();
        trie.add("APDPlat");
        trie.add("APP");
        trie.add("APD");
        trie.add("楊尚川");
        trie.add("楊尚昆");
        trie.add("楊尚喜");
        trie.add("中華人民共和國");
        trie.add("中華人民打太極");
        trie.add("中華");
        trie.add("中心思想");
        trie.add("楊家將");        
        trie.show();
    }
}

 

 

內存佔用狀況以下圖所示:

 

 

如今內存佔用只有HashSet方式的80%了,內存問題總算是解決了,進一步分析,若是詞典夠大,詞典中有共同前綴的詞足夠多,節省的內存空間必定很是客觀。那麼性能呢?看以下從新測試的數據:

#分別運行10次測試,而後取平均值
LinkedList     10000次查詢       cost time:48812 ms
ArrayList      10000次查詢       cost time:40219 ms
HashSet        10000次查詢       cost time:8 ms
HashSet        1000000次查詢     cost time:258 ms
HashSet        100000000次查詢   cost time:28575 ms
Trie           10000次查詢       cost time:15 ms
Trie           1000000次查詢     cost time:1024 ms
Trie           100000000次查詢   cost time:104635 
TrieV1         10000次查詢       cost time:16 ms
TrieV1         1000000次查詢     cost time:780 ms
TrieV1         100000000次查詢   cost time:90949 ms
TrieV2         10000次查詢       cost time:50 ms
TrieV2         1000000次查詢     cost time:4361 ms
TrieV2         100000000次查詢   cost time:483398

 

 

總結一下,ArrayList和LinkedList方式實在太慢,跟最快的HashSet比將近慢約3個數量級,果斷拋棄。Trie比HashSet慢約半個數量級,內存佔用多約2.6倍,改進的TrieV1比Trie稍微節省一點內存約10%,速度差很少。進一步改進的TrieV2比Trie大大節省內存,只有HashSet的80%,不過速度比HashSet慢約1.5個數量級。

 

TrieV2實現了節省內存的目標,節省了約70%,可是速度也慢了,慢了約10倍,能夠對TrieV2作進一步優化,TrieNode的數組children採用有序數組,採用二分查找來加速。

 

下面看看TrieV3的實現:



 

使用了一個新的方法insert來加入數組元素,從無到有構建有序數組,把新的元素插入到已有的有序數組中,insert的代碼以下:

 

        /**
         * 將一個字符追加到有序數組
         * @param array 有序數組
         * @param element 字符
         * @return 新的有序數字
         */
        private TrieNode[] insert(TrieNode[] array, TrieNode element){
            int length = array.length;
            if(length == 0){
                array = new TrieNode[1];
                array[0] = element;
                return array;
            }
            TrieNode[] newArray = new TrieNode[length+1];
            boolean insert=false;
            for(int i=0; i<length; i++){
                if(element.getCharacter() <= array[i].getCharacter()){
                    //新元素找到合適的插入位置
                    newArray[i]=element;
                    //將array中剩下的元素依次加入newArray便可退出比較操做
                    System.arraycopy(array, i, newArray, i+1, length-i);
                    insert=true;
                    break;
                }else{
                    newArray[i]=array[i];
                }
            }
            if(!insert){
                //將新元素追加到尾部
                newArray[length]=element;
            }
            return newArray;
        }

 

 

有了有序數組,在搜索的時候就能夠利用有序數組的優點,重構搜索方法getChild:



  

數組中的元素是TrieNode,因此須要自定義TrieNode的比較方法:



 

好了,一個基於有序數組的二分搜索的性能提高重構就完成了,良好的單元測試是重構的安全防禦網,沒有單元測試的重構就猶如高空走鋼索卻沒有防禦墊同樣危險,同時,不過早優化不作不成熟的優化是咱們應該謹記的原則,要根據應用的具體場景在算法的時空中作權衡。

 

OK,看看TrieV3的性能表現,固然了,內存使用沒有變化,和TrieV2同樣:

 

TrieV2         10000次查詢       cost time:50 ms
TrieV2         1000000次查詢     cost time:4361 ms
TrieV2         100000000次查詢   cost time:483398 ms
TrieV3         10000次查詢       cost time:21 ms
TrieV3         1000000次查詢     cost time:1264 ms
TrieV3         100000000次查詢   cost time:121740 ms

 

 

提高效果很明顯,約4倍。性能還有提高的空間嗎?呵呵......

 

代碼託管於GITHUB

 

 

參考資料:

一、中文分詞十年回顧 

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

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

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

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

 

NUTCH/HADOOP視頻教程

相關文章
相關標籤/搜索