中文分词实战:从词典匹配到二元语法与Viterbi算法

发布时间:2026/10/16 15:07:04

中文分词实战:从词典匹配到二元语法与Viterbi算法 简介本资源为南京邮电大学自然语言处理课程实验一的完整实验报告面向修读NLP基础课程的高校学生及希望入门中文分词与句法分析的初学者。内容围绕词典分词与二元语法分词两大核心任务展开涵盖HanLP工具的分词指令segment、句法分析指令parse的实操记录以及前向最长匹配、后向最长匹配、双向最长匹配三种算法的对比分析并附有基于pyhanlp的Python代码实现示例。资源包内含1个doc文档大小约232KB以实验报告形式系统整理了实验目的、环境配置、原理说明与结果记录便于读者对照教材第27页案例复现分词与依存句法分析流程。目前已有424人学习下载适合需要完成同类实验、理解中文分词歧义处理与句法依存关系的中级学习者参考。1. 分词不是查字典为什么“结婚的和尚未结婚的”会翻车很多人第一次接触中文分词会下意识觉得这就是个查字典的活拿一个词典从左到右扫一遍能匹配多长就切多长切完收工。这个直觉在 80% 的句子上都成立但剩下 20% 会把你按在地上摩擦。最经典的例子就是“结婚的和尚未结婚的”——前向最长匹配切出的是“结婚 / 的 / 和尚 / 未 / 结婚 / 的”后向最长匹配切出的是“结婚 / 的 / 和 / 尚未 / 结婚 / 的”。同一句话两种切法语义完全不同一个是“结婚的和尚还没结婚”一个是“结婚的人和尚未结婚的人”。这就是中文分词里最核心的矛盾——切分歧义。自然语言处理里分词是几乎所有下游任务的第一道工序。分词错了词性标注、句法分析、情感分析全都会跟着错。所以搞懂分词不是学一个工具怎么用而是要搞懂为什么基于词典的方法会翻车二元语法又是怎么用统计的方式把这个坑填上的。这份实验资料把词典分词和二元语法分词放在一起做对比恰好是理解这条演进路线的最佳切入口。2. 词典分词三种匹配策略背后的歧义博弈2.1 前向、后向、双向最长匹配到底差在哪词典分词的核心逻辑非常朴素维护一个词典对输入句子做字符串匹配找到词典中存在的词就切出来。但“怎么找”这件事不同的扫描方向会给出完全不同的结果。前向最长匹配FMM的思路是从句子开头开始取最长的能在词典中命中的子串作为第一个词然后从剩余部分继续。后向最长匹配RMM反过来从句子末尾往前扫。双向最长匹配则是两个方向都跑一遍然后按规则选一个更优的结果——通常是比较分词数量词数少的优先词数相同则看单字数量单字少的优先。用“结婚的和尚未结婚的”来验证# 模拟词典分词三种策略的核心逻辑 # 词典用集合表示实际 HanLP 用的是 DoubleArrayTrie dictionary {结婚, 的, 和, 和尚, 尚未, 未, 结婚} def forward_max_match(text, dictionary): 前向最长匹配从左往右每次取最长命中词 result [] i 0 while i len(text): matched None # 从最长的可能长度开始尝试匹配 for j in range(min(len(text) - i, 5), 0, -1): if text[i:ij] in dictionary: matched text[i:ij] break if matched: result.append(matched) i len(matched) else: result.append(text[i]) # 未命中则单字成词 i 1 return result def backward_max_match(text, dictionary): 后向最长匹配从右往左每次取最长命中词 result [] i len(text) while i 0: matched None for j in range(min(i, 5), 0, -1): if text[i-j:i] in dictionary: matched text[i-j:i] break if matched: result.insert(0, matched) i - len(matched) else: result.insert(0, text[i-1]) i - 1 return result text 结婚的和尚未结婚的 print(前向:, forward_max_match(text, dictionary)) print(后向:, backward_max_match(text, dictionary))前向的结果是[结婚, 的, 和尚, 未, 结婚, 的]后向的结果是[结婚, 的, 和, 尚未, 结婚, 的]。差异出现在第三到第四个词的位置前向扫描到“和”时发现“和尚”在词典里就优先切了“和尚”后向扫描到“尚未”时发现“尚未”在词典里就切了“尚未”。两种切法都“合法”但语义完全不同。双向最长匹配的做法是两个方向都跑然后比较结果。如果分词数量不同选词数少的如果词数相同选单字少的。在这个例子里前向和后向都是 6 个词单字数量也相同所以双向匹配会退化为某种默认策略。这说明双向匹配也不是万能药——它只是降低了出错概率并没有从根本上解决歧义。2.2 DoubleArrayTrie为什么词典查询能做到 O(n)词典分词看起来简单但真正的工程挑战在于词典可能有几十万条词条如果每次匹配都遍历词典速度会慢到不可接受。HanLP 用的是双数组 TrieDoubleArrayTrie这是一种把 Trie 树压缩成两个整型数组的数据结构查询时间复杂度只和待匹配字符串的长度有关和词典大小无关。from pyhanlp import * # 使用 DoubleArrayTrieSegment 进行词典分词 segment DoubleArrayTrieSegment() result segment.seg(春分之特别在一个“均”字均分了昼夜) print(result) # 输出: [春分/null, 之/null, 特别/null, ...]DoubleArrayTrie 的核心思想是把 Trie 树的每个节点映射到两个数组base[]和check[]中。base[i]存储状态转移的基址check[i]验证转移的合法性。查询时通过base和check的联合索引在 O(1) 时间内完成一次状态转移。整个查询过程就是沿着输入字符串的每个字符走一步走完就得到一个词。这种设计的代价是构建时比较耗时——需要把词典的所有词条插入 Trie 并做压缩。但一旦构建完成查询速度极快而且内存占用远小于原始 Trie。HanLP 在启动时会加载核心词典并构建 DoubleArrayTrie这也是为什么第一次调用分词会有一个明显的延迟。2.3 AhoCorasickDoubleArrayTrie多模式匹配的工程解法DoubleArrayTrie 解决的是“给定一个字符串查它是否在词典中”的问题。但停用词过滤的场景不一样给定一段文本要找出所有出现在停用词表中的词。这是一个多模式匹配问题。HanLP 的解法是把 Aho-Corasick 自动机和 DoubleArrayTrie 结合起来。Aho-Corasick 的核心是给 Trie 树加上失败指针fail pointer当某个分支匹配失败时不是回到根节点重新开始而是跳到失败指针指向的节点继续匹配。这样一次扫描就能找出所有命中模式。from jpype import JString from pyhanlp import * HanLP.Config.ShowTermNature False def load_from_file(path): 加载停用词表到 DoubleArrayTrie map JClass(java.util.TreeMap)() with open(path, encodingutf-8) as src: for word in src: word word.strip() map[word] word return JClass(com.hankcs.hanlp.collection.trie.DoubleArrayTrie)(map) def replace_stopwords_text(text, replacement, trie): 用最长匹配搜索器替换停用词 searcher trie.getLongestSearcher(JString(text), 0) offset 0 result while searcher.next(): begin searcher.begin end begin searcher.length if begin offset: result text[offset:begin] result replacement offset end if offset len(text): result text[offset:] return result trie load_from_file(HanLP.Config.CoreStopWordDictionaryPath) text 走在春分二候的京城植物园的玉兰在蓝天下盛开 print(replace_stopwords_text(text, *, trie))这段代码的关键在于getLongestSearcher——它返回一个迭代器每次next()调用都会找到下一个最长匹配的停用词。begin和length分别表示匹配到的起始位置和长度。整个替换过程只扫描一遍文本时间复杂度是 O(n)其中 n 是文本长度。这里有个容易踩的坑load_from_file打开文件时必须指定encodingutf-8。Windows 默认用 GBK 解码遇到 UTF-8 编码的文件会直接抛UnicodeDecodeError。这个错误在实验报告里被明确记录为“主要问题及解决方法”说明它是真实会翻车的地方。3. 二元语法分词用统计概率替代规则匹配3.1 从“能不能切”到“哪种切法概率更大”词典分词的局限在于它只能判断一个字符串是否在词典中无法判断哪种切分方案更“合理”。二元语法分词换了一个思路不纠结于单个词是否在词典中而是计算整个切分序列的概率选概率最大的那个。具体来说对于一个句子 $S w_1 w_2 \ldots w_n$二元语法模型计算的是$$P(S) \prod_{i1}^{n} P(w_i | w_{i-1})$$其中 $P(w_i | w_{i-1})$ 是从训练语料中统计出来的条件概率。实际实现中通常用频率代替概率并且为了避免浮点数下溢会取对数变成加法。这个思路的妙处在于它不需要一个“完美”的词典而是从数据中学习哪种词序列更常见。比如“商品和服务”如果训练语料中“商品 和 服务”出现的次数远多于“商品 和服 务”那么二元语法模型就会倾向于前者。3.2 训练二元语法模型NatureDictionaryMaker 做了什么HanLP 训练二元语法模型的入口是NatureDictionaryMaker。它的工作流程是读取分好词的语料统计每个词的出现频率一元语法以及每对相邻词的出现频率二元语法然后把结果保存成文本文件。from pyhanlp import * CorpusLoader SafeJClass(com.hankcs.hanlp.corpus.document.CorpusLoader) NatureDictionaryMaker SafeJClass(com.hankcs.hanlp.corpus.dictionary.NatureDictionaryMaker) def train_bigram(corpus_path, model_path): # 加载语料每行是一个分好词的句子 sents CorpusLoader.convert2SentenceList(corpus_path) # 给每个词设置词性标签这里统一设为 n for sent in sents: for word in sent: word.setLabel(n) # 统计一元和二元频率 maker NatureDictionaryMaker() maker.compute(sents) # 保存模型到文件 maker.saveTxtTo(model_path) train_bigram(my_cws_corpus.txt, my_cws_corpus_model)训练完成后会生成三个文件文件名内容作用my_cws_corpus_model.txt每行格式为词 词性 频次一元语法模型记录每个词的出现频率my_cws_corpus_model.ngram.txt每行格式为词1词2 频次二元语法模型记录相邻词对的共现频率my_cws_corpus_model.tr.txt词性转移概率用于词性标注分词任务中不直接使用NatureDictionaryMaker.compute()的内部逻辑是遍历所有句子对每个词做一元计数对每对相邻词做二元计数。这些计数最终被写入文本文件供后续的 Viterbi 分词器加载。3.3 ViterbiSegment如何在所有切分方案中找最优路径有了二元语法模型接下来的问题就是给定一个句子如何找到概率最大的切分方案暴力枚举所有可能的切分组合是指数级的不可行。HanLP 用的是 Viterbi 算法——一种动态规划方法把问题分解成“到第 i 个字符为止的最优切分”的子问题。from pyhanlp import * def predict(): # 指定使用训练好的模型 HanLP.Config.CoreDictionaryPath my_cws_corpus_model.txt HanLP.Config.BiGramDictionaryPath my_cws_corpus_model.ngram.txt # 加载 Viterbi 分词器 ViterbiSegment JClass(com.hankcs.hanlp.seg.Viterbi.ViterbiSegment) segment ViterbiSegment() s segment.seg(商品和服务) print(s) predict() # 输出: [商品/n, 和/n, 服务/n]Viterbi 算法的核心是维护一个动态规划表dp[i]表示从句子开头到第 i 个字符的最大概率以及达到这个概率的最后一次切分。状态转移方程是$$dp[i] \max_{j i} { dp[j] \log P(w_{j1..i} | w_{prev}) }$$其中 $w_{j1..i}$ 是从第 j1 到第 i 个字符组成的词$w_{prev}$ 是前一个词。实际实现中HanLP 会先用词典找出所有可能的词构建一个词图Word Graph然后在词图上跑 Viterbi。这里有一个关键细节如果训练语料很小比如实验里只有三行很多词对在二元模型中不存在对应的转移概率为零。HanLP 的处理方式是回退到一元概率或者给一个极小的平滑值。这也是为什么在小语料上训练出来的模型分词效果可能不如通用词典——数据稀疏问题在统计方法里是绕不开的。4. 避坑指南分词实验里最容易翻车的五个地方4.1 UnicodeDecodeError编码问题比算法问题更常见现象读取停用词表或语料文件时抛出UnicodeDecodeError: gbk codec cant decode byte 0x90 in position 2519。原因Windows 系统默认使用 GBK 编码打开文件但文件本身是 UTF-8 编码。Python 的open()函数在没有指定encoding参数时会使用系统默认编码。解决在所有open()调用中显式指定encodingutf-8。这个问题在实验报告中被明确记录说明它是真实高频的翻车点。4.2 模型路径配置错误HanLP.Config 的加载顺序现象训练完二元语法模型后调用ViterbiSegment分词结果和通用词典一样没有用到自定义模型。原因HanLP.Config.CoreDictionaryPath和HanLP.Config.BiGramDictionaryPath必须在实例化ViterbiSegment之前设置。如果在分词器初始化之后再改配置不会生效。解决把配置赋值放在ViterbiSegment()调用之前确保分词器加载的是自定义模型。4.3 词性标签缺失setLabel 为什么必须调用现象训练时报错或生成的模型文件为空。原因NatureDictionaryMaker.compute()要求每个词都有词性标签。如果语料加载后没有调用word.setLabel()后续统计会失败。解决在compute()之前遍历所有词统一设置一个标签比如n。分词任务本身不关心词性但 HanLP 的内部数据结构要求标签存在。4.4 小语料训练的模型效果差数据稀疏是硬伤现象用三行语料训练出来的模型对稍微复杂一点的句子分词效果很差甚至不如词典分词。原因二元语法模型依赖统计频率语料太小会导致大量词对的频率为零。Viterbi 在概率为零的路径上无法做出有意义的区分。解决这是统计方法的固有局限不是代码 bug。理解这一点比“修好它”更重要——它解释了为什么工业级分词系统需要大规模标注语料以及为什么词典分词在特定领域仍然有不可替代的价值。4.5 hanlp segment 命令行参数顺序-a crf 放错位置会静默失败现象执行hanlp segment input1.txt output1.txt -a crf时输出文件为空或结果不符合预期。原因命令行参数的位置会影响解析。-a crf指定使用 CRF 模型进行分词如果放在重定向符号之后可能被 shell 解释为文件名而不是参数。解决把参数放在重定向之前或者使用 HanLP 的 Python API 代替命令行调用。命令行工具适合快速验证但生产环境更推荐用 API。5. 词典还是统计分词方案选型背后的工程权衡分词这件事看起来是算法问题实际上是工程权衡。词典分词和二元语法分词代表了两种截然不同的思路前者依赖人工构建的词典后者依赖数据驱动的统计模型。两者没有绝对的优劣只有适不适合。词典分词的优点是确定性强、速度快、可解释性好。给定一个词典任何输入的分词结果都是可预测的。这在需要稳定输出的场景下非常重要比如搜索系统的查询词切分——你不可能让同一个查询词今天切成一个样、明天切成另一个样。DoubleArrayTrie 的 O(n) 查询复杂度也让它在大规模词典下依然能保持毫秒级响应。但词典分词的死穴是未登录词和歧义消解。“结婚的和尚未结婚的”这个例子已经说明仅靠最长匹配无法解决结构歧义。更麻烦的是新词——网络用语、专有名词、领域术语这些词不可能全部预先收录到词典中。二元语法分词的优势在于它能从数据中学习“哪种切法更常见”。它不要求每个词都在词典中而是通过概率来判断切分方案的合理性。这让它在处理未登录词和歧义时更有优势。但代价是它需要标注语料来训练语料质量直接决定模型效果而且统计模型的输出不如词典分词稳定同一个句子在不同模型上的分词结果可能不同。实际工程中常见的做法是两者结合用词典做基础分词用统计模型做歧义消解和未登录词识别。HanLP 的默认分词器就是这种混合策略——先用 DoubleArrayTrie 找出所有可能的词构建词图然后用 Viterbi 在词图上找最优路径路径的权重来自一元和二元概率。这样既保留了词典的确定性又引入了统计的灵活性。我在处理分词任务时养成了一个习惯拿到一个分词结果先问三个问题——这个词在词典里吗这个切分方案的二元概率是多少如果换一种切法概率差多少这三个问题能帮我快速判断分词器是在“查词典”还是在“做统计”以及它在当前输入上的置信度有多高。从那以后我每次调试分词问题都会强制走一遍这个流程比盲目调参有效得多。这份实验资料的价值在于它把词典分词和二元语法分词放在同一个实验里做对比让你亲手跑出两种方法在同一句话上的不同结果。这种对比比看十篇论文都直观。如果你想把分词这条线彻底搞透建议从这份资料里的代码入手先跑通 DoubleArrayTrie 和 ViterbiSegment再尝试用更大的语料训练模型观察分词结果的变化。希望帮到你。本文还有配套的精品资源点击获取
延伸阅读

