数据结构与算法:哈希表(hash)

哈希表的由来

  • 在编程实现中,常常面临着两个问题:存储查找时间和空间的经典互换),存储和查找的效率往往决定了整个程序的效率。
  • 我们知道在实际的物理存储中存放数据有2种形式:集中存储分散存储,分别对应:
    数据元素在内存中集中存储,采用顺序表示结构,简称“顺序存储”(数组);
    数据元素在内存中分散存储,采用链式表示结构,简称“链式存储”(链表)。
  • 数组的特点是:查找数据容易移动数据困难
  • 链表的特点是:移动数据容易查找数据困难
  • 人总是贪心的,我们很容易想到有没有什么办法能将两者的优点结合起来,做到取其精华去其糟粕呢?
  • 这个时候,哈希表应运而生…

哈希表的定义

  • 来自百度百科的定义:

散列表是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表
给定表M,存在函数f(key),对任意给定的关键字值key,代入函数后若能得到包含该关键字的记录在表中的地址,则称表M为哈希(Hash)表,函数 f(key) 为哈希(Hash) 函数。

  • 如果你一上来直接就看这定义,也许会很懵逼,这讲的啥,但是别担心,我举个例子你也许就有点感觉了。
  • 采用数组储存数据
    数组储存
  • 采用链表储存数据
    链表储存
  • 采用哈希表储存数据
    哈希表储存
  • 总结一下:哈希表(Hash table)做的事很简单,就是将数据映射(简单但不准确地说就是存放)到数组中,这样就能够通过数组下标直接访问该数据,而不需要像链表一样遍历结点,也不用像数组一样开辟多余的空间,提高数据的查找速度,如此一来,在空间复杂度为O(n)的基础上查找数据的平均时间复杂度降低至O(1)。
  • 简化一下,哈希表就是一个数组,只不过数组索引数据的关键字经历过哈希函数映射后得到的哈希地址
  • 搞懂了概念定义,咱们来点实际点儿的。

哈希表实际应用中的问题

  • 在实际应用中,数据的关键字一般是已知的(从数据中可以直接获取),例如,想要存放一个人的身份信息,可以用姓名作为关键字(key),但是问题随之而至。
  • 例如给出两个人的身份信息,他们的名字分别是熊哥熊弟,它们经过哈希函数 (取姓氏的第一个字) 得出的哈希地址都是。这也就是说两个不同的关键字有可能会被映射出一样的哈希地址,从而导致哈希表上该位置上的数据覆盖(丢失),这就是哈希冲突(也叫哈希碰撞),用公式来表达就是key1≠key2,但f(key1)=f(key2)
  • 冲突会给查找带来很大的麻烦,举一个现实的例子,假设你想找你的好朋友熊弟借点钱,但你忘记了他的电话号码,你想在表中查找你的朋友“熊弟的电话号码,但是却找到另一个名叫“熊哥”的电话号码,然后你在不知情的情况下打了电话过去,见面第一句就是“熊(兄)弟,借点钱花花?”,可想而知你会遭受怎样的摧残。
  • 然而,哈希冲突是无可避免的,为啥这么讲呢?因为哈希冲突的根本原因来自于哈希函数对关键字的压缩映像,有一个理想的方法是,直接以关键字本身为哈希地址(先抛开姓名能否作为数组索引的问题),如此一来,不同的关键字,其哈希地址一定不同,但是哈希表就会退化为普通的数组(它本质也就是数组),例如上面用数组储存数据的图示,如此一来,哈希表的空间复杂度就会急剧上升,这不符合哈希表的本意。
  • 既然无法避免,就只能尽量减少冲突带来的损失,那么剩下的问题就是,我们要如何选择一个恰当的哈希函数解决一定量哈希冲突

