AC算法在美团上单系统的应用
1.背景
在美團(tuán),為了保證單子質(zhì)量,需要對(duì)上單系統(tǒng)創(chuàng)建的每一個(gè)產(chǎn)品進(jìn)行審核。為了提高效率,審核人員積累提煉出了一套關(guān)鍵詞庫(kù),先基于該詞庫(kù)進(jìn)行自動(dòng)審核過(guò)濾,對(duì)于不包括這些關(guān)鍵詞的產(chǎn)品信息不再需要進(jìn)行人工審核。因此,如何在頁(yè)面中快速的檢測(cè)是否包含了這些關(guān)鍵詞就變得非常重要。
對(duì)于上述問(wèn)題我們描述為如下的形式:
- 給定關(guān)鍵詞集合P={p1,p2,……,pk},在目標(biāo)串T[1…m]中找到出現(xiàn)了哪些關(guān)鍵詞。
很容易想到的方法就是針對(duì)每個(gè)單詞去匹配一遍,最后總結(jié)出都哪些單詞匹配成功。
考慮KMP算法,單個(gè)關(guān)鍵詞匹配的時(shí)間復(fù)雜度是O(|pk|+m),所以,所有關(guān)鍵詞都匹配一遍的時(shí)間復(fù)雜度為O(|p1|+m+|p2|+m+…+|pk|+m)。令n=|p1|+…+|pk|,上式化簡(jiǎn)為O(n+km),因此,當(dāng)關(guān)鍵詞的數(shù)量變得非常多時(shí),這種算法就變得無(wú)法忍受了。
Alfred V.Aho和Margaret J.Corasick在1974年提出了一個(gè)經(jīng)典的多模式匹配算法-AC算法,這個(gè)算法可以保證對(duì)于給定的長(zhǎng)度為n的文本,和模式集合P{p1,p2,…pm},在O(n)的時(shí)間復(fù)雜度內(nèi)找到文本中的所有目標(biāo)模式,而與模式集合的規(guī)模m無(wú)關(guān)。
2.AC算法詳解
AC算法的具體實(shí)現(xiàn)方法就是創(chuàng)建一棵前綴樹(shù),根據(jù)被查找的目標(biāo)字符串,從樹(shù)的根節(jié)點(diǎn)開(kāi)始往葉子節(jié)點(diǎn)逐字符匹配。在這個(gè)過(guò)程中,如果發(fā)生失配,要根據(jù)失配跳轉(zhuǎn)點(diǎn)進(jìn)行跳轉(zhuǎn),如果找到匹配的模式串則進(jìn)行打印輸出。AC算法在掃描文本時(shí)完全不需要回溯,如果只考慮匹配的過(guò)程,該算法的時(shí)間復(fù)雜度為O(n),也就是只跟待匹配文本的長(zhǎng)度相關(guān)。
AC算法的實(shí)現(xiàn)可以由如下三個(gè)步驟構(gòu)成:
其間共用到三個(gè)函數(shù):goto,fail,output。
步驟一:構(gòu)造前綴樹(shù)
這里我們考慮模式集合P={“he”,”she”,”his”,”hers”}。
首先是goto函數(shù)的建立,該函數(shù)決定了對(duì)于當(dāng)前狀態(tài)S和條件C,如何得到下一狀態(tài)S’。為了構(gòu)建goto函數(shù),我們需要建立一個(gè)狀態(tài)轉(zhuǎn)移圖,開(kāi)始,這個(gè)圖只包含一個(gè)狀態(tài)0,然后通過(guò)添加一條從起始狀態(tài)出發(fā)的路徑的方式,依次向圖中輸入每個(gè)關(guān)鍵字keyword,新的頂點(diǎn)和邊被加入到圖表中,這樣就產(chǎn)生了一條能拼寫(xiě)出關(guān)鍵字keyword的路徑。
添加第一個(gè)關(guān)鍵詞“he”得到下圖,其中從狀態(tài)0到狀態(tài)2的路徑就拼寫(xiě)出了關(guān)鍵字“he”;
接著添加第二個(gè)關(guān)鍵字“she”得到下圖,輸出“she”和狀態(tài)5相關(guān)聯(lián);
增加第三個(gè)關(guān)鍵字“his”得到下圖,當(dāng)我們?cè)黾印癶is”時(shí),因?yàn)橐呀?jīng)存在一條從狀態(tài)0在輸入h的條件下到達(dá)狀態(tài)1的邊,因此我們這里不需要另外添加一條同樣的邊。這個(gè)輸出的“his”是和狀態(tài)7相關(guān)聯(lián)的;
最后我們添加“hers”得到下圖,輸出“hers”和狀態(tài)9相關(guān)聯(lián),最后對(duì)除了h和s外的每個(gè)字符都增加一個(gè)從狀態(tài)0到0的循環(huán);
經(jīng)由上面一系列添加過(guò)程,就構(gòu)造了整個(gè)模式集合的狀態(tài)轉(zhuǎn)移圖,這個(gè)圖也就代表了轉(zhuǎn)向函數(shù)goto。 我們利用偽代碼將goto函數(shù)表示如下,同時(shí)我們?cè)谶@一步驟中構(gòu)造了output函數(shù),但這個(gè)函數(shù)并不是完整的,需要在步驟二中繼續(xù)完善:
beginnewstate ← 0for i ← 1 util k do enter(yi)for all a such that goto(0,a) == fail do goto(0,a) ← 0 endprocedure enter(a1a2…am): beginstate ← 0j ← 1while goto(state, aj) ≠ fail dobeginstate ← goto(state, aj)j ← j+1endfor p ← j util m dobeginnewstate ← newstate + 1goto(state, ap) ← newstatestate ← newstateendoutput(state) ← {a1a2…am} end步驟二:設(shè)置每個(gè)節(jié)點(diǎn)的失配跳轉(zhuǎn)
失效函數(shù)fail決定了當(dāng)goto函數(shù)得到的下一個(gè)狀態(tài)無(wú)效時(shí),應(yīng)該回退到哪一個(gè)狀態(tài)。在構(gòu)造fail函數(shù)時(shí),我們首先定義狀態(tài)轉(zhuǎn)移圖中狀態(tài)S的深度為從狀態(tài)0到狀態(tài)S的最短路徑。以我們上面構(gòu)造的狀態(tài)轉(zhuǎn)移圖為例,起始狀態(tài)的深度為0,狀態(tài)1和3的深度是1,狀態(tài)2、6、4的深度是2,依次類推。計(jì)算失效函數(shù)的思路是這樣的:首先計(jì)算深度為1 的狀態(tài)的失效函數(shù)值,然后是深度為2的,以此類推,直到所有狀態(tài)的失效函數(shù)值都被計(jì)算出。同時(shí),我們規(guī)定所有深度為1的狀態(tài)的fail值為0,假設(shè)所有深度小于d的狀態(tài)的fail值都已經(jīng)計(jì)算出,考慮每個(gè)深度為d-1的狀態(tài)r,基于這些已經(jīng)被計(jì)算出的深度為d-1的fail值,我們是可以得到深度為d的fail值的。
令L(Si)為從根節(jié)點(diǎn)到Si節(jié)點(diǎn)的路徑上的所有邊的值的序列,我們從樹(shù)的根節(jié)點(diǎn)開(kāi)始遍歷計(jì)算fail值,如果L(Sj)是L(Si)的一個(gè)后綴,并且是最長(zhǎng)后綴,那么,fail(Si) = Sj。假設(shè)當(dāng)前狀態(tài)為S1,現(xiàn)在要求fail(S1),S1的前一狀態(tài)我們記為S2,而S2跳到S1的條件為C,也就是S1 = goto(S2,C),而S2的fail值是已知的,記為S3,也即S3 = fail(S2),則L(S3)是L(S2)的一個(gè)最長(zhǎng)后綴,假設(shè)S4 = goto(S3,C)存在,那么fail(S1) = S4,如果不存在則測(cè)試S5 = goto(fail(S3),C),直到得到一個(gè)有效的狀態(tài)為止。這個(gè)計(jì)算的過(guò)程是這樣的:
我們還是以上面構(gòu)造出的狀態(tài)轉(zhuǎn)移圖為例,計(jì)算每個(gè)節(jié)點(diǎn)的fail值,根據(jù)規(guī)定,fail(1) = fail(3) = 0,因?yàn)?和3是深度為1的狀態(tài)。
考慮深度為2的狀態(tài)2、6、4: * 計(jì)算fail(2),令state = fail(1) = 0,由于goto(0,e) = 0,所以fail(2) = 0 * 計(jì)算fail(4),令state = fail(3) = 0,由于goto(0,h) = 1,所以fail(4) = 1 * 計(jì)算fail(6),令state = fail(1) = 0,由于goto(0,i) = 0,所以fail(6) = 0
考慮深度為3的節(jié)點(diǎn)8、7、5: * 計(jì)算fail(8),令state = fail(2) = 0,因?yàn)間oto(0,r) = 0,所以fail(8) = 0 * 計(jì)算fail(7),令state = fail(6) = 0,因?yàn)間oto(0,s) = 3,所以fail(7) = 3 * 計(jì)算fail(5),令state = fail(4) = 1,因?yàn)間oto(1,e) = 2,所以fail(5) = 2
最后考慮深度為4的節(jié)點(diǎn)9: * 計(jì)算fail(9),令state = fail(8) = 0,因?yàn)間oto(0,s) = 3,所以fail(9) = 3
這樣一來(lái)我們構(gòu)造的fail表如下:
| fail值 | None | 0 | 0 | 0 | 1 | 2 | 0 | 3 | 0 | 3 |
失效函數(shù)創(chuàng)建的偽代碼如下:
beginqueue ← emptyfor each a such that goto(0,a) = s ≠ fail dobeginqueue ← queue U {s}fail(s) ← 0endwhile queue ≠ empty dobeginlet r be the next state in queuequeue ← queue - {r}for each a such that goto(r,a) = s ≠ fail dobeginqueue ← queue U {s}state ← fail(r)while goto(state,a) = fail do state ← fail(state)fail(s) ← goto(state,a)output(s) ← output(s) U output(fail(s))endend end步驟三:對(duì)目標(biāo)字符串進(jìn)行搜索匹配
上面兩個(gè)步驟都完成了之后就可以開(kāi)始對(duì)目標(biāo)串進(jìn)行搜索了,只需對(duì)目標(biāo)串從頭到尾線性掃描,且沒(méi)有回溯。搜索之前先記錄樹(shù)的當(dāng)前節(jié)點(diǎn)node,初始時(shí),樹(shù)的當(dāng)前節(jié)點(diǎn)node為根節(jié)點(diǎn)Root。從目標(biāo)串的第一個(gè)字符開(kāi)始,和Root的孩子節(jié)點(diǎn)進(jìn)行匹配,如果不匹配,則目標(biāo)字符串往后挪一個(gè)字符,繼續(xù)在Root的孩子節(jié)點(diǎn)中查找匹配。如果找到匹配的孩子,則目標(biāo)字符串往后挪一個(gè)字符,node變?yōu)槠ヅ渖系暮⒆庸?jié)點(diǎn)。在接下來(lái)的匹配過(guò)程中,如果失配將跳轉(zhuǎn)到node節(jié)點(diǎn)的fail值處繼續(xù)進(jìn)行匹配。在樹(shù)上每次往孩子節(jié)點(diǎn)方向走一步都要檢查該孩子節(jié)點(diǎn)的匹配模式串信息,如果有匹配的模式串信息,則應(yīng)記錄找到了哪些能夠匹配的模式串。
整體的匹配過(guò)程如下代碼所示:
beginstate ← 0for i ← 1 until n dobeginwhile goto(state,ai) = fail do state = fail(state)state ← goto(state,ai)if output(state) ≠ empty thenbeginprint output(state)endend end3.上單系統(tǒng)中的實(shí)現(xiàn)
在美團(tuán)上單系統(tǒng)中,待匹配的關(guān)鍵詞根據(jù)產(chǎn)品類別進(jìn)行分組,不同品類之間的關(guān)鍵詞具有重疊。如果針對(duì)每個(gè)品類生成一棵狀態(tài)轉(zhuǎn)移樹(shù)固然可行,但是隨著品類的增多,對(duì)內(nèi)存的使用也會(huì)隨之增高。考慮到AC算法的時(shí)間復(fù)雜度與關(guān)鍵詞的數(shù)量無(wú)關(guān),因此可以考慮將所有品類的關(guān)鍵詞構(gòu)造在同一棵狀態(tài)轉(zhuǎn)移樹(shù)中,每次進(jìn)行匹配時(shí),在output函數(shù)中對(duì)該關(guān)鍵詞是否屬于該品類做判斷。在上單系統(tǒng)中,關(guān)鍵詞用Keyword類表示,該類的定義如下:
public class Keyword implements Serializable {private Integer id;private Map<Integer, Integer> categoryTypeMap;private String word;private List<Integer> categories; //當(dāng)前的關(guān)鍵詞屬于哪幾個(gè)分類getter and setter ...@Overridepublic boolean equals(Object o) {if (this == o) return true;if (o == null || getClass() != o.getClass()) return false;Keyword keyword = (Keyword) o;if (id != null ? !id.equals(keyword.id) : keyword.id != null) return false;return true;}@Overridepublic int hashCode() {return id != null ? id.hashCode() : 0;}}其中,categoryTypeMap屬性用來(lái)標(biāo)識(shí)該關(guān)鍵詞在不同品類中所代表的類型,當(dāng)匹配命中時(shí),根據(jù)類型信息指出其可能違反了哪些條款。
我們用一個(gè)Node類來(lái)代表狀態(tài)轉(zhuǎn)移樹(shù)的一個(gè)節(jié)點(diǎn),同時(shí),將goto信息、fail信息和output信息封裝到里面,這樣,這個(gè)類的定義就像下面這樣:
static class Node{int state; //自動(dòng)機(jī)的狀態(tài),也就是節(jié)點(diǎn)數(shù)字char character = 0; //指向當(dāng)前節(jié)點(diǎn)的字符,也即條件Node failureNode; //匹配失敗時(shí),下一個(gè)節(jié)點(diǎn)List<Keyword> keywords; //匹配成功時(shí),當(dāng)前節(jié)點(diǎn)對(duì)應(yīng)的關(guān)鍵詞List<Node> children; //當(dāng)前節(jié)點(diǎn)的子節(jié)點(diǎn)... }我們用Patterns類來(lái)表示整個(gè)待匹配的模式串,它是對(duì)Node的進(jìn)一步封裝:
public static class Patterns{protected final Node root = new Node();protected List<Node> tree;public Patterns(List<Keyword> keywords) {tree = new ArrayList<Node>();tree.add(root);for(Keyword keyword : keywords){addKeyword(keyword);}setFailNode();}public void addKeyword(Keyword keyword) {char[] wordCharArr = keyword.getWord().toCharArray();Node current = root;for(char currentChar : wordCharArr){if(current.containsChild(currentChar)){current = current.getChild(currentChar);} else {Node node = new Node(table.size(), currentChar, root);current.addChild(node);current = node;tree.add(node);}}current.addKeyword(keyword);}public void setFailNode(){Queue<Node> queue = new LinkedList<Node>();Node node = root;for (Node d1 : node.children)queue.offer(d1);while (!queue.isEmpty()) {node = queue.poll();if (node.children != null) {for (Node curNode : node.children) {queue.offer(curNode);Node failNode = node.failureNode;while (!failNode.containsChild(curNode.character)) {failNode = failNode.failureNode;if (failNode.state == 0) break;}if (failNode.containsChild(curNode.character)) {curNode.failureNode = failNode.getChild(curNode.character);curNode.addKeywords(curNode.failureNode.keywords);}}}}}}在上單系統(tǒng)中對(duì)關(guān)鍵詞的匹配需要傳遞一個(gè)categoryId,當(dāng)匹配成功時(shí),我們需要根據(jù)傳遞的類別信息判斷是否應(yīng)該保存當(dāng)前關(guān)鍵詞:
public Set<Keyword> searchKeyword(String data, Integer category) {Set<Keyword> matchResult = new HashSet<Keyword>();Node node = patterns.getRoot();char[] chs = data.toCharArray();for(int i=0; i < chs.length; i++){while (!node.containsChild(chs[i])) {node = node.failureNode;if (node.state == 0) break;}if(node.containsChild(chs[i])){node = node.getChild(chs[i]);if(node.keywords != null){for(Keyword pattern : node.keywords){if (category == null) {matchResult.add(pattern);} else {if (pattern.getCategories().contains(category)) {matchResult.add(pattern);}}}}}}return matchResult; }算法的測(cè)試結(jié)果如下:
在第二張圖中,有一個(gè)因素沒(méi)有考慮進(jìn)去,就是同樣關(guān)鍵詞數(shù)量,當(dāng)關(guān)鍵詞在文本中出現(xiàn)的次數(shù)較多時(shí),因?yàn)樾枰闅v找出對(duì)應(yīng)該品類的詞,所以花費(fèi)的時(shí)間會(huì)增加,但整體上還是符合預(yù)期的。
總結(jié)
以上是生活随笔為你收集整理的AC算法在美团上单系统的应用的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。
- 上一篇: 使用Cloud Studio在线编写、调
- 下一篇: 阿里P8架构师谈:分布式系统全局唯一ID