C++进阶-哈希表

1.undered系列的关联式容器

在C++98中,STL提供了底层为红黑树结构的一系列关联式容器,在查询时效率可达到 l o g 2 N log_2 N log2N,即最差情况下需要比较红黑树的高度次,当树中的节点非常多时,查询效率也不理想。最好的查询是,进行很少的比较次数就能够将元素找到,因此在C++11中,STL又提供了4个unordered系列的关联式容器,这四个容器与红黑树结构的关联式容器使用方式基本类似,只是其底层结构不同,只对unordered_map和unordered_set进行介绍。

1.1 unordered_map

  1. unordered_map是存储<key, value>键值对的关联式容器,其允许通过keys快速的索引到与其对应的value。
  2. 在unordered_map中,键值通常用于惟一地标识元素,而映射值是一个对象,其内容与此键关联。键和映射值的类型可能不同。
  3. 在内部,unordered_map没有对<kye, value>按照任何特定的顺序排序, 为了能在常数范围内找到key所对应的value,unordered_map将相同哈希值的键值对放在相同的桶中
  4. unordered_map容器通过key访问单个元素要比map快,但它通常在遍历元素子集的范围迭代方面效率较低
  5. unordered_maps实现了直接访问操作符(operator[]),它允许使用key作为参数直接访问value。
  6. 它的迭代器至少是前向迭代器

1.2 unordered_set

这里不多做解释。
请参见http://www.cplusplus.com/reference/unordered_set/unordered_set/?kw=unordered_set

1.3 unordered系列容器和普通容器的区别

  1. unordered系列的map与set两者与map和set的相似度90%
  2. map和set是双向迭代器 O(logN)
    unordered_set:运用单向迭代器 O(1)
  3. 重复数据多时,unordered全方面碾压map和set
  4. unordered系列容器,存储数据是无序的
    map和set容器,存储数据是有序的

2.底层结构

unordered系列的关联式容器之所以效率比较高,是因为其底层使用了哈希结构。

2.1 哈希概念

顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即O( l o g 2 N log_2 N log2N),搜索的效率取决于搜索过程中元素的比较次数。

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

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

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

什么是哈希/散列?
映射:值和值进行1对1或者1对多的关联
值和位置直接或者间接映射
1.值很分散
2.有些值不好映射,比如:string,结构体对象

2.2 哈希冲突

对于两个数据元素的关键字 k i k_i ki k j k_j kj(i != j),有 k i k_i ki != k j k_j kj,但有:Hash( k i k_i ki) ==Hash( k j k_j kj),即:不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。
在这里插入图片描述
除留取余法之后,会有一些值会放在同一个位置,这个就叫做哈希冲突。
在这里插入图片描述

2.3 哈希函数

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

哈希函数设计原则:

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

常见哈希函数

  1. 直接定址法–(常用)
    取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B
    优点:简单、均匀
    缺点:需要事先知道关键字的分布情况
    使用场景:适合查找比较小且连续的情况
    面试题:字符串中第一个只出现一次字符
  2. 除留余数法–(常用)
    设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,
    按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址
  3. 平方取中法–(了解)
    假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址;
    再比如关键字为4321,对它平方就是18671041,抽取中间的3位671(或710)作为哈希地址
    平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况
  4. 折叠法–(了解)
    折叠法是将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这
    几部分叠加求和,并按散列表表长,取后几位作为散列地址。
    折叠法适合事先不需要知道关键字的分布,适合关键字位数比较多的情况
  5. 随机数法–(了解)
    选择一个随机函数,取关键字的随机函数值为它的哈希地址,即H(key) = random(key),其中
    random为随机数函数。
    通常应用于关键字长度不等时采用此法
  6. 数学分析法–(了解)
    设有n个d位数,每一位可能有r种不同的符号,这r种不同的符号在各位上出现的频率不一定
    相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只
    有某几种符号经常出现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散
    列地址。

2.4 哈希冲突解决(闭散列和开散列)

2.4.1 线性探测的概念(闭散列)

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

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

那如何寻找下一个空位置呢?

  1. 线性探测
    比如2.1中的场景,现在需要插入元素44,先通过哈希函数计算哈希地址,hashAddr为4,因此44理论上应该插在该位置,但是该位置已经放了值为4的元素,即发生哈希冲突。
    线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
  • 插入
    a. 通过哈希函数获取待插入元素在哈希表中的位置
    b. 如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素
    在这里插入图片描述
  • 删除
    采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。比如删除元素4,如果直接删除掉,44查找起来可能会受影响。因此线性探测采用标记的伪删除法来删除一个元素

