哈希的简单实现

哈希

用来进行高效查找的一种数据结构----O(1)
几种不同的查找方式:

  1. 顺序查找—直接遍历—>O(N)
  2. 数据集合—>有序—>二分查找—>O(logN)
  3. 采用二叉平衡树结构组织数据:AVL、红黑树—>O(logN)
    以上都需要进行元素比较

理想的查找方式:不需要比较

1.O(1)的根本原因:

通过某种方式,将元素与其在空间的存储位置建立一一映射的关系

哈希:散列
在这里插入图片描述

在表格查找x的元素:

  1. 计算元素x在表格中的位置:
  2. 看该位置存储的元素是否为待查找的元素

2.哈希缺陷

x1和x2存储到哈希表中

不同元素通过相同哈希函数计算出相同的哈希地址—哈希冲突(哈希碰撞)
例如:x1为34,x2为104,将它们存入到上图哈希表中,就会出现覆盖

3.哈希冲突

由于哈希算法被计算的数据是无限的,而计算后的结果范围有限,因此总会存在不同的数据经过计算后得到的值相同,这就是哈希冲突。(两个不同的数据计算后的结果一样)
如何解决哈希冲突?下面给你解答

4.常见哈希函数

1. 直接定制法–(常用)

取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B
优点:简单、均匀
缺点:需要事先知道关键字的分布情况
==使用场景:适合查找比较小且连续的情况 ==
面试题:字符串中第一个只出现一次字符

2. 除留余数法–(常用)

