如果给你一个整数 2025,你可以在任意两个数字之间插入加号,比如 2+0+2+5=9,一步就变成一位数。但换成 987654321,或者是一个更长的整数,“最快变成 1 位数”这句话就变得有点暧昧——是最少用多少步加法?还是最少插多少个加号?这两个问题的答案并不一样。
最近在准备 C++ 三级考试时,我认真推了一遍这个题目,发现它表面上是个贪心题,背后其实藏着数字根、位运算、甚至记忆化搜索的味道。这篇文章就把我从“看到题就枚举加号位置”到“看穿本质、直接模拟”的过程整理出来,给同样刷三级题的读者一些参考。文中给出的代码可以直接拿去跑,遇到类似题也能改着用。
1. 题目到底在问什么:两个容易混淆的“最少”
1.1 先把原题场景还原
C++ 三级考试里,这类题的描述一般是这样的:给定一个十进制正整数 n,你可以在它的相邻数字之间放若干个加号,得到一个加法算式,算出结果;如果结果仍然不是一位数,就继续在结果的数字之间放加号,直到结果是一位数为止。问:至少需要操作几轮?或者至少需要多少个加号?又或者要求输出其中一种加号摆放方案。
很多同学第一眼看到“加号放哪里”就会想到枚举。毕竟间隔位置就那么几个,每个间隔要么放加号要么不放,最多 2^(len-1) 种情况。于是有人一上来就写 DFS,枚举所有放法,再去比较谁快。这个思路不算错,但它忽略了一个重要的前提:你要求的是“操作轮数最少”,还是“加号符号总数最少”?这两个目标对应的最优策略并不一样。
1.2 用 12345 看出区别
我拿 12345 做个演示,先明确两种指标:
-
方式 A:每个间隔都放加号。
1+2+3+4+5 = 15,接着 1+5 = 6。一共 2 轮,加号用了 4+1=5 个。 -
方式 B:部分间隔放加号。
12+3+4+5 = 24,接着 2+4 = 6。一共 2 轮,加号用了 3+1=4 个。 -
方式 C:只在中间放一个加号。
123+45 = 168,接着 1+6+8 = 15,再接着 1+5 = 6。一共 3 轮,加号用了 2+2+1=5 个。
从轮数看,方式 A 和方式 B 都能在 2 轮内完成;从加号总数看,方式 B 反而更少。这说明“最快变成一位数”如果按轮数算,每个间隔全放不一定是最差方案;如果按加号个数算,全放也不一定是最优方案。题目里“最快”这个词,必须落在一个明确的指标上,然后才能谈解法。
以三级考试的常见出题习惯,“最快使整数变成一位数”通常指的是操作轮数最少。为什么呢?因为轮数最少有一个非常漂亮的贪心结论:每轮都把当前数字的每一位拆开相加,也就是每个间隔都放加号,就能保证总轮数不会比其他任何放法多。这也是本文后续要证明的核心。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 每个间隔都放加号,为什么是“最快”的
2.1 先把结论说清楚
对于任意一个正整数 n,设它的十进制表示长度为 len。把 len-1 个间隔全部放上加号,得到的结果是:
s = n 的每一位数字之和
比如 n=987654321,s = 9+8+7+6+5+4+3+2+1 = 45。
如果 s 仍然不是一位数,继续对这个 s 做同样的操作,直到结果是一位数。这个“全拆”策略很简单,代码也短。但它为什么是最快的?关键在于一条不等式:
如果 n 被分成若干段相加,得到 t,那么“把所有位都拆开求和”得到的 s,一定满足 s ≤ t。
这个不等式的道理非常朴素。一个多位数 ab 如果被拆成 a 和 b 相加,那么 a+b 一定小于等于原来的数 ab;如果原来就是 a+b,那两者相等。每一次“把段拆小”都不会让总和变大。全拆就是拆得最彻底,所以总和最小。
2.2 位数只会更少,所以总轮数不劣
如果 s ≤ t,那么 s 的位数一定不会比 t 的位数多。比如 t=168,位数是 3,而全拆 s=1+6+8=15,位数是 2。更严格地说,假设 s 的位数比 t 的位数多,那么 s 至少是 10 的某个比 t 更高次幂,必然大于 t,这就和 s ≤ t 矛盾了。
所以每一轮结束后,全拆得到的数位数最少。位数少意味着下一轮可供拆分的数字数量少,之后的处理不会比任何其他方案更慢。每一轮都站在最优位置,最后的总轮数自然就是最少的。
这就是一个典型的贪心证明:局部最优的“拆得最碎”,对应的全局效果也最好。考试的时候不需要写得这么严格,但心里要有这个逻辑。
2.3 顺便复习一下数字根
连续做“各位数字相加”的操作,最终得到的一位数字在数学上有个名字,叫数字根。数字根有一个非常著名的性质:
- 当 n > 0 且 n 不是 9 的倍数时,数字根等于 n 除以 9 的余数;
- 当 n 是 9 的倍数时,数字根等于 9。
比如 987654321 除以 9 的余数是 0,所以它的数字根是 9。用全拆模拟,45 → 4+5=9,结果一致。
知道这个性质,可以用来验证自己的模拟代码是否正确。但这种题目通常不会让你直接输出数字根,而是要求输出“轮数”“加号个数”或者“每轮的算式”。模拟仍然是最直接的写法。
2.4 用两个边界例子感受一下
先看 n=999。全拆:9+9+9=27,2+7=9,共 2 轮。如果只放一个加号:99+9=108,1+0+8=9,也正好 2 轮。两种方案轮数一样,但全拆的加号总数是 2+1=3 个,另一种是 1+2=3 个,也打了个平手。
再看一个更大的数:999999999999,也就是 12 个 9。全拆:9*12=108,然后 1+0+8=9,总共 2 轮。如果选择 999999+999999=1999998,再全拆 1+9+9+9+9+9+8=54,再 5+4=9,总共 3 轮。全拆明显更快。
这个例子给我一个直觉:对于大多数数,全拆都能在 2 到 3 轮内结束,而其他放法可能要拖到 3 轮以上。所以考试时看到“求最少轮数”,不要犹豫,直接全拆。
3. C++ 三级代码:读入、循环、计数一次写对
3.1 如果题目保证 n 在 int 范围内
很多三级练习题会把 n 限制在 32 位有符号整数范围内,比如 n ≤ 2147483647。这个时候可以直接用 int 读入,然后循环拆分:
cpp复制#include <bits/stdc++.h>
using namespace std;
int main() {
int n;
cin >> n;
int rounds = 0;
int totalPlus = 0;
while (n >= 10) {
int sum = 0;
int tmp = n;
while (tmp > 0) {
sum += tmp % 10;
tmp /= 10;
}
int len = 0;
tmp = n;
while (tmp > 0) {
len++;
tmp /= 10;
}
// 长度为 len 时,间隔数是 len-1
totalPlus += len - 1;
n = sum;
rounds++;
}
cout << "轮数: " << rounds << "\n";
cout << "加号总数: " << totalPlus << "\n";
cout << "最终一位数: " << n << "\n";
return 0;
}
这段代码有两个细节值得说。第一个是加号总数统计:如果当前 n 有 len 位,那么每两个相邻数字之间放一个加号,正好是 len-1 个,多放不了,少放就不是全拆了。第二个是 rounds 的统计:只有当 n 还不是一位数时才进入循环,所以 0 到 9 这类输入直接输出 0 轮。
3.2 稳妥做法:直接读字符串
说实话,考三级的时候我不太信任题目会规规矩矩给个 int 范围。“整数”这两个字有时会描述得很大,所以更稳妥的写法是直接用 string 读入 n。这样不管输入是 20 位还是 50 位,代码都不用改。
cpp复制#include <bits/stdc++.h>
using namespace std;
int main() {
string s;
cin >> s;
int rounds = 0;
int totalPlus = 0;
while (s.size() > 1) {
int sum = 0;
for (int i = 0; i < (int)s.size(); i++) {
sum += s[i] - '0';
}
totalPlus += (int)s.size() - 1;
rounds++;
s = to_string(sum);
}
cout << rounds << "\n";
cout << totalPlus << "\n";
cout << s << "\n";
return 0;
}
用 string 的好处除了能处理大数,还让代码逻辑更贴近题目描述:当前“整数”变成了一个字符串,每一位就是一个字符,计算和、记录长度都特别直观。to_string 再把求和结果转回字符串,下一轮继续处理,这个循环结构很干净。
3.3 如果题目要求输出每轮的算式
有的题目会问“输出加号放哪里”,也就是要你展示每一轮的加法过程。比如输入 12345,期望输出:
1+2+3+4+5 = 15
1+5 = 6
这种题只需要在上一版代码里加一点输出逻辑,在统计加号的同时把字符和加号打出来:
cpp复制#include <bits/stdc++.h>
using namespace std;
int main() {
string s;
cin >> s;
while (s.size() > 1) {
int sum = 0;
for (int i = 0; i < (int)s.size(); i++) {
if (i > 0) cout << '+';
cout << s[i];
sum += s[i] - '0';
}
cout << " = " << sum << "\n";
s = to_string(sum);
}
cout << "结果: " << s << "\n";
return 0;
}
输出时要注意“加号”只出现在相邻两个字符之间,第一个字符前面不要加号。这个 if (i > 0) 的小判断很容易写错,我见过不少同学把加号输出在了最前面。
3.4 边界情况的三个测试用例
写完代码一定要随手测几个边界:
-
输入 0。按数学习惯 0 已经是一位数,循环不执行,直接输出 0。题目如果保证正整数,这组测试可能不会出现,但写代码时别让它死循环。
-
输入 10。第一轮 1+0=1,ronds=1,totalPlus=1。这个用例能验证你的代码有没有正确处理以 0 结尾的数。
-
输入 1000000000。这是 10 位数,全拆后 sum=1,只需 1 轮。如果代码里用 int 处理也会正常,但用 string 更保险。
还有一个容易忽略的点:如果当前 s 是 "100",全拆求和是 1,to_string(1) 得到 "1",退出循环,不会出现“因为分母为 0”之类的问题。这个流程非常稳。
4. 怎么处理“这个数本身只有一位”的输入
4.1 为什么单独拿出来说
题目说“给定一个十进制正整数 n”,并没有保证 n 一定大于等于 10。如果 n=7,那它本来就是一个一位数,根本不需要放任何加号。很多同学看到题就想着循环,结果 while 循环条件写成 s.size() > 0,直接把一个一位数也拖进循环,最后把 7 拆成 0+7,白白多算一轮,甚至可能死循环。
正确的做法是:循环条件必须是 s.size() > 1。因为只要 s 是一位数,任务已经完成,不需要也不能再放加号。
4.2 如果输入是 0
虽然题目说是正整数,但严谨的代码还是考虑一下 0。0 是一位数,同样不需要操作,结果输出 0。如果考试时输入输出格式对 0 有特殊要求,按题面来就行。
4.3 如果输入是一串 0 加一个 1,比如 "0001"
按通常的整数理解,0001 就是 1,已经是一位数,不需要循环。但作为字符串处理时,size()=4,代码会先做一轮 0+0+0+1=1,然后输出结果 1。这会多算一轮,而且和整数意义下的答案不一致。
怎么处理?可以在循环开始前先把前导 0 去掉:
cpp复制int pos = 0;
while (pos + 1 < (int)s.size() && s[pos] == '0') pos++;
s = s.substr(pos);
当然,如果题目保证输入是纯正整数,没有前导 0,这一步可以省略。不过写上的话,测试数据再怎么奇怪都稳。
5. 如果题目真把“加号总数”当成优化目标
5.1 从轮数最优到加号总数最优
我在前面说过,全拆不代表加号总数一定最少。比如 12345,全拆一共用了 5 个加号;但如果第一轮放成 12+3+4+5=24,第二轮再 2+4=6,总加号是 3+1=4 个,比全拆少 1 个。
所以如果题目明确问“最少需要多少个加号”,全拆就不保证最优了。这类题会更难,经常会出现在竞赛或高分题里,三级考试如果出现,一般数据范围也很小,可以用搜索来做。
5.2 暴力枚举所有加号位置
对于一个长度为 len 的字符串,间隔一共 len-1 个,每个间隔有“放”和“不放”两种选择。用一个整数 mask 表示这些间隔的状态,mask 的二进制位为 1 就代表这个位置放加号。比如 s="12345",mask=0b1010 表示第 1 和第 3 个间隔放加号,也就是 12+3+45=60。
枚举 mask 从 1 到 (1<<(len-1))-1,就能覆盖每一种至少放一个加号的方案。计算当前结果 sum,然后递归处理 to_string(sum)。递归的终止条件是当前字符串长度为 1,返回 0。用记忆化数组存下每个中间状态的最少加号数,避免重复计算。
cpp复制#include <bits/stdc++.h>
using namespace std;
map<string, int> memo;
int solve(string s) {
if (s.size() == 1) return 0;
if (memo.count(s)) return memo[s];
int len = s.size();
int best = INT_MAX;
for (int mask = 1; mask < (1 << (len - 1)); mask++) {
long long curNum = 0;
int sum = 0;
int plusCnt = 0;
for (int i = 0; i < len; i++) {
curNum = curNum * 10 + (s[i] - '0');
if (mask & (1 << i)) {
sum += curNum;
curNum = 0;
plusCnt++;
}
}
sum += curNum;
best = min(best, plusCnt + solve(to_string(sum)));
}
return memo[s] = best;
}
int main() {
string s;
cin >> s;
cout << solve(s) << "\n";
return 0;
}
这个代码的关键在于 mask 的每一位对应哪个间隔。我习惯让第 i 位表示 s[i] 和 s[i+1] 之间的间隔,这样解析时边扫描边切分,代码最顺手。递归结束后,memo 里存的就是当前字符串变成一位数所需的最少加号数。
5.3 为什么不用太担心死循环
有人会问:如果一个数放加号之后结果比原来还大,会不会无限递归?比如 s="108",如果什么都不放,sum=108,又回到 s="108",就死循环了。但我们的 mask 从 1 开始枚举,保证至少有一个间隔放了加号,所以 sum 一定比原数小。比如 10+8=18,而 18 比 108 小;1+0+8=9,更小。因为拆段求和等于把一个多位数字拆成若干个部分相加,和不可能超过原数本身。所以递归一定会走向更小的数,最终停在一位数。
这个性质也是刚才全拆贪心成立的另一个角度:加号放得越多,结果越小;结果越小,离一位数越近。
5.4 记忆化搜索和三级考试的关系
三级考试如果考到“最少加号数”,那已经不是单纯模拟题了,更像是搜索入门题。我的建议是:如果平时训练时间够,可以把这个记忆化搜索练熟;如果只是应付考试,优先把“轮数最少”的贪心模拟题写对,因为那才是高频考点。
搜索题的常见坑是状态空间爆炸。一个长度为 15 的字符串,间隔数 14,枚举量是 2^14=16384,每层再递归,可能比较大。但配合 memo 之后,中间状态都是小于等于 9*len 的数字,去重效果很好,实测跑 20 位以内的数据没有问题。如果遇到 100 位的大数,那就得另想数学优化,三级基本不会考到。
6. 考场上的几个提醒
6.1 先问清楚指标再动手
我踩过最深的坑就是没搞明白“最快”指的是什么。拿到题第一反应是枚举加号位置,写了一堆 DFS 才发现题目只需要输出轮数。考试时先读题,看它要你输出的是“最少操作几步”还是“最少用几个加号”,这两个指标对应完全不同的代码。
如果要求输出轮数:直接全拆模拟。
如果要求输出加号总数且数据小:可以全拆之后发现不一定最少,也可以用搜索。
6.2 复杂度心里要有数
全拆模拟的复杂度很好分析:每一轮要扫描当前数字的所有位,位数随着和迅速缩减。对于 int 范围内的 n,最多 3 轮就能结束,每一轮最多扫描 10 个字符,几乎可以认为是 O(1)。用 string 读入时,第一轮扫描长度就是输入位数,最多三轮也够了,所以整体非常快。
这也是全拆方案在考场上的最大优势:不光是数学上最优,写起来也是最短的代码,出 bug 的概率最小。
6.3 输出格式对照题面
有些题目要求“如果不需要操作,输出原数本身”,有些要求“如果有多组方案,输出任意一种”。这些都属于容易失分的小细节。写代码前先在草稿纸上模拟一个用例,比如输入 12345,把输出格式的样例结果手算一遍,再对照代码输出,基本就能避开格式坑。
6.4 说一个我自己的小体会
后来我再遇到这种“加号放哪里”的题,第一反应不是去枚举,而是先问自己三个问题:目标指标是什么?当前数据范围多大?有没有一个不变的数学规律?比如轮数最少就有“全拆和最小”这个规律,加号总数最少就有“拆段求和不超过原数”这个规律。抓住规律之后,代码往往只需要十几行。
刷题多了你会发现,很多看似要搜索的题目,只要把目标指标想清楚,答案就在一个非常朴素的操作里。加号放哪里?当目标是轮数最少时,放在每一个间隔里就是最快答案。
