哈希表和哈希函数

哈希表的概念

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

理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。

如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。

当向该结构中:

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

该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表)。哈希表的主要优点在于其查找、插入和删除操作的平均时间复杂度可以接近常数级别 O(1) ,效率非常高。

举个例子,如果我们想要知道一个字符串中,每个字母都出现了多少次,那么该如何做呢?我们可以创建一个大小为26的数组,然后让a对应数组中的第一个位置,b对应第二个位置,以此类推,每个位置都对应了一个字母,然后我们可以遍历字符串,出现一次,就把对应位置的字母的出现次数加1。比如说当前的字母是v,按照转换规则:hash[v - 'a']++进行转换。

所以哈希表可以简单的理解为:把数据转换成下标,然后用数组的下标对应的值来表示这个数据。例如,在一个存储学生信息的哈希表中,学生的学号可以作为关键码。通过设计合适的哈希函数,将学号转换为哈希表中的索引,能够快速定位和获取对应的学生信息。

哈希函数

哈希函数的设计原则:

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

常见哈希函数

1.直接定址法

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

优点:简单、均匀

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

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

假设我们要存储一组整数 {12, 25, 36, 48, 59} ,哈希函数为 H(key) = key

那么,对于数字 12 ,其在哈希表中的位置就是 12 ;数字 25 的位置是 25 ;数字 36 的位置是 36 ;数字 48 的位置是 48 ;数字 59 的位置是 59

这样,当我们要查找某个数字时,例如查找数字 36 ,直接去位置 36 查看是否存在即可。

这种直接定址法的优点是简单直观,不会产生冲突。但是如果要存储的数字范围很大,而哈希表的容量有限,就不太适合使用这种方法。


2.除留余数法

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

比如我们想要搜索一个数据集合arr]{1,7,6,4,5,9}里面的某些数据,哈希函数可以设置为hash(key) = key % capacity,capacity为存储元素底层空间总的大小。


3.平方取中法

假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址;

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


4.随机数法

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

还有其它类型的函数,这里就不再过多进行举例,我们常用的就是前2个。

哈希冲突

对于两个数据元素的关键字 k i k_i ki和 k j k_j kj(i != j),有 k i k_i ki != k j k_j kj,但有:Hash( k i k_i ki) ==Hash( k j k_j kj),即:不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。比如上面的除留余数法,当有个数据为24的时候,那么它在数组中的位置就是下标为4的位置,与4共用一个位置,这就是哈希冲突。我们把具有不同关键码而具有相同哈希地址的数据元素称为"同义词"。
注意:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突

那么该如何避免哈希冲突呢?两种常见的方法是:闭散列和开散列

闭散列(开放定址法)

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

比如刚才的情况,插入24,发现4的位置已经被占用了,5,6,7的位置也已经被占用了,只有8的位置没有被占用,所以放在8这个位置

但是当我们查找24的时候,通过哈希函数算出它的下标为4,发现哈希表中4的位置不是24,于是向后查找,第5,6,7的位置也没有,直到发现24在下标为8的位置。

实现方法

基本结构

我们使用枚举标记出哈希表中不同的状态

cpp 复制代码
enum Status
{
	EMPTY,   // 节点为空
	EXIST,   // 节点中的值存在
	DELETE   // 数据被删除
};

我们为什么要这样来标记节点呢?

我们来看一下这种情况。当我们想要删除5时,将位置 5 标记为 "delete"。接下来插入元素 15,在遇到冲突时可能会探查位置 5,由于是 "delete" 状态,所以可以将 15 插入到这个位置。再比如说我们要查找24,根据哈希函数为先到下标为4的位置开始寻找,发现不是24,于是往后找,但是5这个位置发现没有数据了,于是停止查找。所以遇到位置 5 是 "delete" 状态,我们不能停止查找,要继续往后探查。
"delete" 用于标识该位置曾经存储过数据,但现在已经被删除。这与 "empty" 是不同的,"empty" 表示该位置从未被使用过。在查找元素时,遇到 "delete" 位置不能停止查找,而遇到 "empty" 位置则可以确定目标元素不存在。

现在我们来看哈希表的基本结构

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

template<class K,class V>
struct HashData
{
	pair<K, V> _kv;
	Status _s;
};

template<class K,class V, class Hash = HashFunc<K>>
class HashTable
{
public:
	HashTable()
	{
		_tables.resize(10);
	}
private:
	vector<HashData<K,V>> _tables;   // 哈希表
	size_t _n = 0;   // 存储的关键字的个数
};

构造函数的意思是先给10个空间,装不下再进扩容。


查找

