数据结构之哈希(散列)表

哈希(散列)表


声明:本文仅讨论常规的链表写法,无树的内容


文章目录

  • 哈希(散列)表
    • 初见哈希
    • 键值对与哈希函数
    • 解决哈希冲突的两种办法
      • 分离链接法
          • 哈希表与哈希函数的定义
          • 哈希表的初始化
          • 哈希表的增删查
      • 开放定址法
          • 线性探测法 `F(i) = i`
          • 平方探测法 `F(i) = i * i`
          • 双散列 `F(i) = i * hash2(X)`
    • 再散列与负载因子
    • 哈希表的应用

初见哈希

先不看任何内容,给散列表一个简单易懂的概述:一个数据结构,主体为用于存放数据的固定大小的数组

  • 按照以往的经验,名字里含“表”或者表的延申的,理应有链表与数组两种实现形式

    比如最基础的表,栈,队列数据结构,均可以以顺序或链式实现,但它是一个例外,哈希表的本质只能是数组

    当然,解决哈希冲突时还得用链表,这里先对哈希冲突有个印象,知道有这么个东西,后文会具体介绍

  • 作为一个相对前面所学而言的新概念的出现,那么它一定有过人之处,比如栈的先进后出,队列有先进先出,那么哈希表有什么优点呢?

    增删查的效率几乎都为O(1),当然这是写的好的情况下

    哈希表的底层是数组,而单数组的增删查都存在好几个O(n),链表也不能避免,怎么做到的呢,带着这个问题我们接着往下看

下面给出哈希表的官方定义:

散列表(Hash table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。

这么大一长串是不是感觉整个人都不好了,别急听我慢慢道来,先讲个故事


假如现在有一堆人要入住酒店,这个酒店每层只有一个房间,每个人都可以从柜台员那里领到一张纸,纸上可以看到自己所在的楼层,只是看的方式比较特别,写了一个函数

而函数的自变量需要用户自己确认,比如客户生日的月份,需要用户根据自己的经历找到自变量的值,从而算出来找到自己的房间,当然不存在客户数学不好的情况哈

等到每个人都去找自己的门牌号时,大事不妙了,由于酒店设计的函数不好,导致有的楼层内站了好几个人吵起来了,都说这是自己的房间,毕竟谁也不能保证两个人的出生月份一定不同把

酒店一看这是自己的失误,于是赶紧补救,召开大会进行讨论

一种对策是让维修工人赶紧每一层再修房间,哪层发生冲突哪层加修房间,这里就不考虑地基不稳了,故事嘛,这样吵起来的那些人根据来的先后顺序,依次住进了同一楼层的房间内

另一种对策是让同层内的人往那些没有人住的层数走,如果实在不行再多盖几层


好了这就是哈希表的了,我们来一一对应一下官方的定义,故事中的酒店是底层的数组,人以及身上的特征是我们要存入的数据,即关键码值,或者称之为键值对,纸上的函数就是哈希函数,让客户自己算出结果(映射)得到自己应该去的层数,即数据在数组中储存的位置,而吵起来就是哈希冲突的体现,后面的解决方案对应了两种解决哈希冲突的办法:分离链接法(同层加修)和开放定址法(去其他层数),多盖几层则是对数组的扩容

接着我们对这些概念一个一个解释

键值对与哈希函数

正如故事中的一样,这个函数是酒店自己定义的,换句话说就是我们自己写的函数,如果写的太简单随着入住人数的逐渐增加,那冲突将会发生的越来越频繁

那么这个函数的写法就有所讲究了,对于初学者而言,最常用的办法就是做取模运算

int Locate(int pos, int capacity) {
    return pos % capacity;
}

上面代码中的pos就是键值对中的key(),对应到故事里是客户的生日,capacity则是酒店的层数,客户不能住到酒店没有的楼层把

键值对里的就是用户自己,Locate函数只是用于定位的,即哈希函数

让我们将目光重新聚焦在哈希函数上,pos我们无法干预,那为了减少冲突capacity就尤为关键了,这里就直接给出结论了

capacity通常会取一个素数,根据离散数学专家们的研究,对素数取模比起其他数而言冲突的概率要小得多,这个素数可以取得很大,也可以取用户数的下一个素数

当然请牢记函数的形式不一定要是取模,只是这里便于理解

另外散列函数优劣取决于是否能做到平均分配以及其复杂程度

平均分配是为了保障每一层都能住人,而复杂程度对于最终的客户端并没有那么重要,因为算的过程是计算机完成的

解决哈希冲突的两种办法

分离链接法

看着好像挺高大上的,其实就是让数组中的元素变成链表而已,数组里可以塞结构体变成静态链表,自然也可以塞链表变成又一个新东西,就像故事中一样,每一层后面再修房间,就是链表结点的一个个相连,不过与故事中不同的一点,下面我们为了方便操作,统一规定每一层的第一个房间不住人,即保留一个头结点方便操作

了解了这些,开始我们的代码部分

哈希表与哈希函数的定义
typedef struct List{
    int data;
    struct list *next;
}NODE, *LPLIST;

typedef struct HashTable{
    int capacity;//表的大小
    LPLIST *lists;//二级指针,可以放元素的数组,该数组内部为一条条链表
}HASH, *HASHTABLE;

int Locate(int pos, int capacity) {
    return pos % capacity;
}//寻找存储的位置,return的判断为散列函数

这里称每一个数组就是一个bucket(桶),各个桶的链表结点就是桶内的一个个元素

哈希表的初始化
int IsPrime (int pos) {
    int i;
    if (pos < 2) {
        return 0;
    }
    if (pos != 2 && pos % 2 == 0) {
        return 0;
    }
    for (i = 3; i * i <= pos; i += 2) {
        if (pos % i == 0){
            return 0;
        }
    }
    return 1;
}//判断素数

int NextPrime(int pos) {
    while (1) {
        if (IsPrime(pos)) {
            return pos;
        }
        pos++;
    }
}//用于找到从输入值开始的下一个素数

HASHTABLE InitHash(int capacity) {
    int i;
    //为哈希表分配内存
    HASHTABLE h = (HASHTABLE)malloc(sizeof(HASH));
    assert(h);
    //对哈希表内用于表示大小的变量初始化
    //取模的数通常用素数,这样可以稍微缓解一下哈希冲突
    h->size = NextPrime(capacity);
    //为哈希表内的数组分配内存
    h->lists = (LPLIST*)malloc(sizeof(LPLIST)*h->capacity);
    assert(h->lists);
    //为数组内每一条链表分配内存并初始化,可以等到需要时再分配,减少不必要的分配
    for (i = 0; i < h->capacity; i++) {
        h->lists[i] = (LPLIST)malloc(sizeof(NODE));
        assert(h->lists[i]);
        h->lists[i]->next = NULL;
        //每个表分配一个表头方便操作
        //如果不需要删除操作的话最好不好有头结点占用过多的空间
    }
    return h;
}

正如上一部分所说,这里确定哈希函数中的capacity采用了找下一个素数的办法

代码中的注释部分在上面或多或少都有提到过,只是好几层的内存分配让初始化看起来有点复杂

销毁部分也是三层销毁,很简单这里就不写了

哈希表的增删查
LPLIST FindElement(int pos, HASHTABLE h) {
    LPLIST poslist = h->lists[Locate(pos, h->capacity)];
    //看着挺复杂的,Locate函数为了找到下标,将pos映射到对应序号的桶内
    LPLIST pmove = poslist->next;
    while (pmove != NULL && pmove->data != pos) {
        pmove = pmove->next;
    }
    if (pmove == NULL) {
        printf("There is no pos\n");
    }
    return pmove;
}

LPLIST CreateNew(int pos) {
    LPLIST newnode = (LPLIST)malloc(sizeof(NODE));
    assert(newnode);
    newnode->data = pos;
    newnode->next = NULL;
    return newnode;
}

//默认头插法
void InsertHash(int pos, HASHTABLE h) {
    LPLIST new = CreateNew(pos);
    LPLIST poslist = h->lists[Locate(pos, h->capacity)];
    new->next = poslist->next;
    poslist->next = new;
}

void DeleteHash(int pos, HASHTABLE h) {
    LPLIST poslist = h->lists[Locate(pos, h->capacity)];
    LPLIST pre = poslist;
    LPLIST pmove = poslist->next;
    while (pmove != NULL && pmove->data != pos) {
        pre = pmove;
        pmove = pmove->next;
    }
    if (pmove == NULL) {
        printf("There is no pos\n");
        return;
    }
    pre->next = pmove->next;
    free(pmove);
}

这三项操作麻烦的点就在于要先找到自己桶的位置,找到后就和最简单的表一样了,不做过多说明

开放定址法

这是故事中最终讨论出来的第二种解决冲突的方法,冲突了怎么办?找其他没人的楼层,怎么找呢,与哈希函数一样,需要给出一个探测的方法F(i)i表示探测的轮数,下面给出三种常用的方法

线性探测法 F(i) = i

如果有值则绕回到0逐个探测每个单元直到遇到空的进行插入
先通过哈希函数第一次寻找,有值则通过线性探测函数持续查找
为了保证足够大的数组来存放,容易形成断断续续的一些数据出现聚集现象,且查找一次遍历的时间也长

平方探测法 F(i) = i * i

可以解决线性探测中一次聚集的问题
默认探测1的平方次,如果仍然有值则2的平方次,依次增加

双散列 F(i) = i * hash2(X)

和静态链表的两个链表其实是在一个链表中操作一样,双散列也是在一个散列中操作
只是第二个散列的体现时体现在探测函数中
eg.hash2(X) = R - (X % R)hash2函数为自定义函数,R也为自选值,选不好性能会极其低下,X为键key

初学不推荐,本身构建一个好的哈希函数已经够头疼了,有其他方法的情况下最好不要再去构建一个哈希函数

探测函数如下,一般需要探测时是在增加数据时

int explore(int i) {
    return ++i;
    //return i * i;
    //return i * (7 - x % 7)双散列需要将数据x传进来
}

到这,你有没有发现什么致命的问题?

是的,如果进行插入数据,通过哈希函数找到的地方被人占了后,之后再找到的位置是无法通过正常哈希函数映照键的值找到的,这意味着查找和删除都需要一个新的思路

这里我们可以采用一个标记位,什么意思呢,上代码

typedef struct Array{
    int element;//数据位
    int flag;//标记位
}NODE, *ARRAY;

typedef struct HashTable{
    int capacity;
    ARRAY array;
}HASH, *HASHTABLE;

HASHTABLE InitHash(int capacity) {
    HASHTABLE h = (HASHTABLE)malloc(sizeof(HASH));
    assert(h);
    h->array = (ARRAY)malloc(sizeof(NODE)*capacity);
    memset(h->array, 0, sizeof(NODE));//将数据位和标记位全部置0
    assert(h->array);
    return h;
}

加一个标记位,并将其置0,有数据后再将标记位置1,这样无论在增删查哪一个操作中,都可以直接检查标记位的大小就可以判断这个位置是否为空了,删除也因此有了个新名字懒惰删除

实际操作中开放定址法其实使用的很少,所以就不给出其具体代码了,因为它需要构建一个很大的数组,当然还有一个解决这个问题方法扩容

可以开始定义一个恰如其分的数组,当增加操作中因为数组大小受限时再扩容

这里这个“受限”又有新的讲究,数组内元素多到什么程度再进行扩容,让我们进入下一个模块

再散列与负载因子

  1. 扩容在哈希表中又被称为再散列
  • 建一个新表再散列,扩大为原来的两倍后,最好也找到扩容后数的下一个素数作为新表的capacity,为了哈希函数的成功构建
  • 再把原表遍历一遍放到新表里面,但这意味着O(n)的运行时间,所以通常不到万不得已是不会进行再散列的
  1. 这里引入一个新的概念负载因子,即节点数量/哈希表大小,负载因子越大,说明此时哈希表的效率越低
  • 为了判断负载因子的大小,我们可以在之前定义的结构体中加入当前哈希表内元素的数目这个变量以便再散列
  • ​通过判断负载因子的大小,我们可以决定是否进行再散列,根据离散数学专家们的研究,通常这个值为0.75或者0.5
  • ​插入失败时进行再散列也是可以的,但是这样的随机性太强了,容易浪费空间
  1. 并不只有开放定址法可以进行再散列,分离链接法到了负载因子过大的情况也可以进行再散列

如果越来越多的键找到自己的位置后都发生冲突而链接的越来越长,那增删查的遍历的过程中时间复杂度会向O(n)靠拢,那这个时候哈希表的优势便荡然无存,为了保证效率,再散列也是必须的

哈希表的应用

  1. O(1)的效率决定了它在搜索中注定一骑绝尘,在搜索类算法题中哈希查找是一个很常用的方法
  2. 手机通讯录中联系人名字az的首字母排序,类似的这些排序,都是哈希表实现的结果,不过哈希表自身并不支持排序,这是需要注意的一点
  3. 在保护一些信息时通常也会依靠做一个复杂的哈希函数去阻止黑客获取用户的信息,黑客只能看到映射后的值,而哈希函数则是封装起来的,其内复杂的逻辑很好的保护了用户的信息
  • 26
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值