29.哈希算法和哈希表、哈希桶的实现

一、哈希概念

哈希(hash)又称散列,是⼀种组织数据的方式。哈希的本质关键字Key跟存储位置建立⼀个映射关系,使用哈希函数计算出key实际的存储位置,从而能实现快速插入、删除和查找。

易错点1:

哈希是一种用来进行高效查找的数据结构,查找的时间复杂度平均为O(1)。

哈希是以牺牲空间为代价,提高查询的效率。 

易错点2:

  哈希函数设计原则:

  1. 哈希函数应该尽可能简单

  2. 哈希函数的值域必须在哈希表格的范围之内

  3. 哈希函数的值域应该尽可能均匀分布,即取每个位置应该是等概率的

易错点3:

常见的哈希函数有:直接定址法、除留余数法、平方取中法、随机数法、数字分析法、叠加法等。

常见哈希冲突处理:闭散列(线性探测、二次探测)、开散列(链地址法)、多次散列。 

易错点4:

已知有一个关键字序列:(19,14,23,1,68,20,84,27,55,11,10,79)散列存储在一个哈希表中,若散列函数为H(key)=key%7,并采用链地址法来解决冲突,则在等概率情况下查找成功的平均查找长度为(1.5)

[0]  [1]  [2]   [3]   [4]    [5]   [6]

 14   1   23   10   11   19    20

 84       79           68   27

                        55

14、1、23、10、11、19、20: 比较1次就可以找到

84、79、68、27:需要比较两次才可以找到

55: 需要比较三次才可以找到

总的比较次数为:7+4*2+3=18,总共有12个元素

故:等概率情况下查找成功的平均查找长度为:18/12 = 1.5。

平均查找长度是按比较次数算的。

易错点5:

已知某个哈希表的n个关键字具有相同的哈希值,如果使用二次探测再散列法将这n个关键字存入哈希表,至少要进行()次探测。

0+1+2+⋯+(n−1)= n(n-1)/2

第一次插入:0次

第二次插入:1次

....

第n次插入:n-1次

二次探测再散列法这个只是误导内容,无论哪种探测方式,都至少要进行n(n-1)/2次探测,因为在各自探测方式中,探测的位置都是固定不变的。

二、直接定址法

数据范围比较集中时,直接定址法是一种简单高效的方法。

387. 字符串中的第一个唯一字符 - 力扣(LeetCode)

三、哈希冲突

不同的key映射到同一个位置,这种情况称为哈希冲突。

哈希冲突是不可避免的,我们能做的只有尽可能地设计更优的哈希函数减少冲突。

四、负载因子

假设哈希表中已经映射存储了N个值,哈希表的大小为M,那么负载因子就是N/M。

负载因子越大,哈希冲突的概率越大,空间利用率越大。

负载因子越小,哈希冲突的概率越小,空间利用率越小。

五、哈希函数(重点)

1.除法散列法/除留余数法

29.哈希算法和哈希表、哈希桶的实现_第1张图片

除法散列法的公式:h(key) = key%M

%M的结果是在[0,M)区间之内,映射数组的下表恰好不会越界。

注意:

要尽量避免M为某些值,比如2的幂,10的幂,key%(2^x),相等于保留key的后x位。

29.哈希算法和哈希表、哈希桶的实现_第2张图片

%(10^x)就更不用说了,保留10进制的后x位。

当使用除法散列法时,M应取不太接近2的整数次幂的一个质数(素数)(只能被1和自身整除,更能区分)

例如stl30的stl_hash.h实现如下:

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

inline unsigned long __stl_next_prime(unsigned long n)
{
  const unsigned long* first = __stl_prime_list;
  const unsigned long* last = __stl_prime_list + __stl_num_primes;
  const unsigned long* pos = lower_bound(first, last, n);
  return pos == last ? *(last - 1) : *pos;
}

采取了素数表的形式,按照素数表的值进行扩容。

但Java的HashMap,就是用2的整数次幂做的M的值。

M = 2^x

