中文分词方法总结

本文介绍的是基于字符串匹配的中文分词的方法。

通过按照一定策略将待分析的汉字串与一个“词典”中的词条进行匹配,若在词典中找到某个字符串,则匹配成功。

字符串匹配算法:

在通过确定了词典后,目标句子可能含有很多词典中的词语。它们可能互相重叠,到底输出哪一个由规则决定。让我们来制定一些规则查词典。常用的规则有正向最长匹配、逆向最长匹配和双向最长匹配,它们都基于完全切分过程。

完全切分:

完全切分的思想是通过在句子中寻找出现在词典的单词。通过遍历句子中的字,查询该序列是否在字典中即可。

实现:

def fullSegment(text,dict):
    res=[]
    for i in range(len(text)):
        for j in range(i+1,len(text)+1):
            if(text[i:j] in dict):
                res.append(text[i:j])
    return res

结果如下:

print(fullSegment("南京市长江大桥",['南京','南京市','长江','大桥','长江大桥']))

['南京', '南京市', '长江', '长江大桥', '大桥']

正向最大匹配分词:

完全切分算法输出的并不是分词结果,为了输出真正的分词结果,需要完善规则,考虑到越长的单词表达的意义越丰富,于是我们定义单词越长优先级越高。具体说来,就是在以某个下标为起点递增查词的过程中,优先输出更长的单词,这种规则被称为最长匹配算法。该下标的扫描顺序如果从前往后,则为正向最大匹配。

算法思想:

  • 从左向右取待切分汉语句的m个字符作为匹配字段,m为大机器词典中最长词条个数。

  • 查找词典并进行匹配。若匹配成功,则将这个匹配字段作为一个词切分出来。

  • 若匹配不成功,则将这个匹配字段的最后一个字去掉,剩下的字符串作为新的匹配字段,进行再次匹配,重复以上过程,直到切分出所有词为止。

实现:

def forwardSegment(text,dict):
    res = []
    i=0
    while i < len(text):
        long = text[i]
        for j in range(i + 1, len(text) + 1):
            long=text[i:j] if text[i:j] in dict and len(text[i:j])>len(long) else long
        res.append(long)
        i+=len(long)
    return res

结果如下:

print(forwardSegment("南京市长江大桥",['南京','南京市','长江','大桥','长江大桥']))

['南京市', '长江大桥']

逆向最大匹配分词:

逆向匹配思想和正向是相同的,不过扫描顺序变成了从后至前。

实现:

def backSegment(text,dict):
    res = []
    i = len(text)-1
    while i >= 0:
        long = text[i]
        for j in range(0, i):
            long = text[j:i+1] if text[j:i+1] in dict and len(text[j:i+1]) > len(long) else long
        res.insert(0,long)
        i -= len(long)
    return res

结果如下:

print(backSegment("南京市长江大桥",['南京','南京市','长江','大桥','长江大桥']))

['南京市', '长江大桥']

双向最大匹配分词:

有些句子用正向匹配能正确切分,而有些句子只能通过逆向匹配才能正确切分。

那么,具体该怎么使用匹配算法呢?我们一般通过如下规则来进行确定:

  • 同时执行正向和逆向最长匹配,若两者的词数不同,则返回词数更少的那一个。

  • 否则,返回两者中单字更少的那一个。当单字数也相同时,优先返回逆向最长匹配的结果。

实现:

def bidrectionalSegment(text,dict):
    f=forwardSegment(text,dict)
    b=backSegment(text,dict)
    print(f)
    print(b)
    if(len(f)<len(b)):
        return f
    elif(len(f)>len(b)):
        return b
    else:
        lenF=0
        lenB=0
        for i in b:
            if len(i)==1:
                lenB+=1
        for j in f:
            if len(j)==1:
                lenF+=1
        return f if lenF<lenB else b

结果如下:

print(bidrectionalSegment("研究生命起源",['研究','研究生','生命','起源']))

['研究生', '命', '起源'] #forward
['研究', '生命', '起源'] #back
['研究', '生命', '起源'] #bidrectional

总结:

字符串匹配方法算法简单,易于理解和实现,并且切分速度较快,成为分词方法中最流行的。因为字典匹配的方法不考虑具体的语言环境和定义,最大的缺点就是不能处理多词冲突和新词情况,严重依赖于词表,准确度较低。单纯采用字符串匹配的方法不能满足中文信息处理对分词结果准确度的要求。

提高词典匹配速度:

匹配算法的瓶颈之一在于如何判断集合词典中是否含有字符串。如果用有序集合,复杂度是O(logn)( n是词典大小);如果用散列表,时间复杂度虽然下降了,但内存复杂度却上去了。我们需要寻找一种速度又快、内存又省的数据结构。

