解析技术
本书介绍
《解析技术》是编译器前端经典书籍《Parsing Techniques》的中文译本。
作者
- Dick Grune
- Ceriel J.H. Jacobs
译者
- Agnes Huang
- Rex Lee
Github仓库
https://github.com/duguying/parsing-techniques
翻译进度
29% [201/677]
加入协作
- 加QQ群 242838077
- 认领章节
- fork仓库
- PR
捐助
支付宝
微信
请各位捐助时备注一下称呼。以便能在列表中记录。感谢!
捐献列表
昵称 | 金额 | 日期 |
---|---|---|
jello | ¥20.00 | 2018/05/07 |
华子 | ¥50.00 | 2018/06/27 |
*杨 | ¥20.00 | 2018/09/13 |
江南四大才子 | ¥10.00 | 2018/10/17 |
*旭生 | ¥18.88 | 2018/11/28 |
forPandaria | ¥20.00 | 2019/2/24 |
zhi | ¥20.00 | 2019/04/01 |
Rainesli | ¥50.00 | 2019/04/02 |
fangfang | ¥50.00 | 2019/04/10 |
Kris | ¥18.88 | 2019/05/04 |
逸飞 | ¥18.88 | 2019/05/26 |
哲 | ¥20.00 | 2019/09/03 |
匿名 | ¥10.00 | 2020/07/04 |
joker | ¥50.00 | 2020/07/13 |
匿名 | ¥5.00 | 2020/10/24 |
uestc-zyj | ¥20.00 | 2020/12/19 |
冇创意 | ¥20.00 | 2021/03/31 |
岑 | ¥10.24 | 2022/06/29 |
SillyMem | ¥12.00 | 2022/10/31 |
8*t | ¥50.00 | 2022/11/24 |
第二版前言
为了满足读者的需求,同时也是我们自身的愿望,我们出版了第二版。尽管解析技术不是快速发展的领域,但是他依然在向前发展。当第一版在1990年出版的时候,只有一个简单的并且局限性相当大的线性时间字符串解析算法(algorithm for linear-time substring parsing)。但是发展到现在已经有多种可以覆盖所有确定语言的强大的算法;我们将在第12章对这些算法进行详细介绍。在1990年,Theorem 8.1诞生于一篇1961年由Bar-Hillel, Perles, 和Shamir发表的至今已经落满了灰尘的论文;在过去的十年里,Theorem 8.1被用于创建新的算法,并且使现有的算法得到加强。我们在第13章中提及此事。
越来越多的非Chomsky(non-Chomsky)文法的系统被使用,语言学上尤为明显。20年前,除了两级语法之外没有任何建树,而现在却不再是那样,我们在第15章描述了其中6个非Chomsky文法的系统。曾经,非标准解析器被认为是很古怪的;如今,它们是我们所拥有的最强大的线性时间解析器(linear-time parsers)之一,在第10章我们再来说这个。
虽然还不是很实用,但是以另一种方式展现解析原理的奇妙并行解析算法,已经出现在人们的视野中,我们将在第14章详细介绍。在1990年还被认为不可能出现的广义LL语法分析器,现在也已经成为了现实,我们将在第11章介绍其中的2个。
一直以来,解析器都被用于解析;现如今,解析器已经被用于代码生成、数据压缩以及逻辑语言实现等方面,如第17.5节所示。而且,读者还能够在书目的第18章找到更多的开发案例。
Kees van Reeuwijk曾经半开玩笑地称呼我们的书为“濒危语法分析算法最后的栖息地”。我们同意这种说法,但是不完全同意,因为它远不止于此,对于这个说法我们受之无愧。在这本书中收录的的一些算法,它们有非常大的局限性甚至根本没有实用价值。不过我们依旧收录了它们,因为我们觉得这些算法的思路很有意思,并且可以给我们以启迪,并且这些算法还有成长和发展的空间。我们也同样收录了许多有实用价值但是很少被用到的算法,希望将这些算法收录到书中能改变一下它们的现状。
关于练习和问题
这本书不是学校中所谓的教科书。少数大学开设了解析技术的课程,并且,如第一版序言所述,本书并不是只为某一类型的读者所准备。因此,书中几乎没有布置任何习题或作业,读者们可以自己为自己设置一些。章节末尾的问题,只是为了让读者不要陷入书中世界。我们将章节末的问题大致划分为三个类型:
- 无标记型 —— 短时间内可以完成的项目。
- 普通标记型 —— 需要花费一些时间和一些精力来完成的项目。
- 标记未研究型 —— 需要耗费大量时间精力,但目前我们尚未做到的项目。不过我们希望有读者能完成。
对于这些课题我们没有特意使之存在联系性,我们只是希望读者能从中得到启发、乐趣、或任何有用的东西。关于问题的思路、提示、部分或者完整的解决方案,见A章节。
关于形式语言(formal language)也有几个问题,不过我们没有在现有的文献中得到解答,可是却对解析有一定的重要性。这些问题已经在章节末标识出来了。
关于参考文献
第一版中,我们作为作者,阅读分析了所有我们能获得得资料文献。17年后,随着各类出版物得增加,以及互联网带来得查阅便利,查阅分析资料终于不再是一件让我们头疼得事情。在第一版的参考文献中,我们列举了全部的资料。但是在第二版中,尽数列举却不在可能,因为这些文件太多以至于全部列举将会占用远超本书主体内容的空间。在印刷版中,我们只列举了我们引用了原文的资料。我们把完整的参考资料放在了本书的网站中,并且还有我们写的摘要和主题索引。因为建立了网站,我们才能没有顾虑的将全部的文章列举出来,并且可以列举一些平常不太容易获取的资料。网站上的章节是第18章,我们叫做“电子章节”。
本书没有提供URL,有两个原因:首先URL并不稳定,也许过一段时间,这些URL就不在指向我们的资料了;还有,特别是对软件来说,当你在阅读本书的时候也许就有了更好的下载地址。其实我们认为,提供URL还不如提供一些搜索关键字更好,关键字能帮你在搜索引擎上找到你想要的信息。
最近10年里,我们能看到有很多的博士论文使用的不是英语,以德语、法语、西班牙语还有爱沙尼亚语为主。然后很遗憾,这些论文由于语言的选择而难以被科学界所发现。这对相关科学领域以及对作者本人来说,都是一大损失。忽略个人情感,我们得承认,英语是现在科学界的主流语言。在过去的150年时间里,对科学感兴趣的绅士们,在休闲之余会读一些法语、德语、英语、希腊语、拉丁语或者是一点点梵语的东西;但是现在,学生和科学家们则希望花费更多的精力来吸收海量的知识。即便我们能够读懂大部分的上述语言(并非全部)所写的内容,并尽力将这些论文的精华传达给读者,但这仍然不足以让这些论文拥有它们应得的位置。
关于解析技术的未来
如果将来有幸能出版第三版,我们将会致力于精简内容,尽量的精细化(除了参考文献部分)。因为解析算法都大同小异,当你进行深入研究后就会有这样的发现,并且似乎有大一统的趋势。大体来说,差不多所有的解析都是在左递归保护下通过自顶向下的搜索来完成的;这么说是没有问题的,即便是传统的自底向上搜索技术也是如此,比如LR(1),而且自顶向下搜索技术是建立在LR(1)解析表的基础之上的。Earley方法(Earley's method)很明显是通过一些方式来区分自顶向下搜索和自底向上搜索的。表格解析的记忆机制,可以有效的减轻指数级别的搜索所带来的疼痛。并且它似乎可以在将深度优先搜索算法转化为广度优先搜索算法过程中,产生许多广义确定性算法;这方面在Sikkel的博士论文[158]中有详细论述。上述汇集起来基本上就涵盖了本书提到的所有算法,包括学科交叉处涉及的解析算法。纯粹的自底向上,不包含自顶向下组件的解析器,是很罕见的并且功能也不强大。
关于解析理论的未来,可以预见算法的统一将会大大的简化解析的复杂程度;而在领域交叉处,解析技术将会扮演一个什么样的角色尚不得而知。这种简化似乎并不会延伸到形式语言领域:它将依旧难以使用直观的事实来证明所有LL(1)语法就是LR(1),就像35年前一样。
解析技术最实际的未来可能潜藏在先进的模式识别中,但不包含这个模式的默认任务;然而,在领域交叉处,解析技术所将扮演的角色依旧不甚明朗。
Dick Grune
Ceriel J.H. Jacobs
2007年6月 于Amsterdam, Amstelveen
致谢
感谢E. Bermudez, Stuart Broad, Peter Bumbulis, Salvador Cavadini, Carl Cerecke, Julia Dain, Akim Demaille, Matthew Estes,Wan Fokkink, Brian Ford, Richard Frost, Clemens Grabmayer, Robert Grimm, Karin Harbusch, Stephen Horne, Jaco Imthorn, Quinn Tyler Jackson, Adrian Johnstone, Michiel Koens, Jaroslav Král, Olivier Lecarme, Lillian Lee, Olivier Lefevre, Joop Leo, JianHua Li, Neil Mitchell, Peter Pepper, Wim Pijls, José F. Quesada, Kees van Reeuwijk, Walter L. Ruzzo, Lothar Schmitz, Sylvain Schmitz, Thomas Schoebel-Theuer, Klaas Sikkel, Michael Sperberg-McQueen, Michal Žemliˇcka, Hans Åberg,等等,感谢他们寄给我们关于第一版的信件,评论和勘误表。并且在此特别感谢阅读本书初稿的Kees van Reeuwijk 和Sylvain Schmitz当然还有我们这些作者,审阅初稿对本书的出版有着重大的作用。
感谢Faculteit Wiskunde en Informatica of the Vrije Universiteit 允许我们使用了他们设备。
同时,感谢本书结尾处列出的将近1500位作者以及他们所研究的美妙的算法,并且让我们可以使用这些算法。这本书的每一页内容,都是建立在这些算法的基础上的。
第一版前言
语法解析(句法分析)是计算机科学中最好理解分支机构之一。解析器已经被广泛应用于大量的学科︰例如,计算机科学(编译器构造、数据库接口、自描述数据库、人工智能),语言学(文本分析、语料库分析、机器翻译、圣经的文本分析)、文档编制和格式转换、排版化学公式中和在染色体识别等等;解析器可以在大量的学科中使用(也许已经在使用中了)。因此,现今还没有一本向非专业人士系统介绍解析技术的书籍出版,真的是一件很奇怪的事情。其中的一个原因可能是,解析素来被认为是一个“难题”。不过,根据我们在研究阿姆斯特丹编译器(Amsterdam Compiler Kit)以及在讲解编译器构造的经验,解析技术可以通过一些简明的方式来解释说明。本书就是在这样的情况下出版的。
本书并不是只单一的面向某一部分读者。相反,在编写时,我们一直在努力让本书适合于学生、各学科的老师、各个行业不懂解析的人、各种科学报刊的狂热读者、等等等等。这项技术将很难以课程形式出现,因为对于这种定时开讲的内容固定的教学模式,并不适合上面提到的复杂的读者群体;这就是我们出版这本书的目的,它可以让你在需要的时候,在任何时间任何地点没有障碍的获取你需要的知识。
要应对这样一个复杂的读者群体,是有一定的困难的(当然也有好处)。本书虽然没有明显的使用数学理论,但不可避免的书中也蕴含了大量的数学思想。大部分解析技术的专业术语在书中都给出了明确的定义,但仍然有部分处于学科边缘的术语我们没有进行定义。每一位参与过非自己研究领域的主题研究的读者,应该都能明白这种情况。读者们可以跳过这些术语,或者用自己相对熟悉的东西来进行替代,然后你当然不希望这个术语出现的太频繁。当然书中也会有一些地方,会让读者觉得讲述的非常明确易懂(也许本段就是这样的)。有一点可能会让读者们感到欣慰的就是,阅读这本书并不会是浪费时间,或者像你在上一堂专业课时只能盯着窗外那样难以理解老师讲的内容,而只能消磨一下时间。
我们写作这本书的主要目的,希望通过出版书籍的方式,来揭示隐藏于于某些文字表面下的解析的存在,就像这个公式:
Let be a mapping and a homomorphism
读者并不需要一定懂得某种编程语言。书中确实有两三个Pascal语言的程序,不过只是用来展示而已,在对解析的阐述中这个并不重要。真正需要具有的是对算法的了解,尤其是递归算法(recursion)。Howard Johnston (Prentice-Hall, 1985)写的Learning to program或者Richard Bornat (Prentice-Hall 1987)写的Programming from first principles,这些书已经为我们提供了足够多的知识储备(虽然似乎有些过分的详细了)。选择Pascal,是因为这差不多是唯一一门在计算机领域之外被使用的编程语言了。
结尾处符的大量参考文献应该是本书的一大特色了。对参考文献感兴趣的读者可能比我们预期的还要多,尤其是对本书中所提及的某些领域有所了解的读者,不管是不是通过这本书所产生的了解。参考文献通过列表形式展示,这样更利于读者查找;批注放在了文章的页脚,并且也在书尾列出,目的是希望这些批注能像里程碑一样可以帮助读者在阅读时更好的理解。
关于应用程序的解析器,本书不做详细阐述。虽然我们在第一章提到了很多应用程序,但是由于我们没有相关的专业知识以提供更多的详细说明。虽然音乐作品所拥有的文法结构在很大程度上和解析相同,但是我们并不打算在这里对音乐进行探讨,这件事就留给音乐家们来做吧。同样的,虽然企业的营收与各种规章制度的文法似乎关系不大,但是我们坚信这之间也有存在某些联系的,不过这将是社会心理研究的课题。
致谢
在此感谢在我们编写本书的过程中,为我们提供过帮助的人们。Marion de Krieger 为我们检索了无数的书籍和期刊文章的复印版,正是有了她的努力,我们才能毫不费力的将书目收纳的如此完备。Ed Keizer 做好了我们和pic|tbl|eqn|psfig|troff这一堆乱七八糟的东西之间的修复工作,他无数次的纠正了被我们滥用、重用或者明显被误用了的地方。Leo van Moergestel 使用硬件资源为我们完成了大量的工作,而这是我们这些门外汉所不会使用的。同时我们也要感谢Erik Baalbergen, Frans Kaashoek, Erik Groeneveld, Gerco Ballintijn , Jaco Imthorn 以及Egon Amada,他们为我们提供了大量的准确的批注。本书结尾的倒数第二章,出自Arwen Grune 。而Ilana和Lily Grune 为我们做了大量的录入工作。
感谢Faculteit Wiskunde en Informatica of the Vrije Universiteit 允许我们使用了他们设备。
同时,感谢数百位的专家,他们的作品为我们提供了那么多巧妙而精致的算法,并给出了这些算法的使用技巧。希望在我们列举的参考书目中没有遗漏任何一位。
Dick Grune
Ceriel J.H. Jacobs
1990年7月 于Amsterdam, Amstelveen
1 简介
解析是对一种给定语法构建其线性表达的过程。这一定义如此抽象,就是为了尽可能进行广泛的解释。“线性表达”可能是对一个句子,一个计算机程序,一组编织图案,一连串的地质地层,一首乐曲,礼仪仪式中的一组动作,总之线性序列就是前面的元素会以某种方式限制1下一个元素的表达。有一些语法已经被我们所掌握,而有些语法目前还在研究之中尚未完全确定,而还有一些语法目前仅仅只是有了一个大体的形状而内部完全不了解。
每种语法,一般都有无限的线性表达(“句子”)来构造表达。也就是说一组有限的语法结构,可以用来构造无限的句子。这是语法范式的主要力量,也是语法重要性的主要源泉:它简要的总结了某一个类的无数个对象的结构。
有几个理由可以展示这个被称为解析的构造过程。其中一个原因是,现有的结构能帮助我们更进一步的展示这个对象。比如当我们知道了一句话的某一个部分是这句话所描述的主体的时候,那么我们就更容易理解这一句话或者将这句话翻译出来了。一旦一篇文档的结构被弄明白之后,这篇文档就能很容易的被理解了。
第二个原因是,在某种意义上语法代表了我们对观察到的句子的理解:关于蜜蜂行为的解释,我们给出的解释越好则我们越能理解它们在做什么。
第三个原因是,部分丢失的信息,可以通过解析器,尤其是错误修复解析器(error-repairing parsers)来完善。如果能给出合理的语法,那么就能设计出一个错误修复解析器,来修复古阿卡德人的泥简(clay tablets)。
给定一堆句子,从中找出产生它们的语法结构,这种反向问题被称为文法推断。这个问题比解析技术,人们对其的了解要更少,不过仍然在进行当中。这个问题不在本书范围之内。国际文法推断座谈会(International Colloquiums on Grammatical Inference)的纪要由Springer出版,名为Lecture Notes in Artificial Intelligence。
如果不存在元素之间的限制,序列仍然是有语法的,只不过语法也许十分繁杂并且难以理解。
1.1 解析技术
解析技术不再是一种神秘的艺术,自从上世纪70年代,Aho, Ullman, Knuth以及其他科学家为解析技术奠定的坚实的理论基础之后。解析技术也不是一门数学学科;一个解析器的内部工作过程可以是可视的、易理解的以及可修改的,用以适应应用程序,不再仅仅是简单的字符串剪切粘贴。
一位数学家的世界观和一位计算机科学家的世界观差距是相当巨大的。在数学家眼中所有的结构都是静态的:它们一直是静态的并且将永远都是静态的;只不过我们还没有发现全部的结构而已。而计算机科学家则倾向(并且很着迷)不断的创造、组合、分离和破坏结构:时间检验真理。在数学家手里,Peano公理创建整数时没有提及到时间,但是如果一个计算机科学家使用这个公理来实现整数加法,那么他将发现这在计算机中是一个非常缓慢的执行过程,所以计算机科学家就会去寻找更加有效率的方法。在这方面,计算机科学家和物理学家和化学家拥有更多共同点;和他们一样,计算机科学家必须有数学的几个分支的坚实理论基础,但同时计算机科学家也很希望(而且经常迫使)数学家将一些理论的主导权交给他们,这点也和物理学家和化学家一样。没有严谨的数学理论的支持,所有的科学都将不复存在,但就像一栋大楼一样,并非其中的居民都需要了解这栋大楼的框架和梁柱。为各领域的专家们分化一些特殊的知识点的细节,可以减少复杂的脑力劳动,这是计算机科学家们正在做的事情。
本书是为这样的人准备的:需要做解析工作的人:解析器作者、语言学家、数据库接口作者、想要测试他们各自研究对象的语法的地理学家或音乐家、等等。我们需要读者有良好的可视化能力,一些编程经验以及跟进不太繁琐的例子的兴趣和耐心;了解一只袋鼠最好的方式莫过于亲眼去看它是如何跳跃的了。我们对流行的解析技术张开怀抱,同时我们也不会排斥一些奇怪的技术而只关注它们的理论知识:这些技术往往能为读者打开一扇新的大门。
1.2 使用方法
这本书的读者至少可以分为三个层次。感兴趣的非计算机科学家可以把这本书当作“语法和解析的故事书”;他们可以跳过解释算法的细节:只了解首次提到某个算法时的概要介绍。计算机科学家在研究各种算法时将会发现很多技术上的细节。我们为专家们提供了超过1700个项目的系统参考文献。本书的付印版本只包含了书中有引用的文献的参考目录;完整的参考目录收录在本书的网站上。付印版的全部参考文献和网站上将近三分之二的文献,都有注解;这些注解或者说摘要,与文中引用的内容无关,而是文献的简要说明,为了帮助读者判断这个文献是否值得一读。
本书给出了一些无法运行的算法,除了17.3中上下文无关的解析器。让程序员能实现并准确运行的解析算法公式,需要相当大的支持机制,而这不在本书研究的范围之内并且我们也没有足够的知识来为读者进行深入讲解。本书也给出了在大多数编译器构造书籍中多次介绍的算法。而那些不太为人所知的算法,则在原书中有详细介绍,第18章中列举了这些书籍。
1.3 内容概要
由于解析最关键的就是句子和语法,而语法本身又非常的复杂,所以第2章我们将对语法进行详细的讲解。第3章探讨了解析背后的原理,并给出了解析方法的分类。总之,解析技术可以分为自顶向下(top-down)或自底向上(bottom-up)两种,或者是定向(directional)和非定向(non-directional)两种;定向法又可以细分为确定性(deterministic)和非确定性(non-deterministic)的。这就决定了和紧接着后面几章的内容的主体。
第4章我们讲解非定向法,包括Unger和CYK。第5章介绍有限状态自动机(finite-state automata),为后面需要的章节做一个过渡。第6到10章介绍定向法,如下。第6章涵盖了非确定性的自顶向下解析器(向下递归,Definite Clause Grammars),第7章涵盖了非确定性的自底向上解析器(Earley)。第8章和第9章介绍确定性方法(第8章介绍自顶向下法:各种形式的LL。第9章介绍自底向上法:LR)。第10章涵盖非规范(non-canonical)的解析器,以一种不太规范的自顶向下或自底向上的方法来确定解析树的节点的解析器(例如left-corner)。第11章则介绍了类似上一章中的算法的非确定性版本(比如GLR解析器)。
接下来的四章内容,不太符合上述的框架。第12章介绍了最新的用于解析某一语言中完整句子的子字符串技术,包括确定性和非确定性的。第13章介绍了一种正在发展中的技术,这种技术将解析视为贯穿有限状态机的上下文无关语法。第14章介绍了几个并行解析算法,而第15章则解释了几种关于非Chomsky文法系统的建议以及他们的解析器。而这些本身就完成了解析方法。
第16章介绍了一些错误处理方法,第17章介绍了在写作和使用中比较实用的解析器。
1.4 参考文献
第18章是参考目录部分,在付印版和更加庞大的电子版中都是这样。它是本书主体部分的必要补充,也是容易获取的一个方式。参考文献被命名划分为几个小节,每一节都和解析有一定的关系,而不是通常的按照作者名字依次排列;付印版有25个小节而电子版有30个小节。每个小节内,文献的排列按照出版时间先后的顺序。书尾用文献的作者索引代替了通常的字母索引。文中方括号内的数字都指向一本参考文献。例如Earley写的关于Earley解析器的书在文中表示为[14],那么在578页(原著的页数)被标记为14的那本书就是你要找的了。
2
2.1 语言,一个无限的集合
在计算机设备中,常规来说,“语法”是用来“描述”一门“语言”的。然而单从字面上来看的话,这样理解却会带来一些误解。计算机高手和初学者在看待这个问题上有一些细微的差别,具体在三个方面体现。为了建立和划定我们探讨的界限和范围,我们应该从刚才说到的三条差异来介绍,就从第三条开始吧!
2.1.1 语言
对大多数人类而言,语言,首要的作用是用来进行交流和思想沟通的。当人们在激烈的辩论中,这个作用尤为明显,不过当然了这是一种无意识行为。人们沟通交流,是指传递信息,更准确的说是通过空气振动传递声音信息或者是在纸面上书写文字信息。我们仔细看一下语言文字(话语),就是由一堆单词组成的句子,或者说,就是由一堆写在纸上的符号组成的信息。语言的组成上,总体来说有三个层面的区别。各种语言之间,有的存在微小的差异,就好比英语和爱尔兰语;但有的也存在巨大的差别,就好比英语和汉语。但是对单词的使用上却往往有这巨大的不同,甚至对使用同一种的人来说都是如此,比如在德语中“un cheval”和“ein Pferd”都表示“一匹马”。而句子结构的差异很容易被忽视,例如荷兰人中,有的人会有类似莎士比亚的语言习惯像“Ik geloof je niet”,如此来表达“我不相信你(I believe you not)”,而另一个地区的荷兰人则会有类似匈牙利的“Pénzem van”的语言习惯,像“Money-my is”表达的是英语中的“我有钱(I have money)”
但是,计算机研究学者却用一个非常抽象的角度来看待这一切。确实一种语言总是拥有着大量的句子,并且这些句子都有着一种特殊结构;我们不必关心这些句子是不是能传达某些信息,但句子们所具有的结构实实在在的含有了某种“意义”。而句子中包含的单词,这些在计算机中被称为“标记(token)”的东西,每一个都包含了一些特殊的信息,而这些信息汇总起来,就是这个句子所被希望表达的意义。但是在自然语言中,词语是具有意义的不能再分的最小单位了,即便一个单词所包含的意义可能很多。不过,即使单词不能被继续细分。但对计算机研究学者来说并不是问题。通过可伸缩解决方案(telescoping solutions)以及多层技术(multi-level techniques),他们很容易的就能证明,如果一句话确实含有某种结构,那么这句话就属于另外一种语言,其中的单词就属于标记(token)。
形式语言学的从业者,通常被成为形式的语言学家(formal-linguist)(用以和“形式语言学家”做区分,这两者间的不同就留着读者见仁见智了)再次以一种不同的抽象角度来看待语言。语言是句子的“集合”,句子是“符号”的有序集合;这就是说:如果一句话没有意义那是因为没有结构,一个句子是否属于某种语言,就看他的结构是否能在这种语言下产生意义。符号的唯一属性是它具有的标识;在任何语言中一定有一组确定数量的符号集,并且这一数字必须有限,比如字母表。比如,我们可以将这些符号写作a,b,c...,或者✆,✈,❐...也可以,只有数量是足够的就行。词语的顺序(word sequence)表明了,在每一个句子中词语都有他们固定的位置,我们不应该随意更改这些位置组合。而词语集则是把所有不同的词语都放在一起的一个无序的集合(word set)。这个无序集合可以写下来,通过把所包含的所有对象写在一对大括号中里面(就是这个{})。对形式的语言学家(formal-linguist)来说,以下是一种语言:a,b,ab,ba;还有{a,aa,aaa,aaaa,...}也是一种语言,后者所涉及到的符号问题(逗号)我们稍后再说。根据计算机科学家眼中的“句子/单词,单词/字母”之间的对应关系,形式的语言学家(formal-linguist)也把句子称作一个“词语”,所以就会出现“ab这个句子,属于{a,b,ab,ba}这个语言”。
现在我们来思考一下这种巧妙而伟大的思想的含义。
对计算机研究学者来说,语言就是一个无限大的句子的集合,这些句子由标记按照某种结构组成;标记和特定结构通力合作最终表达了句子的语义,也就是句子的“含义”。变成语言的句子结构和语义都是全新的,也就是在现有的结构模式中所没有的,而研究学者们的任务就是提供和完善他们。对计算机研究学者来说,3+4×5 是“个位数运算”的一个句子(“个位数”则是为了保证数字是一个有限的符号集);这个算式的结构可以通过插入括号来展示:(3+(4×5));而这个算式的含义就是 23.
即便是语言学家(linguist)也不得不承认,语言是所有可能有关联的句子的无限集合,这种说法要比上面的两种说法都要正常的多。每一个句子由在现实生活中有明确意义的单词,按照结构化的方式组成。结构和词语一起传达出了一句话的意思。现在再次说到单词是由字母组成,这些字母和结构一起传达出了词语的意思。语义的重点、和现实世界的关系以及“句子/单词”“单词/字母”这两层的融合,是语言学家(linguist)的领域。“圆在疯狂的旋转”是个句子,而“圆睡的发红”就是句胡话。
形式语言学家(formal-linguist)坚持他们对语言的看法,因为他们希望在研究语言的基本性质同时领略语言的原始美。计算机科学家坚持着自己的观点,因为他们想要一种清晰、 易于理解和明确的手段,来描述计算机中的对象以及语言和计算机之间的通信,这种手段严格的不像人类。而语言学家(linguist)也坚持自己的看法,因为这让他找到了和一种看似杂乱无章并且似乎无限复杂的语言:自然语言。
2.1.2 语法
每一个学习过外语的人都知道,语法就是一本充满规则和教会人们这门语言的示例的书。好的语法总是会仔细区分“句子/单词”和“单词/字母”的层级,前者通常被称为句法或句法集,后者通常被称为词法。句法包括一些规则,就像“pour que是虚拟语气,而parce que就不是”。词法也包括了一些规则比如“英文名词的复数形式一般词尾追加-s,除了单词本来就以-s.-sh,-o,-ch或-x结尾,这些单词为不规则复数形式,结尾追加-es”。
我们暂时先跳过计算机科学家看待语法的视线,先看看形式语言学家(formal-linguist)眼中的语法。形式语言学家的观点是抽象的同时又和外行的看法很相似:语法是任何语言的确定的、有限大小的、完整的描述,即句子的集合。这实际上是学院派的语法,只是去掉了其中的模糊性。虽然很明显这一定义具有充分的通用性,但却又太过笼统以至于显得很无力。它包括一些描述像“可能是乔叟写的一组句子”;柏拉图谈到这定义了一个集合,但是我们却没有办法创建这个集合或度量一个句子是否属于这种语言。这这个特定的例子, 即使它“曾经能让人困扰”也并不会令形式语言学家困扰, 但是有更接近他家的例子会。“π的超长的小数部分是一个最长的块”描述了一种语言,这种语言最多只有一个单词(这个单词就是乱七八糟的数字),而且很符合定义说的精确、有限大小并且完备。然而又有一个问题,那就是没人知道这个词完整的样子:假设即便有一个人找到了一堆长度为数十亿的数字,在这后面却任然还有着数不尽的数字。另一个问题就是,我们还根本无法知道这么长的数字是不是真的存在。很可能就是一个人在不停的发现π的小数,另一个人却发现了越来越长的小数仍然没有被找到。关于π的小数的全面展开理论也许能解释这些问题,不过目前这个理论还不存在。
因为一些这样或那样的原因,形式语言学家们放弃了他们静止的柏拉图式的观点,转而接受了另一个更有建设意义的,衍生语法:衍生语法是语言中构建句子的准确且固定大小的诀窍。这意味着,使用这个语法就一定可以构建语言(动作数量有限)中的句子,而不会有其他的可能。这并不是说,给一个句子,语法就能让我们知道这个句子是如何构造的,只是说我们可以通过语法来知道。这些语法可能有几种形式,其中有一些会比其他更具便捷性。
计算机科学家们通常会赞同这个的观点,而且还有一个附加的要求就是衍生语法应该隐含一句话是如何构造的。
2.1.3 无限集带来的问题
上述将语言定义为序号序列的无限集,将语法定义为有限的构造句子的配方,这引出了两个令人尴尬的问题:
1.一个有限的配方如何能产生无限的句子集呢?
2.如果一个句子只是一个序列而没有结构,或者如果一个句子,可以通过其结构推导出其他的意义,那我们该如何理解这个句子呢?
这两个问题有一个漫长而复杂的答案,不过确实是有答案的。我们先解决第一个问题,然后在带着第二个问题去阅读本书的主体部分。
2.1.3.1 有限描述的无限集
其实从一个有限的描述中得到一个无限集,并没有什么问题:“所有正整数集合”是一个非常有限的描述,但描述的却是一个无限集合。不过,还是有一些令人不安的想法,所以我们把问题换一个说法:“所有的语言都能用有限的描述来说明吗?” 正如上文暗示的,答案是“不行”,不过证明却不是显而易见的。实际上这个证明非常有趣并且有名气,如果不展示一下或者至少大致的介绍一下,将会是一个遗憾。
2.1.3.2 枚举描述
证明基于两个事实,并使用了一个窍门。第一个事实是,描述是能够被逐一列出并赋予一个编号。方法如下。首先,找出全部大小为1的描述,也就是那些长度只有一个字母的,然后将他们按字母顺序排序。这是我们列表的开头。取决于我们所接受的描述,长度为1的描述可能是0,或27(所有字母+空格),或者95(所有可打印的ASCII字符)或者其他类似的;具体是什么对下面的讲解都不重要。
第二步,我们找出长度为2的描述并将他们按照字母顺序排序,然后将他们放在列表中的第二块;然后是长度为3的,长度为4的等等等等,对每一个描述都这样做。这样每一个描述都在列表中获得一个位置。例如“所有正整数的集合(the set of all positive integers)”这个描述,不算引号大小为32个字符(英文)。若要查找其在列表中的位置,先计算有多少少于32字符的描述,记为L。然后生产所有长度为32的描述并排序,然后确定我们的描述在其中的位置,记为P,把L和P加起来。这肯定会是一个巨大的数字1,不过这就能确保我们的描述处于这个列表中了,并且有定义明确的位置;见图Fig.2.1。
有两件事情需要注意。第一,只根据字母表顺序列出全部描述而不指定其长度,将导致以字母‘a’开头的描述就会有无限多个,其他字母开头的描述无法在这个列表中列出来。第二,没有必要将所有的描述实际的列出来,这只是一个思想实验。
当然,列表中会有很多胡言乱语的描述;不过这对我们的论点来说是无关紧要的。重要的是以上的论证能够确保所有有意义的描述都在这个列表中。
一些计算表明,在ASCII-128假设下,这个数字是248 17168 89636 37891 49073 14874 06454 89259 38844 52556 26245 57755 89193 30291,或者大致是2.5× 。
2.1.3.3 语言,无限的比特串
我们知道,一种语言中的单词(句子)是由有限的符号集组成的;这个集合很自然的被称为“字母表”。我们假设字母表中的字母是有序的,那么语言中的单词也能排序。在下文中我们用字母来表示字母表。
最简单的语言就是使用字母表中的字母组成所有单词的语言。对于字母表 = {a,b},我们获得了一门语言{ , }。我们把这个语言称为*,稍后再说为什么这么称呼;现在来说,这只是一个名字。
上面用*标记的集合,以“{ , a,”为开头,值得注意的是,这个语言的第一个单词是一个空单词,这个单词包含0个和0个。没有理由排除这个单词,但如果写下来又很容易被忽视掉,所以我们把这个空单词记为。所以,* = { , }。在一些自然语言中,动词“to be”的现在时态就是一个空单词,赋予“I student”这个句子以“I am a student”的意义。俄语和希伯来语就是很好的例子。
因为字母表中的符号是有顺序的,我们可以列出语言*中的单词,使用上一节中说到的方法:第一,将所有大小为0的单词排序;然后大小为1的单词排序;然后后面依次。实际上我们已经在*中这样做了。
语言*有一个有趣的地方,就是所有使用字母表的语言,都是它的子集。这意味着,在的基础上创建一门不那么复杂的语言,称做,那我们就能遍历*列表中的所有单词,然后在属于的单词上做一个标记。这样就能把中的全部单词都包括了,因为*本来就包括了所有由中的字母组成的单词。
假设所有字母比字母多的单词都属于语言。那么就是这样的{}一个集合。那*列表的开头部分就是下面这样的:
给定一个字母表和他的次序,无标记和有标记的部分就完全足够识别和描述一个语言了。为了方便,我们将无标记的部分写作0,将有标记的写作1,就像计算机中的位(bits)一样,那我们现在就可以把写成 = 0101000111010001· · ·(而* = 1111111111111111· · · )。需要指出的是这适用与任何语言,比如一门形式语言,一门编程语言Java,一门自然语言英语。在英语中,标记为“1”是非常稀少的,因为几乎没有任何一个任意序列的单词组合会是一个有意义的句子(同理,没有任何一个任意序列的字母组合是一个有意义的单词,取决于我们针对的是“句子/单词”级别还是“单词/字母”级别)。
2.1.3.4 对角化
上一节将无限位字符串0101000111010001· · ·和“所有字母比字母多的单词集合”这个描述联系在一起。同样,我们可以将这种位字符串一一对应到所有的描述上。有些描述可能不足以产生一门语言,这种情况下我们可以将任意无限位字符串和他对应。既然所有的描述都可以放到一个被编号的列表中,那么我们就有了下面的图:
左侧是所有的描述,右侧是描述对应的语言。现在我们可以声称很多已经存在的语言却不存在于上述列表中:上述列表远远不够完整,虽然列表的描述是很完善了。为了证明这点,我们将使用Cantor的对角化过程(“Diagonalverfahren”)。
想象一下,语言 = 100110· · ·,它是由这样组成的:它的第n阶位并不等于描述#n中描述的第n阶位。语言的第一个比特位为1,因为描述#1的第一个比特位是0;第二位是0,因为描述#2的第二位是1,等等。是由上图language部分的左上方到右下方的对角线的相反位组成。就是图Fig 2.2(a)中对角线。那语言就不存在于列表中!它不是行1,因为它的第一位不同于(应该说,被弄得不同)行1的第一位,更一般的,通过定义得出,它也不是行n,因为它的第n位与行n的第n位不同。
所以,尽管我们已经详尽无遗的列举了所有可能的有限描述,那也至少会有一种语言的描述不在列表中(实际上有很多)。例如,构造一个语言,它的第n+10位与描述#n的第n+10位不同。同样的,这个语言也不在列表中,因为对于任意一个n>0,它的第n+10位与与描述#n的第n+10位不同。这意味着1到9位没起作用,可以任意替代,如图Fig 2.2(b)所示;这将生成另外2 = 512种语言,且都不在列表种。而且我们构造更多!假设我们构建了一门语言,它的第2n位与描述#n(c)的第2 n位不同。明显它也不在原列表中,并且每一个奇数位都没有指定值,而且可以随意选择!这使得我们可以自由的创建无限数量的语言,并且都无法用有限的描述表示;见图Fig 2.2中的斜对角线。简而言之,相对于可以描述的语言来说,还有更多的无法描述的语言。
关于对角化技术,在理论计算机科学中的很多书中有更正式的讲解;例如Rayward-Smith [393, pp. 5-6], 或者 Sudkamp [397, Section 1.4]。
2.1.3.5 讨论
上面的示例表明了几件事情。第一,它展示了把语言当作一个形式对象处理的力量。虽然上述概要仍需要补充很多证据才能成为一个合格的证明。为什么上述语言的定义本身不在描述列表中;见问题2.1,这让我们能深入了解其本质。
第二,这表明我们只能描述所有可能语言的很微小的一部分子集(甚至都不是一小部分):语言的数量是无限的,且远远超出我们能弄清的范围。
第三,我们已经证明,虽然有无穷多的描述和无穷多的语言,但是这两个无穷多却不相等,后者要远大于前者。这些无穷被Cantor称为0和1,而上述只是他对0 1进行证明的一个特殊例子。
2.1.4 通过有限集描述一门语言
一个好的生成一组对象的方法,是先创建一个小对象,然后制定根据现有对象添加新对象的规则来生成一组完整的对象。“有两个偶数,这两个偶数相加得到的数也是偶数”这样就很有效的生成了所有的偶数的集合。 形式主义者会添加一个说明“并且没有别的数是偶数”,不过我们都理解这个。
假设我们想要生成所有人名的枚举的集合,就像“Tom,Dick和Harry”这种,集合里面除了最后两个之外,其他都用逗号分隔开来。我们不接受“Tom,Dick,Harry”或者“Tom和Dick和Harry”这种类型的,但是我们接受重复的,比如“Grubb, Grubb和Burrowes”1是可以的。虽然在英语中这不是完整句子,可我们依旧要把他们称作“句子”,因为它们是我们的微型语言——人名枚举集——中的句子。这个简单的结构是:
-
Tom是一个人名,Dick是一个人名,Harry也是一个人名;
-
一个人名就是一个句子;
-
一个句子后面跟着一个逗号,然后跟着一个人名又是一个句子;
-
在枚举结束之前,如果句子的结尾是“,人名”,那用“和人名”来替换。
虽然这样说对于读者的理解很管作用,但是却有几个错误的地方。条件3尤其会带来麻烦。例如,句子并不是以“,人名”结束,而是以“,Dick”或类似的名字结束,“人名”只是一个符号用以代替真实的名字;这样的符号不能在真是的句子中出现而且在结尾处的必须用条件1中给出的名字替代。同样,条款中的“句子”是一个符号,指代真实的句子。所以这里涉及到两种符号:出现在完备句子中的真实符号,比如“Tom”,“Dick”,逗号和“和”;还有中间体符号,像“句子”和“人名”这些不能在完备句子中出现的词。第一种对应前文中提到的单词或标记;它们的专业术语是终结符(或简称终结)。这种中间体符号被称为非终结符,一个很平庸的术语。为了区分它们,我们将终结符用小写字母表示,非终结符用大写字母表示。 非终结符被称为(语法的)变量或句法的类,在语言学语境上。
为了强调按照条件生成的字符,我们应该将“X就是Y”替换成“Y应该用X替换”:如果“tom”是人名(Name)的一个实例,那么不管我们在哪儿看到人名(Name)我们就可以将之缩小范围为“tom”。这给了我们:
- 人名(Name)可能被“tom”取代
人名(Name)可能被“dick”取代
人名(Name)可能被“harry”取代
-
句子(Sentence)可能被人名(Name)取代
-
句子(Sentence)可能被句子(Sentence),人名(Name)取代
-
句子(Sentence)结尾处的“, 人名(Name)”必须被“和人名(Name)”取代,在人名(Name)被任何替代物替代之前
-
一个句子只有当它不在包含非终结符时才能结束
-
我们从句子(Sentence)开始替换过程
条件1到4说明替代物,但是5和6不一样。条件5不是特定于此语法的。它基本都是有效的,而且是我们这个游戏(解析)的规则之一。条件6告诉我们从哪里开始生成。它的名称自然而然的被称为起始符,而且它是每个语法都要求有的。
条件4还是看起来不让人放心;其他大多数规则都写着“可能被取代”,而这条写着“必须被取代”,并且它是指“一个句子的结尾”。其他规则通过替换来生效,但是问题仍在,我们如何用替换来检测一个句子(Sentence)的结尾。这个问题可以通过在结尾处添加一个终止符来解决。如果我们对一个非终结符做了终止标记,这个非终结符不能在除了从“,人名(Name)”替换为“和人名(Name)”处之外使用,那我们就自动执行了这个限制——除非替换检测已经发生否则没有句子将会结束。为了简便起见我们写作——>而不是“可能被替代”;由于终结符和非终结符现在是专业术语,所以我们把他们写成打字机的字样。——>之前的部分称为左手边,之后的部分称为右手边。这就成了图Fig 2.3中的样子。
这是这个配方的简单和比较精确的形式,规则也很简洁明了:由起始符开始,持续替换过程直到没有非终结符剩下。
引用至The Hobbit, by J.R.R. Tolkien, Allen and Unwin, 1961, p. 311.
2.2 形式语法
上述的配方形式,基于根据规则的替换,已经强大到足以支撑形式语法的基础了。相似的形式,通常被称为“重写系统”,在数学家中已经有了悠久的历史,而且在公元前几个世纪已经在印度被使用了(例如,见Bhate and Kak [411])。图Fig 2.3第一次被广泛研究是由Chomsky[385]做的。他的研究成果成为了形式语言、解析器和相当大部分的编译器构造以及语言学的几乎所有研究和进展的基础。
2.2.1 形式语法的形式主义
由于形式语言是数学的一个分支,所以这一领域的工作是在一个特殊符号下完成的。若要展示一些他的韵味,我们就应该形式定义一下语法,然后解释一下为什么这样就描述了一种语法,就像图 Fig 2.3中的那个一样。形式主义的使用是必不可少的,为了证明的准确性等,但是不是为了理解其基本原理;在这展示只是为了给读者一个印象,或者说跨过沟壑的一个桥梁。
定义2.1: 一个生成语法是一个4元组 ,像这样:
(1) 和 是符号的有限集,
(2) ,
(3) 是一个有序数对 ,像这样
(3a) 和
(3b) ,
(4)
一个4元组只是一个包含了4个可辨识部分的对象;这4个部分按照顺序是非终结符、终结符、规则和起始符。上述定义没有说明这点所以这需要老师来解释。非终结符集用 来表示,终结符集用 来表示。对我们的语法来说,我们就有
(注意,终止符的那个集合)
和(2)的交集必须是空集,用空集合符号 表示。所以非终结符集和终结符集不可能有公共元素,这是可以理解的。
R是所有规则(3)的集合,P和Q分别是放左侧和放右侧的内容。每一个P必须包含一个或多个非终结符和终结符的序列,每一个Q必须包含零个或多个非终结符和终结符的序列。对我们的语法来说,我们就有:
再次注意,两个不同的逗号。
起始符必须是里面的元素,也就是说必须是非终结符:
我们的领域在这里就结束了,后面是语言学的领域。简言之,数学上的形式语言就是一门语言,一门必学的语言;它让表达“是什么”和“怎么做”变得非常简单,但是关于“为什么”却给了很少的说明。把这本书当作一个翻译和一个注释。
2.2.2 通过形式语法生成句子
图Fig 2.3中的语法,就是所谓的我们的t,d&h 短语结构语法(通常缩写为PS语法)。还有一个更紧凑的形式,左侧相同的,对应的右侧写在一起用竖线“|”隔开。这条竖线只是一种形式,就像箭头--->一样,可以读作“或其他”。右侧被竖线分隔开的,也被叫做备选项。这种更简洁的形式下,我们的语法变成了:
带有下角标s的非终结符成为了起始符。(下角标决定了起始符,而不是规则)
现在让我们用这个语法来生成我们的初始例子,只使用根据上述规则的替换方式。我们得到了以下连续形式的句子:
中间形式(intermediate forms )被称为句子形式。如果一个句子形式不包含非终结符,那它则被称为一个句子并且属于生成的语言。从一行到下一行的过渡被称为生成步骤,而其规则被称为生成规则,原因很明显。
生成过程可以更加直观,通过使用“图标”在对应符号之间画一条连接线。一个图就是一个节点集合,和一组边连接起来的。一个节点可以认为是纸上的一个点,一条边是一条线,每条线连着两个点;一个点可能是多条线的结束点。图中的节点通常都是被“标记”的,也就是说它们都有名字,这样就可以很方便的在纸上画出写着它们名字的小圈来代替这些点。 如果这些边是有箭头的,那这个图就是有方向的;如果这些边只是线条,那这个图就是无方向的。几乎所有在解析技术中使用的图都是有方向的。
图标对应上述生成过程见图Fig 2.4。这种图被称为生成树或者句法树,描述了最终句子的句法结构(在给定的语法中)。我们看到的生成图通常是向下的,但偶尔我们也会看到一些星型结构,一般是由重写了一组符号导致。
在图中的一个循环就是一个路径,从节点N顺着箭头一直在回到节点N。而生成树中是不允许有环的;如下所示。要得到一个环,我们就需要在生成树中有一个非终结符节点N,且N已经有了一个直接或间接指向N的子节点。但由于生成过程总会产生节点的副本,所以他布恩那个生成已经存在的节点。因此一个生成树是“非循环”的;有向无环树被称为dags。
明显不可能让语法生成tom,dick,harry,任何试图生成多个人名的企图都会带来结束,而摆脱它(我们也必须摆脱,因为这是一个非终结符)的唯一办法就是把第3条规则吸收进来,而这会产生一个“和”。神奇的是,在一个只使用“可能被替换”的系统中我们成功的实施了“必须替换”这一概念;仔细看一下,我们就会发现,我们将“必须替换”分成了“可能替换”和“必须不能是一个非终结符”。
除了我们的标准例子,语法当然还会生成一些其他的句子;例如:
harry and tom harry tom, tom, tom and tom
以及其他更多的。一个决绝和鲁莽的尝试,生成没有“和”的不正确的形式将会让我们得到这样的句子形式,像
tom, dick, harry End
这样的,不是句子的句子形式,而其没有生成规则适用于它们。这种形式被称为死胡同。就像生成规则中的右箭头表明的一样,在相反的方向上规则可能就不适用了。
2.2.3 形式语法的表达力量
形式语法的主要属性就是它有生成规则,可用于重写部分句子形式(=正在构建的句子),和产生所有句子形式的起始符。在生成规则中我们发现有终结符和非终结符;而在完整的句子中只有终结符。这大概就是:剩下的部分就看语法作家和句子创造者的创造力了。
这是一个令人印象深刻的简洁的框架,然后就马上有了一个问题:这就足够了吗?这很难说,但是如果这还不是最好的,那我们暂时也没有更好的表达形式了。听起来可能有点奇怪,所有其他人们已知的生成集合,都被证明相同或甚至不那么强大,比起短语结构语法。明显生成一个集合的方法,当然最简单的是写一个程序来生成,不过这也证明了所有可以用程序生成的集合都可以用短语结构语法来生成。甚至还有一些神秘的方法,但最终都被证明不够具有表现力。另一方面,也没有证据能表明还存在更强大的方法。但鉴于许多完全不同的方法最后都由于相同的阻碍而停止,所以几乎不可能1在找到一个更强大的方法了。见例子Révész [394, pp 100-102].
作为表现力的另外一个例子,我们应该谈谈Manhattan龟的运动的语法。 Manhattan龟只能在平面上运动,且只能往东南西北方向运动,每次运动距离为一个格子。图Fig 2.5给出了所有可以回到原点的循环路劲。根据第2条规则,应该说明,许多作者需要在左侧至少有一个非终结符的符号。这条规则总是可以强制执行通过添加新的非终结符。
简单的东南西北循环路径已经在图Fig 2.6中展示(使用首字母缩写)。注意规则(ε)中的空替代,这导致了上图中第三个M后的结束。
Paul Vitány指出如果科学家说神秘东西“几乎不可能” has pointed out that if scientists call something “highly unlikely” they are still generally not willing to bet a year’s salary on it, double or quit.
2.3 语法和语言的Chomsky层次结构
图Fig 2.3和2.5中的语法都很容易理解,而事实上一些简单的短语结构语法可以生成复杂的集合。然而,对于任何一组给定的语法通常都很难说是简单的。(虽然语法有无限的集合,但是在这我们还是说“一个给定集合的语法”。通过一个集合的语法,我们来解释任何做了同样工作的语法,并且不是过于复杂的。)理论上说如果一个集合最终可以生成(例如,通过一个程序),那么它就可以通过一个短语结构语法来生成,不过理论上没有表明这样做很容易,或者说语法是容易理解的。在这一节中要说明Manhattan龟的路劲尽量写出其语法,这些路劲中龟总是不能从起始点向西爬行的。(问题2.3)。
不考虑短语结构语法带来的智力问题,他们也表现出了基本和实际的问题。我们可以看出对这些集合没有普适的解析算法,而所有已知的特殊算法,要么效率很低要么就是很复杂;见3.4.2节。
也就是在尽可能的保持它们的生成力的情况下,约束短语结构语法超出控制的情况,这样就有了语法的Chomsky层次结构。这种层次结构分为了四种类型的语法,编号为0到3;引入第5种类型,也就是被称为类型4的类型,是有意义的。0型语法是(无限制的)短语结构语法,我们已经看到了它的例子。其他类型源于越来越被限制的语法规则中被允许的形式。每一种限制条件都带来深远的影响;最终语法逐渐变得更易于理解和掌握,但也逐渐不那么强大。幸运的是,这些不那么强大的语法依旧十分有用,甚至实际上比0型更有用。
我们现在先按顺序来看看前三个类型,然后接着在看看琐碎但有用的第四个类型。
生成0型语言的完全不同的方法,见示例Geffert[395]。
2.3.1 1型语法
0型语法的特征是,它包含一条规则是可能将任意数目(非零)的符号转化为任意数目(可能为零)的符号。例如:
, N E ---> and N
如上三个符号被两个符号取代。通过限制这种自由,我们引入1型语法。奇怪的是关于1型语法的,直觉上完全不同的两个定义,却很容易被证明是相同的。
2.3.1.1 1型语法的两个类型
如果一种语法它不包含规则,左侧的符号比右侧符号更多,那它就是1型单调,。例如,禁止这个规则 , N E ---> and N.
如果一种语法的所有规则都是上下文相关,那它就是1型上下文相关。上下文相关就是,如果左侧只有一个符号(非终结符)被其他符号替代,然后我们把右侧的符号都找到且没有损坏且按顺序放置。例如:
Name Comma Name End ---> Name and Name End
这就说明这个规则
Comma ---> and
可能适用,如果左侧文字是Name而右侧文字是Name End。上下文本身不受影响。替代者必须是至少一个符号长。这意味着上下文相关语法总是单调的;见2.5节。
这里是t,d&h例子的一个单调语法。在书写单调语法时,必须要小心绝不能多生成一个后面本来就会生成的符号。我们通过将结束标记纳入最右侧的名字来避免需要删除键鼠标记:
当EndName是一个单独的符号时。
这就是它的一个上下文相关语法。
请注意,我们需要一个额外的非终结符逗号来产生终结符,并在正确的上下文中。
单调语法和上下文相关语法时同样强大的:每一种可以由单调语法生成的语言,都可以由上下文相关语法生成,反之亦然。但它们都不如0型语法强大,也就是说,可以由0型语法可以生成任何一种1型语法都无法生成的语言。但奇怪的是没有这样的语言被人们所知道。虽然0型语法和1型语法的区别是基础层面的,而且不是Chomsky先生一时兴起的,它们之间差异的语法太复杂而不能写下来;而只能证明这差异的存在(例如Hopcroft and Ullman [391, pp. 183-184], 或 Révész [394, p. 98])。
当然任何类型的1型语法都属于0型语法,因为1型语法是在0型语法的基础上,增加了一些限制条件推导而来。但如果将1型语法也称为0型语法就会引起混乱;就像把猫叫做哺乳动物一样:正确但不精确。一种语法是按照最小适用类型来命名的(也就是,最高类型数)。
我们可以看到语言t,d&h,先由0型语法生成,也可以由1型语法生成。我们应当也能知道同样还有2型和3型语法适用,但没有4型语法。所以我们说语言t,d&h是一种3型语言,经过限制层数最多(最简单和适合)的语法之后。关于此的一些推论:一个n型语言可以由n型语法或更强大的语法生成,但不能由比较弱小的n+1语法生成;以及:如果一种语言是由n型语法生成,那并不意味着(更弱小的)n+1型语法无法生成它。t,d&h语言的0型语法是一个严格的例子,而只是为了演示目的。
2.3.1.2 构建一个1型语法
1型语法的标准例子就是包含相同数目的a,b,c的集合,按照以下顺序:
为了完整起见,以及展示1型语法如何被书写出来,如果够聪明的话,我们现在应该得出这个玩具语言的语法。从最简单的例子开始,我们有了以下规则:
0、S ---> abc
获得了S的一个实例,我们可能会想要在开头追加更多的a;如果我们想要记住已经有多少了,那同时我们就应该附加一点东西在结尾处,而且不能是一个b或者c。我们应该使用一个目前未知的符号Q。追加后的规则如下:
1、S ---> aSQ
如果我们用该规则,例如,使用三次,我们就得到下列句子形式:
aaabcQQ
现在,若要从这中间获得aaabbbccc,每一个Q必须等同于一个b和一个c,如预期,但我们不能这么写:
Q ---> bc
因为这会导致第一个c后面是b。因此,如果限定更换只允许在左边是b右边是c的情况下发生,那上述规则就是可行的。新插入的bc就不会造成影响,如下:
2、bQc ---> bbcc
不过,我们不能应用此规则,因为通常Q是在c的右边。可以通过允许Q向左跳过一个c来纠正:
3、cQ ---> Qc
现在,我们可以完成我们的推导:
应该指出的是,上述推导只显示语法能生成正确的字符串,读者需要说服自己,语法不会生成别的东西或不正确的字符串(问题2.4)。
语法总结为了图Fig 2.7。因为a3b3c3的推到图已经相当笨拙,
所以图Fig 2.8给出了a2b2c2的推到图。
语法是单调的,因此属于1型。可以证明这个语言不符合2型;见2.7.1节。
虽然只有上下文相关的1型语法拥有被称为上下文相关语法(CS语法)的权利,但这个名字经常被使用在单调1型语法上。对于单调语法没有标准缩写,但MT可以用来代替。
2.3.2 2型语法
2型语法被称为上下文无关语法(CF语法),其和上下文相关语法之间的关系如它们的名字所示。上下文无关语法和上下文相关语法是相似的,只是左侧和右侧的文字必须是空缺的(空的)。因此语法可能只包含一个规则,在其左侧只有一个非终结符。语法示例:
2.3.2.1 独立生成
由于左侧总是只有一个符号,生成图中的每个节点具有这样的属性,无论其生成什么都独立于其相邻节点的生成:非终结符的生成周期独立于其上下文。我们在图2.4,2.6,2.8中见到的星型结构不可能发生在上下文无关语法中,因此就会有一个纯粹的树形结构,其被称为生成树。示例如图2.9。
由于左侧只有一个符号,对于一个给定非终结符的所有右侧,可以放在同一个语法规则的集合中(在上述语法中我们已经做了),然后每个语法规则就像一个左侧的定义:
- 一个Sentence是后面接着and接着Name的Name或是List.
- 一个List是Name后面接着一个a,或接着List或是Name.
这显示了上下文无关语法构建字符串是经过了两个过程的:拼接(“...接着...”)和选择("要么...要么....")。除了这些过程在这里是识别机制,其链接了在右侧使用的非终结符的名字和定义规则(“...是一个...”)。
在这章节的开头,我们确定了一门语言作为字符串的一个集合,起始符号的终结符的生成物的集合。独立生成物的属性让我们可以把这个定义扩展到这个语法的任何非终结符上:每一个非终结符都生成一个集合、一门语言,独立于其他非终结符生成的。如果我们将A生成的字符串集合写作L(A),而且A有两种可选的生成规则,A → α|β,那么L(A) = L(α) L(β),代表集合的并集运算符。这对应于上一段说到的选择。如果α由PqR三个字符组成,我们就有L(α) = L(P) L(q) L(R),代表字符串的串联运算符(实际上上集合中的字符串)。这对应于上一段说到的拼接。而L(a)中的a是终结符,属于集合{a}。语言中包含ε的非终结符被称为空。也可以说“生成空”。
注意,我们不能定义图Fig 2.7中Q的语言L(Q):Q本身不能生成任何有意义的东西。为非起始符号定义语言只能在2型语法或更低级的语法上可能实现,将非起始的非终结符廷议为空也是一样的。
有关独立生成属性就是递归的概念。非终结符A是递归的,如果句子形式中的这个A可以生成再次包含A的东西。图Fig 2.9中的生成物开始于句子形式的Sentence,其使用了规则1.2来生成List和Name。下一步很可能是用Name,List来替换List,使用规则2.1。我们看到List生成了再次包含List的东西:
Sentence ---> List and Name ---> Name , List and Name
即List是递归的,尤其是,它是直接递归。非终结符A在A--->Bc, B--->dA中是间接递归的,不过这之间的差异并没有太多意义。
比之更重要的是List是右递归的:一个非终结符A是右递归的,如果它可以在右侧生成包含A的东西,则List为:
List ---> Name , List
同理,一个非终结符A是左递归的,如果它可以在左侧生成包含A的东西:我们就可以定义
List ---> List , Name
一个非终结符A是自嵌入的,有这样一个定义:如果A能在生成一个,左侧是α右侧是β中间依旧是A的东西。自嵌入描述了嵌套:α是进入另一层嵌套时生成的;β是结束这一层嵌套时生成的。嵌套最著名的例子是在算术表达式中括号的使用:
一个非终结符可以同时时左递归的和右递归的;它就是自嵌入的。A--->Ab|cA|d就是一个例子。
如果一个语法中没有非终结符时递归的,则每一个生成步骤使用一个非终结符,因为这个非终结符绝不会再次出现在这个段中。所以生成过程不可能无限持续,结果就产生了一个有限的语言。递归是语法生命周期所必须的。
2.3.2.2 一些例子
在现实世界中,很多事物是根据其他事物来定义的。上下文无关语法是一种非常简洁的方式来制定这种相互关系。一个最浅显的例子就是一本书的组成,如图Fig 2.10所示。
当然这是关于一本书的上下文无关描述,所以人们也可以期待其生成很多冠冕堂皇的废话,就像
不过至少可以保证结果能得到正确的结构。文档编制和文本标记系统,如SGML、HTML、XML使用这种方式来表达和控制文档的基本结构。
一个比较简短但相对重要的例子是,使所有电梯回到原始出发点的运动的语言(被限制在第五大道的Manhattan龟也会有一样的运动轨迹)
(我们假设电梯井使无限长的;第五大道也一样)。
如果我们忽略足够多的细节,我们也能认识到自然语言的句子中底层的上下文无关结构,例如英语:
英语中生成像这样的句子:
the well-read cats criticize the wistful caterpillar
然而,因为没有上下文被纳入,它也会生成不不正确的形式
the cats admires the white well-read castle
为了保持上下文我们需要使用短语结构语法(为了更简单的语言):
标记Singular和Plural控制实际英文单词的生成。仍然,这个语法允许猫吠叫...。一种更好的控制上下文的方式,详见15章的各小节,尤其是Van Wijngaarde语法(15.2节)以及属性和词缀语法(15.3节)。
大部分的CF语法的列子来源于编程语言。这些语言(也就是程序)中的句子都必须自动处理(也就是,通过编译器),而且很快(大约1958年)人们就承认如果语言有一个良好定义的形式语法将会变得更容易。现今使用的所有编程语言的句法都是通过形式语言定义的。
一些作者(例如Chomsky)和一些解析算法,要求CF语法是单调的。CF规则为非单调的唯一方式是右侧为空。这一规则被称为ε法则,而不包含这类法则的语法被称为免ε。
免ε并不是一种限制,而只是一种妨碍。几乎任何一种CF语法都可以变成免ε的,通过ε法则的系统性替换;唯一的例外是一个语法的开头符号已经生成了ε。转换过程在4.2.3.1节有详细介绍,不过这里同时也介绍了其他很多语法的转换,而且不幸的是通常会破坏语法的结构。这个问题将在2.5节进一步讨论。
2.3.2.3 注释样式
编程语言中的CF语法有几种不同的注释样式,每个都有无尽的变形;不过它们的功能都是一样的。这里我们给出了两种主要形式。第一个是Backus-Naur形式(BNF),最初用来定义ALGOL 60的。示例如下:
这个形式的主要属性是通过使用尖括号(<>)将非终结符括起来,"::="来表示“可能生成”。在一些变形中,由分号结尾。
第二种样式是CF van Wijngaarden语法的。示例如下:
结尾符号用**...符号**;它们的表示是硬件依赖的而且不在语法中定义。规则都是正确终止的(以句点结束)。标点符号总是或多或少的按照传统方式使用;例如,逗号联结比分号要紧密。标点符号如下:
上述语法的第二条规则应该读作:“一个sentence被定义为一个name或一个list,其后跟着一个and-symbol然后跟着一个name,除此没有别的了。”虽然这种表达方式仅只在适用于两级Van Wijngaarden语法时能得到最大展现,它依旧有自己的优点:它非常形式且可读性很强。
2.3.2.4 CF语法扩展
CF语法通常是既比较紧凑又可读性较高的,通过为频繁使用的结构引入特殊的短项。如果我们回到图 2.10中的书本结构语法,将会看到类似下面这种规则频繁出现:
SomethingSequence ---> Something | Something SomethingSequence
在上下文无关语法的扩展中,我们可以用**Something+来表示“one or more Something”,而且我们不需要专门给出Something+**的规则;于是有了下面的隐式显示:
Something+ ---> Something | Something Something+
同样,我们可以用Something*来表示“零个或多个Somethings”,以及Something?来表示“零个或一个Something”(也就是说,可选择的Something)。这些例子中,符号+,*和?要与前面的符号一起起作用。它们的范围可以扩展,通过括号:**(Something ;)?来表示“选择一个Something-followed-by-a-;”。这些措施非常有用并且让书本结构的语法可以更高效的写出来(图Fig 2.11)。有一些样式甚至允许Something+4这样的结构,用以表示“一个或不超过4个的Somethings”,或者是Something+,**来表示“一个或多个通过逗号分隔的Somethings”;这似乎可以作为把一件好事做过头的例子。这种表示法被称为BNF扩展(EBNF)。
EBNF语法扩展不会增加其表现力:所有隐式规则都可以变成显式,结果得到一个BNF的正常CF语法注释。它们的力量在于其用户友好性。X的星号表示“一连串的零个或多个X*”,其被称为Kleene星号。如果X是一个集合,那X应该表示“一连串的零个或多个元素X”;这与我们在2.1.3.3节中提到的Σ中的星号一样。涉及到重复运算符,+或者?以及分隔运算符 ( 和 ) 的运算形式被称为正则表达式*。右侧拥有正则表达式的EBNF,就是其偶尔被称为右侧正则语法RRP语法(regular right part grammar)的原因,这个称呼比“上下文无关语法扩展”要更具表现性,不过也有人说这个名称有点像绕口令。
关于RRP语法的结构意义有两个不同的思想流派。一个流派主张规则应该是这样的:
Book ---> Preface Chapter+ Conclusion
上述应该是下面的缩写:
Book ---> Preface α Conclusion α ---> Chapter | Chapter α
这是“(右)递归”的解释。它的优点是易于解释而且转换到“正则”CF语法很简单。缺点是转化过程包含了隐式规则(用α表示)而且,例如一本有四章的书的不平衡生成树不对应于我们关于这本书的结构的本来想法;见图Fig 2.12。
第二种流派主张
Book ---> Preface Chapter+ Conclusion
上述是下面的缩写:
这是“迭代”的解释。它的优点是能产生一个优美的生成树(图Fig 2.13),但是缺点是它涉及到了无限多的生成规则而且生成树的节点有着变化的fan-out。
因为迭代解释的实施是不会妄自菲薄的,最具实用性的解析生成器在一些时候使用递归解释,然而大多数研究都是关于迭代解释的。
2.3.3 3型语法
CF语法的基本属性是它描述的是嵌套:一个对象可能在不同地方包含其他对象,这些对象又可能包含其他对象...等等。在生成过程中我们已经生成了对象中的一个,而右侧依旧“记得”接下来该生成什么:在英语语法中,在升入非终结符Subject之后生成类似wistful cat的东西,右侧的Subject Verb Object依然知道后面必须是一个Verb。当我们正在准备Subject时,Verb和Object已经在句子形式的右侧排队等待了,例如:
the wistful QualifiedNoun Verb Object
右侧是:
up ZeroMotion down ZeroMotion
已经执行了一个up和一个任意的复杂的ZeroMotion后,右侧依旧知道接下来是一个down。
对3型的限制不允许回溯以前的事情:一个右侧可能只包含一个非终结符并且其必须在结尾处。这意味着只有两种规则:1
- 一个非终结符生成零个或多个终结符
- 一个非终结符生成零个或多个其后接着一个非终结符的终结符
3型语法的原始Chomsky定义对规则进行了限制:
- 一个非终结符生成一个终结符。
- 一个非终结符生成一个其后接着一个非终结符的终结符。
我们的定义是等效的并且更加方便,虽然转换为Chomsky3型并不是完全没有意义的。
3型语法也被称为正则语法(RE语法)或有限状态语法(FS语法)。以上版本的更精确定义称为右正则,因为规则中唯一的非终结符出现在右侧的右结尾处。这让其有别于左正则语法,其受到以下限制
- 一个非终结符生成零个或多个终结符
- 一个非终结符生成一个其后接着零个或多个终结符的非终结符
其规则中唯一的非终结符出现在右侧的左结尾处。左正则语法相比于右正则语法不那么直观,且不常出现,以及更难处理,但它们确实偶尔会出现(例子见5.1.1节),并且也需要加以考虑。将会在5.6节进行讨论。
由于右正则语法的普遍性高于左正则语法,“正则语法”一词通常是指“右正则语法”,左正则就需要明确指明了。本书中我们也遵照此约定。
比照右正则和右递归的定义是一件有趣的事情。非终结符A是右递归的,如果它能生成一个右侧结尾是A的句子形式;或A是右正则的,如果它能生成一个包含A的句子形式,且A在其右侧结尾处。
在对上下文无关语法的类比中,其被称为在此之后它们不能做到,正则语法应该被称为“非嵌套语法”。
因为正则语法经常被用来描述字符级的文本结构,所以正则语法的终结符号是一个单一字符是很常见的。所以我们应该用t代替Tom,d代替Dick,h代替Harry以及**&代替and**。图Fig 2.14(a)以这种形式展示了t,d&h语言的右正则语法,Fig 2.14(b)展示的是左正则语法。
3型语法(右正则)的一个句子的生成树退化为非终结符的“生成链”,该链在左侧放置了一列终结符。图Fig 2.15展示了一个例子。相似的生成链由左正则语法形成,其终结符放在左侧。
图Fig 2.14中语法展现出来的致命重复对正则语法来说很正常,然后发明了很多符号处理设备来减弱这个问题。最常用的是使用一组方括号来指出“一组字符集之外的一个”:[tdh]是t|d|h的缩写:
这个第一次看上去可能很神秘,但实际上却更方便,而且能将语法简化至:
第二种方式是引入宏,语法片段的代名词,其在被使用前被恰当的在语法中进行替代:
正则语法的流行解析生成器lex(Lesk and Schmidt [360])都表现了这些便利性。
如果我们坚持3型的Chomsky定义,那我们的语法将被不会小于:
这个形式比较容易处理,但是比起les版本缺少用户友好性。我们在这里看形式的语言学家(formal-linguist)是否感兴趣且是否对我们有帮助通过微小的方式,计算机科学家重视语法($Name, etc.)之下的概念被清楚表达的形式,虽然有需要额外处理的代价。
关于正则语法有两个有趣的观点,我们来看一下。第一个,当我们使用正则语法来生成句子,句子形式将会只包含一个非终结符并且它将总是在结尾处;如下图(使用图Fig 2.14中的语法):
第二个观点是,所有的正则语法都可以缩减体积通过使用2.3.2节中介绍的正则表达式操作符*,+和?,来分别表达“零个或多个”,“一个或多个”以及“选择一个”。使用这些运算符和**(以及)**来进行分组,我们就可以将语法简化为:
Ss ---> (( [tdh], )* [tdh]& )? [tdh]
这里用括号来标定操作符和?的操作数。所有3型语法都存在正则表达式。注意,和+与它们前面的字符一起生效。为了将它们和普通的乘法和加法运算区分开,它们通常是作为上角标打印的,但是在计算机输入中它们和另外两个符号是在同一行上的,所以必须用其他方式来进行区分。
有一个自然的中间类型,2.5型,其中只允许一个右侧有一个单一的非终结符,不过不需要一定在结尾处。这就给了我们所谓的线性语法。
2.3.4 4型语法
我们现在应该谈到的在生成规则中的最后一条限制是:右侧不允许出现非终结符。这从机制上移除了所有的生成力,除了被选中的可选项。起始符号有一列(有限的)可选项,我们所允许的;这个在名称中有所展现,有限选项语法(FC语法)。
对语言t,d&h是没有FC语法的;然而,如果我们愿意将其限制在数量有限(例如不超过一百长度)的名字列表中,那就可以有一个FC语法了,因为可以枚举所有的组合。对于这显然在限制内的三个名字,我们就有了:
Ss ---> [tdh] | [tdh] & [tdh] | [tdh] , [tdh] & [tdh]
以上总计 3+3×3+3×3×3 = 39 条生成规则。
FC语法不是官方Chomsky层次结构的一部分,因为这不是Chomsky定义的。不过它们仍然非常有用,而且常在一些处理或推理中被要求使用。可以用FC语法来描述编程语言中的保留字段集(关键字)。虽然不是很多语法完全是FC,不过很多语法的一些规则是有限选项的。例如,我们第一个语法的第一条规则(图Fig 2.3)就是FC。另一个FC规则的例子是2.3.3节中介绍的宏。我们不需要宏机制,如果我们这样改改:
zero or more terminals
上述正则语法定义改为:
zero or more terminals or FC non-terminals
最后,FC非终结符只会引入有限的终结符。
2.3.5 结论
图Fig 2.16的表格总结了在生成一个字符串时会出现的最复杂的数据结构,在相关语法类型中使用到的。参见图Fig 3.15,在解析中获得的相应的数据类型。
2.4 用语法生成句子
2.4.1 短语结构案例
直到现在,我们用语法只生成了一个句子,一个特定的时尚,不过语法的目的是生成所有句子。幸运的是还有系统可以做到这个。我们用anbncn语法来做例子。我们从起始符号开始,然后用系统尝试所有可能的替换来生成所有的句子形式;我们只需要等着看哪些句子在什么时候演变成句子。手动完成10个句子试试。如果我们不小心,很容易就会生成像aSQ, aaSQQ, aaaSQQQ,...这样的形式,而且我们将永远也看不到一个完整的句子。原因是我们太过专注于一个单一的句子形式:我们应该给所有的句子同样的时间。这可以通过下面的算法完成,其保持句子形式的一个队列(也就是一个列表,表中是我们要加到结尾和从开头删除的元素)。
从起始符号开始,作为队列中唯一的句子形式。现在继续以下操作:
- 考虑队列中的第一个句子形式。
- 从左到右扫描它,寻找符合左侧生成规则的字符串。
- 发现的每一个这样的字符串,复制足够的句子形式,替换每一个符合左侧生成规则的字符串,通过规则中不同的选项,然后把它们全部添加到队列末尾。
- 如果原始句子形式不包含任何非终结符,把它作为语言中的一个句子写下来。
- 扔掉原始的句子形式;它已经被处理完成了。
如果没有匹配的规则,并且句子形式不是一个完成的句子,那这就是一条死胡同;它们会被上述过程自动删除,不留任何痕迹。
因为上述过程枚举了PS语言中所有的字符串,PS语言也被称为递归可枚举集,这里的“递归”是指“通过一个可行的递归算法”。
图Fig 2.7中anbncn语言的处理过程的开始几步,展示在图Fig 2.17中。队列向右运行,其第一项在左侧:
可以看到,并不是每次我们打开曲柄都可以得到一个句子;实际上,在这种情况下真正的句子十分稀少。当然是因为在这一过程中,发展出了很多的侧边线,而这都需要同等的重视。不过我们可以肯定,每一个可以生成的句子最终都会生成:我们不会放过任何可能。这种方式被称为广度优先生成;而计算机做的比人要好。
很容易认为对我们在顶层句子形式中发现的所有左侧内容全部替换是不必须的。为什么不只替换第一个,然后等下一个句子形式出现,在替换下一个?然而这是错误的,因为替换第一个有可能会导致第二次替换时上下文混乱。一个简单的例子就是下面的语法:
第一次替换A--->b将会走到死胡同里,那语法将什么也不会生成。做两个可能的替换也将导致同样的死路,但是却依旧会有第二个句子形式,ac。也有一个语法例子,其中队列在一段时间(很短)后将会变空。
如果语法是上下文无关的(或正则),那就不会有所谓的上下文被破坏了,那么替换第一个(或只替换)匹配的就是很安全的了。
这里有两点要说明。第一,经过我们努力之后得到的句子并不一定就会是我们想要的那个:很可能每一个新的句型又包含着一个非终结符。我们应该通过检查语法事先知道,不过可以证明对PS语法来说是不可能做到的。形式的语言学家(formal-linguist)说“一个PS语法是否生成空集是不可判定的”,这意味着,没有一个算法能明确得出每一个PS语法最终是否能生成一个句子。不过这不意味着我们不能对于给定的算法证明其无法生成句子,如果该语法是这样的情况。这意味着证明方法不适用于所有的语法:在一定时间内能完成那我们就可以有一个程序,而如果是不能完成的那时间就无法估测了。实际上,上述生成过程就是一个确切告诉我们答案是“是\否”的算法(虽然我们可以有一个告诉我们“是\不知道”的算法,在有限时间内)。虽然由于语言的一些深层属性导致我们不能总是确切得到我们想要的答案,但这并不会妨碍我们获取各种信息以更加了解语法。我们应该看到这是一种反复出现的现象。计算机科学家知道形式语言学的绝境,但他们并不气馁。
第二点是,当我们确实从上述生成过程中得到句子的时候,它们却可能是通过不可探寻的顺序生成的。对于非单调语法,句子形式可能短暂增加然后急剧缩小,甚至可能变成空字符串。形式语言学证明,不会有一个适用所有PS语法的算法来生成长度增加(实际上是“非递减”)的句子。换句话说,PS语法的解析问题是不可解决的。(虽然术语是可以互换的,但似乎使用“不可判定”来描述“是\否”问题以及“不可解决”来描述“解析问题”要更合理。)
2.4.2 CS案例
上述的语言生成过程也适用于CS语法,除了有关确定性的那部分。因为在进行中的句子形式绝对不会收缩,所以句子的长度是以单调递增顺序生成的。这意味着如果空字符串不是第一个字符串,那它将再也不会出现了,而且CS语法不会生成ε。此外,如果我们想知道一个给定字符串w是否在语言中,我们可以等着看它是否会出现,如果出现那么答案就是“是”,而如果我们看到生成了长度更长的字符串,那答案就是“否”。
由于CS语言的字符串可以被一个可能的递归算法识别,所以CS语言也被称为递归集。
2.4.3 CF案例
当我们通过CF语法生成句子时,很多事情都简单很多。虽然我们的语法可能永远不会生成句子依旧有可能发生,不过我们现在可以事先进行检测,如下。首先,扫描语法以找出所有的非终结符,其对应右侧拥有终结符或为空的。这些非终结符确保能生成东西。现在再次扫描找到对应右侧只有唯一终结符的非终结符,这些非终结符确保能生成东西。这将给我们新的保证能生成东西的非终结符。重复这个过程直到不在有新的非终结符产生。如果按这种方式始终找不到起始符号,那这个语法将不会生成什么了。
此外我们已经看到如果语法是CF形式的,我们就能每次重写最左侧的非终结符(直到我们重写了所有的可选项)。当然我们也可以一直重写最右侧的非终结符。这种方法类似但也有不同。通过下面的语法:
让我们跟随句子形式历险,而这将最终导致d,h&h。虽然这将经历几次生成队列,但我们在这里只描述对它做了哪些改变。图Fig 2.18展示了句子形式最左侧或最右侧的替换,根据涉及到的规则和备选项;例如(1b)表示规则1备选项b,第二个备选项。
生成规则使用的序列与我们所期望的不同。当然在总体上,相同的规则和替代方案也是适用的,但序列既不相等也不是彼此的镜像,也没有其他什么关系。两个序列都定义了同一个生成树(图Fig 2.19(a)),但如果我们按重写的顺序对其中的非终结符进行编号,我们将得到不同的数字,如图(b)和(c)所示。
在最左侧重写中使用的生成规则序列,被称为句子的最左侧派生(leftmost derivation)。我们不必说明哪个位置必须适用规则,也不需要给出其规则编号。只是另一种选择就足够了;位置和非终结符是隐式的。*最右边派生(rightmost derivation)*是以类似的方式定义的。
最左侧的生成步骤可以用一个标记了小写l的箭头来表示:N,L&N d,L&N,以及最左侧的生成序列:
S L&N N,L&N d,L&N d,N&N d,h&N d,h&h
可以缩写为 S d,h&h。同样,最右侧生成序列:
S L&N L&h N,L&h N,N&h N,h&h d,h&h
可以缩写为 S d,h&h。实际上S以任何方式生成d,h&h被写成Sd,h&h。
解析的任务是为一个给定字符串重建推导树(或图)。一些非常有效率的解析技术可以被更容易的理解,如果将之视为试图重建输入语句的最左或最右推导过程的话;然后解析树就自动生成了。这就是为什么“[ 最左|最右 ]推导([ left|right ]-most derivation)”概念在本书中频繁出现的原因(请注意此处使用的FC语法)。
2.5 收敛或不收敛
在前面的段落中,关于是否一个规则的右侧要比其左侧简短,有时我们很明确但有时我们又是含糊不清的。0型规则应该说肯定是收缩的类型,而单调型规则肯定不是,2型和3型只有在生成空集(ε)时才是收缩的;这些都是肯定的。
原始Chomsky层次结构(Chomsky[385])在这个问题上非常坚定:只有0型规则才能使句子形式收敛。1型、2型和3型规则都是单调的。此外1型规则必须使上下文相关类型,这意味着左侧非终结符的只有一个是被允许替换的(且不由ε替换)。这带来了一个合适的层次,使得每一层都是其父集的一个合适子集,以及使除了0型语法外的所有派生图实际上都是派生树。
作为一个例子,考虑一下图Fig 2.7中语言**anbncn**的语法:
它是单调的,但不是严格意义上的上下文相关。可以通过展开那烦人的规则3和为c引入一个非终结符来使其成为CS:
现在图Fig 2.8的生成图变成了一个生成树:
还有另一个理由回避ε规则:它让定理和解析器都变得更复杂,有时尤其复杂;例如9.5.4节。因此就出现了一个问题,我们到底是为什么要纠结ε规则呢;答案很简单因为这对语法作者和使用者来说十分方便。
如果有一个语言是使用ε规则的CF语法来描述的,而我们想要使用一个不含ε规则的语法来进行描述时,那这个语法将会是非常复杂的。假设我们有一个系统,可以输入比特信息,比如“Amsterdam is the capital of the Netherlands”,“Truffles are expensive”,那就会被问道一个问题了。在一个非常浅显的水平上,我们可以将其输入定义为:
inputs: zero-or-more-bits-of-info question
或者,以一种扩展的记法
inputs: bit-of-info* question
因为zero-or-more-bits-of-info将会生成空字符串,在其他字符串之间,至少在其语法中使用的规则之一是ε规则;在扩展记法中的 * 已经意味着ε规则了。 在使用者的角度来看,上述输入的定义很好的解释了这个问题,并且就是我们想要的。
任何试图为这个输入写ε规则,最终都会定义一个概念,包含后来的bits-of-info和问题一起(因为这个问题是唯一的非空部分,所以它必须出现在所有有关的规则中)。但是这个定义根本不是我们想要的,而且它是一个半成品:
随着语法变得越来越复杂,其是ε无关的要求就变得越来越令人讨厌:语法在和我们作对,而不是在为我们工作。
从理论角度来看这不成问题:任何CF语法都能被一个ε无关语法描述,并且ε规则在也不被需要。更妙的是任何带有ε规则的语法都能被转化为ε无关的语法,作为同一种语言。在以上示例中我们看到了这种转变,而算法详细将在4.2.3.1节讲述。但是我们付出的代价是,对任何语法的转换:这不在是我们的语法,并且它极少的反应原始结构。
底线是研究人员发现ε规则是一个有用的工具,并且除了通常的Chomsky层次结构外,是否存在非单调语法的层次结构,我们拭目以待。一个更大的扩展:2型和3型语法不需要是单调的(因为如果有需要,它们总是可以变成这样);并且收敛的上下文相关语法总是等同于无限制的0型语法;而蕴含ε规则的单调语法总是等同于0型语法。系暗转我们可以把这两个层次画在一张图里面;见图Fig 2.20。将不同作用的语法类型用线条分隔。作用相同但理论上不同的语法用空格分隔。可以看到,如果我们坚持非单调性,那0型和1型的区别就消失了。
如果1型到3型语法本身包含空字符串,那就出现一个特殊的情况。这不能被纳入单调层次结构的语法中,因为其起始符号长度已经为1且没有单调规则能让它收敛。所以空字符串应该被重视作为语法的一个特殊属性。这样的问题不会出现在非单调层次结构中。
许多解析方法原则上只为ε无关语法工作:如果一个东西什么都不能产生,那你可能不太容易发现它是否在那。通常解析方法可以修改来控制ε规则,但这总是会增加方法的复杂度。这么说可能也是公平的,这本书将薄30%,如果ε规则不存在的话——不过,语法就要损失不止30%的价值了!
2.6 生成空语言的语法
在印度当0被作为数字被数学家引入后的大约1500年后,这一概念依旧没有被计算机科学好好接受。许多编程语言不支持0字段的记录,0元素的数组,或0变量的变量定义;在一些编程语言中,调用0个参数协程的语法不同于调用一个或多个参数的协程的语法;许多编译器也无法编译定义0个名称的模块;这些例子还可以很轻松的扩展下去。更具体的说,我们不知道有什么解析器生成器可以生成一个空语言的解析器,这个空语言有0个字符串。
所有这一切都将我们引向一个问题,空语言的语法会是什么样的?首先注意,空语言不同于只包含空字符串的语言,空字符串只是包含0个字符。这种语言很容易由Ss--->ε生成,并且被普通lex-yacc流正确处理。注意,这个语法没有终结符,这意味着2.2节的VT是空集。
对一个语法来说不生成任何东西,那生成过程将不被允许终止。这就有了一个办法来获得这一一种语法:Ss--->S。然而这很令人不齿,有两个原因。从算法角度来说,生成过程只是在循环,而没有获得任何关于语言的空属性的信息;而且符号S的使用是任意的。
另一个方法就是迫使生成过程被卡住,通过让语法中没有任何生成规则。那么2.2节的R就也会是空的了,然后语法的形式就是({S}, {}, S, {})。这还不是很理想,因为我们由一个没有定义规则的非终结符;并且符号S还是任意的。
更好的方式是永远不要让生成过程开始:没有起始符号。这是可行的,通过在语法定义中允许出现一组起始符号而不是一个单一的起始符号。这么做还有其他很好的理由。举个例子,一个大型编程语言的语法,该语言的模块规格、模块定义等有着多重“根”。虽然在顶层这些是不同的,但它们在共同语法上有着大量的段(segment)。如果我们将一个CF语法定义扩展来使用一组起始符号,空语言的语法将获得优雅和令人满意的形式({}, {}, {}, {})。
关于0和空:考虑一下左侧是空的语法规则,可能是有帮助的。这种规则的右侧的最终生成物可能出现在输入的任何地方,因此模拟噪声和其他每天但外部的事件。
我们全神贯注于空字符串、空集、空语言等不是轻浮的,因为这是众所周知的,系统处理空实例的轻松是其洁净度和稳健性的一个衡量。
2.7 CF和FS语法的限制
当使用CF语法工作一段时间后,你就会渐渐感觉到似任何东西都能用一个CF来进行描述。然而,CF语法能描述的却有着严重的限制,按照著名的uvwxy理论的说法,下面将进行解释。
2.7.1 uvwxy理论
当我们从CF语法中获得了一个句子后,我们应该仔细看句中的每一个终结符,然后问自己:它是怎么在这的?然后看一下生成树,我们看到它被生成作为右侧规则m的第n位成员。这个规则的左侧,符号的父节点,再次生成规则Q的第p位成员,等等,直到我们到达起始符号。在某种意义上,我们可以通过这种方式追踪符号的轨迹。如果一个符号的轨迹的所有规则/成员对都是不同的,我们称这个符号是原始的,如果一个句子中的所有符号都是原始的,我们称这个句子是“原始”的。
例如,下面生成树中第一个h:
由下面语法生成的
h的谱系是,来自7,1,来自4,1,来自3,3,来自1,1.这里,第一个数字代表规则数,第二个数字代表这个规则中的成员数。因为所有的规则/成员对都是不相同的,所以h是原始的。
现在对于一个给定的符号,只有有限的方式来让其是原始的。这很容易,如下所示。一个原始符号的谱系中的所有规则/成员对必须是不相同的,所以其谱系的长度一定不会比语法中所有不同规则/成员对的总长度还长。 There are only so many of these, which yields only a finite number of combinations of rule/member pairs of this length or shorter. 理论中,一个符号的原始谱系的数量可能是非常巨大的,但实际中确实非常小的:如果有超过10种方法来生成一个给定的符号,从语法中通过原始谱系,那这个语法将会是非常错综复杂的!
这对原始句子就有了严格的限制。如果原始句子中一个符号出现两次,这两个的谱系必须不相同:如果谱系相同,那它们应该描述的是用一个位置的同一个符号。这意味着原始长度有着最大长度:所有符号的原始谱系的长度总和。对一个编程语言语法的平均的长度,在数以千计的符号的长度顺序中,大致相当于语法的长度。所以,因为有着最长的原始句子,那么就只能有着有限数量的原始句子,然后我们就得到了一个令人惊讶的结论就是任何CF语法只生成大小有限的原始句子核心,以及(也许)无限数量的非原始句子!
“非原始”句子是什么样的?这就是我们开始介绍uvwxy定理的地方。一个非原始句子具有这一的属性,在谱系中包含至少一个重复出现的符号。假设那个符号是q,重复的规则是A。那我们就可以画一幅类似图Fig2.21的图,w是由A的最新应用生成的部分,vwx是A的其他应用生成的部分,uvwxy就是这个非原始句子。现在我们立即就可以找到另一个非原始句子,通过删除以A为顶点的小三角,然后用以A为顶点的大三角副本替换;见图Fig 2.22。
这个新的树生成了句子uvvwxxy,并且以这种方式,可以很容易看到我们能构建句子uvnwxny的完整家族对于所有n ≥ 0的。这种方式显示了w嵌套于一组v和x括号之间,在u和y无关的上下文中。
底线是当我们审查一个上下文无关语言中的越来越长的句子,原始句子慢慢用尽,我们遇到的只是句子的相近形式的家族,慢慢缩进至无限。这在uvwxy理论中有总结:任何由CF语法生成的句子,比最长的原始句子还要长的句子,都可以被切分称五个部分u,v,w,x,y,以这种方式uvnwxny就是这个语法下n ≥ 0得来的一个句子。uvwxy理论也被称为上下文无关语言的泵引理(pumping lemma),而且由几个变种。
有两点必须在这里指出。第一点,如果一个语言持续生成越来越长的句子,而不减少嵌套句子的族系,那这个语言就不会存在一个CF语法。我们已经遇到了上下文相关语言anbncn,很容易看到(但不是很容易去证明!)它不会衰退成这样的嵌套的句子,当句子变得越来越长时。因此,它是没有CF语法的。这种证明的通用技术见Billington [396]。
第二点是,最长的原始句子是语法的一个属性,而不是语言的。通过为语言制造一个并发的语法,我们能增加原始句子的集合,并且可以推开我们被迫诉诸于嵌套部分的边界。如果我们让语法无限并发,那我们就能使边界变得无限,并从中获得一个短语结构语言。如何将CF语法变得无限并发,将会在15.2.1节中的两级语法中介绍。
2.7.2 uvw理论
uvw理论的简单形式应用于正则(3型)语言。我们已经看到FS语法生成的句子形式全部都仅只包含一个非终结符,在结尾出现。在一个很长的句子的生成期间,一个或多个非终结符必须出现两次或多次,因为只能有有限数量的非终结符。图Fig2.23展示了当我们一个一个列出句子形式时所看到的。
子串v在A的一次出现到下一次之中被生成,u是让我们能到达A的一个序列,w是让我们能终止生成过程的序列。将明确指出,从第二个A开始,我们可能重复了跟第一个A一样的路径,从而生成了uvvw。这将我们引导到uvw理论,或正则语言的泵引理(pumping lemma for regular languages):正则语言的任何一个足够长的字符串都能被切分成u,v,w3个部分,所以对n ≥ 0的所有uvnw都是这个语言的一个字符串。
2.8 作为转换图的CF和FS语法
转换图是一个有向图,其中箭头被标记为零或你生成的一个相关联的符号,如果确实有这样的符号的话,否则就什么也不标记。节点,往往没有标记的,是符号在生成中被放置的点。如果一个节点有多个箭头向外传出,你可以选择任何一个继续往下。所以图Fig 2.24中的转换图产生相同的字符串,2.3.2节的示例语法。
把语法变成一组转换图是相当简单的,一个非终结符对应一个转换图,如图Fig 2.25。但它包含标记了非终结符的箭头,而“生成”一个非终结符的意义与箭头相关联并不是非常明确。假设我们在节点n1,从一个标记了非终结符的转换(箭头)N指向节点n2,而且我们想要这种转换。
我们将节点n2推入堆栈,而不是通过追加到输出来生成N,然后继续我们的进入N的转换图的旅程。当我们结束N的转换图是,我们从堆栈中弹出n2然后在N2继续向前。这就是上下文无关语法的递归传递网络释义:转换图组就是传递网,堆栈机制提供递归。
图Fig 2.26展示了图Fig 2.14中FS语法的右正则规则的转换图。这里我们漏掉了图终点处未标记的箭头和与其相关联的节点;我们本可以和图Fig 2.25中一样做,但这么做将会使堆栈机制变复杂。
我们看到只有当我们要离开一个非终结符的时候我们才需要在生成一个,所以我们不需要堆栈任何东西,并且能解释一个标记了非终结符N的箭头作为到N的转换图的跳转。所以一个正则语法对应于一个(非递归)传递网络。
如果我们将网络结尾处的每一个标记了N的箭头和N转换图的起始处相关联,那我们可以忽略掉非终结符,然后获得相关语言的一个转换图。当我们将这个短路劲应用在图Fig 2.26的传递网络,并稍微重新排列一下节点,我们就得到了图Fig 2.24的转换图。
2.9 上下文无关语法的健全
所有种类的语法都可能包含无用的规则,这些规则在任何成功的生成过程中都不能成为一个有用的角色。一个生成过程是成功的,当它一一个终结符结尾时。生成尝试可能失败,通过卡住(下一步没有可替代的)或者进入一种没有替代序列能移除掉所有的非终结符的境地的情况。0型语法被卡住的一个示例如下:
当我们从S的第一个规则开始,一切都进展顺利并且生成了终结符x。但是当我们从S的第二条规则(规则2)开始时,我们就被卡住了,而当我们从规则3开始时,我们就发现进入了一个无限循环中,生成越来越多的C。规则2、3和5永远都不会产生一个成功的生成过程:他们是无意义的规则,并且也无法在不影响语言生成的情况下从语法中移除掉。
无用的规则并不是根本性的问题:它们不会妨碍正常的生成过程。尽管如此,它们依旧是语法中的枯木,而总有人会想移除它们。并且,当他们出现在由程序员指定的语法中时,它们可能会指向某些错误,那就会想要检测它们并给出警告或错误信息。
上述语法的问题很容易理解,但可以表明,大多数情况下是很难判定在0型或1型语法中的一条规则是无用的:不可能有一种算法能在所有情况下都正确判断。然而,对上下文无关语法来说,这个问题就是相当容易解决的了。
在上下文无关语法中的规则可能是无用的,因为三个原因:它们可能包含未定义的非终结符,从起始符号开始可能无法到达它们,以及它们可能无法生存任何东西。接下来我们会详细讨论这些缺陷;2.9.5节给出了一个算法可以帮助语法拜托这些缺陷。
2.9.1 未定义的非终结符
一些规则的右侧可能包含一个非终结符,其没有对应的生成规则。这样的规则永远也不会出现问题,并且可以被从语法中移除。如果我们这样做,我们可能就移除了另一个非终结符的最后一个定义,而这个非终结符会反过来又变成未定义的,等等。
我们会进一步看到(例如4.1.3节),认可未定义非终结符偶尔也是有意义的。在它们右侧的解释它们的规则,又是可以删除的。
2.9.2 不可到达的非终结符
如果一个非终结符,无法从起始符号到达它,它的定义规则将永远不会被用到,那它也不能为任何句子的生成过程做出贡献。不可到达的非终结符有时也被称为“不被使用的非终结符”。但这个说法有点误导,因为一个不可到达的非终结符A也可能出现在右侧B-->...A...,使它看起来是有用的而让B成为不可到达的;这同样也适用于B,等等。
2.9.3 非生成性规则和非终结符
假设X有其唯一规则X → aX并且假设从起始符号可以到达X。那现在X依然不会对其语言语法中的句子做出贡献,因为一旦引入的X就没有办法摆脱它了:X是一个非生成性的非终结符。此外,任何规则其右侧含有X的都是非生成性的。简单说,任何本身不产生非空子语言的规则是非生成性的。如果一个非终结符的所有规则都是非生成性的,那这个非终结符就是非生成性的。
一个极端的例子就是一个语法中的所有非终结符都是非生成性的。这种情况发生在一个语法的所有右侧都包含至少一个非终结符。然后就没有办法能摆脱非终结符,那这个语法就是非生成性的。
这三种情况合起来就被成为无用的非终结符。
2.9.4 循环
上述定义使所有可以包含在一个句子生成过程中的规则是“非无用”的,但仍有一类规则并不是真的有用:类似A → A 形式的规则。这类规则被称为循环。循环也可以是间接的:A → B,B → C,C → A;而且它们可以被隐藏:A → PAQ,P ε, Q ε,所以生成过程A→PAQ→. . .A. . .→A也是可能的。
一个循环可以合理的而出现在句子的生成过程中,并且如果它确实出现了,依旧会有这个句子的另一个不包含循环的生成过程。循环不对语言最贡献,任何句子的生成过程包含一个循环的无限模糊的,意味着对它来说有无限多的生成树。4.1.2 节中给出了循环检测算法。
不同解析器对带有循环的语法的反应不一样。有些(大部分的普通解析器)诚实的试图去构建无限数量的推导树,另一些(例如,CYK解析器)如上文所述一样将循环折叠起来,还有一些(最具决定性的解析器)拒绝了这样的语法。而ε规则可以隐藏循环加剧了这个问题:一个循环只有当某些非终结符生成了ε时,才是可见的。
一个不含有无用非终结符和循环的语法,才被称为一个正确的语法。
2.9.5 清理上下文无关语法
通常情况下,人们提供的语法不会包含未定义,不可到达或非生成性的非终结符。如果出现了,那几乎可以肯定是一个失误(或者是用来测试的!),然后我们就要检测和报告出来。然而,这种异常情况很容易出现在生成的语法或由其他语法转换所引入的语法中,这种情况下我们就希望能检测到然后“清理”一下语法。清理语法也是十分重要的,当我们获取解析森林语法的解析结果时(3.7.4节,13章以及其他很多地方)。
从一个上下文无关语法中检测和删除无用非终结符和规则的算法包含两个步骤:移除非生成性规则以及移除不可到达的非终结符。令人惊叹的由于未定义的非终结符,似的移除无用的规则并不是必须的:第一步为我们自动完成了这个过程。
我们将使用图Fig 2.27中的语法来进行演示。它看起来相当单纯:它的所有非终结符都是定义了的,而且它也没有表现出任何可疑的结构。
2.9.5.1 移除非生成性规则
我们通过找到一个生成性规则来寻找非生成性的规则。我们的算法取决于观测,如果一个规则的右侧包含的符号都是生成性的则这个规则就是生成性的。终结符是生成性的因为它能生成终结符,空也是生成性的因为它能生成空字符串。如果一个非终结符有一个生成性规则对应于它那它也是生成性的,但问题是起初我们并不知道哪条规则是生成性的,因为这本身就是我们在努力寻找的。
我们解决这个问题,首先通过使所有规则和非终结符都是“不知道”的。现在我们来看图Fig 2.27的语法,对于每一条我们不知道的规则,其右侧的成员都是生成性的,那我们就标记这条规则和它定义的非终结符为“生成性”的。这将为规则A--->a, C--->c, 以及E--->e产生标记,还有非终结符A, C和E。
现在我们知道的更多了,并且可以将这些用于对语法的第二轮扫描了。这使我们能标记规则B--->bC以及非终结符B,因为现在C已知是生成性的了。第三轮确定了S--->AB和S。第四轮没有产生新的东西,所以也就没有进行第五轮的必要了。
现在我们知道S, A, B, C和E是生成性的,但是D、F以及规则S--->DE还是标记“不知道”的。然而我们知道了更多的:知道我们尝试了生成性的所有可能路径,并且没有为D、F以及S的第二条规则找到任何可能的路径。这意味着我们现在可以更新一下对于“非生成性”的“不确定性”的信息了。D、F的规则以及S的第二条规则可以从语法中移除了;结果如果Fig 2.28所示。这就使得D、F成为了未定义的,而S仍然留在语法中因为它是生成性的,虽然有一个非生成性规则。
看看当语法中包含一个未定义的非终结符,例如U,将会发生什么,是一件有趣的事情。首先U将被预定义为“不知道”的,而因为没有规则定义它,它将一直保持“不知道”状态。因此,任何右侧有着U的规则R都将会是“不知道”的。最终两者都会被定义为“非生成性”的,然后所有的R规则都会被移除。可以看到“未定义的非终结符”只是“非生成性”的非终结符的一种特殊情况:因为它没有规则,所以他是非生成性的。
上述知识改进的算法使我们关于闭包算法的第一个例子。闭包算法有两个主要特点:初始化,是对最初了解的一个评估,部分源于其现状和“不知道的”部分;推导规则,介绍从几个地方获取的信息是如何结合的。对于我们的问题的推导规则是:
对于每一个我们知道其右侧的所有成员都是生成性的规则,标记它定义的规则和非终结符为“生成性”的。
推导规则一直重复直到没有不在有任何变化,这一点在闭包算法中是隐式的。然后初步的“不知道”类型就会变成一个更明确的“非X”,“X”是算法被设定来检测的属性。
因为预先知道所有依旧是“不知道”状态的最终都将会变成“非X”,所以许多闭包算法的描述和实现直接跳过整个“不知道”步骤,而直接初始化所有的为“非X”。在执行中,这不会有太大差别,因为计算机内存中位的意义不是在计算机中而是在程序员脑中,但是在打印书本描述中这种做法就是不优雅的也是让人疑惑的,因为初始化语法中的所有非终结符为“非生成性”是不正确的。
本书中我们会看到很多闭包算法的例子;在3.9节中会有详细的讨论。
2.9.5.2 移除不可到达的非终结符
一个非终结符存在至少一个句子形式就可以称为是可到达的或可访问的,从开始出现的起始符号开始。所以如果对一些α和β存在SαAβ那么非终结符A就是可到达的。
我们通过找到“生成性”的规则和非终结符来找到非生成性的那些。同样的,我们通过找到可到达的非终结符来找到不可到达的那些。为此,我们可以使用以下的闭包算法。首先,起始符号被标记为“可到达的”;这就是初始化。然后,语法中每一个标记了A的A→α形式的规则,α中所有的非终结符都会被标记;这就是推导规则。我们持续应用推导规则直到不再有变化产生。现在剩下的未标记的非终结符就是不可到达的,而他们对应的规则可以被删除了。
第一轮标记A和B;第二轮标记C,第三轮没有任何不变化。结果就是--一个干净的语法--见图Fig 2.29。如图Fig 2.27中可到达和生成性的规则E--->e,子啊移除非生成性规则后变成了孤立的,然后被第二步的清理算法给删除掉了。
移除不可到达的规则不会导致在一个可到达的规则中使用的非终结符N变成未定义的,因为只有当移除了N的所有定义的规则才能变成未定义的,但是又因为N已经是可到达的,所以上诉过程将不会移除它的任何一条规则。对相同参数的稍微修改可以看出移除不可到达的规则不能导致一个在可到达规则中使用的非终结符N变成非生成性的:生成性的N,否则无法在前面的清理步骤中留存下来,只能通过移除它的定义规则来使之成为非生成性的,但是由于N是可到达的,上面的过程将不会移除它的任何规则。这最后表明在移除了非生成性的非终结符以及移除了不可到达的非终结符之后,我们没有必要再做一遍移除非生成性的非终结符。
然而有趣的是请注意,如果先移除不可到达的非终结符然后在移除非生成性的规则将有可能导致语法再次含有不可到达的非终结符。图Fig 2.27的语法是一个例子。
此外需要注意的是,清理一个语法可能回移除所有的规则,包括其实符号的规则,描述空语言的语法就是例子;见2.6节。
移除非生成性规则是一个自底向上的过程:只有底层,终结符所处的位置,才能知道哪些是生成性的。移除不可到达的非终结符是一个自顶向下的过程:只有顶层,起始符号所处的位置,才知道哪些是可到达的。
2.10 设定上下文无关和正则语言的属性
由于语言是集合,所以很自然会问到集合的标准操作——并集、交集和补集(补充)——是否能用在语言上,如果可以,要怎么做。
S1和S2的并集包含两两个集合中的全部元素;写作S1∪S2。交集包含了两个集合中的共同元素;写作S1∩S2。S的补集包含了属于Σ但不属于S的元素;写作S*。在形式语言的上下文中,这些集合是通过语法定义的,所以实际上我们是想要对语法进行操作,而不是语言。
为两种语言的并集构建语法,对上下文无关和正则语言来说是繁琐的(实际上对所有Chomsky类型都是):仅仅构建一个新的起始符号S'→S1|S2,*S1和S2*是描述两种语言的语法的起始符号。(当然,如果我们想结合两种语言成为一种新的,那我们必须确保他们之中的名字是不同的,不过这是很容易的事情。)
然而交集是另一回事,因为两个上下文无关语言的交集并不一定是上下文无关的,如以下示例所示。有两个语言L1 = anbncm和L2 = ambncn,他们由CF语言描述
当我们拿到一个同时属于两个语言的字符串放入交集时,就有了**apbqcr这种形式,而由于L1和L2其中p = q以及q = r。所以交集中包含anbncn**这样形式的字符串,而我们知道这样的语言不是上下文无关的(见2.7.1节)。
CF语言的交集有一些奇特的属性。第一,两个CF语言的交集总是有一个1型语法——但这个语法却不容易构建。更值得注意的是,三个CF语言的交集比两个的交集要强大的多:Liu 和 Weiner[390]表明,可以获得k个CF语言的交集,而不是k-1个。除此之外,任何1型语言,甚至任何0型语言,可以通过两个CF语言的交集来构建,我们就能擦除结果字符串中的所有属于可擦除符号集中的符号。
我们将用来演示这个惊人现象的CS语言,是由两个相同部分的句子组成的集合:ww,w是给定字符集中的任何字符串;例如aa和abbababbab。用来相交的两个语言由以下定义:
其中x和y是可擦出符号。第一个语法生成的字符串由三个部分组成,a和b的序列A1,其次是其“黑暗镜像”M1,a对应x,b对应y,再接着是a和b的一个任意序列G1。第二个语法生成的字符串包含,a和b的一个任意序列G2,一个“黑暗”序列M2以及它的镜像A2,其中再次a对应x,b对应y。交集强制A1 =G2, M1 =M2,and G1 =A2。这使得*A2成为了A1镜像的镜像,同理A1*也是这样。交集中的一个句子示例如abbabyxyyxabbab,我们可以看到其镜像abbab和yxyyx。现在我们擦除可擦除符号x和y,就得到了最后的结果abbababbab。
通过使用应用上述镜像,就能够很简单的证明任何0型语言能够通过两个CF语言的交集,加上一组可擦除符号来构建了。详细介绍,见Révész [394]。
注意,一个上下文无关和一个正则语言的交集,一般都是一个上下文无关语言,并且,有一个相关的简单算法来为这个交集语言构建一个语法。这让非凡的解析算法得到了增长,浙江在13章进行讨论。
如果我们不能得到两个CF语言的交集,并且仍处于CF语言中,那我们肯定不能得到一个CF语言的补集并仍在CF语言中。如果我们能得到,我们就能得到两个语言的补集,让后取其交集然后在取其补集,最后就得到它们的补集。公式:L1 ∩ L2 = ((L1) ∪ (L2));这个公式就是大家熟知的De Morgan定律(De Morgan’s Law)。
在5.4节我们将会看到正则(3型)语言和正则语言的并集,交集和补集。
有趣的是推测一下将会有什么发送,如果形式语言是基于集合理论,一开始就使用所有的集合操作,而不是Chomsky层次理论。那么上下文无关语言还会被发明么?
2.11 语义连接
有时解析只服务于检测一个字符串的正确性;字符串符合给定的语法就是我们想要知道的,例如因为它证实了我们某些观察模式的猜想,那确定就是被我们特意为之设计的语法正确描述的。然而,一般情况下,我们想要做的更多:我们知道字符串都传达一种含义,一种语义,而且这种语义直接与这个字符串的生成树的结构相关。(如果不是,那我们就弄错了语法!)
语法附加语义是通过一个非常简单但有效的方式完成的:语法中的每一个规则,其规则右侧成员语义相关的一个语义子句被附加在其左侧的语义上,这种情况下语义从生成树的节点直接流向起始符号;或者反过来说,这种情况下,语义反向从起始符号流向节点;又或者同时的,在这种情况下,语义信息一会向上一会向下流动,直到达到某个稳定状态。语义信息向下的流动称为继承:生成树上的每个规则从其父节点继承语义。语义信息向上流动称为派生:每一个规则由其子节点派生。
有许多方式来表达语义子句。因为我们的主题是解析和语法而不是语义,我们将只会简要描述两个经常用到并得到充分研究的技术:属性语法和转导语法。我们会使用同一个简单例子来解释,一位数数字的总和的语言;这个语言的句子的语义是总和的值。这个语言由图Fig 2.30的语法生成。例如他的一个句子是3+5+1;其语义就是9。
2.11.1 属性语法
属性语法中的语义子句假设生成树的每一个节点都有空间给一个或多个属性,这些属性就是放在生成树节点上的值(数字,字符串或其他东西)。为简单起见,我们限定这个属性语法每个节点只有一个属性。这种语法中一个规则的语义子句包含一个公式,用以计算一个规则(由生成树的节点展示)中的一些非终结符着这个规则的其他终结符。这些语义动作之和规则的本地属性关联:整体的语义由所有本地计算的结果构成。
如果规则R的语义子句计算R左侧的属性,这个属性会派生。如果它计算R的右侧非终结符的属性,例如A,那这个属性就被A继承。派生属性也被称为“合成属性”。我们例子的属性语法是:
花括号中给出了语义子句。A0是左侧的(派生)属性;A1,...,An是右侧成员的属性。通常来说,右侧的终结符也计入A的索引,虽然它们(通常)不携带属性;规则2的Digit在位置3并且其属性值为A3。用于处理属性语法的大多数系统都少有重复的方式来通过3j表达3a。
3+5+1 的初始生成树在图Fig 2.31中给出。首先只有节点的属性的明确的,但是到生成树的一个右侧的所有属性都明确后,我们就能使用它的语义子句来计算它的左侧了。这种方式下,属性值(语义)渗透到树上,最后到达起始符号,并提供给我们全句的含义,如图Fig 2.32所示。属性语法是非常强大的操控语言语义的方式。这些将会在15.3.1节中详细讨论。
2.11.2 转导语法
转导语法将一个字符串(“输入字符串”)的语义定义为另一个字符串,“输出字符串”,而不是起始符号的最终属性:
这种方法没那么强大,但比起属性语法却更简单并且也够用了。生成规则中的语义子句只是应该在对应节点输出的字符串。我们假设一个节点的字符串紧接着它所有子节点的字符串后输出。其他的变种是可能的并且很正常。我们现在可以编写一个转导语法,其将数字之和转换为计算机指令之和。
这个转导语法将3+5+1转换为:
这就是3+5+1真实的“意义”。
2.11.3 增广转换网络
语义可以被引入一个递归过渡网络(2.8节),通过附加动作到图形的转换中。这些动作可以设置变量,构造数据结构,等。因此增强的递归转换网络被称为增广过渡网络(或ATN)(Woods [378])。
2.12 语法类型的隐喻比较
教科书声称“n型语法比n+1型语法更强大,n = 0,1,2”,并且经常可以读到这样的语句“一个正则(3型)语法不够强大以匹配括号内的”。有趣的是,看看到底是什么样的力量。天真,一个人可能以为它的力量是生成越来越大的集合,但这明显是不正确的:最大的可能的集合Σ*,可以很容易由3型语法生成:
Ss --->[Σ]S| ε
[Σ]是语言中符号的缩写。只是当我们想要限制这个集合时,我们就需要更强大的语法。更强大的语法可以在正确和不正确的句子间定义更复杂的边界。有的边界定义的太好导致没有任何语法能描述它(也就是,通过任何生成过程)。
这个想法在图Fig 2.33中进行了比喻描述,图中一支玫瑰由越来越细的轮廓接近。在这个比喻中,玫瑰代表语言(想象语言中的句子就是玫瑰的分子);语法就是为了描绘它的轮廓。一个正则语法只允许我们用水平直线和垂直线段来描绘花朵;直尺和T型尺就够了,但结果只是一个粗陋和机械的图片。CF语法能通过各个角度的直线和圆弧近似描绘;图画还是可以使用传统的圆规和直尺就够了。最终结果很生硬但好歹能辨认了。CS语法可以让我们用平滑的曲线包围花朵了,但是曲线太平滑了:它无法表现尖角,而且偏离了复杂的交点;不过,依旧有了非常逼真的效果图。不受限制的短语结构语法可以完美展现大概轮廓。一朵玫瑰不可能被限定在一种限定的描述中;其本质永远都是我们嗦无法企及的。
一个更简单更有效的例子,可以在一个能通过多种语法类型生成的Java1程序的继承集合中找到。
-
所有词法正确的Java程序可以通过一个正则语法生成。一个Java程序词法是正确的,如果字符串中没有换行符,评论在文件结尾时被终止,且所有数值常量都是正确形式,等等。
-
所有语法正确的Java程序都可以通过一个上下文无关语法生成。这些程序在理论上都符合(CF)语法。
-
所有语义上去的Java程序都可以通过一个CS语法生成。这些都是通过一个Java编译器没有抛出错误信息的程序。
-
在有限时间内运行给定输入会终止的所有Java程序可以通过一个无限制的短语结构语法生成。然而,这种语法会非常复杂,因为它会包含Java库的进程和Java运行时间系统的详细描述。
-
解决给定问题(例如,下棋)的所有Java程序不能通过一个语法(尽管集合的描述时有限的)生成。
我们在这里使用编程语言Java,因为我们希望读者或多或少能熟悉它。对于手册所给一个CF语法的,任何一种编程语言都可以做到。
2.13 总结
Chomsky语法是一种有限的机制,产生通常是无限集合的字符串,一门“语言”。不同于其他许多集合生成机制,这种生成过程指派一个结构给生成的字符串,可以用来向其附加语义。对于上下文无关语法(2型),这种结构是一棵树,允许语义由分支的语义组成。这是上下文无关语法的重要性的基础。
问题
问题2.1:2.1.3.4节的对角化似乎是一个不在列表上语言的有限描述。为什么这个描述不在列表上,列表可是包含所有有限描述的?
问题2.2:在2.1.3.4节中,我们考虑函数n,n+10和2n,来找到应该有别于行n的位的位置。这些函数的一般形式是什么,既,什么样的函数集合可以生成不具有有限描述的语言?
问题2.3:位Manhattan龟路径设计一个语法,使其从其起点开始不允许向西爬行。
问题2.4:图Fig 2.7的单调1型语法生成所有***anbncn*形式的字符串,n≥1。为什么n=0排除在外?
问题2.5:设计一个1型语法,可以生成包含两个相同部分的所有字符串的语言:ww,w是给定字母表(见2.10)的任何字符串。
问题2.6:在2.4.1节,我们有句子生成机制,将新创建的句子形式添加到队列结尾,并声称这实现了广度优先生成。当我们将它放在队列开头时,机制采用深度优先生成。说明这是不起作用的。
问题2.7:2.4.1节的最后一段中说到“在增加(实际上是‘不减少’)的长度”。解释为什么说是“不减少”就足以表明。
问题2.8:将一个没有递归的语法生成的有限语言的字符串数量与该语法的结构关联。
问题2.9:查阅2.6节。在你的计算环境中找到更多的例子,零作为一个数字得到二等对待。
问题2.10:在你最喜欢的解析器生成器系统中,为语言{ε}写一个解析器。同样也为语言{}写一个解析器。
问题2.11:使用uvw理论(2.7.2节),说明对于语言***aibi***没有可用的3型语法。
问题2.12:在2.9节我们说到,无用的规则可以被从语法中移除掉而不影响语言的生成。这似乎是表明“其移除不影响语言”就是我们所希望的,而不是仅仅是没有无用的。注释。
问题2.13:根据2.2.2节,写一个Chomsky生成过程,作为一个闭包算法。
3 解析简介
根据语法来分析字符串意味着重建生成树,以展示给定字符串是如何通过给定语法生成的。在这方面它有重要意义,第一个解析方面的出版物(Greibach的1963年博士论文[6]),命名为“逆向短语结构生成器”(Inverses of Phrase Structure Generators),而短语结构生成器被理解为一个从短语结构(事实上是上下文无关)语法生成短语的系统 。
虽然基于0型或1型语法生成句子产生的是生成图而不是生成树,并因此解析得到了解析图,我们应该使用2型,上下文无关语法,以专注于解析以及最后的解析树。偶尔我们会触及基于0型或1型语法的解析,例如3.2节,只是为了展示它是一个有意义的概念。
3.1 解析树
关于重建解析树有两个重要的问题:我们为什么需要它;我们如何实现它。
恢复生成树的需求是不自然的。毕竟,语法是一组字符串的凝聚态描述,既语言,并且也许我们的输入字符串可能属于或者不属于这个语言;没有涉及内部结构或生成路径。如果我们坚持这个形式观点,我们可以问的唯一有意义的问题就是,一个给定的字符串是否可以被一个语法识别;任何关于如何做的问题都是无意义的标志,甚至只能是一种好奇心的表示。然而在实践中,附加了语义的语法:特定的语义附加到特定的规则,而且为了确定一个字符串的语义,我们需要找出在参与了其生成过程的规则以及是如何参与的。总之,识别是不够的,我们还需要恢复生成树以得到句法方式的全部优势。
被恢复的生成树称为解析树。在0型和1型语法中,将语义附加到特定规则几乎是不可能的事实说明了它们在解析中的微不足道,相比于2型和3型语法。
如何重建生成树是本书余下部分的主题。
3.1.1 解析树的大小
一个有n标记的字符串的解析树由属于终结符的n个节点,在加上大量属于非终结符的节点组成。令人惊讶的是,不能有多于CGn的属于非终结符的节点,在n标记节点的一个解析树中,*CG*是由语法决定的一个常量,证明语法没有循环。这意味着,任何解析树的大小都是线性的,根据输入的长度。
表明确实需要用一系列步骤来完成。我们首先证明对语法来说所有右侧都是长度2;这就导致了二叉树,树上每个节点要么有两个子节点要么就是叶子(没有子节点的节点)。二叉树有着显著的特点,所有的给定树叶数量的二叉树都有同样数量的节点,而不在乎它们的形状。下面我们看右侧长度>2的语法,接着有单元规则的语法,最后是可为空规则的语法。
正如我们说的,长度为n的输入字符串包含n标记的节点。当解析树尚不存在时,这些节点是没有父节点的叶子。现在我们来构建一棵二叉树来给每个叶子一个父节点,父节点都标记了来自语法的非终结符。第一个父节点P1我们添加的,减少了2个没有父节点的叶子,但是现在P1自身成为了没有父节点的节点;所以我们现在有n+1个节点,其中n-2+1 = n-1个节点没有父节点了。同样的事情发生在添加的第二个父节点P2上,不论P1是否是其子节点;所以现在我们有n+2个节点,其中n-2个没有父节点。j步之后,我们就有了n+j个节点,其中n-j个没有父节点,并且在n-1步之后,我们就有2n-1个节点,其中1个没有父节点。那没有父节点的1个节点就是根节点,然后解析树就完成了。所以我们看到,当所有右侧长度是2时,对于输入长度是n的解析树,包含2n-1个节点,其线性在n。
如果有的右侧的长度>2,那可能只需要更少的父节点来构造这颗树。所以整棵树的大小可能会小于2n-1,并且肯定会小于2n。
如果语法包含单元规则 - A → B形式的规则 - 那么添加父节点会减少无父节点数这点就不对了:当一个无父节点的节点B通过规则A → B获得了一个父节点A,它就不再是无父节点的节点了,但是A又成为了无父节点的节点,并且更糟的是,节点数却增加了一个。并且可能有必要重复这个过程,就是说使用规则Z → A,等等。但是最终端元规则的链必定会走到尽头,比如P(所以我们有了P →Q···Z → A → B),或者语法中就有一个环。这意味着P获得了一个有多个子节点的父节点,然后无父节点的节点数减少了(或者P就是根节点)。所以单元规则能做的最糟糕的事情就是“延长”每一个节点通过构造因子Cu,因此解析树的大小会小于2Cun。
如果语法包含形式A → ε这样的规则,只有数量有限的ε能在输入的相邻标记中被识别,或者语法中就又有一个环了。所以可为空规则能做的最糟糕的事情就是“延长”输入通过构造一个因子Cn,在两个标志之间被识别的ε的最大数目,还有解析树的大小都小于2CnCun,其线性大小是n。
另一方面,如果语法允许包含循环,以上两个过程就将会在解析树中引入无限延伸的节点,那树的大小就是任何大小了。
3.1.2 各种模糊性
一个语法产生的句子可以很容易拥有多于一个的生成树,既,很容易有多于一种的方式来生成一个句子。从形式角度来看,这不是大问题(一个集合不会计算它包含了一个元素多少次),但是只要我们对语义感兴趣,那这差别就很重要了。不足为奇的是,拥有多个生成树的的句子被称为模糊性,但是我们必须立即区分本质的模糊性和貌似的模糊性。差异来源于我们对生成树本身并不感兴趣的事实,而更感兴趣于它们所描述的语义。一个歧义句是貌似模糊性的,如果它的所有的生成树都描述同样的语义;如果部分语义不不同,其模糊性就是本质的。“模糊”的概念也可以用来定于语法:语法是本质模糊的如果它可以生成一个本质模糊的句子,是貌似模糊的如果它能生成一个貌似模糊的句子(但不是一个本质上模糊的),以及是非模糊性的如果都不能生成的话。一个语法的可能模糊性测试,间9.14节。
图Fig 3.1给出了一个简单的模糊性语法。注意规则2不同于图Fig 2.30中的。
现在3+5+1有了两个生成树(图Fig 3.2),但是两者的语义是一样的:9。模糊性是不确定的。然而如果我们把**+变成-,那其模糊性就是本质的了,如图Fig 3.3。图Fig 2.30的非模糊性语法,当+变成-**后,依旧是非模糊性,并且保持正确的语义。
奇怪的是,语言也可以是模糊性的:(上下文无关)语言,其语法没有非模糊性的。这样的语言是固有模糊性的。语言L = ambncn ∪ apbpcq就是一个例子。L中的句子要么由一些a后接着一组嵌套的b和c的序列组成,要么由一些c后接着一组嵌套的a和b的序列组成。例句:abcc,aabbc,还有aabbcc**;abbc是一个非句子例子。L由图Fig 3.4的语法生成。
直观的说,为什么L是模糊性的是相当清晰的:语法的任何可以生成ambncn的部分都无法避免生成anbncn,并且任何可以生成apbpcq的部分都无法避免生成apbpcp。所以无论我们做什么,与a,b,c数字相等的形式将总是会被生成两次。但真实的证明确实无法做到,这不是本书范围之内的东西了。
3.1.3 解析树的线性化
通常构建一棵实际的解析树是不方便也是不必要的:相反一个解析器可以生成一列规则编号,这意味这其线性化了解析树。有三种主要方法来线性化一棵解析树,前缀、后缀和中缀。前缀表示法中,每一个节点都被列出通过列出其编号然后接着是子节点列表的前缀,由左到右的顺序;这给了我们最左侧的推导(图Fig 3.2的右边的树):
最左侧:2 2 1 3c 1 3e 1 3a
如果一棵解析树是根据这个方法构建的,那它是在前序中构建。在前缀表示中,每一个节点被列出通过列举全部前缀表示按从左到右的顺序,后面接着节点自身规则的编号;这给了我们最右侧的推导(同一棵树的):
最右侧: 3c 1 3e 1 2 3a 1 2
这在后序中构建解析树。中缀表示法中,每个节点都被列举,通过首先给一个列举在括号中第一个n子节点的中缀,然后接着是节点的规则编号,然后接着是一个列举在括号中其余子节点的中缀;n可以自由选择,甚至规则之间可以不同,但是n = 1 是正常的。中缀在派生中是不常见的,但偶尔是有用的。n = 1的情况被称为左角推导;在例子中我们得到:
左角: (((3c)1) 2 ((3e)1)) 2 ((3a)1)
中缀表示法需要括号来使我们从中重建生成树。最左和最右推导不用括号也能完成,使我们准备好语法来找到每个节点的子节点数。
很容易区分一个推导是最左还是最右的:一个最左推导开始于起始符号的一个规则,而一个最右推导开始于只生成终结符的一个规则。(如果两个条件同时成立,那只有一个规则可以,可以同时是最左和最右推导的。)
几个不同推导的存在不应该与模糊性混为一谈。不同的推导只是同一个生成树的符号变形。对于其不同没有不同的语义可以附加。
3.2 解析一个句子的两种方法
一个句子和其起源的语法之间的基本联系就是解析树,解析树描述了语法是如何生成一个句子的。 为了这种连接的重建,我们需要一种解析技术。当我们查阅解析技术方面的文档时,我们似乎找到了几十个,不过却只有两个是解析方面的;其余的都是技术细节和点缀。
第一种方法尝试去模仿初始生成过程,通过重新推导从起始符号推导出句子。这种方式被称为自顶向下,因为解析树是从上向下重构建的。1
第二种方式试图回滚生成过程并减少回到起始符号的判定。自然的这种方式就被称为自底向上。
在计算机科学中,树的生长过程是从根向下生长的;这与物理学中有一个负电荷的点子相类似。
3.2.1 自顶向下解析
假设我们有图Fig 2.7中语言**anbncn**的单调语法,这里重新提一下:
并且假设(输入的)句子是aabbcc。首先我们尝试一些自顶向下解析方式。我们知道生成树必须从起始符号开始:
那现在第二步是什么?对于S我们有两个规则:S--->aSQ和S--->abc。第二个规则要求句子是从ab起始的,但这里不是的。这样就只剩S--->aSQ:
这为我们带来一个很好的解释,为句子中的第一个a。现在又有两个规则:S--->aSQ和S--->abc。一些反射将会揭示在这里第一个规则会是一个坏的选择:S的所有生成规则都起始于a,并且如果我们能推进到阶段aaSQQ,下一步就不可避免的导致aaa...,这与输入相矛盾。然而第二个规则,也不是没有问题的:
现在句子起始于aabc...,这也与输入相矛盾。然而,有另一条出路:cQ--->Qc:
现在就只有一条规则适用了:bQc--->bbcc,这样就获得了我们输入的句子(与解析树一起):
自顶向下解析用前缀顺寻标识了生成规则(并因此特征化了解析树)。
3.2.2 自底向上解析
使用自底向上技术,我们如下进行。一个生成步骤必须在最后,并且其结果在字符串中必须是可见的。我们在aabbcc中识别出bQc--->bbcc的右侧。这给了我们生成的最后一步(也是减少的第一步):
现在我们识别出由cQ--->Qc派生的Qc:
再次,我们只发现了右侧:abc:
以及我们减少的最后一步也让我们没有选择:
自底向上解析倾向于找出前缀顺序中的生成规则。
有趣的是,自底向上解析把解析过程变成了一个生成过程。上述慢慢减少的过程可以被视为用反向语法的生成过程:
增加一条规则,将起始符号变成一个新的终结符:
S--->!
以及增加一条引入新起始符号的规则,原句是:
Is ---> aabbcc
如果,从I起始,我们能生成我们已经在输入字符串中识别的**!**,并且如果我们记录了我们做过的事情,那我们也就获得了解析树。
生成和减少的双重性被Deussen[21]使用,作为形式语言的一个非常根本的途径。
3.2.3 适用性
上述例子表明,自顶向下和自底向上两种方法都在某些情况下能起作用,但同时,也会涉及到一些很微妙的需要注意的事情,这些是我们无法教给一台计算机的。解析文学的几乎整个主体都与形式确定这些微妙的注意事项相关,并且取得了相当大的成功。
3.3 非确定型自动机
上述两个例子都表征了两个组成部分:一个可以进行替换和记录解析树的机器,以及一个决定机器该做那些动作的控制机器。这个机器相对简单,因为其替换仅允许语法所允许的那些,但是控制机制可以是任意复杂的,并且可能包含语法的广泛的知识。
这种结构可以在所有解析方法中看出:总是有一个替换和保持记录的机器,以及一个指导性的控制机器:
替换的机器被称为非确定型自动机或NDA;被称为“非确定型”是因为经常有几个可能的行动,并且特殊选择没有预先决定,而“自动”是因为会自动执行应激的操作。它管理三个组件:输入字符串(实际上是它的副本),部分解析树和一些内部的管理工作。NDA的每一个行动都转换来自输入字符串的一些信息到部分解析树中,通过管理工作。三个组件可能在过程中进行修改:
NDA的强大力量,以及它实用性的主要来源,就是它可以被轻松的构建,以便它能只做“正确”的行为,也就是说,保持系统部分处理输入,内部控制和部分解析树一致的行为。这有可能导致我们按照选择的任何方式移动NDA的结局:它可能在圈中运动,甚至它可能困住,但是如果它能给我们一个答案,以一个完成的解析树的形式,那这个答案就将是正确的。NDA可以做所有正确的行为也是有必要的,以便它能生成所有解析,如果控制机制足够聪明能指导NDA。NDA的这个属性也很容易安排。
NDA固有的正确性给控制机制带来了极大的自由,简称“控制”。它可能是幼稚的又或成熟的,它可能是麻烦的又或是有效的,它甚至可能是错的,但它绝不可能会使NDA生成一个不正确的解析;而这是一个令人欣慰的想法。然而,如果它是错误的,那可能会是NDA错失一个正确的鸡西,导致无限循环,或在不应该的地方被卡住。
3.3.1 构建NDA
NDA直接源于语法。对于一个自顶向下解析,它主要由语法的生成规则组成以及内部控制是最初的起始符号。该控制机制移动机器直到内部管理与输入字符串相等;接下来解析就被找到了。对一个自底向上的解析器,运动主要由语法的生成规则的反向组成(见3.2.2节),并且内部管理最初是输入字符串。该控制机制移动机器直到内部管理与其实符号相等;接下来解析就被找到了。一个左角解析器像一个自顶向下解析器一样工作,其中一套精心选择的生成规则集合被反转,并且其中有特殊的行为来撤销这个反转当需要的时候。
3.3.2 构建控制机制
构建一个解析器的控制机制是一件相当不同的事情。一些控制机制是独立于语法的,一些定期从语法中查询,一些使用语法预计算的巨大的表格,还有一些甚至使用输入字符串计算的表格。我们应该看到这些的单独的例子:这部分开头就表明的“手动控制”,属于“定期从语法中查询”的类别,回溯解析器通常使用语法独立控制,LL和LR解析器使用语法派生的预计算的表格,CYK解析器使用输入字符串派生的表格,以及Early和GLR解析器使用少数的几个由语法和输入字符串派生的表格。
从语法中构建控制机制,包括表,几乎都是由程序完成的。这种程序被称为解析器生成器;这足够了,语法和也许是终结符号的描述,并且生成一个程序就是解析器。解析器通常由一个驱动和一个或多个表组成,这种情况下被称为表驱动。其中的表可以是具有相当规模并且及其复杂的。
由输入字符串派生的表必须被解析器的一个例行程序计算。应该指出的是,这反映了典型配置,大量不同的输入字符串被根据同一个相对静止不变的语法解析。反面例子是很容易想象的:许多语法被用来试图解释一个给定的输入字符串,例如事件的一个观测序列。
3.4 0型到4型语法的识别和解析
根据一个语法解析句子,原则上我们事先就知道这个字符串实际上源于这个语法。如果我们不能想到更好的东西,我们可以对语法按照2.4.1节的一般生成过程来运行,然后就坐等这个句子出现(我们知道一定会出现)。这情况本身还不足够:我们必须在扩展一下生成过程,以便每个句子形式都能携带其本身一部分的生产树,即便需要在必要的时刻进行更新,但很明显可以通过一点编程工作来完成。我们可能需要等待一会儿(几百万年也说不好)直到句子显示出来,不过最后我们肯定可以收获到解析树。当然这是完全不切实际的,但它至少展示了再理论上,任何字符串都能被解析,如果我们知道它是可解析的,不管是什么语法类型。
3.4.1 时间要求
当解析一个包含几个符号的字符串时,对于解析器的时间要求有些想法是很重要的,即解析输入字符串的一些符号的依赖项所需要的时间。预期的输入长度范围从上千万(自然语言种的句子)到数万(大型计算机程序)之间;一些输入字符串的长度甚至可能是几乎无限长的(一台咖啡售货机再其使用寿命之间的按钮按动序列)。输入长度的依赖项的时间要求也称为时间复杂度。
有几个特征属性让时间依赖项能被识别。一个时间依赖项是指数分布的,如果接下来的每一个输入符号所需的时间都乘以一个常量因子,也就是:每一个额外的输入符号需要双倍的解析时间。指数分布的时间依赖项被写作O(C n),其中C是增加的常量因子。指数依赖关系,来自于著名的棋盘上每一个各自上的谷物数量都是前一个格子的两倍这个故事;这种方式意味着破产。
一个时间依赖项是线性分布的,如果接下来的每一个输入符号需要花费的处理时间有一个固定的增量;输入长度增加一倍则处理时间增加一倍。这种行为是我们希望在解析器中看到的;解析所需要的时间与读取输入所需的时间成正比。所谓的实时解析器表现的甚至更好:它们可以在读取完成最后一个符号的恒定的时间内生成解析树;如果计算机运行足够快速,它们能跟上以恒定速率输入的无限输入流。请注意,这不一定是真正的线性时间解析器;原则上它们可以读取有n个符号的完整字符串,然后花费与n成正比的时间来生成解析树。
线性时间依赖被写作O(n)。一个时间依赖项被称为二次曲线式的,如果处理时间与输入长度的平方成正比(写作O(n2)),以及立方式的如果与长度的三次方成正比(写作O(n3))。总之,一个依赖项与n的任意次幂成正比则被称为多项式(写作O(np))。
3.4.2 0型和1型语法
一个任意的0型语法的识别问题是不可解的,这在形式语言学上是一个显著的成果。这意味着不会有一种算法,它接受一个任意的0型语法以及一个任意的字符串,然后在有限的时间内告诉我们这个语法是否能能生成这个字符串。可以证明这种说法,但是证明过程非常令人生畏,并且它不能提供任何能窥探到造成这种现象的原因。这是一个逆向的证明:我们能证明,如果存在这样一种算法,我们就能构造第二个算法,其能证明语法只能在永远不终止的情况下才能终止。由于这在逻辑上是不成立的,并且由于其余所有的前提在证明过程中都表明我们不得不得出一个结论就是,在我们最初的前提下,0型语法的识别器在逻辑上是不可能的。令人信服,但精神上却难以接受。完整的证明见Hopcroft and Ullman [391, pp. 182-183], or Révész [394, p. 98]。
为一定数量的0型语法构建一个识别器却是很有可能的,通过使用某种特定的技术。然而这种技术却不能对所有0型语法起效。事实上,不论我们收集了多少种技术,总会有这些技术不起作用的语法存在。在某种意义上,我们只是不能使我们的识别器足够复杂。
对于1型语法,这种情况却是完全不同的。1型语法的生成规则不能使句子形式收敛这个看似无关紧要的属性,让我们可以为自底向上的NDA构建一个至少原则上能起作用的控制机制,而不考虑语法。这个控件的内部管理包含一组可能在输入的句子中发挥作用的句子形式;它开始只包含输入的句子。NDA的每一次移动都是一次还原,根据语法。现在这个控制将NDA的所有可能的移动都应用在内部管理的所有句子形式中,以任意顺序的方式,并将每一个结果添加至内部管理中,如果不是已经存在的话。这个动作会一直持续到所有的句子都完成了所有可能的移动并得到了结果。由于没有哪个NDA移动可以使句子形式变长(因为所有的右侧至少是与其左侧的长度相当),并且由于句子形式的数量有限,与输入字符串长短相当,这最终都是会发生的。现在我们在内部管理的句子形式中找到仅只包含起始符号的那一个。如果它确实存在,那我们就识别出了输入字符串;如果不存在,那输入字符串就不属于这个语法所代表的语言。如果我们还记得,在一些额外的管理机制中,我们是如何得到这个起始符号的句子形式,我们就已经得到了解析过程。而所有的这些都依赖于大量的书记工作,而我们并不打算讨论这个,因为反正也没有人这么做。
综上所述,我们并不能总是为0型语法构建解析器,但如果是1型语法那我们就能做到。为这种类型的语法构建一个实用、合理高效的解析器,是一个非常困难的问题,但是在过去的40年中一直保持缓慢但稳定的进展(见18.1.1节(电子版))。这不是一个热搜话题,主要是因为0型和1型语法是出了名的不友好并且永远不会被广泛应用的。然而并不是完全没有用处,因为一个好的0型语法解析器可能会让定理证明器1有一个好的开端。
对人类不友好的思虑并不适用于两级语法。有一个实用的两级语法解析器将棒极了,因为这将会让解析技术(以及其所有的内置自动化技术)应用到更广泛领域中相比于现在,尤其是上下文条件很重要的情况。两级语法解析情况的目前的可能性在15.2.3节中讲述。
所有已知的0型、1型以及无限制的两级语法的的解析算法,都有指数级分布的时间依赖项。
一个理论证明程序就是,给定一组定理或公理,在没有或极少认为干预的情况下来证明或反证这个理论的程序。
3.4.3 2型语法
幸运的是,CF(2型)语法的解析算法相比于0型和1型要著名的多。使用CF和FS语法中几乎所有实用的解析都做过了,并且在上下午无关语法的解析中几乎所有遇到的问题都被解决了。这么大差距的原因可以在CF语法生成过程中找到:句子形式中的一个非终结符的演变是完全独立于其余非终结符的演变的,并且相反的是,在解析过程中,我们可以结合部分解析树而不理会它们的历史。在上下午相关语法中这些都不是正确的。
自顶向下和自底向上解析过程都很容易适用于CF语法。在下面的例子中,我们应使用简单的语法
3.4.3.1 自顶向下CF语法解析
在自顶向下CF语法解析中,我们从起始符号入手,并尝试产生输出。这里的关键词是预测和匹配。句子形式中任何时候都有一个最左侧非终结符A,而解析器系统的尝试预测一个A的恰当的替代品,在这个位置的输入中一旦有兼容的符号被发现,那A的产物就应该开始了。这个最左侧的非终结符也被称为预测过程的目标。
想想图Fig 3.5中的例子,其中Object就是最左侧的非终结符,也就是“目标”。在这种情况下,解析器将首先预测Object的the Noun,然后会立即推翻这个替换项,因为the的位置被要求是一个a。接下来,解析器将会尝试a Noun,这个将会被暂时接受。a匹配上了,新的最左侧非终结符就成了Noun。当Noun最终生成了dog时,这个解析器就成功了。解析器将会为Object尝试第三次预测,ProperName;这个替换项不会被立即拒绝,因为解析器无法知道ProperName不能起始于一个a。它将在稍后阶段中失败。
这种方法有两个严重的问题。虽然理论上,它可以处理任意的CF语法,但如果被单纯的照章办事的执行的话,可能会在一些语法中走到死循环中。这个可以通过使用一些特殊技术来避免掉,这在大多数自顶向下解析器中处理了;这个将在第6章中详细讲解。第二个问题是算法必须符合时间指数分布,因为任何一次预测都可能被证明是错误的,并且需要在反复试错中纠正。上面的例子显示了可以通过预处理语法来增加一些效率:其优势在于可以预先知道哪些记号可以开启ProperName,以避免预测注定会失败的替代项。这对于语法中的大多数非终结符来说是正确的,而且这种信息可以很容易的从语法中计算出来并存储在一个表中供解析中使用。对于一个合理的语法集合,可以实现一个线性时间依赖项,比如第8章将会解释的。
3.4.3.2 自底向上CF语法解析
在自底向上的CF语法解析中,我们从输入开始,然后尽量减少它与起始符号的差距。这里的关键字是转移和缩减。当我们在过程中时,我们手中有了一个由输入缩减而来的句子形式。在这个句子中的耨个地方必定有一个段(子字符串),其是这个句子形式的最后一步生成步骤的结果。这一段对应于生成规则A→α的右侧α,而且必须被缩减到A。段和生成规则一起被称为句子形式的句柄,有一个相当拟合的表达式;见图Fig 3.6。(当生成规则在段的发现中是显而易见的,那匹配的段通常单独被称为“句柄”。通常我们会遵守这种习惯,不过我们认为称之为句柄段要更明确一些。)
诀窍是找到句柄。它必须是一个规则的右侧,所以我们从寻找这种右侧开始,通过将句子形式中的符号转换到内部管理机制中。当我们找到这样一个右侧,我们向着其左侧对其进行缩减,然后重复这个过程直到只剩下起始符号。用这种方式我们不一定能找到正确的句柄;如果我们犯错,那将会在接下来的步骤中卡主,然后将不得不撤销一些步骤,然后转向更多的符号并再次尝试。在图Fig 3.6中,我们本可以让a Noun向着Object缩减,但最后大胆走向了死胡同。
这种方式有两个本质上相同的问题,在自顶向下技术中。它可能产生循环,并且在有ε规则的语法中也可能会这样:它将在所有的地方持续找到空的生成。这个可以通过触及语法来纠正。并且可以花费服从指数分布的时间,因为句柄的正确识别必须通过试错来完成。同样的,对语法做预处理往往会有帮助:在语法中很明显,Subject可以通过追逐来找到,但Object却不能。所以如果下一个符号是追逐的,那将一个句柄缩减至Object是无意义的。
3.4.4 3型语法
在正则语法中,一个右侧包含最多一个非终结符,所以最左边和最右边推到没有区别。自顶向下方式对右正则语法来说有效的多;而对左正则语法来说,自底向上方式要好的多。当我们采取图Fig 2.15中的生成树,并且把它逆时针旋转45◦,我们就得到了图Fig 3.7的生成链。非终结符的序列向右滚动,当它们离开的时候就生成了终结符。在解析中,给我们的是终结符而希望得到的是非终结符序列。第一个起始符号是给出的(因此更适用自顶向下)。如果只有一条规则以输入的第一个符号开始,那我们就是幸运的并且知道接下来该怎么做。然后大多数时候,有许多规则是以同一个符号开始的,那时候我们就需要更多的智慧了。至于2型语法,我们当然可以通过试错找到正确的下一步,但存在着更多可以处理任何正则语法的更有效的方式。由于他们中的一些是以更先进的解析技术为基础的,所以我们将在第5章中一一介绍。
3.4.5 4型语法
有限选择语法(FC)不包含生成树,并且对一个FC语法语言的一个给定输入字符串的成员关系可以由简单的查找来决定。这种查找方式通常不认为是“解析”,但仍然在这里提及有两个原因。第一,它可以受益于解析技术,第二,在解析环境中通常需要它。自然语言中有一些单词类别,只包含数量非常有限的成员;例如代词、介词和连词。通常快速的决定一个给定单词是否属于这些有限选择类别之一或者是需要在进一步的分析是至关重要的。这同样适用于编程语言中的保留字段。
一种方法是将FC语言作为正则语法来考虑,并应用第5章的技术。这通常是及其有效的。
另一个经常使用的方法是使用一个哈希表。见任何一本关于算法的书,例如Cormen et al. [415], 或者 Goodrich and Tamassia [416]。
3.5 上下文无关解析方法的概述
在Chomsky语法类型中,上下文无关(2型)语法占据着最突出的位置。这有三个原因:1.CF解析的结果在生成树上,这让语义的表达和结合更容易;2.CF语言覆盖了很大一部分人们想要自动处理的语言;3.有效的CF解析是有可能的----虽然有时候存在很大的困难。在重要性上上下文无关语法后紧跟着有限状态语法。这是因为世界和设备是有限的;自动售货机、远程控制、病毒探测器,所有这些都展示了有限状态的本性。本书的其余章节,因此将主要关注CF解析,除了第5章(有限状态语法)之外,以及第15章(非Chomsky体系)。我们现在先看一下上下文无关解析方法的概述。
关于解析文学的读者面临着大量相互之间关系往往不明确的技术。然而现在所有的技术都可以被放在一个单一的框架中,根据一些简单的标准:见图Fig 3.11。
我们已经看到,一个解析技术要么是自顶向下,从起始符号开始重新生成输入字符串,要么是自底向上,向着起始符号缩减输入字符串。下一个分歧点是在定向和非定向解析方法之间。
3.5.1 方向性
非定向性方法构建解析树,当以任何他们认为合适的顺序访问输入字符串时。当然这要求在解析之前,完整的输入字符串要存在内存之中。分别有自顶向下和自底向上两个版本。定向解析器会按照顺序一个接一个的访问输入字符串,持续访问直到更新完部分解析树。也有自顶向下和自底向上两个版本。
3.5.1.1 非定向方法
非定向性的自顶向下方法即简单又直接,可能已经被很多人独立发明出来了。据我们所知最早是Unger[12]在1968年提出的,但是在他的文章中的描述似乎这个方法当时已经存在了。这个方法在文献中一直不受重视,但却比人们所认为的要重要的多,因为它以不知名的方式被大量的解析器使用。我们应该称之为Unger方法;见4.1节。
非定向性的自底向上方法也被很多人独立发现,其中有Cocke (in Hays [3, Sect. 17.3.1]), Younger [10],和Kasami [13];更早是Sakai [5]提出的。它是以三个最著名的发明家合称命名的CYK(或者说又是称为CYK)。它受到广泛的注意,因为其天生的执行性要比Unger方法更有效率。不过这两种方法的效率都还可以提升,达到大致相同的效果;见Sheil [20]。CYK方法将在4.2节中讲述。
非定向性方法通常首先构建一个数据结构,总结输入句子的语法结构。在第二阶段的时候,解析树就可以从这种数据结构中产生。
3.5.1.2 定向方法
定向方法一个符号一个符号的处理输入字符串,从左到右。(从右到左解析也是可能的,通过使用语法的镜像;这只是偶尔会有用)这样做的好处是,解析可以启动并且确实进行,在输入字符串的最后一个字符出现之前。定向方法全部都显示或隐式的基于解析自动机上,在3.4.3节中描述的,其中自顶向下方法执行预测和匹配,自底向上方法执行转换和缩减。
定向方法通常可以构建(部分)解析树,当它在处理输入字符串时,除非语法不明确或者需要一些后续处理。
3.5.2 搜索技术
第三种分类解析技术的方式涉及到在指导解析自动机通过其所有可能性找到一个或所有解析结果的搜索过程。
总的来说有两个方法来解决问题,其中有几个备选方案其核心都是:深度优先搜索和广度优先搜索。
-
在深度优先搜索中,我们的注意力集中在一个解决一半的问题上。如果在一个给定点P上出现分叉这样的问题,我们先将其中一条放在一边等待稍后处理并继续处理另一条。如果这条路最后失败了(或者即便是成功了,但是我们想要的是全部的结果),我们就回到点P在继续处理刚才被放在一边的那条。这就是所谓的回溯。
-
在广度优先搜索中,我们保留了一套解决一半问题的集合。从这个集合中我们计算出一个新的(更好的)解决一般问题的集合,通过检查每个已有的解决了一半的问题;对每一个选项,我们在新集合中创建一个副本。实际上,这个集合最终会包含所有的解决方案。
深度优先搜索的优点是它需要与已存在的问题数量成正比的内存空间,而不像广度优先搜索,它需要的内存是程指数增长的。广度优先搜索的优点是它将首先找到最简单的方案。这两种方法都需要在原则上服从指数分布的时间。如果我们想要更有效的(并且不接受指数分布要求),我们需要一些手段来限制搜索。搜索技术的更多信息,可以在任意一本关于算法的书上找到,例如Sedgewick [417] or Goodrich and Tamassia [416]。
这些搜索技术根本不限于解析技术,而是可以在广泛的范围内使用。一种传统的用法是找到迷宫的出口。图Fig 3.8(a)是一个简单的有一个入口和两个出口的迷宫,Fig 3.8(b)描绘了深度优先搜索将会采取的路径;这对行走其中的人类来说是唯一的选:他不能复制他自己获取迷宫。死胡同让深度优先搜索回溯到最近的一条没有尝试过的选择。如果搜索者也回溯每一个出口,那他将发现所有的出口。Fig 3.8(c)显示了广度优先搜索在每个阶段已经检查过的空间。死胡同(阶段3)将导致搜索分之被丢弃。广度优先搜索将会找到最快的捷径(最短解决方案)。如果它持续下去直到没有分支剩下,那也将找到全部的出口(所有解决方案)。
3.5.3 一般定向方法
解析就是重建生成过程这个观点,当使用定向方法时就尤为明显。它概述在以下两点中:
- 一个定向的自顶向下(从左到右)的CF解析器,标识生成过程中最左侧的产物。
并且
- 一个定向的自底向上(从左到右)的CF解析器,标识反向生成过程中最右侧的产物。
我们用一个非常简单的语法来证明这个:
这个语法只生成唯一的字符串, pqr 。
pqr 最左侧的产物的生成过程如下:
其中|标识生成过程进行到哪儿了。自顶向下过程通过首先确定生成 p 的规则来模仿这一过程,P--->p ,接下来是 q ,等等:
pqr 的最右侧的生成过程如下:
同样|标识生成过程进行到哪儿了。自底向上分析回滚了这一过程。要做到这一点,必须要先确定步骤4的规则,P--->p,并将它作为还原,然后是第3步,Q--->q,等等。幸运的是解析器可以很容易做到这点,因为最右侧的生成产物成为了已生成和未生成的句子部分的分界,所以最后的产物是结果的最左侧,正如我们前边看到的。然后解析过程就可以从那儿开始了:
这种双重逆转是定向自底向上解析所固有的。
解析树的构建和句子形式的关联如图Fig 3.9所示,其中虚线表示句子形式。左侧我们有一个完整的解析树;对应的句子形式就是终结符的字符串。中间的图展示了在一个自顶向下解析器中的部分解析树,在 p 生成之后。对应这种情况的句子形式就是 pQR 。它起因于两种生成过程 S PQR pQR,这产生了解析树。右图表示了在自底向上解析器中 p 被生成后的部分解析树。对应的句子形式是 Pqr ,由 pqr Pqr 产生;唯一的还原结果导致了只有一个节点的解析树。
将广度优先或深度优先与自顶向下或自底向上结合起来,就得到了四种级别的解析技术。自顶向下技术将在第6章介绍。深度优先自顶向下技术带来了一个非常简单的实现,叫做递归降序;这种技术将在6.6节介绍,非常适合手工编写解析器。由于深度优先搜索内置于Prolog语言中,对这种语言的大量语法的递归降序解析就可以非常简单的定制,使用一个被称为“Definite Clause Grammars”(6.7节)。这项技术的适用性可以扩展到所有语法通过使用一种叫做“cancellation”(6.8 节) 装置。
自底向上技术将在第7章介绍。广度优先和自底向上的结合带来了Earley解析器,对CF语法来说其中有一些非常有效和流行的解析器(7.2节)。一个形式的相似但执行却非常不同的方法带来了“图标解析”(7.3节)。
Sudkamp [397, Chapter 4]给出了一个完全形式的解释,关于[ 广度优先|深度优先 ][ 自顶向下|自底向上 ]上下文无关解析。
3.5.4 线性化方法
上一节所示的大多数一般搜索方法,最坏的情况都依赖于时间指数分布:输入的每个附加符号都将解析时间乘以一个常数因子。这些方法只在输入长度非常小的情况下适用,大约20个字符就是最大值了。即便是上述方法中最优的,在最坏的情况下也要求立方次时间:10个标记时需要1000次操作,100个标记时需要1000000次操作,而10000个标记(一个较大的计算机程序文件)时则需要1012次操作,即便每次操作只需10纳秒也要花费至少3小时的时间。显然在真实的速度下,我们更希望有一个线性时间的计算方法。不幸的是,至今没有找到这样一种方法,尽管没有证据表明这种方法是不存在的,但种种迹象却表明似乎情况就是如此了;详情见3.10节。将此与非严格短语结构解析方法相比,就可以证明没有这种算法存在了(见3.4.2节)。
因此,同时或者永久的,我们将不得不从我们的目标中拿掉一个对象,线性时间通用解析器。我们可以有一个最优也是立方次时间依赖的通用解析器,或者是一个不能适用于所有CF语法的线性解析器,但这两者不能兼有。幸运的是,有延时解析方法(特别是LR解析),可以处理大量语法种类,但依旧如果一个语法只是用最自然的方式来描述一个预期的语言而没有涉及到分析方法,那就只有很小的机会能使用自动线性分析。在实践中,语法通常首先是为了自然而设计,然后通过手动调整来符合现有分析方式的要求。这种调整相对简单,具体取决于所选择的方法。简而言之,对于任意给定语法做一个线性解析器有10%的工作是艰难的,而另外90%可以由计算机来完成。
我们可以通过限制非确定性解析自动机的处理位移数量在每种情况下只有一个来实现线性解析时间。由于这种情况下一个自动机的处理位移没有别的选择,所以被称为“确定性自动机”。
确定性自动机的处理位移是由输入流明确确定的(现在我们可以说是流,因为自动机是从左到右处理的)。这个的序列就是一个自动机能给出的一个句子的唯一一个解析。如果语法是明确的那这就是正确的,单如果语法不明确,那确定性自动机将会把我们定死在一个特定的解析中。我们将在8.2.5.3节和9.9节中细说。
剩下的就是解释如何从语法中推导出解析自动机的确定性控制机制。由于对这个问题没有一个单独的很好的解决办法,那么存在很多的次优的解决方案就不足为奇了。从一个非常广泛的角度来说,它们都使用同样的技术:它们都深入分析语法,以使可用于识别死角的信息被发现。然后就可以避开这些死角。如果应用于该语法的方法,能够避开足够多的死角并使得不在存在死角,那么这个方法对于语法来说就是成功的,并且给我们提供了一个线性时间解析器。或者它失败了,那我们要么换另一个方法要么就改变语法使之能够使用该方法。
与迷宫问题的一个大致的类比可能使让这点更清晰。如果允许我们在迷宫中做预处理(不太可能但有启发性),那下面的方法会使我们的搜索成为确定性的。我们先假定迷宫由正方形房间的网格构成,如果3.10(a)所示。深度优先搜索将在迷宫中找到一个13次移动的路线(图3.10(b))。现在我们对迷宫预处理如下:如果有一个房间有3面墙,那么给其加上第4面墙,持续进行直到没有拥有3面墙的房间。如果现在所有的房间只有两面或者四面墙壁,并且没有别的选择,那么我们就成功了;见图3.10(c),现在路径只有5次移动,并且不需要搜索了。通过这个方法我们就看到如何能将死角识别出来了,用以帮助缩小选择范围。
有一点要说明的是,上述的类比是有限的。它只关心迷宫这一个被预处理的对象。在解析中我们要关注两个对象,一个是静态的并且能被预处理的语法,还有一个是变化中的输入。(但请参阅问题3.6 以扩展类推方法。)
回到解析自动机,我们可以说,它的确定性更精确:一个拥有前瞻符号K的解析自动机是确定性的,如果它的控制机制可以,那么给予内部管理和下一个符号K输入信息,以明确决定下一步做什么——要么匹配要么预测以及在自顶向下情况中预测什么,要么转移要么减少以及在自底向上情况中如何减少。
它的原因是确定性自动机创建了一个线性时间解析器,但这并不完全明显。解析器可能在有限的时间内知道下一步该做什么,但对于给定输入标记可能有很多步要执行。更具体的说,某些确定性技术对于给定位置k会需要k步,这表明二次行为是可能的(见问题3.5)。但是每个解析步骤要么创建一个新的节点(预测或减少),要么消耗一个输入标记(匹配和转移)。两个操作都只能执行O(n)次,其中n是输入的长度:第一个是因为解析树的大小仅为O(n),第二个是因为只有n个输入标记。因此不论各种各样的任务的操作是如何分布的,它们的总和不能超过O(n)。
与语法类型一样,确定性解析方法也可以用缩写表示,就像LL,LALR等。如果一个方法X使用了前瞻符号k,那就写作X(k)。所有确定性方法都需要以某些方式预处理语法来推导出解析自动机,外加一个解析算法或用自动机来处理输入。
3.5.5 确定性自顶向下和自底向上方法
只有一种确定性自顶向下方法,它叫做LL。第一个L代表从左至右(Lift-to-Right),第二个是“确定最左侧生成”(identifying the Leftmost production),就像定向自顶向下分析器一样。LL解析在第8章中进行讲解。LL解析,尤其是LL(1)非常受欢迎。LL(1)解析器通常是由一个解析器生成器生成的,但是一个简版的可以通过手工费点劲写出来,通过递归减少技术;见8.2.6节。偶尔将LL(1)方法从输入的最后一个标记开始,这时就叫做RR(1)。
有很多的确定性自底向上方法,最强大的叫做LR,其中L也是值从左至右(Lift-to-Right),R代表“确定最右侧的生成”(identifyingthe Rightmost production)。线性自底向上方法将会在第9章讲到。他们的解析器只能是由一个解析器生成器生成的:这样一个解析器的控制机制太过复杂以至于人工是无法完成的。部分确定性自底向上方式非常的受欢迎,甚至可能比LL(1)方式更广泛的使用。
LR(1)解析比LL(1)解析更强大,但也更难以理解和不那么方便。其他方法无法随便同LL(1)相比。17.1节有实用解析方式之间的对比。LR(1)解析也能从输入最后端开始,此时被称作RL(1)。
这两种技术都使用了前瞻性来确定下面的行动。通常这种前瞻性会被一个标记的限定(LL(1),LR(1)等),或者至多少数的几个标记的限定,但偶尔不受限的前瞻能带来很大帮助。这需要其他的解析技术,而这会带来确定性解析器的细化分类,见图3.11。
自顶向下和自底向上方法的差异很大,当我们细看不同分析器的选择时就很容易理解了。自顶向下解析器本质上没有什么选择:如果一个终结符已经被预测,那就没有别的选择而只能从存在的匹配中确定;只有当一个非终结符被预测到了,它才有一个选择那个非终结符的选择。一个自底向上解析器移向下一个输入符号,即便是缩减的也是可以的(并且也必须这样做)。此外,如果可能存在缩减,它还可能会选择在右侧的一组中进行选择。总的来说,它比自顶向下解析器有更多的选择,所以需要更强大的技术来确定它。
3.5.6 非规范方法
对于许多实用语法来说,上述方法依旧不能产生一个线性时间确定性解析器。通常采取的方式是对语法稍作修改已适用于选择的方法。但不幸的是,最终的解析树无法对应于原始的语法,后续还需要在手动修改一次。另一种方法是设计一个解析器,让其一直推导直到没有可用的信息,然后以“半猜测”的方式持续解析直到信息再次可用。这样的解析器被称为非规范的,因为它们以非标准的方式在识别解析树中的节点,“非规范”顺序。不用说,这肯定得谨慎的实施,一些最强大、最智能、最复杂的确定性解析算法就属于这个范围内。第10章中会讲到。
3.5.7 广义线性方法
当我们构建一个确定性控制机制的试图失败,并留给我们非确定性的但是又几乎是确定性的机制时,我们还不需要感到绝望:我们可以回到广度优先搜索,在试验期间内解决留下的非确定性问题。我们原本的方法越好,遗留的不确定性就越少,需要用到广度优先搜索的就越少,那解析器的效率就越高。这种解析器被称为“广义解析器”;对大多数自底向上和自顶向下确定性方法,广义解析器已经设计出来了。在第11章中有介绍。广义LR(或GLR)(Tomita [162])是现今可用的最佳通用CF解析器之一。
当然,重新引入广度优先搜索是我们的一个冒险。语法和输入有可能会使每个输入中隐藏不确定性,这样会导致我们的解析器再次具有时间依赖性。然而在实践中,从来没有发生过这种情况,这样的解析器非常有用。
3.5.8 总结
图3.11总结了本书中涉及的解析技术。Nijholt [154]绘制了一个更抽象的分析视图,基于左角解析。更抽象的一个概要参见Deussen [22]。Griffiths和Petrick [9]在早期做了一个系统的调查。
3.6 解析技术的力量
一般来说,一个T1解析技术要比一个T2解析技术更有力量(更强大),当T1能够处理T2所能处理的全部语法,而不是相反的情况。不形式的说法是如果占主动权的一方可以比另一个处理更多的语法时,那这个解析技术就比另一个要强。不过这当然是无稽之谈,由于所有的解析技术都能处理无限集合的语法,那么“更大”的概念就是难以定义的。此外,一个没有明确目的的用户语法几乎没有任何可能能符合现有的解析技术。从用户角度来看,对“平均”实用语法的修改已适用于方法T来处理的工作量才是有意义的,以及修复这种修改对解析树带来的差异。解析技术T的力量(作用)与这个工作量是成反比的。
一个强大的解析器总是更复杂的,相比于弱小的解析器需要花费更多的精力来编写它。但由于一个解析器或者解析器生成器(见17.2节)只需要费一次功夫编写好之后,就可以想怎么用就怎么用,所以长远来说一个强大的解析器更省时省力。
虽然一个“强大”的解析器的概念很直观明了,但当解析器和语法混在一起时就很容易出现混淆了。解析器越强大那么语法的限制就越小,而“弱小的”解析器就要限制语法了。通常会使用语法来命名解析器,而这也就是混乱开始的地方。一个“强大的LL(1)语法”比“LL(1)语法”有更严格的限定;也可以说是更强大的LL(1)。这样的语法的解析器很简单,相比于(完整)LL(1)语法,并且也是属于弱小的--可以承受的。所以实际上,一个强大的LL(1)(strong-LL (1))解析器比一个LL(1)解析器要弱小一些。我们一直都在强调“强大(strong)”和“LL(1)”之间的连字符号,以表明“强大”是用来形容“LL(1)”的,而不是语法,但并非所有的出版物都会遵循这个约定,所以读者一定要清楚这一点。“弱化优先分析器”出现时情况就反过来了,“弱化优先分析器”要比“优先分析器”更强大(尽管还有一些别的不同)。
3.7 解析树的表现形式
解析的目的是获取一个或多个解析树,但很多解析技术不会提前告诉你会有0个、一个、几个或者无限多个解析树将会生成,所以对于即将出现的结果会缺点准备。有两件事情我们要避免:无准备应对和没有解析树,以及准备过渡和分配过多的内存。目前没有太多的文章或书籍涉及到这个问题,但在论文中遇到的技术性问题可以分成两个模型:生产者-消费者模型和数据结构模型。
3.7.1 生产者-消费者模型中的解析树
在生产者-消费者模型中,解析器是生产者,而使用解析树的程序是消费者。正如在计算机科学中的生产者-消费者模型,最直接的问题是,哪个是主进程哪个是子进程。
最好的解决方案是将他们视为同等的,然后可以使用协同例程(coroutine)。协同例程在其他的编程语言和编程技术原理中进行了介绍,例如高级程序语言设计,作者R.A. Finkel (Addison-Wesley)。在网络上也有很多的解释。
在协同例程模型中,用户对新解析树的需求和解析器对解析树的提供是由协同例程自动配对的。协同例程的问题是,它们必须被内置到编程语言中,并且没有主流的编程语言适应它们。因此,对解析树表示方法协同例程并不是一个实用的解决方案。
协同例程的现代表现方式,线程,其中配对是由操作系统或编程语言内一个轻量级操作系统来完成的,在一些主要语言中是可用的,但关于并行性概念的介绍并不是解析所固有的。Unix管道有相似的通信属性,单对解析来说却相差更大。
通常解析器是主程序,而消费者是子例程。每当解析器完成解析树的构造时,它就会将树当做参数用一个指针来调用消费者例程。然后消费者就可以决定如何使用解析树:拒绝它、接受它、存储它以备将来的比较,等等。在这个设置中,解析器可以很好的生成解析树,但是消费者可能必须在每次调用之间存储状态数据,以便能够在解析树之间进行选择。这是解析器设计中通常的设置,在这里只有一个解析树,而消费者状态保存则不是一个问题。
还可以将消费者作为主程序,但这会给解析器带来沉重的负担,因为解析器必须在生成解析树的过程中保存全部未完成解析的状态数据。由于堆栈上的状态不能保存为状态数据(除非采用苛刻的方法),这种设置只适用于不使用堆栈的解析方法。
对着这些设置,在大多数情况下用户还有两个问题。首先,当解析器生成多个解析树时,消费者将它们作为独立的解析树接收,然后可能需要做相当大的比较来找出它们之间的差别来进行进一步的决策。第二,如果语法是无限模糊的,并且解析器解析器产生无限多的解析树,那这个过程将不会停止了。
所以生产者-消费者模型对于确定性语法是一个令人满意的方案,但更多的情况下却会有问题。
3.7.2 数据结构模型中的解析树
在数据结构模型中,解析器构造一个独立的表示所有解析树同时进行的数据结构。令人惊讶的是,即使是无限模糊的语法也可以解决;并且,它可以在一个与输入字符长度的3次幂成正比的空间里完成。有人说数据结构有立方结构依赖性。
有两种表现形式:解析林和解析林语法。虽然两者在本质上是相同的,但在概念和实际使用中却是大有不同,将它们视为单独的个体是有必要的。
3.7.3 解析林
由于森林只是树的集合,所以解析林最天然的形式是由一个单一的节点组成,而解析林中的所有树都可以直接访问。图3.2中的两个解析树合并到图3.12中的解析林中,其中节点中的数字引用了图3.1的语法中的规则号。
当我们看到这幅图时,我们会注意到两件事情:虚线箭头与实线箭头的含义不同;结果树包含大量重复的子树。而且还会好奇顶部的空节点应该是什么。
虚线箭头的含义是“或-或”:顶部的空节点指向左边或是右边的标记2的节点,那么实线箭头就是“与-与”:左边标记2的节点由一个标记Sum的节点与一个标记+的节点与一个标记Sum的节点组成。更特别的是,顶部的空节点,应该被标记为Sum指向规则2的两个应用程序,其中每一个都生成Sum + Sum;最左侧的Sum指向一个规则1应用程序,第二个Sum指向一个规则2应用程序,等等。图3.13展示了完整的与-或树,这里我们看到了一个标记非终结符的节点的替换,即或节点,以及标记了规则号的节点,即与节点。A非终结符的一个或节点对A的终结符的子节点有一个规则号;一个规则号的与节点有右侧规则的子节点的组件。
3.7.3.1 合并重复子树
现在我们可以将解析林中的重复子树合并在一起了。我们通过保留标记了非终结符A以及跨越输入的给定子字符串的一个副本来做到。如果A以多种方式来生成子字符串,那么多个或箭头将从标记了A的或节点出发,每一个都指向一个标记了规则号的与节点。这样,与-或树就变成了一个有向无环图,一个有向无环图,其实正确的叫法应该是一个解析有向无环图,也就是“解析林”变得更加通常了。我们示例的结果如图3.14 所示。
必须注意的是,两个或节点(这代表着规则的右侧)只能在两个节点的其他所有对应节点都相同时才能合并。它不会将图3.14中顶部正下方的两个标记为2两个节点合并;即便它们都是Sum+Sum,因为其中Sum和**+**都是不一样的。如果将它们合并在一起,那解析林会展示出比对应输入更多的解析树;参见问题3.8.
合并重复子树的过程可以在解析过程中,而不是所有解析树都完成之后。这明显是更高效的,并且有额外的优势,它允许在无限模糊的解析展现在有限的数据结构中。然后解析林就包含循环(环),实际上就是解析图。
图3.15总结了各种Chomsky语法类型相对于歧义的情况。请注意,有限状态和上下午相关语法不能无限模糊,因为它们不能包含可以为空(nullable)的规则。有关生产数据结构的类似摘要,请参见图2.16。
3.7.3.2 从解析林中检索解析树
解析林的接收器有多个选择。例如,可以从它生成一个解析树序列, 或者更可能的是, 数据结构可以被修改以剔除各种原因产生的解析树。
从解析林中生成解析树基本上很简单:或-或箭头的每一个选择的组合都是一个解析树。实现应该是从上到下的,并且在这里可以简要的勾勒出来。我们对图做深度优先访问,对于每一个或节点,我们将向外指出的虚线箭头转换为实线箭头;我们将这些选择记录在一个回溯链中。当我们完成了深度优先探索后我们就修复了一个解析树。当我们完成后,检查最近的选择节点,由回溯链提供的最后一个元素,然后如果可以的话做一个与之前不一样的选择;如果不可用那么就后退一步,如此。当我们回溯完整个回溯链,我们就找到了所有的解析树。在图3.16中展示了一个解析树的实现过程。
首先挑出解析树是很好的选择。如何完成这取决于挑选标准,但常用的技术如下。在解析林中的每个节点都添加了信息,其方式与属性语法的内容类似(2.11.1节)。无论何时当该节点的信息与该节点的类型相冲突时,都将该节点从解析林中移除。这通常会导致其他一些节点成了从上至下无法到达的情况,这时也可以将它们一起移除。
对图3.14的解析林进行有意义的挑选可以基于以下依据,+运算符是左关联的,这意思就是a+b+c实际是**((a+b)+c)而不是(a+(b+c))。然后, 对于每个具有+运算符的节点, 其右侧的操作数不能是具有+运算符的非终结符。我们看到,图3.14中标记为2的左上节点违反了这个规则:它有一个+运算符和一个有一个节点(2)以及一个+**符号(位置4)的非终结符(Sum)。因此,这个节点可以被移除,以及两个子节点也可以移除。图3.16中的解析书依旧存在。
上面的规则是在算术表达式中关于运算符优先级的一个(非常)特殊的例子;请参阅问题3.10以了解更普遍的情况。
3.7.4 解析林语法
将解析的结果作为语法展示可能看起来很奇怪,甚至是有些令人失望的;毕竟应该从语法和字符串开始,做的所有解析工作,难道最后只是为了成为另一个语法?但是我们将看到解析林有相当多的优点。但这些优点都不明显,这可能也是为什么解析林直到上世纪八十年代才被提出,由Lang [210, 220, 31]引入。而“解析林语法”这个词似乎是被van Noord [221]首次使用。
图3.17 将图3.2 的解析树显示为一个解析林语法,而这之间的过程很有趣。对于原始语法中的每一个非终结符A,会从i的位置开始产生一个长度为l的段,在解析林语法中有一个非终结符A_i_l,以及显示A_i_l如何产生一个段的规则。例如,解析林语法存在的Sum_1_5展示了Sum产生了整个的输入字符串(从位置1开始,长度为5);Sum_1_5有多个规则的事实表明, 解析是不明确的;两个规则显示了Sum_1_5产生整个输入字符串的两种可能方式。当我们使用这种语法来生成字符串时,它只生成3+5+1的输入字符串,但是生成了两次,由于不明确而导致。
我们写做A_i_l而不是Ai,l,因为A_i_l代表了一个语法符号的名称,而不是元素A的一个下角标;并没有A表或矩阵。A_i_l和A_i_m之间也没有任何关系;每个A_i_l都是一个单独的语法符号名称。
现在说说优势。首先,解析林语法以图形化的方式实现,这一概念已经在上一节中隐晦的表达了:即应该有一个实体来描述一个给定的非终结符如何生成给定的子字符串。
其次,它具有数学美:现在解析一个字符串可以可以看做一个函数,将一个语法映射到一个具体的语法或一个错误值。而不是三个概念——语法、输入字符串和解析林——现在我们只需要两个:语法和输入字符串。更实际的是,所有用于处理原始语法的软件也可以应用到解析林语法。
第三点,解析林语法在梳理过后很容易恢复,使用2.9.5中的算法。例如,将前一节的消歧法则应用于图3.17中的语法规则,可以确定Sum_1_5的第一条规则是相违背的。去除这一条规则并应用语法恢复算法生成图3.18的明确的语法,它对应于图3.16中的树。
第四,无限模糊解析是没有大的影响的:解析林语法只生成无限多个(相同的)字符串。而产生无穷多的字符串正是语法通常所做的。
最后但同样重要的一点,它很好的适应了解析作为一个新兴而又有希望的方法的交点,这点将在第13章进一步讨论。
现在可以说,解析林和解析林语法实际上是相同的,前者的指针在后者中被称为指代,但这也并不完全一样。指代要比指针更强大,因为指针只能指向一个对象,然而一个指代可以用来识别多个对象;通过重载或不明确指代:指代是多路径的指针。更具体的说,在图3.17中Sum_1_5指代了两个规则,因此在图3.14中承担了顶部或节点的角色。我们看到,在解析林语法中,我们不费丝毫就得到了一个与或树机制,因为它是建立在语法生成的机制上的。
3.8 什么时候才是完成了解析呢?
由于非决定性解析器是一次性处理整个输入字符串,并将其汇总到一个单一的数据结构中,然后可以从中提取出解析树,那么何时能完成解析的问题就不大会出现了。当数据结构完成后,第一阶段就完成了;而提取解析树是在数据结构用完之后或者用户满意就完成了。
原则上,一个定向解析器处于接受状态而所有的输入都已经结束,就是完成了。但这本来是重复要求的,有时其中一个条件就蕴含了另外一个;并且通常其他条件会起同样的作用。因此,对定向解析器来说,这个答案很复杂,取决于很多因素:
-
解析器是否在输入的末尾?就是说,它是不是处理完了输入的最后一个符号?
-
解析器是否处于可接受状态?
-
解析器是否可以继续,例如,如果有下一个字符,解析器是不是能继续处理它?
-
解析器是用来生成解析树的还是只是作为一个识别?第一种可能出现几种不同情况;第二种的话我们的答案只会是是/否。
-
如果我们想要解析数,那是想要全部的解析树还是一个就够了呢?
-
解析器必须要接受整个输入,还是用符合语法的合适的隔离符来进行分割?(如果有一个字符串x,是另一个字符串y的开头部分,那x就是一次分割。)
关于我们是否完成了解析的问题的答案,在下表有了对照,其实EOI代表“输入结束”,yes/no代表对选项的回答。
有些答案在直觉上是合理的:如果解析器持续保持在不接受输入的状态下,它应该这样做;如果解析器不能保持在不接受输入的状态,那么输入中存在错误;并且如果解析器在输入结束时仍旧保持可接受状态,那么解析就成功了。但另一些情况要更复杂:如果解析器是在处于可接受状态,我们就会隔离一个前缀,即使解析器在结尾处可以继续“与/或”处理。如果这是我们想要的,那我们可以停止了,但通常情况下,只要可以继续我们就会想要继续:语法S--->a|ab以及输入ab,我们可以在a和声明a的一个前缀后结束,但很可能的是我们会想要继续下去,直到ab整个被解析结束。这可能是事实,即便我们已经处于结尾处:语法S--->a|aB,其中B生成ε,我们要继续输入a以及解析B,如果我们想要获取所有的解析。如果解析器做不到,我们在语言中识别了一个字符串,错误信息通常被称为“尾随垃圾”(trailing garbage)。
请注意,“过早处于结尾(premature EOI)”(在语言中一个字符串的输入是一个前缀),是“前缀隔离(prefix isolated)”(输入的前缀是语言中的一个字符串)的对偶。如果我们正在找一个前缀,那我们一般会想找到最长的可能的前缀。这可以通过标记最近的被识别为前缀的位置P,然后继续解析直到我们到达结尾或者出现错误被卡住。那么P就是最长前缀的末尾。
许多定向解析器使用前瞻方式,这意味着即便在输入的末尾,也必须有足够的标记用于前瞻。这可以通过引入一个输入结尾标记来实现,例如**#或其他任何语法中没有的标记。对于一个使用k个标记做前瞻的解析器,k个#的副本将会被追加到输入字符串中;解析器的前瞻机制将会也会进行相应修改;例子参见9.6节。唯一的接受状态就是第一个#**即将被接受的状态,而这通常也表示解析完成了。
这大大简化了目前的情形和上面的表格,因为现在解析器在输入没有结束时不能处于可接受状态。这去掉了上表中前缀的两个答案。然后我们将上表的上下部分进行叠加,然后最左边的列就变得多余了。就变成了下面这张表:
然后我们将区分“错误输入”和“过早结束”的工作交给错误报告机制来完成。
由于在定向解析器中没有明确的终止标准,所以每个解析器都有自己的停止标准,这是一个有点不理想的状态。这本书中,我们将使用最终输入标记,只要它有助于终止,并且当然,对于解析器使用前瞻机制。
3.9 传递闭包
解析中的许多算法(以及在计算机科学中的许多其他分支)都具有一些从初始信息开始的属性,然后根据一些理论规则推到得出结论,一直到不能得出更多的结论为止。在2.9.5.1 和2.9.5.2 节中的推理规则中, 我们已经看到了两个例子。这些推理是完全不同的,并且一般的推理规则可以是任意复杂的。为了得到一个清晰的计算结论的算法,闭包算法,我们现在将考虑一个最简单的可能的推理规则: 传递性。这种规则的形式如下:
如果AB 并且BC 那么AC
其中是任意符合规则的操作符。最明显的是=,但是<和以及其他许多也是可以的。但是(不等于)却不是的。
作为一个例子,我们将考虑一个非终结符的“左角集”的计算。一个非终结符B在一个非终结符A的左角集中,如果有一个派生AB···,知道这点有时是有用的,因为在其他方面来说,任何B可以开始的字符A也都能开始。
有下面这样一个语法
我们如何才能找出C在S的左角集中?语法中的规则SST和SAa立即就让我们知道S和A在S的左角集中。我们把这写作S∠S和A∠S,其中∠代表左角。这同样告诉我们A∠T,B∠A,还有C∠B。这是我们的初始信息(图3.19(a))。
现在很容易看出,如果A在B的左角集中,B在C的左角集中,那么A也在C的左角集中。公式如下:
A∠B ∧ B∠C A∠C
这是我们的推理规则,而且我们将使用它来得出新结论或“推论”,通过两两组合已知的事实来产生更多的已知因子。然后通过应用推理规则直到不再产生新的因子来获得传递闭包。在传递闭包的上下文中,因子也被称为“关系”,虽然一般∠是(二进制)关系,并且A∠B和B∠C 是关系的 "实例"。
通过图 3.19 (a) 中的列表,我们首先将S∠S和S∠S结合起来。这将产生S∠S,这是相当令人失望的因为我们已经知道了;它在图 3.19 (b) 中, 标有一个**√, 以表明它不是新的。(S∠S, A∠S的)结合产生A∠S**,但是我们也已经知道了。没有其他的因子与S∠S结合,所以我们继续看A∠S,而这得到了A∠S和B∠S;第一个我们已经知道了,但是第二个是第一次被我们知道。接着(A∠T,B∠A)就得到了B∠T,等等,做完剩下部分的第一轮结果见图3.19(b)。
第二轮结合了三个有新有旧的因子。第一个是发现由A∠S和C∠A (c)得到了C∠S,第二个发现是C∠T。
第三轮将(c)中的两个新因子与(a),(b),(c)中的结合起来,但没有发现新的因子;所以这个算法最终发现了10个因子。
请注意,我们已经在这个初级算法中实现了一次优化:基础算法将启动第二轮甚至更多轮次,通过将已知的所有因子之间配对,而不仅仅只是在新发现的因子之间。
在一个图中用弧线表示因子或关系通常是有帮助的。最初的情况见图3.20 (a),最终的结果见(b)。箭头旁边的数字表明得到该因子经过了几轮计算。
闭包算法的效率很大程度上依赖于它所所使用的推理算法,而传递规则的情况被广泛的进行了研究。传递闭包主要有三种方法来进行:初级版、普通版和高级版;我们将简要的对每一种进行介绍。上面描述过的初级算法,在通常情况下往往是相当有效率的,但会画出一张很大的图,而且特殊情况下可能会需要计算很多轮。同样它还会重复计算很多次,我们可以在图3.19中看出;15个结果中有10个是已经得到了的。但考虑到“正常”语法的大小,初级算法几乎可以满足所有情况下的解析。
普通的进行传递闭包的方式是使用Warshall的算法[409]。其优势是非常简单实现,而且它需要的时间仅取决于图中N节点的数量而不是弧线的数量,但它的缺点是总是需要*O(N3)*的时间。这回让它在和其他任何闭包算法的比较中总是输掉。
高级算法避免了导致初级算法效率底下的劣势:1.图中的圆圈被收敛为“强联通分量”;2.弧线以一种顺序组合起来,并允许弧线进行复制而不是重复计算;3.使用更有效的数据表示。例如,一个高级算法首先会计算从A的所有输出弧,然后将之拷贝至T而不是重新计算一次。Tarjan [334] 描述了第一个高级可传递闭包算法。并在其他很多刊物上广泛转载;见Nuutila[412]和互联网。它需要的时间与其最终得出的结果数量成正比。
高级可传递闭包算法在大型应用程序 (数据库等) 中非常有用,但它在解析中的位置确实令人怀疑的。一些作者建议在LALR解析器生成器中使用它们,但应该在非常庞大的语法上使用,以保证算法的复杂性有一个很好的回报。
强调算法的闭包性质的优点可以让人集中于推理规则,并将底层的闭包算法当做理所当然;这对于算法设计很有帮助。然而大多数的解析算法都很简单,以至于不需要分解成推理规则和闭包解释。因此我们将仅在有助于理解的地方使用推理规则(9.7.1.3节),以及当其原本就是语法的一部分的情况下使用(7.3节,图表解析)。对于其余的我们将简单的讲述算法,然后指出他们是传递闭包算法得出的。
3.10 解析与布尔矩阵乘法的关系
在解析和布尔矩阵乘法之间有一个显著但又有点神秘的关系,因为很可能把一个转换为另一个,带有很多可能和但是。这有很有趣的含义。
一个布尔矩阵是一个其中所有元素只能为0或者1的矩阵。比如如果一个矩阵T的索引代表镇,那么元素Ti,j可能就表示城镇i到城镇j的直达铁路的距离。这样的矩阵可以和另外一个矩阵Uj,k相乘,这可以表示比如,一个城镇j到城镇k的直达巴士的距离。而Vi,k(T和U的乘积)的结果是一个布尔矩阵,这代表从城镇i到城镇k是否能联通,首先考虑火车然后才是巴士。这立刻就可以展示Vi,k是怎样计算出来的:必须有一个1,如果存在一个j使得*Ti,j和Uj,k*都能有保有一个1,否则就必须有一个0。公式如下:
Vi,k = (Ti,1 ∧ U1,k)∨(Ti,2 ∧ U2,k)∨· · ·∨(Ti,n ∧ Un,k)
其中∧是布尔运算的和,∨是布尔运算的或,n是矩阵的大小。这意味着O(n)的行为被V的每次输出所要求,其中就有n2;所以这个算法的时间复杂度为O(n3)。
图3.21给了一个示例;矩阵T2, * 的行和矩阵U * ,2的列结合得到了矩阵V2,2的输出。布尔矩阵的乘法运算中,是不遵守交换律的:可以大致的理解为从一个镇到另一个镇有一个火车-巴士的路线,但是没有巴士-火车的路线,也就是T×U不等于U×T。注意这个跟传递闭包是不同的:在传递闭包中,一个单一关系遵循无限的次数,而在布尔矩阵运算中则是一个关系结果之后才有第二个。
上述是布尔矩阵乘法(BMM)的一个小实战,实际上BMM在许多数学和工科分支中是非常重要的,并且关于如何高效的使用它有一个完整的学科1。数十年的集中努力带来了一系列越来越高效和负责的算法。V. Strassen2是第一个打破O(n3)阻碍的算法,使用的是O(n2.81···)算法,到了现在这个记录是O(n2.376···);时间是1987年。很明显,至少*O(n2)*次运行是必须的,但目前看来这个时间复杂度是达不到的。
对我们的论点来说更重要的是,在1975年Valiant [18]展示了如何将一个CP解析问题转换为一个BMM问题。特别的是,如果可以在O(nk)次操作中将两个n×n的布尔矩阵相乘,那就可以在O(nk)+O(n2)步中解析一个长度为n的字符串,其中O(n2)是转换的成本。因此,我们可以在O(n2.376···)中进行一般的CF解析,这确实比CYK算法的立方时间依赖性要好。但是Valiant算法和快速BMM算法都太过复杂和耗时,所以这种方法只有当输入字符串以百万计甚至更多的时候才拿来使用比较好。更要命的是,它要求所有的输入都必须存储在内存中,因为它是无方向的算法,因此它使用的数据结构的大小是O(n2),这意味着它只能在内存单位的TB的计算机上运行。简而言之, 它的意义只是理论上的。
在2002年Lee [39]显示了一个BMM问题如何转换为一个CF解析问题。特别的是,如果你可以在O(n3−δ)次操作中对一个长度为n的字符串进行常规CF解析,那就能在O(n3−δ/3)次操作中将两个大小为n×n的布尔矩阵相乘。那就再次出现了O(n2)的转换成本,但由于δ最多可为2(不太可能在O(n)中解析一个实例),O(n3−δ/3)的步骤至少是O(n2 ),这决定了O(n2);要注意δ = 0的情况*O(n3)通常是多个问题的边界。Lee的转换所涉及的计算工作量比那些用Valiant算法的要小的多,所以一个真正快速的通用CF解析算法可能会提供一个快速实用的BMM算法。这样一个快速通用的CF算法必须是非BMM并且比O(n3)*具有更高的时间复杂性;但不幸的是目前还不知道这样的算法。
一般的CF和布尔矩阵乘法有共同之处,其最佳算法的效率是未知的。图Fig 3.22总结了各种可能性。x轴表示最佳CF算法的效率;y轴表示最佳BMM算法的效率。图中的位置表示了这些值的组合。由于这些值是未知的,我们不知道图中的哪个点对应于实际,但是我们可以排除几个区域。
在现有算法的基础上灰色区域被排除。例如,在*n3的垂直线的右侧灰色部分由于CYK算法被排除了,它在O(n3)中进行一般的CF解析;所以其组合(最好的解析器,最好的BMM)不能有一个大于O(n3)*的第一组件。同样的,*n1垂直线的左侧区域代表了小于O(n)工作量的算法,而这是不可能的因为解析器必须到达每个标记。BMM要求至少O(n2)次动作,但已经有O(n2.376···)*这个算法可以用了;这产生了两个水平禁区。
阴影标记部分是被Valiant和Lee转换算法排除的部分。Valiant的结果不包括右侧的水平阴影部分;Lee的结果不包含顶部的垂直阴影部分。真正的最佳解析和BMM算法的组合只能在中间的白色无阴影区域中。
对BMM算法的广泛研究并没有产生一个比*O(n3)更实用的算法;由于BMM可以被转换为解析,因此可以解释为什么关于CF一般算法的没那么广泛的研究没有产生比O(n3)更实用的算法,除了通过BMM。另一方面,图Fig 3.22显示了普通CF解析还是有可能成为线性的(O(n1)),以及BMM可能比O(n2)*更糟糕。
Rytter [34]已经将普通CF解析和一个特定形式在一个格子中用最短路径连接起来,通过某种含义。
Greibach [389]描述了“最难的上下文无关语言”,是一种语言,如果我们能在时间*O(nx)内解析它,那我们就在O(nx)*时间内解析任何语言。不用说,肯定很难解析。本文隐式的使用了一种很少被注意到的解析技术;见问题3.7。
For a survey see V. Strassen, “Algebraic complexity theory”, in Handbook of Theoretical Computer Science, vol. A, Jan van Leeuwen, Ed. Elsevier Science Publishers, Amsterdam, The Netherlands, pp. 633-672, 1990.
V. Strassen, “Gaussian elimination is not optimal”, Numerische Mathematik, 13:354-356, 1969.
3.11 总结
语法允许通过一个明确的过程产生句子,而这个过程的细节决定了句子的结构。解析通过模拟产生过程(自顶向下解析)或回滚(自底向上解析)来恢复这个结构。真正的工作是收集信息来指导有效的恢复结构的过程。
有一个完全不同的--令人惊讶的--无语法方式来进行CF解析,“面向数据分析”,这在本书的范围之外。见Bod [348]以及互联网。
问题
问题3.1:假设给定语法中的所有终结符是不同的。语法是否明确?
问题3.2:编写一个程序, 给定一个语法G和一个数字n, 计算G允许的不同的有n个叶子(终端)的解析树的数量。
问题3.3:如果您熟悉现有分析器 (生成器), 请标识其分析器组件, 如69页所述。
问题3.4:3.5.4 节中的迷宫预处理算法消除了所有具有三壁的房间;在确定性迷宫中有两面或四面墙的是可以接受的。那么没有墙或者只有一面墙的房间呢?它们如何影响算法和结果?消除掉它们是否可能/有用?
问题3.5:构造一个例子,使得一个确定性自底向上解析器,对于某个k,在位置k必须执行k次操作。
问题3.6:项目:图 3.10*(b)*中的迷宫有几种可能的路径,因此一个迷宫定义了一组路径。很容易就可以看出这些路径形成了一个常规集合。这样一个迷宫等同于一个语法。深入挖掘一下这个类比,例如:1.从迷宫的某些描述中推到出语法规则。2.子集算法(5.3.1节)如何变化迷宫?3.是否有可能生成一组迷宫,它们一起可以定义一个给定的CG集合?
问题3.7:项目: 研究 Greibach [389] 的 "平移和交叉匹配括号" 解析方法。
问题3.8:展示图3.14的一个版本,其中在顶部附近标记为2的节点联合了在输入中不支持的解析数。
问题3.9:实现3.7.3 中草绘的回溯算法的蓝图。
问题3.10:假设算法表达式用一个高度模糊的语法解析,其中对数字进行了适当的定义。设计一个条件可以帮助优化解析林,以获取服从运算符一般优先权的解析树。例如,4+5×6+8应该是((4+(5×6))+8)这样的。考虑到前四个运算符是左结合,但幂运算↑是右结合运算符:6/6/6可以写成((6/6)/6),但6 ↑ 6 ↑ 6却是(6 ↑ (6 ↑ 6))。
问题3.11:研究项目:一些解析问题包含了非常大的CF语法,有数百万的规则。这样的语法是由程序生成的,并且将会把有限的上下文条件合并到语法中。它通常是非常多余的,包含许多相似的规则,并且非常不精确。许多正则CF解析器在语法范围内是二次的,上千万的规则带来1014的因子。能找到一个解析技术可以在这样的语法上很好的工作吗?(另请参见问题4.5。)
问题3.12:扩展项目:
-
对于一个标记对(t1,t2)如果S中有#t1=#t2并且S的前缀都有#t1≥#t2,那么一个字符串S是平衡的,其中#t是S或其前缀中t出现的次数。一个标记对(t1,t2)对语法G来说是括号对,如果所有L(G)的字符串对于(t1,t2)来说都是平衡的。设计一种算法来检查标记对(t1,t2)是否是括号对,对于给定语法G:a)在简化但合理的假设下,括号对一起出现在规则的右侧(例如F (E)),b)普通情况下。
-
在字符串中位置i的标记t1和位置j的标记t2相匹配,如果它们之间的字符串段i+1· · · j−1对于(t1,t2)是平衡的。一个括号对(t1,t2)和另一个括号对(u1,u2)是兼容的,如果L(G)字符串中每一个段对于一个t1以及其匹配的t2,对于(u1,u2)都是平衡的。证明如果(t1,t2)与(u1,u2)兼容,那么(u1,u2)与(t1,t2)兼容。
-
对于一个给定语法,设计一个算法来找到最大的兼容括号对集合。
-
使用括号对集合来构造*L(G)*中的句子,在线性时间内。
-
从G中获取有关*L(G)*中不以这种形式构造的字符串段的信息,例如正则表达式。
-
设计更进一步的技术来利用CF语言的括号对蓝图。
4 一般非定向分析
在这章中我们将会介绍两种解析方法,都是无向的:Unger法和CYK法。这些方法被称为无向性,因为它们以看似任意的方向接受输入。它们要求在解析开始之前,所有的输入都存储在内存中。
Unger方法是自顶向下的;如果输入属于这个语言,则必须从语法的起始符号开始衍生,比如S。因此,它必须从起始符号的右侧开始衍生,比如A1A2...Am。这反过来又意味着*A1*必须可以推倒出输入的第一部分,A2必须可以推倒出第二部分,等等。如果输入的句子是t1t2...tn,这个需求可以描述如下:
Unger方法试图找到适合这个需求的输入的分区。这是一个递归问题:如果一个非终结符*Ai要推导出输入的某个部分,则这部分的一个分区必须适应Ai*的右侧。最终,这样的右侧必须由仅有终结符号组成,并且这些可以很容易与当前的输入部分相匹配。
CYK方法用另一种方法来解决这个问题:它试图在输入的右侧中找到出现的部分;每当找到一个,它就在推导出这一部分的左侧的位置标记一下。用相对应的左侧来替换右侧出现的部分,结果会产生输入的一些句子形式。这些句子形式再次成为查询右侧的对象。最终,我们可能会找到一个句子形式,可以同时派生输入句子和属于起始符号的右侧。
在接下来的两节中,将会对这些方法进行详细介绍。
4.1 Unger解析方法
Unger解析方法[12]由一个CF语法和一个输入句子组成。我们将首先讨论Unger解析方法的语法部分,不含ε规则和循环(见2.9.4节)。然后在讨论引入ε规则之后所带来的问题,并对解析方法进行修改以适应所有的CF语法。
4.1.1 不含ε规则和循环的Unger解析方法
为了了解Unger方法如何解决解析问题,让我们举一个小例子。假设我们有一个语法规则:
S → ABC | DE | F
并且我们想知道S是否推导出输入句子pqrs。然后初始解析问题可以用如下示意图来表现:
对于每个手册,我们必须首先生成输入的所有可能的分区。生成分区并不难:如果我们有m个杯子,编号从1到m,有n个大理石,编号从1到n,我们必须找到所有的分区,使得一个杯子至少装有一个大理石,每个杯子中大理石的编号都是连续的,并且小编号杯子所含大理石编号比大编号杯子所含大理石编号要小。我们这样来做:首先我们将1号大理石放在1号杯子里,然后将其余的n-1个大理石和m-1个杯子全部分区。这就让我们有了全部的分区,在第一个杯子中有且只有大理石1的情况。接下来,我们把大理石1、2放在第一个杯子中,然后在对剩下的n-2个大理石和m-1个杯子进行分区,如此继续下去。如果n小于m,则不存在分区。
划分输入相当于用杯子(右侧的标志)来划分大理石(输入符号)。如果一个右侧有比句子更多的符号,那就找不到任何分区(没有ε规则)。对于右侧的第一个标志,那分区则必须像以下这样:
第一个子分区产生了以下子问题:A是否派生出p,B是否派生出q,C是否派生出rs?这些问题的答案都必须是肯定的,否则分区就是错误的了。
对于第二个右侧,我们得到以下分区:
对于最后一个右侧,可以得到以下分区:
所有这些子问题都涉及到较短的句子,除了最后一个。它们都将导致类似的拆分,到最后许多都会失败因为右侧的终结符无法与对应部分的分区相匹配。唯一会引起关注的分区是最后一个。它和我们开始的那个一样复杂。这就是我们不允许语法中存在循环的原因。如果语法中存在循环,我们可能就得一次又一次的重复原来的问题。例如,如果上面的示例中存在一个F→S的规则,那一定会出现这种情况。
以上说明我们这里有一个搜索的问题,我们可以用深度优先搜索或者广度优先搜索技术(见3.5.2节)来解决它。Unger方法使用深度优先搜索来解决。
在接下来的讨论中,图4.1的语法将作为一个例子。这个语法代表了简单的算数表达式语言,包括运算符**+和×,以及运算数i**。
我们将使用句子**(i+i)×i**作为输入示例。因此最初的问题就表现为:
将Expr的第一个替代项写入**(i+i)×i**的输入,得到一个15个分区的表,见图Fig4.2。在这里我们不对全部进行讨论,虽然算法的优化版本需要这样做。我们只讨论至存在一点成功机会的部分:我们先去掉所有与右侧终结符不匹配的部分。因此,值得我们关注的部分就剩了:
这里面临的第一个问题就是找出是否匹配,如果匹配,那么Expr如何推导出**(i**。我们不能把**(i分解成三个非空部分,因为它只包含两个符号。因此,我们唯一可以应用的规则是Expr--->Term**。同样,接下来我们能用的规则是Term--->Factor。所以现在我们有了:
然而这是不可能的,因为第一个右侧的因子有太多符号,而第二个仅包含一个终结符。因此,我们开始使用的部分并不合适,应该被排除掉。而其他部分已经被排除了,所以我们可以得出结论,规则Expr--->Expr+Term无法派生出输入。
Expr右侧的第二部分包含一个符号,因此我们这里只有一个分区,由一个部分组成。将此部分为Term的第一个右侧进行划分又会产生15种情况,其中又只有一个又成功的可能:
继续我们的探索,我们将得到以下派生结果(唯一的派生):
这个例子展示了这个方法的几个方面:即使是小的例子也需要庞大的工作量,不过稍微进行检查就可以省掉大量的无用功。例如,将右侧的符合与分区进行搭配通常会导致分区被拒绝,因此无需进行下步操作。Unger[12]提出了几个更多的检查点。例如,可以计算每个非终端派生的终端符号字符串的最小长度。一旦知道某个非终结符只能派生出长度为n的终结符字符串,则可以立即排除长度小于这个长度的非终结符分区。
4.1.2 含有ε规则的Unger方法
到目前为止,我们只说了不包含ε规则的语法,而这不是没有原因的。当语法包含了ε规则时就会变得复杂,下面的例子就说明了这点:假设语法规则S → ABC,以及输入语句pqr。如果我们要验证这个规则是否能导出输入语句,并且我们用ε规则,那我们必须要考虑很多部分,因为每一个非终结符A,B,C都可能派生出空字符串。在这种情况下,生成所有分区的过程和上面一样,只是我们首先生成的杯子中没有大理石分区,而杯子1有大理石1分区,等等:
现在假设我们在试验B是否能生成qpr,并假设有一个规则B→SD。那么,我们将不得不尝试一下以下分区:
这是这些分区中最后一个会造成麻烦的分区:在寻找S是否导出qpr的过程中,我们最终会在不同的上下文中问出同样的问题。如果我们不小心没有检查到这一点,那我们的解析器就会陷入死循环中或者内存溢出。
当沿着这条路劲搜索时,我们其实是在寻找以S→· · ·→αSβ这种形式得出的推导。如果语法包含ε规则,那么解析器必须假定α和β可以产生ε,这个循环将会导致解析器一遍又一遍的抛出“S是否派生qpr?”这个问题。
如果α和β确实生成了ε,那么在这条路劲上能找到无数可以推导出的因子,前提是至少能生成ε,所以我们永远都无法列举完他们。唯一有意思的是推导那些没有循环的。因此,这样的情况下我们会停下工作。另一方面,如果α和β不能都生成ε,这样的截止也不会造成什么影响,因为如果最初的尝试没有成功,那么二次搜索无论如何都注定会失败。
因此我们完全可以通过结束搜索过程来避免这种情况。幸运的是,这不是一个很难的任务。我们所需要做的就是维护一个我们目前正在进行的问题的列表。在开始一个新问题之前(例如“S是否派生qpr?”),我们先确认一下这个问题并没有出现在列表中。如果出现了,那我们就放弃这个问题。反之我们就可以继续这个问题了。
例如, 请考虑以下语法:
这个语法以一种尴尬的方式生成d的序列。对于问题Sd?和Sdd?的完全搜索如图Fig 4.3。图Fig 4.3必须从左到右,从上到下看。问题是椭圆形中的,在右侧框中分隔开。如果至少有一个框的结果是“yes”,那么问题就是一个肯定的回答。相反,只有当全部的问题都是“yes”,那这部分最后的结果才是肯定的。
寻找停止的问题很简单:如果提出新的问题,我们就沿着箭头相反的方向走(向左)。这样我们就遍历了当前的问题列表。如果再次遇到这个问题,那我们就可以停止了。
要找到解析,每一个得到肯定结果的问题都必须传回一列规则,这些规则开启了问题中所要求的派出。这些规则列表可以放入椭圆中,与问题一起。我们在图Fig 4.3中没有这么做,因为它已经够复杂了。然而当我们去掉图Fig 4.3中的死角并拿掉方框,我们就得到了图Fig 4.4。这种情况下,每个椭圆只有一个可能的语法规则。
因此,只有一个解析,我们通过图Fig 4.4来得到它。从上到下从左到右的查看:
一般情况下, 解析的总数等于每个椭圆中语法规则数的乘积。
这个例子表明,我们可以通过记住问题的答案来节省大量时间。例如,问题L是否派生ε被提出了很多次。Sheil [20]已经表明,这样可以显著的提升效率:从指数变化变成线性变化。另一个优势是,可以通过预先的计算得到哪个非终结符可以派生出ε。实际上,计算每一个非终结符派生的终结符字符串的最小长度是一种特殊情况。如果一个非终结符能派生出ε,最短长度是0。
4.1.3 从Unger方法中获得解析林语法
在进行Unger解析时构建一个解析林语法是非常容易的:所需要的只是在每一个尝试分区中向解析林添加一个规则。例如,4.1.1节中的第一个分区(图Fig4.2第6行)向语法解析林添加了规则:
分区的每一个段(segment)和分区本身都已经被一个特定的非终结符设定好了,其名称由其原始名称和段的起点加长度组成。这甚至适用于原始终结符,因为上面的分区声明了**+是位置3的+**(从1开始计算输入标记)。
图Fig4.2的第一部分添加了规则:
但犹豫输入并没有包含**+_ 2_ 1**,以及在位置2不包含**+**,所以这个规则可以被马上排除。或者可以说,它包含一个未定义的终结符,然后2.9.5节的语法清理算法将为我们删除它。同样,4.1.1节中中所述的尝试添加了规则:
这再次包含了一个未定义的终结符,i_1_2。(第一个可选因子,因子--->(Expr),是不适用的,因为它需要将Factor_1_2分成3分,而目前在4.1.1节我们还不允许使用ε规则。)
我们看到,Unger解析作为一个自顶向下解析方法,带来了大量的未定义非终结符(和ditto终结符);这代表了自顶向下过程中没有实现的假设。
解析过程产生了一个又294条规则的解析林语法,我们在这里就不展示了。整理之后就是图Fig4.5了,有11条规则。就可以很清晰的看到,它与4.1.1节最后的字符串**(i+i)×i**得出的解析相当。
4.2 CYK解析法
本节中讲述的解析方法来自于J. Cocke、D.H. Younger以及T. Kasami,他们发现了该方法的变体;它被称为Cocke-Younger-Kasami法,或者CYK法。最容易理解的初稿是Younger [10]。更早一版是Sakai [5]。
与Unger解析法一样,CYK法的输入包含一个CF语法和一个输入语句。算法的第一阶段构造了一个表,为我们将非终结符和派生出的句子的子句对应起来。这是识别阶段;它最终也告诉我们输入句子是否可以从语法中推导出来。第二阶段用这个对照表以及语法来生成所有可能的的句子。
我们先关注识别阶段,这是这个算法很特别的地方。
4.2.1 普通CF语法的CYK识别
要了解CYK算法如何解决识别和解析问题,让我们看一下图Fig4.6的语法。它描述了科学计算中数字的语法:
这个语法产生的一个句子是32.5e+1。我们将把这个语法和句子作为例子。
CYK算法首先重点放在输入句子的子句上,最短的句子优先,然后逐步增加。以下长度为1的子字符串可以直接从语法中读取:
这意味着Digit生成3,Digit生成2等等。但是请注意,这个图不完整。一方面,还有其他几个非终结符也可以派生3。这种复杂情况出现是因为语法出现了所谓的单元规则(unit rules),像A→B这样,其中A和B都是非终结符。这种规则也被称为单一规则(single rules)或者链规则(chain rules)。我们在派生中可以得到他们的链。因此下一步重复应用单元规则,例如找出哪些非终结符派生了3。这给出了以下结果:
现在我们已经看到了一些我们从语法中识别的组合:例如,跟着一个Digit的Integer后面接着又是一个Integer,跟着一个Integer的**.(dot)后面是一个Fraction**。我们得到(还是使用单元规则):
在这点上,我们看到Real的规则适用于几种方式,然后Number的规则也是,所以我们就有了:
所以我们发现Number确实生成了32.5e+1。
在上面的例子中,我们就会发现单元规则会使事情复杂化。另一个我们一直避免涉及到的复杂问题,是由ε规则组成的。例如,如果我们想要根据例子语法识别输入43.1,我们必须知道这里Scale派生出ε,因此我们得到以下图片:
大体来说这样更复杂。我们必须考虑到这样一个事实,即几个非终结符可以在输入句子的任意两个相邻终结符之间生成ε,也可以在句子的开始或者结尾得到。然而,正如我们看到的,这类规则造成的问题是可以解决的,尽管需要花一点代价。
与此同时,我们不应该被这些问题难到。在示例中,我们已经看到CYK算法的工作原理是将非终结符与生成的子字符串对应,优先短子字符串。虽然我们在例子中跳过了它们,但任何输入句子的最短子字符串当然都是ε子字符串。我们必须在任意位置都能识别它们,因此我们首先使用下面的闭式算法计算Rε,这是派生了ε的一组非终结符。
集合Rε被初始化为一个非终结符A的集合,其中A→ε是一个语法规则。对于示例语法,Rε被初始化为{Empty}集。接下来,我们检查每一个语法规则:如果右侧仅包含属于Rε的元素,我们就将左侧添加到Rε(它派生ε,因为右侧所有符号都是这样)。在例子中,Scale将会被添加。这个过程将重复进行,直到没有新的非终结符能添加到集合中。对于该例子,就有了:
Rε = {Empty, Scale}.
现在我们将注意力集中到输入句子的非空子字符串上。假设我们有一个输入句子t = t1t2 · · ·tn,并且我们想要计算非终结符的集合,从位置i开始长度为l的t派生了子字符串的非终结符。我们将使用*si,l*来标识这个子字符串,因此有了:
si,l = titi+1 · · ·ti+l−1.
或者是一个完全不同的符号:si,l = ti...i+l−1。图Fig4.7以图形方式展现了这种符号,使用含有4个符号的句子。我们使用*Ri,l来标识这个派生了子字符串si,l*的非终结符集合:
这种标识法可以扩展到处理长度为0的子字符串:si,0 = ε,Ri,0 = Rε,对于所有i取值。
因为较短的子字符串是优先处理的,所以我们可以假设我们处于算法中的一个阶段,在这个阶段,所有长度小于某个l取值的子字符串的信息都是可用的。使用这些信息,我们检查语法中每一个右侧,来确定是否派生si,l,如下所示:假设我们有一个右侧A1 · · ·Am。然后我们将si,l分成m(可能为空)段,这样A1派生第一段,A2派生第二段,等等。我们从A1开始。如果A1 · · ·Am能派生si,l,那么A1必须派生出它的第一段,比如长度k。也就是说,A1必须派生si,k(属于Ri,k),然后*A2 · · ·Am*必须派生剩下的部分:
这是对于属于Ri,k的A1的每一个k的尝试,包括k=0。当然,如果A1是终结符,那么A1必须等于ti,而且k是1。检查*A2 · · ·Am是否派生ti+k · · ·ti+l−1*用同样的方法进行。和Unger方法不同,我们不必尝试所有分区,因为我们已经知道哪个非终结符派生哪个子字符串。
然而,有两个问题。首先,m可以是1,*A1可以是非终结符,这样我们就是在处理一个单元规则了。这种情况下,A1必须派生si,l整个子字符串,并因此属于Ri,1,就是我们现在正在计算的集合,因此目前我们还不知道是不是这种情况。这个问题可以通过观察A1是否派生si,l*来解决,沿派生的某处必须是第一步而不使用单元规则。因此我们就有了:
A1 →B→· · ·→C si,l
其中C是派生中第一个使用非单元规则的非终结符。暂时不管ε规则(第二个问题),这意味着在计算集合Ri,l的过程中的某一个特定时刻,C将被添加到Ri,l中。现在,如果我们一次又一次重复计算Ri,l,在某个时刻B将被添加,并在接下来的重复中,*A1将被添加。所以我们要重复这个过程直到没有新的非终结符被添加为止。这和计算Rε*一样,是一个闭包算法的例子。
第二个问题是由ε规则引起的。如果At中除了一个以外都派生ε,那么我们就有一个基本上等同于单元规则问题的问题。它也需要重复计算R的输入直到没有变化为止,那就再次使用闭包算法。
最后,当我们计算了全部的Ri,l,识别问题就解决了:起始符号S派生t(=s1,n),仅只有当S属于*R1,n*时。
这是一个复杂的过程,这种复杂性的一部分源于ε规则和单元规则。他们的存在迫使我们去重复计算Ri,l;这是低效的,因为在第一次计算*Ri,l*之后,重复计算的价值很少。
另一个不太明显但同样重要的问题是,右侧可能由任意多的非终结符组成,并且尝试所有的可能会是一个非常大的工作。如下我们可以看到这点。对于一个有m个成员组成右侧的规则,必须找到m-1个段端,每个段都与前面所有段结合在一起。找到每一个段端花费O(n)步,因为必须扫描与输入长度成比例的l列表;因此找到所需的m-1个段端花费O(nm−1)步。b并且由于在R中有O(n2)个元素,因此完全填充它需要O(nm+1),因此在语法的右侧的最大长度上时间要求是指数级的。图Fig4.6的最长的右侧长度是3,因此时间要求是O(n4)。这比详尽的搜索要有效的多,后者需要一个输入句子长度呈指数级的时间,这任然是一个让人心惊的时间。
对规则施加一定的限制,可以很大程度解决这些问题。当然,这些限制不能太大的影响到语法的生成能力。
4.2.2 Chomsky基本形式语法的CYK识别
我们想对语法施加的两个限制现在已经很明显了:不要单元规则,也不要ε规则。我们还希望将右侧的最大长度限制为2;这将降低时间复杂度到O(n3)。这样又回到了有一个完全适合这两个限制的CF语法:Chomsky基本形式。就好像这种基本形式就是为这种算法设计的。Chomsky基本形式(CNF)的语法,当所有规则都是形式A → a或A → BC,其中a是终结符而A,B,C是非终结符。幸运的是我们稍后将看到,任何CF语法都将可以机械性的转换为CNF语法。
我们将首先讨论CYK算法如何在CNF中处理语法。在CNF语法中是没有ε规则的,因此Rε为空。集合Ri,1可以直接从规则中读取:它们由形式为A→a的规则所决定。规则A→BC永远不能推导出一个单独的终结符,因为没有ε规则。
接下来,我们像以前一样进行迭代,首先处理长度为2的子字符串,然后是长度为3的,等等。当一个右侧BC要推导出一个长度为l的子字符串是,B必须推导出第一部分(非空),而C负责后面的部分(也非空)。
因此B必须推导出si,k,也就是说,B必须是属于Ri,k的,同样的C必须推导出si+k,l−k;也就是说,C必须是属于Ri+k,l−k。要确定这样的k是否存着很容易:只要试一下所有的可能性;它们的范围从1到l-1。所有集合*Ri,k和Ri+k,l−k*这时候已经计算出来了。
这个过程比我们之前看到的复杂度要低的多,它适用于一般的CF语法,原因有二。最重要的一点是,我们不需要一遍又一遍的重复直到没有新的非终结符被添加到*Ri,l*中。在这里我们处理的子字符串是真正的子字符串:它不能与我们开始的字符串相等。第二个原因是,我们只需要找到一个将字符串分为两部分的位置,因为右侧仅包含两个非终结符。在模糊语法中,可能会有几种不同的拆分方法,不过我们并不需要去担心这个。模糊是一个解析问题,而不是识别问题。
该算法生成了一个完整的集合Ri,l。句子t由n个符号组成,因此从位置i开始的子字符串绝不可能有超过n+1-i个符号。这意味着不存在这样的子字符串si,l,其中i+l > n+1。因此集合*Ri,l*可以放在一个三角形形式的表中,如图Fig4.8所示。这个表称为识别表(recognition table),或者是正确格式的子字符串表(well-formed substring table)。
条目Ri,l是根据箭头V和W同时计算的,如下所示。我们考虑的第一个条目是箭头V开头的Ri,1。Ri,1的所有非终结符B都生成从位置i开始长度为1的子字符串。由于我们在试图获得从位置i开始的长度l的子字符串的解析,那我们对从位置i+1开始的长度为l-1的子字符串感兴趣。这应该在Ri+1,l−1中寻找,在箭头W的开头处。现在我们将从Ri,1中找出的所有B和从Ri+1,l−1中找出的C结成对,并且对于每一对在语法中都有一个规则A→BC的B和C,我们在Ri,l中插入一个A。同样的,Ri,2中的一个B可以和Ri+2,l−2中的一个C结合成一个A,等等;我们一直这样做直到箭头V结尾处的Ri,l−1和W结尾处的Ri+l−1,1。
条目Ri,l不能被计算,直到在顶部的三角的所有输入都已经清晰之后。这将在一定程度上限制可以被计算的条目的顺序。图Fig4.9(a)描述了一种计算识别表的方法;它遵循我们前面的描述,在长度为l-1的子字符串被全部识别完之前不会识别长度为l的子字符串。我们也可以按照图Fig4.9(b)的描述来计算识别表。按这种顺序,一旦计算所需的集合和输入符号都可用了,就可以立马计算*Ri,l*了。这个顺序特别适合于在线解析,当输入的符号数量事先并不知道时,并且每次读取新的符号都会计算其他信息。
现在我们来看一下此算法的成本。图Fig4.8显示有n(n+1)/2个条目需要填充。填写条目需要检查箭头V上的所有条目,其中最多有n个条目;一般来说会更少,并且实际上很多条目是空的并不需要检查。我们把将会真正被计算的条目的“发生次数”写作nocc;通常这个值会远小于n,并且对于许多语法来说这是个常量,但是对于最不理想的情况,这个值应该考虑就是n。一旦箭头v上的条目被选中,箭头W上相应的条目就将固定下来,因此查找的消耗不取决于n。因此,该算法具有*O(n2nocc)*的时间要求,并且在最不理想的情况下,与输入句子长度的立方成正比,与本小结开头所提到的一样。
算法的成本还取决于语法的特性。沿着箭头V和W的条目每个最多能包含|VN|个非终结符,其中|VN|是语法中非终结符的个数,从2.2节中语法的定义中设置的VN的大小。但是实际数量通常要低得多,因为通常只有非常有限的非终结符的子集才能在给定位置产生给定长度的输入段;我们用|VN|occ来代表数量。因此一个组合步骤的成本是O()。在语法中找到将B和C组合成A的规则,可以在一个恒定的时间内完成,通过哈希或者预计算,并且不会增加这个组合步骤的成本。这给出了O( n2 nocc)的总时间。
文献中关于识别表的第二个索引应该代表识别段的长度还是结束位置存在一些分歧。但很显然,两者最终传达的意义一致,但在不同的时候使用两个意义会带来不同的便利性。有些事例可以说明,在Earley解析(7.2节)以及作为交点解析(13章)中,使用结束位置的意义比较好,但是在CYK解析中,无论是概念还是绘图上,使用长度意义都会更好。
4.2.3 将CF语法转换为Chomsky普通形式
上一节中已经表明,将一个CF语法转换为CNF语法是值得的。本节我们将使用数字语法来讲讲这个转换过程。这个过程可以分为几个阶段:
-
首先,去除ε规则被;
-
第二,去除单元规则;
-
第三,如2.9.5节所述,对语法进行整理;
-
第四也就是最后,修改剩余的语法规则,并添加规则,直到它们都是我们所需的形式,即A → a或者A → BC。
所有的这些更改都不会改变原本语法所定义的语言的最终形式。现在你还体会不到这一点。关于形式语言理论的大多数书籍会更形式的讨论和介绍这一点,各位可以去自行了解;例如Hopcroft和Ullman [391]。
4.2.3.1 去除ε规则
假设我们有一个语法G,有一个ε规则A → ε,然后现在我们想要去掉这个规则。我们当然不能直接删掉规则,这样会改变非终结符A定于的语言,以及语法G所定义的语言也很可能被改变。因此对于语法右侧出现的非终结符A,必须采取一些措施。当语法规则B → αAβ中出现A的时候,我们用两个其他的规则来代替:B → αA'β,其中A'是一个全新的非终结符,然后我们稍后会为它增加新的规则(这些规则将是A的非空语法规则);以及B → αβ,这个来处理在规则B → αAβ中A生成ε的部分。需要注意的是,上述规则中的α和β也有可能包含A;这样的情况下,必须用同样的方式替换每一个相关规则,直到所有与A相关的都没有了。当我们做完之后,语法中不会在有A出现。
每一个ε规则都必须用这种方法处理。当然,在这个过程中可能会产生新的ε规则。这只是一个预计:这个过程使所有ε生成变成显示。新产生的ε规则必须用一模一样的方式处理。直到最后这个过程会结束,因为生成ε规则的非终结符是有限的,并且,最终在右侧不会在出现这样的非终结符。
去除ε规则的下一步是为新的非终结符添加语法规则。如果A是引入A'的非终结符,我们就为所有的非ε规则A' → α添加一个A → α规则。由于所有ε规则都已经明确了,我们就可以确定,如果一个规则并不直接产出ε,那么它也将不能间接产生。这里可能会出现的一个问题是,A可能没有一个非ε规则。在这种情况下,A将只能产生ε,所以我们可以去掉所有使用*A'*的规则。
这些依然留给我们一个含有ε规则的语法。但是,任何含有ε规则的非终结符都不可以从起始字符到达,但有一个重要的例外:起始字符本身除外。特别是,现在我们就有一个规则S → ε,当且只当ε是语法G所定义的语言的一个成员时。此时所有其他含有ε规则的非终结符可以安全的去除,但对语法的实际清理将会留到稍后。
图Fig4.10的语法是一个让人讨厌的语法,用来检测ε规则去除方案。这个方案将语法转换为图Fig4.11的语法。
这个语法依旧含有ε规则,但是可以通过去掉非生成性的以及/或者不可到达的非终结符来消除掉。将此语法清理到只有一个规则留下:S → a。去掉我们的数字语法中的ε规则,最后会得到图Fig4.12中的语法。注意,生成ε,Empty和Scale,的两个规则仍在,只是不在使用了。
4.2.3.2 去除单元规则
接下来给我们带来麻烦因此要去掉的是单元规则,也就是A → B这样形式的规则。必须认识到,如果在生成中使用A → B这样的规则,那么必须在某个时间后紧接着使用规则B → α。因此,如果有一个规则A → B,并且B的规则是:
B → α1 | α2 | ··· | αn,
我们可以将规则A → B替换为
A → α1 | α2 | ··· | αn.
在这个过程中,我们当然可以引入新的单元规则。尤其是,在重复这个过程时,我们可以在某个时刻再次得到规则A → B。在这样的情况下, 我们就有一个无限模糊的语法了,因为这意味着B生成B。这似乎提出了一个问题,但我们可以将这个单元规则排除出去;结果是我们缩短了生成过程像下面这样:
A → B → ··· → B → ···
此外,A → A形式的规则也被排除在外了。事实上,去除ε规则和单元规则的一个让人开心的地方是,生成的语法再不是模糊的。
在我们的无ε数字语法中去除单元规则,就得到了图Fig4.13的语法。
4.2.3.3 清理语法
尽管我们的数字语法不包含非生成性非终结符,但它确实包含不可达到的非终结符,在消除ε规则时产生的:Real,Scale和Empty。CYK算法无论使用与否都可以正常工作,因此清理语法是可选项并非必须的,如2.9.5所述的。为了概念的理解以及好描述性,我们在这里选择对语法进行清理。但往后进行(4.2.6节)就会发现并不都是有利的。清理后的语法如图Fig4.14。
4.2.3.4 最后,变成Chomsky正则形式
在所有这些语法转换后,我们就得到了一个没有ε规则和单元规则的语法,并且所有非终结符都可到达,并且不存在非生成性非终结符。因此我们就只剩下两种类型的规则了:A → a形式的规则,这种规则正符合我们的要求;以及A → X1X2 ···Xm形式的规则,其中m≥2。对于这样规则中出现的每一个终结符b,我们为之创建了仅包含一个规则Tb → b的非终结符Tb,并且将规则A → X1X2 ···Xm中出现的每一个b都替换为Tb。现在CNF中尚没有的规则是*A → X1X2 ···Xm其中m≥ 3的形式,以及所有Xi*非终结符。
A → X1X2 ···Xm
这些规则现在可以拆分并被替换为以下两个规则:
A → X1X3 ···Xm
A1 → X1X2
其中*A1*是一个新的非终结符。现在我们用一个短一些的规则以及一个CNF算法的规则替换了原有的规则。可以一直重复此拆分直到所有的规则都在CNF中为止。图Fig4.15向我们展示了CNF中的数字语法。
4.2.4 重访问示例
现在,让我们来看看CYK语法如何与我们刚刚转换为CNF的示例语法一起工作。同样,我们的输入句子是32.5e+1。识别表如图Fig4.16所示。底部的一行可以直接从语法中读取到。例如,唯一有生成规则的右侧为3的非终结符是Number,Integer和Digit。请注意,对句子中的每一个标记a必须至少有一个具有A → a规则的非终结符A,否则该句子就不能从语法中生成。
其他行按前所述计算。实际上有两种方法可以计算某一个Ri,l。第一种方法是检查语法中的每一个右侧。例如,要知道右侧N1 Scale’,是否生成子字符串2.5e(= s2,4)。到目前为止识别表显示:
-
N1不是*R2,1或R2,2*的一个成员
-
N1是*R2,3的一个成员,但**Scale’**不是R5,1*的一个成员
因此答案是否定的。使用此方法,我们必须用这种方法来检查每一个右侧,将左侧添加到R2,4,当我们发现其右侧可以生成s2,4。
第二种方法是计算到目前为止,识别表中可能出现的右侧。例如,R2,4是具有右侧AB的非终结符的集合,同时右侧也不是以下情况:
-
A是*R2,1的一个成员,B是R3,3*的一个成员,或者
-
A是*R2,2的一个成员,B是R4,2*的一个成员,或者
-
A是*R2,3的一个成员,B是R5,1*的一个成员。
这位AB提供了可能的组合:N1 T2和Number T2。现在我们检查语法中的所有规则,以确定是否有测试是这个集合的成员。如果是这样的,那就把左侧添加到R2,4。
4.2.5 Chomsky普通形式的CYK解析
我们现在有一个算法,来确定一个句子是否属于一个语言,并且它比深度搜索要快得多。但是我们不只是想知道一个句子是否属于一个语法,在可能的情况下我们更想知道语法是如何生成这个句子的。并且如果一个句子可以以多种方式生成,那我们还想要知道所有生成的路径。由于识别表包含了我们可能会有的输入句子子句的所有生成信息,因此也就包含了所有我们需要的信息。正因此,这个表所包含的信息太过庞大,以至于我们想要的东西都被隐藏在内,需要我们自己挖掘。这个表包含了非终结符生成子句的信息,这些生成过程又不能被用在起始符号S生成子句的生成过程中。例如在上面的例子中,*R2,3*包含了N1,但N1生成2.5的过程不能被用在Number生成32.5e+1的过程中。
解决这个问题的关键稍加注意就能发现,生成过程必须从起始符号S开始。生成输入句子t(长度n)的第一步,可以从语法和识别表中读取。如果n=1,则必须存在规则S → t;如果n ≥ 2,则我们必须要检查所有S → AB的规则,其中A生成t的第一个k符号,B生成第二个,也就是说,A是R1,k的值,B是Rk+1,n-k的值(k值一定的情况)。必须至少存在这样一个规则,否则S就无法生成t。T
对于每一个AB组合我们都会遇到同样的问题:A如何生成s1,k,以及B如何生成sk+1,n−k?实际上这些问题都可以以同样的方式解决。从哪个非终结符开始并不重要。始终保持对于最左侧的生成结果中采用最左侧的值,以及最右侧的生成结果中采用最右侧的值。
请注意,在这里我们可以使用Unger类解析器。但是不需要在生成所有的部分,因为我们已经知道的需要的部分是哪些。
我们来试一下从图4.16的识别表中,为示例中的句子和语法找到一个最左侧的生成结果。从起始符号Number开始。句子包含七个符号,因为有多个,因此我们必须使用带有一个右侧的AB形式的规则。这里Integer Digit规则不适用,因为Digit的唯一实例可能生成的句子是*R7,1中的,但Integer却并不属于R1,6的子集。Integer Fraction规则也不适用,因为没有生成句子最后一部分的Fraction。因此就只剩一个规则Number ---> N1 Scale’,并且确实适用,因为N1是R1,4的值,并且**Scale’**是R5,3*的值,因此N1生成32.5,Scale’生成e+1。
接下来,我们要找出N1如何生成32.5。只有一个可用的规则:N1 -> Integer Fraction,确定可用是因为Integer是R1,2的值,而Fraction是R3,2的值,因此Integer可以生成32,Fraction生成**.5**。最后,我们就有了以下的生成结果:
但很可惜这并不是我们真正想要的,因为这是适用图4.15中的语法规则生成的,而不是图4.16中的语法规则(我们原本的语法)。
4.2.6 消除CNF转换的影响
从图4.6的语法和图4.16的识别表中,我们不难发现识别表包含了原始语法中大部分非终结符的我们所需的信息。但是有一少部分非终结符没有在识别表中:Scale,Real和Empty。Scale和Empty可以不关注,因为没有ε规则后,它们就变成了死胡同。Empty也可以完全不管,因为它只能生成空的句子。而Scale由Scale'替换,因为Scale和Scale'生成的几乎一模一样,除了空字符串。我们可以用它来向识别表添加更多信息:每当出现Scale',我们就添加一个Scale。
非终结符Real被删除了,因为没有单元规则之后就变得无法到达了。不过现在CYK算法并没有要求语法中的所有非终结符都是可到达的。所以我们也可以把Real留下,然后将它的规则转换成CNF。接下来当合适的时候,CYK算法将会把Real添加到识别表中。Real被添加到图Fig4.15的语法的规则将会是如下:
Real ---> N1 Scale’ | Integer Fraction
生成的识别表如图4.17所示。 在这个图中,我们也在底部添加了额外的一行。这一行是生成了空字符串的非终结符。这些非终结符可能出现在句子的任意两个符号之间,也可能出现在句子头或句子尾。集合*Ri,0包含出现在符号ti前面的非终结符,而集合Rn+1,0*包含出现在句子后面的非终结符。
现在我们就有了一个包含了所有我们解析原始语法中句子的所需信息的识别表。同样,从起始符S开始生成。如果A1A2 · · ·Am是S的右侧,我们来看看这个规则是不是也适用,也就是说A1A2 · · ·Am是否能生成s1,n。确定之后就从A1。有两种情况:
-
A1为终结符。在这种情况下,它必须是s1,n的第一个符号,否则此规则将不适用。接下来,我们要核对A2 · · ·Am是否能生成s2,n-1,用我们核对*A1A2 · · ·Am是否能生成s1,n*相同的方式。
-
A1为非终结符。在这种情况下,它必须是R1,k(部分k值)的一个值,否则这个规则将不适用。接下来我们要核对A2 · · ·Am是否能生成sk+1,n-k,以我们核对A1A2 · · ·Am是否能生成s1,n相同的方式。如果我们想要所有的解析,我们必须对每一个不同k取值的R1,k包含的A1都进行这个操作。请注意对于生成空字符串的所有非终结符都不存在任何问题,因为对于所有i值它们都属于Ri,0。
到此我们就已经确定了规则是否适用了,适用的话,那部分规则生成那部分子句也已经确定了。接下来就要确定是如何生成的子句了。这个任务和我们刚开始时候的任务很像,解决方法也是类似的。这个过程只会持续一段时间,如果语法中不包含循环。这就是一个Unger解析器,在开始之前就已经区分出了能完成解析的部分。
让我们回到图Fig4.6的语法和图Fig4.17的识别表,看看如何在我们的示例句子中起作用。现在我们已经知道Number可以生成32.5e+1,但并不知道是如何生成的。首先我们看看:Number--->Integer规则有用吗?Integer是*R1,1和R1,2的子集,但除了Integer之外没有其他能生成句子其他部分的规则,所以这个不对。那Number--->Real规则对不对呢?对了,就是它!因为Real是R1,7*的值,而句子的长度也是7.因此我们的生成过程从以下开始:
Number ---> Real ---> · · ·
现在对于非终结符Real我们也遇到了基本一样的问题:那Real ---> Integer Fraction Scale是不是有用呢?Integer是*R1,1的值,但在任何一个R2,k集合中都找不到Fraction。不过,Integer是R1,2的值,而且Fraction是R3,2的值。但是,Scale是R5,0的值,而这没用,因为它不能为其他生成带来任何规则。不过好在,Scale还是R5,3*的值,而这与句子的结尾正好相合。所以我们可以确定这个规则是实实在在适用的,那我们继续我们的生成:
Number ---> Real ---> Integer Fraction Scale ---> · · ·
现在句子分成了三部分:
找到唯一的生成就留给读者了,就在下面:
4.2.7 CYK的简短回顾
到现在我们已经在这条路上走了很远。首先我们通过原始语法构建了识别表。接下来,我们找到了单元规则和ε规则,虽然比较复杂但用它们也能达到我们的目的。接着我们改进了一下,将语法转换为CNF。而CNF并不包含单元规则和ε规则。由此我们发现构建识别表的算法会变得更简单。发现右侧的最大长度限制为2,也让我们的效率提高很多,同时也让事情简单了一点。然而Sheil [20]已经证明,效率只取决于语法右侧出现的非终结符的最大个数,而不是每一个sé的右侧的长度。这个很容易理解,只要意识到效率取决于(相对于其他事)子句中“很难”找到的小切片(当检查一个右侧是否能生成这个子句时)。而这些“很难”找的小切片的数量取决于右侧的非终结符的数量。因此为了提升效率,Chomsky Normal Form就比较严格一些了。
转换为CNF的一个缺点是生成的识别表丢失了一些信息,以至于我们需要从原始语法中构建一些派生。在转换过程中,有些非终结符被丢弃了,因为它们变成了非生成性的。好在这些失去的信息倒是很容易恢复。最终,这个过程将带给我们一个与我们首次尝试使用原始语法时得到的,几乎一模一样的识别表。它只多了一些在我们将语法转换为CNF时添加在非终结符上的额外的信息。更重要的是,它是以一个更简单高效的方式得到的。
作为CYK算法的一个更详细版本,详见15.4.2节Tree Adjoining Grammars。
4.2.8 从CYK解析获取解析林语法
与Unger解析器一样,在CYK解析过程中获取一个解析林语法非常简单:每当识别表Ri,1中新增一个非终结符A的时候(因为有A→BC规则,并且B包含于Ri,k,C包含于Ri+k,l−k),规则
A_i_l ---> B_i_k C_m_n
将被添加至解析林语法,当m = i+k且n = i+l−k时。
有两点需要注意。第一点,这个算法永远不会引入未定义的非终结符:只有在规则B_i_k和C_m_n都存在之后,才会引入规则A_i_l--->B_i_k C_m_n。另一方面,在添加此类规则时不考虑其可及性:从起始符号起,非终结符A_i_l可能可及也可能不可及;其实讨论是否可及还太早了。可以看到,CYK解析作为一个自底向上算法,它产生了太多不可及的非终结符;这表示自底向上的这些过程没有意义。
第二点,解析林语法包含的信息比识别表要多,因为它不仅包含给定句子的非终结符,还包含了每个非终结符被记录的原因。解析林语法包含了CYK解析过程的识别阶段(4.2.2节)和解析阶段(4.2.5节)。
从图Fig4.17获得的解析林语法以及从图Fig4.6获得的语法非常简单;结论见图Fig4.18。可以看到,包含非常多的不可及的非终结符,例如Number_2_6,Scale_5_0等。删除掉这些就得到了图Fig4.19的样子;很容易看出它相当于4.2.6节结尾处得到的结果。
4.3 表解析
前面我们已经将CYK识别表绘制成了一个二维三角矩阵,但输入内容(非终结符集合)的复杂性已经表明,这并不是最原始的形式。通过发现CYK识别表是多个表(语法中每个非终结符都是一个表)的叠加可以更简洁有深度;这些输入内容在这些表里面只是位(bits),含义是“Present”或者“NotPresent”。由于图Fig4.6中的数字语法有8个非终结符,因此图Fig4.17的识别表就是8个矩阵的叠加。见图Fig4.20。Number表左上角的点表示一个长度为7的Number已经识别到了位置1;在差不多最右下角的一个点表示一个长度为1的Number已经被识别到了位置7;以此类推。
想象一下,这8个表就以Number · · · Empty的顺序,分隔成一个个单元,然后以队列加入一个单独的块中(block)。现在将这个块的顺序颠倒过来。就有了一个新的矩阵T,原本的底部成为了现在的顶部,如图Fig4.21,新表的x轴的位置不变,但y轴变成了非终结符的名称和输入句子的长度。例如,左上角*T1,Number*中的数组{1,2,4,7}表示Number的长度为这些的生成结果放在位置1。以这种方式为主的解析算法,称为表解析算法(tabular parsing algorithms)。很明显,在此转换中,不会新增或删除任何信息,但表解析也有自身的优势和不足。
表T在将1放入所有输入Ti,A(输入中有一个令牌T位于位置i,且语法为A→t)时进行初始化。有两个方法可以填充表的剩余部分,自顶向下和自底向上。
4.3.1 自顶向下表解析
为了识别,我们只看表T中一个输入的一个元素:TS,1是否包含n(其中S是起始符号,n是输入句子的长度)?
为了找出答案,我们将查询以某种形式放入堆栈,并将所有语法规则对S使用,我们绘制了所有TS,1的包含n的可能的列表,就像我们在Unger解析器中所作的一样。对于像S--->AB这样的规则,要考虑:
-
TA,1是否包含0,且TB,1是否包含n?
-
TA,1是否包含1,且TB,2是否包含n-1?
-
TA,1是否包含2,且TB,3是否包含n-2?
-
· · ·
-
TA,1是否包含n,且TB,n+1是否包含0?
如果满足以上的任何一条,那么TS,1则必须包含n。
每一个新的查询都可以以相同方式扩展和检查。最终这些查询将变成“终结符查询”,可以不产生新的查询来解决现有的查询问题。例如“Ta,K是否包含1?”这样的问题,可以通过检查输入中位置K是否包含a来解决,又或者“TP,K是否包含0?”,这个等同于“P是否生成ε?”。一旦我们得到了一个问题的答案,并将之存储在表中一个合适的位置,我们这样做不止可以用于顶部问题,也可以用于中间的所有问题。这一点非常重要,因为我们可以不做进一步计算就得到答案,只要同一问题再次出现,而实际上这个情况出现的频率很高。请注意,有可能需要存储一些消极信息(实际上应该是有关缺省的积极信息),在表T的输入中:像TA,K这样的输入可能包含“是否包含7”这样的信息。还有一点,这个过程并不总是计算表中的所有输入内容;监狱语法本身,这些漏掉的输入可能永远不会成为识别的一部分。对于部分应用程序,这是一个优势。
将计算结果存储在表中以便通过表查找来代替重新计算的技术称为记忆化(memoization)。这是非常有效且广泛适用的技术,而且可以将算法的时间复杂度从指数级降为线性,就像前面的例子一样。记忆化于1968年由Michie [410]提出,然后由Sheil [20]引入解析(但他至今尚未使用“memoization”一词);见Norvig [343]。
此外,我们要使用Unger解析器中相同的方式,再来处理一次左递归非终结符。如果非终结符A是左递归的,那么问题“TA,1是否包含包含n”将再次产生问题“TA,1是否包含n”,然后就会进入一个死循环。但是只要放弃同一问题的递归问题,这个状况就解决了,因为第二个问题不会产生第一个问题已经解决过的问题。生成的问题是否是递归的可以通过在堆栈中查询来确定。简而言之,自顶向下表解析与拥有了记忆化之后的Unger解析非常相似。
该算法的完整实现见17.3节。
4.3.2 自底向上表解析
自底向上表解析可以正确填充所有输入内容,但要比自顶向下算法更仔细一些。就像CYK一样,对于CNF类型的语法它的效率最高,因此我们将尽可能保证我们的语法属于这类。而且与CYK一样,在计算时关于输入内容的顺序我们要小心处理。此外,我们将填充图Fig4.21的三维“楔形(背面)”(其输入为布尔(位(Bits))),而不是正面的二维部分(其输入内容是整数列表)。楔形的输入内容描述了一个终结符的生成是否是来自于位置i长度K的A(写作Ti,A,K)。
自底向上表解析对整个识别表的填充过程开始于右端的起始列。为了填充输入Ti,A,K,我们从语法中找到了一个A → BC形式的规则,然后我们就可以访问到Ti,B,1。如果这个输入已经设置好了吗,那么在位置i就有B生成的一个长度位1的段。如果都设置好了,那我们接下来就访问Ti+1,C,k−1,同理在位置i+1有C生成的长度位k-1的一个段。由此我们得出结论,在位置i的长度为K的输入段会有一个A的终结符生成,我们将此输入称为Ti,A,K。如果不对,依次检查Ti,B,2和Ti+2,C,K-2等等,直到Ti,B,K-1和Ti+k-1,C,1,就像CYK算法一样。
我们可以在Ti+K-1,C,1停止,因为CNF语法中没有ε规则,因此Ti+k,C,0并不存在。这意味着Ti,A,K在计算过程中会涉及到Ti,B,j(仅只当j < K时)。因此当我们在Ti,Q,K(j < K,P和Q为任意值)之前计算Ti,P,j,那么就需要确保计算*Ti,A,K*输入内容和值都已经准备好了。就会对算法施加特定的计算顺序:
计算Ti,A,K的成本是O(n|Pav|),其中n是输入的长度而*|Pav|是一个非终结符产生规则数的平均值。正如我们看到的,这个计算重复了O(n|G|n)次,其中|G|与语法的大小成正比。因此这个解析算法的时间成本为O(n3|G||Pav|)或O(n3)×O(|G||Pav|)*。
自底向上表解析应用于12章和15.7节的许多算法中。
Nederhof 和 Satta [40]写了一个关于表解析的教程,并将其广泛应用于非确定性解析算法中。
4.4 结语
非定向方式会将输入字符串全部保留。自顶向下方式(Unger)会尝试将输入字符串分割成不同的段,并从起始符号开始就进行派生;如果可行的话,它就会找到一个解析结果。而自底向上方式是尝试着将输入字符串分隔为可识别的段(可以被组合进起始符号中那样的);同样的如果可行的话,它也将得到一个解析结果。虽然它们不管看起来还是感觉上都相差甚远,但这两种方式都可以通过表解析的方式来实现;只是计算的顺序会有所差别。
Rus [28]提出了一个不同寻常的确定的非定向性自底向上解析算法。
问题
问题4.1:如果可以通过在S中的任意位置插入语言中的0个或多个令牌来生成U,那么字符串U称为字符串S的一个超级序列(supersequence)。(见12.4节)
-
a) 为语法G设计一个Unger解析器,且G生成的语言中的一个字符串可以被识别为G的一个超级序列。
-
b) 设计一个CYK解析器,要求同上。
问题4.2:如果可以通过删除S中任意位置的零个或多个token来生成U,那么字符串U称为字符串S的一个子序列(subsequence)。(见12.4节)
-
a) 为语法G设计一个Unger解析器,该解析器可以识别G生成的语言中的字符串的子序列。
-
b) 设计一个CYK解析器,要求同上。
问题4.3:从语法中去掉ε规则将会很大程度的改变语法,在解析过程中必须要花大力气来恢复这种破坏。通过将删除的ε规则合并到修改后的语法中可以省掉一些麻烦,如下所示。给定一个语法S--->aBc, B--->b|ε,我们先将其转换为“AND-OR”形式,将原本的右侧记为非终结符。(只有AND规则和OR规则两种的语法即属于AND-OR形式,AND规则即语法符号是并列关系,OR规则即非终结符是择其一关系,并且对于每一个非终结符而言,同时只有一个规则存在。)上面的语法就成为了S--->aBc, B--->Bb |Bε , Bb --->b, Bε --->ε。接下来替换所有为空的OR规则(举例中B的规则):S--->|, --->aBbc, --->aBεc, Bb--->b, Bε--->ε。接下里替换A → ε形式的规则:S--->|, --->aBbc, --->ac, Bb--->b。在我们解析**--->ac**时,S的角标就是真正的右侧。用解析器将这个想法细化为一个完整的算法。
问题4.4:从下面的语法中删除单元规则:
问题4.5:拓展题:CYK,尤其是图标解析形式,长久以来一直是自然语言解析的最爱方式,即便我们已经知道时间成本是O(|G||Pav|n3)。当遇到一些非常大的自然语言语法时(拥有数百万规则),更甚至是它所生成的语言,那么仅仅O(|G|)就已经很成问题了,勿论O(|Pav|)。请设计一个CYK/图表形式的优化版本(比*O(|G|)更优)。不要指望|ADJ|比|G|2*更小,ADJ是任意右侧会出现的非终结符对的集合。(见问题3.11)
问题4.6:在编辑器中查看源代码时,成对出现的命令(tokens)可以让语法结构一目了然。这就去掉了CYK坚持的很多自底向上的假设。这个方法可以自动去掉大部分的表输入,只留下很少的一部分。这使得它几乎成为一个时间要求为线性的解析器,而这对于解析旧代码可能很重要,因为旧代码通常语法非常难搞。
先检查A生成的非终结符之前、开头、中间、结尾、之后的命令,然后在尝试减少A → α形式的假设。
问题4.7:将4.3节中计算*Ti,A*的推理规则格式化。
问题4.8:使用识别段的结束位置作为第二个索引绘制图Fig4.8和Fig4.16。
问题4.9:Rus算法适用的确定性的语法类型有哪些。
问题4.10:形式语言:设计一个算法,将给定语法转换为一个非终结符数量尽可能少的语法。这一点很重要,因为很多解析算法的时间成本取决于语法的大小。
5 正则文法与有限状态自动机
正则文法,属于3型文法,是最简单的语法形式,仍具有生成力。他们可以描述串联 (连接两个字符串在一起),重复,选择,但却无法表达嵌套。正则文法可能是形式语言学中最好理解的部分,几乎所有有关的问题都能得到解答。
5.1 正则语法的应用
正则文法的简洁性使得它有许多的应用,接下来我们将简短的介绍其中最重要的几类应用。
5.1.1 CF解析中的正则语言
在一些CF文法的解析器中,一个子分析器会在处理正则文法的时候被识别。这样一个子分析器是隐式地明确地基于下列这种令人惊讶的现象。考虑到最左推导和最右推导两种句子形式,这种句子形式由两部分组成,一个是关闭(结束)的部分,包含终结符,和一个开放(未结束)的部分,包含非终结符。在最左推导中,开放的部分,开始于最左边的非终结符,然后向右扩展,在最右推导中,开放的部分,开始于最右的非终结符,然后向左扩展。
显而易见的是,句中的开放部分在CF分析方法中其中非常重要的作用,能被正则文法描述,文法遵循CF文法。
为了解释得更加清楚,我们首先来解决一个概念上的问题,依惯例,使用大写字母来表示非终结符,小写字母表示终结符,然后接下来,我们将书写能产生句子形式的文法,自此这些句子形式中能包含非终结符,我们的文法中也能产生非终结符。为了在这个过程中从“活”的非终结符中区分出“死”的非终结符,我们在字符上用一根短线来表示,就像这样,.
伴随着上述提到的概念,我们来创建一个正则文法G,以一个开始符号R作为在2.4.3节中用到过的文法C的最左推导的开放部分。我们重复:
G中开始符号R的第一种可能是产生C的开始符号,所以我们得到R--->,这里只是一个标记(token),下一步是这个标记,成为句子形式中最左端的非终结符,它被转换成一个“活”的非终结符,从中我们将产生更多的句子形式,他们来自 R--->S。这里S是一个G中的非终结符,描述源于C中的S的句子形式中的开放部分。G中S的第一种可能是产生c中S的右边就像标记(tokens): S--->&,但是也可能是,成为句子最左侧的非终结符,L已经被激活: S--->L&,它甚至可能结束推导进程,因此,所有标记已经成为句子形式的关闭部分的一部分。开放部分&被留下,所以句子形式变为S--->&,接下来我们可以把&从开放部分移出去到关闭部分:S--->,又一次,这个变得能够产生句子,所以被激活:S--->N,就像上面的L,N最终完全消失: S--->ε。我们看到了原本的S--->&是怎样逐渐变为S--->ε,C中S的第二种选择是S--->N,推导过程的规则是S--->N¯, S--->N, 然后 S--->ε,这个推导我们上面已经做过了。
上述过程介绍了G中的非终结符L和N。它们的规则可以用与S相同的方法导出;结果是图5.2中的左正则语法。我们已经看到这个过程能创建同一个规则的副本。我们现在知道它也能产生循环,例如规则 L--->L,在图中被标为×,因为这样的规则没有任何贡献,所以被拒绝了。
相似地,在最右推导中一个右正则语法能构建句子形式的开放部分。这种文法有益于理解自顶向下和自底向上分析(第六章和第七章的内容),是一些解析器功能的基础(9.13.2和10.2.3节)。
5.1.2 拥有无限存储的系统
CF语法(或者更优的)是允许嵌套的。因为原则上来说,嵌套可以是任意多层的,因此产生一个正确的CF句子(或更优)可能需要各种大小的内存来临时存储未处理的嵌套信息。但机械系统中并不会有任意大小的内存空间,因此无法处理CF这样的问题,它只能处理常规性问题。对于像售货机、红绿灯和DVD刻录机等简单机械系统来说,这一点非常明显:它们的运行规律都严格遵照简单的语法逻辑。原则上来说,对于更复杂的机械系统,比如一个国家的火车运行系统或者计算机系统,也是如此。但是这一节中,这一点将不在如此,因为嵌套信息将会明确展示一点内存就可以处理大量的嵌套内容。因此这些系统虽然看起来是严格遵照正则逻辑在运行,但很容易就可以以CF或更强的手段来表示,即便这让人错误的认为这些系统似乎拥有了无限内存。
相反,许多拥有大内存的系统的通用运行也可以用一个正则语法来描述,并且很多CF语法大部分也已经是正则逻辑了。这是由于正则语法也已经充分照顾到了并列、重复和可选性等;嵌套只需要上下文无关。如果我们将生成正则(子)语言的一个规则(并且可以由一个正则规则代替)称为“准正则”,我们可以遵照以下规定。如果该规则的所有选项只包含终结符,那这个规则就是准正则(可选的)。如果一个规则的所有选项只包含终结符和准正则的非终结符以及非递归规则,那这个规则就是准正则(并列)。如果一个规则是递归的,但递归只在可选项的结尾出现且只包含准正则规则,那这个规则依旧是准正则(重复)。这基本已经涵盖了CF语法的大部分了。有关语法中标识所有准正则规则的算法,见Krzemie´n 和 Łukasiewicz [142]。
自然语言就是一个例子。虽然CF或更强的语法似乎有必要描述正确句子集合(并且它们很可能这样做,以抓住某些细微之处),但一个粗略的描述也可以通过正则语言获得。看一下图Fig5.3中Subject-Verb-Object (SVO)语言描述主子句的样式化语法。这个语法就是准正则的:Verb、Adjective、Noun本身是正则的,Subject和Object是正则形式(正则非终结符以及可选性)的重复的并列,因此也是准正则,因此MainClause也是准正则。 将这种语法转换为标准正则形式会有些麻烦,但也不是不能做到,如图Fig5.4所示,其中verbs、adjectives、nouns的列表缩写为verb、adjective、noun以节省空间。
甚至(有限)上下文相关语法也可以合并:对于要求verb和subject在数量上一致的语言,我们将第一条规则复制如下:
并相应的复制其余语法。最终的结果依旧是正则的。嵌套从属子句看起来可能是个问题,但在实际使用中,嵌套的深度往往没有那么大。在英语中,一个包含多重子句的句子,会让读者不知所云,即便是复杂的德语和荷兰语中多层的句子也让人生厌。我们按照所需的次数来复制语法,然后去掉继续嵌套的可能。那么即便是深层次的嵌套也就没那么不正则了。由此产生的语法很庞大但却是正则形式的,就能够使用正则语法适用的简单有效的方法了。所需的重复和修改是机械性的,可以由程序来完成。Dewar, Bratley 和 Thorne [376]描述了这种方法的早期例子,Blank [382]做了最新的论述。
5.1.3 模式搜索
许多线性模式,尤其是文本模式,其结构很容易通过(准)正则语法来描述。例如以各种货币符号区分货币,如图Fig5.5给出的语法结构,通过空格符来区分货币符号和金额。例如$ 19.95和¥ 1600,中间的空格。但这种分隔符号并不会单独出现,只会出现在一大段文本中,而这一大段文本并不一定符号图Fig5.5的语法。为了区分这种符号,就设计了一个识别器(非解析器)来接受任意文本的语法,并在其中找到语法的符号。解析(或者说另一种形式的分析)将推迟到后面的阶段进行。5.10节讲述了构造此类识别器的技术。
5.1.4 SGML和XML验证
有限状态自动机在解析SCML和XML文本中占据了很重要的位置。有关详情,分别参见Brüggemann-Klein 和 Wood [150] 以及 Sperberg-McQueen [359]。
5.2 正则语法的生成物
当从正则语法生成时,生产者需要记住一点:下一个非终结符是哪个。我们将用图Fig5.6的简单正则语法来说明这一点以及证明更进一步的概念。此语法生成一个由a组成的句子,后面跟着b和c交替序列,最后由一个终结符a结束。到目前为止,我们的讨论都限制在标准符号的正则语法范围;接下来我们将扩宽我们的视野以寻找更便捷的形式。
生产者标记的非终结符称为它本身的状态,并且该生产者被认为就是处于该状态。当生产者处于给定状态(例如A)时,它会选择属于该状态的一个规则(例如A--->bC),生成b之后状态变为C。这种变化称为状态转移,对于规则P→tQ写作P Q,一个右侧没有非终结符的规则(例如C--->a),对应的状态转换到一个可接受状态;对于规则P →t将写作P ,其中就是可接受状态。
在*转换图(transition diagram)*中,通常将状态和生产者可能的转换过程组合在一起。图Fig5.7展示了图Fig5.6的正则语法的转换图;例如我们看到A C的状态转换在图中表示为由A指向C的一条标记b的弧线。S是初始状态,而可接受状态标记了一个1。弧线上的符号是在相应转换时产生的。当生产者处于可接受状态时,就可以停止了。
如3.3节我们所见的非确定性自动机,生产者也是一个自动机,或者更确切的说,是非确定性有限状态机、NFA或*有限状态机(finite-state automaton)*FSA。称之为“有限”,是由于它只能处于有限的状态中(本例是5种状态;3位(bits)存储就够了),“非确定性”是由于在状态S种由不止一种方式来生成a。
正则语法可能存在非确定性、非生产性或者非终结符等就像上下文无关语法一样的状况,我们对其所做的分析甚至可能更容易可视化。如果图Fig5.6的语法与下面的规则一起扩展:
我们将获得下面的转换图:
从中我们能看出D不会产生进一步的转换,也就是说D是无定义的;虽然E是有定义的,但也没有产生其他的;而F没有指向其的箭头。
与预处理CF语法时相同的算法(2.9.5节)也可以用来预处理正则语法。与CF语法不同的是,正则语法和有限状态机可能更小:对于给定的FS状态机A,可以构造出具有最少状态数但同样可以识别与A相同语言的FS状态机。在5.7节将会给出一个可以做到这样的算法。
另一种标记可接受状态的方式是围绕它绘制一个额外的圆;由于我们也需要显示标记不可接受状态,在此我们不采用这个方式。
5.3 使用正则语法进行解析
上述用于生成句子的自动机原则上也可以用于解析。假如我们有一个句子,abcba,然后我们想要检查并解析它,那么我们就可以将上面的转换图视为一个迷宫,然后句子(中的令牌)视为一个向导。如果我们设法找到一条穿过迷宫的路径,那就将我们检查过的句子中的符号与迷宫墙上的标记匹配起来,并在末尾以♦结束。如图Fig5.8,其中路线显示为虚线。我们访问过的空间是解析树的主干,如图5.9所示。
但是找到正确的路径,说起来容易做起来难。例如,我们怎么知道在S处应该左转而不是右转?当然我们可以采取惯常的迷宫解决法(时间消耗为指数级),但其实有一个更简单高效的方法:我们兵分两路,各走一边。在句子abcba的第一个a之后,我们就有了两个空间集合 {A,B} 。接下来是b;B之后没有b了,但是A之后还有两个选择B和C。因此我们有了集合 {B,C} 。我们的路径现在更难描述了但是依旧还是可以线性化,如图5.10所示。
我们可以通过从末尾开始,向后指针向前指就有了:♦ <--- C <--- A <--- B <--- A <--- S。如果语法不明确,那这样的方法指针可能会将我们带到错误的方向:已经发现的歧义处必须单独处理以得到两条路径。但是对于正则语法,人们通常没兴趣去解析它,而只对识别感兴趣:输入正确并在适当的地方结尾,这就够了。
5.3.1 用状态替换集合
尽管上述过程在输入长度上是线性的(下一个令牌需要的工作量与长度无关),但是对于每个令牌依旧需要做大量的工作。更糟的是,必须反复查询语法,因此解析的速度会被语法的大小影响。简而言之,我们为非确定性自动机设计了一个解释器,它很方便易懂,但是效率底下。
好在我们有可能从根本上改进它:从图Fig5.7中的NFA中,我们构建了一个新的自动机与一组新的状态集,其中每一个状态都相当于一个旧的状态集。如果原始自动机(非确定性)在第一个a之后不知接下来往哪边(我们用 {A,B}来表示的状态),那么新的自动机(确定性)将明确知道第一个a之后,它的状态就是AB。
新的自动机状态可以系统的构建。我们从旧自动机的初始状态开始,这同样也是新自动机的初始状态。对于我们创建的每个新状态,我们根据旧状态检查其内容,对于语言中的每一个令牌我们决定给定集合导向哪个旧的状态集合。这些旧状态的集合将再次被视为新自动机的状态。如果我们再次撞见相同的状态,它将不会再次被分析。这个过程称为子集构造,并会产生一个(确定性)状态树。图Fig5.6的语法的状态树如图Fig5.11所示。为了强调它系统的检查所有符号的所有新状态,还显示了无向的连接弧线。新生成的但以前就存在的状态用✔标示了。
图Fig5.11的状态树通过将箭头指向✔标记过的状态(首次出现的)并删除死角而转变为一个转换图。新的自动机见图Fig5.12。它是确定性的,因此称为确定性有限状态自动机,简称DFA。
当我们现在使用句子abcba作为遍历此转换图的向导时,就会发现过程中不会产生任何疑惑并且顺利得到可接受状态。状态指出的每一条弧线都带有不通的符号,因此只要遵照符号表,我们始终都只会有一个方向。如果给定一个状态没有给定符号的指出箭头,则该符号可能不会出现在该位置。如果存在,则输入错误。
有两点要注意。首先,我们看到大多数可能的新自动机状态实际上并没有实现:旧状态机有5个状态,所有新自动机有25=32种可能状态,而实际上它的状态只有5种;像SB或者ABC这样的状态不会出现。这很正常,虽然n种状态的非确定性有限状态自动机变成DFA后,理论上有2 n状态,但是其中有些非常罕见并需要特别的构造出来。具有n种状态的NFA通常会产生少于或约等于10 × n状态的DFA。
其次,不再需要查询语法;输入令牌的自动机状态完全确定了下一个状态。为了方便查询,下一个状态可以存储在由旧状态和输入令牌索引的表中。DFA的索引表见图Fig5.13.通过这个表,可以以很少的机器指令来完成检查输入字符串的每个令牌。对于大多数DFA,表中的大多数条目为空(无法通过正确的输入到达,并且指向错误状态)。由于表相当的大(300状态的100次方个令牌,是很普遍的大小),不过有几种技术来压缩为空的部分来减小表的大小。Dencker, Dürre 以及 Heuft [338]对这些技术做了一下调研。
获得的解析树如下:
这不是原始的解析树。如果自动机仅用于识别输入字符串,是没有任何问题的。如果需要解析树,可以按照以下自底向上的方法来重建。从最后一个♦状态和令牌a开始,我们得出结论,最后一个右侧(自底向上解析的“句柄段”)是a。由于状态为BC,B和C的组合,我们看一下B和C的规则。我们会发现有一个派生是C->a,它简化了BC到C。最右侧的b和C组合成为句柄bC,其存在与A派生的集合 **{A,C}**中。用这个方式进行之后,我们就得到如下的解析:
这个方法又会重复查询语法;此外,回退的方式并不总是像上面的例子那么简单,我们将会得到不确定的语法。
对正则语法进行有效的全面解析,关注的人并不多;但是在Ostrand, Paull and Weyuker [144] and by Laurikari [151]的论文中可以找到很多有用的信息。
5.3.2 ε转换和非标准符号
标准形式的正则语法只会有A → a和A → aB的形式。我们先来用另外两种规则A → B和A → ε扩展一下符号,然后看看如何为他们构造NFAs和DFAs。接下来我们看看正则表达式和以正则表达式作为右侧的规则(例如P → a∗bQ),并展示如何将他们转换加入规则的扩展符号中。
图Fig5.14的语法包含两种新类型规则的例子;图Fig5.15展示了这个语法的三剑客:NFA,状态树和DFA。先看NF,当我们出于状态S的时候,我们会在令牌a处转换到预期状态B,最终得到标准规则S->aB。非标准规则S->A表示我们可以从状态S到状态A,而不需要读取(或生成)符号;即我们读取一个长度为0的ε字符串,并且做了一次ε转换(或ε转移);S A。非标规则A->ε产生一个ε转换以得到可接受状态:A ♦。ε转换不能和ε规则混淆:单元规则产生ε转换得到不可接受状态,而ε规则产生的ε转换得到可接受状态。
现在我们已经构建了一个具有ε转移的NFA,现在的问题是我们如何处理ε转移以得到DFA。为了解决这个问题,我们像前面一样推导;在图Fig5.7中,在看到a之后我们并不知道现在是出于状态A还是B,我们将之记为 {A, B}。此时当我们进入状态S时,甚至在处理单个符号之前,我们就已经不知道我们是处于状态S还是A或者 ♦了,由于后两者都可以通过ε转移由S到达。所以DFA的初始状态已经是复合的:SA♦。我们必须考虑此状态将会把符号a和b指向什么地方。如果是状态S,a将指向B,然后如果是A那么a将指向A。因此新状态包含A和B,而由于 ♦可以由A通过ε转移到达,因此它也包含 ♦且命名为AB♦。继续这条路径,我们可以构建完整的状态树(图Fig5.15(b)),折叠起来就成了DFA(c)。注意,DFA的所有状态都包含NFA的状态**♦**,因此输入可能以其中任何一个结束。
通过a的给定状态由ε转移得到NFA状态的集合称为那个状态的ε闭合。例如S的ε闭合是 {S, A, ♦}。
有关从最近发现在 XML 验证领域的正则语法获取 DFA 的完全不同方法,请参阅 Brzozowski [139]。
5.4 操作正则语法和正则表达式
如2.3.3节所述,正则语言通常由正则表达式而不是正则语法指定。例如 [0-9]+(.[0-9]+)?这个正则表达式,应该读作“集合0-9的一个或多个符号,后面跟着点,在后面是集合0-9的一个或多个符号”(表示包含小数点的一个数字);还有 (ab) * (p|q)+应该读作“0或多个字符串ab后接一个或多个p或者q”(并不直接表示其含义)。正则表达式的通常形式在图Fig5.16的表中有说明,其中R,R1,R2是任意正则表达式;有些系统提供的形式更多,但有些提供的较少。
在计算机输入中,元符号* 和符号*之间没有区别。如果要描述的语言包含这些符号 | *+?()[ 或 ],那么就需要特殊符号了。
5.4.1 正则表达式产生的正则语法
通过使用图Fig5.17给出的转换方式,可以将正则表达式转换为正则语法。转换中的T代表中间的非终结符,被选择用来刷新转换的每个应用;α代表处非终结符之外的任意正则表达式,其后可能接着非终结符。如果α为空,当它单独出现在右手侧时应该用ε替换。
正则表达式到正则语法的扩展对于从正则表达式得到DFA很有帮助,对词汇分分析器如lex中是必须的。生成的正则语法可以直接用于NFA,它可用于生成上述的DFA。还有另一种方法从正则表达式创建一个NFA,但是这需要对正则表达式进行一些预处理;见Thompson [140]。
我们将用表达式 *(ab) (p|q)+**说明该方法。我们的方法也将适用于包含正则表达式的正则语法(如 A → ab∗cB),实际上我们马上就要将正则表达式转换为此类语法:
尽管图Fig5.17中的表用T来生成非终结符,但我们使用例子中的A,B,C...(比起T1、T2、T3...没那么易混淆)。持续转换直到所有规则都是(扩展的)标准形式。
第一个转换被应用到 P→R *α,用以下替换__Ss->(ab) *(p|q)+__:
第一个规则已经转换为目标形式,并标记了✔。P → (R)α和P → aα的转换方式应用到A->(ab)A上,就有了:
P → R+α的转换应用到**A->(p|q)+**得到:
ε源于这样一个事实,A->(p|q)+中的 (p|q)+后面没接任何东西(这意味着ε是唯一的)。那么A->(p|q)C和C->(p|q)C就很容易分解成:
完整的扩展标准版本见图Fig5.18;NFA和DFA可以使用5.3.1节(此处未显示)的方式生成。
5.4.2 来自正则语法的正则表达式
例如在9.12节中,有时将正则语法压缩为正则表达式非常有用。转换可以通过交替替换规则并应用图Fig5.19的转换形式来执行。第一模式将同意非终结符的所有规则都结合起来了。第二种模式将右手侧的同一非终结符的所有正则表达式结合在一起;α是不以Q结尾的替代方法的列表(见下段)。第三种模式删除所有的右递归:如果重复部分为 (R),那么所有非递归的备选项写作 (R)*;而β包含所有α有的,以及 (R)*所代表的。Q1,Q2,...并不等同于P(见下段)。当α为α时,且与非空正则表达式串联时,可以将它分离出来。
替换和转换可以按任意顺序使用,这并不会影响最后得到一个正确的正则表达式,但是最终结果却与应用的顺序有极大关系;为了获得一个“好”的正则表达式,就需要特别的指导。此外,上一段中的两个条件虽然不会让结果出错,但可能会让我们得到一个不太理想的正则表达式。
现在我们将转换应用于图Fig5.18的正则语法中,并加以指导。首先我们将规则和左手侧结合起来(转换1):
接下来我们替换B:
后面接着前缀(转换2):
注意,我们还需要将有前缀A的ab打包起来,以便下一次转换,这涉及到将递归转换为重复:
现在,C可以用A和S中的A替换,得到:
*
这是等效的,但与我们开头用的 (ab)*(p|q)+ 不同。
5.5 正则语言的操作
在第2.10节中,我们讨论了CF语言上的操作“union”,“intersection”,“negation”,并发现后面两种并不一定会产生CF语言。对于正则语言来说,情况更简单:这些正则语言的操作集合都可以得到正则语言。
为FS自动机*A1和A2定义的两个联合正则语言创建一个FS自动机很简单:只需要创建一个新的起始状态,并将ε转换从该状态添加到A1和A2*的起始状态。如果需要ε转换可以去掉,如5.3.1所述。
有一个有趣的方法来获取由FS自动机定义的正则语言L的反面(补充),前提是自动机是非ε的。当自动机是非ε时,其中的每个状态t都直接显示令牌集Ct,其中使自动机状态为t的输入可以继续:Ct是t的传出转换的令牌集。这意味着如果字符串继续带有不在Ct中的令牌,那么这个字符串不再L中,因此我们得出结论此字符串为*¬L*。现在我们可以通过在所有不在Ct中的令牌上添加传出箭头(并将这些箭头引导到非接受状态,我们称之为s-1)来完成状态t。如果我们对自动机(包括s-1)的所有状态都进行这样的补全,我们将获得一个完全自动机,即所有转换都定义好了的自动机。
图Fig5.7的自动机的完整版本如图Fig5.20所示,其中非接受状态标记为✘。
一个完整的自动机的重要性在于它永远不会在卡在任何(有限的)输入字符串上。对于属于自动机语言L的字符串,它以可接受状态结束;对于不属于自动机语言的字符串,它以非接受状态结束。这就建议获取L的一个自动机补全(反向):交换可接受状态和非可接受状态,通过让可接受状态变为不可接受,非接受状态变为可接受来完成。
注意,补全自动机已经破坏了它的异常检测属性,因为它不会拒绝一个首字符有问题的输入字符串,然后在完成所有的操作后在返回。
补全过程要求自动机是非ε的。通过其确定性,这很容易实现,如第145页所述,但可能有些过度了。有关仅删除ε转换,请见问题5.4。
现在我们有了FSA的反向自动机,构建两个FSA的交集似乎很容易:只要联合两个自动机,取交集的结果在取非即可,适用摩根定律p ∩ q = ¬((¬ p) ∪ (¬q))。但有一个问题。只有当自动机是非ε的,并且取交集过程中将两个ε转换处于非常特殊的位置时,取交集才是容易的,这使得这种方法并不是很让人喜欢。
好在构建两个FS自动机的交集可以避免这些问题:同时运行两个自动机,在一个新状态下同时追踪两个自动机的状态。例如,我们会将图Fig5.7的自动机*A1*和一个FSA A2(要求输入包含序列 . ba. )取交集。它需要3个状态,我们称之为1(起始态),2和♦(接受态);他有以下转换:,,,**。
我们在组合状态S1中取自动机A1和A2的交集A1 ∩ A2,S1状态由*A1的起始态S和A2的起始态1组成。对于A1*中每一个 *转换,和A2*中每一个 *转换,在A1 ∩ A2*中我们创造了一种新的转换 。这为我们带来了图Fig5.21(a)的状态树;对应的FSA见(b)。可以看到它与图Fig5.7的类似,除了缺少了 :由于要求字符串包含ba序列因此删除了它。
原则上,FSA与n种状态的交集和与m种状态的交集,可能需要n × m种状态,但实际上像c × (n + m)这种,通常当c值较小时其才相等。
相反,一个复杂的FSA可以拆分为两个简单FSA的交集,这需要更大的内存,但有时也不能进行拆分。但不幸的是鲜有关于如何进行拆分的论文,只有一些思考:参见问题5.7。这个过程也称为“分解(factorization)”,但这也意味着一些问题,由于它表示我们找到的分解因子具有相同的特性,因此这意味着FSA的分解并不唯一。
5.6 左正则语言
在左正则语法中,所有规则都是A→a或A→Ba(其中a非终结符,A和B为非终结符)形式。图Fig5.22给出了与图Fig5.6等效的左正则语法。
左正则语法经常被作为右正则语法的变体放在一边,但不论是形式还是给人的感觉都是截然不同的。例如,从语法中生成一个字符串。假设我们要生成5.3节中的句子abcba。为此,我们首先需要确定即将访问的所有状态,并且只有当最后一个状态也确定之后,才能生成第一个令牌:
一旦第一个令牌可用了,那么其他都可以了,并且我们也不再有别的选择了;这与右正则语法的生成过程大相径庭。
解析左正则语法同样很奇特。很容易看到,我们有了所有状态的集合 {S,A,B,C},但如果我们看到在输入中有一个a,我们可以在两个规则B->a和A->a中互移a。假设我们使用规则A->a;我们将会处于什么状态呢?规则不指定除A以外的任何状态;那这样的移动有什么作用呢?
简单的方法是将语法转换为右正则语法(见下文),但尝试寻找A->a中移动a的意义也是非常有意思的。在这样一个操作之后我们唯一知道的就是,我们刚刚完成了A的生成,因此我们所处的状态可以描述为“A完成”;我们将这种状态写作Af。同样的,图Fig5.22的第一条规则表示,我们处于状态Cf并移动a时,我们的状态会是Sf;这就是一个转换CfSf。然后我们就知道“S完成”意味着我们已经解析了S的一个完整生成;因此状态**Sf**就是可接受状态♦,我们可以在图Fig5.7看到最右侧转换。
现在,我们看到规则A → Bt对应于转换BfAf,规则SS→Bt对应Bf,那A → t形式的规则呢?结束t的转换之后,我们就处于状态Af,但我们是从哪开始的呢?答案是我们还没看到任何一个终结符产生,因此我们处于状态εf,这就是起始符号!因此规则A->a和B->a对应的转换是 εfAf 和 εfBf ,图Fig5.7的另外两个部分。接下来我们通过修改状态名称,继续快速重建图Fig5.7的转换图:
这暴露的初始状态和可接受状态之间的不对称,与初始状态不同,可接受状态对应于语法中的一个符号。这种不对称,可以通过替换为一个更中性的符号部分消除,例如。然后我们就获得了下面介于左正则和右正则之间的语法:
从左正则语法中获取一个正则表达式很简单:5.4.2节中的大多数算法都可以在最小代价下被接管。只需要将转换方式由递归变成重复:
必须被替换为:
其中β'由所有α的备选项组成,每个都附加上 (R)∗ 。这是因为A->aA|b产生a* b,而A->Aa|b产生ba* 。
5.7 最小化有限状态自动机
将NFA转换为DFA通常会在自动机中增加大概10个左右的因子,因而使得自动机变得更大,甚至偶尔会使自动机严重膨胀。考虑到对于大型自动机来说,一个10个数量左右的因子增长可能就是一个大问题了;即使是一个小的表格,如果需要存储在一个小型电子设备中,那么任何大小的增加都是难以接受的;但是大尺寸的因子增加的出现是难以预料的,因此很有必要尝试减少DFA中的状态数量。
此处介绍的DFA最小化算法的关键思想是,在我们看到差异之前我们都将状态视为等效状态。因此该算法将DFA状态保存为多个互不相交的子集(“分区”)。一个集合S的分区是S的子集的集合,S的每个元素会处于某个子集中;也就是说,S被划分为互不相交的若干个子集。该算法使用迭代的方式划分子集,根据状态的不同来进行划分。
我们使用图Fig5.23(b)的DFA为例;它可以通过图Fig5.23(a)中的NFA生成,通过A = SQ和B = P算法子集,并且不是最小子集,如我们所见。
最初我们将状态集氛围两个子集:一个包含所有可接受状态,另一个包含所有其他状态;这些肯定是不同的。在我们的示例中,这将带来一个包含状态S,B和A的子集,以及一个包含可接受状态♦的子集。
接下来,我们反过来在处理每个子集Si。如果Si存在两个状态q1和q2,则在某些符号上,a已经转换到现在分区的不同子集的元素上,如此我们找到了不同处那么Si就必须分离出来。假设我们有q1r1和q2r2,而r1属于子集X1 r2属于不同的子集X2,那么Si必须被拆分为包含q1和Si中其他所有状态qj(其中满足qjrj,且rj包含于X1)的子集,以及另一个包含其余所有来自于Si的状态的子集。如果q1在a上没有转换但*q2有(或者反过来),那我们也能发现不同,并且Si*依旧要被分割。
在我们的例子中,状态S和A在a上存在转换(指向相同状态,♦),但是状态B并没有,因此这个步骤将生成两个子集,一个包含状态S和A,另一个包含状态B。
我们重复将此步骤应用于分区的所有子集,直到不能在拆分为止。最终一定会是这样,因为子集的数量是有限的:分区中的子集不会比原始DFA中的状态多,并且在整个过程中不会出现合并子集的情况。(这是闭包算法的另一个示例。)
完成这个过程后,生成的分区的子集Si中的所有状态都有以下属性,对于任何字母符号a它们关于a的转化结束于分区的相同子集Si(a)。因此,我们可以认为每个子集是最小化DFA的一个单一状态。最小化DFA的起始状态由包含原始DFA起始状态的子集表示,而最小化DFA的可接受状态由包含原始DFA可接受状态的子集表示。事实上,生成的DFA即为识别我们开始使用的 DFA 指定的语言的最小 DFA。见Hopcroft and Ullman [391]。
在我们的示例中,我们已经无法在进行拆分,结果DFA如下图所示。
5.8 自顶向下正则表达式识别
第5.3节的3型识别技术是自底向上的方法,期间会收集重建过程的假设,通过自顶向下的组件确保识别的字符串生成于起始符号。事实上,子集算法可以很容易的从一个特定自底向上解析器生成,Earley解析器,我们将会在第7.2节讲到(问题5.9)。令人惊讶的是,许多具有正则表达式的软件都使用6.6节中回溯自顶向下解析器,该解析器适用于正则表达式。它的主要优点在于这个方法不需要对正则表达式进行预处理;缺点是它可能会耗费更多的时间。我们首先简要的解释一下这个技术(6.6节有关于这个技术的详细内容),然后我们再来看它的优缺点。
5.8.1 识别器
自上而下的识别器遵循正则表达式的语法,总结如下:
识别器保留了两个指针,一个在正则表达式中一个在输入中,并尝试同时移动两个指针:当有一个令牌匹配时,两个指针同时向前一步,但是如果simple_re必须重复一次时,正则表达式的指针要向后一步,且输入指针保持原位。当正则表达式指针指向正则表达式末尾时,识别器根据输入指针的移动距离来之策一个匹配项。
当识别器尝试识别compound_re时,首先会判断它是否是repeat_r。如果是的话,它会检测标记。如果标记是 +那表示必须要有simple_re,则识别器接下来会先查找simple_re,但如果simple_re是可选项(即标记为 *、? ),那么接下来的查找分为两条路:一条查找simple_re,另一条查找正则表达式的这个repeat_re之后的其余部分。当识别器完成一个repeat_re之后,接下来它会继续查找标记。如果是一个 ?,那么继续,但如果是 *或者 +,那么查找又分为两条路:一个跳回repeat_re的开头,一个继续搜索剩下的正则表达式。
如果识别器发现simple_re是一个令牌,会将这个令牌和输入指针的令牌进行比较。如果他们匹配,那么两个指针都向前移动;如果不匹配,那么搜索终止。
还有两个问题:我们如何实现搜索拆分,以及如何处理记录下来的匹配项。第一个问题,我们通过按顺序执行来实现搜索拆分:我们先执行完整的首次搜索直到结束或者失败,包括所有的子搜索;然后,无论结果如何我们都进行第二次搜索。这看起来有些麻烦,无论是编码还是效率,但实际上并不会。选项repeat_re的核心代码如下:
其中rp和ip代表正则表达式指针和输入指针。该算法通常很高效,因为搜索几乎都是立马失败,因为两个令牌并不匹配。
记录的匹配项的处理取决于应用程序。如果我们想知道正则表达式是否匹配整个字符串,例如文件名匹配,我们就看看是否同时到达输入的结尾处,如果同时到达,那么直接返回成功;如果不是同时到达,那么就继续搜索。如果我们想要的是找到最长匹配项,那么我们就将每个匹配项的长度记录下来,然后进行搜索直到所有搜索都结束。
5.8.2 评价
自顶向下正则表达式的优点是显而易见的:该算法很容易用编程实现,并且几乎不涉及正则表达式的预处理,具体取决于结构线程(例如after_subexpression())的实现。其他优势不那么明白可见。例如,这个技术允许命名正则表达式的一部分,并检查其在输入中其他位置的重复状态;这是个让人惊喜的强大功能。举个简单的例子,(.* )=x\x模式表示:匹配输入中的任意段,标记为x,然后用已经识别的x去匹配输入的其余所有内容;\x称为反向引用(backreference)。(另一个更有用但也更不明显的有点是,对于相同的正则表达式 (. )\1*,其中 \1表示:匹配包含在 **(**和 **)**中的第一个子表达式。)
面对输入abab,识别器将x的值设为ε, a, ab, aba和abab,然后尝试将每种情况下的尾部于x的当前值进行匹配。仅当x=ε和x=ab时能成功,并且只在最后一种情况时才识别整个输入。因此,上述表达式识别由两个相同部分组成的所有字符串的语言:ww,其中w是给定字母表上的任意字符串。由于这是一种上下文相关语言,我们惊奇的看到,跳过整个2型语言,那么具有反向引用的3型正则表达式识别出了一个1型语言!广泛使用这个属性的系统是§-微积分(§-calculus)(Jackson [285, 291]),这个将在第15.8.3节进一步论述。
自顶向下正则表达式的主要缺点就是时间需求。虽然它们通常是具有非常小的乘积常量的线性存在,但有时候这个乘积常量也可以大的可怕。*O(nk)时间复杂度出现在类似a *a *...a (*a 重复k次)的模式中,所以原则上时间复杂度可以是输入长度的任意多项式,但是通常不会比指数式更差。在本书中查找于表达式 *. )**匹配的所有10000行耗时36秒;而找到匹配 **)**的11000行的耗时就无法计算了。
5.9 FS系统中的语义
在FS系统中,语义操作可以附加到状态或转换中。如果语义附加在状态中,则它一直都是有效的且是静态的。它可以控制某些设备面板上的指示器,或者保持电梯电机的运行。与状态相关联的语义也称为Moore语义(Moore [136])。
如果语义附加到转换中,则它仅在转换时以信号或过程调用的形式出现;它是动态的也是瞬时的。这个信号可以在咖啡机上放下一个纸杯或者切换铁路线;它的稳定性和静态性,由设备的物理结构来提供。过程调用可以告诉编译器中的词法分析器,令牌开始已经找到。与转换相关的语义也称为Mealy语义(Mealy [134])。
转换相关的语义由许多变体。当特定的转换si sj出现(Mealy [134])时,信号会出现;当特定令牌导致输入一个特定状态时(∗ sj,其中*是任意状态);当输入一个特定状态( ∗ sj,McNaughton and Yamada [137]);当离开特定状态时(sj ∗);等等。关于这些差异不需要写太多。因此在阅读论文时,弄明白作者使用的那种约定很重要。当然通常情况下,哪种最合适就用哪种。
5.10 使用有限状态自动机快速文本搜索
假设我们正在寻找一个短文本,例如一个单词或一个名称(被搜索的字符串),在一个很长的文本中例如字典或百科全书。查找长度n字符串的一个傻办法是尝试匹配字符串从1到n:如果失败,那就整体后移一位即2到n+1等等,直到我们找到目标字符串或者直到搜索完全部字符串。但是这个方法耗时极高,因为每个字符都要过n次搜索。
有限自动机提供了一个更有效的文本搜索方法。我们从字符串中生成一个DFA,让它来操作字符串,让它遇到一个可接受状态时,那么就找到目标字符串了。例如,假设搜索字符串是ababc,且文本仅包含a、b和c。搜索此字符串的NFA如图Fig5.24(a)所示;推导过程见下面。
文本中的每个字符串都有两种可能路径:如果搜索从这里开始,那该字符串由右向状态练表示;或者搜索不从这里开始,那我们就要跳过现在的字符并回到初始化状态。自动机是非确定性的,因为我们可以在状态A中找到a,那我们就有两种选择:这是ababc这个字符串在全文开始的位置,或者认为它不是。
使用传统的方式,那这个NFA可以用来生成一个状态树 (b)和一个DFA (c)。图Fig5.25展示了DFA在输入文本aabababca后的状态转换过程。可以看到,我们实现了超字符串识别(superstring recognition),即输入的子字符串被识别为匹配的是语法而不是输入。这使得输入成为该语言的字符串中的一个超级字符串,因此而命名。
这种有限状态自动机的应用被称为the Aho and Corasick bibliographic search algorithm(Aho and Corasick [141])。与任何DFA一样,每个字符只需要几个计算机指令。一个额外的收获是,对多个字符进行搜索的消耗与单个字符相当。与图Fig5.26对应的DFA将同时搜索Kawabata,Mishima和Tanizaki。注意,有三个不同的可接受状态,,和。
The Aho and Corasick算法不是字符串搜索的最后一个。同样优秀的算法还有the Rabin-Karp algorithm (Karp and Rabin [145])和the Boyer-Moore algorithm (Boyer and Moore [143])。Aho [147]提供了快速字符串搜索算法的极佳概述。Watson [149]扩展了Boyer-Moore算法(它只能搜索单个单词),使之可以搜索正则表达式。但是无论这些算法有多么棒,它们都不在本书的范围之内,因此大家可以自己去研究,本书不做探讨。
5.11 总结
正则语法的特点是没有嵌套。从一个语法规则或网络转换切换到另一个是无内存变化的。因此生成过程是有语法的单个位置决定,而识别过程由语法中的有限位置决定。
正则语法对应于正则表达式,反之亦然,尽管转换算法往往会带来意想之外的复杂结果。
在一个正则集合中字符串可以被识别为自底向上,通过“子集算法”产生的有限状态机,或者被识别为自顶向下,通过正则表达式产生的递归进程。第一个的优点在于非常高效;第二个的优点在于允许轻松添加语义操作和识别限制。
从数据挖掘的目录和网络检索到病毒扫描,有限状态自动机在各种文本搜索中都非常重要。
问题
问题5.1:为5.1.1节中语法C的最右侧派生中,句子形式的开放部分构建一个正则语法。
问题5.2:图Fig5.7和Fig5.12的FS自动机只有一个可接受状态,但是图Fig5.15(c)中的自动机有多个可接受状态。那多重可接受状态是否是必要的?注意:1.是否任何FS自动机A都能转换为只有一个可接受状态的FS自动机B?2.此外B是否不具有ε转换?3.B是否是确定性的?
问题5.3:展示在清理正则语法时,可以按照任意顺序执行删除非生成性规则和不可到达的非终结符的语法清理操作。
问题5.4:设计一种算法,可以从一个FS自动机中删除ε转换。
问题5.5:设计一种方法,在正则语法而不是自动机上执行正则自动机(5.5节)的完成和逆向。
问题5.6:对于有逻辑背景的读者:对FSA的补充进行二次补充,并不一定会产生原始自动机,但是对已经进行过补充的补全FSA的补全会产出一个原始自动机,这表明补全自动机在某种程度上是不同的。分析这种现象,并找到与直觉逻辑相似的地方。
问题5.7:项目:研究FSA的分解/裂解;例如Roche [148]。
问题5.8:当我们为每个非终结符A分配两个状态时,As表示“A开始”,Af表示“A完成”,那么规则A → XY会带来3个ε转换,,和,以及一个非ε转换或者,这取决于X或Y谁是终结符。用这个视图,写出比5.6节更对称且结合更好的左正则和右正则语法。
问题5.9:从Earley解析器(7.2节)生成一个子集算法,可以在左正则语法上起作用的。
问题5.10:从图Fig5.22的语法生成一个正则表达式。
问题5.11:项目:5.7节演示了如果通过初始化假设所有非终结符都相等来最小化一个FS自动机/语法。那么CF语法是不是也有类似的过程呢,又能得到什么呢?
问题5.12:历史:追溯Kleene star的使用起源,升起的星星的意思是“一组无界事件集”。(见[135])
6 有向自顶向下解析
在这一章中,我们将要讨论用预测尝试重新推导出输入句子的自顶向下解析方法。正如3.2.1中解释的那样,我们从开始符号开始,尝试从它产生输入句子;在每一时刻,我们有一个句型代表我们对剩余输入句子的预测。将预测写在它所预测的输入句子的那部分正下面会很方便,左端对齐,就像我们在图3.5中做的那样:
这种句型由终结符和非终结符组成。如果是一个终结符在最前面,我们将它跟当前的输入字符匹配。如果是一个非终结符在最前面,我们选择它的右侧中的一个,用它替换该非终结符。这样,我们总是替换掉最左的非终结符,最后,如果成功了的话,我们就模仿了最左推导。注意,预测部分跟做最左推导时句型的开放部分是对应的,就像5.1.1讨论的那样。
6.1 仿最左推导
现在让我们用一个例子解释这样一个推导过程。如图Fig6.1所示语法。这个语法生成与a和b数量相同的句子。
让我们从开始符号S开始尝试解析句子aabb。S是我们的第一个预测.我们的预测的第一个符号是非终结符,所以我们要用它的一个右侧替代它。在这个语法中,S有两个选择:我们或者用规则S→aB,或者用规则S→bA。句子从a开始而不是b,所以这里我们不能用第二个规则。应用第一个规则我们得到预测aB。现在预测的第一个字符是一个终结符。因此,我们别无选择:
我们要将这个字符和句子的当前字符做匹配,这里当前字符也是a。因此我们获得了一个匹配项,并接受a。余下的句子就留给我们一个预测B:abb。预测的第一个字符又是一个非终结符,所以要被它的一个右侧代替。现在我们有三种选择。然而,第一和第二个选择在这里不适用,因为它们由b开始,但实际上我们需要的是a。所以,我们采用第三个选择,现在我们有了预测aBB:
因此对于当前输入字符我们再次有了一个匹配项,所以我们接受它,然后继续bb的预测BB。我们要再次用其右侧替代其最左的B。句子的下一个字符是b,所以这里第三个选择不适用。这仍然留给我们两个选择:b和bS。所以我们可以两个都尝试,但也可以用更聪明一点的方法。如果我们采用bS,我们至少会得到一个额外的a(由于S),所以这不可能是正确的选择。因此我们只能选择b,于是得到了对应bb的预测bB。我们再次得到了一个匹配项,这留给我们b的预测B。由于同样的原因,我们选择b。匹配之后,我们的预测为空。幸运的是,我们同时到达了输入句子的末尾,所以我们接受了这个输入。如果我们记录下了使用过的生成规则,就能得到下面的推导过程:
S→aB→aaBB→aabB→aabb
图Fig6.2用树的形式展现了解析的步骤。图中的虚线将已经处理过的部分和未处理部分分开。整个过程中,预测的最左侧符号都经过了处理。
这个例子显示了本章讨论的解析器的几个共同点:
-
我们总是对预测中的最左符号进行处理
-
如果这个符号是终结符,我们就没有其他选择:我们只能将它和当前的输入字符匹配,或者直接拒绝解析
-
如果这个符号是非终结符,我们需要采用一个预测:它需要被它的一个右侧替代。因此,我们总是先处理预测中的最左符号,从而得到了最左推导。
-
所以,自顶向下方法将解析树的节点用前序组织:父节点总是在它的子节点之前被识别。
6.2 下推自动机
在上面的例子中我们进行的步骤与被称为下推自动机的技术很相似。下推自动机(PDA)是一个假想的数学装置,它读入输入串,用栈(stack)进行控制。栈内可以包含属于栈符号表(stack alphabet)的符号。栈是一种只能从一端被使用的列表:最后一个进入(“pushed”)列表的元素将第一个被取出(“popped”)的。栈有时被叫做先进先出表或FILO表:第一个进去的元素最后一个取出。在上面的例子中,预测的工作方式就像一个栈,这也就是下推自动机使用栈的目的。因此我们将这个栈称作预测栈(prediction stack)。同时,这个栈解释了术语“下推”自动机:为了后续过程,自动机在栈上“推入”符号。
下推自动机的工作方式是弹出一个栈符号然后读取一个输入符号。接着这两个符号一般会提供我们几种将被推入的栈中的选择。所以存在一个(输入符号,栈符号)二元组到栈符号串列表的映射。在栈为空并且达到输入符号的尾部的时候,自动机接受输入的句子。如果选择存在多种(也就是(输入符号,栈符号)映射到不止一个的串上),当这些选项中存在一些选项使得读到句尾时栈为空,自动机接受句子。
这种自动机是以满足Greibach范式(GNF)的上下文无关语法为模型的。在这种范式中,所有语法规则都满足A→a或A→aB1B2...Bn,这里a是一个终结符,A,B1,...,Bn是非终结符。栈符号当然是终结符。A→aB1B2...Bn形式的规则对应着 (a,A)对B1B2...Bn的映射。这意味着如果输入符号是a,并且预测栈栈顶是A,那我们可以接受a,将预测栈上的A替换为串B1B2...Bn。A→a对应着 *(a,A)*向空串的映射。开始时,这个自动机栈顶为语法的开始符号。每一个不产生空串的上下文无关语法都能转化为Greibach范式(Greibach[8])。大多数形式语言理论的书都阐述了如何做这项工作(例如见Hopcroft and Ullman[391])。
图Fig6.1的示例语法已经是Greibach范式了,所以我们轻松的从它构建一个下推自动机。这个自动机在图Fig6.3用映射的形式表示了出来。
这里有很重要的一点需要注意:很多下推自动机是非确定性(non-deterministic)的。比如,图Fig6.3所示的下推自动机可以为 (a,A)选择空串或S。事实上,存在不能构建出具有确定性的下推自动机的上下文无关语言,尽管我们能构建出非确定性的。
还有需要说明的是:这里我们讨论的下推自动机是自动机理论里的的简化版。在自动机理论里,下推自动机有所谓状态(state),相应的映射是从(state, input symbol, stack symbol)三元组到(state, list of stack symbols)的二元组。从这个角度看,它们像是用一个栈拓展了的有限状态自动机(第五章讨论的)。而且,下推自动机有两种类型:一些用栈空表示接受句子,另一些用在标注为接受状态(accepting state)的状态处结束表示接受。也许这让人很意外,拥有状态并不能让下推自动机这个概念更有吸引力,因为有状态的下推自动机仍然只能接受可以被上下文无关语法描述的语言。在我们的论题中,下推自动机只有一个状态,所以我们已经忽略它了。
如果我们想将其转化为解析用的自动机(parsing automata),以上描述的下推自动机有几个问题需要解决。首先,下推自动机需要我们将我们的语法转化为Greibach范式。虽然语法转换对形式语言学家不是问题,但我们还是希望能尽量用原始的语法而不加处理。现在我们稍微放宽Greibach范式的要求,允许在栈中推入终结符,然后对所有终结符a,在映射关系中添加:
(a,a)→
这样我们就能使用所有右侧都由终结符开始的任何语法了。同时,我们可以将下推自动机的工作步骤分为“匹配”和“预测”,就像我们在6.1节中的例子做的那样。“匹配”步骤对应 *(a,a)→*的使用,“预测”步骤对应 (,A)→...,这个就是说,栈上的非终结符被它的一个右侧替代,不用消费输入符号。对图6.1所示语法,就有了图Fig6.4所示的映射关系,这事实上仅仅就是重写了图Fig6.1的语法。
我们之后将会看到,即使用了这种方法,我们可能还是必须改动语法,但现在这看起来就非常可行了,所以我们采用这种策略。这个策略同时解决了另一个问题:ε规则不再需要特殊的处理了。为了得到Greibach范式,我们还是需要除去它们,但这不再是必须的了,因为它们刚好对应 *(,A)→*映射。
第二个缺点是这个下推自动机没有保存使用过的规则(映射)的记录。因此,我们在这个自动机中引入一个分析栈(analysis stack)。对每一个预测的步骤,我们将被替换的非终结符,以使用的右侧的序号(将右侧从1到n标上序号)为后缀推入分析栈。这样,分析栈刚好跟图Fig6.2中虚线左边的部分对应,这个虚线分隔了分析栈与预测栈。这导致了这个自动机在每个时候都有如图Fig6.5所示结构。这个结构,跟当前状态,栈等等,被称作一个即时描述(instantaneous description)。在图Fig6.5中,匹配过程可以本看作将竖直线向右推。
然而最重要的第三个缺点,是非确定性。形式上,这个自动机当且仅当有一个选择的序列使得在句尾的时候栈空接受句子,这个说法可能让人满意,但是这不是我们的目的,因为这没有告诉我们怎么得到这个序列。我们必须引导自动机去做正确的选择。回看6.1节的例子,在推导的很多时候需要做出选择,而且我们的选择基于了一些对当前的语法的特殊的考虑:有时我们关注句子的下一个符号,也有时我们看得更远,去确定之后没有a出现。在这个例子里,选择是容易的,因为所有右侧由终结符开始。然而大多数时候,找到正确的选择要困难的多。比如右侧可以由一个非终结符开始,它又有从非终结符开始的右侧,等等。
在第八章我们将看到,给定句子的下一个符号,很多语法仍然允许我们决定使用哪一个右侧。但是在本章中,我们将关注能在更大一部分的语法上起作用的自顶向下解析方法。而不是尝试基于特殊考虑来决定选择,我们要从所有可能出发来引导自动机。在第三章我们曾看到,在解决在特定的点有多种分支的问题的时候一般有两种方法:深度优先搜索和广度优先搜索。我们将看到如何完成这两种方法的机器操作。由于操作的步数可能会按输入规模以指数级别增长,即使是一个小例子也可能有庞大的工作量。为了让事情更有意思,我们将只用图Fig3.4所示的固有歧义的语言,这个语法在图6.6这里重复了一遍。我们将用aabc作为测试输入。
6.3 广度优先自顶向下解析
用于自顶向下解析问题的广度优先方法是去维护一个所有预测的列表。然后每一个预测都像上面6.2节说的那样处理,也就是说,如果栈顶是一个非终结符,这个预测栈就被一些新的预测栈替换,新的预测栈数量跟它所对应的选择数相同。在这每一个新栈里,栈顶的非终结符被对应的选择替换。这个预测步骤对所有预测栈施用(包括新生成的),直到所有预测栈栈顶都是终结符。
这时对所有预测栈,我们将最前面的终结符与当前输入符号进行匹配,将不匹配的预测栈删去。如果没有预测栈剩下来,那么这个句子不属于该语言。所以,我们的自动机现在维护一个(stack,analysis stack)预测元组的列表而不是单一一个预测元组,其中每一个对应一个可能选择,如图Fig6.7所示。
这个方法对在线解析(on-line parsing)同样适用,因为它按从左到右的顺序处理输入串。任何从左到右处理输入并且得到最左推导(leftmost derivation)的解析方法被称为LL解析方法。第一个L代表从左到右,第二个L代表最左推导。
依此我们基本知道了该怎么写出这样一个解析器,但现在我们还有一个小问题没有解决:终止。当最终我们得到了一个空的预测栈时,输入句子就属于该语法描述的语言吗?不,仅当输入被读完的情况下才是!为了避免这个额外的检查,并且避免出现已经达到句尾但解析没有停止的情况,我们引入一个特殊的终止记号(end marker) #.这个终止记号同时附加在句子和预测的末尾,所以当符号成功匹配时我们就能知道这个预测匹配了输入句子,解析成功。
6.3.1 一个例子
图6.8展示了对句子aabc的一个完整的广度优先解析过程。最开始只有一个预测栈:它包含开始符号和终止记号;没有符号被接受(框架a)。得到 *(b)*的步骤是一个预测步骤;有两个可能的右侧,所以我们得到两个预测栈。预测栈的不同也表现在了分析栈上,S的不同后缀代表不同的右侧选择。下一个有多种右侧的预测步骤得到了(c)。现在所有预测栈栈顶都是终结符;刚好都成功匹配,这就得到(d)。接下来,有些预测开头又是非终结符了,所以进行下一个预测步骤得到(e)。在下一个步骤是匹配步骤,幸运的是,有些匹配失败了,因此这些会被去掉,因为由它们不可能得到成功的解析。从(f)到(g)又是一个预测步骤。另一个有一些失败匹配的匹配步骤让我们得到(h)。在接下来的一个预测步骤会带来(i),然后在接着一个匹配步骤最终给我们带来(j),终止记号匹配,解析成功。
最终的解析(analysis)是
S2A2aA1aB1bc#
目前,我们不需要解析里的终结符;把它们去掉之后就得到
S2A2A1B1
这说明我们通过先应用规则S2,再用**A2**等等一系列规则后,得到最左推导,每次替代掉最左的非终结符。检查一下:
S→AB→aAB→aaB→aabc
这里描述的广度优先最初出现在Greibach [7]。但是,在那个论文中,语法被转化为了Greibach范式,并且使用的步骤跟我们最开始的下推自动机的相似。所以预测和匹配步骤被结合起来了。
6.3.2 一个反例:左递归
上述方法明显适用于那个语法,那么问题来了,它是否对所有上下文无关语法都适用呢?有的人可能认为是,因为在任何出现的预测里,对所有非终结符,所有可能都被有系统地尝试了。不幸的是,这个推理有个严重的缺点,下面的例子可以看出来:让我们看看句子ab是否属于这个简单语法定义的语言:
我们的自动机从下面的状态开始:
因为在预测的开始是一个非终结符,我们进行一个预测步骤就得到:
有一个预测再次以非终结符开始,我们再进行一次预测:
现在就很清晰了:我们好像最终进行了一个无限的过程,什么都得不到。导致这样的原因是我们一直在尝试S→Sb规则,而从未达到可以尝试匹配的状态。实际上无论何时,有一个非终结符能推导出无限多的由非终结符开始的句型,这个问题就会发生,进而导致没有匹配步骤。由于这无限多的句型由非终结符开始,非终结符的数量又是有限的,就至少有一个非终结符在开头出现过超过一次。所以我们有:A→…→Aα.一个能推导出由自己开始的句型的非终结符就叫做左递归。
左递归有几种类型:当有一个A→Aα这样的语法规则,我们称为直接左递归(immediate left recursion),就像规则S→Sb;当递归中间有其他规则,比如A→Bα,B→Aβ,我们称之为间接递归(indirect left recursion)。这些形式的左递归都可能被ε生成隐藏;这就分别有了隐藏左递归(hidden left recursion)和隐藏间接左递归(hidden indirect left recursion)。比如在这个语法里
非终结符S,B和C都是左递归的。有左递归的非终结符的语法也称为左递归语法。
一个语法没有ε规则也没有循环(loop)的情况下,如果我们使用一个额外的步骤,我们仍然可以使用我们的解析策略:如果一个预测栈有超过输入句子长度数量的符号,它不可能推导出输入句子(每一个非终结符推导出至少一个符号),所以它能被去掉。然而,这个小技巧有一个大弊端:它需要提前知道输入句子的长度,所以这个方法不再适用于在线解析。幸运的是,左递归能被消除:当给定一个左递归语法,我们可以将它转化为一个拥有相同定义,并且没有左递归非终结符的语法。鉴于对任何自顶向下解析方法,左递归都是一个主要问题,接下来我们将要讨论这个语法转化。
6.4 左递归的消除
我们先讨论消除直接左递归的方法。假定ε规则和单元规则已经被消除了(见4.2.3.1节和4.2.3.2节)。现在,使A为一个左递归规则,并且
是A拥有的所有规则。没有等于ε的αi,否则我们会有A→A规则和一个单元规则。也没有等于ε的βj,否则会有一个ε-规则。由A生成的句型只用*A→Aαk*规则,这些句型都有这样的形式:
并且当A→βi规则使用时,句型不再以A开头,对一些i,和一些k1,...,kj,它有如下的形式:
这里j可能为0.同样的句型也可以用如下规则生成:
或者,不重新引入ε规则生成的话就是这样:
这里Ahead,Atail和Atails是新引入的非终结符。所有αi都不是ε类型,所以Atail不会推导出ε,所以Atails不是左递归。不过A可能仍然是左递归的,但不是直接左递归,因为没有βj是以A开始,然而它们却可能推导出以A开始的句型。
一般来说,消除间接左递归要更复杂。思路就是先将非终结符标号,标为A1,A2,...,An。那么,对一个左递归非终结符A,就有如下推导
任何时刻句型的最左边都是非终结符,然后每次使用其中一个右侧替代。每一个非终结符都有一个标号,计作i1,i2,...,im,于是在推导中我们得到了这么一串数:i1,i2,...,im,i1。现在,如果我们没有任何包含jα(j≤i)的规则Ai→A,那这就是不存咋的,因为*i1<i2<...<im<i1*是不存在的。
现在就要消除这种形式的规则。我们从A1开始。对A1,要消除了只是直接递归的规则,我们已经看到了应该怎么做。接着轮到A2。每一个有着A2→A1α形式的产生式都要被替代成如下:
这里*A1*的规则为
这不可能产生A2→A1γ形式的新规则,因为我们已经消除了A1的左递归规则,而且αi都不为ε。接着,我们来处理A2的直接左递归规则。这样对A2的工作就结束了。同样,我们对A3到An进行处理,按照总是先替代Ai→A1γ,再Ai→A2δ等的顺序。我们必须按照这样的顺序,因为替换一个Ai→A2δ的规则可能会引入一个Ai→A3γ规则,而不是Ai→A1α规则。
6.5 深度优先(回溯)解析器
上一节叙述的广度优先方法有一个缺点,它会占用大量内存。深度优先方法也有一个不足,它的一般形式不适用于在线解析。然而,有很多应用场景下不需要在线解析,于是深度优先方法就比较有利,因为它不需要大量内存。
在深度优先方法中,当我们面临多种可能选择时,可以先选择一个暂时搁置其他可能。首先,充分地检查我们的选择所生成的结果。如果这个选项被证明是错误的(或者是部分成立的,但我们需要的是完全成立),就回滚我们的操作到重新选择的状态,然后用其他的选项继续。
让我们看看这个搜索技术是怎么应用于自顶向下解析的。我们的深度优先解析器遵循跟广度优先解析器一样的操作,直到遇到一个满足以下条件的选项:预测栈顶的非终结符有不止一个右侧。现在,要做的是选择第一个右侧,而不是建立一个新的解析栈/预测栈。这个选择通过在涉及到的非终结符添加下标1,来反映到解析栈上,跟我们在广度优先解析器那做的一样。然而这次,解析栈不仅仅用作记录解析过程,还用作回溯。
解析器继续这个过程,直到发生匹配失败,或者匹配到终止记号。如果预测栈是空的,那我们就找到了一个解析,它被解析栈的内容所描述;如果匹配失败,解析器会回溯。这个回溯包含如下步骤:首先,解析栈顶的所有终结符出栈,并依次推入预测栈。并且,这些符号中已匹配的输入将被移除,并添加在剩余输入的开始处。这是“匹配”步骤的逆操作。所以遍及终结符的回溯是通过向后移动垂直线来完成,就像图6.9那样。
那么有两种可能:如果解析栈空,就没有其他选项可以尝试,解析结束;或者,解析栈顶是一个非终结符,并且预测栈的栈顶对应了它的一个右侧。选择这个右侧将导致匹配失败。这种情况下,我们从解析栈弹出这个非终结符,用它来替换预测栈顶对应于它的右侧的部分,就像图6.10那样。
那又有两种可能:如果这是这个非终结符最后一个右侧,且我们已经尝试过它的全部右侧,那需要进一步回溯;如果不是,我们再次开始解析,然后首先用它的下一个右侧替换掉这个非终结符。
现在让我们尝试解析句子aabc,这次使用回溯解析器。图6.11一步步展示了这个过程;回溯步骤用B标注了出来。这个例子表露了回溯方法的另一个缺陷:它可能会做出错误的选择,并且要在很久之后才会发现。
就像这里展示的,解析过程会在解析被找到的时候停止。如果我们想找到全部的解析,那么当匹配到终止标记时,我们应该继续而不是停止。我们可以使用回溯来继续,就好像没有找到成功的解析那样,并且在每次匹配终止记号时记录下解析栈(它代表了解析过程)。最后,我们的解析栈变空,表示我们已经穷尽了所有可能,这时解析停止。
6.6 递归下降
在前面的小节里,我们已经见过了几种自动机的工作,在处理输入句子时用语法决定解析的步骤。现在有另一种方式的说明:这些自动机将语法作为程序。将一个语法看作解析器的一个程序并不像看起来那么牵强。毕竟,一个语法是推导其所描述的语言的句子的说明,我们在自顶向下解析中做的就是从一个语法重新推导一个句子。这跟将语法看作生成装置的传统观点的差别仅在于我们现在在尝试重推导一个特定的句子,而不是任意句子。以这种方式看,语法就是程序,以一种描述性的编程语言写成——就是说,这种语言定义结果而不是获得结果需要进行的步骤。
如果想为一个给定的上下文无关语法写一个自顶向下解析器,我们有两种选择。第一种是按照前几节里描述的自动机写一个程序。这个程序可以以一个语法和一个句子为输入。这听起来很完美,而且易于实现。但是当需要解析器在识别了部分输入的同时在做些别的操作的时候,就有些问题了。比如,当处理一个声明序列时,编译器须要生成符号表。基于此以及效率的考量,于是就有了第二个选项:对给定语法写一个专用的解析器。有很多这样的专用解析器,其中的大多数都用了一个叫做递归下降的实现技术。我们将假设读者具有一定的编程经验,对程序和递归有所了解。如果完全没有基础,这一节可以跳过。本节没有描述新的解析方法,只描述了一个实现的技术,它常用于手写解析器和一些机器生成的解析器。
6.6.1 一个纯粹的方式
作为第一种方法,我们把语法规则视为识别其左侧的程序。
S -> aB | bA
上面这样的规则被视为识别S的程序。过程如下:
这与语法规则没有太大区别,但它看起来也不像一个程序。就像菜谱一样,上面不会告诉我们要削土豆皮,更不会教我们怎么削皮,所以有一些过程是被省略的。
当执行此类程序时,我们需要保留部分信息。首先,规则中有“当前位置”(指针)的概念。这个当前位置指向了下一个内容。当我们将规则视为程序执行时,程序计数器会自动维护当前的位置,它显示了我们当前在程序中的位置。接下来是输入句子本身。当使用回溯解析器时,我们通常将句子保存在一个全局数组中,句子中每个符号都有一个元素与之对应。这个数组必须是全局的,因为它包含的信息必须在所有进程中都可以访问。
然后是输入句子的当前位置(指针)的概念。当规则中的指针指向终结符,并且这个符号对应输入句子的指针的符号时,两个指针都前移一位。输入句子中的指针也是全局性的。因此我们将在全局变量中保存这一指针。此外,当开始一个规则时,我们必须记住输入句子的当前位置,因为我们需要它来执行“or else”条件句。这些必须从输入句子的同一位置开始。例如,从图Fig6.1语法的S规则开始,假设a与输入句子当前位置的符号匹配。当前指针向前一步,然后尝试B。对于B有一个与S相似的规则。现在假设B失败了。那么我们就要尝试S的下一个选择,并将输入句子中的指针退回到我们开始的位置。这就是回溯,与前面我们所见一样。
这一切都告诉我们如何用程序处理一个规则。但是,我们处理的语法通常都有多个非终结符,因此会有多个规则。当我们在程序中遇到一个规则的非终结符,我们就必须要调用该非终结符的程序,并且如果调用的程序执行成功,则返回当前调用并继续执行。我们实现这个自动性通过使用实现语言的程序的调用属性。
另一个我们还没注意到的细节,是我们必须记忆我们使用的语法规则。如果我们不记录它们,后续我们就无法知道这个句子是如何产生的。因此,我们用“Parse list”来记录它们,如果失败则会被移除出列表。每一个程序都必须保留自己在此列表中的索引副本,因为我们同样需要它来执行“or else”条件语句:如果选项失败,那其后续操作都将终止。
最后要填充的细节涉及到解析器的启动方式:通过调用起始符号的程序来启动。当该过程成功,且下一个符号是终结符时,接戏列表中留下的语法规则展示了句子的最左推倒。
现在我们看看上面描述的解析器是如何工作的。我们在以图Fig6.6为例,输入句子为abc。我们从S的调用开始,接下来是 #的检查,输入扩展为 #,以及仅由S的节点组成的Parse列表。
我们的管理程序分为三部分。“主动调用”部分是程序主动调用,每次调用都会有一个点(•)来表示当前位置。这部分的地步规则就是当前的活跃程序。“句子”部分代表句子,包括指示在句子中当前位置的位置标记。“解析列表”(“Parse list”)将用于记录我们使用的规则(不仅仅是当前活跃的规则)。此列表中的条目已经编号,并且“主动调用”的每一个条目在Parse列表中包含了它的索引。我们稍后将看到,对于选择错误选项后的回溯过程这是必须的。
最初只有一种可能性:在活跃调用中的当前位置表示我们必须调用S的程序,那我们来试试:
请注意,我们在S的调用中已经前移了位置。它现在表示的是当我们完成S的调用后应该开始的位置:点表示返回地址。现在我们尝试S的第一个备选项。这里有一个选择,因此输入句子的当前位置被保存起来。我们在图片中没有明确说明这一点,因为此位置已经出现在引用S的条目中的“句子”部分。
现在D的第一个选择已经尝试过了。a成功了且接下来b也成功了,因此我们就有了:
现在D的选项已经进行到最后了。这意味着D的程序调用成功并且有返回值。自爱更新上述条目的当前位置后,我们将该条目从活跃调用列表中删除。接下来,调用到C:
如上c成功了,因此C也是成功的,接着S也成功了:
现在#s可以匹配,因此我们找到了解析,且Parse列表部分表示了句子的最左侧推导:
S -> DC -> abC -> abc
此方式成为递归下降,“下降”指的是从上至下的操作路径,“递归”是由于每个非终结符都可以直接或间接(通过其他程序)调用自己。要强调的是,“递归下降”只是指执行方面,尽管是非常重要的一个方面。还有一点需要强调的是,上面描述的解析器是一个回溯解析器,独立于使用的方法。可回溯性是解析方式的一个属性,也不是实现方法。
上面的回溯方法是令人满意的,因为我们实际上将语法本身作为一个程序:实际上我们可以将语法规则自动转换为程序,如下所示。只有一个问题:上面讲到的递归下降并不总是起作用。我们已经知道它并不适用于左递归语法,但问题比这严重。例如,aabc和abcc就是无法识别但实际应该被识别的句子。解析aabc时在第一个a之后就卡住了,解析abcc时在第一个c之后会卡住。然而可以这样导出aabc:
S -> AB -> aAB -> aaB -> aabc
而abcc可以如下推导:
S -> DC -> abC -> abcC -> abcc
那么我们来研究一下我们为什么失败。稍加分析就能发现在解析aabc时,我们从来没有尝试过A->aA,因为A->a是成立的。同样在解析abcc时我们也没有尝试过C->cD,因为C->c是成立的。每当不止一个右侧能成立时,就会出现这样的问题,而每当右侧出现从同一个非终结符的另一个右侧可以生成一个字符串的前缀时,就会出现这种情况。之所以会出现这样的情况,是因为我们假定如果有成功的选项,那么它就是正确的选项。而当它不是唯一选项时,它也不允许我们回溯。那么当语法有ε规则时就会导致严重的问题,因为ε规则总是成立的。无法回溯的另一个问题是,我们无法得到全部的解析,当解析不止一个时(这对非确定性语法来说是极有可能的)。
我们当然需要改进。我们用以确定是否正确的标准显然是不完善的。回顾本节开头的回溯解析器,我们看到该解析器并没有这个问题,因为它不独立于上下文来做出选择。当一个选择最终能带来正确的解析时才能说明选项是正确的;并且即使选项最终可以成功,我们也要尝试其他的选项以便我们能得到全部的解析。在下一节中,我们将讲述一个可以解决上述所有问题的递归下降解析器。
同时,上述方法只适用于无前缀的语法。非终结符A时无前缀的,如果它不产生两个不同的字符串x和y,且一个是另一个的前缀。如果所有非终结符都是无前缀的那么该语法才是无前缀的。对于上述算法,这意味着在输入字符串中特定位置识别A的尝试永远也不会有多余一种方式能成功,因此当第一个识别失败后,不需要返回并尝试另一个选项,因为不会存在可以成功的选项了。
6.6.2 穷举回溯递归下降
在上一节中,我们看到我们必须小心不要过早做出选择;只有当能到达一个成功地解析结果时才应该被选中。现在,这个要求很难用递归下降解析器来表达了。那我们如何能得到这个可以告诉我们是否能带来成功解析的过程呢?原则上是有无限多个的,每个哨兵模式(预测)都有一个,且必须可以推导出剩余部分,但我们不需要把它们都写下来。然而在解析过程中的任意时刻,我们只能处理一个哨兵:即当前的预测,因此我们可以尝试在解析期间动态地为这种感知形式构建一个解析过程。一些编程语言为此提供了有用的工具:过程参数。通过过程参数,一个过程就可以接受另一个过程(甚至是同一个过程),通过参数传入并调用,或将它传给另外一个过程。
现在我们来看看如何在符号X的解析过程中使用过程参数。X的这个过程传递的是一个tail程序,作为解析句子剩余部分的,即X后面的部分。这个过程被称为延续,因为这体现了要完成的工作的延续。因此X(tail)的调用将解析整个X,通过先解析X然后在调用tail来解析剩余部分。这是所有非终结符所采用的方式,同时到目前为止也适用于终结符。
对于终结符a的解析相对简单:只需尝试将当前输入的符号与a进行匹配。如果成功了,那么位置前移一位,并调用tail参数;然后当tail有返回时,则恢复输入位置并返回。如果失败那么直接返回。因此a的抽象代码如下图,其中输入存放在数组text中,输入位置存放在变量tp中。
非终结符A的解析过程相对复杂。最简单的情况是A → ε,它是通过对tail的调用实现的。其次简单的情形是A → X,其中既不是终结符也不是非终结符。为解决这种情况,我们必须记住,我们假设对X有一个解析程序,因此这个程序的实现包含了对X的调用,以及tail参数。
在下一种情况是A → XY。X的解析程序需要一个“X之后有啥”的解析程序作为参数。而此处这个入参程序内置于Y及其tail程序中:我们在这两者之外重新创建的一个新的程序。这本身就是一个简单的程序:它调用Y,以tail为参数。如果我们调用Ytail,那我们可以通过将Ytail作为参数调用X来实现A的解析程序。因此规则A → XY的抽象代码为:
最终,如果右侧包含两个以上的符号,那这个过程就得递归进行:对规则A → X1X2 ···Xn,我们创建一个程序来调用X2 ···Xn及tail,而tail又需要一个调用X3 · · · Xn及其tail的程序...因此规则*A → X1X2 ···Xn*的抽象代码如下:
此处的X2···Xntail和X3···Xntail等只是新程序的名称。我们看到在程序Xn在开始时的预测栈是由X1, X2 ···Xn和tail的调用来展示及编码的。
最终,如果我们有一个具有n个选项的非终结符,即A → α1|···|αn,那么A的解析程序就有n个连续的代码段,根据上面的抽象代码。它们都在最底层调用tail。
如果将此技术应用于所有的语法规则将会产生一个解析器,只是我们还没找到入口在哪。这很容易想到:我们只需调用起始符号的程序,并将结束标记作为参数传入。
结束标记的程序与其他是不同的,因为这是最终确定我们的尝试是否成功的程序。它需要测试我们是否已经到达输入的末尾,是则返回我们已经找到了一个解析;由于它没有参数,因此不会在继续调用其他tail。它的抽象代码如下:
规则A → X1X2 ···Xn的抽象代码,声明了辅助程序X2 ···Xntail到Xntail是作为A的本地程序的。这是必须的,因为tail必须可以从Xntail访问,而唯一可从中访问tail的作用域位于A的程序内。因此要在实践中使用这种编码技术,我们就需要有一种可以在本地运行并允许其作为参数传递的编程语言;不幸的是当今世界几乎没有这样的语言存在。唯一存在可能的是GNU C以及函数式语言。GNU C是C语言中一个被广泛使用的扩展,并在下面的代码中使用;函数式语言编写的解析器将在17.4.2节中介绍。该技术也可以在没有本地程序的语言中使用,但需要一些技巧;见问题6.4。
列表6.13和6.14为图6.6的语法提供了一个穷举回溯递归下降解析器,用GNU C编写。该程序有一种机制来记忆所使用的规则(列表6.14中的程序pushrule()和poprule()),因此可以将每个成功地解析打印出来。例如规则B->bBc的代码为:
我们也使用的GNU C的特性来混合声明和语句。
图6.12展示了此程序的会话。注意对于错误输入abca是没有给出错误信息的;最终解析器找到0个解析。
由此可见,我们可以通过解释语法来执行递归下降,如6.6.1节,或生成代码并编译来实现,如6.6.2节。将他们区分一下,第一种方式称为解释递归下降,第二种方式称为编译递归下降。
6.6.3 广度优先递归下降
Johnstone和Scott [36]提出了一种不同的穷举递归下降方式,称为广义递归下降解析(Generalized Recursive Descent Parsing (GRDP))。与6.6.1节的本地程序一样,它为每个非终结符提供一个单独的解析程序。但是非终结符A的GRDP程序不是在找到匹配项后立马返回(这就是导致本地程序失败的原因),而是持续跟踪所有的匹配项,最终返回从当前位置开始并且匹配A输入长度的所有匹配项集合。如果没有找到匹配项,则集合为空。
此类程序的调用者(可能正在处理A的右侧)必须为此做好准备,并在尝试处理右侧的其余部分时依次处理每个长度。最后,起始符号程序的调用者应该检查输入的长度是否包含在返回集合中。
有以下语法:
SS --->A a b
A--->a A a | ε
以及输入字符串aaaaaaab,A路由的调用者S将返回长度0,2,4,6,而只有长度6能使得S可以解析余下的ab。A路由中的事件更为复杂。在匹配第一个a之后,程序将会调用自身返回长度0,2,4,6。它将逐一尝试每个长度去匹配a;0,2,4可以成功匹配,而6则失败了。匹配后将产生长度2,4,6。A--->ε选项带来了长度0,而返回给S的集合为{0,2,4,6}。
每个程序都返回所有可能的匹配项这一事实,使得我们将此方法称为广度优先,尽管该方法也有深度优先的属性。因为在检查下一个右侧符号之前,总是会先深入查询非终结符当前的右侧符号。
该方法适用于所有非左递归CF语法,并可以进行优化,以便与LL(1)语法或非左递归LR(1)语法解析器进行对比。该方法在免费提供的解析器生成器中实现;见Johnstone与Scott [363]。
6.7 有限子句语法
在6.6.1和6.6.2节中,我们了解到了如何创建保留大部分语法原始结构的解析器。编程语言Prolog为我们提供了更进一步的可能。我们先简单介绍一下Prolog,然后解释如何使用它来创建自上而下的解析器。更多有个Prolog的资料,请参阅The Art of Prolog,Leon Sterling和Ehud Shapiro (麻省理工学院出版社)编著。
6.7.1 Prolog
Prolog以逻辑为基础。程序员会声明对象及其关系,并了解与之相关的内容。Prolog系统通过内置的搜索和回溯机制来解释这些问题。例如,我们可以通过写字来告诉Prolog系统桌子和椅子是一种家具:
furniture(table). furniture(chair).
接下来,我们可以试着问Prolog,面包是家具吗?
| ?- furniture(bread).
答案是“否”。但是下面这个问题:
| ?- furniture(table).
答案是“是”。这种可以得到是或否结论的Prolog形式被称为predicate,当它被作为搜索关键字时,则被称为goal。
我们还可以使用变量,这些变量可以实例化(赋值)也可以不实例化;这种变量称为逻辑变量。逻辑变量的命名规则为大写字母或下划线( _ )开头作为标识。例如我们可以这样使用它们:
| ?- furniture(X).
这是在对逻辑变量X进行实例化。Prolog系统将会搜索可能的实例并响应:
X = table
然后,我们可以通过键入一个RETURN来停止搜索,也可以键入一个分号来继续(然后再键入RETURN)。后一种情况下Prolog系统要返回X的另一个实例。这样一个为已知因子寻找相匹配的逻辑变量实例的过程称之为推理(inference)。
并非每个例子都像上面列举的那么简单。例如,如下这样一个Prolog子句包含了古董家具的信息:
antique_furniture(Obj,Age) :- furniture(Obj), Age > 100.
这里我们看到有一个对象,是两个目标的结合:一个对象Obj和一个年份Age是属于古董家具对象的一部分,即如果它是一件家具,并且年份大于100年,则是一件古董家具。
Prolog系统中一个重要的数据结构是列表,列表长度不需要初始化。空列表用 []表示;[a]则是头部为a尾部为 []的列表;[a,b,c]是头部为a尾部为 [b,c]的列表。另一个有用的数据结构是复合值,其中是一组固定数量的数据组合在一个命名实体中。例如dog(“Fido”,brown)。其中的数据项可以是逻辑变量。
6.7.2 DCG格式
许多Prolog系统允许我们以不同于普通Prolog子句的格式来识别语法。由于Prolog子句有时被称为限定子句,因此这种格式的语法被称为限定子句语法(Definite Clause Grammar),通常缩写为DCG。图6.6格式的DCG语法如图6.15所示。每个非终结符都有一个DCG对象,每条语法规则都有一条DCG子句。由于Prolog中的预测词名称必须与小写字母开头,因此我们需要将S等非终结符转换为s_n类似这种,即“S-non-terminal”。终结符则表示为一个列表元素。
Prolog系统将这些DCG规则转换为Prolog子句。其思想是使得非终结符A的每一个DCG规则都对应一个Prolog规则,该规则都有两个列表类型的逻辑参数,传统上称为Sentence和Remainder,规则如下:
A_n(Sentence, Remainder):- ...
以上规则表示字符列表Sentence与此规则生成的任何A的字符串列表Remainder相等。
更具体的说,DCG规则**d_n-->[a],[b].**与以下Prolog子句
d_n(S,R) :- symbol(S,a,R1), symbol(R1,b,R).
其中我们将Sentence缩写为S,Remainder缩写为R。预测词**symbol()**定义为:
symbol([A|R],A,R).
这是Prolog预测词定义的一种形式,其中条件仅在于参数的匹配:当S可以分为两部分A和R时,预测试symbol(S,a,R1)就成功了,此时A匹配a,R匹配R1。简而言之,当有R1时,则S=aR1。同样的,预测词symbol(R1,b,R)也是为了找到R,这样就有R1=bR。它们一起就得到了S=abR,这正是DCG规则**d_n-->[a],[b].**所表示的:
该技术可以扩展到一个以上的中间逻辑变量中,例如,d_n的第二个DCG规则的转换中:
d_n(S,R) :- symbol(S,a,R1), d_n(R1,R2), symbol(R2,b,R).
Prolog需要找到两个逻辑变量R1和R2的实例,由此S=aR1, R1=P(d_n)R2且R2=bR,其中P(d_n)是d_n的任意终结符生成的。当我们组合这些方程时,我们获得了如上所述的d_n(S,R)语义:S=aP(d_n)bR。(大多数Prolog处理器内部使用的是可读性差得多的格式。)
6.7.3 获取解析树信息
图6.15中的DCG程序是一个识别器而不是解析器,但逻辑变量让它也可以很轻松的获取解析树的信息。因此我们为DCG程序中的非终结符提供了一个逻辑参数,即它构建的树。树中的节点可以直接用复合值表示,整个规则(引号之间的部分)作为名称,子规则作为其组件。因此带有子规则X和Y的规则S--->AB可以表示为 ’S--->AB’(X,Y)。右侧的token不产生解析树,因为它们已经出现在规则名称中了。
由于非终结符A的子节点的解析树是由A的DCG规则右侧的子元素传递的,因此要获得A要传递的正确解析树,我们要做的就是从规则名称和它子集的解析树创建一个复合值。结果如图6.16所示。它很大程度上依赖于Prolog的能力,即推迟值得实例化,直到实例的源可用。
6.7.4 运行有限子句语法程序
图6.16的DCG程序可以加载到Prolog解释器中,然后我们就可以如上述那样提交查询了。在编写这些查询语句时,我们应该了解到语法的根S对应DCG的名称s_n(T),其中T是解析树;而对于一个Prolog来说则是一个预测词,其中S是子句而R是剩余部分。这里介绍的程序是运行在C-Prolog1.5版本上的。
首先我们希望DCG程序能生成一些句子及其解析树。为此我们将两个未实例化的变量S和N传递给s_n,并请求系统查找三个实例(用户输入用下划线区分计算机输出):
我们看到系统只会生成一个句子S,就是以a开头后面是b然后是不断增加的c。Prolog系统使用深度优先搜索算法机制,而这并不适合用于生成句子(参见第2.4节,我们使用广度优先方法)。T的值展示了其对应的解析树;每一个的顶部都是规则S--->DC,以及描述其两个子节点的组件。需要注意的是,在这里S--->DC是作为名称使用,因此S--->DC(D--->ab,C--->cC(C--->c))应该描述为'S->DC'('D->ab','C->cC'('C->c')),对应的解析树是
接下来,我们让系统识别一些句子;我们就用6.6.1节的例子abc来试试:
完整的回溯解析器会纠正对输入句子的解析,同事还提供第二种解析方案,这是普通回溯解析器所没有的。第三个no则说明没有其他可能的解析存在了。
最后我们试试普通回溯解析器没有解析成功的字符串,aabc和abcc:
可以看到两个输入字符串都得到了正确的解析。它们确实都涉及了第一个例子中的A--->a和A--->aA两种规则,以及第二种的C--->c和C--->cC。
这些运行结果表明,有限子句语法可以很好的帮我们识别句子,并在一定程度上也可以用来生成句子。
Cohen and Hickey [26]更详细的解释了Prolog在其他方面的解析器的作用。
6.8 取消解析
我们反复指出,自顶向下解析不能处理左递归。这个问题是由其他想要执行递归操作的任务引出的,例如图像搜索中的环形路径可能导致无限循环。此问题的标准解决方案是使用一个集合B用来保存已经访问过的节点,当再次访问到B中存在的节点时程序则退出。集合*B(Busy)*的存在就是表示在处理一个子问题时,在开始处理的同时也会去检查其是否已经处理过,以避免进入死循环中。
6.8.1 取消集
“繁忙”集(B集)的概念使得DCG解析左递归语法成为可能(Nederhof [105])。非终结符A的每一个Prolog生成词都附带一个逻辑参数CancellationSet,除了初始Sentence和Remainder。取消集包含在左递归中已经处理过的非终结符的名称,并在图像处理中充当“已使用”集合。通过使用这些集合就得到了取消解析。
处理非终结符A的规则要做的第一件事就是检查A是否处于取消集中,如果是则退出。这可以有效防止陷入左递归循环,但这也同时让A的规则不会识别任何涉及左递归的终结符生成。更准确的说,它只能识别A产生的子树,且A位于树的非根节点的左枝丫上。如果不存在这样的子树,那A就什么都不会生成。因此面对A在树左侧出现不止一次的情况,则必须有相应的解决方案。(树的左侧枝丫代表访问从根节点开始访问的顺序,从根节点开始依次往下访问最左侧节点。有关详细介绍在10.1.1.1节)
这个问题的解决方案很简单也很巧妙:一旦识别到一个顶部有A的子树T,就将这个A重定向为一个终结符*,并传回输入流中。实际上我们从输入流中获取终结符 A,然后将其用*替换。现在就剩下改造解析器的剩余部分了。我们下节将介绍如何处理这个问题。
6.8.2 转换方案
我们假设Prolog DCG系统有可能通过将DCG规则放入一对大括号来把它和“正常”Prolog文本穿插在一起,并且当 member(E,L)中E是L的元素时底层Prolog系统可以成功定义E。非终结符A到一组DCG取消规则的转换方案由三个部分组成,一个是用于A → Bα形式的规则,一个是用于A → tα形式的规则,以及一个用于处理**的特定规则:
此时,我们假设α不为空;A → ε的并发形式我们下一节再讨论。
这个转换方案包含了很多微妙之处。逻辑参数C是取消集,并且目标**not member(A,C)**暗含了A是否属于取消集的测试。右手侧的转换遵循第6.7.2节中所示的CF-DCG转换,除非所有非终结符都将取消集作为参数。如果右手侧以一个非终结符作为开头,则该非终结符会有一个与A扩展所得的一样的取消集;而其他非终结符都会得到空的取消集。
DCG形式的untoken(bar(A))将*的一个副本推回输入流。它的工作原理如下:Prolog谓词untoken*定义如下:
untoken(T,S,[T| S]).
DCG处理器会将DCG应用程序untoken(a)开发成Prolog式的untoken(a,Sentence,Remainder)。因此调用untoken(a,Sentence,Remainder)将会给 [a| Sentence]设置Remainder,因此在输入的其余部分的前面加上a。
在A(C)的模式这点上我们已经识别了一个Bα或tα,并将其简化为*后推回;因此就输入而言我们没有取得任何进展,反而依然有一个A需要解析,但这就是调用方所想要的。这个解析过程必须能从输入中获取并将其合并到解析树中。有两个备选项:A 的左递归规则以及 A(C)的调用者。第一种情况下,将在左支构建一个新的A节点;在第二种情况下,A节点的左支就在此处结束。Prolog系统必须同时考虑到这两种情况。这可以通过引入Ax(通过A的所有左递归规则和 来定义)来实现。 此外,为了允许激活新的做递归规则,必须在不显示调用A的情况下调用Ax*。所以我们现在的转换方案是:
其中 *Al*代表 A的所有左递归规则,以及 An代表A的所有非左递归规则。
只要输入前面有一个 ,唯一可以往下进展的规则就是可以吸收 的那些规则。其中之一是 Ax(c)-->Al(c) ,另一个则是第一种模式下的 B。这个B通常与A相等,但如果A是间接左递归那么则不一定是相等的;在这种情况下,对B的调用实际上是调用Al。如果B等于A,则它在转换过程中的替换必须能够吸收 ,且必须能继续解析A的非左递归实例。所以我们还需要一个Ab,定义如下:
一开始围绕在我们周围的谜团可以通过一个简单的观察来解决:我们可以将A的非左递归规则加到 A中,而左递归规则加到Ab中,两者都不会影响解析器工作,原因如下。A的非左递归规则永远都不能吸收 ,因此将它们加到Ax中最多导致调用失败;并且对A的左递归规则的调用将会被Ab的左递归检查堵塞。因此Ax和Ab都变成了At,定义如下:
除了简化转换方案外,这还解决了确定哪些规则是左递归规则的需要。
简化后的转换只留下了A和 At,其中As只能出现在右手侧的非第一个位置。在这些位置,As都可以用Ats替换而没有任何影响,因为唯一的区别是At可以接受 ,而 s不会自行出现在输入中。所以最终在转换模式中只留下了Ats,这意味着可以重命名为A。这就回到了本节开头的转换方案。
图Fig 6.17展示了图Fig4.1的简单数学表达式语法的结果取消解析器。注意:
expr([expr|C]), [’+’], term([]),,第一个expr是代表DCG,第二个expr只是一个常量,用来添加到取消集中。
我们不是在逻辑变量中构建解析树,而是使用print语句来生成它;由于这些被放置在识别的末尾,因此解析树是按自底向上的方向生成的。使用查询 expr([],[i,’×’,i,’+’,i,’×’,i],[]) 运行此DCG取消解析器会产生以下反向最右派生:
捐献列表
昵称 | 金额 | 日期 |
---|---|---|
独孤影 | ¥10.00 | 2016/10/10 |
luoyinjiexx | ¥10.00 | 2016/10/10 |
Hanruis | ¥10.00 | 2017/05/10 |
yetone | ¥10.00 | 2017/06/22 |
yetone | ¥10.00 | 2017/06/23 |
Laily | ¥10.00 | 2017/07/06 |
飞龙 | ¥20.00 | 2017/07/17 |
关大龙 | ¥1.68 | 2017/07/17 |
liman1008 | ¥2.00 | 2017/07/18 |
春晓 | ¥10.00 | 2017/08/07 |
匿名 | ¥50.00 | 2017/08/12 |
匿名 | ¥10.00 | 2017/11/18 |
victor_xie | ¥20.00 | 2017/12/28 |
匿名 | ¥20.00 | 2018/01/13 |
奕普 | ¥10.00 | 2018/03/04 |
匿名 | ¥10.00 | 2018/03/15 |
匿名 | ¥10.00 | 2018/04/10 |
jello | ¥20.00 | 2018/05/07 |
Suzu | ¥10.00 | 2018/05/29 |
幅川 | ¥8.00 | 2018/06/20 |
华子 | ¥50.00 | 2018/06/27 |
匿名 | ¥10.00 | 2018/06/27 |
*杨 | ¥20.00 | 2018/09/13 |
江南四大才子 | ¥10.00 | 2018/10/17 |
*旭生 | ¥18.88 | 2018/11/28 |
forPandaria | ¥20.00 | 2019/2/24 |
匿名 | ¥10.00 | 2019/03/07 |
zhi | ¥20.00 | 2019/04/01 |
Rainesli | ¥50.00 | 2019/04/02 |
fangfang | ¥50.00 | 2019/04/10 |
匿名 | ¥10.00 | 2019/04/15 |
Kris | ¥18.88 | 2019/05/04 |
RexLee | 生蚝一只 | 2019/05/04 |
逸飞 | ¥18.88 | 2019/05/26 |
匿名 | ¥10.00 | 2020/07/04 |
joker | ¥50.00 | 2020/07/13 |
匿名 | ¥5.00 | 2020/10/24 |
uestc-zyj | ¥20.00 | 2020/12/19 |
冇创意 | ¥20.00 | 2021/03/31 |
岑 | ¥10.24 | 2022/06/29 |
SillyMem | ¥12.00 | 2022/10/31 |
8*t | ¥50.00 | 2022/11/24 |