☰
Rank List排名实现:用map与哈希表搞定键值映射与查询
2026/10/17 8:24:33 网站建设 项目流程

做OJ题或者帮人排比赛成绩的时候,“Rank List”(排名名单)这个需求简直是标配:一堆学生ID和成绩丢进来,要么让你输出完整排名,要么让你随时查某个人的名次。我自己第一次认认真真用map解决这类问题时,才意识到这玩意儿到底解决的是什么问题——它根本不是“能存数据的数组”的替代品,而是把“键值映射”这个思路真正落地的容器。这篇文章就把 Rank List 的完整实现拆开讲透,顺手把map这个容器理解透,适合刚学数据结构与算法、或者刷题时对容器选型总犹豫的朋友看。

1. Rank List 问题的真正难点在哪里

1.1 一个看似简单的问题

先把这个题目说清楚。输入一堆记录,每条记录是一个人的ID和成绩,ID往往是字符串(学号、昵称),成绩是整数或带小数。你需要做两件事:

  1. 按成绩从高到低输出所有人的排名,名次要正确。
  2. 支持按ID反查某个人的排名和成绩,比如比赛间隙不断有人问“A同学现在排第几”。

听起来很简单?排序嘛。但第一个坑马上就来了:只排序,你排完还得知道每个ID对应的名次是多少。数组排序后,你只能通过下标取到人和成绩,但你要按ID反向查排名,就得在排序结果里再扫一遍ID找匹配。如果学生数量是几百个,扫一遍没问题;如果是几万、几十万,每次查询都O(n)扫,实际体验就非常糟糕。

1.2 朴素方案为什么不行

最朴素的思路是用两个数组分别存ID和成绩,排序时把两者的下标一起交换。这样能解决“按成绩排序”的问题,但查询还是难受。你甚至可能想用一个二维数组存下排名,可是排名的顺序是排序之后才确定的,数组天然是按下标索引的,而ID是字符串,你没法把“字符串ID”直接当作数组下标去快速定位。

这里最简单的做法是写一个循环:

for (int i = 0; i < n; i++) { if (vec[i].id == query) { rank = i + 1; break; } }

几百次查询,每次遍历一遍,几万人的榜单就是几亿次比较,卡到怀疑人生。问题的本质是:你需要的不是一个“能排序的数组”,而是一个“按ID快速定位”的结构。而map恰好就是“按任意键快速定位对应值”的容器——它存储的是键值对,ID作为键,成绩或名次作为值。

1.3 map 解决问题的切入点

map解决这个问题的切入点非常直接:先排序拿到名次,再建立“ID -> 名次”的映射关系。建立完成后,查询一个人的排名就是一次O(logn)的查找(C++map),在内存条件允许时甚至可以用哈希表做到平均O(1)。这样你既能输出完整榜单,又能随时候查,两者互不干扰。

后面的完整实现会证明,这个思路写出来代码特别干净:排序管排序,映射管映射,互不掺和。

2. 先把 map 理解透:不只是“能存东西的数组”

2.1 从数组下标到任意键:映射的本质

很多人学C++接触map时,会下意识把它想成“一个大数组”。但有两点完全不同:

  • 数组下标必须是整数,map的键可以是字符串、对象、任何可比较的类型。
  • 数组是连续内存,按下标直接定位;map是按树结构组织,按键的大小关系快速定位。

用生活化类比来说,数组像是宿舍楼里按床位号找人的方式:床位号是固定的整数。而map更像学生档案柜,每个档案袋外面贴着一个学号(键),打开袋子里面是个人信息(值)。你想找某个人,不是把几百个档案袋全翻一遍,而是顺着学号排列顺序快速定位到某一个。

这种“一个键对应一个值”的结构,在程序里就叫关联数组、字典,或者映射表。它会自动维护“键唯一”这个约束——同一个键第二次插入时,如果是operator[],会直接覆盖旧值;如果是insert,则不会覆盖,这在实际处理后出现重复ID的记录时非常关键。

2.2 底层结构决定行为:红黑树与哈希表的区别

C++ STL 里有两个长得像但性格完全不同的容器,map和unordered_map,命名都有关系但行为完全不同。

map的底层是一棵红黑树,属于平衡二叉搜索树的一种。它要求键和键之间能比较大小(有<运算符),插入、删除、查找的复杂度都是O(logn)。因为底层是树,map天然可以按键从小到大遍历,也可以快速找到大于等于某个键的第一个元素(lower_bound/upper_bound)。

