散列表----散列函数与用链接法解决冲突


散列表是实现字典操作的一种有效数据结构,你可以把它和STL中的map或者Python中的字典dict相联系。散列表类似于字典的目录,每个查找元素都有一个key与之对应。尽管在最坏情况下散列表查找一个元素的时间与链表持平,达到O(n),然而在实际应用中,散列表的性能是极好的,在一些合理的假设下,在散列表中查找一个元素的平均时间是O(1)

散列表是普通数组概念的推广,由于普通数组可以直接寻址,从而查找元素的时间为O(1),我们由此得到一种更宽泛的概念,即直接寻址表。

一、直接寻址表


如果存储空间允许,且大部分操作只要求查找,那么使用直接寻址表无疑就已是不错的选择。直接寻址表适用于关键字的全域U较小的情况,假设定义一个直接寻址表T[0...m-1],那么T中的每个元素皆是取自全域U中的一个关键字,T中每个位置也称为 槽(slot)

对于直接寻址表来说,不管是search、insert还是delete都只需要O(1)的时间。

对于出现关键字相同的情况,我们也可以在相同关键字下置入一个双链表,直接寻址表的特性依然不会被破坏。


二、散列表

直接寻址表的缺点是非常明显的,对于极大的全域U,先不说一台传统的标准计算机能不能存得下,就算存得下,如果实际用到的关键字只有极小部分的集合,那么剩余的空间都将会被浪费掉。

当关键字集合K比全域U要小得多时,散列表需要的存储空间要比直接寻址表少得多。特别地,散列表虽然能够将存储需求降至Θ(|K|),同时使查找一个元素的时间仍保持在O(1),但O(1)的时间是针对于平均情况而言的,但在直接寻址表中,O(1)的查找损耗是适用于最坏情况的。

在直接寻址表中,具有关键字k的元素直接存放在槽k中,在散列方式下,该元素存放在槽h(k)中,这里的h(k)被称为散列函数。我们设散列表为T,通过散列函数,全域U将被映射到T[0…m-1]的槽位上。散列表的大小,即这里m的大小一般要比|U|小得多。因为|U|一般远大于m,因此一定会有不同的关键字却具有相同的散列值的情况,我们把这种情形称为冲突冲突是不可避免的,尽管我们可以选择尽可能优秀的散列函数使冲突的数量达到尽可能少,但它是无法完全避免的。

这迫使我们不得不去考虑解决可能出现的冲突的办法。

解决冲突的方法有很多,这里先介绍其中非常常见的一种方法:链接法。其它方法将在下一篇再着重介绍。


#1.链接法解决冲突

在链接法中,把散列到同一个槽的元素都放在一个链表中,该槽中存放链表的头指针,如果不存在这样的链表,则该槽为NULL。

槽中的链表既可以是单链表,也可以是双链表,但在单链表的情况下,查询和删除的渐近运行时间是相同的,均与链表的长度成正比,但在采用了双链表的散列表中,删除操作在平均情况下运行时间为O(1),插入在平均情况下也是O(1),查询则平均需要常数时间,具体为O(1+α),α = n/m,n为关键字数量,m为槽位数量,因此,全部的字典操作在平均情况下都可以在O(1)时间内完成。

具体证明参见算法导论11.2。

#2 链接法的代码实现

链接法生成的散列表可以看作是一个链表的数组,数组中每个成员均为指针类型,它指向一个链表的表头,当然它也可能指向空。

首先定义该数组

typedef struct Slot{
    int key;
    struct Slot *pre,*next;
}SLOT;
SLOT *slot[maxn]; //定义一个结构体指针数组

初始化:

void init() 
{
    for(int i = 0; i < maxn; i++)
        slot[i] = NULL; // 一开始所有指针均指向NULL
}

输出:

void print()
{
    int i = 0;
    SLOT *t = (SLOT *)malloc(sizeof(SLOT)); //t作为临时结构体指针变量
    while(i < maxn)
    {
        t = slot[i];
        cout<<i<<":"<<" ";
        if(!t) //如果该槽为空
            cout<<"NULL";
        while(t) //该槽不为空则遍历该槽中的链表
        {
            cout<<t->key;
            t = t->next;
            if(t)
                cout<<"->"; //链表中每个元素在输出时用->连接
        }
        cout<<endl;
        i++;
    }
}

查找:

SLOT *search(int x) //如果查找成功,返回指向查找元素的指针
{
    int i = hash(x);
    SLOT *t = slot[i];
    while(t)
    {
        if(t->key == x)
            break;
        t = t->next;
    }
    return t; //这里的t已经包含当slot[i]=NULL,t也为NULL的情况
}

插入:

void insert(int x) 
{
    SLOT *t = (SLOT *)malloc(sizeof(SLOT));
    int i = hash(x);
    t->key = x;
    t->next = slot[i];
    if(slot[i] != NULL)
        slot[i]->pre = t;
    slot[i] = t;
}

插入过程不太好理解,你可以这样想:本来每个slot[i]都是NULL,在insert时,我创建了一个SLOT *的指针t,给t赋值并使t的next域指向slot[i],因为slot[i]=NULL,因此t->next = NULL,接下来的if语句是用来判断槽点是否为空的,在第一次插入时因为每个槽点都为空因此跳过它,来到slot[i] = t这条语句,它使t赋给slot[i],因此在第一次插入后,slot[i]不再为NULL,而是t->NULL这条链表,如果接下来的某一次插入又要插入到该槽点,那么slot[i]就会变成t1->t2->NULL这条链表,以此类推。
注意插入过程最好是前插。

