【C++ STL】unordered_map&unordered_set (哈希表)


unordered_map&unordered_set

哈希表(Hash Table)是一种数据结构,用于实现关联数组,它能够通过哈希函数将关键字映射到数组中的一个位置来实现快速的数据查找、插入和删除操作。在C++中,哈希表通常通过标准库中的unordered_map来实现。

1. unordered容器

当节点非常多时,map和set查询效率也不够理想。C++11新增unordered_map, unordered_set等底层为哈希的关联式容器。

unordered意为无序的,即存储遍历不按key排序。所以unordered系列容器只有单向迭代器。

容器底层区别
set / map红黑树排序加去重
multiset / multimap红黑树排序不去重
unordered_set / unordered_map哈希去重不排序
unordered_multiset / unordered_multimap哈希不排序不去重

1.1 效率对比

int n = 10000000;
vector<int> v;
srand((unsigned int)time(nullptr));
for (int i = 0; i < n; i++) {
    v.push_back(rand());
    v.push_back(i);
}

set<int> s;
unordered_set<int> us;

int begin1 = clock();
for (auto e : v) {
    s.insert(e);
}
int end1 = clock();

//
int begin2 = clock();
for (auto e : v) {
    us.insert(e);
}
int end2 = clock();

cout << "set insert: " << end1 - begin1 << endl;
cout << "unordered_set insert: " << end2 - begin2 << endl;
  • 当插入随机值且数据量很大时,unordered_set比set快3倍左右。
  • 当插入有序值且数据量很大时,unordered_set比set慢2倍左右。
  • unordered_set的查找效率极高,这得益于哈希的底层结构。

set更适合有序重复度低的数据,unordered_set更适合随机重复度高的数据。综合来看,unordered系列容器比map/set效率要更好些。

 

2. 哈希

2.1 哈希的定义

哈希也叫做散列,记数排序就体现了哈希思想。

计数排序为统计数字出现的个数,为每个数字都开辟了对应的一块空间,用来记录其出现的个数,每遇到就加一。

将一个元素和一个位置建立映射关系,这就是哈希的本质

哈希函数

搜索树中key与元素的存储位置没有直接映射。因此查找必须经历多次比较key。搜索效率还是不够理想。

最理想的搜索方法是:不经任何比较,一次性直接得出元素的存储位置。

通过某种函数(hashFunc)使元素 key 与它的存储位置之间能够建立映射关系,那么就可以一次性找到该元素。

除留余数法

例如,存在数据集合 [ 1 , 7 , 6 , 4 , 5 , 9 ] [1,7,6,4,5,9] [1,7,6,4,5,9],将哈希函数设置为 h a s h ( k e y ) = k e y    %    c a p a c i t y hash(key)=key\;\%\;capacity hash(key)=key%capacity c a p a c i t y capacity capacity 为存储空间总的大小。

自定义哈希函数

常见的数据类型如整数,库中自带哈希函数,但有些类型比如自定义类型,需要我们自定义哈希函数。

可以把哈希函数设计成仿函数,对不同类型添加特化处理。

template<class K>
struct Hash {
    size_t operator()(const K& key) {
        return key;
    }
};
// 特化
template<>
struct Hash<string> {
    //BKDR
    size_t operator()(const string& s) {
        size_t val = 0;
        for (auto ch : s) {
            val = val * 131 + ch;
        }
        return val;
    }
};

如果我们自定义类型作key,可以单独为其设计一个哈希函数,并在创建哈希表时作参数传进去。

struct Date {
    int _year;
    int _month;
    int _day;
};
struct HashDate {
    size_t operator()(const Date& date) {
        return date._year + date._month + date._day;
    }
};
哈希冲突

很可能存在不同数值映射到同一位置的情况,比如 10 % 10 = 0 ,    20 % 10 = 0 10\%10=0,\;20\%10=0 10%10=0,20%10=0。这就是哈希冲突或称哈希碰撞。

一般哈希冲突是不可避免的,冲突越多效率越低,故提高哈希效率在于如何解决哈希冲突。解决哈希冲突的两种常见的方法是:闭散列和开散列。

2.2 哈希冲突的解决

闭散列/开放定址法

