哈希表(如何打造一个工业级的哈希表)

目录

哈希思想

哈希函数

 哈希冲突

1.开放寻址法

2、链表法

解决装载因子过大的问题

选择合适的哈希冲突解决方法


哈希思想


哈希表(hashtable)是数组的一一种扩展,由数组演化而来,底层依赖数组支持按下标快速
访问元素的特性。换句话说,如果没有数组, 就没有哈希表。我们举例来解释一下。

假如有69名选手参加学校运动会。为了方便记录成绩,每个选手胸前都会贴上自己的参赛号码。这69名选手依次编号为1~ 69。现在我们希望编程实现这样一个功能: 通过编号快速找到对应的选手信息。如何实现这个功能呢?
我们可以把这69名选手的信息放在数组里。对于编号为1的选手的信息,放到数组中下标为1的位置;对于编号为2的选手的信息,放到数组中下标为2的位置,依此类推,对于编号为k的选手的信息,放到数组中下标为k的位置。
参赛编号与数组下标一一对应,当需要查询参赛编号为x的选手信息时,只需要将下标为x的数组元素取出,时间复杂度就是0(1),效率非常高。
实际上,这个例子已经用到了哈希思想。在这个例子里,参赛编号是自然数,并且与数组下标形成一一映射关系, 因此,利用数组按下标访问元素的时间复杂度是0(1)这一特性,我们就可以实现按照编号快速查找选手信息这一功能。
不过,这个例子中蕴含的哈希思想还不够明显,我们把这个例子稍微改造一下。
假设校长提出要求,参赛编号不能这么简单,要加上年级、班级这些详细信息,于是,我们把编号的规则稍微修改一下, 用6位数字来表示,如01120639其中,前两位01表示年级,中间两位12表示班级,最后两位还是原来的编号1~ 69。这个时候,又该如何存储选手信息,才能支持通过编号快速查找选手信息呢?

问题的处理思路与之前的类似。尽管现在不能直接把参赛编号作为数组下标来使用,但我们可以截取参赛编号的后两位作为数组下标。当通过参赛编号查询选手信息时,我们用同样的方法,取参赛编号的后两位作为数组下标,从数组中取这个下标对应的选手信息。
这就是典型的哈希思想。其中,参赛选手的编号称为键(key) 或者关键字(keyword)。 我们用键来标识一个选手。我们把参赛编号转化为数组下标的映射方法称为哈希函数。哈希函数计算得到的值称为哈希值。

 哈希表利用的是数组按下标访间元素的时间复杂度是O(1)这一特性。 我们通过哈希函数把元素的键值映射为下标,然后,将对应的数据存储在数组中对应下标的位置。当按照键值查询元素时,我们使用同样的哈希函数,将键值转化为数组下标,从数组中这个下标对应的位置取数据。

哈希函数

哈希函数在哈希表中起着关键作用。哈希函数首先是一个函数,我们可以把它定义成hash(key),其中,key 表示元素的键值,hash(key) 的值表示经过哈希函数计算得到的哈希值。

在上面的例子中,哈希函数比较简单,代码很容易实现。但如果参赛选手的编号是随机生成的6位数字,又或者是a~ z的字符串,那么又该如何构造哈希函數呢?

下面总结了哈希函数设计时的3个基本要求:

  • 1)哈希函数计算得到的哈希值是一个非负整数;
  • 2)如果key1=key2,那么hash(key1)==hash(key2);
  • 3)如果keyl≠key2,那么hash(key1)≠hash(key2)。

其中,第一个基本要求理解起来应该没有任何问题,因为数组下标是从0开始的,因此,哈希函数生成的哈希值也必须是非负整数。第二个基本要求也很好理解,相同的key经过哈希函数得到的哈希值也应该是相同的。第三个基本要求理解起来可能有难度,这个要求看起来合情合理,但是,在实际情况中,要想找一一个没有冲突(key 不同对应的哈希值也不同)的哈希函数,几乎是不可能的。即便像业界著名的MD5、SHA、CRC等哈希算法,也无法完全避免哈希冲突。而且,因为数组的存储空间有限,所以也会加大哈希冲突的概率。

例如有一个数据集合{11,8,6,14,5,9};

哈希函数hash(key) = key%capacity,capacity为存储元素底层空间的总大小。
若我们将该集合存储在capacity为10的哈希表中,则各元素存储位置对应如下:

用该方法进行存储,在搜索时就只需通过哈希函数判断对应位置是否存放的是待查找元素,而不必进行多次关键码的比较,因此搜索的速度比较快。

按照上述哈希方式,向集合中插入元素44,会出现什么问题?

 哈希冲突

再好的哈希函数也无法避免哈希冲突。那么,究竟该如何解决哈希冲突呢?常用的方法有两类:开放寻址法和链表法。

1.开放寻址法

开放寻址法的核心思想:一旦出现哈希冲突,就通过重新探测新位置的方法来解决冲突。如何重新探测新位置呢?
最简单的探测方法是线性探测法

比如上述情景,现在需要插入元素44,先通过哈希函数计算哈希地址,hashAddr为4, 因此44理论上应该插在下标为4位置,但是该位置已经放了值为4的元素,即发生哈希冲突。

插入:当向哈希表中插入数据时,如果某个数据经过哈希函数计算之后,对应的存储位置已经被占用了,我们就从这个位置开始,在数组中依次往后查找,直到找到空闲位置为止。

 删除:哈希表不仅支持插入、查找操作,还支持删除操作。对于使用线性探测法解决冲突的哈希表,删除操作稍微有些特别,不能单纯地把待删除元素所在位置设置为空(NULL)。在查找数据的时候,一旦通过线性探测法遍历到空闲位置,我们就认定哈希表中不存在这个数据。但是,如果这个空闲位置是后来删除的,就会导致原来的查找算法失效。本来存在的数据有可能被认定为不存在。比如删除元素4,如果直接删除掉,44查找起来可能会受影响。因此线性探测采用标记的伪删除法来删除一个元素。将待删除数据的存储空间特殊标记为“deleted"。当利用线性探测法查找数据的时候,遇到标记为“deleted" 的空间,并不会停下来,而是会继续往下探测。

// 哈希表每个空间给个标记
// EMPTY此位置空, EXIST此位置已经有元素, DELETE元素已经删除
enum State{

        EMPTY,

        EXIST,

        DELETE

     }; 

对于线性探测法,当哈希表中插入的数据越来越多时,空闲位置会越来越少,哈希冲突发生的概率就会越来越大,线性探测的时间就会越来越长。在极端情况下,需要探测整个哈希表才能能找到空闲位置并将数据插入,因此,最坏情况下的时间复杂度为0(n)。同理,在删除数据和查找数据时,也有可能线性探测整个哈希表。

对于开放寻址法,除线性探测法之外,还有另外两种经典的探测方法:二次探测法双重哈希法

二次探测法与线性探测法很像线性探测法的探测步长是1,探测的下标序列是hash(key)+0、hash(key)+1、hash(key+2..... 而二次探测法的探测步长变成了原来的“二次方”,探测的下标序列是hash(key)+0、hash(key)+1^2、 hash(key)+2^2......
双重哈希法使用多个哈希函数: hash1(key)、 hash2(key)、 hash3(key)..... 如果第一个哈希函数计算得到的存储位置已经被占用,再用第二个哈希函数重新计算存储位置,依此类推,直到找到空闲的存储位置为止。

研究表明:当表的长度为质数且表装载因子(装载因子一哈希表中的元素个数 / 哈希表的长度(“槽”的个数))a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容。

因此:开放寻址法最大的缺陷就是空间利用率比较低,这也是哈希的缺陷。

代码:

enum State
	{
		EMPTY,
		EXIST,
		DELETE
	};

	template<class K, class V>
	struct HashData
	{
		pair<K, V> _kv;
		State _state = EMPTY;
	};

	template<class K, class V>
	class HashTable
	{
	public:
		bool Insert(const pair<K, V>& kv)
		{
			if (Find(kv.first))
				return false;

			// 负载因子超过0.7就扩容
			if (_tables.size() == 0 || _n * 10 / _tables.size() >= 7)
			{
				size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
				HashTable<K, V> newht;
				newht._tables.resize(newsize);

				// 遍历旧表,重新映射到新表
				for (auto& data : _tables)
				{
					if (data._state == EXIST)
					{
						newht.Insert(data._kv);
					}
				}

				_tables.swap(newht._tables);
			}

			size_t hashi = kv.first % _tables.size();

			// 线性探测
			size_t i = 1;
			size_t index = hashi;
			while (_tables[index]._state == EXIST)
			{
				index = hashi + i;
				index %= _tables.size();
				++i;
			}

			_tables[index]._kv = kv;
			_tables[index]._state = EXIST;
			_n++;

			return true;
		}

		HashData<K, V>* Find(const K& key)
		{
			if (_tables.size() == 0)
			{
				return false;
			}

			size_t hashi = key % _tables.size();

			// 线性探测
			size_t i = 1;
			size_t index = hashi;
			while (_tables[index]._state != EMPTY)
			{
				if (_tables[index]._state == EXIST
					&& _tables[index]._kv.first == key)
				{
					return &_tables[index];
				}

				index = hashi + i;
				index %= _tables.size();
				++i;

				// 如果已经查找一圈,那么说明全是存在+删除
				if (index == hashi)
				{
					break;
				}
			}

			return nullptr;
		}

		bool Erase(const K& key)
		{
			HashData<K, V>* ret = Find(key);
			if (ret)
			{
				ret->_state = DELETE;
				--_n;
				return true;
			}
			else
			{
				return false;
			}
		}

	private:
		vector<HashData<K, V>> _tables;
		size_t _n = 0; // 存储的数据个数

	};

2、链表法

链表法是一种更加常用的解决哈希冲突的方法,相比开放寻址法,它要简单得多。在哈希表中,每个“桶”或者“槽”(slot)会对应一个链表,我们把哈希值相同的元素放到相同槽位对应的链表中。
当插入数据的时候,我们只需要通过哈希函数计算出对应的“槽”,然后将数据插入到这个“槽”对应的链表中。插入数据的时间复杂度是O(1)。当要查找、删除数据时,我们同样通过哈希函数计算出对应的“槽",然后,遍历链表查找或删除数据。

对于基于链表法解决冲突的哈希表,查找、删除操作的时间复杂度与链表的长度k成正比,也就是O(k)。 对于哈希比较均匀的哈希函数,从理论上来讲,k=n/m, 其中n表示哈希表中数据的个数,m表示哈希表中““槽” 的个数。当k是一个不大的常量时,我们可以租略地认为,在哈希表中查找、刪除数据的时间复杂度是O(1)。

我们把上面的k称为装载因子。装载因子用公式表示出来就是:装载因子=哈希表中的元素个数/哈希表的长度(“槽”的个数)。装载因子越大,说明链表长度越长,哈希表的性能就会越低。

解决装载因子过大的问题

装载因子越大,说明哈希表中的元素越多,空闲位置越少,哈希冲突的概率就会越大,插入、删除和查找数时的性能都会随之降低。如果对于没有频繁插入和删除操作的静态数据集合,因为数据是静态已知的,我们容易根据数据的特点等,设计出很好的、冲突极少的哈希函数。但对于有频繁插入和删除除操作的动态数据集合,因为无法事先预估将要加入的数据个数,因此,无法事先申请一个足够大的哈希表。随着越来越多的数据的加入,装载因子会越来越大。当装载因子大到一-定程度之后,大量的哈希冲突就会导致哈希表性能急剧下降。这个时候该怎么办呢?
对于哈希表,当装载因子过大时,我们也可以进行动态扩容,重新申请一个 更大的哈希表,将原哈希表中的数据搬移到新哈希表。假设每次扩容都重新申请个原哈希表两倍大小的新哈希表。如果原哈希表的装载因子是0.8,经过扩容之后,新哈希表的装载因子就下降为原来的一半,变成了0.4。对数组、栈和队列的扩容,数据搬移操作比较简单。但对哈希表n表示哈希表大小的扩容,数据搬移操作要复杂很多。因为哈希表的大小变了,数据的存储位置也变了,所以,我们需要通过哈希函数重新计算每个数据在新哈希表中的存储位置。在大部分情况下,插入新数据不会触发扩容,因此,插入操作的最好时间复杂度是O(1)。如果装载因子过高,超过事先设置的阈值,那么,在插入新数据时,就会触发扩容,需要重新申请内存空间,重新计算每个数据的哈希值,并且将数据从原哈希表搬移到新哈希表,因此,最坏情况下插入操作的时间复杂度是O(n)。

对于支持动态护容的哈春表,在大部分情况下, 插入数据的速度很快,但是,在特殊情况下,当装教因子已经达到阈值时,插入数据前需要先进行扩容。这个时候,插入数据就会变得非常慢,甚至无法令人接受。
我们用一个极端的例子来解释一下。如果哈希表当前的大小为1GB,当启动扩容时,需要对1GB的数据重新计算哈希值,并且搬移到新的哈希表中。这样的操作听起来就很耗时。如果我们的项目的代码直接服务于用户,对响应时间要求比较高,尽管大部分情况下,插入数据的速度很快,但是,极个别速度非常慢的插入操作,也会让用户“崩溃”。这个时候,这种集中扩容的机制就不合适了。
为了解决集中扩容耗时过多的问题,我们将扩容操作穿插在多次插入操作的过程中,分批完成。当装载因子触达阈值之后,我们只创建新哈希表,但并不将原哈希表中的数据全部搬移到新哈希表中。
当有新数据要插入时,除将新数据插入新哈希表之外,还会从原哈希表中搬移一个数据到新哈希表中。每插入一个新数据到哈希表,我们都重复上面的过程。经过多次插入操作之后,原哈希表中的数据就一点点地被搬移到新哈希表中了。这样我们就将数据的搬移工作分散到了多次数据插入操作中,没有了集中的一次性大量数据搬移操作,所有的插入操作都变得很快。

 通过这个方法,将扩容的代价均摊到多次插入操作中,就避免了扩容耗时过多的问题。基于这种扩容方式,在任何情况下,插入数据的时间复杂度都是O(1)。
不过,在原哈希表中的数据没有完全搬移到新哈希表中之前,原哈希表占用的内存不会被释放,内存占用会比较多,而且,对于查询操作,为了兼容新、旧哈希表中的数据,我们需要同时在新、旧两个哈希表中查找。

代码:

template<class K, class V>
	struct HashNode
	{
		HashNode<K, V>* _next;
		pair<K, V> _kv;

		HashNode(const pair<K, V>& kv)
			:_next(nullptr)
			, _kv(kv)
		{}
	};

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

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

			return hash;
		}
	};

	template<class K, class V, class Hash = HashFunc<K>>
	class HashTable
	{
		typedef HashNode<K, V> Node;
	public:
		~HashTable()
		{
			for (auto& cur : _tables)
			{
				while (cur)
				{
					Node* next = cur->_next;
					delete cur;
					cur = next;
				}

				cur = nullptr;
			}
		}

		Node* Find(const K& key)
		{
			if (_tables.size() == 0)
				return nullptr;

			Hash hash;
			size_t hashi = hash(key) % _tables.size();
			Node* cur = _tables[hashi];
			while (cur)
			{
				if (cur->_kv.first == key)
				{
					return cur;
				}

				cur = cur->_next;
			}

			return nullptr;
		}

		bool Erase(const K& key)
		{
			Hash hash;
			size_t hashi = hash(key) % _tables.size();
			Node* prev = nullptr;
			Node* cur = _tables[hashi];
			while (cur)
			{
				if (cur->_kv.first == key)
				{
					if (prev == nullptr)
					{
						_tables[hashi] = cur->_next;
					}
					else
					{
						prev->_next = cur->_next;
					}
					delete cur;

					return true;
				}
				else
				{
					prev = cur;
					cur = cur->_next;
				}
			}

			return false;
		}
		size_t GetNextPrime(size_t prime)
		{
			// SGI
			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
			};

			size_t i = 0;
			for (; i < __stl_num_primes; ++i)
			{
				if (__stl_prime_list[i] > prime)
					return __stl_prime_list[i];
			}

			return __stl_prime_list[i];
		}

		bool Insert(const pair<K, V>& kv)
		{
			if (Find(kv.first))
			{
				return false;
			}

			Hash hash;

			// 负载因因子==1时扩容
			if (_n == _tables.size())
			{
				size_t newsize = GetNextPrime(_tables.size());
				vector<Node*> newtables(newsize, nullptr);
				for (auto& cur : _tables)
				{
					while (cur)
					{
						Node* next = cur->_next;

						size_t hashi = hash(cur->_kv.first) % newtables.size();

						// 头插到新表
						cur->_next = newtables[hashi];
						newtables[hashi] = cur;

						cur = next;
					}
				}

				_tables.swap(newtables);
			}

			size_t hashi = hash(kv.first) % _tables.size();
			// 头插
			Node* newnode = new Node(kv);
			newnode->_next = _tables[hashi];
			_tables[hashi] = newnode;

			++_n;
			return true;
		}

		size_t MaxBucketSize()
		{
			size_t max = 0;
			for (size_t i = 0; i < _tables.size(); ++i)
			{
				auto cur = _tables[i];
				size_t size = 0;
				while (cur)
				{
					++size;
					cur = cur->_next;
				}

				if (size > max)
				{
					max = size;
				}
			}

			return max;
		}
	private:
		vector<Node*> _tables; // 指针数组
		size_t _n = 0; // 存储有效数据个数
	};

