掌握哈希与位图

目录:

哈希概念

哈希冲突(碰撞)

哈系函数

哈希冲突解决 

          闭散列:

 开散列增容:

开散列和闭散列的比较

哈希表的改造

模板参数: 

增加迭代器:

增加通过key获取value

unordered_map:

位图

布隆过滤器 

布隆过滤器的提出:

布隆过滤器概念:

布隆过滤器的数据结构:

布隆过滤器的查找:

布隆过滤器的删除:

布隆过滤器的缺陷:

布隆过滤器的优点:

实战操作 


哈希概念

顺序结构以及平衡树中,元素的关键码与存储位置没有直接关联,因此在查找一个元素的时候,必须要进行关键码的多次比较,顺序查找的时间复杂度为O(n)平衡树中查找则为树的高度O(log2 H)。而哈希可以不经过任何的比较一次直接从表中找到要搜索的元素。 

那么我们就想要构造出一种数据结构,将该元素的储存位置和它的关键码中能够建立一种--映射的关系那么在查找的时候就可以很快的找到该元素,该方法及为哈希(散列)函数

 该数据集合中的关键码就是数据本身的值


哈希冲突(碰撞)

 对于两个数据元素关键字 19 和 9 按上图中的映射方式都会映射到下标为 9  的元素上,即 : 通过不同的关键字却映射出相同的哈希地址,我们称作哈希冲突或哈希碰撞。

那么发生哈希碰撞后该如何处理呢?


哈系函数

首先引起哈希冲突的原因可能是:哈希函数的设计不够合理 

哈希函数设计原则:

  • 哈希函数的定义域必须要包括所有的关键码,例如散列表允许有 m 个地址时,其值域必须在 0 ~ m - 1之间
  • 哈希函数计算出的地址能够均匀的分布在整个空间中,避免在某一区间堆积
  • 哈希函数应该比较简单

常见的哈系函数:

1. 直接定址法 --(常用) 

Hash(Key) = A* Key + B

优点:简单,均匀

缺点:需要事先知道关键字的分布情况

使用场景:适合查找比较小且连续的情况

2. 除留余数法 --(常用)

假设散列表允许的地址数(长度)为m , 那么取一个不大于 m ,但最接近m的质数作为p的除数,Hash(key) = key % p (p<=m) 。将关键码转化成哈希地址。

3. 平方取中法 -- (了解)

假设关键字为 1234,那么它的平方就是 1522756 ,取中间三位作为哈希地址

假设关键字为 4321,那么它的平方就是 18671041,取中间三位671或710作为哈希地址

平方取中法适用于:不知道关键字的分布,而位数又不是很大的情况下

注意:哈希函数设计的越精妙,产生冲突的概率越低,但是无法避免哈希冲突


哈希冲突解决 

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

闭散列:

也叫做开放定址法,当发生冲突的时,如果哈希表未被填满,说明哈希表中仍然有空的位置,那么就可以把key存放到冲突位置中的"下一个"空位置中去。

        1.线性探测:从发生冲突的位置开始,依次向后探测,直到找到个空的位置为止

 但在删除数据的时候不可以随便删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索,因此线性探测采用标记的伪删除法来删除一I个元素

// 注意:假如实现的哈希表中元素唯一,即key相同的元素不再进行插入
// 为了实现简单,此哈希表中我们将比较直接与元素绑定在一起
template<class K, class V>
class HashTable
{
    struct Elem
    {
        pair<K, V> _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<K, V>& val)
    {
    size_t hashAddr = HashFunc(key);
    while(_ht[hashAddr]._state != EMPTY)
    {
        if(_ht[hashAddr]._state == EXIST && _ht[hashAddr]._val.first== key)
        return false;

        hashAddr++;
        if(hashAddr == _ht.capacity())
        hashAddr = 0;
    }
    // 插入元素
    _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<K, V, HF>& ht);//这里省略

private:
size_t HashFunc(const K& key)
{
    return key % _ht.capacity();
}
private:
    vector<Elem> _ht;
    size_t _size;
};

Q: 那么什么时候该扩容呢?                

