从零开始实现中文分词器(1)

前言

前阵子面试的到时候有个面试官问到,你知不知道分词器怎么实现的?当时老实回答,确实不知道。随后面试官就说有空的时候可以看看。

不过看归看,总感觉如果不自己实现一下的话还是很难达到掌握的程度,于是有个想法,从零开始实现一下分词器吧。

分词器介绍

一直以来中文分词都是比较头痛的事情,因为不像英语那样,词语之间有空格隔开。(其实英文也有词组分割问题)

最早的中文分词方法就是查字典:把一个句子从左到右扫描一遍,遇到字典里有的词就标识出来,遇到复合词(比如“上海大学”)就找最长的词匹配,遇到不认识的字串分割成单词。

比如(从左到右遍历):

今天天气很好(首先分隔符在 "今") ->
今/天天气很好(继续遍历发现 "今天"词典中有,就把分隔符往右挪) -> 
今天/天气很好(词典不存在“今天天”这个词,于是在第二个“天”后面新设一个分割符) ->
今天/天/气很好(如此类推) ->
... ->
今天/天气/很好(完成了分词)

存在问题:

  1. 复杂度太高
  2. 二义性问题("发展中国家",应该分词成"发展/中/国家",而采用从左到右查字典会被分割成"发展/中国/家")

随后有一些学者开始注意到统计信息的作用,假定一个句子S可以有几种分词方法(假定为3种):

A1,A2,A3,...,Ak
B1,B2,B3,...,Bm
C1,C2,C3,...,Cn

其中Ai, Bi, Ci都是汉语中的词,那么从统计学的角度看,最好的分词方法那么这个句子出现的概率应该是最大的。即如果A1,A2,A3,...,Ak是最好的分词方法,那么其概率应该满足:

P(A1,A2,A3,...,Ak) > P(B1,B2,B3,...,Bm)` 且`P(A1,A2,A3,...,Ak) > P(C1,C2,C3,...,Cn)

用穷举法计算概率计算量是相当巨大的,可以使用动态规划进行优化,算法过程大致如下:

image.png

通过统计模型能够很好地解决分词的二义性问题(也叫歧义性)。

分词器实现思路

上面已经介绍了中文分词的原理:根据已有词典形成各种组合使得句子概率最大化但是具体怎么实现的还是不清楚。下面就从两个问题入手,逐渐认识分词器。

  1. 词典加载进内存是怎么样的,用什么数据结构?
  2. 从输入一个文本到输出分词结果的完整步骤是怎么样的?

先来看看词典,下面是github当前比较热门的两个分词器(前者是ES的中文分词插件,后者是一个python中文分词模块)中的部分词典内容。

ik词典

一一列举
一一对应
一一道来
一丁
一丁不识
一丁点
一丁点儿
一七八不
...

jieba词典

# 词语 词频 词性
AT&T 3 nz
B超 3 n
c# 3 nz
C# 3 nz
c++ 3 nz
C++ 3 nz
T恤 4 n
A座 3 n
A股 3 n
A型 3 n
...

从上面摘抄的部分词典中可以看出,词典基本是按字典顺序排,即相邻的词很可能有相同的前缀,聪明的同学可能很快就get到,这用前缀树(Trie)来存储不就很合适吗?(如果你以前不知道前缀树是什么东西,那看下面就知道了)

Trie树,又称字典树,单词查找树或者前缀树,是一种用于快速检索的多叉树结构,如英文字母的字典树是一个26叉树,数字的字典树是一个10叉树。

一个节点的所有子孙都有相同的前缀(prefix),Trie树利用字符串的公共前缀来节约存储空间。如下图所示,该Trie树用11个节点保存了8个字符串tea,ted,ten,to,A,i,in,inn。

img

图片来源:

https://www.cnblogs.com/rush/archive/2012/12/30/2839996.html(这篇文章也解释了怎么实现前缀树)

接下来会先从Trie的实现开始逐步介绍怎么实现一个分词器,会比较啰嗦,如果没耐心的话可以直接看上面的文章的TrieTree的实现

相信通过上面的图你可以清楚前缀树的物理结构,那么我们就先把前缀树需要的一些属性列出来:

Trie树所需要的属性

这里顺便说一下java的基础知识: Java中char的使用的是UTF-8,所以任意一个中文其实是可以用一个单位的char来存储的。

首先我们从一个树节点入手,一个节点中必须会有对应的 value 吧,然后还可能有下层子节点 children ,由于子节点可能不止一个,为了方便就使用HashMap来存储所有的子节点:

// 最初可以确定有以下两个属性
char value;
Map<Character, TrieNode> childrenMap;

