按知识点重排的 LeetCode 官方数据库 50 题。每题给出思路拆解、可直接提交的 MySQL 解法、易错点,以及外企面试里可以直接说出口的英文表达。
ROW_NUMBER / RANK / SUM OVER,简单题刷得再快也不如这一章吃透。题号、题名、难度与表结构逐题取自 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 章 过滤与条件判断 | 12 | WHERE / BETWEEN / LIKE / REGEXP / CASE WHEN / IF,把「选行」写熟,重点是 NULL 三值逻辑 |
| 第 2 周 | 第 2 章 多表连接 | 11 | 四种 JOIN、三种自连接、反连接 LEFT JOIN … IS NULL;理解 join fan-out(连接后行数膨胀) |
| 第 3 周 | 第 3 章 分组、聚合与拼接 | 12 | GROUP BY + HAVING、条件聚合、加权平均、GROUP_CONCAT;易错:COUNT(*) 与 COUNT(列) 不等价 |
| 第 4 周 | 第 4 章 子查询、集合运算与改数据 | 9 | 元组 IN、NOT EXISTS、UNION ALL 拉平两列,以及两道 DML(UPDATE / DELETE 自连接、ERROR 1093) |
| 第 5 周 (或前四周并行) | 第 5 章 窗口函数 | 6 | RANK / DENSE_RANK / ROW_NUMBER / LAG / LEAD / SUM OVER,含唯一的困难题 185;ROWS 与 RANGE 帧的区别是外企面试的送命题 |
合计 50 题。难度分布:简单 32 · 中等 17 · 困难 1(185 部门工资前三高)。窗口函数一章可以提前并行做,因为它反过来能简化第 3、4 章的多道题。
NULL 的三值逻辑、LENGTH 与 CHAR_LENGTH 的区别、以及正则有没有首尾锚定上。12 题里有 4 题(584 / 1527 / 1517 / 1789)的错误答案都来自想当然地以为「不等于」会包含空值。做完这一章你应该能条件反射地说出:比较 NULL 只能用 IS。本章题号:1757 · 595 · 584 · 1148 · 1683 · 620 · 1667 · 1527 · 1517 · 610 · 1907 · 1789
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 会被误选进来。SELECT name, population, area FROM World WHERE area >= 3000000 OR population >= 25000000;
面积或人口任一达标即为大国,用 OR;输出列顺序按题面是 name, population, area。
>= 不是 >,3000000 / 25000000 是闭区间边界,写错会漏掉刚好压线的国家。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 数据库题第一名坑。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 直接判错。SELECT tweet_id FROM Tweets WHERE CHAR_LENGTH(content) > 15;
「无效」定义为内容字符数严格大于 15,用长度函数比较即可。
LENGTH 返回的是字节数,遇到多字节字符(中文、emoji)会虚高;统计长度一律用 CHAR_LENGTH。另外是 > 不是 >=。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 会报找不到表。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)。SELECT patient_id, patient_name, conditions FROM Patients WHERE conditions LIKE 'DIAB1%' OR conditions LIKE '% DIAB1%';
DIAB1 只有在「字符串开头」或「空格之后」出现才是有效的疾病代码前缀,所以拆成两个 LIKE。
conditions LIKE '%DIAB1%' 会误伤 SADIAB1、XDIAB1 这种非前缀代码 —— 官方示例里专门放了这种反例。SELECT * FROM Users WHERE mail REGEXP '^[A-Za-z][A-Za-z0-9._-]*@leetcode\\.com$';
前缀必须字母开头,后随字母、数字、_ . -;域名固定 @leetcode.com;首尾都要锚定。
$ 会把 @leetcode.com.cn 判为合法;漏 \\. 转义会把 @leetcodeXcom 判为合法。正则里 . 是任意字符。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。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 分类,零记录的类别会整行消失,输出只剩两行直接判错。这是「按类别补零」的经典模式,面试常考。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(去重),否则同时满足两个条件的员工会出现两次。LEFT JOIN;要求「必须有」,就 INNER JOIN。本章 11 题覆盖四种 JOIN、三种自连接(上下级、相邻日期、首尾配对),以及外企面试一定会追问的join fan-out(连接后行数膨胀)。本章题号:1378 · 1068 · 577 · 1581 · 1661 · 197 · 1280 · 570 · 1934 · 1731 · 1978
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 这样的行。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(数量);两者在示例里数值很像,容易抄错。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 场景里再考一次。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 过滤连接后的行。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,算出负数时长。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 相邻不代表日期相邻,官方示例就是专门设计的反例。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 造骨架 + 聚合数非空 两个知识点的交汇,务必吃透。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。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。比率类题目分母一定要是自己那张表的全量。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 就错了。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 会让整个条件恒假。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
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 退化成内连接。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 提供分组维度,连接员工表取工龄,按项目求平均。
AVG 返回 DECIMAL 不会截断,但同一份 SQL 搬到 PostgreSQL 就会踩整数除法的坑(要 ::numeric),面试跨方言时要点出来。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 升序,少写第二键就判错。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)。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))。SELECT teacher_id, COUNT(DISTINCT subject_id) AS cnt FROM Teacher GROUP BY teacher_id;
按教师统计去重后的科目数。
(subject_id, dept_id),同一科目开在两个系就有两行;用 COUNT(*) 会得 3,正确答案是 2。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。
06-28(等价于 >= DATE_SUB('2019-07-27', INTERVAL 29 DAY))。用 COUNT(*) 会把同一用户当天多次活动重复计数。SELECT class FROM Courses GROUP BY class HAVING COUNT(DISTINCT student) >= 5;
按课名分组,用 HAVING 卡人数门槛。student 列允许重复选课记录,所以保险写法是 COUNT(DISTINCT student)。
>= 5 写成 > 5;以及把过滤条件放进 WHERE —— WHERE 在分组之前执行,那里根本还没有人数可用。SELECT user_id, COUNT(follower_id) AS followers_count FROM Followers GROUP BY user_id ORDER BY user_id;
按「被关注者」分组数粉丝。user_id 是被关注的人,follower_id 是粉丝。
ORDER BY 就判错 —— LeetCode 顺序不敏感时不写没事,写了也不扣分,养成统一加 ORDER BY 的习惯最安全。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。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。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 日。用「≥ 月初 且 < 下月初」的左闭右开写法,既正确又能用上索引。IN 或窗口;② 需要「全都有 / 都没有」→ HAVING COUNT(DISTINCT …) = 总数 或 NOT EXISTS;③ 需要「把两列拉平成一列」→ UNION ALL(602 是范本)。另外两题(626 / 196)是 UPDATE 与 DELETE,考点是同表自连接和 ERROR 1093。本章题号:176 · 1070 · 1045 · 626 · 180 · 1341 · 602 · 585 · 196
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。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。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。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 号)。
FROM Seat 会触发 ERROR 1093(不能在子查询里读同一张被修改的表),必须派生表或 CTE 包一层取快照;LEFT JOIN Seat t 这种自连接写法可以绕开。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。现代写法见下方窗口版。(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(闰年)。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 的人。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 是类型保留字)。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 每轮评测都重建表,删除操作不影响下次提交。ROW_NUMBER / RANK / DENSE_RANK 在并列时的行为差别;② 默认帧是 RANGE UNBOUNDED PRECEDING → CURRENT ROW,排序键有并列时它会把整组 peer 一起算进去,这是 ROWS 与 RANGE 唯一但致命的分歧;③ 窗口函数不能写在 WHERE 里,必须包一层子查询或 CTE 再筛。本章题号:185 · 1164 · 1204 · 1321 · 550 · 1174
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。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) 冒充「最新价」—— 降价场景直接取反。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 有什么区别」就答这个。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(或先补日期日历表)。这一题的样例日期刚好连续,所以两种写法都对,改成真实数据就会不一样。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。留存率的分母永远是全体新用户,不是「有次日行为的人」。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)。注意口径:分母是客户数,不是订单数。书写顺序不等于执行顺序。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. 最后才截断
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 的行
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,避免整数除法被截断
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 中
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
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;
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 相减得到同一个连续段的分组键
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 日历
| 需求 | MySQL 8 | PostgreSQL | SQL 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 - b | DATEDIFF(DAY,b,a) |
| 取前 N 行 | LIMIT n | LIMIT n | TOP n / OFFSET…FETCH |
| 多行合并 | GROUP_CONCAT | string_agg | STRING_AGG |
| 正则匹配 | REGEXP | ~ | 需要 PATINDEX |
| 递归 CTE | WITH RECURSIVE | WITH RECURSIVE | WITH(自动递归,加 ;OPTION) |
| 大小写敏感 | 默认不敏感 | 默认敏感 | 看排序规则 |
= NULL 永远不成立。判空只能 IS NULL / IS NOT NULL。聚合结果为空时也是 NULL,不是 0。NOT IN 子查询里只要有一个 NULL,结果就为空集。要么加 WHERE col IS NOT NULL,要么改 NOT EXISTS / LEFT JOIN … IS NULL。LEFT JOIN 之后把右表条件写在 WHERE 里,会退化成 INNER JOIN。右表条件要放 ON。COUNT(*) 会把 NULL 行算成 1。用 COUNT(o.id) 才对。3/2 = 1(MySQL 里 COUNT(a)/COUNT(b) 得到整数)。乘 1.0 或 CAST(… AS DECIMAL) 再 ROUND(…,2)。WHERE 里不能用 SELECT 定义的别名(执行顺序问题)。GROUP BY/HAVING/ORDER BY 里 MySQL 容忍,PG 里 WHERE 不行 —— 通用做法是包一层 CTE。ONLY_FULL_GROUP_BY 报错(error 1140/1055):非聚合列必须进 GROUP BY,或者用窗口函数改写。别关 sql_mode,改写。WHERE / HAVING 里,必须 WITH … SELECT * FROM base WHERE rn <= 3。RANK/DENSE_RANK 的区别只在并列后:1,2,2,4 还是 1,2,2,3。"前三名"若要求并列都算,用 DENSE_RANK。LIKE '%F%' 会匹到小写 f。需要区分时加 BINARY 或换排序规则。WHERE d = '2024-01-01' 会漏数据。先 DATE(d),或用区间 d >= '2024-01-01' AND d < '2024-01-02'(后者能用索引)。LIMIT 不带 ORDER BY 时结果不确定,"取最值之一"这类题会随机失败;先排序再截断。外企 SQL 面试给分点是结构化表达,不是语法完美。下面每段都能直接背。
面试官听到这句话基本就给分了 —— 它说明你知道 SQL 的坑在哪。
| 题型 | 口述 |
|---|---|
| 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." |
比硬猜语法好得多:先说清意图,语法可以查。
下面按本手册的章节顺序列出全部 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 章 | ☐ | ☐ | |
| 585 | 2016年的投资 | 中等 | 第 4 章 | ☐ | ☐ | |
| 196 | 删除重复的电子邮箱 | 简单 | 第 4 章 | ☐ | ☐ | |
| 185 | 部门工资前三高的所有员工 | 困难 | 第 5 章 | ☐ | ☐ | |
| 1164 | 指定日期的产品价格 | 中等 | 第 5 章 | ☐ | ☐ | |
| 1204 | 最后一个能进入巴士的人 | 中等 | 第 5 章 | ☐ | ☐ | |
| 1321 | 餐馆营业额变化增长 | 中等 | 第 5 章 | ☐ | ☐ | |
| 550 | 游戏玩法分析 IV | 中等 | 第 5 章 | ☐ | ☐ | |
| 1174 | 即时食物配送 II | 中等 | 第 5 章 | ☐ | ☐ |
建议二刷间隔 7 天。SQL 题的遗忘速度比算法题快,因为记住的是"套路"而不是"逻辑"。