由于周末去了一趟泉州丰泽,今天开始了寝室隔离十四天的梦幻生活,在下午四点半下床之后,我觉得确实有写点什么东西的必要了,今晚就来手写一个词法分析器吧,这是编译原理课程的实验一,这篇文章是几点发的,那我就是几点放弃做完的,现在是14号晚上8点43,一会11点半还要去做个核酸,希望能在两点之前弄完。
实验要求
编写一个词法分析器。
1) 能够识别关键字:
if
while
do
break
float
true
false
int
char
bool
float
(其中,int
、char
、bool
、float
在产生式中为basic
)
2)能够识别标识符id
和数值num
3)能够识别专用符号:=
+
-
*
/
<
<=
>
>=
==
!=
;
,
(
)
[
]
{
}
4)忽略空白
5)考虑注释,注释由/*
*/
包围
解决思路
首先第一步往往是查阅资料、选择思路。
在这篇文章中,作者使用了goto语句以及大量的else if来完成,但他给出了本问题的DFA(后续用得上):
粗略查看了文中的代码后,他使用了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树:
#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方法实现。
存放好了数据之后,该怎么处理呢?
对KMP算法比较熟悉的同学都知道,KMP算法是为了最大程度减少不必要的回退,并且确保了在主串上的指针不回退,所以,仿照KMP算法的做法,我们也只能让比较指针在Trie树上移动,不能在主串上回退。
学习KMP算法时,我们可以用一种思路去想,“假如在这个地方失配了,那么子串上的指针应该回退到哪里?”,我们也用这个思路来审视我们的Trie树,假如在节点2失配了,那Trie树下一个应该搜索哪里?
肉眼分析可以看出,它应该去搜索3,因为我们可以确保至少有一个b和他匹配了。我们再来回顾一下next数组的意义,在KMP算法中,next数组里的值存放着某一位置非平凡最大公共前后缀长度,或者说发生失配时下一个搜索的位置,或者说你移动到这里来开始搜保证你前面全部匹配。那么,在AC自动机里同样可以这么理解,它是你下一个搜索的位置,不过这个位置在另一个串上,这样就可以实现多个串的搜索,如此发现,其实,KMP算法的普通版,是AC自动机的一个特例:
从特例推到到一般,就可以得出计算Trie树next数组的方法啦。
前面我们也提到了,next数组的计算是一种类动态规划的办法,每一个值在计算的过程中,使用到了先前的值,在Trie树中也是如此,试着分析下图:
所以,我们寻求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,使它一步到位。
由于边的增加,使得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图。
太晚了,写不动了,今天就到这吧。。
后续将尝试把上述两个高级数据结构运用到编译原理实验中。