【C++】哈希思想

发布于:2024-04-27 ⋅ 阅读:(25) ⋅ 点赞:(0)

目录

哈希介绍:

一,位图

1-1,位图的认识

1-2,位图的简单实现

1-3,位图的应用

二,布隆过滤器

2-1,布隆过滤器的认识

2-2,布隆过滤器的简单实现

2-3,布隆过滤器的应用

三,哈希应用海量数据总结


哈希介绍:

        哈希/散列是一种思想和技术,而不是一个具体的容器。哈希思想是一种将元素与其存储位置建立关联的方法。具体来说,哈希思想是通过一个特定的函数(哈希函数)将元素(键值)映射到一个确定的存储位置(哈希值)。这样,当我们需要查找某个元素时,就可以直接根据这个元素对应的哈希值进行查找,而无需遍历整个数据结构。哈希表(或称为散列表)只是根据哈希思想实现的一种具体的数据结构或容器,哈希应用还在其它方面上运用,比如位图,布隆过滤器等。  


一,位图

1-1,位图的认识

        首先我们来观察一道面试题:给40亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在 这40亿个数中。【腾讯】

        解决以上问题有许多不同的方法,大致分为:1,遍历,时间复杂度O(N)    2,排序,时间复杂度O(NlogN),然后利用二分查找: logN。这两种方法在时间效率上虽可行,但是空间上却存在致命的问题。我们来做详细分析问题。

        我们先来观看空间大小之间的转换。

                1G(吉字节)等于2^32MB,1024^3字节,即1G = 1,073,741,824B。

                1MB(兆字节)等于2^32KB,即1024KB(千字节),即1MB = 1024KB。

                1KB(千字节)等于2^32B,即1024B(字节),即1KB = 1024B。

        数据空间计算时,空间换算可大致约计算为1G = 10^3MB = 10^6KB = 10^9B(10亿B)。1个无符号整数占4字节空间,40亿个不重复的无符号整数共占160亿字节空间,1G约等于10亿字节,160亿数据约等于16G空间大小。当使用以上方法时,普遍计算机上空间根本不够存储,因此需要考虑其它方法。

位图解决

        位图是一种使用二进制位(0或1)来表示某种状态的数据结构,适用于海量数据、数据无重复的场景。位图运用的思想是哈希思想,采取的是直接定值法存储。通常,位图是用来在海量数据中判断某个数据存不存在的,处理海量数据时基本都要运用位图思想。

        数据是否在给定的整形数据中,结果强调的是在或者不在,刚好是两种状态,那么可以使用计算机中最小单位来进行存储此状态,即使用一个二进制比特位来代表数据是否存在的信息,如果二进制比特位为1,代表存在,为0代表不存在。         

        注意:存储时这里是以位单位来存储数据是否存在的状态,存储状态时采用的又是直接定值法,而无符号整数的范围是[0,2^32 - 1],因此,开辟位空间大小的个数与数据大小无关,只跟数据范围有关,即开辟最大位空间是2^32 - 1 + 1 = 2^32,对应空间大小是0.5G。(0.5G=512MB=2^30*8/2b=2^32)。一旦存在超出此范围的数据量必然有大量的重复数据,如:若是将40亿个数据改为100亿个数据其中必然有大量重复的数据。总的来说,位图开辟空间时只根据数据范围开辟,数据的个数与开辟空间的数量无关,与数据大小范围有关,因为这里使用的是直接定址法,数据的大小对应数据的存储位置。如下图。

        C++标准库中的bitset容器可以被视为一种位图的实现。它提供了一种方便且高效的工具来处理位级别的数据,在bitset中,每一位都代表一个布尔值(true或false),可以用来表示各种信息或状态。bitset容器使用链接文档如下,这里不在说明。

        bitset使用链接:bitset位图使用大全

