目录

题目描述

178. 分数排名

题意分析

Scores 表里每行是一条成绩记录,含 idscore。要输出每条成绩以及它的排名,结果按分数从高到低排列。

题面对排名规则给了两条硬要求,必须逐字读。第一,分数相同的记录排名相同。第二,排名之间不能有间隔——如果有两个人并列第 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 BYDISTINCT。粒度一旦搞错,后面所有列都对不上。
  • 第一列直接选出 score。题目只要分数和排名,id 不在输出里,多选一列会被判错。
  • DENSE_RANK() OVER (ORDER BY score DESC) 计算排名。选 DENSE_RANK 而非 RANKROW_NUMBER,理由是它同时满足「并列同名」与「名次连续」;ORDER BY score DESC 让高分排在前,DESC 是必需的。
  • 把结果列别名写成 `rank`。反引号不可省,因为 rank 是保留字;别名的大小写也要与题面一致。
  • 主查询末尾再按 score DESC 排序。窗口内排序负责计算名次,查询级排序负责输出顺序,两者职责不同。
  • 不需要 WHEREJOIN 或子查询。整个逻辑在一次窗口计算里完成,任何额外的嵌套都是多余的。

以一张具体的表走一遍: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_NUMBERRANKDENSE_RANK 三者,选对了题就做完了一半。
  • 窗口函数的价值在于「附加统计量而不改变行数」。凡是需求形如「保留每一行明细,同时给每行加上与整体相关的一个值」,就应该先想窗口函数,而不是分组聚合再回连。
  • OVER 子句里的 ORDER BY 与查询末尾的 ORDER BY 是两回事:前者决定名次怎么算,后者决定行怎么输出。混淆这两者是窗口函数初学者最常见的误解。
  • 保留字作列名必须加反引号(MySQL)或双引号(标准 SQL)。rankordergroupkey 都是高频踩坑名,写别名前先确认。

易错点总结

  • 错误写法:用 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 名,名次完全颠倒。
  • 错误写法:加上 DISTINCTGROUP 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高的薪水 中等 名次由参数给定,用 DISTINCTLIMIT OFFSET 定位,输出压平并列
184. 部门工资最高的员工 中等 分组内取最大值并保留明细,可用窗口也可用 IN 配对,还要连接部门维表
185. 部门工资前三高的所有员工 困难 在本题基础上加 PARTITION BY,把全局排名变成分组内排名
182. 查找重复的电子邮箱 简单 GROUP BYHAVING 做分组过滤,输出粒度从明细变成分组
569. 员工薪水中位数 困难 分组内按名次取中间行,要同时处理奇偶行数,是排名定位的进阶应用
1280. 学生们参加各科测试的次数 简单 重点是交叉连接补全缺失组合再左连接计数,考察输出骨架的构造
610. 判断三角形 简单 CASE WHEN 为每行附加一个派生列,是「保留明细并加一列」的最简形态