手写一个词法分析器(-1) | AC自动机,Trie图

本文介绍了作者在寝室隔离期间着手编写词法分析器的实验,选择了不使用goto而采用子函数的实现方式。作者探讨了AC自动机的概念,它是Trie树和KMP算法的结合,能实现一次匹配多个字符串。文章详细阐述了AC自动机的构建过程,并提及了优化方法,如Trie图的构建。作者计划将这些高级数据结构应用于编译原理的实验中。
摘要由CSDN通过智能技术生成

由于周末去了一趟泉州丰泽,今天开始了寝室隔离十四天的梦幻生活,在下午四点半下床之后,我觉得确实有写点什么东西的必要了,今晚就来手写一个词法分析器吧,这是编译原理课程的实验一,这篇文章是几点发的,那我就是几点放弃做完的,现在是14号晚上8点43,一会11点半还要去做个核酸,希望能在两点之前弄完72bfa364b8df3f2977abaec6fa422204.png

实验要求

编写一个词法分析器。

1) 能够识别关键字:

if while do break float true false int char bool float (其中,intcharboolfloat在产生式中为basic)

2)能够识别标识符id 和数值num

3)能够识别专用符号:
= + - * / < <= > >= == != ; , ( ) [ ] { }

4)忽略空白

5)考虑注释,注释由/* */包围

解决思路

首先第一步往往是查阅资料、选择思路。

09bb31bb4b1e26bd671ef8f9342020ca.png

在这篇文章中,作者使用了goto语句以及大量的else if来完成,但他给出了本问题的DFA(后续用得上):

f6a368f0f9ac2e74c8d2940919ae88e4.png

粗略查看了文中的代码后,他使用了goto进行分支跳转,每个分支结束后又跳回起点重新开始,把C++写出了汇编语言的感觉,我认为goto完全可以用子函数代替,所以不选用goto,选用子函数做大分支,然后依次完成小分支的方式进行。

由于我觉得if-else太多有点low,想要学习怎么用高级一点的结构去实现字符串的匹配,或者说有没有高级一点的数据结构可以用来实现自动机,然后我的脑海里就涌现出一个带有自动机名字的数据结构——AC自动机,一开始觉得这数据结构挺有意思的,难不成能自动AC?于是就继续了解了一下,然后了解完发现时间不早了,还是不熬了,编译原理实验明天再写,今天就来介绍一下AC自动机吧!

至于AC自动机最后能不能用到实验里面,咱也不知道,先当它能吧,本文先偏题了。

AC自动机

AC自动机 = Trie树 + KMP。相较于普通KMP而言,AC自动机能够实现一次多个串匹配,即在一趟里面完成了n趟KMP的功能,这个功能的巨大飞跃是依靠Trie树这一数据结构实现的,Trie树和KMP均在以前的文章介绍过,大家感兴趣的可以回看:

Trie树

KMP算法

这里重新贴一些Trie树和KMP算法的模板:

Trie树:

#include <iostream>


using namespace std;


const int N = 100010;//根据题目而变化
int son[N][26]; // son数组为0表示节点后一字符没有孩子,不为零表示有孩子
int cnt[N]; // cnt数组记录以i节点为结尾的字符串数量
int idx; //全局变量初值为0


void insert(char *s)//插入操作
{
    int p = 0;
    for(int i = 0; s[i]; i++)
    {
        int q = s[i] - 'a';
        if(!son[p][q]) son[p][q] = ++ idx;
        p = son[p][q]; 
    }
    cnt[p] ++;
}


int query(char *s) // 返回查询字符串数量
{
    int p = 0;
    for(int i = 0; s[i]; i++)
    {
        int q = s[i] - 'a';
        if(!son[p][q]) return 0; //查找失败
        p = son[p][q];
    }
    return cnt[p];
}

KMP算法:

//求next[]数组
//边界条件 ne[0] = ne[1] = 0
for(int i = 2, j = 0; i <= m; i++)
{
   // j是当前匹配的位置的指针
  while(j && p[i] != p[j + 1]) j = ne[j]; 
  // j不在起点且 j下一个不匹配了 j就回到next指向的地方,有类动态规划的性质,使用了之前计算出来的状态结果
  if(p[i] == p[j + 1]) j++;
  // 如果匹配,那j就往后移动
  ne[i] = j;
  // 由于求next过程是自己和自己匹配,上述操作使得KMP的j指针停留在了最长匹配位置
  // 也就是1~i字符串后缀和原字符串前缀最长匹配的长度,即next数组值
}
//匹配操作
for(int i = 1, j = 0; i <= n; i++)
{
  while(j && s[i] != p[j + 1]) j = ne[j];
  //如果j指向的下一个不匹配,j回退
  if(s[i] == p[j + 1]) j++;
  //如果j指向的下一个匹配,j前进
  if(j == m)  //指针移动到串尾,满足匹配条件,打印开头下标, 从0开始
  {
            //匹配
            j = ne[j];            //再次继续匹配
   }
}

