词法分析(7):Boss战(下)——用奥卡姆剃刀洗尽铅华

0 阅读1分钟

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

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

1。又不是不能用!

尽管已经琢磨出Boss战的制胜之道,但经历了两次失败的主角似乎终于知道谨慎两字的含义。

一定要充分研究手中的工具,把它的极致性能发挥出来。不能由于对工具的理解不够深入,而被不知道哪里冒出来的幺蛾子整失败。

回想上一次失败,失败明显来自于手中的NFA优化没有做好。对于一个方法而言,效率低下的点要么就是时间占用太多,俗称拖拉;要么就是空间占用太多,俗称虚胖。

在时间方面,对于生成的DFA而言,由于每处理一个符号串,实际上都要一个一个符号处理。假如符号串的长度是n,那么处理的时间复杂度必然是O(n),也就是说时间的增长速度和y=n这个一次多项式函数在一个量级。由于读取一个字符串的时间是不可能少于O(n)的,因此整个算法的时间复杂度不可能再有优化空间。

在空间方面,算法中占大头的就是DFA_tran这张地图了。如果以二维矩阵表示,对于已有的n阶矩阵,每多一个状态,矩阵就要变成n+1阶,从而多出 (n+1)2−n2=2n+1 个元素。

话是这么说,但看着手上就只有这么几个状态的DFA,主角懒癌瞬间发作:就这么一点状态,折腾一个优化整老半天,最后能省多少空间呀?你说用矩阵占位置,那我用稀疏方式,比如哈希表之类的数据结构表示,空间利用率低的问题不就不存在了?又不是不能用!Boss我收你来了!

罗老师别这样罗老师

2。人不能有贰心:贪心和不甘心

主角来到Boss战的场地附近,一个贼眉贼眼的人便一脸谄媚地凑了上来。

“先生你好,我听说刚刚Boss进行了升级,除了识别明面上的那些单词,还需要您手上的状态机满足一些特殊条件才能过关哦!“

实在不想再输第三次的主角,不禁竖起耳朵屏住呼吸聚精会神听了起来。

留意到主角情绪变化的情报商,继续压低声音说道,“这个东西很小众,我冒着极大的危险,通过上百场挑战的观察才看出来的,很不容易呐!但现在我很饿,又花光钱了,没有力气说了。。”

主角听得心焦,赶紧拿出几块干粮递过去。

情报商看他实在不上道,“哎哟谢谢帅哥的美意,但我这牙不好,得去那边那家店吃点软的。我知道您是准备要打Boss的,时间紧迫,我就不劳您费心了,您看您给我点买吃的钱,我直接把情报给您如何?“

一心只关注情报的主角,根本顾不上那么多了,就把自己的钱袋拿出来,掏出了一个金币。情报商看到里面那么多金币,赶紧说“哎呀公子你人太好了,要是我家里的老老少少也能吃上东西那多好呀。。”主角性急,又掏出了几枚金币。那可是几个人几天的生活费。

情报商一脸谄媚,“感谢感谢,代表全家感谢您咧。那个Boss的攻略方法可刁钻了,如果你手中的状态机的状态集元素数量没有达到2的n次幂,就会因为没有凑整导致机魂不悦被强制判定失败!公子你可一定要注意把状态数量凑整啊!另外我这里有一一个DFA升级模块NEW,通过增加状态和调整布局,可以让DFA迎来新生,大幅度增加通过率哦!”

主角接过装备,发现这些补丁模块一下子就融入到DFA中,很开心:这次一定可以打败Boss向那些亲戚证明我的实力了!再也不用看那群恶邻鄙夷的眼神了!!

走出没几步,主角就听到刚刚的情报商人传来杀猪一样的叫声“我是去回家的,你们要干什么!”

主角刚打算拔刀相助,没想到反倒被帽子叔叔扣住了 ,“这人想持械袭警!拿下!”

……

经过好一阵折腾,主角终于被帽子叔叔判定为诈骗受害者,纳入到污点证人的行列。帽子叔叔一脸痛心疾首“你这好歹也是读过书的 ,怎么还会相信这种奇奇怪怪的情报??就没学过奥卡姆剃刀吗?这么一个人人必打的Boss,怎么可能设置这种奇奇怪怪的通关条件?”

“奥卡姆剃刀?那是什么装备?”

……

从警察局出来,太阳已经下山了,打Boss是不可能了。

今天虽然没有看到那个血红又巨大的“菜”字,但帽子叔叔那一脸嫌弃的表情更刺痛主角:

“那不是装备,你打Boss打迷糊了。那是一句格言:如非必要,勿增实体。”

家中的智能音箱进一步解释:就像是古人解释雷电现象的时候,明明只看到天上的云,就非要想象一个雷公蕾姆(划掉)电母的神仙体系出来,想象是丰富了,但和真相倒是越来越远。所以科学的做法,应该是用尽可能简单的模型去解释问题 ,除非实在解释不过去,再去增设新的机制和理论。

主角总算明白,这根本不是优化后能省多少空间的问题。

如果不彻底掌握优化DFA的方法,永远都无法理解DFA问题的本质,永远都只是个半吊子。

如果对一个问题只是得过且过,不去用奥卡姆剃刀追问其背后的本质,那就不叫真正搞懂这个问题。

DFA如是,以后遇到的问题如是。

痛定思痛,主角终于认真观察起被诈骗犯所谓升级模块NEW所污染的DFA。

是时候拿出奥卡姆剃刀给它剃度剃度了。

3。DFA剃度第一式:六根清净

首先,DFA中哪些状态最没用呢?

参考剃度的一般对象——头部角质蛋白,平常根本不参与日常代谢,只是作为副产物被产出,经常由于过多而开展群切手术。

那在DFA的状态集合中同样一无是处的那肯定是不能到达的状态了。因为不可达意味着这些状态根本不可能起到任何效果。

从上图容易看到,从开始状态出发,无论如何都到达不了状态W。灭之。

注意到,它前面的状态E好像也有点问题。问题在于,这个E不是终态,它也没有其他作为接受态的后继,意味着这个状态本身就不可能有合法的结局。

那只要踏入了状态E,就意味着后面绝对错,那为什么还要把这个错误列出来呢?直接在状态N处进行切割不就好了吗?

状态E,灭之。

总结起来,两种绝对无用的状态分别是:

  1. 从起始状态出发,无法到达的状态

  2. 既不是接受态,也没有办法到达任意接受态的状态

那么问题来了,这样的状态我们除了肉眼观察,能否通过自动化方法处理呢?

在学习有向图中,我们曾经学过利用传递闭包的方法去求得任意两点之间的可达性。

那么方案也就简单了:为了检测第1类无用状态,我们只要在求得原始图的传递闭包后,看看初始状态所在行中有哪些列是0,这些列对应的状态,也就是对应的行列全部删除。

之后,检查每行的元素中,在接受态对应的那些列中是否至少存在一个值为1。若否,则这一行对应的状态也为无用状态。如果它不是初始状态,那么直接删除。如果它就是初始状态,把其他状态删除,只留下它一个孤独终老。

至此,我们完成了备皮操作,可以开展下一步手术了。

4。DFA剃度第二式:移经易髓

主角想起曾经有人说过,人类自身就像是一个屎山代码,如果可以真想把底层代码重构一遍,类似这样。

来自小枣君的想法 www.zhihu.com/pin/1954953…

虽然凯米是在一本正经地搞笑,但他的想法正适用于整理DFA中一地鸡毛的状态们:

我们手上已经有DFA,不可能再完全推翻再重新构建。

