☰
重言式判别:递归下降解析与真值表枚举的课程设计实现
2026/10/17 15:05:16 网站建设 项目流程

简介:这份资源是面向计算机科学与技术专业学生的数据结构与算法课程设计报告,主题为重言式判别,适合正在完成课程设计或需要理解逻辑表达式求值算法的学习者参考。报告围绕编写程序判别逻辑表达式属于重言式、矛盾式还是可满足式展开,完整覆盖输入处理、运算符优先级、堆栈求值及二叉树构造等核心环节。压缩包内仅含1个doc文档,大小约293KB,内容为合肥学院计算机科学与技术系2016~2017学年第1学期的课程设计报告,包含问题描述、基本要求、测试数据、问题分析、数据结构选择与概要设计等章节。文档详细阐述了算符优先关系表的归纳、双堆栈实现表达式求值、中序遍历二叉树计算逻辑值以及通过真值表判定重言式的方法,并给出多组测试用例。已有147人学习,适合需要完整课程设计框架、算法推导过程与实现思路的读者参考借鉴。

1. 重言式判别到底在判什么:从一份课程设计报告说起

一份数据结构与算法课程设计报告,题目落在“重言式判别”上,很多人第一反应是打开编译器写个if (a || !a) return true;就交差。真按这个思路做,报告大概率会被打回来,因为重言式判别要解决的不是“这一个式子成不成立”,而是“任意给定一个命题公式,能不能用程序机械地判定它是否恒真”。它背后是命题逻辑、表达式解析、真值表枚举和递归下降这几件事的组合,是数据结构课设里少有的、能把栈、树、递归、哈希表全串一遍的题目。

它适合两类人:一类是正在做课程设计、需要一份能跑通又能讲清楚原理的完整方案的学生;另一类是想拿它当练手项目、把“字符串解析 + 递归 + 状态枚举”这套组合拳打熟的人。下面我按自己带课设时最常用的一条路径,从文法定义讲到可复现的代码,再到参数怎么设、哪里最容易翻车,一层层拆开。读完你应该能独立写出一个支持与、或、非、蕴含、括号和变量的判别器,并且知道它在什么规模下会崩。

2. 从公式字符串到语法树:递归下降解析器的落地写法

重言式判别的第一步永远不是判,而是解析。你拿到的是(P->Q)&(!P|Q)这样的字符串,程序得先把它变成一棵能遍历的语法树,否则后面枚举真值表时你连变量都找不全。这一章把文法、词法、递归下降三件事按可抄作业的粒度写清楚。

2.1 先定文法,再写代码,顺序不能反

命题公式的文法用扩展巴科斯范式写出来,优先级从低到高是:蕴含(->)最低,其次是或(|),再是与(&),最高是非(!),括号改变结合顺序。写成产生式就是:

Expr -> Impl Impl -> Or ('->' Impl)? // 右结合 Or -> And ('|' And)* And -> Not ('&' Not)* Not -> '!' Not | Atom Atom -> Var | '(' Expr ')' Var -> [A-Za-z][A-Za-z0-9_]*

这里有个容易被忽略的点:蕴含写成('->' Impl)?而不是循环,是因为P->Q->R在逻辑上通常按右结合理解,即P->(Q->R)。如果你写成左结合,(P->Q)->R和P->(Q->R)的真值表不一样,判重言式的结论就会错。我见过不止一份课设报告栽在这个细节上,代码能跑,结果全错。

提示:变量名建议限制成单字母或短标识符,真值表枚举是 2 的 n 次方,变量一多直接爆炸,后面第 5 章会讲这个边界。

2.2 词法分析:把字符流切成 token

解析之前先把字符串扫一遍,切成 token 序列,顺便把空白字符丢掉。这一步用 Python 写最省事,因为字符串处理是它的强项。

