进度 0/50
LeetCode · Official Study Plan · SQL50

SQL 50 题单手册

按知识点重排的 LeetCode 官方数据库 50 题。每题给出思路拆解、可直接提交的 MySQL 解法、易错点,以及外企面试里可以直接说出口的英文表达。

题量 50 · 章节 5 · 建议周期 4–5 周 · 方言 MySQL 8.0(附 PostgreSQL 差异)· 生成日期 2026-09-27
这份手册不是把 50 题按官方顺序罗列,而是打散重排到 5 个知识点章节里:同一章的题目用的是同一套套路,连做两三道之后你就会发现它们其实是同一道题。

怎么用这份手册

  1. 先读章节导语,知道这一章要解决什么问题,再开始做题。
  2. 每题先看"一句话题面"和"考点",自己在 LeetCode 上写 15 分钟。写不出来再看手册的"思路拆解",仍然不要直接抄答案。
  3. AC 之后必须做两件事:把手册里的写法敲一遍(不要复制粘贴);把"易错点"对照你的代码检查一遍。
  4. 窗口函数一章(第 5 章)是分水岭。外企 SQL 面试几乎必考 ROW_NUMBER / RANK / SUM OVER,简单题刷得再快也不如这一章吃透。
  5. 英文表达逐字读出声。面试里 "I'd start by…" 这类句式是肌肉记忆,不是知识。
  6. 进度:点每张卡片右上"已 AC"即可记录,浏览器本地保存,打印时自动隐藏。

本手册的核对状态(请读一眼)

题号、题名、难度与表结构逐题取自 LeetCode 中文站题目页(50/50 已核对);所属官方学习计划 SQL50 的名单来自公开镜像题库,与官方页面一致,但官方计划页当时无法直接抓取。参考解法已核对的部分:① 每题都对照官方样例输入输出人工核过;② 全部 50 条 SQL 在 SQLite 3.53 上做过语法解析,其中 47 条原句通过,3 条(626 / 1341 / 196)用的是 MySQL 专有写法(UPDATE…JOIN…SET、括号包裹的 UNION 分支、DELETE p1 FROM … JOIN),SQLite 解析不了,属于方言差异而非语法错误;③ 53 个语义用例(含刻意设计的反例,覆盖全部 50 题)在临时建表的测试机上跑通,结果与独立编写的 Python 参考实现一致。没有做到的部分:没有在真实的 MySQL 8 判题环境上提交过。此外,中文站的数据库题库现已拆成「基础版 / 进阶版」两份,本手册按经典 SQL50 名单编排。

五周刷题节奏

周章节题数目标
第 1 周第 1 章 过滤与条件判断12WHERE / BETWEEN / LIKE / REGEXP / CASE WHEN / IF,把「选行」写熟,重点是 NULL 三值逻辑
第 2 周第 2 章 多表连接11四种 JOIN、三种自连接、反连接 LEFT JOIN … IS NULL;理解 join fan-out(连接后行数膨胀)
第 3 周第 3 章 分组、聚合与拼接12GROUP BY + HAVING、条件聚合、加权平均、GROUP_CONCAT;易错:COUNT(*) 与 COUNT(列) 不等价
第 4 周第 4 章 子查询、集合运算与改数据9元组 IN、NOT EXISTS、UNION ALL 拉平两列,以及两道 DML(UPDATE / DELETE 自连接、ERROR 1093)
第 5 周
(或前四周并行)
第 5 章 窗口函数6RANK / DENSE_RANK / ROW_NUMBER / LAG / LEAD / SUM OVER,含唯一的困难题 185;ROWS 与 RANGE 帧的区别是外企面试的送命题

合计 50 题。难度分布:简单 32 · 中等 17 · 困难 1(185 部门工资前三高)。窗口函数一章可以提前并行做,因为它反过来能简化第 3、4 章的多道题。

第 1 章 · 过滤与条件判断12 题

这一章解决一件事:把符合要求的行挑出来,并按题意塑形。看起来最简单,实际是面试里失分最多的地方,因为坑全部藏在 NULL 的三值逻辑、LENGTH 与 CHAR_LENGTH 的区别、以及正则有没有首尾锚定上。12 题里有 4 题(584 / 1527 / 1517 / 1789)的错误答案都来自想当然地以为「不等于」会包含空值。做完这一章你应该能条件反射地说出:比较 NULL 只能用 IS。

本章题号:1757 · 595 · 584 · 1148 · 1683 · 620 · 1667 · 1527 · 1517 · 610 · 1907 · 1789

1757可回收且低脂的产品 Recyclable and Low Fat Products简单
考点:多条件 AND 过滤 · 题面 ↗

参考解法(MySQL 8)

SELECT product_id
FROM Products
WHERE low_fats = 'Y' AND recyclable = 'Y';

思路

两个枚举列同时取 'Y' 才算达标,直接用 AND 串联。low_fats / recyclable 都是 ENUM('Y','N')。

易错点 把 AND 写成 OR:示例里 2 号产品是 N/Y,用 OR 会被误选进来。
面试可以说 "I filter rows where both flag columns equal 'Y', so only products satisfying both conditions remain."
595大的国家 Big Countries简单
考点:阈值 OR 过滤 · 题面 ↗

参考解法(MySQL 8)

SELECT name, population, area
FROM World
WHERE area >= 3000000 OR population >= 25000000;

思路

面积或人口任一达标即为大国,用 OR;输出列顺序按题面是 name, population, area。

易错点 阈值是 >= 不是 >,3000000 / 25000000 是闭区间边界,写错会漏掉刚好压线的国家。
面试可以说 "A country qualifies if either area or population meets the threshold, so I OR two greater-or-equal checks."
584寻找用户推荐人 Find Customer Referee简单
考点:NULL 三值逻辑 · 题面 ↗

参考解法(MySQL 8)

SELECT name
FROM Customer
WHERE referee_id IS NULL
   OR referee_id <> 2;

思路

题目要的是「不是被 2 号推荐」,这天然包含根本没有推荐人的行,必须把 IS NULL 显式写出来。

易错点 只写 WHERE referee_id != 2。NULL 与任何值比较结果都是 UNKNOWN,会被 WHERE 丢弃,Will / Jane / Bill 全部消失。这是 LeetCode 数据库题第一名坑。
面试可以说 "NULL fails every comparison in SQL, so I explicitly keep rows with a null referee alongside rows that are not referee 2."
1148文章浏览 I Article Views I简单
考点:DISTINCT + 自反相等 · 题面 ↗

参考解法(MySQL 8)

SELECT DISTINCT author_id AS id
FROM Views
WHERE author_id = viewer_id
ORDER BY id;

思路

作者浏览自己的文章即 author_id = viewer_id;同一个人可以浏览多次,所以要 DISTINCT。

易错点 两处分分钟:漏 DISTINCT 会出重复行(Views 表没有主键,本来就有重复);输出列名必须别名成 id,写 author_id 直接判错。
面试可以说 "I select distinct authors who viewed their own article, using DISTINCT because the view log has duplicate rows."
1683无效的推文 Invalid Tweets简单
考点:字符串长度 · 题面 ↗

参考解法(MySQL 8)

SELECT tweet_id
FROM Tweets
WHERE CHAR_LENGTH(content) > 15;

思路

「无效」定义为内容字符数严格大于 15,用长度函数比较即可。

易错点 LENGTH 返回的是字节数,遇到多字节字符(中文、emoji)会虚高;统计长度一律用 CHAR_LENGTH。另外是 > 不是 >=。
面试可以说 "I use CHAR_LENGTH so the check counts characters rather than bytes, and keep anything longer than fifteen."
620有趣的电影 Not Boring Movies简单
考点:奇偶判断 + 排序 · 题面 ↗

参考解法(MySQL 8)

SELECT id, movie, description, rating
FROM cinema
WHERE description <> 'boring'
  AND id % 2 = 1
ORDER BY rating DESC;

思路

座位号为奇数、描述不是 boring,再按评分降序,就是「最有趣的电影」。% 取模,也可写 MOD(id,2)=1。

易错点 表名是小写 cinema。Linux 判题环境下表名大小写敏感,写成 Cinema 会报找不到表。
面试可以说 "I keep odd-numbered rows that are not boring and sort by rating descending."
1667修复表中的名字 Fix Names in a Table简单
考点:大小写规范化 · 题面 ↗

