1. 这50题刷到第四阶段,题型开始出现明显分层
先交代下背景。这个阶段总结系列写到第四期,说明50道题已经推进到后半程了。和前三期比起来,最直观的感受是:题目开始从“这个函数你学过没有”转向“你知不知道该在什么时候用它”。到这一阶段,每道题涉及的考点可能不超过三个,但每个考点都要真正吃透才能顺下去,否则很容易出现一种情况——看题觉得眼熟,写出来跑不过,再看别人的解法觉得自己白刷了。
从题型分布上看,这一阶段的高频题有几个明显的集中点:排名与TopN问题、连续性问题、分组聚合后的过滤、日期区间的处理、以及各种“去重”的变体。这些知识点单拎出来都能写一篇长文,但在力扣的SQL题里,它们往往是组合出现的。比如一道题考的是“每个部门工资前三名”,实际拆开就是窗口函数加分组加排序三个考点叠在一起。
我刷到这里的一个体会是:前期的题偏重单点语法,比如JOIN怎么写、WHERE怎么过滤;到了后期,题目会更像一个小型业务需求,你需要自己判断先做什么后做什么,而不是照着题目里的提示机械翻译成SQL。这个转折点大概出现在第30题之后,越往后越明显。
另外值得注意的一点是,到了第四阶段,题目对执行效率的要求明显上升。有些写法在数据量小的时候看不出问题,但一旦表里数据量上到一定量级,慢查询、超时、内存溢出这些问题就全冒出来了。力扣的SQL题虽然不像真实业务那样有海量数据,但它会通过隐藏的边界用例来逼你去优化写法,这也是刷题的价值所在。
所以这一篇总结,我不会按题号顺序写流水账,而是把这一阶段最值得讲的知识点、踩过的坑、以及一道有代表性的题目完整复盘出来,给大家一个可以照着复盘的思路。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 窗口函数在这轮刷题里出现的频率极高
2.1 排名类问题:ROW_NUMBER、RANK、DENSE_RANK 的差异
排名类问题是SQL题里的常青树,这一阶段几乎每两三题就会遇到一次。三个排名函数的区别,我在这里再精确地说一遍,因为每轮刷题都会有人在这上面丢分。
ROW_NUMBER():给每一行一个唯一的序号,相同值的行顺序随机,但序号不重复。
RANK():相同值的行获得相同排名,但下一个排名会跳过。比如两个并列第一,下一个就是第三。
DENSE_RANK():相同值的行获得相同排名,下一个排名不跳过。两个并列第一,下一个还是第二。
看起来很简单,但实际做题的时候坑藏在细节里。比如“每个部门工资最高的员工”这类题,用ROW_NUMBER就够了,因为只需要取一条;但如果是“每个班级成绩排名前两名的学生,且成绩相同要并列”,那就必须用DENSE_RANK或者RANK,用ROW_NUMBER会把并列的人丢掉。
还有一个容易忽略的点:这三个函数默认的排序方向是ASC,但排名问题几乎都是取最大值或者最小值,所以ORDER BY后面一定要记得写DESC。这属于看题不仔细的低级错误,但我在实际刷题中真的见过很多次,也包括我自己早期写漏过。
2.2 累计求和与移动窗口:ROWS BETWEEN 的边界设定
这一阶段出现了好几道和“累计”相关的题。比如求每个月的累计销售额、每个用户的累计下单金额,这类题需要用SUM加OVER配合ROWS BETWEEN来限定窗口范围。
我最早刷这类题的时候,总觉得ROWS BETWEEN是个可有可无的修饰,直到遇到一道要求“统计截至当前日期前90天的累计值”的题,才发现不限定窗口边界,函数默认会把整个分区都算进去,结果完全不对。
ROWS BETWEEN 的常见写法有三种:
- ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW:从分区第一行到当前行,这是最常见的累计写法
- ROWS BETWEEN N PRECEDING AND CURRENT ROW:从当前行往前N行到当前行,适合滑动窗口
- ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING:从当前行到分区最后一行,适合“未来累计”的场景
注意一点,如果不写ROWS BETWEEN,窗口函数默认的范围是“从分区第一行到当前行”,对于SUM来说效果等同于第一种写法,但对AVG、COUNT这些函数来说,默认行为和你想的可能不一样,尤其是涉及ORDER BY的时候。
2.3 LAG/LEAD 在连续性问题里的应用
连续性问题是SQL题的一个大类,典型场景是“找出连续登录N天的用户”“找出连续上涨的日期”。这类题的常规解法是自连接,但写法复杂且容易出错。这一阶段我遇到的好几道题,用LAG或LEAD会简洁很多。
LAG取前一行,LEAD取后一行,配合分区和排序使用。我举一个实际场景:要判断某一行的值是否比前一天大。
sql复制SELECT
date,
value,
LAG(value) OVER (ORDER BY date) AS prev_value
FROM daily_data
然后在外层查询里比较value和prev_value的大小关系,就能很方便地筛选出所有“比前一天大”的日期。相比自连接,这种写法逻辑更清晰,而且性能通常也更好。
窗口函数这一块如果还没熟练掌握,建议多花点时间专项练习,它是SQL进阶路上绕不开的一关,也是面试题里最常出现的考点区间。
3. 连表查询的瓶颈不在语法,而在业务建模
3.1 JOIN 与 LEFT JOIN 的取舍逻辑
这一阶段的题目里,JOIN相关的题占比不小,但真正卡住人的不是JOIN怎么写,而是“不知道该用哪种JOIN”。这本质上是业务建模的问题——你需要先想清楚:我想要的结果集里,哪些行是必须出现的?
举一个高频场景:查每个用户及其订单信息。如果要求所有用户都出现,即使没有订单也要出现,那必须用LEFT JOIN;如果只要求有订单的用户,用INNER JOIN就行。看起来是常识,但我看过不少人在这一步选错,导致结果集行数不对。
还有一个常见的误用:把LEFT JOIN当成万能写法,无论什么场景都用它。这在大表场景下性能影响很大,因为LEFT JOIN可能会让结果集膨胀,尤其是当主表的一条记录在关联表里有多个匹配时,会产生重复行。真遇到这种需求,先想一想是不是应该先去重,再关联。
3.2 多表关联时的过滤条件位置:ON 与 WHERE 的区别
这个知识点说难不难,但它直接影响最终结果,而且很多人会在这里翻车。区别在于:ON中的过滤条件在JOIN时生效,WHERE中的过滤条件在JOIN完成后生效。
举个例子:
sql复制SELECT *
FROM users u
LEFT JOIN orders o ON u.id = o.user_id AND o.status = 'paid'
这个写法会保留所有用户,只把已支付的订单匹配进来。但如果把o.status = 'paid'放到WHERE里:
sql复制SELECT *
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.status = 'paid'
结果就完全不一样了——未支付订单的用户会被过滤掉,LEFT JOIN退化成INNER JOIN的效果。
这个坑在力扣题里经常出现,特别是题目描述里带“即使没有XX也要显示”这种条件时,一定要把过滤条件放在ON后面。
3.3 三种JOIN的语义辨析
除了JOIN和LEFT JOIN,这一阶段还遇到了需要区分“只出现在A表”“只出现在B表”“出现在A或B但不在交集”的场景。这三种需求的SQL写法分别是:
- 只出现在A表:LEFT JOIN + 右表主键IS NULL
- 只出现在B表:RIGHT JOIN + 左表主键IS NULL(或者把表反过来用LEFT JOIN)
- A和B的并集减去交集:FULL OUTER JOIN + 一边主键IS NULL
力扣上有一道很经典的题——找出没有订单的客户,解法就是LEFT JOIN后加IS NULL条件。这类题看起来简单,但它是很多复杂业务报表的基础,值得多花点时间理解透。
4. BETWEEN AND 的边界问题:这个高频语法没那么简单
4.1 BETWEEN AND 是闭区间还是开区间
很多新手刚接触BETWEEN AND时会默认它是开区间(不含边界),但实际上是闭区间,包含边界值。也就是说:
sql复制WHERE date BETWEEN '2024-01-01' AND '2024-01-31'
等价于:
sql复制WHERE date >= '2024-01-01' AND date <= '2024-01-31'
注意是小于等于,不是小于。这个差异在统计月度数据时非常容易出现偏差。如果表里恰好有2024-01-31 23:59:59这个时间点,用BETWEEN AND会把它算进去,但如果逻辑上你只想统计到1月31日零点,那就要用>=和<的组合:
sql复制WHERE date >= '2024-01-01' AND date < '2024-02-01'
4.2 日期边界与时间戳的坑
力扣里的日期题大多用DATE类型,但在真实业务中,时间字段往往是DATETIME或TIMESTAMP,包含时分秒。这时候用BETWEEN AND处理日期边界会特别容易出问题。
我遇到过一个真实案例:统计“2024年1月1日到2024年1月31日”的订单,直接用BETWEEN AND去查,结果把1月31日当天所有秒级的订单都包含进去了。这在某些业务场景下没问题,但在另一些场景下(比如统计每日0点的库存快照),会产生重复计算。
一个稳妥的做法是:日期类型字段用BETWEEN AND没问题;但如果是时间戳字段,尽量用>= '开始日期 00:00:00' AND < '结束日期加一天 00:00:00'这种写法,既能覆盖所有时间点,又不会跨到下一个周期。
5. 去重场景里 DISTINCT 和 GROUP BY 的边界与选择
5.1 从热搜词里看到的高频困惑
写这篇文章之前我顺手看了一下相关热搜词,里面有好几条关于去重的——比如“sql语句去重查询”“sql去除空值”“清洗--sql语句去重”。看得出来,去重是真实业务里的高频问题,也是SQL面试题里很喜欢考的点。
去重最直观的写法是DISTINCT,但实际问题往往不是“对单列去重”这么简单,更多时候是“按某个维度去重后取其他字段”,这时候DISTINCT就不够用了。
5.2 什么时候用 DISTINCT,什么时候用 GROUP BY
很多资料会说两者等价,但实际使用中有一些差别值得注意:
- DISTINCT适合对整行去重,如果只是想知道“这个表里有多少个不同的城市”,
SELECT DISTINCT city FROM table就够了。 - GROUP BY适合在去重的同时做聚合,比如“每个城市的用户数”“每个城市的订单总金额”,这时候必须用GROUP BY + 聚合函数。
- 在涉及“按A列去重但保留B列”的场景下,DISTINCT和GROUP BY都不够直接,通常需要配合窗口函数或者子查询。
还有一个容易忽略的点:COUNT(DISTINCT col)可以直接在一条语句里完成“去重计数”,但COUNT(DISTINCT col1, col2)在有些数据库里不支持,这时候需要先子查询去重再COUNT。
5.3 去重与NULL的相互作用
“sql去除空值”这个热搜词也反映出另一个常见需求。COUNT(DISTINCT col)默认会忽略NULL,也就是说NULL不会被计入去重后的数量。如果业务上需要把NULL计为一个有效值,需要用COALESCE先把NULL替换成一个不会冲突的占位符。
比如:
sql复制SELECT COUNT(DISTINCT COALESCE(city, 'unknown'))
FROM users
这样NULL会被合并成一个值参与计数,而不是被忽略。这个细节在数据仓库里做维度统计时经常用到,面试题里偶尔也会藏一个。
6. WITH AS 公共表表达式:把复杂查询拆成人能读懂的样子
6.1 WITH AS 的语感优势
力扣题里有一部分题目,一上来就是三四层嵌套子查询。如果不做处理,SQL语句会变得又长又难读,调试的时候也特别痛苦。这个阶段我大量使用WITH AS来拆分逻辑,体验非常好。
WITH AS的核心价值在于:把一个复杂的查询拆成多个有名字的临时查询块,每个块只做一件事,最后再组合起来。
举个例子,一个需要“先筛选出活跃用户,再统计活跃用户的订单”的场景,可以这样写:
sql复制WITH active_users AS (
SELECT user_id
FROM users
WHERE last_active_date >= '2024-01-01'
),
user_orders AS (
SELECT o.user_id, COUNT(*) AS order_cnt
FROM orders o
JOIN active_users a ON o.user_id = a.user_id
GROUP BY o.user_id
)
SELECT *
FROM user_orders
WHERE order_cnt > 5
这样写的好处是:每一步的逻辑都清清楚楚,后续调试只需要单独执行某一段,定位问题很快。
6.2 WITH AS 与子查询、临时表的对比
实际使用中,WITH AS和普通子查询在多数场景下执行计划是一样的,所以不必担心性能差异。它更多是代码组织层面的优化。
和临时表相比,WITH AS的生命周期更短,只在当前查询中有效,不占物理存储,因此它更适合在一个复杂查询内部做逻辑拆分,而不是作为反复复用的中间结果。
6.3 递归查询的入门思路
WITH RECURSIVE在力扣题里出现的频率不算高,但偶尔会出现一道树形结构的题目,比如“找出所有下属的员工”。这类题如果没用过递归,第一次遇到会完全没思路。
递归CTE的基本结构是两部分:初始查询(基本结果)+ 递归查询(基于上一轮结果继续扩展),最后用UNION ALL合并。
sql复制WITH RECURSIVE emp_tree AS (
SELECT employee_id, manager_id
FROM employees
WHERE manager_id IS NULL
UNION ALL
SELECT e.employee_id, e.manager_id
FROM employees e
JOIN emp_tree et ON e.manager_id = et.employee_id
)
SELECT * FROM emp_tree
递归CTE的边界条件要特别注意,否则容易陷入无限循环。力扣上的题通常会在题目描述里给出限制条件,仔细读题就不会有大问题。
7. 一道“中等难度”题的完整复盘:从超时到最优解
7.1 题目背景与第一版直觉写法
这一阶段里让我印象最深的是一道“统计连续登录天数”的题。题目要求找出所有连续登录超过N天的用户。注意这里的“连续”是业务意义上的连续,不是索引意义上的连续,不能靠简单的窗口函数直接算出来。
我一开始的思路很直接:先把用户按日期排序,再用LAG判断当前日期和上一个日期是否差1天,如果连续就把标记设为1,否则设为0。这个思路本身没错,但写出来的SQL又长又慢,在一个测试用例上直接超时了。
第一版的核心逻辑大概是这样:
sql复制SELECT
user_id,
login_date,
LAG(login_date) OVER (PARTITION BY user_id ORDER BY login_date) AS prev_date
FROM user_login_log
然后外层用日期差判断分组。这个写法的问题在于:如果用户登录了几百天,每一行的LAG都要计算一次,数据量大时会非常耗时。
7.2 问题定位:暴力逐行判断不可取
调试的时候我发现,问题不在于LAG本身,而在于“逐行判断是否连续”这个思路本身。一旦用户登录日志很长,逐行比较会产生大量的计算。
换一个角度想:如果我们把“连续登录”转换成“每个连续区间的分组编号”,就可以一次性把同一个连续区间内的所有日期归为一组,然后直接统计每个组内的天数。
关键技巧是:用日期减去行号,如果日期是连续的,差值会是同一个值。这个思路在很多“连续N天”类问题里都非常好用。
7.3 最终写法与关键优化点
最终解法是先用窗口函数给每个用户生成行号,再用登录日期减去行号得到分组标识,最后按分组标识统计天数并筛选大于N天的组。
sql复制WITH dated_group AS (
SELECT
user_id,
login_date,
DATE_SUB(login_date, INTERVAL ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_date) DAY) AS grp
FROM user_login_log
)
SELECT
user_id,
grp,
COUNT(*) AS consecutive_days
FROM dated_group
GROUP BY user_id, grp
HAVING COUNT(*) >= N
这个写法的核心优化在于:把“逐行比较”变成了“一次分组”。分组之后,所有连续日期自动归为同一个grp,统计天数就变成了一次简单的COUNT。数据量再大,也只需要两次扫描——一次生成行号,一次分组统计。
写完这个解法之后,我不由得感慨:SQL题很多时候不是语法不会,而是思路被“逐行处理”这个惯性带偏了。窗口函数 + 差值分组的思路,在连续性问题里确实是通用解法。
8. 刷到这一阶段的几个通用排查技巧
8.1 先看执行计划,别靠猜
不管是力扣练习还是日常开发,遇到SQL性能问题,第一步永远是看执行计划,而不是凭经验瞎猜。力扣虽然不直接展示执行计划,但是超时报错本身就说明问题——优先检查有没有不必要的全表扫描、有没有昂贵的排序、有没有笛卡尔积。
真实的业务场景里,分析执行计划更是必备技能。如果你发现某个关联查询特别慢,先把执行计划打出来,重点看两个地方:关联顺序是否合理、是否走了索引。
8.2 化繁为简:先跑子查询,再拼完整SQL
调试复杂SQL时,我的习惯是先跑通最内层的子查询,确认结果正确后再逐层往外包。不要直接写一坨很长的SQL然后期望一次跑通。
比如上一节提到的连续登录题,我先确认了LAG取出来的prev_date对不对,再确认日期差分组的结果对不对,最后才加上HAVING条件。每一步都能验证,最后合起来才不容易迷路。
8.3 小心NULL:大多数“查不到数据”的问题都是NULL造成的
“sql去除空值”这个热搜词确实点出了一个高频痛点。SQL里NULL和任何值比较都不会返回真,包括和NULL自身比较也不会。所以写过滤条件时,一定要考虑NULL的影响。
举个例子,WHERE score != 90这个条件不会把score为NULL的行排除掉,它会把NULL行保留在结果里,因为NULL判断的结果是UNKNOWN,不是FALSE。如果你想要的是“排除所有score不为90的行”,得额外加条件OR score IS NULL才算完整。
力扣题里,很多“查不到数据”的case并不是真的没有数据,而是NULL在中间环节把行弄丢了。遇到结果比预期少的情况,建议优先检查JOIN和过滤条件涉及的字段里有没有NULL。
9. 关于力扣SQL刷题和一些个人体会
9.1 刷题数量和刷题质量怎么平衡
到了第四阶段,我愈发觉得:SQL刷题,数量只是基础,质量才是关键。50道题如果只是过一遍,遇到不会的看一眼题解然后抄一遍,那基本等于白刷。正确的方式是自己先思考,哪怕只写出了半截,也要把自己的思路和题解做对比,找出差异点。
我有几道题刷了两遍以上,第一遍用自己的解法跑通,过几周再看目标场景有没有新的思路,有时候会发现完全不同的写法。这个“回头再看”的过程,对SQL思维的提升比连刷十道新题都大。
9.2 力扣SQL题和面试的关系
从面试角度来说,力扣SQL 50题覆盖的面已经非常广了。窗口函数、连表、去重、聚合、日期处理这些高频考点全都在里面。把这些题吃透,应付大多数公司的SQL笔试和面试基本够用。
但我也要提醒一句:力扣题环境相对单纯,数据量小、表结构简单、业务规则清晰。真实世界里,SQL往往要面对杂质数据、字段命名混乱、表结构设计不合理等一堆问题。刷题解决的是“写法会不会”的问题,真实业务解决的是“能不能想到这个写法”的问题,两者差着一层实战的距离。
9.3 下一步的刷题方向
50题快要刷完了,接下来我会往两个方向再深入:一是回头复习专题,按“窗口函数”“日期处理”“连续性问题”这几个主题逐个专项再刷一轮;二是找一些模拟真实业务场景的SQL练习题,比如订单分析、用户留存、漏斗转化这些经典分析需求,锻炼自己从需求到SQL的转换能力。
SQL这东西,语法是死的,思路是活的。多刷多练,多看别人的解法,慢慢就会形成一套自己的分析方法论。这个阶段总结(四)就先写到这里,下一个阶段如果有新的收获,再来和大家分享。
