高精度减法C++实现:从原理到避坑指南
1. 为什么高精度减法值得单独拎出来讲很多人第一次接触高精度运算都是从加法开始的。原因很简单加法逻辑最直观逐位相加进位处理一下就完事。但减法不一样它多了一个“符号判断”和“借位链”的问题稍不留神就会在边界用例上翻车。我在实际项目里见过太多这样的情况——加法写得漂漂亮亮一到减法就出现前导零没清干净、结果为负时输出格式错乱、或者借位传播时数组越界。高精度减法说白了就是计算机没法用内置类型直接存下超大整数时我们用数组或字符串来模拟手算减法的过程。C里long long最多也就装下19位左右的十进制数遇到几百位甚至上万位的数字只能自己动手实现。这个需求在算法竞赛、大数计算工具、密码学原型验证、甚至某些财务系统里都会碰到。这篇文章适合谁看如果你已经会写高精度加法但对减法的借位处理总是心里没底那这篇就是写给你的。如果你完全没接触过高精度运算也没关系我会从最基础的存储方式讲起把每一步的“为什么”都说清楚。读完你至少能做到三件事第一理解高精度减法的完整逻辑链第二能自己写出一个稳定可用的C实现第三知道实际写代码时哪些坑最容易踩、怎么绕过去。我下面讲的所有内容都是基于“用数组存每一位数字从低位到高位依次运算”这个经典方案展开的。这个方案不是唯一的但它是最好理解、最容易调试、也最适合作为入门跳板的。你把这个吃透了后面再去看压位优化、FFT加速之类的进阶技巧会顺畅很多。2. 核心思路拆解从手算到代码的映射2.1 存储方式的选择与理由高精度减法第一步要解决的问题是数字怎么存。C里没有现成的大整数类型所以必须自己设计数据结构。常见的选择有三种std::string、std::vectorint、以及固定长度的int数组。我个人的偏好是std::vectorint理由有三个。第一vector的大小是动态的不需要提前预估数字有多少位。你用string的话虽然也能动态增长但每次取某一位数字都要做- 0的转换写起来啰嗦。第二vector可以直接用下标访问和数组一样方便同时又自带size()方法不用额外维护长度变量。第三调试的时候vector可以直接用范围for循环打印比裸数组省心。存储顺序上我强烈建议低位存在下标0高位存在下标大的位置。也就是说数字12345在数组里是[5, 4, 3, 2, 1]。为什么这么存因为减法是从最低位开始算的低位放在前面循环就是从0开始往上走借位也是往高位传逻辑最自然。如果你反过来存每次都要从末尾往前遍历代码里全是size()-1-i这种表达式容易写错。// 把字符串转成低位在前的vector vectorint strToVec(const string s) { vectorint v; for (int i s.size() - 1; i 0; i--) { v.push_back(s[i] - 0); } return v; }这段代码看着简单但有一个细节要注意输入字符串可能带前导零比如00123。转成vector之后是[3,2,1,0,0]高位多了两个零。这在后续减法里会导致结果长度虚高所以要么在转换时就去掉前导零要么在减法结束后统一清理。我一般选择在减法结束后清理因为转换函数保持纯粹职责更单一。2.2 符号处理的两种策略高精度减法最绕的地方在于被减数可能比减数小结果会是负数。处理这个符号问题有两种常见策略。第一种策略是先比较大小再决定谁减谁。如果被减数大于等于减数直接算结果为正如果被减数小于减数交换两者算完之后在结果前面加负号。这种策略的好处是减法核心逻辑只需要处理“大减小”的情况不用在借位的同时还考虑符号代码更干净。第二种策略是带符号运算把符号单独存一个变量减法核心逻辑同时处理正负组合。这种策略更通用但实现复杂度明显上升尤其是两个数一正一负的时候实际上会变成加法。对于入门和大多数应用场景来说第一种策略完全够用而且不容易出错。我下面讲的实现采用第一种策略。具体来说先写一个比较函数判断两个vector表示的数值谁大谁小。比较的规则是先比有效位数去掉前导零之后的长度位数多的更大位数相同就从高位往低位逐位比较。// 比较两个低位在前的vector的大小 // 返回1表示ab0表示ab-1表示ab int cmpVec(const vectorint a, const vectorint b) { // 先去掉前导零高位零 int la a.size(), lb b.size(); while (la 1 a[la-1] 0) la--; while (lb 1 b[lb-1] 0) lb--; if (la ! lb) return la lb ? 1 : -1; for (int i la - 1; i 0; i--) { if (a[i] ! b[i]) return a[i] b[i] ? 1 : -1; } return 0; }这个比较函数里while循环去掉前导零的写法值得注意。条件是la 1而不是la 0这是为了保证即使数字是0也至少保留一位不会把vector清空。这个细节如果不注意后面减法里访问a[0]就会越界。2.3 借位机制的本质减法的核心是借位。手算的时候如果某一位不够减就从高位借1当10。代码里模拟这个过程需要一个变量来记录“当前是否欠了高位一个1”。我习惯把这个变量叫做borrow初始为0。每一位的计算公式是当前位结果 a[i] - borrow - b[i]。如果结果小于0说明不够减需要向高位借位结果加10borrow设为1否则borrow设为0。这个逻辑用代码写出来就是int diff a[i] - borrow - b[i]; if (diff 0) { diff 10; borrow 1; } else { borrow 0; } res.push_back(diff);这里有一个容易忽略的点循环结束之后如果borrow还是1说明最高位也借了但被减数已经没位了。这种情况在“大减小”的前提下不会发生因为我们保证了被减数大于等于减数。但如果你没有做前置比较直接算就会出现最高位借位后结果为负的情况需要额外处理。这也是为什么我推荐先比较再减。借位链还有一个隐蔽的问题连续借位。比如1000减1个位不够减向十位借十位是0再向百位借百位也是0再向千位借。这个过程在代码里不需要显式地“连续借”因为每一位的计算都是独立的borrow变量会自动把借位需求往高位传递。你只要保证循环从低位到高位依次执行借位就会自然地传播上去。3. 完整实现与关键细节剖析3.1 减法核心函数的逐行解读把前面讲的存储、比较、借位三块拼起来就是一个完整的高精度减法函数。我下面给出一个可以直接编译运行的版本然后逐段解释关键行。#include iostream #include vector #include string #include algorithm using namespace std; vectorint strToVec(const string s) { vectorint v; for (int i s.size() - 1; i 0; i--) { v.push_back(s[i] - 0); } return v; } int cmpVec(const vectorint a, const vectorint b) { int la a.size(), lb b.size(); while (la 1 a[la-1] 0) la--; while (lb 1 b[lb-1] 0) lb--; if (la ! lb) return la lb ? 1 : -1; for (int i la - 1; i 0; i--) { if (a[i] ! b[i]) return a[i] b[i] ? 1 : -1; } return 0; } // 前提a b vectorint subCore(const vectorint a, const vectorint b) { vectorint res; int borrow 0; for (int i 0; i (int)a.size(); i) { int diff a[i] - borrow - (i (int)b.size() ? b[i] : 0); if (diff 0) { diff 10; borrow 1; } else { borrow 0; } res.push_back(diff); } // 去掉高位多余的零 while (res.size() 1 res.back() 0) { res.pop_back(); } return res; } string vecToStr(const vectorint v) { string s; for (int i v.size() - 1; i 0; i--) { s char(0 v[i]); } return s; } string bigSub(const string sa, const string sb) { vectorint a strToVec(sa); vectorint b strToVec(sb); int c cmpVec(a, b); if (c 0) return 0; if (c 0) { return vecToStr(subCore(a, b)); } else { return - vecToStr(subCore(b, a)); } } int main() { cout bigSub(1000, 1) endl; // 999 cout bigSub(123456789, 98765432) endl; // 24691357 cout bigSub(100, 200) endl; // -100 cout bigSub(0, 0) endl; // 0 return 0; }subCore函数里有一行值得单独说int diff a[i] - borrow - (i (int)b.size() ? b[i] : 0);。这里用三元运算符处理了减数位数比被减数少的情况。因为我们已经保证了a b所以a的位数一定大于等于b的位数循环以a.size()为准当i超出b的范围时减数那一位按0算。这个写法比先补齐两个vector再减要简洁也避免了不必要的内存分配。另一个细节是res.back() 0的去零循环。条件res.size() 1保证了结果至少保留一位即使结果是0也会输出0而不是空字符串。这个边界条件在面试手写代码时经常被考察很多人会写成res.size() 0导致0减0的结果变成空串。3.2 前导零清理的时机与陷阱前导零这个问题说大不大说小不小但它是最容易让程序输出“看起来对但实际错”的地方。我见过有人把去零逻辑放在subCore的循环里面每算一位就检查一次结果把中间位的零也去掉了。比如1000减999正确结果是1中间过程会产生[1,0,0,0]这样的中间数组如果你在循环里去零可能把某些位误删。正确的做法是所有位算完之后从高位往低位统一清理。也就是我上面代码里while (res.size() 1 res.back() 0) res.pop_back();这一段。注意是从back()开始删因为我们的存储是低位在前back()就是最高位。还有一个陷阱是输入本身就带前导零。比如用户输入000123和45转换之后a是[3,2,1,0,0,0]b是[5,4]。比较函数里虽然做了去零处理但subCore里循环还是按a.size()走会多算几位高位零。结果本身是对的因为高位零减零还是零最后统一去零会清理掉。但如果你在subCore里做了某些基于位数的优化比如提前终止循环就可能出问题。所以我的建议是要么在输入转换时就去掉前导零要么保证去零逻辑只在最后执行一次不要在中途做任何假设。3.3 负数结果的输出格式处理当被减数小于减数时结果是负数。我采用的策略是在bigSub里判断符号然后输出- 正数结果。这个做法简单直接但有一个小问题如果结果是0不应该输出-0。所以我在cmpVec返回0的时候直接返回0不走后面的分支。有些场景下你可能不希望结果带负号比如在某些嵌入式系统里负号显示会占用额外空间。这时候你可以选择返回一个pairbool, string第一个元素表示符号第二个表示绝对值。调用方根据需求决定怎么展示。这种设计更灵活但用起来稍微麻烦一点。对于大多数应用来说直接返回带符号的字符串是最省事的。还有一个格式问题是前导零和负号的组合。比如结果是-0或者-000这些都不应该出现。我的代码里通过cmpVec的等值判断和subCore的去零逻辑保证了这两种情况都不会发生。但如果你自己改代码一定要记得测试这几个边界0-0、5-5、100-100。4. 实操中容易踩的坑与排查方法4.1 数组越界与下标混乱高精度减法里最常见的运行时错误就是数组越界。根源通常有两个一是循环上界写错二是比较函数里去零时没保护好边界。循环上界的问题我建议统一用a.size()作为主循环上界因为我们已经保证了a b所以a的位数一定不小于b。在访问b[i]的时候用i b.size()做保护。这个模式我在前面subCore里已经展示了你直接抄就行。比较函数里的去零while (la 1 a[la-1] 0) la--;这一行如果写成while (a[la-1] 0) la--;当a全是零的时候la会一直减到0然后a[la-1]就是a[-1]直接越界。这个bug在测试用例全是零的时候才会触发很容易漏掉。所以la 1这个条件不是可有可无的它是防止越界的最后一道防线。4.2 借位变量忘记重置借位变量borrow在每一位计算完之后必须正确更新。我见过有人在diff 0的分支里设置了borrow 1但在else分支里忘了写borrow 0导致借位一直传递下去后面所有位都莫名其妙地减了1。这种bug的表现是结果在某些位上比预期小1而且错误会从某一位开始一直影响到最高位。排查这种问题的方法很简单在循环里加一行调试输出打印每一位的a[i]、b[i]、borrow和diff。比如// 调试用 cout i i a a[i] b (i b.size() ? b[i] : 0) borrow borrow diff diff endl;跑一个简单的用例比如1000 - 1看看每一位的借位是不是符合预期。个位是0-0-1diff-1借位后diff9borrow1十位是0-1-0diff-1借位后diff9borrow1百位同理千位是1-1-0diff0borrow0。如果中间某一步borrow没重置你一眼就能看出来。4.3 性能瓶颈与优化方向基础版本的高精度减法时间复杂度是O(n)n是数字的位数。对于几千位的数字这个速度完全够用。但如果你要处理几十万位的数字或者要在循环里反复做减法就需要考虑优化了。第一个优化方向是压位。也就是不存每一位十进制数字而是每几位存一个整数。比如每4位存一个int这样数组长度直接缩小到原来的四分之一循环次数也相应减少。压位的代价是借位逻辑变复杂了因为借位不再是借1当10而是借1当10000。你需要把基从10改成10000所有涉及进位借位的地方都要同步修改。第二个优化方向是避免不必要的拷贝。我上面的代码里strToVec返回的是值subCore的参数是const引用这个已经比较合理了。但如果你在循环里反复调用bigSub每次都要构造新的vector内存分配的开销会累积。这时候可以考虑把vector作为参数传进去复用已有的内存。第三个方向是提前终止。如果减数已经处理完了而且borrow是0那么被减数剩下的高位可以直接拷贝到结果里不需要逐位计算。这个优化在减数位数远小于被减数位数时效果明显。比如1000000 - 1减数只有1位被减数有7位前6位其实可以直接拷贝。但要注意如果borrow是1就不能直接拷贝还得继续处理借位。提示优化之前先用性能分析工具确认瓶颈确实在减法上。我见过有人花大力气优化减法结果发现程序90%的时间花在输入输出上白忙一场。4.4 常见问题速查表问题现象可能原因排查方法修复方式结果末尾多出零去零逻辑没执行或条件写错打印结果vector检查while循环条件结果为负但没负号比较函数返回值判断反了单独测试cmpVec修正if (c 0)分支某一位开始结果全错借位变量未重置加调试输出看borrow确保else分支设borrow0程序崩溃数组越界用gdb或打印下标检查去零循环的边界条件0减0输出空串去零循环把最后一位也删了测试0-0用例条件改为size() 1大数减法结果不对输入带前导零打印转换后的vector转换时或最后统一去零这张表里的每一行都是我在实际写代码和帮别人debug时真实遇到过的。尤其是第一行和第五行几乎每个初学者都会踩一次。你把这张表存下来下次遇到问题先对照排查能省不少时间。5. 从能跑到好用工程化改进建议5.1 封装成类与运算符重载把高精度减法封装成一个类用起来会舒服很多。你可以重载-运算符让代码看起来就像在用内置类型一样。下面是一个简化的类框架class BigInt { private: vectorint digits; // 低位在前 bool negative; void trim() { while (digits.size() 1 digits.back() 0) { digits.pop_back(); } if (digits.size() 1 digits[0] 0) { negative false; } } public: BigInt(const string s) { negative false; int start 0; if (s[0] -) { negative true; start 1; } for (int i s.size() - 1; i start; i--) { digits.push_back(s[i] - 0); } trim(); } BigInt operator-(const BigInt other) const { // 实现减法逻辑处理符号组合 // ... } string toString() const { string s; if (negative) s -; for (int i digits.size() - 1; i 0; i--) { s char(0 digits[i]); } return s; } };封装成类之后符号处理可以内聚到类里面调用方不需要关心底层是正还是负。但代价是实现复杂度上升尤其是减法里要处理四种符号组合正减正、正减负、负减正、负减负。其中正减负和负减正实际上会变成加法你需要同时实现加法逻辑。如果你的项目只需要处理非负数的减法那用我前面的函数版本就够了没必要过度设计。5.2 输入输出的健壮性处理实际使用中输入不一定来自你控制的字符串。可能是用户从命令行输入的可能是从文件读的可能带空格、带换行、带正号。一个健壮的输入处理函数应该做到跳过前导空白、识别可选的正负号、验证后续字符都是数字、遇到非法字符给出明确错误。bool parseBigInt(const string input, vectorint digits, bool neg) { int i 0; while (i input.size() isspace(input[i])) i; neg false; if (i input.size() (input[i] || input[i] -)) { neg (input[i] -); i; } if (i input.size()) return false; digits.clear(); for (; i input.size(); i) { if (!isdigit(input[i])) return false; digits.push_back(input[i] - 0); } reverse(digits.begin(), digits.end()); return true; }这个函数里reverse那一步是因为我习惯低位在前而从字符串读进来的时候是高位在前。反转之后再去零逻辑就统一了。注意isdigit和isspace的参数要转成unsigned char否则遇到负的char值会有未定义行为。这个细节在大多数教材里不会提但实际项目中如果处理非ASCII输入就可能触发。5.3 测试用例的设计思路高精度减法的测试用例不能只测“正常情况”。我一般按下面几个维度来设计位数维度一位减一位、多位减一位、多位减多位、位数相同、位数不同。大小维度大减小、小减大、相等。边界维度0减0、0减非零、非零减0、结果刚好是10的幂、结果中间有零。借位维度不借位、借一次、连续借位、最高位借位。把这些维度组合起来大概能覆盖二三十个用例。我挑几个最容易暴露bug的列出来用例预期结果考察点1000 - 1999连续借位100 - 1000等值判断0 - 00零值边界0 - 5-5负数结果10000 - 99991结果去零123456 - 1234551高位相同低位差11000000 - 1999999长借位链每次改完代码把这几个用例跑一遍基本能拦住大部分回归问题。如果你用CI可以把这些用例写成单元测试每次提交自动跑。5.4 与其他高精度运算的衔接高精度减法很少单独使用它通常是高精度运算库的一部分。你写完减法之后很自然地会想把它和加法、乘法、除法组合起来。这里有一个衔接上的建议统一数据结构和符号表示。如果你加法用的是string存数字减法用的是vectorint那两者之间就要来回转换既麻烦又容易出错。我建议从一开始就定好一个内部表示比如vectorint低位在前所有运算都基于这个表示来做只在输入输出的时候做转换。符号也统一用一个bool或者int来表示不要有的地方用负号字符串有的地方用标志位。另外减法和加法的借位/进位逻辑其实是对称的。你可以把两者抽象成一个统一的“逐位运算”框架用一个函数指针或者lambda来决定每一位是加还是减。这种抽象在需要支持多种运算时会减少重复代码但如果你只是写个作业或者小工具直接分开写反而更清晰。抽象是有成本的不要为了抽象而抽象。6. 一些实战中的个人体会我最早写高精度减法的时候觉得逻辑很简单手算怎么来代码就怎么写呗。结果第一次提交就WA了原因是没处理前导零输出0999而不是999。后来加了去零逻辑又遇到了0-0输出空串的问题。再后来处理负数又发现-0的情况没考虑到。前前后后改了四五版才稳定下来。这些坑其实都不难但每一个都需要你实际跑一遍才能发现。光看代码是看不出来的因为代码逻辑“看起来”是对的。我的建议是写完任何高精度运算先别急着集成到项目里单独写个main函数把上面那张测试用例表跑一遍。花十分钟测试能省你两个小时debug。还有一个体会是关于代码可读性的。高精度运算的代码里下标操作特别多a[i]、b[i]、res[i]满天飞。如果你不把变量名起得清楚一点过两天自己都看不懂。我习惯用a表示被减数b表示减数res表示结果borrow表示借位。循环变量统一用i从0开始。这些命名看起来不起眼但在调试的时候能帮你快速定位问题。最后说一个性能上的小技巧。如果你需要反复做减法比如在一个循环里减同一个数可以考虑把减数预先转换成内部表示避免每次重复解析字符串。这个优化在减数位数很多的时候效果很明显。我实测过一个场景减数有5000位循环减1000次预转换之后耗时从1.2秒降到了0.3秒左右。当然大多数场景下没必要这么抠但知道有这个方向总没坏处。