LeetCode 602 这道题在 SQL 题单里存在感很强,几乎每个刷过数据库专项的人都会遇到它。题目本身不复杂,就是一个统计“好友数量最大值”的需求,但很多人在第一次提交时都会挂,原因几乎都出在同一个点上:好友关系是双向的,而表里只存了申请人和接受人两个字段。这道题做不对,很多时候不是不会写 GROUP BY,而是没把“谁是谁的好友”这件事想透。这篇文章我想把这道题从读题到多种解法、从踩坑到避坑完整拆一遍,给正准备刷 SQL 题的朋友,也给想在面试前把 UNION、窗口函数、分组聚合这些知识点串一遍的人,提供一个可以直接抄作业的参考。
1. 题目全解析:需求、表结构与“双向好友”这个关键坑
1.1 原题到底问了什么
LeetCode 602 的原始需求很简洁:给定一个好友申请接受记录表 RequestAccepted,里面每一行表示一个用户向另一个用户发送了好友申请,并且对方接受了。要求你找出拥有最多好友的用户,以及他拥有的好友数量。
表结构是三列:
requester_id:申请人 IDaccepter_id:接受人 IDaccept_date:接受日期
(requester_id, accepter_id) 是这张表的主键,意味着同一个申请人向同一个接受人发起的申请只可能出现一次。这个主键约束在后面讨论去重问题时非常关键。
输出结果需要包含两列:id(用户 ID)和 num(好友数量)。题目最初版本要求如果存在并列最多的情况,返回所有并列用户;不过 LeetCode 在评测时一般只校验最终结果集,所以很多提交用 LIMIT 1 也能过,因为测试数据可能没有构造并列场景。但作为刷题和学习,写一个能正确处理并列的解法才是有价值的。
1.2 最容易踩的坑:好友关系是双向的
这是整道题的核心,也是大多数人第一次提交失败的原因。
举个例子:用户 1 向用户 2 发送好友申请,用户 2 接受。这时候表里会有一行 (requester_id=1, accepter_id=2)。注意一个事实:一旦这行记录存在,用户 1 的好友里有了 2,用户 2 的好友里也有了 1。好友关系是双向的,而不是单向的“关注”关系。
如果只统计 requester_id,就会漏掉那些只作为接受方出现、从没主动发过申请的用户。比如原题示例数据里的用户 4,他有一条 accepter_id=4 的记录,但没有任何 requester_id=4 的记录。如果只按 requester_id 分组统计,用户 4 的好友数会被算成 0,这显然是错的。
所以正确的思路是:把每一行记录拆成两个方向,分别代表“这条好友关系中,用户 X 作为申请人这边有一个好友”和“用户 Y 作为接受人这边有一个好友”。先把两个方向的数据合并到一起,再按用户 ID 分组计数,才能得到所有人的好友数量。
1.3 为什么网上的题解版本千差万别
如果你搜索过这道题的题解,会发现写法五花八门:有的用 UNION ALL,有的用 UNION,有的在外面套了 DISTINCT,有的直接 ORDER BY num DESC LIMIT 1,还有的用了窗口函数。这些差异一方面来自 MySQL 版本不同,另一方面来自对题目理解角度不同。
我最开始看题解时也被绕晕过,后来才理解:这些写法在“原题测试数据”下都可能通过,但严谨程度不一样。如果你只是为了 AC,怎么写都行;如果你想通过这道题真正掌握这类“无向关系”统计的套路,建议理解最稳妥的写法,并且知道每种写法在什么场景下会出问题。下面我把整个思路拆成几步来讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 解法演进:从直观到优雅的三步拆解
2.1 第一步:UNION ALL 把两个方向拉平
既然好友关系是双向的,那最直接的办法就是把“申请方”和“接受方”两个字段拆开,变成两列用户 ID。这一步在 SQL 里通常用 UNION ALL 来实现。
sql复制SELECT requester_id AS id FROM RequestAccepted
UNION ALL
SELECT accepter_id AS id FROM RequestAccepted;
这个子查询的结果集里,每一行都是一个“用户 ID”,表示“这个用户在这条好友关系中占据了一个端点”。因为有两条 SELECT,所以原始表里每一行会贡献两个用户 ID:一个是申请人,一个是接受人。
这里我建议用 UNION ALL 而不是 UNION,原因后面会详细说。简单来讲,UNION 会做去重,如果我们只是想把所有“端点”收集起来,暂时不需要去重;而且 UNION ALL 不会触发排序去重,性能也更好。
这一步是整个解法的地基。把这个子查询的结果先跑出来看一眼,你就能直观理解“拉平”是什么意思:原本每一行是两个用户之间的关系,现在变成了一个用户 ID 列表,每一行代表一个用户出现在某条好友关系中。
2.2 第二步:GROUP BY 统计每个人的好友总数
有了上面的用户 ID 列表,接下来的事情就顺理成章了:按 ID 分组,统计每个 ID 出现了多少次。
sql复制SELECT id, COUNT(*) AS num
FROM (
SELECT requester_id AS id FROM RequestAccepted
UNION ALL
SELECT accepter_id AS id FROM RequestAccepted
) t
GROUP BY id;
这个结果里,num 就是每个用户的好友数量。为什么 COUNT(*) 就是好友数?因为每一行代表“该用户出现在一条好友关系中”。在理想情况下,每个好友关系会给两个用户各贡献一行,所以一个用户有多少行,就有多少个不同的好友。
这里要注意的一个前提是:原始表里不能存在 (1, 2) 和 (2, 1) 这样对称重复的记录。如果存在,COUNT(*) 会把同一条好友关系算两次。在原题中 (requester_id, accepter_id) 是主键,所以不会出现完全相同的两行,但确实可能出现 (1,2) 和 (2,1) 同时存在的场景。这种情况下,更严谨的做法是先对好友关系去重:
sql复制SELECT id, COUNT(*) AS num
FROM (
SELECT requester_id AS id, accepter_id AS friend FROM RequestAccepted
UNION
SELECT accepter_id AS id, requester_id AS friend FROM RequestAccepted
) t
GROUP BY id;
注意这里第二段 SELECT 我把两个字段做了交换,同时把 UNION ALL 换成了 UNION。这样做的效果是:对于一条真实的好友关系,不管它在表里是以 (A, B) 还是 (B, A) 形式出现,拉平后都只会保留一次 (A, B) 或 (B, A)(UNION 会去重),从根源上避免了重复计数。
不过在 LeetCode 原题环境下,用简单版 UNION ALL 就能过,因为评测数据没有构造这种对称重复。我建议刷题时先用简单版理解思路,同时知道严谨版的存在,这样在面试时如果面试官追问“如果表里有重复关系怎么办”,你也能接住。
2.3 第三步:排序取最大,以及并列场景怎么处理
分组统计之后,找出最大值最简单的方式就是按 num 降序排序,取第一条:
sql复制SELECT id, num
FROM (
SELECT id, COUNT(*) AS num
FROM (
SELECT requester_id AS id FROM RequestAccepted
UNION ALL
SELECT accepter_id AS id FROM RequestAccepted
) t
GROUP BY id
) t2
ORDER BY num DESC
LIMIT 1;
这个写法的优点是简单直观,缺点也很明显:如果两个用户的好友数并列第一,LIMIT 1 只会返回其中一个。虽然 LeetCode 原题评测可能不构造并列数据,但实际业务中“并列第一”是非常常见的情况,比如一个社交 App 里可能同时有好几个用户拥有同样多的好友。
要正确处理并列,思路是先把最大好友数算出来,再过滤出好友数等于这个最大值的所有用户。一种写法是使用子查询:
sql复制SELECT id, num
FROM (
SELECT id, COUNT(*) AS num
FROM (
SELECT requester_id AS id FROM RequestAccepted
UNION ALL
SELECT accepter_id AS id FROM RequestAccepted
) t
GROUP BY id
) a
WHERE num = (
SELECT MAX(num)
FROM (
SELECT id, COUNT(*) AS num
FROM (
SELECT requester_id AS id FROM RequestAccepted
UNION ALL
SELECT accepter_id AS id FROM RequestAccepted
) t
GROUP BY id
) b
);
这种写法虽然看起来冗长,但逻辑非常清晰:先算每个人的好友数,再拿最大值去过滤。缺点是子查询重复出现两次,代码可读性一般。如果你使用的数据库支持窗口函数,用窗口函数会优雅很多。
2.4 窗口函数写法:一条 SQL 解决并列问题
MySQL 8.0 及以上版本支持窗口函数,可以用 DENSE_RANK() 或者 RANK() 来给每个用户的好友数排名,然后取排名为 1 的用户。
sql复制SELECT id, num
FROM (
SELECT id, COUNT(*) AS num,
DENSE_RANK() OVER (ORDER BY COUNT(*) DESC) AS rk
FROM (
SELECT requester_id AS id FROM RequestAccepted
UNION ALL
SELECT accepter_id AS id FROM RequestAccepted
) t
GROUP BY id
) ranked
WHERE rk = 1;
这里我用了 DENSE_RANK() 而不是 RANK()。在这个场景下,因为只需要取第一名,两者结果一样。但如果你以后遇到“取前三名且允许并列”的需求,DENSE_RANK() 通常是更合适的选择:比如三个用户并列第一,它们都排在 1,下一个用户排在 2,能保证“取前三名”时不会漏掉并列的情况。
窗口函数的执行逻辑可以这样理解:先执行 GROUP BY id 得到每个用户的好友数,然后窗口函数 OVER (ORDER BY COUNT(*) DESC) 在这个分组结果上计算排名,最后外层 WHERE rk = 1 过滤出第一名。整个过程不需要手动写 MAX 子查询,可读性和可维护性都好很多。
如果你的面试环境是 MySQL 5.7 或更早版本,不支持窗口函数,那就用前面子查询的写法。这也是为什么我建议两种写法都要掌握的原因。
3. 实操验证:建表、造数、跑 SQL 全过程
3.1 建表和测试数据
理论讲完,还是要动手跑一遍。下面我用 MySQL 语法建一张表,插入 LeetCode 原题的示例数据,然后逐步验证。
sql复制CREATE TABLE RequestAccepted (
requester_id INT NOT NULL,
accepter_id INT NOT NULL,
accept_date DATE NOT NULL,
PRIMARY KEY (requester_id, accepter_id)
);
INSERT INTO RequestAccepted (requester_id, accepter_id, accept_date) VALUES
(1, 2, '2016-06-03'),
(1, 3, '2016-06-08'),
(2, 3, '2016-06-08'),
(3, 4, '2016-06-09');
这四条数据对应的人物关系是:1 的好友有 2 和 3,2 的好友有 1 和 3,3 的好友有 1、2、4,4 的好友有 3。所以最终结果应该是用户 3,好友数为 3。
3.2 逐步执行与结果验证
先单独跑“拉平”这一步:
sql复制SELECT requester_id AS id FROM RequestAccepted
UNION ALL
SELECT accepter_id AS id FROM RequestAccepted;
结果会是 8 行:1、2、1、3、2、3、3、4。注意这里有两行 3,分别来自用户 3 作为申请人和作为接受人的记录,这是正常的,因为用户 3 在两条关系里都出现了。
再跑分组统计:
sql复制SELECT id, COUNT(*) AS num
FROM (
SELECT requester_id AS id FROM RequestAccepted
UNION ALL
SELECT accepter_id AS id FROM RequestAccepted
) t
GROUP BY id;
结果应该是:
| id | num |
|---|---|
| 1 | 2 |
| 2 | 2 |
| 3 | 3 |
| 4 | 1 |
最后在 SQL 客户端里跑完整解法:
sql复制SELECT id, num
FROM (
SELECT id, COUNT(*) AS num,
DENSE_RANK() OVER (ORDER BY COUNT(*) DESC) AS rk
FROM (
SELECT requester_id AS id FROM RequestAccepted
UNION ALL
SELECT accepter_id AS id FROM RequestAccepted
) t
GROUP BY id
) ranked
WHERE rk = 1;
结果只有一行:3, 3,符合预期。
3.3 不同写法的对比和选择
为了验证并列场景,我可以构造一组数据,让两个用户的好友数都是 2:
sql复制INSERT INTO RequestAccepted (requester_id, accepter_id, accept_date) VALUES
(1, 5, '2016-06-10'),
(5, 2, '2016-06-11');
此时用户 1 有好友 2、3、5,共 3 个?不对,原来 1 有 2、3,现在加了 1->5 和 5->2,用户 1 又多了好友 5,变成 3 个。要构造并列,可以把数据调一下,比如新数据是 (4, 5) 和 (5, 1)。这里就不展开具体造数了,关键是想说明:用 ORDER BY num DESC LIMIT 1 在并列时会随机返回一个(实际上取决于存储引擎的返回顺序,不稳定),而窗口函数或 MAX 子查询版本会稳定返回所有并列用户。
在实际开发中,我一般优先用窗口函数版本,因为代码更短,逻辑也清晰。只有在数据库版本不支持窗口函数时,才退回到子查询版本。
3.4 执行计划与索引优化思路
虽然 LeetCode 刷题不需要考虑性能,但真实业务里如果 RequestAccepted 表有百万级数据,这个 SQL 的性能就值得关注。
第一步的 UNION ALL 会对表做两次全表扫描。对每一条记录,先取 requester_id,再取 accepter_id。如果表很大,这个扫描成本会翻倍。优化思路是建立覆盖索引:(requester_id) 和 (accepter_id) 各建一个索引,可以让扫描走索引而不是回表。
sql复制CREATE INDEX idx_req ON RequestAccepted(requester_id);
CREATE INDEX idx_acc ON RequestAccepted(accepter_id);
如果查询经常要按用户统计,甚至可以建联合索引 (requester_id, accepter_id) 和 (accepter_id, requester_id),不过通常单列索引就够用了。
还有一个细节:如果你在子查询里用了 DISTINCT 去重,代价会更高,因为要去重必然涉及排序或哈希。如果业务上能接受“同一条好友关系只算一次”的假设,可以先确保数据入库时做了方向归一化(比如总是把 ID 小的放前面),这样查询时就不需要额外去重,性能会好很多。这是从“数据建模层面”解决问题,比在查询里做去重更彻底。
4. 常见问题与避坑记录
4.1 UNION 和 UNION ALL 混用会导致统计偏差吗
UNION 会对结果集去重,UNION ALL 不会。在这个题目里,如果使用 UNION 而不是 UNION ALL,会把“同一个用户 ID”只保留一行吗?仔细想一下:UNION 去重是对整个结果集的行做去重。我们的子查询结果每一行只有一个 ID 字段,所以 UNION 会把所有重复的用户 ID 合并成一个。这就完全错了——我们需要的不是“这个用户出现过没有”,而是“这个用户出现了多少次”。
所以在“拉平”这一步,一定要用 UNION ALL。如果你写成了 UNION,那么每个用户最多只能得到 1 的计数,所有人并列第一,结果完全不可用。
但前面提到的“严格去重版”里,我在两个方向的子查询之间用了 UNION,那是另一回事:那里去重的是 (id, friend) 这个“好友关系对”,而不是单个用户 ID,目的是防止对称重复。这两个场景要区分开。
4.2 重复记录到底去不去重
这是很多人纠结的点。原题表中 (requester_id, accepter_id) 是主键,所以不会出现完全相同的两行。但一个用户可以同时给多个不同的用户发申请,也可以多次作为接受人出现,这些在逻辑上都是不同的好友关系,不应该去重。
真正需要担心的是“对称记录”:(1, 2) 和 (2, 1) 同时存在,表示用户 1 向 2 发了申请且被接受,同时用户 2 也向 1 发了申请且被接受。从业务上看,1 和 2 的好友关系已经确立,再多一次申请也不会让他们的好友数量增加。所以如果数据里可能存在这种对称记录,就应该把“好友关系对”去重后再计数。
处理方式就是在“拉平”之前,先把数据归一化:把 (requester_id, accepter_id) 统一成 (小ID, 大ID) 的形式,然后去重。
sql复制SELECT id, COUNT(*) AS num
FROM (
SELECT
CASE WHEN requester_id < accepter_id THEN requester_id ELSE accepter_id END AS id,
CASE WHEN requester_id < accepter_id THEN accepter_id ELSE requester_id END AS friend
FROM RequestAccepted
UNION
SELECT
CASE WHEN requester_id < accepter_id THEN accepter_id ELSE requester_id END AS id,
CASE WHEN requester_id < accepter_id THEN requester_id ELSE accepter_id END AS friend
FROM RequestAccepted
) t
GROUP BY id;
这个写法在 LeetCode 上用不到,但在真实业务中很有用。
4.3 ORDER BY + LIMIT 在并列场景下会漏算
ORDER BY num DESC LIMIT 1 的写法在 LeetCode 原题评测中可能通过,因为测试数据很可能没有设计并列最大值的场景。但如果你直接把这个解法背下来,在面试中遇到“并列怎么处理”的追问就会露馅。
面试官通常会顺着你的思路问:“如果两个人的好友数一样多,你这个查询会怎样?”正确回答是:“LIMIT 1 只会返回其中一条,如果题目要求返回所有并列用户,需要用排名函数或 MAX 子查询过滤。”能讲清楚这一点,面试印象会好很多。
我之前在实际工作中就遇到过类似需求:统计活动期间邀请人数最多的用户,要发奖,结果有两个人并列第一。当时代码里写的是 ORDER BY cnt DESC LIMIT 1,结果只给一个人发了奖,运营同事差点出事故。从那以后,凡是“取最大/最小”的需求,我都会多问一句:并列怎么处理。
4.4 大表场景下的性能优化思路
我在 3.4 里提到了索引,这里再补充几个实际的排查手段。
如果你发现 SQL 执行很慢,先用 EXPLAIN 看执行计划。重点看 UNION ALL 的两个分支是否走了索引,有没有出现 Using temporary 或 Using filesort。GROUP BY 本身可能产生临时表和排序,数据量大时这是主要瓶颈。
一个更彻底的优化思路是:如果这张表只是用来统计好友关系,可以提前维护一张好友关系表,每次写入时把两个方向的记录都写进去。这样查询时就不需要 UNION ALL 了,直接查这张宽表就可以。这就是典型的“读时拆分”转“写时拆分”的思路。
不过对于 LeetCode 刷题来说,性能不是重点,能写出正确、清晰的查询才是重点。
4.5 常见报错与排查技巧
如果你在 LeetCode 上提交报了语法错误,大概率是以下几个原因:
- 子查询没有取别名。MySQL 要求派生表必须有别名,比如
FROM (...) t这里的t不能省。Oracle 和 SQL Server 也类似。 DENSE_RANK()窗口函数在 MySQL 5.7 里不可用。如果环境是 5.7,改用子查询版本。- 列名不匹配。
SELECT id在子查询里必须能唯一确定列,如果两个子查询的列名不一致,外层引用会报错。 ORDER BY num里的num是别名,在某些数据库中不能直接在WHERE里引用同层级的别名,需要再包一层。这是常见的“SELECT 别名在 WHERE 中不可见”的坑,我一般习惯多包一层子查询,避免踩到。
排查时我的习惯是从内往外一层层单独执行:先跑最内层的 UNION ALL,确认拉平数据对不对;再跑 GROUP BY,确认每个用户的计数对不对;最后加上排名或过滤,确认最终结果。这样一步步定位,问题很快就能找到。
5. 从 LeetCode 到真实业务:这道题的延伸价值
5.1 社交场景下的“好友数”统计
这道题本质上是在处理“无向图”数据:用户是节点,好友关系是边,每条边没有方向。现实生活中很多关系都可以抽象成无向图,比如:
- 社交软件上的好友关系
- 企业 IM 中的同事关系
- 协同工具里的共享关系
- 游戏里的组队关系
处理这类数据时,最常见的需求就是“统计每个节点的度数”,也就是每个用户的好友数量。这道题里用 UNION ALL 拉平两个方向的方法,正是“无向图度数统计”的标准操作。你在 LeetCode 上练过这道题,以后在真实业务中遇到类似需求,就能直接迁移。
5.2 变体题:怎样继续扩展
顺着这道题的思路,可以延伸出很多变体:
- 统计互为好友且好友数都超过某个阈值的用户对
- 找出好友数增长最快的用户(配合时间维度)
- 求共同好友数最多的两个用户
- 给每个用户的好友数做排名,取 Top 3
这些变体大多在 602 的基础上做扩展。比如求共同好友数最多的用户对,需要先把好友关系自连接,再按用户对分组统计;取 Top 3 需要把 WHERE rk = 1 改成 WHERE rk <= 3,同时用 DENSE_RANK() 避免并列导致的名次跳号问题。
我在刷题时有个习惯:每做完一道题,会给自己出两三个变体,然后写 SQL 验证。这样比盲目刷 100 道新题效率高得多,因为很多题背后的套路是相通的。
5.3 和 LeetCode 其他热门 SQL 题的横向对比
LeetCode 上还有几道和 602 很像的题,可以放在一起对比:
- 180 连续出现的数字:考察连续区间分组,用行号差值。
- 185 部门工资前三高的所有员工:考察窗口函数
DENSE_RANK()和分区。 - 262 行程和用户:考察多表关联和条件计数。
- 178 分数排名:考察窗口函数排名。
602 的核心考点是 UNION ALL 和分组聚合,185 的核心考点是窗口函数和分区排序。如果你能把这两类题目配合着练,SQL 的综合能力会有一个明显的提升。
5.4 一个实际业务中的完整案例
我之前在做一个社区类产品时,运营需要统计“本周新增好友最多的 TOP 10 用户”,用于活动奖励。当时数据表结构和 LeetCode 602 几乎一模一样,只是表名叫 friendship_events,字段叫 user_id 和 friend_id。
我当时的 SQL 大概是:
sql复制SELECT user_id, COUNT(*) AS friend_count
FROM (
SELECT user_id, friend_id FROM friendship_events WHERE created_at >= '2024-01-01'
UNION
SELECT friend_id, user_id FROM friendship_events WHERE created_at >= '2024-01-01'
) t
GROUP BY user_id
ORDER BY friend_count DESC
LIMIT 10;
注意这里我用了 UNION 而不是 UNION ALL,原因就是这张业务表允许存在对称记录,(A, B) 和 (B, A) 都可能存在,所以先去重再计数。这和 LeetCode 原题的写法差异正好对应了前面讲的“去重与否要看数据特征”。
最终这个 SQL 跑在百万级数据上,配合索引和合理的分页,性能完全能接受。运营同学拿到数据后,活动也顺利上线了。
6. 总结与实用建议
我在实际刷题和写 SQL 的过程中,最深的体会是:很多看似简单的题,真正的难点往往不在语法,而在对业务语义的理解。602 这道题如果没意识到“好友关系是双向的”,哪怕你 SQL 语法再熟练,写出来的结果也是错的。反过来,只要想透了这一点,解法就是顺理成章的事:先拉平两个方向,再分组计数,最后找最大值。
最后再分享一个小技巧:如果你在面试中遇到类似题目,千万不要一上来就闷头写 SQL。先把你的思路用几句话讲清楚,比如“我准备把两个方向都取出来,然后按用户分组,因为好友关系是双向的”,面试官通常都会点头。即使你的最终代码有小瑕疵,清晰的思路也会给你加分不少。
这道题后续其实还可以继续扩展,比如加入时间维度分析好友增长速度,或者把统计维度换成“每个月新增好友数排名”。我建议你在掌握基础写法后,顺手把这些变体练一遍,比单纯记住一个答案要实用得多。
