哈希表原理 + 冲突解决 + C++实现

哈希概念

在所有的数据结构中无论是顺序结构还是平衡树(平衡二叉树),元素关键码与其存储位置之间是没有对应的关系的,因此在查询一个元素的时候,必须要经过关键码的多次比较。顺序查询元素的时间复杂度为O(N);平衡树中是树的高度,它的时间复杂度是O(log2​n),搜索的效率取决于搜索过程中元素的比较次数。

在传统数据结构中:

  • 顺序表 :查找需要遍历 → 时间复杂度 O(N)
  • 平衡树 :通过比较查找 → 时间复杂度 O(logN)

本质问题:

元素位置和关键码没有直接关系 → 必须"比较"才能找到

那么在理想条件下,是否存在一种结构,不经过多余的比较,一次直接从存储结构中搜索到对应的元素,只需要通过某种方式(函数)将元素的存储位置与它的关键码之间建立起一一映射的关系,那么在查找的就可以通过这种方式(函数)很快的找到这个元素。

  • 插入元素

根据待插入元素的关键码,通过函数计算出该元素的存储位置并按此位置进行存放。

  • 搜索元素

对元素的关键码进行同样的计算,把求的的函数值当作元素的存储位置,在结构中按此位置取元素比较,若关键码相同,则搜索成功。

这种方式就是哈希(散列)方法,在哈希方法中使用的转化函数就是哈希(散列)函数,构造出来的结构就是哈希表(散列表)。

例如: { 4, 14, 24, 34, 5, 7, 1 }

哈希函数设置为:hash(key) = key % capacity; capacity为存储元素底层空间总的大小。

我们其实可以发现,通过这样的方式,我们不需要像数组那样从头开始遍历,只需要通过哈希函数就可以快速定位到对应的位置,但是我们可以看到上面还有一个问题就是会有多个值被映射到了同一位置,这种情况就是哈希冲突。

哈希冲突

不同的关键字通过相同哈希函数计算之后得到相同的哈希地址,这种情况就是哈希冲突(也叫哈希碰撞)。

把具有不同关键码而具有相同哈希地址的数据元素称为同义词。

那么应该如何解决哈希冲突呢?

哈希函数

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

所以哈希函数设计原则:

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

常见的哈希函数

  1. 直接定址法

取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B

优点:简单、均匀

缺点:需要事先知道关键字的分布情况

使用场景:适合查找比较小且连续的情况

  1. 除留余数法

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

  1. 平方取中法1

假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址; 再比如关键字为4321,对它平方就是18671041,抽取中间的3位671(或710)作为哈希地址 平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况。

  1. 折叠法

折叠法是将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这 几部分叠加求和,并按散列表表长,取后几位作为散列地址。 折叠法适合事先不需要知道关键字的分布,适合关键字位数比较多的情况。

  1. 随机数法

选择一个随机函数,取关键字的随机函数值为它的哈希地址,即H(key) = random(key),其中 random为随机数函数。 通常应用于关键字长度不等时采用此法。

哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突

哈希冲突的解决

两种常见的方法是:闭散列和开散列。

闭散列

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

线性探测

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

插入

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

删除

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

EMPTY此位置空, EXIST此位置已经有元素, DELETE元素已经删除。

复制代码
	enum Status
	{
		EXIST,
		EMPTY,
		DELETE
	};

线性探测的实现

