C++ 哈希表详细讲解(闭散列,开散列)

目录

一、哈希概念

二、哈希冲突

三、哈希函数

四、哈希冲突解决

1.闭散列

1)线性探测

2)扩容

3)二次探测

2.开散列

1)开散列的概念

2)开散列的实现

3)扩容

3.扩展两个问题并加以思考解决

4.开散列和闭散列的比较


一、哈希概念

顺序结构以及平衡树 中,元素关键码与其存储位置之间没有对应的关系,因此在 查找一个元素
时,必须要经过关键码的多次比较 顺序查找时间复杂度为 O(N) ,平衡树中为树的高度,即
O($log_2 N$) ,搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法:可以 不经过任何比较,一次直接从表中得到要搜索的元素
如果构造一种存储结构,通过某种函数 (hashFunc) 使元素的存储位置与它的关键码之间能够建立
一一映射的关系,那么在查找时通过该函数可以很快找到该元素
当向该结构中:
    ● 插入元素
       根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放
    ● 搜索元素
       对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置
       取元素比较,若关键码相等,则搜索成功
该方式即为哈希 ( 散列 ) 方法, 哈希方法中使用的转换函数称为哈希 ( 散列 ) 函数,构造出来的结构称
为哈希表 (Hash Table)( 或者称散列表 )
例如:数据集合 {1 7 6 4 5 9}
哈希函数设置为: hash(key) = key % capacity ; capacity 为存储元素底层空间总的大小。
用该方法进行搜索不必进行多次关键码的比较,因此搜索的速度比较快
提问:按照上述哈希方式,向集合中插入元素 44 ,会出现什么问题?

二、哈希冲突

对于两个数据元素的关键字 key = i key = j(i != j) ,有 i != j ,但有: Hash(i) == Hash(j) ,即: 不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突 或哈希碰撞。
把具有不同关键码而具有相同哈希地址的数据元素称为 同义词
发生哈希冲突该如何处理呢?

三、哈希函数

引起哈希冲突的一个原因可能是: 哈希函数设计不够合理
哈希函数设计原则
   ● 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有 m 个地址时,其值
域必须在 0 m-1 之间
   ● 哈希函数计算出来的地址能均匀分布在整个空间中
   ● 哈希函数应该比较简单
常见哈希函数
1. 直接定址法 --( 常用 )
    取关键字的某个线性函数为散列地址: Hash Key = A*Key + B
    优点:简单、均匀
    缺点:需要事先知道关键字的分布情况
    使用场景:适合查找比较小且连续的情况
2. 除留余数法 --( 常用 )
    设散列表中允许的 地址数为 m ,取一个不大于 m ,但最接近或者等于 m 的质数 p 作为除数,
    按照哈希函数: Hash(key) = key% p(p<=m), 将关键码转换成哈希地址
3. 平方取中法 --( 了解 )
    假设关键字为 1234 ,对它平方就是 1522756 ,抽取中间的 3 227 作为哈希地址;
    再比如关键字为 4321 ,对它平方就是 18671041 ,抽取中间的 3 671( 710) 作为哈希地址
    平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况
4. 折叠法 --( 了解 )
    折叠法是将关键字从左到右分割成位数相等的几部分 ( 最后一部分位数可以短些 ) ,然后将这
    几部分叠加求和,并按散列表表长,取后几位作为散列地址。
    折叠法适合事先不需要知道关键字的分布,适合关键字位数比较多的情况
5. 随机数法 --( 了解 )
    选择一个随机函数,取关键字的随机函数值为它的哈希地址,即 H(key) = random(key), 其中
    random 为随机数函数。
    通常应用于关键字长度不等时采用此法 
6. 数学分析法 --( 了解 )
    设有 n d 位数,每一位可能有 r 种不同的符 号,这 r 种不同的符号在各位上出现的频率不一定
    相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只
    有某几种符号经常出现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散
    列地址。例如:

    假设要存储某家公司员工登记表,如果用手机号作为关键字,那么极有可能前 7 位都是 相同
    的,那么我们可以选择后面的四位作为散列地址,如果这样的抽取工作还容易出现 冲突,还
    可以对抽取出来的数字进行反转 ( 1234 改成 4321) 、右环位移 ( 1234 改成 4123) 、左环移
    位、前两数与后两数叠加 ( 1234 改成 12+34=46) 等方法。
    数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的
    若干位分布较均匀的情况
注意:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突

四、哈希冲突解决

