unorder_map 和unorder_set

深入理解哈希表:从核心概念到 unordered_map/unordered_set 模拟实现

前言

哈希表(Hash Table)是计算机科学中最重要的数据结构之一,它通过哈希函数将键(Key)映射到数组中的某个位置,从而实现平均 O (1) 时间复杂度的插入、删除和查找操作。在 C++ 标准模板库(STL)中,unordered_mapunordered_set就是基于哈希表实现的关联容器,它们相比基于红黑树的mapset,在大多数场景下具有更高的访问效率。

本文将从哈希表的核心概念入手,深入分析其特性,并详细讲解如何模拟实现 STL 中的unordered_mapunordered_set,重点探讨哈希冲突解决、仿函数处理键值、迭代器设计等关键技术点。

一、哈希表的核心概念与特性

1.1 基本概念

  • 哈希函数(Hash Function):将任意长度的输入(键)转换为固定长度的整数输出(哈希值)的函数。理想的哈希函数应满足:

    • 计算速度快
    • 分布均匀(不同键映射到不同位置的概率尽可能高)
    • 确定性(相同输入总是产生相同输出)
  • 桶(Bucket):哈希表底层数组的每个元素称为一个桶,用于存储哈希值映射到该位置的元素。

  • 哈希冲突(Hash Collision):当两个不同的键通过哈希函数计算得到相同的哈希值时,就会发生哈希冲突。这是哈希表不可避免的问题,需要特定的方法来解决。

  • 负载因子(Load Factor):哈希表中已存储元素个数与桶总数的比值。负载因子越大,哈希冲突的概率越高,性能越低。STL 中默认的最大负载因子为 1.0。

  • 扩容(Rehashing):当负载因子超过阈值时,哈希表会创建一个更大的新数组,并将所有元素重新哈希到新数组中,这个过程称为扩容或重哈希。

1.2 哈希表的特性

  • 时间复杂度

    • 平均情况:插入、删除、查找均为 O (1)
    • 最坏情况:所有元素都发生哈希冲突,退化为 O (n)
  • 空间复杂度:O (n),通常会预留一定的空闲空间以降低负载因子

  • 无序性:元素的存储顺序与插入顺序无关,遍历顺序也不固定

  • 键的唯一性unordered_mapunordered_set中的键都是唯一的,不允许重复

  • 不支持排序:由于元素无序,无法直接进行排序操作

1.3 常见的哈希冲突解决方法

1.3.1 开放定址法

当发生哈希冲突时,按照某种规则在哈希表中寻找下一个空桶。常见的探测方式有:

  • 线性探测:依次检查下一个桶(i+1, i+2, ...)
  • 二次探测:按照 i² 的步长进行探测(i+1², i-1², i+2², i-2², ...)
  • 双重哈希:使用第二个哈希函数计算步长

优点 :不需要额外的存储空间,缓存友好性好缺点:容易产生 "聚集" 现象,删除操作复杂

1.3.2 链地址法(拉链法)

每个桶中维护一个链表(或其他数据结构),当发生哈希冲突时,将元素添加到对应桶的链表中。STL 中的unordered_mapunordered_set采用的就是这种方法。

优点

  • 实现简单,删除操作方便
  • 不会产生聚集现象
  • 负载因子可以大于 1

缺点

  • 需要额外的指针空间
  • 链表过长时性能会下降(C++11 后,当链表长度超过 8 时会转换为红黑树)

二、unordered_map 和 unordered_set 的底层结构

STL 中的unordered_mapunordered_set共享相同的底层哈希表实现,它们的主要区别在于:

  • unordered_map存储的是键值对(pair<const Key, T>)
  • unordered_set存储的是单一的键值

它们的底层结构大致如下:

cpp

运行

复制代码
template <class Key, class T, class Hash = hash<Key>, 
          class KeyEqual = equal_to<Key>, class Alloc = allocator<pair<const Key, T>>>
class unordered_map {
private:
    // 哈希表的节点结构
    struct Node {
        pair<const Key, T> value;
        Node* next;
        // 构造函数
        Node(const pair<const Key, T>& val) : value(val), next(nullptr) {}
    };
    
    // 桶数组
    vector<Node*, Alloc> buckets;
    // 元素个数
    size_t size_;
    // 最大负载因子
    float max_load_factor_;
    // 哈希函数
    Hash hash_;
    // 键相等比较函数
    KeyEqual equal_;
    // 分配器
    Alloc alloc_;
    
    // 其他私有成员函数...
};

三、模拟实现 unordered_map 和 unordered_set 的要点分析

3.1 哈希函数与仿函数设计

3.1.1 内置类型的哈希函数

C++ 标准库为内置类型提供了默认的哈希函数模板std::hash,我们可以直接使用:

cpp

运行

复制代码
template <class T> struct hash;

// 特化版本示例
template<> struct hash<int> {
    size_t operator()(int val) const noexcept {
        return static_cast<size_t>(val);
    }
};

template<> struct hash<string> {
    size_t operator()(const string& s) const noexcept {
        // 字符串哈希算法,如BKDRHash
        size_t hash = 0;
        for (char c : s) {
            hash = hash * 131 + c;
        }
        return hash;
    }
};
3.1.2 自定义类型的哈希函数

对于自定义类型,我们需要提供自己的哈希函数。有两种方式:

  1. 特化std::hash模板
  2. 自定义仿函数作为模板参数

cpp

运行

复制代码
// 自定义类型
struct Person {
    string name;
    int age;
    
    bool operator==(const Person& other) const {
        return name == other.name && age == other.age;
    }
};

// 方式1:特化std::hash
namespace std {
    template<> struct hash<Person> {
        size_t operator()(const Person& p) const noexcept {
            // 组合多个字段的哈希值
            size_t h1 = hash<string>()(p.name);
            size_t h2 = hash<int>()(p.age);
            return h1 ^ (h2 << 1);
        }
    };
}

// 方式2:自定义仿函数
struct PersonHash {
    size_t operator()(const Person& p) const noexcept {
        size_t h1 = hash<string>()(p.name);
        size_t h2 = hash<int>()(p.age);
        return h1 ^ (h2 << 1);
    }
};

// 使用自定义仿函数
unordered_map<Person, string, PersonHash> my_map;
3.1.3 键相等比较仿函数

除了哈希函数,我们还需要一个比较函数来判断两个键是否相等。STL 默认使用std::equal_to,它会调用operator==。如果自定义类型没有重载operator==,我们需要提供自己的比较仿函数:

cpp

运行

复制代码
struct PersonEqual {
    bool operator()(const Person& a, const Person& b) const {
        return a.name == b.name && a.age == b.age;
    }
};

// 使用自定义比较函数
unordered_map<Person, string, PersonHash, PersonEqual> my_map;

3.2 哈希冲突的处理:链地址法实现

我们采用链地址法来解决哈希冲突,每个桶中维护一个单向链表。以下是核心操作的实现要点:

3.2.1 插入操作
  1. 计算键的哈希值
  2. 根据哈希值找到对应的桶索引(哈希值 % 桶的数量)
  3. 遍历该桶的链表,检查是否已存在相同的键
  4. 如果存在,返回 false(不允许重复键)
  5. 如果不存在,创建新节点并插入到链表头部(或尾部)
  6. 元素个数加 1
  7. 如果负载因子超过阈值,进行扩容

cpp

运行

复制代码
pair<iterator, bool> insert(const value_type& val) {
    // 检查是否需要扩容
    if (size_ >= buckets.size() * max_load_factor_) {
        rehash(buckets.size() * 2);
    }
    
    // 计算哈希值和桶索引
    size_t hash_val = hash_(val.first);
    size_t bucket_idx = hash_val % buckets.size();
    
    // 遍历链表查找是否已存在相同的键
    Node* cur = buckets[bucket_idx];
    while (cur != nullptr) {
        if (equal_(cur->value.first, val.first)) {
            // 键已存在,返回指向该元素的迭代器和false
            return {iterator(cur, this), false};
        }
        cur = cur->next;
    }
    
    // 创建新节点并插入到链表头部
    Node* new_node = new Node(val);
    new_node->next = buckets[bucket_idx];
    buckets[bucket_idx] = new_node;
    size_++;
    
    // 返回指向新元素的迭代器和true
    return {iterator(new_node, this), true};
}
3.2.2 查找操作
  1. 计算键的哈希值
  2. 根据哈希值找到对应的桶索引
  3. 遍历该桶的链表,查找是否存在相同的键
  4. 如果找到,返回指向该元素的迭代器
  5. 如果未找到,返回 end () 迭代器

cpp

运行

复制代码
iterator find(const Key& key) {
    size_t hash_val = hash_(key);
    size_t bucket_idx = hash_val % buckets.size();
    
    Node* cur = buckets[bucket_idx];
    while (cur != nullptr) {
        if (equal_(cur->value.first, key)) {
            return iterator(cur, this);
        }
        cur = cur->next;
    }
    
    return end();
}
3.2.3 删除操作
  1. 计算键的哈希值
  2. 根据哈希值找到对应的桶索引
  3. 遍历该桶的链表,查找要删除的节点
  4. 如果找到,调整指针将该节点从链表中移除
  5. 释放节点内存
  6. 元素个数减 1
  7. 返回被删除元素的个数(0 或 1)

cpp

运行

复制代码
size_t erase(const Key& key) {
    size_t hash_val = hash_(key);
    size_t bucket_idx = hash_val % buckets.size();
    
    Node* cur = buckets[bucket_idx];
    Node* prev = nullptr;
    
    while (cur != nullptr) {
        if (equal_(cur->value.first, key)) {
            // 找到要删除的节点
            if (prev == nullptr) {
                // 删除头节点
                buckets[bucket_idx] = cur->next;
            } else {
                // 删除中间或尾节点
                prev->next = cur->next;
            }
            
            delete cur;
            size_--;
            return 1;
        }
        
        prev = cur;
        cur = cur->next;
    }
    
    // 未找到要删除的元素
    return 0;
}

3.3 扩容与重哈希(Rehashing)

当负载因子超过阈值时,我们需要对哈希表进行扩容。扩容的步骤如下:

  1. 创建一个新的桶数组,大小通常为原来的 2 倍(或下一个素数)
  2. 遍历原哈希表的所有桶
  3. 对于每个桶中的每个节点,重新计算其在新桶数组中的索引
  4. 将节点插入到新桶数组的对应位置
  5. 释放原桶数组的内存
  6. 更新哈希表的桶数组指针

cpp

运行

复制代码
void rehash(size_t new_bucket_count) {
    if (new_bucket_count <= buckets.size()) {
        return;
    }
    
    // 创建新的桶数组
    vector<Node*> new_buckets(new_bucket_count, nullptr);
    
    // 遍历原哈希表的所有桶
    for (size_t i = 0; i < buckets.size(); i++) {
        Node* cur = buckets[i];
        while (cur != nullptr) {
            // 保存下一个节点的指针
            Node* next = cur->next;
            
            // 重新计算哈希值和桶索引
            size_t hash_val = hash_(cur->value.first);
            size_t new_bucket_idx = hash_val % new_bucket_count;
            
            // 将节点插入到新桶的头部
            cur->next = new_buckets[new_bucket_idx];
            new_buckets[new_bucket_idx] = cur;
            
            cur = next;
        }
    }
    
    // 替换桶数组
    buckets.swap(new_buckets);
}

注意:为了获得更好的哈希分布,桶的数量通常选择素数。STL 中预定义了一个素数表,每次扩容时选择下一个素数作为新的桶数量。

3.4 迭代器设计

迭代器是容器的重要组成部分,它提供了一种统一的方式来遍历容器中的元素。对于哈希表,迭代器需要能够遍历所有桶中的所有节点。

3.4.1 迭代器的结构

cpp

运行

复制代码
template <class Key, class T, class Hash, class KeyEqual, class Alloc>
class unordered_map<Key, T, Hash, KeyEqual, Alloc>::iterator {
public:
    // 迭代器类型定义
    using value_type = pair<const Key, T>;
    using reference = value_type&;
    using pointer = value_type*;
    using difference_type = ptrdiff_t;
    using iterator_category = forward_iterator_tag;
    
    // 构造函数
    iterator(Node* node, unordered_map* map) : node_(node), map_(map) {}
    
    // 解引用操作
    reference operator*() const {
        return node_->value;
    }
    
    pointer operator->() const {
        return &(node_->value);
    }
    
    // 前置++
    iterator& operator++() {
        if (node_ == nullptr) {
            return *this;
        }
        
        // 移动到当前链表的下一个节点
        node_ = node_->next;
        
        // 如果当前链表遍历完毕,找到下一个非空桶
        if (node_ == nullptr) {
            size_t current_bucket = map_->hash_(node_->value.first) % map_->buckets.size();
            for (size_t i = current_bucket + 1; i < map_->buckets.size(); i++) {
                if (map_->buckets[i] != nullptr) {
                    node_ = map_->buckets[i];
                    break;
                }
            }
        }
        
        return *this;
    }
    
    // 后置++
    iterator operator++(int) {
        iterator temp = *this;
        ++(*this);
        return temp;
    }
    
    // 相等比较
    bool operator==(const iterator& other) const {
        return node_ == other.node_;
    }
    
    bool operator!=(const iterator& other) const {
        return node_ != other.node_;
    }
    
private:
    Node* node_;
    unordered_map* map_;
    
    // 让unordered_map可以访问迭代器的私有成员
    friend class unordered_map;
};
3.4.2 begin () 和 end () 函数

cpp

运行

复制代码
iterator begin() {
    // 找到第一个非空桶
    for (size_t i = 0; i < buckets.size(); i++) {
        if (buckets[i] != nullptr) {
            return iterator(buckets[i], this);
        }
    }
    
    // 所有桶都为空,返回end()
    return end();
}

iterator end() {
    return iterator(nullptr, this);
}

3.5 unordered_set 的实现

unordered_set的实现与unordered_map非常相似,主要区别在于:

  • unordered_set存储的是单一的键值,而不是键值对
  • 它的迭代器返回的是键的引用,而不是 pair 的引用

我们可以通过代码复用的方式来实现unordered_set,例如让unordered_set内部包含一个unordered_map,并将值类型设置为charbool

cpp

运行

复制代码
template <class Key, class Hash = hash<Key>, 
          class KeyEqual = equal_to<Key>, class Alloc = allocator<Key>>
class unordered_set {
private:
    // 内部使用unordered_map来存储数据
    unordered_map<Key, char, Hash, KeyEqual, Alloc> map_;
    
public:
    // 类型定义
    using key_type = Key;
    using value_type = Key;
    using size_type = size_t;
    using difference_type = ptrdiff_t;
    using hasher = Hash;
    using key_equal = KeyEqual;
    using allocator_type = Alloc;
    using reference = value_type&;
    using const_reference = const value_type&;
    using pointer = typename allocator_type::pointer;
    using const_pointer = typename allocator_type::const_pointer;
    
    // 迭代器
    class iterator {
    public:
        // 迭代器实现,包装unordered_map的迭代器
        using map_iterator = typename unordered_map<Key, char, Hash, KeyEqual, Alloc>::iterator;
        
        iterator(map_iterator it) : it_(it) {}
        
        reference operator*() const {
            return it_->first;
        }
        
        pointer operator->() const {
            return &(it_->first);
        }
        
        iterator& operator++() {
            ++it_;
            return *this;
        }
        
        iterator operator++(int) {
            iterator temp = *this;
            ++it_;
            return temp;
        }
        
        bool operator==(const iterator& other) const {
            return it_ == other.it_;
        }
        
        bool operator!=(const iterator& other) const {
            return it_ != other.it_;
        }
        
    private:
        map_iterator it_;
        friend class unordered_set;
    };
    
    // 成员函数
    pair<iterator, bool> insert(const value_type& val) {
        auto res = map_.insert({val, 0});
        return {iterator(res.first), res.second};
    }
    
    size_type erase(const key_type& key) {
        return map_.erase(key);
    }
    
    iterator find(const key_type& key) {
        return iterator(map_.find(key));
    }
    
    size_type size() const {
        return map_.size();
    }
    
    bool empty() const {
        return map_.empty();
    }
    
    iterator begin() {
        return iterator(map_.begin());
    }
    
    iterator end() {
        return iterator(map_.end());
    }
    
    // 其他成员函数...
};

四、性能优化与注意事项

4.1 哈希函数的选择

  • 对于整数类型,可以直接使用其值作为哈希值
  • 对于字符串类型,推荐使用 BKDRHash、APHash 等经典算法
  • 对于自定义类型,要确保组合多个字段的哈希值,避免冲突

4.2 负载因子的调整

  • 默认的最大负载因子是 1.0,可以通过max_load_factor()函数调整
  • 降低负载因子可以减少哈希冲突,提高访问速度,但会增加内存消耗
  • 提高负载因子可以节省内存,但会增加哈希冲突的概率

4.3 预分配空间

  • 如果知道要存储的元素数量,可以使用reserve()函数预分配足够的空间
  • 这样可以避免频繁的扩容操作,提高性能

4.4 自定义类型的注意事项

  • 必须提供哈希函数和相等比较函数
  • 相等比较函数必须与哈希函数保持一致:如果两个键相等,它们的哈希值必须相同
  • 键类型必须是可复制和可移动的

五、总结

哈希表是一种高效的数据结构,它通过哈希函数实现了平均 O (1) 时间复杂度的操作。STL 中的unordered_mapunordered_set基于链地址法实现,具有良好的性能和灵活性。

模拟实现这两个容器需要掌握以下核心要点:

  1. 哈希函数和相等比较仿函数的设计
  2. 链地址法解决哈希冲突的实现
  3. 扩容与重哈希机制
  4. 迭代器的设计与实现
  5. 代码复用技巧(unordered_set 复用 unordered_map)

在实际使用中,我们需要根据具体场景选择合适的哈希函数和负载因子,以获得最佳的性能。同时,对于自定义类型,要确保正确实现哈希函数和相等比较函数,避免出现难以调试的问题。

相关推荐
sheeta19988 小时前
LeetCode 每日一题笔记 日期:2026.05.20 题目:2657. 找到前缀公共数组
笔记·算法·leetcode
数智工坊8 小时前
【UniT论文阅读】:用统一物理语言打通人类与人形机器人的知识壁垒
论文阅读·人工智能·深度学习·算法·机器人
梓䈑8 小时前
【算法题攻略】模拟
c++·算法
Evand J8 小时前
【课题推荐与代码介绍】卡尔曼滤波器正反向估计算法原理与MATLAB实现
开发语言·算法·matlab
DFT计算杂谈8 小时前
VASP新手入门: IVDW 色散修正参数
linux·运维·服务器·python·算法
吃着火锅x唱着歌9 小时前
LeetCode 962.最大宽度坡
算法·leetcode·职场和发展
无限进步_9 小时前
【C++】C++11的类功能增强与STL变化
java·前端·数据结构·c++·后端·算法
WL_Aurora9 小时前
Python 算法基础篇之排序算法(一):冒泡、选择、插入
python·算法·排序算法
凌波粒9 小时前
LeetCode--257. 二叉树的所有路径(二叉树)
算法·leetcode·职场和发展