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

先回顾一下上一篇文章的内容:我们简单介绍了中文分词的原理,并且实现了一个前缀树,以及实现了加载词典的方法,还实现了给定一个句子输出里面收录于词典中的词语。

我们最终目标是实现一个分词器(并且最好能够实现歧义消除),现在距离我们的目标已经很近了。这篇文章会继续完善我们的分词器,真正实现基于词典的分词。

接下来会实现的功能:

  1. 将输入的待分词文本构建成一个DAG图。
  2. 使用动态规划的思想,基于DAG图计算出文本的最佳分词方式(上一篇文章说到过,最优分词方案就是使得句子出现频率最高)

在构建DAG图之前,需要这里需要新引入一个元素:

  1. 为了能够对不同分词情况进行对比,需要给每个词语增加一个权重属性 frequency (这样不同的句子就可以用所有词语权重之和来衡量句子的权重了,权重最高的句子也就出现概率最大)

前缀树加载词典的方法需要改成:

    /**
     * 加载字符
     */
    public void load(Queue<Character> wordQueue, int frequency) {
        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, frequency);
        else {
            // 队列为空了,说明当前节点是最后一个字符,刚好成一个词
            node.isWord = true;
            node.frequency = frequency;
        }
    }

对输入文本构建DAG图

首先是实现将输入文本转化成DAG图

  • 了解过jieba分词的实现的同学都知道,jieba实现的动态规划是从右往左开始迭代求解的。这是因为生成的DAG图是由词的首个字符指向最后一个字符。比如输入"抗日战争",刚好"抗日战争"是一个词,(不考虑其他词)生成的邻接矩阵就是: {0:[3], 1:[], 2:[], 3:[]}。这个DAG图正向迭代(从左到右)是无法求出最优解的,因为如果从左开始遍历,决定经过某个字符的最优路径不是看这个字符是哪些词的前缀,而是看他是哪些词的后缀。反向迭代则反过来,看到是组成前缀的情况。(可能描述的还不是很清楚, 最好自己实现一下)
  • 为了容易理解,下面实现反向DAG,然后正向迭代来进行求解
    /**
     * 这里需要构建反向的DAG,
     * 假设三个点的图1,2,3构建成DAG之后是:1 -> 2 -> 3
     * 原邻接矩阵应该是
     * 1 -> 2 -> null
     * 2 -> 3 -> null
     * 3 -> null
     * <p>
     * 但此处要构建成
     * 1 -> null
     * 2 -> 1 -> null
     * 3 -> 2 -> null
     * <p>
     * 这样做是为了后面寻找最近路径的时候能够根据词的最后一个字符迅速定位其对应的首个字符
     */
    public static List<Map<Integer, Integer>> buildDAG(TrieNode head, String str) {
        List<Map<Integer, Integer>> dag = new ArrayList<>(str.length());
        for (int i = 0; i < str.length(); i++) {
            dag.add(i, new HashMap<>());
        }
        // 词典为空直接返回空邻接矩阵
        if (head == null || head.childrenMap == null)
            return dag;
        // 前缀遍历字符串
        for (int i = 0; i < str.length() - 1; i++) {
            char c = str.charAt(i);
            TrieNode node = head.childrenMap.get(c);
            if (node == null)
                continue;
            TrieNode n = node;
            int offset = i;
            while (n != null) {
                if (n.isWord) {
                    dag.get(offset).put(i, n.frequency);
                }
                if (n.childrenMap == null || offset == str.length() - 1)
                    break;
                n = n.childrenMap.get(str.charAt(++offset));
            }
        }
        return dag;
    }

基于DAG图求最优分词方案

构建出DAG图,之后就是使用DAG图来寻找最优路径了,可以通过动态规划法来求解。

先来阐述一下动态规划的思路:假设输入文本为“天下第一”,现在词典中有三个关联的词语“天下”,“第一”,“天下第一”,其对应词频分别是 1,1,3

首先生成反向DAG图:

// 位置0 对应天,3对应上
0: null 
1: 0 -> null
2: null
3: 0 -> 2 -> null

从最左开始遍历:(设w(i)是第i个位置上的最优路径权重,f(s)是词s的权重)

  1. ->"天":因为"天"是第一个字符,且词典中不存在"天"这个词,因此位置0的权重是0,即w(0)=0

  2. ->"下":(根据邻接矩阵)此时有两条路径可选:

    1. "天下":"与"天"组成词"天下",此时路径权重=w(0-1)+f("天下")=f("天下")=1 (这里的w(0-1)的0表示"天下"中首个字符"天"的位置,w(0-1)表示"天"前一个字符的路径权重,由于"天"是第一个字符,所以这里直接去掉了)
    2. "天/下":不与"天"组成词,此时路径权重=w(1-1)=w(0)=0

明显第一条路径权重更高,即w(1)=max(w(0), w(0-1)+f("天下")) = 1

  1. ->"第":此时因为与前面的字符不能组成词语,所以只能与前面字符分开一条路径,此时权重为:w(2)=w(2-1)=1

  2. ->"一":(根据邻接矩阵)此时有三条路径可选:

    1. "天下第一":w = w(0-1)+f("天下第一")=3
    2. "天下/第一":w = w(2-1)+f("第一")=1+1=2
    3. "天下/第/一":w = w(3-1)=w(2)=1

因此 w(3) = 3,

按照这种方法进行迭代求解,最终最后一个字符的最优权重路径其实就是整个输入文本的最优分词方案。

即整词"天下第一"就是输入文本"天下第一"的最优分词方案。

下面来实现一下相关求解代码

    /**
     * 使用动态规划求解
     * 状态转移方程: w(x) = max(w(x-1), w(k1-1) + f(k1), ..., w(kn-1) + f(kn))
     * x是字符位置
     * w(x)表示位置x上的最优路径权重
     * k1~kn是以位置x上字符结尾的不同词
     */
    public static void findOptimalPath(String str, List<Map<Integer, Integer>> list) {
        int[] indexArr = new int[str.length()];
        int[] weightArr = new int[str.length()];

        indexArr[0] = 0;
        weightArr[0] = 0;
        for (int i = 1; i < str.length(); i++) {
            int index = i;
            int weight = weightArr[index - 1];
            Map<Integer, Integer> m = list.get(i);
            for (Integer inx : m.keySet()) {
                int w = m.get(inx);
                if (inx != 0)
                    w += weightArr[inx - 1];
                if (w > weight) {
                    weight = w;
                    index = inx;
                }
            }
            indexArr[i] = index;
            weightArr[i] = weight;
        }
        // 到这一步就已经求出结果了,indexArr[str.length()-1]就是最终结果
        // 剩下的就是往回推导出整个分词路径
        
        // 往回推导并输出分词结果
        LinkedList<String> l = new LinkedList<>();
        int offset = str.length() - 1;
        while (offset >= 0) {
            int start = indexArr[offset];
            l.addFirst(str.substring(start, offset + 1));
            offset = start - 1;
        }
        // 以/的形式表示分词
        for (String s : l) {
            System.out.print(s+"/");
        }
        System.out.println();
    }
  • findOptimalPath 方法追加到 buildDAG 方法末尾就可以构建完DAG图之后直接计算分词结果了。

回到我们的main方法:

TrieNode node = new TrieNode(null, ' ');
node.load(TrieNode.string2Queue("中华"), 10);
node.load(TrieNode.string2Queue("华人"), 8);
node.load(TrieNode.string2Queue("人民"), 15);
node.load(TrieNode.string2Queue("共和国"), 6);
node.load(TrieNode.string2Queue("中华人民"), 24);
node.load(TrieNode.string2Queue("中华人民共和国"), 30);
node.load(TrieNode.string2Queue("国歌"), 8);
node.load(TrieNode.string2Queue("共和"), 5);

TrieNode.buildDAG(node, "中华人民共和国万岁");

>>分词结果:
中华/人民/共和国/万/岁/
    
// 如果将"中华人民共和国"权重调整到50,分词结果将发生变化:
>>分词结果:
中华人民共和国/万/岁/

至此,我们已经实现了一个分词器粗糙的模型了:

  1. 加载词典树
  2. 输出文本中所有词语
  3. 对输入文本进行分词,且进行歧义消除(寻找最优分词路径)

本文仅作学习用途,如有错误,欢迎指出

参考

结巴分词

IK分词

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

<<数学之美>>

完整代码

import java.util.*;