1-2,位图的简单实现

        通过以上分析,位图是以位单位开辟空间的,数据类型中若不使用位段是没有以位为单位进行空间存储的,因此,这里可使用常用类型int,char,dounle等为存储单元进行位的划分而存储状态。其次这里最好不要直接一次开辟无符号类型最大数据的空间比特位,即2^32次方(42亿多)。因为有时存储的数据可能并没有太大,直接开辟最大空间太浪费数据,除非数据量太大或有预感出现数值太大的数据才采用开辟大空间比特位。这里最好使用非类型模板参数来控制开辟空间的大小。

//开辟n位空间(此空间的大小是根据数据的最大值开辟的,单位是位)
template <size_t n>
class bitset
{
public:
    bitset()
    {
        _bitset.resize(n / 8 + 1, 0); //开辟空间要向上开辟,这里直接暴力加一,即向上开辟8位空间
    }

    //把x映射的位标记成1
    void set(const size_t x)
    {
        size_t i = x / 8;
        size_t j = x % 8;
        _bitset[i] |= (1 << j);
    }

    //把x映射的位标记成0
    void reset(const size_t x)
    {
        size_t i = x / 8;
        size_t j = x % 8;
        _bitset[i] &= ~(1 << j);
    }

    //测试数据x是否存在
    bool test(const size_t x)
    {
        size_t i = x / 8;
        size_t j = x % 8;
        return _bitset[i] & (1 << j);
    }
private:
    vector<char> _bitset;  //这里采取char型,一单元可存储8位(采用int型一单元存储32位)
};

测试样例:

void test_bitset()
{
    bitset<100> bs1;

    //无符号整数最大数据2^32 - 1,测试数据时应开辟2^32位空间,防止数据大小过大,如下
    /*bitset<-1> bs2;
    bitset<UINT_MAX> bs3;
    bitset<0xffffffff> bs4;*/

    bs1.set(50);
    bs1.set(30);
    bs1.set(90);

    for (size_t i = 0; i < 100; i++)
    {
        if (bs1.test(i))
        {
            cout << i << "->" << "在" << endl;
        }
        else
        {
            cout << i << "->" << "不在" << endl;
        }
    }
    bs1.reset(90);
    bs1.set(91);
    cout << endl << endl;

    for (size_t i = 0; i < 100; i++)
    {
        if (bs1.test(i))
        {
            cout << i << "->" << "在" << endl;
        }
        else
        {
            cout << i << "->" << "不在" << endl;
        }
    }
}

1-3,位图的应用

        位图存储的只是不重复数据的状态,使用于海量数据,且位图的局限性很强,只能解决整型数据,无法解决非整型数据,因此位图一般运用于海量数据中的判断问题,如以下:

        1,快速查找某个数据是否在一个集合中
        2,排序 + 去重
        3,求两个集合的交集、并集等
        4,操作系统中磁盘块标记

        下面我们来运用位图解决以下问题:

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

        思路:设计两个位图存储100亿个数据,两个位图中标记共有00、01、10、11四种状态。00代表一次都没有出现,01代表只出现了一次,10代表出现了2次,11代表出现了3次及三次以上。在以上封装位图的基础上进行以下算法封装,如下:

template <size_t n>
class two_bitset
{
public:
    void set(const size_t x)
    {
        if (_bitset1.test(x) == false)
        {
            _bitset1.set(x);
        }
        else
        {
            _bitset2.set(x);
        }
    }
    void reset(const size_t x)
    {
        if (_bitset1.test(x) == true)
        {
            _bitset1.reset(x);
        }
        if (_bitset1.test(x) == false && _bitset2.test(x) == true)
        {
            _bitset1.set(x);
            _bitset2.reset(x);
        }
    }
    //以下算法是统计具体出现的次数
    /*int test(const size_t x)
    {
        if (!_bitset1.test(x) && !_bitset2.test(x))
        {
            return 0;
        }
        else if (_bitset1.test(x) == true && _bitset2.test(x) == false)
        {
            return 1;
        }
        else if (_bitset1.test(x) == false && _bitset2.test(x) == true)
        {
            return 2;
        }
        else
        {
            return 3;
        }
    }*/

