数据结构第十一周笔记(3)—— 散列查找 (慕课浙大版本--XiaoYu)

156 阅读13分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第25天,点击查看活动详情

11.3 冲突处理方法

11.3.1 开放定址法

处理冲突的方法

常用冲突的思路:

  1. 换个位置:开放地址法
  2. 同一位置的冲突对象组织在一起:链地址法

开放地址法(Open Addressing)

  1. 一旦产生了冲突(该地址已有其他元素),就按某种规则去寻找另一空地址

  2. image-20220826103852201

  3. di决定了不同的解决冲突方案:线性探测、平方探测、双散列(三种典型的开放地址法)

    1. 线性探测:di = i(di的i是下标),第一次探测i就是1,第二次探测再上次探测基础上加1,往后持续
    2. 平方探测:跟上方类似,只不过i变成了正负i²(在原来位置基础上偏移量升级层次,加一平方减一平方,加二平方减二平方...)
    3. 双散列:两个散列函数,第一个散列函数作为他的最早即时找的这个位置,第二个散列函数用来计算偏移量

11.3.2 线性探测

线性探测法(Linear Probing):以增量序列1,2,.....,(TableSize-1)循环试探下一个存储地址

image-20220826122157322

image-20220826122945504

会形成聚集现象

如果按照与刚才例子输入相反的顺序插入各个元素,这些元素在散列表中的位置还是一样的?不一样

散列表查找性能分析

  1. 成功平均查找长度(ASLs) => 就是我要找的对象最后被我找到了
  2. 不成功平均查找长度(ASLu) =>找对象找不着

什么是成功的 => 在散列表里找到的是成功的,不在散列表的元素就是不成功的

image-20220826133953107

ASL u = 值(值取决于哈希余数)

余数为0要比较3次,为1要比较2次,余数为2要比较1次,余数为3的时候要比较2次,余数为4、5、6都是比较一次就够了,余数为7则要比较9次才能知道,余数为8则是8次


余数-哈希函数

  1. 余数的思想 所谓余数,就是程序中取模运算中的“模”。 余数具有一个非常重要的特性:可以将无限的数据归一到有限的范围(余数总是小于除数)
你知道,整数是没有边界的,它可能是正无穷,也可能是负无穷。但是余数却可以通过某一种关系,让整数处于一个确定的边界内。我想这也是人类发明星期或者礼拜的初衷吧,任你时光变迁,我都是以 7 天为一个周期,“周”而复始地过着确定的生活。因为从星期的角度看,不管你是哪一天,都会落到星期一到星期日的某一天里。
  1. 同余定理 在上边的例子中,第一天与第八天都是周一,第二天与第九天都是周二,即
1%7=8%7=1
2%7=9%7=2

这就引出了余数的另一个重要概念:同余定理

口语化表述:
两个整数 ab,如果它们除以正整数 m 得到的余数相等,我们就可以说 ab 对于模 m 同余

其实,奇数与偶数的确定就是同余定理的应用。将一个数字模2,得0为偶数,得1为奇数 复杂算法拆解后的原理并不一定复杂,同余定理也可以作为有用的应用,就是哈希函数

  1. 哈希函数(散列函数)

将任意长度的输入,通过哈希算法,压缩为某一固定长度的输出,所得存储位置为散列地址

散列过程

(1)存储记录时,通过散列函数记录散列地址,按地址存储记录
(2)查找记录时,通过同样的散列函数计算记录的散列地址,按散列地址访问记录

散列技术通过散列函数建立了从记录的关键码集合到散列表的地址集合的一个映射,显然,会出现两个不同记录存放在同一位置的情况,这种现象称为冲突,此时相同位置的记录称为同义词

散列函数中最常采用的方案是除留余数法,其基本思想:

选择适当的正整数P,以关键码除以P的余数作为散列地址
通常P为小于或等于表长(最好接近)的最小质数或不包含小于 20 质因子的合数

11.3.3 线性探测—字符串的例子

前面5个都是没有冲突的,第六个冲突1次(查找次数变为2),第七个冲突4次,最后一个冲突2次

image-20220826135742628

与例子相似,如果已知散列表的前8个位置有元素(但元素内容与例子不一样)而且后面18个位置也全是空位,那么平均不成功查找次数还是一样的吗?一样的

11.3.4 平方探测法(Quadratic Probing)

也可以叫做:二次探测

image-20220826143648650

image-20220826145722062


image-20220826150859973

  1. 平方探测法(Quadratic Probing)

    1. 是否有空间,平方探测(二次探测)就能找得到?
    2. image-20220827125834414

还有一种平方探测的方式是:img。也就是增量序列为img

如果采用前面讲的img增量序列找不到空位置,意味着采用img的增量序列也一定找不到空位置。

线性探测的缺陷:容易聚集,二次探测虽然也有但不严重

有定理显示:如果散列表长度TableSize是某个4k+3(k是正整数)形式的素数时,平方探测法就可以探测到整个散列表空间

11.3.5 平方探测法的实现

typedef struct HashTbl*HashTable;
struct HashTbl{
    int TableSize;//当前表的实际大小
    Cell*TheCells;//代表自己是一个数组,实际上是一个指针
}H;
​
HanshTable InitializeTable(int TableSize)
{
    HashTable H;
    int i;
    if( TableSize < MinTableSize ){//判别散列表的大小,太小就没必要做散列,直接放在数组就行了
        Error("散列表太小");
        return NULL;
    }
    //分配散列表
    H = (HashTable)malloc(sizeof(struct HashTbl));//申请空间赋给H
    if( H == NULL )
        FatalError("空间溢出!!!");//判断有没有申请成功
    H->TableSize = NextPrime(TableSize);//申请成功的话希望表的size是素数,NextPrime就是这个目的,产生一个比表大一点的素数
    //分配散列表Cells
    H->TheCells=(Cell*)malloc(sizeof(Cell)*H->TableSize);//为真正的TableSize分配一个空间,就相当于指向一个数组了
    if(H->TheCells == NULL)
        FatalError("空间溢出!!!");
    for(i=0;i<H->TableSize;i++)
        H->TheCells[i].Info = Empty;
    return H;
}

友情小提示:typedef struct 的typedef是用来取别名的,比如上方 HashTbl 的别名就是H

image-20220827132244014实际删除的元素不能真的从表中拿掉,不然查找的时候会有问题的。如果我们要删除可以先做个记号。这样在后续的查找与插入的好处有:首先在查找的时候碰到被删掉的元素就说这个位置他做了个记号被删掉了,我们就知道这还不是空位还可以继续找,如果真拿掉变成空位的话就会产生误判。然后插入的时候发现这个元素是被删掉了,他不是空位而是原来有元素占着,现在被删掉了,这个时候插入元素就可以来替代原来删掉的元素。这样我们插入删除的操作都可以做并且不影响我们的查找过程

//表的初始化
Position Find(ElementType Key,HashTable H)//平方探测
{
    Position CurrentPos,NewPos;
    int CNum;//记录冲突次数
    CNum = 0;
    NewPos = CurrentPos = Hash(Key,H->TableSize);//要算哈希函数,所以先调用一个哈希函数。CurrentPos是我们值真正要放的位置 
    while(H->TheCells[NewPos].Info != Empty && H->TheCells[NewPos].Element != Key){//Info位置不空且Element值不等于我要找的Key,那就需要继续找,而循环的条件就是我们要找的位置,被别人占了但是不空
        //字符串类型的关键词需要strcmp函数
        if(++CNum % 2){//判断冲突的奇偶次
            NewPos = CurrentPos +(CNum+1)/2*(CNum+1)/2;//探测方法:在原来的位置上(CurrentPos,也就是最早的哈希函数值)加减i²获得新的地址。因为一会加一会减,所以需要在上方用上if来判别是奇数是偶来决定该加还是该减
            while(NewPos >= H->TableSize)//上方NewPos加上后面的大小可能超出大于TableSize了,所以需要通过不断循环减去TableSize,一直到NewPos不大于他(不大于他就落在0-TableSize之间了)
                NewPos -= H->TableSize;
        }else{//如果是偶数就走这条路啦,减去一个i平方
            NewPos = CurrentPos - CNum/2*CNum/2;
            while(NewPos < 0)//跟上面那个while类似,为了不负到突破地板,需要将值拉回来
                NewPos += H->TableSize;
        }
    }
    return NewPos;
}

image-20220827135442103将Cnum映射为i的平方。CNum1除以2就是这个i的值。所以举个例子

例子:1加1除以2为1,3加1除以2为2,5加1除以2为3

如果是少的话,4除2为2,6除2为3

void Insert(ElementType Key,HashTable H)
{
    //插入操作
    Position Pos;
    Pos = Find(Key,H);//通过Find return出来一个position值
    if(H->TheCells[Pos].Info != Legitimate){//需要判断Pos的状态,如果Pos不属于被占用的状态,那我们这个元素就可以放进去(什么情况不是属于被别人占用:空位或者被删除了)
        //确认在此插入
        H->TheCells[Pos].Info = Legitimate;//将Info设为被我占用的状态,然后下一步将Key放进去
        H->TheCells[Pos].Element = Key;//字符串类型的关键词需要strcpy函数
    }
}
ps:在开放地址散列表中,删除操作要很小心。通常只能"懒惰删除",即需要增加一个“删除标记(Deleted)”,而并不是真正删除它。以便查找时不会"断链"。其空间可以在下次插入时重用

