数据结构: 哈希

哈希概念:

        不经过任何比较,一次直接从表中得到要搜索的元素 如果构造一种存储结构,通过某种函数 (hashFunc) 使元素的存储位置与它的关键码之间能够建立 一一映射的关系,那么在查找时通过该函数可以很快找到该元素. 该方式即为哈希(散列 ) 方法, 哈希方法中使用的转换函数称为哈希 ( 散列 ) 函数。
下面举个例子:

但是当我们想插入11的时候要怎么办呢?

 哈希冲突

        上述想要插入11的时候 但却发现11的位置被占用了,这样的情况我们成为哈希冲突。即不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞

那么我们该怎么处理这个问题?

哈希函数

会引起哈希冲突的原因可能是因为hash函数写的有问题,没有考虑到hash冲突的事情.

hash函数的设计原则有以下几条

  1. 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值 域必须在0m-1之间。
  2. 哈希函数计算出来的地址能均匀分布在整个空间中。
  3. 哈希函数应该比较简单。
常见哈希函数
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 种不同的符号在各位上出现的频率不一定
相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只
有某几种符号经常出现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散
列地址。
解决hash冲突
1. 闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有
空位置,那么可以把 key 存放到冲突位置中的 下一个 空位置中。
(1)线性探测法
顾名思义就是从发生冲突的位置开始一次向后探测知道找到下一个空位置。
举个例子:

 当我们要删除一个值的时候,为了不影响其他值的查找,我们选择标记删除。

enum state
	{
		EMPTY,
		EXIST,
		DELET
	};

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

线性探测如何扩容:

我们试想一下如果所有点都是冲突的,也就是说他们没有一个在hash函数划定的位置那我们查找的时候的会发生冲突进而向后不断查找,这样就有可能让空间复杂度退化。

我们引入负载因子概念:

a = 表中数据个数/表容量

a越大表明插入的数据越多冲突的可能性越大,反之越小,对于开放地址发来说应该严格控制a在0.7-0.8之间 超过后cpu缓存不命中几率大大增加 所以一旦超出了就该扩容。

#pragma once
#include <iostream>
#include <utility>
#include <vector>

using namespace std;
namespace HashTable
{

	enum state
	{
		EMPTY,
		EXIST,
		DELET
	};

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

	template<class K>
	struct _hash // 对类型进行探测地址时使用
	{
		size_t operator()(const K& key)
		{
			return (size_t)key;
		}
	};

	template<>
	struct _hash<string> // 对特殊类型进行探测地址时使用
	{
		size_t operator()(const string& str)
		{
			size_t i = 0;
			for (auto it : str)
			{
				i += it;
			}
			return i;
		}
	};

	template<class K, class V, class hash = _hash<K>>
	class Hash
	{
	public:
		bool insert(const pair<K, V> kv)
		{
			if (Find(kv.first))
			{
				return false;
			}

			if (_size == 0 || _size * 10 / _tables.size() >= 7) // 控制负载因子小于0.7 否则扩容
			{

				size_t newSize = 0;
				newSize = _tables.size() == 0 ? 4 : _tables.size() * 2;

				// 扩容后的映射关系需要调整
				Hash<K, V> newHash;
				newHash._tables.resize(newSize);
				for (auto it : _tables)
				{
					if (it._st == EXIST)
					{
						newHash.insert(it._kv);
					}
				}
				_tables.swap(newHash._tables);
			}
			hash hs;
			size_t hashi = hs(kv.first) % _tables.size();

			// 线性探测
			while (_tables[hashi]._st == EXIST) // 判断当前位置是否冲突
			{
				hashi++;
				hashi = hashi % _tables.size();
			}
			_tables[hashi]._kv = kv;
			_tables[hashi]._st = EXIST;
			_size++;
			return true;
		}

