数据结构---哈希(Hash)

哈希

  • 1. 哈希概念
  • 2. 哈希函数
  • 3. 哈希冲突
    • 3.1 闭散列
    • 3.1.1 闭散列模拟实现
    • 3.2 开散列
    • 3.2.1 开散列模拟实现
  • 4. 基于开散列实现unordered_set
  • 5. 基于开散列实现unordered_map
  • 6. 闭散列和开散列对比

对于set和map来说底层所使用的是红黑树,其在搜索上面已经很厉害了,为什么还需要在搞一个unordered_set和unordered_map呢?

#include
#include
#include
#include
#include

using namespace std;

void test_time()
{
	int n = 1000000;
	vector<int> v;
	srand(time(0)); //初始化随机数发生器
	for (int i = 0; i < n; ++i)
	{
		v.push_back(rand()); //随机数发生器
	}

	set<int> s;
	size_t begin1 = clock();

	for (auto e : v)
	{
		s.insert(e);
	}
	size_t end1 = clock();

	cout << "set : " << end1 - begin1 << endl;


	unordered_set<int> us;
	size_t begin2 = clock();

	for (auto e : v)
	{
		us.insert(e);
	}
	size_t end2 = clock();

	cout << "unordered_set : " << end2 - begin2 << endl;
}

int main()
{
	test_time();
}

数据结构---哈希(Hash)_第1张图片
通过对于很多个数的插入(数越多,插入之间所消耗的时间差距越大),其实也是能够看出来HashTable的优越之处。

1. 哈希概念

可以不经过任何比较,一次直接从表中得到要搜索的元素。如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。

哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表)

例如:数据集合{1,7,6,4,5,9};
哈希函数设置为:hash(key) = key % capacity; capacity为存储元素底层空间总的大小。
数据结构---哈希(Hash)_第2张图片

2. 哈希函数

引起哈希冲突的一个原因可能是:哈希函数设计不够合理

哈希函数设计原则:

  • 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0 到m-1之间。
  • 哈希函数计算出来的地址能均匀分布在整个空间中
  • 哈希函数应该比较简单

常见哈希函数

  1. 直接定制法–(常用)
    取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B优点:简单、均匀缺点:需要事先知道关键字的分布情况使用场景:适合查找比较小且连续的情况。

  2. 除留余数法–(常用)
    设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址。

  3. 平方取中法

  4. 折叠法

  5. 随机数法

  6. 数学分析法

注意:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突

3. 哈希冲突

解决哈希冲突两种常见的方法是:闭散列和开散列

3.1 闭散列

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

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

  • 通过哈希函数获取待插入元素在哈希表中的位置
  • 如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素
  • 采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。比如删除元素4,如果直接删除掉,44查找起来可能会受影响。因此线性探测采用标记的伪删除法来删除一个元素。
    数据结构---哈希(Hash)_第3张图片

载荷因子

  • 为了能够保证一直有空位置,所以这里引入了载荷因子,对于载荷因子来说,经过大量的使用最好能够控制在0.7以下最好,一旦超过就会大大增加哈希冲突的概率

数据结构---哈希(Hash)_第4张图片

线性探测优点:实现非常简单,
线性探测缺点:一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”,即:不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低。(引发踩踏效应,就像我的位置被人占了,我就去占别人的位置,后来的人都效仿,会导致效率变低)

二次探测线性探测的缺陷是产生冲突的数据堆积在一块,这和找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,他不再是挨着找下一个空位置,而是平方式的跳跃找下一个空位置,这样冲突就不会堆积在一片,而是会相对散开一些。

比散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷。

3.1.1 闭散列模拟实现

  1. 对于闭散列来说,每个哈希表对应的位置里面存储了数据和状态
  2. 其中数据的类型是由模板的第二个参数所控制的,模板参数T是Key或者pair,所以这也是一种更高维度的泛型。
  3. 其中对于删除来说,采用的是一种伪删除法。因为对于闭散列来说,最需要的就是找到下一个空位置,如果没有这个状态的话,原本有数据的位置删除了就会直接的变为空位置,那么如果删除的位置刚好在你的要查找的数据之前,那么就会误认为找不到,事实上,是存在的。
  4. 对于闭散列在增容的时候所采用的方法:重新开辟一个原先二倍大小的vector,然后把你原先的数据通过新的哈希函数计算位置,然后这个新的HashTable和你原先的进行交换。
