AC自动机:在一篇文章paper字符串中,搜索查找一批matches字符串,看看有哪些字符串能匹配上?
提示:字符串匹配算法KMP算法,速度极快,KMP查一个字符串,而AC自动机查一批字符串
要学习今天的内容,必须熟练掌握前缀树的基础知识:
【3】前缀树,查找树,trie数据结构:建前缀树,查字符串,查前缀,删除前缀树中的字符串
之前咱们学过KMP算法必须要预备的预设数组信息:next数组
(1)KMP算法预备知识:字符串match的每一个位置i之前的字符串,前缀与后缀匹配的最大长度是多少?
这个文章你必须学会了,学透了,
【2】KMP算法:在字符串s中搜索匹配查找match字符串,如果能找到返回首个匹配位置i,否则返回-1
当时学习KMP算法,在一个字符串s中快速搜索一个 match字符串,并返回匹配的首个字符位置i
今天要学:在一篇文章paper字符串),搜索一批 matches字符串,看看有哪些字符串能匹配上?
文章目录
- AC自动机:在一篇文章paper字符串中,搜索查找一批matches字符串,看看有哪些字符串能匹配上?
-
- 题目
- 一、审题
- 暴力解:o(n*m*k)复杂度,不可取
- 一批字符串,完全可以用前缀树表示
- AC自动机如何利用前缀树加速?
- 在前缀树中添加fail指针:指向matches中,另一个以当前匹配串str为后缀串的,最长前缀字符串身上
-
- 再理解一遍fail指针的含义:它指向谁?以cur的子那个字符c为结尾做后缀串str,fail指针指向其他以str为最长的前缀串的字符串
- fail指针的用处:加速收集答案,当前字符串path没法跟paper继续匹配了,跳到fail指针那接着匹配,舍弃当前后缀str在别的字符串那做前缀的那段搜索
- 怎么收集答案:遍历前缀树每个节点cur,每次沿着cur的fail指针们走一圈,哪个字符串是一个结尾,就要了它
-
- 怎么收集答案:伴随fail匹配的过程中,来到前缀树root的每个节点cur,沿着cur的fail指针们走一圈,哪个字符串是一个结尾end,就要了它
- 外部如何调用AC自动机?
- 总结
文章目录
- AC自动机:在一篇文章paper字符串中,搜索查找一批matches字符串,看看有哪些字符串能匹配上?
- 题目
- 一、审题
- 暴力解:o(n*m*k)复杂度,不可取
- 一批字符串,完全可以用前缀树表示
- AC自动机如何利用前缀树加速?
- 在前缀树中添加fail指针:指向matches中,另一个以当前匹配串str为后缀串的,最长前缀字符串身上
- 再理解一遍fail指针的含义:它指向谁?以cur的子那个字符c为结尾做后缀串str,fail指针指向其他以str为最长的前缀串的字符串
- fail指针的用处:加速收集答案,当前字符串path没法跟paper继续匹配了,跳到fail指针那接着匹配,舍弃当前后缀str在别的字符串那做前缀的那段搜索
- 怎么收集答案:遍历前缀树每个节点cur,每次沿着cur的fail指针们走一圈,哪个字符串是一个结尾,就要了它
- 怎么收集答案:伴随fail匹配的过程中,来到前缀树root的每个节点cur,沿着cur的fail指针们走一圈,哪个字符串是一个结尾end,就要了它
- 外部如何调用AC自动机?
- 总结
题目
AC自动机:在一篇文章paper字符串中,搜索查找一批matches字符串,看看有哪些字符串能匹配上
和KMP算法的区别就是在字符串中查一个还是一批字符串!
查一个KMP
查一批AC自动机
本质上,KMP算法是AC自动机中的一种特殊情况。
一、审题
示例:
paper=abcdtks
matches=
abcd
bcd
ce
请问你,在paper字符串中,有哪些matches字符串能匹配上
显然ans=
abcd
bcd
没有ce
暴力解:o(nmk)复杂度,不可取
你就遍历字符串paper吧,来到paper的i位置,从i开始
依次去matches字符串数组中,一个一个地查,查第j个字符串
来到j字符串,从k=0位置开始对paper的i字符,一个一个匹配
匹配上了,加入ans
否则让i++,从新来匹配
这样下去得把计算机废了,如此复杂
paper是N长度,matches是M长度,每个字符串内部最大长度为K的话
你要查**o(nmk)**的复杂度
咱们要想办法优化这个复杂度!
类似于KMP,看看能否利用额外空间,换取时间,
咱们是否也可以提前准备一些数据信息,然后加速舍弃那些没必要对比的地方?
一批字符串,完全可以用前缀树表示
要学习今天的内容,必须熟练掌握前缀树的基础知识:
【3】前缀树,查找树,trie数据结构:建前缀树,查字符串,查前缀,删除前缀树中的字符串
将matches字符数组,一个一个放入前缀树root
matches=
abcd
bcd
ce
如何构建前缀树,上文已经说过了,你一定学透了
整体数据结构是这样的:
最普通的前缀树的节点:
public static class Node{
public int pass;
public int end;
public Node[] nexts;//有一个自身类型的节点数组,nexts里面放的是node
public Node(){
//一旦生成自己就要初始化,不然没法计数
pass = 0;
end = 0;
nexts = new Node[26];//初始化啥都没有
}
}
最普通的前缀树:可以往root上新加字符串,也可以删除,可以查询前缀有哪些,可以查询字符串有没有存在root上
//复习前缀树:
public static class TrieReview{
public Node root;
public TrieReview(){
root = new Node();
}
//add(word)建前缀树,一个一个字符串加
public void add(String word){
if (word.compareTo("") == 0) return;
char[] str = word.toCharArray();//转化为字符数组去一个个找路径,加
//没有新建一个点,代表这个位置的字符存在0--25==a--z
Node cur = root;//挂在root上的
cur.pass++;//新加入,必然root有一个字符串经过
int path = 0;//索引每一个字符的方向
for (int i = 0; i < str.length; i++) {
path = str[i] - 'a';//0--25==a--z
if (cur.nexts[path] == null) cur.nexts[path] = new Node();//代表存在了
cur = cur.nexts[path];//然去这个分支,代表加入了str[i]字符了
cur.pass++;//统计,当前cur字符来一个新的
}
//全部加完之后,代表多了一个str
cur.end++;
}
//search(word)
//查的就是word末尾的end
public int search(String word){
if (word.compareTo("") == 0) return 0;
Node cur = root;//自然是从root开始查
int path = 0;
char[] str = word.toCharArray();
for (int i = 0; i < str.length; i++) {
path = str[i] - 'a';
if (cur.nexts[path] == null) return 0;//压根这条路就没有
cur = cur.nexts[path];
}
//查完返回end
return cur.end;
}
//prefixSearch(word)
//以word为前缀,也就是说word最后一个字符,cur的pass是多少,就有多少个经过这里
public int prefixSearch(String word){
if (word.compareTo("") == 0) return 0;
char[] str = word.toCharArray();
int path = 0;
Node cur = root;
for (int i = 0; i < str.length; i++) {
path = str[i] - 'a';
if (cur.nexts[path] == null) return 0;//压根这条路没有
cur = cur.nexts[path];//沿着路径找
}
//前缀玩车个,返回pass
return cur.pass;
}
//delete(word)
//删除一个字符串,沿途root,cur的pass全部干掉一遍,如过提前发现某一只已经pass=0,则整条路废了
public void delete(String word){
if (word.compareTo("") == 0) return;
//先查,暂时有没有这个字符串,有才需要删除,否则不必
if (search(word) == 0) return;
char[] str = word.toCharArray();
Node cur = root;
root.pass--;//root必然损失1个字符串
int path = 0;
for (int i = 0; i < str.length; i++) {
path = str[i] - 'a';
if (--cur.nexts[path].pass == 0) {//减过了pass待会不要多减了
//唯独,就一个,则提前删除返回
cur.nexts[path] = null;
return;
}
//这条路如果至少有2个以上,只需要沿途干掉pass,最后干掉end--
cur = cur.nexts[path];
}
//最后必然干end
cur.end--;
}
}
//测试一波:
public static void test2(){
TrieReview trie = new TrieReview();
String s1 = "abc";
String s2 = "abd";
String s3 = "abe";
trie.add(s1);
trie.add(s2);
trie.add(s3);
System.out.println(trie.search(s3));//1
System.out.println(trie.prefixSearch("ab"));//3
System.out.println("--------------");
trie.delete(s3);
System.out.println(trie.search(s3));
System.out.println(trie.prefixSearch("ab"));
}
public static void main(String[] args) {
// test();
test2();
}
这个基础数据结构,我们今天会用,拿来改编,增加变量,这个变量是我们解决本题所需的参数。
AC自动机如何利用前缀树加速?
就是类似于KMP,咱们利用前缀树来加速!
我们想干嘛呢?
我们想,不妨设我们正在匹配matches的字符串x,从paper的j位置对应x的0位置,一直开始对比,都能对比上
现在已经对比到了paper的i位置了,发现i位置和x没法匹配了,
按照暴力解的话,咱们又要从matches中找另一个的字符串y,从y的0位置开始,从新和paper的j再来对比一遍……
有必要么??真的有必要这么损??
没必要暴力再来搞一遍!!!
没必要暴力再来搞一遍!!!
没必要暴力再来搞一遍!!!
你注意,上面x和y,x=abce,以abc作为后缀串,实际上abc也是y=abcdt的前缀
那么你当初x跟paper已经匹配到了abc这个子串了,我就想问问 matches中还有别的哪些字符串,是以这个abc为前缀的,他们没必要再去从新对比abc了!
咱们完全可以从y的abc之后的下一个位置k,开始与paper的i位置对比看看能匹配吗?
咱们完全可以从y的abc之后的下一个位置k,开始与paper的i位置对比看看能匹配吗?
咱们完全可以从y的abc之后的下一个位置k,开始与paper的i位置对比看看能匹配吗?
看下图y的k是d字符,而paper的i也是d字符,巧了,相等
继续k++,i++,仍相等,所以y字符串就出现在了paper中,收集y进入答案ans中。
知道AC自动机想怎么优化了吧?
实际上就想,在paper中查matches的某个字符串x,既然x以i结尾的后缀串str后续没法匹配,我走过的这段后缀串也别浪费了
我想再看看在matches中有谁?比如y,是以str为前缀的字符串?
我们直接在y的这个前缀str的下一个位置,继续跟paper的i位置对比,
这样的话,我就避免了从y的0位置又去和paper重新对比一遍,这个工作没必要重复干了!!
这就是AC自动机的舍弃思想。——就是为了避免重复干相同的事情。
这个事情,谁能做呢?
前缀树!
在前缀树中添加fail指针:指向matches中,另一个以当前匹配串str为后缀串的,最长前缀字符串身上
还是上面那个例子:我们想在x的c那个地方,整一个fail指针,指向y的c,
也就是代表:x以c字符结尾的后缀串abc,c字符的fail指针指向:在y中以最长的以c字符结尾的前缀串
下图直观感受一下,x的c字符为结尾的后缀串,又做y的c字符为结尾的前缀串,最长的那个字符串自然是abc。
前缀树中放matches字符串们
像这样,字符c的fail指针是这么指的:还是指向了c,为毛呢?因为y是以abc做前缀的,所以下次你对比y,直接来c这里继续对比,看看dt能不能匹配上就行了。
OK,下面咱们就来看正常情况下,前缀树的fail指针的连接规则:
fail指针的指向规则,就一条,看看cur的fail指向的节点,是否有与cur的子相同字符方向的路
增设fail指针:
(1)人为谁定:root的fail=null
(2)人为谁定:root的第一层节点的fail=root
(3)其余的节点,每一个节点cur的fail指针,由cur的父节点来指定。
其实就(3)一条规则,具体怎么连呢?
(3)来到节点cur,cur的子的字符为c,看cur.fail指向x节点,x的子是字符c吗?
(4)是c?则cur子的fail指向cur.fail的子
(5)否?看看x节点的fail指向的节点y,有c方向的字符吗?去(4)和(5)继续循环
(6)直到x节点的fail指向null,说明一直没找到c字符,则cur的子的fail指向head。
看图:解释这(3)–(6)的规则
(3)来到节点cur,cur的子的字符为c,cur的子的fail指针指向谁呢?
看cur.fail指向的x节点,x的子有去字符c方向的路吗?
(4)是c,看黑色那个c,有c方向的路,则cur子的fail指向cur.fail的子,看绿色那个fail指针!
(5)假如把黑色那个c划掉,就是没有去c的路?那cur的子的fail指针指向谁呢?
看看x节点的fail指向的节点y,y有c方向的字符吗?
有,那很OK,cur子的fail指向y的子
如果把黑色c删除呢?就是没有去c的路?那cur的子的fail指针指向谁呢?
看看y节点的fail指向的节点z,z有c方向的字符吗?
有,那很OK,cur子的fail指向z的子
如果把黑色c删除呢?就是没有去c的路?那cur的子的fail指针指向谁呢?
z节点的fail指向的节点的请,z的fail竟然指向了null,即进入条件(6)
(6)直到z节点的fail指向null,说明一直没找到c字符,则cur的子的fail指向head。
就是这么一条规则!
沿着cur的fail指针搜索一圈,只要能和cur的子的字符方向契合,就让cur的子的fail指向这个节点x的子。
一圈找下来如果没找到,发现最后一个fail指向了null,那不好意思,请你cur的子的fail去指向head吧!
好,AC自动机的类,咱们就可以定义了,它里面有一颗前缀树,可以构建fail指针
//AC自动机数据结构:
public static class ACAutoMachine{
//成员变量就一个前缀树
public Node root;
public ACAutoMachine(){
root = new Node();//新建一个前缀树
}
正常情况下,来了个字符串,填fail之前,先往里面正常构建前缀树:
//成员方法,往前缀树中插入字符串s,构建前缀树
public void insert(String s){
//传统前缀树怎么建,你就怎么建,不过就是end和不同罢了,pass不要,fail先不管,usedEnd不管
char[] str = s.toCharArray();
Node cur = root;//从root开始查,有就复用,没有新建
int path = 0;
for (int i = 0; i < str.length; i++) {
path = str[i] - 'a';
if (cur.nexts[path] == null){
Node node = new Node();
cur.nexts[path] = node;//path方向加字符
}
cur = cur.nexts[path];//沿途往下加
}
//加完end变化
cur.end = s;
}
//外部可以循环加入前缀树
再理解一遍fail指针的含义:它指向谁?以cur的子那个字符c为结尾做后缀串str,fail指针指向其他以str为最长的前缀串的字符串
有点绕
以cur的子那个字符c为结尾做后缀串str,
fail指针指向:
其他matches中以str为最长的前缀串的字符串
咱们再画一个图理解这句话:
有这么几个字符串,默认root的fail指向null
第一层所有节点的fail指向root
然后咱们按照上面的规则填写剩下节点的fail,用BFS遍历前缀树的每一个节点cur,填写cur的子的fail指针
规则就是上面的规则:
沿着cur的fail指针搜索一圈,只要能和cur的子的字符方向契合,就让cur的子的fail指向这个节点x的子。
一圈找下来如果没找到,发现最后一个fail指向了null,那不好意思,请你cur的子的fail去指向head吧!
第一层给第二层的节点填,从左往右BFS遍历看每个节点
看b的fail,因为父a的fail指向root,root确实有b方向的路,所以b.fail指向a.fail的子b
看c的fail,因为父b的fail指向root,root确实有c方向的路,所以c.fail指向b.fail的子c
看d的fail,因为父c的fail指向root,root确实有d方向的路,所以d.fail指向c.fail的子d
看e的fail,因为父d的fail指向root,root确实有e方向的路,所以e.fail指向d.fail的子e
所以这些fail指向的都是谁呢?不就是以当前字符c结尾的后缀串str,str又是别的字符串中最长的前缀串,fail指向了这个字符串的c字符
看图中第2个fail,对于bc来说,c字符结尾的后缀串可能有:str=c,bc
别的字符串以str为最长的前缀串,那个别的字符串是谁?可不就是cde吗?
因为别的字符串,没有以bc开头的啥字符串,别的字符串,就只有一个c开头的,c=str做前缀(也是最长的前缀了),就cde
这里你要搞清楚,是找别的,str能在其中做一个最长的前缀串的那个字符串哦!
懂不懂?
还没动,看下面,咱们把所有节点的fail填完
所以这些fail指向的都是谁呢?不就是以当前字符c结尾的后缀串str,str又是别的字符串中最长的前缀串,fail指向了这个字符串的c字符
看图中蓝色的那个个fail,
对于abccde来说,e字符结尾的后缀串可能有:str=e,de,cde,bcde,abced
别的字符串以str为最长的前缀串,那个别的字符串是谁?可不就是bcde吗?
虽然别的字符串有这些:e,de,cde,bcde
但是以str为最长前缀串的,bcde是最长的,所以这个别的字符串就是bcde
因此fail就指向了bcde
理解了吗?
当前串abcde的e的fail指向哪里?
str是当前e字符结尾的后缀串,找其他的字符串中,以str为最长前缀的那个字符串,fail指向这个字符串
具体指向str的末尾就行。
总之就是指向最长前缀为str的这个字符串就行!
下面图也是这样:这个其他的字符串,有cde,de,e,bcdetfk
但是当前abcde以e结尾的后缀串str最长能做别的字符串前缀的
必定是str=bcde最长,
因此别的那个字符串应该是bcdetfk,
因此当前字符串abcde的e字符的fail,应该指向这个bcdetfk,具体就是指向str的结尾字符e呗!
这下你应该能理解了吧,道理很透彻的。
好,我们来手撕构建fail指针的代码:
//有了前缀树,调用build,增加好fail指针,BFS遍历root
public void buildFail(){
//认为在前缀树中加root和第一层,其实不用额外搞了,因为正常搞,自然第一次必须指向root的
//那直接把BFS的框架打出来,再改
Queue<Node> queue = new LinkedList<>();
queue.add(root);
Node cur = null;
Node curFail = null;//cur.fail
while (!queue.isEmpty()){
cur = queue.poll();//弹出开始操作cur,然后判断cur的子,不为null,放入queue
for (int path = 0; path < 26; path++) {//26个字符a--z方向的子
if (cur.nexts[path] != null){
//看cur的fail,决定cur的子的fail指向
cur.nexts[path].fail = root;//如果没法加,最终它是要指向root的
curFail = cur.fail;
//看curFail有path方向的子吗???
while (curFail != null){
//一圈走下去,看看有没有path方向的路
if (curFail.nexts[path] != null) {
cur.nexts[path].fail = curFail.nexts[path];
break;//找到了就连接,否则持续找
}
//找不到,curFail继续跳
curFail = curFail.fail;//知道它指向null停止
}
//cur.nexts[path] != null,BFS将其加入queue
queue.add(cur.nexts[path]);
}
}
}
}
整个代码,干的就是填fail的事情。BFS为宏观调度,内部来到cur,就给它子填fail,看cur的fail指向的path的情况决定
不行就只能填root了。
fail指针的用处:加速收集答案,当前字符串path没法跟paper继续匹配了,跳到fail指针那接着匹配,舍弃当前后缀str在别的字符串那做前缀的那段搜索
举个例子:
paper=abcdtks
matches=
abcde
bcdf
cdtks
请问哪些matches字符串能与paper匹配上?
(1)建立matches的前缀树root;
(2)用BFS把root的所有节点的fail指针填写好
(3)从paper的i=0位置开始匹配前缀树,准备收集答案
(4)如果发现paper的i位置字符,无法跟root前缀树中的cur字符匹配了,说明前缀树root中目前这条path字符串【从matches中建出来的其中一个字符串】不可能在paper中。下一次一定是对比matches的其他字符串了
(5)直接跳到cur的fail指针指向的字符x,继续与paper后续i+1位置匹配,看看是否与x相同?
所以fail帮助我们干了啥?最开始文章也说了,目的就是跳过当前path后缀串str,str又是其他字符的前缀串的这一段的重复匹配。
文章开头咱们就瞅过例子的,我现在再说一遍例子
(1)建立matches的前缀树root;
(2)用BFS把root的所有节点的fail指针填写好
e的父一圈fail最后指向null,没有找到去向e的路,所以e的fail指向了root
其他的指向root值得fail也是同理的
(3)从paper的i=0位置开始匹配前缀树,准备收集答案
发现paper的0123四个位置的abcd字符,可以在前缀树root中一路匹配下去
当i=3时,root中cur字符为d,paper的i+1位置字符为t,但是root中cur下一个字符是e,匹配不了了
宣告:root总path=abcde这个字符串,不可能在paper中出现!
(4)下一次一定是对比matches的其他字符串了
(5)直接跳到cur的fail指针指向的字符d,继续与paper后续i+1位置匹配,看看是否与paper的t字符相同?
这其实就是避免了再拿bcdf又去跟paper从头对比 的重复过程,达到加速的目的!
发现cur的子f并不等于paper的t字符,没法匹配
宣告:root总path=bcdf这个字符串,不可能在paper中出现!
(4)下一次一定是对比matches的其他字符串了
(5)直接跳到cur的fail指针指向的字符d,继续与paper后续i+1位置匹配,看看是否与paper的t字符相同?
这其实就是避免了再拿cdftks又去跟paper从头对比的重复过程,达到加速的目的!
发现cur的下一个字符t确实与paper的t匹配上了,继续让i++,cur去子t那继续对比
发现paper的ks和root的ks也都匹配上了
注意前缀树中,s字符节点的end=1,代表matches中有一个bcdtks字符串,因此它在paper中,作为答案,收集起来!!!
但是你注意,当初我们在第二个宣告失败的字符串那,我们并没有从头再去匹配bcdtks
而是借助bcdf的d的fail指针,跳过了可能以cd为前缀的其他字符串的这个前缀的重复对比!
这就是AC自动机的fail指针的加速原理!!
牛还是不牛?
牛逼吧!
怎么收集答案:遍历前缀树每个节点cur,每次沿着cur的fail指针们走一圈,哪个字符串是一个结尾,就要了它
还需要在前缀树中添加end字符串,endUse标记
上面的案例中,
收集答案时:s字符节点的end=1,代表matches中有一个bcdtks字符串,因此它在paper中,作为答案,收集起来!!!
正常情况下,end=1代表s就是一个结尾
但是对于本题来说,咱们要整个前缀树这条路径上的字符串,为了避免从root节点从新遍历path组合一次为bcdtks
咱们当初构建前缀树的时候,让end就直接记住bcdtks这个字符串,方便现在收集答案
另外,如果这个答案收集过了,用endUse来标记一下,以后这个答案不需再来收一遍了
故AC自动机节点是这样的:
//AC自动机的节点
public static class Node{
public Node fail;//fail指针
public Node[] nexts;//所有的0--25代表a--z的方向
public String end;//普通的end是1,代表来了1个字符串,end结尾
public boolean usedEnd;//收集过end这个答案了
public Node(){
fail = null;//默认是null
nexts = new Node[26];//a--z全null
end = null;//默认是不为结尾
usedEnd = false;//没收集过答案
}
}
怎么收集答案:伴随fail匹配的过程中,来到前缀树root的每个节点cur,沿着cur的fail指针们走一圈,哪个字符串是一个结尾end,就要了它
(1)随着paper的i位置匹配前缀树的节点cur,图中cur能走下面就走下面,不能走下面就去cur.fail了。
(2)沿着cur的fail指针们走一圈,哪个字符串是一个结尾end,就要了它,如果图中遇到endUse=true,说明这条路,咱们收集过答案了,break就行
举个例子:
paper=abck
matches=
abck
bct
st
tf
从paper的i=0位置开始匹配,paper的字符是a,cur=root,cur之子a,能匹配,则沿着cur的fail走一圈,发现没有一个end是有用的,不管
从paper的i=1位置开始匹配,paper的字符是b,cur=a,cur之子b,能匹配,则沿着cur的fail走一圈,发现没有一个end是有用的,不管
从paper的i=2位置开始匹配,paper的字符是c,cur=b,cur之子c,能匹配,则沿着cur的fail走一圈,发现没有一个end是有用的,不管
从paper的i=3位置开始匹配,paper的字符是t,cur=c,cur之子k,无法匹配,cur要去cur.fail,看下图cur去了另一个c,则沿着cur的fail走一圈,发现没有一个end是有用的,不管
从paper的i=3位置开始匹配,paper的字符是t,cur=c,cur之子f,无法匹配,cur要去cur.fail,看下图cur去了root,则沿着cur的fail走一圈,发现没有一个end是有用的,不管
从paper的i=3位置开始匹配,paper的字符是t,cur=root,cur之子t,能匹配,则沿着cur的fail走一圈,发现没有一个end是有用的,不管
从paper的i=4位置开始匹配,paper的字符是f,cur=t,cur之子f,能匹配,则沿着cur的fail走一圈,巧了,f字符就是一个结尾,所以当前前缀树中tf就是一个答案。
此时i=5越界,答案搜索完成!
fail就是这么用的,答案就是这么收集的。
好,我们手撕收集答案的代码:
//外围调度,收集答案,一次性搞定
public List<String> containWordsGetAnswer(String paper){
//请问matches有哪些字符串,在paper中呢????
List<String> ans = new ArrayList<>();
//反正挨个从paper的位置匹配字符串,来到root中每一个cur,沿着cur一圈走
//发现cur走不动,去cur的fail指向的字符
Node cur = root;
Node follow = null;//配合cur的fail走一圈,收集答案用的
char[] str = paper.toCharArray();
int path = 0;
for (int i = 0; i < str.length; i++) {
path = str[i] - 'a';
//进来直接看cur有path方向的路吗?没有,而且cur还有fail就去fail
//注意,如果cur本身就调到了root,说明要重新去找别的字符串,就别继续跳到fail=null的地方哦
while (cur.nexts[path] == null && cur != root) cur = cur.fail;//这就是加速点
//如果有path方向的路,就去,否则cur只能去root重新匹配别的字符串了
//经过上面while判断,看看cur能去path吗
cur = cur.nexts[path] != null ? cur.nexts[path] : root;//确实有就去,没有只能会root
//每次来到一个root中的cur,沿cur的fail走一圈,没收集过就收集
follow = cur;
while (follow != root){
//圈尾部就是root
if (follow.usedEnd) break;//这些节点搞过答案就算了
//否则只要是end真的记录结果,就一定出现在paper中,不然fail不会引导我们匹配过来的
if (follow.end != null){
ans.add(follow.end);
follow.usedEnd = true;//标记收过答案了哦!
}
follow = follow.fail;//沿途走一圈
}
}
return ans;
}
外部如何调用AC自动机?
综合AC自动机类:
//AC自动机数据结构:
public static class ACAutoMachine{
//成员变量就一个前缀树
public Node root;
public ACAutoMachine(){
root = new Node();//新建一个前缀树
}
//成员方法,往前缀树中插入字符串s,构建前缀树
public void insert(String s){
//传统前缀树怎么建,你就怎么建,不过就是end和不同罢了,pass不要,fail先不管,usedEnd不管
char[] str = s.toCharArray();
Node cur = root;//从root开始查,有就复用,没有新建
int path = 0;
for (int i = 0; i < str.length; i++) {
path = str[i] - 'a';
if (cur.nexts[path] == null){
Node node = new Node();
cur.nexts[path] = node;//path方向加字符
}
cur = cur.nexts[path];//沿途往下加
}
//加完end变化
cur.end = s;
}
//外部可以循环加入前缀树
//有了前缀树,调用build,增加好fail指针,BFS遍历root
public void buildFail(){
//认为在前缀树中加root和第一层,其实不用额外搞了,因为正常搞,自然第一次必须指向root的
//那直接把BFS的框架打出来,再改
Queue<Node> queue = new LinkedList<>();
queue.add(root);
Node cur = null;
Node curFail = null;//cur.fail
while (!queue.isEmpty()){
cur = queue.poll();//弹出开始操作cur,然后判断cur的子,不为null,放入queue
for (int path = 0; path < 26; path++) {//26个字符a--z方向的子
if (cur.nexts[path] != null){
//看cur的fail,决定cur的子的fail指向
cur.nexts[path].fail = root;//如果没法加,最终它是要指向root的
curFail = cur.fail;
//看curFail有path方向的子吗???
while (curFail != null){
//一圈走下去,看看有没有path方向的路
if (curFail.nexts[path] != null) {
cur.nexts[path].fail = curFail.nexts[path];
break;//找到了就连接,否则持续找
}
//找不到,curFail继续跳
curFail = curFail.fail;//知道它指向null停止
}
//cur.nexts[path] != null,BFS将其加入queue
queue.add(cur.nexts[path]);
}
}
}
}
//外围调度,收集答案,一次性搞定
public List<String> containWordsGetAnswer(String paper){
//请问matches有哪些字符串,在paper中呢????
List<String> ans = new ArrayList<>();
//反正挨个从paper的位置匹配字符串,来到root中每一个cur,沿着cur一圈走
//发现cur走不动,去cur的fail指向的字符
Node cur = root;
Node follow = null;//配合cur的fail走一圈,收集答案用的
char[] str = paper.toCharArray();
int path = 0;
for (int i = 0; i < str.length; i++) {
path = str[i] - 'a';
//进来直接看cur有path方向的路吗?没有,而且cur还有fail就去fail
//注意,如果cur本身就调到了root,说明要重新去找别的字符串,就别继续跳到fail=null的地方哦
while (cur.nexts[path] == null && cur != root) cur = cur.fail;//这就是加速点
//如果有path方向的路,就去,否则cur只能去root重新匹配别的字符串了
//经过上面while判断,看看cur能去path吗
cur = cur.nexts[path] != null ? cur.nexts[path] : root;//确实有就去,没有只能会root
//每次来到一个root中的cur,沿cur的fail走一圈,没收集过就收集
follow = cur;
while (follow != root){
//圈尾部就是root
if (follow.usedEnd) break;//这些节点搞过答案就算了
//否则只要是end真的记录结果,就一定出现在paper中,不然fail不会引导我们匹配过来的
if (follow.end != null){
ans.add(follow.end);
follow.usedEnd = true;//标记收过答案了哦!
}
follow = follow.fail;//沿途走一圈
}
}
return ans;
}
}
(1)拿到paper和matches数组
(2)matches全部加入自动机,然后构建前缀树
(3)然后自动机自动构建fail指针
(4)然后直接查有哪些matches字符串在paper中呢?返回结果即可
//外部如何用?
public static List<String> acMatchString(String paper, String[] matches){
if (paper.compareTo("") == 0 || paper.length() == 0 || matches == null ||
matches.length == 0) return null;
ACAutoMachine acAutoMachine = new ACAutoMachine();
//matches全部加入自动机,然后构建前缀树
for (int i = 0; i < matches.length; i++) {
acAutoMachine.insert(matches[i]);
}
//然后自动机自动构建fail指针
acAutoMachine.buildFail();
//然后直接查有哪些matches字符串在paper中呢?
List<String> ans = acAutoMachine.containWordsGetAnswer(paper);
return ans;
}
测试一把:
public static void test(){
String paper = "abctks";
String[] matches = {
"abce",
"bcd",
"ctks"
};
List<String> ans = acMatchString(paper, matches);
for(String s:ans) System.out.print(s +" ");
}
public static void main(String[] args) {
test();
}
显然,结果中就一个ctks 在paper中,牛逼!
ctks
AC自动机看起来复杂,实际上本质和KMP一样,借助额外空间加速换时间
AC自动机就是利用前缀树的fail指针,绕过已经匹配过的后缀前str,这个str在matches中其他的字符串里面做最长的前缀,这个最长的前缀str不要再跟paper重复对比了,这就是加速的本质!!!
总结
提示:重要经验:
1)AC自动机就是利用前缀树的fail指针,绕过已经匹配过的后缀前str,这个str在matches中其他的字符串里面做最长的前缀,这个最长的前缀str不要再跟paper重复对比了,这就是加速的本质!!!
2)你必须熟悉前缀树!构建前缀树的步骤,pass,end的含义,本题中fail,end,usedEnd的含义,就能做文章匹配了。
3)笔试求AC,可以不考虑空间复杂度,但是面试既要考虑时间复杂度最优,也要考虑空间复杂度最优。