		HashData<K, V>* Find(const K& key)
		{
			if (_size == 0)
				return nullptr;
			hash hs;
			size_t start = 0;
			size_t hashi = hs(key) % _tables.size();
			start = hashi;
			while (_tables[hashi]._st != EMPTY)
			{
				if (_tables[hashi]._kv.first == key && _tables[hashi]._st != DELET)
				{
					return &(_tables[hashi]);
				}
				hashi++;
				hashi %= _tables.size();
				if (start == hashi) // 可能会边删除边插入导致全是DELET 进入死循环 要保证只查找一次
					return nullptr;
			}
			return nullptr;
		}

		bool erase(const K& key)
		{
			if (!Find(key))
			{
				return false;
			}
			(Find(key))->_st = DELET;
			return true;
		}

	private:
		vector<HashData<K, V>> _tables;
		size_t _size = 0;
	};
}

再简单讲一下二次探测

二次探测是根据2的n次幂得来的

和线性探测对比来看 线性探测在发现冲突后会一次一步的向后找空位置,二二次探测会向后2^n-1(n是第几次冲突)查找空位置

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

因此:比散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷。
开散列(哈希桶)
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)
		{}
	};

	template<class K, class V, class Hash = HashFunc<K>>
	class HashTable
	{
		typedef HashNode<K, V> Node;
	public:

		~HashTable()
		{
			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;
			}
		}

		inline size_t __stl_next_prime(size_t n)
		{
			static const size_t __stl_num_primes = 28;
			static const size_t __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
			};

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

			return -1;
		}

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

			Hash hash;
			// 负载因子到1就扩容
			if (_size == _tables.size())
			{
				//size_t newSize = _tables.size() == 0 ? 10 : _tables.size() * 2;
				vector<Node*> newTables;
				//newTables.resize(newSize, nullptr);
				newTables.resize(__stl_next_prime(_tables.size()), nullptr);
				// 旧表中节点移动映射新表
				for (size_t i = 0; i < _tables.size(); ++i)
				{
					Node* cur = _tables[i];
					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[i] = nullptr;
				}

				_tables.swap(newTables);
			}

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

			return true;
		}

		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)
		{
			if (_tables.size() == 0)
			{
				return nullptr;
			}

			Hash hash;
			size_t hashi = hash(key) % _tables.size();
			Node* prev = nullptr;
			Node* cur = _tables[hashi];
			while (cur)
			{
				if (cur->_kv.first == key)
				{
					// 1、头删
					// 2、中间删
					if (prev == nullptr)
					{
						_tables[hashi] = cur->_next;
					}
					else
					{
						prev->_next = cur->_next;
					}

					delete cur;
					--_size;

					return true;
				}

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

			return false;
		}

		size_t Size()
		{
			return _size;
		}

		// 表的长度
		size_t TablesSize()
		{
			return _tables.size();
		}

		// 桶的个数
		size_t BucketNum()
		{
			size_t num = 0;
			for (size_t i = 0; i < _tables.size(); ++i)
			{
				if (_tables[i])
				{
					++num;
				}
			}

			return num;
		}

		size_t MaxBucketLenth()
		{
			size_t maxLen = 0;
			for (size_t i = 0; i < _tables.size(); ++i)
			{
				size_t len = 0; 
				Node* cur = _tables[i];
				while (cur)
				{
					++len;
					cur = cur->_next;
				}

				//if (len > 0)
					//printf("[%d]号桶长度:%d\n", i, len);

				if (len > maxLen)
				{
					maxLen = len;
				}
			}

			return maxLen;
		}

	private:
		vector<Node*> _tables;
		size_t _size = 0; // 存储有效数据个数
	};
5. 开散列与闭散列比较
应用链地址法处理溢出,需要增设链接指针,似乎增加了存储开销 。事实上:
由于开地址法必须保持大量的空闲空间以确保搜索效率,如二次探查法要求装载因子 a <=
0.7 ,而表项所占空间又比指针大的多,所以使用链地址法反而比开地址法节省存储空间。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值