接下来思考,是不是缺少了点什么?假如给定任意一个节点,那如何确定怎么到达这个节点的路径呢(即这个节点的前缀是什么)?

于是我们再引入 parent 属性来存储当前节点的父节点的引用,emmm,顺便再引入当前节点的深度 deep

如果这里想不到引入这两个属性的话其实后续遇到打印节点的方法时也会想到

TrieNode parent;
int deep;

Trie树的构造方法

主要就是考虑父节点和深度的计算(这里把上面的属性也一起列出来)

public class TrieNode {

    char value;
    Map<Character, TrieNode> childrenMap;
    TrieNode parent;
    int deep;


    public TrieNode(TrieNode parent, char value) {
        this.parent = parent;
        this.value = value;
        // 假定根节点不存储有意义的值,深度为0
        if (parent == null)
            deep = 0;
        else
            deep = parent.deep + 1;
    }
}

Trie树的词典加载方法

构造完Trie树之后,回到最初的需求,我们是要做一个能装载词典的数据结构,那么首要的功能当然是加载词典。

  1. 为了处理方便,把每个传入的字符串(词语)转化成队列(这样能够减少subString的开销)
  2. 加载一个词其实是简单的递归创建过程:第一个字符是否已经存在?若存在则直接进入,若不存在则先创建再进入,然后继续判断第二个字符串是否已经在第一个字符串的 childrenMap 里面,如果不存在则创建...按照这种流程递归下去。(不用考虑溢出问题,一般单个词不会很长)
    /**
     * 加载字符
     * */
    public void load(Queue<Character> wordQueue) {
        if (wordQueue.isEmpty())
            return;
        // 弹出队列中第一个字符
        char c = wordQueue.poll();
        if (childrenMap == null)
            childrenMap = new HashMap<>();
        TrieNode node = childrenMap.computeIfAbsent(c, s -> new TrieNode(this, c));
        // 如果队列非空,继续递归加载剩余字符
        if (!wordQueue.isEmpty())
            node.load(wordQueue);
    }

    /**
     * 将字符串转化成字符队列的静态方法
     * */
    public static Queue<Character> string2Queue(String str) {
        Queue<Character> queue = new LinkedList<>();
        for (char c:str.toCharArray()) {
            queue.add(c);
        }
        return queue;
    }

TrieNode 类中加入上面两个方法,基本的词典前缀树就完成了,下面测试一下词典加载:

//下面代码在 public static void main(String[] args) 方法中执行

// 初始化树根节点,置parent=null, value=' '
TrieNode node = new TrieNode(null, ' ');
node.load(TrieNode.string2Queue("北京大学"));
node.load(TrieNode.string2Queue("北京交通大学"));

进入DEBUG模式

image.png

可以看到,内存中两个词共用了"北京"前缀,且深度属性也正常运作

Trie树的匹配方法

既然上面我们已经完成了词典的加载,接下来就应该做词的匹配了:

给定一串文本,如何判断哪些词是词典中存在的?

再简化下问题:给定一串文本,如何识别出文本中存在于词典中的词?

先来模拟一下匹配流程:

比如,之前的例子中,加载了"北京大学"和"北京交通大学"两个词,当我输入"去北京大学玩"这样一个文本的时候,应该要识别出其中的"北京大学"

最简单的做法其实就是遍历:"去北京大学玩","北京大学玩","京大学玩","大学玩"...看下有没有符合前缀的。如果有符合前缀的就开始遍历。这里只有"北京大学玩"是符合前缀,然后开始遍历这个子字符串,最终遍历到"学"的时候发现存在一个满足的词语"北京大学"。

这里会发现一个问题:怎么在遍历到"学"的时候能够知道匹配上了一个词?这时候其实在 TrieNode 类中补充一个标记属性即可

// 判断到当前节点是否为一个词
boolean isWord = false;

并且在加载词典的时候补充几行(12~14)

    public void load(Queue<Character> wordQueue) {
        if (wordQueue.isEmpty())
            return;
        // 弹出队列中第一个字符
        char c = wordQueue.poll();
        if (childrenMap == null)
            childrenMap = new HashMap<>();
        TrieNode node = childrenMap.computeIfAbsent(c, s -> new TrieNode(this, c));
        // 如果队列非空,继续递归加载剩余字符
        if (!wordQueue.isEmpty())
            node.load(wordQueue);
        else
            // 队列为空了,说明当前节点是最后一个字符,刚好成一个词
            node.isWord = true;
    }