字典树:

字典树(trie树)是一种特殊的前缀树结构。它是哈希树的一种变种,专门为字符串处理设计的数据结构。典型的应用是用于统计和排序大量的字符串(不限于字符串)因此经常被搜索引擎用于文字词频统计。优点:最大限度地减少无谓的字符串比较。Trie的核心思想是空间换时间,利用字符串的公共前缀来降低查询时间的开销以达到提高效率的目的

如图为一颗字典树,红色表示结束字符串,表示一个字符串的终止。

前缀树有三个基本性质:

  • 根节点不包含字符,除根节点外每一个节点只包含一个字符。

  • 从根节点到某一节点,路径上经过的字符连接起来,为该节点对应的字符串。

  • 每个节点的所有子节点包含的字符都不相同。

当我们需要查找apple ,字典树的查找步骤:/->a->p->p->l->e,此时e为结尾,说明apple存在字典树中。

当我们需要查找app,字典树的查找步骤:/->a->p->p,此时p不为字符串结尾,则app不存在字典树中。

实现:

class  tree():
    def __init__(self):
        self.child={}
        self.is_end=False
class Trie():
    def __init__(self):
        self.root=tree()
    def insert(self,word)->None:
        node=self.root
        for char in word:
            if char not in node.child:
                node.child[char]=tree()
            node=node.child[char]
        node.is_end=True
    def search(self,word):
        node=self.root
        for char in word:
            if char not in node.child:
                return False
            node=node.child[char]
        return node.is_end
trie=Trie()
for word in ['apple','app','aww']:
    trie.insert(word)
print(trie.search("apple"))
print(trie.search("appl"))
True #trie.search("apple")
False#trie.search("appl")

当我们需要实现中英转换的时候,就需要对字典树加上映射。需要知道自己对应的值。我们约定用值为None表示节点不对应词语,虽然这样就不能插入值为None的键了,但实现起来更简洁。

class Node(object):
    def __init__(self, value) -> None:
        self._children = {}
        self._value = value

    def _add_child(self, char, value, overwrite=False):
        child = self._children.get(char)
        if child is None:
            child = Node(value)
            self._children[char] = child
        elif overwrite:
            child._value = value
        return child
class Trie(Node):
    def __init__(self) -> None:
        super().__init__(None)

    def __contains__(self, key): #函数可以在类的实例化对象上进行 in 操作.
        return self[key] is not None

    def __getitem__(self, key):#按下标访问数列的任意一项
        state = self
        for char in key:
            state = state._children.get(char)
            if state is None:
                return None
        return state._value
    def __setitem__(self, key, value):#以与键相关联的方式存储值
        state = self
        for i, char in enumerate(key):
            if i < len(key) - 1:
                state = state._add_child(char, None, False)
            else:
                state = state._add_child(char, value, True)
Trie=Trie()
Trie["自然"]="nature"
print(Trie["自然"])
nature #output

AC自动机:

学习AC自动机必须掌握Trie树和KMP算法

AC自动机概述:

AC自动机是最一种多模式匹配算法,它由贝尔实验室的两位研究人员Alfred V. Aho 和 Margaret J.Corasick 于1975年发明,几乎与KMP算法同时问世,至今仍然在模式匹配领域被广泛应用。例如:在一篇文章中,需要找到多个词汇所在的位置和出现频率。


上节已经介绍了字典树,接下来介绍KMP算法,再进行融合介绍AC自动机。

KMP算法:

“字符串A是否为字符串B的子串?如果是的话出现在B的哪些位置?“该问题就是字符串匹配问题,字符串A称为模式串,字符串B称为主串。相对比较暴力方法(一个一个字符进行比较)的时间复杂度 O(MN),KMP算法通过设定next数组而减少了匹配的趟数,能够将时间复杂度降至 O(N+M)节省了大量的时间。

简单举一个例子:

我们要在主串S:ababcabcacbab,中匹配模式串T:abcac

KMP的next数组我先直接写出来,具体怎么得到的,我们后面再讲:

在这里插入图片描述

在第一次匹配中,从首部开始匹配:i从0开始,j从0开始。当i=2,j=2时匹配失败,接下来我们查看next数组,next[2]=0,因此我们将模式串的第一位移动到此时j的位置上来,也就是T:abcac向右移动j - next [j] = 2 位

在这里插入图片描述

接下来是第二次匹配,i从2开始,j从0开始。当i=6,j=4时匹配失败,此时i不动,此时next[4]=1,因此我们将模式串的第二位移动到此时j的位置上来,也即是模式串T要相对于主串S向右移动j - next [j] = 3位,j回溯到1

