欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

读IKAnalyzer源码之IK启动 lucene分词器中文分词分词器 

程序员文章站 2022-05-17 12:32:28
...
IKAnalyzer非常流行的中文分词器,对中文切词有兴趣的朋友可以看看。

基本所有的框架都分两部分,一是:框架的初始化,也就是框架启动;二是:调用框架,让框架为我们做一些事。我们今天先来看看一下IK的初始化过程。
IKAnalyzer可以说一个非常流行的分词器了,但我觉得IKAnalyzer的代码写一般。

废话就不多说了,直接看源码吧。IKAnalyzer挂在Google上,直接到google下就好了,文档之类的都很全。

假设1,你用过IKAnalyzer,对IKAnalyzer有了解。或者,你想了解分词器。
假设2,你已经下到源码,建了一个Java SE的项目。

假如,如果你满足两个假设,那就一起来读读IKAnalyzer的源码吧。


新建一个类,主方法的内容如下:
public static void main(String[] args){  
    IKSegmenter seg = new IKSegmenter(new StringReader("中国人民"), true);  
    try {  
        Lexeme lex = seg.next();  
        while(lex != null){  
            System.out.println(lex);  
            lex = seg.next();  
        }  
    } catch (IOException e) {  
        e.printStackTrace();  
    }  
}  

PS:我们从IKSegment而不是从IKAnalyzer开始,如果从IKAnalyzer这个类开始,那么我们需要再加入Lucene3.1以上(Lucene4.0以下)的包。而且,基本都上Lucene的内容,暂时不关注,可以自行了解。


强调一下,IKAnalyzer的作者非常有心,看过源码的朋友应该知道,他注释很多。先赞一个。


从IKSegment的构造方法进来,
/** 
 * IK分词器构造函数 
 * @param input  
 * @param useSmart 为true,使用智能分词策略 
 *  
 * 非智能分词:细粒度输出所有可能的切分结果 
 * 智能分词: 合并数词和量词,对分词结果进行歧义判断 
 */  
public IKSegmenter(Reader input , boolean useSmart){  
    this.input = input;  
    this.cfg = DefaultConfig.getInstance();  
    this.cfg.setUseSmart(useSmart);  
    this.init();  
}  


首先看 DefaultConfig.getInstance() 里面是这样的:
/** 
 * 返回单例 
 * @return Configuration单例 
 */  
public static Configuration getInstance(){  
    return new DefaultConfig();  
}  

由于IKSegment的初始化实际上是IK自己做的,在IKTokenizer。因此,这种写法完全没有问题,它初始化次数绝对是串行。假如,我说假如哈。假如这个东西是并行,这个单例的写法就不合适了,会造成多次初始化。有兴趣的话要以比对着Dictionary.initial(this.cfg)看。当然,这不是我们今天关注的内容。
同时这个地方做资源初始化,也就是解析配置文件,有兴趣的朋友可以看看。


进到 this.init() 方法,代码也是中规中矩的没什么难点,但这是我们今天的关注点。从代码上可以看到,首先初始化了词典,然后初始化分词环境,……
/** 
 * 初始化 
 */  
private void init(){  
    //初始化词典单例  
    Dictionary.initial(this.cfg);  
    //初始化分词上下文  
    this.context = new AnalyzeContext(this.cfg);  
    //加载子分词器  
    this.segmenters = this.loadSegmenters();  
    //加载歧义裁决器  
    this.arbitrator = new IKArbitrator();  
} 

在词典的初始化过程,

   
/** 
     * 加载主词典及扩展词典 
     */  
    private void loadMainDict(){  
        //建立一个主词典实例  
        _MainDict = new DictSegment((char)0);  
        //读取主词典文件  
        InputStream is = this.getClass().getClassLoader().getResourceAsStream(cfg.getMainDictionary());  
        if(is == null){  
            throw new RuntimeException("Main Dictionary not found!!!");  
        }  
          
        try {  
            // 这里转码,原因是我们的词典都是UTF-8,但运行环境不一定。  
            BufferedReader br = new BufferedReader(new InputStreamReader(is , "UTF-8"), 512);  
            String theWord = null;  
            do {  
                //到了这里,我建议大家先看一目词典文件(/src/org/wltea/analyzer/dic/main2012.dic)。现在,我们基本了解词典文件是怎么样的了。因此,你也就知道,它为什么是一行一行读取的了。  
                theWord = br.readLine();  
                if (theWord != null && !"".equals(theWord.trim())) {  
                    // toLowerCase()是规范化,毕竟索引不分大小写。  
                    _MainDict.fillSegment(theWord.trim().toLowerCase().toCharArray());  
                }  
            } while (theWord != null);  
        }  
        ……  
    }   
 

我觉得这段,如果按下来这么写兴许更好一些,你们觉得呢。
String theWord = br.readLine();  
while(theWord != null){  
    if(!theWord.trim().isEmpty()){  
        _MainDict.fillSegment(theWord.trim().toLowerCase().toCharArray());  
    }  
}  

