☰
SQL经典面试题:查出至少有5名直接下属的经理,从自连接到子查询全解析
2026/9/30 3:44:09 网站建设 项目流程

1. 题目与考点拆解

先聊聊这道题本身。如果你刷过LeetCode,应该对SQL题库里的这道“至少有5名直接下属的经理”不陌生,题号570,属于那种“看起来简单、一写就错”的经典题目。原题给了一张Employee表,字段基本是这几列:

  • id:员工编号,主键
  • name:员工姓名
  • department:部门
  • managerId:该员工的直属经理编号,如果为空表示没有经理

题目要求是:找出至少直接管理5名员工的经理,输出他的姓名。注意这里的“直接下属”是指managerId直接指向这位经理的员工,隔级的不算。

这题为什么被反复拿出来讲?因为它同时考察了三个SQL核心能力:表连接、GROUP BY 聚合、HAVING 过滤。你以为三分钟就能写完,实际上有相当多的人栽在“要不要去重”“要不要排除经理自己”“NULL 怎么处理”这些细节上。更关键的是,这道题是一个典型的“按维度聚合后反查主表”场景,在实际业务里多得很——比如统计人效、盘点管理带宽、风控里查“关联订单超过N个的账号”,全是同一个套路。

网上搜这道题,能看到五花八门的解法,有子查询的、有自连接的、有窗口函数的,但很多人没讲清楚为什么这么写、边界条件怎么处理。这篇文章我不打算只贴一个标准答案就完事,我会从数据准备到三种实现方案,再到性能差异、踩坑实录和业务迁移,完整过一遍,保证你看完不仅会做这道题,还能把解法迁移到真实的数据分析工作中。

2. 先说清楚表结构和数据的几个关键特征

2.1 一张表,两个身份

这道题的迷惑点在于:Employee表里的一个人,既可能是员工,也可能是别人的经理。也就是说,这张表是一个自引用结构。

id | name | department | managerId ---|-----------|------------|---------- 1 | John | HR | NULL 2 | Jim | HR | 1 3 | Jill | HR | 1 4 | Josh | HR | 1 5 | Joe | HR | 1 6 | Jamie | HR | 1 7 | Jack | Sales | NULL 8 | Tom | Sales | 7

看数据就明白了:John 的managerId是 NULL,说明他是最高层,但他同时也是 Jim、Jill 等5个人的经理。所以同一个id有时要当“员工主键”用,有时要当“经理外键”用,这决定了我们自连接时的关联方向——员工的managerId指向经理的id,千万别搞反了。

2.2 managerId 为 NULL 的人怎么处理

有一种常见的错误写法是:

SELECT m.name FROM Employee e JOIN Employee m ON e.managerId = m.id GROUP BY m.id, m.name HAVING COUNT(*) >= 5;

看着像模像样,但如果你有一名员工叫“张三”,他的managerId是 NULL,那么他在JOIN的时候匹配不到任何经理记录——因为NULL不等于任何值,包括NULL自己。所以这种写法天然会把“顶层经理”排除在结果外,这其实是正确的,因为没有人汇报给他,他就不会被统计出来。

但如果反过来想:如果某个员工的managerId指向了一个并不存在于表中的id(脏数据),JOIN 会把这条员工记录丢弃,经理端也不会因为这条记录增加计数。也就是说 JOIN 天然做了数据的交集过滤,丢失脏数据的同时也遮蔽了问题。这就是为什么有些团队在写这类统计时,宁可先用子查询把维度表单独算一遍,再回去关联主表拿姓名——至少你能通过额外的COUNT(DISTINCT e.id)看出有没有数据异常。

2.3 重复数据对结果的影响

我见过一个真实案例:某个业务系统在同步员工数据时,由于接口重试机制,同一个员工被插入了两次,id却不同,只是name + managerId完全一样。如果你按managerId分组统计COUNT(*),同一个真实员工会被算两次,本来只有4个下属的经理,莫名其妙变成了8个,结果就错了。

遇到这类题目,如果题目没有说明“Employee表无重复”,那你就得主动考虑去重。最稳妥的方法是用COUNT(DISTINCT e.id)替代COUNT(*)。虽然 LeetCode 原题默认是主键唯一的,但在真实业务环境里,这句替换可能就是你和事故之间的距离。

3. 三种解法实现与原理拆解

3.1 解法一:自连接 + GROUP BY + HAVING(最直观)

先看完整 SQL:

SELECT m.name FROM Employee e JOIN Employee m ON e.managerId = m.id GROUP BY m.id, m.name HAVING COUNT(e.id) >= 5;

一步一步拆解。

第一步,连接:把员工表e和经理表m做内连接,条件是e.managerId = m.id。这时候每一行结果就是“一名员工 + 他的直属经理”。Jim 的 managerId=1,所以 Jim 会和 John 连上;同理,Jill、Josh、Joe、Jamie 也都各自和 John 连成一行。

