C++学习之旅【unordered_map和unordered_set的使⽤以及哈希表的实现】


🔥承渊政道: 个人主页
❄️个人专栏: 《C语言基础语法知识》 《数据结构与算法》

《C++知识内容》《Linux系统知识》

✨逆境不吐心中苦,顺境不忘来时路! 🎬 博主简介:

引言:前篇文章,小编已经介绍了关于C++封装红⿊树实现mymap和myset!相信大家应该有所收获!接下来我将带领大家继续深入学习C++的相关内容!本篇文章着重介绍关于unordered_map和unordered_set的使⽤以及哈希表的实现!本文将从实用角度出发,先系统讲解unordered_map和unordered_set的核心用法(包括初始化、增删改查、常用接口及使用注意事项),再层层拆解哈希表的底层实现逻辑(包括哈希函数设计、哈希冲突解决、负载因子与扩容机制),最终通过手动实现一个简易版哈希表,让读者既能熟练运用 STL 中的无序容器,也能深刻理解其底层原理,真正做到"知其然,更知其所以然".那么这里面到底有哪些知识需要我们去学习的呢?废话不多说,带着这些疑问,下面跟着小编的节奏🎵一起去疯狂的学习吧!

目录

1.unordered_set系列的使⽤

1.1unordered_set和unordered_multiset参考⽂档

1.2unordered_set类的介绍


这是C++ 标准库中std::unordered_set 的官方文档,详细介绍了这个容器的定义、特性、模板参数和核心设计思想.
1️⃣类模板声明

①Key:容器中存储的元素类型,同时也是用于唯一标识元素的键(key_type 和 value_type 都指向它).
②Hash:哈希函数类型,默认是std::hash<Key>,负责将Key 类型的对象映射为一个 size_t 类型的哈希值.
③Pred:相等性判断谓词,默认是std::equal_to<Key>,用于判断两个Key 是否等价.
④Alloc:内存分配器类型,默认是std::allocator<Key>,用于管理容器的内存分配和释放.
2️⃣核心特性(Unordered Set 概述)
①存储与访问:unordered_set 是存储唯一元素的无序容器,元素本身就是它的键,因此元素一旦插入就不可修改(但可以删除或插入新元素).
②底层实现:内部通过哈希表实现,元素根据哈希值被分到不同的"桶(buckets)"中,因此可以通过值直接访问元素,平均时间复杂度为O(1).
③性能对比:相比有序的std::set,unordered_set在单元素查找、插入和删除上更快,但在范围遍历(如遍历所有元素)时效率通常更低.
④迭代器:容器的迭代器至少是前向迭代器,不支持随机访问.
3️⃣容器属性

4️⃣模板参数详解
①Key:元素的类型,同时也是键的类型.每个元素都由这个值唯一标识.
②Hash:一个一元函数对象,接收一个Key类型的对象,返回一个size_t 哈希值.unordered_set 用这个哈希值将元素分到不同的桶中,加速查找.默认实现是std::hash<Key>.
③Pred:一个二元谓词,接收两个 Key 类型的对象,返回一个布尔值.如果pred(a, b)返回true,则认为a和b是等价的键.unordered_set 用它来保证键的唯一性,默认实现是std::equal_to<Key>(等价于a == b).
④Alloc:定义内存分配模型的分配器类型,默认使用std::allocator<Key>,这是最基础的、与值无关的分配器.
5️⃣简短概要
①unordered_set的声明如上,Key就是unordered_set底层关键字的类型.
②unordered_set默认要求Key⽀持转换为整形,如果不⽀持或者想按⾃⼰的需求⾛可以⾃⾏实现⽀将Key转成整形的仿函数传给第⼆个模板参数.
③unordered_set默认要求Key⽀持⽐较相等,如果不⽀持或者想按⾃⼰的需求⾛可以⾃⾏实现⽀持将Key⽐较相等的仿函数传给第三个模板参数.
④unordered_set底层存储数据的内存是从空间配置器申请的,如果需要可以⾃⼰实现内存池,传给
第四个参数.
⑤⼀般情况下,我们都不需要传后三个模板参数.
⑥unordered_set底层是⽤哈希桶实现,增删查平均效率是O(1),迭代器遍历不再有序,为了跟set区分,所以取名unordered_set.
⑦前⾯文章我已经介绍了set容器的使⽤,set和unordered_set的功能⾼度相似,只是底层结构不同,有⼀些性能和使⽤的差异,这⾥我只说它们的差异部分.


1.3unordered_set和set的使⽤差异

1️⃣查看⽂档我们会发现unordered_set的⽀持增删查且跟set的使⽤⼀模⼀样,关于使⽤我们这⾥就不再赘述和演示了.
2️⃣unordered_set和set的第⼀个差异是对key的要求不同,set要求Key⽀持⼩于⽐较,⽽unordered_set要求Key⽀持转成整形且⽀持等于⽐较,要理解unordered_set的这个两点要求得后续我们结合哈希表底层实现才能真正理解,也就是说这本质是哈希表的要求.
3️⃣unordered_set和set的第⼆个差异是迭代器的差异,set的iterator是双向迭代器,unordered_set
是单向迭代器,其次set底层是红⿊树,红⿊树是⼆叉搜索树,⾛中序遍历是有序的,所以set迭代器遍历是有序+去重.⽽unordered_set底层是哈希表,迭代器遍历是⽆序+去重.
4️⃣unordered_set和set的第三个差异是性能的差异,整体⽽⾔⼤多数场景下,unordered_set的增删
查改更快⼀些,因为红⿊树增删查改效率是O(logN) ,⽽哈希表增删查平均效率是O(1),具体可以参看下⾯代码的演示的对⽐差异.

cpp 复制代码
#include<unordered_set>
#include<unordered_map>
#include<set>
#include<iostream>
using namespace std;
int test_set2()
{
const size_t N = 1000000;
unordered_set<int> us;
set<int> s;
vector<int> v;
v.reserve(N);
srand(time(0));
for (size_t i = 0; i < N; ++i)
{
//v.push_back(rand()); // N⽐较⼤时,重复值⽐较多
v.push_back(rand()+i); // 重复值相对少
//v.push_back(i); // 没有重复,有序
}
size_t begin1 = clock();
for (auto e : v)
{
s.insert(e);
}
size_t end1 = clock();
cout << "set insert:" << end1 - begin1 << endl;
size_t begin2 = clock();
us.reserve(N);
for (auto e : v)
{
us.insert(e);
}
size_t end2 = clock();
cout << "unordered_set insert:" << end2 - begin2 << endl;
int m1 = 0;
size_t begin3 = clock();
for (auto e : v)
{
auto ret = s.find(e);
if (ret != s.end())
{
++m1;
}
}
size_t end3 = clock();
cout << "set find:" << end3 - begin3 << "->" << m1 << endl;
int m2 = 0;
size_t begin4 = clock();
for (auto e : v)
{
auto ret = us.find(e);
if (ret != us.end())
{
++m2;
}
}
size_t end4 = clock();
cout << "unorered_set find:" << end4 - begin4 << "->" << m2 << endl;
cout << "插⼊数据个数:" << s.size() << endl;
cout << "插⼊数据个数:" << us.size() << endl << endl;
size_t begin5 = clock();
for (auto e : v)
{
s.erase(e);
}
size_t end5 = clock();
cout << "set erase:" << end5 - begin5 << endl;
size_t begin6 = clock();
for (auto e : v)
{
us.erase(e);
}
size_t end6 = clock();
cout << "unordered_set erase:" << end6 - begin6 << endl << endl;
return 0;
}
int main()
{
test_set2();
return 0;
}
cpp 复制代码
#include <iostream>
#include <set>
#include <unordered_set>
#include <string>

// 自定义类型示例:Person
struct Person {
    std::string name;
    int age;

    // 1. 为set提供比较运算符(<)
    bool operator<(const Person& other) const {
        return age < other.age; // 按年龄升序排列
    }

    // 2. 为unordered_set提供相等判断(==)
    bool operator==(const Person& other) const {
        return name == other.name && age == other.age;
    }
};

// 3. 为unordered_set自定义哈希函数(特化std::hash)
namespace std {
    template<> struct hash<Person> {
        size_t operator()(const Person& p) const {
            // 组合name和age的哈希值(避免单一字段冲突)
            return hash<string>()(p.name) ^ (hash<int>()(p.age) << 1);
        }
    };
}

int main() {
    // 1. 基础类型使用:int 
    std::set<int> s1 = {3, 1, 4, 1, 2}; // 自动去重+排序
    std::unordered_set<int> s2 = {3, 1, 4, 1, 2}; // 仅去重,无序

    // 遍历对比:set有序,unordered_set无序
    std::cout << "std::set遍历(有序):";
    for (int num : s1) std::cout << num << " "; // 输出:1 2 3 4
    std::cout << "\nstd::unordered_set遍历(无序):";
    for (int num : s2) std::cout << num << " "; // 输出:随机顺序(如1 2 3 4 或 3 1 4 2等)
    std::cout << "\n";

    // 2. 查找操作 
    // 两者查找接口相同,但性能不同(数据量大时差异明显)
    auto it1 = s1.find(3);
    auto it2 = s2.find(3);
    if (it1 != s1.end()) std::cout << "set找到3\n";
    if (it2 != s2.end()) std::cout << "unordered_set找到3\n";

    //  3. 特殊操作:set支持有序区间查找 
    // set的lower_bound/upper_bound(unordered_set无此接口)
    auto lower = s1.lower_bound(2); // 第一个≥2的元素(2)
    auto upper = s1.upper_bound(3); // 第一个>3的元素(4)
    std::cout << "set中[2,3]区间元素:";
    for (auto it = lower; it != upper; ++it) std::cout << *it << " "; // 输出:2 3
    std::cout << "\n";

    // 4. 自定义类型使用 
    std::set<Person> person_set = {{"Alice", 20}, {"Bob", 18}, {"Alice", 20}};
    std::unordered_set<Person> person_uset = {{"Alice", 20}, {"Bob", 18}, {"Alice", 20}};

    std::cout << "person_set大小:" << person_set.size() << "\n"; // 输出:2(去重)
    std::cout << "person_uset大小:" << person_uset.size() << "\n"; // 输出:2(去重)

    return 0;
}

2.unordered_map系列的使⽤

2.1unordered_map和unordered_multimap参考⽂档

2.2unordered_map类的介绍


这是C++ 标准库中std::unordered_map 的官方文档,详细介绍了这个容器的定义、特性、模板参数和核心设计思想.
1️⃣类模板声明

①Key:键的类型,用于唯一标识容器中的元素.
②T:值的类型,存储与键关联的数据.
③Hash:哈希函数类型,默认是std::hash<Key>,负责将 Key 类型的对象映射为一个size_t 类型的哈希值.
④Pred:相等性判断谓词,默认是std::equal_to<Key>,用于判断两个Key 是否等价.
⑤Alloc:内存分配器类型,默认是allocator<pair<const Key, T>>,用于管理容器的内存分配和释放.
2️⃣核心特性(Unordered Map概述)
①存储与访问:unordered_map 是存储**键值对(key-value)**的无序关联容器,键唯一标识元素,值是与键关联的数据.
②底层实现:内部通过哈希表实现,元素根据键的哈希值被分到不同的"桶(buckets)"中,因此可以通过键直接访问对应的值,平均时间复杂度为O(1).
③性能对比:相比有序的std::map,unordered_map在单元素查找、插入和删除上更快,但在范围遍历(如遍历所有元素)时效率通常更低.
④特殊接口:支持operator[]运算符,可以直接通过键访问对应的值(若键不存在则会插入一个默认值).
⑤迭代器:容器的迭代器至少是前向迭代器,不支持随机访问.
3️⃣容器属性

4️⃣模板参数详解
①Key:键的类型,每个元素都由这个键唯一标识,别名unordered_map::key_type.
②T:值的类型,存储与键关联的数据,别名unordered_map::mapped_type.注意:mapped_type不等于value_type,value_type是 pair<const Key, T>.
③Hash:一个一元函数对象,接收一个Key类型的对象,返回一个 size_t 哈希值.unordered_map用这个哈希值将元素分到不同的桶中,加速查找.默认实现是std::hash<Key>,别名unordered_map::hasher.
④Pred:一个二元谓词,接收两个Key类型的对象,返回一个布尔值.如果pred(a, b)返回 true,则认为a和b是等价的键.unordered_map用它来保证键的唯一性,默认实现是std::equal_to<Key>(等价于a==b),别名 unordered_map::key_equal.
⑤Alloc:定义内存分配模型的分配器类型,默认使用allocator<pair<const Key, T>>,这是最基础的、与值无关的分配器,别名unordered_map::allocator_type.
5️⃣迭代器与元素访问
unordered_map 的元素类型是 value_type,即 pair<const Key, T>,迭代器指向这种键值对.访问方式如下:
通过迭代器访问:

通过operator[]直接访问值:


2.3unordered_map和map的使⽤差异

1️⃣查看⽂档我们会发现unordered_map的⽀持增删查改且跟map的使⽤⼀模⼀样,关于使⽤我们这⾥就不再赘述和演示了.
2️⃣unordered_map和map的第⼀个差异是对key的要求不同,map要求Key⽀持⼩于⽐较,⽽unordered_map要求Key⽀持转成整形且⽀持等于⽐较,要理解unordered_map的这个两点要求
得后续我们结合哈希表底层实现才能真正理解,也就是说这本质是哈希表的要求.
3️⃣unordered_map和map的第⼆个差异是迭代器的差异,map的iterator是双向迭代器,unordered_map是单向迭代器,其次map底层是红⿊树,红⿊树是⼆叉搜索树,⾛中序遍历是有序的,所以map迭代器遍历是Key有序+去重.⽽unordered_map底层是哈希表,迭代器遍历是Key⽆序+去重.
4️⃣unordered_map和map的第三个差异是性能的差异,整体⽽⾔⼤多数场景下,unordered_map的增删查改更快⼀些,因为红⿊树增删查改效率是O(logN) ,⽽哈希表增删查平均效率是O(1),具体可以参看下⾯代码的演示的对⽐差异.

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

// 自定义键类型示例:Student
struct Student {
    std::string id; // 学号(作为键的核心标识)
    std::string name;

    // 1. 为map提供比较运算符(<):按学号排序
    bool operator<(const Student& other) const {
        return id < other.id;
    }

    // 2. 为unordered_map提供相等判断(==):按学号判断等价
    bool operator==(const Student& other) const {
        return id == other.id;
    }
};

// 3. 为unordered_map自定义哈希函数(特化std::hash)
namespace std {
    template<> struct hash<Student> {
        size_t operator()(const Student& s) const {
            // 仅基于学号哈希(保证键的唯一性)
            return hash<string>()(s.id);
        }
    };
}

int main() {
    // 1. 基础类型使用:string -> int(统计单词次数) 
    std::map<std::string, int> m1 = {{"banana", 2}, {"apple", 1}, {"cherry", 3}};
    std::unordered_map<std::string, int> m2 = {{"banana", 2}, {"apple", 1}, {"cherry", 3}};

    // 遍历对比:map有序(按键升序),unordered_map无序
    std::cout << "std::map遍历(按键升序):\n";
    for (const auto& pair : m1) {
        std::cout << pair.first << ": " << pair.second << "\n";
    } // 输出:apple:1 → banana:2 → cherry:3

    std::cout << "\nstd::unordered_map遍历(无序):\n";
    for (const auto& pair : m2) {
        std::cout << pair.first << ": " << pair.second << "\n";
    } // 输出顺序随机(如banana:2 → apple:1 → cherry:3)

    // 2. 元素访问:operator[] 
    m1["date"] = 4; // map插入新键值对,自动排序到对应位置
    m2["date"] = 4; // unordered_map插入新键值对,位置由哈希值决定
    std::cout << "\nmap[\"date\"] = " << m1["date"] << "\n"; // 输出4
    std::cout << "unordered_map[\"date\"] = " << m2["date"] << "\n"; // 输出4

    // 3. 查找操作 
    // 两者查找接口相同,但性能不同(数据量大时差异明显)
    auto it1 = m1.find("apple");
    auto it2 = m2.find("apple");
    if (it1 != m1.end()) std::cout << "\nmap找到apple: " << it1->second << "\n";
    if (it2 != m2.end()) std::cout << "unordered_map找到apple: " << it2->second << "\n";

    // 4. 特殊操作:map支持有序区间查找 
    // map的lower_bound/upper_bound(unordered_map无此接口)
    auto lower = m1.lower_bound("banana"); // 第一个≥"banana"的键
    auto upper = m1.upper_bound("cherry"); // 第一个>"cherry"的键
    std::cout << "\nmap中[banana, cherry]区间元素:\n";
    for (auto it = lower; it != upper; ++it) {
        std::cout << it->first << ": " << it->second << "\n";
    } // 输出:banana:2 → cherry:3

    // 5. 特殊操作:unordered_map支持桶操作 
    // unordered_map的桶相关接口(map无此接口)
    std::cout << "\nunordered_map桶数量:" << m2.bucket_count() << "\n";
    std::cout << "unordered_map负载因子:" << m2.load_factor() << "\n";

    // 6. 自定义键类型使用 
    std::map<Student, int> student_map = {{{"001", "Alice"}, 90}, {{"003", "Bob"}, 85}, {{"002", "Charlie"}, 95}};
    std::unordered_map<Student, int> student_umap = {{{"001", "Alice"}, 90}, {{"003", "Bob"}, 85}, {{"002", "Charlie"}, 95}};

    std::cout << "\nstudent_map遍历(按学号升序):\n";
    for (const auto& pair : student_map) {
        std::cout << pair.first.id << "(" << pair.first.name << "): " << pair.second << "\n";
    } // 输出:001(Alice) → 002(Charlie) → 003(Bob)

    return 0;
}

3.unordered_multimap/unordered_multiset的使用差异


