%% # 纲要 > 主干纲要、Hint/线索/路标 - 哈希表结构 - 哈希冲突及解决办法 1. 链地址法 2. 开放地址法 - 扩容(Rehashing) - 负载因子 - 自定义哈希函数 # Q&A #### 已明确 #### 待明确 > 当下仍存有的疑惑 **❓<font color="#c0504d"> 有什么问题?</font>** # Buffer ## 闪念 > sudden idea ## 候选资料 > Read it later %% # 哈希表 | 散列表 哈希表是一种存储 **键值对(Key-Value)** 的数据结构,简单来说 = **哈希函数** + **数组**。 数组中每项称为一个**桶或槽**(bucket),可能实际对应一个链表(采用链地址法时),也可能就是只存单项元素(开放地址法时)。 对于给定 key,通过**哈希函数**映射得到 **==桶的索引值==**,在该桶中存储元素 value,即 `index = hash(key) % table_size`,**时间开销为 $O(1)$**。 <br> # 哈希冲突 **哈希冲突**——**不同的键**(Key)经**哈希函数计算的所得索引值**相同,即映射到了哈希表中的**同一个槽位**(同一个桶),也称之为 "**==碰撞==**"。 > [!example] 例如,以取模 `%5` 作为哈希函数,则 `key=7` 与 `key=12` 经哈希函数映射所得索引值均为 2,即为哈希冲突。 两种主要的哈希冲突解决方法: 1. **链地址法**(Separate Chaining):每个桶维护一个链表,冲突元素挂在链表中。 2. **开放地址法**(Open Addressing):所有元素直接存储于哈希表数组中,冲突时按特定**探测策略**寻找下一个空槽。 > [!info] C++中 `unordered_set` 与 `unordered_map` 底层实现采用 "**==链地址法==**" 解决哈希冲突。 <br> ## (1)链地址法 链地址法:**每个桶** 用 **一个链表** 存放所有映射到这个位置的冲突键值对。 > [!note] 链地址法示意 > > 注:下图是逻辑示意 > ![[_attachment/00-数据结构与算法笔记/数据结构/哈希表.assets/IMG-哈希表-03D468B5D792B94FEBA063DC3E900A68.png|246]] #### 探测过程说明 1. 用哈希函数计算**初始索引位置**:`index = hash(key) % table_size` 2. 在该槽位对应的**链表上==线性查找==**。 #### 底层操作说明 以 GCC libstdc++为例,`unordered_map` 的底层结构是 `_Hashtable`,其设计中: - **所有元素** 存储在一个 **全局==单向链表==** 中(从而支持 **==迭代器==** `begin()`、`end()` 遍历) - **哈希表** 底层数组实际上是一个 **==指针数组==**,**每个桶**(bucket)存储一个 **指针**,指向该桶 "**逻辑上对应的==链表的首节点==**" ; - **节点结构**为 `_Hash_node` ,其中 `_M_next` 成员指向的是该节点 "**逻辑上所在桶中的后一元素**" 。 **插入**元素时,实际过程为: 1. 新建节点,将其 "**插入到全局链表头部**" (头插法,`_M_before_begin` 之后) 2. 哈希映射计算得到 **桶索引**,同样采用**头插法**,**插入上述新节点** 伪代码示例: ```c++ __node_type* new_node = allocate_node(...); // 1. 插入到全局链表中(用于迭代器) insert_node_after(_M_before_begin, new_node); // 2. 插入到桶链表中(用于哈希) new_node->_M_next = _M_buckets[bucket_index]; _M_buckets[bucket_index] = new_node; ``` <br> ## (2)开放地址法 开放地址法:冲突时,**在表中寻找一个空位置存放**,常用 **==探测策略==** 包括三种: - **线性探测**——从冲突位置往后,**逐个位置**检查(步长为 1) - **二次探测**——**步长按平方增长**; - **双重哈希**——使用**第二个哈希函数**来决定 "**步长**"; 该方法下,需要为每个槽增加一个 "**==状态标记==**",通常是三种: - `EMPTY`:从未被使用 - `OCCUPIED`:正常放置了一个元素 - `DELETED`:曾经放过元素,但其已被删除(目的在于**维护探测路径的连续性**,查找过程中遇见该状态的槽时,**继续探测而不停止**) #### 探测过程说明 1. 首先,用哈希函数计算**初始索引位置**:`index = hash(key) % table_size` 2. 若位置非空,则采用上述策略**查找其他空位**: - **线性探测**:索引值为 `index = (hash(key) + i) % table_size`,其中 `i = 0, 1, 2, ...` ,即**步长为 1**,**往后的位置挨个检查是否为空**。 - **二次探测**:索引值为 `index = (hash(key) + c1*i + c2*i*i)) % table_size`,其中 `i = 0, 1, 2...`,即**步长随着 `i`按平方增长**。 - **双重哈希**:索引值为 `index = (hash1(key) + i * hash2(key)) % table_size`,即**步长为 `i*hash2(key)`,由第二个哈希函数根据 key 确定**。 > [!caution] 线性探测容易造成聚集效应,另外两种方法会更好,双重哈希开销会略大点 #### 底层操作说明 - 在**向表中插入元素** & **查找元素** 时,开放地址法**必须==按照插入时的探测序列==逐个查找下去**,直至命中元素,或者**找到一个 ==`EMPTY` 位置==**。 - 因此,如果**表已较满**(负载因子较高),会发生连续冲突,**最坏情况下可能会探测整张表**(例如线性探测)。 - 元素删除时,会**将其所在槽位标记为 `DELETED`** 状态(而不是 `EMPTY`),从而**维护探测序列的连续性**(防止切断其它元素的探测路径) - 插入新元素时,若在探测路径上遇到 `DELETED` 槽,即可**插入==复用==**。 <br><br><br> # 扩容(Rehashing) 随着哈希表内元素变多,冲突概率上升,哈希表性能会下降。 此时,需要**扩大哈希表容量**,**让哈希表中元素分布更稀疏**,从而减少冲突保持性能,该过程称之为 "**==Rehashing==**" 扩容。 哈希表通常通过 "**==负载因子==**" 来判断是否需要扩容,例如在**负载因子 > 0.75 时** 触发扩容。 > [!info] C++中 `unordered_map` 与 `unordered_set` 的负载因子默认阈值是 `1.0`,即平均每个桶里都装有一个 1 个元素时扩容 > > - 可通过 `.max_load_factor()` 函数手动修改; > - 可调用 `.rehash(新桶数量)` 函数手动扩容。 #### 扩容过程 1. 按 "**扩容因子**" 创建一个更大的新哈希表(例如扩容因子为 2,则是容量翻倍) 2. 遍历旧哈希表中所有元素,**重新经哈希函数计算得到 bucket索引值**,**插入新表**中; 3. 删除旧表,释放旧表内存。 <br> ## 负载因子(Loader factor) 负载因子用以衡量 **哈希表当前填充程度**,其值为哈希表中**当前元素数量** (`size`) 与**桶数量**(`bucket_count`)之比: $ \large \text{load factor}=\frac{元素数量}{\text{bucket}\,{数量} } $ 高负载因子下,意味着**哈希冲突**可能性增高。 > [!caution] 负载因子是一个全局平均值,并不能完全反映 "局部" 哈希冲突的程度。 > > 例如,在链地址法下,可能绝大部分是空桶,而某个桶的链表非常长(局部冲突很严重),但**反映到负载因子上可能并不高**。 <br><br> # 自定义哈希函数 C++中有两种方式为 "**无序容器**" 提供自定义哈希函数: - (1)提供自定义 "**哈希函数对象**" - (2)提供**自定义的 `hash<MyClass>` 模版特化**;该方式下,**无需在 "==容器类型==" 中显式指出哈希函数对象类型**,也不用传入函数对象。 <br> ### 方式一:提供自定义 "哈希函数对象" ```cpp #include <iostream> #include <unordered_set> #include <functional> #include <utility> using namespace std; // 自定义类型 struct Point { int x; int y; // 需要定义==, 供无序容器判断两个Pointer是否相等 bool operator==(const Point& other) const { return x == other.x && y == other.y; } }; // 自定义哈希函数 struct PointHash { size_t operator()(const Point& p) const { // 使用`std::hash<>`为int类型生成哈希值 // 利用异或、位移操作, 变换哈希值, 减少哈希冲突. return std::hash<int>()(p.x) ^ (std::hash<int>()(p.y) << 1); } }; int main() { auto MyHash = [](const Point& p) -> size_t { return std::hash<int>()(p.x) ^ (std::hash<int>()(p.y) << 1); }; // 以Point类型为键, string为值的哈希表 // 方式一: 接收函数对象 unordered_map<Point, string, PointHash> pointMap; // 方式二: 接收lambda unordered_map<Point, string, decltype(MyHash)> pointMap2(0, MyHash); // 初始化创建0个桶, 指定自定义哈希函数. // 插入元素 pointMap[{1, 2}] = "Point A"; // `[]`里用到了"聚合初始化". pointMap[{3, 4}] = "Point B"; pointMap[{1, 2}] = "Updated Point A"; // 遍历并输出 for (const auto& [key, value] : pointMap) { cout << "Point(" << key.x << ", " << key.y << "): " << value << endl; } // 查找特定键 Point searchKey {3, 4}; if (auto it = pointMap.find(searchKey); it != pointMap.end()) { cout << "Found: " << it->second << endl; } } ``` <br> ### 方式二:提供自定义的 `hash<MyClass>` 模版特化版本 为 STL 中的无序容器,**提供自定义的 `hash<MyClass>` ==模版特例化==版本**。 ```cpp #include <iostream> #include <unordered_set> #include <hash_fun.h> using namespace std; class Sales_data { friend class std::hash<Sales_data>; // 需要将hash<Sales_data>类声明为友元 public: Sales_data(string s, unsigned u, double re) : bookNo(s), units_sold(u), revenue(re) {} bool operator==(const Sales_data& rhs) const { return this->bookNo == rhs.bookNo && this->units_sold == rhs.units_sold && this->revenue == rhs.revenue; } private: string bookNo; unsigned units_sold; double revenue; }; // 必须在原模版所在的命名空间中进行特例化! namespace std { // 示例: 定义hash模版的一个特例化版本, 可供在无序容器中散列Sales_data对象. template <> struct hash<Sales_data> { // 用于在无序容器中进行散列时, 要求必须定义的两个类型成员: `result_type`与`argument_type` // 无序容器将使用`argument_type`作为其key_type, 并在key_type上执行相等运算符. typedef size_t result_type; typedef Sales_data argument_type; size_t operator()(const Sales_data&) const; }; // 类外, 为全特例化的类模版定义成员函数时, 不需要模版声明`template <...>` size_t hash<Sales_data>::operator()(const Sales_data& s) const { return hash<string>()(s.bookNo) ^ hash<unsigned>()(s.units_sold) ^ hash<double>()(s.revenue); } } // end namespace std // 当hash<>的特例化版本在作用域中时, 定义无序容器时将自动使用 // 将使用hash<Sales_data>特例化版本, 以及Sales_data类的operator==. unordered_set<Sales_data> SDset; int main() { Sales_data obj1("Hello", 2, 3.14); Sales_data obj2("Hello", 3, 1.7); SDset.insert(obj1); SDset.insert(obj2); cout << SDset.size() << endl; } ``` %% # 哈希表 trick ### 数组哈希 例如,按值域开一个数组充当哈希表,以下标为 key,对应位置的元素值为 value。 %% # 参考资料 # Footnotes