选择哈希函数

  • 首先,一个好的哈希函数需要有以下特点:
    • 尽量使关键字对应的记录均匀分配在哈希表里面
    • 关键字极小的变化可以引起哈希值极大的变化
    • 计算哈希地址简单高效
    • 哈希表表长尽量短
  • 常见的哈希函数有:
    • 数字分析法:分析数据,找出数据之间的异同点,尽可能利用这些异同点之间规律来构造冲突几率较低的哈希地址。例如:青菜和青瓜都是绿色的,但一个属于蔬菜类,一个属于瓜果类,可以用绿色的菜和绿色的瓜简单区分两者。
    • 平方取中法:当无法确定关键字中哪几位分布较均匀时,可以先求出关键字的平方值,然后按需要取平方值的中间几位作为哈希地址。这是因为:平方后中间几位和关键字中每一位都相关,故不同关键字会以较高的概率产生不同的哈希地址。例如:21^2 = 441 而 22^2 = 484,23^2 = 529,取中间一位(前两位或后两位)都可以有效区分这三者。
    • 折叠法:将关键字分割成位数(长度)相同的几部分,最后一部分位数(长度)可以不同,然后取这几部分的叠加和(去除进位)作为哈希地址。例如:1234 => (1+2)(3+4) => 37,1235 => (1+2)(3+5) => 38,123 => (1+2)(3) => 33。
    • 除留余数法:取关键字被某个不大于哈希表表长m的数p除后所得的余数为哈希地址。即 H(key) = key % p,(p<=m)。对p的选择很重要,一般取素数或m,若p选的不好,容易产生同义词,导致哈希冲突。这是我个人常用的方法之一。

解决哈希冲突

  • 解决哈希冲突的常见方法:
    • 开放寻址法:H=(H(key) + d) % m,其中H(key)为哈希函数,m为哈希表长,d为增量序列,可有下列三种取法:
    • d=1,2,3,…,m-1,称线性探测再散列;
    • d=12,-12,22,-22,⑶2,…,±(k)2,(k<=m/2)称二次探测再散列;
    • d=伪随机数序列,称伪随机探测再散列。
    • 简单来说就是一旦发生冲突,就去寻找下一个空的散列表地址,因为只要散列表足够大,空的散列地址总能找到。
      线性探测再散列图示: 线性探测再散列图示
    • 再散列法(再哈希法):即产生哈希冲突时,将使用另一个哈希函数计算该哈希地址的地址,直到冲突不再发生,这种方法不易产生聚集,但增加了计算时间。
      再散列法图示:再散列法图示
    • 链地址法(拉链法):将重复的数据通过链表储存起来,有点像手拉手一样,该法适用于经常进行插入和删除数据的情况。
      拉链法图示:拉链法图示
    • 建立一个公共溢出区:等同于再创建一个相同的哈希表来储存冲突的数据。

俗话说:笔下见真功夫。我们来道题试试,练练手。

经典例题实战演练

来自LeetCode的 146. LRU缓存机制实现

#define Nothingness -1

struct node{
    int key;
    int value;
    struct node* prev;
    struct node* next;
};//双向链表

struct hash{
    struct node* unused;//数据的未使用时长
    struct hash* next;//拉链法解决哈希冲突
};//哈希表结构

typedef struct {    
    int size;//当前缓存大小
    int capacity;//缓存容量
    struct hash* table;//哈希表
    //维护一个双向链表用于记录 数据的未使用时长
    struct node* head;//后继 指向 最近使用的数据
    struct node* tail;//前驱 指向 最久未使用的数据    
} LRUCache;
struct hash* HashMap(struct hash* table, int key, int capacity)
{//哈希地址
    int addr = key % capacity;//求余数
    return &table[addr];
}

void HeadInsertion(struct node* head, struct node* cur)
{//双链表头插法
    if (cur->prev == NULL && cur->next == NULL)
    {// cur 不在链表中        
        cur->prev = head;
        cur->next = head->next;
        head->next->prev = cur;
        head->next = cur;
    }
    else
    {// cur 在链表中
        struct node* fisrt = head->next;//链表的第一个数据结点
        if ( fisrt != cur)
        {//cur 是否已在第一个
            cur->prev->next = cur->next;//改变前驱结点指向
            cur->next->prev = cur->prev;//改变后继结点指向
            cur->next = fisrt;//插入到第一个结点位置
            cur->prev = head;
            head->next = cur;
            fisrt->prev = cur;
        }
    }
}
LRUCache* lRUCacheCreate(int capacity) {
    /*if (capacity <= 0)
    {//传参检查
        return NULL;
    }*/
    LRUCache* obj = (LRUCache*)malloc(sizeof(LRUCache));
    obj->table = (struct hash*)malloc(capacity * sizeof(struct hash));
    memset(obj->table, 0, capacity * sizeof(struct hash));
    obj->head = (struct node*)malloc(sizeof(struct node));
    obj->tail = (struct node*)malloc(sizeof(struct node));
    //创建头、尾结点并初始化
    obj->head->prev = NULL;
    obj->head->next = obj->tail;
    obj->tail->prev = obj->head;
    obj->tail->next = NULL;
    //初始化缓存 大小 和 容量 
    obj->size = 0;
    obj->capacity = capacity;
    return obj;
}
int lRUCacheGet(LRUCache* obj, int key) {
    struct hash* addr = HashMap(obj->table, key, obj->capacity);//取得哈希地址
    addr = addr->next;//跳过头结点
    if (addr == NULL){
        return Nothingness;
    }
    while ( addr->next != NULL && addr->unused->key != key)
    {//寻找密钥是否存在
        addr = addr->next;
    }
    if (addr->unused->key == key)
    {//查找成功
        HeadInsertion(obj->head, addr->unused);//更新至表头
        return addr->unused->value;
    }
    return Nothingness;
}
void lRUCachePut(LRUCache* obj, int key, int value) {
    struct hash* addr = HashMap(obj->table, key, obj->capacity);//取得哈希地址
    if (lRUCacheGet(obj, key) == Nothingness)
    {//密钥不存在
        if (obj->size >= obj->capacity)
        {//缓存容量达到上限
            struct node* last = obj->tail->prev;//最后一个数据结点
            struct hash* remove = HashMap(obj->table, last->key, obj->capacity);//舍弃结点的哈希地址
            struct hash* ptr = remove;
            remove = remove->next;//跳过头结点
            while (remove->unused->key != last->key)
            {//找到最久未使用的结点
                ptr = remove;
                remove = remove->next;
            }
            ptr->next = remove->next;//在 table[last->key % capacity] 链表中删除结点
            remove->next = NULL;
            remove->unused = NULL;//解除映射
            free(remove);//回收资源
            struct hash* new_node = (struct hash*)malloc(sizeof(struct hash));
            new_node->next = addr->next;//连接到 table[key % capacity] 的链表中
            addr->next = new_node;
            new_node->unused = last;//最大化利用双链表中的结点,对其重映射(节约空间)
            last->key = key;//重新赋值
            last->value = value;
            HeadInsertion(obj->head, last);//更新最近使用的数据
        }
        else
        {//缓存未达上限
            //创建(密钥\数据)结点,并建立映射
            struct hash* new_node = (struct hash*)malloc(sizeof(struct hash));
            new_node->unused = (struct node*)malloc(sizeof(struct node));
            new_node->next = addr->next;//连接到 table[key % capacity] 的链表中
            addr->next = new_node;
            new_node->unused->prev = NULL;//标记该结点是新创建的,不在双向链表中
            new_node->unused->next = NULL;
            new_node->unused->key = key;//插入密钥
            new_node->unused->value = value;//插入数据
            HeadInsertion(obj->head,new_node->unused);//更新最近使用的数据
            ++(obj->size);//缓存大小+1
        }
    }
    else
    {//密钥已存在
    // lRUCacheGet 函数已经更新双链表表头,故此处不用更新
        obj->head->next->value = value;//替换数据值
    }
}

void lRUCacheFree(LRUCache* obj) {
    free(obj->table);//未完待续
    free(obj->head);
    free(obj->tail);
    free(obj);
}

参考资料

严蔚敏《数据结构与算法(C语言版)》

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值