在前面的学习中,我们看了静态查找和动态查找,今天我们来看哈希查找。线性表,二叉搜索树,AVL树,红黑树和B树,元素在存储结构中的位置与元素的关键码之间不存在直接的对应关系。在这些数据结构中,查找一个元素需要进行一系列的关键码的比较。查找的效率取决于查找过程中比较的次数。
1. 哈希查找的定义
在插入或者查找时,根据元素的关键码,以哈希函数计算出该元素的存储位置并按照此位置进行存放。该方式为散列方法( Hash Method),在散列方法中使用的转换函数为散列函数(Hash Function),构造出来的结构为散列表(Hash Table)。
2. 哈希冲突(哈希碰撞)
对于两个数据元素的关键字Ki 和 Kj (i != j),有Ki != Kj ,但HashFun(Ki) == HashFun(Kj),将该种现象称为哈希冲突或哈希碰撞。如下:
在这种情况下,如果插入元素为13,则经过散列函数计算后的地址和元素430的地址相同,即发生了哈希冲突。那么,在这种情况下,我们应该怎么解决呢?等会再来看!
3. 哈希函数(散列函数)
(1)直接定址法
去关键字的某个线性函数为散列地址:Hash (key) = A*key+B。A 和 B的值任意取,但是不能太大。
优点:简单,均匀,适合查找比较小且连续的情况。
缺点:需要事先知道关键字的分步情况
(2)除留余数法
设散列表中允许的地址数为m,取一个不大于m,但接近或者等于m的质数p作为除 数,按照哈希函数:Hash(key) = key % p p<=m,将关键码转换成哈希地址。
优点:冲突较小
缺点:质数(素数)p的值较难取。
(3)平方取中法
假设关键字是1234,那么它的平方就是1522756,再抽取中间的3位就是227作为散列地址;再比如关键字是4321,那么它的平方就是18671041,抽取中间的3位就可以是671或者710用作散列地址。
平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况。
(4) 折叠法
折叠法是将关键字从左到右分割成位数相等的几部分(注意:后一部分位数不够时可以短些),然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。比如:关键字是9876543210,散列表表长为三位,我们将它分成四组987|654|321|0|, 然后将它们叠加求和987+654+321+0=1962,再求后3位得到散列地址为962。有时可能这还不能够保证分布均匀,不妨从一段向另一端来回折叠后对齐相加。比如将987和 321反转,再与654和0相加,编程789+654+123+0=1566,此时的散列地址为566。
折叠法适合事先不需要知道关键字的分布,适合关键字位数比较多的情况。
(5) 随机数法
选择一个随机函数,取关键字的随机函数值为它的哈希地址,即H(key) = random(key),其中random为随机数函数,通常应用于关键字长度不等时采用此法。
(6) 数学分析法
设有n个d位数,每一位可能有r种不同的符号,这r种不同的符号在各位上出现的频率不一定相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只有某几种符号经常出现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散列地址。
数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布较均匀的情况。
总结:构造哈希函数需要注意以下几点:
1、哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0到m-1之间
2、哈希函数计算出来的地址能均匀分布在整个空间中
3、哈希函数应该比较简单
4. 解决哈希冲突
(1)闭散列法
闭散列也叫开地址法。设散列表的编址为0到m-1,当添加关键码key时通过散列函数 hash(key)计算key的存放位置,但在存放时发现这个桶已经被另一个keyx占据了,即发生哈希冲突。如果表未被装满,表示在给定的范围内必然还有空位置,则可以把key存放到表中“下一 个”空位中。
线性探查:
设给出一组元素,它们的关键码为:37,25,14,36,49,68,57,11,散列表为HT[12],表的大小m = 12,假设采用Hash(x) = x % p; // (p = 11) 11是接近m的质数,就有:
Hash(37) = 4 Hash(25) = 3 Hash(14) = 3 Hash(36) = 3 Hash(49) = 5 Hash(68) = 2 Hash(57) = 2 Hash(11) = 0
添加元素时,使用散列函数确定元素的插入位置,如果此空间有值:
1、该值是所要插入元素的关键码,不进行插入
2、产生冲突,依次查看其后的下一个桶,如果发现空位置插入新元素。
注:线性探查法容易产生数据“堆积”,即不同探查序列的关键码占据了可利用的空位置,使得寻找 某关键码的位置需要许多次比较,导致搜索时间增加。
二次探查
使用二次探查法,在表中寻找“下一个”空位置的公式为: Hi = (H0 + i^2)%m, Hi = (H0 - i^2)%m, i = 1,2,3…,(m-1)/2 H0是通过散列函数Hash(x)对元素的关键码x进行计算得到的位置,m是表的大小。
假设数组的关键码为37,25,14,36,49,68,57,11,假设取m=19,这样可设定为HT[19],采用散列函 数Hash(x) = x % 19 Hash(37)=18 Hash(25)=6 Hash(14)=14 Hash(36)=17 Hash(49)=11 Hash(68)=11 Hash(57)=0 Hash(11)=11
在闭散列这种方法下,都是利用一维数组来存储元素的,而一维数组的大小总是有限的,那么在什么情况下,我们认为存储空间是满的呢?在什么情况下,闭散列解决哈希冲突的效率最高呢?
(1)在经过研究后,我们利用负载因子α来衡量散列表装满的程度。由于表长是定值,α与表中的元素个数成正比,所以,α的值越大,产生的冲突可能更大。在一般情况下,负载因子的值要严格限制在0.7 - 0.8 之间,超过0.8,则CPU的缓存按照指数曲线上升,效率会低下。负载因子 α = 填入表中的元素个数 / 散列表的长度。
(2)当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任何一个 位置都不会被探查两次。因此只要表中有一半的空的,就不会有表满的问题。在搜索时可以不考虑 表装满的情况,但在插入时必须确保表的装载因子a不超过0.5;如果超出必须考虑增容。
好了,在简单的介绍完成哈希的基本概念,我们先来实现一个简单的利用线性探查解决哈希冲突的代码吧:
#define size 11
typedef enum State{EMPTY,EXIST,DELETE};
template <class K>
struct HashNode
{
K _key;
State state;
};
template <class K>
class Hash
{
typedef HashNode<K> Node;
public:
Hash()
:sz(0)
{
for (int i = 0; i < size; i++)
{
_array[i].state = EMPTY;
}
}
void Insert(const K& key)
{
int ret = _Insert(key);
if(ret!=-1)
{_array[ret]._key = key;
_array[ret].state = EXIST;
sz++;
}
}
bool Find(const K& key)
{
int Address = HashFunction(key);
int startAdd = 0;
while (_array[Address].state != EMPTY)
{
if (_array[Address].state == EXIST &&_array[Address]._key == key)
return true;
startAdd = Address;
Address++;
if (Address == size)
Address = 0;
if (Address == startAdd)
return false;
}
return false;
}
bool Delete(const k&key)
{
int ret = _Find(key);
if (ret!=-1)
{
_array[ret].state = DELETE;
sz--;
}
}
private:
int _Find(const K& key)
{
int Address = HashFunction(key);
int startAdd = 0;
while (_array[Address].state != EMPTY)
{
if (_array[Address].state == EXIST &&_array[Address]._key == key)
return Address;
startAdd = Address;
Address++;
if (Address == size)
Address = 0;
if (Address == startAdd)
return -1;
}
}
int _Insert(const K& key)
{
int Address = HashFunction(key);
int startAdd = 0;
while (_array[Address].state !=EMPTY)
{
if (_array[Address].state == EXIST && key == _array[Address]._key)
{
return -1;
}
startAdd = Address;
Address++;
if (Address == size)
Address = 0;
if (Address == startAdd)
return -1;
}
return Address;
}
private:
//除留余数法取hash函数
int HashFunction(const K& key)
{
int Address = key % size;
return Address;
}
private:
Node _array[size];
int sz;
};
在上面代码中,我们利用一维数组实现了对哈希表的构造,利用除留余数法获取哈希函数,利用线性探测再散列解决哈希冲突,并且实现了哈希表的查找,插入以及删除元素等。但在上面代码中,存在以下问题:
(1)哈希表是静态的,存储的元素数目有限;
(2)只能存储整型元素,不具有扩展性;
(3)无法判断哈希表的存储是否满了等情况;
在基于上述问题下,我们做了以下优化:
(1)将哈希表改成动态存储的,自行开辟空间,并且实现增容等功能;
(2)引入负载因子,提高哈希表的存储和查找效率;
(3)在程序中引入将字符串转化为整型的函数,再利用仿函数实现对整型和字符串类型的存储和查找;
(4)将解决哈希冲突的二次探测再散列的方式加入其中;
1.//仿函数实现对整型和字符串类型的存储和查找
template<class K>
class _HashFunc
{
public:
int operator()(const K& key)
{
return key;
}
};
class _HashFuncstring
{
public:
size_t operator()(const string& key)
{
return BKDRHash(key.c_str());
}
};
2.//引入素数表,方便取容量
const int _PrimeSize = 28;
int GetNextPrime(int value)
{
static const unsigned long _PrimeList[_PrimeSize] =
{
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
};
for (int i = 0; i < _PrimeSize; i++)
{
if (_PrimeList[i]>value)
return _PrimeList[i];
}
return _PrimeList[_PrimeSize-1];
}
3.字符串转化为整型的函数
static size_t BKDRHash(const char* str)
{
unsigned int seed = 131;
unsigned int hash = 0;
while (*str)
{
hash = hash*seed + (*str++);
}
return (hash & 0x7FFFFFFF);
}
4.整体代码
template<class K,bool IsLine = true,class HashFun=_HashFuncstring >
class HashTable
{
typedef HashNode<K> Node;
typedef Node* PNode;
public:
HashTable(size_t capacity = 10)
:_sz(0)
, _capacity(capacity)
{
capacity = GetNextPrime(capacity);
_arr = new Node[capacity];
for (size_t i = 0; i < _capacity; i++)
{
_arr[i].state = EMPTY;
}
}
int Find(const K& key)
{
int ret = _Find(key);
if (ret != -1)
{
return ret;
}
return -1;
}
void Insert(const K& key)
{
int ret=_Insert(key);
if (ret != -1)
{
_arr[ret]._key = key;
_arr[ret].state = EXIST;
_sz++;
}
}
bool Delete(const K& key)
{
int ret = _Find(key);
if (ret != -1)
{
_arr[ret].state = DELETE;
_sz--;
return true;
}
return false;
}
bool Empty()const
{
return 0 == _sz;
}
size_t size()const
{
return _sz;
}
void clear()
{
delete[] _arr;
_arr = NULL;
}
~HashTable()
{
clear();
_sz = 0;
_capacity = 0;
}
private:
void CheckCapacity()
{
if (_sz * 10 / _capacity >= 7)
{
size_t newCapacity = GetNextPrime(_capacity);
HashTable<string, true, _HashFuncstring >ht(newCapacity);
for (size_t i = 0; i < _capacity; ++i)
{
if (_arr[i].state == EXIST)
{
ht.Insert(_arr[i]._key);
}
}
_capacity = newCapacity;
Swap(ht);
clear();
}
}
//插入唯一元素
int _Insert(const K& key)
{
size_t Address = HashFunc(key);
CheckCapacity();
size_t index = 0;
while (_arr[Address].state != EMPTY)
{
if (_arr[Address].state == EXIST&& _arr[Address]._key == key)
return -1;
//线性探测
if (IsLine)
LineCheck(Address);
//二次探测
//SecondCheck(Address, index++);
}
return Address;
}
int _Find(const K& key)
{
size_t Address = HashFunc(key);
size_t startAdd = 0;
size_t index = 0;
while (_arr[Address].state != EMPTY)
{
if (_arr[Address].state == EXIST&&_arr[Address]._key == key)
{
return Address;
}
//线性探测再散列找位置
startAdd = Address;
if (IsLine)
LineCheck(Address);
//二次探测再散列
//SecondCheck(Address, index++);
}
return -1;
}
private:
void Swap(HashTable<K>& ht)
{
swap(_arr, ht._arr);
swap(_capacity, ht._capacity);
swap(_sz, ht._sz);
}
void LineCheck(size_t& hashAddr)
{
hashAddr++;
if (hashAddr == _capacity)
hashAddr = 0;
}
void SecondCheck(size_t &hashAddr, size_t i)
{
hashAddr = (hashAddr + i*i) % _capacity;
if (hashAddr == _capacity)
hashAddr = 0;
}
size_t HashFunc(const K& key)
{
size_t Address = (HashFun()(key)) % _capacity;
return Address;
}
private:
Node* _arr;
size_t _sz;
size_t _capacity;
};
下节我们再来看哈希存储的第二种方式,开散列式存储,也叫做链地址法,这种方法是利用链表的形式存储的,提高效率。