1. 从一个数字黑洞说起:6174 到底藏着什么
第一次看到 1069 The Black Hole of Numbers 这个题目,很多人会以为是某种加密算法或者数论难题,其实它背后藏着一个非常优雅的数学现象——6174 卡普雷卡常数(Kaprekar Constant)。这个常数由印度数学家 D. R. Kaprekar 在 1949 年发现,规则简单到小学生都能上手,但收敛性质却让无数人第一次接触时感到不可思议。
先把规则说清楚。随便取一个四位数,要求四个数字不完全相同(比如 1111 这种就不行)。然后做两件事:把四个数字从大到小排一次,再从小到大排一次,用大的那个数减去小的那个数。得到结果后,重复同样的操作。最多七步,你一定会停在6174这个数上,而且一旦到达 6174,再操作一次还是 6174,形成固定点。
举个最经典的例子,从 6767 出发:
- 7766 - 6677 = 1089
- 9810 - 0189 = 9621
- 9621 - 1269 = 8352
- 8532 - 2358 = 6174
- 7641 - 1467 = 6174
四步到位。再换一个,从 1069 出发(这正是题目名字里的数字):
- 9610 - 0169 = 9441
- 9441 - 1449 = 7992
- 9972 - 2799 = 7173
- 7731 - 1377 = 6354
- 6543 - 3456 = 3087
- 8730 - 0378 = 8352
- 8532 - 2358 = 6174
七步,正好踩在上限上。所以 1069 这个数在题目里出现不是随便挑的,它是最坏情况的代表之一,需要完整走满七步才收敛。这也是为什么很多在线判题系统喜欢拿它当样例输入。
这个现象能做什么?对初学者来说,它是练习字符串排序、整数与字符串互转、循环终止条件判断的绝佳素材;对数学爱好者来说,它是探索迭代函数、不动点、数字重排的入口;对写博客或者做教学演示的人来说,它是那种"规则一句话讲完,效果却让人想动手试"的完美案例。不管你是刚学编程的新手,还是想找一个轻量级算法题练手的老手,这个题目都值得花半小时认真拆一遍。
我下面会从整体设计思路、核心细节、完整实操、常见坑四个层面,把这个"数字黑洞"彻底讲透,代码用 Python 和 C++ 各给一版,参数和边界条件都会标清楚,你可以直接抄作业。
2. 整体设计与思路拆解
2.1 为什么这道题值得认真做一遍
很多人刷题的习惯是看一眼觉得简单就跳过,但 1069 这类题目恰恰是"看着简单、写起来容易翻车"的典型。它的核心逻辑只有三步:拆分数字、排序、相减。可真正动手写的时候,你会发现边界条件比想象中多。
从算法训练的角度看,这道题覆盖了几个非常基础但极其重要的能力。第一是数字与字符串的相互转换,因为排序操作在字符串上做比在整数上做方便得多。第二是降序与升序的构造,你需要从同一组数字生成两个数。第三是循环的终止判断,什么时候停、怎么记录步数,都有讲究。第四是前导零的处理,这是最容易出错的地方,后面会专门讲。
从数学角度看,这道题让你亲手验证一个迭代收敛过程。你写的不只是一个排序程序,而是一个动力系统的模拟器。每次迭代把当前数映射到下一个数,最终所有合法输入都会落入 6174 这个吸引子。这种"从任意起点出发最终汇聚到同一点"的性质,在混沌理论和动力系统里是很核心的概念,用四位数就能直观感受到。
所以我的建议是:不要把它当成一道水题。认真写一遍,把边界都测一遍,你对基础操作的理解会上一个台阶。
2.2 方案选型:字符串路线 vs 纯数学路线
实现这个逻辑有两条主流路线,我分别说一下取舍。
字符串路线是绝大多数人的选择。思路是把四位数转成字符串,补足前导零到四位,然后sorted()得到升序字符列表,反转得到降序字符列表,再分别转回整数相减。优点是代码短、可读性强、不容易在数字拆分上出错。缺点是涉及多次类型转换,对性能敏感的场景(比如要跑几百万次)会慢一些。
纯数学路线是用取模和除法把每一位抠出来,存进数组,手动排序,再拼回两个数。优点是全程整数运算,没有字符串开销,速度快。缺点是代码长,前导零和位数处理要自己兜底,容易写错。
我的实际选择是:日常练习和面试用字符串路线,性能压测或者嵌入式场景用数学路线。这道题本身输入规模极小(就一个四位数),字符串路线的性能完全够用,可读性带来的收益远大于那点性能损失。下面主体代码我用字符串路线,数学路线会在实操部分作为对比给出来。
2.3 循环终止条件的设计陷阱
这是整道题设计上最需要想清楚的地方。终止条件看似简单——"当结果等于 6174 时停止",但实际写的时候有好几种写法,各有坑。
第一种写法是while num != 6174,循环体里做一次迭代。这种写法的问题在于:如果输入本身就是 6174,循环一次都不进,步数是 0,这符合题意吗?取决于题目怎么定义。有些题目要求输出到达 6174 的步数,输入 6174 时步数应该是 0 还是 1,需要看具体约定。
第二种写法是while True加内部break,先做一次迭代再判断。这种写法保证至少执行一次,适合"输入 6174 也要输出一步"的约定。
第三种是记录"上一个数"和"当前数",当两者相等时停止。这种写法最通用,因为它不依赖具体的黑洞值,换成三位数的 495 黑洞也能直接用。
我个人的习惯是用第三种,因为它把"收敛"这个数学本质直接编码进了终止条件,而不是硬编码一个魔法数字 6174。这样代码的语义更清晰,也更容易扩展到其他位数的黑洞问题。
2.4 前导零:这道题真正的难点
如果只能给这道题挑一个最容易翻车的点,那一定是前导零。举个例子,某一步得到 1000,拆成数字是 1、0、0、0。降序排列是 1000,升序排列是 0001,也就是 1。1000 - 1 = 999。但 999 是三位数,下一步要按四位数处理,也就是 0999,拆成 0、9、9、9。
如果你在转字符串的时候忘了补零到四位,999 会变成 "999",排序后是 "999" 和 "999",相减得 0,直接跑飞。所以每次迭代开始前,必须把当前数格式化成四位字符串,用f"{num:04d}"或者str(num).zfill(4)都行。这一步是硬性要求,不能省。
我见过太多人在这道题上栽跟头,代码逻辑全对,就是忘了补零,结果样例过不了,debug 半天。记住一句话:在这个问题里,数字的"位数"是固定的四位,哪怕它的数值小于 1000。
3. 核心细节解析与实操要点
3.1 数字拆分与排序的正确姿势
拆分这一步,字符串路线的写法是digits = list(f"{num:04d}"),得到一个长度为 4 的字符列表,比如['1', '0', '6', '9']。注意这里每个元素是字符不是整数,排序的时候按字符的 ASCII 码排,对于数字字符来说,'0' < '1' < ... < '9',所以字符排序的结果和数字排序是一致的,这一点可以放心。
升序排列用sorted(digits),得到['0', '1', '6', '9']。降序排列有两种写法,一种是sorted(digits, reverse=True),另一种是sorted(digits)[::-1]。两种都行,前者语义更明确,后者在某些语言里更快。我一般用前者,因为一眼能看出意图。
拼回整数的时候,升序用int(''.join(asc)),降序用int(''.join(desc))。这里join之后一定是四位字符串,所以转整数不会丢前导零的信息——因为前导零本来就在字符串里,转成整数后数值上确实丢了,但下一步我们会重新补零,所以没问题。
有一个细节值得说:排序和拼接的顺序不要搞反。降序拼出来的数一定大于等于升序拼出来的数,所以相减结果非负。如果你不小心把两者写反了,会得到负数,后续补零格式化会出问题(负号占一位)。所以写的时候心里默念一遍"大减小"。
3.2 迭代步数的记录方式
步数记录有两种常见约定,取决于题目要求。第一种是记录执行的迭代次数,也就是循环体执行了几次。第二种是记录到达 6174 时经过的中间值个数。这两种在数值上通常一样,但在输入本身就是 6174 时会分叉。
我的做法是用一个计数器steps,每执行一次迭代就加一,循环结束后输出。对于输入 6174 的情况,如果题目要求输出 0,那就用while num != 6174的写法;如果要求输出 1,就用while True先执行再判断。实际做题时一定要看清楚题目的输出约定,这是很多人丢分的地方。
另外,如果题目要求输出完整的迭代序列(每一步的算式),那就需要在循环里把每一步的desc、asc、result都存下来或者直接打印。这种输出格式题在在线判题里很常见,格式错一个空格都会判错,所以要严格对照样例。
3.3 输入合法性校验要不要做
严格来说,题目保证输入是合法的四位数且四位不全相同,所以理论上不需要校验。但我在实际写的时候习惯加一层防御,原因有两个:一是本地测试的时候可能会手滑输入非法值,加个校验能快速定位问题;二是如果这段代码要被复用或者改造成工具,健壮性很重要。
校验逻辑很简单:先判断是不是四位数(1000 到 9999 之间,或者允许前导零的话判断字符串长度),再判断四位是不是全相同(len(set(digits)) == 1就是全相同)。全相同的情况下,降序和升序拼出来是同一个数,相减得 0,会陷入 0000 的死循环,所以必须提前排除。
注意:1111、2222 这类四位数全相同的输入,在这个规则下不会收敛到 6174,而是直接变成 0 然后卡住。题目通常会明确排除这类输入,但你自己的代码最好也防一手。
3.4 性能与代码风格的取舍
这道题单次运行的性能完全不是问题,但如果你想把它当成一个"批量验证"的工具,比如验证所有 9000 个合法四位数是不是都能在七步内收敛到 6174,那就需要考虑一下效率了。
字符串路线跑 9000 次大概几十毫秒,完全够用。如果你想更快,可以预计算所有可能的四位数字组合的排序结果,因为四位数字的排列组合只有有限的几种(考虑重复的话是 C(10+4-1, 4) = 715 种多重集合),可以打表。不过对于这道题来说,这是过度优化,没必要。
代码风格上,我建议把"一次迭代"抽成一个函数,比如def next_number(num): ...,返回下一个数和这一步的算式信息。这样主循环就很干净,也方便单独测试每一步的逻辑。函数式拆分是这类迭代问题的好习惯。
4. 完整实操过程与核心环节实现
4.1 Python 版本:从零到可运行
先给一版最直白的 Python 实现,把每一步都写清楚,方便对照理解。
def kaprekar_step(num): """执行一次卡普雷卡操作,返回 (下一个数, 降序数, 升序数)""" s = f"{num:04d}" # 补零到四位 desc = int(''.join(sorted(s, reverse=True))) # 降序 asc = int(''.join(sorted(s))) # 升序 return desc - asc, desc, asc def solve(num): """从 num 出发,输出到达 6174 的完整过程""" steps = 0 while num != 6174: nxt, desc, asc = kaprekar_step(num) print(f"{desc} - {asc:04d} = {nxt:04d}") num = nxt steps += 1 if steps > 10: # 防御性上限,正常不会触发 print("未收敛,请检查输入") break return steps if __name__ == "__main__": print("总步数:", solve(1069))跑一下 1069,输出是:
9610 - 0169 = 9441 9441 - 1449 = 7992 9972 - 2799 = 7173 7731 - 1377 = 6354 6543 - 3456 = 3087 8730 - 0378 = 8352 8532 - 2358 = 6174 总步数: 7七步,和前面手算的一致。注意输出里0169、0378这些前导零都保留了,因为格式化的时候用了:04d。如果你不加这个格式,输出会变成169、378,虽然数值对,但和标准输出格式不符,判题会挂。
4.2 C++ 版本:给需要控性能的场景
如果你在准备 C++ 的算法题,或者想把这段逻辑嵌到对性能有要求的程序里,下面这版用纯数学运算实现,不碰字符串。
#include <bits/stdc++.h> using namespace std; pair<int,int> splitAndSort(int num) { int d[4]; for (int i = 0; i < 4; i++) { d[i] = num % 10; num /= 10; } sort(d, d + 4); int asc = d[0]*1000 + d[1]*100 + d[2]*10 + d[3]; int desc = d[3]*1000 + d[2]*100 + d[1]*10 + d[0]; return {desc, asc}; } int main() { int num; cin >> num; int steps = 0; while (num != 6174) { auto [desc, asc] = splitAndSort(num); int nxt = desc - asc; printf("%d - %04d = %04d\n", desc, asc, nxt); num = nxt; steps++; } cout << "总步数: " << steps << endl; return 0; }这版的关键点在于splitAndSort里用取模和除法把四位数字抠出来,然后sort排序,再手动拼回两个数。注意printf里的%04d同样是为了补前导零。C++ 里%04d和 Python 的:04d是一个意思。
两版代码逻辑完全一致,区别只在实现手段。Python 版可读性更好,C++ 版性能更好。你可以根据自己的使用场景选。
4.3 批量验证:所有四位数都能收敛吗
写到这里,一个自然的疑问是:是不是所有合法的四位数都能在七步内到达 6174?我实际跑了一遍验证,代码如下。
def steps_to_6174(num): steps = 0 while num != 6174: s = f"{num:04d}" desc = int(''.join(sorted(s, reverse=True))) asc = int(''.join(sorted(s))) num = desc - asc steps += 1 if steps > 20: return -1 return steps max_steps = 0 worst = [] for n in range(1000, 10000): if len(set(f"{n:04d}")) == 1: continue s = steps_to_6174(n) if s > max_steps: max_steps = s worst = [n] elif s == max_steps: worst.append(n) print("最大步数:", max_steps) print("达到最大步数的数:", worst)跑出来的结果是:最大步数是 7,达到 7 步的数有 1069、1069 的排列组合等一批。这个结论和数学上的已知结果一致——四位数卡普雷卡过程的最大迭代次数就是 7。这个验证过程本身也很有意思,它把"最多七步"这个说法从"听说"变成了"我亲手验证过"。
4.4 扩展到三位数:495 黑洞
卡普雷卡常数不只有四位数版本。三位数有一个对应的黑洞495,规则完全一样:三位数字不全相同,降序减升序,迭代收敛到 495。比如从 100 出发:
- 100 - 001 = 099
- 990 - 099 = 891
- 981 - 189 = 792
- 972 - 279 = 693
- 963 - 369 = 594
- 954 - 459 = 495
六步。你可以把上面的代码稍微改一下,把04d改成03d,把 6174 改成 495,就能验证三位数的情况。这种扩展练习能帮你真正理解"数字黑洞"的一般规律,而不是死记一个 6174。
有意思的是,两位数没有这样的黑洞,五位数及以上也没有唯一的黑洞常数(会出现多个循环)。所以 6174 和 495 是比较特殊的存在,这也是它们被反复拿出来讲的原因。
5. 常见问题与排查技巧实录
5.1 前导零丢失导致死循环
这是最高频的问题,没有之一。症状是程序在某一步之后突然输出 0 然后卡住,或者步数异常少。原因就是某一步结果小于 1000,转字符串时没补零,导致排序后降序和升序相同,相减得 0。
排查方法很简单:在每次迭代开始前打印当前数的四位格式,看看有没有出现位数不足的情况。修复方法就是所有格式化统一用:04d或zfill(4),不要有例外。
提示:如果你用的是
str(num)而不是格式化字符串,一定要在转完之后立刻zfill(4),不要等到排序的时候才想起来。
5.2 输入 6174 时步数输出错误
前面提过,输入本身就是 6174 时,while num != 6174的写法会输出 0 步,而有些题目期望输出 1 步(因为"执行了一次操作,结果还是 6174")。这个要看题目约定。
我的建议是:先看样例,如果样例里有 6174 的输入,直接对照输出。如果没有,就按"迭代次数"的语义来,也就是 0 步。实在拿不准,就把两种写法都试一遍,看哪个能过。
5.3 排序方向写反导致负数
降序减升序,结果一定非负。如果你不小心写成了升序减降序,会得到负数,然后格式化的时候负号会占一位,f"{-123:04d}"会输出-123而不是四位,后续全乱。
排查方法:在相减之前打印一下desc和asc,确认desc >= asc。修复就是把两个变量的位置换回来。这个错误很蠢但很常见,尤其是复制粘贴改代码的时候。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 修复方案 |
|---|---|---|---|
| 某步后输出 0 并卡住 | 前导零丢失 | 打印每步的四位格式 | 统一用:04d格式化 |
| 步数比预期少 1 | 终止条件写法问题 | 检查输入是否 6174 | 按题目约定调整循环 |
| 出现负数 | 排序方向写反 | 打印 desc 和 asc | 确保降序减升序 |
| 输出格式判错 | 前导零未保留 | 对照样例逐字符比 | 输出时补零到四位 |
| 全相同输入死循环 | 未排除非法输入 | 检查len(set(s)) | 提前返回或报错 |
5.5 我踩过的几个坑
第一个坑是用整数排序代替字符串排序。我一开始想省事,把四位数字存进列表用sort()排,结果发现拼回数字的时候要处理前导零,反而更麻烦。后来统一用字符串,世界清净了。
第二个坑是在循环里修改了原始输入。有一次我为了省变量,直接拿输入变量当迭代变量用,结果后面想打印原始输入的时候发现已经被改了。教训是:输入值单独存一份,迭代用另一个变量。
第三个坑是过度信任题目保证。题目说输入合法,我就没做校验,结果本地测试的时候手滑输了个 1111,程序直接死循环,我还以为是逻辑写错了,debug 了十分钟才发现是输入问题。从那以后我养成了习惯:关键位置加防御性检查,哪怕题目说不需要。
6. 这个题目还能怎么玩
把 1069 这道题做透之后,你会发现它其实是一个很好的"算法玩具",可以往好几个方向扩展。
一个方向是可视化。把每一步的降序数、升序数、差值画成折线图或者柱状图,能直观看到数值是怎么一步步收敛到 6174 的。用 matplotlib 几行代码就能画出来,很适合做教学演示。
另一个方向是统计所有四位数的收敛步数分布。前面我跑了最大步数是 7,但步数的分布是什么样的?有多少数一步到位,多少数要七步?画个直方图会很有意思。我实际跑下来,步数集中在 3 到 7 之间,1 步和 2 步的很少。
再一个方向是推广到其他进制。十进制有 6174,那二进制、八进制、十六进制有没有对应的黑洞常数?这个问题在数学上是有研究的,你可以写个程序暴力搜索一下,会发现有些进制有,有些没有,规律还挺有意思。
最后,如果你在准备算法面试,这道题可以作为一个"自我介绍式"的练手题——它足够简单,能在几分钟内写完;又足够有细节,能让你在解释的时候展示对边界条件的敏感度。我面试别人的时候如果看到候选人能把前导零和终止条件都处理干净,基本就能判断他的基本功是扎实的。
我个人在实际操作中的体会是:越是看起来简单的题目,越值得认真对待。1069 这道题我前后写过不下五遍,每次都能发现之前没注意到的细节。第一次是前导零,第二次是终止条件,第三次是输出格式,第四次是性能优化,第五次是扩展到三位数。每一次重写都是一次对基础功的打磨,这种打磨带来的收益,远比多刷十道难题要大。