IK分詞器源碼解析(一):構(gòu)造字典樹

最近在搞ES堡牡,結(jié)合了IK分詞器午绳,偶然間看到IK的主詞典中有27萬的詞筒溃,加上其他的拓展詞庫差不多也有小一百萬了马篮,于是比較好奇IK是如何判斷用戶輸入的詞是否在詞庫中的,于是索性下載了IK的源碼讀一讀怜奖,接下來是分詞流程的解析浑测。
首先先看一下主類,是一個(gè)用來測試的類

public class IKSegmenterTest {
   static String text = "IK Analyzer是一個(gè)結(jié)合詞典分詞和文法分詞的中文分詞開源工具包歪玲。它使用了全新的正向迭代最細(xì)粒度切分算法迁央。";
   public static void main(String[] args) throws IOException {
       IKSegmenter segmenter = new IKSegmenter(new StringReader(text), false);
       Lexeme next;
       System.out.print("非智能分詞結(jié)果:");
       while((next=segmenter.next())!=null){
           System.out.print(next.getLexemeText()+" ");
       }
       System.out.println();
       System.out.println("----------------------------分割線------------------------------");
       IKSegmenter smartSegmenter = new IKSegmenter(new StringReader(text), true);
       System.out.print("智能分詞結(jié)果:");
       while((next=smartSegmenter.next())!=null) {
           System.out.print(next.getLexemeText() + " ");
       }
   }
}

然后從第一行的構(gòu)造器點(diǎn)擊去,這里解釋一下這個(gè)useSmart滥崩。IK分詞器有兩種分詞模式岖圈,一種是ik_max_word,這種模式下會對字符串進(jìn)行最細(xì)粒度的拆分钙皮,如會將“中華人民共和國人民大會堂”拆分為“中華人民共和國蜂科、中華人民、中華短条、華人导匣、人民共和國、人民茸时、共和國贡定、大會堂、大會可都、會堂等詞語缓待。而另一種ik_smart模式會做粗粒度的拆分比如拆分成“中華人民共和國、人民大會堂”汹粤。構(gòu)造器源碼如下

    /**
     * IK分詞器構(gòu)造函數(shù)
     * @param input 
     * @param useSmart 為true命斧,使用智能分詞策略
     * 
     * 非智能分詞:細(xì)粒度輸出所有可能的切分結(jié)果
     * 智能分詞: 合并數(shù)詞和量詞,對分詞結(jié)果進(jìn)行歧義判斷
     */
    public IKSegmenter(Reader input , boolean useSmart){
        this.input = input;
        this.cfg = DefaultConfig.getInstance();
        this.cfg.setUseSmart(useSmart);
        this.init();
    }

然后從getInstance點(diǎn)進(jìn)去看一下如何構(gòu)造的config

    /**
     * 返回單例
     * @return Configuration單例
     */
    public static Configuration getInstance(){
        return new DefaultConfig();
    }

在這里我們發(fā)現(xiàn)IK通過static搞了一個(gè)簡單的單例模式嘱兼,繼續(xù)追蹤new部分源碼

