ARTICLE DETAIL

资讯详情

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

SQL第N高查询通用解法:从DENSE_RANK到Pandas/Excel

SQL第N高查询通用解法:从DENSE_RANK到Pandas/Excel 刷 SQL 题的同学应该都见过这道经典题给定一张员工表找出第二高的薪水。有些版本还会在题面上加一句“如果不存在第二高的薪水返回 NULL”。刷完第二高之后第三高、第四高、Top N 往往就跟着来了。很多人每次碰到“第 N 高”都要临时试半天因为背过第二高的固定写法N 一变就不知道从哪儿改起。这篇分享不打算讲一道题的偏方而是把这类题目背后的通用解法彻底理清楚不管 N 是 2、3 还是 100也不管你用的是 MySQL、Pandas 还是 Excel都能有一套不慌不忙的解题框架。我自己的经验是这类题真正值钱的不是某个数据库方言的语法而是“先把语义定死再选工具”的思维方式。所谓第 N 高到底是去重后的第几个不同值还是物理排序后的第几行并列名次算不算同一名这两个问题没想清楚后面写的所有 SQL 都是空中楼阁。下面我按自己的实战习惯把这个问题从拆解、解法、场景扩展一直讲到避坑希望能给你省掉几小时查资料的功夫。1. 从经典第二高薪水说起问题拆解与通用化思路1.1 经典题目到底在考什么先看最常见的原型表CREATE TABLE employee ( id INT PRIMARY KEY, salary INT ); INSERT INTO employee VALUES (1, 100), (2, 90), (3, 90), (4, 80), (5, 70), (6, 60);题目要求很简单找出表中第二高的薪水。不少人第一反应是“按工资倒序排取第二条”。SELECT salary FROM employee ORDER BY salary DESC LIMIT 1 OFFSET 1;这个写法本身没错但考试场景里通常会埋两个坑第一工资可能有重复员工 2 和 3 都是 90如果直接用上面的 SQL第二名拿到的是 90看起来对可一旦你问“第二高”是什么大多数人的预期其实是“第二高薪水的数值”也就是 90这个结果碰巧正确。可如果第一名有两个人表里是 100, 100, 99上面的 SQL 会返回 100这是错的第二高应该是 99。所以核心考点是能不能意识到要先去重。第二坑是空值如果表里只有一行记录或者所有工资都一样第二高不存在题目通常会要求返回 NULL而不是返回空集或报错。所以这道题表面上考的是 ORDER BY、LIMIT、OFFSET、DISTINCT 这些基础语法实际上考的是“你能否把一个中文业务描述精确翻译成 SQL 的执行步骤”。翻译不过关的人很容易写出先查全部数据再在程序里算的代码。这不是说程序里不行而是在数据库里能一步完成的事情没必要绕路。1.2 关键点先定义“第N高”的语义这是我踩过坑之后养成的习惯动手写 SQL 前先花十秒钟回答一个问题“你说的第 N 高指的是什么”还是拿上面表里的工资举例100, 90, 90, 80, 70, 60。如果按“不同薪水的排名”来理解第 1 高是 100第 2 高是 90第 3 高是 80第 4 高是 70第 5 高是 60。如果按“物理行的第几条”来理解由于 90 出现了两次第 1 高、第 2 高都会指向 90第 3 高才轮到 80。同一个词两种理解差了十万八千里。再换个体育比赛的类比百米赛跑两个选手同时拿了冠军那第二名到底存不存在标准答案是“没有第二名下一个名次是第三名”这是 RANK 的逻辑。但如果你问“有哪些不同成绩”并列第一的人共同占据一个分数档位下一个不同分数自然就是第二高的分数这是 DENSE_RANK 的逻辑。SQL 里恰好就有这三个窗口函数来对应这些不同语义函数对 100, 90, 90, 80 的排名结果是否跳号适合回答的问题ROW_NUMBER1, 2, 3, 4不跳逐行分配物理排序后第 N 条数据RANK1, 2, 2, 4并列后跳号竞赛名次并列冠军后没有亚军直接季军DENSE_RANK1, 2, 2, 3不跳按值分档去重后的第 N 个不同值也就是我们常说的第 N 高所以“第 N 高薪水”这类题我默认按 DENSE_RANK 的语义去理解先去重再按大小顺序数到第 N 个不同值。除非题目明确说“返回第 N 条记录”或者“不考虑重复”才会换成 ROW_NUMBER。1.3 通用化思维从第二高到第N高把“第二高”改成“第 N 高”不是简单把常量 2 换成变量 N 那么轻松至少会冒出三个新问题。第一个问题N 是动态的。原来的 SQL 写成 OFFSET 1 就完事现在要写 OFFSET N-1但很多数据库的 LIMIT 子句并不允许直接写表达式这就需要额外的参数处理手段。第二个问题N 可能超过数据量。表里只有 5 个不同的工资你想找第 100 高该怎么返回有的业务期望 NULL有的期望 0有的期望空集统一口径很关键。第三个问题N1 时有没有特殊性。OFFSET 0 和 OFFSET -1 可完全不是一回事后者直接报错。如果外部传入的参数被用户填成 0 或负数程序得能兜住。我习惯把这道题抽象成一句话给定一张表 T某个数值列 value求“去重后的第 N 大的 value”如果去重后的取值个数小于 N则返回 NULL。后面所有的解法都是围绕这个抽象定义的实现方式。只要定义清晰了换数据库、换工具、换业务表框架都不会乱。2. 三种主流通用解法与实现细节2.1 先准备测试数据集为了把三种解法放在一起对比我用 1.1 节那张表当测试数据工资列表为100, 90, 90, 80, 70, 60。在继续往下看之前你可以先自己想一下按照“去重后的第 N 高”定义第 1 到第 5 高分别是什么答案分别是 100、90、80、70、60第 6 高是 NULL。我先说一个判断方法是否可靠的小技巧拿第 3 高来试。如果一段代码跑出来的第 3 高是 90那基本可以判断它没有去重如果跑出来是 80说明它正确处理了重复值。这个测试样本比只用第二高更有区分度因为 100 只出现一次第一高和第二高的差异不容易暴露问题。2.2 子查询计数法兼容性最好在还不流行窗口函数的年代这是大家最常写的通用解法。思路是对于每一行工资 e1统计“有多少个不同的工资大于等于它”。如果这个数量恰好等于 N那 e1 的工资就是第 N 高。用 SQL 表示就是SELECT DISTINCT salary FROM employee e1 WHERE 3 ( SELECT COUNT(DISTINCT e2.salary) FROM employee e2 WHERE e2.salary e1.salary );拆开解释一下。以工资 80 这行为例内层子查询找所有大于等于 80 的不同工资得到 100、90、80一共 3 个所以 80 就是第 3 高。再看工资 90大于等于 90 的不同工资是 100、90只有 2 个不满足。这样整个查询结果就只剩下 80而且因为外面加了 DISTINCT即使 90 存在两行也不会干扰输出。但直接跑这段 SQL如果第 N 高不存在结果是空集不是 NULL。要满足题目要求需要在外面再包一层SELECT ( SELECT DISTINCT salary FROM employee e1 WHERE 3 ( SELECT COUNT(DISTINCT e2.salary) FROM employee e2 WHERE e2.salary e1.salary ) ) AS nth_salary;当内层查询没有任何行时标量子查询会返回 NULL外层结果自然就是 NULL。这也是我认为子查询计数法最值得肯定的地方它天然兼容 MySQL 5.7、Oracle、SQL Server 这些老版本数据库并且 N 可以直接作为参数传入不需要纠结 LIMIT 表达式的问题。缺点当然也明显这是一个 O(N²) 量级的查询每一行工资都要去扫描一遍内层表。数据量小的时候无所谓一旦表里有几百万行这个写法能把数据库跑得气喘吁吁。所以在工作环境里我会把它当作“面试思路题”或者“小数据量兜底方案”而不是大表常规武器。2.3 LIMIT/OFFSET最直接也最容易翻车如果只看执行效率LIMIT 1 OFFSET N-1 通常是最快的一类写法因为它只要把结果排好序然后直接跳过 N-1 条取一条不需要计算完整排名。SELECT DISTINCT salary FROM employee ORDER BY salary DESC LIMIT 1 OFFSET 2;这个 SQL 查的是第 3 高因为 OFFSET 2 表示跳过两条。即使表里有两个 90前面的 DISTINCT 已经把不同工资压缩成 100、90、80、70、60 五条记录所以第三行是 80结果正确。要返回空值结果的写法还是老套路套一层标量子查询SELECT ( SELECT DISTINCT salary FROM employee ORDER BY salary DESC LIMIT 1 OFFSET 2 ) AS third_salary;如果 OFFSET 超出了去重后的行数范围内层返回空集外层得到 NULL。真正容易翻车的地方是动态 N 传参。很多新手会理所当然地写-- 这段在大多数数据库中会报错或行为异常 SELECT DISTINCT salary FROM employee ORDER BY salary DESC LIMIT 1 OFFSET N - 1;MySQL 里的 LIMIT 子句通常不接受N - 1这种表达式。正确做法是在应用程序里先把 offset 算好再作为参数传入。在 MySQL 里如果实在要完全动态化可以用 PREPARE 动态 SQLSET n 3; SET offset n - 1; PREPARE stmt FROM SELECT DISTINCT salary FROM employee ORDER BY salary DESC LIMIT 1 OFFSET ?; EXECUTE stmt USING offset; DEALLOCATE PREPARE stmt;注意我是在应用层先算好 offset再把结果传给 PREPARE。这个顺序不要颠倒否则你会在不同数据库版本之间踩到莫名其妙的兼容性差异。2.4 窗口函数法DENSE_RANK、RANK、ROW_NUMBER怎么选如果你用的是 MySQL 8.0、PostgreSQL、SQL Server 或者 Oracle窗口函数是我最推荐的通用解法SELECT DISTINCT salary FROM ( SELECT salary, DENSE_RANK() OVER (ORDER BY salary DESC) AS rn FROM employee ) t WHERE rn 3;子查询里先对每个工资生成一个稠密排名DENSE_RANK 会把 100 排成 190 排成 280 排成 3。外层再加个 DISTINCT是因为表里可能存在多个员工的工资都是 90如果不加结果会返回两行 90。加上 DISTINCT 之后不管有多少个并列都只输出一个工资值。为什么不用 RANK回到 1.2 节的样本数据对 100, 90, 90, 80 使用 RANK结果是 1, 2, 2, 4第 3 名直接没人因为并列第二之后跳到了第四。用它找第 3 高结果为空显然不符合直觉。为什么不用 ROW_NUMBERROW_NUMBER 会给每一行一个唯一序号90 的两行分别排第 2 和第 3导致第 3 高变成重复的 90。只有 DENSE_RANK 能把“不同值的档位”这个语义表达得最准确。三者的选型规则我用一张表总结需求描述首选函数原因不同工资的第 N 高DENSE_RANK按值分档不跳号竞赛式名次并列后跳过RANK保持体育比赛语义物理排序后的第 N 条记录ROW_NUMBER每一行都有唯一序号还有一个很多人忽略的细节窗口函数会在子查询里对所有 salary 计算排名数据量大时会产生一个排序中间结果。如果数据库有(salary)索引排序可能会走索引但如果索引不存在就可能走文件排序。所以用窗口函数不代表一定高性能它换来的是写法清晰和参数化方便。3. 从SQL拓展到其他工具Pandas与Excel中的第N高3.1 Pandasnlargest、rank 与去重的坑离开 SQL回到数据分析师更常用的 Pandas 里“第 N 高”同样有坑。先看最直觉的写法import pandas as pd df pd.DataFrame({salary: [100, 90, 90, 80, 70, 60]}) N 3 # 错误的直觉写法 print(df[salary].nlargest(N).iloc[-1]) # 结果是 90但这不是我们要的第3高nlargest(3)做了什么它从原序列中挑了最大的 3 个数因为 90 有两条所以三行分别是 100、90、90最后一行的值 90 就被误当成第 3 高。要还原 SQL 里 DISTINCT 的语义必须先去重result df[salary].drop_duplicates().nlargest(N).iloc[-1] print(result) # 80这里会暴露另一个问题如果 N 大于去重后的数量iloc[-1]会直接抛 IndexError。稳妥写法是先判断一下unique_salary df[salary].drop_duplicates() if len(unique_salary) N: result unique_salary.nlargest(N).iloc[-1] else: result None如果你更倾向用 rank 思路可以这样df[rn] df[salary].rank(methoddense, ascendingFalse) third_salary_rows df[df[rn] 3][salary].unique()这里的methoddense对应 SQL 的 DENSE_RANKascendingFalse表示从大到小排名。如果需求是返回所有并列第 3 高的员工就用df[df[rn] 3]不要加.unique()。如果需求变成“每个部门里的第 N 高”SQL 那边要写 PARTITION BYPandas 这边则用 groupbydef nth_salary(s, n): s s.drop_duplicates() return s.nlargest(n).iloc[-1] if len(s) n else None result df.groupby(department)[salary].apply(lambda s: nth_salary(s, 3))这条经验在工作中特别实用因为很多内部报表系统只给你一个 CSV 导出根本没机会写 SQL最后还是得用 Pandas 顶上。3.2 ExcelLARGE函数与重复值处理Excel 用户遇到“第 N 高”第一反应通常是LARGE函数。它的语法非常直白LARGE(区域, N)返回区域内第 N 个最大值。比如LARGE(A2:A10, 3)就是第三大的数值。但LARGE本身不会去重和 Pandas 的nlargest一个毛病。如果 A 列是 100、90、90、80LARGE(A2:A10, 3)返回的是 90而不是我们期望的 80。处理办法也很简单利用新版 Excel 的 UNIQUE 函数先取出唯一值再套 LARGELARGE(UNIQUE(A2:A10), 3)这个写法在 Office 365、Excel 2021 之后的版本里都可以用非常好理解。老版本 Excel 没有 UNIQUE 函数就得用数组公式或者辅助列做去重。我比较推荐加一列辅助列公式类似IF(COUNTIF($A$2:A2, A2)1, A2, )然后对辅助列再求 LARGE。虽然不如 UNIQUE 优雅但胜在兼容老文件。这里也想顺便提醒一句Excel 的 LARGE 在数据量很大时性能一般几十万行数据用公式会很卡这种情况我更推荐先用 Power Query 去重再排序、筛选取第 N 行或者干脆导到数据库里查。3.3 业务对比第N高和Top N有什么不一样很多业务需求说的是“取销售额前三”这和第 3 高不是一回事。Top N 返回的是一个集合比如销售排行榜前三名通常是三个业务员而“第 3 高销售额”返回的往往只是一个阈值数值。有这样的区分意识很重要因为在 SQL 里这两者的写法差异很大。取 Top 3 用LIMIT 3或ROW_NUMBER()然后保留三行取第 3 高则只保留一行或者干脆用标量子查询返回单值。如果需求描述不清你可能写了半天发现同事想要的只是“比第 2 高工资低的人都给揪出来”那这里第 2 高就是作为一个过滤阈值而不是一条展示记录。举个我实际遇到过的场景运营想要“找出工资高于公司第 70 百分位数的员工”这本质上不是一个“第 N 高”问题而是分位数问题。但如果你手边没有 PERCENTILE 函数一个偷懒做法就是算出第 70 百分位对应的数值再用这个数值当阈值去比较。理解了第 N 高和 Top N 的区别你就知道为什么不能直接ORDER BY salary DESC LIMIT 1 OFFSET 2去当阈值来用了因为你要的是那个值而不是那条员工记录。4. 真实业务场景中的落地与性能优化4.1 动态N传参的三种姿势理想世界里我们会在 SQL 里写一个WHERE rn N然后把参数一传就完事。现实中不同数据库和不同权限限制会逼你换姿势。我列一下自己常用的三种方案。第一种应用层计算 OFF SET参数绑定传值。如果你在用 Python 的 pymysql、psycopg2 或者 Java 的 JDBC那可以在 SQL 文本里写LIMIT 1 OFFSET %s然后在执行时传入n - 1。比如cursor.execute( SELECT salary FROM employee ORDER BY salary DESC LIMIT 1 OFFSET %s, (n - 1,) )这种方式最安全也最推荐。OFFSET 的数值在程序里算好SQL 本身不含动态片段可以避免注入风险也绕开了“LIMIT 不能写表达式”的数据库限制。第二种动态 SQL / PREPARE。当你在数据库客户端工具里临时想跑不同 N用 PREPARE 最方便。MySQL 的写法我在 2.3 节已经演示过核心是把 OFFSET 作为占位符而不是拼字符串。第三种窗口函数参数化。如果你用的数据库支持窗口函数而且 SQL 语法允许直接在 WHERE 里用参数那是最省心的SELECT salary FROM ( SELECT salary, DENSE_RANK() OVER (ORDER BY salary DESC) AS rn FROM employee ) t WHERE rn %s; -- 或 ?它的好处是 N 直接传参不存在 OFFSET 表达式的问题可读性也好。缺点是必须在子查询里给每一行算一次排名数据量大时会有额外排序开销。4.2 大表场景下怎么优化先给一个悲观的结论任何“第 N 高”需求本质上都要对目标字段做排序或至少做去重扫描所以不存在真正的 O(1) 解法。但我们可以尽量让数据库少干点活。对小表三种方法随便选怎么顺手怎么来。对百万级以上的表我按自己的经验给出这么几个优化方向第一给排序列建索引。对 salary 建索引后DISTINCT salary ORDER BY salary DESC可以走索引扫描MySQL 在取 LIMIT 1 OFFSET N 时通常能利用索引的天然有序性避免全表排序。如果你还需要按部门分组可以考虑组合索引(department_id, salary)。第二小心 OFFSET 太大的情况。你要找第 10000 高的工资LIMIT 1 OFFSET 9999 意味着数据库要数过前 9999 条才能停下来N 越往后越慢。这种情况要么用窗口函数一次性算出排名字段要么在业务层做个缓存把高频查询的排名结果落成一张辅助表。第三尽量在进入排序之前缩小数据范围。比如业务只要“在职员工”就在 WHERE 里先过滤status active排序和去重的数据量立刻小一个量级。别看这个建议简单我见过太多人把过滤条件全部丢到外层子查询里内层却先对全表做了 DISTINCT 和排序。第四如果重复值特别多可以试试GROUP BY salary而不是DISTINCT salary。两个写法的执行计划有时会不一样尤其在大表上GROUP BY 可能更容易配合索引做流式聚合。当然这个建议不要盲从最好用 EXPLAIN 看实际计划。4.3 语义扩展第N高的人、第N高的订单怎么查前面一直在聊“第 N 高薪水”它返回的是数值。但真实需求常常是“找出工资第 N 高的员工是谁”或者“找出第 N 高的订单”。这时候你会发现数值可以直接去重人却没法去重。如果需求只要求返回一个员工且允许你认为“并列第 N 高时随便挑一个”最直接的办法是SELECT id, salary FROM employee ORDER BY salary DESC, id ASC LIMIT 1 OFFSET 2;这里的ORDER BY salary DESC, id ASC给并列工资加了一个物理顺序保证结果稳定。但要注意这并不是一个严格的“第 3 高工资”语义因为如果前面有两个 100 并列它会把第三个 100 当作第 3 高而不是跳到 99。如果需求要求“返回所有工资等于第 3 高的员工”那就得用窗口函数WITH ranked AS ( SELECT *, DENSE_RANK() OVER (ORDER BY salary DESC) AS rn FROM employee ) SELECT id, salary FROM ranked WHERE rn 3;这个写法很像报表里的“获取该名次所有人员”。它的关键在于子查询里不能加 DISTINCT否则你把每一行员工的唯一身份给丢了。再扩展一层如果是“每个部门里工资第 N 高的员工”只需要在窗口函数里加分区SELECT department_id, id, salary FROM ( SELECT department_id, id, salary, DENSE_RANK() OVER (PARTITION BY department_id ORDER BY salary DESC) AS rn FROM employee ) t WHERE rn 2;这套模式几乎能覆盖所有“按某分组取第 N 高”的场景。把 employee 换成订单表把 salary 换成 order_amount逻辑不变。5. 常见问题与避坑记录5.1 为什么查出来的结果总是NULL这是我被问得最多的问题尤其是套了标量子查询之后明明表里数据一大把返回却是 NULL。通常跑不出结果的第一个原因是去重后的工资数量本身不够 N。比如表里只有 3 个不同工资你要第 5 高那无论如何都是 NULL这不是 bug。第二个原因是工资列里有 NULL。SQL 的聚合函数通常会忽略 NULL但排序不会。如果你用 ROW_NUMBER 或 RANK 排序NULL 在降序排列时会被放在最前面或最后面具体看数据库实现这会导致排名错位。解决办法是提前过滤SELECT salary FROM employee WHERE salary IS NOT NULL ORDER BY salary DESC;第三个原因是 SQL 里漏了 DISTINCT。回头看看你用的是不是SELECT salary ... ORDER BY salary DESC LIMIT 1 OFFSET 1而不是SELECT DISTINCT salary。当最高工资有两个人并列时第二高就会错误地指向最高工资如果恰好数据没有第二高就会查不到预期值。顺手放一条我自己的排错步骤先用最朴素的 SQL 把去重后的工资按倒序列出来看一眼确认N到底对应哪一行然后再去想是函数用错了还是语法写错了往往一眼就能定位。5.2 并列排名到底该用哪个窗口函数这个问题在面试里经常被追问核心其实是让你说清楚 RANK、DENSE_RANK、ROW_NUMBER 三者的差异。我用自己的话说一遍ROW_NUMBER 像点名册每个人都有一个不重复的序号哪怕两个人考了一样的分数也有先来后到。RANK 像比赛颁奖如果两人并列第一那就没有第二名下一个人是第三名。DENSE_RANK 像按分数分档并列第一之后下一个不同分数就是第二档不管前面并列多少人。对应的选择标准也很简单求“不同值的第 N 大”用 DENSE_RANK求“严格名次且允许跳号”用 RANK求“物理排序后的第 N 条”用 ROW_NUMBER。如果你发现结果是错位或者空值先别急着骂数据库回去检查一下这三个函数是不是选错了。5.3 LIMIT后面到底能不能写表达式我给一个明确的答案绝大多数 SQL 方言里LIMIT 1 OFFSET N - 1这种带运算表达式的写法都不可靠。MySQL 的 LIMIT 子句接受整数常量或参数占位符但不接受一个在运行时计算的普通表达式。所以不要想着让 SQL 帮你算N-1而是在程序里先算好offset n - 1 cursor.execute( SELECT DISTINCT salary FROM employee ORDER BY salary DESC LIMIT 1 OFFSET %s, (offset,) )如果你非要在 SQL 客户端里动态改 N用 PREPARE 动态执行也会比拼接字符串安全得多。说实话我自己早期在这上面吃过亏写了个存储过程给业务方调用因为 LIMIT 里的局部变量在不同版本 MySQL 下表现不一致最后被迫改成 PREPARE 才消停。5.4 N超过总数行怎么办先厘清需求是返回 NULL还是返回空集还是返回 0不同数据库原生行为不一样LIMIT OFFSET 超出范围返回空集标量子查询可以把它变成 NULLCOALESCE 可以把 NULL 变成 0。推荐一个稳妥写法SELECT COALESCE( ( SELECT DISTINCT salary FROM employee ORDER BY salary DESC LIMIT 1 OFFSET 99 ), 0 ) AS nth_salary;这里找第 100 高如果数据不足就返回 0。这个写法很适合把结果拿去当阈值条件避免空值传播到上层逻辑。另外一定要做参数校验。第 0 高、第 -1 高这些说法本身没有意义程序里应该在入口拦住非法 N。否则 OFFSET 变成负数后很多数据库会直接报语法错误而不是返回一个空结果。这一点和 SQL 本身关系不大但往往是在真实项目里比语法更容易炸的单。5.5 第N高速查表最后放一张我自己收藏的速查表方便你下次用到时直接查。适用场景默认是“去重后的第 N 高数值”如果需求不同记得先改函数选择。场景推荐写法MySQL 5.7 / 老版本子查询 COUNT(DISTINCT) 标量子查询兜底MySQL 8.0DENSE_RANK 子查询N 用参数传入PostgreSQLLIMIT 1 OFFSET $1参数绑定SQL ServerDENSE_RANK 或 OFFSET FETCHOracle 12cDENSE_RANK 或 FETCH FIRSTPandasdrop_duplicates().nlargest(N).iloc[-1]Excel 新版本LARGE(UNIQUE(区域), N)Excel 老版本辅助列去重后 LARGE还有一句个人体会想补在最后我在实际项目里见过太多次同事把“第 N 高”当成“排序后取前 N 条”结果报表里因为并列数据反复出现脏数据。其实这类问题只要在写第一行 SQL 前问清楚“要不要去重”“并列怎么算”“不足 N 个怎么显示”这三个问题后边基本不会翻车。先把语义钉死再去翻语法文档这个顺序永远比背写法更重要。
返回列表