刷LeetCode刷到第4天,遇到了202这道“快乐数”,说实话第一眼看到这个题目名字还挺好奇——数字也有快乐和不快乐之分?点进去读题才发现,这题本身思路不难,真正卡人的是“怎么让程序知道这条路永远走不到头”。我见过不少朋友在这道Easy题上反复提交失败,原因几乎都出在死循环上,而不是平方和计算本身。这篇文章我会从题目定义说起,把哈希集合和快慢指针两种主流解法拆开揉碎讲清楚,顺便聊聊背后藏着的数学原理和我在本地调试时冒出来的一些坑,希望能帮你把这道题吃透。
1. 从19开始拆解:快乐数到底在“玩”什么
1.1 一个能到1的数字和一个掉进陷阱的数字
先看题目最核心的定义:对于一个正整数,每一次把它替换为它每个位置上的数字的平方和,然后重复这个过程,如果最终能变成1,那它就是快乐数;如果进入了无限循环、始终变不成1,那就不是快乐数。
听起来很抽象,我们拿19跑一遍流程:
- 19 → 1² + 9² = 82
- 82 → 8² + 2² = 68
- 68 → 6² + 8² = 100
- 100 → 1² + 0² + 0² = 1
你看,19经过4步就到1了,所以19是快乐数。
再看一个反例,拿2试试:
- 2 → 2² = 4
- 4 → 4² = 16
- 16 → 1² + 6² = 37
- 37 → 3² + 7² = 58
- 58 → 5² + 8² = 89
- 89 → 8² + 9² = 145
- 145 → 1² + 4² + 5² = 42
- 42 → 4² + 2² = 20
- 20 → 2² + 0² = 4
发现没有,数字从4出发,绕了一大圈又回到了4。一旦回到4,接下来就是 4→16→37→58→89→145→42→20→4 这个环无限转圈,永远也到不了1。这就是“不快乐”的数。
所以这道题表面上是“算平方和”,但真正要回答的问题是:一条不断迭代的路径,到底是通向1,还是通向某个不包含1的循环。
1.2 题目真正要解决的难点:怎么判断“永远到不了1”
我刚开始刷这道题时,第一个冒出来的想法是:“那就无限循环算呗,只要等到n变成1就返回True。”但代码一写就发现问题:如果输入是2,程序会在 4→16→37→58→89→145→42→20→4 这个循环里无限打转,永不停止。用户要么看到程序卡死,要么等到超时。
所以这个题的实际考点是:如何在不无限运行的前提下,判断一条迭代链是否已经进入循环。这也是解题的核心矛盾——你必须找到某个“证据”,证明这条路已经重复了,再走下去也不会出现新结果。
等一下,为什么“重复”就能证明永远到不了1?这里用到了一个非常朴素的逻辑:如果某个数字在之前的迭代中已经出现过一次,那么它后面跟的数字序列会和之前完全一致。既然之前从那个数字出发没到达1,那这一次也同样到不了1。因为整个迭代过程是确定性的,同样的输入只会产生同样的输出。这个思路是整个题目最关键的突破口。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 哈希集合解法:把走过的每个数字都记住
2.1 先把代码跑起来
最直观、也最适合新手理解的办法,就是用一个集合把所有出现过的数字记录下来。
python复制def isHappy(n: int) -> bool:
seen = set()
while n != 1 and n not in seen:
seen.add(n)
n = sum(int(ch) ** 2 for ch in str(n))
return n == 1
这段代码的逻辑线非常清晰:
- 只要 n 还没变成1,并且 n 还没有在集合里出现过,就一直执行变换。
- 每次变换前,先把当前 n 存入集合,相当于记录“这条路走过这个节点”。
- 循环结束后,只需要检查 n 是否等于1。如果等于1,说明成功收敛;如果不等于1,说明是因为出现了重复数字而退出的,也就是进入了循环,返回 False。
我拿19跑了一遍,集合里依次加入 19、82、68、100,然后 n 变成1,循环条件 n != 1 不满足,退出,返回 True。拿2跑了一遍,集合里依次加入 2、4、16、37、58、89、145、42、20,然后 n 又变成4,发现4已经在集合里,循环退出,返回 False。全部符合预期。
实际上你不需要把集合里的内容打印出来,只要在 set.add() 前后加两行 print,就能看到每次进入的数字。调试时这个信息非常有用,尤其是遇到大数字时,你能直观看到数字是变小还是变大。
2.2 为什么这个算法一定会终止:抽屉原理
有些读者可能会想:万一这个迭代链特别长,集合越存越多,会不会一直膨胀下去、永远不重复?答案是不会,这里涉及一个非常基础的数学原理——抽屉原理(鸽巢原理)。
简单说,如果把 m 个东西放进 n 个抽屉,且 m > n,那么至少有一个抽屉里要放两个东西。映射到我们的场景里:每次平方和计算后的结果,都是一个有限范围内的整数。只要这个范围是有限的,随着迭代次数增加,迟早会出现重复的数字。
那这个“有限范围”有多大?关键在于一个事实:数字经过一次平方和变换后,会迅速变小。拿一个很大的数举例,比如 999999,它的各位平方和是 9² × 6 = 486,直接从6位数缩水成3位数。到了3位数的范围,平方和最大也只是 9² × 3 = 243,所以后续所有数字都会被限制在 1 到 243 的区间内。在这个区间里最多只有243个不同的整数,一旦迭代次数超过243,必然出现重复。这就是为什么“哈希集合检测重复”一定能解决问题,绝不会无限增长下去。
我实测了一下,任何一个非快乐数进入循环前的数字个数都不会太大,通常几十步以内就能完成判定。这个特性保证了哈希集合的内存开销也是可控的,不必担心集合无限膨胀。
2.3 时间复杂度与空间复杂度分析
哈希集合解法的时间复杂度很好分析。每次变换 n → next 时,需要遍历 n 的所有十进制位。n 的位数大约是 log₁₀(n),所以一次变换的时间是 O(log n)。循环会执行多少次呢?由于变换后的数字会被限制在一个有限范围内,理论上最多执行到重复出现为止,这个次数有上界,所以整体可以看成 O(log n) 级别,但如果严格一点,可以记为 O(243 × log n),常数收敛为常数后就是 O(log n)。
空间复杂度同样与哈希集合中元素的个数有关,也就是 O(log n) 数量级。这里的 log n 是位数,不是数值本身,所以实际占用非常小。
但如果你在面试中追求极致,面试官可能会追问:“能不能不用额外空间实现?”这时候就需要引入快慢指针了。
3. 快慢指针优化:从哈希集合到链表环检测的思维转换
3.1 把迭代过程看作一条隐式链表
你可能会觉得奇怪:快乐数和链表有什么关系?
我们把每次变换抽象成一个函数 f(x) = x各位数字的平方和。那么从初始数字 n 出发,就得到一条链:
n → f(n) → f(f(n)) → f(f(f(n))) → ...
每个数字指向它的下一个数字,这种一一对应的“引用关系”和链表的结构一模一样。链表里有“环”的概念,快乐数问题里也有“循环”。这样一来,判断一个数是不是快乐数,就等价于判断这条隐式链表最终是到达一个“1节点”的自环(1 的平方和还是 1),还是进入了一个不包含1的环。
哈希集合是“记住所有访问过的节点”,而快慢指针是另一种更省空间的判环方案——Floyd判圈算法,也就是常说的龟兔赛跑。它的思路是:慢指针每次走一步,快指针每次走两步。如果链表没有环,快指针会先到达终点;如果有环,快指针最终会在环里追上慢指针。
在快乐数这个问题里,如果快慢指针相遇了,说明链中存在环。此时只需要判断相遇点是不是1:如果是1,说明这个环是 1→1 的自环,数字是快乐数;如果不是1,说明坠入了非1的循环,不是快乐数。
3.2 快慢指针的代码实现与边界处理
先写一个辅助函数,负责计算平方和:
python复制def get_next(x: int) -> int:
total = 0
while x > 0:
x, digit = divmod(x, 10)
total += digit * digit
return total
然后实现主逻辑:
python复制def isHappy(n: int) -> bool:
slow = n
fast = get_next(n)
while fast != 1 and slow != fast:
slow = get_next(slow)
fast = get_next(get_next(fast))
return fast == 1
这里有两个特别容易写错的点,我当初就在这两个地方各栽了一次:
第一,初始时 slow 和 fast 不能都等于 n。如果都初始化为 n,那么循环条件 slow != fast 第一次检查时就为 False,直接退出,返回 fast == 1。对于 n=19,第一次检查 fast 还是19,不等于1,退出后返回 False,可19明明是快乐数,直接误判。所以慢指针从 n 出发,快指针必须从 n 的下一个节点(也就是 get_next(n))出发。
第二,快乐数的终止条件不能用“slow == fast 且 fast == 1”来放一起判断。因为快乐数的快慢指针最终会在 1 节点相遇,此时 slow == fast == 1;非快乐数会在环上相遇,此时 slow == fast != 1。我上面的写法是在 while 循环里用 fast != 1 作为提前退出的条件之一,循环结束后统一判断 fast == 1,这个逻辑是完备的。
另一种常见的实现是 slow 和 fast 同时从 n 出发,但第一步先统一更新,再判断是否相等:
python复制def isHappy(n: int) -> bool:
slow = n
fast = n
while True:
slow = get_next(slow)
fast = get_next(get_next(fast))
if slow == fast:
break
return slow == 1
这种写法把“先更新再判断”通过 while True 结构强制实现,从逻辑上也正确。但要注意,如果 n 本身不是快乐数,循环会在环上终止;如果 n 是快乐数,最终两者都会停在1,也能终止。两者对比,我更推荐第一种写法,因为循环条件里同时带了 fast != 1,可以在收敛时提前退出,少做几次无意义的迭代。
3.3 哈希集合和快慢指针:两种方案的取舍
从复杂度上看,哈希集合实现简单、思路直白,适合新手在笔试中快速写出无 bug 代码;快慢指针空间 O(1),更适合面试中展示你对“循环检测”这个通用思想的掌握。
从代码维护角度看,哈希集合版本的代码更短,读起来也更符合直觉;快慢指针则需要你先向面试官解释“为什么快乐数问题等价于链表找环”,解释成本稍高。我的经验是:如果面试官没特别要求,优先用哈希集合版本,因为正确性更容易保证;如果要展示技术深度,再主动写快慢指针版本,并解释清楚两者的等价性。
我本地用两种实现跑了一个100以内的测试集,结果完全一致,没有遇到分歧。
4. 藏在数学里的规律:非快乐数都逃不出同一个循环
4.1 观察数据:非快乐数有一条“必经之路”
在我调试的过程中发现一个有意思的现象:很多非快乐数,不管从哪里出发,最终都会进入同一个环里。这个环就是我们在第1章看到的:
4 → 16 → 37 → 58 → 89 → 145 → 42 → 20 → 4
比如非快乐数 5 的迭代路径是 5 → 25 → 29 → 85 → 89,然后进入这个环;非快乐数 6 的路径是 6 → 36 → 45 → 41 → 17 → 50 → 25 → 29 → 85 → 89,同样进入这个环。我连续测了十几个非快乐数,无一例外。
如果知道这个数学结论,代码还可以进一步“作弊”:只要迭代过程中遇到4,就直接返回 False。因为4一定会进入上面那个循环。但说实话,我不推荐在常规面试或练习里用这个技巧——它依赖一个需要额外证明的数学事实,而且这种写法的可扩展性很差。万一面试官换一道“快乐数变形题”,比如“最后收敛到3算成功”,这种魔法数字就得彻底推翻重写。相比之下,用哈希集合或快慢指针检测循环,才是真正通用的解法。
4.2 数学证明:为什么迭代过程一定会收缩到有限范围
很多初学者会想:数字会不会一直变大,大到没有上限?比如给一个极大的数,平方和会不会也极大?答案是不会。这背后其实是一道很简单的数学题。
对于一个 n 位数,它的每一位最大是9,所以它的各位平方和最大是 9² × n = 81n。而一个 n 位数的最小值是 10^(n-1)。对比一下这两个数:
- n = 1:最大平方和 81,一位数范围 1~9,变换后可能到两位数,不能保证收缩。
- n = 2:最大平方和 162,两位数范围 10~99,变换后可能到三位数,也不能保证收缩。
- n = 3:最大平方和 243,三位数范围 100~999,变换后最多还是三位数,可以维持或收缩。
- n = 4:最大平方和 324,但四位数最小值是 1000,而 324 < 1000,所以任何四位数经过一次变换后都会变成三位数或更小。
当 n ≥ 4 时,10^(n-1) 的增长速度远远超过 81n,所以位数越多,收缩得越明显。以 n = 10 为例,10位数的最小值是 1000000000,而 81 × 10 = 810,一次变换后直接从10位数变成最多3位数。
于是可以得出结论:不管初始数字多大,经过若干次变换,它一定会掉进 1 到 243 这个“陷阱区间”里。在这个区间内只有243个不同的正整数,无限迭代必然产生重复。既然一定会重复,那么就可以放心地用“检测重复”来终止程序。
这个证明还有一个附带产物:它解释了为什么题目不需要考虑“无限增长的路径”这种边界情况。LeetCode 给定的 int 范围内,实际上在几次迭代后就会进入有限的小数字区间,程序判定起来非常快。
5. 用三种语言实现并对比:Python、JavaScript、C++
5.1 哈希集合版本的三语言对照
同一道题,用不同语言写出来的代码风格差异还挺大。我先把三份哈希集合版代码全部贴出来,方便你对比。
Python 版本:
python复制def isHappy(n: int) -> bool:
seen = set()
while n != 1 and n not in seen:
seen.add(n)
n = sum(int(ch) ** 2 for ch in str(n))
return n == 1
JavaScript 版本:
javascript复制function isHappy(n) {
const seen = new Set();
while (n !== 1 && !seen.has(n)) {
seen.add(n);
n = String(n).split('').reduce((sum, ch) => sum + Number(ch) ** 2, 0);
}
return n === 1;
}
C++ 版本:
cpp复制class Solution {
public:
bool isHappy(int n) {
unordered_set<int> seen;
while (n != 1 && seen.find(n) == seen.end()) {
seen.insert(n);
int next = 0;
while (n > 0) {
int d = n % 10;
next += d * d;
n /= 10;
}
n = next;
}
return n == 1;
}
};
5.2 语言差异带来的注意点
先看 Python。sum(int(ch) ** 2 for ch in str(n)) 确实很省事,字符串转换加生成器表达式一行搞定。但它的性能开销在于每次都把整数转成字符串再逐字符转回整数。刷题时这个量级没问题,如果要在性能敏感的场景用,就还是应该写 while 循环配合 % 和 //,像 C++ 那样手动取每一位。
再看 JavaScript。String(n).split('') 转成字符数组,再用 reduce 累加,写法很函数式。有一个小坑:Number(ch) ** 2 里的 ** 运算符在 ES2016 之后才支持,如果你在比较老的环境里跑,得改成 Math.pow(Number(ch), 2)。另外注意 +ch 也能把字符转成数字,但可读性不如 Number(ch),我一般不建议在面试题里用太隐晦的写法。
再看 C++。unordered_set 的 find 返回迭代器,判断条件写起来比较啰嗦,但胜在性能好。C++ 版的按位拆分逻辑最接近本质,n % 10 取低位,n /= 10 右移一位,循环直到 n 为0。这里有个细节:int next 必须在 while 外层定义,不能在里层重复定义,否则每次循环都会重置。我在第一次写的时候,不小心把 int next = 0 写进了外层循环内部,结果每次取完一位就清零,算出来的平方和永远是最后一位的平方,调试了半天才反应过来。
5.3 快慢指针版本:同一份逻辑的三种写法
快慢指针版的思路更统一,差异主要在语法上:
python复制def isHappy(n: int) -> bool:
def get_next(x):
return sum(int(ch) ** 2 for ch in str(x))
slow = n
fast = get_next(n)
while fast != 1 and slow != fast:
slow = get_next(slow)
fast = get_next(get_next(fast))
return fast == 1
javascript复制function isHappy(n) {
const getNext = x => String(x).split('').reduce((sum, ch) => sum + Number(ch) ** 2, 0);
let slow = n;
let fast = getNext(n);
while (fast !== 1 && slow !== fast) {
slow = getNext(slow);
fast = getNext(getNext(fast));
}
return fast === 1;
}
cpp复制class Solution {
public:
int getNext(int x) {
int total = 0;
while (x > 0) {
int d = x % 10;
total += d * d;
x /= 10;
}
return total;
}
bool isHappy(int n) {
int slow = n;
int fast = getNext(n);
while (fast != 1 && slow != fast) {
slow = getNext(slow);
fast = getNext(getNext(fast));
}
return fast == 1;
}
};
我实际跑了三份代码,最直观的感受是:Python 和 JavaScript 的代码量相差不大,C++ 的代码量稍多但运行速度最快。在 LeetCode 的评测环境下,三类代码都能在几毫秒内完成,性能差距可以忽略。真正该关注的不是语言本身的速度,而是你更熟悉哪种语言、哪种写法不容易出 bug。
6. 我刷这道题时踩的坑与5个自测要点
6.1 经典错误:快慢指针初始值相同导致误判
前面说过,快慢指针如果都初始化为 n,在 while 循环里先判断 slow != fast 会直接退出,导致快乐数被误判。这个错误很隐蔽,因为对于非快乐数,它碰巧也会返回 False,你会觉得“结果好像对啊”,但一旦输入快乐数,比如19,就露馅了。
我当时的调试过程是这样的:输入19,期望 True,得到 False。我以为是 get_next 算错了,单独打印每一步,结果步进完全正确。后来打印 slow 和 fast 的初始值,才发现问题出在初始状态上。这种 bug 最容易发生在“从哈希集合思路切换到快慢指针思路”的瞬间,因为哈希集合里不需要考虑初始位置,快慢指针却需要。
还有一个相关的坑:如果用第二种写法(两个指针都从 n 出发),必须保证先更新再判断。我第一次写的时候顺手写成了:
python复制while slow != fast:
slow = get_next(slow)
fast = get_next(get_next(fast))
这看起来没问题,但如果 n 不是快乐数,循环可能在第一次进入时就因为 slow == fast 而退出(它们都等于 n),返回 slow == 1 就又错了。所以一定要理解“先更新再判断”的顺序,而不是机械地背代码。
6.2 边界用例:1、0、大整数和负数
我在本地整理了一份自测清单,虽然 LeetCode 的测试用例不会把边界情况隐藏得很深,但自己测一遍会更安心:
- n = 1:最直接的边界。哈希集合版本的
while n != 1条件一开始就不满足,直接返回 True,正确。 - n = 0:虽然题目限定正整数,但如果你在本地测试传入了0,程序会怎么样?
0 → 0,形成 0 的自环,哈希集合和快慢指针都能检测到循环并返回 False。这个结果没问题,但注意不要因为题目没给0就忽略它。 - n 为一个很大的整数,比如 1999999999。它的平方和是 1² + 9个9² = 1 + 9×81 = 730,一次变换后变成三位数,后面就进入正常的判定流程。
- 负数:题目没让处理负数。如果传入负数,比如 -1,Python 的
str(-1)包含负号,int('-')会直接报错。所以不要给负数测试输入,面试时也可以主动说明“这里假设输入是正整数”。 - 重复数字较多的数,比如 1111111111。平方和是10,然后进入 10 → 1,是快乐数。这种用例可以用来验证“看似很大的数也能快速收敛”。
我建议在本地一次性把 1、7、10、13、19、23、28、31、32、44 这几个已知道的快乐数都跑一遍,再用 2、3、5、6、8、9、11、12、14、15 这些非快乐数跑一遍,如果两边都正确,代码基本就稳了。
6.3 实测快乐数列表和自测方法
我把自己跑出来的 1 到 100 之间的快乐数列在下面,供你对照验证:
1, 7, 10, 13, 19, 23, 28, 31, 32, 44, 49, 68, 70, 79, 82, 86, 91, 94, 97, 100
这个列表是可以用多种方式互相验证的:哈希集合版和快慢指针版跑出来的结果应该完全一致;如果两个版本结果不一致,那几乎可以肯定是你指针更新顺序写错了。另外,你可以顺手统计一下,1到100之间快乐数占比大概五分之一,这个密度比想象中高不少,用来做冒烟测试很合适。
还有一个我在调试中用到的小技巧:把 get_next 单独抽出来测试。比如单独验证 get_next(19) == 82,get_next(82) == 68,如果辅助函数正确,主逻辑的问题就能快速定位到循环或判断条件上。这种“先测纯函数、再测流程”的方式,不仅适用于这道题,刷其他需要辅助计算的题也一样好用。
最后说一点个人体会。快乐数这道题本身不难,但它把“循环检测”这个通用问题包装得很巧妙。刷过这道题之后,我再看链表判环、状态机死循环检测这类问题,都会下意识地先想:能不能用哈希集合?能不能用快慢指针?能不能把状态转换看成一条隐式链表?这种思维迁移的能力,比单纯记住这道题的答案要有用得多。如果你刚开始刷题,建议先把哈希集合版本写顺,再尝试挑战快慢指针版本,两道坎都迈过去之后,你会对“迭代与循环”这件事有更深的感觉。