第二步,分组:按经理的id和name分组。为什么要两个字段都 GROUP BY?因为 SQL 的聚合查询要求:出现在 SELECT 列表里的非聚合列,必须也出现在 GROUP BY 里。数据库比如 MySQL 有ONLY_FULL_GROUP_BY模式,你不写全m.name,直接报错;即使某些数据库不报错,靠这种“运气”写出来的SQL也迟早翻车。按经理 id 和 name 分组之后,每个分组里就是该经理的所有直接下属。

第三步,过滤:HAVINGCOUNT(e.id) >= 5。这里e.id是员工主键,非空,所以COUNT(e.id)等价于COUNT(*)。HAVING 是在分组后的结果上做条件过滤,和 WHERE 的执行时机不同——WHERE 先过滤原始行,HAVING 后过滤聚合值。统计筛选必须用 HAVING。

这样结果就是 John,因为他下面正好有5个直接下属。而且这个写法是“一次扫描完成自连接、分组、过滤”,在数据量不大时非常高效,也是面试官最希望你先说出来的方案。

3.2 解法二:子查询先算人数,再关联主表(解耦更清晰)

连接和聚合挤在一个语句里,对初学者有点绕。可读性优先的场景,我建议拆开:

SELECT name FROM Employee WHERE id IN ( SELECT managerId FROM Employee GROUP BY managerId HAVING COUNT(*) >= 5 );

子查询先干一件事:按经理分组,统计下属数量,筛出至少5个的经理 id 列表。然后用WHERE id IN (...)把这批经理的名字从原表里捞出来。

这个写法有几个天然优势:

  • 不需要 JOIN,少了连接产生的中间结果,内存压力更小
  • 子查询内部只处理一列,索引利用率更高
  • 逻辑分层清楚,先算“哪些人是经理”,再取“这些人的姓名”,代码维护的人看得懂

性能上,子查询会先物化(具体取决于数据库优化器),如果Employee表很大,物化结果比全表 JOIN 小得多。很多生产环境的慢查询优化,第一步就是把 JOIN 改成“聚合 + IN”的形态。如果你的 MySQL 8.0 或者 PostgreSQL 优化器够聪明,它可能会把这种子查询重写成 JOIN,执行计划接近;但在老版本上,IN 子查询往往跑得更稳。

3.3 解法三:窗口函数 COUNT OVER(新写法,但别乱用)

MySQL 8.0 之后,窗口函数让这类题多了一种打开方式:

SELECT name FROM ( SELECT m.name AS name, COUNT(e.id) OVER (PARTITION BY m.id) AS cnt FROM Employee e JOIN Employee m ON e.managerId = m.id ) t WHERE cnt >= 5 GROUP BY name, cnt;

这里的思路是:在 JOIN 后的结果集上用窗口函数按经理分组计数,然后在外层过滤。如果经理 John 带5个下属,JOIN 产生5行,每行都带一个cnt=5,最后 GROUP BY 去重一下就能拿到名字。

但说实话,这题的场景用窗口函数属于杀鸡用牛刀。窗口函数适合“每一行都需要保留聚合上下文”的场景,比如算“每个员工所在部门的总人数”以便后续逐行计算占比。而这里我们只需要每个经理的聚合值,传统 GROUP BY 更直接。窗口函数还要包一层子查询 + 去重,执行计划也不一定更优。

用不用窗口函数,核心判断标准是:行粒度是否有保留意义。没意义就没必要用。

3.4 三种方案怎么选

方案核心机制可读性性能适用场景
JOIN + GROUP BY连接后分组聚合中中小数据量优秀面试首选,日常简单统计
子查询 + IN先聚合后回表高大表更稳定生产环境推荐
窗口函数保留行粒度的编号式聚合中低中间结果集大,可能更慢需要行级上下文时再用

4. 实操过程:从建表到验证的完整流程

与其空谈理论,不如把一套可复现的流程走一遍。下面我在 MySQL 8.0 环境下完整演示。

4.1 建表和插入测试数据

CREATE TABLE Employee ( id INT PRIMARY KEY, name VARCHAR(50), department VARCHAR(50), managerId INT ); INSERT INTO Employee (id, name, department, managerId) VALUES (1, 'John', 'HR', NULL), (2, 'Jim', 'HR', 1), (3, 'Jill', 'HR', 1), (4, 'Josh', 'HR', 1), (5, 'Joe', 'HR', 1), (6, 'Jamie', 'HR', 1), (7, 'Jack', 'Sales', NULL), (8, 'Tom', 'Sales', 7), (9, 'Anna', 'Sales', 7), (10, 'Ben', 'Sales', 7), (11, 'Cathy', 'Sales', 7), (12, 'Dan', 'Sales', 7);

