从离散数学到编译原理--百姓网编程题后序
2010-05-11 11:08 SeasonLee 阅读(11251) 评论(35) 编辑 收藏 举报前言
那天在赖总的blog上面看到百姓网的一道公开编程题,觉得很有意思,赖总如教科书般地用高效又简洁的python,
完成了这道题,只用了300多行(含注释和单元测试代码)。而笔者最近一次遇到这样的编程题可以追溯到去年的腾讯校园招聘,
PS去年的腾讯笔试题远不如迅雷的二笔题难。一时心血来潮,便开始做这道题目,完成的代码已经发给王建硕先生。
以下内容是假设读者有一定的离散数学基础,并且对编译原理有认识,有一定的程序设计经验。当然没接触过这些知识的读者
也同样可以在本文中学到许多新颖的东西,设计思路,还可以重温大学的知识,不过这需要读者在遇到不懂的地方多多参考google的帮助以及维基百科。
限于笔者水平,本文中的疏漏和错误在所难免,欢迎批评和指正。同时也希望能与您进行更多深入的交流,请联系我myonlylee@gmail.com或者@season_lee。
注意:本篇中用到的数学推导,已经在笔者新的一篇博文中详细说明,请点这里。
原题
首先让我们重温一下题目。
题目
请写一个算法,判断一个查询表达式是不是另外一个查询表达式的子集。
就拿SQL语句为例,如果查询A是
age > 21
查询B是
age > 20
我们知道,A所代表的集合是B所代表的集合的子集。凡是满足A的条件的记录,也一定满足B的条件。
我需要一个算法,在给出任意两个表达式以后,判断一个是不是另外一个的子集。
if($queryA->isSubSet($queryB)) { echo("A is subset of B");}
为了简单起见,只需要实现最简单的AND, OR逻辑操作,大于,等于,小于三种比较操作就好。
解题
看到题目之后,很容易联想起大二时学到的离散数学的知识,集合、命题、函数等概念。如果把这道编程题当成数学题来做的话,
估计30分钟不到就可以写出完整的证明过程出来,而用编程的角度来做的话,就是相当的繁琐了。首先你的程序应该要识别出表
达式的基本符号(如“and”、”>”等),还要规定表达式的语法(如“age>21 是正确的语法”,而”age>age是错误的语法”),最后还得
定义表达式的语义(如“age>21”是”age>20”的子集)。好吧,其实我想说的是,这道题除了考你离散数学的知识,还考到了编译器的
前端技术(词法器、语法器和语义器)。下面将详细分析解题的一些思路。
大二的离散数学
如判断集合A是集合B的子集?下面引用wiki上面给出的子集定义。
定义
集合A,B,若∀a∈A,有a∈B;A⊆B。则称A是B的子集,亦称A包含于B,或B包含A,记作 A⊆B。
单一集合的子集判断
举个例子:
A = {x∈R|x>100}
B = {x∈R|x>0}
A表示大于100的实数,而B表示大于0的实数。很显然集合A是集合B的子集。
复合集合的子集判断
A = {x∈R|x>100}
B = {x∈R|x>0}
C = {x∈R|x>50}
D = B ∩ C
D本身是集合B与集合C的交集,如要判断A⊆D是否成立,则需要证明:
(A ⊆ B) ∧ (A ⊆ C)
很显然A ⊆ B与A ⊆ C都是真命题,所以A⊆D,记作:
A⊆D ⇔ A⊆(B ∩ C) ⇔ (A ⊆ B) ∧ (A ⊆ C)
关于本文的数学符号约定:使用∧表示逻辑与,用∨来表示逻辑或,∩表示取集合的交集,∪表示取集合的并集。
接下来读者可以通过逻辑推理,甚至使用真值表来得到下面复合集合的等价关系:
原命题 | 等价命题 |
A ⊆ (B ∩ C) | (A ⊆ B) ∧ (A ⊆ C) |
A ⊆ (B ∪ C) | (A ⊆ B) ∨ (A ⊆ C)(该命题是(A ∩ B) ⊆ C的逆否命题,同样不成立) |
(A ∪ B) ⊆ C | (A ⊆ C) ∧ (B ⊆ C) |
(A ∩ B) ⊆ C | (A ⊆ C) ∨ (B ⊆ C)对于这种情况,不能简单的化简出等价关系,感谢网友ff1的指出。 |
(关于以上4个等价关系,我会在新的一篇文章中进行详细的数学推导,敬请留意)
也许你会感到困惑,对于集合 age > 20 ,和集合age> 18 ∪ height > 170的子集关系判断,以上讲到的复合集合等价关系是否适用?
令
A = {age, height| age > 20}
B = {age, height| age > 18}
C = {age, height| height > 170}
D = B ∪ C
由 A ⊆ (B ∪ C) ⇔ (A ⊆ B) ∨ (A ⊆ C)
(A ⊆ B)可以容易判断出是真命题,(A ⊆ C)是一个假命题(age和height并不是同一个字段,必为假命题),A ⊆ D得证。
因此集合等价关系对于多维集合(如age,height,weight,sex……等各种字段),依然成立。
复合集合与多维集合有什么区别?
复合集合强调当前集合是由2个,或者2个以上的集合取并集、交集或者补集所组成。如age>20是单一集合,age>18∪height > 170就是复合集合,
而age>18∪age>20同样是复合集合。
多维集合是指当前集合是至少含有2个或者2个以上维数的集合取并集、交集或者补集所组成。而在这里维数其实就是集合中的字段age>20是一维集合,
age>18∪height > 170是二维集合,而age>18∪age>20是一维集合。
多维集合本身是复合集合,复合集合不一定是多维集合。
大三的编译原理
词法部分
我在解题那一节中讲过,在编程的角度来讲,你的程序需要识别查询语句的符号、语法、语义,你需要一个lexer来识别符号。
如age > 20 and height > 170的符号有:
“age”: identifier
”>” : operator
“20”: number
“and”: keyword
“height”:identifier
“>”:operator
“170”:number
下面是我使用正则表达式定义的词法:
Token(符号) | Pattern(模式) | Description(描述) |
TOK_AND | and | 取交集 |
TOK_OR | or | 取并集 |
TOK_GT | > | 大于 |
TOK_LT | < | 小于 |
TOK_EQ | = | 等于(不是赋值) |
TOK_OPA | ( | 左括号 |
TOK_CPA | ) | 右括号 |
TOK_ID | [a-zA-Z_][a-zA-Z0-9_]* | 以字母或者‘_’开头,其余字符是字母、数字或者‘_’的标识符 |
TOK_NUM | \d+ | 由数字组成十进制整数 |
TOK_EOS | ‘\0’ | 字符串的结束符 |
正如你看到的,我的词法中并没有取补集的操作(NOT),也不支持否定命题(!=),虽然我在代码中实现了TOK_NUM能解析float类型,
但是并没有经过系统测试,但是支持int类型是没问题的。空白符(“ ”)将被忽略,并不会产生token。
语法部分
语法部分的处理是通过在词法部分产生的token流,与上下文无关文法来生成一棵语法树,供后续的语义分析使用。
下面是消除左递归和二义性之后的LL(0)文法,已实现了运算符的优先级。
exp → and-exp ( TOK_OR and-exp)*
and-exp → simp-exp ( TOK_AND simp-exp)*
simp-exp → TOK_OPA exp TOK_CPA | factor
factor → TOK_ID ( TOK_GT | TOK_LT | TOK_EQ ) TOK_NUM
优先级 | 运算符 |
1(最高) | >, <, = |
2 | (, ) |
3 | and |
4(最低) | or |
使用以上文法,以终结符(如TOK_ID等大写的符号)来做叶子节点,非终结符(如factor 等小写的符号)来做内节点。
如查询语句age> 18 and height > 170生成的语法树是:
语义部分
一般语义处理要做的工作很多,在静态语言中,语义部分要做的工作有:建立符号表,类型检查,对象绑定,明确赋值检查,
并且抛出错误信息和警告,对于不同的编译器会有不同的处理。在这题中的语义比较简单,其实就是把离散数学那一节中讲到的,
用代码给实现出来,不需要作符号表,类型检查,对象绑定,明确赋值检查。我定义了2个主要的子集判断函数:
1 //is_sub_set用来判断复合集合的子集关系,如集合 age > 20 ,和集合age> 18 and height > 170的子集关系判断。
2 //age>20对应着small_tree节点,age> 18 and height > 170对应着big_tree节点。
3 int is_sub_set(TreeNode* small_tree, TreeNode* big_tree);
4 //handle_single用来判断单一集合的子集关系,如集合age > 20 和集合age > 18的子集关系判断。
5 int handle_single(TreeNode* small_tree, TreeNode* big_tree);
is_sub_set中,如果small_tree或者big_tree是一个复合集合,则继续递归调用自身is_sub_set;
如果small_tree和big_tree都是一个单一集合,则会调用handle_single。
单元测试
你需要定义一个函数来做单元测试,
void test_subset(char* small_set, char* big_set, int expected);
如test_subset("age > 40", "age > 18 or weight < 100", TRUE)是验证age > 40是不是age > 18 or weight < 100的子集,
预期结果是TRUE,如果实际运行结果不为TRUE则打印错误结果的信息。
此外,你还需要一大堆测试用例来做代码覆盖测试。而我的代码中的测试用例都是使用赖总之前写到的。
1 char* cplusplus1 = "age > 40";
2 char* cplusplus2 = "age > 18";
3 test_subset(cplusplus1, cplusplus2, TRUE);
4 test_subset(cplusplus1, cplusplus1, TRUE);
5 test_subset(cplusplus2, cplusplus1, FALSE);
6 test_subset(cplusplus2, cplusplus2, TRUE);
7 printf("--------------------------------------------------------------------------------\n");
8
9 char* java1 = "age > 18 and weight < 100";
10 char* java2 = "age > 18 or weight < 100";
11 test_subset(cplusplus1, java1, FALSE);
12 test_subset(cplusplus1, java2, TRUE);
13 test_subset(java1, cplusplus1, FALSE);
14 test_subset(java2, cplusplus1, FALSE);
15 test_subset(java1, java2, TRUE);
16 printf("--------------------------------------------------------------------------------\n");
17
18 char* scala1 = "(age < 15 and sex = 0) or age > 30";
19 char* scala2 = "age < 7";
20 char* scala3 = "age < 18";
21 test_subset(scala1, scala1, TRUE);
22 test_subset(scala1, scala2, FALSE);
23 test_subset(scala2, scala1, FALSE);
24 test_subset(scala3, scala1, FALSE);
25 printf("--------------------------------------------------------------------------------\n");
26
27 char* q1 = "age < 15";
28 char* q2 = "age > 30";
29 char* q3 = "age > 18";
30 char* q4 = "age > 40";
31 char* q5 = "age > 30 and sex = 0";
32 test_subset(q1, q1, TRUE);
33 test_subset(q1, q2, FALSE);
34 test_subset(q1, q3, FALSE);
35 test_subset(q1, q4, FALSE);
36 test_subset(q1, q5, FALSE);
37
38 test_subset(q2, q1, FALSE);
39 test_subset(q2, q2, TRUE);
40 test_subset(q2, q3, TRUE);
41 test_subset(q2, q4, FALSE);
42 test_subset(q2, q5, FALSE);
43
44 test_subset(q3, q1, FALSE);
45 test_subset(q3, q2, FALSE);
46 test_subset(q3, q3, TRUE);
47 test_subset(q3, q4, FALSE);
48 test_subset(q3, q5, FALSE);
49
50 test_subset(q4, q1, FALSE);
51 test_subset(q4, q2, TRUE);
52 test_subset(q4, q3, TRUE);
53 test_subset(q4, q4, TRUE);
54 test_subset(q4, q5, FALSE);
55
56 test_subset(q5, q1, FALSE);
57 test_subset(q5, q2, TRUE);
58 test_subset(q5, q3, TRUE);
59 test_subset(q5, q4, FALSE);
60 test_subset(q5, q5, TRUE);
61 printf("--------------------------------------------------------------------------------\n");
下面是只显示部分测试结果(我的程序都通过上述的测试用例):
代码简述
我大概花了一天的时间完成这道编程题的代码,使用的是C语言,700+行的样子,源码你可以在这里下载得到。
代码是完全开源的,你可以随意使用,而不必担心协议限制,但希望你能注明原作作者与位置,以示尊重。
下面我谈谈代码的特点
特点:
1.词法器语法器都是手写的,只用到C的标准库。因为之前也写过一个简单的编译器,所以可以很容易地对词法语法进行裁剪,以适合本题的基本要求。手写词法器的好处
是更有针对性,解析速度会比较可观,正如龙书中讲到的,你可以把出现频率高的字符(如空格),尽量放在switch中比较靠前的case里面。
2.目前版本支持and ,or (,),>,<,=等token,不支持!=,not等关键字,实现了运算符优先级和括号的嵌套。
3.单元测试的代码在unittest.h中定义,测试用例全部是沿用赖总的。
4.支持int和double类型的数值,不过double的parse部分还是有点小问题。
5.使用了比较严格的文法。对于>,<,=等运算符,左操作数必须是字段,而右操作数必须是数值,也就是说age>10是合法,而10<age是不合法。
不足之处:
1.程序代码行数是700+,包括单元测试代码,比赖总的300+足足多了2倍,更让人担忧的是,再往后拓展功能的话(如增加NOT关键字,或者增加string类型),
从词法器到语法器再到语义部分都需要不少的改动,大概就是手写词法器和语法器的后果,如果你追求很好的扩展性和快速开发,使用LEX和YACC还是很必要的。
2.并没有做查询条件的正确性验证,就像网友lonegunman说到的,: age>40 ∧ age=40本身就是一个错误查询条件,但是我的程序当中并没有作出这样的验证。
3.由于这只是一个实现了基本功能的程序,如果在产品中使用的话,你可能需要增加垃圾回收机制、缓存策略,甚至是分布式的处理。
4.由于对Makefile没有深入研究,所以没有写Makefile,直接把源码编译成可执行程序就可。
参考书籍与文章
1)《Compilers: Principles, Techniques, and Tools》,编译原理,俗称龙书。
2)《Advanced Compiler Design and Implementation》,高级编译器设计与实现,俗称鲸书。
3)《Discrete Mathematics and Its Applications》,离散数学及其应用。
4) 赖总的《百姓网那道题》,http://blog.csdn.net/lanphaday/archive/2010/05/06/5565095.aspx
5) 陈梓翰师兄的《如何手写语法分析器》,http://www.cppblog.com/vczh/archive/2008/06/15/53373.html