①unordered_multimap/unordered_multiset跟multimap/multiset功能完全类似,⽀持Key冗余.
②unordered_multimap/unordered_multiset跟multimap/multiset的差异也是三个⽅⾯的差异,key的要求的差异,iterator及遍历顺序的差异,性能的差异.

cpp 复制代码
//通过代码,展示两者在插入重复元素、查找 / 统计、遍历上的核心区别
#include <iostream>
#include <unordered_set>
#include <unordered_map>
#include <string>

int main() {
    //  1. unordered_multiset:存储可重复的单一元素 
    std::unordered_multiset<int> ums = {1, 2, 2, 3, 3, 3};
    
    // 插入重复元素(允许)
    ums.insert(2); // 现在2出现3次,3出现3次,1出现1次
    
    // 统计元素出现次数
    std::cout << "unordered_multiset中2的数量:" << ums.count(2) << "\n"; // 输出:3
    std::cout << "unordered_multiset中3的数量:" << ums.count(3) << "\n"; // 输出:3
    
    // 查找第一个匹配元素
    auto it_ums = ums.find(2);
    if (it_ums != ums.end()) {
        std::cout << "unordered_multiset找到第一个2:" << *it_ums << "\n"; // 输出:2
    }
    
    // 遍历所有元素(无序)
    std::cout << "unordered_multiset遍历:";
    for (int num : ums) std::cout << num << " "; // 输出示例:1 2 2 2 3 3 3(顺序随机)
    std::cout << "\n\n";

    //  2. unordered_multimap:存储可重复键的键值对 
    std::unordered_multimap<std::string, int> umm;
    
    // 插入重复键的键值对(允许)
    umm.insert({"apple", 5});
    umm.insert({"apple", 8}); // 键"apple"关联两个值:5、8
    umm.insert({"banana", 3});
    
    // 注意:unordered_multimap 无 operator[]!
    // 原因:键重复时,无法确定返回哪个值,会导致歧义
    // umm["apple"] = 10; // 编译报错!
    
    // 统计键出现的次数
    std::cout << "unordered_multimap中apple的数量:" << umm.count("apple") << "\n"; // 输出:2
    
    // 查找第一个匹配键的键值对
    auto it_umm = umm.find("apple");
    if (it_umm != umm.end()) {
        std::cout << "unordered_multimap找到第一个apple:" 
                  << it_umm->first << ": " << it_umm->second << "\n"; // 输出:apple:5
    }
    
    // 遍历同一键的所有值(用equal_range)
    std::cout << "unordered_multimap中apple的所有值:";
    auto range = umm.equal_range("apple"); // 返回[first, last)迭代器对,包含所有键为apple的元素
    for (auto it = range.first; it != range.second; ++it) {
        std::cout << it->second << " "; // 输出:5 8(顺序随机)
    }
    std::cout << "\n";
    
    // 遍历所有键值对(无序)
    std::cout << "unordered_multimap遍历:";
    for (const auto& pair : umm) {
        std::cout << pair.first << ":" << pair.second << " "; // 输出示例:apple:5 apple:8 banana:3
    }
    std::cout << "\n";

    return 0;
}

4.unordered_xxx的哈希相关接⼝

1️⃣桶(Bucket)相关接口:操作哈希表的底层桶结构
这类接口用于访问桶的数量、大小、元素所在桶等,是最基础的哈希表操作.

2️⃣哈希函数与相等谓词接口:访问哈希规则
这类接口用于获取/验证容器使用的哈希函数和键相等判断规则.

3️⃣负载因子与扩容接口:哈希表性能调优
这类接口是优化unordered_xxx 性能的核心,用于控制哈希表的扩容时机和桶数量.

Buckets和Hash policy系列的接⼝分别是跟哈希桶和负载因⼦相关的接⼝,⽇常使⽤的⻆度我们不需要太关注,下⾯介绍了哈希表底层,我们再来看这个系列的接⼝,⼀⽬了然.


5.哈希概念

哈希(hash)⼜称散列,是⼀种组织数据的⽅式.从译名来看,有散乱排列的意思.本质就是通过哈希函数把关键字Key跟存储位置建⽴⼀个映射关系,查找时通过这个哈希函数计算出Key存储的位置,进⾏快速查找.本质是把任意长度的数据,通过固定规则转换成一个固定长度、紧凑的数值/字符串(哈希值)的过程.
1️⃣核心三要素
哈希函数:做转换的"算法规则",负责把输入映射成哈希值.
哈希值:输出结果,通常是数字/短字符串,用来唯一标识原始数据.
哈希表:用哈希值当下标索引的存储结构,是哈希最常用的落地形式.
2️⃣最通俗类比
把哈希当成查字典:
汉字(原数据)
拼音首字母(哈希函数)
字典页码(哈希值)
直接按页码翻字(哈希表的O(1)快速查找)
一句话总结:哈希=用函数把数据"压缩映射"成短标识,用来极速查找、校验或加密.


5.1直接定址法

直接定址法是最简单、最基础的哈希函数构造方法,核心逻辑是:直接用数据的关键字(key)本身,或对关键字做简单的线性变换,作为该数据的哈希值.

①key:数据的关键字(比如学号、身份证号、商品编号等)
②a、b:常数(通常取 a=1,b=0,即Hash(key) = key)
③Hash(key):最终的哈希值(也是数据在哈希表中的存储位置)
当关键字的范围⽐较集中时,直接定址法就是⾮常简单⾼效的⽅法,⽐如⼀组关键字都在[0,99]之间,那么我们开⼀个100个数的数组,每个关键字的值直接就是存储位置的下标.再⽐如⼀组关键字值都在[a,z]的⼩写字⺟,那么我们开⼀个26个数的数组,每个关键字acsii码-a ascii码就是存储位置的下标.也就是说直接定址法本质就是⽤关键字计算出⼀个绝对位置或者相对位置.这个⽅法我们在计数排序部分已经⽤过了,其次在string章节的下⾯OJ也⽤过了.

cpp 复制代码
class Solution {
public:
int firstUniqChar(string s) {
// 每个字⺟的ascii码-'a'的ascii码作为下标映射到count数组,数组中存储出现的次数
int count[26] = {0};
// 统计次数
for(auto ch : s)
{
count[ch-'a']++;
}
for(size_t i = 0; i < s.size(); ++i)
{
if(count[s[i]-'a'] == 1)
return i;
}
return -1;
}
};

5.2哈希冲突

哈希冲突(也叫散列冲突)是哈希算法中必然出现的现象:两个不同的关键字(key),通过同一个哈希函数计算后,得到了完全相同的哈希值,最终导致这两个不同的数据需要被存放到哈希表的同一个位置,这就是哈希冲突.
哈希冲突不可避免!
核心原因是:鸽巢原理(抽屉原理):
哈希函数的输出(哈希值)是固定范围/固定长度的(比如哈希表只有100个存储位置);
但输入(原始数据/关键字)是无限多、范围无边界的(比如学号可以是 1~10000、身份证号是 18 位数字).
简单说:"100个抽屉要装101个苹果,必然有1个抽屉装2个苹果".哪怕是之前讲的直接定址法,如果关键字范围超过哈希表长度(比如哈希表只有5个位置,却要存学号100、105),也会产生冲突.
直接定址法的缺点也⾮常明显,当关键字的范围⽐较分散时,就很浪费内存甚⾄内存不够⽤.假设我们只有数据范围是[0, 9999]的N个值,我们要映射到⼀个M个空间的数组中(⼀般情况下M >= N),那么就要借助哈希函数(hash function)hf,关键字key被放到数组的h(key)位置,这⾥要注意的是h(key)计算出的值必须在[0, M)之间.这⾥存在的⼀个问题就是,两个不同的key可能会映射到同⼀个位置去,这种问题我们叫做哈希冲突,或者哈希碰撞.理想情况是找出⼀个好的哈希函数避免冲突,但是实际场景中,冲突是不可避免的,所以我们尽可能设计出优秀的哈希函数,减少冲突的次数,同时也要去设计出解决冲突的⽅案.


5.3负载因⼦

负载因子是衡量哈希表拥挤程度的核心指标,直接决定哈希冲突多少和查询效率.
①公式:负载因子=哈希表中已存元素个数 ÷ 哈希表总长度(数组大小)
②通俗理解:把哈希表看成停车场
总车位 = 哈希表长度
已停车数 = 元素个数
负载因子 = 停车率
停车越少 → 负载因子小 → 空位多 → 不堵车
停车越满 → 负载因子大 → 空位少 → 哈希冲突暴增
③关键作用
负载因子越小:空间浪费多,但冲突少、查询快O(1)
负载因子越大:空间利用率高,但冲突多、查询变慢(接近O(n))
④经典默认阈值
大部分语言/库的负载因子阈值是0.75(空间与效率的最优平衡):当实际负载因子超过0.75时,哈希表会自动扩容(一般扩大2倍),重新排布所有元素,降低负载因子,减少冲突.
⑤一句话总结
负载因子越小越不冲突,越大越省空间;0.75是工业界通用的平衡阈值.
假设哈希表中已经映射存储了N个值,哈希表的⼤⼩为M,那么负载因⼦= N M \frac{N}{M} MN负载因⼦有些地⽅也翻译为载荷因⼦/装载因⼦等,它的英⽂为load factor.负载因⼦越⼤,哈希冲突的概率越⾼,空间利⽤率越⾼;负载因⼦越⼩,哈希冲突的概率越低,空间利⽤率越低.


5.4将关键字转为整数

我们将关键字映射到数组中位置,⼀般是整数好做映射计算,如果不是整数,我们要想办法转换成整数,这个细节我们后⾯代码实现中再进⾏细节展示.下⾯哈希函数部分我们讨论时,如果关键字不是整数,那么我们讨论的Key是关键字转换成的整数.


5.5哈希函数

哈希函数是实现哈希(散列)的核心算法,它定义了把任意长度的输入(关键字/原始数据)映射成固定长度输出(哈希值/散列值)的规则.简单来说,哈希函数就是哈希体系里的"转换规则",决定了数据该存到哈希表的哪个位置,也直接影响哈希冲突的概率.
本质:输入(key/数据)→哈希函数→输出(哈希值/存储位置),且输出长度固定.
通俗类比:你去快递站寄件,输入是"你的收货地址(任意长度)",哈希函数是"快递分拣规则(比如按省份 + 城市编号)",输出是"分拣口编号(固定长度,比如01-99)"---每个分拣口对应一个存储区域,就是哈希表的位置.
一个"好的哈希函数"必须满足以下核心特性:

⼀个好的哈希函数应该让N个关键字被等概率的均匀的散列分布到哈希表的M个空间中,但是实际中却很难做到,但是我们要尽量往这个⽅向去考量设计.


5.5.1除法散列法/除留余数法

除法散列法(也叫除留余数法)是哈希表场景中最常用、最易实现的哈希函数构造方法,核心逻辑是:用关键字(key)除以哈希表的长度(m),取余数作为该关键字的哈希值(存储位置).

key:要映射的关键字(整数,如学号、用户ID);
m:哈希表的长度(数组大小);
mod:取模运算(求余数),最终哈希值范围是0~m-1(刚好对应哈希表的下标).
除法散列法的性能完全取决于哈希表长度 m 的选择,这是最容易出错的地方:
(1)m 必须选质数(核心原则)
✅推荐:m=11、17、31、61(质数)
❌避免:m=10、16、20、32(合数/2的幂次)
为什么选质数?
质数的约数只有1和自身,能最大程度避免"关键字与m有公约数",从而减少哈希冲突.
反例:若m=10(合数,约数 1、2、5、10),key=10、20、30→余数都是0→全部冲突;
正例:若m=11(质数),key=10、20、30→余数分别是10、9、8→无冲突.
(2)处理负数关键字
如果key是负数(如-101),直接取模会得到负余数(-101mod11 = -2),无法作为数组下标,需转换为正数:
Hash(key)=(keymodm+m)modm
示例:key=-101,m=11→(-101 mod 11) = -2 → (-2 + 11) mod 11 = 9 → 哈希值 = 9.
(3)m不要接近2的幂次
比如避免 m=16、32、64(24、25、26),因为此时取模等价于取 key 的二进制后k 位,若key 的高位无规律,会导致哈希值分布不均.
需要说明的是,实践中也是⼋仙过海,各显神通,Java的HashMap采⽤除法散列法时就是2的整数次幂做哈希表的⼤⼩M,这样玩的话,就不⽤取模,⽽可以直接位运算,相对⽽⾔位运算⽐模更⾼效⼀些.但是他不是单纯的去取模,⽐如M是2^16^,本质是取后16位,那么⽤key' =key>>16,然后把key和key' 异或的结果作为哈希值.也就是说我们映射出的值还是在[0,M)范围内,但是尽量让key所有的位都参与计算,这样映射出的哈希值更均匀⼀些即可.所以我们上⾯建议M取不太接近2的整数次幂的⼀个质数的理论是⼤多数数据结构书籍中写的理论吗,但是实践中,灵活运⽤,抓住本质,学会应变!


5.5.2乘法散列法(了解)

乘法散列法是另一种经典的哈希函数构造方法,核心区别于除法散列法:它不依赖取模运算,而是通过关键字×常数→取小数部分→映射到哈希表长度的方式生成哈希值.

key:整数类型的关键字(如学号、ID);
m:哈希表长度(推荐选 2k,如8、16、32,二进制运算效率极高);
A:0< A<1的常数,必须选"无理数"(最优值为黄金比例的倒数:A≈0.6180339887,也叫Knuth常数);
⌊x⌋:向下取整(取x的整数部分).


5.5.3全域散列法(了解)

全域散列法是一种随机化的哈希函数构造策略,核心区别于除法/乘法散列法(固定单个哈希函数):它不是用一个固定的哈希函数,而是从一个哈希函数族中随机选择一个哈希函数来使用.其目标是从根本上避免"最坏情况"(比如恶意构造的key导致全表冲突),保证对任意输入,哈希冲突的概率被严格控制在极低水平.全域散列法的本质是"随机化":每次运行程序/初始化哈希表时,随机选一个哈希函数,攻击者无法预判,也就无法构造针对性的冲突key.
公式: h a b ( k e y ) = ( ( a × k e y + b ) % P ) % M h_{ab}(key) = ((a \times key + b)\%P)\%M hab(key)=((a×key+b)%P)%M
P需要选一个足够大的质数,a可以随机选[1,P-1]之间的任意整数,b可以随机选[0,P-1]之间的任意整数,这些函数构成了一个P*(P-1)组全域散列函数组.
假设P=17,M=6,a=3,b=4,则 h 34 ( 8 ) = ( ( 3 × 8 + 4 ) % 17 ) % 6 = 5 h_{34}(8) = ((3 \times 8 + 4)\%17)\%6 = 5 h34(8)=((3×8+4)%17)%6=5.
需要注意的是每次初始化哈希表时,随机选取全域散列函数组中的一个散列函数使用,后续增删查改都固定使用这个散列函数,否则每次哈希都是随机选一个散列函数,那么插入是一个散列函数,查找又是另一个散列函数,就会导致找不到插入的key了.


5.6处理哈希冲突

实践中哈希表⼀般还是选择除法散列法作为哈希函数,当然哈希表⽆论选择什么哈希函数也避免不了冲突,那么插⼊数据时,如何解决冲突呢?主要有两种⽅法:开放定址法和链地址法.


5.6.1开放定址法

在开放定址法中所有的元素都放到哈希表⾥,当⼀个关键字key⽤哈希函数计算出的位置冲突了,则按
照某种规则找到⼀个没有存储数据的位置进⾏存储,开放定址法中负载因⼦⼀定是⼩于1的.这⾥的规
则有三种:线性探测、⼆次探测、双重探测.
线性探测
1️⃣从发生冲突的位置开始,依次线性向后探测,直到寻找到下一个没有存储数据的位置为止,如果走到哈希表尾,则回绕到哈希表头的位置.
2️⃣ h ( k e y ) = h a s h 0 = k e y % M h(key) = hash0 = key \% M h(key)=hash0=key%M,hash0位置冲突了,则线性探测公式为:
h c ( k e y , i ) = h a s h i = ( h a s h 0 + i ) % M , i = { 1 , 2 , 3 , . . . , M − 1 } hc(key, i) = hashi = (hash0 + i) \% M,\ i = \{1,2,3,...,M-1\} hc(key,i)=hashi=(hash0+i)%M, i={1,2,3,...,M−1},因为负载因子小于1,则最多探测M-1次,一定能找到一个存储key的位置.
3️⃣线性探测的比较简单且容易实现,线性探测的问题假设,hash0位置连续冲突,hash0,hash1,hash2位置已经存储数据了,后续映射到hash0,hash1,hash2,hash3的值都会争夺hash3位置,这种现象叫做群集/堆积.下面的二次探测可以一定程度改善这个问题.
4️⃣下面演示 { 19 , 30 , 5 , 36 , 13 , 20 , 21 , 12 } \{19,30,5,36,13,20,21,12\} {19,30,5,36,13,20,21,12}等这一组值映射到M=11的表中.

h(19) = 8、h(30) = 8、h(5) = 5、h(36) = 3、h(13) = 2、h(20) = 9、h(21) =10、h(12) = 1

⼆次探测
1️⃣从发生冲突的位置开始,依次左右按二次方跳跃式探测,直到寻找到下一个没有存储数据的位置为止,如果往右走到哈希表尾,则回绕到哈希表头的位置;如果往左走到哈希表头,则回绕到哈希表尾的位置;
2️⃣ h ( k e y ) = h a s h 0 = k e y % M h(key) = hash0 = key \% M h(key)=hash0=key%M, hash0位置冲突了,则二次探测公式为:
h c ( k e y , i ) = h a s h i = ( h a s h 0 ± i 2 ) % M , i = { 1 , 2 , 3 , ... , M 2 } hc(key, i) = hashi = (hash0 \pm i^2) \% M,\quad i = \{1,2,3,\dots,\frac{M}{2}\} hc(key,i)=hashi=(hash0±i2)%M,i={1,2,3,...,2M}
3️⃣二次探测当 h a s h i = ( h a s h 0 − i 2 ) % M hashi = (hash0 - i^2)\%M hashi=(hash0−i2)%M时,当hashi<0时,需要hashi += M
4️⃣下面演示 { 19 , 30 , 52 , 63 , 11 , 22 } \{19,30,52,63,11,22\} {19,30,52,63,11,22}等这一组值映射到M=11的表中.

h(19) = 8、h(30) = 8、h(52) = 8、h(63) = 8、h(11) = 0、h(22) = 0

双重散列(了解)
1️⃣第一个哈希函数计算出的值发生冲突,使用第二个哈希函数计算出一个跟key相关的偏移量值,不断往后探测,直到寻找到下一个没有存储数据的位置为止.
2️⃣ h 1 ( k e y ) = h a s h 0 = k e y % M h_1(key) = hash0 = key \% M h1(key)=hash0=key%M, hash0位置冲突了,则双重探测公式为:
h c ( k e y , i ) = h a s h i = ( h a s h 0 + i ∗ h 2 ( k e y ) ) % M , i = { 1 , 2 , 3 , . . . , M } hc(key, i) = hashi = (hash0 + i * h_2(key)) \% M,\quad i = \{1,2,3,...,M\} hc(key,i)=hashi=(hash0+i∗h2(key))%M,i={1,2,3,...,M}
3️⃣要求 h 2 ( k e y ) < M h_2(key)<M h2(key)<M且 h 2 ( k e y ) h_2(key) h2(key)和M互为质数,有两种简单的取值方法:
①当M为2整数幂时, h 2 ( k e y ) h_2(key) h2(key) 从[0, M-1]任选一个奇数;
②当M为质数时, h 2 ( k e y ) = k e y % ( M − 1 ) + 1 h_2(key) = key \%(M - 1)+1 h2(key)=key%(M−1)+1
4️⃣保证 h 2 ( k e y ) h_2(key) h2(key)与M互质是因为根据固定的偏移量所寻址的所有位置将形成一个群,若最大公约数 p = g c d ( M , h 1 ( k e y ) ) > 1 p = gcd(M, h_1(key)) > 1 p=gcd(M,h1(key))>1,那么所能寻址的位置的个数为 M / P < M M/P < M M/P<M,使得对于一个关键字来说无法充分利用整个散列表.举例来说,若初始探查位置为1,偏移量为3,整个散列表大小为12,那么所能寻址的位置为{1, 4, 7, 10},寻址个数为 12 / g c d ( 12 , 3 ) = 4 12/gcd(12, 3) = 4 12/gcd(12,3)=4.
5️⃣下面演示 { 19 , 30 , 52 , 74 } \{19,30,52,74\} {19,30,52,74}等这一组值映射到M=11的表中,设 h 2 ( k e y ) = k e y % 10 + 1 h_2(key) = key\%10+1 h2(key)=key%10+1


5.6.2开放定址法代码实现

开放定址法在实践中,不如下⾯讲的链地址法,因为开放定址法解决冲突不管使⽤哪种⽅法,占⽤的都是哈希表中的空间,始终存在互相影响的问题.所以开放定址法,我们简单选择线性探测实现即可.

cpp 复制代码
//开放定址法的哈希表结构
enum State
{
EXIST,
EMPTY,
DELETE
};
template<class K, class V>
struct HashData
{
pair<K, V> _kv;
State _state = EMPTY;
};
template<class K, class V>
class HashTable
{
private:
vector<HashData<K, V>> _tables;
size_t _n = 0; // 表中存储数据个数
};

要注意的是这⾥需要给每个存储值的位置加⼀个状态标识,否则删除⼀些值以后,会影响后⾯冲突的
值的查找.如下图,我们删除30,会导致查找20失败,当我们给每个位置加⼀个状态标{EXIST,EMPTY,DELETE} ,删除30就可以不⽤删除值,⽽是把状态改为DELETE ,那么查找20时是遇到 EMPTY才能,就可以找到20.
h(19) = 8、h(30) = 8、h(5) = 5、h(36) = 3、h(13) = 2、h(20) = 9、h(21) =10、h(12) = 1


扩容
这⾥我们哈希表负载因⼦控制在0.7,当负载因⼦到0.7以后我们就需要扩容了,我们还是按照2倍扩容,但是同时我们要保持哈希表⼤⼩是⼀个质数,第⼀个是质数,2倍后就不是质数了.那么如何解决了,⼀种⽅案就是上⾯除法散列中我们讲的Java HashMap的使⽤2的整数幂,但是计算时不能直接取模的改进⽅法.另外⼀种⽅案是SGI版本的哈希表使⽤的⽅法,给了⼀个近似2倍的质数,每次去质数表获取扩容后的⼤⼩.

cpp 复制代码
inline unsigned long __stl_next_prime(unsigned long n)
{
// Note: assumes long is at least 32 bits.
static const int __stl_num_primes = 28;
static const unsigned long __stl_prime_list[__stl_num_primes] =
{
53, 97, 193, 389, 769,
1543, 3079, 6151, 12289, 24593,
49157, 98317, 196613, 393241, 786433,
1572869, 3145739, 6291469, 12582917, 25165843,
50331653, 100663319, 201326611, 402653189, 805306457,
1610612741, 3221225473, 4294967291
};
const unsigned long* first = __stl_prime_list;
const unsigned long* last = __stl_prime_list + __stl_num_primes;
const unsigned long* pos = lower_bound(first, last, n);
return pos == last ? *(last - 1) : *pos;
}

key不能取模的问题
当key是string/Date等类型时,key不能取模,那么我们需要给HashTable增加⼀个仿函数,这个仿函数⽀持把key转换成⼀个可以取模的整形,如果key可以转换为整形并且不容易冲突,那么这个仿函数就⽤默认参数即可,如果这个Key不能转换为整形,我们就需要⾃⼰实现⼀个仿函数传给这个参数,实现这个仿函数的要求就是尽量key的每值都参与到计算中,让不同的key转换出的整形值不同.string做哈希表的key⾮常常⻅,所以我们可以考虑把string特化⼀下.

cpp 复制代码
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
// 特化
template<>
struct HashFunc<string>
{
// 字符串转换成整形,可以把字符ascii码相加即可
// 但是直接相加的话,类似"abcd"和"bcad"这样的字符串计算出是相同的
// 这⾥我们使⽤BKDR哈希的思路,⽤上次的计算结果去乘以⼀个质数,这个质数⼀般去31, 131等效果会⽐较好
size_t operator()(const string& key)
{
size_t hash = 0;
for (auto e : key)
{
hash *= 131;
hash += e;
}
return hash;
}
};
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:
private:
vector<HashData<K, V>> _tables;
size_t _n = 0; // 表中存储数据个数
};
cpp 复制代码
//完整代码实现
namespace open_address
{
enum State
{
EXIST,
EMPTY,
DELETE
};
template<class K, class V>
struct HashData
{
pair<K, V> _kv;
State _state = EMPTY;
};
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:
inline unsigned long __stl_next_prime(unsigned long n)
{
// Note: assumes long is at least 32 bits.
static const int __stl_num_primes = 28;
static const unsigned long __stl_prime_list[__stl_num_primes] =
{
53, 97, 193, 389, 769,
1543, 3079, 6151, 12289, 24593,
49157, 98317, 196613, 393241, 786433,
1572869, 3145739, 6291469, 12582917, 25165843,
50331653, 100663319, 201326611, 402653189, 805306457,
1610612741, 3221225473, 4294967291
};
const unsigned long* first = __stl_prime_list;
const unsigned long* last = __stl_prime_list +
__stl_num_primes;
const unsigned long* pos = lower_bound(first, last, n);
return pos == last ? *(last - 1) : *pos;
}
HashTable()
{
_tables.resize(__stl_next_prime(0));
}
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
return false;
// 负载因⼦⼤于0.7就扩容
if (_n * 10 / _tables.size() >= 7)
{
// 这⾥利⽤类似深拷⻉现代写法的思想插⼊后交换解决
HashTable<K, V, Hash> newHT;
newHT._tables.resize(__stl_next_prime(_tables.size()+1));
for (size_t i = 0; i < _tables.size(); i++)
{
if (_tables[i]._state == EXIST)
{
newHT.Insert(_tables[i]._kv);
}
}
_tables.swap(newHT._tables);
}
Hash hash;
size_t hash0 = hash(kv.first) % _tables.size();
size_t hashi = hash0;
size_t i = 1;
while (_tables[hashi]._state == EXIST)
{
// 线性探测
hashi = (hash0 + i) % _tables.size();
// ⼆次探测就变成 +- i^2
++i;
}
_tables[hashi]._kv = kv;
_tables[hashi]._state = EXIST;
++_n;
return true;
}
HashData<K, V>* Find(const K& key)
{
Hash hash;
size_t hash0 = hash(key) % _tables.size();
size_t hashi = hash0;
size_t i = 1;
while (_tables[hashi]._state != EMPTY)
{
if (_tables[hashi]._state == EXIST&& _tables[hashi]._kv.first == key)
{
return &_tables[hashi];
}
// 线性探测
hashi = (hash0 + i) % _tables.size();
++i;
}
return nullptr;
}
bool Erase(const K& key)
{
HashData<K, V>* ret = Find(key);
if (ret == nullptr)
{
return false;
}
else
{
ret->_state = DELETE;
--_n;
return true;
}
}
private:
vector<HashData<K, V>> _tables;
size_t _n = 0; // 表中存储数据个数
};
}

