☰
CSP化学方程式配平:Python递归下降解析与满分实现
2026/10/13 17:59:47 网站建设 项目流程

CSP认证的第三题,历来是很多人眼里的分水岭。2020年之前那几年,第三题基本都是大模拟:字符串、状态机、递归解析轮着来,数据范围不算大,但代码量不小,很考验考场上的冷静程度。201912-3这道“化学方程式配平”,就是典型代表。

题目内容一句话就能说清:给你一个形如H2+O2=H2O或者2H2+O2=2H2O的化学方程式,判断等号左右两边每种原子的总数是否完全相等。听起来很简单,但真动手写的时候,“括号嵌套”“化学式系数”“原子符号大小写”这几个细节能卡住不少人。我见过很多同学思路是对的,却在递归解析括号时把自己绕进去,最后提交一堆错误代码。

这篇文章会把完整的 Python 满分解法拆开来讲:先讲清楚为什么要用“原子守恒 + 哈希计数”这个思路,再逐步实现一个可复用的化学式解析器,最后给出可以直接参考的满分代码,以及一套能用来验证的测试用例。适合正在准备 CSP 第三题、或者想练字符串模拟题的人。

1. 思路拆解:先想清楚再动手

1.1 这道题到底在考什么

表面上考化学,实际上考的是字符串解析。题目里约定了原子符号只有大写字母开头、最多跟一个小写字母,例如Na、Au、Hg,化学式可以带整体系数、原子可以带右下角数字、括号可以嵌套。整道题没有任何复杂的算法,核心能力就两个:第一,把一个化学式字符串正确地拆成一个个原子并数出数量;第二,把等号两侧的所有原子数分别汇总后比较是否相同。

前者考的是“解析”,后者考的是“用哈希表做计数”。只要把这两个基础动作做稳,满分就是水到渠成的事。很多同学拿到题目第一反应是去回忆化学知识,甚至想写一套“配平器”,但题目明确只要求判断是否配平,不要求输出配平系数,所以千万不要往复杂了想。判定配平比求解配平简单得多,本质只是等式两边原子数是否完全一致。

1.2 配平的本质:原子守恒

化学方程式配平在化学里的含义是反应前后原子种类和数目不变。放到程序里,就是两边各维护一个字典,以原子符号为键、以该原子出现的总数为值,最后比较两个字典是否完全相等。

比如2H2+O2=2H2O:左边 H 有 2×2=4 个,O 有 2 个;右边 H 有 2×2=4 个,O 有 2 个,两边相等,输出 Y。而H2+O2=H2O左边 O 有 2 个,右边 O 只有 1 个,输出 N。这个判定逻辑用一行 Python 就能表达:

analyze_side(left) == analyze_side(right)

字典比较的语义天然适用于原子守恒判断。两个 Counter(或两个普通字典)相等,意味着键集合完全相同,且每个键对应的值也完全相同。左边多一种原子、少一种原子、或者某一种原子数量对不上,都会判为不相等。

这里有个很关键的设计选择:不要预先维护一张“化学元素周期表”来判断元素是否合法。题目输入的元素符号不需要提前知道,来什么就统计什么,字典天然支持任意化学式。这样代码不需要维护任何外部知识,也不怕隐藏测试点里出现冷门元素。

1.3 方案选型:为什么用递归下降而不是手写栈

括号嵌套解析有两种常见写法:显式栈和递归下降。化学式里括号的嵌套深度题目有保证,递归天然对应“遇到左括号解析括号内,遇到右括号返回”的过程,代码和思维负担都小很多。手写栈则需要额外记录“当前处于哪一层括号、括号前的倍率要乘到哪里”,非常容易在细节上崩。

递归下降可以理解成一种“见招拆招”的解析方式:遇到普通原子就读原子名;遇到数字就读整数;遇到左括号就递归处理括号内;遇到右括号就结束当前层。因为化学式的结构是递归定义的,所以代码跟着结构走就行。

递归还有一个额外好处:它天然支持任意层数的括号嵌套。比如(Na(OH)2)3这种结构,外层括号内部遇到内层括号时,直接再调用一次解析函数即可,不需要额外标记这是第几层。

再补充一点:为什么不直接用正则表达式?因为化学式里的括号是嵌套结构,不是正则表达式能优雅处理的,嵌套深度不固定,强行写正则要么写得极长,要么在某些嵌套场景下直接失效。递归下降才是这类问题的正统解法。