namespace Close
{

	enum State
	{
		EMPTY,
		EXIST,
		DELETE
	};

	template<class T>
	struct HashNode
	{
		State _state = EMPTY;
		T _t;
	};

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

	//模板特化
	template<>
	struct Hash < string >
	{
		size_t operator()(const string& s)
		{
			size_t hash = 0;
			for (auto ch : s)
			{
				//hash += ch;
				hash = hash * 131 + ch; //
			}
			return hash;
		}
	};
	//这里和使用红黑树来封装map和set是一样的,都是由第二个模板参数来控制存储的类型是K,还是pair
	template<class K, class T, class HashFunc = Hash<K>>//需要把这里的K转换为整形
	class HashTable
	{
	public:
		bool Insert(const T& t)
		{
			//载荷因子= 填入表中的元素个数 / 散列表的长度
			//负载因子>0.7(严格控制在0.7以下)
			//只有在这种情况下:效率相对较高,并且空间利用率也较好
			if (_tables.size() == 0 || _size * 10 / _tables.size() == 7)
			{
				size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
				//但是这里是不对的,因为有可能原来冲突的,由于空间的扩容,而不再是冲突的了,
				//所以我们应该开辟好一段空间,然后重新对上面的数据进行计算,然后放入这段空间内,在释放原来的空间


				//vector> newtables;
				//newtables.resize(newsize);

				此时就是把原空间上的数据,拿来重新计算放到相对应的新空间上
				//for (size_t i = 0; i < _tables.size(); ++i)
				//{
				//	if (_tables[i]._state == EXIST)
				//	{
				//		//线性探测找在新表中的位置
				//	}
				//}
				//newtables.swap(_tables);

				HashTable<K, T, HashFunc> newht;
				newht._tables.resize(newsize);
				for (auto& e : _tables)
				{
					if (e._state == EXIST)
					{
						//重新计算位置,然后放在这个新的表中
						newht.Insert(e._t); //拿旧空间的数据重新计算我嫌弃太麻烦,我还不如直接重新来一遍插入呢
					}
				}
				_tables.swap(newht._tables);
			}
			//不允许数据冗余
			HashNode<T>*  ret = Find(t);
			if (ret)
				return false;

			HashFunc hf;
			//这里在一开始的时候,你的哈希表里面是没有元素的,所以会出现模0,出错情况
			size_t start = hf(t) % _tables.size();
			size_t index = start; //这样写的好处就是,能够在二次探测的时候方便修改
			//线性探测,找一个空位置
			size_t i = 1;
			while (_tables[index]._state == EXIST) //应该去找下一个空位置
			{
				index = start + i;
				index %= _tables.size();
				++i;
			}
			//跳出循环就是两种情况1.EMPTY  2. DELETE这两种情况都可以把值放进去
			_tables[index]._t = t;
			_tables[index]._state = EXIST;
			_size++;

			return true;
		}

		HashNode<T>* Find(const K& key)
		{
			HashFunc hf;
			size_t start = hf(key) % _tables.size();
			size_t index = start;
			size_t i = 1;
			while (_tables[index]._state != EMPTY)
			{
				if (_tables[index]._t == key && _tables[index]._state == EXIST) //因为可能找到的这个值被删除掉了
				{
					return &_tables[index];
				}
				index = start + i;
				index %= _tables.size();
				++i;
			}
			return nullptr;
		}

		bool Erase(const K& key)
		{
			HashNode<T>* ret = Find(key);
			if (ret == nullptr)
			{
				return false;
			}
			else
			{
				//伪删除
				ret->_state = DELETE;
				return true;
			}
		}
	private:
		vector<HashNode<T>> _tables;
		size_t _size = 0; //有效数据的个数
	};
}

3.2 开散列

开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶各个桶中的元素通过一个单链表链接起来各链表的头结点存储在哈希表中
数据结构---哈希(Hash)_第5张图片
从上图可以看出,开散列中每个桶中放的都是发生哈希冲突的元素。

那如果就是出现了极端的情况,所有的数此时都冲突到一个桶中,那么这个桶中的数据就会太多了,应该怎么办?

  1. 将此时的链表改换挂红黑树
  2. 多阶哈希(不常使用)

3.2.1 开散列模拟实现

  1. 除留余数法,最好模一个素数,如何每次快速取一个类似两倍关系的素数?这里的GetNextPrime()函数,在增容的时候,就能很好的帮助取到这个素数。
  2. 仿函数KeyOfT,是用来取出T类型(如果是unordered_set就是Key,unordered_map就是pair)中的Key值的
  3. 仿函数hash是为了能够支持取模,因为一般很多时候K都是字符串类型,但是string是不支持取模的,所以需要将其转化为整数,在取模。但是单纯的使用ASCII码值相加,字符串还是容易发生冲突,所以这里又引入了一个字符串哈希算法,最常使用的就是C语言之父所写的BKDRHash算法,也就是给每个字符先乘131,然后在把字符的ASCII码进行相加。当然这里还是用到了模板的特化,当数据是具体的string的时候,就会调用这个仿函数进行计算。
  4. 对于迭代器operator++的实现需要使用到HashTable,因为当一个哈希桶内数据遍历完了以后,需要跳到下一个桶,但是如果没有这个HashTable的话,就无法找到下一个桶位置。
  5. 在使用迭代器的时候需要HashTable但是此时还没有实例化出来,所以需要一个前置声明。
  6. 需要在HashTable中将HashIterator声明为友元。(因为迭代器需要使用HashTable的私有成员)