5.6.3链地址法(哈希桶)

解决冲突的思路
开放定址法中所有的元素都放到哈希表⾥,链地址法中所有的数据不再直接存储在哈希表中,哈希表中存储⼀个指针,没有数据映射这个位置时,这个指针为空,有多个数据映射到这个位置时,我们把这些冲突的数据链接成⼀个链表,挂在哈希表这个位置下⾯,链地址法也叫做拉链法或者哈希桶.
下⾯演示{19,30,5,36,13,20,21,12,24,96}等这⼀组值映射到M=11的表中.

h(19) = 8;h(30) = 8;h(5) = 5;h(36) = 3;h(13) = 2;h(20) = 9;h(21) =10;h(12) = 1;h(24) = 2;h(96) = 88.

扩容
开放定址法负载因⼦必须⼩于1,链地址法的负载因⼦就没有限制了,可以⼤于1.负载因⼦越⼤,哈希冲突的概率越⾼,空间利⽤率越⾼;负载因⼦越⼩,哈希冲突的概率越低,空间利⽤率越低;stl中unordered_xxx的最⼤负载因⼦基本控制在1,⼤于1就扩容,我们下⾯实现也使⽤这个⽅式.
极端场景
如果极端场景下,某个桶特别⻓怎么办?其实我们可以考虑使⽤全域散列法,这样就不容易被针对了.但是假设不是被针对了,⽤了全域散列法,但是偶然情况下,某个桶很⻓,查找效率很低怎么办?这⾥在Java8的HashMap中当桶的⻓度超过⼀定阀值(8)时就把链表转换成红⿊树.⼀般情况下,不断扩容,单个桶很⻓的场景还是⽐较少的,下⾯我们实现就不搞这么复杂了,这个解决极端场景的思路,⼤家了解⼀下.


5.6.4链地址法代码实现

cpp 复制代码
namespace hash_bucket
{
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)
{}
};
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
typedef HashNode<K, V> Node;
inline unsigned long __stl_next_prime(unsigned long n)
{
static const int __stl_num_primes = 28;
static const unsigned long __stl_prime_list[__stl_num_primes] =
{
53, 97, 193, 389, 769,
1543, 3079, 6151, 12289, 24593,
49157, 98317, 196613, 393241, 786433,
1572869, 3145739, 6291469, 12582917, 25165843,
50331653, 100663319, 201326611, 402653189, 805306457,
1610612741, 3221225473, 4294967291
};
const unsigned long* first = __stl_prime_list;
const unsigned long* last = __stl_prime_list +__stl_num_primes;
const unsigned long* pos = lower_bound(first, last, n);
return pos == last ? *(last - 1) : *pos;
}
public:
HashTable()
{
_tables.resize(__stl_next_prime(0), nullptr);
}
// 拷⻉构造和赋值拷⻉需要实现深拷⻉,有兴趣的同学可以⾃⾏实现
~HashTable()
{
// 依次把每个桶释放
for (size_t i = 0; i < _tables.size(); i++)
{
Node* cur = _tables[i];
while (cur)
{
Node* next = cur->_next;
delete cur;
cur = next;
}
_tables[i] = nullptr;
}
}
bool Insert(const pair<K, V>& kv)
{
Hash hs;
size_t hashi = hs(kv.first) % _tables.size();
// 负载因⼦==1扩容
if (_n == _tables.size())
{
/*HashTable<K, V> newHT;
newHT._tables.resize(__stl_next_prime(_tables.size()+1);
for (size_t i = 0; i < _tables.size(); i++)
{
Node* cur = _tables[i];
while(cur)
{
newHT.Insert(cur->_kv);
cur = cur->_next;
}
}
_tables.swap(newHT._tables);*/
// 这⾥如果使⽤上⾯的⽅法,扩容时创建新的结点,后⾯还要使⽤就结点,浪费了
// 下⾯的⽅法,直接移动旧表的结点到新表,效率更好
vector<Node*>
newtables(__stl_next_prime(_tables.size()+1), nullptr);
for (size_t i = 0; i < _tables.size(); i++)
{
Node* cur = _tables[i];
while (cur)
{
Node* next = cur->_next;
// 旧表中节点,挪动新表重新映射的位置
size_t hashi = hs(cur->_kv.first) %newtables.size();
// 头插到新表
cur->_next = newtables[hashi];
newtables[hashi] = cur;
cur = next;
}
_tables[i] = nullptr;
}
_tables.swap(newtables);
}
// 头插
Node* newnode = new Node(kv);
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_n;
return true;
}
Node* Find(const K& key)
{
Hash hs;
size_t hashi = hs(key) % _tables.size();
Node* cur = _tables[hashi];
while (cur)
{
if (cur->_kv.first == key)
{
return cur;
}
cur = cur->_next;
}
return nullptr;
}
bool Erase(const K& key)
{
Hash hs;
size_t hashi = hs(key) % _tables .size();
Node* prev = nullptr;
Node* cur = _tables[hashi];
while (cur)
{
if (cur->_kv.first == key)
{
if (prev == nullptr)
{
_tables[hashi] = cur->_next;
}
else
{
prev->_next = cur->_next;
}
delete cur;
--_n;
return true;
}
prev = cur;
cur = cur->_next;
}
return false;
}
private:
vector<Node*> _tables; // 指针数组
size_t _n = 0; // 表中存储数据个数
};
}

5.7开放定址法(线性探测)解决哈希冲突的代码实现

cpp 复制代码
#pragma once
#include <iostream>
#include <vector>
#include <string>
using namespace std;

//哈希函数:除留余数法基础实现
template<class K>
struct HashFunc
{
    size_t operator()(const K& key)
    {
        return (size_t)key;
    }
};

//字符串特化哈希函数
template<>
struct HashFunc<string>
{
    size_t operator()(const string& key)
    {
        size_t hash = 0;
        for (auto e : key)
        {
            hash *= 31;  //31是质数,减少哈希冲突
            hash += e;
        }
        return hash;
    }
};

//开放定址法(线性探测)实现哈希表
namespace open_address
{
    //哈希表节点状态(解决假删除问题)
    enum State
    {
        EXIST,   //存在有效数据
        EMPTY,   //空位置
        DELETE   //数据被删除(假删除)
    };

    //哈希表节点结构
    template<class K, class V>
    struct HashData
    {
        pair<K, V> _kv;    //键值对
        State _state = EMPTY; //节点状态
    };

    template<class K, class V, class Hash = HashFunc<K>>
    class HashTable
    {
    public:
        //构造函数:初始化表大小为10
        HashTable()
        {
            _tables.resize(10);
        }

        //插入键值对(返回是否插入成功)
        bool Insert(const pair<K, V>& kv)
        {
            //负载因子>0.7时扩容(开放定址法推荐阈值)
            if (_n * 1.0 / _tables.size() >= 0.7)
            {
                size_t newSize = _tables.size() * 2;
                HashTable<K, V, Hash> newHT;
                newHT._tables.resize(newSize);

                //遍历旧表,将有效数据重新插入新表(扩容后哈希地址变化)
                for (auto& data : _tables)
                {
                    if (data._state == EXIST)
                    {
                        newHT.Insert(data._kv);
                    }
                }

                //交换新旧表(浅拷贝,效率高)
                _tables.swap(newHT._tables);
            }

            //计算初始哈希地址
            Hash hashFunc;
            size_t index = hashFunc(kv.first) % _tables.size();

            //线性探测:找第一个可插入位置(EMPTY/DELETE)
            while (_tables[index]._state == EXIST)
            {
                //键已存在,插入失败
                if (_tables[index]._kv.first == kv.first)
                {
                    return false;
                }

                //向后探测,超出表尾则回到表头
                index++;
                if (index == _tables.size())
                {
                    index = 0;
                }
            }

            //插入数据并更新状态
            _tables[index]._kv = kv;
            _tables[index]._state = EXIST;
            _n++; // 有效数据个数+1
            return true;
        }