复制代码
	enum Status
	{
		EXIST,
		EMPTY,
		DELETE
	};
	template<class K, class V>
	struct HashNode
	{
		std::pair<K, V> _kv;
		Status _s = EMPTY;
	};

	template<class K, class V, class Hash = HashFun<K>>
	class HashTable
	{
		typedef HashNode<K, V> Node;
	public:
		HashTable()
		{
			_tables.resize(10);
		}

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

			if ((double)_n / (double)_tables.size() == 0.7)
			{
				HashTable<K, V> newHTable;
				newHTable._tables.resize(_tables.size() * 2);
				for (int i = 0; i < _tables.size(); i++)
				{
					if (_tables[i]._s == EXIST)
					{
						newHTable.Insert(_tables[i]._kv);
					}
				}
				_tables.swap(newHTable._tables);
			}

			size_t hashi = hf(kv.first) % _tables.size();
			while(_tables[hashi]._s == EXIST)
			{
				hashi++;
				hashi = hashi % _tables.size();
			}
			_tables[hashi]._kv = kv;
			_tables[hashi]._s = EXIST;
			_n++;
			return true;
		}
		Node* Find(const K& key)
		{
			Hash hf;
			size_t hashi = hf(key) % _tables.size();
			while (_tables[hashi]._s != EMPTY)
			{
				if (_tables[hashi]._s == EXIST && _tables[hashi]._kv.first == key)
				{
					return &_tables[hashi];
				}
				else
				{
					hashi++;
					hashi %= _tables.size();
				}
			}
			return nullptr;
		}
		bool Erase(const K& key)
		{
			Node* data= Find(key);
			if (data)
			{
				data->_s = DELETE;
				_n--;
				return true;
			}
			else
			{
				return false;
			}
		}

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

哈希表是需要扩容的,那么我们应该如何进行扩容呢?

复制代码
bool Insert(const std::pair<K, V>& kv)
{
	Hash hf;
	if (Find(kv.first))
	{
		return false;
	}

	if ((double)_n / (double)_tables.size() == 0.7)
	{
		HashTable<K, V> newHTable;
		newHTable._tables.resize(_tables.size() * 2);
		for (int i = 0; i < _tables.size(); i++)
		{
			if (_tables[i]._s == EXIST)
			{
				newHTable.Insert(_tables[i]._kv);
			}
		}
		_tables.swap(newHTable._tables);
	}

	size_t hashi = hf(kv.first) % _tables.size();
	while(_tables[hashi]._s == EXIST)
	{
		hashi++;
		hashi = hashi % _tables.size();
	}
	_tables[hashi]._kv = kv;
	_tables[hashi]._s = EXIST;
	_n++;
	return true;
}

通过简单的代码测试一下。

开散列

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

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

开散列的实现

复制代码
template<class K,class V>
struct HashNode
{
	HashNode<K, V>* _next;
	std::pair<K, V> _kv;

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

	}
};