# 词法分析:把公式字符串切成 token 列表 # 支持的 token 类型:VAR(变量)、AND、OR、NOT、IMP(蕴含)、LPAREN、RPAREN def tokenize(s): tokens = [] i = 0 while i < len(s): c = s[i] if c.isspace(): i += 1 continue if c == '-' and i + 1 < len(s) and s[i+1] == '>': tokens.append(('IMP', '->')) i += 2 continue if c == '&': tokens.append(('AND', '&')); i += 1; continue if c == '|': tokens.append(('OR', '|')); i += 1; continue if c == '!': tokens.append(('NOT', '!')); i += 1; continue if c == '(': tokens.append(('LPAREN', '(')); i += 1; continue if c == ')': tokens.append(('RPAREN', ')')); i += 1; continue if c.isalpha(): j = i while j < len(s) and (s[j].isalnum() or s[j] == '_'): j += 1 tokens.append(('VAR', s[i:j])) i = j continue raise ValueError(f"非法字符 '{c}' 出现在位置 {i}") return tokens

逻辑说明:->必须优先于单字符-判断,否则会被拆成两个 token。变量用isalpha()起头、isalnum()续接,保证P1、Q_2这类名字能识别。遇到不认识的字符直接抛异常并带上位置,排错时能省很多时间。

参数说明:这段代码没有可调参数,但 token 类型是后续解析器的契约,改这里必须同步改解析器里的match判断,否则会出现“词法过了、语法报错”的玄学问题。

2.3 递归下降建树:每个非终结符对应一个函数

有了 token 序列,按文法一层层往下写函数就行。每个函数负责吃掉自己那一层的 token,返回一个节点。

# 语法树节点用元组表示:(类型, 左子, 右子) 或 ('VAR', 名字) class Parser: def __init__(self, tokens): self.tokens = tokens self.pos = 0 def peek(self): return self.tokens[self.pos] if self.pos < len(self.tokens) else (None, None) def eat(self, kind): t = self.peek() if t[0] != kind: raise SyntaxError(f"期望 {kind},实际 {t[0]} 在位置 {self.pos}") self.pos += 1 return t def parse(self): node = self.expr() if self.pos != len(self.tokens): raise SyntaxError("公式末尾有多余 token") return node def expr(self): return self.impl() def impl(self): left = self.or_expr() if self.peek()[0] == 'IMP': self.eat('IMP') right = self.impl() # 右结合,递归调用自己 return ('IMP', left, right) return left def or_expr(self): node = self.and_expr() while self.peek()[0] == 'OR': self.eat('OR') node = ('OR', node, self.and_expr()) return node def and_expr(self): node = self.not_expr() while self.peek()[0] == 'AND': self.eat('AND') node = ('AND', node, self.not_expr()) return node def not_expr(self): if self.peek()[0] == 'NOT': self.eat('NOT') return ('NOT', self.not_expr()) return self.atom() def atom(self): t = self.peek() if t[0] == 'VAR': self.eat('VAR') return ('VAR', t[1]) if t[0] == 'LPAREN': self.eat('LPAREN') node = self.expr() self.eat('RPAREN') return node raise SyntaxError(f"意外的 token {t[0]}")

逻辑说明:impl里递归调用self.impl()实现右结合,这是和or_expr、and_expr用while循环的关键区别。not_expr递归调用自己,支持!!P这种多重否定。atom处理变量和括号,括号内重新回到expr,形成闭环。

参数说明:self.pos是唯一的游标状态,所有eat都推进它。如果你要加新的运算符(比如异或^),在文法里插一层、在tokenize里加分支、在解析器里加对应函数,三处必须同步,漏一处就会在某个输入上抛SyntaxError。

2.4 收集变量并去重:为真值表枚举做准备

树建好之后,遍历一遍把所有VAR节点收集起来,去重后排序,得到变量列表。这个列表的长度直接决定后面枚举的规模。

# 后序遍历收集变量,用集合去重后排序,保证每次运行顺序一致 def collect_vars(node, acc=None): if acc is None: acc = set() if node[0] == 'VAR': acc.add(node[1]) elif node[0] == 'NOT': collect_vars(node[1], acc) else: collect_vars(node[1], acc) collect_vars(node[2], acc) return sorted(acc)

逻辑说明:用集合去重,最后sorted保证变量顺序稳定,这样生成的真值表行序可复现,方便对照和写报告。NOT只有一个子节点,要单独分支,否则访问node[2]会越界。

参数说明:acc默认None再初始化,避免可变默认参数的坑。变量数量就是len(collect_vars(tree)),这个值超过 20 时,2 的 20 次方是一百多万行,纯 Python 枚举会明显变慢,第 5 章会给优化方向。