解决哈希冲突 两种常见的方法是: 闭散列 开散列 (下面的哈希函数都是以除留余数法所设计)

1.闭散列

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

1)线性探测

比如 2.1 中的场景,现在需要插入元素 44 ,先通过哈希函数计算哈希地址, hashAddr 4
因此 44 理论上应该插在该位置,但是该位置已经放了值为 4 的元素,即发生哈希冲突。
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止
   ● 插入
          •    通过哈希函数获取待插入元素在哈希表中的位置
          •    如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突, 使用线性探测找到下一个空位置,插入新元素

   ● 删除
      采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素
      会影响其他元素的搜索 。比如删除元素 4 ,如果直接删除掉, 44 查找起来可能会受影
      响。
     
      因为我们在查找一个元素时,通过哈希函数找到其在哈希表中的位置,根据上述线性探测,如 果查找元素在哈希表中的位置为空的话不能断定哈希表中没有该元素,因为右哈希冲突的存在,两个不同的元素会映射在哈希表中相同的位置,而后一个元素则需要进行线性探测向后排查到空位置进行存储,而我们如果再对后一个元素进行查找的话其映射到哈希表中的位置还是不变的,但如果此时我们已经将第一个元素进行删除了的话,该位置为空,但所查找的元素是在后面的位置存储,所以我们不能直接只用EMPTY和EXIST两个状态进行存储状态的表示
      因此 线性探测采用标记的伪删除法来删除一个元素
// 哈希表每个空间给个标记
// EMPTY此位置空, EXIST此位置已经有元素, DELETE元素已经删除
enum State
{
   EMPTY, 
   EXIST, 
   DELETE
};

线性探测的实现

// 注意:假如实现的哈希表中元素唯一,即key相同的元素不再进行插入
enum State
{
	EMPTY,
	EXIST,
	DELETE
};

template<class K, class V>
struct HashData
{
	void operator=(const HashData hd)
	{
		_kv = hd._kv;
		_state = hd._state;
	}

	pair<K, V> _kv;
	State _state = EMPTY;
};

template<class K, class V>
class HashTables
{
public:
	HashTables()
	{
		_tables.resize(10);
	}

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

		//空间不足扩容
		if (_n * 10 / _tables.size() >= 7)
		{
			/*size_t newsize = _tables.size() * 2;
			vector<HashData<K, V>> newtables.resize(newsize);
			for (size_t i = 0; i < _tables.size(); i++)
			{
				if (_tables[i]._state == EXIST)
				{
					size_t newhashi = _tables[i].first % newtables.size();
					newtables[newhashi] = _tables[i];
				}
			}
			_tables.swap(newtables);*/

			//现代写法
			HashTables<K, V, Hash> ht;
			ht._tables.resize(_tables.size() * 2);
			for (size_t i = 0; i < _tables.size(); i++)
			{
				if (_tables[i]._state == EXIST)
				{
					ht.Insert(_tables[i]._kv);
				}
			}
			_tables.swap(ht._tables);
		}

		
		//正常插入
		size_t hashi = kv.first % _tables.size();
		while (_tables[hashi]._state == EXIST)
		{
			++hashi;
			hashi %= _tables.size();//循环查找

           /*
           转一圈也没有找到,注意:动态哈希表,该种情况可以不用考虑,哈希表中元
素个数到达一定的数量,哈希冲突概率会增大,需要扩容来降低哈希冲突,因此哈希表中元素是
不会存满的
           if(hashAddr == startAddr)
               return false;
           */
		}

		_tables[hashi]._kv = kv;
		_tables[hashi]._state = EXIST;
		++_n;
		return true;
	}

	HashData<K, V>* Find(const K& key)
	{
		size_t newhashi = key % _tables.size();
		//while (_tables[newhashi].state == EXIST || _tables[newhashi].state == DELETE)
		while (_tables[newhashi]._state != EMPTY)
		{
			if (_tables[newhashi]._state == EXIST && Ha(_tables[newhashi]._kv.first) == Ha(key))
			{
				return &_tables[newhashi];//[]返回的是该数组中的成员
			}
			newhashi++;
			newhashi %= _tables.size();
		}
		return nullptr;
	}

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

private:
	vector<HashData<K, V>> _tables;
	size_t _n;//有效数据个数
};

2)扩容

思考:哈希表什么情况下进行扩容?如何扩容?
散列表的载荷因子定义为:a = 填入表中的元素个数 /散列表的长度

