假设读者熟悉可以执行的典型操作,例如创建单元格或标头、从列表中插入和删除单元格以及返回包含在指定单元格中的数据。可以通过创建集合 S 中所有元素的链表来实现字典。将三个字典操作编译为列表操作很简单。如果假设链表是在计算机的 RAM 模型中实现的,那么我们就有了一个现实的运行时间概念。我们可以为列表单元格上的每个基本操作分配一个时间单位,因为在 RAM 上,每个操作都需要恒定的时间。这一观察结果让我们将运行时间的RAM概念提升到运行时间的链表概念,然后提升到字典级别。但这不是个好消息,平均而言,我们必须至少走到列表的一半,通常一直到最后,才能实现任何字典操作。因此,单个字典操作的运行时间与当时集合 S 的大小成正比。另一种易于理解的实现字典的抽象类的方法是使用搜索树。当三个字典操作的算法保持树平衡时,例如AVL 树或红黑树,每个操作的运行时间与操作时集合 S 的大小是对数关系。但是通常首选的实现字典的抽象是哈希表。1.5 哈希抽象 哈希的数据模型包括以下内容:
全集 U。
哈希桶数 B,从 0 到 B-1 编号。
从 U 到 {0,1,…,B–1} 的哈希函数 h。每个哈希桶 b 是全集 U 的元素 x 的子集,使得 h(x)=b。
通常的操作是计算h(x),其中x是U的一个成员,并在编号为 h(x) 的哈希桶中插入、删除或查找 x。例如,哈希表的插入操作将表示为 h-insert (x, b),其中 b = h(x)。哈希程序包括交替计算一些 h(x),然后对 x 和哈希桶 h(x) 执行三个操作中的某一个。将字典程序编译成哈希程序很简单。例如,字典操作insert (x) 被翻译成b: = h (x); h-insert (x, b)。哈希与机器的距离有些远,我们无法直接使用它来确定运行时间。存在的问题是,哈希法相当独特,因为最坏情况下的性能,即集合中的所有元素都在同一个哈希桶中,比我们对所有可能的哈希函数进行平均时的平均情况要差得多。为简单起见,应该正确地假设,在平均情况下几乎所有的哈希桶都包含接近平均数的元素,即S/B。但即使同意只讨论平均情况,仍然不知道对一个元素和哈希桶的每个操作需要多长时间。本质上,每个哈希桶本身就是一个小型字典,所以我们必须决定如何实现它的操作。如果 S 的大小保持在 B 的数量级,我们可以使用哈希桶的链表实现,并期望每个操作在 RAM 或真实机器上平均花费 O(1) 时间。但是,如果 S 比 B 大得多,则表示哈希桶的列表的平均长度为 O (S/B)。这仍然比每个操作的时间复杂度为O (S) 要好。然而,当 S 太大而无法放入主存时,RAM 模型不再适用,我们就需要考虑另一种计算抽象。1.6 二级存储抽象 作为 RAM 计算抽象的替代方案,在 O(1) 时间内可以访问任何数据片段,我们可以在多个级别引入访问局部性。我们将只讨论一个具有基于磁盘的辅助内存的抽象,其中大数据块(比如64KB)作为一个整体在磁盘和主存之间移动,且必须在主存中读取或写入数据。与在主存中对数据本身进行的典型操作的成本相比,在主存和辅助内存之间移动数据块的成本高昂。因此,在这种新模型中,将运行时间简单地视为磁盘I/O的数量是合理的,即一个数据块从辅助内存移动到主存的次数,反之亦然。在底层机器的二级存储模型中,实现哈希表的最佳方法与使用 RAM 模型的首选方法有些不同。特别是,每个哈希桶将由一个或多个完整的磁盘块组成。为了利用局部性,希望典型的哈希桶由尽可能少的磁盘块组成,但希望尽可能使这些磁盘块充满。因此,假设主存能够容纳全集中的M个元素,而磁盘块能够容纳P个这样的元素。然后希望哈希桶的数量 B 为 B = M/P,那么就可以在主存中为每个哈希桶保存一个磁盘块,并且该磁盘块可能会近乎充满。随着集合S的大小增加,我们使用磁盘块的链表来表示每个哈希桶,只有第一个哈希桶在主存中。最坏的情况下,这三个字典操作需要检查单个哈希桶中的所有磁盘块。因此,平均而言,预计每个操作的磁盘I/O数为O(S/BP),因为S的元素将在B个哈希桶中大致平均分配,将单个哈希桶的元素每隔P个划分成一组,放入一个磁盘块中。由于B=M/P,每个操作的运行时间为O(S/M)。
在这个简写法中,像a-z这样的表达式表示 a 到 z 之间带有ASCII 码的单字符串的并集。因此字母的正则表达式在最初的三个运算符集合中:a+b+…+z+A+B+…+Z类似地定义数字,然后将标记<id>的字符串集定义为字母后跟0个或多个字母和/或数字串组成的字符串。2.1.1 Lex程序产生之前:书目检索从理论研究中可以很好地理解,正则表达式抽象可以编译成几种抽象实现之一,例如确定性或非确定性有限自动机(NFA和DFA)。然而,当需要解决实际问题时,仍有一些技术有待突破。贝尔实验室在首次尝试自动搜索相关文献时采取了一个有趣的步骤:他们在磁带上保存了整个贝尔实验室图书馆的标题,并且开发了软件来获取关键字列表、找到包含这些关键字的文档。然而,当给定一长串关键字时,搜索速度很慢,因为它每搜索一个关键字就会遍历一次磁带。
图2. 表达式 a + b * c 的语法树2.2 上下文无关文法和语法分析 编译器的第二个阶段,语法分析器或「解析器」将词法分析器生成的标记序列映射为树状表示,从而明确标记序列中的语法结构。一种典型的表示是语法树,其中每个内部节点代表某个结构,该节点的子节点代表该结构的组件。例2.3 语法分析器可以将标记序列 a+b*c 映射成如图2所示的语法树。这里,E代表一个表达式。操作数a、b和c本身就是表达式。但b*c也是一个表达式,由运算符标记*和两个表达式b和c组成。在根部,我们看到另一个表达式,这个表达式使用运算符+和两个操作数表达式a和b*c。遵守有关运算符优先级的许多约定很重要。通常,乘法优先于加法,这就是为什么语法树在加a之前将b乘以c,而不是先将a和b相加。给定编程语言所需的语法树结构通常由声明性抽象定义,即上下文无关文法(context free grammar,CFG),希望读者熟悉此概念。CFG 是称为产生式规则的集合,提供了从其他句法类别和终端(句法分析器生成的标记)构造各种语法类别(如表达式或语句)的方法。例如,如果 E 表示该语言的良构表达式的语法类别,那么我们可能会找到如下规则:,这意味着一种构造表达式的方法是在两个较小的表达式之间放置一个加号。 2.2.1 LR(k)语法分析 在20世纪60年代,有一系列关于如何从CFG构造高效语法分析器的提议。人们认识到,对于通用编程语言,只要语法具有某些属性,就可以对程序进行一次从左到右的扫描,而无需回溯,并根据该语言的语法构建语法树。有些决定很棘手。例如,在处理表达式a+b*c时,仅读取a+b后,必须决定是否将表达式a和b与加号组合成更大的表达式。如果向前看一个标记并看到*,就会知道把a和b结合起来是不正确的,但必须继续前进,最终把b和c结合起来。只有在此基础上,把a和表达式b*c结合起来才是正确的。这种语法分析方式称为「移位-归约解析」。在扫描输入时,每一步都需决定是通过将下一个输入标记推入堆栈来移动它还是对堆栈顶部的符号进行归约。归约时,归约的符号必须在CFG的右侧。这些符号出栈后被替换到同一产生式的左侧。此外,为产生式左侧的符号创建语法树节点。它的子节点是刚刚出栈的符号对应的树根。如果一个标记出栈,它的树只是一个节点,但如果一个语法类别出栈,那么它的树就是之前为堆栈上的符号构造的树。Don Knuth提出了LR(k)语法分析,适用于最普遍的语法类别,对输入进行单次从左到右扫描,使用移位-归约范式并查看输入前面的最多k个符号后可以正确解析。这项工作似乎解决了语法分析器应该如何构造的问题。然而,并非每个CFG,甚至每个典型编程语言的CFG,都满足成为任何 k 的 LR(k) 文法所必需的条件。虽然普通编程语言似乎确实有LR(1)语法,即仅使用输入上的一个先行符号就可以进行移位-归约分析的语法,但这些语法的设计相当复杂,通常比直观需要的语法类别多出一个数量级。2.2.2 Yacc语法分析的生成器 因此,在 Knuth 的论文之后,有几次尝试寻找使用 LR(1) 解析的方法,但要使其适用于更简单的 CFG。我们受到普林斯顿大学的一位研究生 Al Korenjak 的启发,他的论文是关于压缩 LR(1) 解析器的方法。我们茅塞顿开,对于通用语言,可以从一个非LR(1)的语法开始,仍然为该语法构建一个从左向右的移位-归约解析器。当语法不是LR(1)形式时,在某些情况下,我们也可以使用两种不同的产生式进行归约和移位或只进行归约。但是我们可以通过考虑运算符的优先级并在输入中向前看一个标记来解决实际情况中的歧义。例2.4 考虑例2.3中所提及的情况。在处理输入a+b*c的a+b之后,堆栈的顶部将有E+E,其中a和b之前都被简化为表达式。存在产生式 E → E + E,可以将 E + E 归约成一个 E,并用标签 E 和子式 E、+ 和 E 构建解析树的一个节点。但是 * 优先级高于+, 我们看到 * 作为下一个输入符号,这说明将 * 移到堆栈上是正确的。稍后,我们也移动 c 并将 c 归约为表达式 E。此时堆栈顶部有 E + E * E。我们正确地将前三个符号归约成 E,留下 E + E。现在,将这些符号归约成 E 是正确的,因为没有任何内容输入(或者还有其他不属于表达式部分的输入,例如结束语句的分号)。通过这种方式,我们将生成如图 2 所示的语法树。我们在贝尔实验室的同事 Steve ohnson 采纳了这个想法并实现了一个名为 Yacc的语法分析的生成器。为了帮助解决移位和归约操作之间的歧义,或者两个不同产生式的归约之间的歧义,Yacc 根据产生式出现的顺序进行判断。在两个产生式都可以归约的情况下,无论哪个产生式首先出现都是首选的。为了解决移位和归约之间的冲突,假设在 Yacc 输入文件中首先出现的运算符优先。Yacc很快成为了编译器实现的重要工具,编译器不仅指传统编程语言的编译器,而且包含许多用途更有限的“小众语言”的编译器。与 Lex 一起,Yacc 提供了一种试验新语言句法结构设计的简单方法。这两种工具贯穿学术界整个学期的编译器课程,学生在课程中设计并实现一种新的领域特定编程语言。
图3. 两种关系:Cities (City, State) and States (State, Country, Pop)。为关系模型选择编程语言是一件趣事。Codd 可以将关系模型视为嵌入在通用语言中的基本抽象,如树或图。关系语言的操作是简单的导航步骤,例如「在给定的行和列中查找值」或「给定一行,查找下一行」。事实上,早期的数据库抽象,例如网络和层次模型,正是采用这种方法。但Codd的观点是一种声明性的抽象,随着编程语言的发展,这种选择一直在跟进,有助于使关系模型成为数据库管理的主要方法。在最初的表述中,关系模型的编程语言被认为是非递归的一阶逻辑,或者等价于五种代数运算的集合,即并集、差集、选择、投影和连接,称为关系代数。最后三种运算可能比较生疏,定义如下: