[数据结构]Trie前缀树及其工程实践

219 阅读5分钟

背景

最近做了个新的需求,大致的内容是这样的:

  • 系统接入了Speech2Text的链路,可以对客服服务质量起到更好的检测和管理作用
  • 系统需要对客服服务内容做违规检测,希望减少人工成本的投入,因此需要完成自动检测违规词的功能

在违规词检测这块的实现上,又重新去回顾了一下常用的字符串匹配算法和前缀树的知识,最终采用了前缀树匹配的方法。

这篇文章主要是想介绍一下前缀树的使用以及分享一下这个功能实现的方式

前缀树介绍

前缀树,又叫字典树,或者Trie树,是一种用空间换时间的数据结构。

在内存中用树形的结构保存字符串,检索的时候可以减少重复前缀的匹配次数,因此常用于统计、排序、保存大量字符串以及搜索。

一棵前缀树大概长成下面这样[图源于网络]

image.png

很容易可以看出,用Trie树来保存大量字符串,或者检索字符串是否存在,可以节省掉重复前缀的开销。

对于Trie树,有三个重要性质:

1:根节点不包含字符,除了根节点每个节点都只包含一个字符。root节点不含字符这样做的目的是为了能够包括所有字符串。

2:从根节点到某一个节点,路过字符串起来就是该节点对应的字符串。

3:每个节点的子节点字符不同,也就是找到对应单词、字符是唯一的。

Golang版前缀树实现

一棵 Trie 树由无数个 Trie 节点构成, 我们使用和初始化时只需要保存他的根节点

type Trie struct { 
    root *TrieNode 
}

每个 Trie 节点包含两个元素

  • 子数组children
  • 本节点是否为某个单词结束的标记位 isEnd
type TrieNode struct {
    children map[rune]*TrieNode 
    isEnd bool 
}

子数组用数组的形式来保存,是为了利用其有顺序的特性,可以用于做排序

但对于字符可能性较多的情况,为了加快查找,子数组也可以用哈希表来保存,逻辑上是一致的

将Trie节点串联成树,就长成下图这样(图中是以数组实现的)

image.png

前缀树最主要的三个功能

  • 初始化
  • 插入
  • 查找
func NewTrie() *Trie {
    return &Trie{
        root: &TrieNode{
            children: make(map[rune]*TrieNode)
        }
    } 
} 
func (t *Trie) Insert(word string) { 
    node := t.root 
    for _, ch := range word { 
        if _, ok := node.children[ch]; !ok { 
            node.children[ch] = &TrieNode{
                children: make(map[rune]*TrieNode)
            } 
        } 
        node = node.children[ch]
    } 
    node.isEnd = true
} 
func (t *Trie) Search(word string) bool {
    node := t.root
    for _, ch := range word { 
        if _, ok := node.children[ch]; !ok { 
            return false
        } 
        node = node.children[ch]
    } 
    return node.isEnd 
}

对于前缀树,插入和搜索的时间复杂都是常数时间O(m),m为单词的长度

违规词匹配和违规词组匹配

很容易可以想到,如何用前缀树来实现违规词匹配。

假如我们有一个待匹配字符串数组:

This is sentence a,contains worda
This is sentence b,contains wordb
This is sentence c,contains wordc
This is sentence d,contains wordd

违规词组为worda,wordb,wordc

首先用违规词组构造一棵前缀树(随便加几个别的词)

trie := NewTrie() 
trie.Insert("worda")
trie.Insert("wordb")
trie.Insert("wordc")
trie.Insert("wall")
trie.Insert("other")

image.png

检测的过程就是将每一个待匹配字符串拆分成单词数组,每个单词到前缀树上搜索,看是否存在

for _,sentence := range sentences{
    words := strings.Split(sentence," ")
    for _,word := range words{
        if trie.search(word){
            fmt.Println("violation")
        }
    }
}

如果存在违规词,则是违规句子。

如果待检测字符串数组的长度为n,平均每个字符串包含m个单词,违规词的长度为k,每个单词进行查找的时间复杂度为O(k);

每个字符串由m个词组成,n个句子的时间复杂度为O(k * m * n)

但实际应用中,更多的是违规词组检测,例如

违规词组为 sentence a,想要在This is sentence a,contains worda中检测出来,按单词拆分不合适,无法成功检测;

但是可以逐单词删除进行匹配

  • This is sentence a,contains worda -- false
  • is sentence a,contains worda -- false
  • sentence a,contains worda -- true

这种方式每次匹配的时间复杂度为O(k), 匹配n个句子的时间复杂度也是O(k * m * n)。 实际上原理是一样的,只是我们不知道词组的分布,所以无法提前对字符串进行拆分。



func (t *Trie) Search(word string) bool {
   node := t.root
   for index, ch := range word {
      if node.children[ch] == nil {
         return false
      }

      node = node.children[ch]

      // node.isEnd证明 句子包含某个违规词前缀,如 违规词:a ,句子: abcd 或者a bcd
      //(index < len(word)-1 &&word[index+1]==' ') 保证这个违规词完整出现在句子,而不是上面例子中的字串,a -> a bcd
      if node.isEnd && (index < len(word)-1 && word[index+1] == ' ') {
         return true
      }
   }
   return node.isEnd
}

字符串匹配

违规词匹配其实第一时间反应到的方法应该是字符串匹配算法。

最简单的暴力匹配,遍历待匹配字符串和每一个违规词

for _, forbiddenWord := range forbiddenWords {
   if contains(input, forbiddenWord) {
      return true
   }
}

func contains(str, substr string) bool {
   m, n := len(str), len(substr)
   for i := 0; i <= m-n; i++ {
      j := 0
      for j < n && str[i+j] == substr[j] {
         j++
      }
      if j == n {
         return true
      }
   }
   return false
}

这种匹配的时间复杂度较高:n个句子,m个违规词,每个违规词长度为k,每个句子长度l,复杂度为O(n * m * k * l)

用于存储违规词的空间大小也比前缀树大,多出来的部分为公共前缀占用的大小。

当然,使用KMP等算法可以降低搜索的时间复杂度,如golang里strings.Contains(),它的实现使用的是Rabin-Karp算法,时间复杂度会降低不少。