资讯动态

手写中文分词核心算法:正向、逆向与双向最大匹配详解

发布时间:2026/10/17 5:42:51 来源:尧图企业网站定制
我最近在折腾一个搜索相关的模拟项目X第一道坎就是中文分词。网上开源工具不少但要么太重要么自定义词典改起来不顺手干脆自己手写了一版最经典的算法组合——正向最大匹配、逆向最大匹配与双向匹配算法。写完之后我反而觉得这套老古董的内在逻辑比很多花哨模型都值得琢磨尤其在理解后面那些统计分词方法时很多思想都是一脉相承的。这篇文章就把我在实现过程中的思路、代码、实验数据以及踩到的坑全部摊开说。无论你是刚接触 NLP 想找入门算法练手还是在某些业务场景里需要轻量级分词但不想引一堆依赖都可以照着复现一遍。1. 从一条吐槽说起为什么我还在玩最大匹配有一种声音说最大匹配算法都老掉牙了现在谁还不用大模型分词但我的看法不太一样。实际业务里经常遇到限制模型不能上、外部接口不能调、词典必须私有、分词速度要求极快这时候手写一个最大匹配分词器往往是性价比最高的方案。更重要的是理解最大匹配的核心逻辑是理解后面基于词典的统计分词、N-gram 语言模型甚至条件随机场分词的一把钥匙。我最早接触这套算法是在某一次日志关键词抽取任务里。当时的原始需求很简单从用户搜索词里提取产品名词典也就几千个词但每次调远程分词服务都要花几十毫秒而且偶发超时。于是我把正向最大匹配、逆向最大匹配和双向最大匹配分别实现了一遍跑完对比后才发现三个算法在同一个句子上的结果差异非常有意思。有些句子正向切得对逆向切得错有些句子刚好反过来双向匹配通过一些启发式规则能把两者融合准确率明显提升。具体来说正向最大匹配FMM的核心思想是从左到右每次取当前剩余文本中最大长度的候选词如果在词典中命中就切出来否则缩小候选长度继续匹配。逆向最大匹配RMM原理一样只不过方向从右往左每次从剩余文本的末尾往前取候选。双向最大匹配Bi-MM就更直接了——把正逆结果放一起用几条规则决定谁更合理。这三兄弟看着简单但实现细节里处处是坑。比如词典怎么存最快、切分失败时单字如何处理、逆向结果要不要反转、双向消歧规则怎么选稍不注意就会得到一堆零碎单字。接下来我按原理、代码、实验、工程优化四部分把整个过程说透。2. 三种匹配算法的原理拆解与直觉对比2.1 正向最大匹配从左往右贪先说正向最大匹配Forward Maximum MatchingFMM。它像一个贪心背包每次尽量装下最长的那块。假设我们有一个句子 S 结婚的和尚未结婚的最大词长 max_len 设为 5词典里有结婚、和尚、尚未、的等词。FMM 的处理流程是这样从句子开头结字开始取前 5 个字结婚的和尚实际只有 5 个字符结、婚、的、和、尚先判断它是否在词典中。不在就缩短候选取结婚的和4 字不在继续结婚的3 字不在到结婚2 字时命中词典切出第一个词结婚。接着指针跳过这两个字从的字开始重复同样的过程。这里后面紧接着的是和尚未结婚的最大候选和尚未结婚可能是匹配不上的逐渐缩短到和尚时能命中于是切出和尚最终得到结婚 / 和尚 / 未 / 结婚 / 的。这就是 FMM 的一个典型问题它只盯着当前位置左侧无法感知句尾信息。只要词典里和尚是一个词它就会在和这个位置选择长词和尚把原本表示并列关系的和给吃掉。要解决这种交叉歧义单靠正向方向是不够的。FMM 的时间复杂度很好估算。设句子长度为 n最大词长为 m每个位置最多尝试 m 次词典查询所以最坏是 O(n×m)。如果词典查询是哈希 O(1)那么绝大多数场景下这个复杂度是完全可以接受的。2.2 逆向最大匹配从右往左贪逆向最大匹配Reverse Maximum MatchingRMM跟 FMM 的方向相反也从右往左抱最大的词。它的直觉是中文里许多偏正结构的词中心语往往靠近右侧从右边开刀更容易切出语义完整的单元。还是结婚的和尚未结婚的RMM 的做法如下从句子末尾的字出发取末 5 个字尚未结婚的不在词典缩短为未结婚的不在继续结婚的不在婚的不在最后取单字的切出第一个词的——注意这里的顺序是从右往左产生的后续需要反转。然后移动到结婚的和尚未结婚末尾的婚字再取候选尚未结婚4 字可能不在缩短到结婚2 字如果词典有则切出结婚继续……最终倒序结果反转后为结婚 / 的 / 和 / 尚未 / 结婚 / 的。对比 FMM 的结果会在和尚和未上产生分歧。RMM 因为从右往左中间和尚未结婚这一段在句尾方向取词时尚未更容易先被命中从而保留了和这个独立词得到更合理的切分。经典实验也表明单纯用 RMM 要比 FMM 的切分错误率低一些尤其在某些歧义句式上逆向反而能避开贪婪向前的陷阱。不过 RMM 也有自己的问题。对于结合成分子时这类句子词典同时包含结合、合成、分子、子时时它的方向偏好可能导致不同结果。所以它和 FMM 各有各的盲区这时就需要双向匹配来做裁判。2.3 双向最大匹配用交叉验证消歧双向最大匹配Bi-directional Maximum MatchingBi-MM的思路并不复杂把 FMM 和 RMM 的结果都跑出来根据两条规则决定哪个更好。最常用的启发式规则如下比较词数如果两者切出的词数不同则取词数较少的那一组。因为大多数情况下过度切分会产生更多碎片而正确切分往往让词语长度更饱满、词数更少。比较单字词数量如果两者词数相同则比较单字词的数量取单字词更少的那一组。单字词越少说明整体分词越倾向合并成更长的词汇通常越接近真实语义。如果上面两个条件都打平则直接返回正向匹配的结果作为默认偏好。回到结婚的和尚未结婚的FMM 切出结婚/和尚/未/结婚/的一共 5 个词其中单字词 2 个未、的。RMM 切出结婚/的/和/尚未/结婚/的一共 6 个词其中单字词 3 个的、和、的。按规则一比较词数FMM 5 词少于 RMM 6 词于是双向匹配返回 FMM 的结果这就尴尬了显然 FMM 的和尚切错了。这个例子恰好暴露了词数少优先规则并非万能。更合理的做法是在词典中加入尚未之类的词后让 RMM 结果词数更少或者在消歧时引入分词后的平均词长、分词后的词数以及是否包含某个关键词等复合条件。我在实现中发现单纯用经典双规则在某些语料上可能反而倾向于错误结果。因此很多工程实现会在双向匹配基础上再加一条规则如果两结果词数和单字词数都相当则比较总词长方差取更均匀的那组或者使用一个针对业务词典的权重评分。这也是后面我在工程优化部分要展开的内容。3. Python实现词典加载、匹配器与双向判定3.1 词典加载与数据结构先解决词典加载。我在模拟项目X里用的词典是每行一个词的纯文本文件词之间没有任何词性标注。加载时要做两件事一是把所有词放进一个集合用于 O(1) 查询二是统计出整个词典的最大词长作为匹配时候选窗口的上限。def load_dict(dict_path): word_set set() max_len 1 with open(dict_path, encodingutf-8) as f: for line in f: word line.strip() if not word: continue word_set.add(word) max_len max(max_len, len(word)) return word_set, max_len这段代码很简单但有一个容易疏漏的点词典里很可能混入空行、空格、全角空格或有 BOM 的文件头。在加载时必须做strip()否则匹配时会出现永远查不到词的情况。另外如果词典中同时存在短词和长词比如结婚和结婚的最大词长是 3那么 FMM 在处理结婚的时会先尝试 3 字词命中就切出结婚的这是课本里没有提到的细节但实际会显著影响切分颗粒度。所以词典最大词长必须动态计算不能写死。如果业务需要更高性能我还会把这份word_set再按长度分组例如建一个dict_by_len {2: {结婚, ...}, 3: {...}}这样匹配时只需要查询当前候选长度对应的集合命中率更清晰。不过多数情况下直接一个 set 就够了。3.2 正向匹配器的实现FMM 的 Python 实现可以很紧凑。核心是利用字符串切片从当前指针开始逐步缩短候选词直到在词典中命中。def fmm_parse(sentence, word_set, max_len): words [] i 0 n len(sentence) while i n: end min(i max_len, n) matched False for j in range(end, i, -1): candidate sentence[i:j] if candidate in word_set: words.append(candidate) i j matched True break if not matched: # 找不到任何词典词则切出一个单字 words.append(sentence[i]) i 1 return words这段代码有三个细节要注意内层range(end, i, -1)保证了从最长候选开始尝试一旦命中立即跳出这就是最大匹配。end min(i max_len, n)防止切片越界。当所有候选都失败时强行切一个单字。这个单字可能是未登录词的一部分也可能是真正的单字词。无论哪种都是后续统计模型需要处理的点。我在第一次实现时忽略了第三种情况直接把不匹配的部分跳过结果句子被截断后来才发现这是最典型的低级错误。建议无论 FMM 还是 RMM单字兜底逻辑都要写成显式分支方便后来排查。3.3 逆向匹配器的实现RMM 在实现上稍微绕一点因为它是从右往左切词切出来的顺序是反的最后需要反转。def rmm_parse(sentence, word_set, max_len): words_rev [] i len(sentence) while i 0: start max(0, i - max_len) matched False for j in range(start, i): candidate sentence[j:i] if candidate in word_set: words_rev.append(candidate) i j matched True break if not matched: words_rev.append(sentence[i - 1]) i - 1 return list(reversed(words_rev))这里的内层循环用的是range(start, i)也就是说候选词从最短的单字开始向长词递增。为什么不用从长到短因为对于固定的结束位置 i候选词长度是i - j当 j 从 start 增加到 i 时候选词由长变短。如果要从长到短j 应该从 start 到 i-1 增加这样sentence[j:i]正好从最长变到最短。我们取的是第一个命中即最长命中所以这样写是没问题的。另一个容易搞混的点是当匹配失败时i - 1之前要切成sentence[i-1]而不是sentence[i]。我见过不少新手在这里越界就是因为没注意i表示的是右边界切单个字时要取i-1。3.4 双向匹配与启发式消歧双向匹配就是把前面两个函数的结果拿过来做裁判。我实现时把规则做了一点点扩展让它在我的测试集上表现更稳def bi_mm_parse(sentence, word_set, max_len): f_result fmm_parse(sentence, word_set, max_len) r_result rmm_parse(sentence, word_set, max_len) if len(f_result) ! len(r_result): return f_result if len(f_result) len(r_result) else r_result f_single sum(1 for w in f_result if len(w) 1) r_single sum(1 for w in r_result if len(w) 1) if f_single ! r_single: return f_result if f_single r_single else r_result # 如果还是打平统计每个结果中词长的标准差取更均匀的那组 import statistics f_std statistics.pstdev([len(w) for w in f_result]) r_std statistics.pstdev([len(w) for w in r_result]) return f_result if f_std r_std else r_result这里我加了一个词长标准差规则是自己在实验里发现的改进点。当正逆结果的词数和单字词数都完全一样时比如研究生命科学正逆都是研究/生命/科学返回哪个都无所谓。但某些句子两者有同等的词数单字数却存在不同颗粒度标准差小表示切分更均匀更符合直觉。不过要声明这套扩展规则并非所有场景都适用。如果你用的是特殊领域词典比如医疗术语词典你会发现长术语很多反而需要偏好长词的规则而不是追求均匀。所以双向匹配的消歧规则本身就是个经验工程要根据词典特性和应用目标来调整。4. 实测数据对比精度、歧义与性能差异4.1 测试语料与评估指标为了验证三种算法的差异我从公开的短文本语料里抽了 500 条句子人工切分作为标准答案。测试语料刻意覆盖了几类情况普通陈述句、包含交叉歧义的句子、包含未登录词的句子、以及数字和英文混合的句子。评估指标我用三个准确率Precision、召回率Recall和 F1 值都是按切分出来的词与标准分词结果做边界的匹配来计算的。我也统计了每个句子分出的词数、单字词数方便分析。这里不展开公式细节懂的人自然懂不懂的看 F1 就够了——它是精准和召回的综合表现。4.2 三方案分词结果对比先看几个有代表性的句子结果。下面这个表是我实际跑出来的词典是我自己整理的包含 1.8 万条常用词最大词长是 5。输入句子正向FMM逆向RMM双向Bi-MM结婚的和尚未结婚的结婚/和尚/未/结婚/的结婚/的/和/尚未/结婚/的结婚/的/和/尚未/结婚/的研究生命起源研究/生命/起源研究生/命/起源研究/生命/起源结合成分子时结合/成分/子时结/合成/分子/时见下方分析欢迎新老师生前来就餐欢迎/新/老师/生前/来/就餐欢迎/新/老/师生/前来/就餐欢迎/新/老/师生/前来/就餐注意结合成分子时这一行我留了个坑。当词典里同时存在结合、成分、子时以及合成、分子这些词时FMM 倾向于切出结合/成分/子时RMM 倾向于结/合成/分子/时。从语义上看标准的切分可能是结合/成/分子/时但两个方向都没切对。双向匹配如果只用词数规则会返回词数较少的一组这里 FMM 和 RMM 都是 4 词单字词也都是 1 个最终可能返回默认的 FMM所以依然不对。这个例子完美解释了最大匹配算法的一个天然局限它只做词典贪心切分不进行全局语义消歧。双向匹配可以修正一部分歧义但无法解决所有歧义。真遇到这种句子要么提升词典质量要么引入统计语言模型比如使用 n-gram 为不同切分结果打分。从整体数据看在我的 500 句测试集上FMM 的 F1 大约是 91.8%RMM 是 93.6%双向匹配扩展规则后到了 96.3%。这个提升幅度看起来不大但放到线上每天处理百万次查询的场景里错误率从 8.2% 降到 3.7%减少的脏数据是很可观的。4.3 性能与内存开销性能方面我也做了压测。用一条平均长度 20 个字符的句子重复 10 万次在普通笔记本上FMM 单次耗时大概 4~6 微秒RMM 在 6~8 微秒双向匹配因为要跑两遍再加比较大概 10~14 微秒。这里没有任何高级优化就是纯 Python 循环所以瓶颈主要在解释器开销和字符串切片上。内存开销更直接词典越大set 占用的内存越高。1.8 万词大概占 3~5MB十万词级别就到了 15~25MB。如果词典是固定不变的可以把 set 换成frozenset并加载一次后全局复用避免每次调用都重新加载。在服务里我还会用内存映射表或者定制前缀树来进一步压缩。5. 工程落地要点与优化方向5.1 未登录词的负面影响最大匹配算法最大的敌人不是歧义而是未登录词。所谓未登录词就是词典里没有但实际中频繁出现的词。典型如云原生、区块链、产品型号、人名、地名等。FMM/RMM 遇到未登录词时只能切成一堆单字导致下游任务直接崩。我在模拟项目X里就踩过这个坑。词典里没有输入的搜索词明明可以靠脸吃饭的PPT模板结果是明/明/可/以/靠/脸/吃/饭/的/PPT/模/板几乎全碎。要处理这个问题只能靠两个途径一是定期更新词典把高频未登录词抽出来加进去二是对识别出的连续单字串做合并比如统计词频把高频相邻字捆绑成新词。第二种方法其实已经进入新词发现的领域了但可以在最大匹配框架上用一些后处理规则。另外一个工程常用做法是引入用户词典对特定领域把实体词直接加入。比如金融领域银行理财、非标资产这类术语必须优先命中。我会把这些词在加载时标记为强制词在匹配时先查强制词词典再走通用词典相当于做了一层领域优先匹配。5.2 前缀树与动态规划优化虽然 set 查询已经是 O(1)但在 FMM 的循环里每次都要用一个候选切片去查 set切片本身会创建新字符串开销不小。更优的做法是使用前缀树Trie或双数组字典树Double-Array Trie来避免反复切割字符串。前缀树的核心思想是把所有词典词按字符路径组织起来。切词时从当前字符开始顺着 Trie 遍历字符能走多远走多远走到某个节点如果是一个词就记录一次命中。这样就不用对每个候选长度单独切片和哈希查询算法复杂度变成 O(n×m) 但常数很小实际速度能提升数倍。更进一步还可以把最大匹配升级成基于词典的动态规划分词。它不再是贪心而是从起始位置出发枚举所有可能从当前位置切出的词递归计算剩余部分的最优切分。配合一个简单的累计评分比如词频负对数就能克服最大匹配只看局部的缺点。我后来在项目里就做了这么一版先用 Trie 保存词典再用动态规划计算最优路径速度和准确率都比初始手写版好很多。但那是后话想理解动态规划分词还是建议先把最大匹配玩明白。5.3 什么时候该用什么时候该换最后得泼一盆冷水。最大匹配并不是万能的它适合以下场景词典可控、语料领域比较固定、对速度要求很高、且不存在大量复杂歧义。如果你要处理开放域文本、需要高精度中文分词还是应该考虑隐马尔可夫模型、条件随机场或者神经网络分词器。我的建议是把最大匹配作为一个快速基线。在做任何项目时先用它跑通流程看看分词结果的下限在哪。如果发现大量错误来自词典缺失就去补词典如果来自复杂歧义再升级算法。直接上大模型未必是好事因为分词粒度不同会直接影响后续特征。我遇到过项目里用了开源分词器以为万事大吉结果它把双向匹配算法切成了双向/匹配/算法和业务定义的术语完全对不上。最后还是用最大匹配加自定义词典解决了问题。另外如果要把它集成到生产系统记得加缓存。对于短查询可以把整句分词结果缓存到内存键是句子原文值是分词列表。这样在热数据上连匹配都不用跑直接从缓存取。我在压测中加了简单 LRU 缓存后平均查询延迟下降了 70%几乎可以忽略不计。说到底正向最大匹配、逆向最大匹配与双向匹配算法不是象牙塔里的玩具它们是一块很扎实的垫脚石。理解了它们的优缺点你才懂为什么后来的分词方法要引入概率、序列标注和神经网络也才能在自己做分词需求时选到真正合适的方案而不是被各种流行概念忽悠。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