这里的 John 和 Jack 各有5名直接下属,结果应该输出 John 和 Jack 两人。

4.2 执行第一种方案

SELECT m.name FROM Employee e JOIN Employee m ON e.managerId = m.id GROUP BY m.id, m.name HAVING COUNT(e.id) >= 5;

执行结果:

name ----- John Jack

逻辑正确。

4.3 验证边界情况

我再加两条记录测试边界:

INSERT INTO Employee (id, name, department, managerId) VALUES (13, 'Eve', 'HR', 1), (14, 'Frank', 'Sales', 7);

现在 John 有6个下属,Jack 有6个下属,结果依然输出 John、Jack。再把 Eve 改成 managerId=NULL:

UPDATE Employee SET managerId = NULL WHERE id = 13;

John 变回5个下属,Jack 仍然是5个,结果还是 John、Jack。这时候“碰巧数据没变”,但如果你用 JOIN 写法,经理 John 的5个下属里少了一个 Eve,多了一条 managerId 为 NULL 的 Eve 行,Eve 不会和 John 匹配上,所以计数不受影响——这验证了 JOIN 自动过滤 NULL 的行为。

4.4 用子查询验证结果一致性

SELECT name FROM Employee WHERE id IN ( SELECT managerId FROM Employee GROUP BY managerId HAVING COUNT(*) >= 5 );

结果同样是 John、Jack。两种写法结果一致,进一步说明逻辑等价。

5. 性能分析:索引怎么建、执行计划怎么读

5.1 索引设计

不管选哪种方案,最关键的查询路径是拿员工的 managerId 去匹配经理的 id,以及按 managerId 分组。

  • managerId上必须有索引:因为 WHERE 条件和 GROUP BY 会频繁扫描它
  • id是主键,默认有索引,无需额外处理

建索引的 SQL:

CREATE INDEX idx_manager_id ON Employee(managerId);

如果你还经常按name查员工,也可以考虑(managerId, name)联合索引,覆盖“分组 + 回表取姓名”的场景。不过索引不是越多越好,写多读少的表要谨慎加索引,每次插入都会同步维护索引树。

5.2 查看执行计划

在 MySQL 里用EXPLAIN看:

EXPLAIN SELECT m.name FROM Employee e JOIN Employee m ON e.managerId = m.id GROUP BY m.id, m.name HAVING COUNT(e.id) >= 5;

重点关注几个字段:

  • type:如果是ALL,说明全表扫描,数据量大时性能堪忧
  • key:显示用了哪个索引
  • rows:预估扫描行数
  • Extra:如果出现Using temporary; Using filesort,说明分组排序使用了临时表,数据量大时要留意

加上idx_manager_id后,JOIN 的驱动方向通常是“先全表扫员工表(比如几百行),然后逐个用 managerId 去主键索引回查经理”——这时索引效率极高。如果反过来了,优化器选了经理表做驱动表,也是可行的,关键在于连接条件和分组键都能走索引。

5.3 数据量大了会怎样

假设员工表有100万行,经理有10万人。JOIN 方案要先做一次100万行的连接,可能产生百万级中间结果,再分组。即便索引足够好,扫描成本也不低。子查询方案里,GROUP BY managerId是直接在索引上扫的——只需要扫managerId索引,能算出各组计数,然后过滤掉 count < 5 的分组,只留满足条件的经理 id,最后在主键索引上回表查名字。整个过程中间结果被压缩到极小,通常性能更好。

所以我的建议很明确:数据量在万行以内随便写,超过十万行优先用子查询方案。这不是说 JOIN 一定慢,而是子查询更可控。

6. 常见坑与排查技巧

6.1 坑1:COUNT(*) 和 COUNT(DISTINCT id) 的差别

前面提过,如果Employee表可能存在重复员工记录(比如多系统同步、清洗前的原始表),用COUNT(*)会重复计数。这时候改成:

HAVING COUNT(DISTINCT e.id) >= 5

就能保证“至少5个不同员工”。但注意:如果表本身数据干净,COUNT(DISTINCT)相比COUNT(*)会有额外去重开销。我的经验是:ETL 清洗后的数仓表可以放心用 COUNT(*),业务库直接查则优先 DISTINCT。

6.2 坑2:GROUP BY 字段不完整导致报错或错数据

MySQL 5.7 之前默认没开ONLY_FULL_GROUP_BY,你写GROUP BY m.id却 selectm.name,数据库不会报错,会随便取一个 name 返回。这种“随机的正确”非常坑人。所以无论数据库允不允许,SELECT 里的每个非聚合列都必须完整出现在 GROUP BY 里,别偷懒。

6.3 坑3:题目要求“至少有5名”还是“超过5名”

>= 5和> 5差一个边界。LeetCode 原题是“at least 5”,即 ≥5。真实业务需求里经常有人把口径说成“5人以上”,那到底含不含5?这种模糊描述必须跟需求方确认。写代码之前先把口径钉死。

