LeetCode 972 相等有理数:分数法破解循环小数与字符串解析
先放结论这道题我前后花了 100 分钟才完全跑通其中大概 70 分钟耗在一条错误路线上。LeetCode 972“相等的有理数”代码量很小核心函数不到三十行但它把字符串解析、无限循环小数的数学表达、精确比较这三个坑叠在一起任何一个踩住都能让你在原地转很久。如果你正卡在这道题上或者准备刷困难题但总在“看起来对了”和“严格证明”之间摇摆这篇题解应该能帮你省下我当初浪费掉的那一个多小时。1. 为什么一道“解析字符串”的题会挂上困难标签1.1 先看清题目到底给了哪几种输入题目要求实现isRationalEqual(s, t)判断两个字符串表示的有理数是否相等。字符串只会出现三种形态纯整数比如8、-3有限小数比如0.5、2.12循环小数用一对括号把循环节括起来比如0.(52)表示0.525252...-2.(10)表示-2.101010...。注意括号前可能没有任何数字比如0.(52)也可能是0.5(25)这种先有一段不循环的5再进入循环节25。字符串长度上限是 100所以循环节可以很长负号也可能出现。我第一次读题时觉得这是道送分题把两种字符串都展开成小数比一比不就行了实际写下去才发现问题远没有这么简单。1.2 三个一眼看不出来、但决定成败的隐藏点第一个隐藏点十进制小数表示不是唯一的。0.5和0.50相等0.5和0.4(9)也相等因为0.4999... 0.5。如果直接用字符串比较遇到这种等价表示必然误判。第二个隐藏点无限循环小数没法用double精确表示。0.(1234567890)的循环节有 10 位转成浮点数之后精度随时可能丢失。即便循环节很短比如0.(3)表示1/3浮点数乘法也会引入误差最后只能用“绝对值小于 epsilon”这种工程近似可题目要求的是精确相等不是“差不多相等”。第三个隐藏点负数、空循环节、没有小数部分这些格式组合解析时任何一个分支没覆盖到代码就崩。LeetCode 的测试用例构造得很细我刚写完第一版时自测了几个常见 case 都过了一提交就挂在-0.(9)和-1这种边界上。所以这道题虽然不涉及任何高级数据结构却把“数学建模能力”和“边界处理能力”两个基本功同时考了一遍。能挂上困难标签靠的就是这两板斧。2. 最稳的解法不展开小数直接把每个数变成精确分数2.1 有限小数转分数的数学原理有限小数转分数很简单小数点后有几位就除以 10 的几次方。比如0.123 123 / 1000。本质含义是把 1 平均分成 1000 份取其中 123 份。如果带有整数部分比如2.12那就拆成2 12/100约分后是53/25。这个操作不会丢失任何精度因为有限小数本质上就是分母为 10 的幂的分数。2.2 循环小数转分数错位相减是核心循环小数的处理需要一次代数变换。以0.(52)为例设x 0.(52)两边同时乘以 100因为循环节长度是 2所以乘 10 的 2 次方100x 52.(52)用下式减去上式100x - x 52.(52) - 0.(52) 99x 52 x 52 / 99这就是循环节转分数的通用公式循环节组成的整数除以 10 的循环节长度次方减 1。再举一个0.(9)会是9/9 1所以0.(9)和1在有理数意义下就是同一个数分数法天然能算出这个结果这正是它比字符串展开法强的地方。2.3 混合形式非循环部分 循环节怎么合并0.5(25)这种结构要拆成两部分看0.5是有限部分(25)是从小数点后第 1 位之后开始循环的部分。写成x 0.5 0.0(25)其中0.0(25)等价于把0.(25)整体缩小 10 倍也就是(25/99) / 10 25/990。于是x 5/10 25/990 495/990 25/990 520/990 52/99得到的结果和0.(52)完全一样。这验证了题目名字里的“相等的有理数”——两个字符串表面不同但表示的分数相同所以应该返回 true。更一般地对于a.b(c)这种形式设非循环部分b的长度为L循环节c的长度为R整体值可以直接写成a b / 10^L c / (10^L * (10^R - 1))这个公式就是代码的直译。c是整数如果是空字符串就当 0 处理b同理。分母里的(10^R - 1)来自错位相减10^L负责把循环节挪到正确的小数位之后。2.4 Python 实现用 Fraction 让约分自动完成Python 的fractions.Fraction会自动做通分和约分两个分数相等等价于分子分母交叉相乘相等所以直接用判断就可以。from fractions import Fraction def parse(s: str) - Fraction: sign 1 if s[0] -: sign -1 s s[1:] # 按三种格式拆分整数部分、非循环节、循环节 if ( in s: int_part, tail s.split(.) nonrep, rep tail.split(() rep rep.rstrip()) elif . in s: int_part, nonrep s.split(.) rep else: int_part, nonrep, rep s, , ans Fraction(int(int_part) if int_part else 0, 1) L len(nonrep) if nonrep: ans Fraction(int(nonrep), 10 ** L) if rep: ans Fraction(int(rep), (10 ** len(rep) - 1) * (10 ** L)) return ans if sign 1 else -ans class Solution: def isRationalEqual(self, s: str, t: str) - bool: return parse(s) parse(t)重点解释几个分支int_part为空的情况几乎不会出现但为了避免int()崩掉加了个if int_part else 0整数格式没有小数点所以先判断( in s再判断. in srep为空时10 ** 0 - 1 0会变成分母为零所以必须在if rep:分支里才做循环节的加法。我实际跑下来这段代码能直接通过所有测试用例。关键在于Fraction会处理大整数和通分比如0.1(6)转出来是1/60.166(6)转出来也是1/6约分之后相等LeetCode 直接判 true。3. 为什么“展开 10000 位小数”的解法不够严谨3.1 展开法为什么看起来能过我一开始走的就是展开法把循环节复制很多次补足到比如 10000 位然后直接比较两个展开后的字符串。思路很直观两个有理数若相等它们的十进制展开每一位都相同若不相等总会在某一位分出差异。理论上只要展开得足够长差异就一定会暴露。第一版代码写起来也快测了几个常规样例全过0.(52)展开成525252...0.5(25)展开成525252...一样0.5和0.50展开后一个是5一个是50我在补零逻辑里也处理成了5000...和5000...也能对上。但提交后第一个失败用例就是0.9(9)和1。展开法给出的结果是9999...对0000...字符串不相等于是返回 false。可0.999...的极限就是 1两个字符串描述的是同一个有理数正确答案显然是 true。3.2 0.999... 问题怎么捅破展开法的窗户纸问题不在展开长度不够而在十进制表示本身不唯一。1可以写成1.000...也可以写成0.999...。任何有限位数内的比较都无法识别这种等价性因为差异发生在“无穷位之后”——实际上是发生在每一位上但靠着无限个 9 和无限个 0 互相抵消了。有人会想那我检测到循环节全是 9 的时候单独做一次进位不就行了理论上可行但实现起来要处理一堆连锁情况比如0.09(9)进位之后变成0.10.99(9)进位之后变成1甚至12.349(9)进位之后变成12.35。这本质上是在手写小数加法而且进位可能一路传播到整数部分逻辑复杂度和出错率远远超过直接转分数。更麻烦的是如果循环节是999但前面还有非循环部分判断条件会变得很琐碎。每加一个特判就多一片新的 bug 温床。到最后我意识到展开法加上进位特判本质上是在重新实现一个不完整的分数运算那不如直接回到分数法。3.3 两种解法的对比与适用边界对比维度分数法展开法正确性严格天然的代数等价不严格需要额外处理 9 的循环进位实现难度低公式直译低到中等但特判越多越容易出错对0.(9)的处理自然得到 1需要单独设计进位逻辑适合场景算法题、金融计算、任何要求精确相等的场景工程近似、误差可容忍的浮点比较展开法并非一无是处。如果一个项目只需要判断两个数字字符串是否“在有限精度内近似相等”展开法实现快、直观配合固定精度截断也能用。但在 LeetCode 这种要求严格逻辑正确的环境里它属于“侥幸能过但理论上有洞”的解法。测试数据可能没覆盖到但你不能赌它不覆盖。4. 卡了我两个小时的细节解析陷阱、边界用例与本地验证4.1 字符串解析的三个易碎点第一个易碎点是括号前没有非循环节。0.(52)拆出来之后nonrep是空字符串此时L 010 的 0 次方是 1分母就是(10^2 - 1) * 1 99。公式本身没问题但新手容易在int()上报错所以一定要判空。第二个易碎点是整数格式。8没有小数点如果代码里直接s.split(.)解包会得到只有一个元素的列表解包成int_part, nonrep直接崩溃。必须先用( in s、. in s做分支。第三个易碎点是负号。-0.(9)的数学值等于-1。我在第一版代码里把负号直接丢给int(int_part)处理结果int_part是-0不仅负号丢失后续计算也乱了。正确做法是先把符号位单独摘出来解析完绝对值部分后在返回前统一把负号乘回去。4.2 建议你提交前先跑一遍的边界用例下面这个表格是我本地反复验证过的一组用例覆盖了整数、有限小数、循环小数、负数和各种等价表示st预期结果说明00.0true整数与有限小数等价0.(52)0.5(25)true两种不同的字符串表示同一个分数0.9(9)1true0.999... 等于 10.8(3)0.83(3)true都等于 5/6-1-0.(9)true负数也遵循同样的规律0.0(0)0true全零循环节等于 01.0(0)1true循环节为 0 不影响值0.1(6)0.1666(6)true都等于 1/6非循环节长度不同0.3330.3(3)false一个是有限小数一个是 1/3严格不等特别注意最后一组。0.333是有限小数精确值是333/10000.3(3)是无限循环小数精确值是1/3。两者差0.000333...展开法如果只展开 3 位可能误判但分数法算出来分子分母不同直接返回 false。4.3 一个有效的本地验证方法我做完分数法之后没有马上提交而是写了一个暴力展开法的脚本作为对照组随机生成合法的输入字符串然后用Fraction的解析结果和暴力展开结果互相验证。具体做法是给定一个字符串我先把循环节重复 20 次生成一个超长小数再借用decimal模块的高精度十进制数转成近似分数与parse函数算出的精确分数做差值看差值是否趋近于 0。这种方法在开发阶段帮了我大忙。因为有些等价关系肉眼很难看出来比如0.1(6)和0.166(6)是否相等靠心算容易翻车但程序一跑就清楚了。建议你也保留一个这样的调试入口尤其是在做字符串解析类题目时fuzz test比手动造数据高效得多。5. 从 972 这道题看一类“字符串 数学”题型的通用解法5.1 先识别数学结构再写解析代码拿到这种题目第一件事不是写split而是想清楚输入到底描述了什么数学对象对象之间的运算规则是什么本题里对象是“有理数”运算是“相等”“相等”的判定标准是“化为最简分数后分子分母完全相同”。想通这一点之后代码结构就非常清晰输入字符串 → 提取整数部分、非循环节、循环节 → 套公式转分数 → 比较分数。所有字符串处理的细节都只是为数学公式服务的前置步骤。反过来如果不做数学建模一上来就写字符串替换、循环复制代码会越写越复杂最后变成一个充满补丁的泥潭。LeetCode 上很多“中等偏难”的题目都有这个特点数据结构的名字听着高级实际解法全靠数学。5.2 精确计算优先于浮点近似这道题最直观的教训就是别用浮点数。0.1 0.2 ! 0.3是每个程序员都踩过的坑但一旦进入“字符串解析 数值比较”的场景很多人又会下意识选择float()一把梭。有理数的精确表示其实很简单一个分子、一个分母。Python 的Fraction可以帮你做约分和通分即使不用现成库手写一个约分函数也只要十几行。代价是计算变慢但在 LeetCode 的规模下完全可以忽略。延伸到实际工作中处理金融金额、测量数据、概率统计时这种“精确优先”的思想同样适用。能用整数表示的量不要用浮点能通分的式子不要先算小数很多诡异的数据偏差在源头就避免了。5.3 这道题值不值得花 100 分钟我的体会是值得先说结论值得但前提是搞清楚自己卡在哪。我在最初的 70 分钟里一直用展开法在错误路线上打转后来停下来推导循环小数的代数结构十分钟就写完了正确版本。这一百分钟真正教会我的不是972的解法而是“当某种方案越写越复杂时先停下来重新审视问题的数学本质”。LeetCode 热门 100 题大多侧重数据结构和经典算法像 972 这种“字符串 数学”的组合在热门清单里并不常见但它在笔试、周赛里出现的频率很高。这种题的难度不在代码量而在“把自然语言描述翻译成精确数学表达式”的能力。练上一道相当于同时巩固了字符串边界处理、有理数运算、测试用例设计三项基本功。如果你目前也在刷这道题我的建议是先别看完整题解自己拿笔推一遍0.(52) 52/99的错位相减过程再试着写解析函数最后用我上面给的边界用例做回归。把这条路走通之后你会发现 972 的代码其实只是一个公式的翻译而已。