双散列探测法(Double Hashing)

image-20220827152627815

再散列(Rehashing)

  1. 当散列表元素太多(即装填因子α太大)时,查找效率会下降(因为冲突在不断增加);

    1. 怎么解决这个问题?扩大散列表。散列表扩大时,原有元素需要重新计算放置到新表中
    2. 实用最大装填因子一般取0.5 <= α <= 0.85(通常控制在0.5以内)
  2. 当装填因子过大时,解决的方法是加倍扩大散列表,这个过程叫做"再散列(Rehashing)"

11.3.6 分离链接法(Separate Chaining)

将相应位置上有冲突的所有关键词存储在同一单链表中

^表示空指针

image-20220827153508063

链表实现

typedef struct ListNode*Position,*List;
struct ListNode{
    ElementType Element;
    Position Next;//把Next分量分给P,P是下方代码块的一个指针,指向单项链表的第一个元素
};
typedef struct HashTbl*HashTable;
struct HashTbl{
    int TableSize;
    List TheLists;
};
Position Find(ElementType Key,HashTable H)//哈希表来表示
{
    Position P;
    int Pos;
    
    Pos = Hash(Key,H->TableSize);//初始散列位置,第一步算哈希函数值,得到散列函数散列地址,散列地址就代表他在这个数组里的位置
    P = H->TheLists[Pos].Next;//获得链表头,这个P就是上方代码块说的那个指针P,指向单项链表的第一个元素
    while(P != NULL && strcmp(P->Element,Key))//典型的遍历单项链表的循环,只要P不等于NULL,P所指向的这个元素跟我要找的这个元素不相等就一个个往后找,P的Next赋给P。意思就是只要P不空(后面还有元素),那么循环就一直做,同时循环的另一个条件是当前的这个元素值不等于我要找的元素值,如果列表不空再找下一个,再下一个就P的Next赋给P
        //等循环退出来要么P空了,就return NULL(没找到)。要么就strcmp返回值等于0,等于0就相等了,那这个时候所在的这个节点就是我们找到了,也就是return P
        P = P->Next;
    return P;
}

image-20220827154233888

创建开放地址法的散列表

#define MAXTABLESIZE 100000 /* 允许开辟的最大散列表长度 */
typedef int ElementType;    /* 关键词类型用整型 */
typedef int Index;          /* 散列地址类型 */
typedef Index Position;     /* 数据所在位置与散列地址是同一类型 */
/* 散列单元状态类型,分别对应:有合法元素、空单元、有已删除元素 */
typedef enum { Legitimate, Empty, Deleted } EntryType;
​
typedef struct HashEntry Cell; /* 散列表单元类型 */
struct HashEntry{
    ElementType Data; /* 存放元素 */
    EntryType Info;   /* 单元状态 */
};
​
typedef struct TblNode *HashTable; /* 散列表类型 */
struct TblNode {   /* 散列表结点定义 */
    int TableSize; /* 表的最大长度 */
    Cell *Cells;   /* 存放散列单元数据的数组 */
};
​
int NextPrime( int N )
{ /* 返回大于N且不超过MAXTABLESIZE的最小素数 */
    int i, p = (N%2)? N+2 : N+1; /*从大于N的下一个奇数开始 */
​
    while( p <= MAXTABLESIZE ) {
        for( i=(int)sqrt(p); i>2; i-- )
            if ( !(p%i) ) break; /* p不是素数 */
        if ( i==2 ) break; /* for正常结束,说明p是素数 */
        else  p += 2; /* 否则试探下一个奇数 */
    }
    return p;
}
​
HashTable CreateTable( int TableSize )
{
    HashTable H;
    int i;
​
    H = (HashTable)malloc(sizeof(struct TblNode));
    /* 保证散列表最大长度是素数 */
    H->TableSize = NextPrime(TableSize);
    /* 声明单元数组 */
    H->Cells = (Cell *)malloc(H->TableSize*sizeof(Cell));
    /* 初始化单元状态为“空单元” */
    for( i=0; i<H->TableSize; i++ )
        H->Cells[i].Info = Empty;
​
    return H;
}

平方探测法的查找与插入

Position Find( HashTable H, ElementType Key )
{
    Position CurrentPos, NewPos;
    int CNum = 0; /* 记录冲突次数 */
​
    NewPos = CurrentPos = Hash( Key, H->TableSize ); /* 初始散列位置 */
    /* 当该位置的单元非空,并且不是要找的元素时,发生冲突 */
    while( H->Cells[NewPos].Info!=Empty && H->Cells[NewPos].Data!=Key ) {
                                           /* 字符串类型的关键词需要 strcmp 函数!! */
        /* 统计1次冲突,并判断奇偶次 */
        if( ++CNum%2 ){ /* 奇数次冲突 */
            NewPos = CurrentPos + (CNum+1)*(CNum+1)/4; /* 增量为+[(CNum+1)/2]^2 */
            if ( NewPos >= H->TableSize )
                NewPos = NewPos % H->TableSize; /* 调整为合法地址 */
        }
        else { /* 偶数次冲突 */
            NewPos = CurrentPos - CNum*CNum/4; /* 增量为-(CNum/2)^2 */
            while( NewPos < 0 )
                NewPos += H->TableSize; /* 调整为合法地址 */
        }
    }
    return NewPos; /* 此时NewPos或者是Key的位置,或者是一个空单元的位置(表示找不到)*/
}
​
bool Insert( HashTable H, ElementType Key )
{
    Position Pos = Find( H, Key ); /* 先检查Key是否已经存在 */
​
    if( H->Cells[Pos].Info != Legitimate ) { /* 如果这个单元没有被占,说明Key可以插入在此 */
        H->Cells[Pos].Info = Legitimate;
        H->Cells[Pos].Data = Key;
        /*字符串类型的关键词需要 strcpy 函数!! */
        return true;
    }
    else {
        printf("键值已存在");
        return false;
    }
}

分离链接法的散列表实现

#define KEYLENGTH 15                   /* 关键词字符串的最大长度 */
typedef char ElementType[KEYLENGTH+1]; /* 关键词类型用字符串 */
typedef int Index;                     /* 散列地址类型 */
/******** 以下是单链表的定义 ********/
typedef struct LNode *PtrToLNode;
struct LNode {
    ElementType Data;
    PtrToLNode Next;
};
typedef PtrToLNode Position;
typedef PtrToLNode List;
/******** 以上是单链表的定义 ********/
​
typedef struct TblNode *HashTable; /* 散列表类型 */
struct TblNode {   /* 散列表结点定义 */
    int TableSize; /* 表的最大长度 */
    List Heads;    /* 指向链表头结点的数组 */
};
​
HashTable CreateTable( int TableSize )
{
    HashTable H;
    int i;
​
    H = (HashTable)malloc(sizeof(struct TblNode));
    /* 保证散列表最大长度是素数,具体见代码5.3 */
    H->TableSize = NextPrime(TableSize);
​
    /* 以下分配链表头结点数组 */
    H->Heads = (List)malloc(H->TableSize*sizeof(struct LNode));
    /* 初始化表头结点 */
    for( i=0; i<H->TableSize; i++ ) {
         H->Heads[i].Data[0] = '\0';
         H->Heads[i].Next = NULL;
    }
​
    return H;
}
​
Position Find( HashTable H, ElementType Key )
{
    Position P;
    Index Pos;
    
    Pos = Hash( Key, H->TableSize ); /* 初始散列位置 */
    P = H->Heads[Pos].Next; /* 从该链表的第1个结点开始 */
    /* 当未到表尾,并且Key未找到时 */ 
    while( P && strcmp(P->Data, Key) )
        P = P->Next;
​
    return P; /* 此时P或者指向找到的结点,或者为NULL */
}
​
bool Insert( HashTable H, ElementType Key )
{
    Position P, NewCell;
    Index Pos;
    
    P = Find( H, Key );
    if ( !P ) { /* 关键词未找到,可以插入 */
        NewCell = (Position)malloc(sizeof(struct LNode));
        strcpy(NewCell->Data, Key);
        Pos = Hash( Key, H->TableSize ); /* 初始散列位置 */
        /* 将NewCell插入为H->Heads[Pos]链表的第1个结点 */
        NewCell->Next = H->Heads[Pos].Next;
        H->Heads[Pos].Next = NewCell; 
        return true;
    }
    else { /* 关键词已存在 */
        printf("键值已存在");
        return false;
    }
}
​
void DestroyTable( HashTable H )
{
    int i;
    Position P, Tmp;
    
    /* 释放每个链表的结点 */
    for( i=0; i<H->TableSize; i++ ) {
        P = H->Heads[i].Next;
        while( P ) {
            Tmp = P->Next;
            free( P );
            P = Tmp;
        }
    }
    free( H->Heads ); /* 释放头结点数组 */
    free( H );        /* 释放散列表结点 */
}