C++ 哈希表、unordered_map 和 unordered_set 的实现

哈希表(Hash Table)是一种常用的数据结构,它能够以接近常数时间复杂度的效率完成插入、删除和查找操作。在现代编程中,标准模板库(STL)提供了 unordered_mapunordered_set,它们都是基于哈希表实现的。在这篇博客中,我们将从零开始,使用 C++ 实现一个通用的哈希表,并基于此实现 unordered_mapunordered_set,同时完成迭代器的设计。

1. 哈希表的基本原理

顺序结构以及平衡树 中,元素关键码与其存储位置之间没有对应的关系,因此在 查找一个元素
时,必须要经过关键码的多次比较 顺序查找时间复杂度为 O(N) ,平衡树中为树的高度, 搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法:可以 不经过任何比较,一次直接从表中得到要搜索的元素
如果构造一种存储结构,通过某种函数 使元素的存储位置与它的关键码之间能够建立
一一映射的关系,那么在查找时通过该函数可以很快找到该元素。
因此当我们插入元素时可以根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放,
而当我们搜索元素时可以 对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置 取元素比较,若关键码相等,则搜索成功。
综上构造出来的表则称作 “哈希表”,相对应的函数就是“哈希函数”
那么现在我们假设一组数据并且将 哈希函数设置为: hash(key) = key % capacity ; capacity 为存储元素底层空间总的大小。
按照上述插入会出现什么问题呢?
我们可以发现4的位置发生了重叠,7的余数也是4,这个4就与我们第一个4发生了冲突,这种情况我们就称“   哈希冲突”: 不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突 或哈希碰撞
因此, 引起哈希冲突的一个原因可能是: 哈希函数设计不够合理
因此,常用的一些优秀哈希函数比如 直接定址法,除留余数法,数学分析法等等,我们本篇采用除留余数法, 设散列表中允许的 地址数为 m ,取一个不大于 m ,但最接近或者等于 m 的质数 p 作为除数, 按照哈希函数: Hash(key) = key% p(p<=m), 将关键码转换成哈希地址
不同情况要采取不同策略的哈希函数,但还有一点, 哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突。
因此,我们该如何解决哈希冲突呢?
解决哈希冲突 两种常见的方法是: 闭散列 开散列

2. 开放地址法(闭散列)哈希表的实现

闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有
空位置,那么可以把 key 存放到冲突位置中的 下一个 空位置中去。 那如何寻找下一个空位置
呢?

1. 线性探测 

从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止

具体实现如下:

C-/unordered_map_set at c0fcac61b3e7110c8e5f3a360eca0d920acf3ccb · hqxnb666/C- · GitHub

首先,我们定义哈希表的状态枚举和数据节点结构

enum State
{
    EMPTY,   // 空状态
    EXIST,   // 已存在
    DELETE   // 被删除
};

template<class K, class V>
struct HashData
{
    std::pair<K, V> _kv;  // 键值对
    State _state;         // 当前节点状态

    HashData() : _state(EMPTY) {}
};

接下来,定义哈希函数模板:

template<class K>
struct HashFunc
{
    size_t operator()(const K& key)
    {
        return static_cast<size_t>(key);
    }
};

// 针对 std::string 的特化
template<>
struct HashFunc<std::string>
{
    size_t operator()(const std::string& key)
    {
        size_t hash = 0;
        for (char ch : key)
        {
            hash = hash * 131 + ch;  // 使用 BKDR 哈希算法
        }
        return hash;
    }
};

这个模板是用来干什么的呢?,当我们传递参数时,能只传递整数吗?传递字符串又该如何处理?

因此我们采用了仿函数的方式,重载对应的(),当传递的是字符串时,编译器按需实例化,通过这个BKDR函数来将字符串进行处理后再进行我们的去留余数法,而这个算法的作用是什么呢,我们当前代码影响效率的因素是什么?最大程度上的影响因素是如果处理后的数字一旦发生冲突,就要挨家挨户往后找空,这样很浪费时间,于是我们采取了目前最优秀的字符串处理算法,这样经过处理后的数字很少会发生冲突,最大程度上提高了我们的效率!

以下是哈希表的类定义: 

template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:
    HashTable(size_t size = 10) : _tables(size), _n(0) {}

    bool Insert(const std::pair<K, V>& kv)
    {
        // 检查装载因子,决定是否需要扩容
        if (_n * 10 / _tables.size() >= 7)
        {
            // 扩容并重新哈希
            Rehash();
        }

        Hash hs;
        size_t idx = hs(kv.first) % _tables.size();

        // 线性探测寻找空位
        while (_tables[idx]._state == EXIST)
        {
            // 如果键已存在,更新值
            if (_tables[idx]._kv.first == kv.first)
            {
                _tables[idx]._kv.second = kv.second;
                return true;
            }
            idx = (idx + 1) % _tables.size();
        }

        _tables[idx]._kv = kv;
        _tables[idx]._state = EXIST;
        ++_n;
        return true;
    }

    HashData<K, V>* Find(const K& key)
    {
        Hash hs;
        size_t idx = hs(key) % _tables.size();
        size_t origin = idx;  // 记录初始位置

        while (_tables[idx]._state != EMPTY)
        {
            if (_tables[idx]._state == EXIST && _tables[idx]._kv.first == key)
            {
                return &_tables[idx];
            }
            idx = (idx + 1) % _tables.size();
            if (idx == origin) break;  // 回到起点,查找结束
        }
        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:
    void Rehash()
    {
        size_t newSize = _tables.size() * 2;
        std::vector<HashData<K, V>> newTables(newSize);

        for (auto& data : _tables)
        {
            if (data._state == EXIST)
            {
                Hash hs;
                size_t idx = hs(data._kv.first) % newSize;

                while (newTables[idx]._state == EXIST)
                {
                    idx = (idx + 1) % newSize;
                }
                newTables[idx] = data;
            }
        }
        _tables.swap(newTables);
    }

    std::vector<HashData<K, V>> _tables;
    size_t _n;  // 有效元素个数
};

 这里涉及到一个概念,我们一开始假设的是十个空间大小对应余数,随着数字越来越多,十个一旦不够用了我们就需要对其进行扩容操作,那么什么时候选择扩容比较好呢?太小了浪费空间,太大了效率不够,因此,通过测试目前最适用的负载因子是0.7,

 

 3. 拉链法(开散列)哈希表的实现

拉链法通过数组加链表的方式处理哈希冲突。每个数组元素(桶)存储一个链表,所有映射到同一桶的元素都链接在这个链表中。 

 

定义哈希节点:

template<class T>
struct HashNode
{
    T _data;
    HashNode<T>* _next;

    HashNode(const T& data)
        : _data(data), _next(nullptr)
    {}
};
template<class K, class T, class KeyOfT, class Hash>
class HashTable
{
public:
    typedef __HTIterator<K, T, KeyOfT, Hash> iterator;
    typedef HashNode<T> Node;

    HashTable(size_t size = 10)
        : _tables(size, nullptr), _n(0)
    {}

    ~HashTable()
    {
        Clear();
    }

    std::pair<iterator, bool> Insert(const T& data)
    {
        KeyOfT kot;
        Hash hs;
        size_t idx = hs(kot(data)) % _tables.size();

        Node* cur = _tables[idx];
        while (cur)
        {
            if (kot(cur->_data) == kot(data))
            {
                return { iterator(cur, this), false };  // 元素已存在
            }
            cur = cur->_next;
        }

        // 插入新节点
        Node* newNode = new Node(data);
        newNode->_next = _tables[idx];
        _tables[idx] = newNode;
        ++_n;

        // 检查装载因子,决定是否需要扩容
        if (_n > _tables.size())
        {
            Rehash();
        }

        return { iterator(newNode, this), true };
    }

    bool Erase(const K& key)
    {
        KeyOfT kot;
        Hash hs;
        size_t idx = hs(key) % _tables.size();

        Node* cur = _tables[idx];
        Node* prev = nullptr;

        while (cur)
        {
            if (kot(cur->_data) == key)
            {
                if (prev)
                {
                    prev->_next = cur->_next;
                }
                else
                {
                    _tables[idx] = cur->_next;
                }
                delete cur;
                --_n;
                return true;
            }
            prev = cur;
            cur = cur->_next;
        }
        return false;
    }

