C++---哈希(Hash Table)

Posted Moua

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了C++---哈希(Hash Table)相关的知识,希望对你有一定的参考价值。

目录

一、unordered系列关联式容器

二、哈希

1、哈希概念

2、哈希冲突

3、哈希函数

4、哈希冲突的解决


一、unordered系列关联式容器

STL库中提供了使用红黑树封装的map和set的关联式容器,查询效率可以达到logN,为了提高查询效率在C++11中,STL又提供了四个unordered系列关联式容器:unordered_set、unordered_map、unordered_multiset、unordered_multimap,它们的底层采用的是一种新的、效率更高的容器Hash来实现的。

unordered_set、unordered_map的使用方法和map和set基本相同,因此这里不在介绍他们的使用。

二、哈希

unordered_set、unordered_map底层结构是哈希表,因此在模拟实现之前先要了解哈希的基本结构、原理及简单的实现。

1、哈希概念

前边学过的STL容器中,无论是序列式容器(vector、string、list等)还是关联式容器(map、set等)它们的元素的关键码和存储位置之间没有联系,因此在查找过程中需要对关键码进行比较,查找效率相对较低。哈希是指,通过某种函数(hashFunc)使元素的存储位置和关键码之间建立一一映射关系,在查找时可以通过该函数很快找到该元素。

向哈希结构中:

  • 插入元素:根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存
  • 删除元素:对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功

我们把这种方法称为哈希方法,用到的函数称为哈希函数,通过该方法构造出的结构称为哈希表

2、哈希冲突

对于两个数据元素的关键字ki,kj有ki != kj但是hash(ki) == hash(kj),即不同元素通过哈希函数映射在了同一个地址,这种现象称为哈希冲突。把这种具有相同哈希值的不同关键码称为同义码

3、哈希函数

引起哈希冲突的原因之一就是哈希函数设计不够合理,因此设计好的哈希函数是非常重要的。通常,哈希函数的设计有以下原则:

  • 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有 m 个地址时,其值域必须在 0到m-1 之间
  • 通过哈希函数计算出来的地址能够均匀分布在整个空间中。
  • 设计出来的哈希函数不能太复杂。
常见的哈希函数(前两个必须掌握,其他了解即可)
  • 直接定制法

去关键子的某个线性三列函数为散列地址:HashKey= A*Key + B

优点:简单、均匀 

缺点:需要事先关键字的分布情况

使用场景:适合查找关键字较少且连续的情况

  • 除留余数法:设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址。
  • 平方取中法:假设关键字为1234,对它平方就是1522756,抽取中间的3227作为哈希地址; 再比如关键字为4321,对它平方就是18671041,抽取中间的3671(710)作为哈希地址 平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况。
  • 折叠法
  • 随机数法
  • 数学分析法

注意:哈希函数设计的越巧妙产生哈希冲突的可能越小,但是无法避免哈希冲突。

4、哈希冲突的解决

1)闭散列

闭散列也叫开放地址法,当产生哈希冲突时如果哈希表没有满(满了还可以扩容),那么可以把要插入的下一个关键码插入到冲突位置的之后的下一个空位置。具体插入方法有线性探测和二次探测两种方法。

  • 线性探测:从插入位置开始向后探测,直到找到空位置。
  • 二次探测:线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为: = (H0+ i^2)% m,或者: = (H0+ i^2)% m。其中:i = 1,2,3…是通过散列函数Hash(x)对元素的关键码 key 进行计算得到的位置,m是表的大小。 例如:

2)线性探测的模拟实现

#include<iostream>
#include<vector>
using namespace std;

enum Stat
{
	EMPTY,
	EXIST,
	DELETE
};

template<class T>
struct HashNode
{
	T _val;
	Stat _st = EMPTY;//该位置的状态,默认为空
};


static 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 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;
	}
};