闭散列,又称开放定址法。当发生哈希冲突时,把元素放到冲突位置后的下一个空位中

如何寻找空位呢?一般有线性探测和二次探测。如果没有空位,可以闭散列扩容。

两种探测方式
探测方式解释问题
线性探测从冲突位置向后遇到的第一个空位,就是存放位置连续位置冲突越来越多,引发洪水效应
二次探测每次向后跳过 i 2 i^2 i2 个长度,如遇到空位就是存放位置能缓解拥堵,快满时“绕圈”现象明显。

如何表示元素位置是否存在、为空、被删除?要为每个位置数据增加状态标记。

在这里插入图片描述

二次探测在得到位置后如果发生冲突,第一次向后跳过 1 2 1^2 12个长度、第二次 2 2 2^2 22个长度,如此类推,直到遇到空位置。

bool insert(const pair<K, V>& kv)
{
    if (_table.size() == 0 || _size * 10 / _table.size() >= 7)
    {
        size_t new_size = _size == 0 ? 10 : _table.size() * 2;
        hash_table<K, V> new_hash;
        new_hash._table.resize(new_size);

        for (auto& data : _table)
            if (data._st == EXIST)
                new_hash.insert(data._kv);

        _table.swap(new_hash._table);
    }

    size_t hashi = kv.first % _table.size();

    size_t i = 1;
    size_t idx = hashi;
    while (_table[idx]._st == EXIST)
    {
        if (_table[idx]._kv.first == kv.first)
            return false;

        idx += i;     // 线性探测
        idx += i * i; // 二次探测
        idx %= _table.size();
    }

    _table[idx]._kv = kv;
    _table[idx]._st = EXIST;
    ++_size;
    return true;
}
闭散列扩容

当二次探测总是在正确位置“绕圈”时,只能用扩容解决问题。但什么时候该扩容呢?扩容多大呢?

载荷因子可以衡量哈希表的装载程度,它的定义是:
α    =    表中元素个数    /    表的总长度 \alpha \; = \;表中元素个数\;/\;表的总长度 α=表中元素个数/表的总长度
负载因子越小,冲突概率越低,效率越高,但空间浪费就多。

使用闭散列时,荷载因子是决定哈希效率的重要因素,应严格限制在0.7-0.8以下。超过0.8,查表的缓存不命中率指数级上升。

哈希表的扩容要维护原有数据的存放位置。数据搬迁很麻烦,不如将原数据插入到新表。

在这里插入图片描述

bool insert(const pair<K, V>& kv)
{
    if (_table.size() == 0 || _size * 10 / _table.size() >= 7)
    {
        size_t new_size = _size == 0 ? 10 : _table.size() * 2;
        hash_table<K, V> new_hash;
        new_hash._table.resize(new_size);

        for (auto& data : _table)
        {
            if (data._st == EXIST)
                new_hash.insert(data._kv);
        }

        _table.swap(new_hash._table);
    }
    // ...
}
HashNode<K, V>* Find(const K& key) {
    if (_table.size() == 0)
        return nullptr;

    HashFunc hf;

    size_t pos = hf(key) % _table.size();
    size_t index = pos;
    size_t i = 0;

    while (_table[index]._status == EXIST) {
        if (_table[index]._kv.first == key) {            
            return &_table[index];
        }

        ++i;
            index = pos + i;
            index %= _table.size();
    }

    return nullptr;
}
bool Erase(const K& key) {
    HashNode<K, V>* ret = Find(key);
    if (ret == nullptr)
        return false;
    else {
        ret->_status = EMPTY;
        --_n;
        return true;
    }
}
开散列/拉链法/哈希桶

闭散列的线性探测和二次探测都会加剧哈希冲突,现实中几乎不会使用闭散列。

开散列又称拉链法、链地址法、开链法、哈希桶。

具有相同地址的key归于一个集合(桶),桶中元素通过链表链接起来,各链表的头结点存储在哈希表中。从根本上解决了冲突的问题。
在这里插入图片描述

template<class K, class V>
struct hash_node
{
    pair<K, V> _kv;
    hash_node<K, V>* _next;

    hash_node<K, V>(const pair<K, V>& kv) : _kv(kv), _next(nullptr)
    {}
};

