English | 中文
基于双数组的AC自动机实现,收到下面两个项目的启发完成
通常,一个 AC自动机是一个 trie 结构, 但是状态节点更加丰富,每个状态节点状态由下面几种结构构成:
- goto 表(goto table)
- 失败指针(failure pointer)
- 输出表(output table)
这个实现是引用1中双数组前缀树(DAT)的拓展,将AC自动机的上面3种元素都编码进两个数组中,最终达到了非常优秀的性能,对于长文本尤其明显。
然而,构造这个数据结构(双数组)的过程比链接表要慢,因此,这个实现更适用于一次构建,多次查询的场景.
<dependency>
<groupId>com.helipy.text</groupId>
<artifactId>ahocorasick-doublearray</artifactId>
<version>1.1.0</version>
</dependency>
import com.helipy.text.ahocorasick.DatAutomaton;
import com.helipy.text.ahocorasick.Emit;
// 构造过程
DatAutomaton.Builder<Void> builder = DatAutomaton.<>builder();
builder.add("he")
.add("she")
.add("say");
Automaton<Void> automaton = builder.build();
如果需要对每个关键词关联一个对象, 例如一个 Float 的权重
DatAutomaton.Builder<Float> builder = DatAutomaton.builder();
builder.put("he", 0.5f)
.put("she", 0.6f)
.put("say", 0.4f);
// 获取关联的对象 (since 1.1.0)
Float weight = builder.get("he");
Automaton<Float> automaton = builder.build();
也可以使用 addAll
, putAll
一次添加一组关键词.
List<Emit<Void>> list = automaton.parseText(text);
for (Emit<Void> emit : emitList) {
// 打印命中的关键词和在文本中的位置
System.out.printf("%s %d %d%n", emit.getKeyword(), emit.getStart(), emit.getEnd());
}
如果给关键词关联了一个对象,例如一个 Float 的权重
List<Emit<Float>> emitList = automaton.parseText(text);
for (Emit<Float> emit : emitList) {
// 打印命中的关键词,在文本中的位置,以及关联的权重
System.out.printf("%s %d %d %f%n", emit.getKeyword(), emit.getStart(), emit.getEnd(), emit.getValue());
}
import com.helipy.text.ahocorasick.MatchHandler;
MatchHandler<V> handler = new MatchHandler<V> {
boolean onMatch(int start, int end, String key, V value) {
// 处理逻辑
// ...
return true; // 如果希望命中后停止解析,请 return false
}
};
List<Emit<V>> emitList = automaton.parseText(text, handler);
如果希望中途停止解析,例如,测试一个输入文档是否包含 "demon",这并不需要遍历整个文档,只需要在发现 "demon"时停止.
import com.helipy.text.ahocorasick.MatchHandler;
MatchHandler<V> handler = new MatchHandler<V> {
boolean hasDemon = false;
boolean onMatch(int start, int end, String key, V value) {
if("demon".equals(key)) {
hasDemon = true;
return false;
}
return true;
}
};
List<Emit<V>> emitList = automaton.parseText(document, handler);
当 handler 返回 false, parse 函数将在找到这个关键词后立即返回.
有时,查询字符串包含一些你希望忽略的字符,例如: "sh##e" 需要被当作 "she" 来处理,而你在构造自动机时添加了关键词 "she",你可以把特殊字符转换为 '\0', 因为 '\0'在解析时会被跳过.
String str = "sh##e";
CharSequence convertStr = new CharSequence() {
@Override
public int length() {
return str.length();
}
@Override
public char charAt(int index) {
char ch = str.charAt(index);
if(ch == '#') {
return '\0';
}
return ch;
}
// this method will not be used, it is ok with no implementation
@Override
public CharSequence subSequence(int start, int end) {
return null;
}
};
List<Emit<V>> list = automaton.parseText(convertStr);
像上面一样重载 charAt
函数. 例如,对于大小写不敏感的场景,可以像下面这样做:
class LowerCaseCS implements CharSequence {
private final CharSequence text;
public LowerCaseCS(CharSequence text) {
this.text = text;
}
@Override
public int length() {
return text.length();
}
@Override
public char charAt(int index) {
char ch = text.charAt(index);
if(ch >= 'A' && ch <= 'Z') {
return ch - ('A' - 'a');
}
return ch;
}
// 这个方法不会被使用,不实现它是可以的
@Override
public CharSequence subSequence(int start, int end) {
return null;
}
}
automaton.parseText(new LowerCaseCS(text));
有时中断查询过程是有必要的,如超时取消,关机等,可以按照如下方式设置:
DatAutomaton.Builder<Void> builder = DatAutomaton.<void>builder();
builder.setInterruptable(true) // 设置可以被中断
.add("he")
.add("she")
.add("say");
Automaton<Void> automaton = builder.build();
setInterruptable
方法表示该自动机是否可以监听线程中断信号。如果设置为 true(默认为 false), 当线程被中断时会停止查询(线程中断状态会被保留)。
在另一个线程中,可以这样中断该自动机:
t1.interrupt(); // 假定该自动机工作的线程为 t1
我在我的笔记本上测试了性能,我的机器配置为 Apple MacBook Pro 15.4, CPU:2.2Hz Intel Core i7, 内存:16G, 结果如下:
text length | query avg (ns) | query tp99 (ns) |
---|---|---|
300 ~ 600 | 15077 | 53644 |
10k ~ 30k | 714744 | 2162130 |
10m | 704315370 | 704315370 |
20m | 178099589 | 224774130 |
上表表明,这个实现可以在1秒内查询千万级字符.
一个更专业的基准测试比较了两种实现(链接表/双数组)的查询用时(纳秒)
Log10(text_length) | Linking Implementation | Double-Array Implementation |
---|---|---|
1 | 5511 | 3609 |
2 | 19055 | 4918 |
3 | 253760 | 16289 |
4 | 1674961 | 132835 |