说明

中文分词是很多文本分析的基础。最近一个项目,输入一个地址,需要识别出地址中包含的省市区街道等单词。与以往的分词技术不同。jieba/hanlp等常用的分词技术,除了基于词典,还有基于隐马尔科夫/条件随机场等机器学习技术对未登录词的分词,有一定的概率性。而我们所使用的地址识别,要求必须基于词库进行精确的分词。这些比较高级的分词技术反而成为了不必要的风险。

另外还有一个原因是,流行的分词技术对多用户词典和词典的动态管理支持也不是很好。本项目就实现了一个可以多词典间相互隔离的分词工具。

基于前缀词典树的中文分词概念简单。其中使用到的trie树/有向无环图(dag)/动态规划计算最长路径等算法,可以说是教科书一样的例子。所以我就自己实现了一遍,测试下来效果还不错。

Trie树(前缀词典树)

trie,又称前缀树字典树,是一种有序树,用于保存关联数组,其中的键通常是字符串。与二叉查找树不同,键不是直接保存在节点中,而是由节点在树中的位置决定。一个节点的所有子孙都有相同的前缀,也就是这个节点对应的字符串,而根节点对应空字符串。一般情况下,不是所有的节点都有对应的值,只有叶子节点和部分内部节点所对应的键才有相关的值。 

 

在图示中,键标注在节点中,值标注在节点之下。每一个完整的单词对应一个特定的整数。

键不需要被显式地保存在节点中。图示中标注出完整的单词,只是为了演示trie的原理。 

trie中的键通常是字符串,但也可以是其它的结构。trie的算法可以很容易地修改为处理其它结构的有序序列,比如一串数字或者形状的排列。比如,bitwise trie中的键是一串位元,可以用于表示整数或者内存地址。

在本实现中,节点的值主要是单词的词频。

初始化时,往根节点中添加一个单词,以及对应的词频。根节点生成子节点递归调用。但是这里方法并没有写成递归,而是写成了循环。因为,递归相对来说不好理解,而且debug也不显而易见。一个基本原则是,对于java这种非函数式语言,凡是使用递归的地方都可以改成循环。

1     public void fillSegment(char[] charArray, int wordFrequency) {
2         int count = charArray.length;
3         CharNode charNode = this;
4         for (int i = 0; i < count; i++) {
5             CharNode child = charNode.searchOrAddChild(charArray[i]);
6             charNode = child;
7         }
8         charNode.wordFrequency += wordFrequency;
9     }
View Code

初始化后,就是搜索了。传入一个字符串,指定从哪个字符开始搜索,找到所有可能匹配的单词。这里就体现出了trie树优于hashmap的地方。在trie树中,一旦找不到子节点,就可以返回了。但hash并不具有这种特性,hash需要把字符串的所有子字符串遍历一遍才知道。

 1     public List<HitWord> match(char[] chars, int beginIndex) {
 2         List<HitWord> result = new ArrayList<>();
 3         int count = chars.length;
 4         if (beginIndex >= count - 1) {
 5             return result;
 6         }
 7         CharNode charNode = this;
 8         for (int i = beginIndex; i < count; i++) {
 9             CharNode tmpCharNode = charNode.searchChild(chars[i]);
10             if (tmpCharNode == null) {
11                 break;
12             }
13             charNode = tmpCharNode;
14             if (tmpCharNode.wordFrequency > 0) {
15                 HitWord hitWord = new HitWord(i, tmpCharNode.wordFrequency);
16                 result.add(hitWord);
17             }
18         }
19         return result;
20     }
View Code

这里返回结果是一个list,因为一个字符串可能匹配到多个结果。比如“上海市”去匹配,词库里有“上海”和“上海市”,那返回的就是List.size() == 2的结果。

DAG(有向无环图)

一个语句根据词频生成一个有向无环图。分词的原理我懒得说了。看下这个链接吧。http://www.cnblogs.com/zhbzz2007/p/6084196.html。

生成dag的关键代码如下。

 1     private List<List<HitWord>> createDag(String sentence){
 2         List<List<HitWord>> result = new ArrayList<>();
 3         char[] chars = sentence.toCharArray();
 4         int count = chars.length;
 5         for(int i = 0; i < count; i++){
 6             List<HitWord> matchList = dictionaryMgr.match(chars, i, this.dictionaryKey);
 7             result.add(matchList);
 8         }
 9         return result;
10     }
View Code

生成的结果是一个二维List。第一维下标对应句子字符串下标。第二维下标对应图的路径:路径包含路径终点下标及路径权重(词频)。

下面是基于dag寻找最优路径。这里用到了动态规划的思想。由于每个节点都是有下标的,而且路径的起点下标恒大于终点下标,基于这个特点,其实这个动态规划实现的还是比较简单粗暴的。不过还好,还算非常简单有效。

 1     private Map<Integer, Integer> calculateRouter(List<List<HitWord>> dag){
 2         int count = dag.size();
 3         Map<Integer, Integer> router = new HashMap<>(count);
 4         double[] frequencies = new double[count + 1];
 5         for(int i = count - 1; i >= 0; i--){
 6             List<HitWord> list = dag.get(i);
 7             if(list.isEmpty()){
 8                 frequencies[i] = frequencies[i + 1];
 9                 continue;
10             }
11             for (HitWord hitWord : list) {
12                 int endIndex = hitWord.getEndIndex();
13                 double tmpFrequency = hitWord.getFrequency() + frequencies[endIndex + 1];
14                 if(tmpFrequency > frequencies[i]){
15                     frequencies[i] = tmpFrequency;
16                     router.put(i, endIndex);
17                 }
18             }
19         }
20         return router;
21     }
View Code

分词

有了路径,就可以分词了。路径是一个map,key为起点,value为终点。根据路径进行分词,就很简单了。

 1     private List<String> cutFromRouter(String sentence, Map<Integer, Integer> router){
 2         List<String> result = new ArrayList<>();
 3         int count = sentence.length();
 4         for(int i = 0; i < count; i++){
 5             int j = i;
 6             if(router.containsKey(i)){
 7                 j = router.get(i);
 8             }
 9             String word = sentence.substring(i, j + 1);
10             i = j;
11             result.add(word);
12         }
13         return  result;
14     }
View Code

最后是分词入口,将生成dag,生成router,分词的 三个方法连起来。

1     public List<String> cut(String sentence){
2         List<List<HitWord>> dag = this.createDag(sentence);
3         Map<Integer, Integer> router = this.calculateRouter(dag);
4         return this.cutFromRouter(sentence, router);
5     }
View Code

附:

代码git,https://github.com/shlugood/wordcut 如果感兴趣,可下载下来。pom项目,还带有测试词典和UT。

看下我跑的UT吧。

1 [北京, 顺义区, 李桥镇, 馨, 港, 庄, 园, 8, 区, 8, 0, 号, 楼, 1, 2, 3, 单, 元, 2, 0, 1]
2 [浙江, 杭州市, 余杭区, 五常街道, 西, 溪, 庭, 院, 9, 8, —, 2, —, 4, 4, 4]
3 [广东, 深圳市, 宝安区, 松岗街道, 松岗镇, 溪, 头, 村, 委, 西, 六, 十, 七, 巷, 一, 百, 二, 十, 三, 号]
View Code

 

 posted on 2018-07-22 17:01  谷堆曲线  阅读(1667)  评论(0编辑  收藏  举报