中文分词算法详解:正向、逆向与双向最大匹配实现 做中文自然语言处理几乎每一件事都要从分词开始。我在几个中小型项目里折腾过搜索关键词抽取、文本聚类、评论情感分析最后总会绕回同一个问题一句话到底该怎么切成一个个词。网上能搜到很多分词库但如果你想把它背后最基本的逻辑搞清楚或者只是想找一个不依赖模型、不需要标注语料、拿来就能用的分词方案那最好的起点就是“最大匹配”这一族算法。它一点不玄核心动作就是查词典把当前窗口里最长能命中的词拽出来。今天只讲三件事正向最大匹配、逆向最大匹配以及把两者合并做决策的双向最大匹配顺带把实现细节、评测方式和避坑经验一次说透。如果你刚入门自然语言处理想在自己代码里跑出第一版分词器它能用如果你想给专业领域文档做快速的词典切词又不想引入一套复杂模型它也能用。你甚至可以把它当作一个基线拿来做后续复杂分词器的对照。1. 核心原理三种匹配算法一次性讲透1.1 一个目标两种扫描方向最大匹配解决的是“在给定词典下一句话怎么切出最合理的词串”的问题。它先把所有可能的切分看作一堆候选路径然后故意选择一条“最长词优先”的路径。什么叫最长词优先你手头有一本词典里面存了“研究”“研究生”“生命”“科学”。现在句子是“研究生命科学”。人的直觉会切分成“研究/生命/科学”但机器并不知道。机器如果按最长优先的原则从句子开头取若干字去词典里找第一次可能试整句话的长度词表没有再缩短还是没有直到“研究生”命中于是它就把“研究生”当作第一段结果反而切坏了。这就是最大匹配的基本脾气宁可吞长不愿切短。两种扫描方向由此而来。正向最大匹配从句子左边开始指针往右移动逆向最大匹配从句子右边开始指针往左移动。同一个贪心规则只是扫描方向反了。双向最大匹配则更简单让两个方向各跑一遍再按预先定义好的规则选出一条更可信的结果。很多人以为双向匹配肯定比任意单一方向都强。实际用下来它只是把单一方向的系统误差变成了相对均衡的折中并不能保证每句话都对。这个结论我后面会用两个实例验证。1.2 正向最大匹配贪心往右吃算法流程很好描述准备一个词典设定最大词长 max_len比如 5。指针从句子开头开始在当前位置截取最长不超过 max_len 的字符串。如果这个字符串在词典里就认作一个词指针右移该词的长度重复上一步。如果不在词典里就把长度减一再试直到找到词为止如果一直缩到 1 还没命中就把当前位置的单字当作一个词指针右移一个字符。用大白话打个比方相当于你手里攥着一把尺子每到一个位置先用最大量程去量量出来的词组不在词表就缩短一点短到命中为止。这个过程的编程实现不难但有一个关键点容易出错窗口长度不能超过剩余文本长度。所以每次尝试长度要用min(max_len, 剩余字符数)否则切片越界或者白白做很多无意义匹配。正向匹配最大的问题是“只顾眼前”它看到“研究”和“研究生”都可能是合法词时一定拿更长的“研究生”不管后面跟的是“生命”还是“课题”。这在多数句子里没有问题却会在交集型歧义上反复翻车。所谓交集型歧义就是两个合法词在同一个字符区间里竞争A词的前缀同时是B词的完整形式。这种歧义是最大匹配类算法的天然软肋因为贪心策略根本不会去考虑“后面的词跟谁搭配更顺”。1.3 逆向最大匹配从右往左兜底逆向最大匹配的思路完全相同只是把句子倒过来扫从最后一个字符开始左移下标每次从当前位置往前截取最长不超过 max_len 的字符串去词典中匹配。命中后指针左移词长重复没命中就缩短长度再试一直缩到单字。这里有一个实现细节困扰过很多新手逆向扫描得到的词是逆序的比如先取到“科学”再取到“生命”最后取到“研究”如果直接把结果丢给下游会变成“科学/生命/研究”完全乱套。正确做法是全部取完后把结果列表整体反转恢复成正常的从左到右顺序。为什么逆向往往显得比正向稳我的理解是这和中文里大量“修饰语中心语”的结构有关很多歧义出现在句子的中后段。逆向扫描时决定性的尾部词先落定前面的修饰成分再去匹配就不容易造成一连串的连锁误切。网上总有人说逆向错的更少这个说法在统计意义上大体成立但千万不要理解成逆向一定优于正向。到了“结合成分子”这类样本上逆向也会结结实实踩坑。1.4 双向最大匹配把决策交给规则双向最大匹配的套路是分别跑正向和逆向之后再按一套比较规则做选择。常见规则大致如下如果正反向结果完全一致直接返回任意一边。如果结果不一致返回分词数量更少的那一边。如果分词数量一样多返回单字词数量更少的那一边。如果依然难分高下默认返回逆向结果。前两条很容易理解分词数量少说明切出来的词更长、更聚合多数情况下更像人的切法。第三条“单字词少优先”本质上是给“更长词优先”的倾向又加了一层保护避免两种切分数量相同却出现细碎单字的情况。第四条回退逆向算是一种经验默认值。规则背后的逻辑不等于绝对真理它只是把两种方向的贪心误差做交叉验证。真正在工程里用这套规则必须意识到一点帮助有限尤其当词典覆盖不佳或者句子本身存在真正歧义时规则常常选不出对的那条路。我后面会用实测数据告诉你差距有多大。从复杂度来看最坏情况下三种算法都要在每隔字符处尝试最多 max_len 次查询时间开销大约是 O(n × max_len)n 是句子长度。双向比单向多一倍计算量但收益却并不是稳定的一倍。这个认知很重要别在改动算法规则上花太多时间词典质量才是大头。2. 手写一套可运行的最小实现2.1 词典设计与加载细节算法本身不挑词典但词典质量决定上限。实际项目里我习惯准备一个纯文本词表文件一行一个词可能附带词频或者词性用空格或制表符分隔。加载的时候只取第一列放到 Python 的集合里。为什么要用集合而不是列表集合的成员判断是平均 O(1)列表则是 O(n)。分词过程中每个位置可能尝试多次查询如果用列表句子一长性能就很难看。生产环境下可以进一步换成前缀树但那一步要看实际数据量级。教学阶段先把集合版本讲透。演示用的词典我直接放在代码里用一小段构造的集合即可word_set { 研究, 研究生, 生命, 命, 科学, 结合, 合成, 分子, 结, 合, 成, 分, 子, 我们, 领域, 算法, 中文, 分词, 信息, 处理, }实际中我会把它换成加载本地词表文件文件内容大致像这样# 自定义词典第一列是词第二列以后是词频或词性注释 研究 10000 研究生 5200 生命 8000 科学 12000加载代码def load_dict(path): word_set set() with open(path, r, encodingutf-8) as f: for line in f: line line.strip() if not line or line.startswith(#): continue word_set.add(line.split()[0]) return word_set有一件事我会反复提醒补充词典不能只加长词还要考虑会不会引入新的歧义。比如你把“成分子”加进词典就可能让“结合成分子”变成“结合/成分子”看起来更离谱。词典更新后最好在固定测试集上重跑一遍三个版本看切分质量是否真的提升而不是凭感觉认为多加了词一定变好。2.2 正向匹配实现与边界条件正向最大匹配的 Python 实现很短真正值钱的还是边界处理。def fmm_cut(text, word_set, max_len5): result [] pos 0 text_len len(text) while pos text_len: matched False for l in range(min(max_len, text_len - pos), 0, -1): word text[pos:pos l] if word in word_set: result.append(word) pos l matched True break if not matched: result.append(text[pos]) pos 1 return result这里有两个细节值得说。第一range(min(max_len, text_len - pos), 0, -1)保证了遍历词长时会自动收缩不会尝试超过剩余文本的长度也不用额外判断切片越界。第二我用matched标志记录是否命中。这样逻辑清晰也方便后续在没命中时做日志统计看看到底有多少字符被单字切分。max_len5是我常用的默认值。为什么是 5中文词汇以二字词和四字成语居多六个字的专有名称也不少比如“中华人民共和国”就是七个字。设太短会漏长词设太长会让大量“尝试失败”拖慢速度。5 到 7 是常见的折中范围我默认 5处理专业文档时会临时调到 7。2.3 逆向匹配实现与顺序反转逆向版本我单独写成bmm_cut这样测试时可以只观察单条链路的情况。def bmm_cut(text, word_set, max_len5): result [] pos len(text) while pos 0: matched False for l in range(min(max_len, pos), 0, -1): word text[pos - l:pos] if word in word_set: result.append(word) pos - l matched True break if not matched: result.append(text[pos - 1]) pos - 1 result.reverse() return result注意切片方向是text[pos - l:pos]不是text[pos:pos l]。很多第一次手写的人会把这两种切片方式写反检查半天都发现不了。另外即使成功取到词也别忘了最后result.reverse()。我见过有人直接在业务代码里不反转结果下游排序全乱最后在分词器里来回找原因浪费了一下午。有一个小技巧这种倒序扫描带来的顺序问题其实可以直接在原始字符串上用一个“倒过来匹配”的思路。我不推荐简单地把字符串反转后再跑正向逻辑因为结果里的每个词还要逐字反转回来容易处理出错。更稳妥的做法就是把 BMM 写成独立函数只在最后统一反转一次输出列表。2.4 双向匹配的合并裁决双向匹配实现比较简单把上面两个函数包一层即可def count_single_char(tokens): return sum(1 for w in tokens if len(w) 1) def bi_mm_cut(text, word_set, max_len5): fm fmm_cut(text, word_set, max_len) bm bmm_cut(text, word_set, max_len) if fm bm: return fm if len(fm) ! len(bm): return fm if len(fm) len(bm) else bm if count_single_char(fm) ! count_single_char(bm): return fm if count_single_char(fm) count_single_char(bm) else bm return bmcount_single_char里对每个 token 判断len(t) 1这就是单字词统计。Python 的len对字符串按 Unicode 码点计量中文逐字计数不会出错。多数人看到这里会问同一个问题为什么最后平局时回退到逆向而不是正向真的没有统一答案我在不同项目里见过两种默认。我自己倾向于逆向是因为大量实验里逆向的极端错误更少但如果你有具体领域词频分布证据支持正向更好把最后一行改成return fm就是。没有绝对正确的优先方向这也是启发式算法的常态。用前面的词典跑一段测试text 研究生命科学 print(fmm_cut(text, word_set)) # [研究生, 命, 科学] print(bmm_cut(text, word_set)) # [研究, 生命, 科学] print(bi_mm_cut(text, word_set)) # [研究, 生命, 科学]输出结果和我后面案例分析完全一致。如果你在本地跑出来不一样多半是词典集合里多了或少了某个词。这也侧面说明算法行为严重依赖词典不同词典会得到完全不同的答案。3. 用两组案例把效果说清楚3.1 案例一“研究生命科学”在给定词典包含“研究”“研究生”“生命”“命”“科学”的前提下句子是“研究生命科学”。正向代码跑出来的结果是研究生 / 命 / 科学逆向代码跑出来的结果是研究 / 生命 / 科学哪种更符合人的理解显然是逆向。为什么正向会错核心就在第一步从句子开头尝试长词最优先尝试的是“研究生”而不是“研究”。这个贪心动作把“研究生”吞进去后后面的“命科学”就很难再组成合理词“命”只能孤零零落下。这是典型的交集型歧义两个词“研究”和“研究生”在同一个字符区间里起跑却竞争同一个起点。逆向从右往左时“生命”和“科学”先被抓住左侧的“研究”不会再误吞“生命”一下绕过了陷阱。我在实际中会把这种句子单独抽出来做回归测试。因为一旦你发现某个错误切分是由交集型歧义引起那么调整词典往往比调整算法更有效。比如想修正这个例子可以把“命”从词典里挪出去正向结果也会变成“研究/生命/科学”。可现实世界里的“命”不可能从词典消失所以这种修法只能针对具体场景不能通用。3.2 案例二“结合成分子”词典里包含“结合”“合成”“分子”“结”“合”“成”“分”“子”句子是“结合成分子”。正向结果是结合 / 成 / 分子逆向结果是结 / 合成 / 分子如果按真实语义“结合成分子”来理解正确切分应该是“结合/成/分子”。这一轮正向赢了逆向输了。原因跟上一个案例正好相反逆向从右往左到“结合成”这一段时会优先把“合成”抓出来因为“合成”出现在从后往前扫描的窗口里于是“结”被单独丢弃切出“结/合成/分子”这种怪结果。这个例子很适合打破“逆向一定更好”的迷信。两种方向都会犯错只是错的模式不同。等到最后交给双向匹配去裁决两个结果都是 3 个词单字词数也都是 1规则只好回退到逆向于是把正确答案白白丢掉。这说明固定决策规则并不是万能的它只是大多数时候把误差兜住碰到规则打不破的平局照样无能为力。更让人头疼的是这种“谁都不算完美”的情况在真实文本里并不少见。歧义永远是分词问题里最顽固的部分最大匹配类算法只能通过加词典、调规则去缓解不可能根除。如果你在一个项目里追求极致准确率早晚要引入统计模型或者上下文语义最大匹配只能作为起点或兜底。3.3 分词指标与实测对比分词任务里最常用的评价指标是精确率、召回率和 F1精确率 P 系统切分结果中正确答案的比例召回率 R 标准答案中被系统找回来的比例F1 2PR/(PR)对单个句子来说这个计算可以简化成“识别一致的词数除以系统词数”和“识别一致的词数除以参考词数”。按这两个例子做一张对照表句子参考切分正向结果逆向结果双向选择研究生命科学研究/生命/科学研究生/命/科学研究/生命/科学研究/生命/科学结合成分子结合/成/分子结合/成/分子结/合成/分子结/合成/分子对应指标句子算法PRF1研究生命科学正向1/3 ≈ 0.3331/3 ≈ 0.3330.333研究生命科学逆向1.01.01.0结合成分子正向1.01.01.0结合成分子逆向1/3 ≈ 0.3331/3 ≈ 0.3330.333结合成分子双向1/3 ≈ 0.3331/3 ≈ 0.3330.333这两个例子加起来结论很直白双向匹配不是“肯定比单向好”它只是把两个方向的错位做折中。在小样本上你完全可能看到双向不如单向的结果但放到几千句的真实语料里双向的平均误差通常比单纯正向低比逆向稍稳波动更小。想压榨出稳定的高质量切分核心还是把词典建好而不是在三条决策规则上纠结太久。另外做评测的时候最好用带词级别的标准切分文件按词计算 F1而不要只数句子对错。我以前曾经只看句级准确率结果高估了算法质量改成词级指标后才发现很多句子被判对只是因为错词数量少词级 F1 反而是很普通的水平。4. 工程落地避坑与优化实践4.1 未登录词与词典覆盖问题最大匹配对“词典里没有的词”毫无办法。一个完全不认识的词出现时算法只能退化成单字切分甚至把本该连在一起的人名、产品名、新兴网络词切得七零八落。现实中的解决办法不是改算法而是做词典运维定期从语料里抽出高频但未覆盖的字串人工确认后补进去。有一个原则必须记住词典补充需要保持一定克制。你把一个领域词加进去就要想到它在别的语境里是否会造成反效果。比如做医疗文本时加入“成分子”会让“结合成分子”变成“结合/成分子”做化学文本也许没错做通用文本就离谱。最好的做法是准备多套领域词典在不同业务场景里按需加载而不是把所有词塞进一个大杂烩词典里。如果遇到仍然无法覆盖的新词另一个可行方案是把最大匹配当作候选召回器对切出来的单字片段做二次合并例如用相邻字互信息或者频繁度统计判断它们是不是一个词。这不属于最大匹配本身的功能但作为工程补救很常见。4.2 分词前预处理标点与数字实践中特别容易忽略的一步是预处理。正文字符串里通常夹杂中英文标点、数字、邮箱、网址如果让最大匹配直接处理这些片段会被当成长串的字符去做匹配既低效又容易错。我的常规做法是先用正则表达式把非中文区间拆开常见的分割符包括标点、空格、换行、数字串等。比如import re def split_units(text): return re.split(r[\s。、,.!?;:], text)拆分之后对纯中文片段走分词算法对数字、字母片段直接保留为一个整体。这样处理之后词典的命中率会显著提高也便于对重复短句做缓存。还有一个小经验不要在一开始就处理得过于复杂像“3.14”这种含小数点的情况如果拆成“3”和“14”会影响后续关键词提取建议把数字、小数点和科学计数符号统一放在保留正则里。4.3 性能调优从集合到前缀树如果只处理几万字的文本用set实现的版本完全够用。但如果你在一个在线服务里对全部文本做实时切词就要注意两个性能瓶颈一是每次查词典都要生成切片字符串二是每个位置最多要试max_len次不同的切片。总体时间复杂度大约是 O(n×max_len×哈希代价)文本量一大就变得很可观。更舒服的方案是换成前缀字典树也叫 Trie。Trie 的好处是可以在一次扫描里沿着字符往下走只要某个前缀不存在就能立刻终止不需要反复创建不同长度的切片。我用百万级词表时从set改到 Trie耗时通常能减少一半以上内存开销也能接受。class TrieNode: __slots__ (children, is_end) def __init__(self): self.children {} self.is_end False def build_trie(word_set): root TrieNode() for word in word_set: node root for ch in word: node node.children.setdefault(ch, TrieNode()) node.is_end True return root这里只是展示结构真正的扫描函数还要在 Trie 上做游标匹配。原理是把“从当前位置尝试多长”的循环变成“沿 Trie 往下走”遇到不存在的路径立刻中断从而省掉大量无效切片。如果项目规模不大先用set版本也没问题毕竟它足够教学和中小场景使用。4.4 常见问题速查与场景选择下面这张表是我实际调试中经常用到的排查清单症状可能原因处理方案连续单字堆叠词典覆盖不足补充领域词表后重测长词总被拆成短词max_len 设置太小调整到 6 或 7正向结果比逆向差很多句子中后段有交集型歧义开启双向匹配兜底分词后顺序乱套逆向结果没有反转检查 result.reverse()标点打断长句未做预处理切分先按标点分段再分词双向结果仍很差词典或句子歧义本身复杂考虑统计模型或人工抽查也不建议在任何严肃项目里只用正向最大匹配每个算法都有自己的位置算法优点缺点适用场景正向最大匹配实现简单执行快容易左贪心过头快速原型、教学样例、极短文本逆向最大匹配统计上更稳能兜住尾部词也有固定歧义盲区中等规模离线批量切词双向最大匹配结果更稳误差波动小速度多一倍规则非绝对稳定输出的生产基线、词典校验4.5 什么时候该放弃最大匹配最大匹配再怎么说也只是词典贪心法。如果你的文本来自开放领域网络新词层出不穷停用词和专有名词混杂甚至句子本身缺字漏字那最大匹配基本撑不住。这时候应该考虑路径全局寻优或者换成带上下文建模的分词策略。如果非要在最大匹配体系内升级我会第一个推荐“带词频的 DAG 最短路径”方案。最大匹配本质上是贪心贪心意味着局部最优而 DAG 最短路径能在全局范围内选出概率更高的切分组合。你可以把词典里的每个词看作一条带权重的边句子的每个字位置看作一个节点用动态规划求从开头到结尾的最小代价路径。这样既保留了贪心算法对词典的高度依赖又消除了“只盯眼前最长词”的短视问题。我在实际项目中体会最深的一点是算法代码很短真正花时间的全是词典、预处理和评测。你把这三个算法轮流跑上一轮测试集比自己对着源码猜半天有效得多。最后再分享一个小习惯我会在代码里保留三个函数各自的输出开关出问题时同时打出来对比一眼就能看出是正向贪心过了头还是逆向关键时刻掉了链子。这个排查手段比花里胡哨的调试器都管用。