处理哈希冲突的拉链法(哈希桶)
对于不同的关键字可能会通过散列函数映射到同一地址,为了避免非同义词发生冲突,可以把所有的同义词存储在一个线性链表中,这个线性链表由其散列地址唯一标识。假设散列地址为 i 的同一此链表的头指针存放在散列表的第 i 个单元中,因而查找、插入和删除操作主要在同义链中进行。
拉链法适用于经常进行插入和删除的情况
例如,关键字序列为{19,14,23,1,68,20,84,27,55,11,10,79},散列函数为H(key)=key%13,用拉链法处理冲突,建立的哈希表如下图所示:
拉链法解决哈希冲突的实现:
底层:使用vector容器实现
哈希函数的构造:key%_table.size(),同样我们可以用仿函数来实现,本文只考虑了最简单的整数的情况
增容:增容的方式同开放定址法;但是在重新映射时需要使用拉链法,采用头插的方式将旧表中的同义词项插入到新表中,在这里要注意区分哈希表的next位置(vector指针)和同义词链表的next位置(链表指针)
插入:若当前位置为空,直接插入;若不为空,则采用链表的头插法进行插入;同样,若此时哈希表中已经有该key值,则插入失败
查找:遍历key标识位置的整个链表
删除:删除我们首先考虑的是使用Find()函数查找需要删除的元素,然后进行删除,但是对于链表中元素的删除,若使用Find(),则我们无法找到当前需要删除的元素的前一个位置,这样就无法将链表链起来;所以我们采用遍历整个哈希表的方法,设置两个指针prev(当前删除的元素的前一个位置),cur(删除元素的位置);在遍历时,通过循环来控制哈希表的下一个位置;通过_next来控制链表的下一个位置
析构:开放定制法中我们不写析构函数是因为vector底层会自动进行析构;但是因为拉链法涉及到链表,vector数组中每一个元素都是一个链表,我们需要手动对链表进行析构,否则会发生内存泄漏
代码实现:
template <class K,class V>
struct HashNode
{
K _key;
V _value;
HashNode<K, V>* _next;
HashNode(const K& key,const V& value)
:_key(key)
, _value(value)
,_next(NULL)
{}
};
template <class K, class V>
class HashTable
{
typedef struct HashNode<K, V> Node;
public:
HashTable()
:_size(0)
{
_table.resize(_GetPrime(0));
}
bool Insert(const K& key,const V& value)
{
_CheckCapacity();
size_t index = _HashFunc(key);
Node* cur = _table[index];
while (cur)
{
if (cur->_key == key) //该key值已存在
return false;
cur = cur->_next;
}
Node* tmp = new Node(key, value);
tmp->_next = _table[index];
_table[index] = tmp;
++_size;
}
Node* Find(const K& key)
{
size_t index = _HashFunc(key);
Node* cur = _table[index];
while (cur)
{
if (cur->_key == key)
return cur;
cur = cur->_next;
}
return NULL;
}
bool Erase(const K& key)
{
size_t index = _HashFunc(key);
Node* cur = _table[index];
Node* prev = NULL;
while (cur)
{
if (cur->_key == key)
{
if (prev == NULL)
_table[index] = cur->_next;
else
prev->_next = cur->_next;
delete cur;
cur = NULL;
--_size;
return true;
}
prev = cur;
cur = cur->_next;
}
return false;
}
~HashTable()
{
for (size_t i = 0; i < _table.size(); ++i)
{
Node* cur = _table[i];
while (cur)
{
Node* next = cur;
delete cur;
cur = next;
}
_table[i] = NULL;
}
}
protected:
size_t _HashFunc(const K& key)
{
return key%_table.size();
}
size_t _GetPrime(const size_t size)
{
const int _PrimeSize = 28;
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 (size_t i = 0; i <_PrimeSize; i++)
{
if (size < _PrimeList[i])
return _PrimeList[i];
}
//没找到就返回最后一个数
return _PrimeList[_PrimeSize - 1];
}
void _CheckCapacity()
{
if (_size * 10 / _table.size() >= 7 || _table.size() == 0)
{
vector<Node*> newTable;
size_t newSize = _GetPrime(_table.size());
newTable.resize(newSize);
for (size_t i = 0; i < _table.size(); ++i)
{
Node* cur = _table[i];
while (cur)
{
//链表的下一个节点,头插
size_t index = _HashFunc(cur->_key);
cur->_next = newTable[index];
newTable[index] = cur;
cur = cur->_next;
}
_table[i] = NULL;
}
_table.swap(newTable);
}
}
private:
vector<Node*> _table;
size_t _size;
};
- 插入元素运行结果如下所示:
- 查找元素运行结果如下所示:
- 删除元素运行结果如下图所示:
散列表查找性能分析
散列表最常用来做查找,通过对记录的关键字的值进行哈希函数运算,直接求出记录存储的位置,是关键字到地址的直接装换方法,而不需要反复比较
通过本文和前面对哈希冲突的开放定址法实现和拉链法实现,我们可知虽然散列表是在关键字和存储位置之间直接建立了对应关系,但是由于产生了冲突,散列表的查找过程仍然有一个与关键字比较的过程,不过散列表的平均查找长度要比顺序查找和二分查找小
我们通过实例来分析一下散列表查找的性能:
线性探测法
(查找成功)平均查找长度:ASL=(1+1+2+1+4+1+1+3+1+1)/10=1.6
(查找不成功)平均查找长度:ASL=(6+5+4+3+2+1+4+3+2+1+3+2+1)/13=2.85
二次探测法
(查找成功)平均查找长度:ASL=(1+1+2+1+3+1+1+3+1+1)/10=1.5
(查找不成功)平均查找长度:ASL=(3+2+3+2)/13=0.77
拉链法
(查找成功)平均查找长度:ASL=(1*7+2*2+3)/10=1.4
(查找不成功)平均查找长度:ASL=(1+3+0+1+0+0+2+1+0+0+1+1+0)/13=0.77
可见,同一个散列函数,用不同的解决冲突方法所构造的散列表,其查找成功时和不成功时的平均查找长度都是不同的
平均查找长度依赖于负载因子a,开放定址法要求散列表的负载因子a<=1,实际中我们一般取0.7-0.8以下,在拉链法中,其负载因子可以大于1,但一般均取a<=1。只要a选择合适,散列表上的平均查找长度就是一个常数。