GESP 5级备考群里最常见的求助帖是什么?不是排序,不是二分查找,而是高精度加法写着写着就出问题。很多人觉得高精度算法就是个"模拟竖式",思路全会,一写就废。这个现象特别奇怪——算法本身不难,难的是和它绑在一起的那些语法细节:字符串转数组、倒序存储、数组清零、进位借位、去前导零,每一个环节都是失分点。
这篇文章我就按自己带学生备考GESP 5级的经验,把高精度算法涉及的语法知识、四则运算实现和进阶方向从头到尾梳理一遍。目标读者很明确:正在准备GESP 5级、或者学过C++但一写高精度就发懵的同学。文章里的代码都是完整可跑的,每个实现我会解释为什么这么写、出问题会出在哪里。看完之后,你不光能写对高精度,还能明白为什么之前的代码会错。
1. GESP 5级为什么把高精度算法当考点
1.1 从考纲看高精度的位置
GESP全名青少年软件编程等级考试,由中国计算机学会主办,一共8个级别。1到4级主要覆盖C++基础语法、顺序分支循环、函数、数组、字符串这些内容,说白了就是在考"你会不会写代码"。到了5级,考纲明显转向"你会不会想算法",高精度计算、排序、二分查找这些经典算法开始成为主角。我个人的看法是,5级是一个分水岭——语法已经够用,接下来拼的是把语法组合成方案的能力。
高精度计算在这个阶段被反复考察,不是因为它难,而是因为它非常考验考生的基本功。一道高精度加法题,表面上只需要模拟竖式,实际上把字符串处理、ASCII码转换、数组下标管理、循环边界、进位逻辑、输出格式全都串在了一起。换句话说,高精度是5级阶段性价比最高的复习素材——把高精度搞定,相关的语法知识基本就都稳了。
1.2 内置整数类型的天花板到底在哪
先回答一个基础问题:C++自带的整数类型到底能装多大的数?我把常见类型整理成了下表:
| 类型 | 占用字节 | 最大值(十进制) | 最多位数 |
|---|---|---|---|
| int | 4 | 2147483647 | 10 |
| long long | 8 | 9223372036854775807 | 19 |
| unsigned long long | 8 | 18446744073709551615 | 20 |
没错,就算用 unsigned long long,也只能表示20位的整数。而GESP 5级的高精度题目,经常给你一个长度几百甚至上千位的数——比如让你计算两个200位整数相加,或者求一个1000位整数除以一个普通int的商和余数。这种规模用内置类型直接爆掉,连讨论溢出方式的资格都没有。
那怎么办?既然一个变量装不下,就把数拆开,用数组一个一个格子地装。这就是高精度算法的核心思路。
1.3 高精度的本质:把"上行竖式"搬进代码
我一直跟学生说,高精度算法本质就是我们小学学过的竖式运算,搬进代码而已。比如计算 456 + 789,你在草稿纸上会这样写:
456 + 789 ------ 1245加法从个位开始,6+9=15,写5进1;5+8+1=14,写4进1;4+7+1=12,写2进1;最后最高位还有1。整个过程包含三个关键词:从低位到高位、进位、逐位处理。
代码要做的事情一模一样。只不过"个位、十位、百位"这些位置在数组里有对应的下标。我通常会用一个数组a,让a[0]存个位、a[1]存十位、a[2]存百位……这样数组下标从低到高,正好对应数的低位到高位,计算方向跟竖式保持一致。
有了这个思想打底,后面四则运算的代码就不难理解了。
2. 写高精度前必须先啃下的语法底座
2.1 字符串读入与ASCII的约定
高精度题目的输入格式一般是一行(或两行)特别长的数字,比如:
123456789012345678901234567890 123456789012345678901234567890这种数用什么读?用 int 或 long long 读必然溢出,正确做法是把整个数字当作字符串读进来。cin 的字符串输入在这里就派上用场了:
#include <iostream> #include <string> using namespace std; int main() { string x, y; cin >> x >> y; // 之后用高精度函数处理 return 0; }cin 遇到空格或换行会自动截断,所以一次读一个数刚好。如果一行里有两个数用空格隔开,cin >> x >> y 也没问题。
读进来之后,字符串里每个字符都是 char 类型,比如字符'5'在ASCII码表里的值是53。要把它变成数字5,直接减去字符'0'(ASCII码48)就行:
char ch = '5'; int digit = ch - '0'; // 结果是5反过来,要把数字5变回字符'5',就加上'0':char('0' + 5)。这个ASCII约定是高精度算法里最常用的语法点,也是新手最容易犯错的点——有人会直接写 int digit = (int)ch,得到的是53而不是5。
2.2 倒序存储:为什么必须倒着存
我见过不少同学的代码,读完字符串之后直接按原顺序存进数组:s[0]存最高位,s[1]存下一位……看着挺直观,但一写加法的进位就傻了。
举个例子,输入 456,字符串下标0是'4'、1是'5'、2是'6'。按原顺序存,a[0]=4, a[1]=5, a[2]=6。现在要计算 456+789,个位'6'和'9'分别在a[2]和另一个数组的b[2],你得从数组尾部往前算。进位方向呢?个位进位要加到十位上,也就是从下标2加到下标1……方向反了,非常别扭。
所以标准做法是倒序存储:让数组下标0存个位,下标1存十位,依次类推。
void strToArr(string s, int a[]) { int len = s.size(); for (int i = 0; i < len; i++) { a[i] = s[len - 1 - i] - '0'; } }比如 s = "456",len=3。循环i=0时取 s[2] = '6',放进a[0];i=1取 s[1] = '5'放进a[1];i=2取 s[0] = '4'放进a[2]。这样整个数组的存储和竖式完全对齐,后面加、减、乘处理起来都非常顺。
2.3 函数、数组与返回值:三个隐蔽坑
写高精度时,很多人习惯把每道题的完整逻辑塞进main函数,代码又臭又长,还容易出bug。我强烈建议把四则运算各自封装成函数。封装时会遇到三个坑,提前说明:
坑一:数组作为函数参数时会退化为指针。这意味着你可以在函数里修改调用者的数组内容(这是优势),但也意味着函数里 sizeof(a) 拿不到数组真实大小,必须额外传入长度。所以不要想着在函数内部重新计算数组元素个数。
坑二:不能直接返回局部定义的数组。比如你在函数里 int c[1005]; 算完了想 return c;,C++会直接报错,因为局部数组在函数返回时就销毁了。解决办法有两个:要么把结果数组也通过参数传出去,要么把结果转成一个 string 再返回。四则运算里我推荐后者——字符串天然适合保存结果,也方便最后直接输出。
坑三:函数传参用值传递会复制整个字符串。高精度题里字符串可能很长,复制一次就是一次O(n)开销。GESP题量不大,复制造成的浪费可以忽略,但更规范的做法是传引用,比如 string& x,既能避免复制,也方便在函数内交换两个字符串(例如保证减法中x >= y)。
这三个坑其实是C++语法核心的一部分,5级考试里哪怕不写高精度,函数和数组的考察也会涉及。搞懂它们,等于一箭双雕。
3. 加减乘除四件套的逐层实现与要点
3.1 加法:先把进位处理熟练
高精度加法是最基础的,我建议直接把它当作标准模板来练,因为后面乘法也要用到类似的进位逻辑。完整代码如下:
#include <iostream> #include <string> #include <algorithm> using namespace std; void strToArr(string s, int a[]) { int len = s.size(); for (int i = 0; i < len; i++) a[i] = s[len - 1 - i] - '0'; } string add(string x, string y) { int a[505] = {0}, b[505] = {0}, c[505] = {0}; int lenA = x.size(), lenB = y.size(); strToArr(x, a); strToArr(y, b); int maxLen = max(lenA, lenB); // 先算每一位的原始和 for (int i = 0; i < maxLen; i++) { c[i] = a[i] + b[i]; } // 统一进位 for (int i = 0; i < maxLen; i++) { if (c[i] >= 10) { c[i + 1] += c[i] / 10; c[i] %= 10; } } int resultLen = maxLen; if (c[maxLen] != 0) resultLen++; string result; for (int i = resultLen - 1; i >= 0; i--) { result += char(c[i] + '0'); } return result; } int main() { string x, y; cin >> x >> y; cout << add(x, y) << endl; return 0; }这里解释几个关键点。
数组为什么定义成 int a[505] = {0}?505 是根据数据规模估计的位数上限,加上一点余量。数组初始化成0非常重要,因为后面进位时会用到 c[maxLen] 这个位置,如果没初始化,读到的是随机脏数据,结果直接错。
进位循环的写法是先算完所有位置的原始和,再统一进位。比如 i=0 位置算出15,先把 c[1] 加上1;等循环到 i=1 时,c[1] 已经包含了进位的1,再继续判断是否大于等于10。这样一层循环就能把进位传递到任意高位。
输出时倒着从最高位往低位拼字符串,char(c[i] + '0') 刚好完成数字到字符的转换。加法的时间复杂度是O(n),n是位数,几百上千位的数据毫无压力。
3.2 减法:借位、比较交换和负号
减法比加法多出两个问题:结果可能是负数;被减数可能小于减数。所以在做减法之前,先得判断两个数谁大。
判断大小的逻辑很直接:位数多的数大;位数相同时,字符串的字典序比较就等价于数值比较(因为'0'到'9'的ASCII码递增,且位数相同的情况下从左到右逐位比较即可)。所以先写一个比较函数:
bool isGe(string x, string y) { if (x.size() != y.size()) return x.size() > y.size(); return x >= y; }减法主逻辑:
string sub(string x, string y) { bool negative = false; if (!isGe(x, y)) { swap(x, y); negative = true; } int a[505] = {0}, b[505] = {0}, c[505] = {0}; int lenA = x.size(), lenB = y.size(); strToArr(x, a); strToArr(y, b); int maxLen = lenA; for (int i = 0; i < maxLen; i++) { if (a[i] < b[i]) { a[i] += 10; a[i + 1]--; } c[i] = a[i] - b[i]; } while (maxLen > 1 && c[maxLen - 1] == 0) maxLen--; string result; if (negative) result += '-'; for (int i = maxLen - 1; i >= 0; i--) { result += char(c[i] + '0'); } return result; }减法中的借位逻辑是逐位进行的:如果当前位置被减数小于减数,向高位借1(相当于自己加10,高位减1)。你可能会问:高位减1之后,如果高位本身是0怎么办?比如1000-1,高位的0会变成-1,但在下一次循环中,-1小于对应位置的减数0,于是继续向更高位借10。这个连锁反应通过整数类型的负数中间态就能完成,不需要特殊处理,最终结果依然是正确的。
去掉前导零是关键。比如 500 - 499 = 001,如果直接输出001就是错的。while (maxLen > 1 && c[maxLen - 1] == 0) maxLen--; 这个循环把最高位的0全部去掉,但保留至少一位,这样结果"0"也能正确输出为"0"而不是空串。
3.3 乘法:双层循环加统一进位
乘法比加法难一个档次,但思路依然来自竖式。两位数乘法在竖式里是这么算的:A的每一位和B的每一位分别相乘,再把所有结果按位置累加。在数组里,下标i位置和下标j位置的数字相乘,结果应该加到 c[i + j] 位置上,这一步必须理解透彻。
string mul(string x, string y) { int a[505] = {0}, b[505] = {0}, c[1010] = {0}; int lenA = x.size(), lenB = y.size(); strToArr(x, a); strToArr(y, b); for (int i = 0; i < lenA; i++) { for (int j = 0; j < lenB; j++) { c[i + j] += a[i] * b[j]; } } int resultLen = lenA + lenB; for (int i = 0; i < resultLen; i++) { if (c[i] >= 10) { c[i + 1] += c[i] / 10; c[i] %= 10; } } while (resultLen > 1 && c[resultLen - 1] == 0) resultLen--; string result; for (int i = resultLen - 1; i >= 0; i--) { result += char(c[i] + '0'); } return result; }注意几个细节。
c数组的大小要开到 lenA + lenB + 1,因为长度为lenA和lenB的两个数相乘,结果最多有lenA+lenB位。比如 99 * 99 = 9801,是4位,lenA+lenB正好等于4。进位循环的边界给到 resultLen 即可,如果最坏情况下 c[resultLen - 1] 的进位传递到 c[resultLen],因为数组多开了一位,所以仍然安全。
在实际计算中,比如 999 * 999 = 998001,lenA=3,lenB=3,resultLen=6,最高位在下标5,完全够用。
乘法复杂度是O(lenA * lenB)。两个1000位的数相乘是10^6次基础操作,1秒内轻松搞定。但如果两个数都是10^4位,运算量达到10^8,就会开始有超时风险——这也是后面压位优化的动机。
3.4 除法:唯一从高位开始的运算
高精度除法的考察形式通常是"大整数除以一个小整数(int范围内)",求商和余数。这种除法和竖式一致,但方向正好和加法相反——要从最高位开始往下除。
string divide(string x, int b, int& remainder) { int len = x.size(); string result; long long cur = 0; for (int i = 0; i < len; i++) { cur = cur * 10 + (x[i] - '0'); result += char(cur / b + '0'); cur %= b; } remainder = cur; int pos = 0; while (pos < result.size() - 1 && result[pos] == '0') pos++; result = result.substr(pos); return result; }这里不需要把字符串倒序存进数组,直接按原顺序逐位处理。核心变量cur相当于"当前余数",每次把下一位数字拼到cur末尾:cur = cur * 10 + 当前数字。然后用cur除以b,商就是结果的一位,新的余数保留下来继续参与下一位的计算。
以 1234 / 5 为例:
- 第0位'1':cur = 1,1/5 = 0,result = "0",cur = 1
- 第1位'2':cur = 12,12/5 = 2,result = "02",cur = 2
- 第2位'3':cur = 23,23/5 = 4,result = "024",cur = 3
- 第3位'4':cur = 34,34/5 = 6,result = "0246",cur = 4
- 余数为4
最后去掉前导零,得到"246",余4,完全正确。
这里有个小技巧:cur要定义成long long。因为cur * 10 + digit理论上可能超过int范围,比如被除数有20位时,cur在最后一位之前可能达到10^9量级,乘以10再加digit后可能越界。但cur每一轮都会取模取余数,所以cur实际始终小于 b * 10,不会真正爆掉long long。
GESP 5级如果考除法,多半会在题目描述里明确"输入为一个高精度整数和一个不超过int范围的整数",或者反过来让你求两个高精度整数的商(这种更少见,通常用长除法+减法模拟实现)。先把除以单整数的情况吃透,已经足够应付大部分考试场景。
4. 课后训练最容易翻车的几个地方
4.1 数组没清零就拿来用
第一个翻车点几乎人人遇到。C++里局部数组在栈上创建时,里面的值是不确定的,也就是"垃圾值"。
有同学写完 int a[505]; 直接往里存数字,存完第i位后,后面没存到的位置是什么?答案是随机垃圾值。所以在后续运算中,如果某个循环试图访问那些没被赋值的下标(比如加法进位到c[maxLen]),就会读到乱七八糟的数,结果自然不对。
解决办法就一行:定义数组时统一初始化成0:
int a[505] = {0};这个写法的含义是第一个元素初始化为0,其余元素自动补0。我见过很多同学翻车,都翻在这个看似不起眼的地方。写高精度题,养成"数组统一初始化为0"的习惯,能省一半调试时间。
4.2 前导零和全零输入
第二个翻车点在于输出格式。减法里最容易出现前导零,比如 1000 - 999 = 1,如果不处理,结果是"0001"。乘法里也可能出现,比如 123 * 0 = 0,如果结果长度较大,直接输出会是"000000"。
去掉前导零的通用思路是:找到最高非零位,然后从这个位置开始拼接字符串。但要小心,如果结果本来就是0,至少要输出一个"0",不能输出空串。所以上面的while循环里加了 result.size() - 1 这个边界条件。
还有一个容易被忽略的场景:输入本身带前导零。比如题目给了"000123"和"45"相加,如果你原样转成数组,可能会多出多余的位。稳妥的做法是在字符串转数组前,先统一去掉所有输入的前导零(但至少保留一位)。GESP的测试数据未必会这么刁钻,但养成防御习惯没坏处。
4.3 位数估计错误导致越界
高精度算法里的数组越界问题非常隐蔽。加法中,如果输入是500位的数,结果最多501位,数组至少要开到505。乘法更麻烦:两个500位数相乘,结果最多1000位,如果你只开了505的c数组,那必炸。
所以我一般会把数组大小按"输入上限 + 10"来开。如果题目说输入不超过1000位,加法开1005就够,乘法开2010。多开一点不浪费,但少开一位就是隐患。测试时可以用最大规模的数据压一下,确认没有越界。
另一个越界坑藏在函数参数里。strToArr函数只负责把字符串内容写进a数组,它自己并不知道a有多大。如果输入长度超过数组容量,写越界也不会立刻报错,只会悄无声息地破坏相邻内存。GESP的数据一般比较温和,但自己训练时要注意控制输入范围和数组大小。
4.4 考试中怎么快速自测
在训练阶段,我推荐一个非常朴素但有效的自测方法:拿几组边界数据反复验。
加法:测 999...9(很多位9) + 1,看进位是否正确;测 0 + 0,看输出是否为"0";测 100000...0 + 1,看最高位是否正常。 减法:测相同数相减,看是否输出"0";测 1000...0 - 1,看借位是否连续;测 1 - 1000...0,看负号是否出现。 乘法:测两个 999...9 相乘,检查进位和结果长度;测任意数乘0,结果必须为"0"。 除法:测 1 / 1000(商0余1);测 1000 / 1;测正好整除的情况,余数必须为0。
除了边界数据,再随机挑几组普通数据,用标准结果(比如直接让Python算一遍,或者用更大的数据类型算一遍)验证。我在本地一般会写一个很短的对比脚本,把几组数据的输出和Python结果做比对。不用很高端,就能把90%的隐蔽bug揪出来。
5. 5级之后:压位优化与高精度组合玩法
5.1 压位高精度:把10换成10000
先说明:GESP 5级考试里,普通高精度已经足够应付绝大多数题目,压位不是必选项。但它是一个很好的进阶训练,能让你对数组、进制、进位有更深的理解,也为后续CSP/NOI做准备。
压位的思路特别简单:普通高精度每一位存0~9,相当于逢10进1;压位高精度每一位存0~9999,相当于逢10000进1。这样做的好处是,同样大小的数组能表示的数字位数多了4倍,计算时的循环次数也少了大约4倍。
具体实现时,有几个细节和普通高精度不一样:
- 读入字符串后,从右往左按4位一组切分,每一组转成一个整数存进数组。比如"12345678",从右往左分成"12"和"345678"(或者"1234"和"5678"),存进a[0]=5678,a[1]=1234。
- 进位判断条件是 c[i] >= 10000 而不是 >= 10。
- 输出时从最高位开始,最高位直接输出整数;后面每一位都要补前导零到4位,因为一个格子里存的可能是0001。所以输出用 printf("%04d") 或者手动补零。
如果不处理前导零,压位的输出就会变成"1"而不是"10000"。这个细节是压位高精度最大的失分点。我一般建议学生:考试写普通高精度,完全没问题;压位只作为思维训练来写,不要在考场上临时用不熟练的技术。
5.2 高精度与二分答案的组合题
过了5级,后面还有6级、7级、8级,CSP-J/S也在招手。高精度从来不单独存在,它常常作为"基础工具"和其他算法搭配。最典型的组合是二分答案 + 高精度判断。
举个例子:给一个高精度整数n,求n的平方根(只取整数部分)。暴力做法是从1试到n,当然不行。正确思路是二分答案:区间[1, n],每次取mid,判断mid * mid是否大于n。mid本身可能就是一个长达几百位的数,mid * mid更是大得离谱,必须用高精度乘法。这个组合思路在CSP-J的这些年里出现过类似题目,GESP 6级及以上的题库里也有影子。
这种题难在两点:一是二分边界要带着高精度比较去写;二是乘法之后的结果要和高精度目标值比较大小,比较函数又要用到按位比较的逻辑。如果你能把加减乘除四个函数封装好,这种组合题就只是"调用"而已。
5.3 一条从GESP到CSP的高精度学习路径
最后聊一下我建议的学习顺序,也是我自己带学生常走的路径。
第一步,把四则运算按照本文的顺序各写三遍,每一遍都不看参考代码。第一遍能对着本文抄对,第二遍能默写出来,第三遍要能做到20分钟内独立写出且一次通过边界测试。
第二步,做题型归纳。准备一个错题本(或者电子文档),把高精度相关的错题按"加/减/乘/除/组合算法"分类,每个分类记2到3个典型题。复盘时重点看错在哪一步:是进位方向反了,还是数组越界了,还是输出格式错了。
第三步,把高精度当成工具箱。学二分、学快速幂、学贪心的时候,遇到大整数,主动想想能不能用高精度拼进去。这样到6级、7级,甚至CSP初赛复赛,高精度都不会成为你的短板。
写到这里有点收不住,最后提一个实在的建议。训练高精度时,我建议你把自己的代码和一份标准答案同时跑,对比输出。别觉得麻烦,高精度这种算法,错误都是藏在边角数据里的。你把1000位的加法、减法、乘法、除法四套代码都跑通大数据,再去考GESP 5级,心里会特别有底。
再送一个小技巧:考场上如果时间紧张,高精度加法、乘法这类题,先写一个"暴力版本"(用内置类型直接算)作为对拍工具——对,内置类型虽然存不下大数,但可以拿小数据来对比验证高精度代码是否正确。这个小技巧我在练习中用了很多次,基本能保证代码逻辑不出错。