您的当前位置:首页正文

使用前缀树实现敏感词过滤

2024-11-30 来源:个人技术集锦

在一些论坛或者博客类的项目需要对内容进行敏感词的匹配以及脱敏操作,像这类的功能就可以使用前缀树实现,接下来我们就使用哈希去实现前缀树。(gin框架的路由树也是基于前缀树实现的)

什么是前缀树?

前缀树(Prefix Tree),也被称为字典树(Trie),是一种用于高效存储和检索字符串的数据结构。它的主要特点是能够快速地查找具有相同前缀的字符串集合。

既然使用前缀树做字符匹配那么它有什么特点?

实现思路

假设我们有以下敏感词列表:["bad", "evil", "danger"]

		(root)                    ["bad", "evil", "danger"]
        / | \
       b  e  d
      /   |   \
     a    v    a
    /     |     \
   d      i      n
          |       \
          l        g
          |         \
          e          e

在这个前缀树中,每个节点代表一个字符,根节点表示空字符,每个节点的子节点表示对应的字符。叶子节点表示一个完整的敏感词。

遍历实现思路:
  1. 遍历待检测的文本。
  2. 从根节点开始,按照文本中的字符依次遍历前缀树的路径。
  3. 如果在遍历过程中遇到空路径,或者遍历完成后当前节点不是叶子节点,则表示没有匹配到敏感词,继续遍历下一个字符。
  4. 如果遍历完成后当前节点是叶子节点,则表示匹配到了敏感词,根据需求进行相应的处理,如替换为星号或进行其他处理。

代码实现

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状态机。下面是一种常见的转换步骤:

  1. 构建初始状态:DFA状态机的初始状态对应于前缀树的根节点。
  2. 逐层构建状态转移:从初始状态开始,按照前缀树的层次结构逐层构建状态转移。对于每个状态,根据前缀树节点的子节点和字符的对应关系,确定该状态的转移条件。
  3. 标记终止状态:对于前缀树中表示一个完整字符串的节点(叶子节点),标记相应的状态为终止状态。
  4. 合并等价状态:在构建状态转移时,可能会出现等价的状态。通过状态合并操作,将等价状态合并为一个状态,从而减少状态的数量。
  5. 优化状态转移表:根据具体情况,可以对状态转移表进行优化,如压缩存储、使用哈希表等。

这里就不展开讲了

如果有更好的思路,欢迎讨论!

显示全文