数据结构(14)——哈希表(1)

欢迎来到博主的专栏:数据结构
博主ID:代码小豪

哈希表的思想

在以往的线性表中,查找速度取决于线性表是否有序,如果是无序的线性表,我们就要从表头开始匹配key值是否相同,因此时间复杂度取决于线性表的元素个数,为O(N)。

如果线性表有序,则可以通过二分查找法大大的减少查找时间,时间复杂度为O(logN),这个时间复杂度看起来让人满意,但是我们要考虑到一点,那就是保持线性表的有序性是要付出代价的,使用排序算法也有O(N*logN)的时间复杂度。

即使是使用关联式容器,它的查找速度也是O(logN),但是好处在于插入和删除元素并不会破坏查找速度。但是查找的速度就局限于此了吗?

已现实为例,如果我们想要在城里找到大舅妈,肯定不会遍历整个城的街道,从街道口走到街道尾去找大舅妈的家,我们会先知道大舅妈的住址,直接去这个住址就能找到大舅妈。

这就是哈希表的查找的方式了,将key值放在映射的地址处,这样查找key值就不用从头开始遍历了。举个例子,假设现在有一个能容纳十个元素的哈希表。我们规定key值的个位数就是映射的地址值,即让1位于1号,2位于2号。以此类推,而10则放在0号。

在这里插入图片描述

假设我们要查找13,那么去3号处就能找到对应值,查找速度为O(1)。这就是哈希表查找的优势。

映射方法(哈希函数)

哈希表中的映射方法叫做哈希函数,以上表为例,其哈希函数为F(key)=key%10。于是F(13)=3,F(25)=5。一个好的哈希函数很重要。这将决定哈希表的查找,插入,删除的速率(最主要还是查找)。哈希函数需要能将key值的数据转换成整形的能力。

但是并非所有类型都可以和整形转换。比如string就不能通过F(key)=key%10的方式获得映射值,此时我们就需要设计一个使用于string的哈希函数,比如可以设计让string的所有元素相加为映射值的哈希函数,即F(string)={a1+a2+……+an}。实际上字符串的哈希函数设计绝没这么简单,感兴趣可以在网上搜索。

那么自定义类型当然也要依靠自定义的哈希函数才能获得其映射值,通常哈希函数的设计需要考虑一下几点:
(1)选取的key值的独特性,比如我们在数据库中查找一个人,如果选择“姓名”作为映射值,那么肯定效率不佳,因为全国同名同性的人是在太多了,如果我们选择“生日+姓名”作为映射值就会好很多。

(2)哈希函数的结果要尽可能的分散,假设现在有N个元素要插入到哈希表,那么它们的映射值越分散在哈希表就越好,如果N个元素经过哈希函数的运算结果的映射值都是1,那么肯定是毫无效率可言的。

(3)哈希函数的计算结果要包含在哈希表的域中,如果一个哈希表能容纳100个元素(即可容纳映射值0-99的元素),那么如果一个key值计算出映射值为101,那么肯定是没法插入的。

在c++标准库中的unordered_map,就是用哈希表为底层的容器,其允许我们传入自定义的哈希函数,已适配那些自定义的类型的映射值计算。

template < class Key,                                    // unordered_map::key_type
           class T,                                      // unordered_map::mapped_type
           class Hash = hash<Key>, //可以传入自定义的哈希函数// unordered_map::hasher
           class Pred = equal_to<Key>,                   // unordered_map::key_equal
           class Alloc = allocator< pair<const Key,T> >  // unordered_map::allocator_type
           > class unordered_map;

除留余数法

除留余数法是一个比较通用,而且简单的哈希函数。其主要方法为:

设哈希表可容纳最大地址数为m,取一个不超过m的值p作为除数,其哈希函数为:hash(key)=key%p。

比如当前哈希表的最大地址数为50,待插入的key的哈希值为313,则其映射值为313%50=13。

在后续哈希表的底层设计中,博主将采用这个方法。

哈希表

hash表的底层可以用序列式容器vector,或者deque,因为哈希表的映射值与下标很适配。

template<class key,class value>
class hash_tab
{
public:
	hash_tab()
	{
		_tab.resize(10);
		_n = 0;
	}

	typedef pair<key, value> value_type;
	
private:
	vector<hash_data<value_type>> _tab;
	size_t _n;//当前有效数据
};

而哈希表每个元素都要存储两个数据,分别是data,以及状态state。由于vector一次性开了十个元素的空间,因此有些空间是没有有效数据的。这些没有有效数据的元素的状态记为空(empty),如果元素具有有效数据则记为有(exist),如果元素的有效数据被删除,则记为删除(delete)。

enum state
{
	EXIST,//存在映射
	DELETE,//删除映射
	EMPTY//空的映射
};

template<class T>
struct hash_data
{
	hash_data()
	{
		_state = EMPTY;
	}

	hash_data(const T& data,state state)
	{
		_data = data;
		_state = state;
	}

	const hash_data& operator=(const hash_data& hashdata)
	{
		_data = hashdata._data;
		_state = hashdata._state;
		return *this;
	}

	T _data;
	state _state;
};

insert

insert的方式如下:
(1)先通过hash函数计算出key值的映射地址处。博主采用除留余数法。
(2)将元素插入到映射值对应的地方。

