词法分析(6):Boss战(中)——从NFA到DFA

0 阅读1分钟

本文是本人撰写的编译原理讲义。

本系列讲义适用于:被强迫学习编译原理前端,或者希望弄明白如何做科研的人

1。Boss战二回目

对着主角迎面挥来的就是Boss的大猪蹄子S->A | C | K。

主角起手挥刀,把S切成猫爪那样的三个分叉。之后沿用DFA的思路,依次料理ABC,顺势躲开了Boss的平A K -> if T_KEYIF | if B。

至此,手上的NFA已经变成如下的模样:

这里实际上藏着一个问题:if会被同时识别到两个终态中。在上一章中提到过,可以为两个终态设置不同的优先级从而避免冲突。

就在此时,Boss发出了怒吼,开始了二阶段,向着主角发射出厚厚的弹幕:“if”“main”"654854"....

"if"字符串在碰到主角手上的NFA之后,从S出发,竟好像充能一般,同时让ACK三个状态都亮了起来,随后是“T_IF”和“B”一起亮起来,一直到最后的接受态,亮光才逐渐熄灭。

就在主角忙着用NFA抵挡这些符号串的时候,令他恐惧的事情发生了:NFA消化弹幕的速度,逐渐跟不上弹幕到达的速度了。

手上的NFA光越来越亮,终于还是把主角的眼睛刺得睁不开眼。

Boss一个偷袭,把主角踢出了战斗。

2。NFA的尽头是DFA

失败了失败了失败了失败了失败了失败了失败了失败了失败了失败了失败了失败了失败了失败了失败了失败了失败了失败了失败了失败了失败了失败了失败了失败了失败了失败了失败了失败了失败了失败了失败了失败了失败了失败了失败了失败了失败了失败了失败了失败了失败了。。。

在主角忿忿不平好一阵之后,终于重整旗鼓复盘起了这第二场Boss战。

说到底就是NFA需要同时处理多条支路的计算,负担太重!

如果可以让NFA像DFA那样,一个激励只会触发一个后继状态,那么绝对能大幅提高NFA的计算。

但我们费尽心力从DFA升级到NFA,就是因为它更灵活、更能处理DFA无法应对的规则,现在却因为NFA的“灵活性”代价太高,又要回头再把它变回一台DFA,这一切意义何在??

心情烦躁的主角越想越乱,决定到处走走,偶然看到公园有个画家在画画。

从来没有绘画天赋的主角,一直以为整幅画就像是打印机那里打印出来一样,直接一步到位,这个位置该是什么颜色直接就用画笔涂上什么颜色。

直到看到这个画家才知道,真正的绘画分为三步:线稿,粗笔氛围绘制,细笔勾勒细节。也就是说,画家一开始会先用几根线条画出轮廓;随后用大号画笔,铺上模糊的氛围色块;最后,才换上精细的小笔,对局部进行勾勒和修饰,让画面变得清晰、锐利。

所以说,DFA → NFA → DFA,这不是一次毫无意义的绕路,这是一次从粗到精的创作过程!

  • NFA,就是那张充满可能性、但细节模糊的草稿。 它的规则灵活、书写简单,能让我们轻松地、快速地勾勒出我们想要识别的语言的“轮廓”以及整体的印象氛围。

  • DFA,就是那幅细节清晰、颜色精准的完稿。 它的路径唯一,执行高效,是最终可以“装裱展出”(在计算机上运行)的成品。

就像“贫穷不是社会主义”,那面对1980一穷二白的瓷器国,要怎么才能建成社会主义?先不管白猫黑猫,充分发展生产力才是正道。我们要相信后人的智慧,相信他们会让社会走到正确的道路上!

但是,社会制度建设、画画这两个事物都不是NFA。类比能给人勇气,但并不能给人以直接有用的启示。我凭什么相信NFA就一定可以转换成DFA呢?