a是散列表装满程度的标志因子。由于表长是定值,a与“填入表中的元素个数”成正比,所以,a越大,表明填入表中的元素越多,产生冲突的可能性就越大;反之,a越小,标明填入表中的元素越少,产生冲突的可能性就越小。实际上,散列表的平均查找长度是载荷因子a的函数,只是不同处理冲突的方法有不同的函数。

对于开放定址法,荷载因子是特别重要因素,应严格限制在0.7-0.8以下。超过0.8,查表时的CPU缓存不命中(cachemissing)按照指数曲线上升。因此,一些采用开放定址法的hash库,如Java的系统库限制了荷载因子为0,75,超过此值将resize散列表。
//空间不足扩容
if (_n * 10 / _tables.size() >= 7)
{
	HashTables<K, V, Hash> ht;
	ht._tables.resize(_tables.size() * 2);
	for (size_t i = 0; i < _tables.size(); i++)
	{
		if (_tables[i]._state == EXIST)
		{
			ht.Insert(_tables[i]._kv);
		}
	}
	_tables.swap(ht._tables);
}
线性探测优点:实现非常简单。
线性探测缺点: 一旦发生哈希冲突,所有的冲突连在一起,容易产生数据 堆积 ,即:不同
关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降
。如何缓解呢?

3)二次探测

线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位
置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题, 找下一个空位置的方法
为: $H_i$ = ($H_0$ + $i^2$ )% m, 或者: $H_i$ = ($H_0$ - $i^2$ )% m 。其中: i =
1,2,3… $H_0$ 是通过散列函数 Hash(x) 对元素的关键码 key 进行计算得到的位置, m 是表
的大小。
对于 2.1 中如果要插入 44 ,产生冲突,使用解决后的情况为:

研究表明: 当表的长度为质数且表装载因子 a 不超过 0.5 时,新的表项一定能够插入,而且任
何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在
搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子 a 不超过 0.5 如果超出
必须考虑增容。
因此:比散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷。

2.开散列

1)开散列的概念

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

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

2)开散列的实现

// 本文所实现的哈希桶中key是唯一的
template<class K, class V>
struct HashNode
{
	HashNode(const pair<K, V>& kv)
		:_kv(kv)
		, _next(nullptr)
	{}

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

template<class K, class V>
class HashTables
{
	typedef HashNode<K, V> Node;
public:
	HashTables()
	{
		_tables.resize(10, nullptr);
		_n = 0;
	}

	~HashTables()
	{
		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;
		}
	}

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

		//扩容
		if (_n == _tables.size())
		{
			负载因子为1就扩容

			vector<Node*> newtables(_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 newhashi = cur->_kv.first % newtables.size();
					//头插
					cur->_next = newtables[newhashi];
					newtables[newhashi] = cur;
					cur = next;
				}
				_tables[i] = nullptr;
			}
			_tables.swap(newtables);
		}

		size_t hashi = kv.first % _tables.size();

		Node* newnode = new Node(kv);//需要自己释放空间!!!
		newnode->_next = _tables[hashi];
		_tables[hashi] = newnode;
		++_n;
		return true;
	}

	Node* Find(const K& key)
	{

		Hash ha;
		size_t hashi = key % _tables.size();
		Node* FNode = _tables[hashi];

		while (FNode)
		{
			if (FNode->_kv.first == key)
			{
				return FNode;
			}
			FNode = FNode->_next;
		}

		return nullptr;
	}

	bool Erase(const K& key)
	{
		size_t newhashi = key % _tables.size();
		Node* cur = _tables[newhashi];
		Node* porv = nullptr;
		while (cur)
		{
			if (cur->_kv.first == key)
			{
				if (porv == nullptr)
				{
					//删除头节点
					_tables[newhashi] = cur->_next;
				}
				else
				{
					porv->_next = cur->_next;
				}

				delete cur;
				return true;
			}

			porv = cur;
			cur = cur->_next;
		}
		return false;
	}

private:
	vector<Node*> _tables;
	size_t _n;//有效数据个数
};

3)开散列扩容

桶的个数是一定的,随着元素的不断插入,每个桶中元素的个数不断增多,极端情况下,可
能会导致一个桶中链表节点非常多,会影响的哈希表的性能,因此在一定条件下需要对哈希
表进行增容,那该条件怎么确认呢?开散列最好的情况是:每个哈希桶中刚好挂一个节点,
再继续插入元素时,每一次都会发生哈希冲突,因此,在元素个数刚好等于桶的个数时,可
以给哈希表增容。
if (_n == _tables.size())
{
	负载因子为1就扩容

	vector<Node*> newtables(_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 newhashi = cur->_kv.first % newtables.size();
			//头插
			cur->_next = newtables[newhashi];
			newtables[newhashi] = cur;
			cur = next;
		}
		_tables[i] = nullptr;
	}
	_tables.swap(newtables);
}

