
高精度减法这个东西说难真不难本质就是把你小学学过的竖式减法搬到程序里但说简单也绝不简单我见过不少写高精度加法挺顺的人一到减法就各种翻车。翻车的原因很集中加法只要盯住进位减法却要同时处理借位、比较大小、符号输出三件事。尤其是去OJ刷题的时候那些“高精度减法例题”看着都平平无奇无非是“求两个大整数之差”可真正提交起来WA到怀疑人生的情况特别常见。这篇文章我就把高精度减法的原理、完整代码、经典例题和调试心得一次讲透所有的写法和测试都是我实际验证过的刚入门的新手可以照着抄写过加法但没写过减法的人也能从中找到自己容易忽略的细节。1. 为什么高精度减法值得单独拿出来写1.1 内置类型撑不住“大数”先聊一个很现实的问题为什么需要高精度减法C的int能表示的最大整数大概是21亿折算下来只有10位long long封顶大约是9223372036854775807也就是约9.2×10^18大概19位。看起来挺长的可一旦题目要求你算两个100位的大整数相减或者做500位的差内置类型立刻“溢出”结果变成一堆莫名其妙的值。有人会说不是还有double吗double的范围能到1.79×10^308数量级看起来完全够用。但double只有大约15到16位有效数字它能存下10^300这种数量级却没法精确表示其中每一位的具体数字。这就是浮点数的精度局限范围广和精度高是两回事。两个大数相减想要精确结果浮点数根本不靠谱。所以高精度算法的核心思路特别朴素一个数放不下就用一个数组来放一位一位地存、一位一位地算。打个比方一个小箱子装不下的货就拆成很多个小格子每个格子存一位数字运算时逐格处理。高精度减法就是在这堆“格子”之间模拟十进制竖式减法的全过程。这个“逐位存储、逐位计算”的思路决定了后面减法代码的基本结构一个数组存被减数的每一位一个数组存减数的每一位循环模拟从低位到高位的相减和借位。理解了这一点你再看任何高精度减法模板都不会觉得它是天书了。1.2 竖式减法的三个关键动作现在回忆一下竖式减法。算432减87的时候从个位开始2减7不够向十位借1个位变成1212减7等于5十位上的3被借走1变成22减8又不够向百位借1十位变成1212减8等于4百位上的4被借走1变成33减0等于3。最后答案345。把整个过程翻译成程序其实就三步从低位到高位逐位相减如果当前位不够减向高位借1本位加10高位要减掉被借走的那个1。这三步拆开看都很简单但串起来就和加法拉开了差距。加法里进位永远在“本位算完之后”影响下一位减法里借位在开始计算下一位之前就已经生效了。如果你用同一个变量既记录“当前位要减掉的借位”又记录“当前位产生的借位”特别容易搞混。竖式减法还有一个加法里不存在的动作判断谁大谁小。432减87能直接算但87减432就不一样了需要先判断出被减数更小然后交换两个数最后在结果前面加一个负号。这个判断放在减法函数里面还是外面很多人一开始没想明白结果负数用例一测就崩。所以我一直建议把“比较大小”单独抽成一个函数和“减法主体”分开。减法函数只认一个前提传入的a的绝对值不小于b的绝对值。至于谁减谁、要不要输出负号交给调用方去判断。模块功能单一出错概率就低后面写高精度乘法和除法时还能复用这个比较逻辑。2. 高精度减法实现前必须想清楚的三件事2.1 存储为什么低位放在数组前面处理高精度减法第一步是输入的大整数怎么存进数组。通常做法是用字符串读入比如123456然后从字符串末尾往前遍历转换成数字放进vector。这样vector下标0存的是个位6下标1存的是十位5最高位在vector最后面。为什么非要低位在前因为减法和加法一样运算从个位开始。循环时下标从0递增第一个处理到的正好是个位计算顺序和竖式顺序天然一致。如果你把高位放在数组前面减到一半还得从数组尾部往前循环借位处理、下标控制都容易写错没有任何好处。我习惯用vector而不是固定数组原因是vector自带size()push_back还能自动扩容。用int a[505]固定数组也可以但必须额外维护一个len变量每次操作后还得小心翼翼更新它忘记更新的bug特别难排查。vector的size()直接代表有效位数代码写起来清爽不少。字符串转数组的代码长这样vectorint a, b; for (int i (int)s1.length() - 1; i 0; i--) { a.push_back(s1[i] - 0); } for (int i (int)s2.length() - 1; i 0; i--) { b.push_back(s2[i] - 0); }这里有个低级但常见的坑字符6在C里和数字6不是同一个东西。如果直接push_back(s1[i])存进去的是ASCII码54后面算出来的结果全是错的。我见过不少人在这上面折腾半天最后发现是忘了减0。我自己也犯过所以现在都会多看一眼。2.2 比较决定符号的关键一步高精度减法里符号判断和减法本身同等重要。题面通常只保证输入是非负整数不保证A一定大于等于B所以你必须自己比较。比较两个大整数大小的规则其实就是小学知识先看位数位数多的数大位数相同从最高位往低位逐位比哪一位先不相等谁的数字大谁就大。翻译成代码int compare(const string a, const string b) { if (a.length() ! b.length()) { return a.length() b.length() ? 1 : -1; } for (size_t i 0; i a.length(); i) { if (a[i] ! b[i]) { return a[i] b[i] ? 1 : -1; } } return 0; }这里藏着一个隐藏前提传入的字符串不能带前导零。比如00012和3直接按长度比较前者长度5后者长度1会得出“00012更大”的结论可数值上12明明小于3。哪怕长度相同逐字符比较也会被前导零干扰。所以读入大整数之后先做去前导零的预处理或者保证题面输入无前导零否则比较结果就是错的。这个坑我放到第4章专门说。比较函数返回1、0、-1三态分别表示a大于b、等于b、小于b。主函数里根据返回值走分支返回0直接输出0减法函数都不用调用返回1调用sub(A数组, B数组)返回-1调用sub(B数组, A数组)并在输出前加负号。有人可能会问为什么不把符号判断直接塞进sub函数因为sub函数的职责应该是“绝对值相减”。把“比较大小”和“符号输出”耦合在一起代码难以测试也难复用。后面写高精度除法的时候经常需要反复用到绝对值相减到时候你肯定不希望它动不动给你吐出一个负号。2.3 借位最容易写错的一环借位处理是减法函数的核心。我推荐下面这种写法vectorint sub(vectorint a, vectorint b) { vectorint c; int t 0; for (int i 0; i (int)a.size(); i) { t a[i] - t; if (i (int)b.size()) { t - b[i]; } if (t 0) { t 10; c.push_back(t); t 1; } else { c.push_back(t); t 0; } } while (c.size() 1 c.back() 0) { c.pop_back(); } return c; }逐行解释一下。循环里的t在每次迭代开始的时候代表“上一位借走的1”在迭代结束的时候被重新赋值成“当前位向更高位借走的1”。同一个变量前后两个含义这就是新手最容易晕的地方。t a[i] - t这一步先把上一位的借位从被减数的当前位里减掉。接着if (i b.size()) t - b[i]如果这一位在减数里存在就再减掉它。为什么要判断i b.size()因为a可能比b长循环已经超过b的位数时b的这一位应当按0处理。减完之后如果t 0说明不够减需要向高位借1。此时先给当前位加10再存进结果然后把t设为1表示“我借了高位的1下一位要少算1”。反过来如果t 0直接把t存进结果t设为0表示没有借位。最后一行while (c.size() 1 c.back() 0) c.pop_back();是去前导零。比如运算结果是00123清完后变成123。如果整个结果就是0要清到只剩一个0为止不能把唯一的0也删掉否则输出变成空串。这套写法的优点是把借位逻辑压缩在一个循环里没有额外分支也不容易越界。网上还有一种写法先把两个数组逐位相减再开一个循环统一处理负数位。那种写法更贴近人的直觉但亲手写过就知道处理“最后一位刚好是0”以及“借位连续传递到最高位”的时候要额外当心数组越界。我实测下来推荐这个写法更稳。3. 完整代码实现与经典例题实战3.1 一道典型的高精度减法例题很多OJ上都有一道经典题比如洛谷P2142题目描述很短求两个大的正整数相减的差。输入两行各一个大整数A和B长度不超过1000位输出A减B的结果题面保证输入是非负整数。样例输入是99999999999999999999 1样例输出是99999999999999999998如果把输入反过来1 99999999999999999999输出应该是-99999999999999999998这种例题看着简单却是检验减法实现的最好试金石。1000位的规模刚好是long long的数十倍以上逼着你老老实实用数组题目又不保证A大于B逼着你去处理符号。你的代码里如果缺了比较、缺了负号输出、缺了前导零去除这题一交就能全部暴露。3.2 完整参考代码我把完整可提交的C代码放在这里可以直接对照。为了清晰比较、减法、主流程分开写#include iostream #include string #include vector using namespace std; void trim(string s) { size_t pos s.find_first_not_of(0); if (pos string::npos) { s 0; } else if (pos 0) { s s.substr(pos); } } int compare(const string a, const string b) { if (a.length() ! b.length()) { return a.length() b.length() ? 1 : -1; } for (size_t i 0; i a.length(); i) { if (a[i] ! b[i]) { return a[i] b[i] ? 1 : -1; } } return 0; } vectorint sub(vectorint a, vectorint b) { vectorint c; int t 0; for (int i 0; i (int)a.size(); i) { t a[i] - t; if (i (int)b.size()) { t - b[i]; } if (t 0) { t 10; c.push_back(t); t 1; } else { c.push_back(t); t 0; } } while (c.size() 1 c.back() 0) { c.pop_back(); } return c; } int main() { string s1, s2; cin s1 s2; trim(s1); trim(s2); vectorint a, b, c; for (int i (int)s1.length() - 1; i 0; i--) { a.push_back(s1[i] - 0); } for (int i (int)s2.length() - 1; i 0; i--) { b.push_back(s2[i] - 0); } int flag compare(s1, s2); if (flag 0) { cout 0 endl; return 0; } if (flag 0) { c sub(a, b); } else { c sub(b, a); cout -; } for (int i (int)c.size() - 1; i 0; i--) { cout c[i]; } cout endl; return 0; }这段代码我多次实测过常规情况都能正确运行。trim函数放在主函数最开始就是为了抹掉前导零带来的所有隐患。值得一提的是sub函数内部假设传入的a的绝对值大于等于b主函数通过compare结果保证了这一点所以减法函数可以保持纯粹。3.3 复杂度与边界情况验证复杂度层面减法主循环的次数等于a的位数也就是较长那个数的位数n时间复杂度O(n)。比较函数扫描一次字符串同样O(n)。空间上开了两个vector存输入、一个vector存结果都是O(n)级别。所以整个程序的时间和空间都是线性的1000位输入跑起来毫无压力百万位级别也能在一两秒内完成。边界情况我列几个最典型的写完代码之后可以逐一验证第一个A等于B。比如两个都是12345678901234567890compare返回0走直接输出0的分支不进sub函数。如果你忘了这个分支让sub处理相等的数虽然去前导零后也能得到0但万一实现不严谨可能输出-0直接被判错。第二个同位数但大小不确定。比如500减499结果1。中间会发生十位向百位借位个位向十位借位正好检验借位标志的连续传递是否正确。第三个结果中间有0。比如1000减999结果1但逐位运算过程会产生一堆0最后靠去前导零的while循环清掉。如果去前导零写成while (c.back() 0) c.pop_back()没判断只剩下一个元素全部0被删光输出空串直接RE或者WA。第四个连续长借位。比如1000000000000000000000000减1除了最高位以外全部变成9。这种连续借位最容易暴露借位标志更新顺序错误建议必测。4. 实战中踩过的坑与性能优化技巧4.1 前导零隐藏最深的敌人题目说输入是大整数但OJ数据里偶尔会出现000123这种带前导零的输入。数值上它和123完全等价可字符串上它就是一个6位串。问题在于如果你的compare函数直接拿字符串比较就可能得出完全错误的结论。举个例子00023和99。compare先看长度前者5位后者2位直接判定00023更大可是99明明大于23。这就是长度比较在带前导零时失效的典型情况。再比如00023和00199长度都是5逐字符比较到第2位时前导零0和1就已经分出大小虽然这次碰巧结论正确但这是运气不是逻辑可靠。你可能会觉得那读入的时候手动跳过去不就行了确实最稳妥的做法是在读入后立刻清理前导零void trim(string s) { size_t pos s.find_first_not_of(0); if (pos string::npos) { s 0; } else { s s.substr(pos); } }这里find_first_not_of(0)找第一个不是0的字符位置。如果整串全是0说明这个数就是0直接置为0。处理完后哪怕输入是000也能保证至少有一个0字符后续转换不会得到空数组。我个人的习惯是凡是从外部读入的大整数一律先trim再进入后续逻辑。这个习惯在写高精度乘法和除法时救过我很多次前导零问题在乘法里同样会导致错误结果。千万不要觉得OJ都是好人题目数据永远规范你无法预测测试用例会塞进来什么。4.2 压位优化万进制让循环次数少4倍基础版高精度减法每个数组元素存一位十进制数字循环次数等于位数。面对1000位量级完全没问题但如果你要处理上百万位的数字一位一位循环就有点浪费了。优化思路是把进制从10改成10000也就是“万进制”。每个数组元素不存0到9而是存0到9999相当于把每4位十进制数字打包进一个int。同样的百万位数数组长度直接缩到25万主循环次数降到原来的四分之一。压位版的减法代码和十进制版几乎一模一样只要把“满10进1”换成“满10000进1”“借1当10”换成“借1当10000”const int BASE 10000; vectorint sub_press(vectorint a, vectorint b) { vectorint c; int t 0; for (int i 0; i (int)a.size(); i) { t a[i] - t; if (i (int)b.size()) { t - b[i]; } if (t 0) { c.push_back(t BASE); t 1; } else { c.push_back(t); t 0; } } while (c.size() 1 c.back() 0) { c.pop_back(); } return c; }压位真正的难点不在减法本身而在输入输出。输入的时候要把字符串按4位一组从低位切到高位输出的时候最高位所在块直接输出其他块必须用%04d补足4位。比如结果数组是[234, 1]表示1*1000023410234。如果最高位输出1后直接cout输出234拼出来是1234但正确应该是10234。少了一个0肉眼很难发现提交就只能WA。输出函数通常是这样的void print(vectorint c) { cout c.back(); for (int i (int)c.size() - 2; i 0; i--) { printf(%04d, c[i]); } }压位优化在1000位级别的题目前显得有点多余但它能帮你提前建立“多位打包”的思维。后面学高精度乘法甚至接触FFT优化时都是同一个思路的变体。有余力的读者建议写一遍压位版减法体会一下输入输出处理和普通版的差异。4.3 常见问题速查表我把实际调试中遇到最多的几个问题整理成一张表方便自查现象可能原因解决办法输出结果前面多出一堆0忘记去前导零减法结果末尾用while循环pop_back负数结果没有负号没有比较A和B的大小主函数中用compare判断后输出负号结果比正确答案差若干借位标志没有正确传递到下一位检查t在每轮结束后的赋值输入带前导零时判断出错直接比较原始字符串读入后先trim前导零再比较压位输出少了0每组没有用%04d补位高位块直接输出低位块printf(%04d)把字符当数字输出字符转数字时忘了减0push_back时用s[i]-0数组越界或段错误访问b[i]时没判断i b.size()循环内加if保护字符串逆序转换时死循环length()返回size_t减1后回绕先强转为int再减1这里面“结果比正确答案差若干”比较有迷惑性。借位是按1传递的某一位漏传最终结果通常会差出10的某个幂次而不是普普通通差1。排查的时候拿正确答案和错误结果逐位对一下往往能快速定位到错在哪一位再反推那一轮循环的借位状态。5. 从减法到整套高精度运算框架5.1 负数参与运算的基本思路竞赛里高精度减法通常默认输入非负但实际工程或更复杂的题目中负数迟早会来。处理负数的核心思路不是给减法函数加一堆分支而是统一成“绝对值加符号”的模型。具体做法是把大整数解析成一个结构体包含两个字段bool sign表示正负vector digits表示绝对值部分。这样一来所有加减法都能规约成四种组合正数加正数绝对值相加符号为正。正数减正数比较绝对值大小大减小符号由大小决定。正数减负数等价于两个正数的绝对值相加符号为正。负数减正数等价于两个正数的绝对值相加后取负。写成伪代码就是function add(a, b) { if (a.sign b.sign) { return new BigInt(a.sign, addAbs(a.digits, b.digits)); } else { int cmp compareAbs(a.digits, b.digits); if (cmp 0) { return new BigInt(a.sign, subAbs(a.digits, b.digits)); } else { return new BigInt(b.sign, subAbs(b.digits, a.digits)); } } }这样抽出来后每个底层函数只做一件事addAbs只做绝对值加法subAbs只做绝对值减法compareAbs只做绝对值比较。符号逻辑全部集中在add和sub这一层。实际开发中调试起来非常舒服因为底层函数可以单独拉出来做随机测试。如果你后续要写BigInt类建议一开始就把sign和digits分开存。把负号混进digits数组里会让所有运算和输出逻辑都变得一团糟。这是过来人的经验别问我是怎么知道的。5.2 减法在整套高精度框架中的位置学完减法和加法高精度运算剩下的大头是乘法和除法。很多人觉得乘法难但乘法的基础操作就是“多次加法加移位”除法更直接基础操作是“反复试商加乘法加减法”。以最朴素的竖式除法为例你要从被除数高位开始逐步试商。试商过程本质上是一个循环用当前余数减去除数的商倍减不动就停。这里的“减去除数的商倍”就是高精度减法。如果你的减法实现不稳固除法一定会被牵连。换句话说高精度减法不是一道孤立的小题它是整套大数运算里被复用最多的零件之一。加法管进位减法管借位比较大小贯穿始终。这三样东西搭好了乘法和除法就有了坚实的地基。很多人学高精度时只盯着算法模板忽略了模块化设计。我建议哪怕是刷题也要把compare、addAbs、subAbs写成独立函数哪怕只是自己用。等以后写高精度除以高精度、高精度取模的时候你会发现当初抽出来的这些“小零件”全都派得上用场。我个人的体会是高精度减法这套代码一旦彻底调对后面很多大数题目都可以直接复用所以值得花点时间把上面提到的边界情况全部跑一遍。最后再分享一个我一直在用的调试小技巧别靠肉眼检查结果直接写对拍脚本。用Python生成几百组随机大整数让Python自己算a减b再把你的C程序跑出的结果逐行对比有差异就立刻打印出数据。Python写对拍脚本也就十分钟的事但它能把你代码里所有隐藏的边界问题一次炸出来。我第一次用这个办法给高精度减法找bug当天就抓到三个前导零相关的WA全是肉眼完全发现不了的那种。后来写高精度乘法和除法我也沿用同样的对拍流程省下来的调试时间远超当初写脚本花掉的时间。