当前位置: 首页 > article >正文

哈希及其封装实现unordermap和set

哈希

直接定址法

哈希和之前的红黑树的区别就是,它是通过映射关系来找到目标的,可以把它想象成之前排序的计数排序,那其实就是哈希的一种方法,叫做直接定址法。

对于比较集中的数据,它只需要开一段区间,然后通过映射关系把数据存到对应的位置上就可以了,但是它的缺点也很明显,如果数据很分散,那它的消耗就很大并且效率也很低。同时,对于非整型的数据它还多了一个转换的过程,否则也无法存入。

除留余数法

那么,为了应对这种情况,进化出了我依旧是开一段区间M,但是我把要存入的数据进行取模M,然后把取模后的结果存入对应位置,这种方法叫做除留余数法。这样也可以以较小的代价存入数据,但是新的问题又出现了,必然会有取模M后相等的值出现,这个就叫做哈希冲突,当然这并不是无法解决的,只需要往它后面第一个空着的位置存入即可,但是同样的,这么做也有可能会挤占别的数据的空间,并且往后走的过程也是需要消耗效率的,理想的情况是不出现,但是实际上一定会出现,我们只能减少出现的次数。

使用这种情况,空出的位置越多,浪费越多,空出的位置越少,冲突的概率越高,我们需要在这两者之间取得一个平衡,所以大佬们决定,如果存入的数据达到70%就进行扩容,这样勉强在两者之间取得了一定的平衡,这就叫做负载因子。 

而对于开的空间M也有说法,对于除,我们可以理解为异或,比如M = 2^16 本质上是取后16位,

但是这就会出现如果我前16位相同第17位才不同的情况,那妥妥的哈希冲突,所以大佬们又想出一个办法, 先(M<<17) -1 ,这样后16位都是1,然后再异或,可是这样只有后16位参与进来,所以再把左移前的M>>16位,这样使得32位都参与了运算,极大程度降低了冲突的概率。

当然上面这种还挺复杂的,所以产生了另一种另辟蹊径的方法,那就是取一个质数,这样虽然无法和上面相媲美,但是多少也降了点。

哈希实现(丐版)

假如我想存入这样一组值,那么我的M取个质数就取11。

那么上面这组值取模11之后必然会出现冲突,所以就按照之前我们所说的往后找空位。

存只要没满冲突就冲突嘛,无法效率低点,可是找就不一样了,如果取模后的结果不是我要找的,那么就说明我的位置被占了,或者根本没存,所以我需要往后找,直到找到空为止,那么另一个情况出现了,如果我删除了呢,人为出现了一个空进而导致找不到。所以我们还需要一个枚举类型来定义非空、空、删除这三个状态。这样就完美解决了遇到删除要不要继续找下去的问题,还需要一个n来记录数据个数。

enum isempty
{
	yes,
	no,
	del
};

template<class M, class N>
struct Data //节点
{
	pair<M, N> _kv;
	isempty _isempty = yes;
};
template<class M, class N>
class aaa
{
public:
	aaa()
		:_tables(11)
		,_n(0)
	{

	}
	bool insert(const pair<M,N>& kv)
	{
        if (find(kv.first))
		{
			//不允许重复值出现的版本
			return false;
		}
        //超过70%就扩容
		if (_n * 10 / _tables.size() >= 7)
		{
			aaa<M, N> newht;
			newht._tables.resize(_tables.size() * 2);//开二倍,
            //这样原有数据的映射位置不会被改变
			for (auto& data : _tables)
			{
				if (data._isempty == no)
				{
					newht.insert(data._kv);
				}
			}
            //最后交换就大功告成了
			_table.swap(newht._table);
		}
        
		size_t hash0 = kv.first % _tables.size();
		size_t hashi = hash0;
		size_t i = 1;
		while(_tables[hashi]._isempty == no )
		{
			hashi = (hash0 + i) % _table.size();//遇到边界可以回绕回去
			i++;
		}
		_tables[hashi]._kv = kv;
		_tables[hashi]._isempty == no;
		_n++;
		return true;
	}
    Data<M, N>* find(const M& key)
	{

		size_t hash0 = key % _tables.size();
		size_t hashi = hash0;
		size_t i = 1;
		while (_tables[hashi]._isempty != yes)
		{
			//不为空就继续往后找
			if (_tables[hashi].kv.first == key)
			{
				//找到了就返回
				return &_tables[hashi];
			}
			hashi = (hash0 + i) % _table.size();
			i++;
		}
		//不然就是没找到 返回空
		return nullptr;

	}

