第一次在题库里看到“浮点数加法(北京大学考研机试真题)”这道题时,很多人的第一反应是:这有什么好写的?直接定义两个 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 就能搞定。找到小数点的索引,从开头截到小数点前是整数部分,从小数点后截到末尾是小数部分。
pair<string, 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; pair<string, 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. 从这道题延伸出去:高精度模拟的通用套路
把浮点数加法做完之后,你会发现高精度减法、乘法甚至除法的套路都差不多。它们都是在做几件事:读字符串、清理格式、按位运算、管理进位、最后再整理格式。区别只是运算方式从加减变成了乘除,还多了些借位、错位、循环之类的细节。
如果你是想把这类题吃透,建议拿到这道题之后,再用同样的框架练一下“大整数乘法”。大整数乘法的核心是从低位开始逐位相乘,把中间结果错位累加,最后统一处理进位。有了浮点数加法的基础,你会发现这个过程非常容易理解,几乎就是同一套思维方式的复刻。
我个人在实际练习里最大的体会是:高精度题目的难点从来不在于算法本身,而在于你能不能把细节管理好。拆字符串、清空、对齐、拼接这些步骤,每一步看起来都不难,但一旦在某个边界情况上疏忽,整个程序就崩了。所以做这类题,花时间最多的不是写代码,而是设计测试用例。每写完一个函数,我都会手动过一遍至少五组测试数据,包括全零、不同位数、连续进位、没有小数点、全是小数点等等,确认没问题再提交。
如果你也在准备机试,建议你把这道题的完整代码背下来,不是死记硬背,而是理解每一步在干什么。等你能闭着眼把浮点数加法的流程写出来,再遇到“大数相加”“小数相加”这类变体题,基本就是送分题了。