从零开始实现中文分词器(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 childrenMap;

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

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

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

TrieNode parent;
int deep;

Trie树的构造方法

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

public class TrieNode {

    char value;
    Map 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 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 string2Queue(String str) {
        Queue 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 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 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 string2Queue(String str) {
        Queue queue = new LinkedList<>();
        for (char c : str.toCharArray()) {
            queue.add(c);
        }
        return queue;
    }

    /**
     * 加载字符
     */
    public void load(Queue 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);
        }
    }


}

你可能感兴趣的:(从零开始实现中文分词器(1))