删除:

void del(int x)
{
    int i = hash(x);
    SLOT *p = search(x);
    if(!p) //如果p为空,返回
    	return;
    if(p->pre != NULL) //如果p不是链表的第一个结点
    {
    	p->pre->next = p->next;
    	p->next->pre = p->pre;
	}
    else //如果p是链表的第一个结点,直接将链表前移一位即可
    	slot[i] = p->next;
    free(p);
    p->next = p->pre = NULL; //防止野指针
}

三、散列函数


#1.设计好的散列函数

一个好的散列函数对散列表性能的影响是非常大的,如果选取散列函数不当,那么在采用了链接法的散列表中,很有可能全部关键字或绝大部分关键字都被分配到一个槽位里,那么该散列表的查询速度就会几乎退化至O(n),那样的散列表就名不副实了。

前面已经讲到,我们无法彻底避免冲突,因此我们只得将重点转移到设计尽可能好的散列函数上,那么什么样的散列函数才算是好的散列函数呢?在算法导论中作者对这个问题进行了详尽的说明,总结如下:

首先,一个好的散列函数应满足每个关键字都能被等可能地散列到m个槽位中的任何一个,并与其它关键字已散列到哪个槽位无关。这个条件一般很难被证明,因为很少能知道关键字的概率分布,相反,如果我们提前知道关键字的概率分布,那么就可以设计出满足上面条件的散列函数。

其次,在实际应用中,常常可以利用启发式方法来构造性能好的散列函数。在设计过程中,可以利用关键字分布的有用信息。例如,在一个编译器的符号表中,关键字都是字符串,表示程序中的标识符,经常会有相近的标识符在程序中多次出现的情况,比如标识符sort,sorted,在这个时候,好的散列函数应能将这些相近符号散列到相同槽的可能性最小化。

最后,一种好的方法导出的散列值,在某种程度上应独立于数据可能存在的任何模式。如除法散列法。

#2.关键字的转换

多数散列函数都假定关键字的全域为自然数集N,因此,如果所给关键字不是自然数,要想办法把它转换为自然数。通常一个字符串可以被转换为按适当基数符号表示的整数,比如字符串pt,将之转换为十进制整数对(112,116),对应于ASCII码,以128为基数,则pt即为 112 * 128 + 116 = 14452。

#3.几种散列函数的设计

① 除法散列法

除法散列法通过取关键字k除以槽数m的余数来确定散列值,散列函数即:h(k) = k mod m。因为只需要做一次取模操作,因此除法散列法是非常快的。

注意点:

在应用除法散列法时,要避免选择m的某些值。例如2的m次幂,因为如果m=2^p,那么h(k)就是k的p个最低位数字。除非已知各种最低p位的排列形式是等可能的,否则在选取散列函数时,最好考虑关键字的所有位。

一个不太接近2的整数幂次的素数,往往是m的一个较好的选择。


② 乘法散列法

乘法散列法的散列函数是:h(k) = [m(kA mod 1)],这里[]是一个向下取整的意思,因为在我的输入法里找不到正规的向下取整的符号,,,。

在 h(k) = [m(kA mod 1)] 这个散列函数里,A是一个常数(0 < A < 1),kA mod 1 意为取KA的小数部分。

乘法散列法的一个好处在于m的选择不再那么关键了,你可以随意选择,一般选择为2的幂次。对于A值,理论上任何一个介于(0,1)之间的小数均可,但有人认为 A ≈ (√5 - 1)/ 2 = 0.61803…是一个较理想的值。

利用乘法散列法你可以这么设计散列函数:
假设某计算机的字长是w位(普遍32 or 64),A为形如s/2 ^ w次方的一个分数,其中s是取自(0,2 ^ w)内的一个整数。
现有一例,假设k = 123456,p = 14,m = 2 ^ 14 = 16384,w = 32.取A = (√5 - 1)/ 2,那么可得 ks = (76300 * 2 ^ 32) + 17612864,该数有两个字长,我们设它的高位字为r1,低位字为r2,r2的p个最高有效位产生了散列值h(k) = 67。

总结步骤就是:
1.定w,求s。s = 2^w * A。
2.求ks,将ks的值化为(r1 * 2^w) + r2的形式,得到低位字r2。
3.取r2的p个有效最高位即得到散列值。

③平方取中法

平方取中法通过将关键字平方从而扩大各关键字之间的差别,然后从平方积的中间选取几位数字作为散列值。为什么选取平方积的中间数字呢,因为中间的数字与关键字的每一位都有关系,这样得到的散列值会更可靠。

举个例子,比如k = 123,m = 100,那么 k^2 = 4059,取中间两位,那么h(123) = 5。

④折叠法

对于关键字相当长的情况,可以采取折叠法。折叠法将一个关键字的所有位均分之后累加,丢到高位后得到散列值。例如对于 k = 123456789,分3组折叠之后有 123 + 456 + 789 = 1368,丢掉前两位高位,得到h(k) = 68。

⑤全域散列法

书上写的没看懂,先记下以后补。
终于体会到数学当初没好好学的痛苦了,现在真真学什么都碰壁!
所以,数学我可。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值