梳理清楚之后,就可以开始写对应的匹配方法了

    public static void match(TrieNode node, String word) {
        if (word == null || word.length() == 0)
            return;
        System.out.println(String.format("开始对\"%s\"进行匹配:", word));
        // 对输入字符串的所有子串均进行前缀匹配
        for (int i = 0; i < word.length(); i++)
            match(node, word, i);
    }

    private static void match(TrieNode node, String word, int index) {
        // 要考虑边界情况
        if (index >= word.length() || node.childrenMap == null)
            return;
        // 取出当前位置的字符进行匹配
        char c = word.charAt(index);
        TrieNode child = node.childrenMap.get(c);
        // 子节点存在对应字符才能往下遍历/判断
        if (child != null) {
            if (child.isWord) {
                char[] w = new char[child.deep];
                TrieNode n = child;
                while (n != null && n.deep != 0) {
                    w[n.deep - 1] = n.value;
                    n = n.parent;
                }
                // 当找到一个匹配的词语时直接打印
                System.out.println(String.valueOf(w));
            }
            match(child, word, index + 1);
        }
    }

回到main方法,在原来的基础上多增加match的测试:

TrieNode node = new TrieNode(null, ' ');
node.load(TrieNode.string2Queue("北京大学"));
node.load(TrieNode.string2Queue("北京交通大学"));

TrieNode.match(node, "去北京大学玩");
TrieNode.match(node, "去北京交通大学玩");
TrieNode.match(node, "去北京交通大学玩北京大学");
// 输出:
开始对"去北京大学玩"进行匹配:
北京大学
开始对"去北京交通大学玩"进行匹配:
北京交通大学
开始对"去北京交通大学玩北京大学"进行匹配:
北京交通大学
北京大学

今天就先到此为止了,后续文章再继续深入

参考

结巴分词

IK分词

中文分词原理理解+jieba分词详解(二)

<<数学之美>>

Trie树和Ternary Search树的学习总结

完整TrieNode类实现

此处代码只是到当前文章为止所介绍到内容的实现,后续随着分词器的逐步完善会不断修改。

package edqi.lucene.util;


import java.util.HashMap;
import java.util.LinkedList;
import java.util.Map;
import java.util.Queue;

/**
 * @Description
 * @auther edqi
 * @create 2020-05-21 23:33
 */

public class TrieNode {

    char value;
    Map<Character, TrieNode> childrenMap;
    TrieNode parent;
    int deep;
    boolean isWord = false;


    public TrieNode(TrieNode parent, char value) {
        this.parent = parent;
        this.value = value;
        // 假定根节点不存储有意义的值,深度为0
        if (parent == null)
            deep = 0;
        else
            deep = parent.deep + 1;
    }

    /**
     * 将字符串转化成字符队列的静态方法
     */
    public static Queue<Character> string2Queue(String str) {
        Queue<Character> queue = new LinkedList<>();
        for (char c : str.toCharArray()) {
            queue.add(c);
        }
        return queue;
    }

    /**
     * 加载字符
     */
    public void load(Queue<Character> wordQueue) {
        if (wordQueue.isEmpty())
            return;
        // 弹出队列中第一个字符
        char c = wordQueue.poll();
        if (childrenMap == null)
            childrenMap = new HashMap<>();
        TrieNode node = childrenMap.computeIfAbsent(c, s -> new TrieNode(this, c));
        // 如果队列非空,继续递归加载剩余字符
        if (!wordQueue.isEmpty())
            node.load(wordQueue);
        else
            // 队列为空了,说明当前节点是最后一个字符,刚好成一个词
            node.isWord = true;
    }


    public static void match(TrieNode node, String word) {
        if (word == null || word.length() == 0)
            return;
        System.out.println(String.format("开始对\"%s\"进行匹配:", word));
        // 对输入字符串的所有子串均进行前缀匹配
        for (int i = 0; i < word.length(); i++)
            match(node, word, i);
    }

    private static void match(TrieNode node, String word, int index) {
        // 要考虑边界情况
        if (index >= word.length() || node.childrenMap == null)
            return;
        // 取出当前位置的字符进行匹配
        char c = word.charAt(index);
        TrieNode child = node.childrenMap.get(c);
        // 子节点存在对应字符才能往下遍历/判断
        if (child != null) {
            if (child.isWord) {
                char[] w = new char[child.deep];
                TrieNode n = child;
                while (n != null && n.deep != 0) {
                    w[n.deep - 1] = n.value;
                    n = n.parent;
                }
                // 当找到一个匹配的词语时直接打印
                System.out.println(String.valueOf(w));
            }
            match(child, word, index + 1);
        }
    }


}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269