2. 化学式解析器:把字符串变成原子字典

2.1 数据结构:用 Counter 统计原子

Python 的collections.Counter本质上就是字典,但它支持cnt[atom] += num这种写法,不用每次写cnt[atom] = cnt.get(atom, 0) + num,代码更干净,可读性也更好。

Counter 在合并时也方便,但有一点必须注意:两个化学式的原子统计结果累加到同一个总数里时,要逐项相加;而括号内部分乘以括号后的数字时,要逐项乘以倍数。这两个操作看起来都是“循环遍历 items”,但一个是加法,一个是乘法,写混了整个统计就会错。

我选择 Counter 还有一个考虑:Counter 之间可以直接用==比较,省掉一层手写循环。因为 Counter 继承自 dict,==的语义是两边键集合相同且每个键的值都相同,正好命中题目的判定需求。

2.2 原子符号读取:大写开头,小写跟随

化学元素符号的公开规则是“一个大写字母,或者一个大写字母加一个小写字母”。读的时候先拿当前字符当作首字母,此时它一定是一个大写字母,因为题目保证输入合法。然后继续往后看,如果下一个字符是小写字母,就把它拼到同一个原子名里。接着再读数字,数字可能是一个多位整数,也可能没有,没有就按 1 处理。

这个顺序千万不能反。否则Cu会被错误读成 C 和 u 两种原子,后面的测试全乱。同理,Na不能读成 N 和 a,Au不能读成 A 和 u。题目考察的就是这种细节,很多第一次写这道题的人都在这里翻车。

另外要特别提醒:数字只属于紧跟它的那个原子或括号单元。Fe2O3里,2 属于 Fe,3 属于 O,两个数字中间夹着大写字母,读原子名、读数字、读原子名、读数字,必须严格按顺序交替进行,不能跳着读。

2.3 括号递归:三个关键处理点

先读左括号,递归处理括号内部,递归返回时位置指针应该停在右括号上,此时把右括号读掉,再往后读一个数字,没有数字就按 1 处理,把这个数字乘到括号内部分得到的字典的每一项上,再合并进当前层的字典。

这一串动作,代码只有十来行,但里面有三个关键点:

第一,位置指针必须是整个解析过程的全局状态。我用一个闭包变量pos,所有读取都移动这个指针,递归调用结束后指针仍然停留在正确位置。如果每次递归都传一个新的位置进去、再返回一个新位置,很容易出现“返回的位置和实际读取的位置对不上”的错位问题。

第二,递归函数遇到右括号时立即break,但不要把右括号消费掉,由调用它的上一层去消费并继续读数字。如果内层递归直接把右括号读走,外层就会丢失“括号结束”这个信号,导致整个解析顺序错乱。

第三,括号后面如果没有数字,就按系数 1 处理;如果有数字,可能是多位数,要循环读完整。比如(OH)10后面的 10 必须完整读出来,只读一位数字就会得到完全错误的统计结果。

这三个点任何一个处理错,都会导致类似(OH)2被解析成 O、H、2 三个独立符号,或者解析到一半字符串越界。

3. 满分代码与主流程实现

3.1 完整可提交的 Python 代码

把上面拆开的模块整合起来,就是一份可以直接运行的版本。整体流程分成三步:第一步,把等式按=拆成左右两个字符串;第二步,对每一侧,再按+拆成各个化学式,逐个读取化学式前面的整体系数,解析化学式,并累加到这一侧的全局原子计数器;第三步,比较左右两个计数器是否相等。

