A+B地狱版全解析:高精度加法、整数溢出与边界用例排查

发布时间:2026/10/8 10:18:09
A+B地狱版全解析:高精度加法、整数溢出与边界用例排查 看到这个标题我第一反应是这位朋友怕不是被某个OJ平台虐到深夜才在群里喊出这么一句。AB问题本来是编程入门最常见的题目——读两个整数把它们加起来打印结果说破天也就是几行代码的事。可一旦挂上“地狱版”三个字事情就完全不一样了。它可能是数据范围大到必须上高精度可能是输入格式坑到能读崩一整批人也可能是多个测试点叠加之后你连第一次WAWrong Answer出在哪都找不到。这篇内容就是来把这些“地狱”一个个拆开聊透背后的原因并给出能直接落地的解法。不管你现在是被这道题卡住的萌新还是想帮别人讲清楚这道题的助教这篇都应该能让你少走很多弯路。1. 先把“地狱”拆清楚AB到底能有多难1.1 数据范围才是第一层地狱大家默认的“AB”是给你两个 int 范围内的整数相加不超出 int 范围。这种题确实简单到像喝水但“地狱版”不会这么客气。我见过的地狱版通常会在一个不起眼的地方改条件比如a 和 b 的范围变成 0 ≤ a, b ≤ 10^1000。int 放不下long long 也放不下你只能上高精度。有一组数据正好卡在 int 边界a 2147483647b 1。如果只开 int结果是 -2147483648WA得莫名其妙。或者把数据规模改成一共有 10^6 组 a、b 需要相加这时候用平时那个 cin / Scanner 慢速读入可能连数据都没读完就 TLE 了。这些改动单独看都不难但组合在一起杀伤力直接翻倍。很多“求助帖”里的代码其实逻辑完全正确输错在数据类型和输入处理上。所以说AB 地狱版的第一课是重新认识你用的语言里整数到底能装多大数。1.2 判题规则才是真正的幕后黑手除了数据范围OJ 的判定规则也贡献了大量“地狱感”。判题系统不会看你的代码“思路对不对”它只把你的程序输出和正确答案做逐字符比对。多一个空格、少一个换行在某些严格模式下都会 WA。我自己见过最离谱的 AB 变种是这样输入文件中前几行有空白行需要用 getline 或 nextLine 逐行读取结果很多人用 cin 配合 读取把空行自动跳过了逻辑上没问题但另一些人用 gets 或 nextLine 读到了空字符串然后又没做非空判断直接转换出错程序崩溃输出 RERuntime Error。另外题目里说“输入包含多组数据以 EOF 结束”很多人不理解 EOF 是什么导致循环写成了死循环或者少读了最后一组这两种情况都会让你离 AC 越来越远。后面我会把这几种判定方式的写法都列出来。2. 溢出与整数类型第一个让你WA到怀疑人生的坑2.1 int、long long、还有unsigned的阴谋先给个参考表把常见语言的整数范围对比放一起类型占用空间取值范围典型场景C/C int32位-2147483648 ~ 2147483647常规整数运算但很容易溢出C/C long long64位-9223372036854775808 ~ 9223372036854775807大部分 AB 地狱版都够用C/C unsigned int32位0 ~ 4294967295非负数据处理但减法有坑Java int32位同 C/C int常规整数运算Java long64位同 C/C long long安全选择Python int动态无上限受内存限制大数直接算最省心JavaScript Number64位浮点超过 2^53 不再精确别用 JS 写高精度除非用 BigInt很多人写 C 的时候一开始就用 int直到 WA 了才发现溢出。溢出的表现是“回绕”int 最大是 2147483647再加 1 就变成 -2147483648。这个负数不会报错程序也不崩溃但答案就是不对属于最阴的一种错误。还有 unsigned 的坑unsigned int 相减如果结果是负数它会被解释成很大的正数。比如 0u - 1u 的结果是 4294967295而不是 -1。如果题目允许 a、b 为负数又用了 unsigned那基本就是主动往坑里跳。我一直建议的做法是凡是不确定范围的整数运算在 C/C 里直接用 long long除非题目内存紧到必须节省。这不算偷懒而是用最小的成本排除最大的一类 bug。2.2 不同语言的大整数表现完全不同Python 在这一类题目里很占便宜因为它的 int 可以无限扩展AB 真正的大数版在 Python 里就是两行a int(input()) b int(input()) print(a b)Java 也提供了现成的 BigInteger但写起来啰嗦一点import java.math.BigInteger; import java.util.Scanner; public class Main { public static void main(String[] args) { Scanner sc new Scanner(System.in); BigInteger a new BigInteger(sc.next()); BigInteger b new BigInteger(sc.next()); System.out.println(a.add(b)); } }C/C 就比较惨标准库没有大整数只能手写高精度。但换个角度想手写高精度也是“AB 地狱版”最想考察你的东西因为很多面试和竞赛场景里你必须自己实现一遍字符串模拟加法。所以别嫌麻烦这是必修课。还有一个容易忽略的坑JavaScript 的 Number 类型是浮点数超过 2^53 后精度就不够了。如果你在 Node.js 环境写 AB别直接用 Number要么用 BigInt要么自己写字符串加法。这个坑我见不少人在线笔试里踩过。3. 高精度大数加法手写一次的收益远比想象中大3.1 为什么必须理解“竖式加法”手写高精度加法说白了就是把小学学过的竖式加法用代码实现一遍从最低位个位开始逐位相加每一位相加结果大于等于 10就进位最后如果最高位还有进位记得补一位。这个办法适用于任意长度的整数相加核心数据结构是“数字数组”。我习惯用 vector 存最低位放在数组开头也就是逆序存储。为什么逆序因为两个数的长度可能不一样逆序存储后从下标 0 开始对齐个位进位也能很自然地往后扩展不用每次都在前面插一个数。3.2 一份可以直接抄的高精度加法模板下面这段 C 代码是我在竞赛里常用的模板支持正整数的任意长度相加#include iostream #include vector #include string using namespace std; vectorint add(const vectorint a, const vectorint b) { vectorint c; int carry 0; int n max(a.size(), b.size()); for (int i 0; i n; i) { int sum carry; if (i a.size()) sum a[i]; if (i b.size()) sum b[i]; c.push_back(sum % 10); carry sum / 10; } if (carry 0) c.push_back(carry); return c; } int main() { string s1, s2; while (cin s1 s2) { vectorint a, b; for (int i (int)s1.size() - 1; i 0; --i) a.push_back(s1[i] - 0); for (int i (int)s2.size() - 1; i 0; --i) b.push_back(s2[i] - 0); vectorint c add(a, b); for (int i (int)c.size() - 1; i 0; --i) cout c[i]; cout \n; } return 0; }这段代码的关键点有三个输入字符串转数字时要减去字符 0我见过新手直接 push_back(s1[i])结果算出来满屏奇怪数字因为 0 的 ASCII 是 48。循环上界用 max(a.size(), b.size())短的那个数组在越界判断里补 0。每次 sum carry a[i] b[i]sum 最大是 9 9 1 19所以 carry 只会是 0 或 1处理起来很简单。很多面试题会要求“字符串相加”其实就是这套模板换一层壳。只要数组加法写顺了换任何形式都不怕。3.3 压位优化提速的关键细节如果只是两个 1000 位的数相加上面那版代码绰绰有余。但如果题目要求处理几十万位的数或者反复相加逐位处理就偏慢了。这时候可以压位把每 8 位数字当作“一个位”来存相当于把竖式加法从 10 进制改成 100000000 进制。压位的好处是循环次数直接除以 8常数优化非常明显。坏处是输出时要小心前导零除了最高位块其他块必须补足 8 位否则会输出残缺的数字。const int BASE 100000000; // 10^8 vectorint toVec(const string s) { vectorint v; for (int i (int)s.size(); i 0; i - 8) { int start max(0, i - 8); v.push_back(stoi(s.substr(start, i - start))); } return v; } void printVec(const vectorint v) { cout v.back(); for (int i (int)v.size() - 2; i 0; --i) printf(%08d, v[i]); printf(\n); }思路就是把 12345678901234567890 分成块123456789 | 01234567 | 890… 不对我这是随意举例但原理就是按 8 位切分。注意 BASE 不能太大否则两个块相加可能超过 int 上限那就得用 long long 存中间的 sum反而更麻烦。4. 输入输出与多组数据隐蔽的地狱入口4.1 多组数据的三种常见判定方式AB 地狱版最常见的变异就是从“读一组数据”变成“读很多组数据”。题目一般用三种方式声明声明方式C/C 写法Java 写法Python 写法第一行给出组数 T先读 T再循环 T 次同左同左读到文件末尾 EOFwhile (scanf(...) ! EOF)while (sc.hasNext())用 sys.stdin 逐行读取读到特定结束标记比如读到 0 0 退出同左同左很多人在 C 里写 EOF 循环时会写成while (scanf(%d%d, a, b) ! EOF) { printf(%d\n, a b); }这样其实是对的。但新手经常写成while (true) { scanf(%d%d, a, b); printf(%d\n, a b); }没有退出条件遇到文件末尾 scanf 返回 -1变量里可能是旧值于是最后两组答案被重复输出WA 得莫名其妙。换成 cin 的话推荐这样写int a, b; while (cin a b) { cout a b \n; }cin 的 operator 返回流对象读到 EOF 时流会转为 false循环自然结束。Java 里的标准写法和 C 类似Scanner sc new Scanner(System.in); while (sc.hasNextInt()) { int a sc.nextInt(); int b sc.nextInt(); System.out.println(a b); }Python 则经常配合 sys.stdin 一次性读取import sys def main(): data sys.stdin.read().strip().split() # 如果不需要分组直接两两配对 nums list(map(int, data)) for i in range(0, len(nums), 2): print(nums[i] nums[i 1]) if __name__ __main__: main()注意这里用了一次性 read比反复调用 input() 快很多。数据量一大这种 IO 差距就会变成 TLE 和 AC 的差距。4.2 快读快写到底快在哪里有些题目数据量特别大比如 10^6 组输入每组两个 int。cin 默认和标准 IO 同步为了兼容 C 的 stdio它做了很多额外工作导致速度比 scanf 慢一截。在 C 里你可以在 main 开头写这两行把同步关掉ios::sync_with_stdio(false); cin.tie(nullptr);这样 cin 的速度就能接近 scanf 了。Java 的 Scanner 同样很慢因为它内部做了正则和编码转换。需要处理大规模输入时我用自定义快读类替代 Scannerimport java.io.*; public class FastScanner { private InputStream in System.in; private byte[] buf new byte[1 16]; private int cur 0, len 0; private int readByte() { if (cur len) { try { len in.read(buf); cur 0; } catch (IOException e) { return -1; } if (len 0) return -1; } return buf[cur]; } public int nextInt() throws IOException { int c; do { c readByte(); } while (c c ! -1); int sign 1; if (c -) { sign -1; c readByte(); } int res 0; while (c 0 c 9) { res res * 10 (c - 0); c readByte(); } return res * sign; } }这段代码的核心就是自己做字节解析绕过 Scanner 的正则开销。核心关键词就是“逐字节读”。Python 里也有类似道理但语言层面不同我通常直接 sys.stdin.read().split() 一把梭简单粗暴效果稳定。4.3 空白字符空格、换行、\r\n、空行按说 cin a b 能自动跳过所有空白包括空格、Tab、换行这在绝大多数题目里没问题。但地狱版总会在你想不到的地方加料比如文件是 Windows 平台的文本行尾是 \r\n。C 的 getline 读一行会把行尾的 \r 也读进字符串里再转换成数字时就会出错。处理办法是读完后判断末尾是不是 \r是就去掉。输入数据第一行之前有空行。用 nextLine 读字符串时可能读到一个空字符串转换成 int 时抛异常。处理办法是判断字符串是否为空为空就继续读。数字中间混入了多个连续空格甚至全角空格。这种属于出题人恶意满满应对方式依然是先 split再做空白清理。这类问题定位起来特别费时间因为你的运算逻辑完全正确但数据在读入环节就已经变了形。所以遇到 WA别只盯着算法先把你读进来的每个字符串原样打印出来看看。5. 变体地狱字符串相加、分数相加、取模组合拳5.1 两个字符串表示的“数字”相加这是面试和竞赛里的常见变体输入是两个字符串里面是可能长度很大的非负整数要求输出它们的和但不能用大整数库。这其实和高精度加法是同一套思路只是我们把“读入并转成逆序数组”和“逐位相加”合在一起做。给一个 Python 风格直观版本def addStrings(num1: str, num2: str) - str: i, j len(num1) - 1, len(num2) - 1 carry 0 res [] while i 0 or j 0 or carry: s carry if i 0: s int(num1[i]) i - 1 if j 0: s int(num2[j]) j - 1 res.append(str(s % 10)) carry s // 10 return .join(reversed(res))这个版本的好处是不需要提前补齐零也不用转数组直接用下标从尾部往头部走。循环结束条件里带上 carry避免最后一位进位被漏掉。关键细节如果两个字符串里有前导零比如 000123也算合法数字。用 int() 转换后求和当然可以但性能一般般而且字符串超长时还是慢。最好就是老老实实逐字符处理前导零只是多几次循环而已。5.2 分数相加通分、约分、负数如果说字符串相加还只是“高精度 1”那分数相加就是“AB 地狱版”里的明显变种。题型长这样给你两个分数 a/b 和 c/d输出它们的最简分数。这本质上也是加法但要先通分再约分。通分需要最小公倍数约分需要最大公约数。C 里可以自己写:long long gcd(long long a, long long b) { while (b) { long long t a % b; a b; b t; } return a; }分数相加完整流程分子 a * d c * b分母 b * d求 g gcd(abs(分子), 分母)分子、分母同时除以 g如果分母是负数分子分母同时取反保证分母为正。第五步很关键因为很多题目要求输出格式不能出现负分母比如 1/(-2) 必须写成 -1/2。如果最后忘了处理符号你可能经历一次非常冤枉的 WA。分母为 0 的情况通常题目会保证不会出现但如果出题人没写清楚可以直接拿一组分母为 0 的数据测一下有些平台会给出“分子为 0输出 0/1”的约定。遇到这种约定不明的情况我的经验是看讨论区没有讨论区就默认最保守方案即输出 0 时不带分母。5.3 (a b) % mod为什么写答案前要先加 mod还有一种常见“地狱”是把加法强行和取模绑定。题目变成给定 a 和 b输出 (a b) mod m。听起来很简单但 m 可能是 10^9 7a 和 b 可能已经接近 long long 上限甚至 a 和 b 本身是负数。这就有三个暗坑直接写 (a b) % ma b 可能溢出 long long。C/C 里负数取模结果也是负数比如 (-7) % 5 -2但大多数题目期望答案是 3即非负余数。Python 和 Java 的取模语义更接近数学定义负数的余数是正的但 C/C 不是。所以 C/C 里稳妥的写法是long long ans ((a % m b % m) % m m) % m;第一步先把 a 和 b 各自缩到 [0, m) 范围内两个数相加最多不超过 2m-2long long 完全扛得住。最后再加一个 m 再取模一次是为了把第一次取模可能产生的负数拉回非负区域。如果你用的是 Python其实直接 (a b) % m 就非常安全因为 Python 的 int 不会溢出而且 % 返回非负结果。这些语言差异在跨语言看题解的时候最容易让人困惑。6. 地狱版实战排查从WA到AC的一段真实旅程6.1 我建议你准备的10组边界用例不管题目描述得多清楚测试数据总会有你没想到的边界。AB 地狱版尤其如此。我每次提交前都会在本地跑一组“边界用例清单”全部通过再交。下面这 10 组是我平时最爱用的序号输入期望输出检查点10 00最基本的零值21 23普通正数32147483647 12147483648检查 int 溢出49223372036854775807 19223372036854775808检查 long long 溢出5-5 3-2负号处理6-2147483648 -1-2147483649负数最小值边界7000123 000456579前导零8999999999999 11000000000000连续进位9123456789123456789123456789 9876543219876543219876543211111111111111111111111111110超长字符串相加10两组输入之间有空行的情况正常输出空白字符解析其中第 3、4 组是专门为 C/C 准备的Python 基本不会踩第 7 组是给所有选手准备的前导零容易出现在分析环节第 10 组则需要看题目是否声明“多组数据”如果不声明空行可能本来就不该出现。6.2 拿到 WA/TLE/RE/PE/CE 之后先别急着改代码评测结果也是一个信息源。我见过很多人看到 WA 就疯狂改算法结果改来改去毫无进展最后发现只是输出多了个空格。所以搞清楚每个状态的含义能帮你少做大量无用功。评测状态含义优先排查方向WA输出与标准答案不一致边界用例、溢出、输出格式、读取错误TLE超出时间限制IO 性能、算法复杂度、死循环RE运行时错误数组越界、未初始化变量、除零、空指针MLE超出内存限制不必要的存储、递归栈过深PE输出格式错误多空格、少换行、行尾空格CE编译错误语法问题、缺头文件、变量重名举个例子AB 里出现 RE最常见的两个原因分别是输入数据里有一行是空行导致字符串转 int 失败或者是数组开小了。你可以做个实验在读入环节不做任何防御故意输入空行程序会立刻抛异常这就是线上 RE 的原型。6.3 一条能保命的DEBUG流程我自己的调试流程是这样的按执行顺序来重读题目把每个输入约束圈出来尤其是 a、b 的数据范围和输入组数。本地跑第 6.1 节里的边界用例清单一次性测完看哪里有偏差。在输出语句前打印调试信息比如 printf(a%d b%d\n, a, b)确认读入是否正确。如果读入没问题再检查类型转换和取模运算尤其注意负数。如果题目给多组数据单独检查最后一组会不会因为 EOF 处理不当而漏算或重复算。尝试把代码里可疑的模块注释掉换一种等价实现比如把 cin 换成 scanf对比结果是否一致。这套流程能覆盖我遇到的绝大多数 AB 地狱版问题。真正需要“猜答案”的场合其实很少大部分问题都是某个细节条件没满足直接从数据流入手定位比盯着代码发呆高效得多。还有一个小技巧我屡试不爽如果本地跑什么都对线上就是 WA那多半是平台编译器版本或输入文件格式差异。比如编译器把 int 按 16 位处理这种极端情况虽然少见但 Windows 平台的 \r\n 和 Linux 的 \n 却是常见差异。你可以写一个读入原始字节的小程序把输入文件的前几个字节转成十六进制打印出来立刻就能看出来里面有没有隐藏的 \r。结尾分享一点个人经验我在学校 OJ 上第一次碰到“AB 地狱版”时连续提交了十几次 WA。后来发现并不是算法错了而是我在输出时多打了一个空格修掉之后还有一个 WA原因是有一组输入是负数而我参考的“标准题解”只处理了非负整数。从那以后我养成一个习惯不管题目看起来多“简单”都要先确认数据范围、输入组数和输出格式这三件事。AB 地狱版其实并不恐怖它只是把编程中最容易忽略的边界问题集中到了一道小题上。把这类题吃透你会收获一批比写十个“普通版 AB”更有用的排查经验。