由_MainDict.fillSegment(theWord.trim().toLowerCase().toCharArray());进来,历经千辛万苦来到这里。下面的代码可以谓重中之得哈。有一个数据结构叫,字典树。不管你知不知道,它都在下面了。这里简单说一下吧,详情请登陆www.baidu.com。


字典树,跟什么B树啊、B+树啊、B-树都差不多,因此跟二叉树不大一样。它是这样的,它广度不定,小于字典字数(“一二三一”算三个字)。然后,节点带有一个状态,即是不是完整词、词性之类的。


最最重要的一点,它的字必须是重复引用的。即有一个拥有字典全部单字的集合。比如,对一个英文字词,它有一个集合放里26个字母,然后所有的节点的都从这个集合引用,从而降低内存的开销。


也就是说,它一棵广度不定、深度不定,并引用来自同一集合的对象的树。我能说的,这就这么多了,剩下的交给百度吧。


看一下下面的代码,也都比较简单,就是字典树的实现。
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;  
    }   

     
    //搜索当前节点的存储,查询对应keyChar的keyChar,如果没有则创建。如:已有“中国”,再来一个“中国人”的时候,它首先发现“中”已存在,那就直接,一点也不客气。若没有,那只能创建了。 
   
DictSegment ds = lookforSegment(keyChar , enabled);  
    if(ds != null){  
        //处理keyChar对应的segment  
        if(length > 1){  
            //词元还没有完全加入词典树  
            ds.fillSegment(charArray, begin + 1, length - 1 , enabled);  
        }else if (length == 1){  
            //已经是词元的最后一个char,设置当前节点状态为enabled,  
            //enabled=1表明一个完整的词,enabled=0表示从词典中屏蔽当前词  
            ds.nodeState = enabled;  
        }  
    }  
} 


这段代码非常坑爹,但能提高效率。从这里可以知道,作者在效率方面下很大的功夫。有得有失,因此代码很复杂。中心思想是数组的效率比集合框架的效率高。具体是这样的,当前节点的宽度不大于ARRAY_LENGTH_LIMIT(默认是3)时,用数组来存;当宽度大于时,用HashMap来存。

/** 
* 查找本节点下对应的keyChar的segment    
*  
* @param keyChar 
* @param create  =1如果没有找到,则创建新的segment ; =0如果没有找到,不创建,返回null 
* @return 
*/  
rivate DictSegment lookforSegment(Character keyChar ,  int create){  
  
DictSegment ds = null;  
  
if(this.storeSize <= ARRAY_LENGTH_LIMIT){  
    //获取数组容器,如果数组未创建则创建数组  
    DictSegment[] segmentArray = getChildrenArray();              
    //搜寻数组  
    DictSegment keySegment = new DictSegment(keyChar);  
    int position = Arrays.binarySearch(segmentArray, 0 , this.storeSize, keySegment);  
    if(position >= 0){  
        ds = segmentArray[position];  
    }  
  
    //遍历数组后没有找到对应的segment  
    if(ds == null && create == 1){  
        ds = keySegment;  
        if(this.storeSize < ARRAY_LENGTH_LIMIT){  
            //数组容量未满,使用数组存储  
            segmentArray[this.storeSize] = ds;  
            //segment数目+1  
            this.storeSize++;  
            Arrays.sort(segmentArray , 0 , this.storeSize);  
              
        }else{  
            //数组容量已满,切换Map存储  
            //获取Map容器,如果Map未创建,则创建Map  
            Map<Character , DictSegment> segmentMap = getChildrenMap();  
            //将数组中的segment迁移到Map中  
            migrate(segmentArray ,  segmentMap);  
            //存储新的segment  
            segmentMap.put(keyChar, ds);  
            //segment数目+1 ,  必须在释放数组前执行storeSize++ , 确保极端情况下,不会取到空的数组  
            this.storeSize++;  
            //释放当前的数组引用  
            this.childrenArray = null;  
        }  
    }             
      
}else{  
    //获取Map容器,如果Map未创建,则创建Map  
    Map<Character , DictSegment> segmentMap = getChildrenMap();  
    //搜索Map  
    ds = (DictSegment)segmentMap.get(keyChar);  
    if(ds == null && create == 1){  
        //构造新的segment  
        ds = new DictSegment(keyChar);  
        segmentMap.put(keyChar , ds);  
        //当前节点存储segment数目+1  
        this.storeSize ++;  
    }  
}  
  
  
return ds; 


接下来,还会加载量词词典及扩展词典,内容基本一样,自己看看就好了。整个过程也就优化这个位置有复杂,其它都还好,主要是要知道作者为什么要用先数组再用集合,这个地方清楚了,也就没啥问题。
后续,作者在用数组装的时候,用了折半搜索。我个人觉得,你竟然用了“折半查找”,为什么要长度设为3呢?这样首配对元素1,再配对元素2,最后配对元素3,对吧?而我们顺序查找也是这样的,所以我觉得这个参数值不大合理。或许作者有其它想法,我还没有参悟。

字典加载完了,回到IKSegmenter#init()继续下一步,即是初始化上下文环境。这个环境,分词器分词的环境,它提供分词基本条件和记录着分词状态。比如,需要分词的东西、分好词的词片、分词进到哪个位置等等等
。对上下文有兴趣的可以看看。