读IKAnalyzer源码之IK启动 lucene分词器中文分词分词器
程序员文章站
2022-05-17 12:32:28
...
IKAnalyzer非常流行的中文分词器,对中文切词有兴趣的朋友可以看看。
基本所有的框架都分两部分,一是:框架的初始化,也就是框架启动;二是:调用框架,让框架为我们做一些事。我们今天先来看看一下IK的初始化过程。
IKAnalyzer可以说一个非常流行的分词器了,但我觉得IKAnalyzer的代码写一般。
废话就不多说了,直接看源码吧。IKAnalyzer挂在Google上,直接到google下就好了,文档之类的都很全。
假设1,你用过IKAnalyzer,对IKAnalyzer有了解。或者,你想了解分词器。
假设2,你已经下到源码,建了一个Java SE的项目。
假如,如果你满足两个假设,那就一起来读读IKAnalyzer的源码吧。
新建一个类,主方法的内容如下:
PS:我们从IKSegment而不是从IKAnalyzer开始,如果从IKAnalyzer这个类开始,那么我们需要再加入Lucene3.1以上(Lucene4.0以下)的包。而且,基本都上Lucene的内容,暂时不关注,可以自行了解。
强调一下,IKAnalyzer的作者非常有心,看过源码的朋友应该知道,他注释很多。先赞一个。
从IKSegment的构造方法进来,
首先看 DefaultConfig.getInstance() 里面是这样的:
由于IKSegment的初始化实际上是IK自己做的,在IKTokenizer。因此,这种写法完全没有问题,它初始化次数绝对是串行。假如,我说假如哈。假如这个东西是并行,这个单例的写法就不合适了,会造成多次初始化。有兴趣的话要以比对着Dictionary.initial(this.cfg)看。当然,这不是我们今天关注的内容。
同时这个地方做资源初始化,也就是解析配置文件,有兴趣的朋友可以看看。
进到 this.init() 方法,代码也是中规中矩的没什么难点,但这是我们今天的关注点。从代码上可以看到,首先初始化了词典,然后初始化分词环境,……
在词典的初始化过程,
我觉得这段,如果按下来这么写兴许更好一些,你们觉得呢。
由_MainDict.fillSegment(theWord.trim().toLowerCase().toCharArray());进来,历经千辛万苦来到这里。下面的代码可以谓重中之得哈。有一个数据结构叫,字典树。不管你知不知道,它都在下面了。这里简单说一下吧,详情请登陆www.baidu.com。
字典树,跟什么B树啊、B+树啊、B-树都差不多,因此跟二叉树不大一样。它是这样的,它广度不定,小于字典字数(“一二三一”算三个字)。然后,节点带有一个状态,即是不是完整词、词性之类的。
最最重要的一点,它的字必须是重复引用的。即有一个拥有字典全部单字的集合。比如,对一个英文字词,它有一个集合放里26个字母,然后所有的节点的都从这个集合引用,从而降低内存的开销。
也就是说,它一棵广度不定、深度不定,并引用来自同一集合的对象的树。我能说的,这就这么多了,剩下的交给百度吧。
看一下下面的代码,也都比较简单,就是字典树的实现。
//搜索当前节点的存储,查询对应keyChar的keyChar,如果没有则创建。如:已有“中国”,再来一个“中国人”的时候,它首先发现“中”已存在,那就直接,一点也不客气。若没有,那只能创建了。
这段代码非常坑爹,但能提高效率。从这里可以知道,作者在效率方面下很大的功夫。有得有失,因此代码很复杂。中心思想是数组的效率比集合框架的效率高。具体是这样的,当前节点的宽度不大于ARRAY_LENGTH_LIMIT(默认是3)时,用数组来存;当宽度大于时,用HashMap来存。
接下来,还会加载量词词典及扩展词典,内容基本一样,自己看看就好了。整个过程也就优化这个位置有复杂,其它都还好,主要是要知道作者为什么要用先数组再用集合,这个地方清楚了,也就没啥问题。
后续,作者在用数组装的时候,用了折半搜索。我个人觉得,你竟然用了“折半查找”,为什么要长度设为3呢?这样首配对元素1,再配对元素2,最后配对元素3,对吧?而我们顺序查找也是这样的,所以我觉得这个参数值不大合理。或许作者有其它想法,我还没有参悟。
字典加载完了,回到IKSegmenter#init()继续下一步,即是初始化上下文环境。这个环境,分词器分词的环境,它提供分词基本条件和记录着分词状态。比如,需要分词的东西、分好词的词片、分词进到哪个位置等等等
。对上下文有兴趣的可以看看。
基本所有的框架都分两部分,一是:框架的初始化,也就是框架启动;二是:调用框架,让框架为我们做一些事。我们今天先来看看一下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()继续下一步,即是初始化上下文环境。这个环境,分词器分词的环境,它提供分词基本条件和记录着分词状态。比如,需要分词的东西、分好词的词片、分词进到哪个位置等等等
。对上下文有兴趣的可以看看。
上一篇: 冒泡排序、快速排序(快排)、KMP算法的Java实现
下一篇: MySQL数据库建表实践