哈希概念讲解及哈希表简单实现

目录

  • 哈希思想的简单实例
  • 哈希概念
  • 哈希冲突
  • 哈希函数
  • 解决哈希冲突的方法——闭散列和开散列

哈希思想的简单实例

leetcode上的一道简单算法题:
在这里插入图片描述
这题的最优解就是拿一个数组对所给的字符串遍历一遍,对字符串中不同字符,对应的在数组中找一个位置存储该字符在字符串中出现的次数,最后对字符串再遍历一次拿到一个字符就看其在数组中记录的次数,返回第一个次数为一的字符,遍历完了还没有找到次数为一的字符就返回负一,这里就按字符的ASCII码为其在数组中的下标存储该字符出现的次数,这样就能统计字符串中所有字符出现的次数了,这就是一种映射的方法,建立一种字符和数组下标位置的一一对应的关系,这就是哈希的思想,因为都是小写字母可以开一个大小为26的int类型的数组,按相对位置存储字符次数,不用开数组开到小写字母ascii码最大的z那么大的数组,节省了空间。

class Solution {
public:
    int firstUniqChar(string s) {
    int arr[26]={0};
    for(auto e:s)
       ++arr[e-97];           //这里就建立了映射关系,a-z26个小写字母都映射到了数组下标0-25的位置上去,再映射的位置上记录该字母的次数
       for(int i=0;i<s.size();++i)
       {
           if(arr[s[i]-97]==1)  //第二次遍历字符串,拿到一个字符按映射关系比较他的次数是不是1
           return i;
       }
       return -1;
    }
};

哈希概念

我们前面学过的存储数据的结构有数组、链表和树系列。在数组中查找数据时间复杂度是O(n) ,平衡二叉搜索树查找的效率是树的高度,通过不断比较key大小来找数据,时间复杂度是log(n)。

理想的搜索方法就是搜索一次就能直接从容器中找到数据,我们要构造一种存储结构,存放数据时,给数据定义一个key值,每个数据的key值按照简单的函数计算出一个存储该数据元素信息的位置坐标,使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。

插入元素

向该结构中插入元素时,用该元素的key值通过选定的函数计算出在结构中存储的位置坐标,将元素放入该位置上。

搜索元素

对元素的key值作同样的计算找到位置坐标,去该结构中对应位置取数据,比较key,相等则搜索成功。

这样的存储和搜索数据的方式就是哈希(散列)方法,哈希方法中用到的计算位置的函数就是哈希(散列)函数,这种存储结构就是哈希(散列)表,前面例题中的哈希函数就是y=x-97这种线性函数,x是字母ascii码值,y是计算出来的下标位置。

哈希冲突

通过选定的哈希函数计算key值,有时不同的key值通过相同的哈希函数会计算出相同的位置,这种现象就是哈希冲突或称为哈希碰撞,要通过特定的方法解决哈希冲突的问题。

哈希函数

引起哈希冲突的一个主要因素就是:哈希函数设计的不合理,设计哈希函数的一个原则

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

常见的哈希函数

  1. 直接定址法
    就是线性函数 y=kx+b 这类的函数。优点:简单、均匀 缺点:需要事先知道关键字的分布情况 使用场景:适合查找比较小且连续的情况。

  2. 除留余数法
    是用key值取模存储结构的容量得到的计算值,即Hash(key)=key%capacity,也可以是取模一个不大于容量的值。

  3. 平方取中法
    假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址; 再比如关键字为4321,对它平方就是18671041,抽取中间的3位671(或710)作为哈希地址 平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况。

  4. 折叠法
    折叠法是将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。折叠法适合事先不需要知道关键字的分布,适合关键字位数比较多的情况。

  5. 随机数法
    选择一个随机函数,取关键字的随机函数值为它的哈希地址,即H(key) = random(key),其中random为随机数函数。通常应用于关键字长度不等时采用此法。

解决哈希冲突的方法——闭散列和开散列

闭散列方法也叫开放定址法,是当计算的位置已经有元素存在即发生冲突时,如果哈希表还没满就找到冲突位置的下一个位置去存放元素。有两种常用的寻找下一个位置的方法。

  1. 线性探测
    当计算的初始位置发生冲突时,下标+1,判断是否位置上为空,是就把元素放入该位置,否则继续向后探测。

  2. 二次探测
    计算的初始位置记为start,设置一个步长i初始为0,用start+i^2作为放入元素的位置,就是从初始位置开始计算,冲突了就探测后面的位置,不过是每次i+1后的平方,这样跳跃式探测。

