接上篇 散列的简要描述和链地址法
解决散列冲突的方法:
1. 线性探测法
如果我们能够预测将要存入表中元素的数目,而且我们有足够的内存空间可以容纳带有空闲空间的所有关键字,那么使用链地址法是不值得的。我们依靠空的存储空间解决冲突:设计表长M大于元素数目N,开放地址法,最简单的开放地址法是线性探测法:
初始化
该符号表的实现将元素保存到大小是元素个数两倍的散列表中。
void HashTableInit(int max)
{
N = 0;
M = 2*max;
hash_table = new Item[M];
for(int i = 0; i < M; i++)
hash_table[i] = NULLItem;
}
插入
(1)当冲突发生时,即准备插入的位置已被占用,我们检查表中的下一个位置,
(2)如果下一个位置也被占用,继续检查下一个,知道遇到一个空位,然后将其插入
在搜索时:
void HashTableInsert(Item item)
{
int hash_key = Hash(GetKey(item), M);
while (hash_table[hash_key] != NULLItem) {
hash_key = (hash_key+1) % M;
}
hash_table[hash_key] = item;
N++;
}
搜索
我们将检查表中是否存在与搜索关键字匹配的元素成为探测。线性探测法的特点是每次探测有3种可能结果:
(1)搜索命中(当前位置上的元素关键字与搜索关键字匹配,停止搜索),
(2)搜索失败(搜索位置为空,停止搜索),
(3)不匹配(搜索位置非空,但是不匹配,继续搜索下一位置)。
Item HashTabelSearch(KeyItem v)
{
int hash_key = Hash(v, M);
//遇到空位置,搜索失败
while (hash_table[hash_key] != NULLItem) {
if(eq(GetKey(hash_table[hash_key]), v)) //搜索命中
break;
hash_key = (hash_key+1) % M; //不匹配
}
return hash_table[hash_key];
}
删除
线性探测表中的删除,仅仅移走删除关键字对应的元素时不够的。因为当移走后形成的空位会导致其后面元素的搜索失败(空位终止向后搜索)。因此,应该将删除位置与其右边的下一个空位之间所有元素重新散列插入到表中。
void HashTabelDelete(Item item)
{
int hash_key = Hash(GetKey(item), M);
//寻找删除位置
while (hash_table[hash_key] != NULLItem) {
if (eq(GetKey(item), GetKey(hash_table[hash_key])))
break;
else
hash_key = (hash_key+1) % M;
}
if (hash_table[hash_key] == NULLItem)
return;
hash_table[hash_key] = NULLItem;
N--;
//将删除位置到其右边下一个空位之间所有的元素重新散列
for (int j = hash_key+1; hash_table[j] != NULLItem; j = (j+1)%M) {
HashTableInsert(hash_table[j]);
hash_table[j] = NULLItem;
}
}
性能分析
开放地址法的性能依赖于α=N/M,表示表中被位置被占据的百分比,成为装填因子。
对于稀疏表(α较小),预期几次探测就能找到表的空位,但对于接近满的表(α较大),一次搜索要经历相当多次的探测。因此我们为了避免过长时间搜索,不允许表达到接近满。
在线性探测中,多个元素聚合到连续一段空间成为聚焦,这将导致搜索时间的变慢。时间平均开销依赖于插入时的聚焦情况。即:要经历很多次的探测才能确定是否搜索成功(匹配)或失败(空位)。
2. 双重散列表
对于线性探测法,当聚焦问题严重或者表接近满时,要搜索一个关键字,往往要逐个检查很多个无关项(先于和搜索关键字匹配的元素插入)。为了解决聚焦问题,提出了双重散列算法,其基本策略和线性探测法一项,唯一不同是:它不是检查冲突位置后的每一个位置,而是采用另一个散列函数产生一个固定的增量。(跳跃式检查和插入,减小聚焦大小)
假设第二个散列函数值为T
- 线性探测法:逐个检查冲突位置的下一个位置
- 双重散列表:每隔T个位置检查一次
注:第二个散列函数要仔细选择,需满足条件
(1)排除散列值是0的情况
(2)产生的散列值必须与表长M互素
常用`#define Hash2(v) ((v % 97) + 1)
2.1 搜索和插入
void HashTableInsert(Item item)
{
int hash_key = Hash(GetKey(item), M);
int hash_key2 = Hash2(GetKey(item), M);
while (hash_table[hash_key] != NULLItem) {
// hash_key = (hash_key+1) % M; 线性探测时 +1
hash_key = (hash_key+hash_key2) % M;
}
hash_table[hash_key] = item;
N++;
}
Item HashTabelSearch(KeyItem v)
{
int hash_key = Hash(v, M);
int hash_key2 = Hash2(v, M);
while (hash_table[hash_key] != NULLItem) {
if(eq(GetKey(hash_table[hash_key]), v))
break;
hash_key = (hash_key+hash_key2) % M;
}
return hash_table[hash_key];
}
2.2 删除
如果双重散列表的删除操作继承线性探测算法:那么删除算法会降低双重散列表的性能,因为待删除关键字有可能影响整个表中的关键字,解决办法是:用一个观察哨代替已删除元素,表示该位置被占用,不与任何关键字匹配。
2.3 性能分析
如果要保证所有搜索的平均开销小于t次探测,那么线性探测法和双重散列法的装填因子分别要小于1?1/t√和1?1/t
3. 动态散列表
因为随着表中关键字的增多,表的性能会下降,一种解决办法是:当表快要满时,表的大小加倍,然后被所有元素重新插入。(非经常性操作,可以接受)
如果表支持删除的ADT操作,随着表元素减少,值得对表大小减半。但需要注意的是表长加倍和减半的阈值时不同的。如在半满时加倍,在1/8满时减半。
表长动态变化能够合理处理各种元素个数的变化,缺点是表的扩张和缩减时的重新散列和内存分配的开销。
4. 散列的综述
- 线性探测是三者中最快的(前提是内存足够大保证表稀疏)
- 双重散列法使用内存最高效(需要额外开销计算第二个散列值)
- 链地址法易于实现(假设已经存在好的内存分配),特别对于删除操作;对于固定表长通常选择链地址法。
如何选择:
- 是选择线性探测还是双重散列主要取决于:计算散列函数的开销和装填因子。α较小,两者均可;长关键字计算散列函数开销大;装填因子α接近于1,双重散列性能大于线性探测。
- 链地址法需要额外的内存空间存储链接,但是有一些符号表中已经事先分配好了链接字段的元素。虽然不如开放地址法快,但性能仍然比顺序搜索快的多。
- 当以搜索为主且关键字数目不能精确预测时,动态散列表是可选的。
5. 所有源码
http://download.csdn.net/detail/quzhongxin/8620465
参考资料 《算法:C语言实现》p388-401