        //查找指定键(返回节点指针,未找到返回nullptr)
        HashData<K, V>* Find(const K& key)
        {
            if (_tables.empty()) return nullptr;

            Hash hashFunc;
            size_t index = hashFunc(key) % _tables.size();
            size_t start = index; //记录初始位置,防止死循环

            //线性探测:遇到EMPTY则终止(无数据),遇到DELETE继续探测
            while (_tables[index]._state != EMPTY)
            {
                //找到有效数据且键匹配
                if (_tables[index]._state == EXIST && _tables[index]._kv.first == key)
                {
                    return &_tables[index];
                }

                //向后探测
                index++;
                if (index == _tables.size())
                {
                    index = 0;
                }

                //遍历一圈回到起点,未找到
                if (index == start)
                {
                    break;
                }
            }

            return nullptr;
        }

        //删除指定键(返回是否删除成功)
        bool Erase(const K& key)
        {
            HashData<K, V>* ret = Find(key);
            if (ret)
            {
                ret->_state = DELETE; //假删除(避免影响后续查找)
                _n--; //有效数据个数-1
                return true;
            }
            return false; //键不存在
        }

    private:
        vector<HashData<K, V>> _tables; //哈希表数组
        size_t _n = 0;                  //有效数据个数
    };
}

//验证int和string类型键的哈希表操作
void TestIntHashTable()
{
    cout << "测试int类型键的哈希表" << endl;
    open_address::HashTable<int, int> ht;

    // 插入测试
    cout << "插入(1,1): " << boolalpha << ht.Insert({1, 1}) << endl;    // true
    cout << "插入(11,11): " << ht.Insert({11, 11}) << endl;              // true(11%10=1,线性探测到2)
    cout << "插入(21,21): " << ht.Insert({21, 21}) << endl;              // true(21%10=1,探测到3)
    cout << "插入(1,2): " << ht.Insert({1, 2}) << endl;                  // false(重复键)

    //查找测试
    auto data1 = ht.Find(1);
    cout << "查找1: " << (data1 ? to_string(data1->_kv.second) : "未找到") << endl; // 1
    auto data100 = ht.Find(100);
    cout << "查找100: " << (data100 ? to_string(data100->_kv.second) : "未找到") << endl; // 未找到

    //删除测试
    cout << "删除1: " << ht.Erase(1) << endl;                            // true
    cout << "删除后查找1: " << (ht.Find(1) ? "找到" : "未找到") << endl; // 未找到
    cout << "删除100: " << ht.Erase(100) << endl;                        // false(不存在)
    cout << endl;
}

void TestStringHashTable()
{
    cout << "测试string类型键的哈希表" << endl;
    open_address::HashTable<string, int> ht;

    //插入测试
    cout << "插入(\"apple\",10): " << boolalpha << ht.Insert({"apple", 10}) << endl;  // true
    cout << "插入(\"banana\",20): " << ht.Insert({"banana", 20}) << endl;            // true
    cout << "插入(\"cherry\",30): " << ht.Insert({"cherry", 30}) << endl;            // true
    cout << "插入(\"apple\",100): " << ht.Insert({"apple", 100}) << endl;            // false(重复键)

    //查找测试
    auto dataApple = ht.Find("apple");
    cout << "查找apple: " << (dataApple ? to_string(dataApple->_kv.second) : "未找到") << endl; // 10
    auto dataGrape = ht.Find("grape");
    cout << "查找grape: " << (dataGrape ? to_string(dataGrape->_kv.second) : "未找到") << endl; // 未找到

    //删除测试
    cout << "删除apple: " << ht.Erase("apple") << endl;                              // true
    cout << "删除后查找apple: " << (ht.Find("apple") ? "找到" : "未找到") << endl;   // 未找到
    cout << "删除grape: " << ht.Erase("grape") << endl;                              // false(不存在)
}

int main()
{
    TestIntHashTable();
    TestStringHashTable();
    return 0;
}

5.8链地址法(哈希桶)解决哈希冲突的代码实现

cpp 复制代码
#pragma once
#include <iostream>
#include <vector>
#include <string>
using namespace std;

//哈希函数:除留余数法基础实现
template<class K>
struct HashFunc
{
    size_t operator()(const K& key)
    {
        return (size_t)key;
    }
};

//字符串特化哈希函数
template<>
struct HashFunc<string>
{
    size_t operator()(const string& key)
    {
        size_t hash = 0;
        for (auto e : key)
        {
            hash *= 31;
            hash += e;
        }
        return hash;
    }
};

//哈希桶(开链法)实现:数组+链表解决冲突
namespace hash_bucket
{
    // 哈希桶节点结构
    template<class T>
    struct HashNode
    {
        T _data;               //存储的数据(可是K或pair<K,V>)
        HashNode<T>* _next;    //下一个节点指针
        HashNode(const T& data)
            : _data(data)
            , _next(nullptr)
        {
        }
    };

    //K: 键的类型;T: 存储的元素类型(如pair<K,V>);
    //KeyOfT: 从T中提取key的仿函数;Hash: 哈希函数仿函数
    template<class K, class T, class KeyOfT, class Hash>
    class HashTable
    {
        typedef HashNode<T> Node;
    public:
        //构造函数:初始化桶数组大小为10,所有桶初始化为空
        HashTable()
        {
            _tables.resize(10, nullptr);
        }

        //析构函数:释放所有节点内存
        ~HashTable()
        {
            //遍历每个桶,释放链表所有节点
            for (auto& bucket : _tables)
            {
                Node* cur = bucket;
                while (cur)
                {
                    Node* next = cur->_next;
                    delete cur;
                    cur = next;
                }
                bucket = nullptr; // 桶置空
            }
        }

        //插入元素:存在则返回false,否则插入并返回true
        bool Insert(const T& data)
        {
            KeyOfT kot;    //提取key的仿函数
            Hash hashFunc; //哈希函数仿函数

            // 1.检查当前桶中是否已存在该key(避免重复插入)
            size_t index = hashFunc(kot(data)) % _tables.size();
            Node* cur = _tables[index];
            while (cur)
            {
                if (kot(cur->_data) == kot(data))
                {
                    return false; // key已存在,插入失败
                }
                cur = cur->_next;
            }

            // 2.扩容:负载因子≥1时扩容(哈希桶推荐阈值,链表结构可容忍更高负载)
            if (_n >= _tables.size())
            {
                size_t newSize = _tables.size() * 2;
                vector<Node*> newTables(newSize, nullptr);

                // 遍历旧桶,将所有节点重新哈希到新桶
                for (auto& bucket : _tables)
                {
                    cur = bucket;
                    while (cur)
                    {
                        Node* next = cur->_next; // 保存下一个节点(防止断链)
                        //计算新桶的索引
                        size_t newIndex = hashFunc(kot(cur->_data)) % newSize;
                        //头插法挂载到新桶
                        cur->_next = newTables[newIndex];
                        newTables[newIndex] = cur;
                        cur = next;
                    }
                    bucket = nullptr; //旧桶置空
                }
                _tables.swap(newTables); //交换新旧桶数组(浅拷贝,效率高)
            }

            // 3.头插法插入新节点(头插效率高于尾插,无需遍历链表)
            Node* newNode = new Node(data);
            newNode->_next = _tables[index];
            _tables[index] = newNode;
            _n++; // 有效数据数+1
            return true;
        }

        //查找key:存在返回true,否则返回false
        bool Find(const K& key)
        {
            if (_tables.empty()) return false;

            Hash hashFunc;
            KeyOfT kot;
            //计算key对应的桶索引
            size_t index = hashFunc(key) % _tables.size();
            //遍历该桶的链表
            Node* cur = _tables[index];
            while (cur)
            {
                if (kot(cur->_data) == key)
                {
                    return true; //找到匹配key
                }
                cur = cur->_next;
            }
            return false; //未找到
        }

        //删除key:成功返回true,否则返回false
        bool Erase(const K& key)
        {
            Hash hashFunc;
            KeyOfT kot;
            size_t index = hashFunc(key) % _tables.size();
            Node* cur = _tables[index];
            Node* prev = nullptr; //记录前驱节点(用于删除)

            //遍历链表找目标节点
            while (cur)
            {
                if (kot(cur->_data) == key)
                {
                    //情况1:删除头节点
                    if (prev == nullptr)
                    {
                        _tables[index] = cur->_next;
                    }
                    //情况2:删除中间/尾节点
                    else
                    {
                        prev->_next = cur->_next;
                    }
                    delete cur; //释放节点内存
                    _n--;      //有效数据数-1
                    return true;
                }
                prev = cur;
                cur = cur->_next;
            }
            return false; //未找到key,删除失败
        }

    private:
        vector<Node*> _tables; //桶数组(存储链表头节点指针)
        size_t _n = 0;         //有效数据个数(所有链表节点总数)
    };
}

//仿函数:从pair<int,int>中提取key(第一个元素)
struct KeyOfIntPair
{
    int operator()(const pair<int, int>& kv)
    {
        return kv.first;
    }
};

//仿函数:从pair<string,int>中提取key(第一个元素)
struct KeyOfStringPair
{
    string operator()(const pair<string, int>& kv)
    {
        return kv.first;
    }
};

