散列表的详细剖析 (算法导论第11章)

注意
  ① 由同一个散列函数、不同的解决冲突方法构造的散列表,其平均查找长度是不相同的。
  ② 散列表的平均查找长度不是结点个数n的函数,而是装填因子α的函数。因此在设计散列表时可选择α以控制散列表的平均查找长度。

      通过链接法解决冲突:成功查找的期望查找长度O(1+α), 不成功查找的平均查找长度也为O(1+α)。

      开放寻址解决冲突:引入探查序列,对于α<1的开放寻址,成功查找的平均查找长度1/α(1+ln(1/(1-α)); 不成功的查找长度为1/(1-α)
  ③ α的取值
       α越小,产生冲突的机会就小,但α过小,空间的浪费就过多。只要α选择合适,散列表上的平均查找长度就是一个常数,即散列表上查找的平均时间为 O(1)。
  ④  散列法与其他查找方法的区别
        除散列法外,其他查找方法有共同特征为:均是建立在比较关键字的基础上。
        其中顺序查找平均时间为 O(n);
        其余的查找均是对有序集合的查找,每次关键字的比较有"="、"<"和">"三种可能,且每次比较后均能缩小下次的查找范围,故查找速度更快,其平均时间为O(lg n)。
        而散列法是根据关键字直接求出地址的查找方法,其查找的期望时间为 O(1)。 

一、散列表
        设所有可能出现的关键字集合记为 U (简称全集)。实际发生(即实际存储)的关键字集合记为 K(|K|比|U|小得多)。
        散列方法是使用函数 h 将 U 映射到表 T[0..m-1] 的下标上( m=O(|U|) )。这样以 U 中关键字为自变量,以 h 为函数的运算结果就是相应结点的存储地址。从而达到在 O(1) 时间内就可完成查找。
其中:
      ① h:U→{0,1,2,…,m-1} ,通常称 h 为散列函数 ( Hash Function )。散列函数 h 的作用是压缩待处理的下标范围,使待处理的 |U| 个值减少到 m 个值,从而降低空间开销。
      ② T 为散列表 (Hash Table)。
      ③ h(Ki) (Ki∈U) 是关键字为Ki结点存储地址 (亦称散列值或散列地址)。
      ④ 将结点按其关键字的散列地址存储到散列表中的过程称为散列 (Hashing)



二、散列表的冲突现象
    1. 冲突
        两个不同的关键字,由于散列函数值相同,因而被映射到同一表位置上。该现象称为冲突 (Collision) 或碰撞。发生冲突的两个关键字称为该散列函数的同义词 (Synonym)。
    【例】上图中的 k2≠k,但 h(k2) = h(k5),故 k和 k所在的结点的存储地址相同。

    2. 安全避免冲突的条件
        最理想的解决冲突的方法是安全避免冲突。要做到这一点必须满足两个条件:
   ① 其一是 |U| ≤ m
   ② 其二是选择合适的散列函数。
     这只适用于 |U| 较小,且关键字均事先已知的情况,此时经过精心设计散列函数 h 有可能完全避免冲突。

    3. 冲突不可能完全避免
        通常情况下,h 是一个压缩映像。虽然 |K| ≤ m,但 |U| > m,故无论怎样设计 h,也不可能完全避免冲突。因此,只能在设计 h 时尽可能使冲突最少。同时还需要确定解决冲突的方法,使发生冲突的同义词能够存储到表中。

    4. 影响冲突的因素
        冲突的频繁程度除了与 h 相关外,还与表的填满程度相关。
        设 m 和 n 分别表示表长和表中填人的结点数,则将 α=n/m 定义为散列表的装填因子(Load Factor)。α 越大,表越满,冲突的机会也越大。通常取 α ≤ 1。

三、散列函数的构造方法

    1. 散列函数的选择有两条标准:简单和均匀。
        简单指散列函数的计算简单快速;
        均匀指对于关键字集合中的任一关键字,散列函数能以等概率将其映射到表空间的任何一个位置上。也就是说,散列函数能将子集K随机均匀地分布在表的地址集 {0,1,…,m-1} 上,以使冲突最小化。   

    2. 常用散列函数
        为简单起见,假定关键字是定义在自然数集合上。

  (1) 平方取中法
        具体方法:先通过求关键字的平方值扩大相近数的差别,然后根据表长度  取中间的几位数作为散列函数值。又因为一个乘积的中间几位数和乘数的每一位都相关,所以由此产生的散列地址较为均匀。
       【例】将一组关键字(0100,0110,1010,1001,0111) 平方后得  (0010000,0012100,1020100,1002001,                   0012321)。
     若取表长为1000,则可取中间的三位数作为散列地址集:(100,121,201,020,123)。

        相应的散列函数用 C 实现很简单:

        int Hash(int key){ //假设key是4位整数
               key*=key; key/=100; //先求平方值,后去掉末尾的两位数
               return key%1000; //取中间三位数作为散列地址返回
         }

  (2) 除余法

        该方法是最为简单常用的一种方法。它是以表长 m 来除关键字,取其余数作为散列地址,即 h(key) = key%m

        该方法的关键是选取 m。选取的 m 应使得散列函数值尽可能与关键字的各位相关。m 最好为素数。

       【例】若选 m 是关键字的基数的幂次,则就等于是选择关键字的最后若干位数字作为地址,而与高位无关。于是高位不同而低位相同的关键字均互为同义词。
       【例】若关键字是十进制整数,其基为10,则当 m=100 时,159,259,359,…,等均互为同义词。


  (3) 相乘取整法

        该方法包括两个步骤:首先用关键字 key 乘上某个常数 A 并提取其小数部分;然后将所得的值乘以 m并向下取整。


        该方法最大的优点是选取 m 不再像除余法那样关键。比如,完全可选择它是2的整数次幂。虽然该方法对任何 A 的值都适用,但对某些值效果会更好。Knuth建议选取 A =  (√5-1)/2 ≈ 0.61803398

        该函数的C代码为:

        int Hash(int key){
             double d=key *A; //不妨设A和m已有定义
             return (int)(m*(d-(int)d));// (int) 表示强制转换后面的表达式为整数
        }


  (4)随机数法

        选择一个随机函数,取关键字的随机函数值为它的散列地址,即 h(key) = random (key)                           

     其中 random 为伪随机函数,但要保证函数值是在0到 m-1之间。


四、处理冲突的方法 

        通常有两类方法处理冲突:开放定址 (Open Addressing) 法和拉链 (Chaining) 法。前者是将所有结点均存放在散列表 T[0..m-1] 中;后者通常是将互为同义词的结点链成一个单链表,而将此链表的头指针放在散列表 T[0..m-1] 中。

1. 开放定址法

  (1) 开放地址法解决冲突的方法
        用开放定址法解决冲突的做法是:当冲突发生时,使用某种探查 (亦称探测) 技术在散列表中形成一个探查 (测) 序列。沿此序列逐个单元地查找,直到找到给定的关键字,或者碰到一个开放的地址 (即该地址单元为空) 为止 (若要插入,在探查到开放的地址,则可将待插入的新结点存入该地址单元) 。查找时探查到开放的地址则表明表中无待查的关键字,即查找失败。

注意
   ① 用开放定址法建立散列表时,建表前须将表中所有单元(更严格地说,是指单元中存储的关键字)置空。
   ② 空单元的表示与具体的应用相关。 

       【例】关键字均为非负数时,可用"-1"来表示空单元,而关键字为字符串时,空单元应是空串。
     总之,应该用一个不会出现的关键字来表示空单元。


  (2) 开放地址法的一般形式

        开放定址法的一般形式为: h= (h(key)+di)%m   (1 ≤ i ≤ m-1)
        其中:
        ① h(key) 为散列函数,d为增量序列,m 为表长。
        ② h(key) 是初始的探查位置,后续的探查位置依次是 hl,h2,…,hm-1,即 h(key),hl,h2,…,hm-1 形成了一个探查序列。
        ③若令开放地址一般形式的 i 从 0 开始,并令 d= 0,则 h= h(key),则有:
             hi= (h(key) + di)%m   (0 ≤ i ≤ m-1)
            探查序列可简记为 hi  (0 ≤ i ≤ m-1)


  (3) 开放地址法装填因子的要求
        开放定址法要求散列表的装填因子α ≤ l,实用中取 α 为 0.5 到 0.9 之间的某个值为宜。

 

  (4) 形成探测序列的方法
        按照形成探查序列的方法不同,可将开放定址法区分为线性探查法、二次探查法、双重散列法等。

  ①  线性探查法(Linear Probing)
        该方法的基本思想是:

     将散列表 T[0..m-1] 看成是一个循环向量,若初始探查的地址为 d (即 h(key)=d ),则最长的探查序列为:
         d,d+l,d+2,…,m-1,0,1,…,d-1
      即:探查时从地址 d 开始,首先探查 T[d],然后依次探查 T[d+1],…,直到 T[m-1],此后又循环到 T[0],T[1],…,直到探查到 T[d-1] 为止。

        探查过程终止于三种情况:
   (1) 若当前探查的单元为空,则表示查找失败(若是插入则将 key 写入其中);
   (2) 若当前探查的单元中含有 key,则查找成功,但对于插入意味着失败;
   (3) 若探查到 T[d-1] 时仍未发现空单元也未找到 key,则无论是查找还是插入均意味着失败 (此时表满)。


        利用开放地址法的一般形式,线性探查法的探查序列为:  h= (h(key)+i)%m     ( 0 ≤ i ≤ m-1)   //即di=i

        利用线性探测法构造散列表

       【例1】已知一组关键字为 (26,36,41,38,44,15,68,12,06,51) ,用除余法构造散列函数,用线性探查法解决冲突构造这组关键字的散列表。
        解答: 为了减少冲突,令装填因子 α = 10/13,即 m = 13。

        由除余法的散列函数计算出的上述关键字序列的散列地址为 (0,10,2,12,5,2,3,12,6,12)。
        前5个关键字插入时,其相应的地址均为开放地址,故将它们直接插入 T[0],T[10),T[2],T[12] 和 T[5] 中。
        当插入第6个关键字15时,其散列地址2(即h(15)=15%13=2)已被关键字41(15和41互为同义词)占用。故探查 h1=(2+1)%13=3,此地址开放,所以将15放入T[3]中。
        当插入第7个关键字68时,其散列地址3已被非同义词15先占用,故将其插入到 T[4] 中。
        当插入第8个关键字12时,散列地址12已被同义词38占用,故探查 hl=(12+1)%13=0,而 T[0] 亦被26占用,再探查 h2=(12+2)%13=1,此地址开放,可将12插入其中。
        类似地,第9个关键字06直接插入 T[6] 中;而最后一个关键字51插人时,因探查的地址12,0,1,…,6均非空,故51插入 T[7] 中。
        构造散列表的具体过程的动画演示 http://student.zjzk.cn/course_ware/data_structure/web/flashhtml/kaifang.htm


聚集或堆积现象 
     用线性探查法解决冲突时,当表中i, i+1,…,i+k 的位置上已有结点时,一个散列地址为 i,i+1,…,i+k+1 的结点都将插入在位置 i+k+1 上。把这种散列地址不同的结点争夺同一个后继散列地址的现象称为聚集或堆积(Clustering)。这将造成不是同义词的结点也处在同一个探查序列之中,从而增加了探查序列的长度,即增加了查找时间。若散列函数不好或装填因子过大,都会使堆积现象加剧。

       这也佐证了上文中提出的在开放寻址中装载因子尤其不能大于1(而链接法是允许大于1的)。装载因子小于1,就是要求可用的槽(slot)的数目应该大于key的数目。但是他不像连接法那样需要存储指针,所以在规模较小时优先选用开放寻址法。

      【例2】上例中,h(15) = 2,h(68) = 3,即15和68不是同义词。但由于处理15和同义词41的冲突时,15抢先占用了T[3],这就使得插入68时,这两个本来不应该发生冲突的非同义词之间也会发生冲突。
       为了减少堆积的发生,不能像线性探查法那样探查一个顺序的地址序列 (相当于顺序查找),而应使探查序列跳跃式地散列在整个散列表中。

② 二次探查法 (QuadraticProbing)
     二次探查法的探查序列是:h= (h(key)+i*i)%m   (0 ≤ i ≤ m-1) //即 d= i2
  即探查序列为d = h(key),d+12,d+22,…,等。
     该方法的缺陷是不易探查到整个散列空间。

 

③ 双重散列法 (Double Hashing)
     该方法是开放定址法中最好的方法之一,它的探查序列是:
               hi=(h(key)+i*h1(key))%m   (0 ≤ i ≤ m-1) //即di=i*h1(key)
     即探查序列为:d = h(key),(d+h1(key))%m,(d+2h1(key))%m,…,等。
     该方法使用了两个散列函数 h(key) 和 h1(key),故也称为双散列函数探查法。


注意
     定义 h1(key) 的方法较多,但无论采用什么方法定义,都必须使 h1(key) 的值和 m 互素,才能使发生冲突的同义词地址均匀地分布在整个表中,否则可能造成同义词地址的循环计算。

【例3】 若 m 为素数,则 h1(key) 取1到 m-1之间的任何数均与 m 互素,因此,我们可以简单地将它定义为:
                h1(key) = key%(m-2)+1
【例4】 对例1,我们可取 h(key) = key%13,而 h1(key) = key%11+1。
【例5】 若 m 是2的方幂,则 h1(key) 可取1到 m-1之间的任何奇数。


  2.  拉链法

  (1) 拉链法解决冲突的方法

        拉链法解决冲突的做法是:将所有关键字为同义词的结点链接在同一个单链表中。若选定的散列表长度为 m,则可将散列表定义为一个由 m 个头指针组成的指针数组 T[0..m-1]。凡是散列地址为 i 的结点,均插入到以 T[i] 为头指针的单链表中。T 中各分量的初值均应为空指针。在拉链法中,装填因子 α 可以大于1,但一般均取α ≤ 1。

       【例6】已知一组关键字和选定的散列函数和例1相同,用拉链法解决冲突构造这组关键字的散列表。

        解答:不妨和例1类似,取表长为13,故散列函数为 h(key) = key%13,散列表为 T[0..12]。

注意

        当把 h(key) = i的关键字插入到第i个单链表时,既可插入在链表的头上,也可以插在链表的尾上。这是因为必须确定 key不在第 i 个链表时,才能将它插入表中,所以也就知道链尾结点的地址。若采用将新关键字插入到链尾的方式,依次把给定的这组关键字插入表中,则所得到的散列表如下图所示。




  (2)  拉链法的优点

         与开放定址法相比,拉链法有如下几个优点:

    ① 拉链法处理冲突简单,且无堆积现象,即非同义词决不会发生冲突,因此平均查找长度较短;

    ② 由于拉链法中各链表上的结点空间是动态申请的,故它更适合于造表前无法确定表长的情况;

    ③ 开放定址法为减少冲突,要求装填因子 α 较小,故当结点规模较大时会浪费很多空间。而拉链法中可取α ≥ 1,且结点较大时,拉链法中增加的指针域可忽略不计,因此节省空间;

    ④ 在用拉链法构造的散列表中,删除结点的操作易于实现。尤其可以把单链表变为双向链表,删除更方便,只要简单地删去链表上相应的结点即可。而对开放地址法构造的散列表,删除结点不能简单地将被删结点的空间置为空,否则将截断在它之后填入散列表的同义词结点的查找路径。这是因为各种开放地址法中,空地址单元(即开放地址)都是查找失败的条件。因此在用开放地址法处理冲突的散列表上执行删除操作,只能在被删结点上做删除标记,而不能真正删除结点。


 (3)  拉链法的缺点

        指针需要额外的空间,故当结点规模较小时,开放定址法较为节省空间,而若将节省的指针空间用来扩大散列表的规模,可使装填因子变小,这又减少了开放定址法中的冲突,从而提高平均查找速度。


五、散列表上的运算

        散列表上的运算有查找、插入和删除。其中主要是查找,这是因为散列表的目的主要是用于快速查找,且插入和删除均要用到查找操作。


    1. 散列表类型说明:

         #define NIL -1//空结点标记依赖于关键字类型,本节假定关键字均为非负整数
         #define M 997 //表长度依赖于应用,但一般应根据。确定m为一素数
         typedef struct{//散列表结点类型
              KeyType key;
              InfoType otherinfo; //此类依赖于实际应用
         }NodeType;
         typedef NodeType HashTable[m]; //散列表类型


    2. 基于开放地址法的查找算法
        散列表的查找过程和建表过程相似。假设给定的值为 K,根据建表时设定的散列函数 h,计算出散列地址 h(K),若表中该地址单元为空,则查找失败;否则将该地址中的结点与给定值 K 比较。若相等则查找成功,否则按建表时设定的处理冲突的方法找下一个地址。如此反复下去,直到某个地址单元为空(查找失败)或者关键字比较相等 (查找成功)为止。

  (1)  开放地址法一般形式的函数表示
         
        int Hash(KeyType k, int i)
        {//求在散列表T[0..m-1]中第i次探查的散列地址hi,0≤i≤m-1
         //下面的h是散列函数。Increment是求增量序列的函数,它依赖于解决冲突的方法
          return(h(K)+Increment(i))%m; //Increment(i)相当于是di
        }

        若散列函数用除余法构造,并假设使用线性探查的开放定址法处理冲突,则上述函数中的 h(K) 和 Increment(i) 可定义为:

         

         int h(KeyType K){//用除余法求K的散列地址
         return K%m;
         }
          int Increment(int i){//用线性探查法求第i个增量di
          return i; //若用二次探查法,则返回i*i
         }

  (2)  通用的开放定址法的散列表查找算法:
        

   int HashSearch(HashTable T, KeyType K, int *pos)
   { //在散列表 T[0..m-1] 中查找 K,成功时返回1。失败有两种情况:找到一个开放地址时返回0,表满未找到时     //返回-1。*pos记录找到K或找到空结点时表中的位置
         int i=0; //记录探查次数
         do{
              *pos=Hash(K,i); //求探查地址hi
              if(T[*pos].key==K) return l; //查找成功返回
              if(T[*pos].key==NIL) return 0;//查找到空结点返回
             }while(++i && i<m)
          return -1; //表满且未找到时,查找失败
          } //HashSearch

注意
       上述算法适用于任何开放定址法,只要给出函数 Hash 中的散列函数 h(K)和增量函数 Increment(i) 即可。但要提高查找效率时,可将确定的散列函数和求增量的方法直接写入算法 HashSearch 中。


   3.  基于开放地址法的插入及建表
        建表时首先要将表中各结点的关键字清空,使其地址为开放的;然后调用插入算法将给定的关键字序列依次插入表中。
        插入算法首先调用查找算法,若在表中找到待插入的关键字或表已满,则插入失败;若在表中找到一个开放地址,则将待插入的结点插入其中,即插入成功。

 

  void Hashlnsert(HashTable T, NodeTypene w)
 { //将新结点new插入散列表T[0..m-1]中
   int pos,sign;
   sign=HashSearch(T,new.key,&pos); //在表T中查找new的插入位置
   if(!sign) //找到一个开放的地址pos
       T[pos]=new; //插入新结点new,插入成功
   else //插入失败
   if(sign>0)
        printf("duplicate key!"); //重复的关键字
   else //sign<0
        Error("hashtableoverflow!"); //表满错误,终止程序执行
    } //Hashlnsert

   void CreateHashTable(HashTable T, NodeType A[],int n)
   { //根据A[0..n-1]中结点建立散列表T[0..m-1]
     int i
     if(n>m) //用开放定址法处理冲突时,装填因子α须不大于1
     Error("Load factor>1");
     for(i=0;i<m;i++)
          T[i].key=NIL; //将各关键字清空,使地址i为开放地址
     for(i=0;i<m;i++)
          Hashlnsert(T, A[i]);
    } //CreateHashTable


   4.  删除

     基于开放定址法的散列表不宜执行散列表的删除操作。若必须在散列表中删除结点,则不能将被删结点的关键字置为NIL,而应该将其置为特定的标记 DELETED。
        因此须对查找操作做相应的修改,使之探查到此标记时继续探查下去。同时也要修改插人操作,使其探查到DELETED标记时,将相应的表单元视为一个空单元,将新结点插入其中。这样做无疑增加了时间开销,并且查找时间不再依赖于装填因子。
        因此,当必须对散列表做删除结点的操作时,一般是用拉链法来解决冲突。


    5. 性能分析

        插入和删除的时间均取决于查找,故下面只分析查找操作的时间性能。
        虽然散列表在关键字和存储位置之间建立了对应关系,理想情况是无须关键字的比较就可找到待查关键字。但是由于冲突的存在,散列表的查找过程仍是一个和关键字比较的过程,不过散列表的平均查找长度比顺序查找、二分查找等完全依赖于关键字比较的查找要小得多。


  (1) 查找成功的ASL  ( ASL: 查找算法的查找成功时的平均查找长度 )

        散列表上的查找优于顺序查找和二分查找。
      【例】在例1和例6的散列表中,在结点的查找概率相等的假设下,线性探查法和拉链法查找成功的平均查找长度分别为:
        ASL= (1×6+2×2+3×l+9×1)/10 = 2.2 //线性探查法
        ASL= (1×7+2×2+3×1)/10 = 1.4 //拉链法
  而当 n = 10时,顺序查找和二分查找的平均查找长度(成功时)分别为:
        ASL= (10+1)/2 = 5.5 //顺序查找
        ASL= (1×l+2×2+3×4+4×3)/10 = 2.9 //二分查找,可由判定树求出该值

  (2) 查找不成功的ASL
        对于不成功的查找,顺序查找和二分查找所需进行的关键字比较次数仅取决于表长,而散列查找所需进行的关键字比较次数和待查结点有关。因此,在等概率情况下,也可将散列表在查找不成功时的平均查找长度,定义为查找不成功时对关键字需要执行的平均比较次数。
    【例】例1和例6的散列表中,在等概率情况下,查找不成功时的线性探查法和拉链法的平均查找长度分别为:
         ASLunsucc= (9+8+7+6+5+4+3+2+1+1+2+1+10)/13 = 59/13 ≈ 4.54
         ASLunsucc= (1+0+2+1+0+1+1+0+0+0+1+0+3)/13 ≈ 10/13 ≈ 0.77




















   


















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

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值