简介:这份课程设计资料围绕“重言式判别程序”展开,面向正在学习数据结构与算法的高校学生及需要完成同类课设的开发者。它解决的核心问题是:如何借助二叉树表示布尔表达式,并通过后序遍历与栈结构完成逻辑恒等式的自动判别,同时兼顾用户输入交互与文档撰写规范。压缩包共4个文件,以2个doc文档和2个c源码为主,整体约38KB,文档侧重项目概述、设计思路、算法描述与测试用例,源码则给出可运行的重言式判别实现,便于对照理解二叉树构建、遍历与栈运算的落地方式。目前已有381人学习下载,适合希望系统掌握树结构、逻辑运算与课程设计文档写法的读者参考,也可作为同类题目实现与排错思路的借鉴。
1. 重言式判别程序到底在判什么:从真值表到主析取范式的工程落地
命题逻辑里有个看似简单、实际能把人绕晕的问题:给定一个命题公式,怎么让机器自动判断它是不是重言式?所谓重言式,就是无论命题变元取真还是取假,整个公式的值恒为真。比如P ∨ ¬P是重言式,P ∧ Q就不是。这个判断在数理逻辑、程序验证、数字电路等价性检查里都是基础操作,而课程设计里要求你把它做成一个能跑的程序,本质上是把「真值表枚举」和「范式化简」这两条路走通。
我拿到这份重言式判别程序课程设计资料时,第一反应是:这东西看着像课后作业,但真动手写,坑比想象中多。它适合正在学数据结构、离散数学、数理逻辑的本科生,也适合想补一下表达式解析和递归下降的开发者。资料里覆盖了从公式输入、词法分析、语法树构建到真值表枚举、主析取范式输出的完整链路,不是只给一个结论,而是让你能把每一步拆开验证。下面我按实际复现顺序,把这份资料里最值得抄的部分和最容易翻车的地方讲清楚。
2. 公式解析与语法树构建:递归下降怎么处理优先级和括号
2.1 为什么不用正则硬拆公式
很多人第一反应是拿正则表达式去匹配P∧Q∨R这种串,然后直接算。我试过,翻车得很快。命题公式的运算符优先级是¬>∧>∨>→>↔,而且括号可以任意嵌套,正则没有递归能力,遇到((P→Q)∧(Q→R))→(P→R)这种就彻底歇菜。常见做法是写一个递归下降解析器,把公式先转成抽象语法树(AST),后面真值表枚举和范式转换都在这棵树上做。
资料里给的解析器核心是三层:词法分析把字符流切成 token,语法分析按优先级递归下降,最后返回树节点。下面这段是我从资料里整理出来的 Python 版本,去掉了冗余包装,保留最关键的逻辑。
# 词法分析:把输入串切成 token 列表 def tokenize(s): tokens = [] i = 0 while i < len(s): c = s[i] if c.isspace(): i += 1 continue if c.isalpha(): # 命题变元,统一大写 tokens.append(('VAR', c.upper())) i += 1 elif c in '¬!~': # 非,兼容多种写法 tokens.append(('NOT', c)) i += 1 elif c in '∧&^': tokens.append(('AND', c)) i += 1 elif c in '∨|': tokens.append(('OR', c)) i += 1 elif c == '→' or s[i:i+2] == '->': tokens.append(('IMP', '→')) i += 2 if s[i:i+2] == '->' else 1 elif c == '↔' or s[i:i+2] == '<->': tokens.append(('IFF', '↔')) i += 3 if s[i:i+3] == '<->' else 1 elif c == '(': tokens.append(('LPAREN', c)) i += 1 elif c == ')': tokens.append(('RPAREN', c)) i += 1 else: raise ValueError(f"非法字符: {c} 位置 {i}") return tokens这段词法分析的关键参数是变元大小写处理。资料里统一转大写,避免p和P被当成两个不同变元。另一个细节是->和→的兼容,课程设计里学生输入习惯不统一,多写几种映射能省很多事。注意i += 2 if s[i:i+2] == '->' else 1这行,如果写成固定i += 1,->会被拆成两个字符,后面语法分析直接报错。
2.2 递归下降的优先级分层
语法分析部分,资料用的是经典的优先级分层递归:每一层函数只处理自己那一级的运算符,遇到更高优先级就往下调。这样写出来的代码结构清晰,改优先级也方便。
class Node: def __init__(self, op, left=None, right=None, var=None): self.op = op # 'VAR','NOT','AND','OR','IMP','IFF' self.left = left self.right = right self.var = var # 仅当 op=='VAR' 时有效 def parse(tokens): pos = [0] # 用列表包一层,方便闭包内修改 def peek(): return tokens[pos[0]] if pos[0] < len(tokens) else (None, None) def consume(): t = tokens[pos[0]] pos[0] += 1 return t def parse_iff(): node = parse_imp() while peek()[0] == 'IFF': consume() node = Node('IFF', node, parse_imp()) return node def parse_imp(): node = parse_or() if peek()[0] == 'IMP': # 蕴含右结合 consume() node = Node('IMP', node, parse_imp()) return node def parse_or(): node = parse_and() while peek()[0] == 'OR': consume() node = Node('OR', node, parse_and()) return node def parse_and(): node = parse_not() while peek()[0] == 'AND': consume() node = Node('AND', node, parse_not()) return node def parse_not(): if peek()[0] == 'NOT': consume() return Node('NOT', parse_not()) return parse_atom() def parse_atom(): t = peek() if t[0] == 'VAR': consume() return Node('VAR', var=t[1]) if t[0] == 'LPAREN': consume() node = parse_iff() if peek()[0] != 'RPAREN': raise ValueError("括号不匹配") consume() return node raise ValueError(f"意外的 token: {t}") result = parse_iff() if pos[0] != len(tokens): raise ValueError("公式末尾有多余字符") return result这里有两个参数值得单独说。第一,蕴含→是右结合,P→Q→R等价于P→(Q→R),所以parse_imp里用的是递归调用而不是循环。第二,parse_not也递归,因为¬¬P是合法的。资料里特别标注了这两处,很多同学写成循环后P→Q→R算出来结果不对,查半天查不出来。
提示:解析完成后一定要检查
pos[0]是否等于 token 总数,否则P∧Q)这种多右括号的输入会被静默接受,后面真值表算出来是错的。
3. 真值表枚举与重言式判定:变元提取和递归求值
3.1 从 AST 里收集变元并排序
要枚举真值表,先得知道公式里出现了哪些变元。遍历 AST 收集到一个集合里,然后排序,保证每次运行顺序一致。资料里用的是中序遍历,代码很短但容易漏掉NOT节点下的变元。
def collect_vars(node, acc=None): if acc is None: acc = set() if node.op == 'VAR': acc.add(node.var) if node.left: collect_vars(node.left, acc) if node.right: collect_vars(node.right, acc) return acc def eval_node(node, env): """env 是 {变元: True/False} 的字典""" if node.op == 'VAR': return env[node.var] if node.op == 'NOT': return not eval_node(node.left, env) if node.op == 'AND': return eval_node(node.left, env) and eval_node(node.right, env) if node.op == 'OR': return eval_node(node.left, env) or eval_node(node.right, env) if node.op == 'IMP': # P→Q 等价于 ¬P∨Q return (not eval_node(node.left, env)) or eval_node(node.right, env) if node.op == 'IFF': return eval_node(node.left, env) == eval_node(node.right, env) raise ValueError(f"未知节点: {node.op}")eval_node里蕴含和等价的处理是重点。蕴含用¬P∨Q展开,等价用 Python 的==比较两个布尔值。资料里提醒,不要用and/or去模拟蕴含,P→Q在P=False时恒为真,用and会算错。
3.2 枚举所有赋值并判定
变元数量为 n 时,真值表有 2^n 行。用二进制计数法枚举最省事,每一位对应一个变元的真假。
def is_tautology(node): variables = sorted(collect_vars(node)) n = len(variables) for mask in range(1 << n): env = {} for i, v in enumerate(variables): env[v] = bool((mask >> i) & 1) if not eval_node(node, env): return False, env # 找到一个反例就返回 return True, None # 使用示例 formula = "(P→Q)∨(Q→P)" ast = parse(tokenize(formula)) result, counter = is_tautology(ast) print("是重言式" if result else f"不是,反例: {counter}")这段代码的参数核心是1 << n,也就是 2 的 n 次方。n 超过 20 时枚举会明显变慢,课程设计里一般变元不超过 5 个,问题不大。但如果想扩展到 10 个以上,就得换主析取范式或归结法,资料里在进阶部分提了一句,没有展开。
注意:
(P→Q)∨(Q→P)是重言式,但如果你把→的求值写成eval_node(left) and eval_node(right),结果会变成False,这是最典型的翻车点。
4. 主析取范式输出与等价性验证:从真值表反推公式
4.1 主析取范式的生成逻辑
判定完重言式,课程设计通常还要求输出主析取范式(PDNF)。思路很直接:遍历真值表,把每一行结果为真的赋值拿出来,每个赋值对应一个极小项,所有极小项析取起来就是 PDNF。极小项的构造规则是:变元为真取自身,为假取否定,然后全部合取。
def to_pdnf(node): variables = sorted(collect_vars(node)) n = len(variables) minterms = [] for mask in range(1 << n): env = {} for i, v in enumerate(variables): env[v] = bool((mask >> i) & 1) if eval_node(node, env): lits = [] for v in variables: lits.append(v if env[v] else f"¬{v}") minterms.append("(" + "∧".join(lits) + ")") if not minterms: return "永假式,无主析取范式" return "∨".join(minterms)参数说明:variables必须排序,否则每次输出的极小项顺序不一样,没法比对。minterms为空说明公式是永假式,这时候 PDNF 不存在,资料里专门处理了这个边界,很多同学漏掉后输出空字符串,看起来像程序崩了。
4.2 用等价性验证反查解析是否正确
写完解析和求值后,怎么确认自己没写错?资料里给了一个很实用的自检方法:构造几个已知等价式,看程序算出来是不是重言式。比如P→Q和¬P∨Q应该等价,那么(P→Q)↔(¬P∨Q)应该是重言式。跑一遍,如果是,说明蕴含和否定的求值逻辑没问题。
| 验证公式 | 预期结果 | 检验点 |
|---|---|---|
(P→Q)↔(¬P∨Q) | 重言式 | 蕴含展开 |
(P↔Q)↔((P→Q)∧(Q→P)) | 重言式 | 等价展开 |
P∨¬P | 重言式 | 否定与析取 |
P∧¬P | 非重言式 | 矛盾式边界 |
(P→Q)∨(Q→P) | 重言式 | 优先级与括号 |
这张表我建议直接抄进测试用例里,每改一次解析器就跑一遍。资料里还提到,如果(P→Q)↔(¬P∨Q)算出来不是重言式,九成是parse_imp的右结合写错了,或者eval_node里蕴含用了and。
5. 避坑与排查:递归下降和真值表枚举里最容易翻车的五处
5.1 现象:输入P→Q→R结果和手算对不上
原因:蕴含是右结合,P→Q→R应该解析成P→(Q→R),但很多实现写成了循环左结合,变成(P→Q)→R。这两个公式真值表不一样。
解决:parse_imp里用递归调用parse_imp()而不是while循环。改完后用(P→Q→R)↔(P→(Q→R))验证,应该是重言式。
5.2 现象:变元超过 9 个时程序卡死
原因:真值表枚举是 2^n,n=20 时超过一百万行,Python 纯循环跑起来很慢。课程设计一般 n≤5,但有人输入了 15 个变元。
解决:在is_tautology开头加一个变元数量检查,超过 12 个就提示改用归结法或主析取范式化简。资料里没有实现归结法,但给了这个边界提示。
5.3 现象:¬和!混用时报非法字符
原因:词法分析只认了一种否定符号。学生输入习惯不统一,有人写¬P,有人写!P,有人写~P。
解决:tokenize里把¬!~都映射到NOT。同理,∧&^映射到AND,∨|映射到OR。这个改动很小,但能省掉大量「为什么我的输入报错」的调试时间。
5.4 现象:括号不匹配时程序不报错,直接算出错误结果
原因:parse_atom里遇到LPAREN后调用了parse_iff,但没有检查后面是否真的有RPAREN。如果输入(P∧Q,解析器会在 token 耗尽时返回,pos[0]不等于 token 总数,但外层没检查。
解决:在parse最后加if pos[0] != len(tokens): raise ValueError,同时在parse_atom里显式检查RPAREN。资料里这两处都有,但很多人抄的时候只抄了前半段。
5.5 现象:主析取范式输出顺序每次不一样
原因:collect_vars返回的是set,遍历顺序不确定。虽然逻辑上极小项顺序不影响等价性,但课程设计报告里要求输出稳定,方便截图。
解决:sorted(collect_vars(node))强制排序。另外,极小项内部的变元顺序也要按排序后的顺序来,不要用env字典的遍历顺序。
6. 进阶技巧:用递归下降解析器做公式等价性批量验证
把上面几块拼起来后,你会发现这个程序不止能判重言式,还能做公式等价性批量验证。具体做法是:把两个公式A和B分别解析成 AST,然后构造A↔B的新树,丢进is_tautology。如果是重言式,说明 A 和 B 等价。这个技巧在课程设计答辩时很加分,因为老师通常会问「你怎么证明你的解析是对的」,你可以当场跑一批等价式验证。
def equivalent(f1, f2): ast1 = parse(tokenize(f1)) ast2 = parse(tokenize(f2)) combined = Node('IFF', ast1, ast2) result, counter = is_tautology(combined) return result, counter # 批量验证 pairs = [ ("P→Q", "¬P∨Q"), ("P↔Q", "(P→Q)∧(Q→P)"), ("¬(P∧Q)", "¬P∨¬Q"), # 德摩根律 ("¬(P∨Q)", "¬P∧¬Q"), ] for a, b in pairs: ok, c = equivalent(a, b) print(f"{a} ≡ {b}: {'成立' if ok else '不成立, 反例 ' + str(c)}")这段代码的参数关键是Node('IFF', ast1, ast2),直接把两棵树拼成一个等价节点,复用已有的求值逻辑,不需要写新的比较函数。跑出来如果德摩根律那两行显示「成立」,说明你的否定、合取、析取求值都没问题。如果显示「不成立」,反例会告诉你哪个赋值下两个公式结果不同,顺着这个赋值去查对应节点的求值代码,定位很快。
我自己的习惯是,每次改完解析器或求值函数,先把这四对等价式跑一遍,再跑重言式判定。从那以后我每次交课程设计前都强制走一遍这个批量验证,比手算真值表靠谱得多。希望帮到你。
本文还有配套的精品资源,点击获取