1.背景
之前的Trie樹
,DBTrie
都屬於前綴樹,雖然DAT每次狀態轉移的時間複雜度都是常數,但全切分長度爲n的文本時,時間複雜度爲O(n2)。這是因爲掃描過程中需要不斷的挪動起點,發起新的查詢。所以說,DAT的全切分複雜度爲O(n2)。
2.爲什麼需要AC自動機
顯然,前綴樹的短板是掃描,查詢一個句子時,前綴樹需要不斷的挪動起點,發起新查詢,這個過程浪費了大量時間。
舉個栗子,掃描"清華大學"這個短語,算法以"清"爲起點掃描"清",“清華”,“清華大”,“清華大學”,之後再回退到"華",繼續掃描"華",“華大”…
如果能夠在掃描到"清華大學"的同時想辦法知道,“華大學”,“大學”,"學"在不在字典樹中,那麼就可以省略掉這三次查詢,觀察一下這三個字符串,它們共享遞進式的後綴,首尾對調後(“學”,“學大”,“學大華”)恰好可以用另一顆前綴樹索引,稱它爲後綴樹。
AC(Aho-Corasick)自動機
的原理就是在前綴樹的基礎上,爲前綴樹上的每個節點建立一顆後綴樹,從而節省了大量查詢。這使得每次掃描由原來的O(n2)降到了O(n),AC自動機
現在被廣泛的用於多字符串匹配
。
3.AC自動機的結構
- success表:用於狀態的成功轉移,本質上是一個前綴樹
- output表:記錄命中的模式串
- failture表:保存狀態間的一對一的關係,存儲狀態轉移失敗後應當回退的最佳狀態,這裏的最佳狀態是指能記住已匹配上的字符串的最長後綴的那個狀態。
4.AC自動機的構建過程
整體流程如下:
- 添加模式串keyWord,構建樹,根據seccess函數構建success表
- 構建完成後,對樹進行掃描,根據fail函數構建fail表
- 外界輸入文本,輸出被命中的模式串
下面以圖爲例講解,圖來源於ProcessOn:
5.建立success表
sucess表
的本質是前綴樹,所以構建不再贅述,唯一不同的是,根節點不光可以按第一層的節點轉移(比如像圖中的h和s),還可以接受其它字符,轉移終點都是自己。下面是構建代碼:
/**
* @author: Ragty
* @Date: 2020/4/1 14:22
* @Description: success跳轉
*/
public Node find(Character character) {
return map.get(character);
}
/**
* @author: Ragty
* @Date: 2020/4/1 14:23
* @Description: 狀態轉移(此處的transition爲轉移的狀態,可理解爲接收的一個詞)
*/
private Node nextState(Character transition) {
Node state = this.find(transition); //先按success跳轉
if (state != null) {
return state;
}
if (this.isRoot) { //如果跳轉到根結點還是失敗,則返回根結點
return this;
}
return this.failure.nextState(transition); // 跳轉失敗,按failure跳轉
}
6.建立Fail表(核心)
Fail表
保存的是狀態(節點)間的一對一的關係,存儲狀態轉移失敗後應當回退的最佳狀態(敲黑板,看下面的實例講解!!!)。
以圖爲例,匹配she之後到達狀態5,再來一個字符,狀態轉移失敗,此時,最長後綴爲he,對應路徑爲0-1-2。因此,狀態2是狀態5 fail的最佳選擇,fail到狀態2之後,做好了接受r的準備。
再比如,匹配his後到達狀態7,此時his的最長後綴爲is,但是途中沒有找到is的路徑,於是找次長後綴s,對應路徑爲0-3,因此狀態7的最佳fail爲3。
下面是構建方法:
- 將深度爲1的節點設爲根節點,第二層中的節點的失敗路徑直接指向根節點
- 爲深度大於1的節點建立fail表,此處需要層序遍歷,用BFS進行廣度優先遍歷。整個過程可以概括爲一句話:設這個節點上的字母爲C,沿着他父親的失敗指針走,直到找到一個節點,孩子節點也爲C。然後把當前節點的fail指向剛找到的孩子節點C。如果一直走到了root都沒找到,那就把fail指向root。
下面是具體的構建代碼:
/**
* @author: Ragty
* @Date: 2020/4/1 16:04
* @Description: 建立Fail表(核心,BFS遍歷)
*/
private void constructFailureStates() {
Queue<Node> queue = new LinkedList<>();
for (Node depthOneState : this.root.children()) {
depthOneState.setFailure(this.root);
queue.add(depthOneState);
}
this.failureStatesConstructed = true;
while (!queue.isEmpty()) {
Node parentNode = queue.poll();
for (Character transition : parentNode.getTransitions()) {
Node childNode = parentNode.find(transition);
queue.add(childNode);
Node failNode = parentNode.getFailure().nextState(transition); //在這裏構建failNode
childNode.setFailure(failNode);
childNode.addEmit(failNode.emit()); //用路徑後綴構建output表
}
}
}
7.建立output表
output表
用來記錄命中的模式串,output表中的元素有兩種:
- 從初始狀態到當前狀態的路徑本身對應的模式串(比如2號狀態的he)
- 路徑的後綴所對應的模式串(比如5號狀態中的he)
所以output表的構造也分爲兩步:
- 第一步與字典樹類似,記錄完整路徑所對應的模式串
- 第二步則是找出所有路徑後綴及其模式串(這一步放在了構建fail表的最後)
下面是構建代碼:
/**
* @author: Ragty
* @Date: 2020/4/1 15:10
* @Description: 添加一個模式串(內部使用字典樹構建)
*/
public void addKeyword(String keyword) {
if (keyword == null || keyword.length() == 0) {
return;
}
Node currentState = this.root;
for (Character character : keyword.toCharArray()) {
currentState = currentState.insert(character);
}
currentState.addEmit(keyword);
}
8.模式匹配
模式匹配實現的功能是,輸入一段文本,輸出AC自動機中所有匹配的詞,下面是實現代碼:
/**
* @author: Ragty
* @Date: 2020/4/1 17:43
* @Description: 模式匹配
*/
public Collection<Emit> parseText(String text) {
checkForConstructedFailureStates();
Node currentState = this.root;
List<Emit> collectedEmits = new ArrayList<>();
for (int position = 0; position < text.length(); position++) {
Character character = text.charAt(position);
currentState = currentState.nextState(character);
Collection<String> emits = currentState.emit();
if (emits == null || emits.isEmpty()) {
continue;
}
for (String emit : emits) {
collectedEmits.add(new Emit(position - emit.length() + 1, position, emit));
}
}
return collectedEmits;
}
9.單元測試
public static void main(String[] args) {
AhoCorasickTrie trie = new AhoCorasickTrie();
trie.addKeyword("hers");
trie.addKeyword("his");
trie.addKeyword("she");
trie.addKeyword("he");
Collection<Emit> emits = trie.parseText("ushers");
for (Emit emit : emits) {
System.out.println(emit.start + " " + emit.end + "\t" + emit.getKeyword());
}
}
輸入文本爲"ushers"時,輸出結果爲:
1 3 she
2 3 he
2 5 hers
10.基於雙數組字典樹的AC自動機
基於雙數組字典樹的AC自動機會進一步優化,結構上只需將原來的success表的構建由Trie樹替換爲DATrie,效果上與雙數組字典樹不相上下,原因爲:
- 漢語中的詞彙都不太長,前綴樹的優勢佔了較大比重,AC自動機的fail機制發揮不了太大作用
- 全切分需要將結果添加到鏈表,也會佔用時間
總結一下,當含有短模式串時,優先用雙數組字典樹,否則優先使用基於雙數組字典樹的AC自動機
11.源碼
public class AhoCorasickTrie {
private Boolean failureStatesConstructed = false; //是否建立了failure表
private Node root; //根結點
/**
* @author: Ragty
* @Date: 2020/4/1 13:49
* @Description: ACTire初始化
*/
public AhoCorasickTrie() {
this.root = new Node(true);
}
/**
* @author: Ragty
* @Date: 2020/4/1 13:54
* @Description: ACTrie節點(內部用字典樹構建)
*
*/
private static class Node{
private Map<Character, Node> map;
private List<String> emits; //輸出
private Node failure; //失敗中轉
private Boolean isRoot = false; //是否爲根結點
public Node(){
map = new HashMap<>();
emits = new ArrayList<>();
}
public Node(Boolean isRoot) {
this();
this.isRoot = isRoot;
}
public Node insert(Character character) {
Node node = this.map.get(character);
if (node == null) {
node = new Node();
map.put(character, node);
}
return node;
}
public void addEmit(String keyword) {
emits.add(keyword);
}
public void addEmit(Collection<String> keywords) {
emits.addAll(keywords);
}
/**
* @author: Ragty
* @Date: 2020/4/1 14:22
* @Description: success跳轉
*/
public Node find(Character character) {
return map.get(character);
}
/**
* @author: Ragty
* @Date: 2020/4/1 14:23
* @Description: 狀態轉移(此處的transition爲轉移的狀態,可理解爲接收的一個詞)
*/
private Node nextState(Character transition) {
Node state = this.find(transition); //先按success跳轉
if (state != null) {
return state;
}
if (this.isRoot) { //如果跳轉到根結點還是失敗,則返回根結點
return this;
}
return this.failure.nextState(transition); // 跳轉失敗,按failure跳轉
}
public Collection<Node> children() {
return this.map.values();
}
public void setFailure(Node node) {
failure = node;
}
public Node getFailure() {
return failure;
}
public Set<Character> getTransitions() {
return map.keySet();
}
public Collection<String> emit() {
return this.emits == null ? Collections.<String>emptyList() : this.emits;
}
}
/**
* @author: Ragty
* @Date: 2020/4/1 15:01
* @Description: 模式串(用於模式串匹配)
*/
private static class Emit{
private final String keyword; //匹配到的模式串
private final int start; //起點
private final int end; //終點
public Emit(final int start, final int end, final String keyword) {
this.start = start;
this.end = end;
this.keyword = keyword;
}
public String getKeyword() {
return this.keyword;
}
@Override
public String toString() {
return super.toString() + "=" + this.keyword;
}
}
/**
* @author: Ragty
* @Date: 2020/4/1 15:10
* @Description: 添加一個模式串(內部使用字典樹構建)
*/
public void addKeyword(String keyword) {
if (keyword == null || keyword.length() == 0) {
return;
}
Node currentState = this.root;
for (Character character : keyword.toCharArray()) {
currentState = currentState.insert(character);
}
currentState.addEmit(keyword); //記錄完整路徑的output表(第一步)
}
/**
* @author: Ragty
* @Date: 2020/4/1 17:43
* @Description: 模式匹配
*/
public Collection<Emit> parseText(String text) {
checkForConstructedFailureStates();
Node currentState = this.root;
List<Emit> collectedEmits = new ArrayList<>();
for (int position = 0; position < text.length(); position++) {
Character character = text.charAt(position);
currentState = currentState.nextState(character);
Collection<String> emits = currentState.emit();
if (emits == null || emits.isEmpty()) {
continue;
}
for (String emit : emits) {
collectedEmits.add(new Emit(position - emit.length() + 1, position, emit));
}
}
return collectedEmits;
}
/**
* @author: Ragty
* @Date: 2020/4/1 16:04
* @Description: 建立Fail表(核心,BFS遍歷)
*/
private void constructFailureStates() {
Queue<Node> queue = new LinkedList<>();
for (Node depthOneState : this.root.children()) {
depthOneState.setFailure(this.root);
queue.add(depthOneState);
}
this.failureStatesConstructed = true;
while (!queue.isEmpty()) {
Node parentNode = queue.poll();
for (Character transition : parentNode.getTransitions()) {
Node childNode = parentNode.find(transition);
queue.add(childNode);
Node failNode = parentNode.getFailure().nextState(transition); //在這裏構建failNode
childNode.setFailure(failNode);
childNode.addEmit(failNode.emit()); //用路徑後綴構建output表(第二步)
}
}
}
/**
* @author: Ragty
* @Date: 2020/4/1 15:28
* @Description: 檢查是否建立了Fail表(若沒建立,則建立)
*/
private void checkForConstructedFailureStates() {
if (!this.failureStatesConstructed) {
constructFailureStates();
}
}
public static void main(String[] args) {
AhoCorasickTrie trie = new AhoCorasickTrie();
trie.addKeyword("hers");
trie.addKeyword("his");
trie.addKeyword("she");
trie.addKeyword("he");
Collection<Emit> emits = trie.parseText("ushers");
for (Emit emit : emits) {
System.out.println(emit.start + " " + emit.end + "\t" + emit.getKeyword());
}
}
}