用同一个哈希表实现unordered_map和unordered_set(C++实现)【STL】

简介: 用同一个哈希表实现unordered_map和unordered_set(C++实现)【STL】

1. 模板参数控制

我们知道,unordered_set和unordered_map与set和map是一样的,前者不是真正的键值对,它的value值和key值相同;后者是真正的键值对。STL非常注重代码的复用,它们在底层使用了同一棵红黑树模板实现,这也是此文要用同一个哈希表实现unordered_set和unordered_map的原因。如果各自拥有一个哈希表,set和unordered_set只要一个key值即可。

1.1 容器模板参数

unordered_set

template<class K>
class unordered_set
{
public:
    // ..
private:
    HashTable<K, K> _ht;
};

unordered_map

template<class K, class V>
class unordered_map
{
public:
    //...
private:
    HashTable<K, pair<K, V>> _ht;
};

1.2 结点类的定义

同一个哈希表实现unordered_set和unordered_map,就需要控制底层的哈希表的模板参数。由于原先实现哈希表时默认是以<key,value>作为键值对的,而哈希表在底层是不知道上层是unordered_set还是unordered_map,所以为了区分两者键值对的第二个模板参数,将哈希表中的第二个模板参数从V改成T

  • 当上层是unordered_set时,T和K相同;
  • 当上层是unordered_map时,T就是value。

同时,原先的键值对也要改成模板参数T,由于这个T可能是key,也有可能是<key,value>键值对,所以将原来结点类中的_kv(键值对)改成_data,以保存数据。

template<class T>
struct HashNode
{
    T _data;                    // 保存数据
    HashNode<T>* _next;         // 后继指针
    HashNode(const T& data)     // 结点构造函数
        :_data(data)
        , _next(nullptr)
    {}
};

1.3 仿函数获取键值

上面的操作将unordered_set和unordered_map的键值对是用一个T类型的_data保存的,由于哈希函数要根据键值key计算,所以要取出_data中的key值。在这里可以使用一个仿函数获取,仿函数其实就是一个结构体中重载的operator()

template<class K, class V>
class unordered_map
{
    struct MapKeyOfT
    {
        const K& operator()(const pair<K, V>& kv)
        {
            return kv.first;
        }
    };
public:
    // ...
private:
    HashTable<K, pair<K, V>, MapKeyOfT> _ht;
};
template<class K>
class unordered_set
{
    struct SetKeyOfT
    {
        const K& operator()(const K& key)
        {
            return key;
        }
    };
public:
    // ...
private:
    HashTable<K, K, SetKeyOfT> _ht;
};

因此,在哈希表中的参数列表要新增一个仿函数:

template<class K, class T, class KeyOfT>
class HashTable
{
    // ... 
};

2. 字符串哈希函数

在使用map和set时,大多数情况key值都是字符串类型,unordered_set和unordered_map也是一样的,但是string虽然是常用的类型,但是它无法直接取模,这也是哈希常见的问题。为此,有许多大佬研究了这个问题。

Kernighan和Dennis在《The C programming language》中提出的BKDRHash 算法,是一个高效且实用的哈希函数,它对每次累加后的结果再乘以素数131。

至于为什么是131,这是一个数学问题,但实际上也有其他类似的哈希算法,只是将131改成了其他数字,大多数是素数。

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

这种专门针对某种特定类型而写的模板,是模板的特化。

注意:

模板的特化必须在原有模板的基础上特化的。

所以哈希表的模板参数还要增加一个:

template<class K, class T, class KeyOfT, class Hash = HashFunc<K>>
class HashTable
{
    // ...
};

3. 哈希表默认成员函数

3.1 默认构造函数

当实例化一个哈希表对象时,哈希表的两个成员会被这样初始化:

  • _table调用vector的默认构造函数初始化;
  • _size被默认值0初始化。
vector<Node*> _table;       // 哈希表
size_t _size = 0;           // 有效元素个数

但是拷贝构造函数和默认构造函数是二选一的,所以需要使用 default 关键字显示指定生成默认构造函数。

//构造函数
HashTable() = default; //显示指定生成默认构造函数

3.2 拷贝构造函数

