在一些论坛或者博客类的项目需要对内容进行敏感词的匹配以及脱敏操作,像这类的功能就可以使用前缀树实现,接下来我们就使用哈希去实现前缀树。(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算法对前缀树进行优化时,需要将前缀树转换成DFA状态机。下面是一种常见的转换步骤:
- 构建初始状态:DFA状态机的初始状态对应于前缀树的根节点。
- 逐层构建状态转移:从初始状态开始,按照前缀树的层次结构逐层构建状态转移。对于每个状态,根据前缀树节点的子节点和字符的对应关系,确定该状态的转移条件。
- 标记终止状态:对于前缀树中表示一个完整字符串的节点(叶子节点),标记相应的状态为终止状态。
- 合并等价状态:在构建状态转移时,可能会出现等价的状态。通过状态合并操作,将等价状态合并为一个状态,从而减少状态的数量。
- 优化状态转移表:根据具体情况,可以对状态转移表进行优化,如压缩存储、使用哈希表等。
这里就不展开讲了
如果有更好的思路,欢迎讨论!