回答这个问题需要把握住DFA的核心点:状态数量有限,这些状态代表了什么,以及他们之间的转移关系。

1.我们升级DFA的一个重点就是增加了ε激励,这尽管一个状态可以连接(产生)多个新的后继状态,但Q中元素的数量终将有限。

2. 下面用一个比喻来说明如何通过切换视角从而合并不同的叠加态。某门课中的课程地图可以用一个链条状的DFA来表示,比如编译原理可以表示为:词法分析、语法分析...而数据结构可以表示为:线性表、链表...现在让你表示其中一门课的学习状态那是容易的。那请问,你在这个学期中,如何表示这两门课在同一学期下的教学进度状态?假设每个知识点所需的课时是一样的,那你可以把词法分析和线性表捆绑成一个整体。比如:{词法分析,线性表}、{语法分析,链表}...甚至更进一步,直接给他们换个名字:第一周,第二周...你看,虽然原本两条并行的路线互不相干,但只要我们从宏观上进行视觉切换,就可以把他们联系在一起,从而消除分裂的状态。

3.在切换视角之后,把Q中不同的状态捆在一起,根据前面关于 的值域的讨论可知,最多不会超过Q的幂集的元素个数,因此这些捆绑的同样也是有限个的。

当我们确定,这个NFA中并行路线中的状态可以归并成一个集体,并且这些集体的个数是有限的,基本上就可以断定对应的DFA必定存在。

3。NFA确定化

我们剩下要做的证明工作,就是把这一个DFA给确实构造出来,毕竟眼见为实嘛。

在已知NFA求对应DFA的前提下,如果把DFA看作一张地图,那么从NFA构建DFA的过程就可以看作如下过程1.从一个起点开始;2.不断探索周边有哪些路可以走,3.不断循环,一直到把所有可以走的路都探索出来。

另一方面,既然已经知道切换视角后的总状态数量总是有限的,而且对应的DFA处理的实义符号必然和NFA一致,那么我们就可以进一步细化这个过程:

1,从初始状态开始,记当前状态为q,初始状态为q₀;

2,找出来哪些状态和q₀通过ε相连,把他们看作一伙,并把这个小团伙记作T0,作为地图探索的起点;

3,下面,我们通过循环,求T0集合在分别遇到Σ中的每个符号后,会变化出什么新集合,也就是探索地图中不同方向分别是否有路,会走向什么新的地点:

对T0中的每个元素,施加Σ中的首个符号(假定为a)作为激励:

如果T0中的某些状态处理不了a,或者说没有定义这个状态遇到a该跳转到哪里,那么就当这个状态死了,不用处理;

如果T0中的某个状态S能处理a,那么把 的结果加入到 的临时集合Ttemp中;

找出来哪些状态和Ttemp中的状态是一伙的,统统加入集合Ttemp中;

如果这个Ttemp和之前出现的集合都不重复,说明我们发现了新的地点,那么就把这个地点命名为T1,并加入到待探索的队列中。

在T0中所有的状态都处理完符号a之后,取出Σ中的下一个符号b,重新执行上述操作。

4,对于待探索的队列中的每个集合Ti,反复进行上述循环,从而求出他们在分别遇到Σ中的每个符号后,会变化出什么新集合。

5,当没有新状态集合生成的时候,称为收敛。

6,最后再把状态集合中的初态和接受态全部找出来,就可以把DFA中的五元组统统找到,算法结束。

注意到,上述过程中有两个很重要的操作。按照编程的习惯,对于重复使用的过程,有必要把它定义成函数从而方便调用。

1.找出来哪些状态和状态S通过ε相连,把他们看作一伙。

如果把还没有补全的状态集合看做一个不完整的还在打开状态中的包裹,那么当我们把通过ε相连的状态加入集合中之后,这个包裹就严实了,我们称为闭包(closure)。如此补全的操作,我们称为求集合T的ε闭包操作,用ε-closure(T)表示。

