LeetCode 1010:用余数配对与哈希表统计歌曲时长整除60的对数
2026/9/7 19:46:17 网站建设 项目流程

刷LeetCode的时候,我习惯先把“看起来简单”的题先做一遍,因为这类题往往最容易在细节上翻车。1010这道题,描述很短,意思也很直白:给定一个歌曲时长列表,找出有多少对歌曲,它们时长之和能被60整除。别看它难度标记是Easy,我第一次提交就踩了超时的坑,后面又踩了边界条件的坑。这篇文章就把这道题从暴力到最优的完整思路、代码实现、常见错法一次讲清楚,顺便分享一些我在实际写题过程中的复盘心得,给正在刷题的朋友一个参考。

1. 题目解析与核心思路拆解

1.1 题目到底在问什么

题目给一个整数数组time,每个元素代表一首歌的时长(秒)。要求返回一共有多少对(i, j),满足i < j(time[i] + time[j]) % 60 == 0

举例来说,如果输入是[30, 20, 150, 100, 40],那么:

  • 30 + 150 = 180,180能被60整除,算一对;
  • 20 + 40 = 60,也能被60整除,算一对;
  • 100 + 20 = 120,也能被60整除,也算一对;
  • 其他的组合不满足。

所以输出是3。

需要注意,题里并没有说数组有序,也没有说元素互不重复,所以所有满足条件的下标组合都要计数。

1.2 为什么第一反应是“哈希表配余数”

如果先想到两层循环暴力枚举,那说明思路方向没问题,但这种做法的时间复杂度是 O(n²)。当数组长度达到几万甚至几十万时,超时几乎是必然的。

我们需要把问题转换成“余数配对”的思路:两个数之和能被60整除,本质上就是它们对60取模后的余数相加等于60,或者两个余数都为0。

换句话说,设a = time[i] % 60b = time[j] % 60,那么两个数能配对的充要条件是:

  • a == 0时,b也必须为 0;
  • a != 0时,b必须等于60 - a

有了这个等价关系,问题就变得非常简单:遍历数组,用哈希表统计之前出现过的余数,对于当前数,查找它需要的“伴侣余数”已经出现了多少次,把次数累加到答案里,然后再将自己计入哈希表。

1.3 复杂度对比

暴力解法的时间复杂度是 O(n²),空间复杂度 O(1)。如果数组有10万个数,最坏情况下要计算约50亿次加法取模,即使每秒钟几亿次运算也要跑好几秒,在实际的判题环境里妥妥超时。

哈希表解法的时间复杂度降到 O(n),空间复杂度 O(60) 或 O(n),取决于实现方式。数组长度再大,也只需遍历一次,差距是数量级的。

2. 为什么用“补数配对”比“整除判断”更优雅

2.1 补数关系的数学原理

这里有个容易犯迷糊的点:为什么条件是b == 60 - a,而不是b == 60 - a时还要考虑a + b = 0的情况?

因为取模结果的范围是 0 到 59,所以a + b只有两种可能:等于 0,或者等于 60。如果两个余数都在 1 到 59 之间,两个正数相加不可能等于0,所以只能是60。那就意味着:

  • a在 1 到 59 之间时,b只能是60 - a
  • a == 0时,b只能是 0,因为任何非零余数和0相加都落在1到59之间,无法被60整除。

只有在a == 0b == 0时才需要单独处理,其他情况统一套用补数公式。

2.2 长度为60的计数数组其实就够了

多数题解会选择长度为60的数组,因为余数只可能有60种取值。但我们也可以直接用哈希表存储余数到出现次数的映射。

用数组和用哈希表有什么区别?

  • 数组下标是固定的 0 到 59,可以直接通过count[remainder]取值,访问时间是 O(1),内存固定;
  • 哈希表在余数分布稀疏时更省空间,但在这个场景里余数最多60种,省不了多少,反而多一层哈希计算的开销;
  • 从代码可读性上看,数组法更直观,也更容易查错。

所以,除非语言里数组初始化特别麻烦,否则我建议直接上长度60的数组。

2.3 处理“余数为0”的经典错法

很多第一次做题的人会把代码写成这样:

for t in time: r = t % 60 ans += count[(60 - r) % 60] count[r] += 1