选择合适的哈希冲突解决方法

开放寻址法
优点:
对于基于开放寻址法解决冲突的哈希表,数据存储在数组中,可以有效地利用CPU缓存加快查询速度。相对于基于链表法解决冲突的哈希表,基于开放寻址法解决冲突的哈希表不涉及链表和指针,方便序列化。
缺点:
基于开放寻址法解决冲突的哈希表,删除数据的操作会比较麻烦,需要特殊标记删除的数据。而且,在开放寻址法中,所有的数据都存储在一个数组中 比起链表法,发生冲突的概率更高。因此,基于开放寻址法解决冲突的哈希表,装载因子不能太大,必须小于1,而基于链表法解决冲突的哈希表,装载因子可以大于1。这就导致存储相同数量的数据,开放寻址法比链表法需要占用更多的存储空间。
综上所述,当数据最比较小。装载因子小的时候,适合采用开放寻址法。
链表法
基于链表法解决冲突的哈希表,数据存储在链表中。基于开放寻址法解决冲突的哈希表,数据存储在数组中。链表节点可以在用到时再创建,而数组必须事先创建好。因此,链表法对内存的利用率比开放寻址法要高。
链表法比起开放寻址法,对大装载因子的容忍度更高。开放寻址法只适用装载因子小于1的情况。在装载因子接近1时,就会有大量的哈希冲突,导致大量的探测、再哈希等,性能急剧下降。但对于链表法,只要哈希函数计算得到的值比较随机且均匀,即便装载因子变成10,也只是链表的长度变长了一点而已,性能下降并不多。不过,链表中的节点要存储next指针,因此,会消耗额外的内存空间,对于小对象的存储,有可能会让内存的消耗翻倍。而且,链表中的节点在内存中是零散分布的,不是连续的,对CPU缓存不友好,这也对哈希表的性能有一定的影响。
当然,如果我们存储的是大对象,也就是说,对象的大小远远大于一个指针的大小(4B或8B),那么链表中指针的内存消耗就可以忽略了。
实际上,我们可以将链表法中的链表改造为其他更高效的数据结构,如红黑树。这样,即便出现哈希冲突,在极端情况下,所有的数据都哈希到同一个“桶”内,最终哈希表也只是退化成一个红黑树,查询的效率也不会太差,时间复杂度是O(log N)。这样就能有效避免哈希碰撞。