function ε_closure(T):
    // T 是一个NFA状态的集合 (即一个临时DFA状态)

    // 1. 初始化闭包集合,它一开始就包含了输入集合T的所有成员
    closureSet = T;

    // 2. 开始一个可以无限循环的流程
    while (true):
        // 3. 在每一轮循环开始时,记录下当前集合的大小
        previous_size = closureSet.size();

        // 4. 创建一个临时集合,用来存放本轮新发现的状态
        newly_discovered_states = new Set();

        // 5. 遍历当前闭包集合中的每一个状态s
        for each state s in closureSet:
            // 6. 查找 s 所有能通过ε“瞬移”到达的后继状态
            epsilon_next_states = NFA_tran[s][ε];    // NFA_tran是存储了状态转移函数的数据结构
            // 7. 将这些后继状态,全部加入到“新发现”集合中
            newly_discovered_states.add(all states from epsilon_next_states);
        
        // 8. 将本轮所有新发现的状态,并入到我们的主集合中
        closureSet.add(all states from newly_discovered_states);

        // 9. 关键一步:检查是否收敛
        //    如果合并后,集合的大小没有变化,说明没有新成员加入
        if (newly_discovered_states.size() == 0):
            // 10. 雪球不再变大,已经达到不动点,跳出循环
            break;

    // 11. 返回最终的完整闭包集合
    return closureSet;

2.找出一个状态集合中的状态在接受了特定符号的激励后,所得到的直接后继集合。这样的操作我们用move(T,a)表示。

function move(T, i):
    // T 是一个NFA状态的集合 (即一个DFA状态)
    // i 是一个输入符号

    // 1. 创建一个空集合,用于存放所有移动后可以到达的新状态
    resultSet = new Set();

    // 2. 遍历当前“超级状态”T中的每一个NFA“成员状态”s
    for each state s in T:
        // 3. 查找 s 在遇到符号 i 后,能直接到达的后继状态集
        //    (我们假设NFA的转移规则存储在 NFA.tran 中)
        nextStates = NFA_tran[s][i] ;    // NFA_tran是存储了状态转移函数的数据结构
        
        // 4. 将所有找到的后继状态,都加入到结果集中
        //    (集合会自动处理重复)
        if (nextStates != Error)
            resultSet.add(all states from nextStates);

    // 5. 返回最终计算出的状态集合
    return resultSet;

最后,由于这个过程就像是开盒薛定谔的猫一样,不断地把NFA中的不确定性消灭,所以我们把这个过程称为NFA的确定化。又由于我们不停地构造了一系列的子集,我们也把其称为“子集构造法”。

另外需要强调的一点是,NFA的确定化是站在DFA的角度,通过不断去穷尽所有可能的单符号激励从而进行地图的探索。如果最开始的NFA是基于扩展的转移函数进行构建,激励不是单符号或者空串而是一个任意长度的串,那么是难以直接应用上述过程的。我们需要在激励串的长度大于一的情况中,引入多个临时状态,从而把基于扩展转移函数的NFA转化为更方便进行确定化的基本NFA。

思路有了,就可以进一步写出NFA确定化的伪代码。

// --- 准备工作 ---
// D_tran 用来存储我们最终的DFA状态转移表
DFA_tran = new Map(); 
// Q 是“待办清单”(用队列实现)
Q = new Queue();
// S_DFA 是“地图册”,存放所有已发现的DFA状态
S_DFA = new Set();
Global NFA_tran;  //NFA的状态转移函数

// --- 算法开始 ---
// 1. 计算起点

T0 = ε-closure({NFA.q₀});   // q₀是NFA的起始状态
Q.push(T0);
S_DFA.add(T0);

// 2. 主循环:探索新大陆
while (Q.NotEmpty()) {
    T = Q.pop(); // 取出一个待探索状态
    
    for each symbol i in Σ {
        T_temp = move(T, i);
        T_next = ε-closure(T_temp);
        
        // 在我们的DFA地图上,记录计算出来的目的地和路线。注意,这个目的地可能是之前已发现的目标
        DFA_tran[T][i] = T_next;
        
        if (T_next.NotEmpty() && S_DFA.NotHave(T_next)) {
            // 发现新状态
            S_DFA.add(T_next);
            Q.push(T_next);
        }
    }
}

// --- 收尾工作 ---  //标记状态集合中的接受态
// 3. 标记
FinalStates_DFA = new Set();
for each state D in S_DFA {
    // 检查D这个“包裹”里,是否含有任何一个NFA的“终点站”
    if (D ∩ F_NFA is not empty) { // F_NFA是NFA的接收状态集
        FinalStates_DFA.add(D);
    }
}

最终成果:我们得到了DFA的状态集Q:S_DFA,状态转移函数(转移表)δ:D_tran,起始状态q₀:T0,和接收状态集F:FinalStates_DFA

4。实战练习

首先,为了方便,把最上面的图中所有状态换一下元,全部换成数字,并且在if中间插入新的中间状态,得到初始NFA:

从初始状态0开始,求出其ε闭包,得到T0={0 1 5 8},加入队列,作为初始元素。

从队列中取出首个元素,分别施加a-zA-Z0-9等多个符号的激励,发现会分裂出不一样的几个结果: 1. 遇到除了i以外的字母和下划线,{0 1 5 8}中只有5能处理,跳转到6。因此move(T0,[a-hj-zA-Z_])={6},之后求{6}的ε闭包,得到{6 7},之前从未出现过,加入到队列中。当前队列为{{6 7}}。

2. 遇到数字,{0 1 5 8}中只有8能处理,跳转到9。因此move(T0,[0-9])={9},之后求{9}的ε闭包,得到{9 10},之前从未出现过,加入到队列中。当前队列为{{6 7}{9 10}}。

3.遇到字母i,{0 1 5 8}中1 和5能处理,分别跳转到{2 6}。因此move(T0,'i')={2 6},之后求{2 6}的ε闭包,得到{2 6 7},之前从未出现过,加入到队列中。当前队列为{{6 7}{9 10}{2 6 7}}。

重复上述的操作,由此得到下表:

[a-eg-hj-zA-Z_]

i

f

[0-9]

是否包含终态

0 1 5 8

6 7

2 6 7

6 7

9 10

9 10

9 10

6 7

6 7

6 7

6 7

6 7

2 6 7

6 7

6 7

3 4 6 7

6 7

3 4 6 7

6 7

6 7

6 7

6 7

然后我们给这五个状态重新命名为T0-T4,得到的DFA如下:

有时候看着这个图真的会想起生命之树

PS: 可以看到在最后一个状态集合中包含了两个接受态,意味着NFA同时进入了两个接受态。正如上一章所说,通过设置优先级,可以确保两个状态只生效一个从而避免冲突。

小结

至此,我们终于走完了一趟看似“绕路”的旅程。

我们之所以要先从简单的DFA,“升级”到更灵活、但难以直接执行的NFA,是因为用NFA来描述复杂的词法规则(特别是从正规式转换而来时),要远比直接构造一个等价的DFA,来得更简单、更符合人类的直觉。NFA是更好的“草稿”。

而我们又必须将NFA“退化”回DFA,是因为DFA的执行模型,才是与我们计算机那经典模型的"单线程"、"确定性"的底层逻辑完全契合的。DFA是更好的“完稿”。

这就好比,我们不能一下子建成完美的社会主义,所以要先“不管白猫黑猫”,极大地发展生产力(NFA阶段,拥抱灵活性和可能性),再相信后人的智慧,将社会引导和规范到正确的道路上(DFA阶段,回归确定性和高效性)。

姑且是得到了一个能击败Boss的思路了。但经历了两次失败的主角会马上再次挑战Boss吗?我们下回再讲。