做算法题最怕的一种情况,是题目读完感觉全会,一写代码全是边界问题。“找到比自己强的人数”这道题,恰好就是这种类型。它看起来就是一个 for 循环套一个 for 循环,统计比自己分数高的人有多少,但真要提交的时候,严格大于还是大于等于、重复分数怎么处理、二分边界怎么写,每个坑都能卡住一批人。按刷题平台的分值体系,这题值 200 分,说明它早就不是单纯考语法,而是考你有没有真正理解“排名”背后的数据结构含义。
这道题的核心可以一句话说清楚:给出一个整数数组 scores,scores[i] 是编号 i 的选手得分,要求返回一个等长数组 ans,ans[i] 表示全场比赛里分数严格高于 scores[i] 的人数。谁适合看?新手想练排序和二分,老手想核对语言细节,都很合适。而且同样是这道题,用 Java、JavaScript、Python 三种语言写,差异点还不小,正好可以拿来对比不同语言在排序、二分、标准库上的习惯用法。下面我按从思路推导到多语言落地的顺序,把这道题完整拆一遍。
1. 题目到底在问什么:先别急着写循环
1.1 一个排行榜引发的统计问题
想象一个游戏天梯榜,这里有五个人的分数:90、85、90、100、60。排行榜上第三名和第四名同分的情况很常见,这时候问“第二名觉得自己被几个人超了”,答案显然不是数一数比自己靠前的人,因为第二名前面只有一个第一名。但如果是问“第二名在全场里被几个人分数碾压”,那就要把第一名的 100 分算上,还要看有没有其他高分段的人。
这道题问的是后者:统计的是全场上分数更高的人,不是单纯看排行榜位置。所以分数相同的人,互相不算“被超越”,只有严格大于才算。这一点几乎决定了解法方向,也决定了后面写二分时用的是哪个边界函数。把题意先锁死,后面写代码才不用反复回头改。
1.2 一个数组,两种理解方式
我在不同地方见过这道题的两种原始描述,输出结果完全不同:
第一种是不看位置,只问“全场上分数高于我的人有多少”。比如 [90, 85, 90, 100, 60] 这个数组,下标 0 这个 90 分选手,场上只有 100 分比他高,所以答案是 1;下标 1 的 85 分选手,90、90、100 都比他高,答案是 3;最后一个 60 分选手,答案就是 4。
第二种是排行榜顺序,只统计排在自己前面且分数更高的人。同样的数组,下标 2 这个 90 分选手,前面只有 100 分比他高,答案仍然是 1;但下标 3 的 100 分选手,前面没有比他高的,答案 0 也没变;可下标 4 的 60 分选手,前面 90、85、90、100 四个都比他高,答案还是 4。这两个版本只是恰好在这个用例上长得像,换个乱序数组结果就不一样了。
我建议把第一种当作基础版,因为它更贴合“找到比自己强的人数”这种统计直觉,也最容易延伸到排序、二分、频次累加这些通用技能。第二种作为进阶变体,放到后面单独说,实现上要上树状数组。
1.3 从暴力到高效,我走过的三个台阶
刚看到这题时,我第一反应就是双层循环:外层遍历每个人,内层遍历全场,数一数分数更高的有多少。代码五分钟就能写完,时间复杂度 O(n²)。小数据完全没问题,但一旦 n 到了 10 万,100 亿次比较在 Java 里还能勉强跑几秒,Python 就直接卡死。
第二个台阶是排序加二分。把所有分数排好序之后,对于任何一个数 x,严格大于 x 的元素一定连续出现在排序数组的右侧。我只需要用二分找到“第一个大于 x 的位置”,答案就是后面的元素个数。排序是 O(n log n),每个人只做一次 O(log n) 的二分,整体时间复杂度降到 O(n log n)。
第三个台阶是在频次累加基础上做的更优雅的方案:先把分数离散化成有序的几个档位,统计每个档位的人数,然后从高分往低分累加,每个分数档位能直接算出“比这个分数高的人一共有多少”。这个方案连二分都省了,查询部分变成 O(1),只是需要一个哈希表来存结果。
这三个台阶对应三种不同的思维层次,也对应三种不同的代码量。我建议新手至少把前两种吃透,第三种当作扩展练习。
2. 核心解法推导:排序、二分与频次累加
2.1 为什么排序是这类统计问题的主心骨
“比某个数大的数有几个”,本质上是一个序关系查询。无序数组里这种问题只能遍历,有序数组里却可以用二分直接定边界。排序的价值是把一个全集问题转化成一个区间问题:原数组保持不动,另起一个副本排好序,原数组里每个元素 x 在排序数组中的位置,就天然把元素分成了“小于等于 x”和“大于 x”两个阵营。
这也是我坚持用克隆数组的原因。直接用原数组排序会破坏下标对应关系,后面要算结果还得对照原始位置。副本排序的方式空间多花 O(n),代码却干净很多。对于这种不到 10 万级别的数据,空间完全不是瓶颈,可读性才是。
2.2 二分边界:要的是“严格大于”而不是“大于等于”
这里藏着一个最容易错的细节:数组里可能有重复分数,而题目要的是严格大于。如果用二分去找“等于 x 的数”,重复值会干扰结果。比如排序后是 [60, 85, 90, 90, 100],我想统计几个数严格大于 90,正确答案是 1 个(100)。但如果二分返回第一个 90 的位置 2,5 - 2 = 3;返回第二个 90 的位置 3,5 - 3 = 2,全错。
正确做法是找“第一个大于 x 的位置”,也就是经典 upperBound。标准库语言里一般叫 bisect_right、upper_bound,而 lowerBound、bisect_left 找的是“第一个大于等于 x 的位置”,这两个函数在处理重复元素时行为完全不同。所以写代码前先问自己一句:题目要的是不是严格大于?如果是,闭眼选 upperBound。
2.3 另一种思路:频次累加与后缀和
排序加二分的思路虽然好,但每个人都要二分一次。其实这题还能做得更直接:先把所有分数去重并升序排列,用哈希表记录每个分数出现的次数,然后从最大的分数往下走,用一个变量 running 累加已经扫过的分数人数。每次碰到一个新档位 val,当前 val 对应的“比它强的人数”就等于 running,然后 running 再加上 val 自己的人数。
这个思路的本质是后缀和。它把每个分数档位的人数先在哈希表里汇总,再一次遍历就能算出全部答案。对 Python 来说,这种写法比列表加二分更贴近 Python 的简洁风格,一句 Counter 加一个反向遍历就搞定了。对 Java 来说,需要自己写 HashMap 和数组,代码稍长,但思路一旦理解,两种方案殊途同归。
2.4 复杂度分析与数据规模判断
| 方案 | 时间复杂度 | 空间复杂度 | 适合数据规模 |
|---|---|---|---|
| 双层暴力 | O(n²) | O(1) | n ≤ 5000 左右 |
| 排序 + 二分 | O(n log n) | O(n) | n ≤ 10^5 以上 |
| 排序 + 频次累加 | O(n log n) | O(n) | n ≤ 10^6 以上 |
刷题时我习惯先看数据规模:n 小于几千,暴力不丢分;n 到 10 万级别,排序加二分是稳妥答案;n 再大一两个数量级,就要开始考虑是不是要改成频次累加甚至其他更花哨的数据结构。200 分的题通常数据不会太仁慈,直接写暴力提交容易超时。
3. Java 实现与细节
3.1 克隆数组与 Arrays.sort 的取舍
Java 里第一行值得细品:int[] sorted = scores.clone();。为什么不用sorted = scores然后排序?因为这样原数组也会被排序,后面循环里scores[i]早就不是原来的下标对应关系了。clone 对基本类型数组是深拷贝,每个元素独立复制一份,排序副本不会影响原数组,这是基本类型数组的福利,换成对象数组就未必了。
排序用Arrays.sort(sorted)即可,底层是双轴快排,对基本类型数组可以直接排,时间复杂度 O(n log n),实测在 10 万级别数据上毫秒级完成。别自己手写排序,标准库永远是第一选择。
3.2 完整代码与逐行说明
import java.util.Arrays; public class StrongerCounter { public int[] countStronger(int[] scores) { int n = scores.length; int[] sorted = scores.clone(); Arrays.sort(sorted); int[] ans = new int[n]; for (int i = 0; i < n; i++) { int pos = upperBound(sorted, scores[i]); ans[i] = n - pos; } return ans; } private int upperBound(int[] arr, int target) { int left = 0; int right = arr.length; while (left < right) { int mid = left + (right - left) / 2; if (arr[mid] <= target) { left = mid + 1; } else { right = mid; } } return left; } }核心逻辑在第 7 到第 9 行:对每个原始分数,在有序数组中找到第一个比它大的位置 pos,然后用n - pos算出右侧有多少个元素,这些就是严格大于当前分数的人。upperBound方法里,arr[mid] <= target时左指针右移,等价于把等于目标值的元素也扫过去,最终 left 停在第一个大于 target 的位置。
3.3 一个坑:为什么不能随手用 Arrays.binarySearch
Java 标准库自带Arrays.binarySearch,但我在题解里刻意手写了二分,原因就是它遇到重复值返回的下标不固定。文档里写得很清楚:如果数组里有多个相等元素,不保证返回哪一个。
比如 sorted 是 [60, 85, 90, 90, 100],binarySearch(sorted, 90)可能返回 2 也可能返回 3。如果你天真地拿返回值直接做n - pos,有时候结果碰巧对,有时候就差了 1,这种随机性在调试时最让人抓狂。手写 upperBound 虽然多几行代码,但语义是确定的,面试时反而能给面试官讲清楚二分边界问题,是加分项。
3.4 Java 版其他注意事项
- 二分里
mid = left + (right - left) / 2是为了防止left + right溢出,虽然数组长度不可能真的到 2^31,但好习惯值得保持。 Arrays.sort(int[])用的是双轴快排,不稳定;但这里排序的是基本类型值,稳定性没有意义,放心用。- 返回数组
new int[n]默认全是 0,如果输入是空数组,循环不会执行,直接返回空数组,边界情况天然正确。
4. JavaScript 实现与细节
4.1 sort 函数的坑与正确的比较函数
JavaScript 的sort()大概是这道题里最大的“新手杀手”。默认情况下,sort()把元素转成字符串再按字典序排,所以[10, 9, 2].sort()的结果是[10, 2, 9],不是升序。这道题里分数如果超过 9,直接默认排序就是灾难。
正确写法是[...scores].sort((a, b) => a - b),传一个减法比较函数。顺便说一句,现代 JavaScript 引擎的排序是稳定排序,这在处理带索引的对象数组时很关键,但本题只是基本数字数组,稳定性不影响结果。
4.2 完整代码与闭包写法
function countStronger(scores) { const n = scores.length; const sorted = [...scores].sort((a, b) => a - b); const ans = new Array(n); function upperBound(target) { let left = 0; let right = n; while (left < right) { const mid = left + ((right - left) >> 1); if (sorted[mid] <= target) left = mid + 1; else right = mid; } return left; } for (let i = 0; i < n; i++) { ans[i] = n - upperBound(scores[i]); } return ans; }和 Java 版本比,JavaScript 的两个特点很明显:一是sort后面必须跟比较函数;二是upperBound直接声明成内部函数,闭包捕获 sorted 和 n,不用每次传参。代码整体更短,但逻辑完全一样。
4.3 位运算与内存细节
代码里(right - left) >> 1用的是位运算右移,等价于Math.floor((right - left) / 2)。在二分中这样的写法很常见,效率略高,语义也清晰。不过要提醒一下,数组长度不可能超过 2^31 - 1,所以(left + right)用位运算也没有溢出风险,但为了风格统一,我还是写了left + ((right - left) >> 1)。
另外注意new Array(n)创建的是稀疏数组,里面每个槽位都是 empty 而不是 0,但因为我们随即逐位赋值,所以不影响最终结果。如果想更稳妥,也可以用new Array(n).fill(0),实测在性能上没有本质区别,看个人习惯。
5. Python 实现与细节
5.1 bisect 模块的正确用法
Python 的sorted()返回新列表,原数组不受影响,这个特性让克隆这一步天然被省略。二分则可以直接用标准库bisect模块,其中bisect_right返回“插入位置”,保证插入后数组仍然有序,如果数组里有多个等于 x 的数,它返回最右边那个位置之后的下标。
这就是我们说的 upperBound。注意不要用bisect_left,它返回的位置在最左边相等元素处,拿它做n - pos会把同分的人也计入“更强”的人,结果错得隐蔽。
5.2 完整代码与列表推导式
from bisect import bisect_right def count_stronger(scores): sorted_scores = sorted(scores) n = len(scores) return [n - bisect_right(sorted_scores, x) for x in scores]整道题的核心逻辑被压缩成一行列表推导式,这是 Python 最擅长的表达方式。bisect_right(sorted_scores, x)返回第一个大于 x 的位置,n减去它就是右边元素的个数。列表推导式会为每个 x 创建一个新列表元素,最终整个返回值就是目标数组。
我实测这个版本在 10 万随机数据上只需要几十毫秒,数据量到百万级也在可接受范围内。真正要注意的反而是sorted_scores占的内存,输入数组本身一份,排序副本一份,峰值内存大概是原数组的两倍,这在 Python 里完全不算事。
5.3 频次累加版本的实现
Python 写频次累加有一种天然的流畅感,用collections.Counter先统计频次,再从高分往低分累加:
from collections import Counter def count_stronger(scores): freq = Counter(scores) greater = {} running = 0 for val in sorted(freq.keys(), reverse=True): greater[val] = running running += freq[val] return [greater[x] for x in scores]这个版本没有一个二分调用,核心是sorted(freq.keys(), reverse=True)从最高分往下扫,running记录已经扫过的所有分数的人数总和。每次遇到一个新分数档位 val,此刻running恰好是所有比 val 高的分数的人数,写入字典之后再把 val 自己的频次加进来。最后用字典查询对应到原数组的每个人。
5.4 Python 版注意事项
bisect_right的参数顺序是(数组, 待查值),别写反,我自己第一次用就吃过这个亏。- 列表推导式里的变量 x 是原始分数,每次调用
bisect_right都是 O(log n),整体 O(n log n),如果想进一步省常数,可以把排序后的数组转成字典再做一次遍历,但没必要,可读性优先。 - 输入为空列表时,列表推导式直接返回空列表,边界情况不用额外处理。
6. 进阶变体:如果“强”还跟位置有关
6.1 变体题目语义
有一类变体味道完全不同:数组 scores 本身就是排行榜顺序,从第一名排到最后一名,要求每个人只统计“我前面有多少人分数比我高”,不再看整个赛场的全局情况。这种统计常用在股票涨幅排行、选手名次变动分析里,数据含义更细。
同样的 [90, 85, 90, 100, 60],按这个规则,下标 2 的 90 分选手,前面比他高的只有 100 分,答案是 1;下标 1 的 85 分选手,前面只有 90 比他高,答案也是 1,但按全局统计是 3。所以变体版的答案和基础版完全是两码事。
6.2 树状数组思路
这个变体本质是动态求解:从左往右遍历,每次把当前分数插入一个能计数的结构,然后立刻查询“已插入的分数里有多少严格大于当前分数”。这比排序加二分多了一个“边插入边查询”的需求,朴素实现是 O(n²),而树状数组能把单次插入和单次查询都压到 O(log n)。
树状数组里存的是频次,下标是离散化后的分数排名。遍历到 scores[i] 时,先查询“当前已见过的所有分数里,有多少个排名大于当前分数”,这就是严格大于自己的前面人数;然后再把当前分数的排名加进树状数组。离散化时相同分数必须映射到同一个排名,这样才能保证同分不算“强于”对方。
6.3 BIT 的核心代码与多语言要点
class BIT { int[] tree; int n; BIT(int n) { this.n = n; tree = new int[n + 1]; } void add(int i, int v) { for (; i <= n; i += i & -i) tree[i] += v; } int sum(int i) { int s = 0; for (; i > 0; i -= i & -i) s += tree[i]; return s; } } public int[] countBeforeStronger(int[] scores) { int n = scores.length; int[] sorted = scores.clone(); Arrays.sort(sorted); Map<Integer, Integer> rank = new HashMap<>(); int idx = 1; for (int x : sorted) { if (!rank.containsKey(x)) rank.put(x, idx++); } BIT bit = new BIT(idx); int[] ans = new int[n]; for (int i = 0; i < n; i++) { int r = rank.get(scores[i]); ans[i] = i - bit.sum(r); bit.add(r, 1); } return ans; }这里ans[i] = i - bit.sum(r)的含义是:已经处理了 i 个人,其中排名不大于当前分数 r 的人数有bit.sum(r)个,剩下的人就是排名严格大于 r 的,也就是前面分数更高的人。之所以能这样算,正是因为相同分数共享同一排名,bit.sum(r)把同分者一并算到了“非更强”的那一侧。
JavaScript 实现 BIT 时,可以用普通数组模拟,把tree长度设为n + 1,所有下标从 1 开始。Python 实现 BIT 也类似,只是函数可以定义成嵌套函数,配合非局部变量tree。这类题的关键不在于语言,而在于理解离散化是树状数组的前置条件,排名一定要和分数一一映射,否则后面查询全是错位。
7. 常见问题与排查实录
7.1 高频错误速查表
| 错误现象 | 根因 | 解决办法 |
|---|---|---|
| 同分的人被算进“更强” | 用了大于等于或 bisect_left | 统一改用 upperBound / bisect_right |
| JavaScript 排序结果完全错误 | 没传比较函数,按字典序排了 | sort((a, b) => a - b) |
| 排序后原数组跟着变 | 直接对输入数组排序 | 先 clone 或 [...scores] 生成副本 |
| 二分结果偶尔对偶尔错 | 使用 binarySearch 处理重复值 | 手写 upperBound,语义确定 |
| 结果全是 0 | 把排序后的数组当作原数组输出 | 循环里仍用原始 scores 去查 |
| BIT 版本同分统计错误 | 离散化时没有去重 | 相同分数映射到同一个排名 |
7.2 一次让我排查了很久的二分问题
某次帮朋友看代码,他 Java 版本跑样例一直差 1,我看了一眼就发现问题在Arrays.binarySearch上。他的输入里好几个重复分数,排序后binarySearch每次都返回一个不确定的等于目标值的下标,他拿这个下标直接做减法,碰巧有一次下标落在最右边,结果就对了,其他时候全错。
这种“时好时坏”的 bug 最难查,因为它不是逻辑完全错误,而是建立在了一个随机性之上。我帮他改成手写upperBound之后,结果立刻稳定。后来我跟他说,凡是涉及到“第一个大于某值”这种语义,标准库的普通查找就别硬凑,老老实实写二分,或者语言里找bisect_right、upper_bound这类专门接口。
7.3 测试用例与验证方法
刷题时一定要准备一组覆盖边界的数据,我常用的有这几条:
- 空数组
[],期望[] - 单元素
[5],期望[0] - 全部同分
[5, 5, 5],期望[0, 0, 0] - 严格递增
[1, 2, 3],期望[2, 1, 0] - 逆序
[3, 2, 1],期望[0, 1, 2] - 重复值混合
[90, 85, 90, 100, 60],期望[1, 3, 1, 0, 4]
每次写完代码,先用这组用例过一遍,边界问题基本都能暴露。最后再用随机生成的 10 万级别数组对比暴力版本的结果是否一致,确认无误再提交。这一套验证习惯,比写任何花哨的测试框架都实在。
我个人在实际操作中的体会是,这三道语言实现里,Python 最容易写对,JS 最容易在 sort 上翻车,Java 最容易在 binarySearch 上踩坑。把这几个坑记牢了,下次遇到任何“统计比某某强/弱的人数”相关问题,都能直接套排序加二分的框架,再配合树状数组应对动态查询变体,基本不会再有压力。最后再分享一个小技巧:写二分前先在心里默念一句“严格大于用 right 区间的 upperBound,大于等于用 left 区间的 lowerBound”,很多边界问题从一开始就能避开。