做算法题绕不过 3n+1 猜想,这个数学上至今没被完全证明、却被无数程序员写进代码里的规则,几乎是每个刷题系统里都会出现的常客。今天想聊的这个题目编号是 0005,名字叫“继续(3n+1)猜想”,光看名字就知道它是某个系列里的进阶版:前面一道只需要模拟计算步数,而这一道要求在一个输入集合里找出“关键数”。从做题角度来说它不算难,但想把边界情况全想清楚、把代码写规范,还是有不少值得掰开讲的地方。这篇文章就按我平时做题的思路,把数学背景、题意拆解、算法设计、完整代码和踩坑记录一次性讲清楚,不管你是刚接触这类模拟题的新手,还是想快速刷过这道题的老手,都能直接抄作业。
1. 3n+1猜想到底是什么,为什么要“继续”
1.1 卡拉兹猜想的定义与背景
3n+1猜想,也叫卡拉兹猜想(Collatz conjecture)、角谷猜想,规则简单到一句话就能说清:任取一个正整数,如果它是偶数,就除以 2;如果它是奇数,就乘 3 再加 1。把得到的结果继续按这个规则操作,最终一定会变成 1。
举个例子,从 3 开始:
3 -> 10 -> 5 -> 16 -> 8 -> 4 -> 2 -> 1,
一共走了 7 步。从 7 开始:
7 -> 22 -> 11 -> 34 -> 17 -> 52 -> 26 -> 13 -> 40 -> 20 -> 10 -> 5 -> 16 -> 8 -> 4 -> 2 -> 1。
这个猜想的问题在于:虽然计算机已经验证了极其庞大的范围,所有数字最终都落到 1,但至今没有人能给出严格的数学证明。所以它看起来像一道小学数学题,实际上是数论里著名的“钉子户”。很多刷题系统拿它当素材,正是因为它的迭代过程非常适合写程序模拟,而且规则固定、结果可预期,不会出现数据上的歧义。
1.2 “继续”二字的含义:从验证步数到覆盖关系
标题里有个关键词叫“继续”。如果你刷过比较完整的题目列表,就会知道前面通常还有一道基础题:给定一个正整数 n,直接模拟上述过程,输出走到 1 一共需要多少步。那种题本质上是“照着规则写循环”,考察的是最基本的循环控制和边界处理。
而这道“继续(3n+1)猜想”就不一样了。它不再是让你计算某一个数的步数,而是给出一组数,要在这组数里找出某些“关键数”。什么叫关键数?题目引入了一个“覆盖”概念:在验证某个数 a 的过程中,如果经过了另一个数 b,就说 a 覆盖了 b。那么在一个给定的数集里,如果一个数没有被集合内其他任何数覆盖,它就是关键数。
我最初看到“覆盖”这个词的时候愣了一下,因为数学资料里很少用这个说法。但仔细想想,它其实就是在说:如果你沿着 3n+1 的迭代链条往下走,某条链上“顺路”经过了另一个输入数字,那后者就没资格成为关键数。你可以把 3n+1 的迭代想象成一条河流,从源头出发一路往下流,凡是沿途被水漫过的地方都被“覆盖”了,关键数就是那些独立源头,没有别的河流从它身上经过。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 读题拆解:题目到底在问什么
2.1 输入输出规则逐条过一遍
在开始写代码之前,先把题目的输入输出格式说清楚。一般这类题目的描述是:第一行给出一个正整数 K,表示待验证数字的个数;第二行给出 K 个互不相同的正整数,每个数不超过 100。输出要求是:把 K 个数中的所有关键数按从大到小的顺序输出,数字之间用空格分隔,行末不能有多余空格。
这里有几个容易忽略的点:
- 输入数字互不相同,所以你不需要处理重复元素的情况。
- 每个数都是正整数且不超过 100,这意味着数量级很小,K 一般也不超过 100。
- 输出必须降序,不是升序,很多人写到最后忘了排序,或者排序方向反了。
- 每个数字之间用空格隔开,末尾不能有空格。这个格式问题在在线评测系统里是硬性要求,多一个空格、少一个空格都可能直接判错。
2.2 关键数的判断逻辑
关键数的判断逻辑需要特别注意“覆盖”的定义范围。题目里说的覆盖,是验证 a 的过程中产生的所有中间数,这个“过程中”不包含起点 a 本身。换句话说,a 自己不能覆盖自己。
举个例子,假设输入的数字是 3、5、6、7、8、11。我们从 3 开始验证:
3 -> 10 -> 5 -> 16 -> 8 -> 4 -> 2 -> 1
这一路上经过了 5 和 8,它们都在输入集合里,所以 5 和 8 都被 3 覆盖了,不能算关键数。再看 6:
6 -> 3 -> 10 -> 5 -> 16 -> 8 -> 4 -> 2 -> 1
6 的验证过程经过了 3,但 3 自己验证的时候并不会经过 6,所以 3 不会被 6 覆盖,3 依然是关键数。再看 7 和 11:
7 -> 22 -> 11 -> 34 -> 17 -> 52 -> 26 -> 13 -> 40 -> 20 -> 10 -> 5 -> 16 -> 8 -> 4 -> 2 -> 1
7 的验证过程经过了 11,所以 11 被 7 覆盖,不能算关键数。最后还有 5、8 已经被覆盖,6、7、3、11 里,6 覆盖了 3,但 3 没有覆盖 6,所以最终关键数是 3、6、7,降序输出为 7 6 3。
这个例子能很好地说明:判断一个数是否为关键数,不是看它覆盖了多少别人,而是看它有没有被别人覆盖。只要有一个输入数字的验证链条经过了它,它就出局了。
3. 算法设计:不要一上来就暴力搞
3.1 朴素方案的问题在哪里
拿到这道题,最直接的想法是:把每个输入数字的验证链条都完整算出来,存成一组集合,然后任意两个数字之间互相比较,看谁覆盖谁。这种做法理论上可行,但写起来非常啰嗦:你需要为每个数维护一个集合,再写双重循环做集合包含判断,代码量大,而且容易写错。
更关键的是,这种方案完全没有必要的复杂度。两个数之间覆盖关系的本质,就是“a 的验证链条里有没有出现 b”。如果你为每个数都存链条,再两两比较,时间复杂度至少是 O(K²),虽然 K 很小不影响通关,但这种写法一旦数据量变大就会立刻崩掉。做题不能只满足于“能过”,还要养成“选对方法”的习惯。
3.2 标记法:用一个全局集合解决问题
更好的思路是逆向思维:不用关心每个数覆盖了谁,只需要知道每个数有没有被覆盖。那么可以把所有输入数字的验证链条统一“画”到一个全局集合里,哪个数字在链条上出现过,就把哪个数字标记为“已被覆盖”。
具体操作分三步:
- 准备一个全局的标记集合(或者标记数组),初始为空。
- 依次对每个输入数字执行 3n+1 迭代,每次迭代产生的数字都加入标记集合。
- 遍历原始输入数字,找出那些没有被标记的数字,这些就是关键数。
这里有一个非常重要的细节:标记集合里不能包含迭代的起点本身。因为起点只是“被验证的对象”,题目说的覆盖只针对验证过程中间产生的数字。如果你把起点也标记了,那所有的输入数字都会被认为“被自己覆盖”,关键数就会全部消失,结果必错。
3.3 数据结构选型:哈希集合还是大数组
既然要标记“数字是否出现过”,最自然的做法是哈希集合,比如 C++ 的 unordered_set 或 Python 的 set。对于这道题,哈希集合完全够用,代码也很简洁。
但如果追求极致性能和稳定,我建议用数组。原因很简单:3n+1 迭代产生的数字范围有限,输入数字不超过 100,虽然过程中会出现大于 100 的中间值,但也不会离谱地大。实际测试下来,开一个长度 100000 的布尔数组完全够用,而且数组的随机访问比哈希表快得多,代码也不会复杂多少。
用数组还有一个额外的好处:标记操作和查询操作都是 O(1),没有任何哈希冲突的隐形成本,调试的时候也能直接通过数组下标看状态,比较直观。
3.4 复杂度分析与可行性估算
来算一下复杂度。输入数字个数 K 最多 100,每个数字在 3n+1 迭代过程中会产生多少个中间值?经验上,对于不超过 100 的数字,迭代步数通常在几十步以内,整条链条的数字个数不会超过 200 个。所以总体标记次数最多也就是 100 × 200 = 20000 次,时间上是完全无压力的。
空间方面,如果用布尔数组,长度 100000 也就占用不到 0.1MB,可以忽略不计。如果用哈希集合,存储的元素个数同样不超过全部中间值去重后的数量,量级也不会很大。
这组数据明确告诉我们:这道题用最朴素的标记法就能跑得飞快,完全不需要任何花哨优化。做这类“小数据模拟题”的时候,先估算数据量级是非常重要的习惯,它决定了你选用什么方案,也决定了你要不要把时间花在优化上。
4. 完整代码实现与逐段解析
4.1 C++ 完整代码
说了这么多,直接上代码。我平时在刷题系统里用的都是 C++,下面是完整的可运行版本:
cpp复制#include <cstdio>
#include <vector>
#include <algorithm>
const int MAXN = 100000;
bool covered[MAXN];
void verify(int x) {
while (x != 1) {
if (x % 2 == 1) {
x = 3 * x + 1;
} else {
x /= 2;
}
covered[x] = true;
}
}
int main() {
int k;
scanf("%d", &k);
std::vector<int> nums(k);
for (int i = 0; i < k; i++) {
scanf("%d", &nums[i]);
}
for (int i = 0; i < k; i++) {
verify(nums[i]);
}
std::vector<int> result;
for (int i = 0; i < k; i++) {
if (!covered[nums[i]]) {
result.push_back(nums[i]);
}
}
std::sort(result.begin(), result.end(), std::greater<int>());
for (int i = 0; i < (int)result.size(); i++) {
if (i > 0) printf(" ");
printf("%d", result[i]);
}
printf("\n");
return 0;
}
这段代码的结构非常清晰,一共四步:读入数据、验证并标记、筛选关键数、降序输出。每步都对应题目的一个要求点,没有任何多余操作。
4.2 关键细节:为什么起点不能标记
代码里最容易出错的地方就是 verify 函数的循环结构。注意我在进入循环之后先把下一个数字标记了,而不是在循环开始前把 x 本身标记了。这样天然保证了起点不会被标记为“被覆盖”,因为起点在进入循环之前没有插入任何标记操作。
可能有人会问:那如果验证到某个中间数字,恰好和起点相等,怎么办?这种情况其实不会发生。3n+1 迭代有一个特性:除了 4-2-1 这个终末循环外,链条上不会回到已经出现过的数字。所以只要输入数字不等于 1,而且不是 4、2、1 这一组里的特殊情况,就不会把自己的起点再次作为中间值出现。
另外,验证到 1 就停,是因为 1 之后下一步是 4,再下一步是 2,再下一步又回到 1,无限循环。题目只需要处理正整数,所有数字最终都会到 1,所以在 x == 1 的时候跳出循环是正确且必要的。
4.3 数组大小到底开多大
MAXN 设置成 100000 是我个人的习惯。有人会觉得 1000 就够了,但实测下来不太保险。虽然输入数不超过 100,但 3n+1 迭代过程中有一个有趣的现象:某些数字的链条会“冲高”到比输入值大很多。比如 27 的链条会冲到 9232,虽然 27 本身不在输入范围(不超过 100)内,但它的中间值远超 100,这提醒我们一定要给中间值留有足够余量。
对于输入不超过 100 的情况,100000 空间已经非常宽裕。即使换一个稍大的数据范围,这个数量级也基本够用。如果你实在不放心,可以开 vector
4.4 Python 和 Java 的实现要点
如果你习惯用 Python,逻辑完全一样,只是语法不同:
python复制def verify(x):
while x != 1:
if x % 2 == 1:
x = 3 * x + 1
else:
x //= 2
covered.add(x)
k = int(input())
nums = list(map(int, input().split()))
covered = set()
for n in nums:
verify(n)
result = [n for n in nums if n not in covered]
result.sort(reverse=True)
print(" ".join(map(str, result)))
Python 版本最需要注意的是集合的命名不要和内置方法冲突,另外就是输出格式,用 join 可以很干净地处理空格问题。Java 版本也是同一个套路,用 HashSet 或 boolean 数组都可以,就不展开贴完整代码了,逻辑和 C++ 一一对应。
5. 常见问题与排查实录
5.1 经典失误:把自己算成被覆盖
我在第一次写这道题的时候就踩过一个坑:在 verify 函数里,我在进入 while 循环之前先把 x 本身标记成了 covered。结果可想而知,所有输入数字都被标记了,输出结果是一个空行。
这个错误的根源是混淆了“起点”和“中间值”的概念。题目说的覆盖,是验证过程中产生的数,起点不被算作验证结果。解决的方法有两种:一是像上面的代码一样,在计算下一步并更新 x 之后再标记;二是先保存起点值,最后从标记集合里去掉起点。第二种办法比较绕,我建议直接用第一种,代码上天然规避这个坑。
排查技巧:如果你运行完发现结果为空,或者结果特别少,十有八九是这个原因。可以先拿一个只有两个数字的例子,比如输入 2 和 1,手算一下覆盖关系再对照输出,很快就能定位。
5.2 输出格式:末尾空格和排序方向
在线评测系统对输出格式非常严格,行末多一个空格都是错。很多新手容易在最后输出的时候写出“每输出一个数字就带一个空格”的代码,最后一行的末尾也会多一个空格,这就会 WA(Wrong Answer)。
我习惯用“先判断是不是第一个,不是就输出空格”的模式,也就是代码里的:
cpp复制if (i > 0) printf(" ");
printf("%d", result[i]);
这样既不会在开头多出空格,也不会在结尾多出空格,很干净。
排序方向也容易被忽略。题目要求从大到小输出,也就是降序。C++ 里用 sort 默认是升序,所以一定要加上 greater
5.3 数据边界:1 特殊吗
输入数字都是正整数,那么 1 有没有可能是输入之一?有可能。如果输入里有 1,它的验证过程会怎样?
我的 verify 函数里,1 进入 while 循环时条件就直接不满足,直接跳过,所以 1 不会被标记为被覆盖。而其他数字的验证过程中会不会标记 1?比如 2 的验证过程是 2 -> 1,会先标记 2 的下一个数字 1 再跳出吗?不会。因为循环条件是 while (x != 1),当 x 从 2 计算下一步得到 1 时,covered[1] 会被标记为 true,然后下一次循环条件判断 x != 1 发现不满足,就退出了。所以 1 确实会被其他数字标记。
这就导致一个有意思的情况:如果输入只有 1,那 1 不会被任何数字覆盖,1 就是关键数,输出 1。如果输入是 1 和 2,2 的验证过程会把 1 标记为被覆盖,但 1 的验证不会标记 2,所以最终只有 2 是关键数。这个结果符合题目的覆盖定义,不用额外特判。很多题解会单独讨论 1,其实按照标准写法,1 的情况被循环条件自然处理了,不需要专门写 if。
5.4 从题目到工程:覆盖思想还能用在哪
做完这道题,我建议你停下来想一想:“标记所有经过的节点,再筛选目标节点”这个套路,远不止这一道题能用。它在很多工程场景里都有对应。
最直接的是依赖关系分析。比如一个构建系统里有 100 个模块,每个模块构建时会自动带上它依赖的模块,你想知道哪些模块是“根模块”,就可以用同样的标记法:遍历所有模块,把每个模块的依赖链路上的模块都打上标记,最后没有被标记的就是根模块。
另一个例子是垃圾回收算法里的可达性分析。从一个根集合出发,遍历所有引用,标记所有可达对象,最后没被标记的对象就是可以被回收的。虽然标记的方式更复杂,但思想是一模一样的:先标记,再筛选。
所以我一直觉得,刷题的意义不在于背下某一个题的解法,而在于把这种“标记 + 筛选”的思维模型内化成自己的工具。下次遇到任何“找出不被别人影响/依赖的元素”这类问题,你都能第一时间想到这个套路。
最后说点个人的体会。这道题初看是个数学模拟题,但实际上考的是怎么把覆盖关系用集合表达清楚。我在实际写的时候,第一版就是忘了把起始数字排除在覆盖标记之外,结果全被过滤掉了,排错花了十分钟。后来我给自己定了个习惯:凡是这种“过程生成中间产物”的模拟题,先画一条从输入到输出的小数据链路,再动手写代码,能省很多时间。这个习惯后来在做图遍历、拓扑排序这类题时也帮了不小忙。如果你正在刷这类题目,建议自己也多留意这些通用思想,别只为了 AC 就完事,搞懂背后的建模方式,才是这道题真正的收获。