    iterator Find(const K& key)
    {
        KeyOfT kot;
        Hash hs;
        size_t idx = hs(key) % _tables.size();

        Node* cur = _tables[idx];
        while (cur)
        {
            if (kot(cur->_data) == key)
            {
                return iterator(cur, this);
            }
            cur = cur->_next;
        }
        return end();
    }

    iterator begin()
    {
        for (size_t i = 0; i < _tables.size(); ++i)
        {
            if (_tables[i])
            {
                return iterator(_tables[i], this);
            }
        }
        return end();
    }

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

private:
    void Rehash()
    {
        size_t newSize = _tables.size() * 2;
        std::vector<Node*> newTables(newSize, nullptr);

        KeyOfT kot;
        Hash hs;

        for (auto& head : _tables)
        {
            Node* cur = head;
            while (cur)
            {
                Node* next = cur->_next;
                size_t idx = hs(kot(cur->_data)) % newSize;
                cur->_next = newTables[idx];
                newTables[idx] = cur;
                cur = next;
            }
        }

        _tables.swap(newTables);
    }

    void Clear()
    {
        for (auto& head : _tables)
        {
            Node* cur = head;
            while (cur)
            {
                Node* next = cur->_next;
                delete cur;
                cur = next;
            }
            head = nullptr;
        }
        _n = 0;
    }

    std::vector<Node*> _tables;
    size_t _n;  // 有效元素个数
};

 

桶的个数是一定的,随着元素的不断插入,每个桶中元素的个数不断增多,极端情况下,可
能会导致一个桶中链表节点非常多,会影响的哈希表的性能,因此在一定条件下需要对哈希
表进行增容,那该条件怎么确认呢?开散列最好的情况是:每个哈希桶中刚好挂一个节点,
再继续插入元素时,每一次都会发生哈希冲突,因此,在元素个数刚好等于桶的个数时,可
以给哈希表增容。
void _CheckCapacity()
{
    size_t bucketCount = BucketCount();
    if(_size == bucketCount)
   {
        HashBucket<V, HF> newHt(bucketCount);
        for(size_t bucketIdx = 0; bucketIdx < bucketCount; ++bucketIdx)
       {
            PNode pCur = _ht[bucketIdx];
            while(pCur)
           {
                // 将该节点从原哈希表中拆出来
                _ht[bucketIdx] = pCur->_pNext;
                
                // 将该节点插入到新哈希表中
                size_t bucketNo = newHt.HashFunc(pCur->_data);
                pCur->_pNext = newHt._ht[bucketNo];
                newHt._ht[bucketNo] = pCur;
                pCur = _ht[bucketIdx];
           }
       }
        
        newHt._size = _size;
        this->Swap(newHt);
   }
}

4. 基于哈希表实现 unordered_mapunordered_set

 unordered_map 是一种以键值对(key-value)形式存储数据的关联容器,其中每个键都是唯一的。由于哈希表天然支持根据键快速查找对应的值,因此我们可以使用哈希表作为 unordered_map 的底层数据结构。

  • 键值对的存储:需要存储键和值的关联关系,通常使用 std::pair<const K, V>
  • 哈希函数:需要一个能够将键映射到哈希表索引的哈希函数。
  • 键的提取器(Key Extractor):在哈希表中,我们需要从存储的元素中提取键,以进行哈希计算和比较。
  • 迭代器的支持:为了使容器的使用更加方便,我们需要实现迭代器,支持遍历所有元素。

首先,我们定义一个提取键的仿函数 MapKeyOfT,用于从键值对中提取键:

template<class K, class V>
struct MapKeyOfT
{
    const K& operator()(const std::pair<const K, V>& data) const
    {
        return data.first;
    }
};

接下来,定义 unordered_map 类模板: 

template<class K, class V, class Hash = HashFunc<K>>
class unordered_map
{
public:
    typedef std::pair<const K, V> ValueType;
    typedef HashTable<K, ValueType, MapKeyOfT<K, V>, Hash> HashTableType;
    typedef typename HashTableType::iterator iterator;

    unordered_map() : _ht() {}

    // 插入元素
    std::pair<iterator, bool> insert(const ValueType& kv)
    {
        return _ht.Insert(kv);
    }