    bool test(const size_t x)
    {
        if (_bitset1.test(x) == true && _bitset2.test(x) == false)
        {
            return true;
        }
        return false;
    }
private:
    bitset<n> _bitset1; //位图1
    bitset<n> _bitset2; //位图2
};

测试样例:

void test_bitset2()
{
    int a[] = { 5,7,9,2,5,99,5,5,7,5,3,9,2,55,1,5,6 };
    two_bitset<100> bs;
    for (auto e : a)
    {
        bs.set(e);
    }

    for (size_t i = 0; i < 100; i++)
    {
        //cout << i << "->" << bs.test(i) << endl;
        //输出只出现一次的数据

        if (bs.test(i))
        {
            cout << i << endl;
        }
    }
}

扩展延伸:        

        以上空间需求最大是1G,若是这里条件限制空间只有512MB甚至更小该如何呢?若空间严格限制,可以分次数开辟空间进行操作,比如空间只有512MB,数据大小范围又是[0,2^32 - 1],我们可分两次统计。第一次只统计前一半的数据,即[0,2^31)。第二次统计后一半大小的数据,即[2^31,2^32 - 1],不过由于使用直接定址法时空间不够存储,所以第二次统计时读取数据x - 2^31进行眏射统计。分次数操作虽然可以满足空间内存上的限制,但是需注意每次运行完后的数据以及相关占用内存的都会被清除,下一次的调用不能与上一次数据相关联。运用时一定要思考此种方法是否可行。

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

        思路:代码还是问题一中的代码,1G内存刚好够开辟最大的两个位图。分别将两个文件中数据存储在两个位图中,然后直接统计两个位图状态为11的数据。

测试代码:

void test_bitset3()
{
    int a1[] = { 5,7,9,2,5,99,5,5,7,5,3,9,2,55,1,5,6 };
    int a2[] = { 5,3,5,99,6,99,33,66 };

    bitset<100> bs1;
    bitset<100> bs2;

    for (auto e : a1)
    {
        bs1.set(e);
    }

    for (auto e : a2)
    {
        bs2.set(e);
    }

    //查找两堆存储在位图中的数据的交集
    for (size_t i = 0; i < 100; i++)
    {
        if (bs1.test(i) && bs2.test(i))
        {
            cout << i << endl;
        }
    }
}

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

        思路:与问题二一样,只不过这里只需统计状态为00、01、11的数据。

测试样例:

void test_bitset3()
{
    int a1[] = { 5,7,9,2,5,99,5,5,7,5,3,9,2,55,1,5,6 };
    int a2[] = { 5,3,5,99,6,99,33,66 };

    bitset<100> bs1;
    bitset<100> bs2;

    for (auto e : a1)
    {
        bs1.set(e);
    }

    for (auto e : a2)
    {
        bs2.set(e);
    }

    //查找出现次数不超过2次的所有整数
    for (size_t i = 0; i < 100; i++)
    {
        if (!bs1.test(i) || !bs2.test(i))
        {
            cout << i << " ";
        }
    }
    cout << endl;
}


二,布隆过滤器