闭散列哈希表的插入元素就按上面两种方式之一进行,删除元素不能通过随便的物理删除哈希表的元素,因为这样做可能在查找元素时造成误判,比如我们插入两个元素,两个元素是发生冲突的,插入第一个元素到计算的哈希地址上去,第二个元素就要按线性探测或二次探测存放到哈希地址的后面,当我们删除第一个元素后,该位置为空了,再来查找第二个元素,我们计算出了这个哈希地址就是原先的哈希地址,该地址为空我们就认为第二个元素不在表中,其实它在该位置的后面位置。合适的做法是把数据元素加上一个状态值,闭散列的数据元素定义如下

enum statue
	{
		EMPTY,           //一个状态值,有空 、删除和存在三种状态
		DELETE,
		EXIST
	};
	template<class K,class V>
	struct hashdata
	{
		pair<K, V> _kv;                  //简单的实现哈希表我们存储pair数据类型
		statue _st=EMPTY;   
	};

用一个vector来存储数据元素,哈希函数选择最常用的除留余数法。在pair中的first就是key值,为了能够用除留余数法,我们的key值必须支持取模运算,一般的整数和字符类型都能转换成size_t来支持取模运算,字符串类型则不支持取模运算,所以我们要设计仿函数,把传入的字符串计算出一个size_t 类型数据来,下面是闭散列哈希表的整体框架。

template<class K>
	struct hashfunc               //用仿函数把key转换成size_t确保可以取模运算计算哈希地址
	{
		size_t operator()(const K& val)
		{
			return val;
		}
	};
	template<>
	struct hashfunc<string>     //string类型走这个特化版本计算一个size_t         
	{
		size_t operator()(const string& s)
		{
			size_t ret=0;
			for (int i = 0; i < s.size(); ++i)
			{
				ret *= 31;
				ret += s[i];
			}
			return ret;
		}
	};
template<class K,class V,class func=hashfunc<K>>  //哈希表模板参数就先设计成这样
	class hashtable
	{
		typedef hashdata<K, V> Node;
	public:
    bool erase(const K& key);      //哈希表的三大主要操作  插入 查找和删除  其余简单的成员方法与其他容器相比操作类似在这里就不详细说了
    
      Node* find(const K& key);
    
     bool insert(const pair<K, V>& val);
private:
		vector<hashdata<K,V>>_table;  //用vector来存储数据
		size_t n=0;    //  实时记录哈希表中数据元素的个数
	};

扩容问题

哈希表也要考虑扩容的情况,线性探测的方式插入元素,发生冲突的元素要去占用其他位置,在接下来的插入元素过程中可能会引发一连串的冲突占用位置的情况,二次探测可以缓解这种情况,插入元素增多,表长不变,冲突的概率还是大大增加,只有表中空位置较多才能大大降低冲突的概率,所以要考虑在上面情况下应该扩容,为此引入一个载荷因子的概念。载荷因子a=填入表中的元素个数/散列表的长度。a就反应了哈希表填满元素的程度,插入元素增多a增大,对于开放定址法载荷因子是非常重要的参数,应该控制在0.7到0.8以下,用载荷因子作为参数来控制扩容,降低冲突的概率。

插入的大逻辑还是不允许相同的元素插入,按照上面的插入元素逻辑进行。

bool insert(const pair<K, V>& val)
		{
			if (find(val.first))   //如果元素已经存在就直接return false
				return false;
			if (_table.size() == 0 || n * 10 / _table.size() >= 7)   //扩容
			{  //这里扩容哈希表依据载荷因子来判断  
				size_t newsize = _table.size() == 0 ? 10 : 2 * _table.size();
				hashtable<K, V>temtable;   //扩容后复用insert把原数据拷贝到新开辟的空间去
				temtable._table.resize(newsize);
				for (auto& e : _table)
				{
					if (e._st == EXIST)
						temtable.insert(e._kv);
				}
				_table.swap(temtable._table);
			}
			func hf;    
			size_t start = hf(val.first) % _table.size();  //用到仿函数转换key值来计算地址
			size_t index = start;
			int i = 0;
			while (_table[index]._st == EXIST)
			{
				++i;
				index=start+i;             //线性探测      这里两步就是来选择是线性探测还是二次探测
				//index=start+i * i;           //二次探测
				index %= _table.size();      //不能超过了数组长度
			}
			_table[index]._kv = val;     //找到位置了可以放入元素
			_table[index]._st = EXIST;     //标记该位置元素已存在
			++n;                          //更新元素个数
			return true;
		}
bool erase(const K& key)   
		{
			Node* r = find(key);
			if (r)
			{
				r->_st = DELETE;      //找到要删除的元素,只需将它的状态值改为DELETE,以后检测这个位置是DELETE 时就可以放入其他元素,查找时也可以继续往后找。
				--n;              //更新元素个数
				return true;
			}
			else
				return false;
		}
Node* find(const K& key)
		{
			if (n == 0)
				return nullptr;
			func hf;            
			size_t start = hf(key) % _table.size();  //查找时同样的计算地址
			size_t index = start, i = 0;
			while (_table[index]._st!=EMPTY) //找到空还没找到,就是不存在的
			{
				if (_table[index]._kv.first == key && _table[index]._st == EXIST)  //满足key相同,而且是存在的就算找到了
				{
					return &_table[index];
				}
				++i;
				index =start+ i;        //是线性探测插入就同样线性探测的查找,二次探测也要同步
				index %= _table.size();
			}
			return nullptr;
		}

开散列方式实现的哈希表

闭散列插入数据,有冲突就把元素从哈希地址开始往后找空位放入,开散列则是把冲突的元素用链表连起来,挂在计算的哈希地址的位置上,下面看开散列哈希表的框架

template<class K,class V>
	struct hashdata
	{
		pair<K, V> _kv;  //就是一个存放元素和一个连接下一个元素的指针,不需要存储状态的值
		struct hashdata<K, V>* next;          
		hashdata(const pair<K,V>&val)
			:_kv(val)
			,next(nullptr)
		{}
	};

仿函数是和闭散列相同的,就是为了把key转换成无符号整数支持取模运算。

template<class K,class V,class func=hashfunc<K>>
	class hashtable
	{
		typedef hashdata<K, V> Node;
	public:
	Node* find(const K& key);
	
  bool erase(const K& key);
  
  bool insert(const pair<K, V>& val)private:
		vector<Node*> _table;  //数组存节点指针就行了,把冲突的元素用链表改在同一个位置下
		size_t n = 0;
	};
bool insert(const pair<K, V>& val)
		{
			if (find(val.first))
				return  false;
			func hf;
			if (n == _table.size())       //扩容
			{ //扩容控制上,数组平均每个位置上有一个元素时我们就扩容,防止有的位置上链表太长,查找效率降低
				size_t newsize = _table.size() == 0 ? 10 : 2 * _table.size();
				vector<Node*>tem;   重新开辟一个数组  
				tem.resize(newsize);
				for (auto& e : _table)
				{
					if (e)      //这里我们只需把原表的元素一个个重新计算地址挂接到新表位置上就行了
					{
						Node* cur = e;    
						while (cur)
						{
							size_t start = hf(cur->_kv.first) % newsize;
							Node* next = cur->next;
							cur->next = tem[start];
							tem[start] = cur;
							cur = next;
						}
						e = nullptr;
					}
				}
				_table.swap(tem);   //最后交换一个数组就行了,
			}	
			size_t start = hf(val.first) % _table.size();
			Node* newnode = new Node(val);    //插入元素,确定位置后,按头插方式插入即可,原先如果该位置上没有元素时为nullptr,没有特殊情况,一直按头插方式即可。
			newnode->next = _table[start];
			_table[start] = newnode;
			++n;
		}

开散列的查找,计算地址,找到特定的链表去遍历链表查找即可

Node* find(const K& key)
		{
			if (n == 0)
				return nullptr;
			func hf;
			size_t start = hf(key) % _table.size();
			Node* cur = _table[start];
			while (cur)
			{
				if (cur->_kv.first == key)
					return cur;
				cur = cur->next;
			}
			return nullptr;
		}
bool erase(const K& key)
		{
			if (_table.empty())
				return false;
			func hf;
			size_t start = hf(key) % _table.size();
			Node* prev = nullptr, * cur = _table[start];//删除元素,就是找到相应的链表,找到元素删除。
			while (cur&&cur->_kv.first != key)
			{
				prev = cur;
				cur = cur->next;
			}
			if (cur == nullptr)
				return false;
			else
			{
				--n;
				if (cur == _table[start])  //头删的情况
				{
					_table[start] = cur->next;
					delete cur;
				}
				else
				{
					prev->next = cur->next;  //中间删和尾删
					delete cur;
				}
				
				return true;
			}
		}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值