查找数据遵循以下原则:通过哈希函数算出数据对应的位置,从该位置开始查找,如果不是,就继续往后进行查找。当遇到EMPTY时,说明数据不在哈希表中,当遇到DELETE,EXIST时,继续往后查找。

cpp 复制代码
HashData<K, V>* Find(const K& key)
{
	size_t hashi = key % _tables.size();
	while (_tables[hashi]._s != EMPTY)
	{
		if (_tables[hashi]._s == EXIST
		&& _tables[hashi]._kv.first == key)
		{
			return &_tables[hashi];
		}
		hashi++;
		hashi %= _tables.size();
	}
	return NULL;
}

代码解析:

  • HashData<K,V>* Find(const K& key)
    输入一个key值,返回指向该节点的指针
  • size_t hashi = key % _tables.size();
    通过哈希函数计算出key对应的下标
  • while (_tables[hashi]._s != EMPTY)
    当该位置不是EMPTY时,就继续往后查找
  • if (_tables[hashi]._s == EXIST&& _tables[hashi]._kv.first == key)
    如果当前位置的状态是EXIST(表示有效数据),并且键值对中的键与要查找的键key相等,就返回当前位置的指针
  • hashi++; hashi %= _tables.size();
    如果没有找到,就往后移动一位,但是防止索引越界,就要对哈希表重新取模。
  • return NULL
    如果遍历完都没有找到,就返回空指针

插入

插入的基本原则是:

1.先通过Find函数,查找目标值在不在哈希表中,因为哈希表中不允许出现重复的值,如果已经存在,返回false,表示无法插入。

2.通过哈希函数计算对应位置的下标

3.开始插入数据

a. 如果下标对应的位置的状态是EXIST,就往后进行查找,直到遇到EMPTY或者DELETE

b. 如果对应的位置没有数据,就直接插入,插入后,把对应位置的状态改成EXIST

4.插入成功后,数据个数加1

cpp 复制代码
bool Insert(const pair<K, V>& kv)
{
	size_t hashi = hf(kv.first) % _tables.size();
	while (_tables[hashi]._s == EXIST)
	{
		hashi++;
		hashi %= _tables.size();
	}

	_tables[hashi]._kv = kv;
	_tables[hashi]._s = EXIST;
	++_n;

	return true;
}

在上面的讲解中,我们的哈希函数模的是数组的容量,而这里为什么模的是数组的大小呢?这是因为比如说我们的数组大小是15,容量是20,有一个数据经过计算得出对应的位置是18,但是数组只有15个位置,显然超过数组大小,所以我们要对数组的size进行取模。

有一个问题,如果插入的时候,位置满了,应该怎么办呢?

显而易见,我们需要进行扩容操作,但是我们并不是当哈希数组已经满了的时候才开始进行扩容。那么我们需要在什么时候就行扩容呢?讲解之前,我们需要引入一个概念:叫做负载因子( ∂ \partial ∂),它的定义为: ∂ \partial ∂ = 填入表中的元素个数 / 哈希表的长度

由于表长是定值, ∂ \partial ∂与"填入表中的元素个数"成正比,所以, ∂ \partial ∂越大,表明填入表中的元素越多,产生冲突的可能性就越大;反之, ∂ \partial ∂越小,表明填入表中的元素越少,产生冲突的可能性就越小。所以对于开放定址法,负载因子应严格控制在0.7~0.8之间,超过0.7就扩容。

cpp 复制代码
// 负载因子0.7就扩容  数据个数/空间长度
if (_n * 10 / _tables.size() == 7)
{
	size_t newSize = _tables.size() * 2;
	HashTable<K, V> newHt;
	newHt._tables.resize(newSize);

	// 遍历旧表
	for (size_t i = 0; i < _tables.size(); i++)
	{
		if (_tables[i]._s == EXIST)
		{
			newHt.Insert(_tables[i]._kv);
		}
	}
	_tables.swap(newHt._tables);
}

由于_n和_tables.size()都是整形,得到的结果不可能是小数,所以我们两边同时乘以10,这样才能得到整数。

代码解析:

  • size_t newSize = _tables.size() * 2;
    计算新的哈希表的大小,变为原大小的二倍
  • HashTable<K, V> newHt; newHt._tables.resize(newSize);
    创建一个新的哈希表对象,并为新的哈希表调整内部存储空间的大小。
  • for (size_t i = 0; i < _tables.size(); i++)
    遍历旧的哈希表
  • if (_tables[i]._s == EXIST) newHt.Insert(_tables[i]._kv);
    只要当前节点的状态是EXIST,就将旧表的有效数据插入到新表当中
  • _tables.swap(newHt._tables);
    交换新旧哈希表的内部存储