namespace Open
{
	size_t GetNextPrime(size_t prime)
	{
		static 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];
	}

	template<class T>
	struct HashLinkNode
	{
		T _t;
		HashLinkNode<T>* _next;

		HashLinkNode(const T& t)
			: _t(t)
			, _next(nullptr)
		{}
	};

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

	// 特化
	template<>
	struct Hash < string >
	{
		size_t operator()(const string& s)
		{
			size_t hash = 0;
			for (auto ch : s)
			{
				//hash += ch;
				hash = hash * 131 + ch;
			}

			return hash;
		}
	};

	//前置声明,因为在构造迭代器的时候,需要HashTable,但是这里模板还没有进行特化,找不到
	template<class K, class T, class KeyOfT, class hash>
	class HashTable;

	//对于封装迭代器来说,++那么就会跳到桶里面的下一个结点的位置,但是当这个桶不再有结点的时候,就需要直接的跳到下一个有hash映射的位置
	template<class K, class T, class Ref,class Ptr ,class KeyOfT, class hash> 
	struct HashIterator
	{
		typedef HashIterator<K, T, Ref,Ptr,KeyOfT, hash> Self;
		typedef HashLinkNode<T> Node;
		Node* _node;
		HashTable<K, T, KeyOfT, hash>* _pht; //还需要一个指向哈希表的指针,因为需要遍历哈希表,如果此时这个通遍历完了
		//能够帮助找到下一个需要到跳跃的位置

		HashIterator(Node* node, HashTable<K, T, KeyOfT, hash>* pht)
			:_node(node)
			, _pht(pht)
		{}

		Ref operator*()
		{
			return _node->_t;
		}

		Ptr operator->()
		{
			return &(_node->_t);
		}

		bool operator!=(const Self& s) const 
		{
			return _node != s._node;
		}
		//哈希表的迭代器是没有--的,因为这是一个单项迭代器
		//对于operator++(前置++)来说,加完之后依旧返回的是一个迭代器的指针
		//1. 当前桶还有数据,继续走
		//2. 当前桶没有数据,跳到下一个桶,从第一个开始
		Self operator++()
		{
			if (_node->_next)
			{
				_node = _node->_next;
			}
			else
			{
				KeyOfT kot;
				//一个桶已经走完了,找到下一个桶
				//因为我在当前桶是可以算出我的位置的,但是还需要一个hash表
				//走到这里说明我算出来的index的原来位置,已经全部都走完了,那么应该从他的下一个位置开始计算
				size_t index = _pht->HashFunc(kot(_node->_t),_pht->_tables.size());
				++index;
				while (index < _pht->_tables.size())
				{
					if (_pht->_tables[index])
					{
						//就开始遍历这个桶
						_node = _pht->_tables[index];
						break;
					}
					else
					{
						++index;
					}
				}

				//但是while结束有可能是两种可能1.break跳出来的   2.循环走结束了
				//所以这两种情况最好能够区分一下
				if (index == _pht->_tables.size())
				{
					_node = nullptr;
				}
			}
			return *this;
		}
	};


	template<class K,class T,class KeyOfT,class hash = Hash<K>>
	class HashTable
	{
		typedef HashLinkNode<T> Node;
		friend struct HashIterator < K, T, T&, T*, KeyOfT, hash > ;
	public:
		typedef HashIterator<K, T, T&,T*,KeyOfT, hash> Iterator;
		typedef HashIterator<K, T, const T&, const T*, KeyOfT, hash> Const_Iterator;
		Iterator Begin()
		{
			for (size_t i = 0; i < _tables.size(); ++i)
			{
				if (_tables[i])
				{
					return Iterator(_tables[i],this); //this是指向HashTable的指针
				}
			}

			//如果哈希表中一个数据也没有,那么返回nullptr也是正确的
			return End();
		}
		Iterator End()
		{
			return Iterator(nullptr,this);
		}

		size_t HashFunc(const K& key, size_t n)
		{
			hash hf;
			size_t ki = hf(key);
			return ki % n;
		}
	
		pair<Iterator, bool> Insert(const T& t)
		{
			//对于开散列/哈希桶依旧要考虑负载因子的问题,但是一般控制在1,也就是满了在加,空间利用率更高
			//自己冲突的多,只会影响我自己,不会影响别人
			KeyOfT kot;
			//控制负载因子 == 1的时候增容
			if (_size == _tables.size())
			{
				size_t newsize = GetNextPrime(_tables.size());//得到素数,有效验证能够提高效率
				vector<Node*> newtables;
				newtables.resize(newsize,nullptr);

				//循环的把每个哈希桶拿过来,重新挂接
				for (size_t i = 0; i < _tables.size(); ++i)
				{
					//旧表中结点直接取下来挂在新表中
					Node* cur = _tables[i];
					while (cur)
					{
						Node* next = cur->_next;
						size_t index = HashFunc(kot(cur->_t),newtables.size());//重新计算在所开辟的新表中的位置
						//头插
						cur->_next = newtables[index];
						newtables[index] = cur;

						cur = next;
					}
					_tables[i] = nullptr;//因为你算来的表   指针可能还指向着这个位置
				}
				newtables.swap(_tables);
			}

			//需要得到下标的位置
			size_t index = HashFunc(kot(t),_tables.size());//首先把T里面的key提取出来,但是还是不能够确定是否可以取模,所以还需要在套用一层
			
			//不允许键值冗余,所以需要查找t在不在
			//先找到那个位置,然后在遍历整个哈希桶
			Node* cur = _tables[index];
			while (cur)
			{
				if (kot(cur->_t) == kot(t))
				{
					return make_pair(Iterator(cur,this), false);
				}
				cur = cur->_next;
			}

			//对于哈希表中一开始存储的是NULL,当底下有挂接的时候,存储的将会变为第一个结点的地址
			//头插到链表桶里面
			Node* newnode = new Node(t);
			newnode->_next = _tables[index];
			_tables[index] = newnode;

			return make_pair(Iterator(newnode,this), true);
		}

		//Find就是通过Key来寻找的
		Iterator Find(const K& key)
		{
			KeyOfT kot;
			size_t index = HashFunc(key,_tables.size());
			Node* cur = _tables[index];
			//现在hash表中找到位置,然后在遍历该位置的整个hash桶
			while (cur)
			{
				if (kot(cur->_t) == key)
				{
					return Iterator(cur,this);
				}
				cur = cur->_next;
			}
			return End();
		}
		bool Earse(const K& key)
		{
			KeyOfT kot;
			size_t index = HashFunc(key, _tables.size());
			//这里就是一个单链表的删除
			Node* prev = nullptr;
			Node* cur = _tables[index];
			//现在hash表中找到位置,然后在遍历该位置的整个hash桶
			while (cur)
			{
				if (kot(cur->_t) == key)
				{
					//就进行删除
					if (prev == nullptr)
					{
						//说明删除的是头
						_tables[index] = cur->_next; //链表挂接的第一个就直接找到了
					}
					else
					{
						prev->_next = cur->_next;
					}
					delete cur;
					return true;
				}
				else
				{
					prev = cur;
					cur = cur->_next;
				}
			}
			return false;
		}
	private:
		vector<Node*> _tables;
		size_t _size = 0;//有效数据的个数
	};

}

