数据结构与算法之美(笔记8)散列表

散列思想

散列表也叫哈希表,或者叫“hash”表。散列表用的是数组支持按照下标随机访问数据的热性,所以散列表其实就是数组的一种扩展,由数组演化而来。可以说,如果没有数组,就没有散列表。

举个例子,假如我们有89个选手参加学校的运动会。为了方便记录成绩,每个选手胸前会贴上自己的参赛号码。这89个选手的编号一次是1到89,。现在我们希望变成实现着这样一个功能,通过编号快速找到对应的选手信息,你会怎么做呢?

我们可以把这89名选手的信息放在数组里。编号为1的选手,我们放到数组中下标为1的位置,编号为2的选手,我们放到数组红下标为2的位置。依次类推,编号为k的选手放到数组放到下标为K的位置。

这样参赛选手的号码与数组下标一一对应,当我们需要找参赛编号为x的选手的时候,我们只需要将下标为x的数组元素取出来就可以了,时间复杂度是O(1)。这样按照编号查找选手信息,效率很高。这就是典型的散列思想,其中,参赛选手的编号我们叫做(key)或者关键字。我们用它来标识一个选手,我们把参赛编号转为数组小标的映射方法就叫做散列函数(hash函数),而散列函数计算得到的值就叫做散列值。

由以上,我们知道散列表用的就是驻足支持按照下标随机访问的时候,时间复杂度是O(1)的特性。我们通过散列函数把元素的键映射为数组的下标,然后将数据存储在数组中对应的下标的位置。当我们按照键值查询元素的时候,我们用同样的散列函数,将键值转为数组下标,从对应的数组下标的位置取数据。 

散列函数

散列函数,我们可以把它定义为hash(key),其中key表示元素的键值,hash(key)的值表示经过散列函数计算得到的散列值(储存数据数组的下标)。那么如何构造一个散列函数呢?

  • 散列函数计算的得到的散列值是一个非负整数。
  • 如果key1 == key2 ,那hash(key1) == hash(key2)
  • 如果key1 != key2,那hash(key1) != hash(key2) 

第一二点理解应该没有问题,因为数组的下标是从0开始的,然后相同的key应该得到相同的hash值,如果不同的话,那查找就不能进行了。然而对于第三点,在真实的情况下,要想找到一个不同的key对应的散列值都不一样的散列函数,几乎是不可能的。因此不同的key可能经过hash之后会得到相同的hash值,这个时候就出现了哈希冲突。而且,数组的存储空间也有限,也会加大散列冲突的概率。

散列冲突

1.开放寻址法

开放寻址法的核心思想是,如果出现了散列冲突,我们就重新探测一个空闲位置,将其插入。那么如何探测一个新的位置?我们先来看线性探测。

插入操作

当我们往散列表中插入数据时,如果某个数据经过散列函数散列之后,储存位置已经被占用了,我们就从当前位置开始,依次往后查找,看是否有空闲的位置,直到找到为止。

查找操作

在散列表中查找元素的过程有点儿类似插入,我们通过散列函数求出要查找元素的键值对应的散列值,然后比较数组中下标为散列值的元素和要查找的元素。如果相等,则说明就是我们要找的元素,否则就顺序往后依次查找。如果遍历到数组中的空闲位置,还没有找到,就说明要查找的元素并没有在散列表中。

删除操作

删除操作稍微有些特别,我们不能单纯地把要删除的元素设置为空。因为在查找中,一旦我们用过线性探测的方法,找到一个空闲位置,我们就可以认定散列表中不存在这个数据。但是,如果这个空闲位置是我们后来删除的,就会导致原来的查找算法失效。所以我们可以把删除的元素,标记为deleted。当线性探测的时候,遇到标记为deleted的空间,并不是停下来,而是继续往下探测。

 

 你可能也发现了,线性探测法其实存在很大问题。当散列表中冲突越来越多,我们查找的时间复杂度就退化到O(n)。对于开放寻址法解决方法,除了线性探测之外,还有另外的两种:二次探测,双重散列。

所谓二次探测,跟线性探测很像,线性探测每次的探测步长为1,而二次探测的步长就变成了原来的“二次方”。所谓双重散列,意思就是不仅要使用一个散列函数,当发生冲突的时候,再用第二个函数。

不管采用哪一种探测方法,当散列表中空闲位置不多的时候,散列冲突的概率会大大提高。为了尽可能保证散列表的操作效率,一般情况下,我们会尽可能保证散列表有一定比例的空闲槽位。用装载因子来表示空位多少。

散列表的装载因子 = 填入表中的元素个数 / 散列表的长度

2.链表法

链表法是一种更加常用的散列冲突解决方法。相比开放寻址法,它要简单很多。

当插入的时候,我们只需要通过散列函数计算出对应的散列函数,将其插入到对应链表中即可,所以插入的时间复杂度是O(1)。当查找、删除一个元素的时间复杂度跟链表的长度k成正比,也就是O(k)。其中,k = n/m。 

这里给出代码的实现:

#ifndef HASH_TABLE_H
#define HASH_TABLE_H
#include <iostream>
#include <string.h>

#define HASHSIZE 100

typedef struct hashNode
{
    char* key;
    char* value;
    hashNode* next;
}hashNode;// 定义散列表的节点

class Hash_Table{
private:
    hashNode* hashtable[HASHSIZE];// 定义了一个储存hashNode指针的数组
public:
    // 初始化数组
    Hash_Table(){
        for(int i=0;i<HASHSIZE;++i){
            hashNode* p = new(hashNode);
            p->next = NULL;
            hashtable[i] = p;
        }
    }
    // 哈希函数
    unsigned int hash(char* s){
        unsigned int hash_add = 0;
        for(;*s;++s){
            hash_add = *s+hash_add*31;
        }
        return hash_add % HASHSIZE;
    }
    // 头插法
    void insert(char* name,char* desc){
        unsigned int add = hash(name);
        hashNode* head = hashtable[add];

        hashNode* p = new(hashNode);
        p->key = name;
        p->value = desc;
        p->next = head->next;
        head->next = p;
    }
    // 查找操作
    char* search(char* name){
        unsigned int hash_add = hash(name);
        if(hashtable[hash_add]->next == NULL){
            return NULL;
        }else{
            hashNode* p = hashtable[hash_add];
            for(p=p->next;p != NULL;p=p->next){
                if(!strcmp(p->key, name)){
                    return p->value;
                }
            }
            return NULL;
        }
    }
    // 删除操作
    bool Delete(char* name){
        unsigned int hash_add = hash(name);
        if(hashtable[hash_add]->next == NULL){
            return false;
        }else{
            hashNode* p = hashtable[hash_add];
            for(;p->next != NULL;p = p->next){
                if(!strcmp(p->next->key,name)){
                    p->next = p->next->next;
                    return true;
                }
            }
            return false;
        }
    }

    void print(){
        for(int i=0;i<HASHSIZE;++i){
            if(hashtable[i]->next != NULL){
                hashNode* np = hashtable[i];
                cout << "\nhashvalue: " << i << "   ";
                for(np=np->next; np != NULL; np=np->next){
                    cout << np->key << ": " << np->value << endl;
                }

            }
        }
    }
};

#endif // HASH_TABLE_H

测试代码:

int main(){
    Hash_Table table;
    char* names[]={"First Name","Last Name","address","phone","k101","k110"};
    char* descs[]={"Kobe","Bryant","USA","26300788","Value1","Value2"};
    for(int i=0;i<6;++i){
        table.insert(names[i],descs[i]);
    }
    table.insert("phone","9433120451");
    table.Delete("phone");
    table.print();
}

如何设计散列函数

首先,散列函数的设计不能太复杂。过于复杂的散列函数,势必会消耗很多计算时间,也就间接的影响散列表的性能。其次,散列函数生成的值尽可能随机并且均匀分布。这样即使出现冲突,散列到每一槽里的数据也会比较平均,不会发现某个槽内数据特别多的情况。

装载因子过大了怎么办?

随着数据的不断插入,势必会出现装载因子越来越大,这样的话,散列表的性能会大大下降。所以我们要进行动态扩容,重新申请一个更大的散列表,将数据搬移到这个散列表中。假设每次扩容我们都申请一个原来两倍的空间。如果原来散列表的装载因子是0.8,那经过扩容之后,新散列表的装载因子就下降为原来的一半。

针对数组的扩容,数据搬移比较简单。但是,针对散列表的扩容,数据搬移操作要复杂很多。因为散列表的大小变了,数据的存储位置也变了,所以我们需要通过散列函数重新计算一个数据的存储位置。

插入一个数据,最好情况下,不需要扩容,时间复杂度是O(1),最坏情况下,散列表装载因子过高,启动扩容,我们需要重新申请地址,重新据算哈希位置,并且搬移数据,所以时间复杂度是O(n)。均摊时间复杂度是O(1)。

实际上,对于动态散列表,随着数据的删除,散列表中的数据会越来越少,空闲空间越来越多。如果我们对空间消耗非常敏感,我们可以在装载因子小于某个值之后,启动动态缩容。

如何避免低效地扩容?

我们刚才分析到,大部分情况下,动态扩容的散列表中插入一个数据都很快,但是在特殊情况下,当装载因子已经到达阈值,需要先扩容,然后插入数据。这个时候,插入将会很慢。比如,我们当前的散列表有1GB,想要扩容为2倍就是要对1GB的数据重新计算哈希值,并且从原来的散列表搬移到新的散列表,实在是太慢了。

如果我们的业务代码直接服务于用户,尽管大部分情况下,插入一个数据的操作都很快,但是,极个别非常慢的插入操作,也会让用户崩溃。因此这种一次性扩容的机制就不适合了。

为了解决这个问题,我们将扩容操作穿插在插入的过程中,分批完成。当装载因子触达阈值之后,我们只申请空间,但并不将老的数据搬移到新的散列表中。

当有数据要插入的时候,我们将新数据插入到新的散列表中,并且从老的散列表中拿出一个数据放入到新散列表。每次插入一个数据到散列表,我们都重复以上的操作。经过多次插入操作之后,老的散列表中的数据就一点一点全部搬移到新的散列表中。

而对于查询操作,我们先从新的散列表总查找,如果没有找到,再去老的散列表中查找。这种实现方法在任何情况下插入一个数据的时间复杂度是O(1)。

如何选择冲突解决方法?

1.开放寻址法

开放寻址法中散列表中的数据都存储在数组中,可以有效地利用cpu缓存加快查询速度。但是删除数据的时候比较麻烦,需要特殊标记已经删除的数据。而且,在开放寻址法中,所有的数据都存储在一个数组中,比起链表法来说,冲突的代价很高。所以使用开放寻址法解决冲突的散列表,装载因子不能太大。这也导致这种方法比链表 更浪费内存。

所以,当数据量比较小,装载因子小,适合使用开放寻址法。

2.链表法

链表法对内存的利用率比开放寻址法更高,因为链表结点可以在需要的时候再创建,并不像开放寻址法那样事先申请好。

链表法比起开放寻址法,对大的装载因子容忍度更高。开放寻址法只能适用装载因子小于1的情况。接近1的时候,就可能有大量的散列冲突,性能下降很多。而对于链表法,即使装载因子为10,也就是链表长度很长,但仍然比顺序查找快很多。

所以,基于链表的散列表冲突处理方法比较适合存储大对象、大数据量的散列表,而且,比起开放寻址法,它更加灵活,支持更多的优化策略,比如使用跳表或者红黑树。

散列表与链表的结合

1.LRU缓存淘汰算法

实现算法:我们需要维护一个按照访问时间从大到小有序排列的链表结构。因为缓存大小有限,当缓存空间不够,需要淘汰一个数据的时候,我们就直接将链表头部的结点删除。

当要缓存一个数据的时候,先在链表中查找这个数据。如果找到了,就把它移动到链表的尾部。如果没有找到,先看缓存是否足够,如果不足,就把第一个结点删除之后,把新的缓存放在末尾,如果缓存够的话,直接放在末尾。因为查找数据需要遍历链表,所以单纯用链表实现的LRU缓存算法的时间复杂度很高,是O(n)。

实际上,一个缓存主要包含下面这几个操作:

  • 往缓存中添加一个数据
  • 删除一个数据
  • 查找一个数据

如果我们将散列表和链表结合,就可以将这三个操作的时间复杂度降低到O(1)。

因为我们的散列表是通过链表法解决散列冲突的,所以每个结点会在两条链中。一个链是刚刚我们提到的双向链表,另一个是散列表中的拉链。前驱和后继指针是为了将结点串在双向链表中,hnext指针是为了将结点串在散列表的拉链中。

我们来分析一下查找、删除、添加的时间复杂度。

查找:我们通过key在散列表中可以直接找到该结点的位置,时间复杂度是O(1),然后当找到数据之后,我们还需要将它移动到双向链表的尾部。

删除:借助散列表我们可以在O(1)时间复杂度内查找到这个数据,然后由于是双向链表,我们可以直接找到前驱结点,然后删除也是在O(1)内完成。

添加:添加到缓存的话需要看这个数据是否存在,如果存在就要移动到末尾,如果不在,还要看缓存是否满了,如果满了,就将头结点删除,然后再放到末尾,如果没有满,直接放在末尾。

这里给出代码的实现:

2.redis有序集合

实际上,在有序集合中,每个成员对象有两个重要的属性,key(键值)和score(积分)。我们不仅会通过score来查找数据,还会通过key来查找数据。

举个例子,比如用户积分排行榜上有这样一个功能,我们可以通过用户的ID来查找积分信息,也可以通过积分区间来查找用户ID或者姓名信息。这里包含ID,姓名和用户信息,就是成员对象,用户ID就是key,积分就是socre。

所以,如果我们细化一下redis有序集合的操作,那就是下面这样:

  • 添加一个成员对象
  • 按照键值来删除一个成员对象
  • 按照键值来查找一个成员对象
  • 按照分值区间查找数据,比如查找积分在[100,300]之间的成员对象
  • 按照分值从小到大排序成员对象

如果我们仅仅按照分值将成员对象组织成跳表的结构,那按照键值来删除、查询成员对象就会很慢,解决方法与LRU缓存淘汰算法的解决方法类似。我们可以再按照键值构建一个散列表,这样按照key来删除,查找一个成员对象的时间复杂度就变成了O(1),同时借助跳表,其他操作也非常高效。

猜你喜欢

转载自blog.csdn.net/weixin_42073553/article/details/88601118
今日推荐