哈希表的查询效率并不能笼统地认为是时间复杂度O(1),因为它与哈希函数、装载因子和哈希冲突等都有关系。如果哈希函数设计得不好,或者装载因子过高,都可能导致哈希冲突发生的概率升高,查询效率下降。
在极端情况下,有些恶意的攻击者,还有可能通过精心构造的数据,使得所有的数据经过哈希函数之后,全部哈希到同一个"槽”里。如果我们使用的是基于链表的冲突解决方法,那么这个时候,哈希表就会退化为链表,查询的时间复杂度就从O(1)急剧退化为O(n)。
如果哈希表中有10万个数据,那么退化后的哈希表的查询时间就变成了原来的10万倍。举例来说,如果之前执行100次查询只需要0.1s,那么现在就需要10000s。这样就有可能因为查询操作消耗大量CPU或线程资源,导致系统无法响应其他请求,从而让恶意攻击者达到“拒绝服务”攻击(DoS)的目的。这就是哈希表碰撞攻击的基本原理。

我们在设计哈希表时,哈希表应该具有以下特性:

  • 支持快速查询、插入和删除操作;
  • 内存占用合理,不能浪费过多的内存空间;
  • 性能稳定,在极端情况下,哈希表的性能也不会退化到无法接受的地步。

我们要设置一个合适的哈希函数,要设置合理的装载因子阈值,并且设计动态扩容策略,以及选择合适的哈希冲突解决方法。

猜你喜欢

转载自blog.csdn.net/m0_55752775/article/details/129434945
今日推荐