这里有一个知识点,就是我们新创建的哈希表newHT的生命周期仅在if的这个括号内。当出了这个作用域,newHT就会调用析构函数,自动销毁内部的vector,交换完成之后,旧的哈希表就交给了newHT,此时这个newHT起到了销毁旧的哈希表的功能。

插入函数总代码

cpp 复制代码
bool Insert(const pair<K, V>& kv)
{
	// 负载因子0.7就扩容  数据个数/空间长度
	if (_n * 10 / _tables.size() == 7)
	{
		size_t newSize = _tables.size() * 2;
		HashTable<K, V> newHt;
		newHt._tables.resize(newSize);

		// 遍历旧表
		for (size_t i = 0; i < _tables.size(); i++)
		{
			if (_tables[i]._s == EXIST)
			{
				newHt.Insert(_tables[i]._kv);
			}
		}
		_tables.swap(newHt._tables);
	}
	size_t hashi = hf(kv.first) % _tables.size();
	while (_tables[hashi]._s == EXIST)
	{
		hashi++;
		hashi %= _tables.size();
	}

	_tables[hashi]._kv = kv;
	_tables[hashi]._s = EXIST;
	++_n;

	return true;
}

删除

删除就比较简单了。先通过Find函数查找目标值在不在哈希表中,如果在,就把该位置的状态变成DELETE,再把数据个数减1,;如果没有找到,就返回false。

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

接下来有一个问题,就是上面的只适合查找整型,当遇到浮点型,字符串类型的时候,就失去作用了,因为无法对字符串类型取模,那么这个问题该如何解决呢?

办法就是把传进来的数据变成一个整型。为此我们可以写一个仿函数,可以把数据转换成整型类型,然后再对这个整型进行除留余数法。我们可以先处理一下"整型->整型"的仿函数。

cpp 复制代码
template<class K>
struct HashFunc
{
	size_t operator()(const K& key)
	{
		return (size_t)key;
	}
};

我们可以把传进来的数据强转成无符号的类型。

接着我们来处理一下string->int的转换规则,有人发现,可以把字符串的每一位的ASCII的值加起来,这样就可以转成整型来处理了,但是还有一个小问题,如果是"abc"和"acb"的ASCII值的和是一样的,这样会产生冲突。所以我们可以在此基础上,对每一个字母乘以一个数值,这样就会减少冲突产生,分散性很强。

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

现在我们就可以跟哈希表传入第三个模板参数,用于传入仿函数

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

由于我们的字符串转整型被写成了一个模板特化,所以我们的string也可以通过默认值直接转化,不用自己传入模板参数,所有用到取模的操作都要通过仿函数转成整型,再对此统一操作。

复制代码
Hash hf;
size_t hashi = hf(kv.first) % _tables.size();

闭散列完整代码展示:

cpp 复制代码
template<class K>
struct HashFunc
{
	size_t operator()(const K& key)
	{
		return (size_t)key;
	}
};

template<>
struct HashFunc<string>
{
	size_t operator()(const string& key)
	{
		size_t hash = 0;
		for (auto e : key)
		{
			hash *= 31;
			hash += e;
		}
		cout << key << ":" << hash << endl;
		return hash;
	}
};

namespace open_address
{
	enum Status
	{
		EMPTY,
		EXIST,
		DELETE
	};

	template<class K,class V>
	struct HashData
	{
		pair<K, V> _kv;
		Status _s;
	};

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

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

			// 负载因子0.7就扩容  数据个数/空间长度
			if (_n * 10 / _tables.size() == 7)
			{
				size_t newSize = _tables.size() * 2;
				HashTable<K, V> newHt;
				newHt._tables.resize(newSize);

				// 遍历旧表
				for (size_t i = 0; i < _tables.size(); i++)
				{
					if (_tables[i]._s == EXIST)
					{
						newHt.Insert(_tables[i]._kv);
					}
				}
				_tables.swap(newHt._tables);

			}

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

			_tables[hashi]._kv = kv;
			_tables[hashi]._s = EXIST;
			++_n;

			return true;
		}

		HashData<K, V>* 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];
				}
				hashi++;
				hashi %= _tables.size();
			}
			return NULL;
		}

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

		void Print()
		{
			for (size_t i = 0; i < _tables.size(); i++)
			{
				if (_tables[i]._s == EXIST)
				{
					cout << "[" << i << "]->" << _tables[i]._kv.first << ":" << _tables[i]._kv.second << endl;
				}
				else if (_tables[i]._s == EMPTY)
				{
					printf("[%d]->\n", i);
				}
				else
				{
					printf("[%d]->D\n", i);
				}
			}
			cout << endl;
		}

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

