哈希的思想及其应用

文章目录

    • 哈希的思想及其应用
      • 哈希的基本概念
      • 哈希表
      • 哈希函数
      • 开散列
      • 位图
      • 布隆过滤器
      • 哈希切分


哈希的思想及其应用

哈希的基本概念

哈希是一种将元素与其存储位置建立关联的一种思想,哈希的关键是确定性,而非顺序性。即一个元素映射一个确定的位置,当要查找某一个元素的时候,就可以根据这个元素对应的位置进行直接查找,而非遍历查找。哈希的思想可以快速高效的完成查找工作,在数据结构中有广泛的应用。

哈希表

哈希表是基于哈希思想的一种数据结构,是专门用来查询的一种数据结构,哈希表也称之为散列表。元素在散列表中的存储方式称之为散列存储,例如现在有一个大小为10的数组,要存储4~9这6个数,可以把4存在下标为4的位置,5存到下标为5的位置,依次类推。

哈希的思想及其应用_第1张图片

我们把元素映射到的位置称之为哈希值,例如4映射到4位置,那么4的哈希值为4。这种最简单的映射方式称之为直接定址法,即元素的哈希值与它本身的值是线性相关的,元素本身的值与它的哈希值之间的关系可以用哈希函数来表示,直接定址法的哈希函数为hash(i)=A*i+B(其中A和B为常数),例如上面最简单的散列表的哈希函数为hash(i)=i.

哈希函数

哈希函数是把元素的值转化为它的哈希值的函数,哈希函数是哈希映射思想的关键,它决定了哈希映射的确定性。哈希函数的种类繁多,下面介绍常见的几种哈希函数。

直接定址法的哈希函数

直接定址法的哈希函数为hash(i)=A*i+B,直接定址法的哈希函数简单,一个元素对应唯一1个哈希值,例如上面最简单的散列表中,4对应的哈希值是4。但是直接定址法的缺陷明显,当元素范围发散时,需要消耗的空间较大,例如这样一组数[1,4,6,1000]需要使用哈希表存储,采用直接定址法,至少需要999个空间,但是值只有4个,有严重的空间浪费,因此直接定址法的哈希函数一般适用于元素集中,且最大值和最小值的差相对较小的情况。

除留余数法的哈希函数

除留余数法的哈希函数为hash(i)=i%m(其中m为散列表的大小)。除留余数法的思想是无论元素值为多少,通过取余的方案,将它们统一映射到一段区间上,例如有一个大小为10的散列表,需要把[16,999,1,78]放到散列表中,采取如下方式

哈希的思想及其应用_第2张图片

1的哈希值是1%10=1,999的哈希值是999%10=9,这样无论元素的范围是多大,经过哈希函数计算以后它们的哈希值都是0~9。

除留余数法的问题

当不同的元素哈希值一样的时候存在哈希冲突,例如上面的散列表放入99的时候,它的哈希值是9,与999冲突

哈希的思想及其应用_第3张图片

这种冲突叫做哈希冲突,指的是不同的元素经过哈希函数计算以后得到的哈希值一样,在采用除留余数法的时候,发生哈希冲突常见的解决方案有线性探测和二次探测。

线性探测

例如上图在插入99的时候发现哈希值为9的位置已经被占用,此时向后寻找空位置。

哈希的思想及其应用_第4张图片

发现0位置没有元素,可以使用,于是把99放到0位置。这样的方法就叫做线性探测,这样在通过哈希函数寻找99的时候可能不能一下直接找到,因为99%10=9,先到哈希值为9的位置查找,不是99,再到1位置查找,是99,查找才结束,如果在0位置也没有找到99,那么需要继续往后寻找,直至找到99或者找到空位置停止。

线性探测虽然一定程度上能够解决哈希冲突的问题,但是造成了效率上的下降,当发生集中的哈希冲突时,容易引发连锁反应,例如:

哈希的思想及其应用_第5张图片

此时想要在向该散列表中插入10,发现哈希值为0的位置被占用,于是被迫向后寻找位置。如果大规模的发生这种情况,散列表的查找效率会退化到O(N),因此,当散列表中的元素达到一定的比例时,需要将散列表扩容,同时重新计算原来表中每一个元素在新的散列表中的哈希值,以便将它们分配到新的散列表。
我们把散列表中的元素个数与散列表的大小之比称为负载因子α,一般负载因子α控制在0.7左右。若原散列表的大小为10,当里面有7个元素的时候,就应该考虑将它扩容,可以扩容到20或者其它大小,在C++标准库中认为每一次扩容以后新的哈希表的大小应该是一个质数。在每一次扩容以后必须重新计算旧的哈希表中的元素在新的哈希表中的哈希值,因为扩容以后哈希函数hash(i)=i%m的m大小发生了改变
哈希表的扩容代价是比较大的,需要重新计算原表中的每一个元素在新表中的哈希值 ,因此,若我们可以提前预知需要在哈希表中插入的元素数量,应该提前把哈希表的容量开好,避免在插入的过程中频繁扩容+重新计算元素的哈希值。

二次探测

解决哈希冲突的方法除了线性探测还有二次探测,二次探测相比于线性探测,效率上有所改善,能减小产生连锁反应的概率。二次探测并非探测2次,它指的是当发生哈希冲突,一个位置被占用的时候,在尝试向后寻找可用位置时,并非一个一个的寻找,而是进行跳跃式寻找。例如

哈希的思想及其应用_第6张图片

无论是线性探测,还是二次探测,都没有从根本上解决哈希冲突的问题,都是在一定程度上采用抢占的方式回避了哈希冲突的问题。因此真正的哈希表不采用这种闭散列的方式设计,采用开散列的方式设计,也称为拉链法

开散列

闭散列的设计当发生哈希冲突的时候,采用抢占的方式,容易发生连锁反应。开散列的设计当发生哈希冲突时,采用拉链的方式,让一个位置存放一条链表,当发生哈希冲突的时候,将冲突的值链接在链表上即可,有效的避免了抢占的问题。例如这里有一个大小为16的哈希表,插入18和2,它们的哈希值都为2

哈希的思想及其应用_第7张图片

它们发生哈希冲突的时候,在下标为2的位置形成一个链表即可解决问题。当然链表的长度不能过长,过长会导致查找的效率下降。一般我们把每一条链表认为是一个桶,在开散列的哈希表中,每一个桶的大小就是它对应的那一条链表的元素个数。为了保证查找的效率,每一个桶的大小应该控制在2以内,这样才能保证O(1)的查找效率,为了达到这个效果,开散列的哈希表中所有桶中的元素个数之和不应该超过桶的数量,例如上面的图中有16个桶,那么这个哈希表中的元素个数就不应该超过16个,即负载因子α达到1的时候,就应该扩容,并重新计算旧的哈希表中的元素在新的哈希表中的哈希值,并将它们重新分配到新的哈希表。

开散列的哈希表重新分配:

哈希的思想及其应用_第8张图片

这种开散列的哈希表就是STL中unordered_set和unordered_map的底层结构。可以根据该哈希表的原理分析出STL中unordered_set和unordered_map某些接口的含义。它们都存在如下接口

哈希的思想及其应用_第9张图片

size_type bucket_count() const noexcept;
有多少个桶
size_type max_bucket_count() const noexcept;
桶的最大个数(取决于内存大小)
size_type bucket_size ( size_type n ) const;
第n个桶中的元素个数,例如bucket_size(0)表示第0个桶中含有的元素数量
size_type bucket ( const key_type& k ) const;
返回k在第几个桶,例如上图中元素2在第二个桶,则bucket(2)=2
float load_factor() const noexcept;
返回负载因子
float max_load_factor() const noexcept;
返回最大的负载因子,一般是1
void rehash( size_type n );
rehash是重新设置桶的数量为n。若原来哈希表中桶的数量为10rehash(9)则该函数不进行任何操作,rehash(20)是将桶的数量重新设置为20并且重新计算元素的hash值进行分配。rehash主要用于提前知道需要在哈希表中放多少个元素的情况,可减少扩容和重新分配元素的次数
void reserve ( size_type n );
void reserve(size_t n)
{
 //根据n自行设置桶的数量
 /*
	* 例如,知道接下来会在哈希表中插入1000个元素。
	* 就可以reserve(1000).reserve会根据1000+已有的元素数量
	* 判断是否需要进行rehash增加桶的数量
	*/
    if (n + _size <= table.size())
        return;
    rehash(n + _size);
}