3.扩展两个问题并加以思考解决

1) 只能存储 key 为整形的元素,其他类型怎么解决?

设置仿函数解决,下面以开散列为例:

//整形数据不需要转化
template<class K>
struct HashFunc
{
	size_t operator()(const K& key)
	{
		return (size_t)key;
	}
};

//对string类型进行转化为整形
//为string类型的话,类型定义是需要传递该仿函数
//struct StringHashFunc
//{
//	size_t operator()(const string& str)
//	{
//		size_t hash = 0;
//		for (auto e : str)
//		{
//			hash += e;
//			hash *= 131;//使数据更加随机,降低发生哈希冲突的概率
//		}
//
//		return hash;
//	}
//};

//也可以单独对string走一个特化
//单独走特化类型定义时不需要特意传仿函数,编译器会先调用string类型的仿函数,不符合类型再进行HashFunc仿函数的调用
template<>
struct HashFunc<string>
{
	size_t operator()(const string& str)
	{
		size_t hash = 0;
		for (auto e : str)
		{
			hash += e;
			hash *= 131;
		}

		return hash;
	}
};

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

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

template<class K,class V,class Hash = HashFunc<K>>//设置仿函数
class HashTables
{
	typedef HashNode<K, V> Node;
public:
	HashTables()
	{
		_tables.resize(10, nullptr);
		_n = 0;
	}

	~HashTables()
	{
		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;
		}
	}

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

		Hash ha;
		//扩容
		if (_n == _tables.size())
		{
			//负载因子为1就扩容

			vector<Node*> newtables(_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 newhashi = ha(cur->_kv.first) % newtables.size();
					//头插
					cur->_next = newtables[newhashi];
					newtables[newhashi] = cur;
					cur = next;
				}
				_tables[i] = nullptr;
			}
			_tables.swap(newtables);
		}

		size_t hashi = ha(kv.first) % _tables.size();
		
		Node* newnode = new Node(kv);//需要自己释放空间!!!
		newnode->_next = _tables[hashi];
		_tables[hashi] = newnode;
		++_n;
		return true;
	}

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

		return nullptr;
	}

	bool Erase(const K& key)
	{
		Hash ha;
		size_t newhashi = ha(key) % _tables.size();
		Node* cur = _tables[newhashi];
		Node* porv = nullptr;
		while (cur)
		{
			if (cur->_kv.first == key)
			{
				if (porv == nullptr)
				{
					//删除头节点
					_tables[newhashi] = cur->_next;
				}
				else
				{
					porv->_next = cur->_next;
				}

				//cur->_next = nullptr;
				delete cur;
				return true;
			}
			
				porv = cur;
				cur = cur->_next;
			
			
		}
		return false;
	}

private:
	vector<Node*> _tables;
	size_t _n;//有效数据个数
};
2. 除留余数法,最好模一个素数,如何每次快速取一个类似两倍关系的素数?

在上面的实现代码中,我们所写得对string类型的转化仿函数:

//对string类型进行转化为整形
//为string类型的话,类型定义是需要传递该仿函数
struct StringHashFunc
{
	size_t operator()(const string& str)
	{
		size_t hash = 0;
		for (auto e : str)
		{
			hash += e;
			hash *= 131;//这里我们用的BKDR数(BKDR两位大佬得名字简写,是经过很多实验得出得数字)
		}

		return hash;
	}
};

里面对每一个hash都乘了131最后再相加,这样就保证了很大概率得到一个两倍素数

将每个数乘以131,该算法是Brian Kernighan与Dennis Ritchie的《The C Programming Language》一书被展示而得 名,是一种简单快捷的hash算法

具体详细学习可以看这篇博客:字符串哈希算法

4.开散列和闭散列的比较

应用链地址法处理溢出,需要增设链接指针,似乎增加了存储开销 。事实上:
由于开地址法必须保持大量的空闲空间以确保搜索效率,如二次探查法要求装载因子 a <=
0.7 ,而表项所占空间又比指针大的多,所以使用链地址法反而比开地址法节省存储空间。
  • 73
    点赞
  • 57
    收藏
    觉得还不错? 一键收藏
  • 50
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 50
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值