1. 先读懂两个需求:这道题真正的考点在审题
LeetCode 1341 电影评分这道题,在 Database 分类里难度标着 Medium。点开题目的第一眼,你大概率会觉得它平平无奇——不就查两个东西嘛:评分次数最多的用户,以及 2020 年 2 月平均评分最高的电影。说实话,我第一次做的时候也觉得这顶多算 Easy 偏中,结果第一次提交就被判错了。
错的原因不是聚合函数写错,而是栽在三个"隐藏条件"上:并列排序规则、日期边界、UNION 的去重行为。这道题恰好是那种"逻辑一看就懂,细节一写就错"的典型,非常适合用来检验你对 GROUP BY、ORDER BY、聚合函数和结果合并这几块基本功的扎不扎实。
1.1 两个查询需求,至少藏着三个限制条件
题目给了三张表:Movies(电影表)、Users(用户表)、MovieRating(评分记录表)。
第一个需求:找出评分次数最多的用户,输出用户名字。这里的关键词是"次数"——不是平均分,不是总分,是数有多少条评分记录。如果两个人评分次数一样多,返回字典序更小的那个名字。
第二个需求:找出 2020 年 2 月平均评分最高的电影,输出电影标题。注意几个限定:时间范围锁定在 2020 年 2 月;电影口径是"平均评分"(AVG),不是总评分;如果平均分相同,返回字典序更小的电影标题。
两个查询的最终结果都要放在一个字段里输出,字段名叫results。第一行放用户名字,第二行放电影标题。
1.2 为什么说这题是在考审题
你仔细琢磨一下上面两个需求,会发现每一句都有限定语。"次数最多"限定了聚合方式是 COUNT,"2020 年 2 月"限定了 WHERE 条件,"字典序最小"限定了 ORDER BY 的决胜列,results限定了输出列名。任何一条漏掉,提交就是一个 Wrong Answer。
另外还必须注意,这两条查询之间没有任何关联关系——不是要求"评分次数最多的用户给 2 月平均分最高的电影打了多少分"这种复杂逻辑,就是两个独立的子问题,最后把答案拼在一起。很多人在这一步想复杂了,试图用一个 JOIN 把三张表全连起来再一次性出结果,反而把自己绕晕了。
我的建议是:先拆成两个独立查询,分别验证正确后,再考虑合并。这也是后面所有步骤的主线思路。
2. 三张表和一条外键链:数据关系里藏着两个坑
先看表结构,我用表格列出来,大家对照着理解:
| 表名 | 字段 | 说明 |
|---|---|---|
| Movies | movie_id, title | 电影主键和标题 |
| Users | user_id, name | 用户主键和名字 |
| MovieRating | movie_id, user_id, rating, created_at | 评分记录,含电影、用户、评分值(1-5)、评分时间 |
MovieRating 是典型的"中间事实表",movie_id 和 user_id 分别外连 Movies 和 Users。整道题的所有计算都发生在 MovieRating 这张表上,Movies 和 Users 只是用来"翻译"ID 对应的人名和片名。
2.1 连接方式:内连接就够了
做第一问时,需要把评分记录关联到用户名字上,所以是Users JOIN MovieRating。做第二问时,需要把评分记录关联到电影标题上,所以是Movies JOIN MovieRating。
这里有个值得说一句的点:两次查询都用 INNER JOIN 即可。为什么不用 LEFT JOIN?因为第一问问的是"评分次数最多的用户",一个用户如果一条评分都没有,他的评分次数是 0,永远不可能成为"最多"的那个,LEFT JOIN 把他带进来反而多余——万一所有用户都至少有 1 条评分,那 LEFT JOIN 和 INNER JOIN 结果一样;但只要存在 0 评分的用户,LEFT JOIN 就会多出 COUNT=0 的行,排序时字典序小的那个 0 次用户可能被排到最前面,直接判错。
第二问同理。2020 年 2 月没有评分的电影,平均分是 NULL,根本不该出现在候选池里。INNER JOIN 天然过滤掉这些干扰项。
2.2 日期字段的口径问题
另一个坑在created_at字段上。题目给的示例数据里它是 DATE 类型,但实际提交环境里,有些版本的测试数据会把它当 DATETIME 处理。这直接影响了"2020 年 2 月"这个条件的写法。
如果created_at是 DATE 类型,写BETWEEN '2020-02-01' AND '2020-02-29'是安全的,因为字符串比较时'2020-02-29'会被解析成'2020-02-29',刚好覆盖这一天。
但如果created_at是 DATETIME 类型,BETWEEN '2020-02-01' AND '2020-02-29'实际上等价于>= '2020-02-01 00:00:00' AND <= '2020-02-29 00:00:00'——注意,2 月 29 日零点之后的所有记录都会被漏掉,因为任何'2020-02-29 08:30:00'都大于'2020-02-29 00:00:00'。
更稳妥的写法是created_at >= '2020-02-01' AND created_at < '2020-03-01'。左闭右开区间,不管字段是 DATE 还是 DATETIME,都能完整覆盖整个 2 月。这也是我在实际业务 SQL 里养成的习惯——能写半开区间就别写 BETWEEN,少一个边界问题。
3. 第一问:评分次数最多的用户,核心是聚合后的排序决胜
先写第一问的答案,完整 SQL 如下:
SELECT u.name AS results FROM Users u JOIN MovieRating r ON u.user_id = r.user_id GROUP BY u.user_id, u.name ORDER BY COUNT(r.rating) DESC, u.name ASC LIMIT 1;这个查询一共做了四件事:连接用户表和评分表 → 按用户分组 → 统计每个人的评分次数 → 按次数降序、名字升序取第一名。
3.1 为什么 GROUP BY 后面跟了两个字段
大多数人对GROUP BY u.user_id没有疑问,但可能想不通为什么还要加一个u.name。原因很简单:MySQL 在开启ONLY_FULL_GROUP_BY模式(5.7 之后默认开启)时,SELECT 中出现的非聚合列必须出现在 GROUP BY 里,否则直接报错。
u.name和u.user_id是函数依赖关系——一个 user_id 只对应一个 name——理论上只按 user_id 分组是安全的,但为了兼容性和规范性,干脆两个都写。这也让语义更清晰:我们是按"用户"这个实体去分组,而不是按一个孤零零的 ID。
3.2 COUNT(*) 还是 COUNT(r.rating)
统计评分次数时,我用的是COUNT(r.rating),写COUNT(*)也完全没问题。两者在本题里的区别可以忽略,因为rating字段是 1-5 的整数表,没有 NULL 值。
但如果你平时写代码比较较真,要记住这个底层区别:COUNT(*)统计的是结果集的行数,COUNT(col)统计的是该列非 NULL 的行数。所以如果某天有个字段允许为 NULL,且你要统计"有多少条有效数据",COUNT(col)才是正确选择。在这里不构成坑,但搞清楚原理总没错。
3.3 ORDER BY 的排序逻辑,是这道题的题眼
ORDER BY COUNT(r.rating) DESC, u.name ASC这一行承载了两个排序规则:
- 主排序:评分次数从多到少,所以
DESC - 次排序:次数相同时,名字按字典序从 a 到 z,所以
ASC
这个"主排序字段 + 决胜字段"的写法,是解决"并列怎么办"问题的标准答案。LeetCode 很多 SQL 题都在考这一点——比如 185 部门工资前三高,也是要在分组结果里用排序决胜。
有人可能会问:为什么名字要升序?题目原话是"如果有多个用户评分次数相同,返回字典序最小的名字"。字典序最小 = 升序排列后的第一个,所以ASC。翻译成 SQL 就是再自然不过的ORDER BY 次数 DESC, name ASC。
3.4 为什么不加 HAVING 过滤
整个查询里我没有写HAVING COUNT(*) > 0,因为它默认就是成立的。能进到这个结果集的用户,全都有至少一条评分记录——JOIN 已经把这个保证给了我们。HAVING 是用来过滤分组结果的,在"题目只要求返回 Top 1"的场景下,加上一个恒真条件只会让查询更啰嗦。
4. 第二问:2月平均分最高的电影,日期过滤是重头戏
第二问的完整 SQL 如下:
SELECT m.title AS results FROM Movies m JOIN MovieRating r ON m.movie_id = r.movie_id WHERE r.created_at >= '2020-02-01' AND r.created_at < '2020-03-01' GROUP BY m.movie_id, m.title ORDER BY AVG(r.rating) DESC, m.title ASC LIMIT 1;结构上和第一问几乎一样,只是多了一个 WHERE 日期过滤,聚合函数从 COUNT 换成了 AVG。
4.1 三种日期过滤写法对比
处理"2020 年 2 月"这个条件,常见的写法有三种:
| 写法 | 示例 | 优点 | 缺陷 |
|---|---|---|---|
| BETWEEN | BETWEEN '2020-02-01' AND '2020-02-29' | 可读性好 | DATETIME 下会漏掉 2 月 29 日零点后的记录 |
| 半开区间 | >= '2020-02-01' AND < '2020-03-01' | 安全稳妥,推荐 | 稍微啰嗦一点 |
| 格式化比较 | DATE_FORMAT(created_at, '%Y-%m') = '2020-02' | 语义直观 | 对字段套函数,无法走索引,大数据量下性能差 |
我最推荐第二种。理由在前面已经说过:不管你面对的是 DATE 还是 DATETIME,它都能精确覆盖目标月份的全部记录。第三种写法虽然业务上最容易理解,但DATE_FORMAT会让 MySQL 无法使用created_at上的索引,在真实业务的大表上会引发全表扫描。
4.2 2020年2月为什么是29天
这里再多说一句边界问题:2020 年是闰年,2 月有 29 天。如果你写BETWEEN '2020-02-01' AND '2020-02-28',会漏掉最后一天的数据,提交直接 WA。很多人第一反应写 28,就是因为没反应过来闰年。
这种细节在真实业务里也一样重要——统计自然月数据时永远要问一句"这个月有几天"。我建议你直接养成"下月 1 号作为开区间右边界"的写法,永远不需要记住具体月份多少天,一劳永逸。
4.3 AVG 的结果和排序陷阱
AVG(r.rating)算出来是浮点数,比如某个电影在 2 月有 3 条评分:5、4、4,平均分是 4.3333。排序时直接按浮点数比较,没问题。
有一个容易忽略的细节:如果某部电影在 2 月没有任何评分,它的AVG(r.rating)是 NULL。在 ORDER BY 中,NULL 的排序位置要看数据库实现,MySQL 默认 NULL 最小(升序在最前),但这不重要——因为 JOIN 已经保证它不会出现在结果集里。
平均分并列的场景,处理方式和第一问完全一致:ORDER BY AVG(r.rating) DESC, m.title ASC,平均分相同时取标题字典序最小的电影。
5. 合并结果:UNION ALL 与三个容易被判错的细节
两个独立查询都写好了,最后一步是把结果合并成两行输出。这一步看起来简单,实际上坑最多。
( SELECT u.name AS results FROM Users u JOIN MovieRating r ON u.user_id = r.user_id GROUP BY u.user_id, u.name ORDER BY COUNT(r.rating) DESC, u.name ASC LIMIT 1 ) UNION ALL ( SELECT m.title AS results FROM Movies m JOIN MovieRating r ON m.movie_id = r.movie_id WHERE r.created_at >= '2020-02-01' AND r.created_at < '2020-03-01' GROUP BY m.movie_id, m.title ORDER BY AVG(r.rating) DESC, m.title ASC LIMIT 1 );5.1 UNION 还是 UNION ALL?选后者
这是我第一次提交栽的跟头。当时图省事写的UNION,结果有个测试用例里,评分次数最多的用户名字恰好叫 "Avengers",而 2 月平均评分最高的电影也叫 "Avengers"——两边结果相同,UNION 自动去重后只返回了一行,直接 Wrong Answer。
UNION和UNION ALL的区别只在去重:前者会对最终结果集做 DISTINCT,后者原样拼接。这道题的两个子查询结果来自完全不同的事实域——一个是用户名,一个是电影名——按道理不会重复,但理论是理论,测试数据千奇百怪,名字撞车就是能发生。
所以结论明确:在"只需要拼接、不需要去重"的场景里,一律用 UNION ALL,省得数据库多做一次去重操作,也避免不可预料的逻辑错误。
5.2 子查询为什么要加括号
MySQL 中,两个带ORDER BY和LIMIT的 SELECT 做 UNION 时,如果不给每个 SELECT 都加上括号,会出两类问题:
- 语法报错(不同版本表现不同)
ORDER BY和LIMIT被误认为作用于整个 UNION 的结果
特别是第二种情况,如果写成:
SELECT u.name AS results FROM Users u ... LIMIT 1 UNION ALL SELECT m.title AS results FROM Movies m ... LIMIT 1第二个 LIMIT 1 实际上作用在整个合并结果上,如果你第一个子查询返回了多行、第二个子查询返回了多行,合并后 LIMIT 1 只取第一行,第二个子查询的结果直接被吞掉了。所以用括号把每个子查询包住,让 ORDER BY 和 LIMIT 的作用域限定在单个查询内部,这是标准且安全的写法。
5.3 提交前自检三件事
即便 SQL 能跑通,提交前我也建议你逐项检查:
- 列名是不是 results。LeetCode SQL 题对输出列名要求严格,写错列名必判错。两个子查询的列别名必须都写成
AS results。 - 返回行数是不是 2。不管怎么合并,最终必须恰好两行。如果你预期两行却只返回一行,优先怀疑
UNION去重。 - 排序方向确认一次。第一问次数要 DESC、名字要 ASC;第二问平均分要 DESC、标题要 ASC。两个子查询的排序方向相互独立,各写各的,别复制粘贴后忘了改。
6. 跳出题目:1341的解法在真实业务里的通用套路
刷完这道题,如果你只是记住了一个答案,那收获有限。真正有价值的是提炼出这类问题的通用解法,因为 LeetCode 1341 的形态——"在分组统计后取 Top 1,并用另一字段破并列"——在真实的业务报表场景里太常见了。
6.1 通用三步套路
任何类似"找出某维度下指标最高/最低的那个对象"的查询,都可以套这个框架:
- JOIN + WHERE 圈定数据范围。先把涉及的表关联好,把过滤条件(时间、状态、类型)全部加在 WHERE 里。这一步决定了你统计的口径。
- GROUP BY 圈定分组维度。想清楚按谁分组:用户、电影、商家、商品?分组的字段要跟 SELECT 中出现的非聚合列保持一致。
- ORDER BY + LIMIT 1 圈定冠军。指标排序放第一位,并列决胜字段放第二位,再用 LIMIT 1 取第一名。
举个例子,你老板问你"这个月哪个商家的销售额最高",翻译成 SQL 就是:
SELECT s.shop_name FROM orders o JOIN shops s ON o.shop_id = s.shop_id WHERE o.created_at >= '2024-01-01' AND o.created_at < '2024-02-01' GROUP BY s.shop_id, s.shop_name ORDER BY SUM(o.amount) DESC, s.shop_name ASC LIMIT 1;跟 1341 的骨架一模一样。你掌握了这题,就等于掌握了一大类"Top 1 指标排名"业务查询的通用写法。
6.2 什么时候要用窗口函数
LIMIT 1 有两个局限:一是它只返回一行,无法处理"并列第一有多个,想全部输出"的场景;二是你如果想同时输出前三名、前五名,LIMIT 1 也帮不上忙。
这时候就该换窗口函数上场了。拿本题的第二问举例,如果想输出 2 月平均评分并列第一的所有电影:
SELECT title FROM ( SELECT m.title, AVG(r.rating) AS avg_rating, RANK() OVER (ORDER BY AVG(r.rating) DESC) AS rk FROM Movies m JOIN MovieRating r ON m.movie_id = r.movie_id WHERE r.created_at >= '2020-02-01' AND r.created_at < '2020-03-01' GROUP BY m.movie_id, m.title ) t WHERE rk = 1;RANK()会为并列的值分配相同排名,且下一个排名会跳过——比如两个并列第一,下一个排名就是 3,不是 2。如果你想要不跳号的并列排序,用DENSE_RANK();如果并列也不影响、只想取前 N 条不重不漏,用ROW_NUMBER()。
这三个窗口函数的使用场景,我在刷题时总结成一句话:要部分并列名次用 RANK,要连续名次用 DENSE_RANK,只要一个名次不重复用 ROW_NUMBER。实际业务里"Top 1 排行榜"用 LIMIT 1 就够了,但一旦需求变成"Top 3 且并列算同一名次",窗口函数才是正解。
回到 LeetCode 1341 这道题本身,我的建议是:先分别跑通两个子查询,确认各自的排序和过滤没问题,再合并成最终提交版本。这个过程就像做菜——食材先各处理干净,最后下锅炒,比一股脑全倒进去更容易控制火候。SQL 题大多如此,看起来越复杂的需求,越要拆解得干净利落。