位图

位图是运用哈希映射的思想处理海量数据的一种数据结构,例如给定一个文件,文件中存在40亿个无符号整数,这些整数没有排过序,要求快速判断某一个数是否在这40亿个数中 。

这样的问题难以使用unordered_set或set解决,因为内存大小有限,无法把40亿个整数存到一个红黑树中,这时就需要使用到位图这一种数据结构。由于unsigned int的范围是0~232-1,一共有232个连续的整数,因此可以考虑使用232个比特位来记录这些数是否出现。

哈希的思想及其应用_第10张图片

例如第0个bit位为1表示数字0存在,第1个bit位为0表示数字1不存在。但是由于我们申请内存的时候是按照字节分配的,1Byte=8bits,对于232个bit位,需要的内存空间大小为512MB,远小于直接使用set存储40亿个整数需要的内存。

位图的设计

template<size_t N>//N是要映射的数据的范围为0~N,不是映射的数据的个数
class bitset
{
public:
 bitset(){ table.resize(N/8+1);}
 //例如需要映射的数据范围是0~21,就需要3个字节(24个比特位),因此是N/8+1
 /*
 bitset的三个核心接口为set,reset,test
 set表示将某一个比特位设置为1,表示某个数存在
 unset是将某一个比特位设置为0,标识某一个数不存在
 test是查询某一个数是否存在
 */
 void set(size_t x)
 {
     int i=x/8;
     int j=x%8;
     //例如x=20,20对应table[2]的第4个比特位
     table[i]|=(1<<j);
     //将table[i]的第j个比特位设置为1
 }
 void reset(size_t x)
 {
     int i=x/8;
     int j=x%8;
     table[i]&=~(1<<j);
     //将x映射的那一个比特位设置为0
 }
 bool test(size_t x)
 {
     int i=x/8;
     int j=x%8;
     //x是否存在
     return table[i]&(1<<j);
 }
private:
 vector<char> table;
};

利用位图就可以解决40亿个无符号整数中某一个数是否存在的问题。

可以开一个位图,N=232-1,在把文件中的数据分批次读到内存中,然后使用位图的set接口。进行判断的时候,直接使用位图的test接口即可

位图的常见应用

给定100亿个整数,设计算法找到只出现一次的整数?

思路:可以考虑使用2个比特位表示1个数的状态,00表示这个数一次都没有出现,01表示这个数出现了1次,10表示这个数出现了2次及以上,这样在把位图设置完成以后在遍历进行test,就可以找出只出现一次的数字。也可以考虑使用2个普通位图解决这个问题

template<size_t N>
class MyBitMap
{
public:
	void set(size_t x)
     {
         if(bit1.test(x)==false&&bit2.test(x)==false)//x出现0次
             bit2.set(x);
         else if(bit1.test(x)==false&&bit2.test(x)==true)//x出现1次
         {
             bit1.set(x);
             bit2.reset(x);
         }
         else //x出现2次及以上
             return;
     }
   void reset(size_t x)
   {
       bit1.reset(x);
       bit2.reset(x);
   }
   int test(size_t x)
   {
       if(bit1.test(x)==false&&bit2.test(x)==false)
           return 0;//出现0次
       else if(bit1.test(x)==false&&bit2.test(x)==true)
           return 1;
       else
           return 2;//出现了2次及以上
   }
private:
 bitset<N> bit1;
 bitset<N> bit2;
};

给两个文件,分别有100亿个整数,我们只有1G内存,如何找到两个文件交集?

思路:给2个文件分别创建它们的位图,找它们位图的交集

1个文件有100亿个int,1G内存,设计算法找到出现次数不超过2次的所有整数

思路:使用2个位图,1个数映射2个比特位,00,01,10,11分别表示这1个数出现的次数为0,1,2,3+(3次及以上)

位图的缺陷:位图只能处理整数,对于结构体、字符串等就无法处理了

布隆过滤器

