【C++ STL】模拟实现 unordered_set/map 系列容器(对一张哈希表进行封装)

前言

unordered 系列容器(unordered_map / unordered_set …),其底层封装了一张哈希表(开散列、哈希桶),重点在于哈希表的结构以及相关的接口的实现。


一、模拟实现 unordered 系列容器

哈希表(哈希桶)的结构:

image-20220408231838483

1.1 定义哈希表的节点结构

这里直接拿我们自己写的开散列(原先是KV模型的)改造下,使其既可兼容K模型,也可兼容KV模型:

哈希表里面具体存的是什么类型的元素,是由模板参数 T 来决定

  • 如果是 unordered_set,传给 T 的就是 Key
  • 如果是 unordered_map,传给 T 的就是 pair<const Key, V>

哈希表节点结构

/* 定义哈希表节点结构
* T: 数据的类型,如果是unordered_set,则为key,如果是unordered_map,则为pair<const key, V>
*/ 
template<class T>
struct HashNode
{
	T _data;            // 数据域
	HashNode<T>* _next; // 后继指针

    HashNode(const T& data) // 构造函数
        : _data(data)
        , _next(nullptr)
    {}
};

1.2 定义哈希表的迭代器

迭代器的结构

👉注意:哈希表迭代器封装的是「节点指针」和「哈希表指针」,因为要在哈希表中找下一个桶,所以要用到哈希表指针

// 前置声明哈希表,因为迭代器中要用哈希表
template<class K, class T, class Hash, class KeyOfT>
class HashTable;

/* 定义哈希表迭代器
* K: 键值key的类型
* T: 数据的类型,如果是unordered_set,则为key,如果是unordered_map,则为pair<const key, V>
* Hash: 仿函数类,将不能取模的类型转换成可以取模的size_t类型
* KeyOfT: 仿函数类,通过T的类型来获取key值
*/
template<class K, class T, class Hash, class KeyOfT>
struct HTIterator
{
    // 类型重命名
    typedef HashNode<T> Node;                        // 哈希表节点
    typedef HashTable<K, T, Hash, KeyOfT> HashTable; // 哈希表
    typedef HTIterator<K, T, Hash, KeyOfT> Iter;     // 迭代器

    Node* _node;    // 节点指针
    HashTable* _ht; // 哈希表指针

    HTIterator(Node* node, HashTable* ht) // 构造函数(迭代器由节点指针和哈希表指针来构造)
        : _node(node)
        , _ht(ht)
    {}
    
    // 相关运算符重载
    T& operator*();
    T* operator->();
    Iter& operator++(); // 找下一个节点,需要用到Hash和KeyOfT仿函数
    // ...
};

迭代器中运算符的重载

1、让迭代器具有类似指针的行为,* 和 -> 运算符的重载

// 让迭代器具有类似指针的行为
T& operator*()  // *解引用操作符
{
    return _node->_data; // 返回迭代器指向节点中数据的引用
}

T* operator->() // ->成员访问(指针)操作符
{
    return &_node->_data; // 返回迭代器指向节点中数据的地址
}

2、让迭代器可以移动,前置++运算符的重载(单向迭代器,不支持–操作)

  • 如果当前桶的节点没有遍历完,则让节点指针 _node 指向下一个节点
  • 如果当前桶的节点遍历完了,则让节点指针 _node 指向下一个不为空的桶的第一个节点
image-20220408222536134
// 让迭代器可以移动(单向迭代器,不支持--操作)
Iter& operator++() // 前置++,返回指向下一个节点的迭代器的引用
{
    // 遍历当前桶的节点

    // 1. 当前节点的下一个节点不为空
    if (_node->_next)
    {
        _node = _node->_next; // _node指向下一个节点
    }

    // 2. 当前节点的下一个节点为空,说明走到当前哈希桶的桶底了
    else
    {
        // 2.1 先通过哈希函数计算出当前桶的位置(先取出key再进行取模)
        size_t index = Hash()(KeyOfT()(_node->_data)) % _ht->_tables.size();

        // 2.2 从下一个位置开始,遍历哈希表,找到下一个不为空的哈希桶
        index++;
        while (index < _ht->_tables.size())
        {
            if (_ht->_tables[index]) // 找到下一个不为空的桶了
            {
                _node = _ht->_tables[index]; // _node指向该桶的第一个节点
                return *this;
            }

            index++;
        }

        // 2.3 后面没有不为空的桶了
        _node = nullptr; // _node指向空
    }

    return *this; // 返回下一个节点的迭代器的引用
}

3、让迭代器可以比较,== 和 != 运算符的重载

// 让迭代器可以比较
bool operator==(const Iter& it) const
{
    return _node == it._node; // 比较两个迭代器中的节点指针,看是否指向同一节点
}

bool operator!=(const Iter& it) const
{
    return _node != it._node; // 比较两个迭代器中的节点指针,看是否指向同一节点
}

1.3 定义哈希表的结构

一个数组,数组中存储着各个链表头结点的地址。

注意

这里有一个私有成员访问的问题,我们在哈希表迭代器中封装了一个「哈希表指针」,当一个桶遍历完成时,用来在哈希表中找下一个桶,但哈希表 _tables 是私有成员,无法访问,所以需要将迭代器类模板声明为友元

/* 定义哈希表结构
* K: 键值key的类型
* T: 数据的类型,如果是unordered_set,则为key,如果是unordered_map,则为pair<const key, V>
* Hash: 仿函数类,将不能取模的类型转换成可以取模的size_t类型
* KeyOfT: 仿函数类,通过T的类型来获取key值
*/
template<class K, class T, class Hash, class KeyOfT>
class HashTable
{
    typedef HashNode<T> Node; // 哈希表节点

    // 声明迭代器类模板为友元,因为迭代器中的哈希表指针要访问哈希表的私有成员_tables
    // 写法一:
    template<class K, class T, class Hash, class KeyOfT>
    friend struct HTIterator;
    // 写法二:
    friend struct HTIterator<K, T, Hash, KeyOfT>;

public:
    /*******************************************************/
    // 迭代器
    typedef HTIterator<K, T, Hash, KeyOfT> iterator; // iterators内嵌类型
    iterator begin(); // 返回指向第一个节点的迭代器
    iterator end();   // 返回指向nullptr的迭代器
    
    /*******************************************************/
    // 构造、拷贝构造、赋值重载、析构函数
    HashTable() = default; // 默认生成,因为实现了拷贝构造函数,就不会再生成构造函数了
    HashTable(const HashTable& ht);
    HashTable& operator=(HashTable ht);
    ~HashTable();
    
    /*******************************************************/
    Node* Find(const K& key);                   // 查找节点
    pair<iterator, bool> Insert(const T& data); // 插入节点
    bool Erase(const K& key);                   // 删除节点
    
private:
    vector<Node*> _tables;  // 哈希表(存储着各个链表头结点的地址)
    size_t _n = 0;          // 哈希表中有效节点的个数(缺省为0)
};
① begin() 和 end() 的实现

注意this 指针指向当前调用 begin() 成员函数的哈希表对象的

// 返回指向第一个节点的迭代器
iterator begin()
{
    // 遍历哈希表,找到第一个不为空的哈希桶
    for (size_t i = 0; i < _tables.size(); i++)
    {
        if (_tables[i])
        {
            /* 注意:迭代器由节点指针和哈希表指针构造,而成员函数的this指针就是指向哈希表的 */
            return iterator(_tables[i], this); // 返回迭代器
        }
    }
    return end();
}

// 返回指向nullptr的迭代器
iterator end()
{
    /* 注意:迭代器由节点指针和哈希表指针构造,而成员函数的this指针就是指向哈希表的 */
    return iterator(nullptr, this);
}
② 默认成员函数的实现
1)构造函数的实现
HashTable() = default; // 默认生成,因为实现了拷贝构造函数,就不会再生成构造函数了
2)拷贝构造函数的实现(深拷贝)

必须是深拷贝,浅拷贝出来会导致两个哈希表中存放的是同一批哈希桶的地址

  • 将新哈希表大小调整为 ht._tables.size()
  • 遍历 ht._tables 的所有哈希桶,将桶里的节点一一「 拷贝 」到新哈希表对应位置上。
  • 更改新哈希表中的有效节点个数为 ht._n
// 拷贝构造
HashTable(const HashTable& ht)
{
    /* 深拷贝,用已存在的对象ht去拷贝构造一个新对象 */

    // 将新哈希表大小调整为ht._tables.size()
    _tables.resize(ht._tables.size());

    // 遍历ht._tables的所有哈希桶,将桶里的节点一一拷贝到新哈希表对应位置上
    for (size_t i = 0; i < ht._tables.size(); i++)
    {
        Node* cur = ht._tables[i]; // 记录当前桶的位置

        while (cur) // 当前桶不为空,开始拷贝桶中的节点
        {
            Node* copy = new Node(cur->_data); // 申请一个新节点

            copy->_next = _tables[i];  // 将新节点插入到新哈希表中
            _tables[i] = copy;

            cur = cur->_next;          // 继续遍历下一个节点
        }
    }

    // 更改新哈希表中的有效节点个数
    _n = ht._n;
}
3)赋值运算符重载函数的实现(现代写法)

通过参数间接调用拷贝构造函数,然后将拷贝构造出来的哈希表和当前哈希表的两个成员变量分别进行交换即可,这样当前哈希表就拿到了自己想要的内容,当函数调用结束后,拷贝构造出来的哈希表 ht 出了作用域会被自动析构。

// 赋值运算符重载
HashTable& operator=(HashTable ht)
{
    // 传参时,调用拷贝构造函数,拷贝构造了一个哈希表ht

    // 将拷贝构造出来的哈希表ht和当前哈希表的两个成员变量分别进行交换
    _tables.swap(ht._tables);
    _n = ht._n;

    return *this; // 返回当前哈希表
}
4)析构函数的实现
// 析构函数
~HashTable()
{
    // 遍历哈希表,找到不为空的哈希桶
    for (size_t i = 0; i < _tables.size(); i++)
    {
        Node* cur = _tables[i]; // 记录当前桶的位置
        
        while (cur) // 当前桶不为空,开始释放桶中的所有节点
        {
            Node* next = cur->_next; // 记录cur指向节点的下一个节点

            delete cur; // 释放节点

            cur = next; // 继续去释放下一个节点
        }
        
        _tables[i] = nullptr; // 当前桶释放完毕,置空
    }

    _n = 0; // 有效节点个数置0
}

1.4 实现哈希表的相关接口

① 查找节点

查找节点接口中,需要获取「不同类型元素」的「关键码 key」,因为元素间的比较是通过 key 值来比较的,以及通过元素关键码 key 取模计算出哈希位置。

所以需要借助两个仿函数类:

  • 仿函数类 KeyOfT,获取不同类型的 data 中的关键码 key 值(如果 data 是 key 就取 key,如果是 pair 就取 first)
  • 仿函数类 Hash,有些元素关键码 key 的类型不能直接取模,需要将其转换成可以取模的 size_t 类型
Node* Find(const K& key)
{
    // 1. 先检查哈希表是否为空
    if (_tables.size() == 0)
    {
        return nullptr;
    }

    // 2. 再通过哈希函数计算出该元素映射的哈希桶的位置
    size_t index = Hash()(key) % _tables.size();

    // 3. 遍历该哈希桶,查找节点
    Node* cur = _tables[index]; // cur指向该哈希桶
    while (cur)                 // 遍历该哈希桶的所有节点
    {
        if (key == KeyOfT()(cur->_data)) // 取key出来比较
        {
            return cur; // 找到了,返回节点地址
        }

        // 继续往后遍历
        cur = cur->_next;
    }

    // 4. 没找到,返回空
    return nullptr;
}

② 插入节点

插入节点接口中,需要获取「不同类型元素」的「关键码 key」,因为元素间的比较是通过 key 值来比较的,以及通过元素关键码 key 取模计算出哈希位置。

所以需要借助两个仿函数类:

  • 仿函数类 KeyOfT,获取不同类型的 data 中的关键码 key 值(如果 data 是 key 就取 key,如果是 pair 就取 first)
  • 仿函数类 Hash,有些元素关键码 key 的类型不能直接取模,需要将其转换成可以取模的 size_t 类型

函数的返回值为 pair<iterator, bool> 类型对象:

  • 目的是为了在 unordered_set 和 unordered_map 中模拟实现 operator[] 运算符重载函数。

插入元素接口功能:插入元素前,会通过该元素的关键码 key 检查该元素是否已在哈希表中(不允许冗余):

  • 如果在,返回:pair<指向该元素的迭代器, false>
  • 如果不在,先插入节点,再返回:pair<指向该元素的迭代器, true>
/* 插入节点
* T: 数据的类型,如果是unordered_set,则为key,如果是unordered_map,则为pair<const key, V>
*/
pair<iterator, bool> Insert(const T& data)
{
    // 1. 先检查哈希表是否需要扩容:表为空或者负载因子超过1
    if (_n == _tables.size())
    {
        // 计算新容量(按2倍扩)
        size_t newSize = _tables.size() == 0 ? 10 : _tables.size() * 2;

        /* 开始扩容 */ 

        // 创建一个新表(局部变量)
        vector<Node*> newTables;
        newTables.resize(newSize);

        /* 遍历完旧表中的所有节点,重新计算它在新表中的位置,转移到新表中
		* 注意:
		* 这里是把旧表的节点转移到新表中,而不是构造新的节点插入到新表中
		*/
        for (size_t i = 0; i < _tables.size(); i++)
        {
            Node* cur = _tables[i]; // cur当前指向的哈希桶

            // 哈希桶不为空,开始转移哈希桶中的节点
            while (cur != nullptr)
            {
                // 保存cur指向节点的下一个节点
                Node* next = cur->_next;

                // 重新计算cur指向的旧表节点,映射到新表中的位置
                size_t index = Hash()(KeyOfT()(cur->_data)) % newSize; // 取key出来取模

                // 把cur指向的旧表节点,转移到新表中
                cur->_next = newTables[index];
                newTables[index] = cur;

                // 继续转移下一个旧表节点
                cur = next;
            }

            // 节点转移完毕,把当前哈希桶置空
            _tables[i] = nullptr;
        }

        // 旧表所有节点全部转移到新表中了,交换新表与旧表
        _tables.swap(newTables);
    }

    // 2. 再通过哈希函数计算出待插入元素映射的哈希桶的位置
    size_t index = Hash()(KeyOfT()(data)) % _tables.size(); // 取key出来取模

    // 3. 插入节点到该位置的哈希桶中

    // 3.1 先检查哈希桶中是否存在重复节点(因为不允许数据冗余)
    Node* cur = _tables[index]; // cur指向哈希桶的第一个节点
    while (cur)                 
    {
        // 存在重复节点,插入失败
        if (KeyOfT()(data) == KeyOfT()(cur->_data)) // 取key出来比较
        {
            // 构造pair<cur指向节点的迭代器, false>,进行返回
            return make_pair(iterator(cur, this), false);
        }

        cur = cur->_next;
    }

    // 3.2 开始头插
    Node* newNode = new Node(data);  // 申请新节点
    newNode->_next = _tables[index]; // 头插
    _tables[index] = newNode;
    _n++;                            // 有效节点个数+1

    // 3.3 插入成功
    // 构造pair<cur指向节点的迭代器, false>,进行返回
    return make_pair(iterator(newNode, this), true);
}

③ 删除节点

删除节点接口中,需要获取「不同类型元素」的「关键码 key」,因为元素间的比较是通过 key 值来比较的,以及通过元素关键码 key 取模计算出哈希位置。

所以需要借助两个仿函数类:

  • 仿函数类 KeyOfT,获取不同类型的 data 中的关键码 key 值(如果 data 是 key 就取 key,如果是 pair 就取 first)
  • 仿函数类 Hash,有些元素关键码 key 的类型不能直接取模,需要将其转换成可以取模的 size_t 类型
bool Erase(const K& key)
{
    // 1. 先判断哈希表是否为空
    if (_tables.size() == 0)
    {
        return false; // 表为空,删除失败
    }

    // 2. 通过哈希函数计算出待删除节点所映射哈希桶的位置
    size_t index = Hash()(key) % _tables.size();

    // 3. 遍历该哈希桶,查找待删除节点,以及它的前驱节点
    Node* cur = _tables[index];
    Node* prev = nullptr;
    while (cur)
    {
        // 找到该节点了
        if (key == KeyOfT()(cur->_data)) // 取key出来比较
        {
            if (cur == _tables[index]) // cur是头节点,进行头删
            {
                _tables[index] = cur->_next;
            }
            else // cur不是头节点
            {
                prev->_next = cur->_next;
            }

            delete cur;    // 删除节点
            cur = nullptr;
            _n--;          // 有效节点个数-1

            return true;   // 删除成功,返回true
        }

        // 继续往后遍历
        prev = cur;
        cur = cur->_next;
    }

    // 没有找到该节点,返回false
    return false;
}

1.5 针对除留余数法的取模问题

实现哈希表,计算元素关键码对应哈希位置的哈希函数采用的是「除留余数法」,需要传「仿函数」将不能取模的类型转换成可以取模的 size_t 类型。

这里给出针对普通类型取模和 string 类型取模的仿函数,放到全局域中,方便模拟实现 unordered_set 和 unordered_map 时使用。

// 仿函数(解决哈希函数采用除留余数法时,将不能取模的类型转换成可以取模的size_t类型)
// 默认仿函数类
template<class K>
struct HashFunc
{
    // 针对size_t类型和能够隐式类型转换成size_t的类型
    size_t operator()(const K& key)
    {
        return key;
    }
};

// 特化
template<>
struct HashFunc<string>
{
    // 把string类型转换成可以取模的size_t类型
    size_t operator()(const string& key)
    {
        size_t hash_key = 0;
        for (size_t i = 0; i < key.size(); i++)
        {
            hash_key *= 131;
            hash_key += key[i];
        }
        return hash_key;
    }
};

1.6 unordered_set 的模拟实现

在 unordered_set 中直接封装一张哈希表,然后将哈希表的迭代器和相关接口包装下即可。

namespace winter
{
	/* 定义unordered_set的结构
	* K: 键值key的类型
	* Hash: 仿函数类,将不能取模的类型转换成可以取模的size_t类型
	*/
	template<class K, class Hash = HashFunc<K>>
	class unordered_set
	{
		// 仿函数类,获取key对象中的key
		struct KeyOfSet
		{
			const K& operator()(const K& key) const
			{
				return key;
			}
		};
        
	public:
		/*******************************************************/
		/* unordered_map的迭代器
		* 这里是要取HashTable<...>类模板里面定义的内嵌类型iterator,要注意:
		* 编译到这里的时候,类模板HashTable<K, K, Hash, KeyOfSet>可能还没有实例化成具体的类
		* 那么编译器就不认识这个类模板,更别说去它里面找iterator了
		* 所以要加typename,告诉编译器这是个类型,等它实例化了再去它里面找iterator
		*/
		typedef typename hash_bucket::HashTable<K, K, Hash, KeyOfSet>::iterator iterator;
		iterator begin()
		{
			return _ht.begin();
		}
		iterator end()
		{
			return _ht.end();
		}

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

	private:
        /* 封装一张哈希表
        * 因为要实现unordered_set,所以要传
        * 键值K、键值K、针对除留余数法取模问题的Hash仿函数,提取Key值的KeyOfSet仿函数
		*/
		hash_bucket::HashTable<K, K, Hash, KeyOfSet> _ht;
	};
}

测试

// 测试
void test_unordered_set()
{
    unordered_set<int> s;
    s.insert(4);
    s.insert(3);
    s.insert(2);
    s.insert(8);
    s.insert(9);
    s.insert(1);
    s.insert(6);

    unordered_set<int>::iterator it = s.begin();
    while (it != s.end())
    {
        cout << *it << " ";
        ++it;
    }
    cout << endl;
}

1.7 unordered_map 的模拟实现

在 unordered_map 中直接封装一张哈希表,然后将哈希表的迭代器和相关接口包装下即可。

namespace winter
{
	/* 定义unordered_map的结构
	* K: 键值key的类型
	* V: unordered_map存储的数据d类型,pair<const key, V>
	* Hash: 仿函数类,将不能取模的类型转换成可以取模的size_t类型
	*/
	template<class K, class V, class Hash = HashFunc<K>>
	class unordered_map
	{
		// 仿函数类,获取pair对象中的key
		struct KeyOfMap
		{
			const K& operator()(const pair<const K, V>& kv) const
			{
				return kv.first;
			}
		};
        
	public:
		/*******************************************************/
		/* unordered_map的迭代器
		* 这里是要取HashTable<...>类模板里面定义的内嵌类型iterator,要注意:
		* 编译到这里的时候,类模板HashTable<K, pair<const K, V>, Hash, KeyOfMap>可能还没有实例化成具体的类
		* 那么编译器就不认识这个类模板,更别说去它里面找iterator了
		* 所以要加typename,告诉编译器这是个类型,等它实例化了再去它里面找iterator
		*/
		typedef typename hash_bucket::HashTable<K, pair<const K, V>, Hash, KeyOfMap>::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);
		}

		/* []运算符重载(其实底层封装是插入元素接口)
        * 调用insert返回一个pair<指向该元素的迭代器, bool>对象
        * 通过ret.first拿到指向该元素的迭代器,然后访问到该元素中的value值
        */
		V& operator[](const K& key)
		{
            // 注意:这里的V()是缺省值,调用V类型的默认构造函数去构造一个匿名对象
			pair<iterator, bool> ret = insert(make_pair(key, V()));
			return (ret.first)->second;
		}

	private:
        /* 封装一张哈希表
        * 因为要实现unordered_map,所以要传
        * 键值K、K和V构成的键值对、针对除留余数法取模问题的Hash仿函数,提取Key值的KeyOfMap仿函数
		*/
		hash_bucket::HashTable<K, pair<const K, V>, Hash, KeyOfMap> _ht;
	};
}

测试

// 测试
void test_unordered_map()
{
    unordered_map<string, string> m;
    m.insert(make_pair("tree", "树"));
    m.insert(make_pair("sort", "排序"));
    m.insert(make_pair("map", "地图"));
    m["boy"] = "男孩";
    m["map"] += ",映射";

    // 拷贝构造
    unordered_map<string, string> copy(m);

    // 迭代器
    unordered_map<string, string>::iterator it = m.begin();
    while (it != m.end())
    {
        cout << it->first << ":" << it->second << endl;;
        ++it;
    }
    cout << endl;
}

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值