这里用(60 - r) % 60其实是一个很巧妙的写法。当r == 0时,60 - 0 = 60,而60 % 60 == 0,索引回到0,正好满足“余数为0需要找余数为0”的规则。当r != 0时,(60 - r) % 60就等于60 - r,因为60 - r的范围是1到59,取模后不变。

这个写法比强制分if r == 0else两条分支更简洁,而且不容易漏条件。我第一次做这道题时用的是显式分支,后来看到这个取模写法,直接替换掉了。

3. 完整实现与核心代码细节剖析

3.1 Python实现:最直观的计数配对法

from typing import List class Solution: def numPairsDivisibleBy60(self, time: List[int]) -> int: count = [0] * 60 ans = 0 for t in time: r = t % 60 ans += count[(60 - r) % 60] count[r] += 1 return ans

这段代码只有几行,但要理解它为什么能正确统计,需要想清楚顺序问题:

  • 当前歌曲只能与它之前的歌曲配对,所以先查count,再把当前歌曲累加进去;
  • 如果先自增再查,会把当前歌曲自己和自身配对,导致计数多算;
  • 但题目要求i < j,同一首歌不能和自己配对,所以必须先查后加。

整个流程走一遍示例[30, 20, 150, 100, 40]

初始化count全0,ans = 0

  1. 遍历30:r = 30,查count[(60 - 30) % 60] = count[30],值为0;将count[30]变为1;
  2. 遍历20:r = 20,查count[(60 - 20) % 60] = count[40],值为0;将count[20]变为1;
  3. 遍历150:150 % 60 = 30,查count[(60 - 30) % 60] = count[30],值为1,答案加1;将count[30]变为2;
  4. 遍历100:100 % 60 = 40,查count[(60 - 40) % 60] = count[20],值为1,答案加1;将count[40]变为1;
  5. 遍历40:40 % 60 = 40,查count[(60 - 40) % 60] = count[20],值为1,答案加1;将count[40]变为2。

最终ans = 3,与预期一致。

3.2 Java实现对比

class Solution { public int numPairsDivisibleBy60(int[] time) { int[] count = new int[60]; int ans = 0; for (int t : time) { int r = t % 60; ans += count[(60 - r) % 60]; count[r]++; } return ans; } }

Java的写法基本和Python一样,但有个地方要留意:如果time数组很大,ans可能超过int的最大值吗?

题目在LeetCode上给出的约束是1 <= time.length <= 6 * 10^4,最多有n * (n - 1) / 2对组合,大约是18亿对,勉强低于Integer.MAX_VALUE(约21.47亿)。所以用int理论上没问题,但如果你在本地测试时把数组长度加到10万,就会溢出,建议直接用long更稳妥。

3.3 C++ 实现与性能考量

class Solution { public: int numPairsDivisibleBy60(vector<int>& time) { vector<int> count(60, 0); int ans = 0; for (int t : time) { int r = t % 60; ans += count[(60 - r) % 60]; count[r]++; } return ans; } };

C++的vector<int>默认初始化为0,不需要额外填充。如果追求极致性能,可以把vector换成裸数组int count[60] = {0};,但差别在这个数据规模下几乎体现不出来。建议优先保证代码可读性。

3.4 Go实现

func numPairsDivisibleBy60(time []int) int { count := make([]int, 60) ans := 0 for _, t := range time { r := t % 60 ans += count[(60-r)%60] count[r]++ } return ans }

Go的数组默认零值也是0,逻辑上的写法和上面几种语言没有区别。需要注意,Go里%对负数取模会得到负值,但这道题的输入全是正整数,所以不用担心。

4. 逐步推演与边界条件测试

4.1 自己手写一遍完整的推演

再找一个更复杂的例子手动推一下,确认代码在边缘情况下的行为。假设输入为[60, 60, 60]

  • 三个数的余数都是0;
  • 第一首:查count[0],为0,然后count[0]变为1;
  • 第二首:查count[0],为1,答案加1,然后count[0]变为2;
  • 第三首:查count[0],为2,答案加2,然后count[0]变为3。

答案总共是3,手动枚举也确实是3对:(0,1)(0,2)(1,2)。这说明相同余数连续出现时,累加逻辑是自洽的。

4.2 输入只有一个元素

题目约束数组长度至少为1。如果只有一个元素,循环里只会查一次,count里全是0,答案就是0。不需要额外判断。

4.3 输入恰好都是余数互补的组合

