C++ - 开散列的拉链法(哈希桶) 介绍 和 实现

前言

 之前我们介绍了,闭散列 的 开放地址法实现的 哈希表:C++ - 开放地址法的哈希介绍 - 哈希表的仿函数例子_chihiro1122的博客-CSDN博客

 但是 闭散列 的 开放地址法 虽然是哈希表实现的一种,但是这种方式实现的哈希表,有一个很大的弊端,就是可能会引起一大片的哈希冲突,因为当发生哈希冲突的时候,他是按照线性探测的方式去找新的位置的,那么在冲突的位置之后,可能有一大片都是有数据存在的,那么每一次寻找都会发生哈希冲突。

而且使用 开放地址法 实现的哈希表,在查找数据的时候,计算出的起始位置不是要查找的数据的时候,也是按照上述哈希冲突的方式去寻找数据的,那么这样的效率就更低了。

更不用想,当 这种哈希表需要扩容的时候,还是类似想 realloc 函数一样,先开一个更大空间,然后再把之前的值都赋值进去之后,再计算新插入的数的初始位置,然后按照上述的方式进行插入。

 这种在表当中相邻位置元素比较多的情况,使用开放地址法就会发生拥堵的情况。把本来没有发生哈希冲突的值,都加到 冲突当中了。

二次探测哈希表介绍

 在上述线性探测的基础之上,引出了二次探测,如果说线性探测是按照 每次 起始位置 + i (i >= 0,i 往后迭代) 这样的方式实现 一次往后遍历,那么 二次探测就是  起始位置 + i^2 (i>=0) 这样的方式进行探测,遮掩的话就缓解了一些 拥堵情况。但是,这并不是最优解,他的本质上还是 一种线性探测,只不过把 冲突的值 分开了一些而已。

两者的本质都是在块共用的空间当中,进行存储值,这样不管你怎么进行探测,当数据量比较密集之后,很难做到 不发生 一大片的拥堵情况。

 在发生哈希冲突之后,只是无脑的往后寻找空位置插入,那么这个位置本来应该插入的元素就被占了,归根结底还是占了别人的空间,而且这种情况随着冲突的变多之后,会发生得更多。

开放地址法的缺点:冲突会互相影响。 

 所以,此时就有人想了,开放地址法都是在一个空间当中寻找可以插入的地址,那么能不能走出这块空间呢?不要让自己冲突之后,去占用别人的位置,来诱发哈希冲突

 开散列的拉链法(哈希桶)

 如果知道计数排序的小伙伴应该很好理解,这里的哈希桶和计数排序当中有点类似。

如果他的规则是 hash = key % mol这个哈希函数的话。那么他会先开一个 mol 大小的指针数组,结点也不会像之前一样用一个 数组来存储,而且是把一个元素用一个结点存储,把 hash 值相同的元素用类似链表的方式链接在 对应 指针数组下标上元素指针来指向,如下图所示:

C++ - 开散列的拉链法(哈希桶) 介绍 和 实现_第1张图片

 大体框架

	template
	struct HashNode
	{
		pair _kv;
		hashNode* next;  // 每个结点都有一个指针指向这个结点的下一个结点
	};

	template
	class Hash
	{
		typedef HashNode Node;
	public:
		Hash()
		{
			_table.resize(10, nullptr);
		}

	private:
		vector _table;  // 指针数组
		int _n = 0;   // 用于记录哈希桶当中的有效数据个数
	};

 指针数组我们使用 vector 的容器来实现,这样就方便我们对这个数组进行管理。每个结点要存储的是 本节点的 key 和 value 以及 指向下一个 结点的指针。

insert()插入函数

 同样要先按照key值大小计算出 这个结点的 hash 值,然后再 对应数组下标位置进行链表的 头插或者尾查,两种插入都行。因为,后头插的元素可能会被先找到,但是我们不知道哪一个元素被查找的次数多,所以用哪一种都行。

核心插入逻辑:
 

			// 计算hash值
			size_t hash = kv.first % _table.size();

			Node* newnode = new Node(kv);
			newnode->_next = _table[hash];
			_table[hash] = newnode;
			++_n;

			return true;

当然,因为哈希桶也是一种 开放地址法,那么只要是开放地址法都是需要扩容的,在哈希桶当中就是要扩容 指针数组。因为当数据很多的时候,就会导致 每一个 指针数组元素下面的链表会很长,那么对于查找时候的效率就会降低

 我们应当控制 负载因子 尽量到 1 ,也就是让每个桶当中都尽量存储一个 数据,这样的话,查找的效率就保持在一个很高的 标准。

 而且扩容还有一个好处,我们发现一种极端情况,就会有大量的数据集中在一个桶当中,这种情况虽然概率小,但是还是有可能发生的,那么当我扩容的时候,每一个桶当中的数据都会得到分散,这样可以缓解一个桶的压力

 库容的思路不能再使用 闭散列开发地址法当中,在把 旧表当中的数据插入到 新表当中,直接复用 insert()函数,这样不好

 以为按照我们上述写 insert 的逻辑,如复用的话,每一次复用,都需要重新使用 new Node(kv) 开辟一个新结点出来,而且还有把旧表当中的该结点给释放了,但是,在旧表当中的结点空间是完全可以复用的

所以,我们在依次变量的时候就 直接依次遍历链表和其中的结点,把每个结点直接挪到 新表的 指针数组后面,按照新表当中的 插入规则插入即可。 

 扩容代码:
 

// 负载因子 到 1 就扩容(每一个桶当中都有数据)
			if (_n == _table.size())
			{
				size_t newsize = _table.size() * 2;
				vector newTable;
				newTable.resize(newsize, nullptr);

				for (int i = 0; i < _table.size(); i++)
				{
					Node* cur = _table[i];
					while (cur)
					{
						Node* next = cur->_next;  // 保存链表的下一个结点

						// 头插到新表当中
						size_t hashi = kv.first % newTable.size();
						cur->_next = newTable[hashi];
						newTable[hashi] = cur;

						// 向链表后迭代
						cur = next;
					}
				}

				// 交换 两个表在 对象当中的指向,让编译器 帮我们释放旧表的空间
				_table.swap(newTable); 
			}

 上述使用现代写法,直接交换指针,出了insert()作用域,让编译器把旧表空间释放掉,但是释放的 vector 只是释放 vector 容器的空间,并不会释放 Node* 结点空间。vector 的销毁是调用 delete[] ,而 delete[] 分为两种,先要调用容器中与元素的析构函数,遍历析构各个元素,但是这个容器当中的 每一个元素的类型是一个指针,是一个内置类型,内置类型没有析构函数。所以不会调用析构函数,直接进行第二部,直接对 vector 空间释放。

 只有 类似 vector _table 这种结构,那么vector当中的每一个元素存储的都是一个 list 自定义类型,那么都是有析构函数的,那么我们上述 扩容逻辑使用的是 这种结构的话,就不能复用 结点了, delete[] vector 就会先遍历调用其中每个元素的析构函数来进行析构

哈希表当中的扩容所带来的消耗是无法避免的,但是其实哈希表扩容次数并不多,按照我们上述书写的逻辑,一次扩容两倍的话,2^20 大概 100w,也就是说插入100w 个数据 只用扩容 20次,已经非常的少了。

但是,虽然哈希表的扩容优化不好写,但是还是有人提出:
 

C++ - 开散列的拉链法(哈希桶) 介绍 和 实现_第2张图片

 比如上述,他是用两个表,一个是当前的表,一个是扩容之后的表,当 我们想要 没有桶的位置(比如指针数组下标1位置)插入一个数据的时候,就直接把这个桶移到 扩容的 指针数组当中的对应位置。他用这样的方式,相当于是把 一次扩容的消耗,分散到了每一次插入数据当中,那么在用户体验来说,就大大增强了

 但是这种方法实现起来非常麻烦,其中还有很多细节,我们就是用之前实现的扩容逻辑就已经很好用了。

写一个 print()函数方便查看 哈希桶当中的数据:
 

		void Print()
		{
			for (size_t i = 0; i < _table.size(); i++)
			{
				printf("[%d]->", i);
				Node* cur = _table[i];
				while (cur)
				{
					cout << cur->_kv.first << "->";
					cur = cur->_next;
				}

				printf("NULL\n");
			}
		}

insert()代码:

		bool insert(const pair& kv)
		{
			if (find(kv.first))
			{
				return false;
			}

			// 负载因子 到 1 就扩容(每一个桶当中都有数据)
			if (_n == _table.size())
			{
				size_t newsize = _table.size() * 2;
				vector newTable;
				newTable.resize(newsize, nullptr);

				for (int i = 0; i < _table.size(); i++)
				{
					Node* cur = _table[i];
					while (cur)
					{
						Node* next = cur->_next;  // 保存链表的下一个结点

						// 头插到新表当中
						size_t hashi = cur->_kv.first % newTable.size();
						cur->_next = newTable[hashi];
						newTable[hashi] = cur;

						// 向链表后迭代
						cur = next;
					}
				}

				// 交换 两个表在 对象当中的指向,让编译器 帮我们释放旧表的空间
				_table.swap(newTable); 
			}

			// 计算hash值
			size_t hashi = kv.first % _table.size();

			Node* newnode = new Node(kv);
			newnode->_next = _table[hashi];
			_table[hashi] = newnode;
			++_n;

			return true;
		}

find()函数

 哈希桶的find()查找函数,先利用哈希函数计算链表起始位置,然后就是一个链表的遍历查找。

		Node* find(const K& key)
		{
			size_t hash = key % _table.size();
			Node* cur = _table[hash];
			while (cur)
			{
				if (cur->_kv.first == key)
					return cur;
				
				cur = cur->_next;
			}
			return nullptr;
		}

erase()函数

 删除结点函数,在寻找结点这一块,我们不复用 find()函数,因为find()函数返回的是该结点指针,但是我们删除链表当中的结点需要修改链接关系,需要找到该结点的上一个结点,或者是指针数组当中元素,所以我们可以字节写查找逻辑:
 

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

析构函数

 因为,指针数组当中存储的是一个 Node* 是一个指针,也就是一个内置类型,在释放的时候编译器对内置类型不处理,但是对自定义类型会去调用这个自定义类型的析构函数

所以,因为 _table 的类型是 vector ,vector 当中的数组空间是属于 vector 自定义类型的空间,那么回去调用vector 的析构函数,但是对于 Node* 就不会了,所以我们要写析构函数把 哈希桶,也就是每一个 指针数组元素指向的链表的空间给释放了。

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

把 哈希当中的key 值转换成适用多多种类型的仿函数写法

 当然,哈希表当中不能可能只存储 int 这一种类型,如果是 string类,也是有自己的比较方式的,所以,我们可以像 在优先级队列当中,实现多种类型的适配仿函数,利用模版参数的控制来控制key 值的取出方法:

template
struct DefaultHashFunc
{
	size_t operator()(const K& key)
	{
		// 不管是什么类型的,都转换成 size_t
		// 不管是 负数还是正数,都转换为 正数
		return (size_t)key;
	}
};

// string 的特化
template<>
struct DefaultHashFunc
{
	size_t operator()(const string& str)
	{
		// 字符串转 int 的算法
		int hash = 0;
		for (auto& ch : str)
		{
			hash *= 131;
			hash += ch;
		}

		return hash;
	}
};

