一篇文章带你实现 哈希表(Hash Table)

序言

 在这里有 n 个数值范围在 0 ~ 9 的数字,我希望你统计每个数字的出现次数,你可以在线性时间内做到吗❓
 我们提供一个思路⭐️,我们开一个 10 个 int 大小的数组 int q[10],依次遍历 n 个数字,每遍历一个数字 num ,我们就执行一次 q[num]++,这样就很快统计出了每一个数字的出现次数了。这就是 哈希表 的简单使用,将输入映射到数组的指定位置,该位置存储输入的出现次数。
 那如果数据的范围在 0 ~ 10000000,你不可能开这么大个空间的数组吧?😢那么我们对每个数字取个 %,收缩数的范围,但是就比如 %10,你的 0 是不是和 10 位置冲突了呀?
 有了问题就需要解决,请大家带着这些疑问进入本篇内容。😁


1. 哈希表的介绍

1.1 哈希表的概念⛄️

 哈希表通过 哈希函数 将键(Key)映射到表中的一个位置(通常是数组的索引),从而实现对数据的快速查找、插入和删除。哈希函数是哈希表的核心,它决定了键如何被映射到哈希表的索引上。

1.2 哈希函数☀️

 哈希函数是哈希表的关键组成部分,它接受一个输入(即键),并输出一个固定长度的哈希值。这个哈希值再经过映射,对应的就是数组的索引,一个好的哈希函数应该具备以下特性:

  • 确定性:相同的输入必须产生相同的输出,类似于函数的特性,一个键值只能对应一个哈希值
  • 高效性:计算哈希值的过程应该尽可能快
  • 均匀性:理想的哈希函数应该能够将不同的输入映射到哈希表的不同位置,以减少哈希冲突的发生。然而,由于哈希表的长度是有限的,完全避免冲突是不可能的,但好的哈希函数应该能够尽量减少冲突的发生。

哈希函数主要有以下两个步骤:

哈希化

 哈希化就是将任意类型的输入,通过某种算法转换成固定长度输出(通常是一个整数)的函数,这个整数就是输入的哈希值。上面的例子中,输入本身就是整数,所以不需要转换,但是在其他场景中,我们的键值可能会是 字符串,浮点数,以及其他自定义类型。
 就拿字符串举例子,在这里将 abcd 转换为整数的方法有,取第一个字符的 ASCII,这种方法极易与其他字符串的哈希值冲突,又或者是将所有字符的 ASCII 相加,这也不理想,也很容易冲突。现在字符串哈希化的方法已经相对成熟了,在这里我给大家介绍一种算法 — BKDR算法

	size_t hash = 0;
	for (auto& ch : str) {
		hash *= 131;
		hash += ch;
	}

	return hash;

每次加字符串的 ASCII 前,该值先乘上 131.

映射

 获取哈希值后,我们就需要将该值映射到我们的指定区间的某个位置上,一般采取的运算就是取模运算(模的大小就是哈希表的大小,这样才能让表的每一个位置被映射到)。

1.3 哈希表的优缺点

优点:

  • 查找效率高:在平均情况下,哈希表的查找时间复杂度为O(1)。
  • 插入和删除操作快:哈希表支持快速的插入和删除操作。
  • 支持动态扩容:当哈希表中的元素数量超过一定阈值时,可以通过重新哈希和扩容来保持性能。

缺点:

  • 空间利用率可能较低:哈希表需要预留一定的空间以处理哈希冲突,这可能导致空间利用率降低。
  • 对哈希函数要求高:哈希函数的设计直接影响哈希表的性能。
  • 不支持顺序遍历:哈希表不支持按照键值对的插入顺序进行遍历。

2. 哈希冲突

 在哈希表的概念中我们了解到,由于哈希表的长度是有限的,完全避免冲突是不可能的。 当我们对哈希值进行映射(取模运算)时,不同的哈希值可能会映射到同一个位置,就比如,一个大小在 10 int 的哈希表,当哈希值是 0,10,20 的键值同时存在时,都会映射到下标为 0 的位置。
 哈希表对这种情况有两种解决方案,分别是 开放定址法(闭散列)链地址法(开散列)

2.1 开放定址法

 当冲突发生时,在哈希表中 寻找下一个空位置,直到找到空位置或者遍历完整个表。具体方法包括线性探测法、二次探测法和再哈希法等。在这里我们着重介绍线性探测法:

在这里我们初始化了一个大小为 6 的数组,下标的范围是 0 ~ 5,现在分别插入 6,13,4,这三个元素在数组的位置是:
在这里插入图片描述

现在需要插入 12 ,12 % 6 = 0,可以发现下标为 0 的位置已经被 6 所占有了,所以,12只能向后移动,寻找其他位置,下标为 1 的位置也被 13占有了,所以继续向后寻找,直到找到了下标为 2 的位置,所以 12 就放置在该位置:
在这里插入图片描述
现在又需要插入 8, 8 % 6 = 2,但是下标为 2 的位置已经被 12 占有了,所以向后探测,找到下标为 3 的位置:
在这里插入图片描述
这就是线性探测发,总结起来一句话,我的位置被霸占了,我就去霸占别人的位置😡。

当使用该方法时,插入的元素个数 / 哈希表的大小 > 0.75 时,就需要进行扩容操作,避免拥挤。