6.4 坑4:多级组织架构下被“隔级”误导

“直接下属”的语义限制在managerId这一层。但有些数据里会同时存在“组织架构上级”和“汇报上级”,甚至有员工 managerId 指向一个已离职、不在表里的经理。JOIN 方案会把这种脏数据丢弃,导致计数偏少。排查办法是写一个“孤儿检查”:

SELECT DISTINCT managerId FROM Employee e LEFT JOIN Employee m ON e.managerId = m.id WHERE e.managerId IS NOT NULL AND m.id IS NULL;

如果这个查询返回了记录,说明你的数据里有指向不存在的经理,统计结果就会偏差。我在实际项目里遇到过这种问题,源头是离职员工清理的时候只删了经理记录,没回填下属的 managerId,害得我排查了整整一下午。

6.5 坑5:分组后要取经理姓名,回表多了会不会慢

不会太慢,因为经过 HAVING 过滤后,满足条件的经理数量已经很小了。真正的性能瓶颈永远在“全表扫描 + 大连接”这一步。所以一个常见的优化技巧是:先尽量缩小数据集,再关联主表拿字段。

7. 从 LeetCode 到真实业务:这道题的工程化变形

这道题表面是一道SQL题,实际上是一个业务模型的抽象:实体表 + 自引用外键 + 统计筛选。把这个模式记下来,你在很多场景都能套用。

7.1 业务场景变形1:人数盘点与管理带宽分析

人力资源部门经常要查“管理幅度”——一位经理直接带多少人。超过某个阈值(比如10人)说明管理带宽亮红灯。SQL 几乎就是照搬:

SELECT m.id AS manager_id, m.name AS manager_name, COUNT(e.id) AS direct_reports FROM dim_employee e JOIN dim_employee m ON e.manager_id = m.id GROUP BY m.id, m.name HAVING COUNT(e.id) > 10 ORDER BY direct_reports DESC;

7.2 业务场景变形2:交易风控中的“一人多卡”或“多设备关联”

互联网风控场景里,如果一张表记录的是user_id, device_id,要找出“同一个设备关联超过5个账号”的风险设备:

SELECT device_id FROM user_device GROUP BY device_id HAVING COUNT(DISTINCT user_id) >= 5;

这种模式和经理题完全同构,只不过把“经理”换成了“设备”。

7.3 业务场景变形3:内容平台的刷量识别

比如评论区表comment(user_id, content_id),找出“同一用户一天内评论超过50条”的嫌疑账号。同样是GROUP BY + HAVING,只是在分组维度里多加了日期字段:

SELECT user_id, DATE(created_at) AS d FROM comment GROUP BY user_id, DATE(created_at) HAVING COUNT(*) >= 50;

所以我说这道题的价值不在 SQL 本身,而在它背后的“聚合过滤”思维。你把这个模式想透了,一类题就都通了。

7.4 建好通用报表的思路

真实业务里,不要每次需要数据都手动写一遍这种SQL。可以把它封装成一个视图:

CREATE VIEW v_manager_span AS SELECT m.id AS manager_id, m.name AS manager_name, COUNT(e.id) AS direct_report_cnt FROM Employee e JOIN Employee m ON e.managerId = m.id GROUP BY m.id, m.name;

以后想查“管理幅度过大的经理”,只需要:

SELECT * FROM v_manager_span WHERE direct_report_cnt >= 5;

但注意,视图在底层每次都是重新执行查询,如果表很大,不如用定时任务把结果落到一张汇总表。这也是数据仓库里常见的做法:明细层 → 汇总层 → 应用层。

8. 最后的实操心得

我记得很久以前在一次面试里,面试官让我在白板上写这题。当时我第一反应就是 JOIN + GROUP BY + HAVING,写得飞快。面试官问我:“如果这张表有5000万行,你还这么写吗?”我愣了一下。后来我用生产数据实测过:5000万行的员工快照表,JOIN 方案跑了将近40秒,改成子查询 + 索引之后降到了3秒以内。差距一目了然。

从那以后,我养成了一个习惯:凡是“先按某个维度聚合,再拿聚合结果回查明细”的场景,优先想子查询 + 聚合,而不是一上来就 JOIN。这不仅是性能问题,更是 SQL 思维方式的转变——JOIN 是横着连接两张表,聚合是纵向往一个维度收拢,两者本质服务于不同的查询意图。能先把意图想清楚,SQL 才不会写得拧巴。

另外多说一句关于刷题的事:LeetCode 这种题,答案背下来很容易,但真正拉开差距的是能不能答出边界条件、能不能分析执行计划、能不能把解法迁移到业务场景。这几个方向都做到,一道题吃透,比刷十道同类型的题更值。

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

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

立即咨询