3. 真值表枚举与递归求值:把“恒真”判出来

解析只是把公式变成了结构,真正判重言式靠的是枚举所有变量赋值组合,逐行求值,看是否每一行都为真。这一章把枚举、求值、判定三件事写透,并给出可复现的完整调用链。

3.1 用位运算枚举所有赋值组合

n 个变量有 2 的 n 次方种赋值。用整数mask从 0 到2**n - 1遍历,第 i 位表示第 i 个变量的真假,这是最省内存的写法。

# 枚举所有赋值:mask 的第 i 位对应 vars[i] 的真值 def all_assignments(variables): n = len(variables) for mask in range(1 << n): env = {} for i, name in enumerate(variables): env[name] = bool((mask >> i) & 1) yield env

逻辑说明:1 << n就是 2 的 n 次方。(mask >> i) & 1取出第 i 位,转成布尔值塞进env字典。用生成器yield而不是一次性建列表,变量多的时候内存占用是常数级。

参数说明:variables必须是去重排序后的列表,顺序决定位和变量的对应关系。如果你中途改了变量顺序,之前缓存的真值表就全废了,所以这个列表一旦生成就不要动。

3.2 递归求值:遇到变量查环境,遇到运算符算子树

求值函数按节点类型分派,变量查env,NOT取反,AND、OR、IMP各自按真值表算。蕴含P->Q等价于!P | Q,这是最不容易记错的一种写法。

# 递归求值:env 是 {变量名: bool} 的字典 def evaluate(node, env): op = node[0] if op == 'VAR': return env[node[1]] if op == 'NOT': return not evaluate(node[1], env) if op == 'AND': return evaluate(node[1], env) and evaluate(node[2], env) if op == 'OR': return evaluate(node[1], env) or evaluate(node[2], env) if op == 'IMP': # P -> Q 等价于 !P or Q return (not evaluate(node[1], env)) or evaluate(node[2], env) raise ValueError(f"未知节点类型 {op}")

逻辑说明:IMP用!P or Q实现,比写if P then Q else True更简洁,也不容易在边界上写错。递归深度等于树高,正常公式不会超过几十层,Python 默认递归限制够用。

参数说明:env必须包含树里出现的所有变量,缺一个就KeyError。如果你要做部分赋值下的求值(比如化简时),得先判断变量是否在env里,不在就抛一个明确的异常,而不是让它静默出错。

3.3 判定主流程:一行假就短路

把所有赋值跑一遍,只要有一行使公式为假,它就不是重言式,可以直接返回并给出反例。全为真才是重言式。

# 主判定:返回 (是否重言式, 反例赋值或 None) def is_tautology(formula_str): tokens = tokenize(formula_str) tree = Parser(tokens).parse() variables = collect_vars(tree) for env in all_assignments(variables): if not evaluate(tree, env): return False, env # 找到反例,短路返回 return True, None # 调用示例 if __name__ == '__main__': print(is_tautology("P | !P")) # (True, None) print(is_tautology("P -> Q")) # (False, {'P': True, 'Q': False}) print(is_tautology("(P -> Q) & (Q -> R) -> (P -> R)")) # (True, None)

逻辑说明:短路返回反例是实用设计,报告里能直接展示“为什么它不是重言式”,比只输出一个False有说服力。第三个例子是假言三段论,经典重言式,适合当自测用例。

参数说明:is_tautology的输入是原始字符串,内部完成解析到判定的全流程。如果你要批量判多个公式,把解析结果缓存起来,避免重复解析同一棵树。

3.4 用等价变换做交叉验证

真值表枚举是“笨办法”,但笨办法最适合当基准。你可以再写一个基于等价变换的判定:把公式化成合取范式,看是否每个子句都含互补文字。两种方法结果一致,才说明实现没大问题。

验证方法思路适用规模实现难度
真值表枚举枚举 2^n 种赋值逐行求值n ≤ 20低
等价变换化范式后检查子句n 较大但结构简单中
随机对拍两法跑同一批公式比对任意低

注意:等价变换实现起来比枚举复杂得多,课设里如果时间紧,优先把枚举法写扎实,再用它给等价变换法做对拍,而不是反过来。

4. 避坑与排查:重言式判别最容易翻车的 5 个地方

