多模式串匹配
多模式串匹配的场景常见于一些平台屏蔽某些用户的发言中的敏感词条。
用字符串匹配算法找出文本中的敏感词条,并用“***”代替。虽然可以使用单模式串匹配算法逐个进行查找敏感词条,再进行替换,但是实际场景中,若敏感词的库很大,并且要匹配的文本内容很多,则匹配时长过长,很可能导致发一条消息发好久。显然这会导致用户体验下降。
因此,需要一种在多个模式串下的高效匹配算法来应对这种场景。
基于 Trie 树过滤敏感词
Trie 树本身就是基于多模式串匹配的算法,将多个模式串构建成一棵 Trie 树,当模式串有变动时,只需要改变 Trie 树就可以了。
与主串匹配时,从主串的第一个字符开始逐个与 Trie 树进行匹配,当匹配到坏字符时,我们将主串的开始字符往后移一个,继续从 Trie 树根开始匹配,这样一来我们只需要扫描一遍主串就可以完成对多个模式串的匹配工作。效率远远高于用单模式串匹配。
AC 自动机原理
上述基于 Trie 树的多模式串匹配算法类似于单模式串匹配中的暴力匹配算法,我们知道暴力匹配算法可以通过引入 next 数组来提高效率,即 KMP 算法,那么在这个多模式串匹配中,能否将 next 数组这样的思想加入其中呢?
答案显然是肯定的,只需要将 Trie 树稍微改造一下即可,当然不是加 next 数组,而是在 Trie 树每个节点中加入一个next指针,即失败指针。
如图,字符c
的失败指针是字符串bcf
中的c
,当我们匹配到abc
后,发现与字符d
不匹配了,此时可以通过c
的失败指针跳转到bcd
中,接着去匹配f
。
如此一来,便不再是遇到匹配不上重头开始匹配了,思想与 next 数组一样,如果没理解 KMP 算法的原理,建议先弄明白 next 数组,再看这个失败指针就很容易理解了(KMP 算法推荐阅读:著名字符串匹配算法:KMP 算法原理分析和代码实现)
接下来的问题是,如何才能找到每个节点的失败指针指向的下一个节点呢?
Trie 树实际上就是包括了所有模式串的,假设我们现在要求上图中的节点c
的失败指针,已知条件是匹配到c
处时,abc
是已经与主串匹配成功的前缀,接着需要匹配的模式串应该是以abc
的后缀子串为前缀子串的其他模式串,并且是最长可匹配的前缀子串。
abc
的后缀子串有c
和bc
,其他模式串中只有bcf
的前缀bc
与abc
的后缀子串可匹配,因此c
的失败指针应该指向bcf
的c
。
构建自动机
构建一个自动机的条件如下:
- 构建一棵 Trie 树
- 初始化节点失败指针
首先来看一下每个节点的数据结构:
public class AcNode {
public char data; //数据域
public AcNode[] children = new AcNode[26]; //字符集只包含a~z这26个字符
public boolean isEndingChar = false; //记录模式串结尾字符
public int length = -1; //记录模式串长度
public AcNode fail; // 失败指针
public AcNode(char data) {
this.data = data;
}
}
可以发现,与 Trie 树相比只是多了一个失败指针
因此构建自动机的第一步是构建一棵 Trie 树,这部分内容此处不再细讲(参见 Trie 树构造原理、应用场景与复杂度分析)。
现在我们要考虑的问题是,构建完 Trie 树后,如何才能得到所有节点的失败指针?
通过上面的分析,我们已经知道要得到某个节点的失败指针指向的节点,其实就是要找与这个节点所在的前部分模式串的后缀子串匹配的最长前缀子串。
在一棵 Trie 树中,某个节点的失败指针指向的节点只会在它的上层。因此,可以采用求 next 数组一样的方法,求失败指针,即通过已经求得失败指针的节点来推导当前节点的失败指针。
根节点root的失败指针是 null,即指向自己,然后,求得某个节点p
的失败指针q
后,如何去求它子节点的失败指针?
情况一:将p
的子节点与q
的子节点互相比较,若相同,则找到对应的失败指针了。
情况二:若p
的子节点与q
的子节点存在不相等的,那么我们通过q
的失败指针,获得对应节点,继续查找子节点,直到查到 null 为止,即 root 节点。
下面是构建失败指针的代码:
public void buildFailurePointer(AcNode root) {
Queue<AcNode> queue = new LinkedList<>();
root.fail = null;
queue.add(root);
while (!queue.isEmpty()) {
AcNode p = queue.remove();//拿到节点p
for (AcNode pc : p.children) {
//遍历节点p的子节点
if (pc == null) continue;
if (p == root) {
//root的子节点失败指针为root
pc.fail = root;
} else {
AcNode q = p.fail;//找到p的失败指针节点q
while (q != null) {
//查找p的子节点是否存在q的子节点
AcNode qc = q.children[pc.data - 'a'];
if (qc != null) {
//存在则找到失败指针
pc.fail = qc;
break;
}
q = q.fail;//否则继续找下一个失败指针
}
if (q == null) {
//直到找到null,则失败指针为root
pc.fail = root;
}
}
queue.add(pc);
}
}
}
构建完失败指针后,如图:
使用 AC 自动机匹配
假设主串str
,从主串第一个字符开始匹配,自动机从指针p=root
开始匹配
- 假设
p
的子节点x
等于str[0]
,则把p
更新为x
,接着要检查p
(当前指向x
)的失败指针是否是某个模式串的结尾,若是,则查找到一个匹配的模式串。处理完之后,继续匹配str[2]。 - 若进行到某一步后,
p
的子节点中没有找到能匹配的字符,那么,失败指针就派上用场了,即:在失败指针指向的节点的子节点中查找。
public void match(char[] str, AcNode root) {
// str是主串,root是自动机
AcNode p = root;
for (int i = 0; i < str.length; i++) {
int idx = str[i] - 'a';
//p的子节点中没有,就往p的失败节点的子节点中找,直到失败指针指向null为止
while (p.children[idx] == null && p != root) {
p = p.fail; // 失败指针发挥作用的地方
}
p = p.children[idx];//找到匹配的字符后,p更新指向到这个节点
if (p == null)// 如果没有匹配的,从 root 开始重新匹配
p = root;
AcNode tmp = p;
while (tmp != root) {
// 找到已经匹配到的模式串
if (tmp.isEndingChar == true) {
int pos = i - tmp.length + 1;
System.out.println(" 匹配起始下标 " + pos + "; 长度 " + tmp.length);
}
tmp = tmp.fail;
}
}
}
AC 自动机匹配的效率
- Trie 树构建的复杂度是
O(m*len)
,其中m
为模式串数量,len
为模式串平均长度。 - 构建失败指针时,最耗时的是 while 循环中逐层往上查找失败指针,每次循环至少往上一层,而树的高度不超过
len
,因此时间复杂度为O(K*len)
,K 为 Trie 树中的节点个数。 - 以上两步操作只需执行一次完成构建,不影响与主串匹配的效率,在匹配时,最耗时的同样是 while 循环中往下一个失败指针的代码,因此时间复杂度为
O(len)
,若主串长度为n
,那么总匹配时间复杂度为O(n*len)
实际在匹配敏感词时,敏感词的平均长度不会很长,因此,AC 自动机的匹配效率很接近O(n)
,只有在极端情况下,效率才会退化成与 Trie 树匹配效率一样。
极端情况如下: