【C++】 --- 哈希

Welcome to 9ilk's Code World

(๑•́ ₃ •̀๑) 个人主页: 9ilk

(๑•́ ₃ •̀๑) 文章专栏: C++


本篇博客主要是对哈希相关知识的梳理总结。

unordered系列关联式容器

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

unordred_map和map以及unordered_set和set的核心功能是大差不差的,它们主要的区别如下:

  1. unordred系列采用的底层实现是哈希表,map/set采用的底层实现是红黑树

  2. 对key的要求不同:set/map要求key支持比较大小,而unordered系列要求支持key能转成整形&&能比较相等

  3. set/map遍历是有序的,unordered系列遍历是无序的

  4. set/map提供的迭代器是双向迭代器,而unordered系列是单向迭代器

  5. 增删查改性能差异:set/map时间复杂度是O(logN),而unordered系列是O(1)

注:unordered_map容器通过key访问单个元素要比map快,但它通常在在遍历元素子集的范围迭

代方面效率较低。

性能测试

cpp 复制代码
#include <iostream>
#include <set>
#include <unordered_set>
#include <time.h>
using namespace std;

int main()
{
	int N = 1000;
	vector<int> v;
	v.reserve(N);
	srand((unsigned int)time(NULL));
	//随机生成N个数字
	for (int i = 0; i < N; i++)
	{
		v.push_back(rand());
	}

	/****************插入效率测试****************/
	//将这N个数插入set容器
	set<int> s;
	clock_t begin1 = clock();
	for (auto e : v)
	{
		s.insert(e);
	}
	clock_t end1 = clock();

	//将这N个数插入unordered_set容器
	unordered_set<int> us;
	clock_t begin2 = clock();
	for (auto e : v)
	{
		us.insert(e);
	}
	clock_t end2 = clock();

	//分别输出插入set容器和unordered_set容器所用的时间
	cout << "set insert: " << end1 - begin1 << endl;
	cout << "unordered_set insert: " << end2 - begin2 << endl;

	/****************查找效率测试****************/
	//在set容器中查找这N个数
	clock_t begin3 = clock();
	for (auto e : v)
	{
		s.find(e);
	}
	clock_t end3 = clock();

	//在unordered_set容器中查找这N个数
	clock_t begin4 = clock();
	for (auto e : v)
	{
		us.find(e);
	}
	clock_t end4 = clock();

	//分别输出在set容器和unordered_set容器中查找这N个数所用的时间
	cout << "set find: " << end3 - begin3 << endl;
	cout << "unordered_set find: " << end4 - begin4 << endl;

	/****************删除效率测试****************/
	//将这N个数从set容器中删除
	clock_t begin5 = clock();
	for (auto e : v)
	{
		s.erase(e);
	}
	clock_t end5 = clock();

	//将这N个数从unordered_set容器中删除
	clock_t begin6 = clock();
	for (auto e : v)
	{
		us.erase(e);
	}
	clock_t end6 = clock();

	//分别输出将这N个数从set容器和unordered_set容器中删除所用的时间
	cout << "set erase: " << end5 - begin5 << endl;
	cout << "unordered_set erase: " << end6 - begin6 << endl;
	return 0;
}

对1000个数据进行测试,做增删查改操作

Debug环境下测试数据:set容器和unordered_set容器增删查改的效率差异并不大

Release环境下测试:set容器和unordered_set容器对1000个数做增删查改操作所用的时间更是被优化到了接近0毫秒。

对10000000个数据进行增删查改操作

Debug:当数据量增大,set容器和unordered_set容器增删查改的效率的差异就很明显了

Release:经过Release版本的优化之后,set容器与unordered_set容器相比还是占了下风

结论:

  • 当处理的数据量较小时,map/set容器与unordered_map/unordered_set容器增删查改的效率差异不大。
  • 当处理的数据量较大时,map/set容器与unordered_map/unordered_set容器增删查改的效率相比,unordered系列的效率更高。

因此总结一下,当处理数据量较小/要求存储的序列是有序,可以选择map/set容器;当处理数据量较大时,建议选用unordered系列容器。

哈希结构

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

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

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

当向该结构中:

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

这种利用函数将值和存储位置建立映射关系以便快速查找元素 的方式,我们就称之为哈希/散列 ,哈希是一种映射思想。哈希中使用到的转换函数称为哈希(散列)函数 ,这种函数能将key转换为整数 ,按照这种思想构造出来的数据结构 称之为哈希表散列表)。

哈希冲突

假设有数据集合{1,7,6,4,5,9} 哈希函数设置为 hash(key) = key % capacity ,capacity为存储元素底层空间总的大小:

上面的这几个元素都能很好的被映射对应的存储位置,用该方法去进行搜索不必进行多次关键码的比较,搜索的速度比较快。但是如果向集合中插入元素44,44%10=4,44应该被插入到4号位置,我们可以看到4号位置已经被元素4占用了,这种情况我们就称之为哈希冲突

哈希冲突(哈希碰撞)指的是不同关键字通过相同哈希函数计算出了相同的哈希地址,此时会影响到元素的映射方案以及搜索的效率。我们把具有不同关键码但是具有相同哈希地址的数据元素称为"同义词"。发生哈希冲突应该如何处理呢?

哈希函数

我们知道哈希冲突是因为两个不同关键码通过相同哈希函数映射到相同存储位置的现象,因此我们应该把焦点放在哈希函数上,应该尽可能让哈希函数设计得合理些

哈希函数设计原则:

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

常见哈希函数

  1. 直接定址法(常用)

取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B,用Key的值来映射一个绝对位置或相对位置。

面试题:387.字符串中的第一个唯一字符 存储位置 = 1*s[i] - 'a'

cpp 复制代码
class Solution {
public:
    int firstUniqChar(string s) 
    {
         int arr[26] = {0};
         for(size_t i  = 0 ; i < s.size() ; i++)
         {
            arr[s[i] - 'a']++; //相对映射
         }
           for(size_t i = 0 ; i < s.size() ; i++)
         {
            if(arr[s[i] - 'a'] == 1)
               return i;
         }
         return -1;

    }
};

优点:简单、快、均匀、没有冲突

缺点:需要事先知道关键字的分布情况,适合key的范围相对集中的情况,否则空间浪费;同时key必须是整数

  1. 除留余数法(常用)

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

  1. 平方取中法

假设关键字为1234,对它平方就是1522756,抽取中间的3位2227作为哈希地址。再比如关键字为4321,对它平方就是18671041,抽取中间的3位671(或710)作为哈希地址。

平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况

  1. 折叠法

折叠法是将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这

几部分叠加求和,并按散列表表长,取后几位作为散列地址。

折叠法适合:事先不需要知道关键字的分布,适合关键字位数比较多的情况

  1. 随机数法

选择一个随机函数,取关键字的随机函数值为它的哈希地址,即H(key)=random(key),其中

random为随机数函数。

适合:通常应用于关键字长度不等时采用此法

  1. 数学分析法

设有n个d位数,每一位可能有r种不同的符号,这r种不同的符号在各位上出现的频率不一定

相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只

有某几种符号经常出现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散

列地址。

注意哈希函数设计得再巧妙 ,它也只能是使key分布均匀,减少扎堆,从而减少哈希冲突 ,但是哈希冲突是无法避免的,因为实际存储位置是有限的,而key往往是无限的,由鸽巢原理得,必定有多个key落在同一个存储位置,因此冲突是无法避免的

哈希冲突处理方法

哈希冲突可以减少,但冲突是不可避免的,万一发生冲突,好的处理方法也很关键:解决哈希冲突两种常见的方法是闭散列和开散列

闭散列

闭散列也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有

空位置,那么可以把key存放到冲突位置中的"下一个"空位置中去。那如何寻找下一个空位置呢?

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

比如按下图的hash函数,插入16,它是映射到6号位置的,但是发生冲突了,于是往后找到第一个非空位置,往后找不一定是找到数组末尾就不能找了,可以将表看做环形。

查找:插入16之后,如果要查找16怎么找呢?首先通过hash函数计算出是在6号下标位置处,发现冲突了,于是往后继续找,找到自然是好的,什么时候找不到呢?我们的线性探测是从冲突位置开始往后找第一个空位置,因此查找时从映射位置按规则开始查找,直到遇到空,才能确定是找不到。

删除:假设现在要删除66,不能像图中一样随便删除哈希表中已有的元素,如果这样的话,未来我查找16的话,从6号下标开始找,发现7号下标是空的(之前删除66导致的),此时判定16不存在,这肯定是不符合预期的。

解决方案:采用标记的伪删除的方式来删除一个元素。当删除一个元素的时候,并不是真正删除,而是将这个元素标记为Delete状态。我们需要在哈希表内部增加状态字段:EMPTY(此位置为空)、EXIST(此位置已有元素)、DELETE(元素已经删除)

因此总结一下,对于线性探测的几个操作:

a. 查找:按照哈希函数确定映射位置,从映射位置开始往后找,如果位置状态为EXIST&&该位置元素的key符合我们要查找的key,则返回;如果位置状态为空,说明不存在。

b. 插入:在对应元素没有被重复插入的情况下,如果映射位置为空直接插入,否则往后找第一个状态为EMPTY或DELETE后的位置插入。

c. 删除:先查找到对应位置,可以复用查找接口,如果存在直接修改对应存储位置的状态为DELETE即可。

对于上面的插入操作是存在漏洞的,有没有可能找不到位置插入呢?此时就需要进行扩容,什么情况下进行扩容比较好,满了才扩容吗?扩容时怎么扩容呢?这里需要先引入负载因子的概念。

散列表的载荷因子定义为:α = 填入表中的元素个数/散列表的长度

α 是散列表装满程度的标志因子。由于表长是定值,α 与"填入表中的元素个数"成正比,所以α 越大,表明填入表中的元素越多,产生冲突的可能性就越大;反之α 越小,标明填入表中的元素越少,产生冲突的可能性就越小。实际上,散列表的平均查找长度是载荷因子α 的函数,只是不同处理冲突的方法有不同的函数。

对于开放定址法,载荷因子是特别重要因素,应严格限制在0.7-0.8以下 。超过0.8,查表时的CPU缓存不命中(cache missing)按照指数曲线上升。因此,一些采用开放定址法的hash库,如Java的系统库限制了荷载因子为0.75,超过此值将resize散列表。

因此我们还需额外统计哈希表中元素的个数,方便计算出负载因子,这里**我们规定当负载因子超过0.7时我们进行扩容。**那具体怎么扩容呢?首先需要确定的不是不能直接对原表进行扩容,因为这会导致表的长度变了,映射关系也就发生变化,我们需要重新把数据根据线性探测的规则给映射到正确的位置,这无疑的效率是低下的。

一种解决方法是创建一个新表,遍历旧表将所有数据搬运到新表,但是这种做法搬数据时,可能还会出现冲突,比如原本size为10,扩容后变为20,此时搬数据时4、24会冲突,我们需要再把线性探测的逻辑写一次,比较好的方案是定义一个新表,两倍扩容,对原表中的元素复用insert逻辑进行插入。

cpp 复制代码
namespace operaddress
{

	enum State
	{
		EMPTY,
		EXIST,
		DELETE
	};

	template<class K, class V>
	struct HashData
	{

		pair<K, V> _kv;
		enum State _state = EMPTY;
	};

	

	template<class K, class V, class Hash = Hashfunc<K>>
	class HashTable
	{
	public:
		HashTable()
		{
			_table.resize(10); //会对自定义类型调用默认构造
		}

		HashData<K, V>* Find(const K& key)
		{
			Hashfunc<K> hs;
			//先确定映射位置
			size_t hasi = hs(key) % _table.size();
			while (_table[hasi]._state != EMPTY)
			{
				if (_table[hasi]._kv.first == key && _table[hasi]._state != DELETE)
				{
					return &_table[hasi];
				}
				++hasi;
				hasi %= _table.size();
			}
			return nullptr;
		}

		bool insert(const pair<K, V>& kv)
		{
			if (Find(kv.first))
				return false;
			//判断扩容(防止没位置插入)
			if (_n * 10 / _table.size() >= 7)
			{
				//这里不能直接resize 直接resize映射关系就发生改变
				//解决办法是用一个新的重新映射 如果开一个新的table还得再写映射的逻辑 所以建议开一个新的对象复用insert

				HashTable<K, V> newHT;
				newHT._table.resize(2 * _table.size());
				for (size_t i = 0; i < _table.size(); i++)
				{
					if (_table[i]._state == EXIST)
					{
						newHT.insert(_table[i]._kv);
					}

				}
				_table.swap(newHT._table);

			}

			Hashfunc<K> hs;
			//先确定映射位置
			size_t hasi = hs(kv.first) % _table.size();//注意不能模capacity
			//HashData<K,V> * newdata = new HashData<K,V>(kv);
			while (_table[hasi]._state == EXIST)
			{
				++hasi;
				hasi %= _table.size();
			}
			_table[hasi]._state = EXIST;
			_table[hasi]._kv = kv;
			_n++;
			return true;
		}



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

		}


	private:
		vector<HashData<K, V>> _table;
		size_t _n = 0;//表示数据个数
	};

}

上面代码中我们对key是做了两层映射的,因为有些类型在确定映射位置时不能取模,比如string类型,这也是为什么之前说unordered系列要求key能转化成整形,也就是多了一层映射。

因此我们需要传入仿函数,对这样的类型做一层处理转化为整数,内部重载operator(),比如可以使用字符串中字符ascii码之和来映射,这里不能用string对象取地址转成整形,因为如果key相等但两个string对象地址不同,这样两个key会映射到不同位置:

cpp 复制代码
//将key转换成整数
template<class K>
struct Hashfunc
{
	size_t operator()(const K& key)
	{
		return (size_t)key;
	}

};

//处理string
struct Hashfunc
{
	size_t operator()(const string& s)
	{
		size_t hash = 0;
		for (auto e : s)
		{
			hash += e;
		}
		return hash;
	}

};

实际使用string作key非常常见,因此库里采用模板特化的方式对string做了特殊处理,当使用同样的HashFunc能特化匹配到string类型:

cpp 复制代码
template<>
struct Hashfunc<string>
{
	size_t operator()(const string& s)
	{
		size_t hash = 0;
		for (auto e : s)
		{
			hash *= 31;
			hash += e;
		}
		return hash;
	}

};