private DefaultConfig(){        
        props = new Properties();
        
        InputStream input = this.getClass().getClassLoader().getResourceAsStream(FILE_NAME);
        if(input != null){
            try {
                props.loadFromXML(input);
            } catch (InvalidPropertiesFormatException e) {
                e.printStackTrace();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

可以看到這里讀取了Resource文件夾下的配置文件放到了props里国葬,也就是IKAnalyzer.cfg.xml,它的配置文件讀取過程不像Spring那么復(fù)雜芹壕,直接就通過類庫就完成了汇四。這個(gè)文件主要是用來配置用戶自己拓展的詞庫的,順手貼一下配置文件踢涌。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd">  
<properties>  
    <comment>IK Analyzer 擴(kuò)展配置</comment>
    <!--用戶可以在這里配置自己的擴(kuò)展字典 -->
    <entry key="ext_dict">ext.dic;</entry> 
    <!--用戶可以在這里配置自己的擴(kuò)展停止詞字典-->
    <entry key="ext_stopwords">stopword.dic;</entry> 
</properties>

到這里算是把config初始化完成了通孽,我們回到最上面來看init方法里面是如何初始化的

/**
    * 初始化
    */
   private void init(){
       //初始化詞典單例
       Dictionary.initial(this.cfg);
       //初始化分詞上下文
       this.context = new AnalyzeContext(this.cfg);
       //加載子分詞器
       this.segmenters = this.loadSegmenters();
       //加載歧義裁決器
       this.arbitrator = new IKArbitrator();
   }

這里的代碼也是比較簡單的,能看出來這里是通過剛才加載的配置文件來加載詞庫睁壁,加載分詞器背苦。但是上下文和裁決器還是不知道是什么互捌,我們先看一下詞典是如何初始化的

   /**
    * 詞典初始化
    * 由于IK Analyzer的詞典采用Dictionary類的靜態(tài)方法進(jìn)行詞典初始化
    * 只有當(dāng)Dictionary類被實(shí)際調(diào)用時(shí),才會開始載入詞典行剂,
    * 這將延長首次分詞操作的時(shí)間
    * 該方法提供了一個(gè)在應(yīng)用加載階段就初始化字典的手段
    * @return Dictionary
    */
   public static Dictionary initial(Configuration cfg){
       if(singleton == null){
           synchronized(Dictionary.class){
               if(singleton == null){
                   singleton = new Dictionary(cfg);
                   return singleton;
               }
           }
       }
       return singleton;
   }

我們可以看到這里用了一個(gè)面試中經(jīng)常被問到但是我們從來沒用過(手動狗頭)的雙重檢查的方式實(shí)現(xiàn)了一個(gè)單例模式秕噪,然后點(diǎn)進(jìn)去看一下new方法部分

private Dictionary(Configuration cfg){
        this.cfg = cfg;
        this.loadMainDict();
        this.loadStopWordDict();
        this.loadQuantifierDict();
    }

我們可以看到這里load了三個(gè)詞典,分別是主詞典厚宰,停止詞詞典以及數(shù)量詞詞典腌巾,我們挑主詞典的加載看一下

    private void loadMainDict(){
        //建立一個(gè)主詞典實(shí)例
        _MainDict = new DictSegment((char)0);
        //讀取主詞典文件
        InputStream is = this.getClass().getClassLoader().getResourceAsStream(cfg.getMainDictionary());
        if(is == null){
            throw new RuntimeException("Main Dictionary not found!!!");
        }
        
        try {
            BufferedReader br = new BufferedReader(new InputStreamReader(is , "UTF-8"), 512);
            String theWord = null;
            do {
                theWord = br.readLine();
                if (theWord != null && !"".equals(theWord.trim())) {
                    _MainDict.fillSegment(theWord.trim().toLowerCase().toCharArray());
                }
            } while (theWord != null);
            
        } catch (IOException ioe) {
            System.err.println("Main Dictionary loading exception.");
            ioe.printStackTrace();
            
        }finally{
            try {
                if(is != null){
                    is.close();
                    is = null;
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        //加載擴(kuò)展詞典
        this.loadExtDict();
    }   

首先它new了一個(gè)DictSegment對象,并傳了一個(gè)0進(jìn)去铲觉,我們點(diǎn)擊去看一下澈蝙。

/**
 * 詞典樹分段,表示詞典樹的一個(gè)分枝
 */
class DictSegment implements Comparable<DictSegment>{
    
    //公用字典表撵幽,存儲漢字
    private static final Map<Character , Character> charMap = new HashMap<Character , Character>(16 , 0.95f);
    //數(shù)組大小上限
    private static final int ARRAY_LENGTH_LIMIT = 3;

    
    //Map存儲結(jié)構(gòu)
    private Map<Character , DictSegment> childrenMap;
    //數(shù)組方式存儲結(jié)構(gòu)
    private DictSegment[] childrenArray;
    
    
    //當(dāng)前節(jié)點(diǎn)上存儲的字符
    private Character nodeChar;
    //當(dāng)前節(jié)點(diǎn)存儲的Segment數(shù)目
    //storeSize <=ARRAY_LENGTH_LIMIT 灯荧,使用數(shù)組存儲, storeSize >ARRAY_LENGTH_LIMIT ,則使用Map存儲
    private int storeSize = 0;
    //當(dāng)前DictSegment狀態(tài) ,默認(rèn) 0 , 1表示從根節(jié)點(diǎn)到當(dāng)前節(jié)點(diǎn)的路徑表示一個(gè)詞
    private int nodeState = 0;  

通過查看DictSegment這個(gè)類并齐,我們可以確認(rèn)IK是使用的字典樹對用戶的輸入進(jìn)行的匹配漏麦,可以說是用空間換取了時(shí)間,這棵樹的根結(jié)點(diǎn)是0况褪,然后每個(gè)節(jié)點(diǎn)存儲一個(gè)字符撕贞。
然后根據(jù)config中的默認(rèn)配置把主詞典的文件加載到內(nèi)存中,這里是個(gè)循環(huán)测垛,一次加載一行捏膨。這里給大家看一下主詞典的文件,一行一個(gè)詞食侮,一共27萬行


image.png

然后我們往下看是如何使用加載到內(nèi)存中的詞進(jìn)行fillSegment操作的

    /**
     * 加載填充詞典片段
     * @param charArray
     */
    void fillSegment(char[] charArray){
        this.fillSegment(charArray, 0 , charArray.length , 1); 
    }

這里貌似沒什么好說的号涯,繼續(xù)往下看

    private synchronized void fillSegment(char[] charArray , int begin , int length , int enabled){
        //獲取字典表中的漢字對象
        Character beginChar = new Character(charArray[begin]);
        Character keyChar = charMap.get(beginChar);
        //字典中沒有該字,則將其添加入字典
        if(keyChar == null){
            charMap.put(beginChar, beginChar);
            keyChar = beginChar;
        }
        
        //搜索當(dāng)前節(jié)點(diǎn)的存儲锯七,查詢對應(yīng)keyChar的keyChar链快,如果沒有則創(chuàng)建
        DictSegment ds = lookforSegment(keyChar , enabled);
        if(ds != null){
            //處理keyChar對應(yīng)的segment
            if(length > 1){
                //詞元還沒有完全加入詞典樹
                ds.fillSegment(charArray, begin + 1, length - 1 , enabled);
            }else if (length == 1){
                //已經(jīng)是詞元的最后一個(gè)char,設(shè)置當(dāng)前節(jié)點(diǎn)狀態(tài)為enabled,
                //enabled=1表明一個(gè)完整的詞眉尸,enabled=0表示從詞典中屏蔽當(dāng)前詞
                ds.nodeState = enabled;
            }
        }

    }

首先這里把傳入的這一行字符串中的第一個(gè)詞拿了出來域蜗,放到一個(gè)用來存儲漢字的公共字典里,至于為什么不像主詞庫一樣放到文件里噪猾,可能是覺得維護(hù)兩個(gè)文件比較麻煩霉祸,也可能后面能看到原因,但是這里最讓我奇怪的是這個(gè)用來做公共字典的HashMap袱蜡,它的負(fù)載因子沒有用默認(rèn)的0.75丝蹭,而是用了0.95,這里是個(gè)需要之后思考的地方

    //公用字典表坪蚁,存儲漢字
    private static final Map<Character , Character> charMap = new HashMap<Character , Character>(16 , 0.95f);

然后走到lookforSegment方法奔穿,這里是將詞庫中的詞加入到字典樹的方法镜沽,也是一個(gè)比較重要的方法是,我們點(diǎn)進(jìn)去看一下

/**
     * 查找本節(jié)點(diǎn)下對應(yīng)的keyChar的segment  * 
     * @param keyChar
     * @param create  =1如果沒有找到巫橄,則創(chuàng)建新的segment ; =0如果沒有找到淘邻,不創(chuàng)建,返回null
     * @return
     */
    private DictSegment lookforSegment(Character keyChar ,  int create){
        
        DictSegment ds = null;

        if(this.storeSize <= ARRAY_LENGTH_LIMIT){
            //獲取數(shù)組容器湘换,如果數(shù)組未創(chuàng)建則創(chuàng)建數(shù)組
            DictSegment[] segmentArray = getChildrenArray();            
            //搜尋數(shù)組
            DictSegment keySegment = new DictSegment(keyChar);
            int position = Arrays.binarySearch(segmentArray, 0 , this.storeSize, keySegment);
            if(position >= 0){
                ds = segmentArray[position];
            }
        
            //遍歷數(shù)組后沒有找到對應(yīng)的segment
            if(ds == null && create == 1){
                ds = keySegment;
                if(this.storeSize < ARRAY_LENGTH_LIMIT){
                    //數(shù)組容量未滿,使用數(shù)組存儲
                    segmentArray[this.storeSize] = ds;
                    //segment數(shù)目+1
                    this.storeSize++;
                    Arrays.sort(segmentArray , 0 , this.storeSize);
                    
                }else{
                    //數(shù)組容量已滿统阿,切換Map存儲
                    //獲取Map容器彩倚,如果Map未創(chuàng)建,則創(chuàng)建Map
                    Map<Character , DictSegment> segmentMap = getChildrenMap();
                    //將數(shù)組中的segment遷移到Map中
                    migrate(segmentArray ,  segmentMap);
                    //存儲新的segment
                    segmentMap.put(keyChar, ds);
                    //segment數(shù)目+1 ,  必須在釋放數(shù)組前執(zhí)行storeSize++ 扶平, 確保極端情況下帆离,不會取到空的數(shù)組
                    this.storeSize++;
                    //釋放當(dāng)前的數(shù)組引用
                    this.childrenArray = null;
                }

            }           
            
        }else{
            //獲取Map容器,如果Map未創(chuàng)建,則創(chuàng)建Map
            Map<Character , DictSegment> segmentMap = getChildrenMap();
            //搜索Map
            ds = (DictSegment)segmentMap.get(keyChar);
            if(ds == null && create == 1){
                //構(gòu)造新的segment
                ds = new DictSegment(keyChar);
                segmentMap.put(keyChar , ds);
                //當(dāng)前節(jié)點(diǎn)存儲segment數(shù)目+1
                this.storeSize ++;
            }
        }

        return ds;
    }

我們可以看到结澄,IK的字典樹存儲規(guī)則是按是否超過ARRAY_LENGTH_LIMIT也就是3來決定的

  1. 如果沒超過3哥谷,則子節(jié)點(diǎn)用數(shù)組存儲。
  • 判斷如果當(dāng)前節(jié)點(diǎn)的存儲個(gè)數(shù)在3及以下麻献,先在數(shù)組中進(jìn)行一個(gè)二分查找们妥,如果能夠找到就把值拿出來。
  • 如果沒有找到且允許新建的話勉吻,再判斷是否容量超過3监婶,沒超過則直接使用數(shù)組存儲,加入到數(shù)組中并進(jìn)行sort齿桃,由于DictSegment重寫了compareTo方法惑惶,所以是按存儲字符的字典序進(jìn)行排序的。如果超過3短纵,則改為用map存儲带污,將之前數(shù)組中的內(nèi)容放到新建的map中并將數(shù)組的內(nèi)存釋放掉。
  1. 超過3則用map存儲香到。
  • 搜索整個(gè)map鱼冀,若有則把值拿出來,沒有則put

之后我們遞歸執(zhí)行這個(gè)過程养渴,直到這一個(gè)字符串中的所有字符都加入到字典樹中雷绢,當(dāng)最后一個(gè)字符放入字典樹時(shí),會設(shè)置一個(gè)標(biāo)記位理卑,也就是之前的enabled翘紊,來表示當(dāng)前是一個(gè)完整詞的結(jié)尾。當(dāng)主詞典所有詞都加入字典樹之后藐唠,主詞典加載完畢帆疟,然后加載擴(kuò)展詞典等其他詞典鹉究,過程完全一致

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市踪宠,隨后出現(xiàn)的幾起案子自赔,更是在濱河造成了極大的恐慌,老刑警劉巖柳琢,帶你破解...
    沈念sama閱讀 217,907評論 6 506
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件绍妨,死亡現(xiàn)場離奇詭異,居然都是意外死亡柬脸,警方通過查閱死者的電腦和手機(jī)他去,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,987評論 3 395
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來倒堕,“玉大人灾测,你說我怎么就攤上這事】寻停” “怎么了媳搪?”我有些...
    開封第一講書人閱讀 164,298評論 0 354
  • 文/不壞的土叔 我叫張陵,是天一觀的道長骤宣。 經(jīng)常有香客問我秦爆,道長,這世上最難降的妖魔是什么涯雅? 我笑而不...
    開封第一講書人閱讀 58,586評論 1 293
  • 正文 為了忘掉前任鲜结,我火速辦了婚禮,結(jié)果婚禮上活逆,老公的妹妹穿的比我還像新娘精刷。我一直安慰自己,他們只是感情好蔗候,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,633評論 6 392
  • 文/花漫 我一把揭開白布揣钦。 她就那樣靜靜地躺著抛蚤,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上蜕衡,一...
    開封第一講書人閱讀 51,488評論 1 302
  • 那天拴孤,我揣著相機(jī)與錄音口糕,去河邊找鬼譬胎。 笑死,一個(gè)胖子當(dāng)著我的面吹牛爬立,可吹牛的內(nèi)容都是我干的钾唬。 我是一名探鬼主播,決...
    沈念sama閱讀 40,275評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼抡秆!你這毒婦竟也來了奕巍?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,176評論 0 276
  • 序言:老撾萬榮一對情侶失蹤儒士,失蹤者是張志新(化名)和其女友劉穎的止,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體着撩,經(jīng)...
    沈念sama閱讀 45,619評論 1 314
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡诅福,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,819評論 3 336
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了睹酌。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片权谁。...
    茶點(diǎn)故事閱讀 39,932評論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖憋沿,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情沪猴,我是刑警寧澤辐啄,帶...
    沈念sama閱讀 35,655評論 5 346
  • 正文 年R本政府宣布,位于F島的核電站运嗜,受9級特大地震影響壶辜,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜担租,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,265評論 3 329
  • 文/蒙蒙 一砸民、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧奋救,春花似錦岭参、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,871評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至背亥,卻和暖如春秒际,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背狡汉。 一陣腳步聲響...
    開封第一講書人閱讀 32,994評論 1 269
  • 我被黑心中介騙來泰國打工娄徊, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人盾戴。 一個(gè)月前我還...
    沈念sama閱讀 48,095評論 3 370
  • 正文 我出身青樓寄锐,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個(gè)殘疾皇子锐峭,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,884評論 2 354