第一次在算法题单里看到“B进制星球”这个名字的时候,我还以为是个星际探索模拟题,点进去才发现是个高精度加法题:给你一个进制 B,再给两个 B 进制的大数字符串,要你算出它们的和,并且结果仍然用 B 进制输出。这名字起得确实很有迷惑性,但题目本身是极其经典的进制模拟题。B进制的出镜率很高,数组/字符串的高精度处理、字符与数字的映射、进位的边界判断,全挤在这一道题里。对于刚学高精度或者看到“B进制”三个字就发怵的人来说,这道题值得完整啃一遍。下面我就以实操的角度,把这道题的完整思路、能直接提交的代码、以及我实际调试时踩过的几个坑写清楚,希望能帮你少走一点弯路。
1. 题目在问什么:B进制、高精度、字符串三件事搅在一起
1.1 名不副实的“星球题”:一次被封面骗了的开题经历
“B进制星球”这个题目,表面上像是在描述某个外星文明使用的计数系统,实际上它就是在考两件事:一是你对“进制”这个概念是不是真懂,二是你能不能手写一个高精度加法。
题目一般是这样:第一行给一个整数 B,表示进制,B 的范围通常在 2 到 36 之间。接下来给两个由字符构成的 B 进制大数,可能长度能达到几百上千位。数字部分用0-9表示,超过 9 的部分用大写字母A-Z表示。比如在 16 进制里A代表 10,F代表 15;在 36 进制里Z就代表 35。要求把两个数相加,输出 B 进制的结果。
很多人看到“进制”就条件反射地想去调用转换函数,看到“大数”就想去开BigInteger,这其实都不是这道题的正确打开方式。它的正确打开方式,是你得理解高精度加法到底在模拟什么,以及为什么 B 进制的进位规则和十进制没有本质区别。
1.2 进制的本质:从钟表的“逢60进一”说起
进制这东西,听起来很数学,实际上你每天都在用。钟表就是“逢 60 进一”:59 秒之后再加 1 秒,秒位归 0,分位进 1。十进制是“逢 10 进一”,9 加 1 之后个位归 0,十位进 1。二进制则是“逢 2 进一”,1 加 1 之后本位归 0,向高位进 1。那 B 进制就是“逢 B 进一”,当某一位的数达到 B,就向高一位进位。
理解到这一步,竖式加法就顺理成章了。你小学算十进制加法时,个位相加超过 9,就往十位进 1;超过 19,就进 2。这在本质上就是把“当前位的数字和”除以 10,商是进位,余数是留在本位的数字。B 进制一模一样,只是把除数从 10 换成 B。
还以钟表举例:计算3小时58分 + 1小时17分,你肯定不会先把所有时间换算成分钟,算完再除 60 一次、取余一次。正常人会直接分钟位相加得到 75 分钟,超过 60,所以分钟位留 15,小时位进 1,最后得到5小时15分。这就是竖式思维。B 进制星球这道题,本质就是让你把这种“逢 B 进一”的竖式思维用代码表达出来。
1.3 数据范围就是解题风向标
很多初学者卡在这道题,不是因为不会写加法,而是没读懂数据范围。题目给的两个数,不是普通整数,而是可能长达几千位的字符串。这在十进制下都不可能用一个long long存下,更不用说在 36 进制下,任意一位都可能代表 0 到 35 的值。就算用 64 位整数,最大也就支持约 1.8×10^19,而一个 2000 位的 36 进制数,数值量级是 36^1999,这是多少个零你可能都无法想象。所以这道题根本不可能用语言内置的整数类型直接完成。
数据范围这样设计,就是逼你往高精度方向想。所谓高精度,就是不会算一种数字就用“数组的每一位存一个数位”的方式,手动模拟人工计算的过程。把一串数字拆开,逐位运算,自己处理进位,这就是高精度。它不是什么高深算法,只是一种“因为内存放不下,所以我用数组硬算”的思路。想通了这一点,题目就开始往“模拟 B 进制竖式加法”的方向收敛了。
2. 常见的错误路线:先把B进制转十进制,再转回去
2.1 “先转十进制再算”到底哪里错了
我第一次做这类题的时候,脑子里闪过的第一个方案是:能不能先把两个字符串按给出的进制转成十进制整数,加完再转回 B 进制?相信很多初学的人都有同样的冲动。这个思路在数值很小的时候确实能跑通,但它有致命问题。
第一,数值根本放不下。B 进制字符串长度可能达到上千位,就算你一位位地乘 B 累加,结果也是一个天文数字,int放不下,long long放不下,甚至连double都会因为精度丢失而出现错误。
第二,就算你用的是支持任意大整数的类库(比如 Java 的BigInteger),也会陷入二次转换的泥潭:先要把两个大数分别转成十进制,这意味着要做大数乘法和加法;加完之后又要用“除 B 取余法”转回 B 进制,这意味着要准备一套大数除法。难度直接翻倍,而且完全没必要。数学上偷懒的代价,是工程上更复杂的实现。
2.2 高精度加法的本质:用数组拼出一个人工大整数
要避开上面那条弯路,我们需要回到“竖式加法”这个朴素的模型。你手算两个多位数相加的时候,第一步是数位对齐,从个位开始逐位加;第二步是超过当前进制的部分要进位。计算机用数组做高精度加法,做的就是这件事。
具体来说,一个 B 进制大数可以看成一个数组,数组每个位置存一位,低位放在前面或者后面都可以,但为了逐位相加方便,我们通常把低位放在数组开头,也就是反转字符串后操作。这样索引i就对应从低位往高位的第i位。相加时,把相同索引位置的数字相加,再加上进位,然后用商和余数分别得到进位和当前位的结果。
这里有一个值得说的点:为什么这种方法不会溢出?因为在 B 进制下,每一位最大也就是B-1。两个数位相加,再加上进位,最大值是(B-1) + (B-1) + 1 = 2B - 1。除以 B 之后,商最大是 1,余数最大是B-1。所以进位永远只可能是 0 或 1(更精确地说,两个一位数相加进位最大就是 1),根本不存在溢出风险。这也是高精度数组能够稳定工作的数学基础。
2.3 为什么要坚持“直接在原进制下做竖式”
有人可能会问:进制本质上只是数的表示方式,先转成十进制再算,数学上不也一样吗?是,数学上一样,但工程上差很多。直接在 B 进制下做竖式,整个过程中你只需要处理两件事:字符到数字的映射,以及逢 B 进一。这比“转十进制再转回来”少了一整套高精度乘除法。
更重要的是,这种“在原进制下直接模拟”的思路可以扩展到很多场景。比如算 B 进制大数减法,只需要把“进位”换成“借位”;算 B 进制大数乘法,只需要双循环逐位相乘再累加。所有逻辑都在同一个框架里,清晰、可控、容易调试。相反,那句“先转成十进制再说”往往只在题目数据很弱的时候能蒙混过关,一旦数据范围变大,就是白忙活。
3. 实现细节逐段拆解:从字符映射到进位处理
3.1 字符和数字之间的双向转换
B 进制里超过 9 的位用大写字母表示,这给编码提出了一个要求:你得会写字符转数字和数字转字符两个函数。
字符转数字的规则是这样的:
int charToVal(char c) { if (c >= '0' && c <= '9') { return c - '0'; } return c - 'A' + 10; }这段代码里,'0'到'9'直接减掉'0'得到 0 到 9;'A'到'Z'减掉'A'再加 10,得到 10 到 35。为什么是+10?因为'A'代表的数值是 10,不是 0,所以要做偏移。
数字转字符则是反过来的:
char valToChar(int v) { if (v < 10) { return '0' + v; } return 'A' + v - 10; }注意细节:如果v等于 10,'A' + v - 10正好是'A';如果v等于 35,结果就是'Z'。这两个函数是整道题的基石,后面所有运算都依赖它们。写的时候建议先单独测试一下charToVal('Z') == 35和valToChar(10) == 'A',能省下后面不少调试时间。
3.2 反转字符串让低位对齐
这一步是很多新手最容易忽略的。两个字符串的长度可能不一样,比如一个是 5 位,一个是 8 位。如果直接从左往右逐位相加,两个数的个位根本对不上,结果必然错误。
正确做法是把两个字符串都反转,让低位跑到前面来:
int lenA = a.size(); int lenB = b.size(); for (int i = 0; i < lenA / 2; i++) { swap(a[i], a[lenA - 1 - i]); } for (int i = 0; i < lenB / 2; i++) { swap(b[i], b[lenB - 1 - i]); }在 C++ 里可以直接用reverse(a.begin(), a.end()),非常省事。反转之后,索引 0 就是个位,索引 1 就是进制位,依此类推。两个数位数不同也没关系,短的数在越界位置直接视为 0 即可。
这个“低位对齐”的思想和十进制竖式完全一致。你在纸上算 123 + 4567 的时候,也不会从最左边开始加,而是从个位 3 和 7 开始。反转字符串就是在代码里实现这个对齐动作。
3.3 循环体里的三行核心计算:本位、进位、拼接
核心计算其实就是三句话,拿一个变量记录进位,然后逐位处理:
int carry = 0; int len = max(a.size(), b.size()); string res; for (int i = 0; i < len; i++) { int da = i < (int)a.size() ? charToVal(a[i]) : 0; int db = i < (int)b.size() ? charToVal(b[i]) : 0; int sum = da + db + carry; res.push_back(valToChar(sum % B)); carry = sum / B; }这三行的意思:da和db是当前位的数字,sum是当前位数字加上进位的总和。sum % B是留在当前位的数字,sum / B是向高位进的数。然后把当前位数字转回字符,append 到结果串里。整个循环结束后,所有没有被处理的位都已经算完,只剩一个可能非 0 的最终进位。
这里需要再强调一下“为什么进位用除法、本位用取余”。你可以把 B 进制的一位理解成一个容量为 B 的容器,超过容器容量的部分就要往高一位倒。sum / B算的是“满了几次 B”,sum % B算的是“倒完之后还剩多少”。这和十进制里17 / 10 = 1、17 % 10 = 7是一个道理。
3.4 最高位进位与最后反转输出
循环结束之后,还有一个很容易忘的边界:如果carry不是 0,说明最高位还有进位,需要额外补一位。比如十进制999 + 1,循环只处理三位是不行的,最后必须再输出一个1,才能得到1000。B 进制也一样,36 进制下Z + 1得到的是10,也就是35 + 1 = 36,写成 36 进制是10。这个结果的产生,靠的就是循环后的这个判断:
if (carry != 0) { res.push_back(valToChar(carry)); }最后,因为我们在循环里一直是从低位向高位往res末尾追加字符,此时res里的顺序是反的。要得到正确的从左到右的输出顺序,还需要再反转一次:
reverse(res.begin(), res.end()); cout << res << endl;反转这一步如果忘了,像101 + 1这种输入会输出1011这种明显不对的结果。我见过太多人栽在这个地方,包括当年的我。
4. 完整可提交代码与样例验证
4.1 C++参考实现(带注释)
把上面这些碎片拼起来,就是一份可以提交的完整代码。我在这里给出一份 C++ 版本的实现,注释写得比较详细,方便你逐行对照理解。
#include <bits/stdc++.h> using namespace std; int charToVal(char c) { if (c >= '0' && c <= '9') { return c - '0'; } return c - 'A' + 10; } char valToChar(int v) { if (v < 10) { return '0' + v; } return 'A' + v - 10; } string addB(string a, string b, int B) { // 反转两个字符串,让低位对齐 reverse(a.begin(), a.end()); reverse(b.begin(), b.end()); string res; int carry = 0; int len = max(a.size(), b.size()); for (int i = 0; i < len; i++) { int da = i < (int)a.size() ? charToVal(a[i]) : 0; int db = i < (int)b.size() ? charToVal(b[i]) : 0; int sum = da + db + carry; res.push_back(valToChar(sum % B)); carry = sum / B; } // 处理最高位进位 if (carry != 0) { res.push_back(valToChar(carry)); } // 反转回来,得到正确顺序 reverse(res.begin(), res.end()); return res; } int main() { int B; string a, b; cin >> B >> a >> b; cout << addB(a, b, B) << endl; return 0; }这份代码里,cin会自动跳过换行和空格,所以输入是36换行Z换行1,还是一行里写36 Z 1,都不影响读入。需要注意的只是题目要求读入顺序,如果题意是先给两个数再给 B,那就按题目调整一下顺序即可。
4.2 手动验证三个典型样例
有了代码之后,一定不要直接交,先在本地跑几个典型样例确认逻辑。我最常用的验证组合有三种:普通十进制、二进制、最大进制 36 进制。
第一个样例:
10 123 456输出应该是579。这个例子验证的是最基础的逐位加法,能跑通说明大框架没有问题。
第二个样例:
2 101 1101是二进制下的 5,加 1 等于 6,二进制写作110。输出应该是110。这个例子能验证“逢二进一”的进位逻辑,毕竟 1 + 1 要进位这件事,和十进制很不相同。
第三个样例:
36 Z 1输出应该是10。Z是 35,加 1 等于 36,36 进制下写作10。这个例子是最高位进位和字母符号转换的综合测试。
这三个样例跑过,后面再出错大概率就是边界问题,而不是主逻辑问题。我自己在本地测试时,还会再加一个0 + 0 = 0的用例,确保空串或者全零的情况没有被错误处理掉。
4.3 边界用例检查:0、1位、最大符号Z
除了上面三个样例,还有几个边界用例值得单独拿出来讲。
第一个是结果为 0。输入两个 0,反转后长度都是 1,循环里算出来的sum是 0,res会存一个'0',所以最终输出0,这是正确的。但如果你把“res 为空”当成输出空串,就会直接漏掉这个 0。好在我们从循环一开始就会向res里 push 字符,所以每个位上都会生成结果,不会出现空串问题。要格外注意的其实是另一种写法:如果你先把每一位数字存进vector<int>,最后统一转字符,那么全 0 的结果也要保证至少有一个0被输出。
第二个是位数不对称。比如 36 进制下输入Z和ZZZZZ,短的数在高位全是 0,长的数有五位。反转后索引 0 都是个位,其余位置短数取 0,循环跑完正好得到六位结果。这个用例能测试补零逻辑是否写对。
第三个是低位进位连续传导。比如十进制999 + 1,个位 9 + 1 = 10,本位 0,进位 1;十位 9 + 1 = 10,本位 0,进位 1;百位同理。最后循环结束进位还是 1,必须补一位变成1000。这种连续进位的情况最容易在“最高位进位”那里翻车,测试时一定不要只测12 + 34这种温和用例。
5. 我实际提交时踩过的坑与排查链路
5.1 长度不一致导致的数组越界
我第一次提交的时候,犯过一个很蠢的错误:我直接按a.size()作为循环次数,然后把b[i]也取出来算。当a比b长的时候没问题,但当b比a长,循环还没跑完就已经越界了。本地测试时我用的是两个长度相同的数,没暴露问题;一提交,直接 Runtime Error。
排查的时候我还以为是reverse写错了,回头一行行看才发现是循环边界用了短的字符串长度。这个问题也给我留下一个教训:高精度题里,“长度不一致”几乎是必考边界,一定要用较长的长度作为循环次数,短的数在越界处用 0 代替。现在我在代码里一律写da = i < (int)a.size() ? ... : 0,从根上杜绝越界。
5.2 结果全是0却被当成空串输出
另一个坑和res的初始化方式有关。我最早写高精度时习惯用一个vector<int>存数字位,最后统一转字符。在处理0 + 0时,循环只产生了一个0,本来完全正常。但我后来为了省事,把res字符串直接留空,循环里只对非零数字才执行push_back,结果就是输出一个空串。
这个问题的排查过程其实很典型。我先是把样例从123 + 456一路试到0 + 0,发现输出是空的时候,第一反应是charToVal或者valToChar写错了。但打印出来之后发现数字全对,最后才想到是“我以为每一位都会 push,但实际上我只在特定条件下 push”。解决方案很简单:不管当前位结果是什么,都要把结果的字符写进去,哪怕是'0',除非你有其他逻辑保证最终输出非空。
5.3 大写字母处理与“漏判a-z”的教训
题目说数字用0-9,超过 9 的部分用大写字母A-Z表示,这本身没有问题。但有些测试数据可能混入小写字母,或者你从别的平台复制代码时前辈的代码里用了tolower,这些都会造成字符转数字的错乱。
我遇到过一道类似进制题,数据里全是小写字母,当时的charToVal只判断了'A'到'Z',结果所有小写字母都被当成非法字符处理,程序直接崩。后来我把字符转换函数改成兼容大小写的版本:
int charToVal(char c) { if (c >= '0' && c <= '9') { return c - '0'; } if (c >= 'a' && c <= 'z') { return c - 'a' + 10; } return c - 'A' + 10; }虽然很多题并不需要这么写,但在本地调试时,我会故意构造小写输入来验证程序的鲁棒性。这种习惯救了我很多次。
5.4 调试方法:把进制改成10、2去对照验证
如果你写的代码在某些测试用例上输出不对,我最推荐的排错方式不是盯着代码干想,而是把进制参数改成 10 或 2,用最容易心算的数据去验证。
举个例子,把B固定为 10,输入999和1,你知道正确答案是1000,那就直接用这个用例跑。如果输出不是1000,问题大概率出现在进位的处理上;如果输出是1000,再把B改成 2,输入101和1,检验不同进制下的进位逻辑。进制一改,很多隐藏的问题就会立刻暴露。
我还有一个“土办法”:在循环里打印i、da、db、sum、carry这些中间值。比如输入36 Z 1,你会看到个位的中间值是35 + 1 = 36,carry变成 1,余数 0 被拼接。这个打印输出会直观地告诉你进位到底有没有生效。调试高精度题,千万不要觉得打印中间值很笨,它能帮你在五分钟内定位到任何细节错误。
6. 从B进制星球延伸出去:进制题背后的通用思维
6.1 同一套竖式框架改造成减法与乘法
B进制星球这道题学会之后,很多类似题都会变得很简单。比如 B 进制大数减法,核心代码还是那个循环,只不过把“进位”换成“借位”:当前位数字不够减时,向高位借 1,相当于本位加 B,高位减 1。再比如 B 进制大数乘法,用一个外层循环遍历第一个数的每一位,内层循环遍历第二个数的每一位,把乘积累加到对应的结果位上,最后统一处理进位。你会发现,所有这些操作的底子都是同一个竖式模拟框架。
我在之后写某个“B 进制大数乘法”的题目时,几乎没有重新思考,直接沿用了这套代码结构:反转字符串、字符转数字、逐位运算、处理进位、反转输出。只是把核心的sum = da + db + carry改成了sum = da * db + carry,再多加一层循环。理解了 B 进制加法,等于拿到了整个高精度运算家族的钥匙。
6.2 进制互转的深层逻辑
从这道题还能延伸出另一个高频考点:进制互转。B 进制字符串转十进制,本质上是“从左到右逐位累乘 B 再加下一位”;十进制转 B 进制,本质上是“除 B 取余,倒序输出”。这两个操作看起来完全不一样,但如果你理解了“进制只是数的表示方式”这件事,就会发现它们本质上就是同一个数学过程的两个方向。
这里给新手一个忠告:十进制转 B 进制时,如果数字很大,你写“除 B 取余”其实需要高精度除法,因为你用来除以 B 的数字本身可能溢出。而 B 进制星球教会我们的做法,是干脆不转来转去,直接在目标进制下运算,避开整套转换流程。所以下次遇到进制题,先问自己:我能不能在这个进制下直接计算?如果答案是能,就尽量别干“先转十进制再转回去”的傻事。
6.3 做这类题最值钱的一个习惯
回顾整个做题过程,我最值钱的一个习惯是:在写代码前,先在纸上手推一个带有进位的样例,把每一步的中间结果写出来,再照着写代码。比如 36 进制下Z + 1到底该得到什么,为什么是10;再比如 2 进制下101 + 1为什么是110。这种先在纸上推演的过程,能帮你把“进位”“补零”“反转”这些脑内容易糊掉的细节,提前变成清晰可见的步骤。我在实际做这类题时,至少一半的时间花在纸上推演,剩下的一半才交给键盘。听起来很慢,但反而是最快的方式。
另外,提交前一定要跑至少三组用例:普通加法、有连续进位的加法、位数不对称的加法。只要这三组能过,剩下的问题基本都是小概率的边界情况。你可以把这道题里积累的这套流程,直接套用到任何高精度练习上。