2.4.2 开放地址法(线性探测)的模拟实现

哈希表的模拟实现,用一个vector数组来存放哈希数据,每个数据要有相应的状态。
在这里插入图片描述

1.节点数据的状态条件

// 哈希表中的三种状态
enum State
{
	EMPTY,// 空
	EXIST,// 值在的话,就是存在
	DELETE // 删除
};

2.哈希数据

// 哈希的数据
template<class K, class V>
struct HashData
{
	pair<K, V> _kv; // 存放的数据
	State _state = EMPTY; // 数据虽在的状态,初始状态置为空
};

3.哈希表

// 哈希表
//       key	value	 仿函数
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:
	// 构造函数
	HashTable()
	{
		_tables.resize(10);// 防止负载因子除数为零的情况,提前初始化空间
	}

//...
private:

	vector<HashData<K, V>> _tables;// 哈希表
	size_t _n;// 由于哈希表不是每个索引都有数据,所以要记录有效数据的个数
};

哈希表中的一些类型结构

  1. 插入
    a.插入数据不能冗余
    b.应用载荷因子, 载荷因子/负载因子 = 填入表中的元素个数/表的总长度,负载因子达到0.7时,进行扩容,将旧表数据插入到新表当中
    在这里插入图片描述

c.出留取余法时,取余的key值可以是很多类型,所以要应用,仿函数来返回可以取余的类型,进行强制转换成int型,用ASCII码值进行区分。对于string类型,常见类型进行单独处理,进行特化。

// 普遍的仿函数
// 用于可以直接转换成整形的类型
// double,int,float,char
template<class K>
struct HashFunc
{
	// 比较他们的ASCII码值
	size_t operator()(const K& key)
	{
		return (size_t)key;// 也不怕复数,都会为正数
	}
};

// 如果是string类型的数据比较
// string数据比较的仿函数
// 特化
// 以适应编译器对上面的仿函数遇到特殊情况的处理
template<>
struct HashFunc<string>
{
	// 1.通常是将string的首字符的ASCII码值进行比较,但是可能会出现收字符是一样的情况
	// 2.将string的字符的ASCII码值全部加在一起,但是下面的情况,字符不一样,但是加起来的ASCII码值是一样的
	// abcd
	// bcad
	// aadd
	// 如果是上述的情况,则会无法判断
	size_t operator()(const string& key)
	{
		size_t hash = 0;// 存放字符ASCII的总值
		for (auto ch : key)
		{
			// 3.(BKDR)每次字符乘以131,则可以保证ASCII的码值会不一样
			hash *= 131;
			hash += ch;
		}

		return hash;
	}
};

string类型,转化为ASCII码可能会有重复,所以我们将string的所有字符的ASCII码值*131,之后再加起来,则不会有相等的情况,即为BKDR方法。

// 插入
bool Insert(const pair<K, V>& kv)
{
	// 判断不能冗余
	if (Find(kv.first))
	{
		return false;
	}

	// 扩容
	// 载荷因子/负载因子 = 填入表中的元素个数/表的总长度
	// 负载因子越高,冲突率越高,效率就越低
	// 负载因子越小,冲突率越低,效率就越高,空间利用率越低
	if (_n / _tables.size() >= 0.7)
	{
		// 第二种情况,用在开辟一个哈希表的空间,然后插入旧表数据到新表
		HashTable<K, V, Hash> newHT;
		newHT._tables.resize(_tables.size() * 2);

		// 将旧表的数据重新计算负载到新表
		for (size_t i = 0; i < _tables.size(); i++)
		{
			if (_tables[i]._state == EXIST)
			{
				newHT.Insert(_tables[i]._kv);
			}
		}

	}

	Hash hs;// 仿函数对象,用于不同类型对象的通用比较
	size_t hashi = hs(kv.first) % _tables.size();// 除留取余法,寻找相应的位置

	// 如果除留取余法判断的hashi位置有数据,则要进行线性探测找到可以放置数据的位置
	// 线性探测:是一个循环探测,探测完后,在绕到开头,继续探测
	// EXIST位置不能插入,其他的EMPTY和DELETE都可以插入数据
	while (_tables[hashi]._state == EXIST)
	{
		++hashi;
		hashi %= _tables.size();// 防止探测到最后,循环探测
	}

	// 找到位置之后
	_tables[hashi]._kv = kv;
	_tables[hashi]._state = EXIST;
	++_n;


	return true;
}
  1. 查找
    a.用除留取余法找到相应的位置,如果当时的位置的数据不匹配,则时进行线性探测,向后查找,查找空就停。
    b.查找时,也会有查找到删除了的值只是将状态置为DELETE,但是本身实际的数据没有删除可以找到,所以查找时,要不为空的状态查找。
// 查找
// 查找时,也会有查找到删除了的值可以找到,由于删除的时候只是修改状态
// 并没有真的删除那个值,所以需要加上条件
HashData<K, V>* Find(const K& key)
{

	Hash hs;
	size_t hashi = hs(key) % _tables.size();// 除留取余法,寻找相应的位置

	// 如果除留取余法判断的hashi位置有数据,则要进行线性探测找到可以放置数据的位置
	// 线性探测,从除留取余法的hashi位置,向后查找
	// 为空则就停止
	while (_tables[hashi]._state != EMPTY)
	{
		// 数据存在并且找到的key是相等的
		// 防止是之前删除的值被找到,有可能状态是DELETE的数据也是一样的
		if (_tables[hashi]._state == EXIST &&
			_tables[hashi]._kv.first == key)
		{
			return &_tables[hashi];
		}


		++hashi;
		hashi %= _tables.size();// 循环查找
	}


	return nullptr;// 没找到返回空

}
  1. 删除
    用Find先查找,查找到,如果为空,就不存在,不为空则将状态置为DELETE

		// 删除
		// 找到位置,直接将状态置为DELETE
		bool Erase(const K& key)
		{
			// 先查找,查找到,如果为空,就不存在,不为空则将状态置为DELETE
			HashData<K, V>* ret = Find(key);

			if (ret == nullptr)
			{
				return false;
			}
			else
			{
				ret->_state = DELETE;
				--_n;

				return true;
			}
		}

线性探测优点:实现非常简单,
线性探测缺点:**一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”,即:不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低。**如何缓解呢?

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

2.4.3 哈希桶的概念(开散列)

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

2.4.4 哈希桶的模拟实现

哈希同是由一个存放节点指针的vector数组,将节点都挂在这个数组的节点上。

  1. 节点数据
// 节点数据
template<class K,class V>
struct HashNode
{
	pair<K, V> _kv;// 节点数据
	HashNode<K, V>* _next;// 下一个节点地址

	// 构造函数
	HashNode(const pair<K,V>& kv)
		:_kv(kv),
		_next(nullptr)
	{}
};
  1. 实现哈希表
    析构函数应该自己写,vector数组会调用自己的析构函数,但是我们开辟的节点空间需要自己单独释放
// 哈希表
//			Key	   Value       哈希强制类型转换成相应int类型,ASCII码值
template< class K,class V,class Hash= HashFunc<K>>
class HashTable
{
public:
	// 重定义节点
	typedef HashNode<K, V> Node;

	// 构造函数
	HashTable()
	{
		_tables.resize(10,nullptr);// 提前开辟好空间
		_n = 0;
	}

	// 析构函数
	~HashTable()
	{
		// vector的数组有自己的析构函数
		// 我们只有写相应的Node*节点的销毁
		// 遍历整个vector,全部删除节点
		for (size_t i = 0; i < _tables.size(); i++)
		{
			Node* cur = _tables[i];

			while (cur)
			{
				Node* next = cur->_next;

				delete cur;

				cur = next;
			}

			_tables[i] = nullptr;
		}
	}

// ...
private:
	vector<Node*> _tables;
	size_t _n;

	//vector<list<pair<K, V>>> _tables; 不用这种,这种不好套迭代器
};
  1. 插入
    a.扩容,负载因子为1,理想状态下,平均每个桶下面挂一个数据,将旧表上的节点数据,挂在新表的相应位置。
    在这里插入图片描述
    b.头头插节点
    在这里插入图片描述
    除留取余法,由于不知道key的类型,应该用仿函数进行判断
	// 普遍的仿函数
// 用于可以直接转换成整形的类型
// double,int,float,char
	template<class K>
	struct HashFunc
	{
		// 比较他们的ASCII码值
		size_t operator()(const K& key)
		{
			return (size_t)key;// 也不怕复数,都会为正数
		}
	};

	// 如果是string类型的数据比较
	// string数据比较的仿函数
	// 特化
	// 以适应编译器对上面的仿函数遇到特殊情况的处理
	template<>
	struct HashFunc<string>
	{
		// 1.通常是将string的首字符的ASCII码值进行比较,但是可能会出现收字符是一样的情况
		// 2.将string的字符的ASCII码值全部加在一起,但是下面的情况,字符不一样,但是加起来的ASCII码值是一样的
		// abcd
		// bcad
		// aadd
		// 如果是上述的情况,则会无法判断

		size_t operator()(const string& key)
		{
			size_t hash = 0;// 存放字符ASCII的总值
			for (auto ch : key)
			{
				// 3.(BKDR)每次字符乘以131,则可以保证ASCII的码值会不一样
				hash *= 131;
				hash += ch;
			}

			return hash;
		}
	};

插入

// 插入
bool Insert(const pair<K,V>& kv)
{
	// 扩容
	// 负载因子为1,理想状态下,平均每个桶下面挂一个数据
	// 第一种扩容,会不断开辟新空间,会造成浪费
	//if (_n == _tables.size())
	//{
	//	// 建立一个新表
	//	HashTable<K, V> newHT;
	//	newHT._tables.resize(_tables.size() * 2);// 新表是旧表空间的两倍

	//	// 将旧表的数据加入到新表中
	//	for (size_t i = 0; i < _tables.size(); i++)
	//	{
	//		// 取旧表中的每一个指针
	//		Node* cur = _tables[i];

	//		while (cur)
	//		{
	//			// 直接插入到新表
	//			newHT.Insert(cur->_kv);
	//			cur = cur->_next;
	//		}
	//	}

	//	// 将新表和旧表在此交换
	//	// 则就变成_tables指向这份新表的空间了
	//	_tables.swap(newHT._tables);
	//}

	Hash hs;
	// 第二种扩容,建立一个Node*数据的数组
	if (_n == _tables.size())
	{
		vector<Node*> newTables(_tables.size() * 2, nullptr);

		for (size_t i = 0; i < _tables.size(); i++)
		{
			// 取旧表中的每一个头节点指针
			Node* cur = _tables[i];

			while (cur)
			{
				// 记录旧表的cur的下一节点位置,cur被移动到新表中时,将下面的节点与数组的指针相连
				Node* next = cur->_next;

				// 头插到新表的位置
				// 先判断旧表数据在新表的哪个位置
				size_t hashi = hs(cur->_kv.first) % newTables.size();

				// 旧表数据插入到新表中
				cur->_next = newTables[hashi];
				newTables[hashi] = cur;

				cur = next;// 之后将旧表的节点数据向前移动一个
			}

			_tables[i] = nullptr;// 旧表当前节点指针,全部插入到新表完后,将旧表的指针指向空
		}

		// 旧表数据全部插入到新表中后
		// 交换两表的数据
		_tables.swap(newTables);

	}
	// 查找相应的数据存放在数组的相对位置
	size_t hashi = hs(kv.first) % _tables.size();
	Node* newnode = new Node(kv);

	// 头插新节点
	// _tables[hashi]只是一个指针,并不是哨兵位
	newnode->_next = _tables[hashi];
	_tables[hashi] = newnode;

	return true;
}
  1. 查找
    a.除留取余法,先找到key所在数组的索引位置
    b.进行单链表节点的数据查找
    在这里插入图片描述
// 查找
Node* Find(const K& key)
{
	// 运用仿函数对象,才能进行哈希的比较
	Hash hs;

	// 查找相应的数据存放在数组的相对位置
	size_t hashi = hs(key) % _tables.size();

	// 数组相应位置的节点数据
	Node* cur = _tables[hashi];

	// 链表查找
	while (cur)
	{
		// 相等,找到了
		if (cur->_kv.first == key)
		{
			return cur;
		}
		else// 不相等,则就继续向下查找
		{
			cur = cur->_next;
		}
	}

	// 找完了,没找到
	return nullptr;

}
  1. 删除
    a.除留取余法,先找到key所在数组的索引位置
    b.进行单链表节点的数据查找到所匹配的节点数据
    c.节点删除分为删除头节点和删除非头节点
    在这里插入图片描述
// 删除
// 找到相应的key
bool Erase(const K& key)
{
	Hash hs;

	// 先找到key在数组中的相应位置
	size_t hashi = hs(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;
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值