在中文分词里,字典树(Trie)是一种很常见的“查字典”工具。它把词表组织成树状结构,从根节点出发,沿着每个字符往下走,就能判断一个前缀是不是完整词。这种方法简单直接,很多初学分词的同学都会先拿它练手。但真正用起来,你会发现它经常“翻车”,特别是用“最长匹配”策略的时候。今天咱们就聊聊这个问题,然后看看怎么用上下文概率来“救场”。
一、从“查字典”说起:字典树到底怎么干活?
想象一下,你面前放着一本很厚的词典,词典里全是中文词语。你要把一个句子切成一个个词,最简单的方法就是从第一个字开始,在词典里找以这个字开头的所有词,挑一个最长的。这就是“最长匹配”。字典树就是用来加速这个查找过程的。它的结构有点像家谱,每个节点代表一个字符,从根到叶子的一条路径就组成一个词。比如“中国”和“中国人”,它们共享“中”“国”这两个节点,然后“人”分叉出去。
咱们用 Python 写一个最简单的字典树。假设词表里有“中国”“中国人”“国”“中”“国人”等等。注意:下面的代码全部使用 Python 3.8+。
# 技术栈:Python 3.8+
# 定义字典树节点
class TrieNode:
def __init__(self):
self.children = {} # 子节点字典,key是字符,value是TrieNode
self.is_word = False # 从根到当前节点是否构成一个完整词
class Trie:
def __init__(self):
self.root = TrieNode()
# 插入一个词
def insert(self, word):
node = self.root
for ch in word:
# 如果当前字符不在子节点里,就新建一个节点
if ch not in node.children:
node.children[ch] = TrieNode()
node = node.children[ch]
node.is_word = True # 标记这个词结束
# 在文本中从start位置开始,找到所有以text[start]开头的词
def search_prefix(self, text, start):
node = self.root
words = []
# 逐字符向下走
for i in range(start, len(text)):
ch = text[i]
if ch not in node.children:
break # 没有这个前缀,直接退出
node = node.children[ch]
if node.is_word:
words.append(text[start:i+1]) # 记下这个完整词
return words
这里 search_prefix 返回从某个位置开始的所有可能词,比如从“中”开始,可能返回“中国”“中国人”。然后实现前向最大匹配:
def max_match(trie, text):
result = []
i = 0
while i < len(text):
words = trie.search_prefix(text, i)
if words:
# 取最长的那个词
best = max(words, key=len)
result.append(best)
i += len(best)
else:
# 没有词,就单字成词
result.append(text[i])
i += 1
return result
这个逻辑很简单,但问题就出在“取最长”上。
二、最长匹配也有“翻车”的时候
咱们看一个经典例子:“研究生命的起源”。这个词表里如果有“研究生”“研究”“生命”“起源”等词,那么从“研”字开始,字典树会找到“研”“研究”“研究生”。最大匹配毫不犹豫选“研究生”,于是切成了“研究生/命/的/起源”。这显然不对,应该是“研究/生命/的/起源”。同样,“南京市长江大桥”如果词表里有“南京”“南京市”“市长”“长江大桥”,最长匹配会切成“南京市/长江大桥”还是“南京/市长/江大桥”?其实从“南”开始,有“南京”“南京市”两个,取“南京市”;然后“长”开始有“长江”“长江大桥”,取“长江大桥”,结果看起来是对的。但有些情况会更离谱,比如“乒乓球拍卖完了”可能被切成“乒乓球/拍卖/完了”,而不是“乒乓球/拍/卖/完了”。关键点在于,字符串匹配只看长度,不管语义。字典树本身没有“思考能力”,它只是告诉你哪些词存在,而最长匹配则盲目地认为“越长越正确”。这就是它的核心缺陷:没有结合上下文。
这里我们列出这种方式的优缺点:
优点:
- 实现简单,几行代码就能跑起来。
- 速度快,不需要训练模型,也不依赖外部语料。
- 对冷启动友好,只要有一本词表就能开始干活。
缺点:
- 无法解决歧义,尤其是“交集型歧义”和“组合型歧义”。
- 所谓交集型歧义,就是“abc”里“ab”和“bc”都是词;组合型歧义就是“ab”和“a”“b”都成词。最长匹配只选最长,很容易选错。
为了修正,我们需要引入“上下文概率”。通俗说,就是让机器看看周围的词,判断哪种切分方式更像人话。
三、让机器学会“看上下文”:概率模型登场
怎么让机器“看上下文”?一个朴素的想法是:统计大量真实文本中,每个词出现的次数,以及每个词后面经常跟着哪些词。比如在正确语料里,“研究”后面经常跟着“生命”,而“研究生”后面很少跟着“命”。这样我们就能给每一种切分方案打个分,选分数最高的。
这里用到的就是简单的概率模型,叫二元语法模型(bigram)。它假设一个词出现的概率只跟它前面的一个词有关。比如切分“研究/生命/的/起源”的概率,大致等于 P(研究) * P(生命|研究) * P(的|生命) * P(起源|的)。我们把这些概率乘起来,哪个切分的概率大,就选哪个。当然,直接乘很多小数会变得特别小,所以通常取对数再加和。
具体怎么算?假设我们有一个训练好的词表,每个词有自己的词频 count(w),每个相邻词对也有统计 count(w1, w2)。那么条件概率 P(w2|w1) = count(w1,w2) / count(w1)。如果没统计到,就给个很小的概率,避免变成 0。
为了演示,咱们造一个微型语料库。注意:技术栈还是 Python。我们直接用统计计数来实现。
# 技术栈:Python 3.8+
# 模拟一个很小的训练语料,每个句子已经按正确分词切好
corpus = [
["研究", "生命", "的", "起源"],
["研究", "生命", "现象"],
["研究生", "学习", "任务"],
["研究生", "导师", "指导"],
["生命", "在于", "运动"],
["南京市", "长江大桥"],
["南京", "市长", "亲自", "视察"],
]
# 统计词频和bigram共现次数
from collections import defaultdict
word_count = defaultdict(int)
bigram_count = defaultdict(int)
for sent in corpus:
for w in sent:
word_count[w] += 1
for i in range(len(sent) - 1):
bigram_count[(sent[i], sent[i+1])] += 1
V = len(word_count) # 词表大小,用于平滑
total_words = sum(word_count.values())
然后计算概率。这里我们采用最简单的“加一平滑”,防止没见过的词对概率为 0:
import math
def word_prob(w):
# 单词概率,加一平滑
return (word_count[w] + 1) / (total_words + V)
def bigram_prob(w1, w2):
# 条件概率 P(w2|w1),加一平滑
# 如果w1没出现过,就退回单词概率
if word_count[w1] == 0:
return word_prob(w2)
return (bigram_count[(w1, w2)] + 1) / (word_count[w1] + V)
接下来,我们需要根据输入句子,用字典树找出所有可能的词,再用动态规划找最佳切分。
四、从“猜”到“算”:动态规划求最优路径
现在我们有了一套概率打分规则,但怎么在所有可能的切分里找最优?总不能枚举所有切分吧,句子一长就爆炸。这时候可以用动态规划。思路很简单:假设我们处理到第 i 个字,定义 dp[i] 为“从句子开头到第 i 个字的最佳切分概率对数”。那么从 i 往前走,对于每个以第 j 到第 i-1 字构成的词 w(j<i),我们尝试用 dp[j] + log(P(w|前面词)) 来更新 dp[i]。
为了代码清晰,我们用递归加记忆化搜索。函数 dp(pos, prev_word) 表示从 pos 位置开始到句子末尾,并且前一个词是 prev_word 时,能得到的最大对数概率和对应的切分路径。
# 技术栈:Python 3.8+
from functools import lru_cache
# 假设 trie, word_prob, bigram_prob 都已定义好
# 这里我们复用前面章节的 Trie 和概率函数
def get_candidates(text, start):
"""返回从 start 开始的所有候选词:多字词来自字典树,单字永远兜底"""
cands = trie.search_prefix(text, start)
cands.append(text[start]) # 单字永远是一个候选
return cands
def best_segment(text):
n = len(text)
@lru_cache(None)
def dp(pos, prev_word):
# 到末尾了,得分0,路径空
if pos == n:
return 0.0, []
best_score = -float('inf')
best_path = None
for w in get_candidates(text, pos):
# 计算当前词的对数概率
if prev_word is None:
score = math.log(word_prob(w))
else:
score = math.log(bigram_prob(prev_word, w))
# 递归计算剩余部分
rest_score, rest_path = dp(pos + len(w), w)
total = score + rest_score
if total > best_score:
best_score = total
best_path = [w] + rest_path
return best_score, best_path
score, path = dp(0, None)
return path
注意,这里 prev_word 是字符串,在递归中作为缓存参数没问题。如果句子特别长,递归深度可能不够,实际工程里建议改成迭代版动态规划,但核心思路完全一样。
五、混合策略:字典树+概率修正的实战示例
现在我们把所有零件拼起来,写一个完整可运行的脚本。这个脚本会同时输出“最大匹配”和“概率分词”的结果,方便对比。
# 技术栈:Python 3.8+
from collections import defaultdict
from functools import lru_cache
import math
# ---------- 1. 构造微型语料 ----------
corpus = [
["研究", "生命", "的", "起源"],
["研究", "生命", "现象"],
["研究生", "学习", "任务"],
["研究生", "导师", "指导"],
["生命", "在于", "运动"],
["南京市", "长江大桥"],
["南京", "市长", "亲自", "视察"],
]
# ---------- 2. 统计词频和bigram ----------
word_count = defaultdict(int)
bigram_count = defaultdict(int)
for sent in corpus:
for w in sent:
word_count[w] += 1
for i in range(len(sent) - 1):
bigram_count[(sent[i], sent[i+1])] += 1
V = len(word_count)
total_words = sum(word_count.values())
def word_prob(w):
return (word_count[w] + 1) / (total_words + V)
def bigram_prob(w1, w2):
if word_count[w1] == 0:
return word_prob(w2)
return (bigram_count[(w1, w2)] + 1) / (word_count[w1] + V)
# ---------- 3. 构建字典树 ----------
class TrieNode:
def __init__(self):
self.children = {}
self.is_word = False
class Trie:
def __init__(self):
self.root = TrieNode()
def insert(self, word):
node = self.root
for ch in word:
if ch not in node.children:
node.children[ch] = TrieNode()
node = node.children[ch]
node.is_word = True
def search_prefix(self, text, start):
node = self.root
res = []
for i in range(start, len(text)):
ch = text[i]
if ch not in node.children:
break
node = node.children[ch]
if node.is_word:
res.append(text[start:i+1])
return res
trie = Trie()
for w in word_count:
if len(w) > 1:
trie.insert(w)
# ---------- 4. 获取候选词列表 ----------
def get_candidates(text, start):
cands = trie.search_prefix(text, start)
cands.append(text[start]) # 单字兜底
return cands
# ---------- 5. 前向最大匹配 ----------
def max_match(text):
i = 0
res = []
while i < len(text):
cands = trie.search_prefix(text, i)
if cands:
best = max(cands, key=len)
res.append(best)
i += len(best)
else:
res.append(text[i])
i += 1
return res
# ---------- 6. 概率分词(递归+记忆化,bigram) ----------
def best_segment(text):
n = len(text)
@lru_cache(None)
def dp(pos, prev_word):
if pos == n:
return 0.0, []
best_score = -float('inf')
best_path = None
for w in get_candidates(text, pos):
if prev_word is None:
score = math.log(word_prob(w))
else:
score = math.log(bigram_prob(prev_word, w))
rest_score, rest_path = dp(pos + len(w), w)
total = score + rest_score
if total > best_score:
best_score = total
best_path = [w] + rest_path
return best_score, best_path
score, path = dp(0, None)
return path
# ---------- 7. 测试 ----------
test_sentence = "研究生命的起源"
print("原句:", test_sentence)
print("最大匹配:", "/".join(max_match(test_sentence)))
print("概率分词:", "/".join(best_segment(test_sentence)))
print()
test_sentence2 = "南京市长江大桥"
print("原句:", test_sentence2)
print("最大匹配:", "/".join(max_match(test_sentence2)))
print("概率分词:", "/".join(best_segment(test_sentence2)))
运行这段代码,你会看到对于“研究生命的起源”,最大匹配错误地切成了“研究生/命/的/起源”,而概率分词正确地切成了“研究/生命/的/起源”。原因很简单:在语料里,“研究”后面跟“生命”的概率,远大于“研究生”后面跟“命”的概率。
六、这么干有哪些坑?——技术优缺点与注意事项
6.1 技术优点
把字典树和概率模型结合起来,算是“取长补短”。字典树负责快速枚举候选词,不用暴力遍历整个词表;概率模型负责从候选词中挑出最像人话的切分。对常见歧义有很好的修正效果,而且不需要搭建复杂的深度学习模型,在内存小、算力低的设备上也能跑。
6.2 技术缺点
首先,概率模型依赖训练语料。语料不够大时,数据稀疏问题很严重,很多 bigram 根本没出现过,平滑参数要反复调。其次,字典树和概率是两层的,如果字典树本身漏了词,后面概率再高也白搭,这就是未登录词问题。再次,bigram 只看前一个词,长距离依赖捕获不到。比如“他喜欢研究生命科学”,到底是“研究/生命”还是“研究生/命科学”?光看前一个词“研究”和“研究生”还不够,可能还要看后面的“科学”才能决定。最后,递归动态规划在句子特别长时可能栈溢出,需要改成迭代写法。
6.3 注意事项
- 词表要和实际场景匹配。做新闻分词和做医学分词,词表完全是两码事。
- 平滑方法别只用加一平滑,可以试试 Kneser-Ney 或者插值平滑,效果会更好。
- 对未登录词,可以加一个独立的分支:比如把连续的单字合并成新词,或者用规则识别数字、英文、人名等。
- 在工程实现里,最好用迭代动态规划而不是递归,避免爆栈。
- 概率模型需要定期用新语料更新,语言是活的,新词每天都在出现。
七、总结
字典树本身是个好工具,但“最长匹配”这种贪心策略太天真。通过引入上下文概率,我们能让分词结果更贴近真实语言习惯。当然,这只是一个简单的示例,真正的工业级分词器还会用到更多技巧,比如隐马尔可夫模型、条件随机场甚至深度学习。但核心思想不变:字典树给你候选,概率帮你做选择。希望这篇文章能帮你理清思路,下次再遇到分词错误,你就知道可以往哪个方向去“修”了。
评论
围绕“中文分词场景中基于字符串匹配的字典树可能出现最长词组切分错误,如何结合上下文概率来修正匹配结果?”参与讨论