【数据结构】哈希底层结构

目录

一、哈希概念

二、哈希实现

1、闭散列

1.1、线性探测

1.2、二次探测

2、开散列

2.1、开散列的概念

2.2、开散列的结构

2.3、开散列的查找

2.4、开散列的插入

2.5、开散列的删除

3、性能分析


一、哈希概念

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

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

当向该结构中:

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

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

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

 用该方法进行搜索不必进行多次关键码的比较,因此搜索的速度比较快。但是可能会造成哈希冲突。

哈希整体代码结构:

enum State
{
	EMPTY,
	EXIST,
	DELETE
};

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

template<class K>
struct HashFunc
{
	//key本身可以进行隐式类型转换
	size_t operator()(const K& key)
	{
		return key;
	}
};

template<>
struct HashFunc<string>
{
	//BKDR哈希
	size_t operator()(const string& s)
	{
		size_t hash = 0;
		for (auto ch : s)
		{
			hash += ch;
			hash *= 31; //通过这种方式减少不同字符串的ascll码值相同造成的冲突问题
						//这个值可以取31、131、1313、131313等等
		}
		return hash;
	}
};

template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:
	bool Insert(const pair<K, V>& kv)
	{}

private:
	vector<HashData<K, V>> _tables;
	size_t _n = 0; //存储的数据个数
};

 模板参数中,Hash是一个仿函数,用于将key值转换成整型。 如果key是一个字符串类型,则使用特化,通过BKDR的方式转换成整型。

二、哈希实现

 对于两个数据元素的关键字 k_i 和 k_j(i != j),有 k_i != k_j,但有:Hash(k_i) ==Hash(k_j),即:不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。

 产生哈希冲突的原因是哈希函数设计不够合理。哈希函数设计原则:

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

常见哈希函数:

  1. 直接定址法--(常用)
    取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B。
    优点:简单、均匀。
    缺点:需要事先知道关键字的分布情况。
    使用场景:适合查找比较小且连续的情况。
  2. 除留余数法--(常用)
     设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址。

哈希冲突的解决主要有两种方法:闭散列和开散列。

1、闭散列

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

1.1、线性探测

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

插入

  1. 通过哈希函数获取待插入元素在哈希表中的位置。
  2. 如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素。

插入代码

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

    Hash hash;
	
    size_t hashi = hash(kv.first) % _tables.size(); //这里是size,而不是capacity
                                          //因为 [] 无法访问 size 外的数值
	//线性检测
	size_t i = 1;
	size_t index = hashi;
	while (_tables[index]._state == EXIST)
	{
		index = hashi + i;
		index %= _tables.size();
		++i;
	}

	_tables[index]._kv = kv;
	_tables[index]._state = EXIST;
	_n++;
    return true;
}

 因为可能出现 size 0 ,或者容量不够的情况,因此需要扩容操作:

//size为0,或者负载因子超过 0.7 就扩容
if (_tables.size() == 0 || _n * 10 / _tables.size() >= 7)
{
	size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
	vector<HashData> newtables(newsize); //创建一个新的vector对象
	//遍历旧表,重新映射到新表
	for (auto& data : _tables)
	{
		if (data._state == EXIST)
		{
			//重新算在新表中的位置
            size_t hashi = hash(data.kv.first) % newtables.size();
			size_t i = 1;
			size_t index = hashi;
			while (newtables[index]._state == EXIST)
			{
				index = hashi + i;
				index %= newtables.size();
				++i;
			}

			newtables[index]._kv = data.kv;
			newtables[index]._state = EXIST;
		}
	}
	_tables.swap(newtables);
}

 需要注意的是,在扩容时,需要重新开辟一个 vector 对象,所有的数据都要重新插入一遍。而不能在原有的 vector 对象上扩容,因为这样做的话,扩容后,映射位置关系就变了,原来不冲突的值可能冲突了,原来冲突的值可能不冲突了。

 因为以上写法存在代码的冗余,所以可以采用如下写法简化代码:

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

    Hash hash;

	//负载因子超过 0.7 就扩容
	if (_tables.size() == 0 || _n * 10 / _tables.size() >= 7)
	{
		size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
		HashTable<K, V> newht;  //创建一个新的哈希表对象
		newht._tables.resize(newsize); //扩容
		//遍历旧表,重新映射到新表
		for (auto& data : _tables)
		{
			if (data._state == EXIST)
			{
				newht.Insert(data._kv);
			}
		}
		_tables.swap(newht._tables);
	}

	size_t hashi = hash(kv.first) % _tables.size(); //这里是size,而不是capacity

	//线性检测
	size_t i = 1;
	size_t index = hashi;
	while (_tables[index]._state == EXIST)
	{
		index = hashi + i;
		index %= _tables.size();
		++i;
	}

	_tables[index]._kv = kv;
	_tables[index]._state = EXIST;
	_n++;
    return true;
}

 删除:

 采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。比如删除元素4,如果直接删除掉,44查找起来可能会受影响。因此线性探测采用标记的伪删除法来删除一个元素。 