同理,身上的静动脉是一个已经过几千年验证的系统,你不可能直接全部推翻重新建设,只能把功能相似的经脉合并到一起。然而,面对一大堆的线,是很难一下子看出来到底谁跟谁功能一致。这个时候就可以先把所有的线都先看作同一个功能,然后用不同的信号去刺激它们的这头,看在线的另外一头影响了什么。对于响应一样的线,就可以划分为一组。

对于我们的DFA状态,我们同样可以一开始先把他们看作是一个集合体。然后先根据他们是否为接受态进行简单的二分类。之后,分别对两个集合中的状态,施加不同的不同的激励,并观察这个集合中的元素是否作出了不同的反应。所谓不同的反应,就是经过相同的激励之后,映射到的集合是否完全一致。如果是,那就把他们提取出来,作为一个新的类别;如果不是,那就继续进行迭代。

但和上面的血管不同的是,DFA中的这些状态是互相作用的。换句话来说,如果原本的一个状态集合发现被分裂了,那么原本指向这个状态集合的那些集合,也因此变成指向了不同的集合,也应该进行分裂。因此,我们需要不停迭代,直到算法无法分开任何状态集合。

举个具体例子:如果在某个时刻中,集合{A B}中的两个元素A和B在接受同一个符号的激励后分别映射到另一个集合{C D}中的C和D,那这个时候我们认为A和B无法区分。

但一旦在后续的操作中发现,C和D的反应并不一致导致他们需要被分割到不同的集合中,那么这个时候原本的集合{A B}再面对相同的激励也就产生了不一样的反应了,从而要把他们也拆分到不同的集合中。

以上,就是在众多课本中流传已久的算法:分割法。

一句话总结,圈子不同,不要硬融。

// 算法:DFA状态最小化 (通过等价性划分——迭代循环版)
// 输入:一台DFA = {Q, Σ, δ, q₀, F}
// 输出:一台最小化的DFA'

function MinimizeDFA(DFA):

    // --- 第一步:最初的划分 ---
    // 根据“是否接受态”,将所有状态分为两大集合。
    P_current = { DFA.F, DFA.Q - DFA.F) }  // P是“划分”的集合,它包含若干个“状态集合”
    P_old = new Set()

    // --- 第二步:迭代分裂,直至稳定 ---
    // 设立一个“循环待办清单”,一开始包含我们的初始划分P
    while (P_current != P_old):

        P_old = P_current
        P_current = new Set() // 准备存放本轮精炼后的新划分
        
        // 对“旧划分”中的每一个“旧集合”G,进行激励测试
        for each group G in P_old:
            
            // 尝试用不同的激励对G进行分裂测试
            // 分裂的参照物,是“上一轮”的完整划分 P_old
            subgroups = split(G, P_old, DFA)
            //如果不存在新划分,那就把原来的G返回。
            
            // 将分裂后的结果(可能是一个,也可能是多个)加入到“新划分”中
            P_current.add(all subgroups)

    // 循环结束,P_current就是最终的、最稳定的划分
    return P_current

终于,经过苦苦的计算,状态N也揪出来了。

至此,DFA中的W E N状态们被奥卡姆剃刀全部剃度干净。

5。Boss战尾声

第二天,主角面对又临场加了for关键字的万法归一Boss完全体,凭着这几天磨炼的DFA构造技巧,轻轻松松就把Boss打趴下了。

周围围观的群众窃窃私语:“这Boss这么简单的啊?我看我上我也行。”

只有整理好装备重新出发的主角知道:挑战重在过程,结果只是赠品罢了。

画外音:

我们完成了正则文法的构建,也对它成功进行了落地,这一切看来都那么美好。那在推广使用的时候又遇到什么问题呢?我们下回再讲。

彩蛋

在研究DFA最小化的过程中,主角被慢悠悠运行的红石电路折磨疯了。这破电路跑这么慢,卡在哪呀??看半天,发现原来的算法运行到后期后居然一直把时间花在迭代一些单元素集合。

???就不能聪明一点吗??

网上查找半天,发现有一个叫Hopcroft的人提出了一个更快的思路:

首先通过接受态与否搞出来两个状态集合,然后用较小的那个作为审查官,并且找出已有状态中哪些在遇到特定激励后会成为它。之后这个审查官挨个检查当前众多的划分集合,如果集合里面只有部分(而不是全部或一个都没有)有前面找出来的这些的状态,说明这个集合中的元素对于这个审查官存在分歧,需要分裂。假定被分裂的集合叫做G,在G被分裂后,分情况讨论:如果原有集合G已经在排队准备作为审查官去试炼其他集合,由于现在已经被分拆,分拆出来的两个部分将继承原来的任务,再次排队等待审查其他集合;如果原有集合没有在队列中,那么就只要把拆分出来的两个集合中较小的那个作为审查官加入队列。

整个算法的核心在于,一个集合拆分出来两部分,只取两者中较小的那一个。这样的小集合不但同样可以起到刺激集合产生分裂的作用,而且在计算上避免了反复迭代较大那个集合中的元素,使得复杂度从 大幅度下降至 。

以下是算法的伪代码。

function MinimizeDFA_Hopcroft(DFA):
// 算法:DFA状态最小化 (Hopcroft算法核心思想)
// 输入:一台DFA = {Q, Σ, δ, q₀, F}
// 输出:一个包含了所有等价状态集合的最终划分P

function MinimizeDFA_Hopcroft(DFA):
    // --- 第一步:最初的划分 ---
    P = { F, (Q - F) } 
    
    // --- 第二步:初始化“待办清单”(Worklist) ---
    // Worklist里装的是“审查员”。我们把初始划分中,较小的那个集合作为第一个“分裂者”
    Worklist = new Queue()
    Worklist.add(the smaller of F and (Q-F))

    // --- 第三步:迭代处理“分裂者”,直至清单为空 ---
    while (Worklist.NotEmpty()):
        // 1. 从清单中,取出一个“分裂者” Splitter
        Splitter = Worklist.pop()

        // 2. 对于字母表中的每一个符号 a...
        for each symbol 'a' in Σ:
            
            // 3. 找到所有“上游”状态:即,哪些状态在接收 a 后,会进入到 Splitter 中
            UpstreamStates = all states 'q' such that delta(q, a) is in Splitter

            // 4. 对于当前划分P中的每一个可能被分裂的集合 GroupToSplit ...
            //    (注意:我们是在 P 的副本上迭代,因为P本身在循环中会被修改)
            for each group GroupToSplit in a copy of P:
                
                // 5. 将 GroupToSplit 分裂成两部分:
                //    - 一部分是其成员中,也属于“上游”的 (即,在接收a后会进入Splitter)
                //    - 另一部分是剩下的
                Intersection = GroupToSplit ∩ UpstreamStates
                Difference = GroupToSplit - UpstreamStates

                // 6. 如果成功发生了分裂 (两部分都非空)
                if (Intersection.NotEmpty() && Difference.NotEmpty()):
                    // a. 将旧的、大的集合 GroupToSplit 从 P 中移除
                    P.remove(GroupToSplit)
                    // b. 将分裂出的两个新集合加入 P
                    P.add(Intersection)
                    P.add(Difference)

                    // c. 关键一步:将分裂出的“新筛子”,加入“待办清单”
                    //    这里的逻辑是,如果旧的GroupToSplit在清单里,就用两个新的替换它;
                    //    如果不在,就把较小的那个新集合加入清单。
                    if (GroupToSplit is in Worklist):
                        Worklist.remove(GroupToSplit)
                        Worklist.add(Intersection)
                        Worklist.add(Difference)
                    else:
                        Worklist.add(the smaller of Intersection and Difference)
    
    // 循环结束,P就是最终的划分
    return P

虽然看不懂,但起码知道有这么一回事,插个眼,以后有需要再来研究吧~!

就这样,主角闭上书本,休息迎接第二天的Boss战去了。