初识C++ · 哈希表

目录

前言:

对于哈希的思考 + 实现


前言:

哈希,部分说法叫散列,在编程里面哈希是一种思想,即一种映射,像数学函数一样,每个不同的值对应每个不同的值,数学里面使用函数来实现哈希,即值映射,但是在C++里面,我们可以使用不同的对象来映射不同的值,今天介绍的是用整型 自定义类型(string)来介绍哈希。


对于哈希的思考 + 实现1

计数排序的基本思想也是一种映射,但是有的时候对于开的空间大小来说,是不太靠谱的,比如只排序 100001 1 0,可能就会开10002个空间,这时候空间浪费就很大了,那么,我们不管三七二十一,就开10个空间,我们采用 除留余数法,即i = 该值对空间的大小取模,按照数组的规律去放。

那么现在新问题来了,10001 1对10取模的之后的大小都是1,占据的就是同一个位置,那么怎么办呢?

此时引入一个概念:哈希冲突/碰撞,即不同的值映射的值变成一样的了,这个在数学上来说是一个x映射了多个y,那么在C++里面我们应该如何解决哈希冲突呢?

第一种方法是闭散列的线性探测法/二次探测法:

比较主流的是这两种探测方法,简单理解这两种方法就是,强行抢占别人的位置,比如10001先映射,那么10001就在数组下标为1的位置,1就在数组下标为2的值,那么2怎么办呢?同理可得,已然是抢占别人的位置。

线性探测的意思就是,一个空间一个空间的去查找,从自己的映射索引开始,有空的空间就进去,那么二次探测的就是i^2的去找空的,这样的好处是为了解决查找慢的问题,我们今天就使用线性探测即可,二次探测需要改动的不大。

那么现在我们能知道的是哈希可以使用数组来实现,那么我们就使用Vector,同分析红黑树封装map + set一样,我们分析一样不同类之间的关系。

对于节点来说,需要存放哪些?要知道,我们实现的是增删查改,节点因为使用的是数组,也不好说置为空什么的,那么我们就可以实现伪删除,即用枚举来表示状态,删除的时候改变枚举状态即可,所以节点类:

enum State
{
	EXIST, DELETE, EMPTY
};

template <class K, class V>
struct HashData
{
	pair<K, V> _kv;
	State _state = EMPTY;

};
template<class K, class V>
class HashTable
{
public:
	HashTable()
		:_n(0)
	{
		_table.resize(10);
	}

private:
	vector<HashData<K, V>> _table;
	size_t _n;
};

在哈希表里面顺序表一个,用来计算有多少个元素的成员变量一个,表的基本创建就完成了,然后初始化一下,现在就是进行增删查改了。

查找:

查找一般是最简单的,找key就可以了,那么找的方式应该是从值的对应映射的下标索引开始,一直找,找到的条件是值对上了,并且状态也对上了,状态一定要是EXITST的,那么我们思考一个问题,哈希表的大小是哪个?是顺序表的size还是顺序表的capacity?我们这里确定哈希表的大小是size,如果是capacity的话,一旦扩容了,值的映射关系会被打乱,保险期间使用size作为哈希表的大小,那么为了不”越界“,索引可以对size取模,保证能在size里面找即可:

//查找
HashData<K, V>* Find(const K& key)
{
	Hash hs;
	size_t hashi = hs(key) % _table.size();
	while (_table[hashi]._state != EMPTY)
	{
		//因为是伪删除
		if (_table[hashi]._state == EXIST
			&& _table[hashi]._kv.first == key)
		{
			return &_table[hashi];
		}
		hashi++;
		hashi %= _table.size();
	}
	return nullptr;
}

增:

增的前提是不能有一样的,也就是要去重吗,这点还没讲,因为unordered_map + unordered_set的底层就是用哈希来实现的,这里简单提及一下它们和map + set的区别,90%都是一样的,可能性能有区别,map + set底层中序遍历出来就是自动有序,但是这个不是,打印出来不是有序的,但是它们的函数基本上都是联会贯通的,没什么特别需要注意的。

增去重我们就用Find,如果找的到就说明有,就直接返回就可以了,下面就是找空,有就插进去

,那么有一个新问题,扩容怎么办?我们在原表的基础上直接扩容可以吗?答案是不太可以,因为你要取值吧?取值然后重新映射吧?那么把原来的值重新覆盖了怎么办?

所以这里的解决方案是:
重新创建一个新的哈希表对象,复用插入代码,然后现代写法进行交换就可以了。

那么什么情况需要扩容呢?负载因子达到0.7的时候就可以扩容,当然也可以0.8,负载因子的计算是已有的变量数 / 哈希表的大小,因为是整型相除,到不了小数位,所以* 10解决,因为负载因子大了之后,查找 插入的效率都变低了,就需要扩容:

//插入
bool Insert(const pair<K, V>& kv)
{
	//去重
	if (Find(kv.first))
	{
		return false;
	}

	//扩容 -> load_factor > 0.7的时候
	if (_n * 10 / _table.size() >= 7)
	{
		size_t hashi = 0;

		//_table.resize(_table.size() * 2);
		//这种可能出现的问题是,修改一次之后,再取值可能已经改变了 -> 因为覆盖
		//第一种解决方案 ->创建一个新表 然后使用新的映射关系
		//第二种解决方案 ->直接复用现在没有完成的Insert代码
		HashTable<K, V> newHt;
		newHt._table.resize(_table.size() * 2);
		//遍历的是旧表
		while (hashi != _table.size())
		{
			if (_table[hashi]._state == EXIST)
			{
				newHt.Insert(_table[hashi]._kv);
			}
			hashi++;
		}
		_table.swap(newHt._table);
	}

	Hash hs;

	//插入
	size_t hashi = hs(kv.first) % _table.size();
	while (_table[hashi]._state == EXIST)
	{
		hashi++;
		hashi %= _table.size();
	}

	_table[hashi]._kv = kv;
	_table[hashi]._state = EXIST;
	_n++;

	return true;
}

删:

这个删除是目前位置最简单的删除,改状态即可:

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

对于整型可以取模,那么对于自定义来说,比如string,我们取模不了,但是,我们就可以转为整型,因为每个字符对应的也是int类型,那么将aabb bbaa转为整型之后,整型又是一样的,这我们应该处理?这里就简单介绍字符串哈希算法,乘某个特殊的数,比如131,就可以实现字符相同的字符串转换出不同的整型:

template<class K>
struct SHAlgorithm
{
	size_t operator()(const K& key)
	{
		return size_t(key);
	}
};

template<>
struct SHAlgorithm<string>
{
	size_t operator()(const string& s)
	{
		size_t hash = 0;
		for (auto e : s)
		{
			hash *= 131;
			hash += e;
		}
		return hash;
	}
};

这是使用仿函数来处理,如果不用仿函数,到时候判断还有点麻烦,这里也是用到了模板特例化,语法有点遗忘,可以回想一下,然后就可以处理string一类的了。


实现2

前面提及,第一种解决方式是闭散列的开放定址法,那么第二种就是哈希桶,即我们将余数相同的数放在一个桶里面,所以我们实现的方式是节点改一下,即将节点类型改为:

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

	HashNode<K, V>* _next;
	pair<K, V> _kv;
};

可以很形象的想成为是一个链表,但是这个链表是我们自己实现的,那为什么我们不用现成的链表呢?因为后面我们要通过哈希桶来封装unordered_map + set的,到时候如果用的是链表,我们还要实现链表的迭代器,就十分麻烦了对吧。

那么增删查,这里其实也没啥了增加无非就是链表的头插,删除无非就是节点跳跃连接一下,查找就基本一样了:

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

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

	//扩容 -> load_factor达到1时进行扩容
	if (_n == _table.size())
	{
		HashTable<K, V> newHash;
		newHash._table.resize(_table.size() * 2);
		size_t hashi = 0;
		//插入顺序表的每个头
		for (int i = 0; i < _table.size(); i++)
		{
			//插入每一个元素 而不是直接移植过来
			//插入每一个链条
			Node* cur = _table[i];
			while (cur)
			{
				newHash.Insert(cur->_kv);
				cur = cur->_next;
			}
		}
		_table.swap(newHash._table);
	}
	//插入方式使用头插
	Hash hs;
	size_t hashi = hs(kv.first) % _table.size();
	Node* newnode = new Node(kv);
	newnode->_next = _table[hashi];
	_table[hashi] = newnode;
	_n++;
	
	return true;
}

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

感谢阅读!

  • 7
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值