[数据结构系列]散列01-散列表初级篇

193 阅读5分钟

散列表

散列表,又叫哈希(hash)表是字典的一种实现形式,它通过散列函数将关键字映射到表中的某个位置存储,并以同样的方式访问,查询的时间复杂度是O(1)

散列函数

定义: 用来将关键字映射到表中某一位置的函数

问题: 由于关键字集合比散列表地址集合大得多,因此,难免会出现某些关键字对应同一个散列表地址的情况,此时就产生冲突

解决冲突的办法:

1.我们希望这个散列函数能将关键字比较均匀地映射到表中

2.人为制定解决冲突的策略

性质:

1.散列函数的定义域是关键字全体,值域是散列表地址全体

2.散列函数计算出的映射地址应该尽可能地均匀分布在整个地址空间

3.散列函数应是简单的,要快速计算出结果

常见的散列函数实现方法:

1.除留余数法

我们选取某个数p,把关键字除以该数得到的余数作为存储的地址,也就是(m是关键码集合的长度):

hash(key)=key%pp<=mhash(key)=key \% p \quad p<=m

这里的p一般取不大于m的最大质数,或者取不含20以内质因子的合数

同时,如果key是十进制数,p要避免取10的幂,否则,地址就会被压缩在0~9这个狭小空间中,冲突量会很大,2进制同理

总之,p的选取就是为了让冲突尽可能小

2.数字分析法

设有n个d位数,每位可能有r种不同的符号,根据关键字集合,把n,r,d确定后,我们选取这r种符号分布比较均匀的若干位作为散列地址,我们可以用下式计算均匀度(k代表不同的位):

λk=i=1r(aikn/r)2\lambda_{k}=\sum_{i=1}^{r}(a_{i}^{k}-n/r)^{2}

但是,这种方法完全依赖于关键字集合,换一个关键字集合,散列地址就要重算,因此不常用

3.平方取中法

平方取中法需要计算构成关键字标识符的内码的平方,所谓内码就是某种字符编码,它包含ASCII码并且其他诸如汉字等特殊符号也有对应编码,我们取内码平方后的中间若干位作为散列地址,由于内码通常用八进制表示,因此,我们通常取散列地址为8的某次幂,比如,散列地址总数m=8的r次方,那么我们就取中间r位

4.折叠法

折叠法要求我们先将关键字分成位数相等的几部分,最后一部分位数可以短一点,然后按下面两种之一的叠加方法获取到散列地址:

(1)移位法:把划分好的各部分竖着排最后一位对齐相加

(2)分解法:排法与上面一种一样,只是奇数排保持正序,偶数排需要反序,最后对齐相加

应用以上这些方法求出的地址如果是r位,那么也就是说,地址这个整数的范围是(010^r-1),表空间不可能这么大,因此我们需要规定一个比例因子(01之间的一个数),将地址乘上比例因子得到表中地址

处理冲突的必要性:

由于不论我们如何选取散列函数,冲突无法避免,因此我们必须解决人为规定冲突处理办法,我们对散列表加以改造,如果散列表HT原本有m个地址,我们把它们视为m个桶,每个桶可以装相同的关键字的元素,这些散列地址相同的元素又叫做同义词,下面介绍两大冲突处理方法:

闭散列法

定义: 闭散列法,又叫开地址法,所有的桶直接放在散列表数组中,把数组看作环形结构,每个桶只能放一个元素,桶的下标就是数组的下标,我们通过散列函数将关键字映射为数组的某个位置,如果这个位置的桶是空的,就把该元素放进去,否则我们就需要找“下一个”空的桶,把元素放进去,如果没有空桶,则说明表满了,存入失败,找“下一个“空桶主要有下面的3种方法:

介绍之前,我们先搭一个散列表的基本结构:

#define MAXCAP 200
struct Hashtable{
    int bucket[MAXCAP];  //hash桶
    int st[MAXCAP];//记录桶被装入元素的个数
    int keypos[MAXCAP];//记录某个key的值被存入了哪个桶
    int curnum; //已经装了元素的桶个数
};

线性探查法

所谓线性探查法,其实就是,如果遇到冲突,就直接线性地找冲突点后面的桶,遇到空的就放,一个空的都没遇到就存入失败

我们用一个pair类型变量存储元素,第一关键字存储元素关键字,第二关键字存值

我们来实现以下线性探查法的插入函数:

Hashtable hst;
//我们选择199这个质数作为除数,使用除留余数法
bool insert(pair<int,int>a){
    if(hst.curnum==MAXCAP){
        return false;
    }
    int adr=a.first%199;//获取插入位置
    int i=adr;
    while(true){
        if(hst.st[i]==0){
            break;
        }
        if(hst.st[hst.keypos[a.first]]!=0){
            cout<<"Error:插入了重复的key!"<<endl;
            return false;
        }
        i=(i+1)%MAXCAP;
    }
    hst.bucket[i]=a.second;
    hst.keypos[adr]=i;
    hst.st[i]++;
    return true;
}
int search(int key){
    return hst.bucket[hst.keypos[key%199]];
}
int main(){
    if(insert({1,2})){
        cout<<"插入成功"<<endl;
    }
    cout<<search(1)<<endl;
    return 0;
}