	bool erase(const M& key)
	{
		Data<M, N>* ret = find(key);
		if (ret)
		{
			//我们不用真的去删除,只需要改一下它的状态就好
			ret->_isempty = del;
			return true;
		}
		else
		{
			//不然就是没找到
			return false;
		}
	}
private:
	vector<Data<M, N>> _tables;
	size_t _n = 0;//数据个数
};

是的,丐版的哈希就完成了,就这样。但哈希的实现会因为你使用的方法不同,在实现上也会有所不同,所以接下来我们就在丐版的基础上增加一点东西。

加强部分

利用素数表扩容

比如说在开辟的空间上,大佬们创建了一个素数表,这样可以最大限度的避免冲突。

inline unsigned long __stl_next_prime(unsigned long n)
		{
			static const int __stl_num_primes = 28;
			static const unsigned long __stl_prime_list[__stl_num_primes] =
			{
				53,97,193,389,769,1543,3079,6151,12289,24593,49157,98317,196613,393241,786433,
				1572869,3145739,6291469,12582917,25165843,50331653,100663319,201326611,
				402653189,805306457,1610612741,3221225473,4294967291
			};
			const unsigned long* first = __stl_prime_list;
			const unsigned long* last = __stl_prime_list + __stl_num_primes;
			const unsigned long* pos = lower_bound(first, last, n);
			return pos == last ? *(last - 1) : *pos;
		}

这样我们哈希表的大小就在这么一组数里取,除了最后一个数,其他的树基本上也是靠近二倍的增长,因为最后一个还二倍就溢出了,所以对于最后一个数如果lower_bound取到值等于last,就给

个last-1的值。

取模加强

然后我们就可以对数据取模的部分进行改进,之前  数据%表大小  的方法太原始了,所以我们专门写一个函数来进化它。

size_t HashiFunc(const K& key)
		{
			size_t hashi = key & (_table.size() - 1);
			hashi ^= (key >> (32 - _m));//m=16
			return hashi; 
		}

//更新后的扩容
if (_n * 10 / _table.size() >= 7)
		{
    
		        HashiTable<K, V,Hash> newht;
				++_m;
				newht._table.resize(pow(2, _m));
				for (auto& data : _table)
				{
					if (data._isempty == no)
					{
						newht.insert(data._kv);
					}
				}
				_table.swap(newht._table);
		}

这么改完之后虽然我们的冲突会因为和所有的位取模而降低冲突率,但是大小也上来了。

对非整数类型的数据进行转换

这一步我们没法直接改,因为本身它的数据就是不确定的,所以我们增加一个模板参数,写一个仿函数,默认就用我们的HashiFunc,如果使用者的类型不对,那么由他自己设计仿函数传入。

//比如我们变成string类型
//那么外部就自己写一个仿函数传入
struct stringhashi
{
	size_t operator()(const string& s)
	{
		size_t hashi = 0;
		for (auto ch : s)
		{
			hashi += ch; 
		}
		return hashi;
	}
};

链地址法

直接定址法什么都好,就是对于离散数据消耗太高,于是大佬进化了一下,它还是开一片空间,但是这次它每个节点变成了其他结构,可以是链表,数组甚至红黑树,这样避免了相同值的消耗,我们只要头插即可。

	//节点
    template<class T>
	struct HashiNode
	{
		T _data;
		HashiNode<T>* _next;
		HashiNode(const T& data)
			:_data(data)
			, _next(nullptr)
		{}
	};

那么对于哈希表来说,主体是不变的,并且相比于丐版,我们还要把加强的部分加上,让它进入全盛状态。