更多相关文章

2026/10/16 15:07:04

macOS下VirtualBox桥接模式IP获取失败的根因与修复

1. 这个问题不是VirtualBox的Bug,而是macOS网络栈的“礼貌性沉默” 我第一次在某高校实验室帮学生调试跨平台开发环境时,就撞上了这个经典问题:VirtualBox虚拟机设成桥接模式,启动后 ifconfig 一查,只有 lo0 &…

2026/10/16 15:02:03

Qt手绘流程图导出可解析XML:轻量级矢量图形建模方案

简介:本资源是一套基于Qt框架的图形绘制与XML序列化完整实践项目,面向C/Qt初学者及GUI开发进阶者,解决自定义图形界面构建、可视化元素持久化存储与跨平台数据交换等核心问题。项目涵盖从基础QPainter绘图(矩形、椭圆、路径等&…

2026/10/16 15:02:03

AI写论文工具实测:5款横向对比,宏智树AI全流程深度测评

每到毕业季,我身边总有一批人被论文折腾得脱了一层皮。从选题开题、文献综述、大纲打磨、初稿撰写,到降重、调格式、做答辩PPT,每一环都能卡住一批人。这两年AI工具越来越猛,市面上号称“AI写论文”的产品一大堆,但真正…

2026/10/16 16:27:29

LabVIEW控制程控电源IT3200:SCPI指令与VISA通讯自动化测试实战

简介:一套针对 IT3200 程控电源的 LabVIEW 控制方案,面向仪器控制、自动化测试与实验室供电场景的开发人员,解决通过 RS-232 串口完成远程电压/电流设定、读取与实时监控等需求。压缩包共 98 个文件,约 924KB,包含 84 …

2026/10/16 16:27:29

AD9361配置脚本包详解:射频硬件初始化与寄存器级调试指南

简介:本资源是ADI公司AD9361射频收发器专用的官方级配置工具安装包,面向无线通信、软件定义无线电(SDR)及FPGA协同开发工程师,解决AD9361芯片参数配置复杂、LUT文件手动生成易错等核心痛点。压缩包共75个文件&#xff…

2026/10/16 16:27:29

Unity SRP/URP实战:从原理到自定义渲染管线

从什么时候开始觉得内置管线不够用的?对我来说,是在某项目里反复调整半透明物体的深度写入,折腾自定义后处理却只能依赖OnRenderImage(还在用各种奇技淫巧)的时候。那一刻特别想要一种“我能自己决定画面先画什么、后画…

2026/10/16 16:27:29

语义流网络:从特征流动到边界优化的分割方法解析

1. 从一张街景图说起:为什么语义流值得单独拿出来聊语义分割这件事,做过的人都知道痛点在哪。给一张街景图,要求把每个像素分类成道路、车辆、行人、天空、建筑,模型输出的分割图边缘经常是糊的——车顶和天空交界处像被橡皮擦蹭过…

2026/10/16 16:27:29

高质量数据集:AI战略的基石与运营商数据实践

高质量数据集:AI战略的基石与运营商的数据实践在AI圈待得久了,你会发现一个很扎心的现实:模型结构可以抄,算力可以堆,但真正决定一个模型能用不能用、好用不好用的,往往是那些看不见的数据。尤其是这两年&a…

2026/10/16 16:22:23

排序算法全解析:复杂度、稳定性与工程实战选型指南

直接进入正题。这一章“排序”,可以说是整个数据结构课程里最实用、也最容易被轻视的一章。说它实用,是因为不管你以后写业务代码还是做底层框架,排序都逃不掉;说它被轻视,是因为很多人背了快排的代码就以为掌握了排序…

2026/10/16 5:33:43

Python调用Gemini Structured Outputs实现工单路由门禁

客服工单最怕的不是模型“答错一句话”,而是它给出一段看起来合理的说明,程序却从中猜错优先级。通俗做法是:要求模型只交 JSON(JavaScript Object Notation,轻量数据格式),再让代码验证它。Gem…

2026/10/16 4:03:48

Spring Boot超市进销存系统毕设实战:从需求拆解到答辩通关

最近带的一个学生项目组里,有A同学跑来问我:选什么毕设题目最稳妥,既能让评审老师觉得工作量够,又不会在答辩时被问到语无伦次。我第一反应就是推荐基于Spring Boot的超市仓库管理系统——也就是超市进销存系统。这个题目乍一看平…

2026/10/15 13:42:16

Flutter StatefulWidget 生命周期核心解析

很多刚开始接触 Flutter 的朋友,在看完一堆“Hello World”和基础组件之后,大概率都会撞上同一堵墙:StatefulWidget 里那堆 initState、build、dispose 方法,到底什么时候被调用?为什么顺序是那样?在里面到…

2026/10/16 0:01:11

Cephadm实战指南:Linux系统工程师的生产级Ceph集群部署与排障

简介:本资源是一份完整的Ceph分布式存储系统中文技术文档合集,面向Linux系统运维工程师、云平台架构师及存储方向开发者,帮助读者系统掌握Ceph集群的部署、配置与运维核心能力。文档覆盖对象存储(RGW)、块设备&#xf…

2026/10/16 0:01:11

ITK中SpatialObject几何体系解析:坐标系、变换与管道集成

1. 空间对象的定位:为什么 ITK 需要一套几何描述体系很多刚开始接触 ITK 的朋友,包括我自己,最初都是被它的图像滤波、配准、分割这些“正统”功能吸引进来的。等真正上手做点实际项目,比如把分割出来的器官边界做量化分析、给血管…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