1. 从题目到解法:快乐数到底在考什么
LeetCode 202这道题,名字叫“快乐数”,听起来挺轻松,但不少第一次刷到它的朋友都会卡一下。原因很简单:题目描述很短,看起来只是让判断一个数是不是“快乐”,但真正动手写代码的时候,你会发现它其实在考察两个基础但极其重要的能力——循环检测和状态去重。
先回忆一下题目定义:对于一个正整数,每一次将该数替换为它每个位置上的数字的平方和,然后重复这个过程,直到这个数变为1,或者进入一个无限循环但始终变不到1。最终结果为1的就是快乐数,否则就不是。
举个最经典的例子,19:
1² + 9² = 82
8² + 2² = 68
6² + 8² = 100
1² + 0² + 0² = 1
所以19是快乐数。
再看一个不快乐的例子,比如2:
2² = 4
4² = 16
1² + 6² = 37
3² + 7² = 58
5² + 8² = 89
8² + 9² = 145
1² + 4² + 5² = 42
4² + 2² = 20
2² + 0² = 4
转了一圈,又回到了4。之后就会在4 → 16 → 37 → 58 → 89 → 145 → 42 → 20 → 4这个环里无限循环,永远到不了1。所以2不是快乐数。
这道题表面上是在问“数字会不会变成1”,本质上是在问:重复做同一个操作,结果状态会不会重复。如果状态重复了,说明进入了循环,而1不在循环里,那就永远等不到1。这就是判断“不快乐”的关键。
我当年第一次做这道题的时候,脑子里第一反应是“那我最多循环多少次呢?会不会一直循环下去?”,后来才意识到,这题根本不需要数学上的严格证明,只要抓住“重复”这两个字就够了。
适合刷这道题的人,不光是准备面试的求职者。哪怕你刚学完哈希表、刚接触链表,也可以用这道题来练手。它不涉及高深的算法,但能把你对哈希集合、快慢指针的理解串起来。而且,它还有一个很妙的数学性质——所有不快乐数都会进入同一个循环,这个性质在面试里被问到的概率很高,值得深挖。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心思路与解法选型:为什么都想到用哈希集合
2.1 模拟过程 + 哈希集合去重:最自然的解法
最容易想到的解法,就是老老实实按题目描述的流程走:每次计算平方和,判断是不是1,如果不是,就看看这个数之前有没有出现过。如果出现过,说明开始循环了,直接返回false;如果不重复,就把它记录下来,继续算下一个。
这里需要用到哈希集合(HashSet)来记录已经出现过的数字。为什么用哈希集合?因为它的查询和插入平均都是O(1)时间,非常适合“判断某个元素是否出现过”这种场景。
对应的Python代码非常简单:
python复制def isHappy(n: int) -> bool:
seen = set()
while n != 1 and n not in seen:
seen.add(n)
n = sum(int(c) ** 2 for c in str(n))
return n == 1
我第一次写的时候,误把 n not in seen 放在了 seen.add(n) 之前,导致第一次判断时永远不重复,虽然逻辑上没错,但容易混淆。后来我习惯先把当前数加入集合,再计算下一个数,这样每一步都保证“当前数已经被记录”。
这个解法的时间复杂度怎么算?关键在于循环次数到底有多少。有一种说法是,对于一个int范围内的数,平方和的结果不会无限增长。比如9999999999这种十位数,平方和是10 * 81 = 810,远小于2^31。而且在进入循环前,最多只会出现几百个不同的数。所以时间复杂度可以近似看成O(log n)或者更准确地说,是O(243 * 3)级别的常数时间。空间复杂度是O(1)级别,因为状态量是有限的。
但这道题在LeetCode上的官方难度是Easy,很多人刷完觉得“就这?”。其实没那么简单,因为面试官大概率会追问一句:“能不能不用额外空间?”
2.2 为什么哈希集合是最容易理解的解法
哈希集合解法的优势在于,它直接把“是否重复”这件事变成了“是否存在于集合中”的判断,思路和题目描述完全一致,几乎不需要额外推导。特别是对刚接触算法题的新手来说,这种“模拟流程 + 查重”的思维模式非常通用,可以迁移到很多其他题目上,比如判断链表是否有环的“哈希表法”、检测重复元素的“哈希计数法”。
而且,这道题用哈希集合还有一个额外的好处:它不需要你提前知道“不快乐数会进入循环”这个数学事实。你只需要遵守题目给出的规则,代码就会自动处理所有情况——如果它真的变成了1,循环条件自然结束;如果它陷入循环,集合里一定会有重复,也能退出。
这也是为什么很多题解都把哈希集合作为“标准解法”来写。它不炫技,但足够稳健,准确率最高。
2.3 快慢指针解法:省掉O(n)空间的进阶思路
如果面试官要求“不能使用额外空间”,那就得上快慢指针了。这个思路其实和判断链表有没有环一模一样:把每次计算平方和得到的新数看成链表的下一个节点,如果链表有环,说明不是快乐数;如果链表走到了1,说明是快乐数。这里有个小细节:1的平方和还是1,所以1本身就是一个自环,但这个环是“快乐”的环,遇到它就可以直接返回true。
快慢指针的代码也不复杂:
python复制def isHappy(n: int) -> bool:
def get_next(number):
total = 0
while number > 0:
digit = number % 10
total += digit * digit
number //= 10
return total
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
这里慢指针每次走一步,快指针每次走两步。如果存在环,快指针一定会追上慢指针。如果不存在环,快指针会先到达1,然后停在1(因为1的下一跳还是1),循环条件 fast != 1 会让循环退出,返回true。
我第一次写这段代码时犯了个低级错误:忘记把 fast 初始化成 get_next(n),而是直接 fast = n。结果在死循环里跑了半天。后来才意识到,快慢指针的初始位置必须不同,否则第一次判断就相等了,会误判为有环。正确做法是让慢指针指向 n,快指针指向 n 的下一跳,这样两个指针从一开始就差一步,符合“快指针走两步、慢指针走一步”的节奏。
快慢指针解法的空间复杂度是O(1),时间复杂度跟哈希集合解法差不多,同样可以看成常数级别。这种解法在面试中的加分点在于:你不仅能想到“查重”,还能联想到“链表判环”这个经典模型,说明你对数据结构之间的抽象关联有理解。
2.4 数学性质:不快乐数都会进入同一个循环
这道题背后藏着一个非常有趣的数学结论:所有不快乐数都会进入同一个循环,也就是 4 → 16 → 37 → 58 → 89 → 145 → 42 → 20 → 4 这个环。
这个结论意味着什么?意味着你可以直接判断“当前数是否等于4”来提前终止循环,而不需要记下所有出现过的数。因为一旦某个数在循环里,它迟早会变成4。所以有一种极简写法:
python复制def isHappy(n: int) -> bool:
while n != 1 and n != 4:
n = sum(int(c) ** 2 for c in str(n))
return n == 1
这个代码够短,但有一个潜在问题:它依赖“所有不快乐数都会经过4”这个数学事实。这个事实确实成立,但面试时如果你直接写这个版本,很容易被追问“为什么”?如果答不上来,反而会扣分。所以我建议:面试中优先写哈希集合或快慢指针版本,数学版本可以作为额外亮点提一下,展示你知道这个性质,但不要把它作为默认答案。
从算法面试的角度看,这道题的考点不是“会不会算平方和”,而是“能不能识别循环”。哈希集合解法考的是查重能力,快慢指针解法考的是抽象建模能力,数学解法考的是对规律的理解。三种解法各对应一种思维方式,把这些想明白,比单纯背代码有用得多。
3. 实操细节与代码实现:从直接能跑到跑得优雅
3.1 计算平方和的两种常见方式
很多初学者会在“怎么求一个数各位数字的平方和”这里卡一下。最简单的方式是转成字符串,然后逐字符处理:
python复制sum(int(c) ** 2 for c in str(n))
这种方式代码短,可读性好,Python选手一般都会这么写。但它有一个隐藏的开销——字符串转换。虽然这道题的数据规模很小,影响可以忽略,但在面试中,如果你能写出不依赖字符串转换的版本,会显得更扎实:
python复制total = 0
while n > 0:
digit = n % 10
total += digit * digit
n //= 10
这段代码用取模和整除来提取每一位数字。n % 10 得到个位数,n //= 10 去掉个位数,循环直到 n 变成0。这种方式适用于所有编程语言,尤其是C、C++、Java里没有Python那种便捷的列表推导式时,更常用。
我个人的习惯是:在LeetCode上做题用字符串转换版,因为快;在面试手写时用取模整除版,因为不需要解释“为什么转字符串”。两种方式选哪种,看场景。
3.2 哈希集合版本的一步步拆解
假设我们用Python写一个完整的哈希集合解法,可以拆成三个部分:
- 定义辅助函数
get_next(n),负责计算平方和。 - 初始化一个空集合
seen。 - 在循环中判断:如果 n 等于1,返回True;如果 n 已经在 seen 里,返回False;否则把 n 加入 seen,然后让 n = get_next(n)。
对应的代码:
python复制def isHappy(n: int) -> bool:
def get_next(number):
total = 0
while number > 0:
number, digit = divmod(number, 10)
total += digit * digit
return total
seen = set()
while n != 1 and n not in seen:
seen.add(n)
n = get_next(n)
return n == 1
这里用到了Python内置的 divmod,它同时返回商和余数,代码可以少写一行。不过 divmod 的可读性对新手来说可能不如 % 和 // 直观,所以你可以根据自己习惯来。
这段代码的执行流程,拿 n=2 走一遍:
- n=2,不为1,不在seen里,加入seen,n变成4。
- n=4,不为1,不在seen里,加入seen,n变成16。
- n=16,加入seen,n变成37。
- ... 一直走到 n=4 再次出现时,发现4已经在seen里,循环退出。
- 返回
n == 1,也就是4 == 1,结果是False。
看起来非常顺利。但如果你在LeetCode上提交这个代码,会发现所有测试用例都能过。为什么?因为题目给出的整数范围是1到2^31-1,在这个范围内,平方和的收敛速度非常快,哈希集合的大小不会超过几百,所以性能完全没问题。
3.3 快慢指针版本的正确初始化
快慢指针版本最容易出错的地方就是初始化。这里再说一遍:
slow = nfast = get_next(n)
为什么 fast 不能直接等于 n?因为快指针和慢指针同时起步的话,在第一次循环判断 slow != fast 时就会相等,然后直接退出,返回 fast == 1。如果 n 不是1,那就会错误返回False。
举个例子,n=19时:如果 fast 初始化为19,slow=19,第一次循环判断 slow != fast 为False,直接返回 fast == 1,也就是 False。但19明明是快乐数,这就错了。
所以必须让 fast 先走一步。这个“先走一步”的思想在链表判环里非常常见,叫做“快慢指针的初始化错位”。牢记这一点,以后遇到类似题目就不会踩坑。
完整代码:
python复制def isHappy(n: int) -> bool:
def get_next(number):
total = 0
while number > 0:
number, digit = divmod(number, 10)
total += digit * digit
return total
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
这里循环条件是 fast != 1 and slow != fast。为什么要判断 fast != 1?因为如果快指针先到达1,那么下一跳还是1,如果不加这个条件,它会在1上一直转圈,循环会继续跑,直到 slow 也变成1,然后 slow == fast 退出。虽然结果还是对的,但多算了若干次,没必要。加了 fast != 1 可以提前结束,效率更高。
如果快指针最终等于1,返回 True;如果快指针和慢指针相遇,说明有环,返回 False。这里的相遇不一定是 slow 和 fast 同时进入环,而是快指针在环里追上了慢指针。追上时,它们一定不可能是1,所以返回 False 是合理的。
3.4 复杂度分析与边界情况
这道题的时间复杂度,从理论上看是 O(log n) 甚至更小。为什么?因为每次计算平方和,数字的位数都会大幅度减少。比如一个10位数,平方和最大是810,直接降成3位数。再算一遍,3位数的平方和最大是243,继续降。所以在进入循环之前,数字的规模会被压缩到一个很小的范围内。LeetCode官方题解给出的时间复杂度是 O(243 * 3 + log n + log log n + ...),也就是常数级别。
空间复杂度方面,哈希集合版本是 O(log n) 级别的,因为存储的数字个数和位数相关。但实际运行时,存储的数字数量非常有限,可以近似看作O(1)。快慢指针版本则是严格的O(1)。
边界情况需要注意几个:
- n = 1:直接就是快乐数。哈希集合版本会跳过循环,返回 True。快慢指针版本,slow=1, fast=get_next(1)=1,循环条件不成立,返回
fast == 1,也是 True。 - n = 0:题目说了是正整数,0不在范围内,但如果你手测代码,会发现0会进入 0 → 0 的循环,不是快乐数。
- n 为负数:题目没要求,但负数各位数字的平方和是正的,后面就按正常流程走了。LeetCode不会给负数测试用例,不用太在意。
还有一个经常被问到的:如果 n 很大,比如接近2^31-1,int类型能装下平方和吗?当然可以,因为31位数字的平方和最大是 31 * 81 = 2511,远小于int范围。这也是为什么这个算法能保证不溢出。
3.5 不同编程语言的实现要点
如果你用C++写这道题,需要注意整数溢出虽然不会发生,但要注意用 long 或 long long 来接收平方和,以防万一。另外,C++的 unordered_set 或者 set 都能用,但面试时最好用 unordered_set,因为平均O(1)查找。Java里对应的是 HashSet<Integer>,还有一个更快的做法是直接用 Set<Integer> seen = new HashSet<>();。
Go语言的话,可以用 map[int]bool 来模拟集合。Rust则可以用 HashSet<i32>。不管哪种语言,核心逻辑都一样,只要你理解了“状态重复”这个关键点,迁移到任何语言都是几分钟的事。
4. 常见问题与排查技巧实录
4.1 问题一:为什么我的哈希集合版本会超时?
理论上这道题用哈希集合不会超时,但如果你真的遇到了,检查一下是不是写了死循环。最常见的原因是:你忘记在循环里更新 n 的值,导致一直用同一个 n 去判断。比如:
python复制while n != 1:
if n in seen:
return False
seen.add(n)
# 忘了更新 n
这种情况编译器不会报错,但程序会一直在 while 循环里打转。排查方法很简单:在循环里加一个 print(n) 或者用调试器看 n 的变化,如果 n 一直不变,就说明更新语句漏了。
还有一个可能:你用了递归来计算平方和,但递归函数写错了,导致返回的结果永远是0或者同一个数。建议把计算平方和单独抽成一个函数,并写几个简单的测试用例验证一下。
4.2 问题二:快慢指针为什么有时返回错误结果?
如果快慢指针代码返回错误,绝大多数原因是初始化问题。我前面提到过,fast 必须等于 get_next(n)。但也有人会想:那如果我把 slow 初始化为 get_next(n),fast 初始化为 get_next(get_next(n)) 行不行?也行,只要保证 slow 和 fast 之间有一步的错位即可。甚至你还可以让 slow=0, fast=get_next(0)(假设从0开始走),但这不符合题意。
另一种错误原因是 fast 的更新只走了一步,没有走两步。比如:
python复制slow = get_next(slow)
fast = get_next(fast)
这样就是两个慢指针,永远不会相遇(除非恰好同时到达同一个数,但概率极低,而且如果它们恰好到达同一个数,也不能正确判环)。所以快指针必须走两步:
python复制fast = get_next(get_next(fast))
还有一种情形:你在循环条件里判断了 slow != fast,但忘了判断 fast != 1。这时候如果 n 是快乐数,且 fast 先到达1,slow 还在路上,循环会继续,直到 slow 也到达1,然后因为 slow == fast 退出,最终返回 fast == 1,结果还是对的。所以这个遗漏不会导致错误,但会多算几步。如果 n 不是快乐数,则不影响结果。所以这个不是致命问题,但最好加上,显得严谨。
4.3 问题三:有没有必要用“数学性质”来优化?
很多人会被网上那种“非快乐数必然经过4”的解法吸引,觉得代码短就是好。但我想说,在面试中,代码可解释性比代码短更重要。你写一个基于哈希集合的解法,面试官一眼就能看懂,甚至不需要你解释。你写一个基于数学性质的解法,面试官一定会追问:“你怎么证明所有非快乐数都会经过4?”如果答不上来,面试官会觉得你只是背了结论,反而印象不好。
如果非要提数学性质,可以这样回答:“我查过资料,也验证过,目前已知所有不快乐数都会进入 4→16→37→58→89→145→42→20→4 这个循环。所以可以用 n==4 作为结束条件。不过为了更通用,我还是选择用哈希集合来检测循环。”这样既展示了知识面,又保持了代码的通用性,还能体现你的判断力。
4.4 问题四:如何验证自己的解法是正确的?
除了在LeetCode上提交,我强烈建议你在本地手动跑几个用例。不用多,几个典型的就够:
- 快乐数:1, 7, 10, 13, 19, 23, 28, 31, 32, 44, 49, 68, 70, 79, 82, 86, 91, 94, 97, 100
- 非快乐数:2, 3, 4, 5, 6, 8, 9, 11, 12, 14, 15, 16, 17, 18, 20
注意,7是快乐数,因为 7²=49,4²+9²=97,9²+7²=130,1²+3²+0²=10,1²+0²=1。这个用例很容易测错,如果你把7判成非快乐数,那一定是计算平方和或循环条件有问题。
另外可以写一个暴力验证脚本,用哈希集合版本和数学性质版本分别跑1到1000,比较结果是否一致。如果一致,基本可以确定代码没写错。
4.5 避坑技巧:调试时用“打印过程”代替纯看代码
我调试这道题时,最喜欢做的一件事是打印出从 n 开始的整个变化序列。比如:
python复制while n != 1 and n not in seen:
print(n)
seen.add(n)
n = get_next(n)
这样能直观看到数字是怎么变化的。如果是快乐数,你会看到它最终变成1;如果是非快乐数,你会看到它进入循环,然后开始重复之前的某个数。这种调试方式比纯看逻辑快很多,尤其是当你的代码逻辑比较复杂时,能看到实际数据流比盯着变量猜强太多。
有一次我就是靠打印发现,我的 get_next 函数里把 number //= 10 写成了 number /= 10,导致 number 变成浮点数,取模结果全是0,平方和恒为0,然后程序无限循环。这种错误不打印根本看不出来。
4.6 高频面试追问整理
在面试中,这道题最常见的追问有这些:
-
“为什么可以用快慢指针?它和链表判环有什么关系?”
答:每次计算平方和相当于把当前数映射到下一个数,这可以看作一个从数字到数字的函数。如果从某个数开始重复,就说明形成了环。快慢指针可以在不使用额外空间的情况下检测环的存在。 -
“如果n非常大,比如有100位,这种方法还适用吗?”
答:位数很大时,第一次平方和的大小是位数乘以81,仍然会迅速下降到三位数以下。所以复杂度依然是常数级别。但注意,如果输入是一个超长字符串表示的数,就需要先将字符串转成整数或直接按位处理。 -
“为什么不快乐数一定会进入循环?会不会无限增长下去?”
答:不会无限增长。因为对于任意一个多位数,平方和都会远小于原数。比如三位数的平方和最大是243,四位数最大是324,数字越大,平方和相对于原数越小。所以最终一定会收敛到一个有限的范围内,然后要么到1,要么进入循环。 -
“这道题能用数学方法直接判断吗?”
答:可以,如果不快乐数都会进入 4 的循环,那么只需要判断过程中是否出现4即可,但这依赖数学证明,代码层面反而不够通用。
把这些追问都准备到位,这道题就不再是简单的一道Easy题,而是能体现你算法思维、边界处理和沟通能力的综合面试题。
5. 从一道题看算法面试的思维范式
5.1 “状态重复检测”是算法题里的常青树
快乐数这道题,本质上属于“状态机 + 循环检测”这一类。类似的问题还有:
- 判断链表是否有环(141. 环形链表)
- 寻找重复数(287. 寻找重复数)
- 判断字符串是否有循环节(比如“abab”)
- 模拟机器人行走,判断是否会进入循环(LeetCode 1041. 困于环中的机器人)
这些题目的共同点是:给定一个规则,让你重复执行,判断最终会不会到达某个目标状态或者陷入循环。而解决这类问题的核心工具就两个:哈希表记录历史状态,或者快慢指针检测环。
如果你能把这套思维抽象出来,下次看到类似题目,就不会觉得“这题是新的,我没见过”,而是能立刻归类到“状态重复检测”这个模板里。这种归纳能力,才是刷题从量变到质变的关键。
5.2 从暴力模拟到数学优化,每一步都有价值
很多人刷题只追求“Accepted”,但其实从暴力解法到更优解法的演进过程,才是最能提升能力的地方。快乐数这道题,暴力模拟(用哈希集合)已经能过,但如果你再想想“能不能不用额外空间”,就会想到快慢指针;再想想“有没有数学规律”,就会发现不快乐数都经过4。
这个过程很像做科研:先有一个能用的方案,然后不断追问“能不能更省资源”“有没有更简洁的规律”。即使最后你依然选择写最稳的哈希集合版本,但思考过其他方案之后,你对这道题的理解深度是完全不同的。
我建议你在刷题时养成一个习惯:每做完一道题,花五分钟问自己三个问题:
- 我用的解法里,时间和空间分别花在了哪里?
- 如果要让空间变成O(1),我能不能用快慢指针或数学方法?
- 这道题和之前做过的哪道题有相似之处?
坚持下来,你会发现自己对算法的直觉越来越准。
5.3 面试中如何讲清楚这道题
如果面试官让你现场写这道题,我建议按这个节奏来讲:
第一步,先确认题意。问清楚n为正整数,判断最终是否会变成1。
第二步,说思路。用一句话概括:“我每次计算平方和,然后用一个集合记录出现过的数,如果重复了就说明进入循环,返回false;如果变成1就返回true。”
第三步,写代码。写的时候边说边写,注释可以简单一点,但要解释关键行。
第四步,验证。跑几个例子,比如19和2,展示一下过程。
第五步,提出优化。主动说:“如果不希望用额外空间,可以用快慢指针。”然后简单描述一下思路。如果面试官让你写,就写快慢指针版本;如果只是问问,你就口头说明即可。
这样一套下来,既展示了基础能力,又展示了优化意识,面试官一般都会比较满意。
我个人在实际操作中还有一个体会:永远不要轻视Easy题。很多Easy题看起来简单,但背后可以挖出Medium甚至Hard的考点。快乐数就是典型的例子,它可以用哈希、链表、数学三种方式解,每种方式都对应一套算法思想。把这题吃透,远比你刷十道同类型的简单题更有价值。
最后再分享一个小技巧:如果你在本地调试,可以写一个辅助函数,打印出某个数生成的所有序列。比如 def show_sequence(n): ...,然后传入2,你就能亲眼看到4→16→37→58→89→145→42→20→4这个循环是怎么转起来的。一旦你直观地看到了这个环,你对“为什么需要检测循环”的理解就再也不会忘记了。
