在中文分词里,字典树(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 或者插值平滑,效果会更好。
  • 对未登录词,可以加一个独立的分支:比如把连续的单字合并成新词,或者用规则识别数字、英文、人名等。
  • 在工程实现里,最好用迭代动态规划而不是递归,避免爆栈。
  • 概率模型需要定期用新语料更新,语言是活的,新词每天都在出现。

七、总结

字典树本身是个好工具,但“最长匹配”这种贪心策略太天真。通过引入上下文概率,我们能让分词结果更贴近真实语言习惯。当然,这只是一个简单的示例,真正的工业级分词器还会用到更多技巧,比如隐马尔可夫模型、条件随机场甚至深度学习。但核心思想不变:字典树给你候选,概率帮你做选择。希望这篇文章能帮你理清思路,下次再遇到分词错误,你就知道可以往哪个方向去“修”了。