2-1,布隆过滤器的认识

        布隆过滤器和位图具有相似的功能,但是位图运用的是哈希思想中的直接定值法,也就是说只能处理正整数,而布隆过滤器是由一个很长的二进制向量(或称为位数组,即位图)和一系列哈希函数组成的概率型数据结构,可理解为在位图的基础上使用了多个哈希函数进行眏射。这样一来布隆过滤器就可以处理所有类型的数据。

        布隆过滤器的工作原理是,当添加元素时,通过多个哈希函数将元素映射到位数组中的不同位置,并将这些位置设为1。当查询一个元素是否存在于集合中时,布隆过滤器会检查该元素通过哈希函数映射到的所有位置是否都被设置为1。如果所有位置都是1,布隆过滤器就认为该元素可能存在于集合中;如果有任何一个位置是0,那么就可以确定该元素不在集合中。

        注意:布隆过滤器可能出现错误的判断现象。哈希函数由于存在碰撞的可能性,即不同的输入可能会产生相同的哈希结果,因此布隆过滤器可能会错误地认为某个元素存在于集合中。这种错误判断就是误报。这种误报的发生概率与布隆过滤器的大小、哈希函数的数量以及集合中元素的数量有关。在布隆过滤器的大小固定的情况下,如果集合中的元素数量增加,或者哈希函数的数量不足,误报的概率就会增加。比如当我们试图查询一个并不在集合中的元素时,由于哈希碰撞的可能性,该元素通过哈希函数映射到的某些位置可能已经被集合中的其他元素设置为1。这样,布隆过滤器就可能错误地认为该元素存在于集合中,从而产生误报。

        布隆过滤器特点是高效地插入和查询,可以用来告诉你“某样东西一定不存在或者可能存在”,它是用多个哈希函数,将一个数据映射到位图结构中。此种方式不仅可以提升查询效率,也可以节省大量的内存空间。布隆过滤器虽然存在误判,但是综合效率非常高。

        C++标准库中没有提供布隆过滤器的实现。布隆过滤器不是一种通用的数据结构,而是一种用于解决特定问题(即快速判断元素是否存在于某个集合中)的概率型数据结构。C++标准库的设计原则之一是提供通用、可移植且高效的基础组件。由于布隆过滤器具有特定的应用场景和特性,它可能并不适合作为C++标准库的一部分。相反,对于需要布隆过滤器的开发者,他们可以根据自己的需求实现布隆过滤器或者使用第三方库。因此,C++标准库并没有直接提供布隆过滤器的实现,若有需求可使用第三方库自己实现。

2-2,布隆过滤器的简单实现

        布隆过滤器主要用于判断,即插入和判断,但一般不直接支持删除工作,因为在删除一个元素时,可能会影响其他元素,比如:两个元素在多个哈希函数计算出的比特位上刚好有重叠,一旦删除其中一个,另一个也将受到影响。若非要实现删除功能,我们可将布隆过滤器中的每一比特位扩展成一个小的计数器,插入元素时给对应的哈希地址加一,删除时减1,但计数器也是占用内存空间的,可以说将位存储单元扩大了好几倍,因此,删除操作一般不采用。如下三个哈希函数对应的情况图。

        一旦删除上图中"tencent"元素,如果直接将该元素所对应的二进制比特位置0,“baidu”元素也 被删除了。

        我们模拟实现三个哈希函数,专门处理串类型的布隆过滤器。这里主要操作基本都通过位图实现。如下:

//三大处理字符串的哈希函数
//下面处理串的操作都是大佬研究出出现冲突率很小的算法