处理时,hashi = h(key) = key%M = key%(2^x),这样就保留了2进制的后x位,但是前32-x位没有参与运算,于是做了这样的处理,hashi = hashi ^ (key>>(32-x)),将x位和32-x位进行异或。(但需注意,若x<16时,前32-x和后x位异或的值可能大于2^x,需要特殊处理

目的:尽量让可以所有的位都参与运算,这样映射出的哈希值更均匀一些。

2.乘法散列法

h(key) = floor(M*((A*key)%1.0))     ((A*key)%1.0表示取A*key的小数部分

实现思路:找出一个跟key相关的小数。

第一步:抽取key*A的小数部分。(A为常数,A的范围为(0,1))

第二步:再用M乘以(key*A)的小数部分,然后向下取整

其中常数A的值建议选择,(sqrt(5)-1)/2,即黄金分割率:0.618....

3.全域散列法

如果存在⼀个恶意的对手,他针对我们提供的散列函数,特意构造出⼀个发生严重冲突的数据集, 比如,让所有关键字全部落入同⼀个位置中。 只要散列函数是公开且确定 的,就可以实现此攻击。
应对方法:全域散列法
h ab ( key ) = (( a × key + b )% P )%M
P需要选⼀个足够大的质数,a可以随机选[1,P-1]之间的任意整数,b可以随机选[0,P-1]之间的任意整数,这些函数构成了⼀个P*(P-1)组全域散列函数组。
这样运行时,a,b是随机的选定的值。

六、处理哈希冲突

不论选择哪种哈希函数,哈希冲突是不可避免的。

1.开放定址法

其中开放定址法的解决哈希冲突分为三种:

1)线性探测

2)二次探测

3)双重散列

线性探测 

以除法散列法举例:

29.哈希算法和哈希表、哈希桶的实现_第3张图片

线性探测公式: h(key) = (key%M+i)%M (i=1,2,3,...,M-1)

因为负载因子小于1,最多探测M-1次就可以找到。

线性探测思路:若当前位置有元素,往后找直到找到空位置,期间若走到尾又返回到头。

但需要注意的是:

若元素删除了,如何区分是否被删除了?

因为存在数组上的元素,一定会有一个值,且哈希表是散列分布的,元素的分布是无法确定的,因此无法像vector一样根据size来记录,vector是挨着放的。

那么有两种做法:

第一种:设置一个key的值,表示空。哈希表初始化时,所有位置都初始化成那个key值,删除完后,删除位置设置成那个值,但这样做有缺点:如何选择这个key值,因为key的类型是不确定的,选择一个key值必然会导致某些值不能使用。

第二种:给哈希表的节点值设置标志位,{EMPTY,EXIST,DELETE},标识节点的状态。

综合考虑,第二种明显更优。设置delete状态的原因:为了不影响后续冲突位置的查找。

二次探测:

h ( key ) = hash 0 = key % M , hash0位置冲突了,则二次探测公式为:
hc ( key , i ) = hashi = ( hash 0 ± i^2   ) % M i = {1, 2, 3, ..., M/2}
需要注意的是:当hashi是负数时,需要+M,否则就越界了。

双重散列

第⼀个哈希函数计算出的值发生冲突,使用第二个哈希函数计算出⼀个跟key相关的偏移量值,不断往后探测,直到寻找到下⼀个没有存储数据的位置为止。
h 1 ( key ) = hash 0 = key % M , hash0位置冲突了,则双重探测公式为:
hc ( key , i ) = hashi = ( hash 0 + i h 2 ( key )) % M i = {1, 2, 3, ..., M }
理解和分析:与其名称一致,双重散列通过两个散列函数(哈希函数)。
要求 h 2 (key) < h2 (key )和M互为质数,有两种简单的取值方法:
1、当M为2整数幂时, 从[0,M-1]任选⼀个奇数;
2、当M为质数时,h 2 ( key ) = key % ( M − 1) + 1
保证h 2 ( key )与M互质是因为若不互质,根据固定的偏移量所寻址的所有位置将形成⼀个群

开放定址法插入的具体实现:

以下示例采用除法散列法,冲突时使用线性探测的方法:

对于扩容情况,创建一个新的哈希表,然后遍历一遍原哈希表,将值重新映射插入到新的哈希表中,然后交换两个表的vector。

bool insert(const pair& kv)
{
	if (find(kv.first))
		return false;
	//负载因子为0.7时扩容
	if (_n * 10 / _tables.size() >= 7)
	{
		Self newtable(__stl_next_prime(_tables.size() + 1));
		for (size_t i = 0; i < _tables.size(); ++i)
		{
			if (_tables[i]._state == EXIST)
			{
				newtable.insert(_tables[i]._kv);
			}
		}
		_tables.swap(newtable._tables);
	}
	Hash hash;
	size_t tsize = _tables.size();
	size_t hashi = hash(kv.first) % tsize;
	//线性探测
	while (_tables[hashi]._state == EXIST)
	{
		++hashi;
		hashi %= tsize;
	}
	_tables[hashi]._kv = kv;
	_tables[hashi]._state = EXIST;
	++_n;
	return true;
}

由于还要key默认支持转化成无符号整形

	template
	struct HashFun
	{
		size_t operator()(const K& key)
		{
			return static_cast(key);
		}
	};

	template<>
	struct HashFun
	{
		size_t operator()(const string& key)
		{
			size_t ret = 0;
			for (auto e : key)
			{
				ret += e;
				ret *= 131;
			}
			return ret;
		}
	};

string作为常用的类型,应做模板的特化,特殊处理。

其中每次+=完之后*131,为了减少因顺序不同但值相同带来的哈希冲突。

开放定址法的HashTable模拟实现: 

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

inline unsigned long __stl_next_prime(unsigned long n)
{
	const unsigned long* first = __stl_prime_list;
	const unsigned long* last = __stl_prime_list + __stl_num_primes;
	const unsigned long* pos = lower_bound(first, last, n);
	return pos == last ? *(last - 1) : *pos;
}

namespace hash_table
{
	enum State
	{
		EXIST,
		EMPTY,
		DELETE
	};

	template
	struct HashData
	{
		pair _kv;
		State _state = EMPTY;
	};

	template
	struct HashFun
	{
		size_t operator()(const K& key)
		{
			return static_cast(key);
		}
	};

	template<>
	struct HashFun
	{
		size_t operator()(const string& key)
		{
			size_t ret = 0;
			for (auto e : key)
			{
				ret += e;
				ret *= 131;
			}
			return ret;
		}
	};

	template >
	class HashTable
	{
		typedef typename HashData HashData;
		typedef typename HashTable Self;
	public:
		HashTable(const size_t N = 11)
		{
			_tables.resize(N);
		}

		bool insert(const pair& kv)
		{
			if (find(kv.first))
				return false;
			//负载因子为0.7时扩容
			if (_n * 10 / _tables.size() >= 7)
			{
				Self newtable(__stl_next_prime(_tables.size() + 1));
				for (size_t i = 0; i < _tables.size(); ++i)
				{
					if (_tables[i]._state == EXIST)
					{
						newtable.insert(_tables[i]._kv);
					}
				}
				_tables.swap(newtable._tables);
			}
			Hash hash;
			size_t tsize = _tables.size();
			size_t hashi = hash(kv.first) % tsize;
			//线性探测
			while (_tables[hashi]._state == EXIST)
			{
				++hashi;
				hashi %= tsize;
			}
			_tables[hashi]._kv = kv;
			_tables[hashi]._state = EXIST;
			++_n;
			return true;
		}

		bool find(const K& key)
		{
			Hash hash;
			size_t tsize = _tables.size();
			size_t hashi = hash(key) % tsize;
			//为delete往后找,可能因为冲突放后面去了
			while (_tables[hashi]._state == EXIST
				|| _tables[hashi]._state == DELETE)
			{
				if (_tables[hashi]._state == EXIST &&
					_tables[hashi]._kv.first == key)
					return true;
				++hashi;
				hashi %= tsize;
			}
			return false;
		}