参考解法(MySQL 8)

SELECT user_id,
       CONCAT(UPPER(LEFT(name, 1)), LOWER(SUBSTRING(name, 2))) AS name
FROM Users
ORDER BY user_id;

思路

首字母转大写、其余全部转小写再拼回。LEFT(name,1) 取首字母,SUBSTRING(name,2) 从第 2 个字符取到结尾。

易错点 SUBSTRING(str, pos) 的第二个参数是起始位置不是长度;写成 SUBSTRING(name,2,2) 会把长名字截断(bOB → bO)。
面试可以说 "I upper-case the first character, lower-case the remainder, and concatenate them back."
1527患某种疾病的患者 Patients With a Condition简单
考点:词边界匹配 · 题面 ↗

参考解法(MySQL 8)

SELECT patient_id, patient_name, conditions
FROM Patients
WHERE conditions LIKE 'DIAB1%'
   OR conditions LIKE '% DIAB1%';

思路

DIAB1 只有在「字符串开头」或「空格之后」出现才是有效的疾病代码前缀,所以拆成两个 LIKE。

易错点 偷懒写 conditions LIKE '%DIAB1%' 会误伤 SADIAB1、XDIAB1 这种非前缀代码 —— 官方示例里专门放了这种反例。
面试可以说 "The code has to start a word, so I match either the string prefix or a space followed by DIAB1."
1517查找拥有有效邮箱的用户 Find Users With Valid E-Mails简单
考点:正则首尾锚定 · 题面 ↗

参考解法(MySQL 8)

SELECT *
FROM Users
WHERE mail REGEXP '^[A-Za-z][A-Za-z0-9._-]*@leetcode\\.com$';

思路

前缀必须字母开头,后随字母、数字、_ . -;域名固定 @leetcode.com;首尾都要锚定。

易错点 漏 $ 会把 @leetcode.com.cn 判为合法;漏 \\. 转义会把 @leetcodeXcom 判为合法。正则里 . 是任意字符。
面试可以说 "I anchor the pattern at both ends so the prefix must start with a letter and the domain must be exactly leetcode.com."
610判断三角形 Triangle Judgement简单
考点:三角不等式 + IF · 题面 ↗

参考解法(MySQL 8)

SELECT x, y, z,
       IF(x + y > z AND x + z > y AND y + z > x, 'Yes', 'No') AS triangle
FROM Triangle;

思路

任意两边之和严格大于第三边才构成三角形,三个不等式全部成立才输出 Yes。IF 是 MySQL 专有函数。

易错点 只判一条 x+y>z 不够:13,15,30 会误判成 Yes。另外 IF 在 PostgreSQL 里要换成 CASE WHEN。
面试可以说 "I apply the triangle inequality in all three directions and return Yes only when every one of them holds."
1907按分类统计薪水 Count Salary Categories中等
考点:UNION ALL 三段计数 · 题面 ↗

参考解法(MySQL 8)

SELECT 'Low Salary' AS category, COUNT(*) AS accounts_count
FROM Accounts WHERE income < 20000
UNION ALL
SELECT 'Average Salary', COUNT(*)
FROM Accounts WHERE income BETWEEN 20000 AND 50000
UNION ALL
SELECT 'High Salary', COUNT(*)
FROM Accounts WHERE income > 50000;

思路

三段各自条件计数,再纵向拼成三行。空集上 COUNT(*) 返回 0 而不是空,所以某个类别没人时也能输出 0 行值。

易错点 改用 CASE WHEN + GROUP BY 分类,零记录的类别会整行消失,输出只剩两行直接判错。这是「按类别补零」的经典模式,面试常考。
面试可以说 "I run one aggregate per bucket and stack them with UNION ALL, so an empty bucket still reports zero instead of disappearing."
1789员工的直属部门 Primary Department for Each Employee简单
考点:标记 + 单部门兜底 · 题面 ↗

参考解法(MySQL 8)

SELECT employee_id, department_id
FROM Employee
WHERE primary_flag = 'Y'
UNION
SELECT employee_id, department_id
FROM Employee
GROUP BY employee_id
HAVING COUNT(*) = 1;

思路

直属部门有两种来源:显式标了 'Y' 的;以及该员工只属于一个部门(此时 flag 可能是 N,但那个部门就是直属)。两个集合取并。

易错点 只取 primary_flag='Y' 会漏掉「单部门但标记为 N」的员工。第二部分必须用 UNION(去重),否则同时满足两个条件的员工会出现两次。
面试可以说 "I take rows flagged Y and union them with employees who have exactly one department, using UNION to deduplicate."

第 2 章 · 多表连接11 题

JOIN 章节的核心不是语法,而是「以哪张表为主、缺失的要不要留」这一个问题。判断标准只有一句:题目要求「没有也要显示」,就 LEFT JOIN;要求「必须有」,就 INNER JOIN。本章 11 题覆盖四种 JOIN、三种自连接(上下级、相邻日期、首尾配对),以及外企面试一定会追问的join fan-out(连接后行数膨胀)。

本章题号:1378 · 1068 · 577 · 1581 · 1661 · 197 · 1280 · 570 · 1934 · 1731 · 1978

1378使用唯一标识码替换员工ID Replace Employee ID With The Unique Identifier简单
考点:LEFT JOIN 保留主表 · 题面 ↗

参考解法(MySQL 8)

SELECT u.unique_id, e.name
FROM Employees e
LEFT JOIN EmployeeUNI u ON u.id = e.id;

思路

以 Employees 为左表,所有员工都要出现在结果里;没有匹配到唯一码时 unique_id 输出 NULL。

易错点 写成 INNER JOIN,Alice、Bob 两行直接丢失。官方输出里明确有 null | Alice 这样的行。
面试可以说 "I keep every employee with a left join and let the unique code be null when there is no match."
1068产品销售分析 I Product Sales Analysis I简单
考点:INNER JOIN 补维表 · 题面 ↗

参考解法(MySQL 8)

SELECT p.product_name, s.year, s.price
FROM Sales s
JOIN Product p ON p.product_id = s.product_id;

思路

事实表 Sales 每行都有 product_id,内连接维表取名称即可。这是最标准的星型模型查询。

易错点 输出列是 price(单价),不是 quantity(数量);两者在示例里数值很像,容易抄错。
面试可以说 "It is a straightforward inner join from the fact table to the product dimension."
577员工奖金 Employee Bonus简单
考点:LEFT JOIN + 空值反过滤 · 题面 ↗

参考解法(MySQL 8)

SELECT e.name, b.bonus
FROM Employee e
LEFT JOIN Bonus b ON b.empId = e.empId
WHERE b.bonus IS NULL OR b.bonus < 1000;

思路

左连奖金表,保留「压根没奖金记录」和「奖金低于 1000」两类人。

易错点 只写 WHERE b.bonus < 1000 就把 bonus 为 NULL 的人过滤掉了 —— 与 584 同一个坑,套在 JOIN 场景里再考一次。
面试可以说 "Left join the bonus table, then keep rows where the bonus is null or below one thousand."
1581进店却未进行过交易的顾客 Customer Who Visited But Did Not Make Any Transactions简单
考点:反连接 + 分组计数 · 题面 ↗

参考解法(MySQL 8)

SELECT v.customer_id, COUNT(v.visit_id) AS count_no_trans
FROM Visits v
LEFT JOIN Transactions t ON t.visit_id = v.visit_id
WHERE t.visit_id IS NULL
GROUP BY v.customer_id;

思路

左连后只保留没配到交易的那些行(右表列为 NULL),再按顾客分组计数。这就是「反连接」的标准形态。

易错点 把 t.visit_id IS NULL 这个条件写进 ON 里 —— 那样只会影响连接匹配、不会过滤行,结果全错。ON 过滤右表、WHERE 过滤连接后的行。
面试可以说 "I anti-join visits to transactions and count the visits that survived without a match."
1661每台机器的进程平均运行时间 Average Time of Process per Machine简单
考点:自连接首尾配对 · 题面 ↗

参考解法(MySQL 8)

SELECT a.machine_id,
       ROUND(AVG(b.timestamp - a.timestamp), 3) AS processing_time
