1. 项目概述:这是一道绕不开的基础题
第一次在蓝桥杯真题里看到“高精度减法”时,我其实有点不以为然——减法谁不会?小学一年级的内容。直到自己动手写了一遍,才发现这里面的坑比想象中多得多。字符串转数字、借位处理、符号判断、前导零清理,任何一个环节没想清楚,评测就是一遍又一遍的WA(Wrong Answer)。
先把这个题目拆开看:所谓高精度,就是当参与运算的数字超出了编程语言内置数据类型的表示范围时,我们改用数组或字符串来手工模拟四则运算。C++里的long long最大也就19位左右(约9.2×10^18),而这类竞赛题目的约定往往是1000位、10000位甚至更长。long long完全顶不住,必须自己造轮子。
这道题适合两类人:一是刚接触算法竞赛、准备蓝桥杯省赛的新手,高精度是必须翻过的一堵墙,掌握它之后大数加法、乘法、除法、阶乘、斐波那契大数版都能顺藤摸瓜;二是想系统梳理“字符串模拟运算”思路的选手,虽然题面简单,但把它吃透,字符串相关的处理能力会有一个实质性的提升。
我打算从零开始,把这道题涉及的思路拆解、代码实现、调试踩坑、性能优化一次讲透,你可以直接照着复现,也可以把它当作模板背下来,后面遇到变体题再改。
2. 为什么非要用高精度:数据范围是竞赛的第一约束
2.1 内置类型的上限就是天花板
不说理论,直接看数字。C++中常用整型:
| 类型 | 位数 | 最大值 |
|---|---|---|
| int | 32位 | 2147483647(约2.1×10^9) |
| long long | 64位 | 9223372036854775807(约9.2×10^18) |
| unsigned long long | 64位 | 18446744073709551615(约1.8×10^19) |
看到没,哪怕把unsigned long long撑满,也就20位。而蓝桥杯高精度题目的输入规模,至少100位起步,多的能到几千位。这种数据量下,任何直觉上“先转成数字再算”的方案都是错的。我见过不少新手用long double或者__int128去莽,前者精度丢失,后者虽然能撑到39位,但遇到几百位的输入照样崩。
所以结论很明确:当数字位数超过内置类型的承载能力,就必须把数字当作字符串读入,用数组逐位存储,逐位运算。这不仅是这道题的解法,也是处理大整数问题的通用范式。
2.2 高精度减法“特殊”在哪里
很多人会先学会高精度加法,然后觉得减法就是把加法里的进位改成借位,改两行就完事。真上手才会发现:减法的难点不在“减”本身,而在大小比较和符号处理。
- 如果
a >= b,正常逐位相减,结果为非负数; - 如果
a < b,需要交换两个数,在结果前面加负号; - 逐位相减时出现负数要向高位借1,相当于当前位+10、高位-1;
- 最终结果要去掉前导零,但至少要保留一个0(比如
5 - 5 = 0不能输出空串)。
这几个点单独拎出来都简单,组合在一起就很容易出错。尤其是a < b的判断,如果用了字符串直接比较,必须保证两个字符串长度一致才合法,长度不同的情况要先按长度比较。这个细节我当年第一次写的时候就漏了,WA到怀疑人生。
3. 整体设计思路:用字符串还是用整型数组
3.1 两种存储方式的取舍
高精度数在代码里一般有两种存法:string(字符数组)和int数组。
用string存,读入方便,cin >> a直接搞定;字符串长度就是数字位数,也方便比较;但每一位是字符,运算时要- '0'转换。
用int数组存,运算时不用反复转换,但读入时要手动把每一位拆开塞进数组,通常还要倒序存储(低位在前),写起来略繁琐。
我个人推荐比赛时直接用string。理由很简单:高精度减法在蓝桥杯里就是一道基础题,字符串方案代码短、逻辑直观、不易错。到了高精度乘法、压位高精度时再考虑int数组也不迟。写题首先是求稳,不是炫技。
3.2 核心步骤拆解
整个减法分四步走:
- 读入两个大数,存成字符串
a、b; - 比较大小,确定结果的正负;若
a < b则交换,记标志位; - 逐位相减,从最低位(字符串末尾)开始,处理借位;
- 清理前导零并输出,注意负号输出。
其中第2步是“减法专属的钥匙”,做加法时不需要,所以很多从加法转向减法的人卡在了这里。第3步的逐位相减虽然代码只有十几行,但借位逻辑必须从头到尾想清楚,不能凭感觉写。
3.3 为什么要从最低位开始算
人类手算减法是先从个位开始,从右往左;代码模拟自然也是如此。字符串的存储方向是高位在前(下标0是最高位),因此运算时要从a.length() - 1开始往左遍历。这也是使用string最需要留神的地方——倒序遍历时下标容易写乱,一乱就数组越界或者漏位。
这里有一个小技巧:两个数长度不一样时,短的数高位视为0。你可以事先在短字符串前面补0对齐,也可以在循环里加条件判断,见招拆招。两种做法都能过,我习惯用标准写法:循环条件设为i >= 0 || j >= 0,处理时用i >= 0 ? a[i] - '0' : 0这样取位,省去补零的额外操作。
4. 核心代码实现:从比较到借位的完整闭环
4.1 比较大小的实现
这部分不能直接用a < b,因为字符串比较是字典序:"9" > "123",但数字9小于123。必须先看长度:
// 判断 a 是否小于 b(a、b均为非负数字字符串) bool isLess(const string& a, const string& b) { if (a.length() != b.length()) { return a.length() < b.length(); } return a < b; // 长度相等时,字典序就是数字大小顺序 }长度相等时,"123" < "124"与数字大小完全一致,所以可以直接比。这个函数是整个减法正确性的地基。注意参数用const string&,避免不必要的拷贝,虽然这题数据量不大,但这个习惯要养成。
4.2 借位相减的实现
假设已经保证a >= b,下面这个函数负责计算a - b:
string sub(string a, string b) { string res; int borrow = 0; int i = a.length() - 1; int j = b.length() - 1; while (i >= 0 || j >= 0) { int digitA = (i >= 0) ? a[i] - '0' : 0; int digitB = (j >= 0) ? b[j] - '0' : 0; int diff = digitA - digitB - borrow; if (diff < 0) { // 需要借位 diff += 10; borrow = 1; } else { borrow = 0; } res.push_back(diff + '0'); // 注意这里是从低位往高位push的 i--; j--; } while (res.length() > 1 && res.back() == '0') { res.pop_back(); // 去掉前导零(此时末尾对应最高位) } reverse(res.begin(), res.end()); // 反转成正序输出 return res; }我解释几个容易卡壳的点:
为什么diff < 0时要+10而不是+9?因为基数就是10。当前位不够减,向高位借1,相当于当前位凭空多了10,所以diff += 10。这跟我们手算时“借1当10”一模一样。
borrow为什么只取0或1?因为是十进制减法,每次最多只借1。即便出现了0 - 9这种极端情况,借1后0 + 10 - 9 = 1,也够用。这一点和加法进位只进1是等价的,属于十进制运算的自然性质。
为什么循环条件是i >= 0 || j >= 0?因为a和b长度可能不同,短的先遍历完,另一根还没走完。用||连接才能保证较长的那根继续处理。那如果a = "1000"、b = "1",最后会得到一个只有最高位是1、其余全是0的结果"0999"?不对,我们逐位看:0-1要借位变成10-1=9,高位又继续借位,最终结果是"999",借位一直传导到最高位。这个“链式借位”是减法最容易错的地方,写完后建议人工模拟一遍。
为什么要reverse?因为循环里push_back是把低位先放进去,得到的res是倒序的(比如321实际代表123)。反转成"123"后再输出。这里特别注意:res.back()对应的是最高位,所以清理前导零也是在反转之前做,顺序不要搞反。
4.3 带符号处理的完整封装
string bigSubtract(string a, string b) { bool negative = false; if (isLess(a, b)) { swap(a, b); negative = true; } string result = sub(a, b); if (negative) { result = "-" + result; } return result; }这段代码非常短,但它的价值在于把“符号处理”和“主体运算”解耦。sub函数只负责非负数的减法,不管正负;符号由外层统一决定。这种分层设计在竞赛代码中很实用——你只需要测试sub的正确性,外层逻辑看一眼就能确认。
4.4 完整可运行代码
把上面几段拼起来,再加上输入输出,就是一个可以直接交的完整程序:
#include <iostream> #include <string> #include <algorithm> using namespace std; bool isLess(const string& a, const string& b) { if (a.length() != b.length()) { return a.length() < b.length(); } return a < b; } // 前提:a >= b,且 a、b 均非负 string sub(string a, string b) { string res; int borrow = 0; int i = a.length() - 1, j = b.length() - 1; while (i >= 0 || j >= 0) { int da = (i >= 0) ? a[i] - '0' : 0; int db = (j >= 0) ? b[j] - '0' : 0; int diff = da - db - borrow; if (diff < 0) { diff += 10; borrow = 1; } else { borrow = 0; } res.push_back(diff + '0'); i--; j--; } while (res.size() > 1 && res.back() == '0') { res.pop_back(); } reverse(res.begin(), res.end()); return res; } string bigSubtract(string a, string b) { bool neg = false; if (isLess(a, b)) { swap(a, b); neg = true; } string res = sub(a, b); if (neg) res = "-" + res; return res; } int main() { string a, b; while (cin >> a >> b) { cout << bigSubtract(a, b) << endl; } return 0; }注意循环读入(while (cin >> a >> b))是竞赛里常见的写法,支持多组测试用例。蓝桥杯虽然一般是一组输入,但多组输入的这个习惯能防住一部分变体题。
5. 实操过程中的关键案例验证
光写完代码不算完,必须用一组覆盖全场景的测试数据来证明代码没错。我习惯把这五类用例跑一遍:
5.1 正常借位场景
输入:1000 1
模拟过程:0-1不够,借位,变10-1=9,高位继续借……最终res倒序是"0999",去掉前导0得到"999",反转后输出999。
结果:999。这个用例能检验链式借位是否正确。
5.2 结果为零的场景
输入:42 42
每一位相减都是0,res倒序是"00",清理前导零后只剩"0",输出0。
这个用例最容易错:如果清理前导零时把最后一个0也删了,输出空串直接判错。所以清理条件必须是res.size() > 1,而不是res.size() > 0。
5.3 负数场景
输入:1 1000
isLess("1", "1000")返回true(长度1 < 长度4),交换后a = "1000"、b = "1",算出999,加上负号输出-999。
这个用例检验符号判断。如果把isLess写成return a < b而不看长度,"1" < "1000"按字典序是false,那就把正负号算反了。
5.4 长度不等且无借位场景
输入:321 1
为了对齐,b的高位视为0。1-1=0,2-0=2,3-0=3,倒序得到"023",清理前导零得"23",反转输出320。
这个用例看似简单,但能暴露“短数字高位补0”的处理是否正确。如果你事先给短的补零且补到位,结果也是一样的。
5.5 相等长度连续借位场景
输入:100000000000000000000 99999999999999999999
两个20位数字,除了最高位1以外全是0,减一个19位全9的数。逐位借位会一路传导,最考验耐心的就是这种用例。正确结果是1。跑完这个用例,你的链式借位基本就过关了。
这五个用例我每次写完高精度减法都会跑一遍,十几秒就能确认代码是否可交。说真的,比直接提交到评测系统里试错高效得多。
6. 常见问题与排查技巧实录
6.1 排行榜第一的WA原因:前导零没清干净
症状:输出000123、0000、甚至空串。
排查思路:检查清理前导零的代码位置。注意清理必须在reverse之前,对应最高位的方向。同时要保留至少一位数字,防止所有位都为0时被删空。
6.2 符号判断反了
症状:1 - 2输出1,2 - 1输出-1。
排查思路:检查isLess比较逻辑。先比长度、长度相等再比字典序,缺一不可。很多人只记得比数字大小,忘了"9" > "100"这种长度为王的情况。
6.3 借位状态未重置
症状:中间某位忽然多减了1,或者结果整体偏小。
排查思路:借位必须逐位更新。每轮循环结束要明确borrow的新状态:借位发生则下一轮borrow=1,否则为0。我见过把borrow写在if外面忘记更新的写法,结果借位只影响了一位,后面全错。
6.4 下标越界或漏位
症状:结果位数不对,或者程序直接崩溃。
排查思路:仔细检查a[i] - '0'这类访问。循环里i和j可能一个先变成-1,另一个还大于等于0,所以取位时必须判断下标合法性。用i >= 0 ? ... : 0这种写法能避免越界。
6.5 负号拼接位置错了
症状:输出1-或者-0。
排查思路:-0是个有趣的坑。比如输入5 5,按代码逻辑a不小于b,不会加负号,所以不会出现-0。但如果你的负号判断在清理前导零之后、加负号之前没有判断result == "0",理论上可能出现-0。为了保险,可以在加负号前判断:若结果为"0"则不加负号。更通用的做法:if (negative && result != "0") result = "-" + result;。
6.6 速查表
| 症状 | 可能原因 | 排查方法 |
|---|---|---|
输出000123 | 前导零清理失败 | 确认清理在reverse前,保留至少一位 |
| 输出空串 | 前导零全删 | 清理条件改为res.size() > 1 |
1-2结果为正 | isLess未先比长度 | 先比较length()再比较字典序 |
| 结果末尾多1 | 借位忘传给下一位 | 每轮更新borrow |
| 结果位数少一位 | 循环条件写成&& | 改为i >= 0 || j >= 0 |
结果带-0 | 符号拼接未判零 | 结果为0时不加负号 |
7. 进一步优化:从“能过”到“写得优雅”
7.1 压位高精度
string方案虽然好写,但每个字符只存一位十进制,空间和时间利用率都不高。到了高精度乘法或阶乘,动辄上百万位的运算,用int数组做压位能提速好几倍。
所谓压位,就是每个int元素存4位或9位十进制数,一次运算处理更多位。比如base = 10000时一个int存4位,数组长度就能压缩到原来的四分之一。缺点是代码复杂度上升,进位/借位基数不再是10而是base,容易写错。
我的建议是:蓝桥杯基础题用string做,见复杂大整数题再上压位。比赛求的是通过率,不是秀操作。
7.2 减少字符串拷贝
上面的代码为了清晰,直接传值传入string。如果数据量巨大,频繁拷贝会拖慢速度。可以改成传引用:
string sub(const string& a, const string& b);bigSubtract里交换a、b时确实需要拷贝(因为交换本身就是拷贝),但sub内部不需要修改原始数据。实测在千位级数据下差距不大,但在万位以上,传引用能省掉大量时间。
7.3 一个偷懒小技巧:复用加法模板
如果你已经写过bigAdd,可以这样组合出减法思想上的对照:加法处理进位,减法处理借位,其余结构几乎一致。把两份代码并排放在一起看,你会更深刻理解为什么借位要diff += 10、进位要sum -= 10——一个往高位补,一个往高位送。这种对比记忆法,比死记代码骨架有效得多。
8. 我的实操体会
这道题我前前后后写过不下五遍,从最初快两百行还带各种if嵌套的“屎代码”,到后来四五十行的干净解法,最大的体会就是:高精度减法的难点不在“减”,而在“格式化”。两个大数谁大谁小、结果正还是负、前导零去没去干净、空串和-0有没有处理,这些问题每一个都比逐位相减本身更容易踩雷。
也正因如此,我强烈建议你把这套模板背到滚瓜烂熟,而不是考试时临时推演。蓝桥杯的评测系统是按点给分的,一道高精度题往往有多组测试数据,任何一组的边界情况没考虑好,这一道题就废了大半。把这五个测试用例当护身符,提交前跑一遍,心里就有底了。
最后再分享一个实用扩展:掌握了高精度减法之后,高精度加法几乎不用学;高精度乘法只需要引入“错位相加”的思路;高精度除法稍微复杂一点,但如果你能把减法模板吃透,也能在它基础上推出来。说到底,蓝桥杯的算法提高组考的不是偏题怪题,而是这些基础运算扎实不扎实。把减法这道题当成磨刀石,后面你会发现自己处理字符串和边界问题的功力上了一个台阶。