哈希:探索快速的数据存储和搜索方法

哈希:探索快速的数据存储和搜索方法

哈希表作为一种高效的数据存储结构,可以使数据的存储位置与关键码之间建立一一映射的关系,从而加快元素的搜索速度。然而,哈希方法也面临着哈希冲突的问题,即不同的关键字通过相同的哈希函数计算出相同的哈希地址。如何处理哈希冲突成为了一个重要的问题。

本篇博客将介绍哈希的概念、哈希冲突的处理、常见的哈希函数设计原则和具体实现方法以及哈希思想的相关应用场景等内容。通过学习本篇博客,你将能够深入理解哈希表的原理,并且能够选择合适的哈希函数来解决哈希冲突。

如果你对哈希感兴趣,或者想了解如何设计一个合适的哈希函数来解决哈希冲突,那么这篇博客将为你提供一些有价值的参考和指导。让我们一起探索哈希的奥秘吧!

哈希的概念

顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即O(log2N),搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。
如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。
当向该结构中:

  • 插入元素
    根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放
  • 搜索元素
    对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功

该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表)

例如:数据集合{1,7,6,4,5,9};
哈希函数设置为:hash(key) = key % capacity; capacity为存储元素底层空间总的大小。

哈希:探索快速的数据存储和搜索方法_第1张图片

用该方法进行搜索不必进行多次关键码的比较,因此搜索的速度比较快
问题:按照上述哈希方式,向集合中插入元素44,会出现什么问题?

可以预见的是,假设不扩容,hash(44)=44%10=4,那么4的位置上已经有数字了,44放哪里呢?这就引出了哈希冲突。

哈希冲突

对于两个数据元素的关键字k_ik_j(i != j),有k_i != k_j,但有:Hash(k_i) ==Hash(k_j),

即:不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。

把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。
发生哈希冲突该如何处理呢?

这就要看是如何处理映射关系了,也就是哈希函数的设计

哈希函数

引起哈希冲突的一个原因可能是:哈希函数设计不够合理。
哈希函数设计原则

  • 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0到m-1之间
  • 哈希函数计算出来的地址能均匀分布在整个空间中
  • 哈希函数应该比较简单

常见哈希函数

  1. 直接定址法–(常用)
    取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B
    优点:简单、均匀、不存在哈希冲突
    缺点:需要事先知道关键字的分布情况
    使用场景:适合查找比较小且连续的情况

  2. 除留余数法–(常用)
    设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,
    按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址

    缺点:存在哈希冲突,重点解决哈希冲突

  3. 平方取中法–(了解)
    假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址;
    再比如关键字为4321,对它平方就是18671041,抽取中间的3位671(或710)作为哈希地址平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况

  4. 折叠法–(了解)
    折叠法是将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。
    折叠法适合事先不需要知道关键字的分布,适合关键字位数比较多的情况

  5. 随机数法–(了解)
    选择一个随机函数,取关键字的随机函数值为它的哈希地址,即H(key) = random(key),其中random为随机数函数。
    通常应用于关键字长度不等时采用此法

  6. 数学分析法–(了解)
    设有n个d位数,每一位可能有r种不同的符号,这r种不同的符号在各位上出现的频率不一定相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只有某几种符号经常出现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散
    列地址。例如:

哈希:探索快速的数据存储和搜索方法_第2张图片

假设要存储某家公司员工登记表,如果用手机号作为关键字,那么极有可能前7位都是 相同的,那么我们可以选择后面的四位作为散列地址,如果这样的抽取工作还容易出现 冲突,还可以对抽取出来的数字进行反转(如1234改成4321)、右环位移(如1234改成4123)、左环移位、前两数与后两数叠加(如1234改成12+34=46)等方法。
数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布较均匀的情况
注意:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突

哈希冲突的解决方法

解决哈希冲突两种常见的方法是:闭散列开散列

闭散列

闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。那如何寻找下一个空位置
呢?

有两种方式:线性探测和二次探测

线性探测

线性探测的概念:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。

举个栗子:

就拿我们之前说过的这张图来说,现在需要插入元素44,先通过哈希函数计算哈希地址,hashAddr为4,因此44理论上应该插在该位置,但是该位置已经放了值为4的元素,即发生哈希冲突。使用线性探测法该怎么做呢?从4开始依次往后探测,直到找到下一个空位置为止。

