代码改变世界

从离散数学到编译原理--百姓网编程题后序

2010-05-11 11:08 SeasonLee 阅读(...) 评论(...) 编辑 收藏

前言

那天在赖总的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类型),

从词法器到语法器再到语义部分都需要不少的改动,大概就是手写词法器和语法器的后果,如果你追求很好的扩展性和快速开发,使用LEXYACC还是很必要的。

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