1. 先看懂题目:快乐数到底在考什么
1.1 题目原文与核心含义
LeetCode 202这道题,描述起来非常短:编写一个算法来判断一个数 n 是不是快乐数。快乐数的定义是:对于一个正整数,每一次将该数替换为它每个位置上的数字的平方和,然后重复这个过程直到这个数变为 1,也可能是无限循环但始终变不到 1。如果可以变为 1,那么这个数就是快乐数。
我最早刷到这道题的时候,第一反应是"这不就是个简单的循环求和吗",但真正动手写才发现,难点不在"求平方和",而在后面那句"也可能是无限循环但始终变不到 1"。判断一个数最终能不能到 1,本质上是在问:这个过程是走向终止,还是陷入了一个永远逃不出来的环。这一下就把题目从"模拟计算"提升到了"循环检测",这才是 LeetCode 把它放在 202 这个位置、并且标为"简单"却依然让很多人卡住的原因。
举个具体例子,19 是快乐数,因为:
19 → 1^2 + 9^2 = 82 → 8^2 + 2^2 = 68 → 6^2 + 8^2 = 100 → 1^2 + 0^2 + 0^2 = 1
而 2 不是快乐数,因为:
2 → 4 → 16 → 37 → 58 → 89 → 145 → 42 → 20 → 4
看到没有,从 4 开始绕了一圈又回到 4,这就是"无限循环但始终变不到 1"的典型案例。很多第一次接触这道题的人,模拟到 89 或者 145 的时候会觉得"是不是我算错了",其实不是,这就是非快乐数的宿命——它们最终都会掉进同一个固定循环里。
1.2 这道题远不止"简单题"那么简单
虽然 LeetCode 把快乐数标为 Easy,但它在面试里出现的频率一点都不低。我身边好几个朋友在面试中被问过这道题,而且面试官几乎都会追问一句:如果不能用额外空间,你怎么知道它陷入了循环?这个问题才是真正的分水岭。
这道题实际上在考察三件事:
第一,能不能看穿"无限循环"的本质。很多新手一上来就写 while 循环,结果发现程序要么跑不完,要么超时,就是因为没有意识到数字会循环。第二,知不知道用什么数据结构来检测重复。最常见的答案是哈希集合,没毛病,但能不能进一步想到快慢指针,就需要对"环检测"有更深的理解。第三,能不能写出高效的"求各位数字平方和"的函数。很多解法用字符串转换,方便是方便,但在性能和面试观感上都会打折扣。
另外,这道题背后还有一个被大部分人忽略的数学事实:非快乐数一定会进入循环,而且所有非快乐数最终都会进入同一个循环。这个性质直接催生了第三种解法——用数学规律硬编码判断。这个解法虽然看起来"不讲武德",但在特定场景下确实是最优解。
所以不要因为它是 Easy 就轻视,把这道题吃透,你等于同时复习了哈希表、链表环检测、数学归纳和复杂度分析四个知识点。接下来我按三种解法逐一拆解,从最直观的到最极致的,再附上我在实际刷题和面试中踩过的坑。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 解法一:哈希集合检测循环,最直观的破题思路
2.1 先解决"怎么求平方和",再谈检测循环
写这道题的第一步,其实是写一个干净利落的 getNext 函数,它接收一个数,返回各位数字平方和。这个函数看似简单,但实现方式直接影响后续的代码质量。
最偷懒的写法是转字符串:
python复制def get_next(n):
return sum(int(c) ** 2 for c in str(n))
一行搞定,可读性也好。但如果面试官让你不用字符串,或者你用的语言是 C/C++,就得用取模运算:
python复制def get_next(n):
total = 0
while n > 0:
digit = n % 10
total += digit * digit
n //= 10
return total
我个人的建议是:平时练习就写取模版本,不要图省事。原因有两个:一是取模版本不依赖语言特性,移植到任何语言都通用;二是它更能体现你对"位运算"的理解,面试时被追问的概率会小很多。至于效率,两者的差异其实不大,但取模版本在应对超大整数时没有任何隐式转换的开销,心理上更踏实。
有个小细节值得注意:get_next 函数里的循环条件是 n > 0,而不是 n != 0。这是习惯问题,但写成 > 0 语义更清晰,因为 n 本身就是正整数。还有 digit * digit 其实可以用 digit ** 2,不过乘法在大多数语言里更直观也更快,我推荐用乘法。
2.2 哈希集合解法的完整实现
有了 get_next,剩下的就很简单了。用一个集合记录所有出现过的数,只要某个数重复出现,说明进入了循环;如果过程中出现了 1,说明是快乐数。
python复制def isHappy(n: int) -> bool:
seen = set()
while n != 1 and n not in seen:
seen.add(n)
n = get_next(n)
return n == 1
这段代码的退出条件有两个:n 变成 1,或者 n 已经出现在 seen 集合里。最后返回 n == 1,如果是 1 就说明是快乐数,否则就是在循环里被逮住了。
很多初学者会疑惑:为什么 n not in seen 这个判断要在 get_next 之前做?为什么不先计算再判断?其实这里有个逻辑顺序问题:如果当前 n 已经在集合里,说明我们曾经处理过这个数,再算下去只会重复走老路。所以要先判断、再更新。另外一种变体是先计算再判断,也能工作,但会多算一次 get_next,逻辑上也绕一些。
这里补充一个细节:集合存储的是"已经访问过的数",而不是"已经计算出的下一个数"。二者有区别吗?其实没有,因为每个数的下一个是确定的,不存在多分支。但理解成"走过的足迹"更符合直觉。
2.3 复杂度分析:时间与空间的真实成本
这个解法的时间复杂度是 O(log n),看起来很唬人,但实际指的是需要执行的步数。因为在 get_next 过程中,每轮计算的位数都是 log n 级别的(比如一个 10 位数,每轮最多算 10 次平方和)。至于到底会循环多少轮,从数学上可以证明,对于任意 n,最终都会落到一个有限的数值范围内,所以循环次数是常数级别的,但因为和具体 n 相关,用 O(log n) 描述比较保守。
空间复杂度是 O(log n),因为 seen 集合里最多存储循环出现过的所有数字,这些数字的数量同样和位数相关。在实际测试中,对于 32 位整数范围内的输入,seen 集合的大小通常不会超过 20 个元素,空间消耗非常小。
所以这个解法从各方面看都足够应对题目要求,也是绝大多数题解采用的方案。但如果你追求极致,或者面试官要求"不能使用额外空间",那就得看快慢指针了。
3. 解法二:快慢指针,把空间复杂度降下来
3.1 为什么能想到快慢指针
如果你做过 LeetCode 141"环形链表",再来看这道题,会有一种强烈的既视感。环形链表判断的是链表里有没有环,而快乐数判断的是"数列"里有没有环。链表里用快慢指针,一个走一步、一个走两步,如果有环,两者终会相遇;在这个数列里,也可以让一个"走得快"的数和一个"走得慢"的数同向而行,如果存在循环,两个数必然会相等。
快慢指针的核心思想是:不需要记录所有的历史状态,只需要两个指针。慢指针每轮走一步(算一次 get_next),快指针每轮走两步(算两次 get_next),如果存在循环,快指针总有一天会追上慢指针。如果最终快指针变成了 1,说明是快乐数;如果快指针追上了慢指针,说明进入了循环。
这个思路的精妙之处在于空间复杂度从 O(log n) 直接降到 O(1),因为你不再需要一个集合保存所有历史数字,只需要两个变量。
3.2 快慢指针的完整代码与边界处理
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 直接等于 n,而 fast 要先走一步,等于 get_next(n)。这个设计不能改成两个都等于 n,否则 while 循环条件 slow != fast 在一开始就不满足,直接跳过了。这其实和环形链表里常用的"空头节点"处理是一个道理——让快指针先起步,才能触发后续的比较逻辑。
while 循环的退出条件有两个:fast 变成 1,说明找到了快乐终点;slow 等于 fast,说明两个指针在环上相遇了,整个序列必然陷入循环。最后返回 fast == 1,为什么不是 slow == 1?因为 fast 要么是 1,要么在环里,如果环里有 1 那 1 就是快乐数,但实际不会出现这种情况,所以检查 fast 就好。
还有一个小坑:fast = get_next(get_next(fast)) 这种嵌套写法,会不会有性能问题?不会,因为 get_next 本身就是常数量级的操作,多算一次完全可以接受。有些人会写成先算一步存变量再算第二步,也能工作,但嵌套写法更精简。
3.3 快慢指针和哈希集合的对比:什么时候用哪个
我直接说结论:刷题阶段,哈希集合更直观、更好写,面试时你写哈希集合完全没问题,这是官方题解也是大多数人会选择的方向。但如果你能顺手写出快慢指针,并且在复杂度分析时提到"空间复杂度可以优化到 O(1)",面试官对你的印象会加分不少。
下面是我整理的对比表格:
| 维度 | 哈希集合 | 快慢指针 |
|---|---|---|
| 空间复杂度 | O(log n) | O(1) |
| 代码可读性 | 更高,容易理解 | 稍绕,需要理解环检测 |
| 边界条件 | 少,只需注意循环条件 | 需要注意 fast 初始走一步 |
| 适用场景 | 通用,适合快速解题 | 适合空间敏感的高级解法 |
实际工作中,如果数据量极大、内存受限,快慢指针有明显优势。但在面试环境中,我更推荐先用哈希集合讲清楚思路,如果面试官追问"能不能优化空间",再引出快慢指针。这样既展示了你对基础解法的掌握,又体现了你具备优化意识。
4. 解法三:数学规律与硬编码,终极优化方案
4.1 非快乐数的宿命:唯一循环
如果说前两种解法靠的是数据结构,那第三种解法靠的就是数学观察。我前面提到过,非快乐数最终都会进入同一个循环:4 → 16 → 37 → 58 → 89 → 145 → 42 → 20 → 4,这个结论不是我拍的,而是可以从数学上严格证明的。
证明思路大致是:对于任意大于 999 的数,一次 get_next 处理后,结果都会显著变小。比如 999 → 243,位数直接从 3 位变成 2 位。利用这个压缩性质,可以证明任何数在若干轮之后都会落到 1 到 243 这个范围内。然后你可以写一个程序或者手算,验证 1 到 243 之间的所有数,最终要么到 1,要么进入上面那个循环。因为规律是固定的,所以判断一个数是否为快乐数,等价于判断它是否会遇到那个循环中的任何一个数。
这个发现带来了一个非常取巧的解法:只要在循环过程中发现当前数命中 4、16、37、58、89、145、42、20 中的任意一个,就可以直接判定不是快乐数并终止。
4.2 硬编码集合的极致优化:代码与演示
基于上面的规律,可以写出非常精简的版本:
python复制def isHappy(n: int) -> bool:
cycle_members = {4, 16, 37, 58, 89, 145, 42, 20}
while n != 1 and n not in cycle_members:
n = get_next(n)
return n == 1
这个版本连集合都省了,直接用一个固定的循环成员集合判断。它的效率理论上是最高的,因为最坏情况下只需要走完从 n 到循环入口的路径,不需要记录中途所有数字。而且代码非常短,一眼就能看懂在干什么。
但我必须提醒一句:这个解法虽然酷,却不是一个通用的算法思路。它不是靠推理流程来检测循环,而是依赖于"你已经知道答案"的数学结论。在面试中如果一上来就写这个版本,面试官很可能会觉得你在背题,反而不利于印象分。我的建议是:把它当作一个知识点了解,可以在讲完前两种解法后,作为补充提一句"如果你对这道题足够熟,还可以利用数学规律做到常数时间",这反而能让面试官看到你的知识广度。
4.3 数学规律的验证与扩展思考
如果你对此有怀疑,可以自己写一个脚本,遍历 1 到 243 的所有数,把 get_next 的结果打印出来,你会发现所有非快乐数最终都会命中 4 → 16 → 37 → 58 → 89 → 145 → 42 → 20 → 4 这个环。这个环是唯一的非 1 循环,也是解决这道题的所有数学方法的基础。
进一步扩展:这道题和"循环检测"相关的数学基础其实可以迁移到很多场景,比如随机数生成器的周期检测、状态机的死循环判断、甚至区块链中的哈希碰撞检测问题。核心思想都是"有限状态下的重复即循环"。理解了这一层,你再看 LeetCode 上其他和环有关的问题,比如 457"环形数组是否存在循环"、141"环形链表",会感觉它们和快乐数是同一个家族的问题。
5. 常见问题与排查技巧实录:刷题路上的真实坑点
5.1 整数溢出的陷阱
如果你用的是 C++ 或 Java,在写 get_next 的时候很容易忽略一个细节:一个很大的数经过平方和后,理论上不会超过一个 10 位数自身平方和的规模,但如果你在求平方和的过程中用了 int 类型,可能在某些极端输入下溢出。比如 n 是一个 10 位数 9999999999,它的平方和最多也就是 9^2 * 10 = 810,根本不会溢出,所以这道题在常规输入下其实不用担心整数溢出。但如果你在其他类似的题目中遇到"平方后求和"并且中间结果不设防,就真要留意了。
Python 不存在这个问题,因为它的整数是任意精度的。所以如果你用 Python 刷题,这一点可以完全忽略;但如果你用 C++,建议把 total 声明为 long long,保险起见。
5.2 循环判断的边界条件
我在练习中发现一个特别容易出错的点:判断循环的时候,很多初学者会把退出条件写成 while n != 1,然后发现程序陷入死循环,因为非快乐数永远不会到 1。这时候才想起要加一个 seen 集合。但在加集合的时候,有人的写法是:
python复制while n not in seen:
seen.add(n)
n = get_next(n)
return n == 1
看起来没问题,但如果 n 一开始就是 1,这个循环会直接跳过,然后 return n == 1 返回 True,没毛病。但如果 n 一开始是 2,循环会把 2、4、16... 依次加进集合,直到遇到循环成员时 n 已经在集合里了,循环退出,return n == 1 返回 False。逻辑是对的。不过这里有一个微妙的区别:你是先判断再添加还是先添加再判断,如果顺序反了,可能会把同一个数重复判断多次,虽然结果不变,但效率上会差一点。我的建议是严格遵守"先判断、后添加"的顺序。
5.3 测试用例设计:从特殊值到压力测试
刷题不能只看题解,还得多造几个测试用例验证自己的代码。关于快乐数,我总结了一套测试策略:
第一层是基础用例。1 是快乐数(直接到 1),2 不是快乐数(进入循环),19 是快乐数(官方示例),4 不是快乐数(进入循环的入口)。
第二层是边界用例。最大 32 位整数 2147483647 应该能快速出结果;0 虽然题目没说但严谨起见可以测一下,按定义 0 不是快乐数;负数题目没说但通常也不会遇到。
第三层是压力用例。比如让代码连续判断 1 到 1000000 中所有数字,看有没有超时、有没有死循环、有没有答案前后矛盾。我实测下来,哈希集合版本在 10 万级别数字上依然毫秒级响应,快慢指针版本更快,硬编码版本几乎瞬间返回。
如果你在这些测试中发现程序卡住不返回了,八成是死循环。这时候不要慌,先在 get_next 里加一个 print,把整个序列打印出来,看看数字是不是在围绕某个环打转。如果是,说明你的循环检测逻辑有漏洞;如果序列一直变大,说明 get_next 的实现可能有 bug。
5.4 面试中的延伸追问与回答思路
这道题在面试中的追问方向,我已经遇到过好几轮了。最常见的问题是"如果空间中不能使用额外存储怎么办",这时可以答快慢指针;"你如何证明非快乐数一定会循环",这时可以答数学归纳加范围压缩;"能不能在 O(1) 时间内判断",这时可以答硬编码循环成员集合。
还有一个容易被问到的点是:"既然你用了哈希表,那哈希表在什么情况下会退化?"虽然这在当前题目下影响不大,但面试官可能借机考察你对哈希表原理的理解。你可以回答:哈希碰撞会导致链表化、红黑树化,最坏情况是 O(n) 访问。不要跑偏,简洁准确地回答就好。
我个人建议,如果你时间和精力充裕,可以把这道题和 141"环形链表"、457"环形数组是否存在循环"放到一起刷。这三道题都涉及环检测,但数据结构完全不同(数值序列、链表、数组),对照着看会让你对"环检测"这个抽象概念有更立体的理解。
6. 一些个人的刷题体会
这道题我前前后后刷了不下五遍,每次都有新的收获。第一次是懵懵懂懂写出了哈希集合版本;第二次做了环形链表后突然意识到可以快慢指针;第三次看到硬编码版本时觉得"这也行?";第四次再刷已经能流畅地把三种思路讲给别人听。
我的体会是:简单题的价值不在于"会做",而在于"能讲透"。如果你能把快乐数的哈希集合、快慢指针、数学规律串起来讲清楚,你对循环检测这个主题的理解就已经超过了很多人。这也是我为什么在博文里花了大量篇幅说"为什么"而不是只给代码。
最后分享一个小技巧:刷题的时候,不妨给自己加一个约束——不用内置的字符串转换函数,强制用取模运算求平方和。这个约束看起来只是换了一种写法,但长期坚持下来,你对数字位运算的敏感度会提升很多,遇到更复杂的数字处理题目时你会感谢这种训练。