FROM Activity a
JOIN Activity b
  ON b.machine_id = a.machine_id AND b.process_id = a.process_id
WHERE a.activity_type = 'start' AND b.activity_type = 'end'
GROUP BY a.machine_id;

思路

同一台机器、同一个进程,把 start 行和 end 行配成一行,时间差取平均。

易错点 ON 里漏掉 process_id,会把 A 进程的 start 配上 B 进程的 end,算出负数时长。
面试可以说 "I self-join on machine and process to pair each start with its end, then average the difference."
197上升的温度 Rising Temperature简单
考点:自连接 + DATEDIFF · 题面 ↗

参考解法(MySQL 8)

SELECT a.id
FROM Weather a
JOIN Weather b ON DATEDIFF(a.recordDate, b.recordDate) = 1
WHERE a.temperature > b.temperature;

思路

为每一天找出「前一天」那一行做对比。DATEDIFF(较晚, 较早) 返回天数差。

易错点 用 a.id = b.id + 1 定位前一天 —— 日期是不连续的,id 相邻不代表日期相邻,官方示例就是专门设计的反例。
面试可以说 "I self-join with a datediff of one day so each date compares against its true previous calendar day."
1280学生们参加各科测试的次数 Students and Examinations简单
考点:CROSS JOIN 造骨架 · 题面 ↗

参考解法(MySQL 8)

SELECT s.student_id, s.student_name, sub.subject_name,
       COUNT(e.student_id) AS attended_exams
FROM Students s
CROSS JOIN Subjects sub
LEFT JOIN Examinations e
  ON e.student_id = s.student_id AND e.subject_name = sub.subject_name
GROUP BY s.student_id, s.student_name, sub.subject_name
ORDER BY s.student_id, sub.subject_name;

思路

题目要求「每生 × 每科」都要有一行,考试表里没出现过的组合也得报 0。所以先用 CROSS JOIN 造出完整骨架,再左连明细。

易错点 这里必须 COUNT(e.student_id):COUNT(*) 会把未匹配行也数成 1,全部变成 1。这一题是 JOIN 造骨架 + 聚合数非空 两个知识点的交汇,务必吃透。
面试可以说 "I cross join students with subjects to build the full grid, then left join the exams and count non-null matches."
570至少有5名直接下属的经理 Managers with at Least 5 Direct Reports中等
考点:自连接 + GROUP/HAVING · 题面 ↗

参考解法(MySQL 8)

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

思路

把员工表当成「下属表」连到经理行上:一条 e.managerId = m.id 就是一条汇报关系,按经理分组数关系条数。

易错点 GROUP BY 只写 name:两个同名经理的下属会被合并计数,凭空造出一个不存在的经理。分组键必须带主键 id。
面试可以说 "I self-join employees on manager id and keep managers whose report count is at least five."
1934确认率 Confirmation Rate中等
考点:LEFT JOIN + 条件聚合求比率 · 题面 ↗

参考解法(MySQL 8)

SELECT s.user_id,
       ROUND(SUM(IF(c.action = 'confirmed', 1, 0)) / COUNT(*), 2) AS confirmation_rate
FROM Signups s
LEFT JOIN Confirmations c ON c.user_id = s.user_id
GROUP BY s.user_id;

思路

左连后分母用 COUNT(*)(把没有任何确认请求的用户也算进去),分子用 IF 把非 confirmed / NULL 全记 0。

易错点 分母写成 COUNT(c.user_id):没有任何请求的用户会变成 0/0 = NULL,而题目要求输出 0.00。比率类题目分母一定要是自己那张表的全量。
面试可以说 "I left join and divide confirmed requests by the full signup row count so a user with no attempts still gets zero."
1731每位经理的下属员工数量 The Number of Employees Which Report to Each Employee简单
考点:自连接 + ROUND 取整 · 题面 ↗

参考解法(MySQL 8)

SELECT m.employee_id, m.name,
       COUNT(*) AS reports_count,
       ROUND(AVG(r.age)) AS average_age
FROM Employees m
JOIN Employees r ON r.reports_to = m.employee_id
GROUP BY m.employee_id, m.name
ORDER BY m.employee_id;

思路

内连接天然只留下「有人向他汇报」的行,所以不需要额外排除非经理,这是它比 570 更省一步的原因。

易错点 ROUND(AVG(age)) 不写第二个参数就是取整;官方要求 38.5 → 39(MySQL 四舍五入半值进位)。若写成 ROUND(AVG(age),1) 输出 38.5 就错了。
面试可以说 "I inner self-join on reports_to, count the reports and round the average age to a whole number."
1978上级经理已离职的公司员工 Employees Whose Manager Left the Company简单
考点:LEFT JOIN 探测缺失 + 三条件 · 题面 ↗

参考解法(MySQL 8)

SELECT e.employee_id
FROM Employees e
LEFT JOIN Employees m ON m.employee_id = e.manager_id
WHERE e.salary < 30000
  AND e.manager_id IS NOT NULL
  AND m.employee_id IS NULL
ORDER BY e.employee_id;

思路

经理 id 左连不回来说明这个人已经不在表里了。三个条件同时成立才输出:薪资门槛、有经理、经理不存在。

易错点 漏掉 e.manager_id IS NOT NULL:顶层老板的 manager_id 本身就是 NULL,左连必然连不到,会被误判成「经理离职」。用 NOT IN 子查询时更要小心 —— 子查询含 NULL 会让整个条件恒假。
面试可以说 "I left join the table onto itself to detect a missing manager, and explicitly exclude null manager ids so the CEO is not flagged."

第 3 章 · 分组、聚合与拼接12 题

GROUP BY 之后每一组只出一行,所以所有「每个 X 的 Y」都是本章的题。三件必须内化的事:① WHERE 过滤行、HAVING 过滤组;② COUNT(*) / COUNT(列) / COUNT(DISTINCT 列) 三种写法结果不同;③ 小数位要按题面写死,多数题要求 ROUND(...,2),1661 是 3 位。外企面试的 follow-up 一般是「这个查询为什么慢」,答案是对索引列套函数会让索引失效,见 1327。

本章题号:1251 · 1075 · 1633 · 1211 · 1193 · 2356 · 1141 · 596 · 1729 · 619 · 1484 · 1327

1251平均售价 Average Selling Price简单
考点:加权平均 + 区间连接 · 题面 ↗

参考解法(MySQL 8)

SELECT p.product_id,
       COALESCE(ROUND(SUM(p.price * u.units) / SUM(u.units), 2), 0) AS average_price
FROM Prices p
LEFT JOIN UnitsSold u
  ON  p.product_id = u.product_id
  AND u.purchase_date BETWEEN p.start_date AND p.end_date
GROUP BY p.product_id;

思路

总价 = 单价×销量,除以总销量就是加权平均(不是简单 AVG(price))。价格表是按生效区间存的,所以连接条件里必须带日期区间。

易错点 三处:① JOIN 只写 product_id 不带日期区间,会把同一产品的多个价格段全乘进去;② 零销量商品要输出 0 而不是空;③ ROUND(...,2) 是两位小数。另外 WHERE 里过滤右表会让 LEFT JOIN 退化成内连接。
面试可以说 "I join each sale to the price interval covering its purchase date, then compute a units-weighted average."
1075项目员工 I Project Employees I简单
考点:JOIN + 分组 AVG · 题面 ↗

参考解法(MySQL 8)

SELECT pr.project_id,
       ROUND(AVG(e.experience_years), 2) AS average_years
FROM Project pr
JOIN Employee e ON e.employee_id = pr.employee_id
GROUP BY pr.project_id;

思路

关系表 Project 提供分组维度,连接员工表取工龄,按项目求平均。

易错点 两位小数。MySQL 的 AVG 返回 DECIMAL 不会截断,但同一份 SQL 搬到 PostgreSQL 就会踩整数除法的坑(要 ::numeric),面试跨方言时要点出来。
面试可以说 "I joined project assignments to employees and averaged experience years per project, rounded to two decimals."
1633各赛事的用户注册率 Percentage of Users Attended a Contest简单
考点:分组占比 + 全表分母 · 题面 ↗

参考解法(MySQL 8)