import sys from collections import Counter def parse_molecule(s: str) -> Counter: """把不带整体系数的化学式解析成原子计数 Counter""" n = len(s) pos = 0 def parse(): nonlocal pos res = Counter() while pos < n: ch = s[pos] if ch == '(': pos += 1 # 跳过 '(' inner = parse() # 递归解析括号内,返回时 pos 指向 ')' pos += 1 # 跳过 ')' num = 0 while pos < n and s[pos].isdigit(): num = num * 10 + int(s[pos]) pos += 1 if num == 0: num = 1 for atom, cnt in inner.items(): res[atom] += cnt * num elif ch == ')': break else: # 读取原子名:大写字母开头,后接若干小写字母 atom = s[pos] pos += 1 while pos < n and s[pos].islower(): atom += s[pos] pos += 1 # 读取原子个数,没有数字就是 1 num = 0 while pos < n and s[pos].isdigit(): num = num * 10 + int(s[pos]) pos += 1 if num == 0: num = 1 res[atom] += num return res return parse() def analyze_side(expr: str) -> Counter: """统计等号某一侧所有化学式的原子总数""" total = Counter() for part in expr.split('+'): part = part.strip() # 读取化学式整体系数,没有数字就是 1 p = 0 coef = 0 while p < len(part) and part[p].isdigit(): coef = coef * 10 + int(part[p]) p += 1 if coef == 0: coef = 1 molecule = part[p:] cnt = parse_molecule(molecule) # 整体系数乘到该化学式的每种原子上,再累加 for atom, num in cnt.items(): total[atom] += num * coef return total def solve(): data = sys.stdin.read().strip().splitlines() n = int(data[0]) out = [] for i in range(1, n + 1): left, right = data[i].split('=') if analyze_side(left) == analyze_side(right): out.append('Y') else: out.append('N') print('\n'.join(out)) if __name__ == '__main__': solve()

代码风格上我有意做了几个选择:没有用input()逐行读,而是用sys.stdin.read()一次性读入再按行处理,因为测试数据可能多行,这样更稳;analyze_side里对每一侧单独统计,最后利用 Python 的字典比较语义判断相等,简洁且不容易漏。

3.2 主流程:等式拆分与系数乘法

主流程里最容易忽视的一步是“整体系数乘到整个化学式”。化学式前的系数乘的是化学式中所有原子的总数,不是只乘第一个原子。所以必须先解析出一个完整的化学式 Counter,再整体乘系数,最后合并进这一侧的总 Counter。

如果图省事,在解析代码里遇到第一个原子就先乘一遍整体系数,后面再解析出的原子就漏掉了系数。比如2H2O,正确结果是 H=4、O=2;如果只把 2 乘到 H 上,就会得到 H=4、O=1,最后判定结果直接反掉。

这道题给不少考生造成的心理压力来自“化学”两个字,但只要把它拆成“字符串解析”和“字典计数”两个动作,代码其实非常简单。整份代码去掉空行和注释不超过 60 行,核心的parse_molecule只有 30 行左右。

3.3 边界情况与语言细节

题目输入格式里,第一行是一个整数 n,表示接下来的化学方程式数量。每个方程式形如成分+成分=成分+成分,成分由可省略的整体系数、原子段、可能嵌套的括号组成。所有字符都是 ASCII,没有空格,不过strip()一下也没坏处。

不会出现负系数,不会出现小数系数,所以所有数字都按十进制正整数读。题目也不会给空化学式,所以part至少有一个字符。这些边界在判断时不用特意做分支,因为解析函数本身已经兼容:读不到数字时默认为 1,遇到空字符串时返回空 Counter。

还有一个很实用的细节:Counter做加法时,如果某个键不存在会从 0 开始加,所以不需要事先给所有可能的元素符号初始化。这也是哈希计数相比“固定数组计数”的核心优势——你不需要预先知道会出现哪些原子。

4. 测试用例与性能验证

4.1 手工构造用例:从简单到复杂

我建议不要一写完就急着提交,先准备一组覆盖不同难度的用例,在本地跑一遍。下面这组是我自己常用的:

编号输入化学方程式期望输出验证重点
12H2+O2=2H2OY最基础的标准配平
2H2+O2=H2ON等号右侧氧数量不足
3(NH4)2SO4=2NH3+H2SO4Y括号后数字与多原子化学式
4Fe2(SO4)3=Fe2O3+3SO3Y括号后数字 3、整体系数 3
5KClO3=KCl+O2N右侧氧数量不足
62KClO3=2KCl+3O2Y整体系数为多位数
712H2O=12H2OY整体系数为两位数

第 3 题里,左边(NH4)2SO4的 N 是 2、H 是 8,右边2NH3+H2SO4的 N 是 2、H 是 6+2=8,两边一致,所以输出 Y。第 4 题同样:左边 Fe2(SO4)3 是 Fe=2、S=3、O=12,右边 Fe2O3+3SO3 是 Fe=2、S=3、O=3+9=12,完全匹配。

4.2 本地验证一个完整样例

把上面代码保存为csp_201912_3.py,用下面这种here-document方式喂入数据,可以快速得到输出:

python3 csp_201912_3.py <<EOF 4 2H2+O2=2H2O H2+O2=H2O (NH4)2SO4=2NH3+H2SO4 Fe2(SO4)3=Fe2O3+3SO3 EOF

期望输出:

Y N Y Y

我实际跑下来,输出和期望完全一致。这里的第四行特意测试了括号后数字和化学式整体系数同时存在的情况,是这道题最容易写错的场景组合,值得单独测一次。

4.3 复杂度与稳定运行

每个化学式的长度在题目约束下非常有限,整套程序在数据范围最坏情况下也就是几十万量级的字符扫描,Python 在 1 秒内轻松跑完。

复杂度上,解析单个化学式是线性的,因为每个字符只会被位置指针读取一次,括号递归也只会把访问摊到常数倍。两侧的 Counter 合并同样是线性。所以这道题完全没有必要做任何优化,把精力放在写对递归上,比纠结常数时间重要得多。

从运行稳定性角度说,递归深度取决于括号嵌套层数,题目数据保证不会把 Python 的递归栈压爆,不需要手动改成迭代版本。如果实在不放心,也可以在代码开头加一句sys.setrecursionlimit(10000),不过对这道题来说纯属心理安慰。

5. 踩坑实录与考场排查技巧

5.1 最常见的六个错误

结合我准备这套题时反复踩过的坑,以及身边同学反馈的问题,这里整理一份高频错误清单:

错误类型错误示范正确做法
原子名只读大写字母把Cu读成 C、u大写开头后继续读小写字母
数字只读一位把12H2O的 12 读成 1用 while 循环读完所有数字
括号数字没乘到全部原子(OH)2只给 O 乘 2遍历括号内 Counter 逐项乘以倍数
整体系数乘错位置只给第一个原子乘系数先解析完整化学式,再整体乘系数
递归遇到右括号提前消费内层把)读走,外层错位内层只 break,由外层统一跳过)
直接修改录入字符串在循环里删除已读字符用位置指针移动,不修改原字符串

前三条是最多人翻车的点,后三条属于思路正确但实现细节出了问题。这些错误单独看都不难,难的是同时出现在一段代码里,而且是隐藏测试点触发之后才暴露出来。

5.2 考场上的排查顺序

如果你提交后拿到的是 WA,别急着反复重写,先按下面的顺序排查一遍。

先确认是不是原子名读取的问题。拿CuSO4这种双字母元素测试,如果统计结果里出现了 C 和 u,那问题就出在大小写读取规则上。

再确认括号数字是否匹配。拿Ca(OH)2测试,解析结果应该是 Ca=1、O=2、H=2。如果 O 只有 1,说明括号后的数字没有乘到括号内所有原子,或者根本没读。

接着确认整体系数。拿2Fe2O3测试,正确结果是 Fe=4、O=6。如果 Fe=2、O=3,说明整体系数被忽略了;如果 Fe=4、O=3,说明系数只乘了第一个原子。

这几个用例都很短,人工就能算出正确结果,非常适合作为“本地对拍”的输入。我在考场上的习惯是:每次写完大模拟,先在本地跑 4 到 6 个这样的小用例,确认无误再提交,能大幅度减少无谓的 WA 重交。

5.3 把解析器做成通用能力

这道题练完之后,最大的收获其实不是“会写化学方程式”,而是掌握了一个可复用的解析器模板。化学式、带括号的四则运算、JSON 的子集解析、命令行参数解析,底层都是同一套递归下降思路:遇到什么结构就调用对应的解析逻辑,所有解析状态由同一个位置指针维护。

以后再遇到类似题目,比如“表达式求值”“括号匹配”“嵌套结构展开”,可以直接复用这套框架,只需替换“读取原子”那一小段逻辑。这个模板的通用性,远比记住某一道题的答案更有价值。

最后再分享一点个人体会。我第一次写这道题时,也曾经试图用正则表达式一次性把化学式拆开,结果被括号嵌套折磨得狼狈不堪。后来把思路换成“递归下降 + Counter 统计”,代码量降到了 40 行出头,而且再也不用担心括号的写法。如果你现在也在准备 CSP,我建议把这道题吃透。它表面上是化学,内里是所有字符串解析题的缩影,学会之后,第三题的半壁江山基本就拿稳了。

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

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

立即咨询