struct HashFuncBKDR
{
    size_t operator()(const string& s)
    {
        size_t hash = 0;
        for (auto ch : s)
        {
            hash *= 131;
            hash += ch;
        }
        return hash;
    }
};
struct HashFuncAP
{
    size_t operator()(const string& s)
    {
        size_t hash = 0;
        for (size_t 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 HashFuncDJB
{
    size_t operator()(const string& s)
    {
        size_t hash = 5381;
        for (auto ch : s)
        {
            hash = hash * 33 ^ ch;
        }
        return hash;
    }
};

template <size_t N,
    class K = string,
    class HashFunc1 = HashFuncBKDR,
    class HashFunc2 = HashFuncAP,
    class HashFunc3 = HashFuncDJB>
    class BloomFilter
{
public:
    BloomFilter()
    {
        _bitset = new bitset<2 * N>;
    }

    void Set(const K& data)
    {
        size_t pos1 = HashFunc1()(data) % N;
        size_t pos2 = HashFunc2()(data) % N;
        size_t pos3 = HashFunc3()(data) % N;

        _bitset->set(pos1);
        _bitset->set(pos2);
        _bitset->set(pos3);
    }
    //判断是只能百分百确定不存在,由于误判无法百分百确定存在
    bool Test(const K& data)
    {
        size_t pos1 = HashFunc1()(data) % N;
        size_t pos2 = HashFunc2()(data) % N;
        size_t pos3 = HashFunc3()(data) % N;

        //判断时只要有一个为零,代表该元素一定不在哈希表中,否则可能在哈希表中
        if (!_bitset->test(pos1))
            return false;
        if (!_bitset->test(pos2))
            return false;
        if (!_bitset->test(pos3))
            return false;

        return true;   //存在误判,不一定准确
    }
private:
    /*注意:不能直接创建对象,库中bitset里面定义的数组是静态数组,如 : int a[N]。因为静态数组开辟的空间存储在栈区上,栈的大小限制取决于多种因素,而在大多数现代操作系统中,默认的栈大小通常不足以容纳数百万个整数的数组。如果你需要分配大量的内存,更好的做法是使用动态内存分配。*/

    /*堆的大小通常比栈大得多,并且可以通过操作系统的内存管理机制进行扩展。C++STL大多数容器的底层通常是在堆上分配空间的,运行完毕之后自动释放*/
    //bitset<2 * N> _bitset;

    //动态开辟内存,直接在堆上存储
    bitset<2 * N>* _bitset;
};

样例测试1:

void Test1()
{
    string strs[] = { "百度","字节","腾讯" };
    BloomFilter<10> bf;
    for (auto& s : strs) bf.Set(s);

    for (auto& s : strs) cout << bf.Test(s) << endl;
    cout << endl;

    for (auto& s : strs) cout << bf.Test(s + 'a') << endl;
    cout << endl;

    cout << bf.Test("摆渡") << endl;
    cout << bf.Test("百渡") << endl;
}

样例测试2:

//此样例主要用来判断误判率
void Test2()
{
    srand(time(0));
    const size_t N = 10000000;
    BloomFilter<N> bf;

    std::vector<std::string> v1;
    //std::string url = "https://www.cnblogs.com/-clq/archive/2012/05/31/2528153.html";
    //std::string url = "https://www.baidu.com/s?ie=utf-8&f=8&rsv_bp=1&rsv_idx=1&tn=65081411_1_oem_dg&wd=ln2&fenlei=256&rsv_pq=0x8d9962630072789f&rsv_t=ceda1rulSdBxDLjBdX4484KaopD%2BzBFgV1uZn4271RV0PonRFJm0i5xAJ%2FDo&rqlang=en&rsv_enter=1&rsv_dl=ib&rsv_sug3=3&rsv_sug1=2&rsv_sug7=100&rsv_sug2=0&rsv_btype=i&inputT=330&rsv_sug4=2535";

    std::string url = "猪八戒";

    for (size_t i = 0; i < N; ++i) v1.push_back(url + std::to_string(i));
    
    for (auto& str : v1) bf.Set(str);
    
    // v2跟v1是相似字符串集(前缀一样),但是后缀不一样
    std::vector<std::string> v2;
    for (size_t i = 0; i < N; ++i)
    {
        std::string urlstr = url;
        urlstr += std::to_string(9999999 + i);
        v2.push_back(urlstr);
    }

    //相似字符串集的误判
    size_t n2 = 0;
    for (auto& str : v2)
    {
        //误判量
        if (bf.Test(str)) ++n2;
    }
    cout << "相似字符串误判率:" << (double)n2 / (double)N << endl;

    //不相似字符串集,前缀后缀都不一样
    std::vector<std::string> v3;
    for (size_t i = 0; i < N; ++i)
    {
        //string url = "zhihu.com";
        string url = "孙悟空";
        url += std::to_string(i + rand());
        v3.push_back(url);
    }

    //不相似字符串集的误判
    size_t n3 = 0;
    for (auto& str : v3)
    {
        if (bf.Test(str)) ++n3;
    }
    cout << "不相似字符串误判率:" << (double)n3 / (double)N << endl;
}

        再次强调,布隆过滤器如果说某个元素不存在时,该元素一定不存在,如果该元素存在时,该元素可能存在,因为有些哈希函数存在一定的误判。

2-3,布隆过滤器的应用

        1,假设用户在使用网站注册昵称,而已存在的昵称不被允许注册,这时系统该如何判断昵称是否存在?

        思路:已注册的昵称肯定是存放在数据库中,但是系统若直接从数据库中查找会导致效率太慢——中间还要进行网络的传播。这时我们可以考虑使用布隆过滤器,当发现已存在的昵称时直接返回拒绝,即使发生了误判——昵称没有注册过误判成了已存在,这里也影响不大。若是不忍受误判,当布隆过滤器判断出已存在时再去数据库中查找。有人可能会说这里判断了两次不是更加浪费吗?布隆过滤器的效率非常高,它在这里主要用于判断“不在”的情况,即使判断“在”,多跑了一次布隆过滤器,性能方面也是非常高的,相反,若两种情况都从数据库中查询,效率方面将非常低。布隆过滤器只是充当过滤价值。

        2,给两个文件A和B,分别有100亿个query(一系列字符串),但我们只有1G内存,如何找到两个文件交集?假设一个query平均占据50字节,分别给出精确算法和近似算法。

        近似算法:属于字符串的查询,位图已排除,直接使用布隆过滤器即可。

        精确算法:根据已知条件可计算出,100亿个query共约占500G(1G大约10亿字节),内存空间明显存储不下。有人可能会想将两文件数据都平均切割成几份存储到小文件中,然后每一小组分别使用指定的结构(比如红黑树、vector等)在内存中存储查找,但是这样一来效率非常低,切分成的小文件Ai需跟所有切分成小文件Bi比对一遍,效率是O(N^2)。

        下面我们来认识下哈希切分。首先,我们先编号1000个小文件Ai和1000个小文件Bi(i = 0,1,2......998,999)。然后从A,B文件中依次读取每一个query,通过哈希函数i = HashFunc(query) % 1000(因为有1000个小文件,所以余1000,对应的哈希值是文件的编号,为什么要开辟1000个小文件下面会说明)分别将query进入Ai、Bi小文件,因为A、B使用的都是同一个哈希函数,若存在相同的query一定会进入编号相同的Ai和Bi小文件(哈希冲突也会进入编号相同的文件),所以,最后分别将编号相同的Ai和Bi放到内存中使用数据结构(最好使用set<string>,因为set会过滤Ai和Bi中相同的数据)比对交集即可。

        以上之所以开辟1000个小文件是因为这样计算下来平均每个小文件共500G / 1000 = 0.5G,即Ai和Bi放入内存中刚好是1G。但是这里也存在问题,以上的计算只是平均理想的情况,实际中每个小文件占据的内存都会有幅度,若相同数据太多或哈希冲突太多,小文件会占据太大空间,可能会使内存存储不下。解决相同数据我们只需要使用set<string>结构过滤相同的数据即可,若是set还是抛异常,说明哈希冲突的太多,需要二次处理,使用不一样的哈希函数二次使用哈希切分。

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

        思路:以上一样,使用哈希切分开辟100个小文件,相同IP会进入相同编号的文件。下面可以使用map<string, int>统计次数,若map抛异常说明冲突很多,然后换个哈希函数进行二次哈希切分。


三,哈希应用海量数据总结

海量数据的处理方式有三种:位图、布隆过滤器、哈希切分。

        位图:只能处理正整数且判断数据的状态,运用时要考虑正整数和数据状态两方面。

        布隆过滤器:判断任意类型的数据状态,无论是空间效率还是时间效率都非常高,但是本身却存在“误判”,即布隆过滤器判断出“不在”是百分百确定的,判断出“在”不一定确定。若情况不允许误判可利用其它结构来填补这一现象。如以上网站注册昵称。

        哈希切分:能够精确的解决布隆过滤器的误判缺点。此方法是将大事化小,运用哈希思想将大数据切成一小块(不是平均切分),切小之后放到内存中,利用相关的数据结构和算法解决问题。此方法最后才考虑,优先考虑具有特点的数据结构是否可以解决,如:位图、布隆过滤器等。