template>
	class hash
	{
		typedef HashNode Node;
	public:
····································
····································
····································


bool insert(const pair& kv)
		{
			HashFunc hf;

			if (find(kv.first))
			{
				return false;
			}

			// 负载因子 到 1 就扩容(每一个桶当中都有数据)
			if (_n == _table.size())
			{
				size_t newsize = _table.size() * 2;
				vector newTable;
				newTable.resize(newsize, nullptr);

				for (int i = 0; i < _table.size(); i++)
				{
					Node* cur = _table[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;
					}
				}

				// 交换 两个表在 对象当中的指向,让编译器 帮我们释放旧表的空间
				_table.swap(newTable); 
			}

			// 计算hash值
			size_t hashi = hf(kv.first) % _table.size();

			Node* newnode = new Node(kv);
			newnode->_next = _table[hashi];
			_table[hashi] = newnode;
			++_n;

			return true;
		}

 把各个函数需要取出key 值的方法都用仿函数封装。

 具体的实现逻辑,可以看看下面博客当中对 闭散列开放式地址法哈希表当中对 仿函数的书写:

C++ - 开放地址法的哈希介绍 - 哈希表的仿函数例子_chihiro1122的博客-CSDN博客

 哈希桶的完整代码:

namespace hash_bucket
{
	template
	struct HashNode
	{
		T _data;
		HashNode* _next;  // 每个结点都有一个指针指向这个结点的下一个结点

		HashNode(const T& data)
			:_data(data)
			,_next(nullptr)
		{}
	};

	template>
	class hash
	{
		typedef HashNode Node;
	public:
		hash()
		{
			_table.resize(10, nullptr);
		}

		bool Insert(const T& data)
		{
			HashFunc hf;
			KeyOfT kot;

			if (find(data))
			{
				return false;
			}

			// 负载因子 到 1 就扩容(每一个桶当中都有数据)
			if (_n == _table.size())
			{
				size_t newsize = _table.size() * 2;
				vector newTable;
				newTable.resize(newsize, nullptr);

				for (int i = 0; i < _table.size(); i++)
				{
					Node* cur = _table[i];
					while (cur)
					{
						Node* next = cur->_next;  // 保存链表的下一个结点

						// 头插到新表当中
						size_t hashi = hf(kot(cur->_data)) % newTable.size();
						cur->_next = newTable[hashi];
						newTable[hashi] = cur;

						// 向链表后迭代
						cur = next;
					}
				}

				// 交换 两个表在 对象当中的指向,让编译器 帮我们释放旧表的空间
				_table.swap(newTable); 
			}

			// 计算hash值
			size_t hashi = hf(data) % _table.size();

			Node* newnode = new Node(data);
			newnode->_next = _table[hashi];
			_table[hashi] = newnode;
			++_n;

			return true;
		}

		Node* find(const K& key)
		{
			HashFunc hf;
			KeyOfT kot;

			size_t hash = hf(kot(key)) % _table.size();
			Node* cur = _table[hash];
			while (cur)
			{
				if (kot(cur->_data) == key)
					return cur;
				
				cur = cur->_next;
			}
			return nullptr;
		}

		bool erase(const K& key)
		{
			HashFunc hf;
			KeyOfT kot;

			size_t hashi = hf(kot(key)) % _table.size();
			Node* cur = _table[hashi];
			Node* prev = nullptr;
			while (cur)
			{
				if (hf(kot(cur->_data)) == key)
				{
					if (!prev)
					{
						_table[hashi] = cur->_next;
					}
					else
					{
						prev->_next = cur->_next;
					}
					delete cur;
					return true;
				}
				prev = cur;
				cur = cur->_next;
			}
			return false;
		}

		void Print()
		{
			for (size_t i = 0; i < _table.size(); i++)
			{
				printf("[%zd]->", i);
				Node* cur = _table[i];
				while (cur)
				{
					cout << kot(cur) << "->";
					cur = cur->_next;
				}

				printf("NULL\n");
			}
			cout << endl;
			cout << endl;
		}

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

	private:
		vector _table;  // 指针数组
		int _n = 0;   // 用于记录哈希桶当中的有效数据个数
	};
}

 结言

 上述实现的 哈希桶,还有很多的封装工作没有做,因为上述的哈希桶准备用来实现 unordered_set 和  unordered_map 的底层实现,所以关于 key 值不能修改,或者是迭代器在  unordered_set 和  unordered_map 的 封装博客的当中还要对 哈希桶进行 改进。

你可能感兴趣的:(哈希算法,c++,散列表)