A:在散列表中有一个概念叫做负载因子,负载因子 X = 填入表中的元素个数 / 散列表的长度

X越大说明产生冲突的可能性越大,因此使用开放定址法的时候要保证X小于0.7~0.8

void CheckCapacity()
{
    if(_size * 10 / _ht.capacity() >= 7)
        {
        HashTable<K, V, HF> 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);

}

  有研究表明:当长度为质数且负载因子a不超过0.5时,新的表项一定能够被插入,而且任何一个位置都不会被探查两次。因此只要保证表中有一半的空位置,就不会存在装满的问题。

闭散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷。

开散列: 

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

 从图中可以看出,哈希桶中存放的都是发生哈希冲突的元素

template<class V>
struct HashBucketNode
{
    HashBucketNode(const V& data)
        : _pNext(nullptr), _data(data)
        {}
    HashBucketNode<V>* _pNext;
    V _data;
};
// 本文所实现的哈希桶中key是唯一的
template<class V>
class HashBucket
{
    typedef HashBucketNode<V> 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<V, HF>& ht;
~HashBucket();
private:
size_t HashFunc(const V& data)
{
return data%_ht.capacity();
}
private:
vector<PNode*> _ht;
size_t _size; // 哈希表中有效元素的个数
};

 开散列增容:

 桶的个数是一定的,随着元素不断插入,每个桶的元素个数不断增多,极端情况下,可能会导致一个桶中链表结点特别多,会影响性能。所以当总结点个数等于链表长度的时候,再插入结点就会导致哈希冲突,所以这时候应该扩容。

void _CheckCapacity()
{
    size_t bucketCount = BucketCount();
    if(_size == bucketCount)
    {
        HashBucket<V, HF> 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);
    }
}

思考:开散列只能储存key为整形的元素,其他类型怎么解决?

--> 哈希函数采用除留余数法的话,被模的key必须要为整形才可以处理,所以我们应该提供一个可以将key转化为整形的办法。

//类型为double||float时
template<class T>
class DefHashF
{
public:
    size_t operator()(const T& val)
    {
        return val;
    }
};
//类型为字符串时
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);
    }
};
//最后在哈希桶中的哈希函数就直接显示调用operator(),HF为模板,经过特化后会调用对应的HF()
template<class V, class HF>
class HashBucket
{
private:
    size_t HashFunc(const V& data)
    {
        return HF()(data.first)%_ht.capacity();
    }
};

开散列和闭散列的比较

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

哈希表的改造

模板参数: 

// K:关键码类型
// V:不同容器V的类型不同,如果是unordered_map,V代表一个键值对,如果是
// unordered_set,V 为 K
// KeyOfValue: 因为V的类型不同,通过value取key的方式就不同,详细见
//unordered_map/set的实现
// HF: 哈希函数仿函数对象类型,哈希函数使用除留余数法,需要将Key转换为整形数字才能
//取模
template<class K, class V, class KeyOfValue, class HF = DefHashF<T> >
class HashBucket;

K:关键字类型 

V:值,不同容器V的类型不同

KeyOfValue:因为V的类型不同,通过value取key的方式不同,找到V值

HF:哈希函数的仿函数对象类型,当Key不为整数时,可以将Key转化成整形数字


增加迭代器:

template <class K, class V, class KeyOfValue, class HF>
struct HBIterator
{
    typedef HashBucket<K, V, KeyOfValue, HF> HashBucket;
    typedef HashBucketNode<V>* PNode;
    typedef HBIterator<K, V, KeyOfValue, HF> Self;

    HBIterator(PNode pNode = nullptr, HashBucket* pHt = nullptr);

    Self& operator++()
    {
        // 当前迭代器所指节点后还有节点时直接取其下一个节点
        if (_pNode->_pNext)
        _pNode = _pNode->_pNext;
        else
        {
            // 找下一个不空的桶,返回该桶中第一个节点
            size_t bucketNo = _pHt->HashFunc(KeyOfValue()(_pNode->_data))+1;下一个桶的位置
            for (; bucketNo < _pHt->BucketCount(); ++bucketNo)要小于总桶的数量
            {
                if (_pNode == _pHt->_ht[bucketNo])
                    break;
            }
        }
        return *this;
}

Self operator++(int);
V& operator*();
V* operator->();
bool operator==(const Self& it) const;
bool operator!=(const Self& it) const;

PNode _pNode; // 当前迭代器关联的节点
HashBucket* _pHt; // 哈希桶--主要是为了找下一个空桶时候方便
};