SELECT contest_id,
       ROUND(COUNT(*) * 100 / (SELECT COUNT(*) FROM Users), 2) AS percentage
FROM Register
GROUP BY contest_id
ORDER BY percentage DESC, contest_id;

思路

分子是本次赛事的报名行数,分母是全站用户总数(标量子查询)。乘 100 变百分数。

易错点 分母误用 COUNT(*) FROM Register(那是全部报名记录数,会比用户数大)。排序是先 percentage 降序、并列再 contest_id 升序,少写第二键就判错。
面试可以说 "Percentage is contest registrations over the total user count, scaled by one hundred and rounded to two decimals."
1211查询结果的质量和占比 Queries Quality and Percentage简单
考点:比值再取均值 + 布尔求占比 · 题面 ↗

参考解法(MySQL 8)

SELECT query_name,
       ROUND(AVG(rating / position), 2) AS quality,
       ROUND(AVG(rating < 3) * 100, 2) AS poor_query_percentage
FROM Queries
GROUP BY query_name;

思路

quality 是「先算每条的 rating/position,再对组内取平均」,不是 AVG(rating)/AVG(position);占比用布尔表达式求均值(MySQL 里 true=1)。

易错点 SUM(rating)/AVG(position) 是最常见的错法,数学上完全不等价。非 MySQL 方言里 AVG(rating<3) 要换成 AVG(CASE WHEN rating<3 THEN 1.0 ELSE 0 END)。
面试可以说 "Quality averages the per-row rating-to-position ratio, and the poor share is the mean of a boolean condition."
1193每月交易 I Monthly Transactions I中等
考点:DATE_FORMAT + 条件聚合 · 题面 ↗

参考解法(MySQL 8)

SELECT DATE_FORMAT(trans_date, '%Y-%m') AS month,
       country,
       COUNT(*)                                        AS trans_count,
       SUM(state = 'approved')                         AS approved_count,
       SUM(amount)                                     AS trans_total_amount,
       SUM(IF(state = 'approved', amount, 0))          AS approved_total_amount
FROM Transactions
GROUP BY month, country;

思路

一次分组同时算四个指标,靠的是条件聚合:布尔表达式本身就是 0/1,可以直接 SUM。

易错点 格式串必须是 '%Y-%m';写成 %M 会输出英文月份名 2019-January。另一种常见错是 SUM(state='approved') 在该组全是 NULL时返回 NULL 而不是 0,稳妥写法是 SUM(IF(...,1,0))。
面试可以说 "I grouped by formatted month and country and used conditional sums to derive the approved metrics in one pass."
2356每位教师所教授的科目种类的数量 Number of Unique Subjects Taught by Each Teacher简单
考点:COUNT(DISTINCT) · 题面 ↗

参考解法(MySQL 8)

SELECT teacher_id, COUNT(DISTINCT subject_id) AS cnt
FROM Teacher
GROUP BY teacher_id;

思路

按教师统计去重后的科目数。

易错点 主键是 (subject_id, dept_id),同一科目开在两个系就有两行;用 COUNT(*) 会得 3,正确答案是 2。
面试可以说 "I counted distinct subject ids per teacher because the same subject can appear in several departments."
1141查询近 30 天活跃用户数 User Activity for the Past 30 Days I简单
考点:日期窗口 + COUNT(DISTINCT) · 题面 ↗

参考解法(MySQL 8)

SELECT activity_date AS day, COUNT(DISTINCT user_id) AS active_users
FROM Activity
WHERE activity_date BETWEEN '2019-06-28' AND '2019-07-27'
GROUP BY activity_date;

思路

锁定「最近 30 天(含当天)」的窗口,按天去重统计用户。题目已把活动日期上限给定为 2019-07-27。

易错点 边界差一天:30 天含当天,起点是 06-28(等价于 >= DATE_SUB('2019-07-27', INTERVAL 29 DAY))。用 COUNT(*) 会把同一用户当天多次活动重复计数。
面试可以说 "I filtered a thirty-day inclusive window and counted distinct users per active day."
596超过 5 名学生的课 Classes With at Least 5 Students简单
考点:GROUP BY + HAVING · 题面 ↗

参考解法(MySQL 8)

SELECT class
FROM Courses
GROUP BY class
HAVING COUNT(DISTINCT student) >= 5;

思路

按课名分组,用 HAVING 卡人数门槛。student 列允许重复选课记录,所以保险写法是 COUNT(DISTINCT student)。

易错点 >= 5 写成 > 5;以及把过滤条件放进 WHERE —— WHERE 在分组之前执行,那里根本还没有人数可用。
面试可以说 "I grouped by class and filtered in HAVING, since the threshold is on the aggregate, not on individual rows."
1729求关注者的数量 Find Followers Count简单
考点:分组计数 + 排序 · 题面 ↗

参考解法(MySQL 8)

SELECT user_id, COUNT(follower_id) AS followers_count
FROM Followers
GROUP BY user_id
ORDER BY user_id;

思路

按「被关注者」分组数粉丝。user_id 是被关注的人,follower_id 是粉丝。

易错点 这一页明确要求按 user_id 升序,漏 ORDER BY 就判错 —— LeetCode 顺序不敏感时不写没事,写了也不扣分,养成统一加 ORDER BY 的习惯最安全。
面试可以说 "I counted followers grouped by the followed user and ordered by user id."
619只出现一次的最大数字 Biggest Single Number简单
考点:派生表 + MAX 的空集语义 · 题面 ↗

参考解法(MySQL 8)

SELECT MAX(num) AS num
FROM (
  SELECT num FROM MyNumbers GROUP BY num HAVING COUNT(*) = 1
) t;

思路

先筛出「只出现过一次」的数字,再对它取最大值。妙处在于:内层为空集时,MAX 作用在 0 行上返回一行的 NULL,正好符合题意。

易错点 用 ORDER BY num DESC LIMIT 1 代替 MAX:没有单次数字时它会返回0 行,而题目要求返回一行 NULL。
面试可以说 "I wrapped the singleton numbers in a derived table and applied MAX, so an empty set still returns one null row."
1484按日期分组销售产品 Group Sold Products By The Date简单
考点:GROUP_CONCAT 拼接 · 题面 ↗

参考解法(MySQL 8)

SELECT sell_date,
       COUNT(DISTINCT product) AS num_sold,
       GROUP_CONCAT(DISTINCT product ORDER BY product SEPARATOR ',') AS products
FROM Activities
GROUP BY sell_date
ORDER BY sell_date;

思路

同一天的商品名拼成一行字符串,同时统计去重后的商品数。

易错点 分隔符是英文逗号、不带空格(SEPARATOR ',');组内必须 ORDER BY product 字典升序,外层再按日期排。DISTINCT 不能省,否则重复商品会在字符串里出现两次。PostgreSQL 用 string_agg,SQL Server 用 STRING_AGG。
面试可以说 "I grouped by sell date and used GROUP_CONCAT with distinct and in-group ordering to list the products alphabetically."
1327列出指定时间段内所有的下单产品 List the Products Ordered in a Period简单
考点:日期左闭右开 + HAVING 门槛 · 题面 ↗

参考解法(MySQL 8)

SELECT p.product_name, SUM(o.unit) AS unit
FROM Products p
JOIN Orders o ON o.product_id = p.product_id
WHERE o.order_date >= '2020-02-01'
  AND o.order_date <  '2020-03-01'
GROUP BY p.product_id, p.product_name
HAVING SUM(o.unit) >= 100;

思路

只保留 2 月的订单,按产品聚合销量,再卡 100 的门槛。

易错点 门槛是 >= 100(示例里 Leetcode Kit 正好 100,必须输出)。日期不要用 BETWEEN '2020-02-01' AND '2020-02-28' —— 2020 是闰年,会漏掉 2 月 29 日。用「≥ 月初 且 < 下月初」的左闭右开写法,既正确又能用上索引。
面试可以说 "I restricted to February with a half-open range, which keeps the date index usable, then filtered the summed units in HAVING."

第 4 章 · 子查询、集合运算与改数据9 题

