LeetCode 178. 分数排名
题目描述
题意分析
Scores表里每行是一条成绩记录,含id与score。要输出每条成绩以及它的排名,结果按分数从高到低排列。题面对排名规则给了两条硬要求,必须逐字读。第一,分数相同的记录排名相同。第二,排名之间不能有间隔——如果有两个人并列第 2,那么下一个人是第 3 名而不是第 4 名。这两条合起来精确地描述了「稠密排名」,与「跳号排名」和「行号」是三种不同的东西,选错一个结果就全错。
输出的列名与列序也是判定的一部分:第一列是
score、第二列必须叫rank。而rank在较新的 SQL 标准与 MySQL 8 中是保留字,直接写会撞语法,这是本题唯一的工程陷阱。输出不去重:有几条成绩记录就输出几行,两个 90 分的人各占一行,各自带着相同的排名。这一点和 177 那类「第 N 高」的题正好相反——那类题要压平并列,这题要保留每条记录。
边界包括:表为空(输出零行);所有分数都相同(全部排名 1);所有分数互不相同(排名就是 1 到 n)。
解法:SQL 查询建模
核心思路
不借助窗口函数时,标准写法是关联子查询:对每一行统计「有多少个不同的分数严格大于它」,再加一。这在逻辑上完全正确,但每一行都要扫一遍全表做去重计数,代价是 $O(n^2)$ 量级,而且 SQL 写起来嵌套很深、可读性差。
瓶颈在于「排名」这个信息被逐行重复计算,而它本可以在一次排序中一次性算完。窗口函数正是为此存在的——它在一次扫描中为每一行附上与整个结果集相关的统计量,而不改变行数。
三个排名类窗口函数的差别,恰好对应题面的两条要求。
ROW_NUMBER()给每行一个唯一序号,并列的分数会拿到不同名次,违反第一条。RANK()让并列同名次,但会跳号——两个并列第 2 之后直接是第 4,违反第二条。DENSE_RANK()既让并列同名次又不跳号,两条都满足,所以它是唯一正确的选择。窗口的定义是
OVER (ORDER BY score DESC):不分组(没有PARTITION BY,整张表是一个窗口),按分数降序决定名次先后。DESC不能少,否则名次会从最低分开始算。最后是
rank这个列名。它是保留字,必须用反引号包起来写成`rank`,否则解析器会把它当成窗口函数关键字而报语法错误。至于结果的行序,
DENSE_RANK()内部的ORDER BY score DESC只决定名次怎么算,不保证最终输出顺序。因此主查询末尾再写一次ORDER BY score DESC,把题目要求的展示顺序也变成显式契约,不能依赖某个执行计划恰好复用窗口排序。
解题步骤
- 确定输出粒度是「每条成绩记录一行」,所以主查询直接从
Scores出发,不做任何GROUP BY或DISTINCT。粒度一旦搞错,后面所有列都对不上。- 第一列直接选出
score。题目只要分数和排名,id不在输出里,多选一列会被判错。- 用
DENSE_RANK() OVER (ORDER BY score DESC)计算排名。选DENSE_RANK而非RANK或ROW_NUMBER,理由是它同时满足「并列同名」与「名次连续」;ORDER BY score DESC让高分排在前,DESC是必需的。- 把结果列别名写成
`rank`。反引号不可省,因为rank是保留字;别名的大小写也要与题面一致。- 主查询末尾再按
score DESC排序。窗口内排序负责计算名次,查询级排序负责输出顺序,两者职责不同。- 不需要
WHERE、JOIN或子查询。整个逻辑在一次窗口计算里完成,任何额外的嵌套都是多余的。以一张具体的表走一遍:
Scores中有(1, 3.50)、(2, 3.65)、(3, 4.00)、(4, 3.85)、(5, 4.00)、(6, 3.65)。窗口按
score DESC排序后,行的顺序是 4.00、4.00、3.85、3.65、3.65、3.50。DENSE_RANK()逐行赋值:两个 4.00 同为第 1 名;3.85 是遇到的第二个不同分值,名次 2;两个 3.65 同为第 3 名;3.50 是第四个不同分值,名次 4。输出六行,依次是
(4.00, 1)、(4.00, 1)、(3.85, 2)、(3.65, 3)、(3.65, 3)、(3.50, 4),与预期完全一致。对照另外两个函数在同一份数据上的表现:
ROW_NUMBER()会给出 1、2、3、4、5、6,两个 4.00 被拆成第 1 和第 2,违反「并列同名」;RANK()会给出 1、1、3、4、4、6,3.85 直接跳到第 3 名,违反「名次连续」。只有DENSE_RANK()给出 1、1、2、3、3、4。
代码实现
SELECT
score,
-- DENSE_RANK 并列同名且名次连续;rank 是保留字必须加反引号。
DENSE_RANK() OVER (ORDER BY score DESC) AS `rank`
FROM Scores
ORDER BY score DESC;
复杂度分析
- 时间复杂度:主要成本是窗口函数所需的一次排序,$O(n \log n)$,$n$ 为成绩记录数;排序完成后只需一次线性扫描即可为每行赋上名次。相比关联子查询的 $O(n^2)$,这是本解法的核心收益。若
score上建有索引,优化器可能直接顺着索引有序读取,把排序成本省掉。- 空间复杂度:$O(n)$,来自排序缓冲与窗口计算的中间结果;输出行数与输入相同,不产生额外的行膨胀。
关键点总结
- 排名类问题的第一步永远是辨析并列规则:并列是否同名、同名后是否跳号。这两个二元选择恰好对应
ROW_NUMBER、RANK、DENSE_RANK三者,选对了题就做完了一半。- 窗口函数的价值在于「附加统计量而不改变行数」。凡是需求形如「保留每一行明细,同时给每行加上与整体相关的一个值」,就应该先想窗口函数,而不是分组聚合再回连。
OVER子句里的ORDER BY与查询末尾的ORDER BY是两回事:前者决定名次怎么算,后者决定行怎么输出。混淆这两者是窗口函数初学者最常见的误解。- 保留字作列名必须加反引号(MySQL)或双引号(标准 SQL)。
rank、order、group、key都是高频踩坑名,写别名前先确认。
易错点总结
- 错误写法:用
RANK()代替DENSE_RANK()。用例:分数为 4.00、4.00、3.85 → 输出名次 1、1、3,3.85 应该是第 2 名,名次出现了跳号。- 错误写法:用
ROW_NUMBER()。用例:分数为 4.00、4.00、3.85 → 输出名次 1、2、3,两个并列的 4.00 被判成不同名次。- 错误写法:别名不加反引号,写成
AS rank。用例:任意数据 → MySQL 8 把rank当作保留字解析,报语法错误,查询根本无法执行。- 错误写法:
OVER (ORDER BY score)漏掉DESC。用例:分数为 4.00、3.85、3.50 → 最低分 3.50 被排成第 1 名,名次完全颠倒。- 错误写法:加上
DISTINCT或GROUP BY score。用例:分数为 4.00、4.00、3.85 → 输出只剩三行中的两行,两个 4.00 被压成一行,正确答案要求每条记录各占一行。- 错误写法:把
id也选进结果。用例:任意数据 → 输出多出一列,与题目要求的两列结构不符,判定失败。- 错误写法:把
ORDER BY score DESC只写在查询末尾而OVER ()里留空。用例:分数为 4.00、3.85、3.50 → 窗口没有排序依据,DENSE_RANK()对所有行给出相同名次 1,排名全错。- 错误写法:用
COUNT(*)而非COUNT(DISTINCT score)写关联子查询版本。用例:分数为 4.00、4.00、3.85 → 3.85 的名次算成 3,正确答案是 2;统计的必须是「不同分值的个数」。- 错误写法:关联子查询里的比较写成
>而忘了加一。用例:分数为 4.00、3.85 → 最高分统计到 0 个比它大的分值,名次算成 0,正确答案是 1。- 错误写法:给窗口加上
PARTITION BY score。用例:分数为 4.00、4.00、3.85 → 每个分值各自成为一个窗口,所有行的名次都变成 1,排名信息完全丢失。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 176. 第二高的薪水 | 中等 | 只取单个名次且要求无解时返回 NULL,重点从输出排名转向处理空结果 |
| 177. 第N高的薪水 | 中等 | 名次由参数给定,用 DISTINCT 加 LIMIT OFFSET 定位,输出压平并列 |
| 184. 部门工资最高的员工 | 中等 | 分组内取最大值并保留明细,可用窗口也可用 IN 配对,还要连接部门维表 |
| 185. 部门工资前三高的所有员工 | 困难 | 在本题基础上加 PARTITION BY,把全局排名变成分组内排名 |
| 182. 查找重复的电子邮箱 | 简单 | 用 GROUP BY 加 HAVING 做分组过滤,输出粒度从明细变成分组 |
| 569. 员工薪水中位数 | 困难 | 分组内按名次取中间行,要同时处理奇偶行数,是排名定位的进阶应用 |
| 1280. 学生们参加各科测试的次数 | 简单 | 重点是交叉连接补全缺失组合再左连接计数,考察输出骨架的构造 |
| 610. 判断三角形 | 简单 | 用 CASE WHEN 为每行附加一个派生列,是「保留明细并加一列」的最简形态 |