AC自动机其实是二维的KMP算法,它使用Trie树的数据结构替代了原来的一维数组的数据结构(如下图),使用类似于KMP的算法,结合图的广度优先搜索bfs方法实现。

71ce79efbe5ef78dde8cf2ff620eb255.jpeg

存放好了数据之后,该怎么处理呢?

对KMP算法比较熟悉的同学都知道,KMP算法是为了最大程度减少不必要的回退,并且确保了在主串上的指针不回退,所以,仿照KMP算法的做法,我们也只能让比较指针在Trie树上移动,不能在主串上回退。

学习KMP算法时,我们可以用一种思路去想,“假如在这个地方失配了,那么子串上的指针应该回退到哪里?”,我们也用这个思路来审视我们的Trie树,假如在节点2失配了,那Trie树下一个应该搜索哪里?

f6eb3ed242fda6368e12cc7ba0e11640.jpeg

肉眼分析可以看出,它应该去搜索3,因为我们可以确保至少有一个b和他匹配了。我们再来回顾一下next数组的意义,在KMP算法中,next数组里的值存放着某一位置非平凡最大公共前后缀长度,或者说发生失配时下一个搜索的位置,或者说你移动到这里来开始搜保证你前面全部匹配。那么,在AC自动机里同样可以这么理解,它是你下一个搜索的位置,不过这个位置在另一个串上,这样就可以实现多个串的搜索,如此发现,其实,KMP算法的普通版,是AC自动机的一个特例:

fc03eb7c98ff3963cbe536a39b6c7e52.jpeg

从特例推到到一般,就可以得出计算Trie树next数组的方法啦。

前面我们也提到了,next数组的计算是一种类动态规划的办法,每一个值在计算的过程中,使用到了先前的值,在Trie树中也是如此,试着分析下图:

ef2c229d2d6d3eb835e2a1381f8015df.jpeg

所以,我们寻求next数组的行为是按层去寻找的,这一层的next数组始终依靠上一层的结果得出,于是需要用到树的层序遍历,这是一个广度优先的搜索算法。

//寻求Trie树的next数组,即AC自动机的构建过程
void build()
{
    queue<int> q;
    for(int i = 0; i < 26; i ++)
        if(tr[0][i]) q.push(tr[0][i]); // 相当于多源点广搜
    
    while(q.size())
    {
        int t = q.front();
        q.pop();


        for(int i = 0; i < 26; i ++)
        {
            int child = tr[t][i];
            if(!child) continue; // 没有孩子跳过


            int j = ne[t];
            while(j && !tr[j][i]) j = ne[j]; // 继续跳转
            if(tr[j][i]) j = tr[j][i]; // 如果退出的原因不是j==0


            ne[child] = j;
            q.push(child);
        }
    }
}

这样我们便构建出了一个AC自动机。

优化——Trie图

AC自动机甚至连优化也要去抄KMP的,还记得KMP是怎么优化的吗?

KMP算法为了避免无效的空跳问题,研发出了一个nextval数组进行优化,Trie图也是相同的道理,合理优化next,使它一步到位。

5fcdd516d51738b83bcddaa7809c2bd1.jpeg

由于边的增加,使得tr[i][j]的任意边都指向了对应的节点,所以,原先的Trie树就变成了Trie图,同时,也简化了代码一层循环:

void build()
{
    queue<int> q;
    for(int i = 0; i < 26; i ++)
        if(tr[0][i]) q.push(tr[0][i]);
    
    while(q.size())
    {
        int t = q.front();
        q.pop();


        for(int i = 0; i < 26; i ++)
        {
            int child = tr[t][i];
            // 无论是否存在
            if(!child) tr[t][i] = tr[ne[t]][i]; 
            // 由于已被提前搜索不用担心tr[ne[t]][i]不存在
            else{
                ne[p] = tr[ne[t]][i]; // 存在则修改next
                q.push(p);
            }
        }
    }
}

这样便搭建好了我们的Trie图。

太晚了,写不动了,今天就到这吧。。

后续将尝试把上述两个高级数据结构运用到编译原理实验中。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值