template<class K, class V>
class hash_table
{
public:
    typedef hash_node<K, V> node;
public:
    ~hash_table<K, V>() {
        for (auto cur : _table) {
            while (cur) {
                node* next = cur->_next;
                delete cur;
                cur = next;
            }
        }
    }

private:
    vector<node*> _table;
    size_t _size;
};
开散列实现

桶由链表实现,本质效率和线性探测一样。如果桶中节点过多会影响哈希表的性能。必须在适当条件下对哈希表增容。让哈希表处于最佳状态。

开散列的最佳情况是每个哈希桶中刚好挂一个节点。如果再插入必然会发生冲突。因此当元素个数等于桶的个数时,可以给哈希表增容

在这里插入图片描述

开散列扩容复用插入逻辑的话,会浪费原节点,最好是遍历原表节点放到新表中。

bool insert(const pair<K, V>& kv)
{
    // 如果哈希表为空或者已经满了(达到了负载因子),需要进行扩容
    if (_table.empty() || _size == _table.size())
    {
        // 计算新的哈希表大小
        size_t new_size = _size == 0 ? 10 : _table.size() * 2;
        // 创建一个新的哈希表
        vector<node*> new_table(new_size);

        // 将旧哈希表中的元素重新哈希到新的哈希表中
        for (auto cur : _table)
        {
            while (cur)
            {
                // 计算当前节点应该存放在新哈希表的位置
                size_t hashi = cur->_kv.first % _table.size();
                node* next = cur->_next;

                // 将当前节点插入到新哈希表中
                cur->_next = new_table[hashi];
                new_table[hashi] = cur;

                cur = next;
            }
            cur = nullptr;
        }

        // 使用新的哈希表替换旧的哈希表
        _table.swap(new_table);
    }

    // 计算待插入节点应该存放的位置
    size_t hashi = kv.first % _table.size();

    // 创建新节点,并插入到哈希表中
    node* new_node = new node(kv);  // 假设 node 是节点类型
    new_node->_next = _table[hashi];
    _table[hashi] = new_node;

    return true;
}

node* find(const K& key)
{
    // 如果哈希表为空,直接返回 nullptr
    if (_table.empty())
        return nullptr;

    // 计算键 `key` 对应的哈希表索引
    size_t hashi = key % _table.size();
    // 获取哈希表中对应索引位置的链表头节点
    node* cur = _table[hashi];

    // 在链表中查找匹配的键值对
    while (cur)
    {
        // 如果找到匹配的键,返回当前节点指针
        if (cur->_kv.first == key)
            return cur;

        // 否则继续遍历链表
        cur = cur->_next;
    }

    // 如果未找到匹配的键,返回 nullptr
    return nullptr;
}

bool erase(const K& key)
{
    // 如果哈希表为空,直接返回 false
    if (_table.empty())
        return false;

    // 计算键 `key` 对应的哈希表索引
    size_t hashi = key % _table.size();
    // 获取哈希表中对应索引位置的链表头节点
    node* cur = _table[hashi];
    node* prev = nullptr;

    // 在链表中查找并删除匹配的键值对
    while (cur)
    {
        // 如果找到匹配的键
        if (cur->_kv.first == key)
        {
            // 如果是链表头节点
            if (!prev)
            {
                _table[hashi] = cur->_next; // 直接修改哈希表中的头指针
            }
            else
            {
                prev->_next = cur->_next; // 修改前一个节点的指针,跳过当前节点
            }
            delete cur; // 释放当前节点的内存
            return true; // 返回删除成功
        }

        // 更新前一个节点和当前节点指针,继续遍历链表
        prev = cur;
        cur = cur->_next;
    }

    // 如果未找到匹配的键,返回 false 表示删除失败
    return false;
}

 

3. 模拟实现

哈希表的实现比红黑树要简单不少,但是它的封装比较繁琐。

根据哈希表原理,实现unordered系列容器,需要对key类型作出如下要求:

