浮点数加法怎么破?从字符串模拟高精度计算到北大机试实战
第一次在题库里看到“浮点数加法北京大学考研机试真题”这道题时很多人的第一反应是这有什么好写的直接定义两个 double加一下输出不就完事了但真拿到北大机试的环境里跑一遍大概率会吃个哑巴亏。这个题挂在“高精度计算”这个分类下核心考的是字符串模拟加法不是浮点运算。标题里的 PKUKY150 如果我没记错是某个 OJ 题库里的题号题目本身并不长但坑不少很适合拿来练手。这篇文章就从拿到题开始一步一步拆解这个“浮点数加法”到底想考什么、怎么下手写、有哪些细节容易被忽略。如果你正在准备考研机试或者刚开始刷高精度模拟这类题这篇内容应该能帮你省下不少踩坑的时间。1. 拿到题目先别急先看清它到底考什么1.1 为什么不能直接用 double 一把梭我先说结论这道题如果你敢直接用 double 做基本是白给。原因很简单——输入的两个浮点数可能极其长长到 double 连近似值都存不下。double 在计算机里用 64 位存储其中有效数字部分只有 53 位换算成十进制大概是 15 到 17 位有效数字。也就是说超过这个位数的数字double 会自动做四舍五入精度直接丢得一干二净。而这类机试题给的数据动不动就是几十位、上百位的数字double 一上去就变成了一堆科学计数法后续的加法结果自然对不上。更麻烦的是浮点数本身还有精度误差比如 0.1 在二进制里是一个无限循环小数存进 double 里本身就是近似值。两个近似值相加结果还是近似值而题目要的是精确输出。所以正确的做法只有一条把输入当成字符串逐位做加法。1.2 大数加法的底层逻辑就是“列竖式”其实不管是整数大数加法还是浮点数大数加法本质都是我们小学学过的“列竖式”。把两个数按位对齐从最低位开始逐位相加满十进一。浮点数碰到的问题只是多了两个小数点要对齐、整数部分和小数部分要分开处理。换句话说这道题 两次大数加法 一堆格式处理。把整数部分当一个大整数来加小数部分也当一个大整数来加唯一不同的是小数部分对齐方向是左侧补零整数部分对齐方向是右侧补零——等一下这里我说反了实际是这样的小数部分两个数的小数位数可能不一样比如 1.2 和 3.456那小数部分就是“2”和“456”要在短的后面补零变成“200”和“456”。整数部分两个数整数位数可能不一样比如 123 和 45那整数部分就是“123”和“45”要在短的前面补零变成“123”和“045”。这样做好之后两部分就可以各自按普通大数加法处理了。整个思路清晰了后面写代码就顺了。1.3 这类题在机试中的真实定位北大计算机考研机试的风格是代码量不算大但特别看重基本功和边界处理能力。浮点数加法就是典型代表——它没有复杂的算法没有高级数据结构考的就是你处理字符串、控制进位、处理边界条件这些底层能力。你能不能在紧张的环境下把这种看似简单的问题写得滴水不漏这才是出题人想看的。所以这道题刷一遍的价值不在于它本身有多难而在于它是一个很好的“基本功试金石”。把这道题吃透了同类的高精度加减法、大数乘法基本上都能横向迁移。2. 数据预处理先把字符串收拾得干干净净2.1 拆分整数部分和小数部分我用的思路是把输入拆成四个部分a 的整数部分、a 的小数部分、b 的整数部分、b 的小数部分。具体用字符串的 find 找小数点找到就分割找不到就说明这个数没有小数部分小数部分直接置为空字符串。这里有个边界情况要注意有的输入可能是“123.”这种形式也就是小数点存在但后面没有数字。这种情况在题目里可能出现也可能不出现稳妥的做法是兼容它小数部分为空就当作没有小数部分处理。拆分这一步用 C 的 substr 就能搞定。找到小数点的索引从开头截到小数点前是整数部分从小数点后截到末尾是小数部分。pairstring, string splitNum(const string s) { size_t pos s.find(.); if (pos string::npos) { return {s, }; } return {s.substr(0, pos), s.substr(pos 1)}; }这段代码简洁但已经把主要逻辑说清了。注意题目如果要求多组输入每个输入都要重新拆分一次所以这个函数要独立出来。2.2 去掉前导零和末尾零别小看这一步很多第一次写这道题的人会忽略清理零的工作。比如输入是“000123.4500”如果你不做任何处理直接把整数部分“000123”拿去加最后结果会输出一串多余的前导零比如“00130.9500”OJ 判题时大概率会判错。所以做加法之前必须先把整数部分的前导零去掉把小数部分的末尾零去掉。注意“去掉”不是随便删要保留至少一位数字。比如整数部分是空串或全零应该变成“0”小数部分全是零应该变成空串。string stripLeadingZeros(string s) { size_t pos s.find_first_not_of(0); if (pos string::npos) { return 0; } return s.substr(pos); } string stripTrailingZeros(string s) { while (!s.empty() s.back() 0) { s.pop_back(); } return s; }stripLeadingZeros 里如果整个字符串全是 ‘0’find_first_not_of 会返回 npos我直接返回 “0”这样后面不管怎么拼接结果都是正常的。这一步就是典型的“做不好就会在边界翻车”的细节。2.3 小数部分补零对齐先处理两个小数部分让它们的长度一致。补零的方向是右侧也就是在短的小数部分末尾补 ‘0’。这一步的目的是让逐位加法的时候每一位都能对上位置。补零之后还要额外记住一个信息小数部分的最终长度。因为后面输出结果的时候如果小数部分全为零我们可以选择不输出小数点和这一串零如果有非零的小数位就要完整输出。string aFrac stripTrailingZeros(aFracRaw); string bFrac stripTrailingZeros(bFracRaw); int fracLen max(aFrac.length(), bFrac.length()); while (aFrac.length() fracLen) aFrac 0; while (bFrac.length() fracLen) bFrac 0;补完零之后两个小数部分一样长下一步就可以直接逐位加了。3. 核心加法实现从低位到高位一寸一寸推进3.1 整数部分加法——高精度加法的标准写法整数部分的加法是整道题的地基。我单独写一个函数 addIntegers接收两个字符串返回它们的和。整体思路是两个字符串从右到左逐位相加用一个变量 carry 记录进位。为了方便很多教程会把字符串翻转过来让低位在索引 0 的位置这样遍历起来更自然。我习惯翻转因为直接从右往左处理需要各种下标换算容易写着写着就晕了。string addIntegers(string a, string b) { reverse(a.begin(), a.end()); reverse(b.begin(), b.end()); int len max(a.length(), b.length()); int carry 0; string res; for (int i 0; i len; i) { int digitA i a.length() ? a[i] - 0 : 0; int digitB i b.length() ? b[i] - 0 : 0; int sum digitA digitB carry; carry sum / 10; res.push_back(char(sum % 10 0)); } if (carry) { res.push_back(char(carry 0)); } reverse(res.begin(), res.end()); return res; }这里要注意几点短的字符串越界时补 0进位 carry 在循环结束后如果还有值说明最高位有进位要在结果最前面补一个 1最后别忘了翻转回来这样结果的排列才是正常的高位在左。3.2 小数部分加法——思路一样方向换成从左往右小数部分的加法和整数部分基本上是一个模子刻出来的。唯一区别是两个小数部分在补零之后长度相等我们从最右侧开始加也就是从最后一位开始往前遍历进位同样往左传递最后如果最高位有进位这个进位要加到整数部分的结果里去。我直接复用补零后的字符串从末尾开始遍历避免重新翻转string addFractions(string a, string b, int carryOut) { int i a.length() - 1; int carry 0; string res; while (i 0) { int sum (a[i] - 0) (b[i] - 0) carry; carry sum / 10; res.push_back(char(sum % 10 0)); i--; } carryOut carry; reverse(res.begin(), res.end()); return res; }对和整数加法的差别只是这里两个字符串长度一致不需要判断越界而且最后一位的进位不能直接拼到字符串前面而是要交给整数部分去处理。这一点特别容易忘记一旦忘了像 99.9 0.1 这种输入就会输出错误的答案。3.3 拼接结果整数部分和小数部分的组合策略整数部分相加得到 sumInt小数部分相加得到 sumFrac然后处理进位最后把它们拼起来。拼接的时候要考虑几个情况如果原始输入的两个数都没有小数部分或者小数部分相加后全是 0就不输出小数点和后面的 0。如果有小数部分就输出“整数部分.小数部分”。完整的处理流程我写一个主入口函数从上到下把各个步骤串起来string addFloatStrings(string a, string b) { auto [aIntRaw, aFracRaw] splitNum(a); auto [bIntRaw, bFracRaw] splitNum(b); string aInt stripLeadingZeros(aIntRaw); string bInt stripLeadingZeros(bIntRaw); string aFrac stripTrailingZeros(aFracRaw); string bFrac stripTrailingZeros(bFracRaw); int fracLen max(aFrac.length(), bFrac.length()); while (aFrac.length() fracLen) aFrac 0; while (bFrac.length() fracLen) bFrac 0; string sumInt addIntegers(aInt, bInt); int carryOut 0; string sumFrac; if (fracLen 0) { sumFrac addFractions(aFrac, bFrac, carryOut); if (carryOut 0) { sumInt addIntegers(sumInt, 1); } } string result sumInt; if (fracLen 0 sumFrac.find_first_not_of(0) ! string::npos) { result . sumFrac; } return result; }我在判断要不要输出小数部分时增加了一个条件sumFrac 里只要还有非零数字就输出。如果小数部分全是 0就只输出整数部分这样 1.0 2.0 会输出 3而不是 3.000…。3.4 一个完整的可运行代码模板把这些函数拼在一起主函数里用 while 循环支持多组输入。考虑到北大机试可能有“多组测试数据直到 EOF”的约定我用 while (cin a b) 是最稳妥的#include iostream #include string #include algorithm using namespace std; pairstring, string splitNum(const string s) { size_t pos s.find(.); if (pos string::npos) { return {s, }; } return {s.substr(0, pos), s.substr(pos 1)}; } string stripLeadingZeros(string s) { size_t pos s.find_first_not_of(0); if (pos string::npos) { return 0; } return s.substr(pos); } string stripTrailingZeros(string s) { while (!s.empty() s.back() 0) { s.pop_back(); } return s; } string addIntegers(string a, string b) { reverse(a.begin(), a.end()); reverse(b.begin(), b.end()); int len max(a.length(), b.length()); int carry 0; string res; for (int i 0; i len; i) { int digitA i a.length() ? a[i] - 0 : 0; int digitB i b.length() ? b[i] - 0 : 0; int sum digitA digitB carry; carry sum / 10; res.push_back(char(sum % 10 0)); } if (carry) { res.push_back(char(carry 0)); } reverse(res.begin(), res.end()); return res; } string addFractions(string a, string b, int carryOut) { int i a.length() - 1; int carry 0; string res; while (i 0) { int sum (a[i] - 0) (b[i] - 0) carry; carry sum / 10; res.push_back(char(sum % 10 0)); i--; } carryOut carry; reverse(res.begin(), res.end()); return res; } string addFloatStrings(string a, string b) { auto pa splitNum(a); auto pb splitNum(b); string aInt stripLeadingZeros(pa.first); string bInt stripLeadingZeros(pb.first); string aFrac stripTrailingZeros(pa.second); string bFrac stripTrailingZeros(pb.second); int fracLen max(aFrac.length(), bFrac.length()); while (aFrac.length() fracLen) aFrac 0; while (bFrac.length() fracLen) bFrac 0; string sumInt addIntegers(aInt, bInt); int carryOut 0; string sumFrac; if (fracLen 0) { sumFrac addFractions(aFrac, bFrac, carryOut); if (carryOut 0) { sumInt addIntegers(sumInt, 1); } } string result sumInt; if (fracLen 0 sumFrac.find_first_not_of(0) ! string::npos) { result . sumFrac; } return result; } int main() { ios::sync_with_stdio(false); cin.tie(nullptr); string a, b; while (cin a b) { cout addFloatStrings(a, b) \n; } return 0; }这段代码在本地编译后我拿几组常见的用例跑过123.456 78.9、999.999 0.001、0.0 0.0、100.0 200.5输出都符合预期。当然每台机器对“不需要输出末尾零”的要求可能略有不同这个要看你刷题时 OJ 的具体要求。有的 OJ 要求原样补零有的要求删除末尾零我的代码是删末尾零的版本如果题目要求不同把删除末尾零和判断输出小数部分的逻辑调整一下即可。4. 常见问题与排查技巧我刷这道题踩过的坑4.1 输入可能是“0”或者全零“0.0 0.0”这种最容易被忽略。如果你没有做前导零清理结果可能是一堆空字符串拼接最后输出“.”这种莫名其妙的东西。我处理的办法是整数部分全零时保留一个 “0”小数部分全零时直接置空最后判断 sumFrac 是否全零再决定要不要输出小数点。实测下来这一套逻辑对所有全零组合都安全。4.2 小数位数不一致时别忘补零比如“1.1 2.345”如果你不补零小数部分直接逐位相加会变成“1 345”这种错位的计算结果就是错的。补零之后变成“1.100 2.345”两个小数部分都是 3 位逐位相加就不会出问题。这个坑看起来简单但真到了考场时间一紧很容易犯。我自己的习惯是在写代码前先手写一个测试用例把“小数位不一致”的情况标出来专门验证。4.3 进位从小数部分跳到整数部分“9.9 0.1”这种用例是经典的进位测试。小数部分相加等于 10保留 0 进 1这个 1 要加到整数部分 9 上面最终结果是 10。如果你忘了处理小数部分的最高位进位结果会变成 9.0这种错误哪怕你用很多测试用例都不一定能发现。我建议你写完代码后至少跑三组进位相关用例小数部分进位、整数部分连续进位、两者同时进位都过了再说。4.4 读题要看清是一行两个数还是先一个数再一个数北大这些机试题里输入格式一般是一行包含两个浮点数空格分隔。但也有些版本是分开两行。我的 while (cin a b) 写法对“一行两个数”和“两行各一个数”都兼容因为 cin 会忽略换行符纯粹按空白字符切分。如果你用 getline 去读那就要自己解析一行里的多个数麻烦得多。所以能不用 getline 就别用直接 cin 最省事。4.5 输出格式删零还是不删零这类题最让刷题人头疼的其实是输出格式。我上面写的代码是删掉末尾零、删掉前导零的“最干净输出”版本。但有的题目要求保留原始小数位数比如“1.00 2.00”必须输出“3.00”那就不能删末尾零。建议拿到题先看样例如果样例输出是 3.00你就得把 stripTrailingZeros 和 sumFrac 的判断逻辑改掉保留补零后的长度。这道题我刷到的版本是“不要求保留末尾零”的但不同年份、不同 OJ 可能不一样这是整个题目里最需要根据题目要求调整的地方。5. 从这道题延伸出去高精度模拟的通用套路把浮点数加法做完之后你会发现高精度减法、乘法甚至除法的套路都差不多。它们都是在做几件事读字符串、清理格式、按位运算、管理进位、最后再整理格式。区别只是运算方式从加减变成了乘除还多了些借位、错位、循环之类的细节。如果你是想把这类题吃透建议拿到这道题之后再用同样的框架练一下“大整数乘法”。大整数乘法的核心是从低位开始逐位相乘把中间结果错位累加最后统一处理进位。有了浮点数加法的基础你会发现这个过程非常容易理解几乎就是同一套思维方式的复刻。我个人在实际练习里最大的体会是高精度题目的难点从来不在于算法本身而在于你能不能把细节管理好。拆字符串、清空、对齐、拼接这些步骤每一步看起来都不难但一旦在某个边界情况上疏忽整个程序就崩了。所以做这类题花时间最多的不是写代码而是设计测试用例。每写完一个函数我都会手动过一遍至少五组测试数据包括全零、不同位数、连续进位、没有小数点、全是小数点等等确认没问题再提交。如果你也在准备机试建议你把这道题的完整代码背下来不是死记硬背而是理解每一步在干什么。等你能闭着眼把浮点数加法的流程写出来再遇到“大数相加”“小数相加”这类变体题基本就是送分题了。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →