您现在的位置是:首页 >技术交流 >【C++】哈希表封装unordered系列网站首页技术交流

【C++】哈希表封装unordered系列

朵猫猫. 2024-07-11 18:01:02
简介【C++】哈希表封装unordered系列

 

文章目录


前言

在看本篇文章前大家尽量拿出上一篇文章的代码跟着一步步实现,否则很容易引出大量模板错误而无法解决。


一、哈希表的封装

首先我们要解决映射的问题,我们目前的代码只能映射整形,那么如何支撑浮点数等的映射呢?只需要多加一个模板参数就可以了:

template <class K, class V>
	struct HashNode
	{
		HashNode<K, V>* _next;
		pair<K, V> _kv;
		HashNode(const pair<K, V>& kv)
			:_kv(kv)
			, _next(nullptr)
		{

		}
	};
	template<class K>
	struct HashFunc
	{
		size_t operator()(const K& key)
		{
			return key;
		}
	};
	template <class K, class V, class Hash>
	class HashTable
	{
		typedef HashNode<K, V> Node;

 这个仿函数可以将任何支持隐式类型转换的key转换为size_t类型,比如double类型会被隐式转换为size_t类型,那么字符串该如何解决呢?我们直接用模板特化来解决:(模板的特化就是有特化就走特化,没有特化就走原类型)

template <>
	struct HashFunc<string>
	{
		size_t operator()(const string& s)
		{
			size_t hashi = 0;
			for (auto& e : s)
			{
				hashi += e;
			}
			return hashi;
		}
	};

然后我们给Hash这个模板参数一个缺省参数,默认使用我们的仿函数:

 然后我们在每个函数体内将取模的key值用仿函数包一下:(这里我们只展示了修改后的代码,并不是函数体就只有这些代码)

bool insert(const pair<K, V>& kv)
		{
            //.....
			Hash hash;
			size_t hashi = hash(cur->_kv.first) % newtable.size();
			size_t hashi = hash(kv.first) % _tables.size();
            //......
			return true;
		}

Node* Find(const K& key)
		{
            //.....
			Hash hash;
			size_t hashi = hash(key) % _tables.size();
            //.......
			return nullptr;
		}


bool eraser(const K& key)
		{
            //.......
			Hash hash;
			size_t hashi = hash(key) % _tables.size();
			//......
			return false;
		}

但是这个会有什么问题呢?比如abc这个字符串和cab这个字符串计算出来的hashi是一样的啊,这样不就增加了哈希冲突了吗,如下图所示:(HashStr就是我们的特化版本)

为了解决这个问题我们查阅相关资料后发现:

 只需要每次累乘因子31就能解决这样的问题,为什么是31呢?因为31是在大量测试中表现最好的一个值:

template <>
	struct HashFunc<string>
	{
		size_t operator()(const string& s)
		{
			size_t hashi = 0;
			for (auto& e : s)
			{
				hashi += e;
				hashi *= 31;
			}
			return hashi;
		}
	};

 所以特化版本变成上面这样就解决了问题:

 可以看到确实成功解决了这个问题,在这里我们补充一个上一篇遗留的问题:哈希表增删查改的时间复杂度都是O(1),并且因为有着载荷因子的控制每个桶的元素不会太长,下面我们验证一下:

 我们随机插入10000个随机数,然后将每个桶插入几个元素就打印出来:

 结果就是每个桶基本就是0-2之间,这就证明了我们刚刚说的由载荷因子控制每个桶的长度。那么如果面试官问出现了一个桶有很多的值,这个时候查找变成O(N)该怎么解决?其实很简单,当某个桶的长度超过规定的大小我们就将这个桶改为红黑树,红黑树会大幅度减少高度,这样就解决了这样的问题。除了刚刚挂红黑树可以优化哈希表,还有一个方法能优化,那就是每次开空间的大小都为素数,为什么是素数呢?因为素数只能被1和他本身整除,如果空间大小是素数每次映射的时候就能减少冲突。代码如下:

size_t GetNextPrime(size_t prime)
 {
 const int PRIMECOUNT = 28;
 static const size_t primeList[PRIMECOUNT] =
      {
 53ul, 97ul, 193ul, 389ul, 769ul,
 1543ul, 3079ul, 6151ul, 12289ul, 24593ul,
 49157ul, 98317ul, 196613ul, 393241ul, 786433ul,
 1572869ul, 3145739ul, 6291469ul, 12582917ul, 
25165843ul,
 50331653ul, 100663319ul, 201326611ul, 402653189ul, 
805306457ul,
 1610612741ul, 3221225473ul, 4294967291ul
      };
 size_t i = 0;
 for (; i < PRIMECOUNT; ++i)
 {
      if (primeList[i] > prime)
       return primeList[i];
 }

 return primeList[i];
 
 }

 只需要在扩容的时候把素数表中的值给newsize即可。下面我们就开始封装unordered系列了,这里很多地方都和红黑树的封装一样,所以我们就不讲的那么细了:

#include "HashTable6.h"

namespace sxy
{
	template <class K>
	class unordered_set
	{
		struct SetKeyOfT
		{
			const K& operator()(const K& key)
			{
				return key;
			}
		};
	public:
		bool insert(const K& key)
		{
			return _ht.insert(key);
		}
	private:
		HashBucket::HashTable<K, K, SetKeyOfT> _ht;
	};
}

SetKeyOfT的作用是:如果HashNode中存储的是k模型那么就返回key,如果存储的是KV模型就返回pair中的first,而为什么调用哈希表要传两个相同的K是因为第一个参数K是用于Find,eraser接口使用的,而第二个K是HashNode中实际存储的类型,因为节点中有可能存储的是key,也有可能是pair。下面我们把map简单实现一下:

#include "HashTable6.h"

namespace sxy
{
	template <class K,class V>
	class unordered_map
	{
		struct MapKeyOfT
		{
			const K& operator()(const pair<const K, V>& kv)
			{
				return kv.first;
			}
		};
	public:
		bool insert(const pair<const K, V>& kv)
		{
			return _ht.insert(kv);
		}
	private:
		HashBucket::HashTable<K, pair<const K, V>, MapKeyOfT> _ht;
	};
}

可以看到我们MapKeyOfT的仿函数是取pair中的first,下一步我们在哈希表中加入KeyOfT参数:

 然后我们修改一下节点中的kv键值对,用一个T类型来表示set中的key或者map中的pair:

template <class T>
	struct HashNode
	{
		HashNode<T>* _next;
		T _data;
		HashNode(const T& data)
			:_data(data)
			, _next(nullptr)
		{

		}
	};

然后相应的哈希表中用到节点的都需要修改:

bool insert(const T& data)
		{
			Hash hash;
			KeyOfT kot;
			if (Find(kot(data)))
			{
				return false;
			}
			if (_n == _tables.size())
			{
				//扩容
				size_t newsize = _tables.size() == 0 ? 10 : 2 * _tables.size();
				vector<Node*> newtable(newsize, nullptr);
				for (auto& cur : _tables)
				{
					while (cur)
					{
						Node* next = cur->_next;
						size_t hashi = hash(kot(cur->_data)) % newtable.size();
						cur->_next = newtable[hashi];
						newtable[hashi] = cur;
						cur = next;
					}
				}
				_tables.swap(newtable);
			}
			size_t hashi = hash(kot(data)) % _tables.size();
			Node* newnode = new Node(data);
			newnode->_next = _tables[hashi];
			_tables[hashi] = newnode;
			++_n;
			return true;
		}

用KeyOfT这个仿函数可以准确的拿到key值,然后我们用key值经过哈希仿函数转化为可以取模的无符号整形,这样就可以让unordered_set和map用哈希表做底层了,然后我们再把其他接口修改一下:

Node* Find(const K& key)
		{
			Hash hash;
			KeyOfT kot;
			if (_tables.size() == 0)
			{
				return nullptr;
			}
			size_t hashi = hash(key) % _tables.size();
			Node* cur = _tables[hashi];
			while (cur)
			{
				if (kot(cur->_data) == key)
				{
					return cur;
				}
				cur = cur->_next;
			}
			return nullptr;
		}

修改到find和eraser接口我们就应该能体会到刚刚在set中多传一个参数的作用了,还记得我们怎么说的吗?第一个参数是给Find等接口使用的,因为这些接口用的参数一定是key。

bool eraser(const K& key)
		{
			Hash hash;
			KeyOfT kot;
			size_t hashi = hash(key) % _tables.size();
			Node* cur = _tables[hashi];
			Node* prev = nullptr;
			while (cur)
			{
				if (kot(cur->_data) == key)
				{
					if (prev == nullptr)
					{
						Node* next = cur->_next;
						_tables[hashi] = next;
					}
					else
					{
						prev->_next = cur->_next;
					}
					delete cur;
					return true;
				}
				else
				{
					prev = cur;
					cur = cur->_next;
				}
			}
			return false;
		}

下面我们给一组测试用例先跑一下,在这里大家一定要注意,因为模板参数很多一定要写一部分就编译一下,否则模板参数的报错很容易把人搞崩溃。

void _usettest()
	{
		unordered_set<int> ust;
		ust.insert(1);
		ust.insert(2);
		ust.insert(3);
	}

 set的运行没问题,下面我们再试试map:

void test_umap()
	{
		unordered_map<int, int> ump;
		ump.insert(make_pair(1, 1));
		ump.insert(make_pair(2, 2));
		ump.insert(make_pair(3, 3));
		
	}

 经过编译后我们发现没有问题,接下来就进行迭代器的实现了:

那么迭代器该如何实现呢?首先我们都知道要想遍历桶中的节点是一定需要节点的指针的,还需要什么呢?其实还需要一个哈希表的vector或者一整个哈希表,因为当我们要从一个桶++到另一个桶的时候是需要vector的,我们实现的迭代器里面就直接存指针和哈希表好了,大家也可以下去试试存vector,步骤都是一样的,STL源码中存的是哈希表,可以给大家看看:

 下面我们就实现起来:

	//前置声明
	template <class K, class T, class KeyOfT, class Hash>
	class HashTable;
	template <class K, class T, class KeyOfT, class Hash>
	struct HashIterator
	{
		typedef HashNode<T> Node;
		typedef HashTable<K, T, KeyOfT, Hash> HT;
		Node* _node;
		HT* _ht;
		HashIterator(Node* node, HT* ht)
			:_node(node)
			,_ht(ht)
		{

		}
	};

首先我们实现的迭代器有哈希表的指针和节点的指针,由于我们的迭代器实现在哈希表之前,所以我们需要加上前置声明才能正常使用哈希表,要注意的是:哈希表参数中的Hash的缺省参数我们只需要在哈希表的地方给出,在声明的时候不用带上缺省参数,如果有重定义的报错那么一定是你将Hash给上HashFunc的缺省参数了。

 如上图,只需要在哈希表的位置给出缺省参数就可以了,因为这里是定义。我们在初始化迭代器的时候不仅需要节点还需要哈希表的指针,下面我们实现迭代器的主要功能:

struct HashIterator
	{
		typedef HashNode<T> Node;
		typedef HashTable<K, T, KeyOfT, Hash> HT;
		typedef HashIterator<K, T, KeyOfT, Hash> Self;
		Node* _node;
		HT* _ht;
		HashIterator(Node* node, HT* ht)
			:_node(node)
			,_ht(ht)
		{

		}
		T& operator*()
		{
			return _node->_data;
		}
		T* operator->()
		{
			return &_node->_data;
		}
		bool operator!=(const Self& s)
		{
			return _node != s._node;
		}
	};

对于->的返回类型相信不用多说了吧,引用本身就是指针实现的所有返回引用是没问题的。下面实现++,由于unordered系列的迭代器是单向迭代器,所以我们实现个++就可以了:

Self& operator++()
		{
			if (_node->_next)
			{
				_node = _node->_next;
			}
			else
			{
				Hash hash;
				KeyOfT kot;
				size_t hashi = hash(kot(_node->_data)) % _ht->_tables.size();
				++hashi;
				while (hashi < _ht->_tables.size())
				{
					if (_ht->_tables[hashi])
					{
						_node = _ht->_tables[hashi];
						break;
					}
					++hashi;
				}
				if (hashi == _ht->_tables.size())
				{
					_node = nullptr;
				}
			}
			return *this;
		}

首先我们要判断迭代器当前节点的next是否为空,如果不为空那么我们++直接走到next的那个节点就可以了,如果next为空就说明我们++后要变成下一个不为空的桶的头结点,所以当next为空时,我们算出当前节点映射出来的哈希桶位置,因为当前的位置已经遍历完了(毕竟刚刚next都为空了),所以我们需要让当前位置++变成下一个位置,在这里要说明一下:迭代器的起始位置是哈希表中第一个不为空的桶的头结点 ,所以我们需要判断算出来的位置是否小于总的表长度,进入循环后判断当前位置的桶的头结点是否存在如果存在那么++后的位置就是这个头结点然后break即可。因为有可能在++的过程中后面有连续空的桶所以需要判断如果出循环后发现hashi和表长度一样大,那么就说明表中没有不为空的桶,所以我们让迭代器的节点为空即可。最后返回迭代器。

下面我们实现begin和end:

template <class K, class T, class KeyOfT,class Hash = HashFunc<K>>
	class HashTable
	{
	public:
		typedef HashNode<T> Node;
		typedef HashIterator<K, T, KeyOfT, Hash> iterator;
		iterator begin()
		{
			Node* cur = nullptr;
			for (size_t i = 0; i < _tables.size(); i++)
			{
				if (_tables[i])
				{
					cur = _tables[i];
					break;
				}
			}
			return iterator(cur, this);
		}
		iterator end()
		{
			return iterator(nullptr, this);
		}

因为begin是哈希表中第一个不为空的桶的头结点,所以我们挨个遍历哈希表,找到不为空的桶我们就将头结点给cur,然后返回由cur这个节点构造的迭代器,因为我们的迭代器还有哈希表,而我们就是在哈希表中实现的begin,所以直接返回this指针,this指针就是我们当前的哈希表(注意不是*this,因为我们的迭代器要的就是哈希表的指针,所以不需要解引用),end的实现非常简单,只需要返回空节点构造的迭代器即可,因为哈希表的结束位置的节点就是为空,下面我们将迭代器封装进set和map中:

 还记得typename的作用吗?当我们使用模板的时候编译器会找不到iterator,这个时候需要typename让编译器知道iterator以后会由模板实例化生成,所以不要报错。然后我们运行起来:

void _usettest()
	{
		unordered_set<int> ust;
		ust.insert(1);
		ust.insert(2);
		ust.insert(3);
		unordered_set<int>::iterator it = ust.begin();
		while (it != ust.end())
		{
			cout << *it << " ";
			++it;
		}
	}

运行起来后发现报错了:

 这是因为我们的迭代器无法访问哈希表中的私有成员,所以我们直接用友元函数搞一下:

 上图这样的方法不用声明模板参数,下面我们再用一下传统方法:

 这里报错的原因是没加迭代器的模板参数,我们加一下:

 同样解决问题,下面我们在用一下map的迭代器:

 map的迭代器测试也没问题,下面我们实现一下map的特有功能:[]运算符

要实现[]运算符需要先修改insert插入的返回值,让其返回值变成pair<iterator,bool>想必我们实现过红黑树的封装的同学并不陌生,下面我们就修改一下:

 我们在修改哈希表中的insert前需要先修改一下find接口,因为有了迭代器后find应该返回迭代器才对:

iterator Find(const K& key)
		{
			Hash hash;
			KeyOfT kot;
			if (_tables.size() == 0)
			{
				return end();
			}
			size_t hashi = hash(key) % _tables.size();
			Node* cur = _tables[hashi];
			while (cur)
			{
				if (kot(cur->_data) == key)
				{
					return iterator(cur, this);
				}
				cur = cur->_next;
			}
			return end();
		}

 当我们找不到的时候返回end()即可,end就是nullptr构造的迭代器,找到了就返回由找到的节点构造的那个迭代器。

pair<iterator,bool> insert(const T& data)
		{
			Hash hash;
			KeyOfT kot;
			auto it = Find(kot(data));
			if (it!=end())
			{
				return make_pair(it, false);
			}
			if (_n == _tables.size())
			{
				//扩容
				size_t newsize = _tables.size() == 0 ? 10 : 2 * _tables.size();
				vector<Node*> newtable(newsize, nullptr);
				for (auto& cur : _tables)
				{
					while (cur)
					{
						Node* next = cur->_next;
						size_t hashi = hash(kot(cur->_data)) % newtable.size();
						cur->_next = newtable[hashi];
						newtable[hashi] = cur;
						cur = next;
					}
				}
				_tables.swap(newtable);
			}
			size_t hashi = hash(kot(data)) % _tables.size();
			Node* newnode = new Node(data);
			newnode->_next = _tables[hashi];
			_tables[hashi] = newnode;
			++_n;
			return make_pair(iterator(newnode, this), true);
		}

插入的时候因为find接口返回的是迭代器所以我们也用迭代器来接收find的返回值,如果it不等于end()说明就是要插入的值在哈希表找到了,这个时候不在插入了因为我们不插入重复的值,返回it这个迭代器和false即可。最后插入成功返回新节点构造的迭代器和哈希表即可。

V& operator[](const K& key)
		{
			pair<iterator, bool> ret = _ht.insert(make_pair(key, V()));
			return ret.first->second;
		}

【】不管怎么样都返回pair的second,在插入的时候由于不知道V给的是什么所以用匿名对象就可以了。下面测试一下:

void test_umap2()
	{
		string arr[] = { "苹果","西瓜","西瓜","香蕉","苹果" };
		unordered_map<string, int> mp;
		for (auto& e : arr)
		{
			mp[e]++;
		}
		for (auto& m : mp)
		{
			cout << m.first << ":" << m.second << endl;
		}
	}

 经过测试没有问题,下面我们要引出另一个问题,如果是自定义类型该如何存入哈希表呢?我们以日期类为例:

	class Date
	{
	
	public:
		Date(int year = 1900, int month = 1, int day = 1)
			: _year(year)
			, _month(month)
			, _day(day)
		{}
		bool operator<(const Date& d)const
		{
			return (_year < d._year) ||
				(_year == d._year && _month < d._month) ||
				(_year == d._year && _month == d._month && _day < d._day);
		}
		bool operator>(const Date& d)const
		{
			return (_year > d._year) ||
				(_year == d._year && _month > d._month) ||
				(_year == d._year && _month == d._month && _day > d._day);
		}
		
		friend ostream& operator<<(ostream& _cout, const Date& d)
		{
			_cout << d._year << "-" << d._month << "-" << d._day;
			return _cout;
		}
	private:
		int _year;
		int _month;
		int _day;
	};

下面是测试代码:

void test_umap3()
	{
		Date d1(2023, 1, 11);
		Date d2(2013, 4, 16);
		Date d3(2022, 6, 20);
		Date d4(2014, 7, 19);
		Date d5(2030, 8, 24);
		Date d6(2020, 1, 17);
		Date a[] = { d1,d2,d3,d4,d5,d6 };
		unordered_map<Date, int> ump;
		for (auto& data : a)
		{
			ump[data]++;
		}
		for (auto& data : ump)
		{
			cout << data.first << ":" << data.second << endl;
		}
	}

 运行时报错了因为日期类无法转化为size_t类型,这也在我们意料之中,我们用库中的哈希表要解决这样的问题实际上是自己提供一个将key转化为可以取模的整形就可以搞定了,所以我们之前实现的Hash是有问题的,因为这个参数应该是给map和set,当有人调用map和set的时候自己传入Hash,所以我们修改一下:

 调用哈希表的时候必须显式的传Hash这个参数,然后修改一下map和set:

 下面我们再修改一下map的:

 修改完成后我们写一个针对日期类的取模仿函数:

struct HashDate
	{
		size_t operator()(const Date& d)
		{
			size_t hash = 0;
			hash += d._year;
			hash *= 31;
			hash += d._month;
			hash *= 31;
			hash += d._day;
			hash *= 31;
			return hash;
		}
	};

写完后我们发现这个仿函数用不了日期类的私有成员,下面将仿函数设为友元:

然后显示传参运行一下:

 

 运行后发生报错,原因是日期类没有支持等于操作符,下面我们实现一下:

bool operator==(const Date& d) const
		{
			return (_year == d._year
				&& _month == d._month
				&& _day == d._day);
		}

 然后我们将<<运算符重载放到外面,因为放在类里面会少一个参数无法直接打印日期类。

 然后这次我们运行一下:

 ok运行成功没问题,下面我们总结一下:

1.红黑树实现的map/set要用自定义类型只需要支持<符号。

2.哈希表实现的unordered_map/set要用自定义类型需要一个支持取模转化为无符号整形的仿函数和支持==运算符。

接下来我们再实现一下const迭代器,还是和红黑树一样,set不管是普通迭代器还是const迭代器都是用哈希表中的const迭代器实现的,map中普通迭代器还是普通迭代器,const迭代器还是const迭代器,下面我们实现一下:

 我们还是和以前一样,加入ref和ptr这两个模板参数:

 以上修改完成后我们实现一下const迭代器:

const_iterator begin() const
		{
			Node* cur = nullptr;
			for (size_t i = 0; i < _tables.size(); i++)
			{
				if (_tables[i])
				{
					cur = _tables[i];
					break;
				}
			}
			return const_iterator(cur, this);
		}
		const_iterator end() const
		{
			return const_iterator(nullptr, this);
		}

然后我们在修改一下set中的迭代器:

public:
		typedef typename HashBucket::HashTable<K, K, SetKeyOfT,
			Hash>::const_iterator iterator;
		typedef typename HashBucket::HashTable<K, K, SetKeyOfT,
			Hash>::const_iterator const_iterator;
		iterator begin()
		{
			return _ht.begin();
		}
		iterator end()
		{
			return _ht.end();
		}
		const_iterator begin() const
		{
			return _ht.begin();
		}
		const_iterator end() const
		{
			return _ht.end();
		}

然后我们运行一下set:

 这里报的错与红黑树那部分一样,因为我们已经将普通迭代器用const迭代器实现了,所以begin()中返回的普通迭代器和begin的返回类型无法转换了,因为begin的返回类型是经过typedef的const迭代器,而返回值是调用哈希表中的普通迭代器,无法从普通迭代器转化为const迭代器,所以我们像之前一样直接增加一个支持将普通迭代器转化为const迭代器的构造函数:

 首先我们typedef一个普通迭代器,这一定是普通迭代器。因为只有传Ref和Ptr的时候如果传参是const类型就会转化为const迭代器,而我们用的T&,T*是不会转化的。

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

		}

 这次就解决刚刚的问题了,我们再实现一下map的const迭代器:

public:
		typedef typename HashBucket::HashTable<K, pair<const K, V>,
			MapKeyOfT,Hash>::iterator iterator;
		typedef typename HashBucket::HashTable<K, pair<const K, V>,
			MapKeyOfT, Hash>::const_iterator const_iterator;
		iterator begin()
		{
			return _ht.begin();
		}
		iterator end()
		{
			return _ht.end();
		}
		const_iterator begin() const
		{
			return _ht.begin();
		}
		const_iterator end() const
		{
			return _ht.end();
		}

 运行起来并没有问题,以上就是我们哈希表的封装。


总结

哈希表的封装是比红黑树复杂的相信大家看出来了,不过只要大家在红黑树的封装那边没有问题,那么哈希表的封装也不会很困难的,如果有不懂的完全可以查一下源码或者看上一篇红黑树封装的文章,那篇文章将源码的实现都列出来了。

风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。