分词不只靠最长匹配:Trie 与动态规划逐格展开

简介: 本文提出基于Trie与逆向动态规划的中文分词算法,以“未知字符最少、词数最少”为双重目标,克服最长匹配的局部贪心缺陷。通过从右向左递推、路径还原与反例验证,给出完整Python实现,兼顾准确性、稳定性和可扩展性。(239字)

最长匹配每一步都选当前最长词,却可能把后半句切成未知字符。本文把词典装进 Trie,从字符串末尾向前展开动态规划状态,以“未知字符最少、词数最少”为双重目标,给出 Python 完整实现、路径还原和反例测试,说明局部选择为何不能替代全局比较。

看到“研究生命起源”,从左往右最长匹配会先拿走“研究生”,剩下“命起源”。如果词典没有单独的“命”,算法只能把它当未知字符;选择较短的“研究”,反而可以接上“生命”和“起源”。这个四字交界处,是检验分词算法是否只会贪心的好样本。

画面一:Trie 负责枚举,不负责决定

词典中有大量共享前缀。若从每个位置尝试每个词,匹配成本会重复;Trie 把相同前缀合并成路径。从位置 i 出发沿字符向下走,每遇到词尾就得到一个候选切分终点。Trie 回答“有哪些词能从这里开始”,但选择哪一个仍需全局目标。

本文的目标按字典序比较两个量:先最小化未知字符数,再最小化总词数。这样一条完全由词典覆盖但词稍多的路径,优先于含未知字符的短路径。若两个指标都相同,再按切分序列字典序稳定决胜,保证多次运行结果一致。

画面二:从终点倒着点亮格子

令 dp[i] 表示后缀 text[i:] 的最优结果,包括代价 (未知字符数, 词数) 和切分列表。dp[n] 是空后缀,代价为 (0,0)。计算 dp[i] 时有两类边:

  1. 把当前单个字符当未知项,转移到 dp[i+1],未知数加一、词数加一。
  2. 沿 Trie 找到每个词尾 j,把该词接到 dp[j+1] 前,未知数不增、词数加一。

因为所有转移都走向更大的下标,从右向左计算时依赖已经就绪。

from dataclasses import dataclass, field


@dataclass
class TrieNode:
    children: dict = field(default_factory=dict)
    is_word: bool = False


def build_trie(words):
    root = TrieNode()
    for word in words:
        if not word:
            raise ValueError("词典不能包含空词")
        node = root
        for char in word:
            node = node.children.setdefault(char, TrieNode())
        node.is_word = True
    return root


def best_split(text, words):
    root = build_trie(words)
    n = len(text)
    # 每项为 ((未知字符数, 词数), 切分元组)
    dp = [None] * (n + 1)
    dp[n] = ((0, 0), ())

    for i in range(n - 1, -1, -1):
        suffix_cost, suffix_tokens = dp[i + 1]
        best = (
            (suffix_cost[0] + 1, suffix_cost[1] + 1),
            (text[i],) + suffix_tokens,
        )

        node = root
        for j in range(i, n):
            node = node.children.get(text[j])
            if node is None:
                break
            if node.is_word:
                tail_cost, tail_tokens = dp[j + 1]
                candidate = (
                    (tail_cost[0], tail_cost[1] + 1),
                    (text[i:j + 1],) + tail_tokens,
                )
                if candidate < best:
                    best = candidate
        dp[i] = best
    return dp[0]


if __name__ == "__main__":
    dictionary = {
   "研究", "研究生", "生命", "起源"}
    cost, tokens = best_split("研究生命起源", dictionary)
    print(cost, "/".join(tokens))
    assert cost == (0, 3)
    assert tokens == ("研究", "生命", "起源")

    cost2, tokens2 = best_split("研究X", dictionary)
    assert cost2 == (1, 2)
    assert tokens2 == ("研究", "X")
    print("tokenizer tests passed")

画面三:把反例逐格还原

终点格 dp[6] 是空。向左计算“起源”时,Trie 能走到完整词,因此这段代价为 (0,1)。到“生命起源”开头时,“生命”接上已有结果得到 (0,2)。回到下标 0,候选“研究生”之后是未知“命”再接“起源”,代价至少 (1,3);候选“研究”之后直接接 dp[2],得到 (0,3)。比较第一维后,算法选择后者,无需任何特判。

这也是动态规划优于最长匹配的核心:最长匹配只看到当前词长,dp 把后缀质量带回了当前位置。若业务认为词频更重要,可以把代价换成负对数概率;若需要返回多个候选,可在每个格子保留前 k 条路径,形成简化的 beam search。