迭代器

	template<class K,class T,class Ref,class Ptr, class KorT,class Hash>
	struct HashIterator
	{
		typedef HashiNode<T> Node;
		typedef HashiTable<K, T, KorT, Hash> HT;
		typedef HashIterator<K, T, Ref, Ptr, KorT, Hash> Self;
		Node* _node;
		const HT* _ht;//我们不希望可以更改数据而打乱我们的映射 所以const
		HashIterator(Node* node, const HT* ht)
			:_node(node)
			,_ht(ht)
		{}
		Ref operator*()
		{
			return _node->_data;
		}
		Ptr operator&()
		{
			return &_node->_data;
		}
		bool operator!=(const Self& s)
		{
			return _node != s._node;
		}

        //由于是编译器里实现的是单向迭代器 所以我们有样学样也实现单向迭代器 只往后走
		Self& operator++()
		{
			if (_node->_next)//如果下一个不为空就往后走一步
			{
				_node = _node->_next;
			}
			else
			{
				KorT kt;//由于我们不知道传的是pair还是别的类型 所以干脆给个模版类型来定义
				Hash hash;
				//在++之前我们得先找到它在表里映射的位置
				size_t hashi = hash(kt(_node->_data)) & _ht->_table.size();
				++hashi;
				//因为并不是每个位置都一定有值 
				while (hashi < _ht.table.size())
				{    
                //所以每次先把位置的值给_node 如果是空也无所谓
                //如果不是空 就说明找到了 break就好 不然就++ 继续往后走 直到不为空
					_node = _ht->_table[hashi];
					if (_node)
					{
						break;
					}
					else
					{
						++hashi;
					}
				}
				//如果++到了size的位置就说明走完了 所以用end()来标识_node
				if (hashi == _ht->_table.size())
				{
					_node = nullptr;
				}
			}
             return *this;
		}
	};

哈希表主体部分

插入

	//为了配合迭代器 所以返回类型更改为pair
pair<Iterator,bool> insert(const T& data)
		{
			KorT kt;
			//利用我们的迭代器先找到数据的映射位置 
			Iterator it = find(kt(data));
			//如果能找到 说明已经存在 所以返回那个位置的迭代器就好 
			if (it != End())
			{
				return { it,false };
			}
			//
			Hash hash;
			//和前面的除留余数法不一样的是 这里只要不满就可以不扩容 尤其是我们还是链地址法
			if (_n == _table.size())
			{
				
				vector<Node*> newtable(__stl_next_prime(_table.size()) + 1);
				for (int i = 0; i < _table.size(); i++)
				{
					//因为扩容的关系 除数改变也会改变取模结果 所以我们还得一个个重新算 而不能直接改
					Node* cur = _table[i];
					while (cur)
					{
						//所以再来一个循环 把数据重新计算然后再头插到新表
						Node* next = cur->_next;
						size_t hashi = hash(kt(cur->_data)) % newtable.size();
						cur->_next = newtable[hashi];
						newtable[hashi] = cur;
						cur = next;

					}
					_table[i] = nullptr;
				}
				_table.swap(newtable);
			}
			//如果不考虑上面的扩容,这就是一个链表的头插 就多了一个先找映射位置的过程

            //先判断是pair还是普通类型 然后再用仿函数转换一下以防不是整形 然后再取到映射位置
			size_t hashi = hash(kt(data)) % _table.size();
			Node* newnode = new node(data);
			newnode->_next = _table[hashi];
			_table[hashi] = newnode;
			++_n;
			return { Iterator(newnode,this),true };
		}

查找

		Iterator find(const K& key)
		{
			Hash hash;
			//找到映射位置
			size_t hashi = hash(key) % _table.size();
			//遍历
			Node* cur = _table[hashi];
			while (cur)
			{
				if (kt(cur->_data) == key)
				{
					return Iterator(cur,this);
				}
				cur = cur->_next;
			}
			//如果没有找到就返回空
			return End();
		}

相比起插入,查找简直不要太简单。

删除

这里虽然也不复杂,但是需要分情况删除,因为我们的节点结构是链表,所以删除的如果是头节点,我们需要先记录它的下一个位置,而如果不是头结点,那么我们还需要记录它的前一个节点。

 