比如[10, 50, 10, 50]

  • 第一首10:查count[50],0,count[10]=1
  • 第二首50:查count[10],1,答案1,count[50]=1
  • 第三首10:查count[50],1,答案2,count[10]=2
  • 第四首50:查count[10],2,答案4,count[50]=2

答案是4,手动枚举所有对(0,1)(0,3)(2,1)(2,3),确实是4对。程序没有问题。

4.4 边界条件小结

这道题的边界条件主要集中在余数为0和余数为30这两类特殊值。

余数为0的情况前面已经分析过,必须寻找余数为0的配对。余数为30的情况也容易让人困惑:30 + 30 = 60,也能被60整除,所以余数30的配对对象是它本身。用(60 - 30) % 60 = 30可以正确处理。

5. 三个常见错误与调试实录

5.1 两层循环超时,不是算法问题而是题目规模问题

我最初做这题时,第一版就是两层for循环写的:

ans = 0 for i in range(len(time)): for j in range(i + 1, len(time)): if (time[i] + time[j]) % 60 == 0: ans += 1 return ans

逻辑完全正确,示例测试也能过,但提交后直接判超时。后来我特意去看了一下题目的数据范围,发现数组最大长度是60000,O(n²) 在最坏情况下大约要算 1.8e9 次循环,在LeetCode的判题环境里基本不可能通过。这个教训让我养成了一个习惯:写题之前先看数据范围,快速估算复杂度是否在可接受范围内,而不是直接开写。

5.2 先更新计数导致多算

这是另一个非常隐蔽的错法。如果把代码写成:

for t in time: r = t % 60 count[r] += 1 ans += count[(60 - r) % 60]

问题在于,count[r]已经包含了当前元素自己。如果当前余数正好需要找自己(比如余数是0或者30),答案就会多算1。比如数组只有一个元素[60],正确输出应该是0,但这种写法:

  • r = 0,先count[0] = 1
  • 再查count[(60 - 0) % 60] = count[0],得到1,答案加1。

输出变成了1,错误很明显。所以顺序必须是先查后加。

5.3 用布尔值而不是计数

还有一次我把count设计成了set或布尔数组,想当然地认为“出现过就够了”。但题目要求统计所有配对数量,如果一个余数出现过多次,它们分别都能配对,那就必须计数。比如输入[20, 40, 20, 40],正确答案是4对,但用布尔标记只能记录“20出现过”和“40出现过”,最多算出一对,直接丢掉了大量答案。正确做法是存出现次数,每次配对时把次数全部累加进去。

5.4 自测用例与调试技巧

如果你写完代码后不确定对不对,可以先跑这几个用例:

输入预期输出
[30, 20, 150, 100, 40]3
[60, 60, 60]3
[10, 50, 10, 50]4
[60]0
[30, 30, 30]3
一个长度为60000的全60数组1799970000

最后一个用例需要解释一下:如果有60000个60,任意两首都能配对,组合数就是60000 * 59999 / 2 = 1799970000。这个值接近int上限但没超过,但如果你用Python,整数随便存;用Java/C++,记得确认变量类型是否够用。

6. 从这道题延伸出的几个思考

6.1 “先查后加”是一种通用套路

这个“遍历当前元素,先查询历史信息,再把当前元素加入历史”的套路,在处理“两两配对”类问题时非常常见。类似的题目包括 LeetCode 1(两数之和)、LeetCode 454(四数相加 II),本质上都是借助哈希表把暴力枚举压缩成单次遍历。区别在于这里钥匙是余数,其他题钥匙可能是数值本身。

6.2 如果不是60而是任意K,怎么改

如果把60换成别的数,比如让总和能被K整除,代码几乎不用动,只需要把count的长度从60改成K,把(60 - r) % 60改成(K - r) % K。比如 K = 1 时,所有余数都是0,任意两首都配对,答案应该是n * (n - 1) / 2。用公式(1 - 0) % 1在某些语言里会出现对0取模的问题,因此实际写通用解法时要注意 K=1 的特殊处理,或者用(K - r) % K并确认语言对除零或取模零的行为。 Python和Java中x % 0会直接抛异常,所以如果写通用函数,必须单独处理 K=1 的情况。

6.3 时间复杂度的直觉判断法