开散列(哈希桶)

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

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

而使用哈希桶来实现哈希表,那么哈希表中的数组不再直接存储数据,而是存储一个链表的指针,通过哈希函数得到对应的下标后,就插入到当前的位置。每一个链表称为一个哈希桶,每个哈希桶中,都存放着存在哈希冲突的元素。本质是通过指针数组来实现的。

实现方法

基本结构

既然是链表存储,那么每个节点既要存储当前的值,也要存储下一个节点的指针。

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

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

哈希表的结构:

cpp 复制代码
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
	typedef HashNode<K, V> Node;
public:
	HashTable()
	{
		_tables.resize(10);
	}
private: 
	vector<Node*> _tables; //链表指针数组 
	size_t _n = 0;    //数据个数 
};

由于我们开辟了外部资源,所以我们需要写一个析构函数,防止内存泄露。

cpp 复制代码
~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;
	}
}

查找

先通过哈希函数算出对应的下标,找到对应下标的链表,然后遍历链表,如果找到,就返回该节点的指针,否则返回NULL。

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

		cur = cur->_next;
	}

	return NULL;
}

插入

开始的逻辑和开放定址法中的一样,找到对应的位置后,使用头插将数据插入到链表当中。

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

	Hash hf;

	// 负载因子最大到1		
	if (_n == _tables.size())
	{
		vector<Node*> newTables;
		newTables.resize(_tables.size() * 2, nullptr);
		// 遍历旧表
		for (size_t i = 0; i < _tables.size(); i++)
		{
			Node* cur = _tables[i];
			while (cur)
			{
				Node* next = cur->_next;

				// 挪动到映射的新表
				size_t hashi = hf(cur->_kv.first) % newTables.size();
				cur->_next = newTables[hashi];
				newTables[hashi] = cur;

				cur = next;
			}

			_tables[i] = nullptr;
		}

		_tables.swap(newTables);
	}

	size_t hashi = hf(kv.first) % _tables.size();
	Node* newnode = new Node(kv);

	// 头插
	newnode->_next = _tables[hashi];
	_tables[hashi] = newnode;
	++_n;

	return true;
}

如果按照以前的方法进行扩容的话,会极大的浪费时间,因为如果我们单纯的进行插入,就要把原来的节点释放掉,再创建一个新的节点,所以我们需要重新处理一下。

代码解析:

  • vector<Node*> newTables;
    创建一个新的存储指针节点的数组
  • newTables.resize(_tables.size() * 2, nullptr);
    调整新数组的大小为原大小的2倍,初始化为空
  • Node* cur = _tables[i];
    获取当前桶中的头结点的指针。当cur不为空的时候,重新计算新的哈希表中的映射位置,然后使用头插法把数据插入到新的哈希表中。插入完成后将旧表中的位置置成空。
  • _tables.swap(newTables);
    交换新旧表中的数据

删除

先通过哈希函数找出桶的位置,然后在桶的位置遍历查找目标值。删除节点的逻辑和链表一样,使用头删法。

cpp 复制代码
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 == nullptr)
			{
				_tables[hashi] = cur->_next;
			}
			else
			{
				prev->_next = cur->_next;
			}
			delete cur;
			return true;
		}

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

	return false;
}

需要注意的是,删除节点后,需要把节点的前后连接起来。

相关推荐
计算机毕设源码分享88888816 分钟前
番茄采摘机器人的视觉系统设计
人工智能·算法·机器人
DARLING Zero two♡30 分钟前
C++寻位映射的奇幻密码:哈希
c++·哈希算法
gyeolhada38 分钟前
2025蓝桥杯JAVA编程题练习Day8
java·数据结构·算法·蓝桥杯
freyazzr1 小时前
Leetcode刷题 | Day60_图论06
数据结构·c++·算法·leetcode·图论
AI technophile1 小时前
OpenCV计算机视觉实战(6)——经典计算机视觉算法
opencv·算法·计算机视觉
qq_584598921 小时前
day30python打卡
开发语言·人工智能·python·算法·机器学习
zhangpeng4555479401 小时前
C++--综合应用-演讲比赛项目
开发语言·c++·算法
霜羽68921 小时前
【数据结构篇】排序1(插入排序与选择排序)
数据结构·算法·排序算法
啊我不会诶1 小时前
CF每日4题(1300-1400)
开发语言·c++·算法
JK0x071 小时前
代码随想录算法训练营 Day51 图论Ⅱ岛屿问题Ⅰ
算法·深度优先·图论