问题描述:
涉及语言对话的项目中,难免遇到敏感词过滤的需求。这里介绍一下常用的DFA(Deterministic Finite Automaton 确定有限自动机)算法,使用python实现。
DFA流程:
我们的目光首先回到需求--敏感词过滤--上面,逐步分化这个任务。
任务目标
一段文本里面所有的敏感词被替换为*
特点:敏感词不唯一,待检测文本有长有短。典型的多模式匹配
单模式匹配:在一个较长的文本字符串中查找一个单一的目标字符串或模式。这种情况下,搜索的重点是确定这一个模式是否存在于文本中,以及它出现的位置。
多模式匹配:同时在文本中搜索多个模式或关键词。这种类型的匹配通常用于如文本分析、过滤系统(例如敏感词过滤)和病毒扫描等场景,其中需要同时检测文本中的多个关键词或模式。
任务流程
1.敏感词的存放 2.敏感词与文本的匹配 3.匹配后的过滤替换
那么DFA的思路就是,将敏感词按照树的结构去存放,进行路径的查找,迅速且可以公用前缀。
我们先理解DFA的思路:
假设我们的敏感词里面有这么几个:sexy,hello,help,helpline(纯粹举例,无其他含义)。那么我们应该这样存放:
{
's': {
'e': {
'x': {
'y': {
'\x00': 0
}
}
}
},
'h': {
'e': {
'l': {
'l': {
'o': {
'\x00': 0
}
},
'p': {
'\x00': 0,
'l': {
'i': {
'n': {
'e': {
'\x00': 0
}
}
}
}
}
}
}
}
}
这里,使用嵌套字典模拟树结构,最后的终止状态字典,键为分隔符 \x00 ,值为0,来确定一个敏感词被查询出。利用嵌套字典,可以逐层进行查询,当前层匹配以后,即可进入下一层。同时,由于层级的分布,可以公用前缀,在不一样的符号处分开即可。
图解
这样就可以显著看出DFA存放敏感词的结构,采用树结构存储,将终止状态设置为叶子节点。同层次按照不同的路径查找,简单快捷。
查找举例
假设我们的文本为 A sexy girl held the helpline and said, Hello.
这时候的流程应该是:
1.统一转小写,去空白字符:a sexy girl held the helpline and said, hello.
2.设置保存过滤后文本的列表ret,并且记录层级
3.输入a,查询第一层,发现无匹配,传入ret 即 [a]
4.输入 s 第一层匹配,层级+1,继续匹配 e,x,y 遇到分隔符,替换sexy为****,加入ret,即 [a ****]
5.继续查询 girl,不匹配 ret [a **** girl]
6.查询 hel匹配,到d,发现不匹配,原字符传入,ret [a **** girl held]
7.按照这个逻辑完成全部查询,即,ret [a **** girl held the ******** and said, *****.]
8.输出ret的内容即:a **** girl held the ******** and said, *****. 完成替换。
这就是DFA敏感词过滤的算法!
下面给出python的具体实现:
class DFAFilter():
def __init__(self):
# 存放敏感词的字典
self.keyword_chains = {}
# 设置分隔符为 '\x00',用于在特定场合下作为字符串的分隔标志
self.delimit = '\x00'
def add(self, keyword):
"""
将给定的敏感词添加到敏感词链中。
:param keyword: 需要添加的关键词,将被转换为小写并去除首尾空白。
:return: 无返回值。
"""
keyword = keyword.lower() # 将敏感词转换为小写
chars = keyword.strip() # 去除敏感词的首尾空白字符
if not chars: # 如果处理后的敏感词为空,则直接返回
return
level = self.keyword_chains # 初始化根级别
# 遍历敏感词中的每个字符,逐级向下创建或更新字典结构
for i in range(len(chars)):
# 如果字符在当前级别存在,说明可以公用,进入下一级
if chars[i] in level:
level = level[chars[i]]
# 如果不存在,从当前位置创建所有缺失级别到敏感词末尾
else:
for j in range(i, len(chars)):
level[chars[j]] = {}
last_level, last_char = level, chars[j]
level = level[chars[j]]
# 在最后一个创建的级别中,添加一个以self.delimit为键,值为0的项
last_level[last_char] = {self.delimit: 0}
break
# 如果遍历到了关键词的末尾,则在当前级别添加一个以self.delimit为键,值为0的项
if i == len(chars) - 1:
level[self.delimit] = 0
def parse(self, path):
if path.endswith('pkl'):
with open(path, "rb") as f:
data = pickle.load(f)
for keyword in data:
self.add(keyword.strip())
else:
with open(path, "r") as f:
for keyword in f:
self.add(keyword.strip())
def filter(self, message, repl="*"):
"""
过滤给定消息中的敏感关键词,用指定字符替换。
参数:
- message: 待过滤的字符串消息。
- repl: 用于替换敏感词的字符,默认为"*"。
返回值:
- 过滤后的字符串。
"""
message = message.lower()
ret = [] # 存储过滤后的字符
start = 0 # 指向当前处理的字符位置
while start < len(message):
level = self.keyword_chains # 初始化敏感词层级
step_ins = 0 # 记录当前嵌套层级
for char in message[start:]:
if char in level:
step_ins += 1
if self.delimit not in level[char]:
level = level[char]
else:
# 当遇到分隔符时,添加替换字符到结果中,并调整起始位置
ret.append(repl * step_ins)
start += step_ins - 1
break
else:
# 当字符不在关键词列表中,添加原字符到结果中,并结束当前循环
ret.append(message[start])
break
start += 1 # 更新起始位置
return ''.join(ret) # 将结果列表转换为字符串并返回
与其他字符串匹配算法对比分析:
这里列出与BS(后向排序映射),KMP算法,和python包中的replace函数的对比分析。
1. BSFilter(后向排序映射)
- 优点:
- 简单实现,直接使用 Python 的字典和集合进行关键词的管理和快速查找。
- 适合于关键词数量不是非常大的情况,尤其是单词较短的关键词。
- 缺点:
- 对于含有大量关键词的大型文本,效率可能较低,因为每个单词或字符都需要检查是否在字典中,且可能重复替换。
- 不能有效处理重叠关键词或多模式匹配的复杂情况。
- 时间复杂度:
- 理论上,最坏情况下的时间复杂度可以达到 O(mnk),其中 m 是消息长度,n 是平均关键词长度,k 是关键词数量。实际上,这取决于消息和关键词的具体内容。
2. DFA 算法
- 优点:
- 高效处理多关键词匹配,尤其是在关键词共享前缀时表现优越。
- 一次扫描完成匹配,适用于流式处理。
- 缺点:
- 预处理(构建 DFA)成本高,尤其是关键词很多时。
- 可能需要较多的内存来存储状态机。
- 时间复杂度:
- 匹配过程的时间复杂度为 O(m),m 是文本长度。
3. Python 的 replace
方法
- 优点:
- 极其快速和高效,对于简单的字符串替换任务几乎是最优的。
- 内部实现高度优化,易于使用。
- 缺点:
- 不适合复杂的模式匹配或多模式匹配任务。
- 无法处理重叠关键词或需要查找的关键词间的关系。
- 时间复杂度:
- 实现依赖于具体的 Python 解释器,通常非常接近于 O(m)。
4. KMP 算法
- 优点:
- 解决单模式匹配问题非常高效,特别是长模式字符串。
- 预处理阶段计算部分匹配表,避免不必要的比较。
- 缺点:
- 预处理有一定的开销,尤其是对于较长的模式字符串。
- 不适合同时处理多个模式的匹配。
- 时间复杂度:
- 预处理时间复杂度为 O(n),匹配时间复杂度为 O(m),n 是模式长度,m 是文本长度。
总结
选择哪种算法取决于具体的应用场景。如果需要处理大量的文本数据和多关键词匹配,DFA可能更合适。对于简单的替换任务,Python的 replace
方法可能是最快的。而对于高效的单模式长字符串匹配,KMP是一个好的选择。BSFilter 提供了一种简便的方法来处理较少数量的关键词替换,但可能不适合大规模数据处理。