1.散列搜索算法由两部分组成:
1.计算一个散列函数:将搜索关键字转换为表中地址。
2.冲突解决:处理多个关键字对应同一个地址的情况。
2.散列缺点:
1.运行时间依赖于关键字长度,对于长关键字不利。
2.对于选择和排序操作,散列不能带来高效。散列一般应用于有大量搜索、插入、删除的操作中。
3.散列函数:
散列函数将关键字的值转化为表中的地址。
一个好的散列函数应该考虑关键字的全部位。
理想的通用散列函数使得表中两个不同关键字产生冲突的几率为表大小M的倒数,即1/M。
1.对于w-位整数一种简单有效办法,选择表长M为素数,对于任何整数关键字k,计算k除以M的余数:h(k) = k mod M.
(对M取模得到的余数在 1~M 范围之内)
2.对于整数关键字,另一种可选择的方法是 把乘法和取模结合:将关键字乘以0到1之间的一个常数,然后做模M运算:
h(k)= kα mod M
k,α,和关键字之间相互影响。一般取得α为0.618(黄金分割比)
3.对于浮点型关键字,如果关键字在一个较小的范围内,则可以按比例将他们转化为0到1之间的数,再乘以2的W次方得到w-位的结构,然后使用模散列函数。
4.冲突解决:
如果我们能估计将要填入的散列表的元素数目,并且有足够的内存空间可以容纳带有空闲的空间的所有关键字,那么在散列表中采用链地址法是不值得的。对表长M大于元素数目N的情况,采用开放地址法:依靠空的存储空间来解决冲突问题。
一般开放定址法有线性探测,双重散列,动态散列。
4.1 链地址法
发送冲突的元素由各自的链表链接在一起,共有M个链表,可以使冲突元素在各自的链表中有序或者无序存储。
通常,我们在链地址法中采用无序链表,具有如下优点:
1.插入时间为常量,速度快。
2.每个链表可以轻易的插入、删除从链表头位置操作,像栈一样。
而有序链表使得搜索速度加快,但是插入操作减慢。
M取值:
链地址法使用M个链表的额外空间,平均来说,将顺序搜索的比较次数降低到1/M.
在链地址法的实现中,我们既要取M尽量小以避免浪费大量连续的内存空间,又要使M足够大以使顺序搜索效率最高。
根据经验一般选择M为链表中关键字数目的1/15或者1/10,这样每个地址链锁包含的元素数目期望值为5或者10.
当空间不是关键资源的时候,可以选择足够大的M以使得搜索时间为常量;当空间至关重要的时候,仍然可以在不超过我们能承受的范围内选择M,并使得性能得到与M成正比的提高。
链地址法实现
static link *heads,z;
static int N,M;
void STinit(int max)
{
int i;
N = 0; M =max/5;//M个链表,每个链表中大约5个节点。
heads = malloc(M * sizeof(link));
z=NEW(NULLitem,NULL);
for(i = 0;i < M; i++)
heads[i] = z;//初始化时每个链表头结点置空
}
Item STsearch(Key v)
{
return searchR(heads[hash[v,M]],v);//从第hash[v,M]个链表中取出v
}
Item STinsert(Item item)
{
int i = hash(key(item),M);//根据item 和 M 算hash值 i
heads[i] = NEW(item,heads[i]);//分配节点,从表头插入
N++;//数量增加
}
void STdelete(Item item)
{
int i = hash(key(item),M);//根据item 和 M 算hash值 i
heads = deleteR(heads[i],item);//从表头删除
N--;
}
4.2 线性探测法
当冲突发生时,我们检查表中的下一个位置,将这种检查称为探测。
线性探测法的特点是每次探测有三种可能的结构:
1.如果表位置上含有与搜索关键字匹配的元素,那么搜索命中。
2.如果表位置上为空,那么搜索失败。
3.如果表位置上的元素与搜索关键字不匹配,则继续探测表的更高索引,直到出现前两种结果中的一种。
装填因子α
与链地址法一样,开放定址法的性能依赖于一个比例α = N/M,但是这两种方法中的α意思不一样。
对于链地址法来说,α是每个地址链元素的平均个数,并且通常大于1.
对于开放定址法来说,α是表中位置被占据的百分比,其一定小于1.则称其为装填因子。对稀疏表(α较小),我们预期大多数搜索只需要几次探测就能找到表的空位。而对于一个接近满的表(α接近1),一次搜索将需要相当多次的探测,而且当表完全填满时,探测将变成死循环。
聚集
在线性探测中,多个元素聚合在一段连续空间中的现象称为 聚集。对于半满表(M = 2N)线性探测两种极端情况:
1.最佳情况是偶数位置的表是空的,奇数位置的表有元素。
2.最坏情况是表的后半段有元素,而前半段是空的。
插入与搜索程序实现:
#define null(A) (key(st[A] == key(NULLitem)))
static int N,M;
static Item *st;
void STinit(int MAX)
{
int i;
N = 0;M = 2*max;//将元素保存在大小为元素数目2倍的散列表中。
st = malloc(M * sizeof(Item));
for(i = 0;i<M;i++)
st[i] = NULLitem;//散列值初始化为空。
}
int STcount()
{
return N;
}
void STinsert(Item item)
{
Key v = key(item);
int i = hash(v,M);
while(!null(i))
i = (i+1) % M;//位置不空,则进行下一个位置比较。
st[i] = item;
N++;
}
Item STsearch(Key v)
{
int i = hash(v,M);
while(!null(i))
if eq(v,key(st[i]))//相等,则直接找到
return st[i];
else i = (i+1) % M;//不相等,则找下一个
return NULLitem;//没找到
}
![](https://i-blog.csdnimg.cn/blog_migrate/ad8aba5e7f7ab1f25ef4dbcdb52d86c4.png)
线性探测散列表中删除
如何在线性探测散列表中删除一个关键字呢?仅仅移走它是不行的,因为后面要插入的元素会跳过移走的元素留下的空位,因此应该将删除元素所在位置到其右边下一个空位之间的全部元素重新散列。
删除具有给定关键字的一个元素:
1.搜索这个元素,并用NULLitem代替。
2.纠正某个元素位于现在占据位置右边的可能性,而它初始时被散列到那个位置或其左边,因为空位将会终止对这个元素的搜索。
3.把同一聚集中的所有元素作为被删除的元素重新插入,并插入到那个删除元素的右边。
void STdelete(Item item)
{
int j,i=hash(key(item),M);
Item v;
while(!null(i))
if eq(key(item),key(st[i]))//找到待删元素
break;
else
i = (i + 1) % M;//继续找下一个
if (null(i)) //没有找到
return;
st[i] = NULLitem;//对其删除,设置为NULL
N--;
for(j = i+1;!null(i);j=(j+1)%M,N--)//将删除元素到其右边下一个空格之间的元素重新散列
{
v = st[j];
st[j] = NULLitem;//先删
STinsert(v);//再散列
}
}
4.3 双重散列法
线性探测法的一个重要原则是当搜索到某一特定关键字的时候,要保证对散列到同一地址的所有关键字都进行探测。
然而在开放定址法中,当表快要填满时,其他关键字也要被检查。
插入一个具有某一散列值的关键字会大大增加其他散列值关键字的搜索时间,使得接近满的散列表的线性探测操作运行速度变慢。
有一种简单的消除聚集问题方法,就是双重散列法,其基本策略与线性探测算法一样,不同的是不是检查表中冲突点后面的每一个位置,而是采用第二个散列函数得到一个用于探测序列的固定增量。
双重散列除了在每次冲突之后,使用第二个散列函数来确定搜索增量之外,其他与线性探测相同。搜索增量必定非零,表的大小与搜索增量互为素数。
第二个散列函数必须仔细选择,否则程序不起作用:
1.必须排除第二个散列函数产生散列值0的情况,因为0散列值将导致第一次冲突时出现死循环。
2.第二个散列函数产生的散列值必须与表长互素,否则某些散列探测序列将会非常短。
一种方法是使M为素数,再选择第二个散列函数,使其返回值小于M的值:#define hashtwo(v) ((v % 97) +1)
如果散列表大而稀疏,则表长不必为素数。
void STinsert(Item item)
{
Key v = key(item);
int i = hash(v,M);
int k = hashtwo(v,M);
while(!null(i))
i = (i+k) % M;//若第一次hash之后有冲突存在,则不断使用第二次hashtwo的值。
st[i] = item;//直到没有冲突,进行插入。
N++;
}
Item STsearch(Key v)
{
int i = hash(v,M);
int k = hashtwo(v,M);
while(!null(i))
{
if eq(v,key(st[i])) //找到
return st[i];
else //没找到,则不断使用第二次hashtwo的值。
i = (i+k) % M;
}
return NULLitem;//真没找到
}
![](https://i-blog.csdnimg.cn/blog_migrate/48c6757419552f12a94c67467348ac25.png)
4.4 动态散列表
随着散列表中关键字数目的增多,搜索性能会不断下降。采用链地址法,搜索时间逐步增大--当表中关键字的个数加倍时,搜索时间也加倍。这种时间对于采用线性探测或者双重散列开放定址法也是一样。甚至到达不能插入更多关键字状态。
一种不使散列表增大的方法,当表接近半满时使表大小加倍,加倍之后表中每个元素重新插入。当删除操作使得表降为1/8满时,我们将表长减半以使表收缩。
因此总开销是线性变化的,而且散列表总是介于1/8与1/4满的状态之间。
其缺点是在于表扩张和缩减时重新散列和内存分配所带来的开销问题。
动态散列插入步骤:
1.每当表处于半满时加倍表长。
2.加倍要求我们为新表分配内存空间。
3.重新把关键字散列到新表中。
4.释放旧表所占的内存。
void STinsert(Item item)
{
Key v = key(item);
int i = hash(v,M);
while(!null(i))
i = (i + 1) % M;//线性探测法
st[i] = item;
if(N++ >= M/2)//1.半满则加倍
expend();
}
void expend()
{
int i;
Item *st = st;
init(N+M);//2.新表分配空间
for(i = 0;i < M/2;i++)
if(key(t[i]) != key(NULLitem))//3.重新散列关键字到新表
STinsert(t[i]);
free(t);//4.释放旧表内存
}
5 总结
所有散列算法都可以把符号表的插入和搜索操作开销减少到某一常数时间。
线性探测法最快,双重散列法使用内存最高效,链地址法最易实现。
链地址法需要额外的空间存储链表,开放定址法需要在表内使用额外的空间来终止探测。