比如现在插入的元素key值为60,由于当前哈希表的最大空间为10(默认构造函数),因此除留余数法为hash(60)=60%10=0。插入在0下标处。
在这里插入图片描述
ok,现在我们来面临第一个问题,如果我们现在插入30,那么它该插入在什么位置呢?根据哈希函数hash(30)=30%10=0。那么它应该插入在映射值为0的下标处,但是0下标处已经存在60这个数据了,那么该如何处理呢?

这种情况被称为“哈希冲突”,即不同的key值(30与60),但是经过哈希函数计算后,得到了相同的映射值(0)。如果想要减少哈希冲突,优化哈希函数是一个不错的选择,但是这并不能解决哈希冲突,通常我们会用两种方法解决哈希冲突,闭散列和开散列。这篇文章我们先来了解闭散列的哈希表,在下一篇中博主再谈谈开散列的哈希表如何实现。

闭散列

闭散列:也称开放地址法,当发生哈希冲突时。探测哈希表的空位置,将key值插入在冲突位置的下一个空位置处。探测方法也分为多种

方法(1)线性探测

线性探测:从冲突位置开始,依次往后探测,直到找到下一个新位置为止。以上例为例,由于30插入的位置与60发生了哈希冲突,那么30从0下标开始线性探测后续位置,直到遇到第一个空位置(EMPTY),DELETE也算作空位置。
在这里插入图片描述
如果此时在插入一个20,与60依然发生了哈希冲突,根据线性探测的方法,20应该插入在下标2的位置。
在这里插入图片描述
假如这个哈希表满了,那么再次插入一个key值会发生什么事呢?
在这里插入图片描述
根据线性探测的规则,插入2的位置会陷入一个死循环,因为在这个哈希表中已经不存在空位置了,在这个哈希表中无论探测多少次都找不到位置。

实际上,当哈希表中的元素占比整个空间越来越多时,哈希冲突发生的几率会越来越高,而每次发生哈希冲突时,都会带来额外的时间开销(线性探测)。因此,最好的解决方法是控制元素与空间之间的占比。

负载因子

我们将哈希表中元素与空间之间的占比成为负载因子。即:

负载因子=元素个数÷哈希表的总容量

负载因子与元素个数成正比,与哈希表的总容量成反比,当负载因子小时,发生哈希冲突的几率低,插入效率高,但是空间利用率也低。当负载因子大时,发生哈希冲突的几率大,插入效率低,但是空间利用率高。因此控制负载因子在一定的数值内也是很重要的。

通常来说,采取线性探测的哈希表,其负载因子应该控制到0.7~0.8。如果负载因子超过这个区间,就要对哈希表进行扩容。以降低哈希表的负载因子。

扩容

哈希表的扩容策略是异地扩容,即先构造一个新的哈希表,这个新的哈希表是原哈希表的二倍,然后再讲原哈希表的元素重新插入在新哈希表当中。最后交换新旧两个哈希表。那么为什么要这样做呢?与其说异地扩容的好处,不如讲讲原地扩容的坏处。

假如我们将上例中的哈希表扩容两倍,即新哈希表的容量为20.
在这里插入图片描述
由于哈希表的最大容量发生了变化,那么哈希函数也会随之变化,因为我们设定的哈希函数为

hash(key)=key%size

由于size从10变成了20,那么哈希函数就变成了key%20。那么哈希表中17的映射值为
hash(17)=17%20=17,这个插入位置就不对。因此更好的方式是新建一个新哈希表。将旧表的内容插入至新表当中。
在这里插入图片描述

	bool insert(const value_type& kv)
	{
		//负载因子过多,需要扩容 负载因子等于size%_n
		if (double(_n) / _tab.size() >= 0.7)
		{
			hash_tab new_tab;
			new_tab._tab.resize(_tab.size() * 2);//建立新表
			for (auto& e : _tab)
			{
				if (e._state == EXIST)//只插入有效值
				{
					new_tab.insert(e._data);//将旧表的值插入到新表当中
				}
			}
			_tab.swap(new_tab._tab);//交换新旧两表
		}

		size_t hashnum = kv.first % _tab.size();//哈希函数——除留余数法
		while (_tab[hashnum]._state ==EXIST )//线性探测
		{
			hashnum++;
			hashnum %= _tab.size();
		}
		_tab[hashnum] = hash_data<value_type>(kv, EXIST);//插入新值
		_n++;
		return true;
	}

find和erase

find函数的方法如下:

通过哈希函数计算出映射值,找到映射的位置,然后线性检测找到正确的key值,然后返回节点,如果线性检测到空节点(DELETE不算空节点,仅EMPTY)。就说明哈希表中不存在对应key值,返回nullptr。

在这里插入图片描述

	hash_data<value_type>* find(const key& key)
	{
		size_t hashnum = key % _tab.size();
		while (_tab[hashnum]._state != EMPTY)
		{
			if (key == _tab[hashnum]._data.first&&_tab[hashnum]._state!=DELETE)
			{
				return &_tab[hashnum];
			}
			hashnum++;
			hashnum %= _tab.size();
		}
		return nullptr;

	}

erase的实现就更简单,我们先通过find查找到待删除的元素,然后将该元素的状态调整成DELETE就可以了,因为对这个元素的data进行修改不是一个明智的选择。这会影响到映射关系。


bool erase(const key& key)
{
	size_t hashnum = key % _tab.size();
	hash_data<value_type>* ptr=find(key);
	if (ptr == nullptr)
		return false;
	ptr->_state = DELETE;
	_n--;
	return true;
}
  • 14
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

代码小豪

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值