CSP化学方程式配平:Python递归下降解析与满分实现 CSP认证的第三题历来是很多人眼里的分水岭。2020年之前那几年第三题基本都是大模拟字符串、状态机、递归解析轮着来数据范围不算大但代码量不小很考验考场上的冷静程度。201912-3这道“化学方程式配平”就是典型代表。题目内容一句话就能说清给你一个形如H2O2H2O或者2H2O22H2O的化学方程式判断等号左右两边每种原子的总数是否完全相等。听起来很简单但真动手写的时候“括号嵌套”“化学式系数”“原子符号大小写”这几个细节能卡住不少人。我见过很多同学思路是对的却在递归解析括号时把自己绕进去最后提交一堆错误代码。这篇文章会把完整的 Python 满分解法拆开来讲先讲清楚为什么要用“原子守恒 哈希计数”这个思路再逐步实现一个可复用的化学式解析器最后给出可以直接参考的满分代码以及一套能用来验证的测试用例。适合正在准备 CSP 第三题、或者想练字符串模拟题的人。1. 思路拆解先想清楚再动手1.1 这道题到底在考什么表面上考化学实际上考的是字符串解析。题目里约定了原子符号只有大写字母开头、最多跟一个小写字母例如Na、Au、Hg化学式可以带整体系数、原子可以带右下角数字、括号可以嵌套。整道题没有任何复杂的算法核心能力就两个第一把一个化学式字符串正确地拆成一个个原子并数出数量第二把等号两侧的所有原子数分别汇总后比较是否相同。前者考的是“解析”后者考的是“用哈希表做计数”。只要把这两个基础动作做稳满分就是水到渠成的事。很多同学拿到题目第一反应是去回忆化学知识甚至想写一套“配平器”但题目明确只要求判断是否配平不要求输出配平系数所以千万不要往复杂了想。判定配平比求解配平简单得多本质只是等式两边原子数是否完全一致。1.2 配平的本质原子守恒化学方程式配平在化学里的含义是反应前后原子种类和数目不变。放到程序里就是两边各维护一个字典以原子符号为键、以该原子出现的总数为值最后比较两个字典是否完全相等。比如2H2O22H2O左边 H 有 2×24 个O 有 2 个右边 H 有 2×24 个O 有 2 个两边相等输出 Y。而H2O2H2O左边 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 和 aAu不能读成 A 和 u。题目考察的就是这种细节很多第一次写这道题的人都在这里翻车。另外要特别提醒数字只属于紧跟它的那个原子或括号单元。Fe2O3里2 属于 Fe3 属于 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正确结果是 H4、O2如果只把 2 乘到 H 上就会得到 H4、O1最后判定结果直接反掉。这道题给不少考生造成的心理压力来自“化学”两个字但只要把它拆成“字符串解析”和“字典计数”两个动作代码其实非常简单。整份代码去掉空行和注释不超过 60 行核心的parse_molecule只有 30 行左右。3.3 边界情况与语言细节题目输入格式里第一行是一个整数 n表示接下来的化学方程式数量。每个方程式形如成分成分成分成分成分由可省略的整体系数、原子段、可能嵌套的括号组成。所有字符都是 ASCII没有空格不过strip()一下也没坏处。不会出现负系数不会出现小数系数所以所有数字都按十进制正整数读。题目也不会给空化学式所以part至少有一个字符。这些边界在判断时不用特意做分支因为解析函数本身已经兼容读不到数字时默认为 1遇到空字符串时返回空 Counter。还有一个很实用的细节Counter做加法时如果某个键不存在会从 0 开始加所以不需要事先给所有可能的元素符号初始化。这也是哈希计数相比“固定数组计数”的核心优势——你不需要预先知道会出现哪些原子。4. 测试用例与性能验证4.1 手工构造用例从简单到复杂我建议不要一写完就急着提交先准备一组覆盖不同难度的用例在本地跑一遍。下面这组是我自己常用的编号输入化学方程式期望输出验证重点12H2O22H2OY最基础的标准配平2H2O2H2ON等号右侧氧数量不足3(NH4)2SO42NH3H2SO4Y括号后数字与多原子化学式4Fe2(SO4)3Fe2O33SO3Y括号后数字 3、整体系数 35KClO3KClO2N右侧氧数量不足62KClO32KCl3O2Y整体系数为多位数712H2O12H2OY整体系数为两位数第 3 题里左边(NH4)2SO4的 N 是 2、H 是 8右边2NH3H2SO4的 N 是 2、H 是 628两边一致所以输出 Y。第 4 题同样左边 Fe2(SO4)3 是 Fe2、S3、O12右边 Fe2O33SO3 是 Fe2、S3、O3912完全匹配。4.2 本地验证一个完整样例把上面代码保存为csp_201912_3.py用下面这种here-document方式喂入数据可以快速得到输出python3 csp_201912_3.py EOF 4 2H2O22H2O H2O2H2O (NH4)2SO42NH3H2SO4 Fe2(SO4)3Fe2O33SO3 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测试解析结果应该是 Ca1、O2、H2。如果 O 只有 1说明括号后的数字没有乘到括号内所有原子或者根本没读。接着确认整体系数。拿2Fe2O3测试正确结果是 Fe4、O6。如果 Fe2、O3说明整体系数被忽略了如果 Fe4、O3说明系数只乘了第一个原子。这几个用例都很短人工就能算出正确结果非常适合作为“本地对拍”的输入。我在考场上的习惯是每次写完大模拟先在本地跑 4 到 6 个这样的小用例确认无误再提交能大幅度减少无谓的 WA 重交。5.3 把解析器做成通用能力这道题练完之后最大的收获其实不是“会写化学方程式”而是掌握了一个可复用的解析器模板。化学式、带括号的四则运算、JSON 的子集解析、命令行参数解析底层都是同一套递归下降思路遇到什么结构就调用对应的解析逻辑所有解析状态由同一个位置指针维护。以后再遇到类似题目比如“表达式求值”“括号匹配”“嵌套结构展开”可以直接复用这套框架只需替换“读取原子”那一小段逻辑。这个模板的通用性远比记住某一道题的答案更有价值。最后再分享一点个人体会。我第一次写这道题时也曾经试图用正则表达式一次性把化学式拆开结果被括号嵌套折磨得狼狈不堪。后来把思路换成“递归下降 Counter 统计”代码量降到了 40 行出头而且再也不用担心括号的写法。如果你现在也在准备 CSP我建议把这道题吃透。它表面上是化学内里是所有字符串解析题的缩影学会之后第三题的半壁江山基本就拿稳了。