SQL自连接实战:LeetCode 181题“超过经理收入的员工”全解析

发布时间:2026/9/28 6:29:03
SQL自连接实战:LeetCode 181题“超过经理收入的员工”全解析 要说 LeetCode 的 SQL 题库哪道题最值得当作“自连接”的入门教材我一定会选 181 题“超过经理收入的员工”。题面虽然简单但它把 SQL 里一个很容易绕晕的概念讲透了——同一张表能不能像两张表一样去比较这种需求在真实业务里太常见了比如找出比同组同事绩效高的人、判断哪批商品比上一批卖得好、筛查加班时长超过上级的员工本质上都是“拿一张表和它自己比”。这篇就以 181 题为抓手把自连接的原理、三种写法、执行顺序、性能陷阱和常见翻车场景从头到尾拆一遍适合刚刷 SQL 题的朋友也适合写过不少业务 SQL 但一直没把连接逻辑理清楚的开发。1. 题目拆解与核心思路1.1 先读懂题意一张表里找“比经理赚得多”的人题目给的表结构很简单一张Employee表四个字段字段类型含义idint员工编号namevarchar员工姓名salaryint工资managerIdint经理的员工编号这里最关键的字段是managerId。它不是外键也不一定非空它存的是“当前员工的经理在id字段里的值”。CEO 没有经理所以managerId是 NULL。也就是说这张表里普通员工和经理都在同一张表里经理本身也是员工也有自己的 id、name、salary。题目的要求是写一个 SQL 查询找出“工资高于自己经理”的员工姓名结果列名返回Employee。先感受一下这题的坑点到底在哪儿。一般业务里我们比较两个数字通常是从两张表里取数或者在一张表的不同列之间比较。但这题要比较的是“同一张表的不同行”员工行里的salary要跟另一行经理的salary比较。如果不用连接单纯靠WHERE条件根本没法在一行里同时拿到“员工的工资”和“他经理的工资”因为它们不在同一行。所以解题的第一步不是想函数而是认识到“必须让这张表同时扮演两个角色”。1.2 自连接把一张表当作两张表来用所谓自连接就是一张表和自己做连接操作。很多人第一次听到“自己和自己连”会觉得抽象其实换个说法就清楚了在 SQL 里只要给同一张表起了两个不同的别名它就成了两张“逻辑上的表”。比如我给员工表起两个别名a和b把a当作“员工表”看把b当作“经理表”看。a表里每一行都是一个员工b表里每一行都是一个潜在的经理。连接条件就顺理成章了a.managerId b.id意思是a表里这个员工的经理就是b表里id等于a.managerId的那一行。这时候a.salary是员工的工资b.salary是他经理的工资两者只隔着一个WHERE条件a.salary b.salary两个条件合起来完整的逻辑就是先通过a.managerId b.id给每个员工找到对应经理再过滤出员工工资高于经理工资的行最后输出员工姓名。这里我建议你养成一个好习惯给表起别名的时侯不要写a、b这种没有语义的字母而是用emp表示员工、mgr表示经理。下面的 SQL 可读性会好很多SELECT emp.name AS Employee FROM Employee AS emp JOIN Employee AS mgr ON emp.managerId mgr.id WHERE emp.salary mgr.salary;我第一次做这题的时候就因为别名起得随意来回改条件改到头晕后来才意识到自连接的核心困难不是语法而是语义混淆。只要别名语义明确连接方向就不容易写反。1.3 关联与筛选是两个动作不是一回事很多初学者会把a.managerId b.id和a.salary b.salary混在一起觉得都是WHERE里的条件谁先谁后无所谓。在大多数数据库里语义上确实能算出同样结果但概念上它们是两个完全不同的动作a.managerId b.id负责“行与行的匹配”a.salary b.salary负责“匹配结果里的行过滤”。顺序错了会出什么结果如果只写a.salary b.salary不写连接条件那就是每个员工的工资跟表里所有其他员工的工资挨个比一遍。假设表里有 4 个人结果最多能出来 16 行而且里面会出现“Joe 工资 70000比 Sam 工资 60000 高”这种毫无业务意义的组合因为 Sam 根本不管 Joe。这就是笛卡尔积。连接条件的作用就是把这种“所有组合”收窄成“有汇报关系的组合”然后过滤才有意义。想清楚这一点再看后面推荐的几种写法就会明白它们只是语法形式不同逻辑骨架完全一样。2. 三种写法实现与细节对比2.1 最常用的 INNER JOIN 写法先看数据准备。我一般会先建表、插数据把题目场景还原出来再写答案。下面这套示例数据在 MySQL 8.0、SQL Server 2019、PostgreSQL 上都能直接跑CREATE TABLE Employee ( id INT PRIMARY KEY, name VARCHAR(255), salary DECIMAL(10, 2), managerId INT ); INSERT INTO Employee (id, name, salary, managerId) VALUES (1, Joe, 70000, 3), (2, Henry, 80000, 4), (3, Sam, 60000, NULL), (4, Max, 90000, NULL);数据关系很简单Joe 的经理是 id 为 3 的 SamJoe 工资 70000Sam 工资 60000Joe 比经理高所以 Joe 是答案Henry 的经理是 id 为 4 的 MaxHenry 工资 80000Max 工资 90000Henry 比经理低不满足条件。用INNER JOIN的解法是SELECT emp.name AS Employee FROM Employee AS emp INNER JOIN Employee AS mgr ON emp.managerId mgr.id WHERE emp.salary mgr.salary;执行逻辑分三步先从Employee表查出所有员工然后对每一行员工拿emp.managerId去Employee表里找mgr.id相等的那一行。找到后组合成一行宽表同时包含员工姓名、员工工资、经理姓名、经理工资。最后WHERE把emp.salary mgr.salary的筛选条件加上。因为我们的示例里只有 Sam 和 Max 的managerId是 NULL他们俩在连接阶段就被丢掉了没有经理可匹配自然不可能出现在结果里。有人会问INNER JOIN里ON和WHERE能不能换个顺序结果上在 MySQL 里基本等价因为优化器会调整连接和过滤的顺序。但从可读性出发我还是建议把行匹配条件放ON把行过滤条件放WHERE这样别人看你的 SQL 时一眼就能分辨这两层逻辑。2.2 隐式连接与“老派风格”如果你看过老一点的 SQL 教程会见到这种写法SELECT emp.name AS Employee FROM Employee AS emp, Employee AS mgr WHERE emp.managerId mgr.id AND emp.salary mgr.salary;这种写法把连接条件写在WHERE里没有JOIN关键字。它的执行逻辑和INNER JOIN完全一样。在 ANSI SQL 标准里这种隐式连接也算合法旧系统的存量代码里大量存在。但从工程规范角度我不推荐你在新代码里用它因为它的结构不够清晰而且漏写连接条件时不会报错只会默默给你算笛卡尔积数据量一大就是性能灾难。举个例子如果你的WHERE里漏了emp.managerId mgr.id只留AND emp.salary mgr.salarySQL 不会报错它会先把两张“表”做完整笛卡尔积再过滤工资比较。4 个员工还能跑如果是 100 万个员工中间结果直接爆炸。而显式INNER JOIN结构上强迫你先写清楚连接条件不容易踩这种坑。当然刷题平台里两种写法都能过甚至有些旧教程还特别喜欢隐式写法。但你要知道两者的本质区别一个把连接语法显式化一个隐藏在WHERE里。理解归理解落笔还是推荐第一种。2.3 相关子查询的另类解法这道题不只有连接一种思路。还可以用相关子查询外层查员工对每个员工执行一次子查询找到他经理的工资然后外层再比较。SELECT name AS Employee FROM Employee AS emp WHERE salary ( SELECT salary FROM Employee AS mgr WHERE mgr.id emp.managerId );这里的子查询和普通子查询不一样关键在于内层WHERE mgr.id emp.managerId里引用了外层表的emp.managerId。这种“内层引用外层字段”的子查询叫相关子查询。它的执行过程很直观外层查出一行员工把emp.managerId的值代入内层内层查出经理工资返回给外层做salary 比较。这种写法在逻辑上比JOIN更“口语化”比较贴近人的思考方式——拿到一个员工就去查一下他经理赚多少然后比大小。但如果Employee表很大每查一个员工都要执行一次子查询性能通常不如一次连接。这也是面试官可能会追问的点为什么能用JOIN就不用相关子查询因为子查询在极端情况下会退化成逐行扫描而连接优化器有很多手段可以调整执行策略。这道题如果只追求“能跑通”三种写法都能过。但如果以后在工作中写报表、写数据分析 SQL连接是更通用、更可控的方案。子查询当成理解 SQL 执行顺序的辅助工具更合适。2.4 三种方案速查对比写法执行逻辑优点注意点INNER JOIN ON先按关联条件匹配两表行再过滤结构清晰最推荐注意输出列用员工别名隐式连接 WHERE先笛卡尔积再过滤代码短存量系统常见漏写关联条件会出大问题相关子查询外层逐行代入内层查经理工资逻辑直观贴近自然语言数据量大时性能可能较差表格看起来简单但建议你自己动手把三种写法都跑一遍对比一下输出结果。尤其是把ON和WHERE的顺序换一换把子查询里的emp和mgr对调一下感受一下 SQL 解析和优化器执行时的微妙差异。3. 执行顺序、索引与性能细节3.1 SQL 执行顺序先有连接的结果才有过滤很多 SQL 初学者以为 SQL 是按照书写顺序执行的即先SELECT再FROM再WHERE。实际上逻辑执行顺序完全是另一回事。理解执行顺序对这道题有什么直接帮助它能解释为什么ON条件的“匹配”必须先于WHERE条件的“过滤”发生。标准 SQL 的逻辑执行顺序大致是FROM子句确定数据来源可能包含多个表或子查询ON/JOIN按连接条件合并两张表生成中间结果WHERE对中间结果做行级过滤GROUP BY按字段分组HAVING对分组后的结果过滤SELECT计算并投影需要的列ORDER BY排序LIMIT/OFFSET分页拿自连接的题代入一遍FROM Employee AS emp, Employee AS mgr先把两张“逻辑表”摆出来ON emp.managerId mgr.id把有关联关系的人和经理合并成宽表WHERE emp.salary mgr.salary对这个宽表做筛选最后SELECT emp.name把名字投影出来。理解这层之后你就能解释一个很经典的现象如果把emp.salary mgr.salary从WHERE挪到ON里结果在部分数据库里可能不变但语义和性能可能完全不一样。ON是连接阶段的事WHERE是连接后过滤的事。对这道题来说把比较条件放ON可能会让优化器在连接时就跳过大量行减少中间结果但放WHERE更符合阅读直觉两种写法结果一致。区别在真实大数据环境下才会明显。3.2 表数据量大时慢到底慢在哪里先看一个实际问题如果Employee表有 10 万行自连接怎么跑最粗暴的方式是拿每个员工去和全表比对复杂度接近 O(n²)10 万行就是 100 亿次比较任何数据库都扛不住。数据库不会这么傻它会走索引。回到我们的连接条件emp.managerId mgr.id。mgr.id是主键主键自带索引所以当数据库以员工表为驱动表拿员工表的emp.managerId去匹配经理表主键时走的是主键索引查找速度非常快。这是这个查询自带的最重要优化点。但如果优化器选择以经理表作为驱动表它就需要拿mgr.id去匹配员工表的emp.managerId。managerId如果没有索引就得全表扫描员工表性能就下来了。所以实际工作中如果这类查询频繁出现建议给managerId加一个普通索引CREATE INDEX idx_emp_manager ON Employee(managerId);加了索引之后自连接的两条访问路径都有了索引支撑优化器无论选谁当驱动表都不会太差。这也解释了一个让很多人困惑的现象明明连接的两个表都很大为什么有时候加个索引查询就快了因为索引让“匹配”从全表扫描变成了树查找复杂度从 O(n) 降到了 O(log n)。我们可以用EXPLAIN看看 MySQL 会怎么执行EXPLAIN SELECT emp.name AS Employee FROM Employee AS emp JOIN Employee AS mgr ON emp.managerId mgr.id WHERE emp.salary mgr.salary;正常会看到类似这样的执行计划信息id select_type table type key rows Extra 1 SIMPLE emp ALL NULL 4 Using where 1 SIMPLE mgr eq_ref PRIMARY 1 Using where第一行说明emp被当作驱动表做全表扫描因为要遍历所有员工第二行说明每来一个员工就用主键索引PRIMARY去精确匹配mgr.id所以访问类型是eq_ref只回一行。这是自连接非常理想的执行路径。如果你的执行计划里两行都是ALL那就要检查managerId有没有索引了。3.3 从 181 题到真实业务场景的思维迁移解完这道题真正值得记住的是它的“自比较”心智模型。工作中大量需求都是这种类型某个业务对象和它同类的其他对象比较而“同类关系”由某个字段表达。比如员工和经理经理也在员工表里商品和供应商供应商信息在同表冗余订单和上一笔订单用户和推荐人。只要有层级、有上下级、有先后关系就可能用到自连接。再举个例子如果需求改成“找出每个经理手下工资最高的员工”题目就会升级。这时候自连接依然有用但还要配合聚合或窗口函数。比如先用自连接拿到员工和经理的对应关系再用ROW_NUMBER()按经理分组排序。这类题目在 LeetCode 里还有不少变体比如 184 题“部门工资最高的员工”核心思路也是“先建立关联关系再做分组取极值”。181 题就是把最底层的关联关系练扎实。4. 常见错误与排查心得4.1 连接条件写反结果全是空的或错的这道题最容易犯的错误是把连接条件写成mgr.managerId emp.id也就是把经理的经理和员工匹配。逻辑上完全不对因为经理的managerId可能是 NULL也可能是更上层领导的 id跟当前员工没有直接关系。结果要么返回空要么返回乱七八糟的组合。怎么快速检查看语义emp.managerId mgr.id读作“员工表的经理编号等于经理表的员工编号”。写 SQL 前先在草稿纸上把两个角色写清楚左边是“当前员工”右边是“他的经理”。只要脑子里的角色不混条件就不会写反。4.2 输出字段选错答案列成了经理名字另一个容易翻车的地方是SELECT里选了mgr.name。题目要求的Employee是“超过经理收入的员工”的姓名也就是员工自己的名字。如果你选的是mgr.name结果会变成经理的姓名完全跑偏。更隐蔽的情况是两表别名都有name字段SQL 不会报错因为查询里出现了歧义列部分数据库会直接报错说“字段不明确”。这其实就是变相提醒你选择列的时候一定要用别名限定。我的习惯是SELECT阶段只写emp.name不写裸的name不管多少个表限定列名都是好习惯。4.3 经理字段为 NULLCEO 被“静默丢弃”INNER JOIN只保留两边都匹配上的行。CEO 的managerId是 NULLNULL 和任何值比较结果都是未知所以 CEO 根本不会出现在连接结果里。大部分情况下这无所谓因为题目找的是“收入超过经理的员工”CEO 没有经理必然不在答案里。但如果你在业务报表里需要保留未匹配的行比如统计所有在职员工哪怕他没有经理也要显示出来就必须换LEFT JOINSELECT emp.name AS Employee FROM Employee AS emp LEFT JOIN Employee AS mgr ON emp.managerId mgr.id;这时候mgr的行可能全是 NULL说明这个员工没有经理。提到 NULL还有一个细节值得记住判断某个字段是不是 NULL永远不能用 NULL要用IS NULL或IS NOT NULL。因为NULL NULL的结果是NULL不是布尔真这在WHERE里会被当作假所以写WHERE mgr.id IS NULL才是保留无经理员工的正确姿势。4.4 不同数据库方言里的环境坑同一个自连接语句在主流数据库里基本都能跑但有几个环境差异值得提一下。MySQL 的JOIN和INNER JOIN完全等价可以省略INNER关键字。SQL Server 对排序规则比较敏感如果表字段用了大小写敏感的排序规则name和Name就是两个不同的列查询时要特别注意列名大小写。PostgreSQL 对双引号敏感Employee和Employee可能是不同对象。Oracle 里子查询不能随便取别名空字符串和 NULL 的处理也跟 MySQL 不一样。SQLite 对类型要求宽松INT字段里塞字符串也不会报错所以做题环境里跑通不代表生产环境没问题。在 LeetCode 自带的 MySQL 8.0 环境里181 题没有用到窗口函数也不用考虑分组模式所以解法很直接。但如果你把同样的思路迁移到 SQL Server 或 Oracle建议跑一遍验证。我们团队有一次就是把 MySQL 的 SQL 搬到 Oracle结果因为表别名加不加AS的兼容性问题花了好几分钟排查这类小坑写下来就是个经验。4.5 自查清单提交前看一眼检查项正确姿势连接角色左边是员工右边是经理emp.managerId mgr.id输出字段选员工姓名emp.name不是经理姓名过滤条件emp.salary mgr.salary不能漏NULL 处理无经理员工会被 INNER JOIN 丢弃视需求是否用 LEFT JOIN别名语义使用 emp/mgr 这类可读别名避免 a/b 混淆性能数据量大时给 managerId 建索引用 EXPLAIN 验证我在实际刷题和带新人复盘时发现很多人最后不是不会写解法而是写完不敢提交怕边界情况出问题。上面这张清单基本覆盖了 181 题所有易错点提交前按顺序过一遍心里会踏实很多。最后再分享一个我个人的小习惯凡是遇到“同一张表里自己跟自己比较”的题目我第一件事不是打开编辑器敲代码而是先在纸上把两个角色分别圈出来一个圈里写“我”一个圈里写“我的上级”然后用箭头把关联字段连起来。箭头画对了SQL 就成功了一半。刷题刷多了你会发现SQL 最难的往往不是函数和语法而是把业务关系用表关系表达清楚的能力181 题就是训练这个能力最好的起点。