由于位图只能处理海量的整形数据,对于海量的结构体和字符串就无从下手了,为了解决这一问题,需要使用布隆过滤器,布隆过滤器的思想与位图一致,但是布隆过滤器是想办法让字符串或结构体映射不同的比特位。例如:

哈希的思想及其应用_第11张图片

不过因为字符串的数量是无限的,但是比特位是有限的,因此,无论多么精妙的哈希函数也无法保证2个字符串的哈希值一定不一样。为了减小这种概率,可以考虑将一个字符串经过3个哈希函数计算得到3个哈希值映射到3个比特位,例如:

哈希的思想及其应用_第12张图片

布隆过滤器就是基于该思路的一种数据结构。它的性质有

  1. 如果要寻找的元素经过哈希函数计算以后有一个哈希值对应的比特位为0,那么这个元素一定不存在

  2. 如果要寻找的元素经过哈希函数计算以后所有的哈希值对应的比特位都为1,那么这个元素可能存在,也可能不存在。即若所有的哈希值对应的比特位都是1,存在误判的可能性。原因如图:

    哈希的思想及其应用_第13张图片

为了减小误判率,可以考虑将一个元素映射更多的比特位,提供更多的哈希函数,同时将布隆过滤器开到足够大。一般为了确保误判率处于较低水平,布隆过滤器的长度m=(哈希函数的个数k*插入元素的个数n)/0.7。例如想要建立一个快速查询1000个元素的布隆过滤器,每一个元素提供2个哈希函数,那么布隆过滤器应该开的大小为2000/0.7=2857比特位≈358字节。

struct HashFunc1
{
    size_t operator()(const string& s)
    {
        unsigned int seed = 131; // 31 131 1313 13131 131313 etc..
        unsigned int hash = 0;
        int i = 0;
        while (i < s.size())
        {
            hash = hash * seed + s[i];
            i++;
        }
        return (hash & 0x7FFFFFFF);
    }
};
struct HashFunc2
{
    size_t operator()(const string& s)
    {
        unsigned int hash = 5381;
        int i = 0;
        while (i < s.size())
        {
            hash += (hash << 5) + s[i];
            i++;
        }
        return (hash & 0x7FFFFFFF);
    }
};
//N为元素个数
template<size_t N, class T = string, class Func1 = HashFunc1, class Func2 = HashFunc2>
class BloomFilter
{
public:
    void set(const T& x)
    {
        bit.set(hash1(x) % bitcount);
        bit.set(hash2(x) % bitcount);
    }
    bool test(const T& x)
    {
        return bit.test(hash1(x) % bitcount)&&bit.test(hash2(x) % bitcount);
    }
private:
    static const int bitcount = 2 * N / 0.7;
    bitset<bitcount> bit;
    Func1 hash1;
    Func2 hash2;
};

普通的布隆过滤器不支持删除接口,因为可能有1个比特位被多次映射,如果想要布隆过滤器支持删除,可以增减引用计数。

哈希切分

给一个超过100G大小的log.txt, log中存着IP地址, 设计算法找到出现次数最多的IP地址?如何找到top K的IP?

思路:题目要求得出ip的出现次数,而不是判断ip在或不在,因此,不能使用位图或布隆过滤器。应该考虑使用哈希切割的思想解决。

哈希的思想及其应用_第14张图片

采用如图操作,一样的ip一定在一个文件中,在把每一个小文件的内容分别加载到内存,用set统计出它们的出现次数即可。(由于每一个文件的大小不一样,若小文件过大可以将其继续进行哈希切分)

给两个文件,分别有100亿个query,我们只有1G内存,如何找到两个文件交集?分别给出精确算法和近似算法

近似算法:使用布隆过滤器存储其中一个文件的quary映射,将另一个文件的内容分批加载到内存在布隆过滤器中查找,如果找不到,一定不是交集,如果找到的,可能是交集(存在误判)。这种方法是近似的且无法实现去重

精确算法:采用哈希切分的思想。
哈希的思想及其应用_第15张图片

1_0.txt和2_0.txt的交集一定是文件1和文件2的交集,对应的小文件的交集一定是大文件的交集,小文件是可以加载到内存中使用map或unordered_map处理的。

你可能感兴趣的:(哈希算法,数据结构,散列表)