在一些论坛或者博客类的项目需要对内容进行敏感词的匹配以及脱敏操作,像这类的功能就可以使用前缀树实现,接下来我们就使用哈希去实现前缀树。(gin框架的路由树也是基于前缀树实现的)
前缀树(Prefix Tree),也被称为字典树(Trie),是一种用于高效存储和检索字符串的数据结构。它的主要特点是能够快速地查找具有相同前缀的字符串集合。
假设我们有以下敏感词列表:["bad", "evil", "danger"]
(root) ["bad", "evil", "danger"]
/ | \
b e d
/ | \
a v a
/ | \
d i n
| \
l g
| \
e e
在这个前缀树中,每个节点代表一个字符,根节点表示空字符,每个节点的子节点表示对应的字符。叶子节点表示一个完整的敏感词。
package sensitiveWord
import (
"io/ioutil"
"os"
"project/consts"
"project/types"
"strings"
"go.uber.org/zap"
)
// SensitiveMap 使用前缀树实现敏感词过滤
type SensitiveMap struct {
sensitiveNode map[string]interface{}
isEnd bool
}
// Target 目标词索引、长度
type Target struct {
Indexes []int
Len int
}
var s *SensitiveMap
// getMap 将自己的词库放入/static/dictionary下,放入下列切片中!!!!
func getMap() *SensitiveMap {
if s == nil {
var Sen []string
Sen = append(Sen, consts.OtherSen)
s = InitDictionary(s, Sen)
}
return s
}
// CheckSensitiveWord 判断是否有敏感词
func CheckSensitiveWord(content string) []*types.SensitiveWord {
var res []*types.SensitiveWord
sensitiveMap := getMap()
target := sensitiveMap.FindAllSensitive(content)
for k, v := range target {
t := &types.SensitiveWord{
Word: k,
Indexes: v.Indexes,
Length: v.Len,
}
res = append(res, t)
}
return res
}
// FindAllSensitive 查找所有的敏感词
func (s *SensitiveMap) FindAllSensitive(text string) map[string]*Target {
content := []rune(text)
contentLength := len(content)
result := false
ta := make(map[string]*Target)
for index := range content {
sMapTmp := s
target := ""
in := index
result = false
for {
wo := string(content[in])
target += wo
if _, ok := sMapTmp.sensitiveNode[wo]; ok {
if sMapTmp.sensitiveNode[wo].(*SensitiveMap).isEnd {
result = true
break
}
if in == contentLength-1 {
break
}
sMapTmp = sMapTmp.sensitiveNode[wo].(*SensitiveMap)
in++
} else {
break
}
}
if result {
if _, targetInTa := ta[target]; targetInTa {
ta[target].Indexes = append(ta[target].Indexes, index)
} else {
ta[target] = &Target{
Indexes: []int{index},
Len: len([]rune(target)),
}
}
}
}
return ta
}
// InitDictionary 初始化字典,构造前缀树
func InitDictionary(s *SensitiveMap, dictionary []string) *SensitiveMap {
// 初始化字典树
s = initSensitiveMap()
var dictionaryContent []string
for i := 0; i < len(dictionary); i++ {
dictionaryContentTmp := ReadDictionary(dictionary[i])
// TODO:将所有词拿到
dictionaryContent = append(dictionaryContent, dictionaryContentTmp...)
}
for _, words := range dictionaryContent {
sMapTmp := s
// 将每一个词转换为一个rune数组,不光英文、中文
w := []rune(words)
wordsLen := len(w)
for i := 0; i < wordsLen; i++ {
t := string(w[i])
isEnd := false
if i == (wordsLen - 1) {
isEnd = true
}
func(tx string) {
// 查看当前字符在不在树结构,在的话就复用节点
if _, ok := sMapTmp.sensitiveNode[tx]; !ok {
sMapTemp := new(SensitiveMap)
sMapTemp.sensitiveNode = make(map[string]interface{})
sMapTemp.isEnd = isEnd
sMapTmp.sensitiveNode[tx] = sMapTemp
}
sMapTmp = sMapTmp.sensitiveNode[tx].(*SensitiveMap)
sMapTmp.isEnd = isEnd
}(t)
}
}
return s
}
// initSensitiveMap 初始化map
func initSensitiveMap() *SensitiveMap {
return &SensitiveMap{
sensitiveNode: make(map[string]interface{}),
isEnd: false,
}
}
// ReadDictionary 将词库读取出来
func ReadDictionary(path string) []string {
file, err := os.Open(path)
if err != nil {
zap.L().Error("read dictionary file failed:", zap.Error(err))
return nil
}
defer file.Close()
str, err := ioutil.ReadAll(file)
dictionary := strings.Fields(string(str))
return dictionary
}
上述是前缀树实现的基本思路,使用map构造前缀树目的就是为了快速定位节点。
我们判断的时候肯定不可以每次读区词库,这样io消耗过大。初步思路就是可以把前缀树加载到缓存中去,到时候操作缓存匹配敏感词效率就高了,如果要更新词库就重新加载缓存即可,更新词库树形结构变化不会过大,即可动态更新。
如果大规模过滤敏感词的时候,前缀树就会遇到性能瓶颈,这个时候就需要使用dfa进行前缀树的算法优化。
当应用DFA算法对前缀树进行优化时,需要将前缀树转换成DFA状态机。下面是一种常见的转换步骤:
这里就不展开讲了
如果有更好的思路,欢迎讨论!