深拷贝实现拷贝构造函数,新哈希表和旧哈希表中的结点的地址都是相同的。

步骤:

  1. 将新哈希表的大小risize到旧哈希表大小;
  2. 将旧哈希表的每个桶的结点全部拷贝到新的哈希表中;
  3. 更新新哈希表的_size
//拷贝构造函数
HashTable(const HashTable& ht)
{
    _table.resize(ht._table.size());
    for (size_t i = 0; i < ht._table.size(); i++)
    {
        if (ht._table[i])
        {
            Node* cur = ht._table[i];
            while (cur)
            {
                Node* copy = new Node(cur->_data);
                copy->_next = _table[i];
                _table[i] = copy;
                cur = cur->_next;
            }
        }
    }
    _size = ht._n;
}

3.3 赋值运算符重载函数

赋值运算符重载函数的本质就是拷贝构造函数:

在参数部分构造一个哈希表,然后将新旧哈希表的地址和有效数据_size交换,为了=能连续赋值,最后返回当前对象的this指针。这里巧妙使用了参数部分构造新对象和函数结束时调用对应的析构函数。

//赋值运算符重载函数
HashTable& operator=(HashTable ht)  // 这里调用了构造函数
{
    _table.swap(ht._table);
    swap(_size, ht._size);  // 交换数据
    return *this;  
} // 调用析构函数

3.4 析构函数

由于每个哈希桶中的结点都是new出来的,所以要遍历哈希表,将每个哈希桶中的所有结点delete。

// 析构函数
~HashTable()
{
    for (size_t i = 0; i < _table.size(); i++)
    {
        Node* cur = _table[i];
        while (cur)
        {
            Node* next = cur->_next;
            free(cur);
            cur = next;
        }
        _table[i] = nullptr;
    }
}

4. 正向迭代器

由于上层容器有++和–等操作,所以要先在哈希表中增加迭代器,以供上层封装。STL中实现了双向迭代器,但这里只实现了正向迭代器。

4.1 定义迭代器结构体

迭代器实际上就是对结点的封装,它本质上是结点的地址,是指针。由于不同的运算符重载函数的返回值可能不同,类型有引用、指针和迭代器本身,所以在迭代器内部typedef一下。

// 前置声明
template<class K, class T, class KeyOfT, class Hash>
class HashTable;
//正向迭代器
template<class K, class T, class KeyOfT, class Hash = HashFunc<K>>
struct __HTIterator
{
    typedef HashNode<T> Node;                         // 哈希结点类型
    typedef HashTable<K, T, KeyOfT, Hash> HT;         // 哈希表类型
    typedef __HTIterator<K, T, KeyOfT, Hash> Self;    // 正向迭代器类型
    Node* _node;            // 结点指针
    HT* _pht;               // 哈希表的地址
    // ...
};

注意

这里将哈希表的结构体和迭代器的结构体放在了同一个头文件中,其原因是模板不支持分离编译,为了结构清晰,将迭代器放在哈希表之前,而迭代器也需要使用到哈希表的定义,所以需要前置声明,就像声明函数一样。

4.2 构造函数

由于需要++操作,所以必须要保存哈希表的地址(稍后就会知道)。

// 构造函数
__HTIterator(Node* node, HT*, pht)
    :_node(node)
        , _pht(pht)
    {}

4.3 operator*()和operator->()

对结点进行*解引用操作,就是取出结点中保存的value值。

->成员访问操作符就是取出结点中数据的地址。

因此需要区分它们的返回值。

T& operator*()
{
    return _node->_data;
}
T* operator->()
{
    return &_node_ -> _data;
}

4.4 operator==()和operator!=()

判断结点是否相等,只需要判断结点的地址是否相同即可。

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

4.5 operator++()

运算符++的重载,可以分为两种情况:

  • 如果当前在桶中:找链表的下一个结点;
  • 如果当前在桶的最后一个元素:找下一个非空的桶:
  • 通过哈希函数计算当前桶的下标值;
  • 然后对算出来的下标值++,得到下一个桶的位置;
  • 如果下一个桶为空,则重复上述操作;
  • 找到非空桶,更新当前结点的值;
  • 走到最后一个位置也没找到非空桶,返回nullptr。