在这里插入图片描述

第三次匹配中,i从6开始,j从1开始。当i=10,j=5时匹配成功,返回i - j = 5,也即是在主串的第六位能够匹配成功。

在这里插入图片描述
KMP的代码实现:

通过上述例子,我们已经知道KMP主要是通过Next数组的值而进行字符串的前进,而减少匹配的趟数,可以根据此写出代码:

def KMP(word,string,next):
    i=0
    j=0
    while(i<len(word) and j<len(string)):
        if(j==-1 or word[i]==string[j]):
            j+=1
            i+=1
        else:
            j=next[j]
    if j==len(string):
        return True
    else:
        return False

那么next数组该如何得出呢?

next数组是通过最长公共前后缀得出的。什么是最长公共前后缀呢?

  • 规则如下:
P=p0p1p2pj1pjP串中有一个最大长度为k+1的公共前后缀。defgetNext(b):c=[1,0]a=b[0:i]fortimeinrange(1,len(a)):res=timeiftime>reselseresreturncpk=pj时,next[j+1]=next[j]+1=k+1,代表字符E前的模式串中,有长度k+1的最大公共前后缀。![在这里插入图片描述](https://imgblog.csdnimg.cn/20190424214221535.png?xossprocess=image/watermark,typeZmFuZ3poZW5naGVpdGk,shadow10,textaHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3l5enNpcg==,size16,colorFFFFFF,t70)pk!=pj时,说明p0p1pk1pk!=pjkpjk+1pj1pj这时ABCABD不相同,也就是字符E前的模式串中没有长度为k+1的最大公共前后缀,P=“p_0p_1p_2 …p_{j-1}p_j”\\ \\则P串中有一个最大长度为k+1的公共前后缀。 def getNext(b): c=[-1,0] a=b[0:i] for time in range(1,len(a)): res=time if time>res else res return c 当p_k=p_j时,next[j + 1] = next[j] + 1 = k + 1,代表字符E前的模式串中,\\有长度k+1 的最大公共前后缀。 ![在这里插入图片描述](https://img-blog.csdnimg.cn/20190424214221535.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3l5enNpcg==,size_16,color_FFFFFF,t_70) 当p_k ! = p_j时,说明p_0p_1…p_{k-1}p_k != p_{j-k}p_{j-k+1}…p_{j-1}p_j,\\这时ABC与ABD不相同,也就是字符E前的模式串中没有长度为k+1的最大公共前后缀,\\

在这里插入图片描述

也是D的话,那么最大公共前后缀长度就为k’+1。\\ \\从而next[j+1] = k’ + 1 = next[k’] + 1。否则前缀没有D,next[j+1] = 0。 ![在这里插入图片描述](https://img-blog.csdnimg.cn/20190424221058306.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3l5enNpcg==,size_16,color_FFFFFF,t_70) 在p_k != p_j时,k = next[k],用p_{next[k]}去跟p_j继续匹配。\\ 的已匹配串,说明p_k字符前有一段长度为next[k]的最大公共前后缀(蓝色的那段)。\\ 那么我们只能找更短的最大公共前后缀,此时因为p_k和p_{next[k]}前面的蓝色串已完全匹配,\\ p_{next[next[k]…]}去跟p_j继续匹配,直到找到长度更短公共前后缀。 综上,优化后的代码如下:时间复杂度降为`O(N)` ```python j=0 next=[-1 for i in range(len(b))] if(k==-1 or b[j]==b[k]): j+=1 else: return next ![](https://voluntexi.github.io//post-images/1657624055865.png) class node: self.char=char self.tail=0 #尾标志 self.childValue=[] #子结点的值 def __init__(self): self.count=0 self.count+=1 for i in strKey: child=node(i) p.childValue.append(i) else: p.tail=self.count def acAutomation(self): while len(p): #BFS p.remove(temp) if temp==self.root: #若根的子结点fail指向自己 else: while q: i.fail=q.child[q.childValue.index(i.char)] q=q.fail #否则继续循环 i.fail=self.root #模式匹配 p=self.root for i in strMode: p=p.fail #若字符不在子节点中且不为根节点,循环 p=p.child[p.childValue.index(i)] p=self.root while temp is not self.root: #若此结点不为根节点且fail存在则匹配成功 if temp.tail not in cnt: cnt[temp.tail]=1 cnt[temp.tail]+=1 return cnt dic=['南京','南京市','长江','大桥','长江大桥'] ac.Insert(i) print(ac.runkmp("南京市长江大桥")) {1: 1, 2: 1, 3: 1, 5: 1, 4: 1} #南京1次,南京市1次,长江1次,长江大桥1次,大桥1次