template<class T,class HashFunc = Hash<T>>
class HashTable
{
public:
	bool insert(const T& key)
	{
		/*1、判断是否需要扩容:
		 *   ·哈希表为空时需要扩容
		 *   ·哈希表为满时需要扩容
		 *   ·扩容时需要注意的是:如果哈希表为空,该扩容为多少呢?
		 *   ·本质上说,这个值由我们自己定,但是研究表明当哈希函数的模数为质数时,效率较高
		 *   ·因此,这里扩容我们使用STL库中的扩容方法:调用GetNextPrime按照模数为质数的情况进行扩容
		 *   ·还有一个问题:如果哈希表满了,如何扩容?
		 *   ·如果直接扩容,不对之前插入的数据处理,会导致之前的数据的查找效率会变得非常的低
		 *   ·因此,在扩容时还需要将之前插入的数据在新的哈希表中进行重新插入。
		*/
		if (_htb.size() == 0 || _size*10 /_htb.size() >= 7)
		{
			size_t newSize = GetNextPrime(_htb.size());
			//将旧的哈希表插入到新的哈希表中---构造一个哈希对象,调用该对象的额insert方法
			HashTable<T, HashFunc> ht;
			ht._htb.resize(newSize);//对象中的哈希表的大小为newsize
			for (auto& e : _htb)
			{
				//将旧哈希表中的数据插入到对象中的新的大小为newsize的哈希表中
				ht.insert(e._val);
			}
			//交换两个哈希表,出了作用域对象会自动调用析构函数释放原来的空间
			_htb.swap(ht._htb);
		}

		/*需要注意的是,如果该值已经存在则不需要再进行插入*/
		if (find(key) != nullptr)
			return false;

		/*2、使用hash函数计算出插入位置
		 *	·这里的哈希函数使用除留余数法
		 *  ·需要注意的是,使用除留余数法对于int类型来说可以直接取模
		 *  ·对于string等其他类型不能直接取模,因此需要自定义取模方法
		 *  ·这里,我们对string的处理方式为:所有字符的ASCII求和在取模
		 *  ·因此,类模板参数中应该还有一个用来控制取模方法的仿函数
		*/
		HashFunc hf;
		int index = hf(key) % _htb.size();

		/*3、解决哈希冲突
		 *  ·线性探测:查找空位置
		 *	·这里需要注意的是,如果我们直接在哈希表中存储T类型的关键码
		 *  ·当一个元素从哈希表中删除后,它的位置实际上会变成随机值
		 *  ·那么在插入时,我们不知道这个位置是删除后的随机值还是没有插入的空值
		 *  ·或者说,这个位置就是我们存储的关键码。
		 *  ·因此,我们需要标记每一个位置,到底是empty还是delete或者是exist
		 *  ·因此,我们在哈希表中存储的是一个节点,这个节点中一个是值一个是状态,状态使用了一个枚举类型来表示
		 */
		if (_htb[index]._st == EXIST)
		{
			//存在哈希冲突,解决哈希冲突
			while (_htb[index]._st == EXIST)
			{
				index++;
				//为了防止数组越界,需要取模
				index %= _htb.size();
			}
		}
		/*4、插入元素*/
		_htb[index]._val = key;
		_htb[index]._st = EXIST;

		return true;
	}
	//查找
	HashNode<T>* find(const T& key)
	{
		//取模找到位置
		HashFunc hf;
		int index = hf(key) % _htb.size();
		//位置冲突,线性查找,找到第一个空位置停止查找
		if (_htb[index]._val != key)
		{
			while (_htb[index]._st != EMPTY && _htb[index]._val != key)
			{
				++index;
				index %= _htb.size();
			}

		}
		if (_htb[index]._st == EMPTY)
			return nullptr;
		return &_htb[index];
	}
	bool erase(const T& key)
	{
		HashNode<T>* ret = find(key);
		if (ret == false)
		{
			return false;
		}
		else
		{
			ret->_state = DELETE;
			return true;
		}
	}
private:
	vector<HashNode<T>> _htb;//哈希表
	size_t _size;//实际存储元素的个数
};

void TestHashTable()
{
	HashTable<int> ht;
	ht.insert(5);
	ht.insert(15);
	ht.insert(16);
	ht.insert(17);
	ht.insert(25);
	ht.insert(35);
	ht.insert(45);
	ht.insert(55);

	struct StrHash
	{
		size_t operator()(const string& s)
		{
			size_t hash = 0;
			for (auto ch : s)
			{
				hash += ch;
			}

			return hash;
		}
	};

	//HashTable<string, string, StrHash> strht;
	HashTable<string> strht;
	strht.insert("sort");
	strht.insert("insert");

}

注意:

  • hash在进行扩容什么时候开始扩容呢?当载荷因子为0.7~0.8时进行扩容。
  • 载荷因子 = 填入表中的元素个数/表的长度
  • 因为,通过大量实验证明,当载荷因子为0.7~0.8时,线性探测的冲突最小。

3)开散列

开散列法又叫链地址法 ( 开链法 ) ,首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码 归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结 点存储在哈希表中

 

以上是关于C++---哈希(Hash Table)的主要内容,如果未能解决你的问题,请参考以下文章

[译]C语言实现一个简易的Hash table

建立简单的Hash table(哈希表)by C language

C函数学习:GLib HashTable函数

C函数学习:GLib HashTable函数

C函数学习:GLib HashTable函数

C函数学习:GLib HashTable函数