这一章开始用查询喂查询。三条判断标准:① 需要「每组的极值对应的整行」→ 元组 IN 或窗口;② 需要「全都有 / 都没有」→ HAVING COUNT(DISTINCT …) = 总数 或 NOT EXISTS;③ 需要「把两列拉平成一列」→ UNION ALL(602 是范本)。另外两题(626 / 196)是 UPDATE 与 DELETE,考点是同表自连接和 ERROR 1093。

本章题号:176 · 1070 · 1045 · 626 · 180 · 1341 · 602 · 585 · 196

176第二高的薪水 Second Highest Salary中等
考点:标量子查询 + LIMIT/OFFSET · 题面 ↗

参考解法(MySQL 8)

SELECT (
  SELECT DISTINCT salary
  FROM Employee
  ORDER BY salary DESC
  LIMIT 1 OFFSET 1
) AS SecondHighestSalary;

思路

先去重、降序,再跳过 1 行取 1 行。整个 SELECT 放进括号当标量子查询时,没有行会自动变 NULL,所以连 IFNULL 都不用写。

易错点 漏 DISTINCT:薪水 200,200,100 时 OFFSET 1 落在第二个 200 上,答案错。另一常见错是忘包标量子查询导致空表时返回 0 行而不是 1 行 NULL。
面试可以说 "I deduplicate salaries, order descending, take an offset of one, and rely on the scalar subquery returning null when nothing matches."
1070产品销售分析 III Product Sales Analysis III中等
考点:元组 IN 取组内最早 · 题面 ↗

参考解法(MySQL 8)

SELECT product_id, year AS first_year, quantity, price
FROM Sales
WHERE (product_id, year) IN (
  SELECT product_id, MIN(year) FROM Sales GROUP BY product_id
);

思路

先按产品求出最小年份,再用行构造器 (product_id, year) IN (…) 回到明细表取整行。首年有多条记录时全部保留,符合题意。

易错点 只写 WHERE year IN (SELECT MIN(year)…):丢掉了 product 维度,A 产品首年 2008、B 产品首年 2009,结果会互相污染。输出列必须别名成 first_year。
面试可以说 "I use a row-constructor IN subquery on the per-product minimum year, so every first-year row is kept and no cross-product leakage happens."
1045买下所有产品的客户 Customers Who Bought All Products中等
考点:HAVING 对比全集计数 · 题面 ↗

参考解法(MySQL 8)

SELECT customer_id
FROM Customer
GROUP BY customer_id
HAVING COUNT(DISTINCT product_key) = (SELECT COUNT(DISTINCT product_key) FROM Product);

思路

「买了全部产品」= 该客户买过的不同产品数等于产品表的不同产品总数。总数用标量子查询动态算,绝不硬编码。

易错点 用 COUNT(*):Customer 表存在完全相同的 (customer_id, product_key) 重复行,会虚高而误判为「买全了」。右侧同理也要 DISTINCT。
面试可以说 "I group by customer and compare the distinct product count against a scalar subquery counting distinct products."
626换座位 Exchange Seats中等
考点:UPDATE 自连接 + 奇偶位移 · 题面 ↗

参考解法(MySQL 8)

UPDATE Seat s
LEFT JOIN Seat t
  ON t.id = IF(s.id % 2 = 1, s.id + 1, s.id - 1)
SET s.student = COALESCE(t.student, s.student);

思路

奇数 id 取下一行的学生,偶数 id 取上一行的学生;最后一行若是奇数,连不到人,COALESCE 回退保留本人(示例里 Jeames 仍在 5 号)。

易错点 SET 的子查询里直接 FROM Seat 会触发 ERROR 1093(不能在子查询里读同一张被修改的表),必须派生表或 CTE 包一层取快照;LEFT JOIN Seat t 这种自连接写法可以绕开。
面试可以说 "I self-join the seat table inside the update and coalesce the missing partner so a trailing odd row stays put."
180连续出现的数字 Consecutive Numbers中等
考点:三次自连接 / LAG · 题面 ↗

参考解法(MySQL 8)

SELECT DISTINCT l1.num AS ConsecutiveNums
FROM Logs l1
JOIN Logs l2 ON l2.id = l1.id + 1 AND l2.num = l1.num
JOIN Logs l3 ON l3.id = l1.id + 2 AND l3.num = l1.num;

思路

把同一个数字在三个连续 id 上对齐,只要存在这样一个起点就命中。id 在这题是连续的,所以可以 +1/+2。

易错点 忘 DISTINCT 会输出多行相同数字,别名必须写成 ConsecutiveNums。现代写法见下方窗口版。
面试可以说 "I self-join the log three times on consecutive ids with equal values, or equivalently compare two LAG offsets in one scan."
1341电影评分 Movie Rating中等
考点:两个 Top-1 用 UNION ALL 拼接 · 题面 ↗

参考解法(MySQL 8)

(SELECT u.name AS results
 FROM MovieRating mr JOIN Users u USING (user_id)
 GROUP BY u.user_id, u.name
 ORDER BY COUNT(*) DESC, u.name
 LIMIT 1)
UNION ALL
(SELECT m.title
 FROM MovieRating mr JOIN Movies m USING (movie_id)
 WHERE mr.created_at BETWEEN '2020-02-01' AND '2020-02-29'
 GROUP BY m.movie_id, m.title
 ORDER BY AVG(mr.rating) DESC, m.title
 LIMIT 1);

思路

两个口径各取第一名,是两个独立的排序查询,只能上下堆叠成一列。UNION ALL 负责纵向拼接。

易错点 用 UNION:万一两个第一名恰好同名,会被去重成一行,直接判错。各带 ORDER BY/LIMIT 的 SELECT 不加括号也会语法报错。日期上界 2020-02-29(闰年)。
面试可以说 "Two ranked top-one subqueries, one per metric, stacked with UNION ALL so identical results are not collapsed."
602好友申请 II:谁有最多的好友 Friend Requests II: Who Has the Most Friends中等
考点:UNION ALL 把两列拉平 · 题面 ↗

参考解法(MySQL 8)

SELECT id, COUNT(*) AS num
FROM (
  SELECT requester_id AS id FROM RequestAccepted
  UNION ALL
  SELECT accepter_id FROM RequestAccepted
) t
GROUP BY id
ORDER BY num DESC
LIMIT 1;

思路

一条接受记录意味着两个人各多一个好友,所以把 requester_id 和 accepter_id 纵向拼成一列,再按 id 计数。这个「unpivot」动作是处理对称关系表的通用套路。

易错点 用 UNION(带去重):同一个人重复出现的记录被压掉,人数全变小。另外不能只统计 requester_id 一列,会漏掉只作为 accepter 的人。
面试可以说 "I unpivot requester and accepter into a single column with UNION ALL, then count occurrences per id."
5852016年的投资 Investments in 2016中等
考点:双条件独立子查询 + 元组 IN · 题面 ↗

参考解法(MySQL 8)

SELECT ROUND(SUM(tiv_2016), 2) AS tiv_2016
FROM Insurance
WHERE tiv_2015 IN (
  SELECT tiv_2015 FROM Insurance GROUP BY tiv_2015 HAVING COUNT(*) > 1)
  AND (lat, lon) IN (
  SELECT lat, lon FROM Insurance GROUP BY lat, lon HAVING COUNT(*) = 1);

思路

两个条件各自独立判断:① 2015 年投资额和别人相同(共享);② 经纬度组合在全表唯一(不在小城市)。都成立才计入求和。

易错点 把两个条件塞进同一个 GROUP BY tiv_2015, lat, lon,逻辑完全不同 —— 那会变成「(投资额,坐标) 这个组合」的计数。列名是 lon 不是 long(LONG 是类型保留字)。
面试可以说 "Two independent subqueries each establish one rule, and I round the resulting sum to two decimals."
196删除重复的电子邮箱 Delete Duplicate Emails简单
考点:多表 DELETE 自连接 · 题面 ↗

参考解法(MySQL 8)

DELETE p1
FROM Person p1
JOIN Person p2 ON p1.email = p2.email AND p1.id > p2.id;

思路

只要存在「同邮箱且 id 更小」的行,当前行就该删。保留每组最小 id。MySQL 支持 DELETE 别名 FROM 表 别名 JOIN … 的多表删除语法。