复杂度透视

设文本长度为 n,词典最长词长为 L。每个起点最多沿 Trie 走 L 步,时间复杂度为 O(nL);Trie 空间为词典所有字符总数,dp 保存切分元组时最坏可能产生 O(n^2) 复制。生产实现可只保存下一跳和代价,将空间降到 O(n),最后再沿指针还原一次路径。

边界条件

  • 空文本返回零代价和空切分,这是递推基座。
  • 空词会制造不前进的转移,必须拒绝。
  • 全部字符不在词典时,每个字符都作为未知项,未知数等于文本长度。
  • Unicode 字符按 Python 码点迭代;表情组合和规范化等更复杂文本应先统一规范。
  • 词典中重复词不影响 Trie,但加载阶段可先去重减少工作。

常见错误

只保存最少词数会偏爱一个超长未知片段;把所有未知连续字符合并又不计长度,会让未知内容几乎没有惩罚;从左到右填写却读取尚未计算的后缀,会得到偶发空值;路径相同代价时不设稳定规则,会受字典集合迭代顺序影响。目标函数必须先于代码被写清楚。

可复制测试

保存为 tokenizer.py 并执行 python tokenizer.py。第一行应显示 (0, 3) 研究/生命/起源,最后一行是 tokenizer tests passed。删除词典中的“生命”,预期结果包含一个未知“命”;加入完整词“研究生命起源”,预期词数降为一且未知数仍为零。

把路径复制改成下一跳

示例把完整切分元组放进每个 dp 格,代码直观,但长文本会重复复制后缀。更节省的实现让 dp[i] 只保存代价、下一个下标和当前词。计算结束后从下标零开始沿下一跳移动,依次收集词项,直到文本末尾。这样每个格子只占常数信息,路径只在最后构造一次。

稳定决胜也可以在不复制整条路径的情况下实现。若业务只要求固定结果,可在代价相等时优先更长词、词典编号更小或终点更靠后。若必须按完整切分序列的字典序比较,则需要持久化链、排名压缩或延迟比较,复杂度会提高。决胜规则应服务于业务,而不是为了数学形式增加无谓成本。

词频怎样进入动态规划

真实分词往往不只区分“词典内外”。可以给每个词一个概率,把路径代价设为负对数概率之和,乘法概率就变成可累加分数。未知字符使用一个平滑概率,既不会完全禁止新词,也会让已知高频组合更有优势。此时 dp 的结构不变,只是边权从二元计数换成浮点分数。

浮点比较要设置稳定策略,不能把极小误差当成显著差异。可以将语料计数和总量转成高精度对数,或在分数差小于阈值时启用次级规则。测试除固定样例外,还应验证所有边权非负或确认算法允许负权;本文 DAG 式下标前进没有环,即使使用任意有限边权也能递推。

前向最大匹配仍有用武之地

动态规划更全局,不代表每个场景都必须使用。词典很小、文本极短、错误代价低时,最长匹配实现简单且延迟稳定;协议关键字解析若语法本身规定最长词素,贪心甚至就是正确规则。算法选择要依据目标性质,而不是把动态规划视为更高级的固定替代品。

可以把最长匹配作为基线和回退:先运行快速贪心,若出现未知字符、歧义前缀或低置信度,再启动动态规划。这样普通输入走短路径,困难输入得到全局比较。无论采用哪种组合,都要保留“研究生命起源”这类最小反例,防止优化后悄悄退回错误局部选择。

词典更新与线上一致性

词典版本变化会改变同一文本的切分,进而影响搜索索引、特征统计和缓存键。更新时应给 Trie 标记版本,让离线索引与在线查询使用同一快照;不能在处理一个请求过程中替换词典根。热更新可以先构建新 Trie,完成校验后原子切换引用,旧请求继续使用旧版本。

测试集也应按领域分层:通用词、专有名词、数字字母混合、表情、繁简体和故意拼写错误。只报告总体准确率会掩盖未知词集中爆发。记录未知字符率、平均词数和路径分数分布,更容易发现词典加载失败或某次更新造成的系统性漂移。

还要检查输入文本是否被意外修改。分词器应返回原文中的连续片段,所有词项拼接后必须与规范化后的输入完全相同;这条简单不变量能抓住漏字符、重复字符和下标越界,也是随机生成文本时最值得保留的属性断言。

收束画面

Trie 缩短了候选枚举路径,动态规划负责比较完整切分,两者承担不同职责。遇到分词、路径拼接或协议解析问题时,先找一个能击穿局部贪心的短反例,再决定状态和代价,往往比背模板更快接近正确实现。

