【数据结构】`unordered_map` 和 `unordered_set` 的底层原理

unordered_mapunordered_set 是 C++ 标准库中的两个容器,它们被广泛应用于需要快速查找的场景中。它们的查找、插入和删除的平均时间复杂度都是 O(1) ,这也是它们的一个重要特性。本文将详细介绍 unordered_mapunordered_set 的底层原理,帮助计算机专业的小白理解什么是哈希、桶以及为什么它们的查找效率如此之高。

本篇文章需要有unordered_map、unordered_set、vector等的基础,若不清楚,建议先去了解后再来阅读

若本文对你有帮助的话,可以给我点点关注和赞👍,感谢。


一、什么是 unordered_mapunordered_set

在 C++ 中,unordered_mapunordered_set 是两个基于 哈希表 实现的容器。

  • unordered_map:是一个关联容器,用于存储键值对(key-value pairs)。每个键(key)是唯一的,并且与一个值(value)相关联。
  • unordered_set:是一个无序的集合,用于存储唯一的键值(key)。它不存储重复元素。

简单代码示例

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

int main() {
    // 使用 unordered_map
    std::unordered_map<int, std::string> map;
    map[1] = "One";
    map[2] = "Two";

    // 使用 unordered_set
    std::unordered_set<int> set;
    set.insert(1);
    set.insert(2);

    // 输出 map 和 set 内容
    std::cout << "Map:" << std::endl;
    for (const auto& pair : map) {
        std::cout << pair.first << ": " << pair.second << std::endl;
    }

    std::cout << "Set:" << std::endl;
    for (const auto& item : set) {
        std::cout << item << std::endl;
    }

    return 0;
}

二、哈希表的工作原理

哈希表的关键概念包括桶(bucket)、哈希函数(hash function)、链表等。我们将通过一些符号表示出其内部结构。

在标准库的实现中,哈希表的桶通常是一个 std::vector ,但其具体实现可能因标准库(如 libstdc++libc++)的不同而有所差异。下面是对桶的具体结构的详细分析:


1. 桶的结构

是一个容器,用于存储所有映射到该桶的元素。其底层实现通常是以下两种方式之一:

(1) 指针形式

每个桶存储一个指向链表(或其他结构)的指针。例如:

plaintext 复制代码
[ 桶0 ] -> nullptr
[ 桶1 ] -> head -> [key2] -> [key1] -> nullptr
[ 桶2 ] -> nullptr
[ 桶3 ] -> head -> [key3] -> nullptr

这种实现中,桶本身存储指向链表的指针,而链表存储具体的键值。


(2) 动态数组形式(std::vector 或类似结构)

每个桶本身是一个动态数组(如 std::vector),用于直接存储冲突的元素:

plaintext 复制代码
[ 桶0 ] -> 空
[ 桶1 ] -> [key1, key2]
[ 桶2 ] -> 空
[ 桶3 ] -> [key3]

2. 实际实现中的常见选择

大多数标准库实现中,unordered_mapunordered_set 的桶是用 std::vector 作为底层数据结构,并且每个桶存储一个 指向链表头部的指针,而链表用来存储具体元素。原因如下:

(1) 使用 std::vector 管理桶
  • std::vector 是常见的选择,因为它提供高效的随机访问(通过下标快速定位桶)。
  • 动态扩展的特性允许哈希表在需要时进行 rehashing(增加桶数量)。
(2) 使用链表存储冲突元素
  • 链表 允许高效插入和删除元素,尤其在发生哈希冲突时。
  • 每个链表节点可能存储键值对(对于 unordered_map)或单独的键(对于 unordered_set),并且包含指向下一个节点的指针。

3. 哈希表中桶的管理

哈希表维护一个桶数组,这个数组的大小会根据装载因子(load factor)动态调整:

  • 装载因子 是表中元素总数与桶数量的比值。例如:
    • 如果有 10 个桶和 30 个元素,装载因子为 3。
  • rehashing 发生时,桶的数量通常会增大到一个质数(比如 2 倍+1),以减少冲突概率。

rehashing 过程中:

  • 将旧桶中的元素重新分配到新桶中。
  • 由于每个桶的元素重新映射到新桶,链表也会被重新构造。

4. 小结

  • 桶本身通常是一个 std::vector,它存储指向链表的指针。
  • 链表 是冲突处理的主要数据结构,用于存储同一桶中的多个元素。
  • 这种设计兼顾了随机访问的效率(通过 std::vector)和动态操作的灵活性(通过链表)。

三、 哈希表的基本结构

哈希表中的每一个桶都是一个存放数据的单元,用于存放一个或多个元素。当我们向哈希表中插入一个键(key)时,哈希表会先通过 哈希函数 将该键映射到一个特定的桶。如下图所示:

假设有一个简单的哈希表结构,包含 5 个桶:

哈希表结构:
[ 桶0 ]    [ 桶1 ]    [ 桶2 ]    [ 桶3 ]    [ 桶4 ]

1. 哈希函数的作用

哈希函数将键值转化为整数值(索引),表示对应桶的位置。例如,对于键 key1,如果哈希函数将其映射到索引 1,那么 key1 就会存储在 桶 1 中。

hash(key1) = 1,即 key1 存在 桶 1 中。

2. 哈希冲突的处理:链地址法

由于不同的键可能会映射到同一个桶中,这种现象称为 哈希冲突 。在 unordered_mapunordered_set 中,通常使用 链地址法 来处理冲突。链地址法是指,每个桶中有一个链表,用于存储发生冲突的元素。每当冲突发生时,新元素会被追加到链表的末尾。

例如,假设 key1key2 都映射到 桶 1,哈希表的结构如下所示:

