1.中文分词概要
1.1什么是中文分词?
中文分词(Chinese Word Segmentation) 指的是将一个汉字序列切分成一个一个单独的词。分词就是将连续的字序列按照一定的规范重新组合成词序列的过程。
1.2分词方法的分类?
现有的分词方法可分为三大类:基于字符串匹配的分词方法、基于理解的分词方法和基于统计的分词方法。
1.2.1基于字符串匹配的分词方法
基于字符串匹配的分词方法又称机械分词方法:它是按照一定的策略将待分析的汉字串与一个“充分大的”机器词典中的词条进行配,若在词典中找到某个字符串,则匹配成功(识别出一个词)。
按照扫描方向的不同,字符串匹配分词方法可以分为正向匹配和逆向匹配;按照不同长度优先匹配的情况,可以分为最大(最长)匹配和最小(最短)匹配;按照是否与词性标注过程相结合,可以分为单纯分词方法和分词与词性标注相结合的一体化方法。常用的字符串匹配方法有如下几种:
(1)正向最大匹配法(从左到右的方向);
(2)逆向最大匹配法(从右到左的方向);
(3)最小切分(每一句中切出的词数最小);
(4)双向最大匹配(进行从左到右、从右到左两次扫描)
这类算法的优点是速度快,时间复杂度可以保持在O(n),实现简单,效果尚可;但对歧义和未登录词处理效果不佳。本文介绍的IKAnalyzer中文分词器采用的是这一分词方法。
1.2.2基于理解的分词方法
基于理解的分词方法是通过让计算机模拟人对句子的理解,达到识别词的效果。其基本思想就是在分词的同时进行句法、语义分析,利用句法信息和语义信息来处理歧义现象。它通常包括三个部分:分词子系统、句法语义子系统、总控部分。在总控部分的协调下,分词子系统可以获得有关词、句子等的句法和语义信息来对分词歧义进行判断,即它模拟了人对句子的理解过程。这种分词方法需要使用大量的语言知识和信息。由于汉语语言知识的笼统、复杂性,难以将各种语言信息组织成机器可直接读取的形式,因此目前基于理解的分词系统还处在试验阶段。
1.2.3 基于统计的分词方法
基于统计的分词方法是在给定大量已经分词的文本的前提下,利用统计机器学习模型学习词语切分的规律(称为训练),从而实现对未知文本的切分。例如最大概率分词方法和最大熵分词方法等。随着大规模语料库的建立,统计机器学习方法的研究和发展,基于统计的中文分词方法渐渐成为了主流方法。
主要的统计模型有:N元文法模型(N-gram),隐马尔可夫模型(Hidden Markov Model ,HMM),最大熵模型(ME),条件随机场模型(Conditional Random Fields,CRF)等。
在实际的应用中,基于统计的分词系统都需要使用分词词典来进行字符串匹配分词,同时使用统计方法识别一些新词,即将字符串频率统计和字符串匹配结合起来,既发挥匹配分词切分速度快、效率高的特点,又利用了无词典分词结合上下文识别生词、自动消除歧义的优点。
2.IK中文分词简介
2.1概要
2.1.1两种切分模式
IK分词器采用正向迭代最细粒度切分算法对字符串进行切分,支持最细粒度切分和智能切分两种模式。
1)最细粒度切分:将所有匹配的词元进行输出。
public static void main(String[] args) {
String t = "张三说的确实在理";
//使用最细粒度切分
IKSegmenter ikSegmenter = new IKSegmenter(new StringReader(t),false);
try {
Lexeme l = null;
while((l = ikSegmenter.next()) != null){
System.out.print(l.getLexemeText() + " | ");
}
}catch (IOException e){
e.printStackTrace();
}
}
结果:
张三 | 三 | 说的 | 的 | 的确 | 确实 | 实在 | 在理 |
2)智能切分:将所有匹配的词元进行歧义裁决(以及合并数词和量词),选出最合适的词元链。
public static void main(String[] args) {
String t = "张三说的确实在理";
//使用智能切分模式
IKSegmenter ikSegmenter = new IKSegmenter(new StringReader(t),true);
try {
Lexeme l = null;
while((l = ikSegmenter.next()) != null){
System.out.print(l.getLexemeText() + " | ");
}
}catch (IOException e){
e.printStackTrace();
}
}
结果:
张三 | 说的 | 确实 | 在理 |
2.1.2分词步骤
IK分词主要分为三部分:
1)字典加载:形成词典树(包括几个主词典、扩展词典以及扩展的停用词典)。
2)匹配切分:对输入文本进行切分,获得原始的词元。
3)歧义裁决:进行歧义裁决生成结果集。
IK分词器的源码包如下:
其中主要的三个包:
dic包主要包含:中、英文主词典、中文量词词典等资源以及Dictionary(字典树)和DictSegment(词典树的分支)。
core包主要包含:IKSegmenter(主分词器)、CJKSegment(中日韩子分词器)、CN_QuantifierSegmenter(中文量词子分词器)、EnglisghSegmenter(英文子分词器)等子分词器、AnalyzeContext(分词上下文)、Lexeme(词元)、LexemePath(词元链)、IKArbitrator(歧义裁决器)、QuickSortSet(IK专用快排集合,底层是双向链表)。
lucene包主要包含:lucene(搜索框架)支持相关的类。
3.字典加载
3.1字典树
1)基本概念
字典树,又称为单词查找树,Trie树,是一种树形结构。
如图,红色的节点status=1表示成词,绿色的节点status=0表示不成词。
除去根节点一共有21个节点,存储了15个词,分别是:
中 | 中华 | 中华人民 | 中国 | 中国人 | 中国人民 | 人 | 人民 | 人民万岁 | 人艰不拆 | 民 | 民生 | 民生银行 | 民心 | 民心所向 |
2)基本性质
根节点不包含字符,除根节点外的每一个子节点都包含一个字符
从根节点到某一节点。路径上经过的字符连接起来,就是该节点对应的字符串
每个节点的所有子节点包含的字符都不相同
3)应用场景
典型应用是用于统计,排序和保存大量的字符串(不仅限于字符串),经常被搜索引擎系统用于文本词频统计。
4)优点
利用字符串的公共前缀来减少查询时间,最大限度的减少无谓的字符串比较,查询效率比哈希树高。
3.2字典树的实现
3.2.1DictSegment(字典树分支类)
1)属性:
//公用字典表,存储汉字(所有对象共用)
private static final Map<Character, Character> charMap = new HashMap<Character, Character>(16, 0.95f);
//数组存储的界限,超过该值子节点的存储方式改为HashMap方式存储
private static final int ARRAY_LENGTH_LIMIT = 3;
//HashMap存储结构
private Map<Character, DictSegment> childrenMap;
//数组方式存储结构
private DictSegment[] childrenArray;
//标识当前分支为哪个字的分支
private Character nodeChar;
//当前节点存储的子节点(DictSegment)数目
//storeSize <=ARRAY_LENGTH_LIMIT ,使用数组存储, storeSize >ARRAY_LENGTH_LIMIT ,则使用Map存储
private int storeSize = 0;//禁用词依然统计
//0标识不成词,1标识成词
private int nodeState = 0;
需要注意的是,子节点的存储方式默认为数组方式存储,当子节点的数目大于3时,改为HashMap方式存储。
2)构造方法:
DictSegment(Character nodeChar) {
if (nodeChar == null) {
throw new IllegalArgumentException("参数为空异常,字符不能为空");
}
this.nodeChar = nodeChar;
}
3)主要方法:
/**
* 加载填充词典
* @param charArray 需要保存或禁用的词
* @param begin 当前节点在目标词中的下标
* @param length 剩余长度
* @param enabled 0表示禁用,1表示存储
*/
private synchronized void fillSegment(char[] charArray, int begin, int length, int enabled) {
//获取字典表中的汉字对象
Character beginChar = charArray[begin];
//在公用字典表中查找该字,若其中没有该字,则将其添加入公用字典表字典
Character keyChar = charMap.get(beginChar);
if (keyChar == null) {
charMap.put(beginChar, beginChar);
keyChar = beginChar;
}
//搜索当前节点的存储,查询对应keyChar的DicSigment,如果没有则创建
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;
}
}
}
注意:这一方法不仅可以增加词汇,还可以禁用词汇。enable值为0时,表示禁用已有词汇,enable值为1时表示增加词汇。
/**
* 在当前分支下,根据nodechar来查找对应的DictSegment子节点对象
* @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);
//搜索匹配规则是根据compareTo方法,这里是根据nodeChar来比较,nodeChar相等返回下标,没有就返回负值
int position = Arrays.binarySearch(segmentArray, 0, this.storeSize, keySegment);//中间参数是搜索范围
if (position >= 0) {
ds = segmentArray[position];
}
if (ds == null && create == 1) {//没有找到对应的segment,并且是创建模式,则创建子节点
ds = keySegment;
if (this.storeSize < ARRAY_LENGTH_LIMIT) {//子节点数<3,采用数组存储
//数组容量未满,使用数组存储
segmentArray[this.storeSize] = ds;
//segment数目+1
this.storeSize++;
//重新排序
Arrays.sort(segmentArray, 0, this.storeSize);
} else {//现有子节点数大于等于3,先将原来三个迁移至HashMap中,在增加新的子节点
//数组容量已满,切换Map存储
//获取HashMap存储的子节点集合,如果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 {//在HashMap中查找
//获取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;
}
这一方法的目的是在当前的DictSegment节点下,查找nodeChar为keyChar的子节点,如果是创建模式,没找到则创建节点字符为keyChar的子节点;如果是非创建模式,没找到返回null。
其中,由于作者根据子节点数的大小,选用数组或HashMap存储,在查找时,根据子节点数来判断从哪个集合查找,不超过3时在数组中找,没找到时(子节点数要增加),需要判断是否需要迁移至HashMap;子节点数大于3,则在HashMap中查找。
此外DictSegment还有一个用于匹配的match方法:
/**
* 匹配词段:
* 当searchHit为null时,调用方法的对象为根节点(某一字典树的)
* 当不为null时(在字典类有一个matchWithHit方法),方法调用对象为searchHit的DictSegmenter属性
* @param charArray 缓冲字符数组
* @param begin 此次匹配的字符在数组中的位置
* @param length CJK分词器传来都为1
* @param searchHit 指针在前一位匹配时的前缀匹配碰撞对象
* @return Hit 匹配碰撞结果(记录了前缀文本在缓冲数组中的位置范围信息以及上次碰撞的节点(也是本次匹配match方法的调用对象))
*/
Hit match(char[] charArray, int begin, int length, Hit searchHit) {//ik分词中中文分词此处length恒等于1
if (searchHit == null) {
//如果hit为空,新建
searchHit = new Hit();
//设置hit的起始文本位置
searchHit.setBegin(begin);
} else {
//否则要将前缀匹配初始化为不匹配
searchHit.setUnmatch();
}
//设置hit的文本结束位置(文本范围加1)
searchHit.setEnd(begin);
Character keyChar = 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 = (DictSegment) segmentMap.get(keyChar);
}
//STEP2 找到DictSegment,判断词的匹配状态,是否继续递归,还是返回结果
if (ds != null) {//找到了,更改掉hit的unMatch状态为{完全匹配、前缀匹配、叠加匹配}
if (length > 1) {//针对英文(中文不走这一分支)
//词未匹配完,继续往下搜索
//中文分词的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状态为前缀匹配
//如果前面状态已经设为match,这里setPrefix方法执行后hit的状态为0x00000011,为叠加态,完全匹配和前缀匹配都为真
//反之,这里setPrefix执行后,状态仅为前缀匹配
searchHit.setPrefix();
//记录当前位置的DictSegment
searchHit.setMatchedDictSegment(ds);
}
return searchHit;
}
}
return searchHit;
}
方法总的分为三步:
1:获得返回值对象:若searchHit不为null,则将其状态初始化为不匹配状态(保证后面状态计算正确);反之,创建碰撞对象。注意:在调用时,不为null时,实际上在碰撞节点下匹配,不是根节点。
2:在当前节点下,查找nodeChar为此时指针指向字符的子节点。(分别在数组和Map中查找)
3:根据查找结果设置碰撞的status属性值(不匹配、匹配、前缀匹配、叠加匹配)并返回结果。注意:这里set方法通过位运算来操作。
3.2.2Dictionary(字典类)
1)属性:
//自带的中文主字典文件路径
private static final String PATH_DIC_MAIN = "org/wltea/analyzer/dic/main2012.dic";
//自带中文量词字典路径
private static final String PATH_DIC_QUANTIFIER = "org/wltea/analyzer/dic/quantifier.dic";
//自带的英文字典路径
private static final String PATH_DIC_ENGLISH = "org/wltea/analyzer/dic/english.dic";
//字典的单例对象
private static Dictionary singleton;
//扩展的词集合,外部传入
private CharArraySet words;
//停用词集合,外部传入
private CharArraySet stopWords;
//中文主字典树
private DictSegment _MainDict;
//英文字典树
private DictSegment _EnglishDict;
//停用字典树
private DictSegment _StopWordDict;
//中文量词字典树
private DictSegment _QuantifierDict;
2)构造方法:
//单例模式(私有的构造方法)
private Dictionary(CharArraySet words, CharArraySet stopWords) {
this.words = words;
this.stopWords = stopWords;
//加载中文主字典
this.loadMainWord();
//加载英文字典
this.loadEnglishWord();
//将扩展词加入主字典树
this.loadExtWord();
//加载停用字典典
this.loadStopWord();
//加载中文量词字典
this.loadQuantifierWord();
}
/**
* 获取词典单子实例,对提供的获得字典对象的接口
*
* @return Dictionary 单例对象
*/
public static Dictionary getSingleton() {
if (singleton == null) {
throw new IllegalStateException("词典尚未初始化,请先调用initial方法");
}
return singleton;
}
/**
* 词典初始化
* 由于IK Analyzer的词典采用Dictionary类的静态方法进行词典初始化
* 只有当Dictionary类被实际调用时,才会开始载入词典,
* 这将延长首次分词操作的时间
* 该方法提供了一个在应用加载阶段就初始化字典的手段
* @return Dictionary
*/
public static Dictionary initial(CharArraySet words, CharArraySet stopWords) {//传入的是扩展词的词典
if (singleton == null) {
synchronized (Dictionary.class) {
if (singleton == null) {
singleton = new Dictionary(words, stopWords);
return singleton;
}
}
}
return singleton;
}
字典对象采用单例模式,提供了一个对外的静态初始化方法,避免首次分词操作时间过长。
3)主要方法:
词典的加载以中文主词典的加载为例:
/**
* 加载主词典
*/
private void loadMainWord() {
//建立一个中文主字典树实例,根节点为字符'0'
_MainDict = new DictSegment((char) 0);
//读取主词典文件
InputStream is = Dictionary.class.getClassLoader().getResourceAsStream(PATH_DIC_MAIN);
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();
}
}
}
过程是:找到文件当行读取每一个词元,转为数组调用DictSegment实例的fillSegment方法,无需多讲。
此外,Dictionary有许多匹配相关的方法,内部是调用DictSegment对象的的match方法,将在词元匹配模块讲到。
4.词元匹配(以中文分词对象为例)
4.1AnalyzeContext(分词上下文)
1)属性
//默认缓冲区大小
private static final int BUFF_SIZE = 4096;
//缓冲区耗尽的临界值(当指针到缓冲区临界值时会将剩余字符重新填充后面补充读入新的字符)
private static final int BUFF_EXHAUST_CRITICAL = 100;
//字符窜读取缓冲
private char[] segmentBuff;
//字符类型数组
private int[] charTypes;
//记录Reader内已分析的字串总长度
//在分多段分析词元时,该变量累计当前的segmentBuff相对于reader起始位置的位移
private int buffOffset;
//当前缓冲区位置指针
private int cursor;
//最近一次读入的,可处理的字串长度
private int available;
//子分词器锁
//该集合非空,说明有子分词器在占用segmentBuff
private Set<String> buffLocker;
//原始分词结果集合,未经歧义处理
private QuickSortSet orgLexemes;
//LexemePath位置索引表
private Map<Integer, LexemePath> pathMap;
//最终分词结果集
private LinkedList<Lexeme> results;
注意:这里QuickSortSet(原始分词结果集)是链表,在后面详细讲。
2)构造方法
public AnalyzeContext(boolean useSmart) {
this.useSmart = useSmart;
this.segmentBuff = new char[BUFF_SIZE];
this.charTypes = new int[BUFF_SIZE];
this.buffLocker = new HashSet<String>();
this.orgLexemes = new QuickSortSet();
this.pathMap = new HashMap<Integer, LexemePath>();
this.results = new LinkedList<Lexeme>();
}
传入分词模式即可。
3)主要方法
填充文本:fillBuffer
//填充文本到缓冲字符数组
int fillBuffer(Reader reader) throws IOException {
int readCount = 0;
if (this.buffOffset == 0) {
//首次读取reader
readCount = reader.read(segmentBuff);
} else {
int offset = this.available - this.cursor;//剩余需要填充的文本
if (offset > 0) {//处理本次缓冲区快要耗尽时的重新填充
//最近一次读取的>最近一次处理的,将未处理的字串拷贝到segmentBuff头部
System.arraycopy(this.segmentBuff, this.cursor, this.segmentBuff, 0, offset);
readCount = offset;
}
//继续读取reader ,以onceReadIn - onceAnalyzed为起始位置,继续填充segmentBuff剩余的部分
readCount += reader.read(this.segmentBuff, offset, BUFF_SIZE - offset);
}
//记录最后一次从Reader中读入的可用字符长度
this.available = readCount;
//重置当前指针
this.cursor = 0;
return readCount;
}
方法效果:重新填充缓冲字符数组,返回本次读入的有效字符数组
获取下一个词元(并处理合并):getNextLexeme
Lexeme getNextLexeme() {
//从结果集取出,并移除第一个Lexme
Lexeme result = this.results.pollFirst();
while (result != null) {
//数量词合并 英文2/3词组合,增加组合词元不会覆盖原有的词元
this.compound(result);
//停止词继续去下一个
if (Dictionary.getSingleton().isStopWord(this.segmentBuff, result.getBegin(),result.getLength()){
//是停止词继续取列表的下一个
result = this.results.pollFirst();
} else {//不是停止词
if (result.getLexemeText() == null || result.getLexemeText().isEmpty()) {
//如果为设置词元文本, 生成lexeme的词元文本,输出
result.setLexemeText(String.valueOf(segmentBuff, result.getBegin(), result.getLength()));
}
break;//合并结束,跳出循环
}
}
return result;
}
方法效果:返回下一个词元,如果存在合并,则在集合中增加组合词元(不覆盖原有的词)。
/**
* 推送分词结果到结果集合
* 1.从buff头部遍历到this.cursor已处理位置
* 2.将map中存在的分词结果推入results
* 3.将map中不存在的CJK字符以单字方式推入results
*/
void outputToResult() {
int index = 0;
for (; index <= this.cursor; ) {
LexemePath path = this.pathMap.get(index);
if (path != null) {
//输出LexemePath中的lexeme到results集合
Lexeme l = path.pollFirst();
while (l != null) {
this.results.add(l);
//将index移至lexeme后
index = l.getBegin() + l.getLength();
l = path.pollFirst();
if (l != null) {
//输出path内部,词元间遗漏的单字
for (; index < l.getBegin(); index++) {
this.outputSingleCJK(index);
}
}
}
} else {//pathMap中找不到index对应的LexemePath
//单字输出
this.outputSingleCJK(index);
index++;
}
}
//清空当前的Map
this.pathMap.clear();
}
遍历缓冲数组,遇到单字则以单字存入结果集,存在于词元链的将词元链每个词元放入结果集。
4.2IkSegmenter(主分词器)
1)属性:
//字符窜reader(待分词的文本)
private Reader input;
//分词器配置项
private boolean useSmart;
//扩展词典
private CharArraySet words;
//扩展停止词典
private CharArraySet stopWords;
//分词器上下文
private AnalyzeContext context;
//分词处理器列表
private List<ISegmenter> segmenters;
//分词歧义裁决器
private IKArbitrator arbitrator;
2)构造方法:
public IKSegmenter(Reader input, boolean useSmart) {
this.input = input;
this.useSmart = useSmart;
this.init();
}
/**
* IK分词器,由TokenizerFactory提供词典
* @param input Reader
* @param useSmart 为true,使用智能分词策略
* @param words 分词词典
* @param stopWords 停止词词典
*/
public IKSegmenter(Reader input, boolean useSmart, CharArraySet words, CharArraySet stopWords) {
this.input = input;
this.useSmart = useSmart;
this.words = words;
this.stopWords = stopWords;
this.init();
}
/**
* 初始化
*/
private void init() {
//初始化词典单例
Dictionary.initial(this.words, this.stopWords);
//初始化分词上下文
this.context = new AnalyzeContext(useSmart);
//加载子分词器
this.segmenters = this.loadSegmenters();
//加载歧义裁决器
this.arbitrator = new IKArbitrator();
}
3)主要方法:
获取下一分词:
/**
* 分词,获取下一个词元
* @return Lexeme 词元对象
* @throws IOException
*/
public synchronized Lexeme next() throws IOException {
Lexeme l;
while ((l = context.getNextLexeme()) == null) {//若果词元为null,则填充、分词、歧义消除,次元放进结果集
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.useSmart);//歧义消除的部分分析
//将分词结果输出到结果集,并处理未切分的单个CJK字符
context.outputToResult();
//记录本次分词的缓冲区位移
context.markBufferOffset();
}
return l;
}
方法步骤:从结果集取出第一个词元,不为null怎返回,为null时:重新填充缓冲数组,根据返回值判断是否分词完所有文本,是则返回null,否则重新分词获得原始词元、经过歧义消除获得分词结果。(歧义裁决在后面讲)
4.3CJKSegementer(中日韩分词器)
1)属性:
//子分词器标签
static final String SEGMENTER_NAME = "CJK_SEGMENTER";
//待处理的分词hit队列
private List<Hit> tmpHits;
tmpHits中存储的的是之前的前缀匹配碰撞队列。
2)主要方法:
public void analyze(AnalyzeContext context) {
if (CharacterUtil.acceptChar(context.getCurrentChar()) || CharacterUtil.CHAR_USELESS != context.getCurrentCharType()) {
//1:优先处理tmpHits中的hit
if (!this.tmpHits.isEmpty()) {
//处理词段队列
Hit[] tmpArray = this.tmpHits.toArray(new Hit[this.tmpHits.size()]);
for (Hit hit : tmpArray) {
//在hit包含的DictSegmenter属性节点下,开始匹配得到碰撞结果
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);
}
}
}
//2:再对当前指针位置的字符进行单字匹配
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()) {//若status为0x00000011,则这里为真
//前缀匹配则放入hit列表
this.tmpHits.add(singleCharHit);
}
} else if (singleCharHit.isPrefix()) {//首字为词前缀
//前缀匹配则放入hit列表
this.tmpHits.add(singleCharHit);
}
} else {
//遇到CHAR_USELESS字符,继续匹配
//清空队列
this.tmpHits.clear();
}
//缓冲区因为上锁(使得needFillBuffer为false),可能指针到数组末尾,此时必须要清空队列,解锁缓冲数组,才能进行重新填充,可能会丟词。
if (context.isBufferConsumed()) {
//清空队列,这样才能解锁,重新填充
this.tmpHits.clear();
}
if (this.tmpHits.size() == 0) {
context.unlockBuffer(SEGMENTER_NAME);
} else {
context.lockBuffer(SEGMENTER_NAME);
}
}
方法分为3步:
1:将前缀匹配队列中的元素取出在此匹配当前字符,存在匹配状态则将词元加入原始结果集,不存在前缀状态,不需要继续匹配,则移出队列;不匹配则同样移出。
2:在根节点下匹配当前字符,处理同上。
3:缓冲区上锁、解锁。上锁是因为仅在本次缓冲数组中,hit队列记录的位置信息才是正确的;在碰到无用字符时,不需要再继续进行匹配,此时解锁,另外,当指针移动到数组末尾,不得不清空队列,从而解锁缓冲数组,才能进行重新填充(needFillBuffer为true)。
疑问:当tmpHits数组不为空,而指针移动到4095位置,此时强行清空队列,是否真的丟词?
4.4QuickSortSet(快排集合)
如图:链表结构
5.歧义裁决
5.1LexemePath(词元链)
1) 简述:
LexemePath类继承了QuickSortSet类,它本身就是一个集合。歧义裁决的过程实际上是将原始词元进行分组,分组规则:相交的词元为一条链,各条链之间互不相交。对每一条内部相交词元链,进行歧义处理得到一组不相交词元链,组内每一条链对应一种分词可能(组内联按照某种排序规则,最优的链放在最前面),选组第一条链即可。
2)属性:
//相对起始位置<闭区间>
private int pathBegin;
//相对结束位置<开区间>
private int pathEnd;
//词元链的有效字符长度
private int payloadLength;
3)主要方法:
boolean addCrossLexeme(Lexeme lexeme) {
if (this.isEmpty()) {
this.addLexeme(lexeme);
this.pathBegin = lexeme.getBegin();
this.pathEnd = lexeme.getBegin() + lexeme.getLength();
this.payloadLength += lexeme.getLength();
return true;
} else if (this.checkCross(lexeme)) {
this.addLexeme(lexeme);
if (lexeme.getBegin() + lexeme.getLength() > this.pathEnd) {
this.pathEnd = lexeme.getBegin() + lexeme.getLength();
}
this.payloadLength = this.pathEnd - this.pathBegin;
return true;
} else {
return false;
}
}
向当前词元链中插入一个相交词元,词元相交则插入成功返回true,词元不相交则返回false。此外还有一个addNotCrossLexeme方法,与之相反。
5.2IKArbitrator(歧义裁决器)
1)主要方法:
void process(AnalyzeContext context, boolean useSmart) {
QuickSortSet orgLexemes = context.getOrgLexemes();
Lexeme orgLexeme = orgLexemes.pollFirst();
LexemePath crossPath = new LexemePath();
while (orgLexeme != null) {
if (!crossPath.addCrossLexeme(orgLexeme)) {//不相交词元
//1 处理原有相交链
if (crossPath.size() == 1 || !useSmart) {//,非智能模式不用歧义处理
context.addLexemePath(crossPath);//将原有链加入分词上下文的处理集合中
} else {//非智能模式原有相交链结束,可以进行其已处理,然后加入结果集。
//对当前的crossPath进行歧义处理
QuickSortSet.Cell headCell = crossPath.getHead();
//将原链进行裁决,返回裁决结果,加入结果集。
LexemePath judgeResult = this.judge(headCell, crossPath.getPathLength());
//输出歧义处理结果judgeResult
context.addLexemePath(judgeResult);
}
//2初始化新链
crossPath = new LexemePath();
crossPath.addCrossLexeme(orgLexeme);
}
orgLexeme = orgLexemes.pollFirst();
}
//处理最后的path
if (crossPath.size() == 1 || !useSmart) {
//crossPath没有歧义 或者 不做歧义处理
//直接输出当前crossPath
context.addLexemePath(crossPath);
} else {
//对当前的crossPath进行歧义处理
QuickSortSet.Cell headCell = crossPath.getHead();
LexemePath judgeResult = this.judge(headCell, crossPath.getPathLength());
//输出歧义处理结果judgeResult
context.addLexemePath(judgeResult);
}
}
思路:将原始词元集合依次加入词元链中,中间如果词元跟原有词元链不相交,则需要做两件事:
1.处理原有词元链(智能模式需要进行裁决),将结果链加入上下文集合。
2.形成新链,向,新链中加入相交词元。
/**
* 歧义识别
* @param lexemeCell 歧义路径链表头
* @param fullTextLength 歧义路径文本长度(无用)
* @return
*/
private LexemePath judge(QuickSortSet.Cell lexemeCell, int fullTextLength) {
//候选路径集合
TreeSet<LexemePath> pathOptions = new TreeSet<LexemePath>();
//默认结果路径
LexemePath option = new LexemePath();
//对crossPath进行一次遍历,同时返回本次遍历中有冲突的Lexeme栈
Stack<QuickSortSet.Cell> lexemeStack = this.forwardPath(lexemeCell, option);
//当前词元链并非最理想的,加入候选路径集合
pathOptions.add(option.copy());
//存在歧义词,处理
QuickSortSet.Cell c = null;
while (!lexemeStack.isEmpty()) {
c = lexemeStack.pop();
//回滚option词元链至与当前词元无交叉
this.backPath(c.getLexeme(), option);
//从歧义词位置开始,生成可选方案
this.forwardPath(c, option);
pathOptions.add(option.copy());
}
//返回集合中的最优方案
return pathOptions.first();
}
以文本:"张三说的确实在理"为例:
原始分词结果:张三 | 三 |c
形成两条词相交词元:
L1:张三 | 三
L2:说的 | 的 | 的确 | 确实 | 实在 | 在理 |
对L2进行歧义裁决过程如下:
前向遍历形成默认无冲突链和冲突栈:
confilctStack:的 | 的确 | 实在
L21:说的 | 确实 | 在理 将L21加入候选集
处理冲突栈:
弹出词元:实在
回滚L21至与词元无冲突:L21:说的 然后前向遍历形成新的候选链
L22:说的 | 实在
…
L23:的确 | 实在
L24:的 | 确实 | 在理
判断L21最优,返回L21。
词元链的择优规则如下:
public int compareTo(LexemePath o) {
//比较有效文本长度
if (this.payloadLength > o.payloadLength) {
return -1;
} else if (this.payloadLength < o.payloadLength) {
return 1;
} else {
//比较词元个数,越少越好
if (this.size() < o.size()) {
return -1;
} else if (this.size() > o.size()) {
return 1;
} else {
//路径跨度越大越好
if (this.getPathLength() > o.getPathLength()) {
return -1;
} else if (this.getPathLength() < o.getPathLength()) {
return 1;
} else {
//根据统计学结论,逆向切分概率高于正向切分,因此位置越靠后的优先
if (this.pathEnd > o.pathEnd) {
return -1;
} else if (pathEnd < o.pathEnd) {
return 1;
} else {
//词长越平均越好
if (this.getXWeight() > o.getXWeight()) {
return -1;
} else if (this.getXWeight() < o.getXWeight()) {
return 1;
} else {
//词元位置权重比较
if (this.getPWeight() > o.getPWeight()) {
return -1;
} else if (this.getPWeight() < o.getPWeight()) {
return 1;
}
}
}
}
}
}
return 0;
}
1:有效文本越长越优;
2:词元个数越小越优
3:路径跨度越大越好
4:词元链位置越靠后越优
5:词元长度越平均越好
6:词元位置权重越大越好(词元长度与词元在链中的位置乘积之和)
6.丟词问题
public static void main(String[] args) {
StringBuffer t = new StringBuffer("北京天安门");
IKSegmenter ikSegmenter = new IKSegmenter(new StringReader(t.toString()),false);
try {
Lexeme l = null;
while((l = ikSegmenter.next()) != null){
System.out.print(l.getLexemeText() + " | ");
}
}catch (IOException e){
e.printStackTrace();
}
}
分词结果:北京 | 天安 | 天安门 |
试着改变字符串长度:
public static void main(String[] args) {
StringBuffer t = new StringBuffer("");
for(int i= 0;i<3990;i++){
t.append("不");
}
String str = "西南接到北平朋友的信说生活虽艰难还不至如传说之甚说北平的街上还跟从前差不多"
+ "的样子是的北平就是粮食贵得凶别的还差不离儿因为只有粮食贵得凶所以从上海来的人简直松了一大"
+ "口气只说便宜呀便宜我们从重庆来的却没有这胃北京天安门";
t.append(str);//length = 4097
System.out.println("字符个数:" + t.length());
IKSegmenter ikSegmenter = new IKSegmenter(new StringReader(t.toString()),false);
try {
Lexeme l = null;
while((l = ikSegmenter.next()) != null){
System.out.print(l.getLexemeText() + " | ");
}
}catch (IOException e){
e.printStackTrace();
}
}
分词结果:
不… | 西南 | … | 北京 | 天 | 天安 | 安 | 门 |
发现丢失 :天安门
原因在于:指针移动到4095,指向’安’时,tmpHit队列中有前缀:天安,而此时已经到了缓冲数组末尾,前置清空碰撞队列,重新填充缓冲数组,使得前缀’天安’丢失,无法前缀匹配到’门’,故’天安门’丢失。