设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p(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种不同的符号在各位上出现的频率不一定相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只有某几种符号经常出现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散列地址。例如:

电话号
130xxxx1234
130xxxx2345
130xxxx4829
130xxxx2396
130xxxx8354
易重复分布太集中的某几个数字
分布均匀,可用作散列地址

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

数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布较均匀的情况
注意: 哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突

5.哈希冲突解决

哈希函数—不论设计的多精妙—不可能完全解决哈希冲突

哈希函数设计比较好—>将产生哈希冲突的概率降低,但是不能完全杜绝
检测哈希函数的设计是否合理:

不合理—>重新设计哈希函数

  1. 哈希函数尽可能简单
  2. 哈希函数产生的哈希地址尽可能均匀分布
  3. 哈希函数的值域(哈希地址的集合)必须在哈希表格的范围内

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

5.1闭散列

从发生哈希冲突位置开始,向后找"下一个"空位置

1.线性探测

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

  • 插入

    1.通过哈希函数获取待插入元素在哈希表中的位置
    2.检测该位置是否为空:

    没有元素则直接插入新元素
    如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素
    在这里插入图片描述

  • 删除

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

// 哈希表每个空间给个标记
// EMPTY此位置空, EXIST此位置已经有元素, DELETE元素已经删除
enum State{EMPTY, EXIST, DELETE}; 

要求: 表格中的元素唯一,状态为删除的位置不能插入元素

1.1什么时机增容,如何增容?

表格中的元素一定不会存储满—随着元素不增多,产生哈希冲突的概率就急剧增加
哈希负载因子α: α = 有效元素个数/哈希表的容量

越小:产生冲突概率越低
越大:产生冲突概率越高
线性探测α控制在0.7左右最好,超过性能急速下降,达到70%就扩容

除留余数法,最好模一个素数,如何每次快速取一个类似两倍关系的素数?

Common.hpp

const int PRIMECOUNT = 28;
const size_t primeList[PRIMECOUNT] =
{
	53ul, 97ul, 193ul, 389ul, 769ul,
	1543ul, 3079ul, 6151ul, 12289ul, 24593ul,
	49157ul, 98317ul, 196613ul, 393241ul, 786433ul,
	1572869ul, 3145739ul, 6291469ul, 12582917ul, 25165843ul,
	50331653ul, 100663319ul, 201326611ul, 402653189ul, 805306457ul,
	1610612741ul, 3221225473ul, 4294967291ul
};
size_t GetNextPrime(size_t prime)
{
	size_t i = 0;
	for (; i < PRIMECOUNT; ++i)
	{
		if (primeList[i] > prime)
			return primeList[i];
	}

	return primeList[i];//PRIMECOUNT-1
}
1.2线性探测的优缺点

优点: 实现非常简单,
缺点: 一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”,即:不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低。在这里插入图片描述
线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题

2.二次探测

H(0)—>第一次计算出的哈希地址,H(i)=H(0)+i²(i代表第几次探测),H(i+1)=H(0)+(i+1)²,
即:H(i+1)=H(i)+2*i+1
当表的长度为质数且表装载因子a不超过0.6时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.6,如果超出必须考虑增容。

2.1 二次探测的优缺点

优点: 解决了线性探测数据堆积的问题
缺点: 因为一直要控制α在0.6左右,所以不易探查到整个散列空间,空间利用率低。即:空位置比较少时,可能需要多次才能找到

3.闭散列的两种探测实现

字符串哈希算法
HashTable.hpp

#pragma once
enum State { EMPTY, EXIST, DELETE };

//哈希表:元素唯一
template<class T,bool IsLine=true>
class hashtable
{
	struct Elem
	{
		Elem()
			:_state(EMPTY)
		{}
		T _data;
		State _state;
	};
public:
	hashtable(size_t capacity)
		: _table(capacity)
		, _size(0)
		, _total(0)
	{}
	bool Insert(const T& data)
	{
		//考虑增容
		_CheckCapacity();
		//1.通过哈希函数计算哈希位置
		size_t hashAddr = HashFunc(data);
		size_t i = 0;//二次探测中的第i次探测
		//2.检测位置是否可以存储
		while (_table[hashAddr]._state != EMPTY)
		{
			//检测该位置元素是否有效&&是否为待插入元素
			if (EXIST == _table[hashAddr]._state&&data == _table[hashAddr]._data)
				return false;
			//发生哈希冲突
			if (IsLine)
			{
				DetectiveLine(hashAddr);
			}
			else
			{
				++i;
				DetectiveTwice(hashAddr, i);
			}
		}
		_table[hashAddr]._data = data;
		_table[hashAddr]._state = EXIST;	
		_size++;
		_total++;
		return true;
	}
	
	int Find(const T& data)
	{
		size_t hashAddr = HashFunc(data);
		size_t i = 0;//二次探测中的第i次探测
		while (EMPTY != _table[hashAddr]._state)
		{
			if (EXIST == _table[hashAddr]._state&&data == _table[hashAddr]._data)
				return hashAddr;
			//发现哈希冲突
			if (IsLine)
			{
				DetectiveLine(hashAddr);
			}
			else
			{
				++i;
				DetectiveTwice(hashAddr, i);
			}
		}
		return -1;
	}
	bool Erase(const T& data)
	{
		size_t ret = Find(data);
		if (-1!=ret)
		{
			_table[ret]._state = DELETE;
			--_size;
			return true;
		}
		return false;
	}
	size_t Size()const
	{
		return _size;
	}
	void Swap(hashtable<T,IsLine>& ht)
	{
		_table.swap(ht._table);
		swap(_size, ht._size);
		swap(_total, ht._total);
	}
private:
	size_t HashFunc(const T& data)
	{
		return data % _table.capacity();
	}
	void DetectiveLine(size_t &hashAddr)//线性探测
	{
		hashAddr += 1;
		if (hashAddr == _table.capacity())
			hashAddr = 0;
	}
	void DetectiveTwice(size_t &hashAddr,size_t i)//二次探测
	{
		hashAddr = hashAddr + 2 * i + 1;
		if (hashAddr >= _table.capacity())
			hashAddr %= _table.capacity();
	}

	void _CheckCapacity()
	{
		if (_total * 10 / _table.capacity() >= 7)
		{
			//新的哈希桶
			hashtable<T,IsLine> newHT(_table.capacity() * 2);
			//旧哈希表中有效元素搬移到新哈希表中
			//注意:已经删除的元素不用搬移
			for (size_t i=0;i<_table.capacity();i++)
			{
				if (EXIST == _table[i]._state)
					newHT.Insert(_table[i]._data);
			}
			this->Swap(newHT);
		}
	}
private:
	vector<Elem> _table;
	size_t _size;//哈希表中有效元素的个数
	size_t _total;//哈希表中元素个数:存在和删除(为了保证哈希表中的唯一性,在删除位置不能插入元素)
};

void TestHashTable()
{
	hashtable<int,true> ht(10);
	ht.Insert(1);
	ht.Insert(23);
	ht.Insert(78);
	ht.Insert(13);
	ht.Insert(19);
	ht.Insert(29);
	cout << ht.Size() << endl;
	int ret = ht.Find(23);
	if (-1 != ret)
		cout << "23 is in hashtable" << endl;
	else
		cout << "23 is not in hashtable";
}
4.闭散列的缺陷

因此:由上面两种方法的缺点分析,比散列最大的缺陷就是空间利用率比较低(只能是0.6-0.7左右),这也是哈希的缺陷,所以一般很少用闭散列实现哈希

5.2开散列

1. 开散列概念

开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
在这里插入图片描述
开散列中每个链表桶中放的都是发生哈希冲突的元素。

1.1拉链法的优缺点

优点:

  • 拉链法处理冲突简单,且无堆积现象,即非同义词决不会发生冲突,因此平均查找长度较短;
  • 由于拉链法中各链表上的结点空间是动态申请的,故它更适合于造表前无法确定表长的情况;
  • 开放定址法为减少冲突,要求装填因子α较小,故当结点规模较大时会浪费很多空间。而拉链法中可取α≥1,且结点较大时,拉链法中增加的指针域可忽略不计,因此节省空间;
  • 在用拉链法构造的散列表中,删除结点的操作易于实现。只要简单地删去链表上相应的结点即可。而对开放地址法构造的散列表,删除结点不能简单地将被删结点的空间置为空,否则将截断在它之后填人散列表的同义词结点的查找路径。这是因为各种开放地址法中,空地址单元(即开放地址)都是查找失败的条件。因此在用开放地址法处理冲突的散列表上执行删除操作,只能在被删结点上做删除标记,而不能真正删除结点。

缺点

  • 指针需要额外的空间,故当结点规模较小时,开放定址法较为节省空间,而若将节省的指针空间用来扩大散列表的规模,可使装填因子变小,这又减少了开放定址法中的冲突,从而提高平均查找速度。
2.开散列代码实现

HashBucket.hpp

#pragma once

//开散列:一个链表的集合---产生相同哈希地址的元素放到同一个链表中
#include <sstream>
#include <string>
#include <vector>
#include "Common.hpp"
template<class T>
struct HashNode
{
	HashNode(const T&data = T())
		:_pNext(nullptr)
		, _data(data)
	{}
	HashNode<T>* _pNext;
	T _data;
};
template<class T>
struct DefD2INIT
{
	const T& operator()(const T& data)
	{
		return data;
	}
};
struct Str2INT
{
	size_t operator()(const string& s)
	{
		return (size_t)s.c_str();//可以学习博客中的字符串哈希算法
	}
};
template<class T,class DTOINT=DefD2INIT<T>>
class hashbucket
{
	typedef HashNode<T> Node;
	typedef hashbucket<T, DTOINT> Self;
public:
	hashbucket(size_t capacity)
		:_table(GetNextPrime(capacity))
		, _size(0)
	{}
	~hashbucket()
	{
		Clear();
	}
	bool Insert(const T& data)
	{
		_CheckCapacity();
		//通过哈希函数计算哈希桶号
		size_t bucketNo = HashFunc(data);
		Node* pCur = _table[bucketNo];
		while (pCur)
		{
			if (data == pCur->_data)
				return false;
			pCur = pCur->_pNext;
		}
		pCur = new Node(data);
		pCur->_pNext = _table[bucketNo];
		_table[bucketNo] = pCur;
		_size++;
		return true;
	}
	bool Erase(const T& data)
	{
		size_t bucketNo = HashFunc(data);
		Node* pCur = _table[bucketNo];
		Node* pPre = nullptr;
		while (pCur)
		{
			if (data == pCur->_data)
			{
				//可以删除
				if (pCur == _table[bucketNo])
					_table[bucketNo] = pCur->_pNext;
				else
					pPre->_pNext = pCur->_pNext;
				delete pCur;
				--_size;
				return true;
			}
			else
			{
				pPre = pCur;
				pCur = pCur->_pNext; 
			}
		}
		return false;
	}
	Node* Find(const T& data)
	{
		size_t bucketNo = HashFunc(data);
		Node* pCur = _table[bucketNo];
		while (pCur)
		{
			if (pCur->_data == data)
				return pCur;
			pCur = pCur->_pNext;
		}
		return nullptr;
	}
	size_t Size()const
	{
		return _size;
	}
	bool Empty()const
	{
		return 0 == _size;
	}
	void Clear()
	{
		for (size_t bucketNo=0;bucketNo<_table.capacity(); bucketNo++)
		{
			Node* pCur = _table[bucketNo];
			while (pCur)
			{
				_table[bucketNo] = pCur->_pNext;
				delete pCur;
				pCur = _table[bucketNo];
			}
		}
	}
	void Swap(Self& ht)
	{
		_table.swap(ht._table);
		swap(_size, ht._size);
	}
	void PrintfHashBucket()
	{
		for (size_t i=0;i<_table.capacity();++i)
		{
			Node* pCur = _table[i];
			cout << "table[" << i << "]:";
			while (pCur)
			{
				cout << pCur->_data << "--->";
				pCur = pCur->_pNext;
			}
			cout << "NULL"<<endl;
		}
	}
private:
	size_t HashFunc(const T& data)
	{
		//T:可以是任意类型,可能不是整型,string怎么办?
		return DTOINT()(data) % _table.capacity(); //怎么保证每次扩容是两次关系且(%的是素数),有待百度
	}
	void _CheckCapacity()
	{
		if (_size == _table.capacity())
		{
			Self newHT(GetNextPrime(_table.capacity()));
			//将旧哈希桶中的节点往新哈希桶中搬移
			for (size_t i = 0; i < _table.capacity(); ++i)
			{
				Node* pCur = _table[i];
				//将i号桶中的所有节点搬移到新哈希桶中
				while (pCur)
				{
					//将pCur节点从_table的第i号桶移除掉
					_table[i] = pCur->_pNext;
					//将pCur节点插入到新哈希桶中
					size_t bucketNo = newHT.HashFunc(pCur->_data);
					//采用头插法
					pCur->_pNext = newHT._table[bucketNo];
					newHT._table[bucketNo] = pCur;
					newHT._size++;
					_size--;
					pCur = _table[i];
				}
			}
			this->Swap(newHT);
		}
	}
private:
	vector<Node*> _table;//存每一个链表的首地址
	size_t _size;
};
void TestHashBucket1()
{
	hashbucket<int> ht(10);
	ht.Insert(1);
	ht.Insert(5);
	ht.Insert(15);
	ht.Insert(23);
	ht.Insert(24);
	ht.Insert(28);
	ht.Insert(25);
	ht.Insert(20);
	ht.Insert(29);
	ht.Insert(19);
	cout << ht.Size() << endl;
	ht.PrintfHashBucket();
	ht.Insert(11);
	ht.PrintfHashBucket();
	ht.Erase(5);
	ht.PrintfHashBucket();
	ht.Erase(25);
	ht.PrintfHashBucket();
}
void TestHashBucket2()
{
	hashbucket<string,Str2INT> ht(10);
	ht.Insert("hello");
	ht.Insert("111111");
	ht.Insert("你好");
	cout << ht.Size() << endl;
	ht.PrintfHashBucket();
}

5.3开散列与闭散列比较

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值