经过测试,结果正确,我们也发现了,散列表查询的时间复杂度是O(1)

二次探查法

线性探查法完全按照线性搜索,时间复杂度最坏是O(n),这还是有很大优化空间的,我们来看一下二次探查法

所谓二次探查法,寻找下一个”空桶“的公式如下:

Hi=(H0+i2)%mHi=(H0i2)%mH_{i}=(H_{0}+i^{2})\%m \\H_{i}=(H_{0}-i^{2})\%m

这里的m有要求,表示哈希表的长度,并且必须是值符合4k+3的质数

二次探查法的探查序列如下:

H0,H0+1,H01,H0+4,H04...H_{0},H_{0}+1,H_{0}-1,H_{0}+4,H_{0}-4 ...

H0就是第一次通过散列函数算出来的那个桶的下标

我们具体通过代码看一下二次探查法是如何实现的:

#define MAXCAP 199 //注意我们这次选择了199
Hashtable hst;
//我们选择199这个质数作为除数,使用除留余数法
bool insert(pair<int,int>a){
    if(hst.curnum==MAXCAP){
        return false;
    }
    int adr=a.first%199;//获取插入位置
    int i=adr;
    int k=0,odd=0,save=i;
    while(true){
        if(hst.st[i]==0){
            break;
        }
        if(hst.st[hst.keypos[a.first]]!=0){
            cout<<"Error:插入了重复的key!"<<endl;
            return false;
        }
        if(odd==0){
            k++;
            i=(save+k*k)%MAXCAP;
            odd=1;
        }
        else{
            i=(save-k*k)%MAXCAP;
            odd=0;
            if(i<0){
                i=i+MAXCAP;
            }
        }
        if(i==save){
            return false;
        }
    }
    hst.bucket[i]=a.second;
    hst.keypos[adr]=i;
    hst.st[i]++;
    return true;
}
int search(int key){
    return hst.bucket[hst.keypos[key%199]];
}
int main(){
    if(insert({2,4})){
        cout<<"插入成功"<<endl;
    }
    cout<<search(2)<<endl;
    return 0;
}

经过检测,结果正确,二次探查法优越就优越在它在已经装满的桶数量小于总容量一半时,新的数据一定可以插入,并且任何一个位置不会被探查两次,因为它不会让关键字聚在一边

双散列法

双散列法就是当一个散列函数算出的地址发生冲突之后,用另一个散列函数再算一次,双散列完了之后还有冲突,那么此方法就无能为力了,还有一种叫做再散列法的方法,就是双散列遇到冲突就继续再用一个散列函数算,具体公式如下:

Hi=(Hash1+iHash2)%mH_{i}=(Hash_{1}+i*Hash_{2})\% m

这里的Hash1,表示用第一个散列函数算出的地址,i是冲突次数,Hash2是用第二个散列函数算出的地址,Hi则是最终的地址,这种方法对于Hash2的选取要求较高,因此不常用

注意: 闭散列法不能随意删除元素,我们如果要删除,应当使用一个标记表明删除即可,不能物理删除,否则会影响整个散列表

总结: 闭散列法的三种方法都各有缺陷,因此在实际使用时闭散列法并不常用

开散列法

定义: 所谓的开散列法,其实就是,当出现冲突时,把冲突的那个元素作为一个链表节点接在与之冲突的元素地址之后,也就是说,此时哈希桶中就不是存一个元素了,而是存储了一系列同义词,这种方式也是散列表最为常用的处理冲突的方式,示意图如下:

image-20221204091605860.png

这种方式的hash函数就没必要特意设计了,直接设为Hash(x)=x%m(m是表长)即可

我们用开散列法实现一下散列表:

#include<iostream>
using namespace std;
#define MAXCAP 200
struct Node{
    int key,val;
    Node* next;
    Node():next(NULL){}
    Node(int k,int v):key(k),val(v),next(NULL){}
};
struct Hashtable{
    Node* bucket[MAXCAP];  //hash桶
};
Hashtable hst;
void insert(pair<int,int>a){
    int adr=a.first%MAXCAP;
    Node* y=new Node(a.first,a.second);
    if(hst.bucket[adr]==nullptr){
        hst.bucket[adr]=y;
        return;
    }
    y->next=hst.bucket[adr]->next;
    hst.bucket[adr]->next=y;
}
void remove(int key){
    int adr=key%MAXCAP;
    Node* p=hst.bucket[adr];
    Node* pre=new Node();
    pre->next=p;
    while(p!=nullptr){
        if(p->key==key){
            break;
        }
        p=p->next;
        pre=pre->next;
    }
    pre->next=p->next;
    delete p;
}
int getvalue(int key){
    int adr=key%MAXCAP;
    Node* p=hst.bucket[adr];
    while(p!=nullptr){
        if(p->key==key){
            break;
        }
        p=p->next;
    }
    if(p==nullptr){
        return 0;//没有对应的值就返回0
    }
    return p->val;
}
int main(){
    insert({2,4});
    insert({7,8});
    insert({3,6});
    remove(3);
    cout<<getvalue(2)<<endl;
    cout<<getvalue(7)<<endl;
    cout<<getvalue(3)<<endl;
    return 0;
}