哈希:探索快速的数据存储和搜索方法_第3张图片

  • 插入

    • 通过哈希函数获取待插入元素在哈希表中的位置
    • 如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素
  • 删除

    • 采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。比如删除元素4,如果直接删除掉,44查找起来可能会受影响。因此线性探测采用标记的伪删除法来删除一个元素。

    • // 哈希表每个空间给个标记
      // EMPTY此位置空, EXIST此位置已经有元素, DELETE元素已经删除
      enum State{EMPTY, EXIST, DELETE};
      
  • 线性探测的实现

    // 注意:假如实现的哈希表中元素唯一,即key相同的元素不再进行插入
    // 为了实现简单,此哈希表中我们将比较直接与元素绑定在一起
    template
        class HashTable
        {
            struct Elem
            { 
                pair _val;
                State _state;
            };
    
            public:
            HashTable(size_t capacity = 3)
                : _ht(capacity), _size(0)
                {
                    for(size_t i = 0; i < capacity; ++i)
                        _ht[i]._state = EMPTY;
                }
    
            bool Insert(const pair& val)
            {
                // 检测哈希表底层空间是否充足
                // _CheckCapacity();
                size_t hashAddr = HashFunc(key);
                // size_t startAddr = hashAddr;
                while(_ht[hashAddr]._state != EMPTY)
                {
                    if(_ht[hashAddr]._state == EXIST && _ht[hashAddr]._val.first== key)
                        return false;
    
                    hashAddr++;
                    if(hashAddr == _ht.capacity())
                        hashAddr = 0;
                    /*
         // 转一圈也没有找到,注意:动态哈希表,该种情况可以不用考虑,哈希表中元
    素个数到达一定的数量,哈希冲突概率会增大,需要扩容来降低哈希冲突,因此哈希表中元素是
    不会存满的
         if(hashAddr == startAddr)
           return false;
         */
                }
    
                // 插入元素
                _ht[hashAddr]._state = EXIST;
                _ht[hashAddr]._val = val;
                _size++;
                return true;
            }
            int Find(const K& key)
            {
                size_t hashAddr = HashFunc(key);
                while(_ht[hashAddr]._state != EMPTY)
                {
                    if(_ht[hashAddr]._state == EXIST && _ht[hashAddr]._val.first== key)
                        return hashAddr;
                    hashAddr++;
                }
                return hashAddr;
            }
            bool Erase(const K& key)
            {
                int index = Find(key);
                if(-1 != index)
                {
                    _ht[index]._state = DELETE;
                    _size++;
                    return true;
                }
                return false;
            }
            size_t Size()const;
            bool Empty() const;  
            void Swap(HashTable& ht);
            private:
            size_t HashFunc(const K& key)
            {
                return key % _ht.capacity();
            }
            private:
            vector _ht;
            size_t _size;
        };
    

    思考:哈希表什么情况下进行扩容?如何扩容?

    散列表的载荷因子定义为: a =填入表中的元素个数/散列表的长度
    a是散列表装满程度的标志因子。由于表长是定值,a与“填入表中的元素个数”成正比,所以,a越大,表明填入表中的元素越多,产生冲突的可能性就越大;反之,a越小,标明填入表中的元素越少,产生冲突的可能性就越小。实际上,散列表的平均查找长度是载荷因子a的函数,只是不同处理冲突的方法有不同的函数。

    对于开放定址法,荷载因子是特别重要因素,应严格限制在0. 7-0. 8以下。超过0. 8,查表时的CPU缓存不命中(cache missing)按照指数曲线上升。因此,一些采用开放定址法的hash库,如Java的 系统库限制了荷载因子为0.75,超过此值将resize散列表。

    void CheckCapacity()
    {
      if(_size * 10 / _ht.capacity() >= 7)
     {
        HashTable newHt(GetNextPrime(ht.capacity));
        for(size_t i = 0; i < _ht.capacity(); ++i)
       {
          if(_ht[i]._state == EXIST)
            newHt.Insert(_ht[i]._val);
       }
       
        Swap(newHt);
     }
    }
    

    线性探测优点:实现非常简单,
    线性探测缺点:一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”,即:不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低。如何缓解呢?

二次探测