template<class K, class V, class Hash = Hashfunc<K>>
	class HashTable
	{
	public:
       //...
		HashData<K, V>* Find(const K& key)
		{
			Hashfunc<K> hs;
			//先确定映射位置
			size_t hasi = hs(key) % _table.size();
            //..
		}
  • 二次探测

线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系。因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为:

Hi=(H0+i^2)%m ( i = 1 , 2 , 3 , . . . )

H0:通过哈希函数对元素的关键码进行计算得到的位置。

Hi :冲突元素通过二次探测后得到的存放位置。

m :表的大小。

对于如果要插入44,产生冲突,使用解决后的情况为:Hi = (4+1^2)%10 有元素继续下次探测,Hi = (4+2^2)%10此时将44插入到8号下标。

采用二次探测为产生哈希冲突的数据寻找下一个位置,相比线性探测而言,采用二次探测的哈希表中元素的分布会相对稀疏一些,不容易导致数据堆积。

和线性探测一样,采用二次探测也需要关注哈希表的负载因子。研究表明,当表的长度为质数且表负载因子不超过0.5时新的表项一定能够插入 ,而且任何一个位置都不会被探查两次,因此只要表中有一半的空位置,就不会在表满的问题,在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容。

因此为了维持良好的查找性能,闭散列必须保持较低的负载因子,也就是哈希表不能太满,因此闭散列最大的缺陷是空间利用率比较低,这也是哈希的缺陷。

开散列

开放地址法(闭散列)对于冲突位置的解决方案是,往后找空的位置,而开散列对于冲突位置是就放在这个位置,我可以挂在这个位置下面。开散列又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合 ,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。

使用链地址法处理哈希冲突,需要增设链表指针,似乎增加了存储开销。但是事实上,由于开地址法必须增加大量的空闲空间来确保搜索效率,而表项所占空间又比指针大的多,因此使用链地址法反而比开放地址法节省存储空间

与闭散列不同的是,这种将相同哈希地址的元素通过单链表链接起来,然后将链表的头结点存储在哈希表中的方式,不会影响与自己哈希地址不同的元素的增删查改的效率,因此开散列的负载因子相比闭散列而言,可以稍微大一点:

  • 闭散列的开放定址法,负载因子不能超过1,一般建议控制在[0.0, 0.7]之间。
  • 开散列的哈希桶,负载因子可以超过1,一般建议控制在[0.0, 1.0]之间。

在实际中,开散列的哈希桶结构比闭散列更实用,主要原因有两点:

  1. 哈希桶的负载因子可以更大,空间利用率高。
  2. 哈希桶在极端情况下还有可用的解决方案。

哈希桶的极端情况就是,所有元素全部产生冲突,最终都放到了同一个哈希桶中,此时该哈希表增删查改的效率就退化成了O ( N ) ,因为它退化为一条长度为N的链表:

这时,我们可以考虑将这个桶中的元素,由单链表结构改为红黑树结构,并将红黑树的根结点存储在哈希表中:

在这种情况下,就算有十亿个元素全部冲突到一个哈希桶中,我们也只需要在这个哈希桶中查找30次左右,这就是所谓的"桶里种树"。

为了避免出现这种极端情况,当桶当中的元素个数超过一定长度,有些地方就会选择将该桶中的单链表结构换成红黑树结构 ,比如在JAVA中比较新一点的版本中,当桶当中的数据个数超过8时,就会将该桶当中的单链表结构换成红黑树结构,而当该桶当中的数据个数减少到8或8以下时,又会将该桶当中的红黑树结构换回单链表结构

有些地方也会选择不做此处理 ,因为随着哈希表中数据的增多,该哈希表的负载因子也会逐渐增大,最终会触发哈希表的增容条件,此时该哈希表当中的数据会全部重新插入到另一个空间更大的哈希表,此时同一个桶当中冲突的数据个数也会减少,因此不做处理问题也不大。


哈希表开散列实现

cpp 复制代码
template<class K,class V>
struct HashNode
{
	HashNode(const pair<K,V>& kv)
		:_kv(kv),
		_next(nullptr)
	{}
	
	pair<K, V> _kv;
	HashNode<K, V>* _next;
};

template<class K,class V,class Hash = Hashfunc<K>>
class Hashtable
{
public:

	typedef HashNode<K, V> Node;
	Hashtable()
	{
		_table.resize(10,nullptr);
	}
private:
	vector<Node*> _table;
	size_t _n = 0;
};

插入:根据hash函数找到映射位置,然后在该桶对该元素进行头插。但是注意开散列也要注意控制负载因子,因为开散列的链表一旦太长,查找就会退化成顺序扫描,但是相比闭散列,开散列的负载因子可以大一点。这里我们规定当负载因子为1.0时我们进行二倍扩容,此时我们需要注意,扩容时我们复用insert将旧表中的元素搬运到新表,内部会new很多节点,搬运结束之后又会delete很多节点,这无疑造成了结点的浪费,我们可以复用原表中的节点,只不过需要重新映射。

cpp 复制代码
	bool insert(const pair<K, V>& kv)
	{
		if (Find(kv.first))
			return false;
		Hashfunc<K> hs;
		//扩容
		if (_n / _table.size() == 1)
		{
			/*HashTable<K, V> newHT;
			newHT._table.resize(2 * _table.size());
			for (size_t i = 0; i < _table.size(); i++)
			{
				if (_table[i]._state == EXIST)
				{
					newHT.insert(_table[i]._kv);
				}
			}
			_table.swap(newHT._table);*/
			vector<Node*> newtable;
			newtable.resize(2*_table.size(),nullptr);
			for (int i = 0; i < _table.size(); i++)
			{
				if (_table[i])
				{
					Node* cur = _table[i];
					while (cur)
					{
						Node* next = cur->_next;
						// 旧表中节点,挪动新表重新映射的位置
						size_t hashi = hs(cur->_kv.first) % newtable.size();
						// 直接头插到新表
						cur->_next = newtable[hashi];
						newtable[hashi] = cur;
						
						cur = next;
					}
					_table[i] = nullptr;//注意原表置空
				}

			}
			//交换
			_table.swap(newtable);
		}
		size_t hashi = hs(kv.first) % _table.size();
		HashNode<K, V>* newnode = new HashNode<K,V>(kv);
		newnode->_next = _table[hashi];
		_table[hashi] = newnode;
		_n++;
		return true;
	}

析构:析构函数是需要我们自己写的,因为对于Node*,vector是不会自动释放链表资源的,它只会销毁"存放Node*指针"的那块数组内存。因此我们需要显示地把每个桶释放

cpp 复制代码
	~Hashtable()
	{
		// 依次把每个桶释放
		for (size_t i = 0; i < _table.size(); i++)
		{
			Node* cur = _table[i];
			while (cur)
			{
				Node* next = cur->_next;
				delete cur;
				cur = next;
			}
			_table[i] = nullptr;
		}
	}

查找:根据hash函数计算key值的映射位置,然后转化为单链表的查找操作

cpp 复制代码
    Node* Find(const K& key)
	{
		//确定映射位置
		Hashfunc<K> hs;
		size_t hashi = hs(key) % _table.size();
		Node* cur = _table[hashi];
		while (cur)
		{
			if (cur->_kv.first == key)
			{
				return cur;
			}
			cur = cur->_next;
		}
		return nullptr;

	}

删除:根据hash函数计算key值的映射位置,然后转化为删除链表的某个节点

cpp 复制代码
bool erase(const K& key)
	{
		Hashfunc<K> hs;
		size_t hashi = hs(key) % _table.size();
		Node* cur = _table[hashi];
		Node* prev = nullptr;
		while (cur)
		{
			if (cur->_kv.first == key)
			{
				if (cur->_next == nullptr)//cur是头节点
				{
					_table[hashi] = nullptr;
				}
				else
				{
					prev->_next = cur->_next;
				}
				delete cur;
				_n--;
				return true;
			}

			prev = cur;
			cur = cur->_next;
		}
		return false;

	}

unordered_map和unordered_set模拟实现

下面我们将对一个KV模型的哈希表进行封装,使用同一个哈希表模拟实现STL库中的unordered_map和unordered_set,由于开散列比闭散列更实用,因此我们使用开散列:

cpp 复制代码
template<class K,class V>
struct HashNode
{
	HashNode(const pair<K,V>& kv)
		:_kv(kv),
		_next(nullptr)
	{}
	
	pair<K, V> _kv;
	HashNode<K, V>* _next;
};

template<class K,class V,class Hash = Hashfunc<K>>
class Hashtable
{
public:

	typedef HashNode<K, V> Node;
	Hashtable()
	{
		_table.resize(10,nullptr);
	}

	~Hashtable()
	{
		// 依次把每个桶释放
		for (size_t i = 0; i < _table.size(); i++)
		{
			Node* cur = _table[i];
			while (cur)
			{
				Node* next = cur->_next;
				delete cur;
				cur = next;
			}
			_table[i] = nullptr;
		}
	}


	bool insert(const pair<K, V>& kv)
	{
		if (Find(kv.first))
			return false;
		Hashfunc<K> hs;
		//扩容
		if (_n / _table.size() == 1)
		{
			/*HashTable<K, V> newHT;
			newHT._table.resize(2 * _table.size());
			for (size_t i = 0; i < _table.size(); i++)
			{
				if (_table[i]._state == EXIST)
				{
					newHT.insert(_table[i]._kv);
				}
			}
			_table.swap(newHT._table);*/
			vector<Node*> newtable;
			newtable.resize(2*_table.size(),nullptr);
			for (int i = 0; i < _table.size(); i++)
			{
				if (_table[i])
				{
					Node* cur = _table[i];
					while (cur)
					{
						Node* next = cur->_next;
						// 旧表中节点,挪动新表重新映射的位置
						size_t hashi = hs(cur->_kv.first) % newtable.size();
						// 直接头插到新表
						cur->_next = newtable[hashi];
						newtable[hashi] = cur;
						
						cur = next;
					}
					_table[i] = nullptr;//注意原表置空
				}

			}
			//交换
			_table.swap(newtable);
		}
		size_t hashi = hs(kv.first) % _table.size();
		HashNode<K, V>* newnode = new HashNode<K,V>(kv);
		newnode->_next = _table[hashi];
		_table[hashi] = newnode;
		_n++;
		return true;
	}

	Node* Find(const K& key)
	{
		//确定映射位置
		Hashfunc<K> hs;
		size_t hashi = hs(key) % _table.size();
		Node* cur = _table[hashi];
		while (cur)
		{
			if (cur->_kv.first == key)
			{
				return cur;
			}
			cur = cur->_next;
		}
		return nullptr;

	}

	bool erase(const K& key)
	{
		Hashfunc<K> hs;
		size_t hashi = hs(key) % _table.size();
		Node* cur = _table[hashi];
		Node* prev = nullptr;
		while (cur)
		{
			if (cur->_kv.first == key)
			{
				if (cur->_next == nullptr)//cur是头节点
				{
					_table[hashi] = nullptr;
				}
				else
				{
					prev->_next = cur->_next;
				}
				delete cur;
				_n--;
				return true;
			}

			prev = cur;
			cur = cur->_next;
		}
		return false;

	}


private:
	vector<Node*> _table;
	size_t _n = 0;
};

哈希表模板参数控制

我们首先需明确,unordered_set是K模型的容器,而unordered_map是KV模型的容器。我们的需求是使用同一个哈希表封装出K模型(只存储键值)和KV模型(存储键值对)的容器,因此我们需要调整哈希表的模板参数,为了和原哈希表的模板参数进行区分,这里将哈希表的第二个模板参数名字改为T。

cpp 复制代码
template<class K,class T>
class HashTable

如果上层使用的是unordered_set容器,那么传入哈希表的模板参数就是key和key

cpp 复制代码
template<class K>
class unordered_set
{
private:
	HashTable<K, K> _ht;
};

如果上层使用的是unordered_map容器,那么传入哈希表的模板参数就是key和键值对pair<key,value>

cpp 复制代码
template<class K,class V>
class unordered_set
{
private:
	HashTable<K, pair<K,V>> _ht;
};

哈希表中的模板参数T的类型是什么,取决于上层所使用容器的种类。

KOFT

开散列每个链表节点结构定义如下:

cpp 复制代码
template<class T>
struct HashNode
{
	HashNode() = default;
	HashNode(const T& data)
		:_data(data),
		_next(nullptr)
	{}

	T _data;
	HashNode<T>* _next;
};

不管你模板参数T的类型是什么,未来你都是需要拿到key通过hash函数计算出对应的存储位置,但是我们知道不同种类的容器传给模板参数T的类型是不一样的,unordered_set传入的就是key,而unordered_map传入的是pair<key,value>,因此我们需要提供一种统一的方式来提取key,因此我们在哈希表增加一个模板参数KeyOFT:

cpp 复制代码
template<class K, class T, class KOFT, class Hash>
class HashTable;

//unordered_set
template<class K>
class unordered_set
{

public:
	struct setKOfV
	{
		const K& operator()(const K& data)
		{
			return data;
		}

	};
private:
	HashTable<K, K, setKOfV > _ht;
};

//unordered_map
template<class K, class V>
class unordered_map
{

public:
	struct mapKOfV
	{
		const K& operator()(const pair<K, V>& data)
		{
			return data.first;
		}
	};
private:
	HashTable<K, pair<const K,V>, mapKOfV> _ht;
};

当进行映射获取存储位置时就可以这样操作:

cpp 复制代码
KeyOFT kot;
Hash hs;
size_t hashi = hs(kot(_node->_data)) % size ;

迭代器封装

未来我们是需要通过迭代器访问容器获取元素的,因此迭代器内部肯定是需要一个节点指针,表示该迭代器指向的当前元素,同时我们的迭代器是单向迭代器,未来是可以进行++操作的,因此我们迭代器内部还需要一个装桶的表,否则如果当前迭代器指向节点是该桶单链表的最后一个节点,我们迭代器++就找不到下一个桶,也就找不到下一个结点,因此我们内部还需要封装一个哈希表对象。

cpp 复制代码
template<class K, class T, class KOFT, class Hash>
class HashTable;

template<class K, class T, class Ref,class Ptr,class KOFT, class Hash = HashFunc<K>>
struct HashIterator
{
public:
	typedef HashNode<T> Node;
	typedef HashIterator<K, T, Ref,Ptr,KOFT, Hash> Self;

	HashIterator(Node* node, const HashTable<K, T, KOFT, Hash>* ht)
		:_node(node),
		_ht(ht)
	{}

    Node* _node;
	HashTable<K, T, KOFT, Hash>* _ht;
};

迭代器内部需要访问哈希表,因此HashTable内部应该将迭代器声明为友元,否则无法进行访问:

cpp 复制代码
template<class K,class T,class KOFT,class Hash = HashFunc<K>>
class HashTable
{
	//声明为友元才能访问_table
public:
  template<class K, class T, class Ref, class Ptr, class KOFT, class Hash >
  friend struct HashIterator;
}

operator++:找到哈希表中的下一个节点

  • 当前节点不是桶中最后一个节点,则将指针指向下一个结点
  • 当前节点是桶中最后一个节点,此时需要找下一个不为空的桶,如果后面没有不为空的桶,则返回空;找到不为空的桶,则返回桶中第一个节点。
cpp 复制代码
Self& operator++()
	{
		KOFT kot;
		Hash hs;
		//先看当前桶还有下一个节点没
		if (_node->_next)
		{
			_node = _node->_next;
		}
		else
		{
			size_t hashi = hs(kot(_node->_data)) % _ht->_table.size();
			++hashi;//找到下一个不为空的桶
			while (hashi < _ht->_table.size())
			{
				if (_ht->_table[hashi])
					break;
				++hashi;
			}
			if (hashi == _ht->_table.size())//找不到置为空
				_node = nullptr;
			else
			{
				_node = _ht->_table[hashi];
			}
		}
		return *this;

	}

	Self& operator++(int)
	{
		KOFT kot;
		Hash hs;
		//先看当前桶还有下一个节点没
		if (_node->_next)
		{
			_node = _node->_next;
		}
		else
		{
			size_t hashi = hs(kot(_node->_data)) % _ht->_table.size();
			++hashi;//找到下一个不为空的桶
			while (hashi < _ht->_table.size())
			{
				if (_ht->_table[hashi])
					break;
				++hashi;
			}
			if (hashi == _ht->_table.size())//找不到置为空
				_node = nullptr;
			else
			{
				_node = _ht->_table[hashi];
			}
		}
		return *this;

	}

迭代器其他操作:

cpp 复制代码
	Ref operator*()
	{
		return _node->_data;
	}

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

	bool operator!=(const Self& s)
	{
		return _node != s._node;
	}

	bool operator ==(const Self& s)
	{
		return _node == s._node;
	}

哈希表封装迭代器完成增删改查

为了方便操作,我们可以将迭代器进行重命名:注意我们这里的是普通迭代器,传的不是const T

cpp 复制代码
template<class K,class T,class KOFT,class Hash = HashFunc<K>>
class HashTable
{
	//声明为友元才能访问_table
public:
  typedef HashNode<T> Node;
  typedef HashIterator<K, T, T&,T*,KOFT, Hash> Iterator;
}

End():

cpp 复制代码
  Iterator End()
  {
	  return Iterator(nullptr, this);
  }

Begin():返回的迭代器需要指向哈希表的第一个元素,因此遍历哈希表,找到第一个不为空的桶,构造迭代器指向该桶的第一个节点,如果哈希表没有节点则返回End()

cpp 复制代码
  Iterator Begin()
  {
	  if (_n == 0)
		 return End();
	  //找到第一个不为空的
	  size_t hashi = 0;
	  while (hashi < _table.size())
	  {
		  if (_table[hashi]) //找到就构造
		  {
			  return Iterator(_table[hashi],this);//this就是哈希表对象指针
		  }
		  hashi++;
	  }
	  return End();
  }

查找:根据映射规则确定桶的位置,然后在单链表找对应的节点,找到则构造迭代器对象返回

cpp 复制代码
 Iterator Find(const K& key)
  {
	  KOFT kot;
	  Hash hs;
	  size_t hashi = hs(key) % _table.size();
	  Node* cur = _table[hashi];
	  while (cur)
	  {
		if (kot(cur->_data) == key)
			return Iterator(cur,this);
		cur = cur->_next;
	  }

	  return End();
  }

插入:如果的时候先判断是否已经存在对应key,存在则直接返回;注意,insert的返回值应该设计为pair<iterator,bool>,这样既能明确知道是否插入成功,同时可以直接访问元素。

cpp 复制代码
  pair<Iterator,bool> insert(const T& data)
  {
	  KOFT kot;
	  Hash hs;
	  Iterator ret = Find(kot(data));
	  if (ret != End())
		return make_pair(ret,false);
	  //扩容
	  if (_n / _table.size() == 1)
	  {
		  vector<Node*> newHT;
		  newHT.resize(2 * _table.size());
		  for (int i = 0; i < _table.size(); i++)
		  {
			  if (_table[i])//原表该桶有节点
			  {
				  Node* cur = _table[i];
				  while (cur)
				  {
					  Node* next = cur->_next;
					  //头插到新表但要确定新的映射位置
					  size_t hashi = hs(kot(cur->_data)) % newHT.size();
					  cur->_next = newHT[hashi];
					  newHT[hashi] = cur;

					  cur = next;
				  }
				  _table[i] = nullptr;
			  }

		  }
		  _table.swap(newHT);
	  }

	  //先确定映射位置
	  size_t hashi = hs(kot(data)) % _table.size();
	  Node* newnode = new Node(data);  
	  newnode->_next = _table[hashi];
	  _table[hashi] = newnode;
	  _n++;
	  return make_pair(Iterator(newnode,this),true);
  }

删除:

cpp 复制代码
 bool Erase(const K& key)
  {
	  KOFT kot;
	  Hash hs;
	  size_t hashi = hs(key) % _table.size();
	  Node* cur = _table[hashi];
	  if (cur == nullptr)
		  return false;
	  else
	  {
		  Node* prev = nullptr;
		  while (cur)
		  {
			  if (kot(cur->_data) == key)
			  {
				  if (_table[hashi] == cur)
					  _table[hashi] = nullptr;
				  else
				  {
					  prev->_next = cur->_next;
				  }
				  delete cur;
				  _n--;
				  return true;
			  }
			  prev = cur;
			  cur = cur->_next;
		  }
		  return false;
	  }
  }

const迭代器

const迭代器和普通迭代器的区别是,迭代器指向的内容不能改变,因此在使用迭代器返回对象时,我们返回的应该是一个const对象,同时返回迭代器时应该是const成员函数,这样const对象才不会匹配到非const的成员函数,const对象只能使用只读迭代器。

cpp 复制代码
template<class K,class T,class KOFT,class Hash = HashFunc<K>>
class HashTable
{
	//声明为友元才能访问_table
public:
  typedef HashNode<T> Node;
  //... 
  typedef HashIterator<K, T, const T&,const  T*, KOFT, Hash> ConstIterator;

  template<class K, class T, class Ref, class Ptr, class KOFT, class Hash >
  friend struct HashIterator;


  ConstIterator Begin()const
  {
	  if (_n == 0)
		  return End();
	  //找到第一个不为空的
	  size_t hashi = 0;
	  while (hashi < _table.size())
	  {
		  if (_table[hashi]) //找到就构造
		  {
			  return ConstIterator(_table[hashi], this);//this就是哈希表对象指针
		  }
		  hashi++;
	  }
	  return End();
  }

  ConstIterator End()const
  {
	  return ConstIterator(nullptr, this);
  }



private:
	vector<Node*> _table;
	size_t _n = 0; 

};

注意:调用Begin构造Iterator对象,传入的this指针是const哈希表指针,由于我们在构造迭代器对象时参数是非const对象指针,此时传入const对象指针,会导致权限变大,这是不允许的,因此我们需要做出调整:

cpp 复制代码
//const HashTable<>* 
HashIterator(Node* node, const HashTable<K, T, KOFT, Hash>* ht)
		:_node(node),
		_ht(ht)
	{}

const HashTable<K, T, KOFT, Hash>* _ht;

修改key的问题

对于set来说,key是不能修改的,因为key修改了,hash值就会变,而节点仍留在原桶,后续进行增删查操作定位到的是错误桶;对于map来说,map的key是不能修改的,但是value可以修改。解决方案是修改模板参数T,对于set,迭代器模板参数T传的是const K,对于map,迭代器模板参数擦传入的是pair<const K,V>:

cpp 复制代码
template<class K, class V>
class unordered_map
{

public:
	struct mapKOfV
	{
		const K& operator()(const pair<K, V>& data)
		{
			return data.first;
		}
	};
	typedef typename HashTable<K, pair<const K, V>, mapKOfV>::Iterator iterator;
	typedef typename HashTable<K, pair<const K, V>, mapKOfV>::ConstIterator const_iterator;
//..
private:
	HashTable<K, pair<const K,V>, mapKOfV> _ht;
}


template<class K>
class unordered_set
{

public:
	struct setKOfV
	{
		const K& operator()(const K& data)
		{
			return data;
		}

	};

	typedef typename HashTable<K,const K, setKOfV>::Iterator iterator;
	typedef typename HashTable<K, const K, setKOfV>::ConstIterator const_iterator;
//...
private:
	HashTable<K, const K, setKOfV > _ht;
}

unordered_map和unordered_set完整封装

cpp 复制代码
template<class K, class V,class Hash = HashFunc<>>
class unordered_map
{

public:
	struct mapKOfV
	{
		const K& operator()(const pair<K, V>& data)
		{
			return data.first;
		}
	};
	typedef typename HashTable<K, pair<const K, V>, mapKOfV,Hash>::Iterator iterator;
	typedef typename HashTable<K, pair<const K, V>, mapKOfV,Hash>::ConstIterator const_iterator;

	unordered_map() = default;

	unordered_map(initializer_list<pair<K,V>> il)
	{
		for (const auto& e : il)
		{
			insert(e);
		}
	}

	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 pair<K, V>& kv)
	{
		return _ht.insert(kv);
	}

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

	bool erase(const K& key)
	{
		return _ht.Erase(key);
	}

	size_t size()const
	{
		return _ht.Size();
	}

	bool empty()const
	{
		return _ht.Empty();
	}

private:
	HashTable<K, pair<const K,V>, mapKOfV,Hash> _ht;
};
cpp 复制代码
template<class K,class Hash = HashFunc<>>
class unordered_set
{

public:
	struct setKOfV
	{
		const K& operator()(const K& data)
		{
			return data;
		}

	};

	typedef typename HashTable<K,const K, setKOfV,Hash>::Iterator iterator;
	typedef typename HashTable<K, const K, setKOfV,Hash>::ConstIterator const_iterator;

	unordered_set() = default;

	unordered_set(initializer_list<K> il)
	{
		for (const auto& e : il)
		{
			insert(e);
		}
	}

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

	bool erase(const K& key)
	{
		return _ht.Erase(key);
	}

	size_t size()const
	{
		return _ht.Size();
	}

	bool empty()const
	{
		return _ht.Empty();
	}
	

private:
	HashTable<K, const K, setKOfV,Hash> _ht;

};

对于有些类型是不能转换为整数的,所以我们提供了Hash模板参数,供使用者传入,之前我们是在HashTable设置Hash的缺省,实际上HashTable算是unordered系列的底层,我们应该在上层unordered系列设置缺省参数才对。

同时还要注意的是,我们使用的是开散列实现的哈希表,因此找到对应桶之后,我们需要根据key对比出我们要找的节点,但是有些key的类型是自定义类型,此时需要我们要么在自定义类型内部重载operator==,或者提供仿函数,STL库中的Pred模板参数就是用来比较key是否相等的。

哈希应用

位图

面试题:假设现在给40亿个不重复的无符号整数,这些数是无序的,现在给一个无符号整数,如何快速判断一个数是否在者40亿个数中?

  • 方案一:暴力遍历,时间复杂度O(N)
  • 方案二:排序+二分查找,时间复杂度O(N*logN)+ O(logN)

1G = 1024MB = 1024*1024KB = 1024*1024*1024 Byte约等于10亿多Byte,也就是说40亿个整数约等于是16G内存,说明40亿个数是无法直接放到内存中的,只能放到硬盘文件中。而二分查找只能对内存数组中的有序数据进行查找,因此方案2是不太可行的。

  • 方案三:数据是否在给定的整形数据中,结果是在或者不在,刚好是两种状态,那么可以使用一个二进制比特位来代表数据是否存在的信息,如果二进制比特位为1,代表存在,为0代表不存在。那么我们可以设计一个用位表示数据是否存在的数据结构 ,这个结构就叫位图

总结来说,位图就是用每一个位来存放某种状态,适用于海量数据、数据无重复的场景,通常是用来判断某个数据存不存在的。

位图设计和实现

我们可以使用一个整形数组来实现位图,我们知道一个整形的大小是4个字节,即32个bit位,位图本质是一个直接定址法的哈希表,每个整形值映射到一个bit位上,因此每个数组元素可以映射32个整数是否存在。那如何确定一个数在哪个bit位上或者说在整形数组第几个元素上呢?

假设现在要确定24是否存在,则24/32=0表示24对应的bit位在数组的一个整数v[0],24%32 = 24表示它在第一个int的第24位。

总结一下:

  1. m / 32表示m的bit位映射在数组的第几个整数

  2. m % 32 表示m在该整数的第几个bit位

那这个整形数组大小应该怎么确定呢?我们首先需要知道数据范围,根据数据范围确定要覆盖的bit位数是多少,再根据bit位数开数组大小。

cpp 复制代码
template<size_t N>
class bitset
{
public:
   bitset()
   {
      _bit.resize(N / 32 + 1);
   }

private:
   vector<int> _bt;
};

bitset核心接口:

  • set:将映射位置置为1,先将1左移(x%32)位,再与v[x / 32]进行按位或运算。
  • reset:将映射的位置变为0,先将1左移(x%32)位再取反,然后再与v[x / 32]进行按位与运算
  • test:判断数据是否存在/数据映射的bit位是0还是1,可以先将1左移(x%32)位,然后再与v[x / 32]进行按位与运算
cpp 复制代码
template<size_t N>
class bitset
{
public:
	bitset()
	{
		_bt.resize(N / 32 + 1);
	}


	//将映射位置映射为1
	//将1左移到第j位 进行或运算
	void bit_set(size_t x)
	{
		int i = x / 32; //映射的第i个整形
		int j = x % 32; //第i个整形的第j位
		_bt[i] |= (1 << j);
	}

	//将映射位置映射为0
	//将1移动到第j位 先取反 后让该位与0进行与运算
	void bit_reset(size_t x)
	{
		int i = x / 32;
		int j = x % 32;
		_bt[i] &= (~(1 << j));
	}

	//测试映射的位置是0还是1
	bool bit_test(size_t x)
	{
		int i = x / 32;
		int j = x % 32;
		return (_bt[i] & (1 << j));
	}


private:
	vector<int> _bt;
};

库里的bitset:

它的核心功能还是set、reset、test

库的位图结构set提供了两个版本,除了对指定数映射的比特位set,另一个版本是对所有bit位set,我们可以使用to_string验证,它能打印出位图结构:

cpp 复制代码
	std::bitset<20> bt;
	std::cout << bt.to_string() << endl;
	//
	bt.set();
	std::cout << bt.to_string() << endl;

还要注意的一点是:我们自己封装的位图可以开出像bitset<-1>的大空间(-1补码全f),因为我们是用vector封装的,vector对象里只有几个指针,指针指向堆区,而库里的是对栈上创建的静态数组进行封装,我们可以使用sizeof验证:

cpp 复制代码
	bit::bitset<10000> bt;
	std::cout << sizeof(bt) << endl;

	std::bitset<10000> bt1;
	std::cout << sizeof(bt1);

位图优缺点:

  • 优点:增删查改快,节省空间
  • 缺点:只适用于整形
位图应用

Q1:给定100亿个整数,设计算法找到只出现一次的整数

解决思路一:

之前我们实现的位图只适合数据不重复的情况,如果数据重复,我们可以标记整数为三种状态:出现0次、出现1次、出现2次及以上。一个位只能表示两种状态,而要表示三种状态,我们至少需要两个位,因此我们可以开辟两个位图,这两个位图的对应位置分别表示该位置整数的第一个位和第二个位。

我们可以按照三种状态分别定义00、01、10,当我们读取到重复的整数时,就可以让其对应的两个位按照00→01→10的顺序进行变化,最后状态为01的整数就是只出现一次的整数。

cpp 复制代码
template<size_t N>
class Twobitset
{
public:
	void set_bit(size_t x)
	{
		if (!_bt1.bit_test(x) && !_bt2.bit_test(x)) //00->01 0次->1次
		{
			_bt2.bit_set(x);
		}
		else if (!_bt1.bit_test(x) && _bt2.bit_test(x)) //01 -> 10 1次-》2次
		{
			_bt1.bit_set(x);
			_bt2.bit_reset(x);
		}
		else if (_bt1.bit_test(x) && !_bt2.bit_test(x)) //10 -> 11 3次及以上
		{
			_bt2.bit_set(x);
		}
	}

	int set_count(size_t x)
	{
		if (!_bt1.bit_test(x) && !_bt2.bit_test(x)) //00 0次
		{
			return 0;
		}
		else if (!_bt1.bit_test(x) && _bt2.bit_test(x)) //01 1次
		{
			return 1;
		}
		else if (_bt1.bit_test(x) && !_bt2.bit_test(x))
		{
			return 2;
		}
		else
			return 3;
	}
private:
	bitset<N> _bt1;
	bitset<N> _bt2;
};

需要注意的是:

  • 存储100亿个整数大概需要40G的内存空间,因此题目中的100亿个整数肯定是存储在文件当中的。
  • 为了能映射到所有整数,位图大小必须开辟为2的32次位,因此开辟一个位图大概需要521M的空间,两个位图就需要占用1G的内存空间,因此不能在栈区开辟Twobitset对象,否则会栈溢出,应该在堆区开辟

解决思路二:采用两个bit位来映射一个数(一个位图)

  • 映射到的数组下标:i = (x * 2) / 32; // 等价于 i = x / 16
  • 映射到的元素的起始bit位:j = (x * 2) % 32; // 等价于 j = (x % 16) * 2

Q2:给两个文件,分别有100亿个整数,我们只有1G内存,如何找到两个文件交集

思路一:采用一个位图512M内存

  • 依次读取第一个文件中的所有整数,将其映射到一个位图。
  • 再读取另一个文件中的所有整数,判断在不在位图中,在就放进交集,否则不是交集。

思路二:采用两个位图 1G内存

  • 将数据读取出来,分别放到两个位图,依次遍历,同时在两个位图的值就是交集

Q3:一个文件有100亿个整数,1G内存,设计算法找到出现次数不超过2次的所有整数

思路和题目一是一样的,一个整数要表示四种状态也是只需要两个位就够了,此时当我们读取到重复的整数时,就可以让其对应的两个位按照00→01→10→11的顺序进行变化,最后状态是01或10的整数就是出现次数不超过2次的整数。

布隆过滤器

我们在使用新闻客户端看新闻时,它会给我们不停地推荐新的内容,它每次推荐时要去重,去掉那些已经看过的内容。问题是,新闻客户端推荐系统如何实现推送去重的?用服务器记录了用户看过的所有历史记录,当推荐系统推荐新闻时会从没有用户的历史记录里进行筛选,过滤掉那些已经存在的记录,那如何快速查找呢?

  1. 用哈希表存储用户记录,缺点是浪费空间。

  2. 用位图存储用户记录,缺点是位图一般只能处理整形,如果内容编号是字符串,就无法处理。

  3. 将哈希和位图结合,即布隆过滤器。

布隆过滤器 是由布隆(Burton Howard Bloom)在1970年提出的一种紧凑型的、比较巧妙的概率型数据结构,特点是高效地插入和查询 ,可以用来告诉你"某样东西一定不存在或可能存在 ",它是用多个哈希函数,将一个数据映射到位图结构中,此种方式不仅可以提升查询效率,还可以节省大量的内存空间。

布隆过滤器的思路 是把key先映射转成哈希整数值,再映射一个位,如果映射一个位的话,冲突率会比较高,因此可以通过多个哈希函数映射多个位,来降低冲突率

布隆过滤器跟哈希表不一样,它无法解决哈希冲突,因为它根本不存储k ey,只是标记这个key映射的位,它的思路是尽可能降低哈希冲突 ,也就是说,布隆过滤器用来判断一个key在是不准确的,但是判断一个key不在是准确的

总结一下,布隆过滤器的特点:

  1. 布隆过滤器使用多个hashFunc来支持不同数据类型的key

  2. 布隆过滤器通过将一个数据映射到多个bit来降低冲突率

  3. 布隆过滤器判断一个数据是否存在可能是不准确的,因为这个数据对应的位可能被其他一个数据或读个数据占用

  4. 布隆过滤器判断一个数据是否不存在是准确 ,因为如果该数据存在那么该数据对应的位都应该已经被置为1了。

布隆过滤器误判率

由于布隆过滤器采用的是多个hash函数将一个数据映射到多个位,如下唐僧的三个bit位分别和猪八戒、孙悟空的重叠,猪八戒和孙悟空都存在的话,此时会误判唐僧是存在的。

对于这种情况,我们应怎么控制好布隆过滤器的长度、哈希函数等参数来减少误判呢?我们先大概分析:

  • 如果过滤器的长度m较短,此时很快就把位占满,误判率就会较高,因此布隆过滤器长度会直接影响误判率,布隆过滤器的长度越长,其误判率越小。
  • 对于插入元素个数n,在m不变的情况下,插入元素越多,误判率越高。
  • 哈希函数的个数也是需要权衡的,哈希函数的个数越多,布隆过滤器中比特位被设置为1的速度就越快(每插一个元素要多写 k 个位,当然更快把数组填满),并且布隆过滤器的查询效率越低,毕竟每次要算k次,同时如果哈希函数的个数也不行,因为会导致误判率更高

实际上关于布隆过滤器的误判率有大佬进行了推导:

假设m为布隆过滤器的bit长度,n为插入过滤器的元素个数,k为哈希函数的个数:

布隆过滤器哈希函数等条件下某个位设置为1的概率为:1 / m

布隆过滤器哈希函数等条件下某个位不为1的概率为:1 - (1 / m)

在经过k次哈希后,某个位置依旧不为1的概率为:

根据极限公式:

可得:

添加n个元素某个位置不置为1的概率:

添加n个元素某个位置置为1的概率:

查询⼀个元素,k次hash后误判的概率为都命中1的概率:

结论

布隆过滤器的误判率为

由误判率的公式可知,在k一定的情况下,当n增加时,误判率增加,m增加时,误判率减少

在m和n一定时,对误判率进行公式求导,误判率尽可能小的情况下,可得到的hash函数个数:

此时的k是误判率最低的。我们可以通过这个公式控制好布隆过滤器的长度m以及哈希函数的个数。

期望的误判率p和插入数据个数n确定的情况下,再把上面的公式带入误判率公式可以得到。通过期望的误判率和插入数据个数n得到bit长度:

布隆过滤器实现

布隆过滤器是哈希+位图的结合,因此内部是封装了位图的,同时需要多个hash函数进行映射:

cpp 复制代码
struct HashFuncBKDR
{
	/// @detail 本 算法由于在Brian Kernighan与Dennis Ritchie的《The CProgramming Language》
		// ⼀书被展⽰⽽得 名,是⼀种简单快捷的hash算法,也是Java⽬前采⽤的字符串的Hash
		
	size_t operator()(const string& s)
	{
		size_t hash = 0;
		for (auto ch : s)
		{
			hash *= 31;
			hash += ch;
		}
		return hash;
	}
};

struct HashFuncAP
{
	// 由Arash Partow发明的⼀种hash算法。
	size_t operator()(const string& s)
	{
		size_t hash = 0;
		for (size_t i = 0; i < s.size(); i++)
		{
			if ((i & 1) == 0) // 偶数位字符
			{
				hash ^= ((hash << 7) ^ (s[i]) ^ (hash >> 3));
			}
			else // 奇数位字符
			{
				hash ^= (~((hash << 11) ^ (s[i]) ^ (hash >> 5)));
			}
		}
		return hash;
	}
};

struct HashFuncDJB
{
		// 由Daniel J. Bernstein教授发明的⼀种hash算法。
	size_t operator()(const string & s)
	{
		size_t hash = 5381;
		for (auto ch : s)
		{
			hash = hash * 33 ^ ch;
		}
		return hash;
	}
};

template<size_t N,size_t X = 5,class K = std::string,class Hash1= HashFuncBKDR,class Hash2 = HashFuncAP,class Hash3 = HashFuncDJB >
class Bloomfiter
{
public:
/// ....

private:
	static const size_t M = N * X;//M为过滤器bit的长度 N为插入数据的个数
	bitset<M> _bt;
};
  • 这里选取将字符串转换成整形的哈希函数,是经过测试后综合评分最高的几个哈希算法,这三种哈希算法在多种场景下产生哈希冲突的概率是最小的。
  • 此时本来这三种哈希函数单独使用时产生冲突的概率就比较小,现在要让它们同时产生冲突概率就更小了
  • 这里我们只是简单实现,并不严谨,应该是根据期望误判率推出m,这里我们只是简单给个倍数X
cpp 复制代码
	void bloom_set(const K& key)
	{
		//用不同的哈希函数分别映射
		size_t x1 = Hash1()(key) % M;
		size_t x2 = Hash2()(key) % M;
		size_t x3 = Hash3()(key) % M;
		// 分别用位图置为1
		_bt.bit_set(x1);
		_bt.bit_set(x2);
		_bt.bit_set(x3);

	}

	bool test(const K& key)
	{
		//用不同的哈希函数分别映射
		size_t x1 = Hash1()(key) % M;
		if (!_bt.bit_test(x1))
			return false;
		size_t x2 = Hash2()(key) % M;
		if (!_bt.bit_test(x2))
			return false;
		size_t x3 = Hash3()(key) % M;
		if (!_bt.bit_test(x3))
			return false;
		return true;
	}
布隆过滤器删除操作