增加通过key获取value

template<class K, class V, class KeyOfValue, class HF = DefHashF<T> >
class HashBucket
{
    friend HBIterator<K, V, KeyOfValue, HF>;//将上面实现的迭代器设置为友元函数

public:
    typedef HBIterator<K, V, KeyOfValue, HF> Iterator;

// 迭代器
Iterator Begin()
{
    size_t bucketNo = 0;
    for (; bucketNo < _ht.capacity(); ++bucketNo)
    {
        if (_ht[bucketNo])//找到哈希桶中不为空的第一个元素
            break;
    }
    if (bucketNo < _ht.capacity())//判断该元素是否在哈希桶内
        return Iterator(_ht[bucketNo], this);//返回该值
    else
        return Iterator(nullptr, this);//返回空值
}

Iterator End(){ return Iterator(nullptr, this);}
Iterator Find(const K& key);
Iterator Insert(const V& data);
Iterator Erase(const K& key);
// 为key的元素在桶中的个数
size_t Count(const K& key)
{
    if(Find(key) != End())
        return 1;
    return 0;
}
size_t BucketCount()const{ return _ht.capacity();}
size_t BucketSize(size_t bucketNo)
{
    size_t count = 0;
    PNode pCur = _ht[bucketNo];
    while(pCur)
    {
        count++;
        pCur = pCur->_pNext;
    }
    return count;
}
};

unordered_map:

  map和umorderde_map的区别: 

内部实现是否有序查找效率
unordered_map哈希表无序O(1)
map红黑树默认升序树的高度O(H)

 代码实现:

// unordered_map中存储的是pair<K, V>的键值对,K为key的类型,V为value的类型,HF哈希
函数类型
// unordered_map在实现时,只需将hashbucket中的接口重新封装即可

template<class K, class V, class HF = DefHashF<K>>
class unordered_map
{
    typedef pair<K, V> ValueType;
    typedef HashBucket<K, ValueType, KeyOfValue, HF> HT;
    // 通过key获取value的操作
    struct KeyOfValue
    {
        const K& operator()(const ValueType& data)//data是pair.first value是pair.second
            { return data.first;}
    };
public:
    typename typedef HT::Iterator iterator; //因为要对原有的哈希桶重新封装
public:
    unordered_map(): _ht()
    {}

    iterator begin(){ return _ht.Begin();}
    iterator end(){ return _ht.End();}


    size_t size()const{ return _ht.Size();}
    bool empty()const{return _ht.Empty();}

    V& operator[](const K& key)
    {
        return (*(_ht.InsertUnique(ValueType(key, V())).first)).second;
    }
    const V& operator[](const K& key)const;

    iterator find(const K& key){ return _ht.Find(key);}
    size_t count(const K& key){ return _ht.Count(key);}

    pair<iterator, bool> insert(const ValueType& valye)
        { return _ht.Insert(valye);}
    iterator erase(iterator position)
        { return _ht.Erase(position);}

    size_t bucket_count(){ return _ht.BucketCount();}
    size_t bucket_size(const K& key){ return _ht.BucketSize(key);}
private:
    HT _ht;
};

位图

所谓的位图,就是每一位用来存放某种状态,适用于海量数据,数据无重复的场景。通常是用来判断某个数据存不存在,就像注册账号时判断用户名是否重复一样。

Q:给你40亿个不重复的无符号整数且无序。如何判断一个数是否在其中

A:1.遍历一次,时间复杂度O(n)

   2.排序后二分查找,时间复杂度O(NlogN)+O(logN)

   3.利用位图解决

数据是否存在刚好是两种状态,那么可以用一个二进制位来存放这个状态,存在为1不存在为0

位图的应用:

1.快速查找某一个数据是否在一个集合中

2.排序加去重

3.求两个集合的交集,并集等

4.操作系统中的磁盘标记


布隆过滤器 

布隆过滤器的提出:

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

那么如何快速查找呢?

1.用哈希表储存用户记录,缺点:浪费空间

2.用位图存储用户记录,缺点:位图一般只能处理整形,如果编号是字符串的话,就无法处理了

3.将哈希和位图结合,即布隆过滤器

布隆过滤器概念:

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

布隆过滤器相比于传统的vector,list,map等数据结构,它更高效,占用空间更小,但缺点是返回结果是概率性的,而不是确切的

布隆过滤器的数据结构:

布隆过滤器是一个bit向量 或者是 一个bit数组

如果我们要映射一个值到布隆过滤器中,我们需要使用多个不同的哈希函数生成多个哈希值,并对每个哈希值指向的bit位 置为1 例如:像布隆过滤器中插入“baidu”

继续再插入一个值"tencent" 

        

 这时候我们要注意了“baidu”和“tencent”在下标为3的这个位置发生冲突了,也就是两者的哈希函数都有映射到3,。随着越来越多的插入,会有更多的冲突那么就会导致原本没有出现过的值会出现

布隆过滤器的查找:

当我们去查询“meituan”时候,哈希函数返回了1,2,4三个值结果发现2这个bit位上为0,所以可以说布隆过滤器中一定没有“meituan”,查询“baidu”的时候哈希函数返回1,3,8三个值,三个值对应的bit位都为1,说明“baidu”可能存在布隆过滤器中

布隆过滤器的删除:

布隆过滤器不支持直接删除,因为删除一个元素时,可能会影响其他元素(同一个bit位可能有多个key映射)

如果一定要支持删除,将布隆过滤器每个bit位拓展成一个计数器,插入元素时候给k计数加1,删除元素时给k个计数器减1,通过多占用几倍存储空间来实现删除操作

布隆过滤器的缺陷:

1.无法确定元素是否真的在布隆过滤器中,有误判率即存在假阳性(False Position)ps:补救办法建立一个白名单,存储可能会误判的数据

2.如果用计数方式删除可能会存在计数回绕问题

3.不能获取元素本身

布隆过滤器的优点:

1.查询元素可能存在的时间复杂度为O(k)k为哈希函数的个数,一般比较小与数据量大小无关

2.哈希函数之间相互没有关系,方便硬件并行运算

3.布隆过滤器本身不存储元素,有很强的保密性

4.在能够承受一定误判的情况下,布隆过滤器比其他的数据结构有很强的空间优势

5.数据量很大的时候,布隆过滤器可以表示全集,其他的数据结构就不行

6.使用同一组散列函数的布隆过滤器可以进行交,并,差运算  


实战操作 

Q1:给定一个超过100亿大小的log file中存着IP地址,设计算法找出出现次数最多的IP地址?

A1:首先考虑到100亿大小那么遍历和排序就先排除,剩下最合适的就是使用哈希桶的分桶

  

 

Q2:给定100亿个整数找出只出现一次的数

A2:同样可以使用哈希切割的方法,但是这里也可以使用位图来解决

可以使用bitset或者bitmap来实现。这样100亿个元素只需要200亿个bit位≈2384MB

 

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

分别给出精确算法和近似算法

A3:近似算法:使用布隆过滤器,1G内存差不多可以开70亿的位,再从第一个文件中读取query后一个接一个映射到布隆过滤器中,接着再从第二个文件中读取一个接一个在布隆过滤器中查询是否出现过,但是不一定准确,所以叫做近似算法

        精确算法:一样使用哈希切割,一个query大约60byte,100亿大约600G,那么先进行哈希切割成6000份,对第一个文件的query进行哈希得到key,然后利用除留余数法(%6000)分配到不同的子文件中。 切割完毕后第二个文件中的每一个query都进行哈希后得到key利用除留余数法判断落在哪一个子文件中,再将子文件加载到unordered_map中进行find。分割成6000块每一块文件约等于100M也符合题意

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Obto-

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值