OI-wiki 教程:C++ 无序关联式容器(unordered_map / unordered_set)哈希原理、冲突构造与自定义哈希函数

OI-wiki 教程:C++ 无序关联式容器(unordered_map / unordered_set)哈希原理、冲突构造与自定义哈希函数 OI-wiki 教程C 无序关联式容器unordered_map / unordered_set哈希原理、冲突构造与自定义哈希函数【免费下载链接】OI-wiki:star2: Wiki of OI / ICPC for everyone. 某大型游戏线上攻略内含炫酷算术魔法项目地址: https://gitcode.com/GitHub_Trending/oi/OI-wiki本文基于 OI-wiki 的 C 标准模板库STL容器专题展开系统讲解unordered_set、unordered_multiset、unordered_map、unordered_multimap四种无序关联式容器的底层哈希原理、平均/最坏时间复杂度、制造哈希冲突的攻击手法以及如何在竞赛中编写防 hack 的自定义哈希函数。读完本文你将能够根据题目场景正确选用无序容器并能写出在 Codeforces 等对抗性评测环境下不易被构造数据卡掉的自定义哈希实现。概述四种基于哈希的无序关联式容器自 C11 标准起四种基于哈希实现的无序关联式容器正式纳入了 C 的标准模板库中分别是容器允许重复键用途unordered_set否无序集合只关心「元素是否存在」unordered_multiset是无序多重集合允许等值元素共存unordered_map否无序映射键到值的对应关系unordered_multimap是无序多重映射同一键可对应多个值这四种容器与相应的关联式容器set/multiset/map/multimap在功能、函数接口等方面有诸多共同点。关于容器的整体分类可参见 STL 容器总览二者最大的不同点体现在底层实现与内部元素排列方式上普通的关联式容器一般采用红黑树实现见红黑树内部元素按特定顺序键的大小关系进行排序因此支持lower_bound、upper_bound等有序查询无序关联式容器则采用哈希方式存储元素内部元素不以任何特定顺序进行排序所以访问无序关联式容器中的元素时访问顺序也没有任何保证。由于无序关联式容器与相应的关联式容器在用途和操作中有很多共同点本专题不再赘述其具体成员函数插入、删除、查找、迭代器等这些内容可以参考关联式容器一节。??? note 编译器不支持 C11 的使用方法 在 C11 之前无序关联式容器属于 C 的 TR1 扩展。所以如果编译器不支持 C11在使用时需要在头文件的名称中加入tr1/前缀并且使用std::tr1命名空间。如#include unordered_map需要改成#include tr1/unordered_mapstd::unordered_map需要改为std::tr1::unordered_map如果使用using namespace std;则为tr1::unordered_map。OI-wiki 的 [C 标准介绍](https://link.gitcode.com/i/7c80264cc0e696e0e74fe57e6d581cec)指出NOI Linux 2.0 环境g 9.3.0默认支持 C14 并支持 C17因此目前国内主流竞赛环境早已不存在此兼容问题只有面对年代久远的 OJ 或编译器时才需要 TR1 写法。哈希存储 vs 红黑树排序本质差异采用哈希存储的特点使得无序关联式容器在平均情况下大多数操作包括查找、插入、删除都能在常数时间复杂度内完成相较于关联式容器与容器大小成对数$O(\log n)$的时间复杂度更加优秀。从本仓库的实战代码中也能观察到两者的分工。例如在 dp-of-dp_2.cpp 中同时包含了map与unordered_map头文件——对于需要按状态大小排序、枚举转移的场景使用map而对只做存在性判定的场景则可改用unordered_map换取平均 $O(1)$ 的访问。不过常数级优势是有代价的必须清醒认识到无序容器的两个致命弱点??? warning Warning 在最坏情况下对无序关联式容器进行插入、删除、查找等操作的时间复杂度会与容器大小成线性关系这一情况往往在容器内出现大量哈希冲突时产生。同时由于无序关联式容器的操作时通常存在较大的常数其效率有时并不比普通的关联式容器好太多。 因此应谨慎使用无序关联式容器尽量避免滥用例如懒得离散化直接将 unordered_mapint, int 当作空间无限的普通数组使用。制造哈希冲突构造数据使复杂度达到上界上文提到在最坏情况下对无序关联式容器进行一些操作的时间复杂度会与容器大小成线性关系。在哈希函数确定的情况下可以构造出数据使得容器内产生大量哈希冲突导致复杂度达到上界——这正是对抗性 OJ如 Codeforces 的 hack 机制中卡unordered_map的常见手段。libstdc 的默认哈希行为在标准库实现里每个元素的散列值是将值对一个质数取模得到的更具体地说是 gcc-mirror 仓库中libstdc-v3/src/shared/hashtable-aux.cc里的质数列表该质数表自 g 6 及以前与 g 7 及之后存在差异g 6 及以前版本的编译器这个质数一般是126271g 7 及之后版本的编译器这个质数一般是107897。因此可以通过向容器中插入这些模数的倍数来达到制造大量哈希冲突的目的。例如对于 g 7 环境向unordered_mapint, int中插入形如 $k \times 107897$ 的键所有键都会落到同一个桶中哈希表退化为链表或长冲突链插入、查找操作退化为 $O(n)$进而拖垮整个程序。这也解释了为什么 126271 和 107897 是 OI/ICPC 圈内知名的「卡 unordered_map 质数」——它们是 libstdc 桶数选择逻辑中实际使用的模数。自定义哈希函数从入门到防 hack使用自定义哈希函数可以有效避免构造数据产生的大量哈希冲突是竞赛中防御「哈希冲突攻击」的标准做法。最简形式重载operator()要想使用自定义哈希函数需要定义一个结构体并在结构体中重载()运算符像这样struct my_hash { size_t operator()(int x) const { return x; } };这种形式的哈希函数只做到了「让容器使用自定义哈希器」这一层其散列值仍等于键本身因此并不能抵御构造数据造成的冲突。引入随机化与 splitmix64防 hack 的关键为了确保哈希函数不会被迅速破解例如 Codeforces 中对使用无序关联式容器的提交进行 hack可以试着在哈希函数中加入一些随机化函数如时间来增加破解的难度。这样攻击者无法预知运行时实际使用的散列函数也就无法批量构造同桶数据。例如在 Codeforces 的一篇著名博客Blowing up unordered_map, and how to stop getting hacked on it中给出了如下基于 splitmix64 的哈希函数struct my_hash { static uint64_t splitmix64(uint64_t x) { x 0x9e3779b97f4a7c15; x (x ^ (x 30)) * 0xbf58476d1ce4e5b9; x (x ^ (x 27)) * 0x94d049bb133111eb; return x ^ (x 31); } size_t operator()(uint64_t x) const { static const uint64_t FIXED_RANDOM chrono::steady_clock::now().time_since_epoch().count(); return splitmix64(x FIXED_RANDOM); } // 针对 std::pairint, int 作为主键类型的哈希函数 size_t operator()(pairuint64_t, uint64_t x) const { static const uint64_t FIXED_RANDOM chrono::steady_clock::now().time_since_epoch().count(); return splitmix64(x.first FIXED_RANDOM) ^ (splitmix64(x.second FIXED_RANDOM) 1); } };其设计要点可以拆解为三层splitmix64 混合对键做多次「异或 移位 乘大质数常数」的雪崩式混合0x9e3779b97f4a7c15即黄金比例常数、0xbf58476d1ce4e5b9与0x94d049bb133111eb为精心挑选的乘数使得相邻键的散列值在二进制层面剧烈扩散大幅降低碰撞概率随机种子FIXED_RANDOM取chrono::steady_clock::now().time_since_epoch().count()程序运行时刻的时间戳计数作为静态常量种子每次运行程序得到的哈希函数都不同攻击者无法在赛前针对该程序的散列函数预构造冲突数据针对pair键的专用重载对两个分量分别做 splitmix64 后再异或合并并将第二个分量右移 1 位再异或避免(a, b)与(b, a)这类对称键产生相同散列值。将自定义哈希函数传入容器写完自定义的哈希函数后就可以通过如下定义方式将自定义的哈希函数传入容器unordered_mapint, int, my_hash my_map; unordered_mappairint, int, int, my_hash my_pair_map;即把哈希函数类型作为容器的第三个模板参数传入对unordered_set同理。此后容器内部所有桶定位都走my_hash默认的取模行为被完全替换。仓库实战OI-wiki 中无序容器的真实用法OI-wiki 仓库的多个算法代码示例都直接使用了无序容器可作为实战参考。自定义哈希的完整范例random_8.cpp 给出了一个结构完整、可直接编译运行的MyHash范例其mix64函数与上文 splitmix64 的后半段完全一致并且代码注释明确推荐使用chrono库的std::chrono::steady_clock::now().time_since_epoch().count()作为FIXED_RANDOMstruct MyHash { u64 mix64(u64 x) const { x (x ^ (x 30)) * 0xbf58476d1ce4e5b9; x (x ^ (x 27)) * 0x94d049bb133111eb; return x ^ (x 31); } size_t operator()(size_t x) const { static const u64 FIXED_RANDOM 0x9e3779b97f4a7c15; return mix64(x FIXED_RANDOM); } };随后以std::unordered_mapu64, u64, MyHash kvs;的方式使用与本文前述的模板参数传入方式完全一致。BSGS 离散对数unordered_mapint, int做快速查表在 bsgs.cppBSGS 大步小步算法求离散对数中使用std::unordered_mapint, int mp;记录 $g^i \bmod m$ 与指数 $i$ 的对应关系并通过mp.count(po1)在 $O(1)$ 平均时间内查询匹配的小步值。同样的用法还出现在 bsgs-mod-p.cpp、tonelli-shanks.cpp、discrete-logarithm-1.cpp 等数论示例中——这些场景的共同点是「键为整数、只做存在性判定与取值」正是unordered_map平均 $O(1)$ 优势的典型应用。复杂状态作键unordered_mapstate, int做状态压缩当合法状态稀疏时用哈希表存储状态能显著优化时空复杂度。plug.md插头 DP 专题明确指出在状压 DP 中合法状态可能是稀疏的可以使用std::unordered_map存储合法 DP 状态。fsm_3.cpp 中就用std::unordered_mapstate, int ids;以std::bitset91类型的 DFA 状态为键做去重编号配合ids.count(cr)判断状态是否已访问。值得注意这里state是自定义类型std::bitsetL能够直接作为键是因为std::hash对std::bitset提供了标准特化如果键是自定义结构体且未提供std::hash特化就需要像前文那样自定义哈希函数。经典数值问题中的使用inversion_1.cpp 使用std::unordered_mapint, int ids;对排列中的数值做离散化编号映射powerful-number.md 则指出在杜教筛等数论筛法中可以用std::map与std::unordered_map这类支持快速随机访问的数据结构记录较大的函数值避免重复计算。这些例子共同印证了无序容器在竞赛中的高频使用场景。使用建议与常见误区结合前文的理论与仓库实践总结出以下使用准则默认优先用普通容器只有在「键无序、只需要 $O(1)$ 平均查询/插入、且不存在被针对构造数据风险」时才选用无序容器需要有序遍历、lower_bound/upper_bound、找第 $k$ 大等操作时应坚持使用set/map红黑树实现。不要把unordered_map当无限数组unordered_mapint, int的内存开销远大于普通数组且常数较大、存在哈希冲突风险切勿因「懒得离散化」而滥用。对抗性环境必须自定义哈希在 Codeforces 等支持 hack 的平台上使用无序容器务必加上 splitmix64 随机种子如chrono时间的自定义哈希否则很容易被 126271/107897 的倍数卡成 $O(n)$。注意内存重分配C 引用/注意事项 指出std::unordered_map等容器的插入操作均有可能导致内存重新分配在性能敏感的大规模插入场景中可结合reserve()预留空间。运算符支持差异与set/map不同无序容器不支持、、、等字典序比较参见 STL 容器总览 的「共同点」一节因为它们不维护元素顺序。总结无序关联式容器是 C 标准库中以哈希为底层实现的「平均 $O(1)$」容器族。理解其与红黑树关联式容器的本质差异、最坏情况退化为线性的冲突风险、以及 libstdc 中 126271/107897 两个关键模数是安全使用它们的前提而自定义哈希函数结构体重载operator() splitmix64 混合 运行时随机种子则是应对对抗性评测环境的标准防御手段。OI-wiki 仓库中的 random_8.cpp、bsgs.cpp、fsm_3.cpp 等示例提供了可直接参考的完整实现建议读者在本地编译运行以加深理解。【免费下载链接】OI-wiki:star2: Wiki of OI / ICPC for everyone. 某大型游戏线上攻略内含炫酷算术魔法项目地址: https://gitcode.com/GitHub_Trending/oi/OI-wiki创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考