4. 基于开散列实现unordered_set

  1. 对于unordered_set来说实现迭代器,其实就是对HashTable进行了一层封装,但是HashTable的迭代器也是由模板所实现的,所以这里的typename还有另外的作用:先别急着报错,先等一等,等具体实例化了再来找,如果还没有在报错也不出。
#pragma once
#include "HashTable.hpp"

//其实和map和set一样,本身他们自身是没有什么东西的,他们的种种操作都是在红黑树的基础上所实现的
//现在的unordered_set和unordered_map也是,都是在开散列HashTable基础上所实现的
namespace wzy
{
	template<class K,class hash = Open::Hash<K>> //对于orderedset_set来说你想怎样使用,你就怎么控制这个模板参数
	class unordered_set
	{
		struct SetKOfT
		{
			const K& operator()(const K& k)
			{
				return k;
			}
		};
	public:
		typedef typename Open::HashTable<K, K, SetKOfT, hash>::Iterator iterator;

		iterator begin()
		{
			return _t.Begin();
		}

		iterator end()
		{
			return _t.End();
		}

		iterator find(const K& key)
		{
			return _t.Find(key);
		}

		bool earse(const K& key)
		{
			return _t.Earse(key);
		}
		pair<iterator, bool> insert(const K& k)
		{
			return _t.Insert(k);
		}

	private:
		Open::HashTable<K, K, SetKOfT, hash> _t;
	};

	void test_unordered_set()
	{
		wzy::unordered_set<int> us;
		us.insert(1);
		us.insert(54);
		us.insert(58);
		us.insert(59);
		us.insert(21);
		us.insert(22); 
		us.insert(23);
		us.insert(24);
		for (auto& e : us)
		{
			cout << e << " ";
		}
		cout << endl;

		unordered_set<int>::iterator it = us.find(22);
		cout << *it << endl;

		us.earse(24);
		us.earse(21);
		us.earse(22);
		for (auto& e : us)
		{
			cout << e << " ";
		}
		cout << endl;
	}
}

数据结构---哈希(Hash)_第6张图片

5. 基于开散列实现unordered_map

  1. 对于unordered_map来说,重载了[],对于这个返回的是Value的引用。
#pragma once 
#include"HashTable.hpp"

namespace wzy
{
	template<class K,class V,class hash = Open::Hash<K>>
	class unordered_map
	{
		struct MapKOfT
		{
			const K& operator()(const pair<const K, V>& kv)
			{
				return kv.first;
			}
		};

	public:
		typedef typename Open::HashTable<K, pair<const K, V>, MapKOfT, hash>::Iterator iterator;
		pair<iterator, bool> insert(const pair<const K, V>& kv)
		{
			return _ht.Insert(kv);
		}

		iterator begin()
		{
			return _ht.Begin();
		}

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

		iterator find(const K& key)
		{
			return _ht.Find(key);
		}

		bool earse(const K& key)
		{
			return _ht.Earse(key);
		}

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

	void test_unordered_map()
	{
		wzy::unordered_map<int, int> um;
		um.insert(make_pair(1, 1));
		um.insert(make_pair(2, 1));
		um.insert(make_pair(3, 1));

		unordered_map<int,int>::iterator it = um.begin();
		while (it != um.end())
		{
			cout << it->first << ":"<<it->second<<endl;
			++it;
		}

		wzy::unordered_map<string,string> dict;
		dict["hash"] = "哈希";
		dict["sort"] = "排序";
		dict["insert"] = "插入";
		dict["find"] = "查找";

		for (auto& e : dict)
		{
			cout << e.first << ":" << e.second << endl;
		}

		cout <<endl;

		dict.earse("hash");
		dict.earse("find");
		for (auto& e : dict)
		{
			cout << e.first << ":" << e.second << endl;
		}
	}
}

数据结构---哈希(Hash)_第7张图片

6. 闭散列和开散列对比

由于开放定址法必须保持大量的空闲空间以确保搜索效率,并且非常容易发生踩踏效应,如二次探查法要求装载因子a <= 0.7,而表项所占空间又比指针大的多,所以使用链地址法反而比开地址法节省存储空间。相对于开放定址法来说,开链法的空间利用率为1,只有满的时候才需要增容(空间利用率很高),并且不同的位置冲突值不再相互干扰踩踏

你可能感兴趣的:(数据结构,C++语法知识)