参数解释
Hash=hash<Key>支持取模或支持映射到整数进行取模
Pred=equal_to<Key>支持判断相等的函数
template < class Key,  									// unordered_map::key_type
		   class Value,									// unordered_map::mapped_type
		   class Hash = hash<Key>, 						// unordered_map::hasher
		   class Pred = equal_to<Key>, 					// unordered_map::key_equal
		   class Alloc = allocator<pair<const Key,T>>   // unordered_map::allocator_type
		 > class unordered_map;

3.1 改造哈希表

  • unordered_map/unordered_set要复用同一个哈希表,同样要提供仿函数KeyOfVal
  • 哈希只支持单向迭代器,所以只有++。
  • 哈希表遍历是从头到尾遍历每个桶,必须能访问表中的数组,所以必须提供哈希表对象的指针。
template<class K, class V, class HashFunc, class KeyOfVal, class EqualKey>
class hash_table;

template<class K, class V, class HF, class KOV, class Ref, class Ptr>
struct __hash_iterator
{
 	typedef __hash_node<V> node; // 定义哈希表节点类型
    typedef hash_table<K, V, HF, KOV> hash_table; // 定义哈希表类型
    typedef __hash_iterator<K, V, HF, KOV, V&, V*> iterator; // 迭代器类型
    typedef __hash_iterator<K, V, HF, KOV, Ref, Ptr> self; // 自身类型,用于比较

    node* _node;
    const hash_table* _ht;

    __hash_iterator(node* n, const hash_table* ht) : _node(n), _ht(ht)
    {}

    __hash_iterator(const iterator& it) : _node(it._node), _ht(it._ht)
    {}

    Ref operator*() { return _node->_val; }

    Ptr operator->() { return &_node->_val; }

    bool operator==(const self& s) { return _node == s._node; }

    bool operator!=(const self& s) { return !this->operator==(s); }

    self& operator++()
    {
        if (_node->_next)
        {
            _node = _node->_next;
        }
        else
        {
            size_t hashi = _ht->_hash(_ht->_kov(_node->_val)) % _ht->_table.size();
            ++hashi;

            while (hashi < _ht->_table.size())
            {
                if (_ht->_table[hashi])
                {
                    _node = _ht->_table[hashi];
                    break;
                }
                ++hashi;
            }

            if (hashi == _ht->_table.size())
                _node = nullptr;
        }
        return *this;
    }
};
template<class V>
struct __hash_node
{
    V _val;                // 存储的值
    __hash_node<V>* _next; // 指向下一个节点的指针

    // 构造函数,初始化节点值和指针
    __hash_node<V>(const V& val) : _val(val), _next(nullptr)
    {}
};

template<class K, class V, class HashFunc, class KeyOfVal>
class hash_table
{
public:
    typedef __hash_node<V> node; // 定义哈希表节点类型
    typedef __hash_iterator<K, V, HashFunc, KeyOfVal, V&, V*> iterator; // 定义迭代器类型
    typedef __hash_iterator<K, V, HashFunc, KeyOfVal, const V&, const V*> const_iterator; // 定义常量迭代器类型

    // 声明友元,使迭代器能够访问私有成员
    template<class K, class V, class HF, class KOV, class Ref, class Ptr>
    friend struct __hash_iterator;

public:
    // 返回常量迭代器指向第一个非空节点的位置
    const_iterator begin() const
    {
        for (auto cur : _table)
        {
            if (cur) return const_iterator(cur, this);
        }
        return end();
    }

    // 返回常量迭代器指向结束位置(nullptr)
    const_iterator end() const
    {
        return const_iterator(nullptr, this);
    }

    // 返回迭代器指向第一个非空节点的位置
    iterator begin()
    {
        for (auto cur : _table)
        {
            if (cur) return iterator(cur, this);
        }
        return end();
    }