		bool erase(const K& key)
		{
			Hash hash;
			size_t tsize = _tables.size();
			size_t hashi = hash(key) % tsize;
			//为delete往后找,可能因为冲突放后面去了
			while (_tables[hashi]._state == EXIST
				|| _tables[hashi]._state == DELETE)
			{
				if (_tables[hashi]._state == EXIST &&
					_tables[hashi]._kv.first == key)
					break;
				++hashi;
				hashi %= tsize;
			}
			if (_tables[hashi]._state == EXIST)
			{
				_tables[hashi]._state = DELETE;
				--_n;
				return true;
			}
			else
			{
				return false;
			}
		}
	private:
		vector _tables;
		size_t _n = 0; // 表中存储的数据个数
	};
}

2.链地址法

链地址法:

29.哈希算法和哈希表、哈希桶的实现_第4张图片

如图所示,散列方式采用的是:除法散列法,链地址法处理哈希冲突的方式是:在key映射的位置存一个单链表头结点的指针,key值映射的位置冲突时,就尾插到单链表的尾节点。

极端场景:节点全在一个链表上,退化成了一个单链表。

Java8:当单链表节点数量大于等于8时,就改为挂红黑树。

链地址法的插入:

思路:key转成无符号整形,再根据除法散列法,算出hashi,得到头指针,就转换成了单链表的插入。对于单链表的插入,头插,需要修改_bucket[hashi],即头结点的指针。

对于扩容:复用插入不是一种好的方法,因为复用插入,每次插入时都要new节点,最后还要把原哈希桶的所有节点delete,效率低。应该把原哈希桶的节点“拿下来”插入到新桶

具体代码实现:

bool insert(const pair& kv)
{
	if (find(kv.first))
		return false;
	//负载因子等于1时,扩容
	if (_n == _bucket.size())
	{
		//思路:将原哈希桶的节点拿下来,插入到新的桶
		vector newbucket;
		newbucket.resize(__stl_next_prime(_bucket.size()) + 1);
		size_t newsz = newbucket.size();
		Hash hash;
		//按原桶的vector下标遍历
		for (size_t i = 0; i < _bucket.size(); ++i)
		{
			if (_bucket[i] != nullptr)
			{
				Node* cur = _bucket[i];
				Node* next = cur->_next;
				//cur即为当前遍历的节点
				// 将其 头插 到新的哈希桶中
				while (cur)
				{
					size_t hashi = hash(cur->_kv.first) % newsz;
					//头插,cur head,cur成为新的头
					cur->_next = newbucket[hashi];
					newbucket[hashi] = cur;
					//迭代
					cur = next;
					if (cur)
						next = cur->_next;
				}
			}
			_bucket[i] = nullptr;
		}
		_bucket.swap(newbucket);
	}
	Hash hash;
	size_t hashi = hash(kv.first) % _bucket.size();
	Node* newnode = new Node(kv);
	//头插,newnode head,newnode成为新的头
	newnode->_next = _bucket[hashi];
	_bucket[hashi] = newnode;
	++_n;
	return true;
}

链地址法的删除

需要注意的是删除头结点时,要改变vector存的头结点的指针。

若没有找到,注意空指针解引用的问题。

bool erase(const K& key)
{
	Hash hash;
	size_t hashi = hash(key) % _bucket.size();
	Node* cur = _bucket[hashi];
	//cur为头结点的指针
	if (cur == nullptr)
		return false;
	//头删,_bucket[hashi](cur) curNext
	if (cur->_kv.first == key)
	{
		_bucket[hashi] = cur->_next;
		delete cur;
        --_n;
		return true;
	}
	//中间删除
	else
	{
		Node* prev = nullptr;
		//prev cur curNext
		while (cur && cur->_kv.first != key)
		{
			prev = cur;
			cur = cur->_next;
		}
		if (cur == nullptr)
		{
			return false;
		}
		else
		{
			prev->_next = cur->_next;
			delete cur;
            --_n;
			return true;
		}
	}
}

完整代码如下:

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