怎么快速判断 O(n²) 能不能过?我自己的经验公式是:如果 n 在 10^4 以内,O(n²) 在大部分判题机上勉强能过;当 n 达到 10^5 以上,O(n²) 几乎必挂;这时候至少要优化到 O(n log n) 或 O(n)。这道题 n 最大可取到 6 * 10^4,两层循环的风险很高,应该直接选 O(n) 方案。

虽然 O(n²) 在最坏情况下也许能跑到几秒,但算法题不能只看平均情况,要看上限。既然有简单到几行的 O(n) 写法,就不要赌判题环境了。

6.4 工程上的应用联想:播放列表与时间窗口统计

在现实场景里,这种“按模值分组再配对”的思想也能用到一些运营分析的场景。例如,如果要统计一段时间内所有时长为整分钟的音频文件配对情况,或分析直播间里观众停留时长能否凑成整数分钟的组合,都可以使用类似思路。不过LeetCode题毕竟是抽象模型,真实场景往往还有时间顺序、权重等其他条件,这里仅作联想。

7. 实操心得与后续扩展建议

7.1 我在调试中觉得最有用的技巧

遇到这种统计类问题,千万不要只靠眼睛检查代码。把几个手算过的例子输入进去,把每一轮count数组和ans的变化逐行打印出来,比看十遍代码都有效。

具体做法可以先写个带print的调试版:

def num_pairs_divisible_by_60_with_debug(time): count = [0] * 60 ans = 0 for t in time: r = t % 60 need = (60 - r) % 60 print(f"current={t}, r={r}, need={need}, count[need]={count[need]}") ans += count[need] count[r] += 1 print(f"after update, count[{r}]={count[r]}, ans={ans}") return ans

这样你就能看到每一步配对数量是从哪里来的,一旦某个用例结果不对,马上能定位到是余数计算错、查询对象错还是更新顺序错。

7.2 注意处理“返回类型”和“中间值溢出”

LeetCode的原版函数返回类型是int,Java和C++在这道题内没问题,但如果你把数据加强就不好说了。写算法题时,习惯性考虑“最坏情况下中间变量会不会溢出”是个好习惯。Python虽然不会溢出,但Java/C++/Go都会,尤其涉及计数的题目,如果数量级到 10^5 甚至 10^6,组合数会瞬间超过 2^31。判断不了的场景,直接选用更大的类型最安全。

7.3 补充一个更精巧的数学写法

有些题解会把“先查后加”等价地写成“先累加再配对”的变形,还有的会用乘法公式:等遍历结束之后,对每个余数r60-r做组合数相乘,最后加上余数0和余数30的组合数。这种方式代码如下:

def numPairsDivisibleBy60_math(time): count = [0] * 60 for t in time: count[t % 60] += 1 ans = 0 # 余数为0:自己和自己配对 ans += count[0] * (count[0] - 1) // 2 # 余数为30:自己和自己配对 ans += count[30] * (count[30] - 1) // 2 # 其他余数:r 和 60-r 互相配对,只需要遍历一半 for r in range(1, 30): ans += count[r] * count[60 - r] return ans

这种方法需要单独处理r = 0r = 30,因为它们的配对对象是自己。如果套用count[r] * count[60-r],就会把同一对歌曲算两遍。先遍历数组做完统计,再用组合数一次算出答案,逻辑上也很清晰,而且不容易出错。

两种写法的复杂度一样,区别在于第一种直观,顺序感强;第二种数学感更强,代码最后几行能看出组合数学的意味。平时练习时建议两种都写一遍,加深理解。

7.4 后续还可以做的扩展尝试

这道题做完以后,我顺手看了一下讨论区里的其他解法,有人提到可以用“同余类”思想把60替换成任意模数,也有人把问题扩展到了三元组:是否存在三个歌曲时长之和能被60整除。如果是三元组,复杂度会怎么变?还能不能继续用余数计数?思考这样的扩展,可以帮助你彻底掌握余数类题目的内在逻辑。我后来自己把K从60改成了7和13,写了个通用函数,跑了一遍随机测试对比暴力解,结果一致,对这类题的理解就又深了一层。

如果你正在刷LeetCode的热门百题,类似思路还会出现在“974. 和可被 K 整除的子数组”里。那道题是把余数用在连续子数组上,核心完全是前缀和模K加哈希表。学完1010再去做974,你会发现思路是连续递进的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询