线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为:H_i = (H_0 + i^2 )% m, 或者:H_i= (H_0 - i^2)% m。其中:i =1,2,3…, H_0是通过散列函数Hash(x)对元素的关键码 key 进行计算得到的位置,m是表的大小。
对于前面中如果要插入44,产生冲突,使用解决后的情况为:

哈希:探索快速的数据存储和搜索方法_第4张图片

​ 研究表明:当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容。
因此:比散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷。

那么有办法解决吗?

开散列

开散列的概念

开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。

哈希:探索快速的数据存储和搜索方法_第5张图片
哈希:探索快速的数据存储和搜索方法_第6张图片

​ 从上图可以看出,开散列中每个桶中放的都是发生哈希冲突的元素。

开散列实现

template
    struct HashBucketNode
    {
        HashBucketNode(const V& data)
            : _pNext(nullptr), _data(data)
            {}
        HashBucketNode* _pNext;
        V _data;
    };
// 本文所实现的哈希桶中key是唯一的
template
    class HashBucket
    {
        typedef HashBucketNode Node;
        typedef Node* PNode;
        public:
        HashBucket(size_t capacity = 3)
            : _size(0)
            { 
                _ht.resize(GetNextPrime(capacity), nullptr);
            }

        // 哈希桶中的元素不能重复
        PNode* Insert(const V& data)
        {
            // 确认是否需要扩容。。。
            // _CheckCapacity();

            // 1. 计算元素所在的桶号
            size_t bucketNo = HashFunc(data);

            // 2. 检测该元素是否在桶中
            PNode pCur = _ht[bucketNo];
            while(pCur)
            {
                if(pCur->_data == data)
                    return pCur;

                pCur = pCur->_pNext;
            }

            // 3. 插入新元素
            pCur = new Node(data);
            pCur->_pNext = _ht[bucketNo];
            _ht[bucketNo] = pCur;
            _size++;
            return pCur;
        }

        // 删除哈希桶中为data的元素(data不会重复),返回删除元素的下一个节点
        PNode* Erase(const V& data)
        {
            size_t bucketNo = HashFunc(data);
            PNode pCur = _ht[bucketNo];
            PNode pPrev = nullptr, pRet = nullptr;

            while(pCur)
            {
                if(pCur->_data == data)
                {
                    if(pCur == _ht[bucketNo])
                        _ht[bucketNo] = pCur->_pNext;
                    else
                        pPrev->_pNext = pCur->_pNext;

                    pRet = pCur->_pNext;
                    delete pCur;
                    _size--;
                    return pRet;
                }
            }

            return nullptr;
        }

        PNode* Find(const V& data);
        size_t Size()const;
        bool Empty()const;
        void Clear();
        bool BucketCount()const;
        void Swap(HashBucket& ht);
        ~HashBucket();
        private:
        size_t HashFunc(const V& data)
        {
            return data%_ht.capacity();
        }
        private:
        vector _ht;
        size_t _size;    // 哈希表中有效元素的个数
    };

开散列增容

桶的个数是一定的,随着元素的不断插入,每个桶中元素的个数不断增多,极端情况下,可能会导致一个桶中链表节点非常多,会影响的哈希表的性能,因此在一定条件下需要对哈希表进行增容,那该条件怎么确认呢?

开散列最好的情况是:每个哈希桶中刚好挂一个节点,再继续插入元素时,每一次都会发生哈希冲突,因此,在元素个数刚好等于桶的个数时,可以给哈希表增容。

void _CheckCapacity()
{
 size_t bucketCount = BucketCount();
 if(_size == bucketCount)
 {
     HashBucket newHt(bucketCount);
     for(size_t bucketIdx = 0; bucketIdx < bucketCount; ++bucketIdx)
     {
         PNode pCur = _ht[bucketIdx];
         while(pCur)
         {
             // 将该节点从原哈希表中拆出来
             _ht[bucketIdx] = pCur->_pNext;

             // 将该节点插入到新哈希表中
             size_t bucketNo = newHt.HashFunc(pCur->_data);
             pCur->_pNext = newHt._ht[bucketNo];
             newHt._ht[bucketNo] = pCur;
             pCur = _ht[bucketIdx];
         }
     }

     newHt._size = _size;
     this->Swap(newHt);
 }
}