易错点 写成 WHERE id NOT IN (SELECT MIN(id)… GROUP BY email) 会触发 ERROR 1093(同表子查询),需要再包一层派生表。LeetCode 每轮评测都重建表,删除操作不影响下次提交。
面试可以说 "I delete every row that has a smaller-id twin with the same email, using a self-join delete."

第 5 章 · 窗口函数6 题

外企面试的主战场,也是 50 题里唯一有「困难」的一章(185)。三个必须先讲清楚的点:① ROW_NUMBER / RANK / DENSE_RANK 在并列时的行为差别;② 默认帧是 RANGE UNBOUNDED PRECEDING → CURRENT ROW,排序键有并列时它会把整组 peer 一起算进去,这是 ROWS 与 RANGE 唯一但致命的分歧;③ 窗口函数不能写在 WHERE 里,必须包一层子查询或 CTE 再筛。

本章题号:185 · 1164 · 1204 · 1321 · 550 · 1174

185部门工资前三高的所有员工 Department Top Three Salaries困难
考点:DENSE_RANK 去重排名 · 题面 ↗

参考解法(MySQL 8)

SELECT d.name AS Department, e.name AS Employee, e.salary AS Salary
FROM (
  SELECT name, salary, departmentId,
         DENSE_RANK() OVER (PARTITION BY departmentId ORDER BY salary DESC) AS dr
  FROM Employee
) e
JOIN Department d ON d.id = e.departmentId
WHERE e.dr <= 3;

思路

「工资排名前三」指的是前三档工资,同一档的并列员工要全部输出,所以只有 DENSE_RANK 对:并列不占号。dr <= 3 在外层过滤。

易错点 用 RANK:某部门 90000,85000,85000,70000 会算成 1,2,2,4,70000 掉出前三。用 ROW_NUMBER:并列的人直接被砍掉。另一个高频错误是把窗口函数写进 WHERE。
面试可以说 "Top three distinct salaries means DENSE_RANK, so tied salaries share a rank and every tied row is kept. RANK consumes numbers and ROW_NUMBER drops ties."
1164指定日期的产品价格 Product Price at a Given Date中等
考点:ROW_NUMBER 取最近 + UNION 兜底 · 题面 ↗

参考解法(MySQL 8)

SELECT product_id, new_price AS price
FROM (
  SELECT product_id, new_price,
         ROW_NUMBER() OVER (PARTITION BY product_id ORDER BY change_date DESC) AS rn
  FROM Products
  WHERE change_date <= '2019-08-16'
) t
WHERE rn = 1
UNION
SELECT product_id, 10 AS price
FROM Products
GROUP BY product_id
HAVING MIN(change_date) > '2019-08-16';

思路

先把记录截到目标日期之前,再按日期倒序取 rn=1,就是「当天有效价」。第二部分专门补齐在那天之前从未改过价的产品,回落初始价 10。

易错点 漏掉兜底那一半会导致输出少行(题目要求每个产品都出现)。另外不能拿 MAX(new_price) 冒充「最新价」—— 降价场景直接取反。
面试可以说 "I take the latest change on or before the date with ROW_NUMBER, then union a fallback branch so every product appears exactly once at its initial price."
1204最后一个能进入巴士的人 Last Person to Fit in the Bus中等
考点:累计和 + ROWS/RANGE 帧语义 · 题面 ↗

参考解法(MySQL 8)