这一章按“现象 → 原因 → 解决”写,都是我见过或自己踩过的真实问题。每一条都对应前面某段代码,出问题时先往这里对。

4.1 现象:P->Q->R判出来和手算不一致

原因:蕴含的结合性写成了左结合。(P->Q)->R和P->(Q->R)真值表不同,前者在P=假时整体为真,后者在R=假时整体为假。

解决:解析器里impl必须递归调用自己实现右结合,不能像or_expr那样用while循环。改完用P->Q->R和P->(Q->R)对拍,两者结果应完全一致。

4.2 现象:变量名P1被拆成P和1,解析报错

原因:词法分析里变量只吃了首字母,续接字符判断漏了数字或下划线。

解决:变量扫描用while j < len(s) and (s[j].isalnum() or s[j] == '_'),保证P1、Q_2整体识别。改完补一个P1 & !P1的用例,应判为重言式。

4.3 现象:公式里多一个右括号,程序不报错但结果乱

原因:atom里eat('RPAREN')之后没有检查是否还有多余 token,parse末尾的检查被绕过。

解决:parse里解析完expr后必须确认self.pos == len(self.tokens),否则抛“末尾有多余 token”。补用例(P|Q)),应明确报语法错误而不是给个莫名其妙的结果。

4.4 现象:变量一多程序卡死,风扇狂转

原因:真值表枚举是 2 的 n 次方,n=25 时是三千万行,纯 Python 每行还要递归求值,时间直接爆炸。

解决:先看变量数,超过 20 就在报告里说明“枚举法不适用”,改用等价变换或加剪枝。实际课设公式变量一般不超过 5 个,但你要知道边界在哪,答辩时被问到能答上来。

4.5 现象:!和&优先级搞反,!P & Q算成了!(P & Q)

原因:文法里Not层写在And层下面,但解析器里and_expr直接调了atom而不是not_expr。

解决:and_expr必须调not_expr,not_expr再调atom。用!P & Q在P=真, Q=假时验证:正确结果是假 & 假 = 假,如果算成!(真 & 假) = 真,就是优先级错了。

5. 进阶技巧:把判别器做成能讲清楚、能扩展的版本

课设报告写到能跑通只是及格线,真正拉开差距的是你能不能把边界讲清楚、把扩展方向留出来。这一章给三个具体技巧,都是我在带课设时反复用到的。

第一个技巧是加一个“反例解释器”。现在is_tautology返回反例字典,但报告里光有字典不够直观。你可以再写一个函数,把反例字典代回原公式,逐步展示求值过程,输出类似P=True, Q=False 时,P->Q 为 False。这个功能实现起来就是给evaluate加一个可选的 trace 参数,记录每层子树的中间结果。答辩时演示这个,比干讲真值表有说服力得多。

第二个技巧是支持变量别名和公式化简。很多课设题目会给P、Q、R这种短名,但实际用的时候可能想写Rain、Traffic。你可以在tokenize之后加一层映射,把长变量名映射成内部短名,判定完再映射回去。化简则是另一个方向:如果公式里某个变量只出现一次且不影响结果,可以标记出来,报告里写“该变量为冗余变量”,这是加分项。

第三个技巧是给判定器加一个规模预警。在is_tautology开头算一下变量数,超过阈值就打印一行提示,比如“变量数 22,枚举规模约 419 万行,预计耗时较长”。这个提示不改变结果,但能让使用者心里有数,也体现你对复杂度的理解。阈值设多少合适?我一般设 20,因为 2 的 20 次方是一百多万,纯 Python 跑几秒还能接受,再往上就明显卡了。

扩展方向改动位置预计工作量报告加分点
反例解释器evaluate加 trace半天演示效果好
变量别名映射tokenize后加一层两小时体现实用性
规模预警is_tautology开头半小时体现复杂度意识
等价变换对拍新增模块一天验证严谨性

最后说个我自己的习惯:每写完一个判定函数,先拿三个公式自测——P | !P(应真)、P & !P(应假)、(P->Q) & (Q->R) -> (P->R)(应真)。这三个覆盖了重言式、矛盾式和经典推理式,跑通它们,基本逻辑就不会有大问题。剩下的时间花在把反例输出做得清楚、把边界写进报告,比堆功能更值。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询