    // 返回迭代器指向结束位置(nullptr)
    iterator end()
    {
        return iterator(nullptr, this);
    }

public:
    // 插入操作,将值插入到哈希表中
    std::pair<iterator, bool> insert(const V& val)
    {
        auto pos = find(_kov(val)); // 查找当前值是否已存在于哈希表中
        if (pos != end())
            return {pos, false}; // 如果存在,返回插入失败

        if (_table.empty() || _table.size() == _size)
        {
            size_t new_size = _table.empty() ? 10 : _table.size() * 2;
            std::vector<node*> new_table(new_size);

            for (auto& cur : _table)
            {
                while (cur)
                {
                    size_t hashi = _hash(_kov(cur->_val)) % new_table.size();
                    node* next = cur->_next;

                    cur->_next = new_table[hashi];
                    new_table[hashi] = cur;

                    cur = next;
                }
            }

            _table.swap(new_table); // 更新哈希表
        }

        size_t hashi = _hash(_kov(val)) % _table.size();
        node* new_node = new node(val); // 创建新节点

        new_node->_next = _table[hashi];
        _table[hashi] = new_node;
        ++_size;

        return {iterator(new_node, this), true}; // 返回插入成功和迭代器指向新节点
    }

    // 查找操作,根据键查找节点
    iterator find(const K& key)
    {
        if (_table.empty())
            return end();

        size_t hashi = _hash(key) % _table.size();
        node* cur = _table[hashi];

        while (cur)
        {
            if (_hash(_kov(cur->_val)) == _hash(key))
                return iterator(cur, this); // 找到节点,返回迭代器

            cur = cur->_next;
        }

        return end(); // 未找到,返回结束迭代器
    }

    // 删除操作,根据键删除节点
    bool erase(const K& key)
    {
        if (_table.empty())
            return false;

        size_t hashi = _hash(key) % _table.size();
        node* cur = _table[hashi];
        node* prev = nullptr;

        while (cur)
        {
            if (_hash(_kov(cur->_val)) == _hash(key))
            {
                if (!prev)
                {
                    _table[hashi] = cur->_next;
                }
                else
                {
                    prev->_next = cur->_next;
                }
                delete cur; // 删除节点
                return true;
            }

            prev = cur;
            cur = cur->_next;
        }
        return false; // 未找到,返回删除失败
    }

private:
    std::vector<node*> _table; // 存储哈希表的数组
    size_t _size = 0; // 哈希表当前元素个数

    KeyOfVal _kov; // 获取值的键
    HashFunc _hash; // 哈希函数
};

3.2 封装容器

unordered_set
template<class K, class Hash = hash<K>>
class unordered_set
{
private:
    struct KeyOfVal {
        const K& operator()(const K& key) const { return key; }
    };

public:
    typedef hash_table<K, K, Hash, KeyOfVal> rep_type;
    typedef typename rep_type::const_iterator iterator;
    typedef typename rep_type::const_iterator const_iterator;

public:
    std::pair<iterator, bool> insert(const K& key) { return _ht.insert(key); }

    size_t erase(const K& key) { return _ht.erase(key); }

    iterator find(const K& key) { return _ht.find(key); }

    iterator begin() { return _ht.begin(); }
    iterator end() { return _ht.end(); }

    const_iterator begin() const { return _ht.begin(); }
    const_iterator end() const { return _ht.end(); }

private:
    rep_type _ht;
};
unordered_map
template<class K, class V, class Hash = hash<K>>
class unordered_map
{
private:
    struct KeyOfVal {
        const K& operator()(const std::pair<const K, V>& kv) const {
            return kv.first;
        }
    };

public:
    typedef hash_table<K, std::pair<const K, V>, Hash, KeyOfVal> rep_type;
    typedef typename rep_type::iterator iterator;
    typedef typename rep_type::const_iterator const_iterator;

public:
    std::pair<iterator, bool> insert(const std::pair<K, V>& kv) { return _ht.insert(kv); }

    size_t erase(const K& key) { return _ht.erase(key); }

    iterator find(const K& key) { return _ht.find(key); }

    iterator begin() { return _ht.begin(); }
    iterator end() { return _ht.end(); }

    const_iterator begin() const { return _ht.begin(); }
    const_iterator end() const { return _ht.end(); }

    V& operator[](const K& key) { return _ht.insert({key, V()}).first->second; }

private:
    rep_type _ht;
};

两者主要区别:

unordered_setunordered_map 都是基于哈希表实现的C++容器,适用于快速插入、查找和删除数据。unordered_set 用于存储唯一的键集合,而 unordered_map 则用于存储键值对映射关系,支持对值的直接访问和修改。选择适合的容器取决于是否需要存储和操作值,以及是否需要保证键的唯一性。

  • 27
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

SuhyOvO

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值