删除代码:

HashData<K, V>* Find(const K& key)
{
	if (_tables.size() == 0)
	{
		return nullptr;
	}

    Hash hash;

	size_t hashi = hash(key) % _tables.size();

	size_t i = 1;
	size_t index = hashi;
	while (_tables[index]._state != EMPTY)
	{
		if (_tables[index]._state == EXIST && _tables[index]._kv.first == key)
		{
			return &_tables[index];
		}

		index = hashi + i;
		index %= _tables.size();
		++i;

		//如果找了一圈,那么说明全是存在或删除
		if (index == hashi)
		{
			break;
		}
	}
	return nullptr;
}

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

 需要注意的是,为了防止哈希表中只有存在与删除而造成的死循环问题,在函数中需要增加一次判断,限制查找次数。

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

1.2、二次探测

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

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

2、开散列

2.1、开散列的概念

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

 从上图可以看出,开散列中每个桶中放的都是发生哈希冲突的元素。

2.2、开散列的结构

template<class K, class V>
struct HashNode
{
	HashNode<K, V>* _next;
	pair<K, V> _kv;

	HashNode(const pair<K, V>& kv)
		:_next(nullptr)
		,_kv(kv)
	{}
};

template<class K>
struct HashFunc
{
	//key本身可以进行隐式类型转换
	size_t operator()(const K& key)
	{
		return key;
	}
};

template<>
struct HashFunc<string>
{
	//BKDR哈希
	size_t operator()(const string& s)
	{
		size_t hash = 0;
		for (auto ch : s)
		{
			hash += ch;
			hash *= 31; //通过这种方式减少不同字符串的ascll码值相同造成的冲突问题
						//这个值可以取31、131、1313、131313等等
		}
		return hash;
	}
};

template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
	typedef HashNode<K, V> Node;
public:
    ~HashTable()
	{
		for (auto& cur : _tables)
		{
			while (cur)
			{
				Node* next = cur->_next;
				delete cur;
				cur = next;
			}
			cur = nullptr;
		}
	}
	bool Insert(const pair<K, V>& kv)
	{}

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

2.3、开散列的查找

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

2.4、开散列的插入

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

    Hash hash;

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

	return true;
}

 因为有可能出现哈希表的 size 为 0 ,或者需要扩容的情况。所以需要给哈希表扩容。负载因子越大,冲突的概率越高,查找的效率就越低,同时空间利用率越高。

 因为原表中的节点都是自定义类型的,所以不会被自动析构。我们只需要把原表中的节点重新计算位置,挪动到新表就可以了。

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

    Hash hash;

	//负载因子为1时,扩容
	if (_n == _tables.size())
	{
		size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
		vector<Node*> newtables(newsize, nullptr);
		for (Node*& cur : _tables)
		{
			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.swap(newtables);
	}

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

2.5、开散列的删除

bool Erase(const K& key)
{
    Hash hash;

	size_t hashi = hash(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;
}

 应用链地址法处理溢出,需要增设链接指针,似乎增加了存储开销。事实上:由于开地址法必须保持大量的空闲空间以确保搜索效率,如二次探查法要求装载因子a <=0.7,而表项所占空间又比指针大的多,所以使用链地址法反而比开地址法节省存储空间。

3、性能分析

 对于开散列的哈希来说,增删查改的时间复杂度是 O(1) ,虽然在最坏的情况下(所有的值都挂在同一个下标上,即在同一个桶中),时间复杂度是 O(N),但是因为扩容操作的存在,这种最坏的情况几乎不可能出现。

 如果真的出现了极端情况,导致所有的数据都在一个桶中。则可以采取当单个桶超过一定的长度,就把这个桶改挂成红黑树的方式:把哈希数据类型设置为结构体,结构体中包括链表指针、桶长度以及树的指针,如果桶的长度超出指定数值,就使用树的指针,反之则使用链表指针。


关于哈希底层结构的内容就讲到这里,希望同学们多多支持,如果有不对的地方欢迎大佬指正,谢谢!

猜你喜欢

转载自blog.csdn.net/weixin_74078718/article/details/130747216