前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >【C++】哈希

【C++】哈希

作者头像
青衫哥
发布2023-04-23 17:40:13
3280
发布2023-04-23 17:40:13
举报
文章被收录于专栏:C++打怪之路C++打怪之路

一、哈希相关概念

1.哈希概念

顺序结构以及平衡树 中,元素关键码与其存储位置之间没有对应的关系,因此在 查找一个元素

时,必须要经过关键码的多次比较顺序查找时间复杂度为 O(N) ,平衡树中为树的高度,即 O(log_2 N) ,搜索的效率取决于搜索过程中元素的比较次数。

理想的搜索方法:可以 不经过任何比较,一次直接从表中得到要搜索的元素

如果构造一种存储结构,通过某种函数 (hashFunc) 使元素的存储位置与它的关键码之间能够建立

一一映射的关系,那么在查找时通过该函数可以很快找到该元素

当向该结构中:

  • 插入元素

根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放?

  • 搜索元素

对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置

取元素比较,若关键码相等,则搜索成功

该方式即为哈希 ( 散列 ) 方法, 哈希方法中使用的转换函数称为哈希 ( 散列 ) 函数,构造出来的结构称

为哈希表 (Hash Table)( 或者称散列表 )

例如:

数据集合 {1 , 7 , 6 , 4 , 5 , 9} ;

哈希函数设置为: hash(key) = key % capacity ;

capacity 为存储元素底层空间总的大小。

上图插入1,模10得到1,就填入到1的位置,其他元素也是同理。

如计数排序,其实就有哈希的思想。详情参考:计数排序?。

当我们向上图中再插入一个数字44的时候,44和4取10的模的余数都是4,那么都应该在4的位置。这该怎么填入数字呢?这种出现几个数字都符合一个位置的条件的情况,叫做哈希碰撞/冲突

2.哈希冲突

对于两个数据元素的关键字 k_i? 和 k_j (i != j) ,有 k_i != k_j ,但有: Hash(k_i) ==? Hash(k_j)? ,即: 不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突 或哈希碰撞

把具有不同关键码而具有相同哈希地址的数据元素称为 同义词

发生哈希冲突该如何处理呢?

3.哈希函数

引起哈希冲突的一个原因可能是: 哈希函数设计不够合理

哈希函数设计原则

  1. 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0到m-1之间
  2. 哈希函数计算出来的地址能均匀分布在整个空间中
  3. 哈希函数应该比较简单

常见哈希函数:

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

假设要存储某家公司员工登记表,如果用手机号作为关键字,那么极有可能前 7 位都是 相同的,那么我们可以选择后面的四位作为散列地址,如果这样的抽取工作还容易出现 冲突,还可以对抽取出来的数字进行反转( 如 1234 改成 4321) 、右环位移 ( 如 1234 改成 4123) 、左环移位、前两数与后两数叠加( 如 1234 改成 12+34=46) 等方法。

数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的 若干位分布较均匀的情况

注意:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突

解决哈希冲突两种常见的方法是: 开散列? 和? 闭散列?


二、闭散列

1.线性探测

比如 2.1 中的场景,现在需要插入元素 44 ,先通过哈希函数计算哈希地址, hashAddr 为 4 ,

因此 44 理论上应该插在该位置,但是该位置已经放了值为 4 的元素,即发生哈希冲突。

线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止

插入:

  • 通过哈希函数获取待插入元素在哈希表中的位置
  • 如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素

删除:

  • 采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。比如删除元素4,如果直接删除掉,44查找起来可能会受影响。因此线性探测采用标记的伪删除法来删除一个元素将此节点状态标记为delete

闭散列的模拟实现

结构:??既然要线性存储,底层肯定要用vector来存储的。我们还需要设置一个size_t类型变量来记录哈希表中有效元素的数量。 ??????????每一个位置还需要有三种状态:空、存在、删除,以便后序的插入删除和查找。我们可以用枚举变量。 构造函数:我们给定元素个数为10。 查找:获取数据对应的hashi,找到数据对应的位置,如果不在该位置,则继续往后查找,直到位置上的状态为 EMPTY。(注意:这里是不能在状态DELETE的时候停下的,有可能会影响查找后面的元素。)在全是DELETE和EXIST的情况下,会陷入死循环,所以我们设置一个初始值 starti,每次查找完 hashi %= 哈希表元素个数,如果 hashi == starti ,那么说明回到了原点,就应该停止查找。? 插入:我们首先需要判定是否已经存在了插入的元素的值。并且。我们还需要设定一个负载因子(负载因子 = 有效元素 / 总元素个数),如果负载因子超过了设定值,那么则需要扩容。由于传入的数据不一定是size_t类型的,我们需要构造一个仿函数Hashfunc来获取。通过获取数据对应的hashi,将数据插入到对应位置,若该位置已经有了数据,则++hashi。

  • 扩容:创建一个新的vector,大小是旧表的两倍,把旧表中的有效数据插入到新表中,然后交换新旧表。

删除:找到对应的元素,将其状态修改为DELETE即可。

关于负载因子:

代码:

代码语言:javascript
复制
#pragma once
#include <vector>

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

namespace closehash
{
	enum State
	{
		EMPTY,
		EXIST,
		DELETE,		
	};

	template<class K, class V>
	struct HashData
	{
		pair<K, V> _kv;
		State _state = EMPTY;  // 默认状态为空
	};

	template<class K, class V, class Hash = HashFunc<K>>
	class HashTable
	{
		typedef HashData<K, V> Data;
	public:
		HashTable()
			:_n(0)
		{
			_tables.resize(10);
		}

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

			//大于标定负载因子,就需要扩容
			if (_n * 10 / _tables.size() >= 7) //负载因子 0.7
			{
				HashTable<K, V, Hash> newHT;
				newHT._tables.resize(_tables.size() * 2);

				//重新插入存在的节点
				for (auto e : _tables)
				{
					if (e._state == EXIST)
					{
						newHT.Insert(e._kv);
					}
				}
				
				_tables.swap(newHT._tables);
			}
			//仿函数获取pair中first
			Hash hf;
			size_t hashi = hf(kv.first) % _tables.size();
			while (_tables[hashi]._state == EXIST)
			{
				//线性探测
				hashi++;
				hashi %= _tables.size();  //保证不越界
			}

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

			return true;
		}

		Data* Find(const K& key)
		{
			Hash hf;
			size_t hashi = hf(key) % _tables.size();
			size_t starti = hashi;		//防止死循环查询
			while (_tables[hashi]._state != EMPTY)
			{
				if (_tables[hashi]._state == EXIST && _tables[hashi]._kv.first == key)
				{
					return &_tables[hashi];
				}
				//如果是delete也要继续往下走
				++hashi;
				hashi %= _tables.size();

				//防止极端环境:全是delete和exist,这时会死循环
				if (hashi == starti)
				{
					break;
				}

			}
			return nullptr;
		}

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

	private:
		vector<Data> _tables;
		size_t _n = 0; //表中存储的有效数据的个数
	};
}

线性探测优点:实现非常简单,

线性探测缺点:一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”,即:不同

关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降

低。


三、开散列

1.开散列概念

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

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

2.开散列实现

结构: 因为表中是存储的单链表,所以基础结构当然是链表节点。链表节点中存储着pair结构和状态_state。 插入: 如果有效数据个数和表大小相同的时候,需要扩容。重新创建节点插入的方法十分浪费空间,我们可以服用旧表的节点。获取对应的位置后插入节点到新表中。由于是单链表,插入节点如果是尾插,十分浪费时间,而链表头插十分方便,所以节点插入时采用头插的方式。 扩容:桶的个数是一定的,随着元素的不断插入,每个桶中元素的个数不断增多,极端情况下,可能会导致一个桶中链表节点非常多,会影响的哈希表的性能,因此在一定条件下需要对哈希表进行增容,那该条件怎么确认呢?开散列最好的情况是:每个哈希桶中刚好挂一个节点,再继续插入元素时,每一次都会发生哈希冲突,因此,在元素个数刚好等于桶的个数时,可以给哈希表增容。 查找: 获取元素对应位置,通过链表一直遍历下去,找到了就返回该节点,没找到返回空指针。 删除:和查找类似,找到节点,需要判断一下删除节点是不是头结点,是的话需要改变头结点。不是头结点只需要前一个节点指向该节点后一个节点,再删除该节点即可。

代码:

代码语言:javascript
复制
#pragma once
#include <vector>
#include <string>

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

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

namespace buckethash
{
	template<class K, class V>
	struct HashNode
	{
		pair<K, V> _kv;
		HashNode<K,V>* _next;

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


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

		//析构
		~HashTable()
		{
			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())
			{
				//方法一:重新开辟相同节点插入
				//缺点:消耗空间更大
				/*HashTable<K, V> newHT;
				newHT._tables.resize(2 * _n, nullptr);
				for (size_t i = 0; i < _tables.size(); ++i)
				{
					Node* cur = _tables[i];
					while (cur)
					{
						newHT.Insert(cur->_kv);
						cur = cur->_next;
					}
				}
				_tables.swap(newHT._tables); */


				//方法二:复用旧表中的节点
				vector<Node*> newTables;
				newTables.resize(_n * 2, nullptr);
				for (size_t i = 0; i < _tables.size(); ++i)
				{
					Node* cur = _tables[i];
					while (cur)
					{
						Node* next = cur->_next;
						size_t hashi = Hash()(cur->_kv.first) % newTables.size();
						cur->_next = newTables[hashi];
						newTables[hashi] = cur;

						cur = next;

					}
				}
				_tables.swap(newTables);
			}
			size_t hashi = Hash()(kv.first) % _tables.size();
			Node* newNode = new Node(kv);
			//头插新节点
			newNode->_next = _tables[hashi];
			_tables[hashi] = newNode;
			++_n;

			return true;
		}

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

		bool Erase(const K& key)
		{
			size_t hashi = Hash()(key) % _tables.size();
			Node* prev = nullptr;
			Node* cur = _tables[hashi];

			while (cur)
			{
				if (cur->_kv.first == key)
				{
					//需要判断是否为头节点
					if (cur == _tables[hashi])
					{
						_tables[hashi] = cur->_next;
					}
					else
					{
						prev->_next = cur->_next;
					}
					delete cur;
					--_n;
					return true;
				}
				prev = cur;
				cur = cur->_next;
			}

			return false;
		}

	private:
		vector<Node*> _tables;
		size_t _n = 0; //表中存储的有效数据的个数
	};
}

四、传入其他参数的处理办法

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

对与能够强制转换为整形的类型,我们采用强制类型转换使其变成整形。

代码语言:javascript
复制
template<class K>
struct HashFunc
{
	size_t operator()(const K& key)
	{
		return (size_t)key;
	}
};

2. 将字符串转化为整形

方法很多,我们值介绍常用的方法。

最常用的方法就是每次乘上一个数字,然后加上一个字符。返回最终获取到的数字。

不同的类型需要对应的转化方法,这点可以参考库里的实现方法。

代码语言:javascript
复制
template<>
struct HashFunc<string>
{
	size_t operator()(const string& key)
	{
		size_t hash = 0;
		for (auto ch : key)
		{
			hash *= 131;
			hash += ch;
		}
		return hash;
	}
};

五、除留余数法

在开辟哈希表的大小时,我们可以采用取一个素数的方式。

当使用素数作为除数时,能够更加均匀地散列 key 值,减少了哈希冲突的发生,而如果使用合数(即非素数)作为除数,那么就会有更多的键被映射到相同的索引上,从而增加哈希冲突的概率 – 合数有多个因子,取模后产生的余数可能比较集中,所以不能很好地散列 key 值。

另外,每个素数几乎都是接近二倍的关系,也基本符合我们的扩容两倍的要求。

源码:

代码语言:javascript
复制
// Note: assumes long is at least 32 bits.
static const int __stl_num_primes = 28;
static const unsigned long __stl_prime_list[__stl_num_primes] =
{
  53,         97,         193,       389,       769,
  1543,       3079,       6151,      12289,     24593,
  49157,      98317,      196613,    393241,    786433,
  1572869,    3145739,    6291469,   12582917,  25165843,
  50331653,   100663319,  201326611, 402653189, 805306457, 
  1610612741, 3221225473, 4294967291
};

inline unsigned long __stl_next_prime(unsigned long n)
{
  const unsigned long* first = __stl_prime_list;
  const unsigned long* last = __stl_prime_list + __stl_num_primes;
  const unsigned long* pos = lower_bound(first, last, n);
  return pos == last ? *(last - 1) : *pos;
}

在某些极端情况下,可能会存在哈希表中某一条链表过长的情况,从而导致效率的低下。

为了解决这种问题,可能会将其转化成红黑树的结构。

本文参与?腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2023-04-15,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客?前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与?腾讯云自媒体分享计划? ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、哈希相关概念
    • 1.哈希概念
      • 2.哈希冲突
        • 3.哈希函数
          • 二、闭散列
            • 闭散列的模拟实现
        • 三、开散列
          • 1.开散列概念
            • 2.开散列实现
            • 四、传入其他参数的处理办法
              • 1. 只能存储key为整形的元素,其他类型怎么解决?
                • 2. 将字符串转化为整形
                • 五、除留余数法
                相关产品与服务
                对象存储
                对象存储(Cloud Object Storage,COS)是由腾讯云推出的无目录层次结构、无数据格式限制,可容纳海量数据且支持 HTTP/HTTPS 协议访问的分布式存储服务。腾讯云 COS 的存储桶空间无容量上限,无需分区管理,适用于 CDN 数据分发、数据万象处理或大数据计算与分析的数据湖等多种场景。
                领券
                问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档
                http://www.vxiaotou.com