    // 查找元素
    iterator find(const K& key)
    {
        return _ht.Find(key);
    }

    // 访问元素,如果键不存在,则插入默认值
    V& operator[](const K& key)
    {
        auto it = _ht.Find(key);
        if (it == _ht.end())
        {
            auto ret = _ht.Insert({ key, V() });
            return ret.first->second;
        }
        else
        {
            return it->second;
        }
    }

    // 删除元素
    bool erase(const K& key)
    {
        return _ht.Erase(key);
    }

    // 迭代器支持
    iterator begin()
    {
        return _ht.begin();
    }

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

private:
    HashTableType _ht;
};

 

 

  • ValueType 定义ValueType 是存储在哈希表中的数据类型,即键值对 std::pair<const K, V>

  • HashTableType 定义HashTableType 是我们之前实现的哈希表类型,模板参数包括键类型、存储的数据类型、键提取器和哈希函数。

  • insert 函数:调用底层哈希表的 Insert 方法,将键值对插入哈希表。返回值是一个包含迭代器和布尔值的 std::pair,表示插入操作的结果。

  • find 函数:调用底层哈希表的 Find 方法,根据键查找元素,返回迭代器。

  • operator[]:提供数组下标操作符,方便用户访问或修改元素。如果键不存在,则插入一个默认值(值的默认构造),并返回其引用。

  • erase 函数:调用哈希表的 Erase 方法,根据键删除元素。

  • 迭代器支持:通过底层哈希表的迭代器,提供 begin()end() 方法,支持遍历。

unordered_set 的实现 

unordered_set 是一种存储唯一元素的无序容器,不存储键值对。由于只需要存储键,因此可以直接使用哈希表,将键作为存储的元素。

在设计 unordered_set 时,需要考虑:

  1. 元素的存储:直接存储键,类型为 K
  2. 哈希函数:用于将键映射到哈希表索引。
  3. 键的提取器:由于存储的就是键本身,因此键提取器可以直接返回元素本身。
  4. 迭代器的支持:需要提供迭代器,支持遍历所有元素。

 

定义键提取器 SetKeyOfT

template<class K>
struct SetKeyOfT
{
    const K& operator()(const K& key) const
    {
        return key;
    }
};

 定义 unordered_set 类模板:

template<class K, class Hash = HashFunc<K>>
class unordered_set
{
public:
    typedef K ValueType;
    typedef HashTable<K, ValueType, SetKeyOfT<K>, Hash> HashTableType;
    typedef typename HashTableType::iterator iterator;

    unordered_set() : _ht() {}

    // 插入元素
    std::pair<iterator, bool> insert(const K& key)
    {
        return _ht.Insert(key);
    }

    // 查找元素
    iterator find(const K& key)
    {
        return _ht.Find(key);
    }

    // 删除元素
    bool erase(const K& key)
    {
        return _ht.Erase(key);
    }

    // 迭代器支持
    iterator begin()
    {
        return _ht.begin();
    }

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

private:
    HashTableType _ht;
};
  • ValueType 定义:存储的元素类型就是键类型 K

  • HashTableType 定义:使用哈希表,模板参数包括键类型、存储的数据类型(也是 K)、键提取器和哈希函数。

  • insert 函数:调用哈希表的 Insert 方法,将键插入哈希表。

  • find 函数:根据键查找元素,返回迭代器。

  • erase 函数:根据键删除元素。

  • 迭代器支持:提供 begin()end() 方法,支持遍历。

为了使 unordered_mapunordered_set 能够像标准容器一样使用迭代器遍历,我们需要实现符合 STL 标准的迭代器。在哈希表的实现中,我们已经定义了迭代器 __HTIterator,它能够遍历哈希表中的所有元素。unordered_mapunordered_set 的迭代器可以直接使用哈希表的迭代器。

typedef typename HashTableType::iterator iterator;

 通过以上的实现,我们成功地基于哈希表构建了 unordered_mapunordered_set,并提供了迭代器支持,使其用法与标准库中的容器类似。在这个过程中,我们深入理解了哈希表的工作原理,以及如何利用模板和仿函数实现通用的、可扩展的容器。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

hqxnb666

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

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

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

打赏作者

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

抵扣说明:

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

余额充值