IKAnalyzer 源码走读
首先摘抄一段关于IK的特性介绍:
采用了特有的“正向迭代最细粒度切分算法”,具有60万字/秒的高速处理能力。
采用了多子处理器分析模式,支持:英文字母(IP地址、Email、URL)、数字(日期,常用中文数量词,罗马数字,科学计数法),中文词汇(姓名、地名处理)等分词处理。
优化的词典存储,更小的内存占用。支持用户词典扩展定义。
针对Lucene全文检索优化的查询分析器IKQueryParser,采用歧义分析算法优化查询关键字的搜索排列组合,能极大的提高Lucene检索的命中率。
Part1:词典
从上述内容可知,IK是一个基于词典的分词器,首先我们需要了解IK包含哪些词典?如果加载词典?
IK包含哪些词典?
主词典
停用词词典
量词词典
如何加载词典?
IK的词典管理类为Dictionary,单例模式。主要将以文件形式(一行一词)的词典加载到内存。
以上每一类型的词典都是一个DictSegment对象,DictSegment可以理解成树形结构,每一个节点又是一个DictSegment对象。
节点的子节点采用数组(DictSegment[])或map(Map(Character, DictSegment))存储,选用标准根据子节点的数量而定。
如果子节点的数量小于等于ARRAY_LENGTH_LIMIT,采用数组存储;
如果子节点的数量大于ARRAY_LENGTH_LIMIT,采用Map存储。
ARRAY_LENGTH_LIMIT默认为3。
这么做的好处是:
子节点多的节点在向下匹配时(find过程),用Map可以保证匹配效率。
子节点不多的节点在向下匹配时,在保证效率的前提下,用数组节约存储空间。
数组匹配实现如下(二分查找):
int position = Arrays.binarySearch(segmentArray, 0, this.storeSize, keySegment);
其中加载词典的过程如下:
1)加载词典文件
2)遍历词典文件每一行内容(一行一词),将内容进行初处理交给DictSegment进行填充。
初处理:theWord.trim().toLowerCase().toCharArray()
3)DictSegment填充过程
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;
}
} }
/**
* 查找本节点下对应的keyChar的segment *
* @param keyChar
* @param create =1如果没有找到,则创建新的segment ; =0如果没有找到,不创建,返回null
* @return
*/
private 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 = segmentMap.get(keyChar);
if (ds == null && create == 1) {
//构造新的segment
ds = new DictSegment(keyChar);
segmentMap.put(keyChar, ds);
//当前节点存储segment数目+1
this.storeSize++;
}
} return ds;
}
(IK作者注释太全面了,不再做赘述!)
举个例子,例如“人民共和国”的存储结构如下图:
Part2:分词
IK的分词主类是IKSegmenter,他包括如下重要属性:
Read:待分词内容
Configuration:分词器配置,主要控制是否智能分词,非智能分词能细粒度输出所有可能的分词结果,智能分词能起到一定的消歧作用。
AnalyzerContext:分词器上下文,这是个难点。其中包含了字符串缓冲区、字符串类型数组、缓冲区位置指针、子分词器锁、原始分词结果集合等。
List<ISegment>:分词处理器列表,目前IK有三种类型的分词处理器,如下:
- CJKSegmenter:中文-日韩文子分词器
- CN_QuantifierSegmenter:中文数量词子分词器
- LetterSegmenter:英文字符及阿拉伯数字子分词器
IKArbitrator:分词歧义裁决器
在IKSegment中主要的方法是next(),如下:
/**
* 分词,获取下一个词元
* @return Lexeme 词元对象
* @throws IOException
*/
public synchronized Lexeme next() throws IOException {
if (this.context.hasNextResult()) {
//存在尚未输出的分词结果
return this.context.getNextLexeme();
} else {
/*
* 从reader中读取数据,填充buffer
* 如果reader是分次读入buffer的,那么buffer要进行移位处理
* 移位处理上次读入的但未处理的数据
*/
int available = context.fillBuffer(this.input);
if (available <= 0) {
//reader已经读完
context.reset();
return null; } else {
//初始化指针
context.initCursor();
do {
//遍历子分词器
for (ISegmenter segmenter : segmenters) {
segmenter.analyze(context);
}
//字符缓冲区接近读完,需要读入新的字符
if (context.needRefillBuffer()) {
break;
}
//向前移动指针
} while (context.moveCursor());
//重置子分词器,为下轮循环进行初始化
for (ISegmenter segmenter : segmenters) {
segmenter.reset();
}
}
//对分词进行歧义处理
this.arbitrator.process(context, this.cfg.useSmart());
//处理未切分CJK字符
context.processUnkownCJKChar();
//记录本次分词的缓冲区位移
context.markBufferOffset();
//输出词元
if (this.context.hasNextResult()) {
return this.context.getNextLexeme();
}
return null;
}
}
这个过程主要做3件事:
1)将输入读入缓冲区(AnalyzerContext.fillBuffer());
2)移动缓冲区指针,同时对指针所指字符进行处理(进行字符规格化-全角转半角、大写转小写处理)以及类型判断(识别字符类型),将所指字符交由子分词器进行处理;
3)字符缓冲区接近读完时停止移动缓冲区指针,对当前分词器上下文(AnalyzerContext)中的原始分词结果进行歧义消除、处理一些残余字符,为下一次读入缓冲区做准备。最后输出词条。
在这个过程中,一些中间状态都记录在分词器上下文当中,可以理解IK作者当时的设计思路。
在上面next()方法当中,最主要的步骤是调用各个子分词器的analyze()方法,这里重点介绍CJKSegmenter,如下:
public void analyze(AnalyzeContext context) {
if (CharacterUtil.CHAR_USELESS != context.getCurrentCharType()) { //优先处理tmpHits中的hit
if (!this.tmpHits.isEmpty()) {
//处理词段队列
Hit[] tmpArray = this.tmpHits.toArray(new Hit[this.tmpHits.size()]);
for (Hit hit : tmpArray) {
hit = Dictionary.getSingleton().matchWithHit(context.getSegmentBuff(),
context.getCursor(), hit);
if (hit.isMatch()) {
//输出当前的词
Lexeme newLexeme = new Lexeme(context.getBufferOffset(), hit.getBegin(),
context.getCursor() - hit.getBegin() + 1, Lexeme.TYPE_CNWORD);
context.addLexeme(newLexeme); if (!hit.isPrefix()) {//不是词前缀,hit不需要继续匹配,移除
this.tmpHits.remove(hit);
} } else if (hit.isUnmatch()) {
//hit不是词,移除
this.tmpHits.remove(hit);
}
}
} //*********************************
//再对当前指针位置的字符进行单字匹配
Hit singleCharHit = Dictionary.getSingleton().matchInMainDict(context.getSegmentBuff(),
context.getCursor(), 1);
if (singleCharHit.isMatch()) {//首字成词
//输出当前的词
Lexeme newLexeme = new Lexeme(context.getBufferOffset(), context.getCursor(), 1,
Lexeme.TYPE_CNWORD);
context.addLexeme(newLexeme); //同时也是词前缀
if (singleCharHit.isPrefix()) {
//前缀匹配则放入hit列表
this.tmpHits.add(singleCharHit);
}
} else if (singleCharHit.isPrefix()) {//首字为词前缀
//前缀匹配则放入hit列表
this.tmpHits.add(singleCharHit);
} } else {
//遇到CHAR_USELESS字符
//清空队列
this.tmpHits.clear();
} //判断缓冲区是否已经读完
if (context.isBufferConsumed()) {
//清空队列
this.tmpHits.clear();
} //判断是否锁定缓冲区
if (this.tmpHits.size() == 0) {
context.unlockBuffer(SEGMENTER_NAME); } else {
context.lockBuffer(SEGMENTER_NAME);
}
}
这里需要注意tmpHits,在匹配的过程中属于前缀匹配的临时放入tmpHits,hit中记录词典匹配过程中当前匹配到的词典分支节点,可以继续匹配。
在遍历tmpHits的过程中,如果不是前缀词(全匹配)、或者不匹配则从tmpHits中移除。遇到遇到CHAR_USELESS字符、或者缓冲队列已经读完,则清空tmpHits。
是否匹配由DictSegment的match()方法决定。
(时时刻刻想想那棵字典树!)
什么时候上下文会收集临时词条呢?
1)首字成词的情况(如果首字还是前缀词,同时加入tmpHits,待后继处理)
2)在遍历tmpHits的过程中如果“全匹配”,也会加入临时词条。
下面再了解下match()方法,如下:
/**
* 匹配词段
* @param charArray
* @param begin
* @param length
* @param searchHit
* @return Hit
*/
Hit match(char[] charArray, int begin, int length, Hit searchHit) { if (searchHit == null) {
//如果hit为空,新建
searchHit = new Hit();
//设置hit的其实文本位置
searchHit.setBegin(begin);
} else {
//否则要将HIT状态重置
searchHit.setUnmatch();
}
//设置hit的当前处理位置
searchHit.setEnd(begin); Character keyChar = new Character(charArray[begin]);
DictSegment ds = null; //引用实例变量为本地变量,避免查询时遇到更新的同步问题
DictSegment[] segmentArray = this.childrenArray;
Map<Character, DictSegment> segmentMap = this.childrenMap; //STEP1 在节点中查找keyChar对应的DictSegment
if (segmentArray != null) {
//在数组中查找
DictSegment keySegment = new DictSegment(keyChar);
int position = Arrays.binarySearch(segmentArray, 0, this.storeSize, keySegment);
if (position >= 0) {
ds = segmentArray[position];
} } else if (segmentMap != null) {
//在map中查找
ds = segmentMap.get(keyChar);
} //STEP2 找到DictSegment,判断词的匹配状态,是否继续递归,还是返回结果
if (ds != null) {
if (length > 1) {
//词未匹配完,继续往下搜索
return ds.match(charArray, begin + 1, length - 1, searchHit);
} else if (length == 1) { //搜索最后一个char
if (ds.nodeState == 1) {
//添加HIT状态为完全匹配
searchHit.setMatch();
}
if (ds.hasNextNode()) {
//添加HIT状态为前缀匹配
searchHit.setPrefix();
//记录当前位置的DictSegment
searchHit.setMatchedDictSegment(ds);
}
return searchHit;
} }
//STEP3 没有找到DictSegment, 将HIT设置为不匹配
return searchHit;
}
注意hit几个状态的判断:
//Hit不匹配
private static final int UNMATCH = 0x00000000;
//Hit完全匹配
private static final int MATCH = 0x00000001;
//Hit前缀匹配
private static final int PREFIX = 0x00000010;
在进入match方法时,hit都会被重置为unMatch,然后根据Character获取子节点集合的节点。
如果节点为NULL,hit状态就是unMatch。
如果节点存在,且nodeState为1,hit状态就是match,
同时还要判断节点的子节点数量是否大于0,如果大于0,hit状态还是prefix。
(时时刻刻想想那棵字典树!)
对一次buffer处理完后,需要对上下文中的临时分词结果进行消歧处理(具体下文再分析)、词条输出。
在词条输出的过程中,需要判断每一个词条是否match停用词表,如果match则抛弃该词条。
Part3:消歧
稍等!
IKAnalyzer 源码走读的更多相关文章
- Apache Spark源码走读之23 -- Spark MLLib中拟牛顿法L-BFGS的源码实现
欢迎转载,转载请注明出处,徽沪一郎. 概要 本文就拟牛顿法L-BFGS的由来做一个简要的回顾,然后就其在spark mllib中的实现进行源码走读. 拟牛顿法 数学原理 代码实现 L-BFGS算法中使 ...
- Apache Spark源码走读之16 -- spark repl实现详解
欢迎转载,转载请注明出处,徽沪一郎. 概要 之所以对spark shell的内部实现产生兴趣全部缘于好奇代码的编译加载过程,scala是需要编译才能执行的语言,但提供的scala repl可以实现代码 ...
- Apache Spark源码走读之13 -- hiveql on spark实现详解
欢迎转载,转载请注明出处,徽沪一郎 概要 在新近发布的spark 1.0中新加了sql的模块,更为引人注意的是对hive中的hiveql也提供了良好的支持,作为一个源码分析控,了解一下spark是如何 ...
- Apache Spark源码走读之7 -- Standalone部署方式分析
欢迎转载,转载请注明出处,徽沪一郎. 楔子 在Spark源码走读系列之2中曾经提到Spark能以Standalone的方式来运行cluster,但没有对Application的提交与具体运行流程做详细 ...
- twitter storm 源码走读之5 -- worker进程内部消息传递处理和数据结构分析
欢迎转载,转载请注明出处,徽沪一郎. 本文从外部消息在worker进程内部的转化,传递及处理过程入手,一步步分析在worker-data中的数据项存在的原因和意义.试图从代码实现的角度来回答,如果是从 ...
- storm-kafka源码走读之KafkaSpout
from: http://blog.csdn.net/wzhg0508/article/details/40903919 (五)storm-kafka源码走读之KafkaSpout 原创 2014年1 ...
- spring-data-redis-cache 使用及源码走读
预期读者 准备使用 spring 的 data-redis-cache 的同学 了解 @CacheConfig,@Cacheable,@CachePut,@CacheEvict,@Caching 的使 ...
- ConcurrentHashMap源码走读
目录 ConcurrentHashMap源码走读 简介 放入数据 容器元素总数更新 容器扩容 协助扩容 遍历 ConcurrentHashMap源码走读 简介 在从JDK8开始,为了提高并发度,Con ...
- underscorejs 源码走读笔记
Underscore 简介 Underscore 是一个JavaScript实用库,提供了类似Prototype.js的一些功能,但是没有继承任何JavaScript内置对象.它弥补了部分jQuery ...
随机推荐
- python 加密 hashlib与hmac模块
https://www.jb51.net/article/128911.htm hashlib模块简介: hashlib模块为不同的安全哈希/安全散列(Secure Hash Algorithm)和 ...
- PHP5和PHP7的安装、PHP和apache的整合!
1.PHP5的安装: 下载: wget -c http://cn2.php.net/distributions/php-5.6.36.tar.gz (php5) wget -c http://cn2 ...
- poj 2155 B - Matrix 二维树状数组
#include<iostream> #include<string> #include<string.h> #include<cstdio> usin ...
- emacs安装
1.我选用的是Ubuntu16.04. 2.Ubuntu安装好之后不能直接sudo apt-get install emacs,因为Ubuntu的源默认是emacs24,最好是用最新的emacs25, ...
- buckaroo 去中心化的c++包管理工具
buckaroo 是一款去中心化的c++ 包管理工具,使用上,类似yarn(nodejs),cargo (rust) ,使用buckaroo 我们可以很容易集成一个大规模的项目 支持以下特性 直接从G ...
- 13机器学习实战之PCA(1)
降维技术 对数据进行降维有如下一系列的原因: 使得数据集更容易使用 降低很多算法的计算开销 去除噪音 使得结果易懂 在以下3种降维技术中, PCA的应用目前最为广泛,因此本章主要关注PCA. 主成分分 ...
- 02HDFS架构
https://www.cnblogs.com/zhoujingyu/p/5040957.html https://blog.csdn.net/firstchange/article/details/ ...
- 让Windows XP的键盘“说话”,全语音键盘
长期面对无声的电脑,我们难免疲倦.如果正在输入的内容被系统一字(字母)不差地念出来,你还能在无声的疲倦中输入错误的内容吗?本文以Windows 2000/XP中一个鲜为人知的“讲述人”为例,来教你DI ...
- C#遍历菜单项
(1)横向遍历 ToolStripMenuItem foreach (ToolStripMenuItem con in this.MainMenuStrip.Items) { ...
- MySQL 学习资料
MySQL 学习资料: MySQL 学习资料 网址 MySQL 教程(菜鸟教程) http://www.runoob.com/mysql/mysql-tutorial.html MySQL 教程(极客 ...