template<class K, class V, class Hash = HashFun<K>>
class HashTable
{
	typedef HashNode<K, V> Node;
public:
	HashTable()
	{
		_tables.resize(10, nullptr);
	}
	~HashTable()
	{
		for (int i = 0; i < _tables.size(); i++)
		{
			Node* cur = _tables[i];
			while (cur)
			{
				Node* next = cur->_next;
				delete cur;

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

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

		if (_n == _tables.size())
		{
			std::vector<Node*> newTable;
			newTable.resize(_tables.size() * 2, nullptr);
			for (int i = 0; i < _tables.size(); i++)
			{
				Node* cur = _tables[i];
				while (cur)
				{
					Node* next = cur->_next;

					size_t hashi = hf(cur->_kv.first) % newTable.size();
					cur->_next = newTable[hashi];
					newTable[hashi] = cur;

					cur = next;
				}
				_tables[i] = nullptr;
			}
			_tables.swap(newTable);
		}

		size_t hashi = hf(kv.first) % _tables.size();
		Node* newNode = new Node(kv);
		newNode->_next = _tables[hashi];
		_tables[hashi] = newNode;
		_n++;

		return true;
	}

	Node* Find(const K& key)
	{
		Hash hf;
		size_t hashi = hf(key) % _tables.size();
		Node* cur = _tables[hashi];
		while (cur)
		{
			if (hf(cur->_kv.first) == hf(key))
			{
				return cur;
			}
			cur = cur->_next;
		}
		return nullptr;
	}

	bool Erase(const K& key)
	{
		Hash hf;
		size_t hashi = hf(key) % _tables.size();
		Node* prev = nullptr;
		Node* cur = _tables[hashi];
		while (cur)
		{
			if (cur->_kv.first == key)
			{
				if (prev)
				{
					prev->_next = cur->_next;
				}
				else
				{
					_tables[hashi] = cur->_next;
				}
				delete cur;
				_n--;
				return true;
			}
			prev = cur;
			cur = cur->_next;
		}
		return false;
	}

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

开散列扩容

桶的个数是一定的,随着元素的不断插入,每个桶中元素的个数不断增多,极端情况下,可 能会导致一个桶中链表节点非常多,会影响的哈希表的性能,因此在一定条件下需要对哈希表进行增容。开散列最好的情况是:每个哈希桶中刚好挂一个节点, 再继续插入元素时,每一次都会发生哈希冲突,因此,在元素个数刚好等于桶的个数时,可以给哈希表扩容。

复制代码
bool Insert(const std::pair<K, V>& kv)
{
	Hash hf;
	if (Find(kv.first))
	{
		return false;
	}

	if (_n == _tables.size())
	{
		std::vector<Node*> newTable;
		newTable.resize(_tables.size() * 2, nullptr);
		for (int i = 0; i < _tables.size(); i++)
		{
			Node* cur = _tables[i];
			while (cur)
			{
				Node* next = cur->_next;

				size_t hashi = hf(cur->_kv.first) % newTable.size();
				cur->_next = newTable[hashi];
				newTable[hashi] = cur;

				cur = next;
			}
			_tables[i] = nullptr;
		}
		_tables.swap(newTable);
	}

	size_t hashi = hf(kv.first) % _tables.size();
	Node* newNode = new Node(kv);
	newNode->_next = _tables[hashi];
	_tables[hashi] = newNode;
	_n++;

	return true;
}

这里还有一个细节问题就是无论是开散列还是闭散列都只能存储key为整形的元素,其他类型怎么解决?

我们的哈希函数采用处理余数法,被模的key必须要为整形才可以处理,假如是string类型应该如何处理呢?我们可以通过仿函数+模板特化的方式来对其进行解决,这样就可以解决string类型无法取模的问题了。

复制代码
template<class K>
struct HashFun
{
	size_t operator()(const K& data)
	{
		return data;
	}
};
template<>
struct HashFun<std::string>
{
	size_t operator()(const std::string& data)
	{
		size_t hash = 0;
		for (auto& s : data)
		{
			hash *= 31;
			hash += s;
		}
		return hash;
	}
};

哈希表的核心价值在于用空间换时间,通过建立关键码与存储位置的映射关系,将查找效率提升到接近 O(1)。但同时也引入了哈希冲突这一不可避免的问题,因此如何设计合理的哈希函数以及选择合适的冲突解决策略,就成为了哈希表性能的关键。

相关推荐
Dillon Dong4 小时前
【风电控制】TI TMS320F28379D 双CPU架构解析与任务分布设计
嵌入式硬件·算法·变流器·风电控制
NiceCloud喜云9 小时前
Opus 4.8 的 Effort Control 怎么选:Low 到 Max 五档策略
android·java·大数据·前端·c++·python·spring
小羊在睡觉9 小时前
力扣84. 柱状图中最大的矩形
后端·算法·leetcode·golang·go
cjhbachelor10 小时前
c++继承
c++
3DVisionary10 小时前
蓝光三维扫描:医疗制造的精度焦虑怎么解
人工智能·算法·制造·蓝光三维扫描·医疗制造·三维检测·义齿检测
好评笔记10 小时前
机器学习面试八股——常用损失函数
人工智能·深度学习·算法·机器学习·校招
weixin_4684668510 小时前
全局与局部注意力机制新手实战指南
人工智能·python·深度学习·算法·自然语言处理·transformer·注意力机制
肩上风骋10 小时前
C++14特性
开发语言·c++·c++14特性
_日拱一卒10 小时前
LeetCode:994腐烂的橘子
java·数据结构·算法·leetcode·深度优先