SELECT person_name
FROM (
  SELECT person_name,
         SUM(weight) OVER (ORDER BY turn
                           ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS total
  FROM Queue
) t
WHERE total <= 1000
ORDER BY total DESC
LIMIT 1;

思路

按 turn 顺序做运行总量,取「仍然不超过 1000 的最大累计」那个人。写显式 ROWS 帧是为了防止排序键并列时高估。

易错点 本题的考点其实在帧语义:默认帧 RANGE 会把 ORDER BY 值相同的所有 peer 行并入同一个累计值。这里 turn 唯一所以两者等价,但一旦按 weight 排序或 turn 有重复,RANGE 会让一整组并列乘客「同时上车」,累计值直接跳过 1000。面试被问「ROWS 和 RANGE 有什么区别」就答这个。
面试可以说 "Running total ordered by boarding turn with an explicit ROWS frame, so tied rows cannot jump the cumulative sum past the capacity."
1321餐馆营业额变化增长 Restaurant Growth中等
考点:先按天预聚合 + 7 行滑动帧 · 题面 ↗

参考解法(MySQL 8)

SELECT visited_on, amount, ROUND(amount / 7, 2) AS average_amount
FROM (
  SELECT visited_on,
         SUM(SUM(amount)) OVER (ORDER BY visited_on
                                ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS amount
  FROM Customer
  GROUP BY visited_on
) t
WHERE DATEDIFF(visited_on, (SELECT MIN(visited_on) FROM Customer)) >= 6
ORDER BY visited_on;

思路

内层 GROUP BY visited_on 先把同一天多笔消费压成「当日总额」,外层再用 ROWS BETWEEN 6 PRECEDING AND CURRENT ROW 取满 7 天。SUM(SUM(x)) OVER 是「聚合之后再开窗」的合法写法。

易错点 分母必须是常量 7。写成 AVG(amount) 或除以 COUNT(*),得到的是「有记录天数的均值」而不是「一周日均」。另外满 7 天才出第一个窗口,起点条件要卡 datediff >= 6。更隐蔽的一条:ROWS 数的是「行」,这里靠 GROUP BY visited_on 保证一天一行,所以 ROWS 6 PRECEDING 恰好等于 6 个自然日的前提是日期连续;一旦中间有某天完全没人消费,行距就和日距脱钩,必须改用 RANGE BETWEEN INTERVAL 6 DAY PRECEDING(或先补日期日历表)。这一题的样例日期刚好连续,所以两种写法都对,改成真实数据就会不一样。
面试可以说 "I aggregate to one row per day first, apply a seven-row frame, and always divide by the constant seven because the metric is average daily revenue over a full week."
550游戏玩法分析 IV Game Play Analysis IV中等
考点:首日留存:MIN OVER + LEAD · 题面 ↗

参考解法(MySQL 8)

SELECT ROUND(SUM(IF(nd = d + INTERVAL 1 DAY, 1, 0)) / COUNT(*), 2) AS fraction
FROM (
  SELECT event_date AS d,
         MIN(event_date)  OVER (PARTITION BY player_id) AS ins,
         LEAD(event_date) OVER (PARTITION BY player_id ORDER BY event_date) AS nd
  FROM Activity
) t
WHERE d = ins;

思路

每个玩家只看安装日那一行(d = ins),用 LEAD 取他的下一次活动日期,判断是否正好是次日;分母 COUNT(*) 是全部玩家,包括只登录过一次的。

易错点 nd 为 NULL 时(只登录一次的玩家)比较结果恒为假,走 IF 的 0,正确;但如果你写成 SUM(nd = d + INTERVAL 1 DAY) 而漏了 IF,某些方言会因 NULL 传染让整列变 NULL。留存率的分母永远是全体新用户,不是「有次日行为的人」。
面试可以说 "I take each player's install date and their next activity date with LEAD, then divide returning players by all players and round to two decimals."
1174即时食物配送 II Immediate Food Delivery II中等
考点:ROW_NUMBER 锁首单 + 布尔均值 · 题面 ↗

参考解法(MySQL 8)

SELECT ROUND(AVG(order_date = customer_pref_delivery_date) * 100, 2) AS immediate_percentage
FROM (
  SELECT order_date, customer_pref_delivery_date,
         ROW_NUMBER() OVER (PARTITION BY customer_id
                            ORDER BY order_date, delivery_id) AS rn
  FROM Delivery
) t
WHERE rn = 1;

思路

先用 ROW_NUMBER 给每位客户的订单排序(并列时用 delivery_id 兜底保证确定性),rn = 1 就是首单;外层对布尔求平均即占比。

易错点 不加 rn = 1 直接对全表算,会把客户之后的订单混进来(官方示例从 50.00 变 42.86)。注意口径:分母是客户数,不是订单数。
面试可以说 "I isolate each customer's first order with ROW_NUMBER, then average a boolean, scale by one hundred and round to two decimals."

附录 A · MySQL 8 语法速查

A1. SELECT 的真实执行顺序(背下来,能解释 80% 的报错)

书写顺序不等于执行顺序。MySQL 按下面的顺序处理,这解释了为什么 WHERE 里不能用 SELECT 的别名、GROUP BY 之后才能用 HAVING、以及 ORDER BY 里为什么可以用别名。

-- 执行顺序(不是书写顺序)
FROM / JOIN / ON     -- 1. 先确定来源表并连接
WHERE                -- 2. 在连接结果上逐行过滤(此时还没有聚合)
GROUP BY             -- 3. 分组
HAVING               -- 4. 对分组结果过滤
-- 5. SELECT 计算列、别名在这里才生效
DISTINCT             -- 6. 去重
ORDER BY             -- 7. 排序(可以用 SELECT 里的别名)
LIMIT / OFFSET       -- 8. 最后才截断

A2. 连接写法

SELECT a.id, b.name
FROM   a
INNER  JOIN b ON b.id = a.bid      -- 只保留两边都有的
LEFT   JOIN b ON b.id = a.bid      -- 左表全保留,右表缺失补 NULL
RIGHT  JOIN b ON b.id = a.bid      -- 与 LEFT 镜像(少用,改个顺序更清楚)
CROSS  JOIN b                      -- 笛卡尔积,用来造日期序列 / 全组合
LEFT   JOIN b ON ... WHERE b.id IS NULL   -- 反连接:只要 a 中没有匹配 b 的行

A3. 条件与空值

CASE WHEN score >= 90 THEN 'A'
     WHEN score >= 60 THEN 'B'
     ELSE 'C' END AS grade          -- 没有 ELSE 时不匹配会返回 NULL

IF(x > 0, 'yes', 'no')              -- MySQL 专有;PostgreSQL 用 CASE
IFNULL(a, 0)                        -- MySQL 专有;通用写法 COALESCE(a, 0)
NULLIF(a, 0)                        -- a 等于 0 时返回 NULL,专门用来防除零
COALESCE(a, b, c)                   -- 返回第一个非 NULL
WHERE col IS NULL                   -- 永远不要用 = NULL
WHERE col NOT IN (subquery)         -- 子查询结果含 NULL 时整个条件恒为假!
WHERE NOT (col IN (subquery))       -- 更安全:配合 IS NULL 判断,或改用 NOT EXISTS
ROUND(x, 2)                         -- 保留两位
CAST(x AS DECIMAL(10,2))            -- 或 x * 1.0,避免整数除法被截断

A4. 聚合与分组

SELECT user_id,
       COUNT(*)             AS rows_all,      -- 数所有行,含 NULL
       COUNT(reporter_id)   AS rows_nonnull,  -- 只数非 NULL 的
       COUNT(DISTINCT id)   AS uniq
FROM   tasks
GROUP  BY user_id
HAVING COUNT(*) > 1        -- HAVING 只能用聚合函数或分组列
ORDER  BY rows_all DESC, user_id ASC
LIMIT  5 OFFSET 10;
-- MySQL 允许 GROUP BY / ORDER BY user_id, 2 用序号
-- ONLY_FULL_GROUP_BY 模式下,SELECT 里的非聚合列必须出现在 GROUP BY 中

A5. 窗口函数骨架(面试必考)

SELECT name, dept, salary,
       ROW_NUMBER()  OVER w AS rn,   -- 1,2,3,4  绝不并列
       RANK()        OVER w AS rk,   -- 1,2,2,4  并列后跳号
       DENSE_RANK()  OVER w AS drk,  -- 1,2,2,3  并列不跳号
       PERCENT_RANK()OVER w AS pr,
       LAG(salary, 1, 0)  OVER (PARTITION BY dept ORDER BY pay_date)  AS prev_day,
       LEAD(salary)       OVER (PARTITION BY dept ORDER BY pay_date)  AS next_day,
       SUM(salary)   OVER (PARTITION BY dept)                          AS dept_total,
       AVG(salary)   OVER (PARTITION BY dept
                           ORDER BY pay_date
                           ROWS BETWEEN 2 PRECEDING AND CURRENT ROW)   AS moving_3
FROM   emp
WINDOW w AS (PARTITION BY dept ORDER BY salary DESC);
-- WINDOW 子句(MySQL 8.0+)可给窗口命名,避免重复写 OVER
-- 取"每组前 N 名":窗口函数不能直接写在 WHERE 里,必须包一层子查询或 CTE

A6. CTE 与派生表

WITH base AS (
  SELECT user_id, spend, DATE_FORMAT(created_at, '%Y-%m') AS ym FROM orders
), agg AS (
  SELECT ym, AVG(spend) AS avg_spend FROM base GROUP BY ym
)
SELECT b.user_id, b.spend, a.avg_spend
FROM   base b JOIN agg a USING (ym);

-- 派生表必须有别名:FROM (SELECT ...) t
-- 递归 CTE 造日期序列(MySQL 8.0+):
WITH RECURSIVE d AS (
  SELECT DATE('2024-01-01') AS dt
  UNION ALL
  SELECT dt + INTERVAL 1 DAY FROM d WHERE dt < DATE('2024-01-31')
)
SELECT dt FROM d;

A7. 日期函数

DATEDIFF(d2, d1)                     -- d2 - d1 的天数(整数);PostgreSQL 用 d2 - d1
DATE_ADD(d, INTERVAL 7 DAY)          -- 加 7 天;等价写法 d + INTERVAL 7 DAY
DATE_SUB(d, INTERVAL 1 MONTH)
SUBDATE(d, INTERVAL 1 DAY)
DATE_FORMAT(d, '%Y-%m-%d')           -- %Y 4位年 %m 月 %d 日 %H 时 %i 分 %s 秒
DATE(d) / CAST(d AS DATE)            -- 截掉时间部分
YEAR(d), MONTH(d), DAYOFWEEK(d)      -- DAYOFWEEK: 1=周日
LAST_DAY(d), DAYOFYEAR(d)
d1 = d2                              -- 日期列带时间时等值会失败,先 DATE() 再比
-- 连续 N 天 / 滑动 N 天平均的经典技巧:
--   ROW_NUMBER() OVER (ORDER BY date) 与 date 相减得到同一个连续段的分组键

A8. 字符串、并集与行列转换

CONCAT(a, '-', b) / CONCAT_WS(', ', a, b)
GROUP_CONCAT(name ORDER BY name SEPARATOR ', ')   -- 多行合成一格(PG: string_agg)
LENGTH / CHAR_LENGTH, LOWER, UPPER, TRIM, REPLACE, SUBSTRING(s, 1, 3), LOCATE('@', s)
col REGEXP '^[0-9]'            -- PG 用 ~
col LIKE 'Sci%'                -- 也能匹配 'sci...':MySQL 默认排序规则大小写不敏感,要敏感就 LIKE BINARY 或 BINARY col

SELECT ... UNION SELECT ...    -- 去重 + 排序,慢
SELECT ... UNION ALL SELECT ...-- 不去重,快;确定无重复时用这个
-- 宽转长:每个属性一段 SELECT 再 UNION ALL;长转宽:CASE WHEN + MAX 分组透视
SELECT report_date,
       MAX(CASE WHEN type = 'finance'    THEN paid END) AS finance,
       MAX(CASE WHEN type = 'personal'   THEN paid END) AS personal,
       MAX(CASE WHEN type = 'community'  THEN paid END) AS community
FROM   department GROUP BY report_date;   -- 未出现的月份/类型补 NULL,需要 RIGHT JOIN 日历

A9. 与 PostgreSQL / SQL Server 的差异(外企技术栈常混用)

需求MySQL 8PostgreSQLSQL Server
空值兜底IFNULL(a,0)COALESCE(a,0)(通用,优先记这个)
二选一条件IF(c,x,y)CASE WHEN c THEN x ELSE y END
字符串拼接CONCAT|| 或 CONCAT+ 或 CONCAT
日期减法DATEDIFF(a,b)a - bDATEDIFF(DAY,b,a)
取前 N 行LIMIT nLIMIT nTOP n / OFFSET…FETCH
多行合并GROUP_CONCATstring_aggSTRING_AGG
正则匹配REGEXP~需要 PATINDEX
递归 CTEWITH RECURSIVEWITH RECURSIVEWITH(自动递归,加 ;OPTION)
大小写敏感默认不敏感默认敏感看排序规则

附录 B · 12 个反复踩的坑

  1. = NULL 永远不成立。判空只能 IS NULL / IS NOT NULL。聚合结果为空时也是 NULL,不是 0。
  2. NOT IN 子查询里只要有一个 NULL,结果就为空集。要么加 WHERE col IS NOT NULL,要么改 NOT EXISTS / LEFT JOIN … IS NULL。
  3. LEFT JOIN 之后把右表条件写在 WHERE 里,会退化成 INNER JOIN。右表条件要放 ON。
  4. 统计"每个用户的订单数(含 0 单)"时,COUNT(*) 会把 NULL 行算成 1。用 COUNT(o.id) 才对。
  5. 整数除法会截断。3/2 = 1(MySQL 里 COUNT(a)/COUNT(b) 得到整数)。乘 1.0 或 CAST(… AS DECIMAL) 再 ROUND(…,2)。
  6. WHERE 里不能用 SELECT 定义的别名(执行顺序问题)。GROUP BY/HAVING/ORDER BY 里 MySQL 容忍,PG 里 WHERE 不行 —— 通用做法是包一层 CTE。
  7. ONLY_FULL_GROUP_BY 报错(error 1140/1055):非聚合列必须进 GROUP BY,或者用窗口函数改写。别关 sql_mode,改写。
  8. 窗口函数不能直接写在 WHERE / HAVING 里,必须 WITH … SELECT * FROM base WHERE rn <= 3。
  9. RANK/DENSE_RANK 的区别只在并列后:1,2,2,4 还是 1,2,2,3。"前三名"若要求并列都算,用 DENSE_RANK。
  10. MySQL 的字符串比较默认大小写不敏感。LIKE '%F%' 会匹到小写 f。需要区分时加 BINARY 或换排序规则。
  11. 日期列带时间部分时 WHERE d = '2024-01-01' 会漏数据。先 DATE(d),或用区间 d >= '2024-01-01' AND d < '2024-01-02'(后者能用索引)。
  12. LIMIT 不带 ORDER BY 时结果不确定,"取最值之一"这类题会随机失败;先排序再截断。

附录 C · 外企面试英文口述模板

外企 SQL 面试给分点是结构化表达,不是语法完美。下面每段都能直接背。

C1. 拿到题目的前 30 秒(clarify)

"Before I write anything, let me confirm a few assumptions: are there duplicate rows in this table, can this column be NULL, and do we want to include customers with zero orders? Those three answers change the query quite a bit."

面试官听到这句话基本就给分了 —— 它说明你知道 SQL 的坑在哪。

C2. 万能四步框架

"I'd approach it in four steps."
1. "Start from the table that has the grain I need — one row per X."
2. "Join whatever is missing, and decide LEFT vs INNER depending on whether we keep unmatched rows."
3. "Filter early in WHERE, and only push aggregate conditions into HAVING."
4. "Group or window, then order and shape the output."

C3. 按题型的关键句

题型口述
JOIN 类"I use a LEFT JOIN here so customers without orders still show up, and the condition on the right table goes in the ON clause — otherwise it silently becomes an inner join."
找第 N 高"The safest way is DENSE_RANK over salary descending, then filter rank = N in an outer query. It handles ties and returns NULL instead of a wrong value when fewer than N distinct salaries exist."
每组前 N"Group-wise top N can't be done with LIMIT, so I partition by department, rank inside each partition, and filter in a CTE."
连续 N 天"The classic trick is subtracting a row number from the date — consecutive dates collapse to the same value, which becomes my grouping key."
滑动平均"I'd use SUM OVER with a ROWS BETWEEN frame, order by date, partitioned by the entity, then divide by the fixed window size and round."
留存 / retention"I install cohort week with MIN(installed_at) per user, DATEDIFF to bucket the activity day, then COUNT(DISTINCT) to avoid double-counting."
行列转换"This is a pivot: one row per date, and MAX(CASE WHEN type = … THEN amount END) per type. It gives NULL for missing types, which I can coalesce to zero."
被问性能"Function calls on an indexed column, like DATE(created_at), break the index. I'd rewrite it as a half-open range so the optimizer can seek."

C4. 写完之后的收尾话术

"This should be correct for the sample data. Two things I'd double-check against the real schema: whether the join key is unique — if it isn't, rows will fan out and inflate the sums — and whether NULLs are meaningful here."

C5. 自我介绍一下你的 SQL 水平(60 秒)

"I work with SQL daily rather than just recalling syntax. I'm comfortable modelling multi-table queries, aggregations, and I reach for window functions whenever I need ranking, moving averages, or cohort analysis. I also check my queries for join fan-out and index usage before shipping them."

C6. 不会写时的正确说法

"I don't recall the exact syntax for that function, but I know I need a running total ordered by date — I'd write it as SUM OVER with an ordered frame and look up the frame keyword if I'm off."

比硬猜语法好得多:先说清意图,语法可以查。

附录 D · 打卡总表

下面按本手册的章节顺序列出全部 50 题,题号可直接在 LeetCode 搜索;「卡住的地方」建议只写一句话,二刷时先看这句话再动手。

#题名难度章节AC二刷卡住的地方
1757可回收且低脂的产品简单第 1 章☐☐
595大的国家简单第 1 章☐☐
584寻找用户推荐人简单第 1 章☐☐
1148文章浏览 I简单第 1 章☐☐
1683无效的推文简单第 1 章☐☐
620有趣的电影简单第 1 章☐☐
1667修复表中的名字简单第 1 章☐☐
1527患某种疾病的患者简单第 1 章☐☐
1517查找拥有有效邮箱的用户简单第 1 章☐☐
610判断三角形简单第 1 章☐☐
1907按分类统计薪水中等第 1 章☐☐
1789员工的直属部门简单第 1 章☐☐
1378使用唯一标识码替换员工ID简单第 2 章☐☐
1068产品销售分析 I简单第 2 章☐☐
577员工奖金简单第 2 章☐☐
1581进店却未进行过交易的顾客简单第 2 章☐☐
1661每台机器的进程平均运行时间简单第 2 章☐☐
197上升的温度简单第 2 章☐☐
1280学生们参加各科测试的次数简单第 2 章☐☐
570至少有5名直接下属的经理中等第 2 章☐☐
1934确认率中等第 2 章☐☐
1731每位经理的下属员工数量简单第 2 章☐☐
1978上级经理已离职的公司员工简单第 2 章☐☐
1251平均售价简单第 3 章☐☐
1075项目员工 I简单第 3 章☐☐
1633各赛事的用户注册率简单第 3 章☐☐
1211查询结果的质量和占比简单第 3 章☐☐
1193每月交易 I中等第 3 章☐☐
2356每位教师所教授的科目种类的数量简单第 3 章☐☐
1141查询近 30 天活跃用户数简单第 3 章☐☐
596超过 5 名学生的课简单第 3 章☐☐
1729求关注者的数量简单第 3 章☐☐
619只出现一次的最大数字简单第 3 章☐☐
1484按日期分组销售产品简单第 3 章☐☐
1327列出指定时间段内所有的下单产品简单第 3 章☐☐
176第二高的薪水中等第 4 章☐☐
1070产品销售分析 III中等第 4 章☐☐
1045买下所有产品的客户中等第 4 章☐☐
626换座位中等第 4 章☐☐
180连续出现的数字中等第 4 章☐☐
1341电影评分中等第 4 章☐☐
602好友申请 II:谁有最多的好友中等第 4 章☐☐
5852016年的投资中等第 4 章☐☐
196删除重复的电子邮箱简单第 4 章☐☐
185部门工资前三高的所有员工困难第 5 章☐☐
1164指定日期的产品价格中等第 5 章☐☐
1204最后一个能进入巴士的人中等第 5 章☐☐
1321餐馆营业额变化增长中等第 5 章☐☐
550游戏玩法分析 IV中等第 5 章☐☐
1174即时食物配送 II中等第 5 章☐☐

建议二刷间隔 7 天。SQL 题的遗忘速度比算法题快,因为记住的是"套路"而不是"逻辑"。

本手册为个人练习整理。题目版权归 LeetCode(力扣)所有;官方题单入口:leetcode.cn/studyplan/sql50。