inline unsigned long __stl_next_prime(unsigned long n)
{
	const unsigned long* first = __stl_prime_list;
	const unsigned long* last = __stl_prime_list + __stl_num_primes;
	const unsigned long* pos = lower_bound(first, last, n);
	return pos == last ? *(last - 1) : *pos;
}

namespace hash_bucket
{
	template
	struct HashFun
	{
		size_t operator()(const K& key)
		{
			return static_cast(key);
		}
	};
	template<>
	struct HashFun
	{
		size_t operator()(const string& key)
		{
			size_t ret = 0;
			for (auto e : key)
			{
				ret += e;
				ret *= 131;
			}
			return ret;
		}
	};

	template
	struct ListNode
	{
		ListNode(const pair& kv)
			:_kv(kv)
			, _next(nullptr)
		{}
		pair _kv;
		ListNode* _next;
	};

	template>
	class HashBucket
	{
	private:
		typedef typename ListNode Node;
	public:
		HashBucket(const size_t sz = 11)
		{
			_bucket.resize(sz);
		}

		~HashBucket()
		{
			for (size_t i = 0; i < _bucket.size(); ++i)
			{
				if (_bucket[i] != nullptr)
				{
					Node* cur = _bucket[i];
					Node* next = cur->_next;
					while (cur)
					{
						delete cur;
						cur = next;
						if (cur)
							next = cur->_next;
					}
				}
			}
		}

		bool insert(const pair& kv)
		{
			if (find(kv.first))
				return false;
			//负载因子等于1时,扩容
			if (_n == _bucket.size())
			{
				//思路:将原哈希桶的节点拿下来,插入到新的桶
				vector newbucket;
				newbucket.resize(__stl_next_prime(_bucket.size()) + 1);
				size_t newsz = newbucket.size();
				Hash hash;
				//按原桶的vector下标遍历
				for (size_t i = 0; i < _bucket.size(); ++i)
				{
					if (_bucket[i] != nullptr)
					{
						Node* cur = _bucket[i];
						Node* next = cur->_next;
						//cur即为当前遍历的节点
						// 将其 头插 到新的哈希桶中
						while (cur)
						{
							size_t hashi = hash(cur->_kv.first) % newsz;
							//头插,cur head,cur成为新的头
							cur->_next = newbucket[hashi];
							newbucket[hashi] = cur;
							//迭代
							cur = next;
							if (cur)
								next = cur->_next;
						}
					}
					_bucket[i] = nullptr;
				}
				_bucket.swap(newbucket);
			}
			Hash hash;
			size_t hashi = hash(kv.first) % _bucket.size();
			Node* newnode = new Node(kv);
			//头插,newnode head,newnode成为新的头
			newnode->_next = _bucket[hashi];
			_bucket[hashi] = newnode;
			++_n;
			return true;
		}

		bool find(const K& key)
		{
			Hash hash;
			size_t hashi = hash(key) % _bucket.size();
			Node* cur = _bucket[hashi];
			while (cur)
			{
				if (cur->_kv.first == key)
				{
					return true;
				}
				cur = cur->_next;
			}
			return false;
		}

		bool erase(const K& key)
		{
			Hash hash;
			size_t hashi = hash(key) % _bucket.size();
			Node* cur = _bucket[hashi];
			//cur为头结点的指针
			if (cur == nullptr)
				return false;
			//头删,_bucket[hashi](cur) curNext
			if (cur->_kv.first == key)
			{
				_bucket[hashi] = cur->_next;
				delete cur;
                --_n;
				return true;
			}
			//中间删除
			else
			{
				Node* prev = nullptr;
				//prev cur curNext
				while (cur && cur->_kv.first!=key)
				{
					prev = cur;
					cur = cur->_next;
				}
				if (cur == nullptr)
				{
					return false;
				}
				else
				{
					prev->_next = cur->_next;
					delete cur;
                    --_n;
					return true;
				}
			}
		}

	private:
		vector _bucket;
		size_t _n = 0; // 表中存储的数据个数
	};
}

你可能感兴趣的:(C++,c++,数据结构,哈希算法)