/**
 * @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;
    int frequency = 0;
    String word;


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

    @Override
    public String toString() {
        return "TrieNode{" + nodePath() + "}";
    }

    String nodePath() {
        if (word == null) {
            char[] w = new char[deep];
            TrieNode n = this;
            while (n != null && n.deep != 0) {
                w[n.deep - 1] = n.value;
                n = n.parent;
            }
            word = String.valueOf(w);
        }
        return word;
    }

    /**
     * 将字符串转化成字符队列的静态方法
     */
    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, int frequency) {
        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, frequency);
        else {
            // 队列为空了,说明当前节点是最后一个字符,刚好成一个词
            node.isWord = true;
            node.frequency = frequency;
        }
    }


    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);
        }
    }


    /**
     * 这里需要构建DAG的反向引用,
     * 1 -> 2 -> 3
     * 原邻接矩阵应该是
     * 1 -> 2 -> null
     * 2 -> 3 -> null
     * 3 -> null
     * <p>
     * 但此处要构建成
     * 1 -> null
     * 2 -> 1 -> null
     * 3 -> 2 -> null
     * <p>
     * 这是为了后面寻找最近路径的时候能够根据词的最后一个字符迅速定位其对应的首个字符
     */
    public static List<Map<Integer, Integer>> buildDAG(TrieNode head, String str) {
        List<Map<Integer, Integer>> dag = new ArrayList<>(str.length());
        for (int i = 0; i < str.length(); i++) {
            dag.add(i, new HashMap<>());
        }
        // 词典为空直接返回空邻接矩阵
        if (head == null || head.childrenMap == null)
            return dag;
        // 前缀遍历字符串
        for (int i = 0; i < str.length() - 1; i++) {
            char c = str.charAt(i);
            TrieNode node = head.childrenMap.get(c);
            if (node == null)
                continue;
            TrieNode n = node;
            int offset = i;
            while (n != null) {
                if (n.isWord) {
                    dag.get(offset).put(i, n.frequency);
                }
                if (n.childrenMap == null || offset == str.length() - 1)
                    break;
                n = n.childrenMap.get(str.charAt(++offset));
            }
        }
        findOptimalPath(str, dag);
        return dag;
    }

    /**
     * 使用动态规划求解
     * 状态转移方程: w(x) = max(w(x-1), w(k1-1) + f(k1), ..., w(kn-1) + f(kn))
     * x是字符位置
     * w(x)表示位置x上的最优路径权重
     * k1~kn是以位置x上字符结尾的不同词
     */
    public static void findOptimalPath(String str, List<Map<Integer, Integer>> list) {
        int[] indexArr = new int[str.length()];
        int[] weightArr = new int[str.length()];

        indexArr[0] = 0;
        weightArr[0] = 0;
        for (int i = 1; i < str.length(); i++) {
            int index = i;
            int weight = weightArr[index - 1];
            Map<Integer, Integer> m = list.get(i);
            for (Integer inx : m.keySet()) {
                int w = m.get(inx);
                if (inx != 0)
                    w += weightArr[inx - 1];
                if (w > weight) {
                    weight = w;
                    index = inx;
                }
            }
            indexArr[i] = index;
            weightArr[i] = weight;
        }
        // 到这一步就已经求出结果了,indexArr[str.length()-1]就是最终结果
        // 剩下的就是往回推导出整个分词路径

        // 往回推导并输出分词结果
        LinkedList<String> l = new LinkedList<>();
        int offset = str.length() - 1;
        while (offset >= 0) {
            int start = indexArr[offset];
            l.addFirst(str.substring(start, offset + 1));
            offset = start - 1;
        }
        // 以/的形式表示分词
        for (String s : l) {
            System.out.print(s+"/");
        }
        System.out.println();
    }
}

测试用例

public class Main {
    public static void main(String[] args) {
        // 初始化树根节点,置parent=null, value=' '
        TrieNode node = new TrieNode(null, ' ');
        node.load(TrieNode.string2Queue("中华"), 10);
        node.load(TrieNode.string2Queue("华人"), 8);
        node.load(TrieNode.string2Queue("人民"), 15);
        node.load(TrieNode.string2Queue("共和国"), 6);
        node.load(TrieNode.string2Queue("中华人民"), 24);
        node.load(TrieNode.string2Queue("中华人民共和国"), 50);
        node.load(TrieNode.string2Queue("国歌"), 8);
        node.load(TrieNode.string2Queue("共和"), 5);

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