// 前置++
Self& operator++()
{
    if (_node->_next)                   // 当前结点不是哈希桶的最后一个结点
    {
        _node = _node->_next;           // 迭代
    }
    else                                // 当前结点是哈希桶的最后一个结点
    {
        KeyOfT kot;
        Hash hash;
        size_t index = hash(kot(_node->_data)) % _pht->_table.size(); // 得到当前哈希桶的下标
        index++;                                                      // 下标+1
        while (index < _pht->_table.size())                           // 遍历哈希表中所有哈希桶
        {
            if (_pht->_table[index])                                  // 当前哈希桶不为空
            {
                _node = _pht->_table[index];
                return *this;                                         // 更新结点指针并返回
            }
            index++;                                                  // 当前哈希表为空,往后找
        }
        _node = nullptr;                                              // 上一个是空桶
    }
    return *this;
}

5. 哈希表正向迭代器

5.1 定义迭代器

写好正向迭代器以后,可以在哈希表的内部封装迭代器了。在此之前,需要在哈希表的内部进行以下操作:

  1. 为了能方便地像使用STL中的iterator类型一样,将带有模板参数的迭代器typedef为iterator
    注意:为了方便哈希表外部使用iterator类型,要在哈希表的public区域定义。
  2. 除此之外,由于正向迭代器中++操作符重载函数使用了哈希表中的_table,而它是哈希表类的私有成员,所以要将正向迭代器结构体作为哈希表类的友元。
  3. 将哈希表类中的Find函数的返回值类型改为由结点指针和哈希表地址构造的正向迭代器。将Insert函数的返回值类型改成由正向迭代器和布尔类型构成的键值对。
template<class K, class T, class KeyOfT, class Hash = HashFunc<K>>
class HashTable
{
    typedef HashNode<T> Node;
    // 将正向迭代器类声明为哈希表类的友元
    template<class K, class T, class KeyOfT, class Hash>
    friend struct __HTIterator;
public:
    typedef __HTIterator<K, T, KeyOfT, Hash> iterator;          // 定义迭代器
    iterator begin()
    {
        size_t i = 0;
        while (i < _table.size())                           // 遍历哈希表中所有哈希桶
        {
            if (_table[i])                                  // 当前哈希桶不为空
            {
                return iterator(_table[i], this);           // 返回桶地址和表地址构造的迭代器
            }
            i++;                                            // 下一个桶
        }
        return end();
    }
    iterator end()
    {
        return iterator(nullptr, this);                     // 返回nullptr和表地址构造的迭代器
    }
    // ...
private:
    vector<Node*> _table;       // 哈希表
    size_t _size = 0;           // 有效元素个数
};

6. 实现unordered_set和unordered_map

实现unordered_set和unordered_map,只要调用哈希表开放的接口即可。

6.1 unordered_set

template<class K, class Hash = HashFunc<K>>
class unordered_set
{
    struct SetKeyOfT
    {
        const K& operator()(const K& key)
        {
            return key;
        }
    };
public:
    typedef typename HashTable<K, K, SetKeyOfT>::iterator iterator; // 定义迭代器    
    iterator begin()
    {
        return _ht.begin();
    }
    iterator end()
    {
        return _ht.end();
    }
    // 插入函数
    pair<iterator, bool> insert(const K& key)
    {
        return _ht.Insert(key);
    }
    // 删除函数
    void erase(const K& key)
    {
        _ht.Erase(key);
    }
    // 查找函数
    iterator find(const K& key)
    {
        return _ht.Find(key);
    }
private:
    HashTable<K, K, SetKeyOfT> _ht;
};

6.2 unordered_map

