【C++】《unordered系列关联式容器以及哈希底层、哈希冲突、闭散列与开散列完整解析》

一.unordered 系列关联式容器

在 C++98 中,STL 的map、set底层基于红黑树实现,查找复杂度O(logN),查找次数等于树高,数据量很大时查询效率存在局限。为了获得更快的平均查找速度,C++11 引入 4 种 unordered 哈希关联容器: unordered_map/unordered_set/unordered_multimap/unordered_multiset,底层是哈希表

**遍历特性:**不保证 key 有序;迭代器为单向前向迭代器,不能逆向遍历。


Key 重复规则

unordered_map、unordered_set:key 唯一,不能重复

unordered_multimap、unordered_multiset:允许 key 重复

\[\] 下标操作

unordered_map支持\[\];unordered_set 没有 \[\] 运算符

multiset、multimap 由于 key 可重复,不能使用\[\]

  • 复杂度:平均(O(1),最坏哈希冲突严重时O(N);红黑树容器稳定O(logN)。
  • 使用取舍:需要有序选 map/set;追求平均查找速度、无需有序选 unordered 系列。

unordered 系列的关联式容器之所以效率比较高 ,是因为其底层 使用了哈希结构。 在一般情况下,建议使用 unordered 系列的关联式容器。


1.unordered_map

(1)unordered_map介绍

unordered_map - C++ Reference

【翻译如下】

  1. unordered_map 是关联式容器,存储由键(key) 和**映射值(mapped value)**组合而成的元素,能够根据键快速检索单个元素。
  2. 在 unordered_map 中,键一般用来唯一标识元素;映射值是和该键关联的内容对象。键的类型和映射值的类型可以不相同。
  3. 内部存储特点:容器内元素不会按照键或者映射值进行排序。元素依靠哈希值分到不同桶(buckets)中,从而支持通过键直接快速访问元素,平均时间复杂度为常数级别 O (1)
  4. 与 map 对比:通过键访问单个元素时,unordered_map 速度比 map 更快;但是遍历一段区间元素时,整体效率通常更低。
  5. 支持下标运算符operator[],可以传入键,直接访问对应的映射值。
  6. 迭代器类型:容器提供的迭代器至少是前向迭代器(forward iterators,单向迭代器)

(2)unordered_map 的接口说明

a. 构造函数

https://cplusplus.com/reference/unordered_map/unordered_map/unordered_map/

cpp 复制代码
#include <iostream>
#include <unordered_map>
using namespace std;

int main() {
    // 1. 空构造,后面再赋值
    unordered_map<string, int> m1;
    m1["a"] = 1;
    m1["b"] = 2;

    // 2. 直接初始化
    unordered_map<string, int> m2 = {{"x", 10}, {"y", 20}};

    // 3. 拷贝构造
    unordered_map<string, int> m3 = m2;

    // 输出看看
    cout << "m1[a] = " << m1["a"] << endl; 
    cout << "m2[x] = " << m2["x"] << endl;  
    cout << "m3[y] = " << m3["y"] << endl; 

    return 0;
}

b. 容量函数

cpp 复制代码
#include <iostream>
#include <unordered_map>
using namespace std;

int main() {
    unordered_map<string, int> score;

    // 判断是否为空的
    if (score.empty()) {
        cout << "还没人得分呢" << endl;
    }

    // 放几个数据进去
    score["hjq"] = 95;
    score["hz"] = 87;

    // 再瞅瞅
    cout << "现在有 " << score.size() << " 个人的成绩" << endl;

    if (!score.empty()) {
        cout << "有人得分了" << endl;
    }

    return 0;
}

c. 迭代器

  1. **begin/end:**可读写迭代器;
  2. **cbegin/cend:**只读迭代器;
  3. **迭代器区间 `[begin,end)`,**左闭右开,用于范围遍历;
  4. 没有 rbegin、rend 反向迭代器(只有前向单向迭代器,不能后退)。
cpp 复制代码
#include <iostream>
#include <unordered_map>
#include <string>
using namespace std;

int main()
{
   
    unordered_map<int, string> m;  
    m[1] = "张三";
    m[2] = "李四";
    m[3] = "王五";

    cout << "使用begin()和end()遍历:" << endl;
    //用迭代器遍历,begin开始,end作为结束位置
    for (auto it = m.begin(); it != m.end(); it++)
    {
        cout << it->first << "  " << it->second << endl;
    }

    cout << "\n使用cbegin()和cend()只读遍历:" << endl;
    for (auto it = m.cbegin(); it != m.cend(); it++)
    {
        cout << it->first << "  " << it->second << endl;
    }
    return 0;
}

d. 元素访问

https://cplusplus.com/reference/unordered_map/unordered_map/operator%5B%5D/

cpp 复制代码
#include <iostream>
#include <unordered_map>
#include <string>
using namespace std;

int main()
{
    unordered_map<int, string> mp;
    //1.key不存在,[]会自动插入,value为默认空字符串
    cout << mp[10] << endl;
    //此时容器里已经多了(10,"")

    //2.key存在,[]直接取出对应value并修改
    mp[10] = "hello";
    cout << mp[10] << endl;

    return 0;
}

注意:operator[]内部会尝试执行插入。若容器中没有该 key,则插入一条 key 和默认值V()组成的元素,返回默认值;若 key 已经存在,插入不会生效,直接返回该 key 对应的 value。

cpp 复制代码
unordered_map<int,string> mp;
cout << mp[1]; //没有key=1,自动插入(1,""),返回空字符串
mp[1] = "hi";  //key已存在,不会新增,直接修改value

e. 查询操作

https://cplusplus.com/reference/unordered_map/unordered_map/find/

https://cplusplus.com/reference/unordered_map/unordered_map/count/

cpp 复制代码
#include <iostream>
#include <unordered_map>
#include <string>

int main() {
   
    std::unordered_map<std::string, int> hashTable;

    // 插入数据
    hashTable["apple"] = 5;
    hashTable["banana"] = 3;
    hashTable["cherry"] = 7;

    //  1. 使用 find: 查找 key 为 "banana" 的元素
    auto it = hashTable.find("banana");
    if (it != hashTable.end()) {
        std::cout << "找到 banana,值为: " << it->second << std::endl;
    }
    else {
        std::cout << "未找到 banana" << std::endl;
    }

    // 查找不存在的 key
    auto it2 = hashTable.find("grape");
    if (it2 != hashTable.end()) {
        std::cout << "找到 grape,值为: " << it2->second << std::endl;
    }
    else {
        std::cout << "未找到 grape" << std::endl;
    }

    //2. 使用 count:统计 key 为 "apple" 的个数
    size_t count1 = hashTable.count("apple");
    std::cout << "apple 的个数: " << count1 << std::endl; 

    size_t count2 = hashTable.count("grape");
    std::cout << "grape 的个数: " << count2 << std::endl;  

    return 0;
}

unordered_map 的 key 是唯一的,不会存在重复,所以它的 count 方法最多返回 1。找到目标 key 就返回 1,找不到返回 0。这点和支持重复键的 multimap 不一样,后者 count 可以大于 1。


f. 修改操作

https://cplusplus.com/reference/unordered_map/unordered_map/insert/

https://cplusplus.com/reference/unordered_map/unordered_map/erase/

https://cplusplus.com/reference/unordered_map/unordered_map/clear/

https://cplusplus.com/reference/unordered_map/unordered_map/swap/

cpp 复制代码
#include <iostream>
#include <unordered_map>
#include <string>
using namespace std;

int main() {
    // 1. insert:插入键值对
    unordered_map<string, int> m;
    m.insert({ "apple", 5 });
    m.insert({ "banana", 3 });
    m.insert({ "cherry", 7 });
    m["date"] = 10;  // 另一种插入方式

    cout << "插入后: ";
    for (auto& p : m) {
        cout << p.first << ":" << p.second << " ";
    }
    cout << endl;

    // 2. erase:删除
    m.erase("banana");  // 根据key删除
    cout << "删除banana后: ";
    for (auto& p : m) {
        cout << p.first << ":" << p.second << " ";
    }
    cout << endl;

    // 3. clear:清空
    unordered_map<string, int> m2 = { {"x", 1}, {"y", 2} };
    cout << "清空前大小: " << m2.size() << endl;
    m2.clear();
    cout << "清空后大小: " << m2.size() << endl;

    // 4. swap:交换
    unordered_map<string, int> a = { {"A", 1}, {"B", 2} };
    unordered_map<string, int> b = { {"C", 3}, {"D", 4}, {"E", 5} };

    cout << "交换前 a大小:" << a.size() << " b大小:" << b.size() << endl;
    a.swap(b);
    cout << "交换后 a大小:" << a.size() << " b大小:" << b.size() << endl;

    return 0;
}

g. 桶操作

cpp 复制代码
#include <iostream>
#include <unordered_map>
#include <string>
using namespace std;

int main() {
    unordered_map<string, int> m;

    // 插入一些数据
    m["apple"] = 5;
    m["banana"] = 3;
    m["cherry"] = 7;
    m["date"] = 10;
    m["grape"] = 8;

    // 1. bucket_count():返回桶的总个数
    cout << "桶的总个数: " << m.bucket_count() << endl;

    // 2. bucket_size(n):返回n号桶中有效元素个数
    cout << "\n各个桶的元素个数:" << endl;
    for (size_t i = 0; i < m.bucket_count(); i++) {
        cout << i << "号桶: " << m.bucket_size(i) << "个元素" << endl;
    }

    // 3. bucket(key):返回元素key所在的桶号
    cout << "\n各元素所在的桶号:" << endl;
    cout << "apple 在 " << m.bucket("apple") << " 号桶" << endl;
    cout << "banana 在 " << m.bucket("banana") << " 号桶" << endl;
    cout << "cherry 在 " << m.bucket("cherry") << " 号桶" << endl;
    cout << "date 在 " << m.bucket("date") << " 号桶" << endl;
    cout << "grape 在 " << m.bucket("grape") << " 号桶" << endl;

    return 0;
}

2.unordered_set

unordered_set - C++ Reference

(1)unordered_set 的介绍

【翻译如下】:

无序集合(Unordered Set)

  1. unordered_set 是一类容器,用来存储唯一元素,元素没有固定顺序;它支持根据元素的值快速查找单个元素。
  2. 在 unordered_set 中,元素的值同时就是它的键,用来唯一标识自身。键不可修改,所以元素一旦存入容器就不能改动 ------ 但可以插入、删除元素。
  3. 在底层,unordered_set 的元素不做排序,而是根据哈希值分到不同桶(bucket)中,这样就能直接依靠值快速访问单个元素,平均时间复杂度为常数级别
  4. 按键访问单个元素时,unordered_set 的速度比 set 更快;但如果需要对一部分元素做范围遍历,它的效率通常更低。
  5. 该容器的迭代器至少满足**前向迭代器(forward iterator)**的要求。

(2)unordered_set 的接口说明

a. 构造函数

https://cplusplus.com/reference/unordered_set/unordered_set/unordered_set/

cpp 复制代码
#include <iostream>
#include <unordered_set>
using namespace std;

int main() {
    // 1. 默认构造:空集合
    unordered_set<int> s1;
    s1.insert(1);
    s1.insert(2);
    s1.insert(3);
    cout << "s1: ";
    for (int x : s1) cout << x << " ";
    cout << endl;

    // 2. 拷贝构造:复制另一个集合
    unordered_set<int> s2(s1);
    cout << "s2(拷贝s1): ";
    for (int x : s2) cout << x << " ";
    cout << endl;

    // 3. 迭代器区间构造:用数组初始化
    int arr[] = { 10, 20, 30, 40, 50 };
    unordered_set<int> s3(arr, arr + 5);
    cout << "s3(从数组): ";
    for (int x : s3) cout << x << " ";
    cout << endl;

    // 4. 列表初始化:直接指定元素
    unordered_set<int> s4 = { 100, 200, 300, 400 };
    cout << "s4(列表初始化): ";
    for (int x : s4) cout << x << " ";
    cout << endl;

    // 5. 指定桶的数量
    unordered_set<int> s5(20);  // 创建20个桶
    s5.insert(1);
    s5.insert(2);
    cout << "s5桶数: " << s5.bucket_count() << endl;

    return 0;
}

b. 容量函数

cpp 复制代码
#include <iostream>
#include <unordered_map>
using namespace std;

int main() {
    unordered_map<string, int> m;
    
    cout << m.empty() << endl;  
    cout << m.size() << endl;   
    
    m["a"] = 1;
    m["b"] = 2;
    
    cout << m.empty() << endl;  
    cout << m.size() << endl;   
    
    m.clear();
    cout << m.empty() << endl;  
    cout << m.size() << endl;

    return 0;
}
  • empty() 空返回1,非空返回0
  • size() 元素个数

c. 迭代器

cpp 复制代码
#include <iostream>
#include <unordered_map>
#include <string>
using namespace std;

int main() {
    unordered_map<string, int> m;
    m["apple"] = 5;
    m["banana"] = 3;
    m["cherry"] = 7;
    
    // begin() / end():遍历
    cout << "begin/end遍历: ";
    for (auto it = m.begin(); it != m.end(); it++) {
        cout << it->first << ":" << it->second << " ";
    }
    cout << endl;
    
    // cbegin() / cend():const遍历(不能修改)
    cout << "cbegin/cend遍历: ";
    for (auto it = m.cbegin(); it != m.cend(); it++) {
        cout << it->first << ":" << it->second << " ";
    }
    cout << endl;
    
    // 范围for(底层也是用begin/end)
    cout << "范围for遍历: ";
    for (auto& p : m) {
        cout << p.first << ":" << p.second << " ";
    }
    cout << endl;
    
    return 0;
}

d. 查询操作

https://cplusplus.com/reference/unordered_set/unordered_set/find/

https://cplusplus.com/reference/unordered_set/unordered_set/count/

cpp 复制代码
#include <iostream>
#include <unordered_map>
using namespace std;

int main() {
    unordered_map<string, int> m;
    m["a"] = 1;
    m["b"] = 2;

    auto it = m.find("a");
    cout << (it != m.end()) << endl;  // 1(找到)

    it = m.find("c");
    cout << (it != m.end()) << endl;  // 0(没找到)

    cout << m.count("a") << endl;  // 1
    cout << m.count("c") << endl;  // 0

    return 0;
}

注意:unordered_set 存储的元素本身就作为 key,不允许存在重复元素。所以它的 count() 函数返回值只能是 0 或者 1,最大值就是 1。 返回 1 代表该元素存在,返回 0 代表不存在。

对比:unordered_multiset 允许存放重复元素,它的count返回值可以大于 1,代表这个值一共出现了多少次。


e. 修改操作

https://cplusplus.com/reference/unordered_set/unordered_set/insert/

https://cplusplus.com/reference/unordered_set/unordered_set/erase/

https://cplusplus.com/reference/unordered_set/unordered_set/clear/

https://cplusplus.com/reference/unordered_set/unordered_set/swap/

cpp 复制代码
#include <iostream>
#include <unordered_map>
using namespace std;

int main() {
    unordered_map<string, int> m;
    
    // insert
    m.insert({"a", 1});
    m.insert({"b", 2});
    m["c"] = 3;
    
    // erase
    m.erase("b");
    
    // clear
    m.clear();
    cout << m.size() << endl;  
    
    // swap
    unordered_map<string, int> m1 = {{"x", 10}, {"y", 20}};
    unordered_map<string, int> m2 = {{"z", 30}};
    m1.swap(m2);
    cout << m1.size() << " " << m2.size() << endl;  
    
    return 0;
}

f. 桶操作

cpp 复制代码
#include <iostream>
#include <unordered_map>
using namespace std;

int main() {
    unordered_map<string, int> m;
    m["a"] = 1;
    m["b"] = 2;
    m["c"] = 3;
    m["d"] = 4;
    
    cout << m.bucket_count() << endl;  // 桶总数
    
    cout << m.bucket_size(0) << endl;  // 0号桶元素个数
    cout << m.bucket_size(1) << endl;  // 1号桶元素个数
    
    cout << m.bucket("a") << endl;  // a在几号桶
    cout << m.bucket("b") << endl;  // b在几号桶
    
    return 0;
}

二.底层结构

之所以unordered 系列的关联式容器效率比较高,是因为其底层使用了哈希结构。

1.哈希概念

顺序结构、二叉平衡树这两种结构里,元素关键码和它实际存放的存储位置没有预先绑定的映射关系。所以查找目标元素时,只能依靠多次关键码比对来寻找。 顺序查找的时间复杂度是 O(N);二叉平衡树查找的时间复杂度是树高O(log₂ N) 。这类查找方式的效率,由查找过程中关键码的比较次数决定。

于是就有了一种理想查找思路:不需要多次比较,通过计算直接定位到目标元素的存储位置,一次访问就能拿到元素。 思路是这样:设计一个哈希函数hashFunc ,建立关键码与存储位置之间的映射关系 。查找的时候,直接用这个函数算出位置,快速定位元素。

【插入元素】 用待插入元素的关键码代入哈希函数,算出对应的存储位置,再把元素存到这个位置上。

【搜索元素】 对目标关键码执行同样的哈希计算,将算出的结果当作存储地址,到该位置取出元素,再比对关键码。如果关键码一致,查找成功。

这种查找方式就叫做哈希(散列)方法;其中用来做转换的函数叫哈希(散列)函数;用这套规则构建出来的存储结构,叫做哈希表(Hash Table,也叫散列表)


举个例子:现有数据集合 {1,7,6,4,5,9}。 我们设定哈希函数:hash(key) = key % capacity。 其中 capacity 代表哈希表底层数组的总空间大小。用取模作为哈希函数,计算出来的结果就是元素要存放的数组下标。

用该方法进行搜索查找不必进行多次关键码的比较,因此搜索的速度比较快。


2.哈希冲突

在搜索过程总,对于两个数据元素的关键字 ki 和 kj (i != j),有 ki != kj,但有:Hash(ki) == Hash(kj)

即:不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为 哈希冲突 或 哈希碰撞。把具有不同关键码而具有相同哈希地址的数据元素称为**"同义词"**。

举例如下:

cpp 复制代码
// 假设哈希函数:hash(key) = key % 10
hash(11) = 1
hash(21) = 1  // 11 ≠ 21,但哈希地址相同 --> 哈希冲突
// 11 和 21 互为同义词

3.哈希函数

引起哈希冲突,很常见的一个原因就是哈希函数本身设计得不好。

设计哈希函数一般要把握这几条原则:

  1. 定义域要覆盖所有需要存入的关键字。假设哈希表一共有 n 个存储位置,那么函数算出来的结果,也必须落在 0 ~ n‑1 这个下标区间里,不能越界。
  2. 计算出来的哈希地址尽量分散开,均匀分布在整个哈希表空间。尽量不要让大量关键字扎堆映射到同一个位置,以此减少冲突发生的概率。
  3. 计算逻辑不能太复杂。哈希函数本身运算要快,如果计算开销太大,会拖慢查找、插入的整体效率。

就算哈希函数设计得很不错,也做不到完全消除冲突。如果哈希表里存的数据越来越多,装填因子变大,冲突照样会变多。


4.常见哈希函数

a.直接定址法(常用)

直接定址法是比较基础的哈希构造方法,取关键字的线性函数结果作为哈希地址:Hash(Key)= A*Key + B,A、B 为自定义常数,A 也可以取 1,此时就是关键字本身直接作为哈希地址。

优点:实现简单,不会产生哈希冲突,地址分布天然均匀。

缺点:必须提前掌握关键字的整体分布范围;当关键字离散、跨度很大时,会造成哈希表数组空间巨大,大量位置闲置浪费内存。

适用场景:关键字范围较小、数值连续的场景。

举例:

  1. 计数排序,直接把元素值当作数组下标;
  2. OJ 算法题统计字符频次,直接拿字符 ASCII 码作为下标映射;
  3. 把取值范围有限的 int 变量的值,直接当作哈希表的存储下标。

代码示例如下:

cpp 复制代码
#include <iostream>
using namespace std;

// 直接定址法 Hash(Key) = A*Key + B
int hashFunc(int key, int A, int B)
{
    return A * key + B;
}

int main()
{
    const int A = 2;
    const int B = 1;
    int hashTable[20] = { 0 };

    int keys[] = { 1,2,3,4 };
    for (int k : keys)
    {
        int pos = hashFunc(k, A, B);
        hashTable[pos] = k;
        cout << "key=" << k << " 哈希地址=" << pos << endl;
    }
    return 0;
}

for (int k : keys) 这是 C++11 引入的范围 for(范围 for 循环)


2.除留余数法(常用)

开辟一块固定大小的哈希表空间,假设哈希表地址总数为 n。 哈希函数公式就是说**:Hash(key) = key % n**,把关键字对n 取模 ,得到的余数就是哈希地址,将元素存入哈希表对应下标位置。

补充: 这里的n不是随便选的,尽量选质数,可以有效降低冲突发生概率;如果 n 取偶数,很多关键字的取模结果会扎堆,冲突会变多。

缺点

  1. 原生只支持整数关键字,字符串、浮点数不能直接拿来取模;这类数据需要先转换成整数,再做除留余数计算。
  2. 不同 key 算出来余数一样,就会产生哈希冲突,冲突无法彻底避免。
    适用场景:绝大多数整数关键字哈希场景,实际写代码、考试出现频率很高。

代码示例如下:

cpp 复制代码
#include <iostream>
using namespace std;

//除留余数法哈希函数
int hashFunc(int key, int n)
{
    return key % n;
}

int main()
{
    int n = 11;   //哈希表容量,选质数,降低冲突
    int hashTable[11] = { 0 };
    int keys[] = { 1,4,5,6,7,9 };
    int len = sizeof(keys) / sizeof(keys[0]);

    for (int i = 0; i < len; i++)
    {
        int k = keys[i];
        int addr = hashFunc(k, n);
        cout << "key=" << k << " 哈希地址:" << addr << endl;
        hashTable[addr] = k;
    }
    return 0;
}

问题补充:这里为什么要选择质数呢?

公式:Hash(key) = key % n

简单说:如果 n 是合数(非质数),关键字如果含有 n 的因子,取模结果就会大量聚集在部分下标,冲突变多;选质数可以打散这种聚集,让地址分布更均匀。

给大家举个通俗例子: 假设 n=10(合数,因子 2、5) 如果一组 key 全是偶数:2,4,6,8,12,14 key%10 结果只会落到 0,2,4,6,8,奇数下标 1、3、5、7、9 永远用不上,一半空间直接浪费,大量数据挤在少数位置,冲突暴增。

如果换成质数 n=11: 同样这组偶数做取模,结果会分散到 0‑10 全部下标,不会扎堆。

简单图示一下:


3.平方取中法(了解)

把关键字做平方,再从平方后的结果里挑中间的几位,当作哈希地址。
举两个例子: 关键字 1234,平方之后得到 1522756,截取中间 3 位 227,就是哈希地址。 关键字 4321,平方得到 18671041,可以截取中间 3 位 671,也可以取 710。

之所以取中间部分,是因为数字平方后,中间几位会受原数字每一位的影响,更容易把地址打散开来。

适用情况: 我们事先不清楚关键字的分布,并且关键字本身位数不算太大。

缺点:

  1. 截取出来的地址大小不好控制,有可能超出哈希表的下标范围,拿到结果往往还要再加工;
  2. 关键字数值大的时候,平方计算容易发生数据溢出;
  3. 现实开发里很少用,主要是课本理论了解。

4.折叠法(了解)

把一个长关键字,从左到右切成好几段,每段位数保持一致,最后一段不够长也没关系。把切出来的几段数字全部加在一起,最后按照哈希表的长度,拿相加结果的后面几位当作哈希地址。

折叠法有两种做法:

  • 移位叠加:切出来的几段直接对齐相加。
  • 间界叠加(翻转折叠):相邻的分段首尾颠倒之后再相加,打散数据的效果会更好。

适用场景: 不知道关键字的分布情况,而且关键字本身位数特别多,像手机号、长串编号这类数据就适合用。

缺点:

  1. 需要自己确定每一段切多少位;
  2. 还是会出现哈希冲突;
  3. 实际开发几乎不用,主要是课本概念了解。

举个例子: 关键字 5824232416,我们需要 3 位的哈希地址,按 3 位一段切开:582、423、241、6 移位叠加计算:582 + 423 + 241 + 6 = 1252,取后 3 位 252,这就是哈希地址。


5.随机数法(了解)

挑选一个随机函数,把关键字传入随机函数,用函数返回的结果当作哈希地址:H(key) = random(key)。一般在各个关键字长度参差不齐的时候会考虑这个办法。

缺点:

  1. 同一个关键字,每次调用随机函数必须得到相同结果,普通的真随机函数不能拿来用,得用伪随机函数
  2. 依旧会产生哈希冲突;
  3. 实际开发很少用,仅做理论了解。
    注意点:不能用真正完全随机的函数,不然同一个 key 每次算出来地址不一样,后续查找就找不到数据了。
cpp 复制代码
#include <iostream>
using namespace std;

//简单伪随机,同一个key输出固定值
int randomHash(int key)
{
    //简单伪随机算法
    return (key * 1103515245 + 12345) % 100;
}

int main()
{
    int keys[] = { 12, 345, 6789 };
    int len = sizeof(keys) / sizeof(keys[0]);
    for (int i = 0; i < len; i++)
    {
        int k = keys[i];
        int addr = randomHash(k);
        cout << "key=" << k << " 哈希地址=" << addr << endl;
    }
    return 0;
}

6.数学分析法(了解)

现有一批 d 位的关键字,每一位上可以出现 r 种符号。这些符号在每一位上出现频次并不一样:有些位各个符号出现概率差不多,分布均匀;有些位就很集中,只有少数几种数值反复出现。

数字分析法就是结合哈希表的实际大小,挑选出符号分布比较均匀的若干位,直接拿这部分作为哈希地址。

举个实际例子: 保存公司员工信息,拿手机号当作关键字。手机号前 7 位大多是运营商号段,大量员工的这部分数字会一模一样,这几位分布极不均匀,不能选用。我们就选取手机号末尾 4 位当作哈希地址。 如果只截取之后冲突还是比较多,还可以对截取出来的数字做二次加工:

  • 数字反转:1234 --> 4321
  • 循环右移:1234 --> 4123
  • 循环左移:1234--> 1432
  • 分段叠加:1234,前两位加后两位,12+34=46

适用场景 关键字位数比较大,并且我们提前掌握这批关键字的整体分布,其中存在部分数位分布均匀。

缺点 必须事先分析全部关键字每一位的分布情况;换另一批数据,之前选好的位可能就不再均匀,这套哈希函数就失效了。

cpp 复制代码
#include <iostream>
#include <string>
#include <algorithm>
using namespace std;

//数字分析法示例:截取手机号后4位,可选择反转
int numAnalyseHash(string phone, bool reverse_flag = false)
{
    string seg = phone.substr(phone.size() - 4, 4); //截取末尾4位
    if (reverse_flag)
    {
        reverse(seg.begin(), seg.end());
    }
    return stoi(seg);
}

int main()
{
    string phone1 = "13800138000";
    string phone2 = "13800138123";
    cout << phone1 << " 截取后4位哈希地址:" << numAnalyseHash(phone1) << endl;
    cout << phone2 << " 截取后4位反转哈希地址:" << numAnalyseHash(phone2, true) << endl;
    return 0;
}

但是:哈希函数设计再巧妙,只能降低冲突发生的概率,不可能完全消除哈希冲突


5.哈希冲突解决

哈希冲突主要有两种解决方式:闭散列(开放定址法)开散列(链地址法)

a. 闭散列(开放定址法)

所有数据全部存放在哈希表数组内部,不新开其他空间。 一旦出现哈希冲突,就按照固定规则,在原数组里继续往后找空位,把数据存进去。

特点:

  • 只用一个数组,不借助链表
  • 冲突多了容易扎堆、聚集
  • 不能直接删除元素,直接删会打断查找路径,需要做删除标记
  • 装填因子不能太大,否则冲突爆炸

b.开散列(链地址法)

哈希表数组只存每个位置的链表头结点。 只要哈希地址相同的元素,直接挂在同一条链表后面。

特点:

  • 冲突元素直接进链表,不会挤占数组空间
  • 不会出现数据扎堆,效率更稳定
  • 支持装填因子大于 1
  • 需要额外开辟链表空间

三.解决哈希冲突两种常见方法的介绍

1.闭散列(开放定址法)

闭散列也叫开放定址法,是解决哈希冲突的常用方式。 主要逻辑很简单:所有数据都只存在哈希表的数组里,不额外开链表

当我们通过哈希函数算出一个存储位置时,如果这个位置已经被占了,就说明发生了哈希冲突。 只要哈希表还没存满,数组里一定还有空位,我们就按照固定规则,向后不断寻找下一个空闲位置,把当前要存储字存进去。

插入数据流程: 先通过哈希函数算出基准下标。如果该位置为空,直接存入;如果已经有数据,就持续往后找空位,找到就插入。如果数组全部占满,没有空位,就必须扩容哈希表。

查找数据流程: 先计算哈希下标,直接定位到对应位置。 取出数据后必须比对关键字: 如果刚好是要找的数据,直接返回; 如果不是,说明这里发生过哈希冲突,需要继续往后遍历探测位置,逐一比对,直到找到目标数据,或者遇到空位(说明数据不存在)。

核心特点:

  1. 全程只用一个数组,不用链表,结构简单
  2. 冲突数据会扎堆堆积,数据越多、冲突越严重,查找效率会下降
  3. 不能直接删除元素直接清空位置会打断探测路径,导致后续数据查找不到,只能做删除标记

(1)找下一个空位置(闭散列探测方式)

当两个不同关键字,算出的哈希地址一模一样,就会出现哈希冲突。 如果当前位置已经存了数据,就不能直接覆盖,需要往后找新的空位存放

只要哈希表没有存满,数组里一定还有空闲位置,按照规则继续往后探测、依次查找空位。 如果数组全部被占满,没有任何空位,就需要对哈希表进行扩容。

常用的找空位方式只有两种:

  1. 线性探测
  2. 二次探测(二次哈希探测)
    没有 "二次线性探测" 这个说法,标准名称是二次探测

① 线性探测(闭散列常用)

线性探测的规则很简单:一旦当前哈希位置发生冲突、已经被占用,就从冲突位置开始,挨个向后依次查找,一直找到第一个空位置为止,把数据存进去。


【插入】

大致插入流程

  1. 先用哈希函数算出当前元素对应的哈希下标。
  2. 如果这个位置是空的,直接插入数据。
  3. 如果位置已经有数据(发生哈希冲突),就从当前位置往后逐个遍历探测。
  4. 找到空位就插入;如果遍历完整个表都没有空位,就对哈希表进行扩容。

当我们在计算:

hash(44) = 44%10 = 4
哈希值(44) = 44%10 = 4

会出现哈希冲突:哈希地址 4 已经存放了数据,从冲突位置开始往后找空位置。


【删除】(闭散列)

使用闭散列解决哈希冲突的时候,不能直接物理删掉数组里的元素。 如果直接把元素清空,会破坏线性探测的查找链路,导致其他发生过冲突而向后存放的数据找不到。

举例子: 4 存放在下标 4,44 哈希算出来也是下标 4,发生冲突,线性探测往后放到下标 8。 如果直接把下标 4 的 4 清空,之后查找 44 的时候,探测走到下标 4 发现是空位,就会判定 44 不存在,直接结束查找,找不到真正存在下标 8 的 44。

所以闭散列一般用伪删除(标记删除),不去抹除数据,只给位置打上状态标记。给哈希表的每个位置设置三种状态:

cpp 复制代码
enum State{
    EMPTY,   // 该位置为空,从未存放过数据
    EXIST,   // 该位置存有有效元素
    DELETE   // 元素曾经存在,已经被逻辑删除
};
  • 插入:只可以向EMPTY的位置存放;遇到DELETE的位置,也可以复用该位置存入新数据。
  • 查找:碰到DELETE标记不能停止,要继续向后探测;只有遇到EMPTY,才代表数据不存在。
  • 删除:不把数据擦掉,仅仅把状态改成DELETE。

删除这里有一个缺点:就是说:伪删除只是打标记,不会释放空间。如果大量删除,表中DELETE标记会越来越多,降低查找效率,这种情况需要对哈希表扩容并重新整理。


【线性探测的实现】

代码如下:

cpp 复制代码
// 注意:假如实现的哈希表中元素唯一,即key相同的元素不再进行插入
// 为了实现简单,此哈希表中我们将比较直接与元素绑定在一起

// 哈希表每个位置状态枚举
enum State{EMPTY, EXIST, DELETE};

template<class K, class V>
class HashTable
{
    //存储单元结构体:保存键值对 + 位置状态标记
    struct Elem
    {   
        pair<K, V> _val;   // key‑value键值对
        State _state;      // 标记:EMPTY空 / EXIST有效数据 / DELETE逻辑删除
    };
    
public:
    //构造函数:初始化哈希表,默认容量3
    HashTable(size_t capacity = 3)
        : _ht(capacity), _size(0)
    {
        //全部位置初始化为空状态
        for(size_t i = 0; i < capacity; ++i)
            _ht[i]._state = EMPTY;
    }
    
    //插入键值对,key重复返回false,插入成功返回true
    bool Insert(const pair<K, V>& val)
    {
        _CheckCapacity(); //检测装填因子,达到阈值进行扩容
        size_t hashAddr = HashFunc(val.first); //计算初始哈希地址
        size_t startAddr = hashAddr;

        //线性探测:跳过已经存有有效数据的位置;EMPTY、DELETE位置可以用于插入
        while(_ht[hashAddr]._state != EMPTY && _ht[hashAddr]._state != DELETE)
        {
            //探测途中遇到相同key,代表重复,禁止插入
            if(_ht[hashAddr]._state == EXIST && _ht[hashAddr]._val.first == val.first)
                return false;
           
            hashAddr++;
            if(hashAddr == _ht.capacity())
                hashAddr = 0; //到达数组末尾,回绕到数组头部

            //绕回起点,代表表已经存满,无法插入
            if(hashAddr == startAddr)
                return false;
        }
       
        //找到合适位置,写入数据,标记为有效
        _ht[hashAddr]._state = EXIST;
        _ht[hashAddr]._val = val;
        _size++; //有效元素计数+1
        return true;
    }

    //查找key,找到返回数组下标,找不到返回-1
    int Find(const K& key)
    {
        size_t hashAddr = HashFunc(key);
        size_t startAddr = hashAddr;
        //遇到EMPTY空位直接停止查找
        while(_ht[hashAddr]._state != EMPTY)
        {
            //找到有效匹配key,返回下标
            if(_ht[hashAddr]._state == EXIST && _ht[hashAddr]._val.first == key)
                return (int)hashAddr;
           
            hashAddr++;
            if(hashAddr == _ht.capacity())
                hashAddr = 0; //探测越界,回绕头部

            //完整遍历一圈,没有找到目标
            if(hashAddr == startAddr)
                break;
        }
        return -1; //没有该元素返回-1
    }

    //伪删除:只打DELETE标记,不清空数据,保护探测链路
    bool Erase(const K& key)
    {
        int index = Find(key);
        if(-1 != index)
        {
            _ht[index]._state = DELETE;
            _size--; //有效元素数量减一
            return true;
        }
        return false;
    }

    //返回当前有效元素个数
    size_t Size()const
    {
        return _size;
    }
    //判断哈希表是否为空
    bool Empty() const
    {
        return _size == 0;
    }    
    void Swap(HashTable<K, V>& ht);

private:
    //扩容检测,装填因子超标则扩容
    void _CheckCapacity()
    {
        //此处仅声明,扩容逻辑:开辟更大vector,把EXIST状态元素重新哈希插入新表
    }

    //哈希函数:除留余数法,计算哈希地址
    size_t HashFunc(const K& key)
    {
        return key % _ht.capacity();
    }
private:
    vector<Elem> _ht;   //底层数组,存放所有哈希表单元
    size_t _size;       //记录有效元素(EXIST)的数量
};

线性探测

优点:实现逻辑简单,编码容易。

缺点:容易产生聚集(数据堆积)。 发生冲突后,后续元素会顺着探测路径连续占用位置,就算关键字原本哈希地址并不相同,也会挤在一片连续区域。堆积出现之后,插入、查找、删除都要执行更多次探测比较,哈希表整体搜索效率明显下降。

如何缓解呢?

出现堆积之后,插入和查找的效率都会大幅下降。 插入元素时,要从冲突位置不断向后遍历,才能找到空闲位置; 查找元素时,同样需要顺着探测路径多次比对,只有探测遇到空位置,才能判定目标元素不存在。大量的探测比较,直接拉低整体搜索效率。

这里我先给大家结论,后面会有解答:

缓解手段: ①使用二次探测,缓解数据聚集; ②控制装填因子,及时扩容; ③改用开散列链地址法。


② 二次探测

线性探测会出现数据堆积,原因就是冲突之后只能挨着往后一个一个找空位,冲突的数据就挤到一块了。

二次探测就是用来改善这个堆积问题。 探测位置公式:Hash(key) = key % n + i² ( i = 1,2,3... ),通过哈希函数 Hash(key) 计算出元素的关键码 key 对应的位置再加上 i 的平方,n 是表的大小。

注意:不是直接 key%n+i^2。出原始下标先用除留余数算,发生冲突后,再叠加i的平方做偏移,一般正负偏移都会用。

简单流程:

  1. key%n算出元素本该存放的位置
  2. 如果位置被占,就按i^2算新位置,i 不断增大,直到找到空位

**优点:**多个数据冲突到同一个起始位置时,二次探测靠平方偏移把数据打散,不会扎堆挤在相邻位置,缓解线性探测的数据堆积问题。

缺点:

  1. 只能缓解堆积,没法彻底消除,还会产生次级聚集;
  2. 就算表里还有空位,也有可能找不到可用位置;
  3. 哈希表容量尽量选素数,装填因子最好控制在 0.5 以内,超了就要扩容。

如果上面要插入 44,产生冲突,使用解决后的情况为:

二次探测装填因子说明:

研究表明:当哈希表长度为质数,且装填因子 α ≤ 0.5时,二次探测一定能找到空位完成插入,并且每个位置最多只会被探测一次。

也就是说只要哈希表还留有一半空闲位置,就不会出现找不到插入位置的问题。 搜索的时候不用考虑表存满的情况,但做插入操作时,要保证装填因子不能超过 0.5;一旦超过,就必须扩容。

但有缺点:二次探测空间利用率不高,最多只能用到一半的表空间,这是闭散列(开放定址)哈希表的短板

**公式:**有效元素个数/哈希表的总长度


【二次探测相比线性探测的好处】

如果多个数据冲突到同一个位置,二次探测会把这些数据打散存到不同地方,不会像线性探测那样,冲突的数据挤成一大片,出现数据堆积。


【插入】

假设要插入 333 和 33,产生冲突,分别使用线性探测 和二次探测,解决后的情况为:


【闭散列的实现】

哈希表其实就是数组,只不过是按照某种映射关系把元素存放进去的数组。


1.怎么往哈希表里插入元素?

  • 先看要不要扩容:算一下负载因子,如果超过规定阈值,就先做扩容。
  • 接着用哈希函数算出元素一开始该放的下标。
  • 如果这个位置已经存有数据(状态 EXIST),代表出现哈希冲突,用线性探测或者二次探测继续找可用空位,找到之后再插入。
  • 如果位置是空的(EMPTY),或者是之前删除留下的位置(DELETE),就直接把元素插进去。

2.怎么在哈希表里查找元素?

先判断哈希表是不是空表,如果是空,直接查找失败,返回 nullptr。

用哈希函数算出目标元素对应的起始下标。 如果这个位置不是空白(状态是 EXIST 或者 DELETE),就开始向后探测查找,碰到 EMPTY 空位就直接停止。

  • 探测途中遇到状态为 EXIST 的元素,就对比是不是要找的值,匹配上就查找成功,返回元素地址;不匹配就继续往下找。
  • 如果一路查到 EMPTY 空位都没找到目标,说明表里没有这个元素,返回 nullptr。

如果一开始算出来的位置就是 EMPTY,直接返回 nullptr。


3.怎么标记哈希表每个位置的存放状态?

不能直接拿 0 或者‑1 当做空位置标记,因为实际存储的数据本身就有可能是 0、‑1,会出现混淆。

**解决办法:**哈希表的每个位置,除了存数据本身,额外再加一个状态标记,用来记录这个位置是三种状态之一:空、已有元素、已删除。


4.怎么删除哈希表里的元素?

使用开放定址(闭散列)解决冲突的时候,不能直接把元素物理删掉。如果直接清空该位置,会打断探测链路,导致其他元素找不到。

举个例子,如果直接删掉 333,这个位置直接变成空。后续去查找 44 的时候,探测到这个空位就直接停止,就会误以为 44 不存在。

所以线性探测一般用伪删除(标记删除)。 每个位置除了存数据,额外带一个状态标记:空、存在、已删除。删除的时候只把状态改成 "已删除",数据不动,不直接清空位置。查找时遇到 "已删除" 标记会继续往后探测,不会直接终止查找。


问题思考:

【思考 1】

除留余数法要求 key 得是整数才能做取模运算,那是不是意味着闭散列哈希表只能存整型 key?如果是字符串或者自定义类型该怎么办?

像 string 这类类型,没法直接进行取模计算哈希下标。解决方案就是提供对应的哈希仿函数,把非整型的 key 转换成一个整数,之后再用除留余数法算出存储位置。

cpp 复制代码
// 默认哈希仿函数:用于size_t,以及可以隐式转换为size_t的整型类型
template<class K>
struct HashFunc
{
    // key:元素的关键码
    // 如果key是整型,直接转成size_t返回
    // 如果是浮点数,会发生隐式转换为size_t(浮点数转整数会截断小数部分)
    size_t operator()(const K& key)
    {
        return key;
    }
};
 
// 专门处理string类型的哈希仿函数,把string转为可用于取模的size_t
struct HashFuncString
{
    size_t operator()(const string& key)
    {
        // 方式1:累加每个字符的ASCII码
        // 缺点:不同字符串有可能累加得到相同哈希值,发生哈希冲突,不能保证key唯一
        size_t hash_key = 0;
        for (size_t i = 0; i < key.size(); i++)
        {
            hash_key += key[i];
        }
        return hash_key;
    }
};

【思考2】

unordered 系列容器底层是哈希表,但平时使用的时候,我们并没有手动传哈希仿函数。 string 作为 key 又特别常用,总不能每次用 string 都自己手写一份哈希仿函数,那它内部是怎么处理的?

答案:对 string 类型,给哈希仿函数写模板特化版本。 当传入的 key 是 string 时,编译器就会匹配到这个特化版本,自动调用对应的哈希转换逻辑,不用我们手动传仿函数。

cpp 复制代码
// 默认仿函数类(针对size_t类型和能够隐式类型转换成size_t的类型)
template<class K>
struct HashFunc
{
    size_t operator()(const K& key)
    {
        return key;
    }
};
 
// 特化仿函数(把string类型转换成可以取模的size_t类型)
template<>
struct HashFunc<string>
{
    size_t operator()(const string& key)
    {
		size_t hash_key = 0;
		for (size_t i = 0; i < key.size(); i++)
		{
            hash_key *= 131;
			hash_key += key[i];
		}
		return hash_key;
    }
};

通用模板 HashFunc<K> 适用于整型,还有能隐式转成 size_t 的类型。直接返回 key,后面拿这个值做除留余数取模,就能算出哈希下标。


**注意:**string 不能用这个通用模板,string 没法隐式转换成 size_t,直接用会编译报错。

模板特化 HashFunc<string> template<> 表示这是全特化。当模板参数 K 是 string 的时候,编译器会优先选用这个特化版本,不会再跑上面的通用模板。


**算法逻辑:**hash_key = hash_key * 131 + 当前字符的 ASCII 值 先乘质数 131,再加字符。相比直接把字符 ASCII 简单相加,能明显减少不同字符串算出同一个哈希值的情况,降低冲突。最后返回 size_t 类型的哈希值,外部拿到这个值再对哈希表容量取模,得到存放的下标。

为什么不需要手动传仿函数?

用 string 当 key 的时候,编译器自动匹配这个特化仿函数,我们写代码时不用手动传,效果和 unordered_map 里面的逻辑差不多。

**小提示:**就算用 131 加权算法,也没法彻底消除哈希冲突,只是冲突变少。出现冲突,还是要靠线性探测或者二次探测这类开放定址方案处理。


【闭散列的结构(KV模型)】

cpp 复制代码
// 闭散列
namespace close_hash
{
	// 标记哈希表中某个位置的存储状态
	enum Status
	{
		EMPTY,   // 此位置空
		EXIST,   // 此位置已有元素
		DELETE   // 此位置元素已被删除
	};
 
	// 定义哈希表中元素的结构
	template<class K, class V>
	struct HashData
	{
		pair<K, V> _kv;         // 键值对
		Status _status = EMPTY; // 存储状态标记,默认为空
	};
    
    // 仿函数(解决哈希函数采用除留余数法时,将不能取模的类型转换成可以取模的size_t类型)
    // 默认仿函数类
    template<class K>
	struct HashFunc
    {
        // 针对size_t类型和能够隐式类型转换成size_t的类型
        size_t operator()(const K& key)
        {
            return key;
        }
    };
 
    // 特化仿函数
    template<>
    struct HashFunc<string>
    {
        // 把string类型转换成可以取模的size_t类型
        size_t operator()(const string& key)
        {
            size_t hash_key = 0;
            for (size_t i = 0; i < key.size(); i++)
            {
                hash_key *= 131;
                hash_key += key[i];
            }
            return hash_key;
        }
    };
    
	// 定义哈希表(KV模型)
	// Hash = HashFunc<K>:仿函数,给一个默认的仿函数
	template<class K, class V, class Hash = HashFunc<K>>
	class HashTable
	{
	public:
		// 构造、拷贝构造、赋值重载、析构都不需要写,调用vector的就行了
        HashData<K, V>* Find(const K& key); // 查找元素
        bool Insert(const pair<K, V>& kv);  // 插入元素
        bool Erase(const K& key);           // 删除元素		
	private:
		vector<HashData<K, V>> _tables; // 哈希表底层容器
		size_t _n = 0;                  // 存储的有效元素个数,默认为0
		
        // 注意:因为元素不是挨着挨着存的,所以需要一个变量去表示有效元素个数
	};
}

【查找元素】

这里写了线性探测 / 二次探测两个版本。

cpp 复制代码
HashData<K, V>* Find(const K& key)
{
    // 先检查哈希表是否为空
    if (_tables.size() == 0) // 说明查找失败
    {
        return nullptr;
    }
 
    // 1、先通过哈希函数计算出要查找元素在哈希表中对应的位置
    size_t start = Hash()(key) % _tables.size();
    size_t i = 0;
    size_t index = start;
 
    // 2、该位置不为空
    while (_tables[index]._status != EMPTY)
    {
        /* 
        * 当前位置存储状态为存在,才去判断当前位置是不是要查找的元素。为什么呢?
		* 因为我们采用标记的伪删除法来删除一个元素,并没有清除元素的关键码,所以还可以被查找到
		*/
        if (_tables[index]._status == EXIST && key == _tables[index]._kv.first)
        {
            return &_tables[index]; // 返回该元素的地址
        }
 
        i++;
        index = start + i;        // 线性探测
        // index = start + i * i; // 二次探测
 
        index %= _tables.size(); // 如果超出表尾了,又从表头继续开始找
    }
    // 3、该位置为空
    return nullptr;
}

【插入元素】

1.为什么闭散列要控制存放的元素数量?

如果表里元素太多,插入的时候就很容易出现哈希冲突。 冲突一多,不管是插入还是查找,效率都会掉得很厉害。

查找的时候,要一直探测直到碰到真正的空位才会停下。所以闭散列不能把表存满。一旦全部占满,没有 EMPTY 空位,去查一个不存在的 key,代码就会陷入死循环。

所以有效元素数量涨到一定程度,就必须做扩容。减少冲突,保证读写的效率。


2.哈希表什么情况下进行扩容?又是如何扩容?

这里我们引入一个东西**:负载因子**

  • 负载因子越大,哈希表里存放的元素就越多,出现哈希冲突的概率越高,但内存空间浪费更少。
  • 负载因子越小,哈希表里存放的元素就越少,哈希冲突概率越低,但会浪费更多存储空间。
cpp 复制代码
void CheckCapacity()
{
    if(_size * 10 / _ht.capacity() >= 7)
    {
        HashTable<K, V, HF> newHt(GetNextPrime(_ht.capacity()));
        for(size_t i = 0; i < _ht.capacity(); ++i)
        {
            if(_ht[i]._state == EXIST)
                newHt.Insert(_ht[i]._val);
        }
        Swap(newHt);
    }
}

1.if(_size * 10 / _ht.capacity() >= 7) 判断扩容条件 这是负载因子的判断,用整数运算避免浮点数。 负载因子 = _size / capacity,这里判断负载因子≥0.7 就扩容。 _size *10 / capacity >=7等价于 _size / capacity >= 0.7。

**注意:**线性探测一般阈值取 0.7;二次探测阈值一般取 0.5。

2.HashTable<K, V, HF> newHt(GetNextPrime(_ht.capacity())); 创建新哈希表,调用GetNextPrime()获取比原容量更大的质数作为新表容量。 闭散列除留余数法,容量选质数,有助于降低哈希冲突。

3.遍历旧表做重新插入

cpp 复制代码
for(size_t i = 0; i < _ht.capacity(); ++i)
{
    if(_ht[i]._state == EXIST)
        newHt.Insert(_ht[i]._val);
}

只搬移状态为EXIST的有效元素。 DELETE、EMPTY位置直接丢弃,伪删除标记不会拷贝到新表。 放到新表要调用Insert,因为新表容量变了,哈希下标全部要重新计算,不能直接拷贝下标。

4.Swap(newHt); 交换新旧哈希表内部资源。底层容器、有效元素数量全部交换。 交换完成后,旧表的数据就交给局部对象newHt,函数结束局部对象析构,自动释放旧内存。

比如闭散列的容量是10,负载因子是 0.7,10 * 0.7 = 7,也就是说,当容量超过 7 的时候就会进行扩容操作。


这里有几点注意事项:

【注意1】

如果闭散列容量是 10,里面有 n 个有效元素,怎么判断负载因子是否超过 0.7?

不能直接写 if (n / 10 >= 0.7)。 n 和 10 都是 int,整数除法会直接取整,算出来结果只能是 0,判断失效。

注意:(double)(n / 10) >= 0.7 这行写法是错的!括号位置不对,是先做整数除法,再强转 double,依旧会出错。

两种正确写法:

  • 强转版本(把 n 先转 double)
cpp 复制代码
if ((double)n / 10 >= 0.7)
  • 整数运算,避免浮点数:两边同时乘 10
cpp 复制代码
if (n * 10 / 10 >= 7)

我在这里示例容量是 10,实际代码要替换成真实的表容量。


【注意 2】

如果允许插入重复的 key,闭散列表里就会多个位置存一样的关键码,后续查找、删除的时候就会出现逻辑混乱。那怎么避免存重复数据?

插入之前先调用 Find (key) 查一遍。 如果查到这个 key 已经存在哈希表里面,就直接放弃插入;不存在才执行插入操作。

cpp 复制代码
bool Insert(const pair<K, V>& kv)
{
    // 防止数据冗余
    if (Find(kv.first) != nullptr)
    {
        return false; // 若待插入元素的关键码已存在表中,则插入失败
    }
 
    // 1、如果表为空或表的负载因子>=0.7,那么需要检查哈希表是否需要扩容
    if (_tables.size() == 0 || _n * 10 / _tables.size() >= 7) // 注意
    {
        // 计算新容量(按2倍扩容)
        size_t new_size = _tables.size() == 0 ? 10 : _tables.size() * 2;
 
        // 开始扩容
        // 定义一个新的哈希表(局部变量)
        HashTable<K, V, Hash> new_hash_table;
        new_hash_table._tables.resize(new_size); // 更改新表容量
 
        // 遍历旧表中的所有元素,重新计算它在新表中的位置,一一插入到新表中
        for (size_t i = 0; i < _tables.size(); i++)
        {
            if (_tables[i]._status == EXIST) // EXIST: 此位置已有元素
            {
                new_hash_table.Insert(_tables[i]._kv); // 递归调用Insert,复用代码
            }
        }
 
        // 交换新表和旧表的内容(即交换新旧表vector的内容)
        _tables.swap(new_hash_table._tables);
    }
 
    // 2、再通过哈希函数计算出待插入元素在哈希表中的位置
	// 这里要模size(),不能模capacity(),因为vector中能存放元素的个数为size()
    size_t start = Hash()(kv.first) % _tables.size();
    size_t i = 0;
    size_t index = start;
 
    // 3、该位置有元素,说明发生了哈希冲突,则使用线性检测找到下一个空位置
    while (_tables[index]._status == EXIST) // EXIST: 表示该位置已经有元素
    {
        i++; // 往后找
        index = start + i;        // 线性探测
        // index = start + i * i; // 二次探测
 
        // 如果超出表尾了,又从表头继续开始找
        index %= _tables.size();
    }
 
    // 4、该位置没有元素则直接插入
    _tables[index]._kv = kv;
    _tables[index]._status = EXIST; // 标记该位置的存储状态:存在
    _n++;                           // 有效元素个数+1
 
    // 5、插入成功,返回true
    return true;
}

/*
1.去重判断:调用Find查找key,key已存在直接返回false,禁止重复key。
2.扩容判断:表为空 或者 负载因子>=0.7触发扩容;使用整数运算_n*10/_tables.size()>=7,规避浮点数。
3.扩容逻辑:创建局部新哈希表,resize开辟新空间;遍历旧表,只迁移_status为EXIST的有效元素,调用Insert,在新表重新计算哈希下标。
4.swap交换vector,局部对象出作用域自动释放旧表资源。
5.计算起始下标:仿函数算出哈希值,对_tables.size()取模,不能用capacity。
6.线性探测:只避开EXIST已占用位置,EMPTY、DELETE位置均可插入;index取模实现循环回绕。
7.写入键值对,状态置EXIST,有效元素计数_n++,返回true。
注意:扩容后旧表DELETE伪删除标记全部丢失;二次探测替换index那一行代码即可。
*/

**【测试如下】**插入元素过程中,分别用线性探测和二次探测来找下一个空位置。


【删除元素】

cpp 复制代码
// 删除元素
bool Erase(const K& key)
{
    // 查找,判断该元素是否在表中
    HashData<K, V>* ret = Find(key);
 
    // 待删除元素的关键码不在表中
    if(ret == nullptr)
    {
        return false;
    }
 
    // 待删除元素的关键码在表中
    ret->_status = DELETE; // 伪删除,标记该位置的存储状态为:删除
    _n--;                  // 有效元素个数-1
 
    return true;
}
// /*
// 解析:
// 1.调用Find(key)查找目标节点,找不到返回nullptr,直接返回false代表删除失败。
// 2.闭散列不能直接清空节点内容,使用伪删除,仅把状态置为DELETE,不清空kv。
// 3.不能直接清空节点,否则会破坏探测链,导致后面的元素查找失败。
// 4._n减1,DELETE状态不计入有效元素数量。
// 5.返回true代表删除成功。
// 注意:扩容拷贝元素时,DELETE标记的节点会被丢弃,不会迁移到新表。
// */

【闭散列的效率】 从上面能够看出,开放寻址法(闭散列)并不是完美的方案。 最好情况下,一次命中,时间复杂度为 O (1); 最坏情况下需要遍历整张哈希表,时间复杂度退化到 O (n); 平均时间复杂度为 O (1),前提是控制好负载因子。


2.开散列(链地址法)

(1)开散列概念

开散列也叫哈希桶、链地址法。先用哈希函数算出关键码对应的哈希位置,哈希地址一样的元素就归到同一组,这一组就叫一个桶。桶里面的元素用单链表串起来,各个链表的头结点存放在哈希表数组中。

查找数据的时候,先做哈希运算拿到数组下标,再遍历这个桶的链表比对 key 就行。看上去复杂度跟闭散列差不多,实际不一样。哈希冲突不会频繁发生,再加上会动态扩容,冲突概率进一步降低,桶里的链表不会像闭散列那样出现大量元素扎堆堆积的现象。


从上面两幅图可以看出,开散列中每个桶中放的都是发生哈希冲突的元素


(2)开散列实现

【开散列的结构(KV模型)】

【思考】

除留余数法要求 key 是整型才能做取模运算,那是不是代表开散列只能存整型的 key?那字符串、自定义类型这类 key 该怎么处理?

如果 key 是 string 或者其他自定义类型,可以传入对应的仿函数,把无法直接取模的类型,转换成 size_t 类型的哈希值,之后再做取模计算。

cpp 复制代码
namespace hash_bucket
{
	// 定义哈希表节点结构(KV模型)
	template<class K, class V>
	struct HashNode
	{
		pair<K, V> _kv;        // 数据域:键值对
		HashNode<K, V>* _next; // 后继指针

		// 构造函数
		HashNode(const pair<K, V>& kv)
			: _kv(kv)
			, _next(nullptr)
		{}
	};

    /*
     * 哈希仿函数:除留余数法只支持整型取模,
     * 对于string、自定义类等非整型key,通过该仿函数转换得到size_t类型哈希值
     */
    // 默认仿函数类,处理整型,可以直接强转为size_t的类型
    template<class K>
	struct HashFunc
    {
        // 针对size_t类型和能够隐式类型转换成size_t的类型
        size_t operator()(const K& key)
        {
            return key;
        }
    };

    /*
     * string特化版本:字符串不能直接取模,遍历字符计算哈希值返回size_t
     * 使用131作为乘数,减少哈希冲突概率
     */
    // 特化仿函数
    template<>
    struct HashFunc<string>
    {
        // 把string类型转换成可以取模的size_t类型
        size_t operator()(const string& key)
        {
            size_t hash_key = 0;
            for (size_t i = 0; i < key.size(); i++)
            {
                hash_key *= 131;
                hash_key += key[i];
            }
            return hash_key;
        }
    };

	/*
	 * 开散列哈希桶(链地址法)
	 * K:键类型,V:值类型,Hash:哈希仿函数,提供默认HashFunc<K>
	 * _tables:vector存放每个桶链表的头指针
	 * _n:统计哈希表里有效节点总数量,用于计算负载因子
	 */
	// 定义哈希表结构(KV模型)
	// Hash = HashFunc<K>:仿函数,给一个默认的仿函数
	template<class K, class V, class Hash = HashFunc<K>>
	class HashTable
	{
		typedef HashNode<K, V> Node;

	public:
		// 构造、拷贝构造、赋值重载需要自己写,因为这里是哈希桶结构
        // ...
		/*
		 * 析构函数:释放全部桶内链表节点
		 * 遍历vector数组,对每个不为空的桶,遍历单链表逐个delete释放节点,防止内存泄漏
		 */
		~HashTable() // 析构函数
		{
			// 遍历哈希表,找到不为空的哈希桶
			for (size_t i = 0; i < _tables.size(); i++)
			{
				Node* cur = _tables[i];
				while (cur) // 哈希桶不为空,释放哈希桶中的所有节点
				{
					Node* next = cur->_next; // 记录cur指向节点的下一个节点
					delete cur;              // 释放节点
					cur = next;              // 继续去释放下一个节点
				}
				_tables[i] = nullptr;
			}
			_n = 0;
		}

        Node* Find(const K& key);          // 查找节点
        bool Insert(const pair<K, V>& kv); // 插入节点
        bool Erase(const K& key);          // 删除节点

    private:
		vector<Node*> _tables; // 哈希表存储各链表的头结点地址
		size_t _n = 0;         // 哈希表中有效节点的个数,缺省为0
    };
}

【查找节点】

开散列的查找效率,主要取决于对应桶里面链表的长度,链表越短查找越快。

查找节点思路: 先判断哈希表是不是空表,如果是空,直接返回 nullptr。 用哈希函数算出 key 对应的桶下标,接着遍历这个桶里的链表挨个比对 key。找到就返回该节点的地址,遍历完没找到就返回空。

cpp 复制代码
Node* Find(const K& key)
{
    // 1、先检查哈希表是否为空
    if (_tables.size() == 0)
    {
        return nullptr;
    }
    // 哈希表数组还没有开辟空间,不存在任何数据,直接返回空。

    // 2、再通过哈希函数计算出该元素所映射的位置(即映射的哈希桶位置)
    size_t index = Hash()(key) % _tables.size();
    // 调用哈希仿函数得到哈希值,对数组长度取模,算出key落在哪个桶。

    // 3、遍历该哈希桶,查找节点
    Node* cur = _tables[index]; // cur指向该哈希桶
    while (cur)                 // 遍历该哈希桶的所有节点
    {
        if (key == cur->_kv.first)
        {
            return cur; // 找到了,返回节点地址
        }
        cur = cur->_next;
    }
    // 只遍历当前桶的单链表,挨个比对key,匹配就返回节点地址。

    // 4、没找到,返回空
    return nullptr;
    // 链表遍历完,没有匹配的key,返回nullptr。
}

【插入节点】

1.负载因子

哈希桶一般把负载因子控制在 1 以内,代表平均每个桶挂 1 个节点;负载因子等于 1 的时候就要做扩容。

2.开散列扩容逻辑

桶的总数量固定,不停插入元素后,部分桶里的链表会越变越长。极端情况下一个桶挂大量节点,查找效率会明显下降,所以要在合适时机扩容。 开散列理想状态是每个桶只挂一个节点,再插入新元素就极易产生冲突。所以约定:有效元素总数等于桶的数量时,触发扩容。

思路:

  • 先判断要不要扩容:哈希表为空,或者负载因子达到 1,就执行扩容。
  • 扩容时,把旧表里全部有效节点重新计算哈希,迁移到新桶数组,再交换新旧哈希表。
  • 接着用哈希函数算出待插入 key 对应的桶下标。
  • 遍历当前桶检查 key 是否已经存在: 不存在就头插新节点; key 已经存在,不允许重复插入,直接插入失败。
cpp 复制代码
bool Insert(const pair<K, V>& kv)
{
    // 1、扩容检查
    // 当哈希表为空,或者负载因子(元素个数/桶个数)>= 1 时,需要扩容
    if (_n == _tables.size())
    {
        // 计算新容量:如果当前为0则设为10,否则扩大为2倍
        size_t newSize = _tables.size() == 0 ? 10 : _tables.size() * 2;
        // (注:也可以用素数表扩容,这里注释掉了)
        // size_t newSize = GetNextPrime(_tables.size());

        // 创建新哈希表
        vector<Node*> newTables;
        newTables.resize(newSize);

        // 2、转移旧表节点到新表
        // 遍历旧表的每一个桶
        for (size_t i = 0; i < _tables.size(); i++)
        {
            Node* cur = _tables[i]; // cur指向当前桶的第一个节点

            // 如果当前桶不为空,开始转移
            while (cur != nullptr)
            {
                Node* next = cur->_next; // 保存下一个节点,防止丢失

                // 重新计算当前节点在新表中的位置(哈希地址)
                size_t index = Hash()(cur->_kv.first) % newSize;

                // 头插法将当前节点插入新表的对应桶中
                cur->_next = newTables[index];
                newTables[index] = cur;

                cur = next; // 继续处理下一个节点
            }
            // 当前桶所有节点转移完毕,将旧表指针置空
            _tables[i] = nullptr;
        }
        // 旧表和新表交换,旧表(vector)会自动释放
        _tables.swap(newTables);
    }

    // 3、计算插入位置 
    // 用哈希函数计算 key 对应的桶号
    size_t index = Hash()(kv.first) % _tables.size();

    // 4、查重 
    // 遍历该桶,检查 key 是否已存在(不允许重复)
    Node* cur = _tables[index];
    while (cur)
    {
        if (kv.first == cur->_kv.first)
        {
            return false; // key 已存在,插入失败
        }
        cur = cur->_next;
    }

    //  5、头插新节点 
    Node* newNode = new Node(kv);       // 创建新节点
    newNode->_next = _tables[index];    // 新节点指向原第一个节点
    _tables[index] = newNode;           // 桶头指向新节点
    _n++;                               // 元素个数+1

    return true; // 插入成功
}

(3)开散列的思考

1.只能存储key为整形的元素,其他类型怎么解决?

cpp 复制代码
//1、默认哈希函数(处理整型)
// 哈希函数采用处理余数法,被模的key必须要为整形才可以处理,此处提供将key转化为整形的方法
// 整形数据不需要转化
template<class T>
class DefHashF
{
public:
    size_t operator()(const T& val)
    {
        return val;  // 整型直接返回自身
    }
};

//  2、字符串哈希函数(处理字符串) 
// key为字符串类型,需要将其转化为整形
class Str2Int
{
public:
    size_t operator()(const string& s)
    {
        const char* str = s.c_str();  // 获取C风格字符串
        unsigned int seed = 131;      // 种子值(常用质数:31、131、1313等)
        unsigned int hash = 0;        // 哈希值初始为0
        
        // 遍历字符串每个字符
        while (*str)
        {
            hash = hash * seed + (*str++);  // 累乘加字符
        }
        return (hash & 0x7FFFFFFF);  // 取31位,保证非负(去掉符号位)
    }
};

// 3、哈希桶类(使用哈希函数) 
// 为了实现简单,此哈希表中我们将比较直接与元素绑定在一起
template<class V, class HF>
class HashBucket
{
    // ...
private:
    // 哈希函数:将key转为整型,再模容量得到桶号
    size_t HashFunc(const V& data)
    {
        return HF()(data.first) % _ht.capacity();  // HF()是哈希函数对象
    }
};

2.除留余数法,模数最好选用素数。那扩容时怎么快速拿到接近两倍大小的素数?

哈希表扩容的时候,调用GetNextPrime(_tables.size())获取下一个素数。这样既能让哈希表大小保持为素数,实现近似二倍的扩容效果,计算哈希下标时也可以对素数取模,减少哈希冲突。

cpp 复制代码
//  素数表扩容 =
// 素数表,放了经过筛选的28个素数
// 作用:哈希表扩容时,选择素数作为桶的个数,可以减少哈希冲突
// 原因:取模运算时,素数的模数能更好地分散数据,避免周期性聚集
size_t GetNextPrime(size_t prime)
{
    // 素数表(28个经过筛选的素数)
    const int PRIMECOUNT = 28;
    static const size_t primeList[PRIMECOUNT] =
    {
        53ul, 97ul, 193ul, 389ul, 769ul,
        1543ul, 3079ul, 6151ul, 12289ul, 24593ul,
        49157ul, 98317ul, 196613ul, 393241ul, 786433ul,
        1572869ul, 3145739ul, 6291469ul, 12582917ul, 25165843ul,
        50331653ul, 100663319ul, 201326611ul, 402653189ul, 805306457ul,
        1610612741ul, 3221225473ul, 4294967291ul
    };
 
    // 遍历素数表,找到第一个大于当前容量的素数
    size_t i = 0;
    for (; i < PRIMECOUNT; ++i)
    {
        if (primeList[i] > prime)
            return primeList[i];  // 返回合适的素数作为新容量
    }
    return primeList[i];  // 若都比当前容量小,返回最后一个
}

3.扩容问题

哈希桶负载因子一般控制在 1 以内,也就是平均每个桶挂一个节点,负载因子等于 1 就需要触发扩容。

如果遇到极端情况,所有节点全都挂在同一个桶里,链表会越变越长。查找、删除的效率会大幅下降;但插入是头插,受这个问题的影响不大。

那GetNextPrime(_tables.size())是解决扩容大小的问题。还有另一种处理思路:当单个桶里链表长度过长时,可以把链表转换成红黑树来提升查询性能。

不过 C++ 标准库的哈希桶没有采用这套方案,Java 的 HashMap 是这么实现的。红黑树的查询效率比长链表更高,JDK 里默认规则:单个桶节点数量超过 8,链表就转为红黑树。


【删除节点】

思路:

  • 先判断哈希表是不是空表,如果是空,直接删除失败。
  • 用哈希函数算出 key 对应的桶下标。
  • 遍历当前桶的链表,找到目标节点,同时记录它的前驱节点。 如果找到了目标节点,区分是头节点还是普通中间节点,按对应逻辑完成删除;
  • 遍历完没找到该节点,删除失败。
cpp 复制代码
// 删除节点
bool Erase(const K& key)
{
    // 1、先判断哈希表是否为空
    if (_tables.size() == 0)
    {
        return false; // 表为空,删除失败
    }
 
    // 2、通过哈希函数计算出待删除节点所映射哈希桶的位置
    size_t index = Hash()(key) % _tables.size();
 
    // 3、遍历该哈希桶,查找待删除节点,以及它的前驱节点
    Node* cur = _tables[index];   // cur指向当前桶的第一个节点
    Node* prev = nullptr;          // 记录前驱节点(用于删除非头节点)
    while (cur)
    {
        if (key == cur->_kv.first) // 找到该节点了
        {
            //情况1:删除头节点 
            if (cur == _tables[index]) // cur是头节点
            {
                _tables[index] = cur->_next; // 桶头指向下一个节点
            }
            //情况2:删除非头节点
            else // cur不是头节点
            {
                prev->_next = cur->_next; // 前驱节点跳过cur,指向cur的下一个
            }
 
            delete cur;      // 释放节点内存
            cur = nullptr;   // 置空,避免野指针
            _n--;            // 有效节点个数-1
 
            return true;     // 删除成功
        }
        prev = cur;          // 更新前驱指针
        cur = cur->_next;    // 继续遍历下一个节点
    }
    return false; // 遍历完没找到,删除失败
}

【开散列的效率】

开散列扩容时选用接近两倍大小的素数,目的就是尽可能降低哈希冲突。 理想情况下哈希表的时间复杂度可以达到 O (1),一旦发生大量哈希冲突,桶内链表变长,实际的查询效率就不再是 O (1)。


(4)开散列与闭散列对比

**链地址法(开散列)**每个节点要多存一个指针,看上去会多消耗一点内存。

但**闭散列(开放地址法)**必须预留大量空闲位置,才能保证查找性能,比如二次探查法要求负载因子不能超过 0.7。数组里每个存储单元占用的空间远大于一个指针。综合算下来,开散列实际反而更省内存。

相关推荐
2402_8828938643 分钟前
封装红黑树实现map和set —— 从源码到模拟实现
c++·set·map
神仙别闹2 小时前
基于C++ MFC 实现的智慧公交系统
开发语言·c++·mfc
dear_bi_MyOnly2 小时前
函数模块化:企业级项目高效之道
c++·后端·学习
码匠许师傅3 小时前
【设计模式精讲】13.装饰器模式(Decorator)
c++·设计模式·uml·装饰器模式
OPEN-F3 小时前
C++综合实战:面向对象图书管理系统
开发语言·c++
NeoGressAI外贸数字化4 小时前
外贸独立站零询盘排查:从 Google Search Console 到 PageSpeed 的技术实操
开发语言·c++
神仙别闹4 小时前
基于 QT(C++)实现操作系统
数据库·c++·qt
无小道4 小时前
C/C++——异步编程小记
开发语言·c++·c++11
奇树谦4 小时前
Pimpl 模式(d-pointer)详解:如何解决 C++ 头文件过大、编译依赖和 ABI 兼容问题
开发语言·c++