//测试int类型键的哈希桶
void TestIntHashBucket()
{
    cout << "测试int键的哈希桶" << endl;
    hash_bucket::HashTable<int, pair<int, int>, KeyOfIntPair, HashFunc<int>> ht;

    //插入测试
    cout << "插入(1,1): " << boolalpha << ht.Insert({ 1, 1 }) << endl;    // true
    cout << "插入(11,11): " << ht.Insert({ 11, 11 }) << endl;              // true(11%10=1,和1同桶)
    cout << "插入(1,2): " << ht.Insert({ 1, 2 }) << endl;                  // false(重复key)

    //查找测试
    cout << "查找1: " << ht.Find(1) << endl;                             // true
    cout << "查找11: " << ht.Find(11) << endl;                           // true
    cout << "查找100: " << ht.Find(100) << endl;                         // false

    //删除测试
    cout << "删除1: " << ht.Erase(1) << endl;                            // true
    cout << "删除后查找1: " << ht.Find(1) << endl;                       // false
    cout << "删除100: " << ht.Erase(100) << endl;                        // false
    cout << endl;
}

//测试string类型键的哈希桶
void TestStringHashBucket()
{
    cout << "测试string键的哈希桶" << endl;
    hash_bucket::HashTable<string, pair<string, int>, KeyOfStringPair, HashFunc<string>> ht;

    //插入测试
    cout << "插入(\"apple\",10): " << boolalpha << ht.Insert({ "apple", 10 }) << endl;  // true
    cout << "插入(\"banana\",20): " << ht.Insert({ "banana", 20 }) << endl;            // true
    cout << "插入(\"apple\",30): " << ht.Insert({ "apple", 30 }) << endl;              // false(重复key)

    //查找测试
    cout << "查找apple: " << ht.Find("apple") << endl;                              // true
    cout << "查找banana: " << ht.Find("banana") << endl;                            // true
    cout << "查找grape: " << ht.Find("grape") << endl;                              // false

    //删除测试
    cout << "删除apple: " << ht.Erase("apple") << endl;                              // true
    cout << "删除后查找apple: " << ht.Find("apple") << endl;                         // false
    cout << "删除grape: " << ht.Erase("grape") << endl;                              //false
}

int main()
{
    TestIntHashBucket();
    TestStringHashBucket();
    return 0;
}


6.哈希表的相关OJ题

6.1两句话中的不常见单词

cpp 复制代码
class Solution {
public:
    vector<string> uncommonFromSentences(string s1, string s2) {
        unordered_map<string, int> cnt1, cnt2;
        string word;

        // 统计s1中单词的出现次数
        istringstream iss1(s1);
        while (iss1 >> word) {
            cnt1[word]++;
        }

        // 统计s2中单词的出现次数
        istringstream iss2(s2);
        while (iss2 >> word) {
            cnt2[word]++;
        }

        vector<string> res;
        // 筛选s1中满足条件的单词
        for (auto& [w, c] : cnt1) {
            if (c == 1 && !cnt2.count(w)) {
                res.push_back(w);
            }
        }
        // 筛选s2中满足条件的单词
        for (auto& [w, c] : cnt2) {
            if (c == 1 && !cnt1.count(w)) {
                res.push_back(w);
            }
        }
        return res;
    }
};

6.2两数之和

cpp 复制代码
class Solution {
public:
    vector<int> twoSum(vector<int>& nums, int target) {
        // 哈希表:key = 数组值,value = 对应下标
        unordered_map<int, int> hashMap;
        
        for (int i = 0; i < nums.size(); ++i) {
            // 计算当前值的补数
            int complement = target - nums[i];
            
            // 检查补数是否已在哈希表中
            if (hashMap.find(complement) != hashMap.end()) {
                // 找到结果,返回补数下标和当前下标
                return {hashMap[complement], i};
            }
            
            // 补数不存在,将当前值和下标存入哈希表
            hashMap[nums[i]] = i;
        }
        
        return {};
    }
};

6.3字母异位词分组

cpp 复制代码
class Solution {
public:
    vector<vector<string>> groupAnagrams(vector<string>& strs) {
        unordered_map<string, vector<string>> hashMap;
        for (string& str : strs) {
            vector<int> count(26, 0);
            for (char c : str) {
                count[c - 'a']++; // 统计字符出现次数
            }
            // 将计数数组转换为唯一键
            string key;
            for (int num : count) {
                key += "#" + to_string(num);
            }
            hashMap[key].push_back(str);
        }
        vector<vector<string>> result;
        for (auto& pair : hashMap) {
            result.push_back(pair.second);
        }
        return result;
    }
};

6.4最长连续序列

cpp 复制代码
class Solution {
public:
    int longestConsecutive(vector<int>& nums) {
        //存入哈希集合,去重且支持O(1)查找
        unordered_set<int> numSet(nums.begin(), nums.end());
        int maxLen = 0; // 记录最长连续序列长度

        //遍历每个数字,寻找起始点并统计长度
        for (int num : numSet) {
            //关键:num-1不存在,说明num是序列起始点
            if (numSet.find(num - 1) == numSet.end()) {
                int currentNum = num;
                int currentLen = 1; // 自身长度为1

                //向后扩展连续序列
                while (numSet.find(currentNum + 1) != numSet.end()) {
                    currentNum++;
                    currentLen++;
                }

                //更新最大长度
                maxLen = max(maxLen, currentLen);
            }
        }

        return maxLen;
    }
};

6.5和为k的子数组

cpp 复制代码
class Solution {
public:
    int subarraySum(vector<int>& nums, int k) {
        //哈希表:key=前缀和,value=该前缀和出现的次数
        unordered_map<long long, int> prefixSumCount;
        //初始化:前缀和0出现1次(处理pre[i]=k的情况)
        prefixSumCount[0] = 1;
        
        long long currentSum = 0; //存储当前前缀和
        int count = 0;            //统计和为k的子数组个数
        
        for (int num : nums) {
            currentSum += num; //累加得到当前前缀和
            //计算目标值:若存在currentSum - k,说明有子数组和为k
            if (prefixSumCount.find(currentSum - k) != prefixSumCount.end()) {
                count += prefixSumCount[currentSum - k];
            }
            //将当前前缀和存入哈希表(次数+1,不存在则初始化为1)
            prefixSumCount[currentSum]++;
        }
        
        return count;
    }
};

6.6四数相加||

cpp 复制代码
class Solution {
public:
    int fourSumCount(vector<int>& nums1, vector<int>& nums2, vector<int>& nums3, vector<int>& nums4) {
        //哈希表:key = 两数之和,value = 该和出现的次数
        unordered_map<long long, int> sumCount;
        int count = 0; // 记录满足条件的四元组数量

        //遍历nums1和nums2,统计所有两数之和的出现次数
        for (int a : nums1) {
            for (int b : nums2) {
                long long sum = (long long)a + b; 
                sumCount[sum]++;
            }
        }

        //遍历nums3和nums4,查找目标值并统计结果
        for (int c : nums3) {
            for (int d : nums4) {
                long long target = 0 - ((long long)c + d); // 目标:sum1 = -sum2
                // 若目标值存在,累加其出现次数
                if (sumCount.find(target) != sumCount.end()) {
                    count += sumCount[target];
                }
            }
        }

        return count;
    }
};

敬请期待下一篇文章内容:
⽤哈希表封装myunordered_map和myunordered_set以及位图和布隆过滤器介绍.


每日心灵鸡汤:别为过去后悔,要为未来努力!
特别喜欢这句话:别为过去后悔,要为未来努力.如果你总为某件事或某个人遗憾,总觉得自己错过理想,抱怨生活处处为难,抱怨现实和期待总是不一样,真的没必要,只要最终能到达,晚点也没关系.
或许命运早就给你安排了更合适的路,这边错过那边补上,现在看是事与愿违,可能转个弯就到了该去的地方.你其实从没真正失去什么,记住这句话:有些事比别人慢半拍又如何,别怕,晚霞也能照亮天空!

相关推荐
Trouvaille ~2 小时前
【贪心算法】专题(一):从局部到全局,数学证明下的最优决策
c++·算法·leetcode·面试·贪心算法·蓝桥杯·竞赛
嘉琪0012 小时前
Day2 完整学习包(闭包 & 立即执行函数)——2026 0311
学习
南浦别a2 小时前
第三十一天--继续学习--TreeSet排序方式和HashSet
学习
承渊政道2 小时前
C++学习之旅【⽤哈希表封装myunordered_map和myunordered_set以及位图和布隆过滤器介绍】
数据结构·c++·学习·哈希算法·散列表·hash-index·图搜索算法
0 0 02 小时前
CCF-CSP 37-4集体锻炼【C++】考点:数学(最大公因数gcd特性),常数优化
开发语言·c++·算法
天若有情6732 小时前
【C++实用工具】RandEmmet:致敬Emmet的极简随机数生成器(附完整源码+GitHub)
开发语言·c++·github
金山几座2 小时前
C#学习记录-变量与类型
学习·c#
智者知已应修善业2 小时前
【花费最少钱加油到最后(样例数据推敲)】2024-11-18
c语言·c++·经验分享·笔记·算法
mjhcsp2 小时前
C++状压 DP解析
开发语言·c++·动态规划·状压 dp