template<class K, class V, class Hash = HashFunc<K>>
class unordered_map
{
    struct MapKeyOfT
    {
        const K& operator()(const pair<K, V>& kv)
        {
            return kv.first;
        }
    };
public:
  typedef typename HashTable<K, pair<K, V>, MapKeyOfT>::iterator iterator;
  iterator begin()
  {
    return _ht.begin();
  }
  iterator end()
  {
    return _ht.end();
  }
  //插入函数
  pair<iterator, bool> insert(const pair<K, V>& kv)
  {
    return _ht.Insert(kv);
  }
  //删除函数
  void erase(const K& key)
  {
    _ht.Erase(key);
  }
  //查找函数
  iterator find(const K& key)
  {
    return _ht.Find(key);
  }
  //赋值运算符重载
  V& operator[](const K& key)
  {
    pair<iterator, bool> ret = insert(make_pair(key, V()));
    iterator it = ret.first;
    return it->second;
  }
private:
    HashTable<K, pair<K, V>, MapKeyOfT> _ht;
};

源代码

目录
相关文章
|
21天前
|
算法
你对Collection中Set、List、Map理解?
你对Collection中Set、List、Map理解?
55 18
你对Collection中Set、List、Map理解?
|
14天前
|
存储 缓存 安全
只会“有序无序”?面试官嫌弃的List、Set、Map回答!
小米,一位热衷于技术分享的程序员,通过与朋友小林的对话,详细解析了Java面试中常见的List、Set、Map三者之间的区别,不仅涵盖了它们的基本特性,还深入探讨了各自的实现原理及应用场景,帮助面试者更好地准备相关问题。
51 20
|
1月前
|
存储 C++ 容器
【C++】map、set基本用法
本文介绍了C++ STL中的`map`和`set`两种关联容器。`map`用于存储键值对,每个键唯一;而`set`存储唯一元素,不包含值。两者均基于红黑树实现,支持高效的查找、插入和删除操作。文中详细列举了它们的构造方法、迭代器、容量检查、元素修改等常用接口,并简要对比了`map`与`set`的主要差异。此外,还介绍了允许重复元素的`multiset`和`multimap`。
30 3
【C++】map、set基本用法
|
1月前
|
存储 算法 C++
【C++】unordered_map(set)
C++中的`unordered`容器(如`std::unordered_set`、`std::unordered_map`)基于哈希表实现,提供高效的查找、插入和删除操作。哈希表通过哈希函数将元素映射到特定的“桶”中,每个桶可存储一个或多个元素,以处理哈希冲突。主要组成部分包括哈希表、哈希函数、冲突处理机制、负载因子和再散列,以及迭代器。哈希函数用于计算元素的哈希值,冲突通过开链法解决,负载因子控制哈希表的扩展。迭代器支持遍历容器中的元素。`unordered_map`和`unordered_set`的插入、查找和删除操作在理想情况下时间复杂度为O(1),但在冲突较多时可能退化为O(n)。
22 5
|
1月前
|
存储 C++ 容器
【C++】set模拟实现
C++中的`set`是STL提供的一种关联容器,用于存储唯一元素并自动按特定顺序(默认升序)排序。其内部通过红黑树实现,保证了高效的插入、删除和查找操作,时间复杂度均为O(log n)。`set`支持迭代器遍历,提供了良好的数据访问接口。
34 3
|
2月前
|
存储 JavaScript 前端开发
Set、Map、WeakSet 和 WeakMap 的区别
在 JavaScript 中,Set 和 Map 用于存储唯一值和键值对,支持多种操作方法,如添加、删除和检查元素。WeakSet 和 WeakMap 则存储弱引用的对象,有助于防止内存泄漏,适合特定场景使用。
|
2月前
|
存储 缓存 Java
【用Java学习数据结构系列】HashMap与TreeMap的区别,以及Map与Set的关系
【用Java学习数据结构系列】HashMap与TreeMap的区别,以及Map与Set的关系
44 1
|
3月前
|
算法
你对Collection中Set、List、Map理解?
你对Collection中Set、List、Map理解?
42 5
|
3月前
|
存储 JavaScript 前端开发
js的map和set |21
js的map和set |21
|
3月前
|
存储 前端开发 API
ES6的Set和Map你都知道吗?一文了解集合和字典在前端中的应用
该文章详细介绍了ES6中Set和Map数据结构的特性和使用方法,并探讨了它们在前端开发中的具体应用,包括如何利用这些数据结构来解决常见的编程问题。
ES6的Set和Map你都知道吗?一文了解集合和字典在前端中的应用