
无数次在OJ平台上看到新手卡在模拟加法这道入门题上心里都会咯噔一下。很多人瞧不起这道题加法int直接加不就行了吗结果一交上去不是WA就是RE心态直接炸裂。今天就把这道题掰开揉碎讲清楚包括大数加法的完整思路、OJ埋下的各种边界坑、以及怎样把这套模拟思想迁移到更多题目上。先说清楚模拟加法是什么。OJ里的模拟加法绝大多数情况下不是让你算35而是让你用程序模拟手写竖式加法的过程处理几百位甚至上千位的大整数相加因为这类数字远远超过int、long long甚至double的表示范围只能借助数组或字符串逐位运算。它既是高校和在线判题系统里最常见的入门题之一也是后续学习高精度乘除法、大数阶乘、进制转换这些内容的必经之路。不论你是初入OJ刷题的新手、准备机试的在校生还是想扎实基本功的工程师这篇文章都值得你从头看完。1. 模拟二字才是这道题真正的题眼1.1 为什么int和long long撑不住这道题先做个简单的数量级概念。int能表示的最大值是2147483647十个手指头就能数出它的位数long long大概能扛19位数字就算用上无符号长长整型也就23位左右。但OJ里的模拟加法经常给你来两个一千位的数字操作系统自带的任何基本数值类型都装不下。你如果用int读入第一秒就会在输入环节溢出即使用字符串存储再强行用转成整数相加再转回字符串的思路也照样会因为中间转换直接丢失精度。这里最关键的一个认知转变在于在这个场景里数字不应该被当成数值来理解和处理而应该被当成一串字符、一串按位排列的符号序列来操作。加法这个运算的本质其实是把一串符号和另一串符号从最低位开始逐位合并、逐位进位的过程。人做竖式加法时根本没有一次读完整个大数的概念不是吗我们是从个位往上加、满十进一。模拟加法要求你做的就是在程序里复刻这个逐位过程。1.2 核心输入输出形态大多数模拟加法题目的输入有两种常见格式。第一种是单组输入格式像12345678901234567890 98765432109876543210用空格分隔两个数字第二种是多组输入前面用一个整数T表示测试数据组数后面跟着T对数字。无论哪种格式都需要记得大数一般是以字符串的形式整体读入的不要试图先用数值型变量接收。有经验的刷题者拿到这类题第一件事不是看题目描述里的示例——示例是骗你用的——而是先看数据范围。如果题目写了每个数不超过1000位或者偷偷给出一个长度最多为10^5之类的描述那基本可以确定要用模拟字符串加法而不是普通数值加法。哪怕题面上一个范围都没给看到模拟加法这种标题你也应该默认它为高精度加法。这是OJ最常见的one-trick pony式命名方式标题越朴实数据往往越狠。2. 从纸面竖式到代码核心实现步骤与关键设计决策2.1 先把竖式的每一步翻译成程序语言人做竖式加法时步骤可以拆成三件事对齐末位、从最低位开始逐位相加、满十向高位进一。模拟加法里这三件事分别对应字符串处理中的补齐长度从尾部遍历维护进位标志。第一步补零对齐。比如a123b4567我们要让两个数字从个位开始对齐最简单的方法不是去统一用某个固定长度而是在短的那一个字符串前面补字符0。很多初学者喜欢反过来做减法再索引也能跑通但补零之后代码可读性和鲁棒性会高很多。实际操作时可以用字符串拼接操作比如在短串前补上差值长度个0也可以不补零、直接用索引判断是否出界看你对哪种风格更有把握。第二步确定最少需要做几次加法循环。两个数长度分别为lenA和lenB逐位加法最多进行max(lenA, lenB)次但注意最终结果可能比两个操作数都长一位——比如9991。所以结果数组或字符串的长度要开max(lenA, lenB)1位用来放那个额外的最高进位。第三步逐位相加并处理进位。这一步要写成通用公式而不是用if判断一堆特殊情况。对第i位来说取出a在这一位的数字超出长度就当0。取出b在这一位的数字超出长度就当0。当前和 a位数字 b位数字 上一次进位。当前结果位 当前和 mod 10。进位 当前和 / 10。循环结束之后如果进位还是1就在结果前面补一个1。整个算法的复杂度和输入长度成线性关系也就是O(max(lenA, lenB))空间复杂度同样如此对一个入门题来说这已经是理论上最优的复杂度。2.2 核心代码实现以及为什么我要这样组织我贴一份个人常用的写法也是我认为最好理解、最不容易写错的一种#include bits/stdc.h using namespace std; string addStrings(string a, string b) { string res ; int carry 0; int ia a.size() - 1; int ib b.size() - 1; // 从最后一个字符开始往前遍历直到两个字符串都处理完 while (ia 0 || ib 0 || carry ! 0) { int da (ia 0) ? (a[ia] - 0) : 0; int db (ib 0) ? (b[ib] - 0) : 0; int sum da db carry; carry sum / 10; res.push_back(char(0 (sum % 10))); ia--; ib--; } reverse(res.begin(), res.end()); return res; } int main() { string a, b; while (cin a b) { cout addStrings(a, b) endl; } return 0; }这段代码有几个设计决策值得单独拿出来说。第一个决策是用while循环而不是固定次数循环。条件ia 0 || ib 0 || carry ! 0把两个操作数都耗尽和最后一次进位还存在这两个终止条件合并了。如果写成for循环且次数是max(lenA, lenB)最后那个额外的进位就容易被漏掉然后你就会在9991这类用例上翻车。把carry纳入循环条件后即使两个字符串都遍历完了只要还有进位没放上去循环就会继续跑一位。第二个决策是最后用reverse翻转字符串而不是每算一位就插到结果前面。如果采用res digit res这种头部插入写法字符串长度n时每次插入都需要把后面所有字符往右挪整体复杂度会退化到O(n²)。对于几千位的大数可能感觉不明显但当两个数的长度到达几十万甚至上百万时这个差别是决定生死的一级。先用尾插法把低位的数字按顺序堆积起来最终一次性翻转是处理这类问题的一个底层好习惯。第三个决策是字符转数字时a[ia] - 0和数字转字符时char(0 digit)这样显式处理。不少初学者直接拿char做加法然后用ASCII细节处理立即正确相当难。让字符和整数之间保持清晰的转换边界读数、做运算、存结果三个环节彻底分清后期debug时会省一大口气。2.3 一种更方便调试的分步版方案网上还流传一种更贴近竖式纸面流程的写法先把两个字符串分别补零到等长然后用for循环从高位向低位或从低位向高位处理。它的优势在于逻辑和纸面竖式一一对应特别适合第一次接触高精度加法的读者。我把它以分步骤伪代码的形式列在这里方便你对照理解输入 a, b 把 a 和 b 都补成 len 位短的前面补 0 新建结果数组 result长度 len 1初始化全为 0 carry 0 for i len-1 到 0 sum a[i] - 0 b[i] - 0 carry result[i1] sum % 10 carry sum / 10 若 carry 不为 0result[0] carry 从 result 的第一个非零数字位置开始组装结果字符串输出补零法让对齐这个动作变得非常显式调试时你可以在每一步打印出来看非常直观。但它有个潜在问题如果字符串特别长额外做一次补零会产生一份新字符串内存会多占用一倍。好在OJ题目的数据量通常不会大到让这份多出来的开销变成致命问题所以学习阶段用分步版代码里追求极致性能时用上面那份精简版我认为是比较合理的分工。3. OJ最容易埋的几种雷边界用例与判题器视角的纠错思路3.1 前导零陷阱很多WA不是因为算错而是因为输出格式错得离谱很多OJ不会在题目示例里给你前导零这类输入但测试数据里会毫不客气地出现。比如输入000123和000456正常情况下你应该输出579而不是000579。如果你不做前缀零清理程序会非常工整地输出一长串0打头的数字然后被判WA但你在本地怎么跑都觉得没错。这里分两种情况处理。第一种读入后直接清理两个操作数的前导零让123和456干干净净参与运算。第二种算完结果后统一去掉结果前导零至少保留一位数字。我个人倾向第二种多一点因为它还能顺手把两个操作数恰好都是0这种极端情况照顾到。实践里还要注意一个细节当结果本身就是0时至少保留一位0否则清过头了输出一个空字符串一样是WA。写清理逻辑时判断条件用res.size() 1来兜底这样就不会把唯一的数字干掉。3.2 长度不平衡与连续进位不要假定两个数字一样长两个输入长度不一样是模拟加法最常规的测试点比如9加999999。如果你写的循环次数只有短串长度那多出来的高位字符压根不会被加入结果如果循环次数刚好是长串长度短串越界后你又不能直接访问a[-1]这种非法索引。解决方式就是我在前面代码中反复强调的出界判断ia 0 ? a[ia] - 0 : 0。这个三元表达式已经相当于越界当0处理它把短串那部分高位自动视为0非常干净。连续进位这个坑更隐蔽。像999999999999加1从最低位一路触发进位一直要进到最高位上面再新增一位1。如果你的循环条件没有考虑carry这个变量你最终会得到000000000000或999999999990这类错误结果。用我上面的写法while循环会在两个字符串都耗尽后带着最后的进位再跑一次然后把1尾插到结果里翻转后输出1000000000000000这才是正解。另外还有一类边界是两个大数同时耗尽但carry恰好为0。这个场景在while循环里表现为循环条件不满足直接退出不会多输出一次。保证这个逻辑正确的关键就是不要用for (int i 0; i len; i)这种死板写法——除非你有十足的把握已经覆盖掉最后的进位。3.3 空字符串与0的特殊形态有些题目的输入可能存在空行比如样例里偷偷留了一个空白测试组。使用cin a b接收时空字符串会被跳过很少直接出事。但有一种特殊情况值得留意某个操作数本身是0比如0 12345。这时只要你的补零和越界判断逻辑正确结果是12345没问题。最怕的是0 0输出必须是0而不是。很多人在清理前导零时没保留最后一位就会在这个极简用例上挂掉。我的建议是无论如何都要在测试环节手动补测这几种数据00、1999、999999、00010002。这四组数据基本能覆盖90%的入门级WA原因。3.4 大长度与性能极限下的隐形风险如果题目把每个数长度拉到10^5级别你还要注意代码是否会无意间变成O(n²)。比如反复用字符串头部插入、反复多次reverse、甚至每次循环都调用to_string再拼接都会让性能雪崩。我曾见过一个非常典型的写法先把结果用int数组存起来然后用std::string反复拼接加着加着就TLE了原因是在一个很长的字符串后面每次只追加一个字符并没有问题但有人贪方便用了res to_string(digit) res每加一位都整体搬运一次。这类性能坑在高精度加法题目里远没有你是否去overflow一个int数组那样明显但真实OJ的测试数据一大TLE就会立刻教做人。写模拟加法的时候时刻牢记尾插 最后翻转这个原则能让你少走很多弯路。4. 从正数加法走向更通用的模拟框架负数、进制与数组化优化4.1 负数情况的处理策略模拟加法做熟了之后很多题目会升级为两个大整数相加可能包含负号。这时候就不能直接套刚才的字符串加法了而是要设计一套符号逻辑。最通用的做法是这样的分别判断两个数的符号分四种情况处理。正数加正数直接调用基础加法负数加负数提取绝对值相加后结果加负号一正一负则转化为绝对值大的减绝对值小的符号随绝对值大的那个。减法本身又需要一套逐位借位的模拟逻辑这里有个核心细节比较两个绝对值的大小时务必逐位比较不能转成数值比较否则大数又会溢出。比较规则很简单——长度长的绝对值大长度相同时从最高位开始逐位比较字符。符号系统一旦建立你的模拟加法模块就会成为后面模拟乘法、模拟除法的公共地基。所以我不建议把负数处理硬塞进基础加法的代码里最好独立封装一版保持基础加法函数的纯粹性。4.2 进制从10变成任意进制只需要改一个参数如果你稍微观察一下竖式计算逻辑会发现被我们反复强调的满十进一本身是10进制的一个特例。事实上模拟加法在任意进制下的通用模型是满base进一。把刚才代码里的/10和%10换成/base和%base就得到了一套通用的大数加法框架输入输出由各位数字构成。很多学生的进制转换题就会瞬间打开思路从一个进制读入模拟加一位数后再转换成另一进制输出整个过程本质上还是用这套体系。我在实际写OJ题时还有一个偏好用进制基数的超大基数来压缩数组长度。例如使用(10^9)作为数组的基让每一个数组元素存储九个十进制位做加法时按(10^9)进位数组长度直接变为原来的九分之一。真正常见的高精度库BigInteger实现方式就是基于这个思路用更大的底、更小的数组来降低循环次数和内存占用。模拟加法的模拟思想到这里已经不局限于逐位而是逐块运算了这个升维理解对后面做快速幂、大数斐波那契都有直接帮助。4.3 一套代码的复用高精度加法合适作为一切高精度运算的底座在OJ刷题中模拟加法往往是一系列高精度题的起点。你后面会遇到高精度减法、高精度乘法模拟乘法的竖式或按位乘积累加、高精度阶乘甚至高精度除以高精度。它们每一个的实现核心都离不开一个可靠的大数加法模块。加法是所有进位体系的起点乘法的最终累加靠加法阶乘的每次乘法结果累积也靠加法甚至高精度快速幂里的累乘和累加都是同一套逻辑的排列组合。所以我的建议是把模拟加法的版本当成你本地的算法工具箱第一个成员来维护。多花十分钟封装接口清晰、支持超出int范围的数字运算函数之后遇到任何需要大数运算的题目你都不是从零开始而是直接调取工具箱里最成熟的那个模块。这种先做地基再做上层的思路会在实战中给你省下大量时间。5. 刷这类入门题时更值得留意的几个习惯与真实教训5.1 不要急着看排行榜上的一行高级解法有些OJ题解区会有一两行代码搞定模拟加法的奇技淫巧比如直接使用Python自带的大数或Java的BigInteger。如果你是个练习算法的学生我建议你坚决不要复制这种解法。用内置大数很简单但对你理解模拟毫无帮助一旦面试或者机试环境禁用此类内置类你会发现自己连字符串加法的接口都写不顺。相反手写一遍模拟加法后再去对比BigInteger的输出结果你会对系统大数是怎么实现出来的有非常具体的认知。以我的经验这就是分水岭会手写模拟的人再看BigInteger源码能看懂很多设计决策只会调BigInteger的人连问题错在哪里都说不清。5.2 测试用例思维你至少要准备这五组数据再提交很多人写完代码觉得能跑通示例就直接交这是一种很低效的刷题方式。示例只是为了标定题面大多数测试点藏在看不见的地方。刷模拟加法这道题我不管题目多简单都必然在本地手动测这五组00、0123、9991、999999999999、前导零混合用例如0000123000000000456。分组测试完了再去交命中率会高很多。建立自己的边界测试习惯本质上比这单一题目的解法更重要因为你到后面刷任何一道题都能用这个流程快速验证实现是否存在低级失误。5.3 我踩过的一次典型WA复盘说一个我记忆很深的教训。有一版模拟加法我自认为处理了前导零处理了连续进位甚至把while循环条件都检查了三遍结果交上去WA。后来对着数据手动跑才发现题目要求读入直到EOF而我写的循环是只读一组数据就返回。输入输出格式的正确性在OJ里和算法本身一样重要很多看起来像是算法答案错误的WA根源其实是输入输出控制有问题。尤其是模拟加法这种题判定器只看你的输出字符串是否逐字节一致。多了一个空格、少了一个换行、甚至某行结尾多打一个回车都会被无情判为错误。建议第一组代码先写一个最简单的输入循环本地生成几组测试数据验证输出格式再逐步加强核心算法。这个经验看似白菜却能解决大量初学者的玄学WA。5.4 多看题目标题背后的数据范围这是最重要的信息源最后我想专门强调一个容易被忽略的信息点OJ的题目标题往往很简洁有时就模拟加法四个字但真正的关键信息全埋在题面描述的数据范围里。如果题面里出现每个数字长度不超过1000你就明确知道复杂度O(n)是安全的如果出现每个数字长度可以达到10^6就要额外关注代码里有没有产生O(n²)的字符串操作如果出现数字可能用十进制以外的进制表示则要重点在解析格式上花心思。当我遇到一个题目觉得这题怎么数据范围这么大完全没法存第一反应一定是去确认是否看错了输入形态第二反应才是考虑优化算法。数据范围直接决定了你的实现路线告别了只看示例不看范围的阶段你的AC率会肉眼可见地提升。这道题看似基础但它背后牵扯到数据表示、字符串处理、边界测试、输入输出规范、通用化封装等一堆贯穿整个刷题生涯的能力点。下次再见到模拟加法希望你不再是无脑调加号而是能平静地写出一份稳定、通用、可复用的高精度加法模块。这就是入门题真正要教会你的事。