[ 桶0 ]      [ 桶1 ]               [ 桶2 ]       [ 桶3 ]       [ 桶4 ]
           head -> key1 -> key2

这里,桶 1 存储一个链表,其中 key1 是链表头节点,key2 是链表的下一个节点。

3. 链表结构的插入和查找

在链表结构中:

  • 插入:新元素被追加到链表末尾。
  • 查找:通过遍历链表,依次检查节点是否与目标键匹配。

具体过程如下:

插入过程:

假设我们要插入 key3,且 hash(key3) = 1(与 key1key2 冲突)。哈希表插入后的结构如下:

[ 桶0 ]      [ 桶1 ]                       [ 桶2 ]       [ 桶3 ]       [ 桶4 ]
           head -> key1 -> key2 -> key3
查找过程:

假设要查找 key2,查找步骤如下:

  1. 通过哈希函数找到桶索引:hash(key2) = 1
  2. 在桶 1 中找到对应链表,遍历链表并依次检查各节点:
    • headkey1,不匹配,继续向后找;
    • 第二个节点是 key2,匹配成功,返回结果。

四、为什么查找是 O(1)

在理想情况下,哈希表能够将数据分布在不同的桶中,每个桶中只有少量元素,查找和插入的时间复杂度接近 O(1)。我们可以通过以下符号化结构了解平均 O(1) 时间复杂度的实现条件。

1. 理想情况

假设哈希表结构如下,其中每个桶中只有一个节点:

[ 桶0 ]    [ 桶1 ]    [ 桶2 ]    [ 桶3 ]    [ 桶4 ]
  key0      key1      key2      key3      key4

这种情况下,哈希表中没有冲突,查找过程只需要哈希函数定位到特定桶即可完成,查找时间为 O(1)。

2. 发生冲突时的情况

当哈希表元素数量增加或哈希函数无法避免冲突时,多个键会映射到同一个桶。例如:

[ 桶0 ]         [ 桶1 ]                  [ 桶2 ]       [ 桶3 ]       [ 桶4 ]
              head -> key1 -> key2

此时,在桶 1 中查找 key2,需要遍历链表,这样的查找复杂度接近 O(n)。不过,在实际使用中,C++ unordered_map 会自动 扩容,将桶数量增多,从而降低冲突发生的几率,使查找平均复杂度保持在 O(1)。


五、代码示例

1. unordered_map 查找示例

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

int main() {
    std::unordered_map<std::string, int> map;
    map["apple"] = 1;
    map["banana"] = 2;
    map["cherry"] = 3;

    std::string key = "banana";
    if (map.find(key) != map.end()) {
        std::cout << "Found: " << key << " => " << map[key] << std::endl;
    } else {
        std::cout << key << " not found." << std::endl;
    }

    return 0;
}

2. unordered_set 查找示例

cpp 复制代码
#include <iostream>
#include <unordered_set>

int main() {
    std::unordered_set<int> set = {1, 2, 3, 4, 5};

    int key = 3;
    if (set.find(key) != set.end()) {
        std::cout << "Found: " << key << std::endl;
    } else {
        std::cout << key << " not found." << std::endl;
    }

    return 0;
}

六、扩展内容:使用哈希表时的注意事项

1. 负载因子

负载因子定义了哈希表的装填情况。过高的负载因子会导致更多冲突,进而影响性能。在 C++ 中,可以通过 max_load_factorrehash 函数管理负载因子。

2. 自定义哈希函数

C++ 提供了 std::hash 作为默认的哈希函数,但在某些情况下我们可以自定义哈希函数。

示例:自定义哈希函数

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

struct Key {
    int x, y;
    bool operator==(const Key& other) const {
        return x == other.x && y == other.y;
    }
};

struct KeyHash {
    std::size_t operator()(const Key& k) const {
        return std::hash<int>()(k.x) ^ (std::hash<int>()(k.y) << 1);
    }
};

int main() {
    std::unordered_map<Key, int, KeyHash> map;
    map[{1, 2}] = 3;

    Key key = {1, 2};
    if (map.find(key) != map.end()) {
        std::cout << "Found key (1, 2) with value: " << map[key] << std::endl;
    } else {
        std::cout << "Key (1, 2) not found." << std::endl;
    }

    return 0;
}

六、总结

unordered_mapunordered_set 是 C++ 中重要的容器类型,它们基于哈希表实现,能够在平均 O(1) 的时间内完成查找、插入和删除操作。这种特性在需要高效查找的应用场景中非常有用。理解其底层的哈希表原理及冲突处理方法,是编写高性能代码的基础。

相关推荐
西北大程序猿2 小时前
C++ ──── set和map的模拟实现
开发语言·数据结构·c++
卷卷的小趴菜学编程5 小时前
类和对象(中)
java·c语言·开发语言·数据结构·c++·算法·链表
鲨鱼吃橘子6 小时前
【C++】了解map和set及平衡二叉树和红黑树的原理
c语言·开发语言·数据结构·c++·算法·链表
Shiroha Wang7 小时前
【数据结构OJ】【图论】红色警报
数据结构·笔记·算法·深度优先·图论
做人不要太理性7 小时前
C++:哈希表
数据结构·c++·哈希算法·散列表·哈希·关联式容器
躺不平的理查德8 小时前
数据结构及算法--排序篇
c语言·开发语言·数据结构·算法·排序算法
赔罪8 小时前
C 语言标准库 - <math.h>
java·c语言·开发语言·数据结构·c++·python·算法
lb363636363610 小时前
统计从输入的两个整数a和b所确定的范围内(0 ~ 9)出现的次数(c基础)
c语言·数据结构·算法
原来是猿13 小时前
类和对象(中)
c语言·开发语言·数据结构·c++