LeetCode的SQL题刷到602《好友申请 II》时,我第一反应是“又是求最大值的聚合题,应该不难”。结果真正动手写的时候才发现,这道题和常见的“按某列分组求总数”不太一样,难点不在语法,而在于你怎么把“好友关系”这个业务概念翻译成SQL里的行。刷题群里也经常有人问:为什么写出来的结果只有发申请的人?为什么用UNION以后数字全部变成1?其实都是同一个问题——好友关系是双向的,但表里只存了一条记录。
这道题对准备面试的同学、日常写SQL的分析师和开发都很友好。它考察的不仅是GROUP BY和聚合函数的用法,更考验你能不能把一个隐含的映射关系,也就是“一条好友申请记录 = 两个用户的好友数各加1”,拆成两个方向来处理。搞清楚这个套路之后,你会发现类似“谁互动最多”“哪个账户进出最活跃”这类业务问题都能顺手解决。下面我把这道题的完整拆解、标准写法、平局处理方案和易错点一次讲清楚。
1. 先读懂这张好友申请表:题目到底在问什么
1.1 表结构和示例数据
题目给的表叫 RequestAccepted,意思是被接受的好友申请。结构很简单,三列:
sql复制+----------------+---------+
| Column Name | Type |
+----------------+---------+
| requester_id | int |
| accepter_id | int |
| accept_date | date |
+----------------+---------+
主键是 (requester_id, accepter_id)。也就是说,同一对用户之间只会存在一条申请记录,不会出现完全相同的两行。我用官方示例跑一下:
sql复制+--------------+-------------+-------------+
| requester_id | accepter_id | accept_date |
+--------------+-------------+-------------+
| 1 | 2 | 2016/06/03 |
| 1 | 3 | 2016/06/08 |
| 2 | 3 | 2016/06/08 |
| 3 | 4 | 2016/06/09 |
+--------------+-------------+-------------+
这是一个非常典型的“关系表”:每一行代表用户 requester_id 向 accepter_id 发了好友申请,并且对方接受了。注意,表里只有一条记录,但从好友关系的语义来说,用户1和用户2一旦成为好友,这层关系对双方是同时成立的。也就是说,这一行不仅给用户1的好友数加1,也要给用户2的好友数加1。
1.2 需求拆解:好友关系是“双向”的
题目要求很简单:找出好友数最多的用户,返回他的 id 和好友数 num。
那什么是“好友数”?一个用户可能作为 requester_id 发出申请,也可能作为 accepter_id 接受别人的申请。无论哪种角色,只要这条记录出现在 RequestAccepted 表里,就说明这对用户之间已经建立了真实的好友关系。所以一个用户的总好友数,一定等于他“发出去并被接受的好友申请数”加上“收到并接受的好友申请数”。
这就是这道题最核心的坎:表里一行记录只能通过某个固定字段归属到一个用户头上,但业务上它影响的是两个用户。如果不做处理,只盯着 requester_id 或者只盯着 accepter_id,都会漏掉一半的好友关系。后面很多错误答案都是从这里开始的。
1.3 示例数据手工推演
我习惯在做SQL题之前先手工把结果算一遍,这样写出来的SQL可以对拍。以示例数据为例:
- 用户1:作为请求者出现2次(对应2、3),作为接受者出现0次,所以好友数是2。
- 用户2:作为请求者出现1次(对应3),作为接受者出现1次(来自1),好友数是2。
- 用户3:作为请求者出现1次(对应4),作为接受者出现2次(来自1、2),好友数是3。
- 用户4:作为请求者出现0次,作为接受者出现1次(来自3),好友数是1。
结果很清楚:用户3的好友数最多,为3。题目要求输出:
sql复制+------+-----+
| id | num |
+------+-----+
| 3 | 3 |
+------+-----+
手工推演看起来很简单,但它明确了一个关键点:用户2的好友数不能只统计他发出去的申请,也不能只统计他收到的申请,必须两边加起来。这个“两边加起来”的思路,就是整道题的解法核心。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心SQL写法:一条UNION ALL搞定问题
2.1 核心思路:先把两个方向拆开,再按用户聚合
既然每个用户的好友数来自两个方向,那就干脆把表里的每一行“拆”成两行:一行归 requester_id,一行归 accepter_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
ORDER BY num DESC
LIMIT 1;
这段SQL的执行逻辑非常直白。先用第一个子查询把所有请求者ID捞出来,再用第二个子查询把所有接受者ID捞出来,UNION ALL 把两个结果直接纵向拼接。拼接之后得到一列名为 id 的中间表,每出现一次代表这个用户的一段好友关系。最后 GROUP BY id 统计每个人出现几次,再按次数倒序取第一名。
拿示例数据来验证:第一个查询得到 1,1,2,3;第二个查询得到 2,3,3,4。UNION ALL 拼接后得到 1,1,2,3,2,3,3,4,按id分组计数后发现3出现3次,1和2各出现2次,4出现1次。和手推结果完全一致。
2.2 为什么必须用UNION ALL,而不是UNION
这是这道题最容易踩的坑。我见过很多人在两个子查询之间写成了 UNION,然后惊讶地发现结果里的好友数全变成1,或者干脆报错。原因很简单:UNION 会去重,UNION ALL 不会。
假设你写成这样:
sql复制SELECT requester_id AS id FROM RequestAccepted
UNION
SELECT accepter_id AS id FROM RequestAccepted
那么第一个查询得到 1,1,2,3,第二个查询得到 2,3,3,4,UNION 合并时会去掉重复行,最终得到 1,2,3,4。这等于把原始数据压缩成了一个“出现过的用户ID集合”,根本没法统计出现次数。哪怕你套上 GROUP BY id 和 COUNT(*),结果也必然是每个用户只出现一次,好友数全变1。
如果你对这两条记录的去重行为还是不放心,可以记一个判断原则:你需要的是“保留所有出现过的行,方便后续计数”,那必须用 UNION ALL。只有在你明确“只要集合,不要重复”的时候,才应该用 UNION。SQL里的去重不是免费的,它会抹掉你用来计数的重复信息。
2.3 为什么不能只GROUP BY一个字段,或者直接JOIN
有同学会想:那我不拆了,直接 GROUP BY requester_id 不也能统计吗?能统计,但只统计了“发出去的好友申请”。一个只接受别人好友申请、从不主动发申请的用户,会被完全漏掉。比如示例里的用户4,作为接受者出现一次,如果只按 requester_id 分组,用户4根本不会出现在结果里,最终统计会出错。
还有人会尝试自连接,想通过 JOIN 把两个方向同时拉出来。比如先按 requester_id 统计得到一张表,再按 accepter_id 统计得到另一张表,然后用 JOIN 合并两张表。这样做逻辑上勉强可行,但写法比 UNION ALL 复杂得多,而且还容易掉进 JOIN 带来的重复计算陷阱。如果只是用 requester_id 和 accepter_id 直接连接两张聚合表,一旦某个用户在某张表里出现多行,连接后就会产生笛卡尔积式的膨胀,导致结果完全失真。
更关键的是,UNION ALL 的写法在几乎所有数据库里都能用,不需要考虑 FULL OUTER JOIN 支持不支持的问题。MySQL的老版本不支持 FULL OUTER JOIN,Oracle、SQL Server的写法又各不相同。用 UNION ALL 是兼容性最好、逻辑最清晰的方案。
3. 进阶场景:出现平局怎么处理
3.1 用HAVING子查询,返回所有并列最多的用户
很多网上的题解用了 ORDER BY num DESC LIMIT 1,这在数据集恰好只有一个最多用户时没问题。但真实业务里,完全可能有两个用户好友数一样多,比如用户1和用户3都是3个好友。这时候 LIMIT 1 只会随机返回其中一个,不符合“谁有最多好友”的语义,在面试里也容易被认为是边界处理不严谨。
要稳妥地处理平局,可以先找出最大好友数,再筛选出好友数等于这个值的所有用户:
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
HAVING COUNT(*) = (
SELECT cnt
FROM (
SELECT COUNT(*) AS cnt
FROM (
SELECT requester_id AS id FROM RequestAccepted
UNION ALL
SELECT accepter_id AS id FROM RequestAccepted
) t2
GROUP BY id
ORDER BY cnt DESC
LIMIT 1
) m
);
这段SQL看起来有点长,其实拆开看很清晰:最内层继续用 UNION ALL 拼接两个方向,按用户分组统计每个用户的好友数;然后通过 ORDER BY cnt DESC LIMIT 1 取出最大的好友数;外层再对同样的中间结果做一次 GROUP BY,用 HAVING 筛选出好友数等于最大值的所有用户。这样平局时能返回多行,符合“最多”的语义。
不过要注意,当整张表为空表时,最内层子查询不会返回任何行,HAVING 里的标量子查询结果为 NULL,最终结果为空。这通常是可以接受的,因为没人加好友时自然也没有“最多好友”的用户。如果面试官特别在意空表行为,你可以在最外层加一个空结果处理,或者明确说明你的假设是表里有数据。
3.2 用窗口函数RANK(),写法更优雅
如果你用的数据库支持窗口函数,比如MySQL 8.0、PostgreSQL、SQL Server 2012+、Oracle,那平局处理可以写得更加简洁:
sql复制SELECT id, num
FROM (
SELECT id, COUNT(*) AS num,
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
) s
WHERE rk = 1;
这里的核心是 RANK() OVER (ORDER BY COUNT(*) DESC),它会给每个用户按好友数从高到低排名,好友数相同的人得到相同名次。然后外层用 WHERE rk = 1 把所有排名第一的用户筛出来,天然支持平局。
RANK() 和 DENSE_RANK() 在这个场景下效果一样,因为我们要的就是第一名。但不要用 ROW_NUMBER(),它会强制给每个用户分配唯一的递增序号,即使好友数相同也会随机排出一个第一名,平局仍然被吞掉。
3.3 两种方案怎么选
先说说我自己的选择习惯。如果面试环境下明确支持窗口函数,我会优先写 RANK() 版本,因为逻辑更直白,不容易在嵌套子查询里写错。如果环境是MySQL 5.7这种老版本,那就老老实实用 HAVING 子查询方案。
从执行效率上看,RANK() 版本只需要把 UNION ALL 的中间结果做一次分组聚合,再开窗排序,整体步骤比 HAVING 子查询要少。后者因为要在外层重新对 UNION ALL 结果再聚合一次,还要执行内层子查询,中间结果会被多次扫描,数据量一上来差别就更明显。
从兼容性看,HAVING 方案是纯标准SQL,几乎所有数据库都能跑。RANK() 方案在老旧数据库里不受支持,所以在不确定环境的情况下,HAVING 更保险。我在本地用的测试环境是MySQL 8.0,两种写都能跑通,但如果是接线上数据库,你得先确认版本再决定用哪一种。
4. 易错点与排查思路实录
4.1 常见错误写法盘点
这道题的错误答案非常有规律,我总结了几种最常见的,你看完基本能避开同类问题。
第一种,只统计一个方向:
sql复制SELECT requester_id AS id, COUNT(*) AS num
FROM RequestAccepted
GROUP BY requester_id
ORDER BY num DESC
LIMIT 1;
这种写法只统计了“主动发出好友申请的人”,会漏掉那些只接受申请的用户。放到真实场景里,一个用户如果从不主动加人、但每天被几百人加好友,他的好友数在这条SQL下直接变成0,显然不对。
第二种,把 UNION ALL 错写成 UNION。前面已经讲过,UNION 去重后无法保留重复计数信息,所有好友数都会变成1。这种错误最难排查,因为SQL不报错,结果表结构也对,就是数字不对。
第三种,虽然用对了方向,但用 COUNT(DISTINCT id) 代替 COUNT(*)。这看起来只是多了个 DISTINCT,实际上代价很高。在本题的主键约束下,同一个用户在同一方向不会重复出现,比如用户1不会在同一行里出现两次,所以 COUNT(*) 就够了。但如果在真实业务表里没有主键约束,同一对好友申请可能记录多次,那就得考虑 COUNT(DISTINCT CASE WHEN ... THEN ... END) 这类写法,而不是简单加个 DISTINCT。
第四种,排序字段写错。有人喜欢写成 ORDER BY COUNT(*) DESC LIMIT 1,这里没问题。但如果你在子查询里用了别名 num,又在同一个查询里写 ORDER BY num DESC,这个也是可以的,SQL允许用 SELECT 里的别名排序。容易翻车的反而是你不小心 ORDER BY id,那排序就变成了按用户ID排序,结果完全错误。
4.2 怎么验证你的答案是对的
SQL题不像写业务代码,跑通了不一定对。我的习惯是先用最小数据集验证,再构造几个边界case,最后再提交。示例数据验证过后,我会额外构造一组数据来测试平局情况,比如让用户1和用户2都有2个好友,看SQL是不是返回两行。
一个很实用的自查技巧:不要只查最终答案,先把中间结果捞出来看看。你可以单独运行 SELECT requester_id AS id FROM RequestAccepted UNION ALL SELECT accepter_id AS id FROM RequestAccepted,确认展开后的行数是否等于表行数的两倍。如果这个基础数不对,后续聚合结果必然不对。
如果发现最终输出少了人,优先检查是不是漏掉了某个方向。如果发现好友数偏大,优先怀疑是不是 JOIN 产生了重复计数。如果发现好友数全是1,不用犹豫,99%是 UNION 去重干的。按照这个排查路径,基本能在1分钟内定位问题。
4.3 数据量大时的性能优化
刷题时数据量小,怎么写都能过。但真实业务里好友关系表可能几百万、几千万行,这时候 UNION ALL 会生成一个很大的中间结果,再进行分组聚合。虽然逻辑正确,性能却可能堪忧。
一种优化思路是,先分别在两个方向上做聚合,再把聚合结果合并。因为每个用户在每个方向上的统计结果通常远小于明细行数:
sql复制SELECT id, SUM(cnt) AS num
FROM (
SELECT requester_id AS id, COUNT(*) AS cnt
FROM RequestAccepted
GROUP BY requester_id
UNION ALL
SELECT accepter_id AS id, COUNT(*) AS cnt
FROM RequestAccepted
GROUP BY accepter_id
) t
GROUP BY id
ORDER BY num DESC
LIMIT 1;
这个写法先把“每个用户发出并接受的好友数”压缩成一行,再用 UNION ALL 合并,最后按用户ID汇总两个方向的数量。中间结果的大小从“表的行数×2”变成了“不同用户数量×2”,通常小一个数量级,聚合压力小很多。
另外,别忘了索引。RequestAccepted 表的主键是 (requester_id, accepter_id),按 requester_id 分组的查询可以用到主键前缀,但按 accepter_id 分组的查询用不上这个索引。如果要优化,可以在 accepter_id 上单独加一个普通索引。不过要注意,加索引会增加写入开销,具体加不加得看业务里读写比例,不能无脑加。
5. 从LeetCode到真实业务:这个套路还能用在哪
5.1 本质是图论里的“度中心性”计算
如果你把用户当成节点,好友关系当成边,这张 RequestAccepted 表其实就是一张无向图的边表。求每个用户的好友数,等价于求每个节点的度。在无向图里,节点的度等于它关联的边数,而入度和出度在有向图里才需要分开算。
我们的解法把每条边拆成两个方向,分别给两个端点计数,这正是计算无向图度数的标准手段。更重要的是,许多真实社交网络分析问题,比如寻找关键人物、判断社区中心,底层都会依赖这类“谁关联的节点最多”的统计。所以你别觉得LeetCode这道题只是为了面试,它背后对应的是图分析里一个非常基础且高频的指标。
我碰到过一个挺有意思的案例:一个做用户增长的朋友想找出平台上“人脉最广”的用户,他们原始数据就是类似 user_a, user_b 的关系对。我当时给的思路和这道题一模一样:拆成两个方向,再按用户聚合。他反馈说结果跟预期的核心用户高度重合。从那之后,我就更确信这个套路的通用性。
5.2 同类场景:互动统计、转账分析、关注关系
这类“一张表里两个字段都要算到同一个人头上”的问题,在真实业务里非常常见。
比如互动表 interaction(user_id, target_user_id),你想找互动最多的用户,就得同时统计“主动发起互动”和“被动收到互动”两个方向,然后合并。又比如转账流水 transfer(from_account, to_account),想找资金往来最活跃的账户,也得把转出和转入两个方向加起来。再比如关注表 follow(follower_id, followee_id),想看谁的人脉圈子最大,需要把“关注了谁”和“被谁关注”两个方向都算进去。
这些业务场景背后,都可以套用同一个模板:先把一条关系记录拆成两个方向,再按主体ID分组计数。如果你理解了这道题,等于掌握了一个可以迁移到多个分析场景的通用模型。很多同学刷SQL题感觉刷完就忘,其实就是因为没有把题目抽象成“解题模式”,只记了答案,没有记套路。
5.3 同类LeetCode题横向对比
LeetCode里“找最多/最大”的SQL题有不少,但每道题的坑不太一样,我整理了一个对比表:
| 题目 | 核心场景 | 关键坑点 | 推荐套路 |
|---|---|---|---|
| 602 好友申请 II | 双向关系计数 | 必须拆两个方向,处理平局 | UNION ALL + GROUP BY |
| 586 下单最多的客户 | 单方向计数 | 只有一个维度,直接排序 | GROUP BY + ORDER BY + LIMIT |
| 574 当选者 | 多表JOIN后计数 | 注意平局和JOIN去重 | JOIN + GROUP BY + 子查询 |
| 619 只出现一次的最大数字 | 单表聚合筛选 | 没有匹配时返回NULL | GROUP BY + HAVING COUNT = 1 |
你看,602和586虽然都是“找最多”,但难度差别很大。586只涉及一张订单表,按客户分组计数即可;602则需要你先做一次“双向拆解”,否则统计口径就是错的。这种对比能帮你在面试时快速判断题目考察的是“单纯聚合”还是“关系拆解”,从而选择不同的解题路径。
我自己的刷题习惯是,每做完一道有启发性的SQL题,就想想这类题还能变形成什么业务场景,再花10分钟手写一个能跑通的小例子。这样从LeetCode到工作场景的迁移会自然很多。602这道题,我建议你把两种平局处理方案都练一遍,尤其是以前不熟窗口函数的话,可以借此把它用起来。下次遇到“谁最多”“谁最活跃”这类问题,你就能瞬间反应过来:先拆方向,再聚合,最后排序过滤。