布隆过滤器一般是不支持删除操作的:

  • 删除数据就需要先判断数据是否存在,但是由布隆过滤器特点可知,判断存在是不准确的,这会导致误删其他元素。
  • 如果这个元素确实存在,当删除时需要把对应的映射位置置为0,这些位置可能是与其他位置共用的,这可能会使其他元素也标记为不存在。

要让布隆过滤器支持删除,必须做到以下两点:

  • 保证要删除的元素在布隆过滤器中,如果通过调用Test函数得知要删除的元素可能存在布隆过滤器当中后,可以进一步存储元素的文件,确认该昵称是否真正存在
  • 保证删除之后不会影响到其他元素,可以为位图中的每一个比特位设置一个对应的计数值,当插入元素映射到该bit位之后,计数值++,当删除元素时将该元素对应bit位的计数值--即可。

引用计数的方案是存在其实也是有缺陷的,如果一个值不在布隆过滤器中(比如上图中的唐僧),如果去删除,减减了对应映射位的计数,就会导致孙悟空、猪八戒的位白白被减减了,这会导致一个确定存在的值,可能会变成不存在。还有人提出,考虑计数方式支持删除,但是需要定期重建一个布隆过滤器,这也算是一种思路。

但是归根结底,布隆过滤器本来的目的是节省空间和提高效率的,在删除时需要遍历文件或磁盘中确认待确认删除元素确实存在,而文件IO和磁盘IO的速度相对内存来说是很慢的,并且为位图中的每个bit位额外设置一个计数器,就需要多用原位图几倍的存储空间,这个代价也是不小的。

布隆过滤器优缺点

优点:

  • 主要是效率高、节省空间,而且相比位图可以用于各种类型的标记过滤
  • 增加和查询元素的时间复杂度为O(K),K为哈希函数的个数,一般比较小,与数据量大小无关
  • 哈希函数相互之间没有关系,方便硬件并行运算
  • 布隆过滤器不需要存储元素本身,在某些对保密要求比较严格的场景有很大优势
  • 在能够承受一定的误判时,布隆过滤器比其他数据结构有着很大的空间优势
  • 数据量很大时,布隆过滤器可以表示全集,其他数据结构不能,毕竟布隆过滤器只存固定长度的 bit 签名
  • 使用同一组哈希函数的布隆过滤器可以进行交、并、差运算。

缺点:

  • 有误判率,即存在假阳性,不能准确判断元素是否在集合中(补救方法:再建一个白名单,去白名单精确查一次)
  • 不能获取元素本身
  • 一般情况下不能从布隆过滤器删除元素
布隆过滤器应用
  • 爬虫系统总URL去重

在爬虫系统中,为了避免重复爬取相同的URL,可以使⽤布隆过滤器来进行URL去重。爬取到的URL可以通过布隆过滤器进行判断,已经存在的URL则可以直接忽略,避免重复的⽹络请求和数据处理。

  • 垃圾邮件过滤

在垃圾邮件过滤系统中,布隆过滤器可以⽤来判断邮件是否是垃圾邮件。系统可以将已知的垃圾邮件的特征信息存储在布隆过滤器中,当新的邮件到达时,可以通过布隆过滤器快速判断是否为垃圾邮 件,从⽽提⾼过滤的效率。

  • 预防缓存穿透

在分布式缓存系统中,布隆过滤器可以⽤来解决缓存穿透的问题。缓存穿透是指恶意⽤⼾请求⼀个不存在的数据,导致请求直接访问数据库,造成数据库压⼒过⼤。布隆过滤器可以先判断请求的数据是否存在于布隆过滤器中,如果不存在,直接返回不存在,避免对数据库的⽆效查询。

  • 对数据库查询提效

在数据库中,布隆过滤器可以⽤来加速查询操作。例如:⼀个app要快速判断⼀个电话号码是否注册过,可以使⽤布隆过滤器来判断⼀个用户电话号码是否存在于表中,如果不存在,可以直接返回不存在,避免对数据库进行无用的查询操作。如果在,再去数据库查询进行二次确认。

Q1:给两个文件,分别有100亿个query,我们只有1G内存,如何找到两个文件交集?分别给出精确算法和近似算法

假设平均每个query字符串是50Byte,100亿个就是5000亿byte,约等于500G(1G约等于10亿多Byte)

方案一:布隆过滤器

一个文件中的query放进布隆过滤器,另一个文件依次查找,在的就是交集,问题就是找到交集不够准确,因为在的值可能是误判的,但是交集一定被找到了。

方案二:哈希切分

  • 哈希切分,⾸先内存的访问速度远⼤于硬盘,⼤⽂件放到内存搞不定,那么我们可以考虑切分为⼩ ⽂件,再放进内存处理。
  • 但是不要平均切分,因为平均切分以后,每个⼩⽂件都需要依次暴⼒处理,效率还是太低了。
  • 可以利⽤哈希切分,依次读取⽂件中query,i=HashFunc(query)%N,N为准备切分多少分⼩⽂件,N取决于切成多少份,内存能放下,query放进第i号⼩⽂件,这样A和B中相同的query算出的 hash值i是⼀样的,相同的query就进⼊的编号相同的⼩⽂件就可以编号相同的⽂件直接找交集,不用交叉找,效率就提升了。
  • 本质是相同的query在哈希切分过程中,⼀定进⼊的同⼀个⼩⽂件Ai和Bi,不可能出现A中的的 query进⼊Ai,但是B中的相同query进⼊了和Bj的情况,所以对Ai和Bi进⾏求交集即可,不需要Ai 和Bj求交集。(本段表述中i和j是不同的整数)
  • 哈希切分的问题就是每个⼩⽂件不是均匀切分的,可能会导致某个⼩⽂件很⼤内存放不下。我们细细分析⼀下某个⼩⽂件很⼤有两种情况:1.这个⼩⽂件中**⼤部分是同⼀个query** 。2.这个⼩⽂件是有很多的不同query构成,本质是这些query冲突了 。针对情况1,其实放到内存的set中是可以放 下的,因为set是去重的。针对情况2,需要换个哈希函数继续⼆次哈希切分。所以本体我们遇到大于1G⼩⽂件,可以继续读到set中找交集,若set在insert时抛出了异常(set插⼊数据抛异常只可能是 申请内存失败了,不会有其他情况),那么就说明内存放不下是情况2,换个哈希函数进⾏⼆次哈希切分后再对应找交集。

Q2:给⼀个超过100G⼤⼩的log file,log中存着ip地址,设计算法找到出现次数最多的ip地址?查找出现次数前10的ip地址

思路:依次读取⽂件A中query,i = HashFunc(query)%500,query放进Ai号小文件,然后依次⽤map对每个Ai⼩⽂件统计ip次数,同时求出现次数最多的ip或者topk ip。本质是相同的ip在哈希切分过程中,⼀定进⼊的同⼀个⼩⽂件Ai,不可能出现同⼀个ip进⼊Ai和Aj 的情况,所以对Ai进⾏统计次数就是准确的ip次数。

相关推荐
小邓   ༽44 分钟前
50道C++编程练习题及解答-C编程例题
c语言·汇编·c++·编程练习·c语言练习题
报错小能手1 小时前
数据结构 定长顺序表
数据结构·c++
MC丶科1 小时前
Spring Boot + Elasticsearch 实现全文搜索功能(商品搜索)!让搜索快如闪电
spring boot·后端·elasticsearch·软考高级·软考架构师
qq_419203231 小时前
深浅拷贝、STL迭代器失效
c++·深浅拷贝·stl迭代器失效
9***P3341 小时前
Rust在网络中的Rocket
开发语言·后端·rust
再卷也是菜2 小时前
C++篇(21)图
数据结构·c++·算法
星轨初途2 小时前
C++入门(算法竞赛类)
c++·经验分享·笔记·算法
Wzx1980122 小时前
go聊天室
开发语言·后端·golang
Bona Sun2 小时前
单片机手搓掌上游戏机(十三)—pico运行fc模拟器之硬件准备
c语言·c++·单片机·游戏机