文章目录
哈希
用来进行高效查找的一种数据结构----O(1)
几种不同的查找方式:
- 顺序查找—直接遍历—>O(N)
- 数据集合—>有序—>二分查找—>O(logN)
- 采用二叉平衡树结构组织数据:AVL、红黑树—>O(logN)
以上都需要进行元素比较
理想的查找方式:不需要比较
1.O(1)的根本原因:
通过某种方式,将元素与其在空间的存储位置建立一一映射的关系
哈希:散列
在表格查找x的元素:
- 计算元素x在表格中的位置:
- 看该位置存储的元素是否为待查找的元素
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.哈希冲突解决
哈希函数—不论设计的多精妙—不可能完全解决哈希冲突
哈希函数设计比较好—>将产生哈希冲突的概率降低,但是不能完全杜绝
检测哈希函数的设计是否合理:
不合理—>重新设计哈希函数
- 哈希函数尽可能简单
- 哈希函数产生的哈希地址尽可能均匀分布
- 哈希函数的值域(哈希地址的集合)必须在哈希表格的范围内
解决哈希冲突两种常见的方法是: 闭散列和开散列
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,而表项所占空间又比指针大的多,所以使用链地址法反而比开地址法节省存储空间。