unordered_map的底层是哈希表。它要求键能计算哈希值(有哈希函数),查找、插入的平均复杂度是O(1),但代价是不保证顺序。你在遍历unordered_map时看到的东西是乱序的,而且遇到哈希冲突严重的情况,它会退化到链表甚至红黑树,性能可能反而比map更差。

这两个容器选哪个,核心看一个需求:你需不需要按键有序地遍历或者区间查询。需要有序,选map;只需要按Key查值,不在乎顺序,选unordered_map。

map和unordered_map的差异对照表:

对比维度mapunordered_map
底层结构红黑树(平衡二叉树)哈希表
键是否有序有序,可正反遍历无序
查找复杂度O(logn)平均O(1)
插入复杂度O(logn)平均O(1)
区间查询能力支持 lower_bound/upper_bound不支持
对键的要求需要支持<比较需要支持哈希

在 Rank List 这个场景里,如果你只需要“ID查名次”,两个容器都能用;但如果后面要“查看成绩大于等于某分的所有学生名单”,map的有序性就是刚需。

2.3 map 的使用细节与常见误区

map的核心操作就那几个:

map<string, int> scoreMap; // 定义:键是string,值是int scoreMap["A001"] = 90; // 插入/覆盖值 int s = scoreMap["A001"]; // 取值,键不存在时这个操作会插入一个默认值 scoreMap.insert({"A001", 95}); // 插入,键已存在时不覆盖 scoreMap.count("A001"); // 返回0或1,判断键是否存在 scoreMap.find("A001"); // 返回迭代器,找不到返回end() scoreMap.erase("A001"); // 删除

这里最容易踩的坑是operator[]的“副作用”:scoreMap["A999"]如果这个键不存在,它不会报错,而是悄悄插入一个成绩为0的记录。所以在“查某个ID是否存在”时不要用operator[],要用find或count,否则榜单会被你查出一堆不存在的人。

另一个常见误区是遍历时修改键的值没问题,但不要修改键本身。比如你不能for(auto& kv : mp) kv.first = "xxx";,因为map内部按键有序组织,直接改键会破坏树的结构,这是未定义行为。

3. Rank List 完整实现:从数据到名次

3.1 数据结构设计

我在处理这种题时,通常只用两个结构:一个vector存原始记录并参与排序,一个map用来快速反查。

先定义一个记录结点:

struct Node { string id; int score; int rank; // 名次,排序后填上 };

为什么要把 id、score、rank 放一起?因为排序后位置变了,放一起方便输出榜单时直接打印名次。再用一个map<string, int>存“ID->名次”供查询。

不过这里有个小技巧:如果内存充足,我还会再建一个map<string, int> scoreMap存“ID->成绩”。因为有时候用户不只查名次,还想顺带查成绩。但两个map显得有些冗余,更省内存的做法是只存名次,查成绩时通过scoreMap和rankMap搭配:名次=rankMap[id],分数则可以在名次确定后通过分数map查。实际写代码我会优先保证代码清晰,多一个小map在几万条数据下根本不算什么。

3.2 建立映射与读入数据

输入一段可能是这样的:

int n; cin >> n; vector<Node> people(n); map<string, int> rankMap; for (int i = 0; i < n; i++) { cin >> people[i].id >> people[i].score; }

这里不需要在读入时马上建映射,因为名次还没确定。等排完序、名次算好后再建映射,逻辑最顺。

如果你遇到一种情况:输入里同一个ID出现多次(比如多次提交取最高分),你得提前去重。我的做法是读入时用另一个map<string, int> latest存每个ID的最高成绩,循环判断一下:

map<string, int> maxScore; for (int i = 0; i < n; i++) { string id; int score; cin >> id >> score; if (!maxScore.count(id) || maxScore[id] < score) { maxScore[id] = score; } }

这样就把重复记录压缩成了“一人一个最高分”,再把maxScore里的记录搬回vector参与排序。

3.3 按成绩排序并处理并列名次

排序直接用sort,自定义比较器,成绩高的排前面,成绩相同就按ID字典序排(这样榜单稳定性更好):

sort(people.begin(), people.end(), [](const Node& a, const Node& b) { if (a.score != b.score) return a.score > b.score; return a.id < b.id; });

排序之后算名次。这里名次规则有两种:一种是严格名次,分数相同的人名次相同,然后跳过占用的名次(比如 1, 2, 2, 4),另一种是并列时都取同一个名次,后面的按照顺序连续编号(1, 2, 2, 3)。竞赛榜单一般用第一种,我按这个来:

int rank = 0, count = 0; int lastScore = -1; for (auto &p : people) { count++; if (p.score != lastScore) { rank = count; lastScore = p.score; } p.rank = rank; }

这个逻辑的妙处在于:count 记录的是已经处理的总人数,只有当分数变化时才把当前人数赋给 rank。两个并列第二的人,count 跑到3,分数变化时 rank 才等于3,于是出现 1, 2, 2, 4。如果你希望并列名次后下一个名次是3而不是4,把rank = count改成rank++就行,看榜单具体要求灵活调整。

此时,榜单已经完整了,按顺序输出vector即可。真正要做的“反向按ID查名次”还没解决。

3.4 建立ID到名次的映射并查询

榜单排完名次之后,把结果灌进map:

for (auto &p : people) { rankMap[p.id] = p.rank; }

这样这个map就承载了“ID->排名”的反向索引。之后任何时刻查询,只需要:

string query; while (cin >> query) { auto it = rankMap.find(query); if (it != rankMap.end()) { cout << query << " " << it->second << "\n"; } else { cout << query << " 未上榜\n"; } }

为什么这里用find而不是operator[]?前面说过,operator[]在键不存在时会插入默认值,查询一次就把一个不存在的ID写进map了,之后榜单数据会越查越脏,输出量也莫名增加。这算是我踩过的很典型的坑了。

完整代码合到一起,整体结构非常干净。如果后面再加一个“按成绩查所有达到该成绩的人名”的需求,就在排序前的people上再建一个multimap<int, string>,按分数分组,按键有序,输出一个分数段的人就是一次二分定位加顺序遍历。

4. 真实场景与常见错误排查

4.1 并列名次导致的预期偏差

在线榜单系统里,最常被运维投诉的问题就是并列名次规则不一致。有的比赛要求“同分选手名次并列,后位名次跳号”,有的要求“同分后下一名还是连续编号”。这两种规则下,代码只需要改一行,但很多人实现时把排名写成了排序时的下标i+1,完全忽略了并列的情况。

我推荐在需求确认阶段就把名次规则确定下来,是竞赛榜(1, 2, 2, 4)还是自然榜(1, 2, 2, 3),写进注释里。

还有一种情况是成绩相同但ID相同,去重时如果用的是“取最后一次”逻辑,而题目要求“取最高一次”,结果会完全不同。我在 3.2 用的去重逻辑是取最高分,如果你没做这一步,一个人会出现多条记录,榜单上会有同一个ID的好几个名次——map不会报错,它只会保留最后一次插入或覆盖的值,最终效果取决于覆盖顺序,极易出错。

4.2 迭代器失效与引用陷阱

map和vector的迭代器失效规则完全不同,这个很多人会搞混。map的插入和删除操作不会导致其他迭代器失效(除了被删除的),这点比vector温和得多。但有一个场景很容易出事:在遍历map的过程中插入或擦除当前迭代器指向的元素,如果不小心把删除操作写在下一行继续用旧迭代器,程序会直接崩溃。

比如错误写法:

for (auto it = mp.begin(); it != mp.end(); it++) { if (...) mp.erase(it); // 迭代器it已经失效,循环无法自增 }

正确写法是借用返回值提前保存下一个迭代器:

for (auto it = mp.begin(); it != mp.end(); ) { if (...) it = mp.erase(it); // erase返回下一个迭代器 else it++; }

在 Rank List 的场景里不太会频繁删除,但如果你是做一个在线榜单,需要不断有人退赛、删除掉落榜记录,这段代码早晚会遇到。

4.3 大规模数据下的性能选择

前面讲map查找复杂度是 O(logn),unordered_map平均 O(1)。当榜单人数达到几十万级,查询次数又特别多时,用unordered_map会明显快一截。但要注意,unordered_map需要一个好的哈希函数,字符串作为键时,默认的std::hash<string>已经够用,不必自己乱写。

如果你既需要“按ID查名次”,又需要“按键有序输出”,最理想的组合是:

  • unordered_map<string, int>负责高频查询。
  • vector<Node>排好序负责输出榜单。

这样查询和遍历互不干扰。如果直接把map当唯一存储结构,一边遍历输出还是要敲一遍数据,绕。

另外,operator[]和find的性能差异也要注意。find只做查找,operator[]会在找不到时插入默认值,引发额外内存分配和树结构调整。在大量查询的场景下,这个差异会被放到很大。

4.4 常见错误速查表

现象可能原因解决思路
编译报错,string不能当map下标没有包含<map>和<string>补头文件
自定义结构体当键编译失败结构体没有重载<运算符自定义比较器,或用tuple
查询榜单时多出很多成绩为0的人operator[]自动插入了空键查询一律用find/count
榜单名次是1,2,3,没体现并列分数用i+1直接赋名次改用分数变化判断名次
同样ID出现多行输入未去重先按ID聚合,取最高分
大量查询超时map的O(logn)叠加查询次数太多改用unordered_map,或预生成结果

这张表是我在实际调Rank List类题目时真实的踩坑记录,很多人写完看着能跑就对,一上线遇到奇葩输入就崩,多数都是表里这几类问题。

5. map 在盘点统计类问题里的扩展用法

5.1 高频词统计:一次遍历自动建表

Rank List 的本质,其实是一个“按键统计值”的模式。把它推广出去,map最常见的另一个用法就是统计数组中每个元素出现的次数:

map<string, int> freq; for (auto &s : words) freq[s]++; // 输出出现次数最多的词 for (auto &[k, v] : freq) { if (v == maxCnt) cout << k << "\n"; }

这里operator[]天然适合做累加统计:第一次访问不存在的键时会插入初始值,第二次再访问时就能在初始值上加一。不过要注意,freq[s]++这个操作本质上也会在键不存在时插入一个值为0的记录再自增,所以统计量本身不会出错,这正是它适合累计计数的原因。

如果只需要统计次数不需要排序,我更愿意用unordered_map,省掉每次插入时的O(logn)树调整成本。处理百万级词语时差距很明显。

5.2 双向映射的两种方案

有些场景需要“ID查人名”和“人名查ID”都能快速完成。只靠一个map做不到双向,因为键唯一、值不一定唯一,而且map只支持按键查询。这时有两条路:

  • 建两个map,一个map<string, int>和一个map<int, string>,空间换时间。
  • 写一个双向映射类,插入时同时维护两个map,但要小心值重复时覆盖语义。

我用过的偏门技巧:如果ID和人名都是唯一的整数,可以用两个vector互相当索引,按下标互相查;但如果键是字符串,老老实实建两个map最省心。记住一个判断标准:一个应用中查询频率远超插入频率,就值得为每个方向各建一个索引。

5.3 利用有序性做区间统计

map的真正价值,在需要“按范围查询”时最能体现。比如这个排名需求扩展一下:给出一个分数线,输出所有分数大于等于该线的参赛者名单。因为map内部按键有序,可以用lower_bound:

map<int, vector<string>> scoreToNames; // score -> 名单 auto it = scoreToNames.lower_bound(threshold); for (; it != scoreToNames.end(); it++) { for (auto &name : it->second) cout << name << "\n"; }

这里为了让分数作为键、且能让多个名字共同拥有同一个分数,用了map<int, vector<string>>,分数从高到低还是从低到高取决于你自己的存储方式。lower_bound定位到第一个不小于阈值的键,从它往后遍历,就是一个完整的目标区间。这个操作天然是 O(logn + k) 的,k是输出结构大小,数据量很大时远比扫全榜快。

5.4 结构体作键时的一点心得

榜单有时要用复合键,比如“比赛组+学号+题目编号”共同定位一条记录。直接用结构体当map的键时,必须提供严格弱序比较规则。C++ 17 以后,我比较推荐用std::tuple避免手写一堆operator<:

struct Record { string team; string uid; int problemId; int score; }; map<tuple<string, string, int>, int> scoreMap; scoreMap[{r.team, r.uid, r.problemId}] = r.score;

tuple自带按元素依次比较的语义,天然满足严格弱序要求,省事不易错。如果你要按struct当作键且结构体里只有两个字段,pair的默认比较也够用,三个及以上字段就交给tuple,代码会干净不少。

6. 写在最后的几点体会

Rank List 这个题本身不难,但它是一个非常典型的索引思想练习:数据一旦排好序,如何快速反查,是几乎每天都在工程里遇到的需求。map不是银弹,用对了是利器,用错了会在性能上栽跟头。我个人的习惯是:默认先用unordered_map解决“查”的需求,只有当有序输出或区间查找出现时才切回map。遇到并列名次、重复ID这类业务规则,一定要先问清楚定义再动手写代码,不然返工成本比写代码本身还高。

最后分享一个小技巧:给map取名时,用能体现“键->值”含义的名字,而不是叫mp、mm。比如idToRank、scoreCount、maxScoreByName,这样一周后你回头看自己的代码,不用读逻辑就能知道每个容器是干什么的。这个习惯,做小题目时看不出差别,可以后维护真正的业务系统时才觉得值。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询