ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

LeetCode 1341电影评分:SQL聚合排序与UNION ALL合并实战解析

LeetCode 1341电影评分:SQL聚合排序与UNION ALL合并实战解析 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 为什么说这题是在考审题你仔细琢磨一下上面两个需求会发现每一句都有限定语。次数最多限定了聚合方式是 COUNT2020 年 2 月限定了 WHERE 条件字典序最小限定了 ORDER BY 的决胜列results限定了输出列名。任何一条漏掉提交就是一个 Wrong Answer。另外还必须注意这两条查询之间没有任何关联关系——不是要求评分次数最多的用户给 2 月平均分最高的电影打了多少分这种复杂逻辑就是两个独立的子问题最后把答案拼在一起。很多人在这一步想复杂了试图用一个 JOIN 把三张表全连起来再一次性出结果反而把自己绕晕了。我的建议是先拆成两个独立查询分别验证正确后再考虑合并。这也是后面所有步骤的主线思路。2. 三张表和一条外键链数据关系里藏着两个坑先看表结构我用表格列出来大家对照着理解表名字段说明Moviesmovie_id, title电影主键和标题Usersuser_id, name用户主键和名字MovieRatingmovie_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 就会多出 COUNT0 的行排序时字典序小的那个 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 月这个条件常见的写法有三种写法示例优点缺陷BETWEENBETWEEN 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 题大多如此看起来越复杂的需求越要拆解得干净利落。
返回列表