标签: Trie 动态规划 Python 分词算法 字符串

相关文章
|
存储 网络协议 安全
DNS科普系列: DNS防火墙(DNS Firewall)
DNS防火墙是一种网络安全解决方案,可以防止网络用户和系统链接到已知的恶意网络位置,可以有效的预防威胁发生, 阻止访问受感染的站点,进而防止进一步的威胁。能够主动检测到已被感染的系统,并通过安全报告的形式将此类威胁通知用户。还能够保护用户的网络环境,提高网络安全性。
DNS科普系列: DNS防火墙(DNS Firewall)
|
2月前
|
人工智能 数据安全/隐私保护
百炼图像生成三档模型实测:中文小字的正确率、耗时与计价口径
本文实测百炼三大图像生成模型在中文文字渲染上的真实表现,聚焦公众号封面、海报等物料中易出错的日期、价格、法务句等小字号文本。结果表明:z-image-turbo快且便宜(5.1s/¥0.1),但2026年误为2016年;qwen-image-3.0与3.0-pro两轮全对(19/19),兼顾精度与交付稳定性。推荐按场景分档使用,避免“一档通吃”。
百炼图像生成三档模型实测:中文小字的正确率、耗时与计价口径
|
28天前
|
存储 数据采集 人工智能
IDC权威报告:阿里云AI云存储市场份额位列第一
IDC报告显示,阿里云以35.2%份额领跑2025年中国AI云存储市场,超第二至四名总和。依托全栈自研CPFS、KVCacheStore及Agentic Storage矩阵,覆盖训练、推理与Agent全场景,支撑超50%中国大模型公司。
266 0
|
3月前
|
人工智能 运维 自然语言处理
国内首批,阿里云 STAROps 通过《智能原生软件工程》系列标准认证
STAROps 已在吉利汽车、畅捷通、精臣智慧等多家企业的生产环境中承担 7×24 小时智能运维职责,覆盖从基础设施到业务链路的全栈观测与诊断。
250 27
|
3月前
|
人工智能 弹性计算 运维
STAROps 主机智能巡检:给你的 ECS 请个 24 小时在线的 AI 医生
STAROps 主机智能巡检从事后救火转向事前防护。
252 23
|
3月前
|
人工智能 定位技术 API
高德汽车业务 AI Native 工程实践|基于 Qoder 的业务知识工程建设实践
高德企业业务通过 Qoder 知识引擎构建业务知识的"生产—调优—更新—消费"体系,同一类错误不再发生第二次,任务一次性通过率从 37.3% 提升至 61.5%。
478 0
高德汽车业务 AI Native 工程实践|基于 Qoder 的业务知识工程建设实践
|
3月前
|
消息中间件 存储 安全
RocketMQ 高可用创新论文入选 ACM FSE:无需复制业务数据,实现有状态服务秒级接管
面向云上有状态服务高可用,提出无需额外业务数据复制的秒级故障接管机制,在不牺牲成本和稳态性能的前提下,实现云上有状态消息服务的快速恢复。
316 17
|
3月前
|
人工智能 运维 自然语言处理
强制国标18个月倒计时:企业AI安全的六大关口怎么过
6月27日,国标委发布《智能体应用安全基本要求》强制性国标(2026年41号),设六道安全关:资产盘点、身份权限、数据管控、多模态防护、全链路审计、应急熔断。本文不复述条文,而是逐关解析落地难点与工程解法——从流量侧自动盘点AI资产,到Agent身份映射、PII实时拦截、多模态越狱防御、结构化审计溯源,再到凭证级秒级熔断,助力企业高效合规。
400 0
|
JSON 数据格式
全网首发:Seata Saga状态机设计器实战(下)
全网首发:Seata Saga状态机设计器实战(下)
1289 119
全网首发:Seata Saga状态机设计器实战(下)
|
存储 分布式计算 JavaScript
Fury系列(四):一个比Kryo/Hessian快30~40倍的类型前后兼容序列化器
问题背景类型前后兼容是复杂业务场景序列化的常见需求。在快速迭代的业务场景当中,读写端经常发生对象字段发生变更:在线应用场景:线上SOFA/HSF应用提供服务给多个调用方,服务的滚动升级以及各个调用方独立更新都可能导致对象类型不一致的情况;在线服务场景:在线服务框架常驻不更改对象类型,但调用方业务逻辑变动独立更新导致对象字段跟服务端不一致;对象持久化场景:对象数据序列化后持久化写入存储(如Spark
2314 2
Fury系列(四):一个比Kryo/Hessian快30~40倍的类型前后兼容序列化器

热门文章

最新文章