开散列的思考

  1. 只能存储key为整形的元素,其他类型怎么解决?

    // 哈希函数采用处理余数法,被模的key必须要为整形才可以处理,此处提供将key转化为整形的方法
    // 整形数据不需要转化
    template
        class DefHashF
        {
            public:
            size_t operator()(const T& val)
            {
                return val;
            }
        };
    // key为字符串类型,需要将其转化为整形
    class Str2Int
    {
        public:
        size_t operator()(const string& s)
        {
            const char* str = s.c_str();
            unsigned int seed = 131; // 31 131 1313 13131 131313
            unsigned int hash = 0;
            while (*str)
            {
                hash = hash * seed + (*str++);
            }
    
            return (hash & 0x7FFFFFFF);
        }
    };
    // 为了实现简单,此哈希表中我们将比较直接与元素绑定在一起
    template
        class HashBucket
        {
            // ……
            private:
            size_t HashFunc(const V& data)
            {
                return HF()(data.first)%_ht.capacity();
            }
        };
    
    1. 除留余数法,最好模一个素数,如何每次快速取一个类似两倍关系的素数?

      size_t GetNextPrime(size_t prime)
      {
          const int PRIMECOUNT = 28;
          static const size_t primeList[PRIMECOUNT] =
          {
              53, 		97, 		193, 		389, 		769,
              1543, 		3079, 		6151, 		12289, 		24593,
              49157, 		98317, 		196613, 	393241, 	786433,
              1572869, 	3145739, 	6291469, 	12582917,	25165843,
              50331653, 	100663319, 	201326611, 	402653189,	805306457,
              1610612741, 3221225473, 4294967291
          };
          size_t i = 0;
          for (; i < PRIMECOUNT; ++i)
          {
              if (primeList[i] > prime)
                  return primeList[i];
          }
          return primeList[i];
      }
      

    更加具体的描述请参考这篇博客☞字符串哈希算法

开散列与闭散列比较

应用链地址法处理溢出,需要增设链接指针,似乎增加了存储开销。

事实上:
由于开地址法必须保持大量的空闲空间以确保搜索效率,如二次探查法要求装载因子a <=0.7,而表项所占空间又比指针大的多,所以使用链地址法反而比开地址法节省存储空间。

哈希的应用

位图

所谓位图,就是用每一位来存放某种状态,适用于海量数据,数据无重复的场景。通常是用来判断某个数据存不存在的。

位图的应用

应用一

给40亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在这40亿个数中。【腾讯】

怎么办?

先分析一下题目,1G=1024MB=1024*1024KB=1024*1024*1024Byte约等于10亿Byte,40亿个不重复的无符号整数占用空间约等于15~16G

方式一:搜索二叉树或者哈希表,但是数据量太大了,虽然时间复杂度是O(n),但是内存存不下

方式二:外排序,再利用二分查找,和前面一样,数据太大,只能放在磁盘上,不好支持二分查找,效率低

方式三:位图,直接定值法,一个比特位映射标记值,1就是在,0就是不在

我们直接使用最小字节的数据结构:char为一个单位

哈希:探索快速的数据存储和搜索方法_第7张图片

怎么让数据与位置对应上呢?

设计一个函数

思路:

让它除8就知道在哪个char里面,对8取余就知道在第几位

然后想让对应的位置取1,那就需要或等于(1<,1左移J个位置,利用或运算的原理,或1均为1

void set(size_t x)
{
    size_t i=x/8;
    size_t j=x%8;
    
    _byt[i] | = (1<

那怎么删除呢?

同样找到那个位置,将它置为0,1左移J个位置后取反,再利用与等的性质

void reset(size_t x)
{
    size_t i=x/8;
    size_t j=x%8;

    _byt[i] & = ~(1<

怎么查找数据是否存在?

找到那个位置,让它与(1<

void test(size_t x)
{
    size_t i=x/8;
    size_t j=x%8;

    return _byt[i]&(1<
应用二
  1. 给定100亿个整数,设计算法找到只出现一次的整数?

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

我们完全可以在前一题的基础上进行修改,我们可以用两个比特位来表示状态

可以只用一个数组

哈希:探索快速的数据存储和搜索方法_第8张图片

0次->00

1 次->0 1

2次->1 0

3次及以上->11

但是太麻烦了,不妨使用两个数组,一一对应

哈希:探索快速的数据存储和搜索方法_第9张图片

代码实现:

template
class bitset
{
    public:
    bitset()
    {
        _bits.resize(N/8+1, 0);
    }

    void set(size_t x)
    {
        size_t i = x / 8;
        size_t j = x % 8;

        _bits[i] |= (1 << j);
    }

    void reset(size_t x)
    {
        size_t i = x / 8;
        size_t j = x % 8;

        _bits[i] &= ~(1 << j);
    }

    bool test(size_t x)
    {
        size_t i = x / 8;
        size_t j = x % 8;


        return _bits[i] & (1 << j);
    }

    private:
    vector _bits;
};
template
class twobitset
{
    public:
    void set(size_t x)
    {
        bool inset1 = _bs1.test(x);//查询是否为1
        bool inset2 = _bs2.test(x);

        // 00
        if (inset1 == false && inset2 == false)
        {
            // -> 01
            _bs2.set(x);
        }
        else if (inset1 == false && inset2 == true)
        {
            // ->10
            _bs1.set(x);
            _bs2.reset(x);
        }
        //第二个问题是同样的道理,只要加上10-》11的条件判断即可,最后需要判断不超过两次就是00和01即可
        /*else if (inset1 == true && inset2 == false)
        {
            // ->11
            _bs1.set(x);
            _bs2.set(x);
        }*/
    }

    void print_once_num()
    {
        for (size_t i = 0; i < N; ++i)
        {
            if (_bs1.test(i) == false && _bs2.test(i) == true)
                //如果是01的状态说明只出现一次
            {
                cout << i << endl;
            }
        }
    }

    private:
    bitset _bs1;
    bitset _bs2;
};
应用三

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

和之前一样的思路,只要设计两个数组,然后相互映射均为1的位置就是交集

哈希:探索快速的数据存储和搜索方法_第10张图片

位图特点
  1. 快、节省空间,使用直接定值法不存在冲突
  2. 相对局限,只能映射处理整形

布隆过滤器

布隆过滤器提出

我们在使用新闻客户端看新闻时,它会给我们不停地推荐新的内容,它每次推荐时要去重,去掉那些已经看过的内容。问题来了,新闻客户端推荐系统如何实现推送去重的? 用服务器记录了用户看过的所有历史记录,当推荐系统推荐新闻时会从每个用户的历史记录里进行筛选,过滤掉那些已经存在的记录。 如何快速查找呢?

  1. 用哈希表存储用户记录,缺点:浪费空间
  2. 用位图存储用户记录,缺点:位图一般只能处理整形,如果内容编号是字符串,就无法处理了。
  3. 将哈希与位图结合,即布隆过滤器

布隆过滤器概念

布隆过滤器是由布隆(Burton Howard Bloom)在1970年提出的 一种紧凑型的、比较巧妙的概率型数据结构,特点是高效地插入和查询可以用来告诉你 “某样东西一定不存在或者可能存在”,它是用多个哈希函数,将一个数据映射到位图结构中。此种方式不仅可以提升查询效率,也可以节省大量的内存空间。

哈希:探索快速的数据存储和搜索方法_第11张图片

布隆过滤器的插入

哈希:探索快速的数据存储和搜索方法_第12张图片

向布隆过滤器中插入:“baidu”

哈希:探索快速的数据存储和搜索方法_第13张图片

哈希:探索快速的数据存储和搜索方法_第14张图片

像这样就让一个val对应多个位置就可以减少冲突的概率

但是怎么计算这个值呢?

有很多种方式,这里只举出常见的哈希函数

struct BKDRHash
{
    size_t operator()(const string& s)
    {
        // BKDR
        size_t value = 0;
        for (auto ch : s)
        {
            value *= 31;
            value += ch;
        }
        return value;
    }
};
struct APHash
{
    size_t operator()(const string& s)
    {
        size_t hash = 0;
        for (long i = 0; i < s.size(); i++)
        {
            if ((i & 1) == 0)
            {
                hash ^= ((hash << 7) ^ s[i] ^ (hash >> 3));
            }
            else
            {
                hash ^= (~((hash << 11) ^ s[i] ^ (hash >> 5)));
            }
        }
        return hash;
    }
};
struct DJBHash
{
    size_t operator()(const string& s)
    {
        size_t hash = 5381;
        for (auto ch : s)
        {
            hash += (hash << 5) + ch;
        }
        return hash;
    }
};
template
    class BloomFilter
    {
        public:
        void Set(const K& key)
        {
            size_t len = X*N;
            size_t index1 = HashFunc1()(key) % len;
            size_t index2 = HashFunc2()(key) % len;
            size_t index3 = HashFunc3()(key) % len;
            /* 	cout << index1 << endl;
				cout << index2 << endl;
				cout << index3 << endl< _bs;
    };

布隆过滤器的查找

布隆过滤器的思想是将一个元素用多个哈希函数映射到一个位图中,因此被映射到的位置的比特位一定为1。所以可以按照以下方式进行查找:分别计算每个哈希值对应的比特位置存储的是否为零,只要有一个为零,代表该元素一定不在哈希表中,否则可能在哈希表中。
注意:布隆过滤器如果说某个元素不存在时,该元素一定不存在,如果该元素存在时,该元素可能存在,因为有些哈希函数存在一定的误判。
比如:在布隆过滤器中查找"alibaba"时,假设3个哈希函数计算的哈希值为:1、3、7,刚好和其他元素的比特位重叠,此时布隆过滤器告诉该元素存在,但实该元素是不存在的。

布隆过滤器删除

布隆过滤器不能直接支持删除工作,因为在删除一个元素时,可能会影响其他元素。

比如:删除上图中"tencent"元素,如果直接将该元素所对应的二进制比特位置0,“baidu”元素也被删除了,因为这两个元素在多个哈希函数计算出的比特位上刚好有重叠。

一种支持删除的方法:将布隆过滤器中的每个比特位扩展成一个小的计数器,插入元素时给k个计数器(k个哈希函数计算出的哈希地址)加一,删除元素时,给k个计数器减一,通过多占用几存储空间的代价来增加删除操作。

图示:

哈希:探索快速的数据存储和搜索方法_第15张图片

缺陷:

  1. 无法确认元素是否真正在布隆过滤器中
  2. 存在计数回绕

布隆过滤器优点

  1. 增加和查询元素的时间复杂度为:O(K), (K为哈希函数的个数,一般比较小),与数据量大小无关
  2. 哈希函数相互之间没有关系,方便硬件并行运算
  3. 布隆过滤器不需要存储元素本身,在某些对保密要求比较严格的场合有很大优势
  4. 在能够承受一定的误判时,布隆过滤器比其他数据结构有这很大的空间优势
  5. 数据量很大时,布隆过滤器可以表示全集,其他数据结构不能
  6. 使用同一组散列函数的布隆过滤器可以进行交、并、差运算

布隆过滤器缺陷

  1. 有误判率,即存在假阳性(False Position),即不能准确判断元素是否在集合中(补救方法:再建立一个白名单,存储可能会误判的数据)
  2. 不能获取元素本身
  3. 一般情况下不能从布隆过滤器中删除元素
  4. 如果采用计数方式删除,可能会存在计数回绕问题

布隆过滤器的应用

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

前面算过1G约等于10亿字节

假设每个query 30byte,100亿query需要多少空间?->3000亿byte约等于300G

假设两个文件叫A和B

依次读取文件A/B中query,i=Hash(query)% 1000,这个query就进入Ai/Bi小文件

然后对编号相同的就可以开始找交集,放到内存的两个set中即可。

为什么A0和B0找,A1和B1找,平均切就不行?

因为使用的相同的哈希函数,A、B中编号相同的,一定进入相同编号的小文件。

哈希:探索快速的数据存储和搜索方法_第16张图片

有木有可能某个小文件加载不进内存?某个小文件太大了,有可能,可以对小文件重新进行上面步骤,当成子问题处理就可以,但是要换个哈希函数

哈希切分

给一个超过100G大小的log file, log中存着IP地址, 设计算法找到出现次数最多的IP地址?与上题条件相同,如何找到top K的IP?

找到次数最多的IP地址和之前讲过的思路类似

读取每个IP,先切分为500份,去算出每个IP的对应的哈希值:i=Hash(ip)%500,这个IP进入第i个小文件

关键点:相同IP的地址都在一个小文件中

依次使用map对每个小文件统计次数

topk,建一个K个值为的小堆

本文由mdnice多平台发布

你可能感兴趣的:(C++进阶,后端,c++,数据结构,哈希算法,散列表,青少年编程)