bool erase(const K& key)
		{
			size_t hashi = key % _table.size();
			//头结点的前一个一定是空
			Node* cur = _table[hashi];
			Node* prev = nullptr;
			//然后开始分情况遍历
			while (cur)
			{
				//如果就是删头结点 那就简单了 直接链接即可
				if (kt(cur->_data) == key)
				{
					if (prev == nullptr)
					{
						_table[hashi] = cur->_next;
					}
					//不然就是前一个和后一个链接
					else
					{
						prev->_next = cur->_next;
					}
					//然后删除cur即可
					delete cur;

					return true;
				}
				//否则就往后走 然后继续遍历
				else
				{
					prev = cur;
					cur = cur->_next;
				}
			}
			//走到这里就代表删除失败
			return false;
		}

 

封装

UnorderSet

namespace cxk
{
	//由于我们的哈希桶实现的很通用 所以我们把该给的参数设置好就可以直接使用了
	template<class K,class Hash = HashiFun<K>>
	class UnderedSet
	{
		//仿函数 确定我们是什么类型的数据
		struct SetMorS
		{
			const K& operator()(const K& key)
			{
				return key;
			}
		};
	public:
		//迭代器部分的参数设置的和链表一样 不过不论是不是const迭代器 我们的数据都不允许更改 所以都加上const
		typedef typename hashi_bucket::HashiTable<K, const K, SetMorS,Hash>::Iterator iterator;
		typedef typename hashi_bucket::HashiTable<K, const K, SetMorS,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();
		}
		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);
		}
	private:
		hashi_bucket::HashiTable<K, const K, SetMorS,Hash> _ht;

	};
}

UnorderMap

namespace wyf
{
	template<class K,class V,class Hash = HashiFun<K>>
	class UnderedMap
	{
		//和set不一样的地方就是这里的数据类型是pair 所以传入的只有first
		struct MapMorS
		{
			const K& operator()(const pair<K, V>& kv)
			{
				return kv.first;
			}
		};
	public:
		//同样的迭代器和const迭代器
		typedef typename hashi_bucket::HashiTable<K, pair<const K, V>, MapMorS,Hash>::Iterator iterator;
		typedef typename hashi_bucket::HashiTable<K, pair<const K, V>, MapMorS,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();
		}
		
		V& operator[](const K& key)
		{
			pair<iterator, bool> ret = insert({ key,V() });
			return ret.first->second;
		}

		pair<iterator, bool> insert(const pair<K, V>& kv)
		{
			return _ht.insert(kv);
		}
		iterator find(const K& key)
		{
			return _ht.find(key);
		}
		bool erase(const K& key)
		{
			return _ht.erase(key);
		}
	private:
		hashi_bucket::HashiTable<K, pair<const K, V>, MapMorS<K>,Hash> _ht;
	};
}

在我们对哈希的使用进行升级之后,大多数的操作只需要把我们设计好的参数准备好就可以直接复用,从而达成封装,所以封装其实并不困难,难的点在于对哈希升级的同时兼顾到封装的逻辑。


http://www.kler.cn/a/369818.html

相关文章:

  • PSI-BLAST位点特异性矩阵PSSM和ProteinMPNN中氨基酸顺序映射
  • 华为OD机试真题---字符串摘要
  • 【含开题报告+文档+PPT+源码】基于SSM的旅游与自然保护平台开发与实现
  • 重工业数字化转型创新实践:某国家特大型钢铁企业如何快速落地基于实时数仓的数据分析平台
  • 开源模型应用落地-qwen模型小试-Qwen2.5-7B-Instruct-玩转ollama(一)
  • 【最新华为OD机试E卷-支持在线评测】机器人活动区域(200分)多语言题解-(Python/C/JavaScript/Java/Cpp)
  • 如何通过自动化有效地简化 Active Directory 操作?
  • Java基于微信小程序的童装商城的设计与实现,附源码+文档
  • 使用语言模型进行文本摘要的五个级别(llm)
  • 51单片机 复位电路
  • 解决Redis缓存穿透(缓存空对象、布隆过滤器)
  • k8s部署metallb实现service的LoadBalancer模式
  • 微信小程序地图功能开发:绘制多边形和标记点
  • kotlin等待异步任务完成
  • 100种算法【Python版】第18篇——Prim算法
  • 使用 `screen` + `nohup` 实现高效日志记录和多环境任务管理
  • electron的常用api
  • SegNet DeconvNet——论文阅读
  • Java(三十) --- 基于比较的七大比较的排序算法(巨详细)
  • 【前端JS登录接口逆向破解】