2.2 链地址法

 这个方法比较有意思,我们的位置冲突了,那我们就凑活过,采用链表的形式连接起来,共享一个下标。
在这里插入图片描述
当寻找某个键值时,先映射到该下标,在到该下标对应的链表寻找该键值。

该方法,插入的元素个数 / 哈希表的大小 > 1 时,才进行扩容操作,比起上一种方法更宽容些,是因为理想情况下,一个链表只有一个元素,查找效率依然很高。

综上当数据规模较大时使用链地址法,这样就可以避免在查找时,不断地向后探测,时间花销大;相反,数据规模较小时,可以使用开放定址法。


3. 实现哈希表

我们在这里实现链地址法类的哈希表。

3.1 元素的结构

哈希表采取的是数组结构,因为我们实现采取链地址法的方式,所以数组的每一个元素应该是一个链表结构:

template<class T>
	struct Elem {
		Elem(const T& val = T())
			: _val(val)
			, _next(nullptr)
		{}

		T _val;
		Elem* _next;
	};

3.2 仿函数 — 键值哈希化

因为我们采取的是泛型编程,所以键值有可能是整数,有可能是字符串还可能是其他自定义类型,所以为了使对应类型的值哈希化,需要传入仿函数,该仿函数来进行相应的操作,默认可以处理整形以及字符串类型:

	// 哈希函数
	template<class T>
	struct HashFunc {
		size_t operator()(const T& key) {
			return key;
		}
	};

	// 哈希函数(string 特化)
	template<>
	struct HashFunc<string> {
		size_t operator()(const string& key) {
			size_t hash = 0;
			for (auto ch : key) {
				hash *= 131;
				hash += ch;
			}

			return hash;
		}
	};

3.3 成员变量以及构造函数

我们的成员变量包括一个指针数组以及 _size,来记录插入元素的数量,便于后续扩容操作。
构造函数为:

HashBucket(size_t capacity = 10)
	: _ht(capacity, nullptr)
	, _size(0)
{}

3.4 Find 函数

之所以将该函数放到前面介绍,是因为后续在插入时,我们就必须该键值是否已经存在于哈希表中,如果不存在才允许插入操作。主要的操作是先通过哈希值映射到指定的下标,再遍历该下标对应的链表,查看是否存在该键值。

// 查找
bool Find(const Key& key) {
	Hash _hs;
	
	size_t hashi = _hs(key) % _ht.size();
	Elem* cur = _ht[hashi];

	while (cur != nullptr) {
		if (cur->_val == key) {
			return true;
		}
		cur = cur->_next;
	}

	return false;
}

3.5 Insert 函数

在继续插入操作前,首先需要判断该键值是否已经存在于哈希表中,若不存在才能够正常插入。
若存在还需要判断 元素数量 / 哈希表的大小 > 1 是否成立,若成立需要进行扩容操作。

扩容操作
遍历哈希表中每一个元素,重新映射到新的哈希表数组(容量为原来的两倍)中, 将该结点直接从原来的表在接到新的表上,避免重复的内存申请和释放,直到所有结点遍历完成。然后将新的哈希表和老的哈希表内容交换,析构老的表。

最后是插入操作,直接链表的头插方式,非常简单,别忘了 ++_size

// 插入
bool Insert(const T& val) {
	if (it != end()) return false;
	
	Hash _hs;

	Elem* newnode = new Elem(val);

	// 如果超过负载因子,则扩容
	if (_size >= _ht.size()) {
		vector<Elem*> _temp(_ht.size() * 2, nullptr);

		for (size_t i = 0; i < _ht.size(); ++i) {
			Elem* cur = _ht[i];
			while (cur) {
				Elem* next = cur->_next;

				size_t hashi = _hs(cur->_val) % _temp.size();
				cur->_next = _temp[hashi];
				_temp[hashi] = cur;

				cur = next;
			}
		}
		_ht.swap(_temp);
		_temp.~vector();
	}

	size_t hashi = _kt(val) % _ht.size();
	// 头插法
	newnode->_next = _ht[hashi];
	_ht[hashi] = newnode;
	_size++;

	return true;
}

3.6 Erase 函数

删除操作先使用哈希函数映射到指定下标位置,之后直接遍历该链表,查看是否存在,若存在删除。因为是单链表结构,所以要使用两个指针,一个指向当前的,一个指向之前的,删除成功时 --_size

// 删除
bool Erase(const Key& key) {
	Hash _hs;
	size_t hashi = _hs(key) % _ht.size();

	Elem* cur = _ht[hashi];
	Elem* prev = nullptr;
	while (cur) {
		if (cur->_val == key) {
			// 非头节点
			if (prev) {
				prev->_next = cur->_next;
			}
			// 头节点
			else {
				_ht[hashi] = cur->_next;
			}

			delete cur;
			_size--;
			return true;
		}
		else {
			prev = cur;
			cur  = cur->_next;
		}
	}

	return false;
}

4. 思考

 在 C++STL 库中 unordered_mapunordered_set 都是由哈希表实现的,但是两者的存储的数据却不同,前者是一个 pair,后者存储的键即是值,值即是键。同是哈希表实现的,表现的却不一致,那是底层上是如何实现的呢 ?答案是采用模板,通过使用模板,STL 能够创建出既灵活又强大的容器,这些容器可以存储几乎任何类型的数据,同时保持高效的数据访问和修改性能。
 欲知后事如何,且看下回分解😎。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值