C++——哈希的应用

发布于:2025-09-10 ⋅ 阅读:(20) ⋅ 点赞:(0)

上一篇,我们了解完了哈希桶之和,现在我们来认识 一下关于哈希的应用。

前言

我们接下来会了解2种应用:

位图

布隆过滤器

接下来正式开始:

位图

什么是位图?

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

我们现在用一个面试题来引入位图:

面试题1

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

首先,我们先来复习一下计算机的单位转化:

1GB=1024MB=1024*1024KB=1024*1024*1024Byte≈10亿多Byte

一、分析传统方法的局限性
1. 用 set 存储:
无符号整数通常占4字节(32位),40亿个整数需占用  40*10^8*4  字节  = 160*10^8  字节  ≈16  GB内存。若内存不足(如普通设备内存远小于16GB), set 会因内存溢出无法使用。因此否定这种方法!

2. 排序+二分查找:(排序:O(NlogN)),二分查找: O(logN))
排序40亿个数本身需要极大内存和时间;且即使排序完成,存储这些数仍需约16GB内存,同样存在内存瓶颈。
 
那么,我们就引出来了今天所讲的---位图
原理
 
位图利用二进制位表示“存在与否”:用1个二进制位(0或1)对应1个无符号整数若整数存在则对应位设为1,否则为0
 
计算空间消耗---------优势
无符号整数范围是  0~ 2^32-1 (共约43亿个数),因此位图只需  2^{32}  个二进制位。
- 换算为字节:2^32 / 8 = 2^29  字节  = 512  MB(因为  1  字节  = 8  位)。

- 512MB内存易满足,远小于 set 或直接存储的16GB。
 
操作步骤
1. 初始化位图:创建大小为  2^{32}  位的位图,初始所有位为0。

2. 加载数据:遍历40亿个无符号整数,将每个数对应的位图位置设为1。

3. 查询判断:对于待查询的数,直接查看位图中对应位是1(存在)还是0(不存在),时间复杂度为  O(1) 。

那它是怎么进行在对应的位图设1的呢?

复习点:

我们学C语言的时候,知道计算机会有两种存储方式:分别是大端机和小端机。

干讲可能有点抽象,现在我们来模拟实现一下:

ps:位图是C++中的库,这里只是简单模拟一下

模拟实现

namespace bai
{
	template<size_t N>
	class BitSet
	{
	public:
		BitSet()
		{
			a_.resize(N / 32 + 1);
		}

		void set(size_t x)
		{
			int i = x / 32;
			int j = x % 32;
			a_[i] |= (1 << j);
		}

		void reset(size_t x)
		{

			int i = x / 32;
			int j = x % 32;
			a_[i] &= (~(1 << j));
		}

		bool test(size_t x)
		{
			int i = x / 32;
			int j = x % 32;
			return a_[i] & (1 << j);
		}


	private:
		vector<int> a_;
	};
}

整个代码量很少,所以直接摆上去了,现在主要任务是,分析它:

其中/32,%32在上面也已经解释过了。

关于操作符的知识:

|:有1则1,两0为0

&:有0则0,两1为1

~:取反,0变1,1变0

1<<j:1向左移到j位

相信学到这里的伙伴,看到这些知识点,应该就能看懂上面的代码逻辑了,不难。

测试:

额外知识:

关于计算机中整数表示范围及相关概念
-  INT_MAX:3.921亿多 : INT_MAX 是C语言等编程语言中 int 类型(通常为32位有符号整数)能表示的最大正整数,其值为2^{31}-1 = 2147483647,21亿多
-   UINT_MAX :无符号整数(如 unsigned int )能表示的范围是0到2^{32}-1(约42亿多)。
-  < -1 > 和 <0xffffffff> :在计算机中,-1的补码表示为全1(对于32位整数就是 0xffffffff )

位图的拓展:

以一个面试题来引入:

面试题2

给定100亿个整数,设计算法找到只出现一次的整数?
(ps:这肯定得排除哈希,红黑树....)

但是,还是有一个麻烦点的思路:

分治+哈希:

将100亿个整数分散到多个小文件中,eg:分100个小文件。

依据:相同的整数,哈希值一定相同,因此,只出现一次的整数,一定只会出现在一个小文件当中。

接着,逐批处理小文件,对每个小文件,用普通哈希表统计其中整数出现的次数,筛选处“只出现一次”的整数,记录到结果集中。

最后合并结果。

这里我们使用两个bit位:

现在我们来模拟实现第一种方法:

(ps:下面代码是基于BitSet来进一步写的)

template<size_t N>
class towbit
{
public:
	void set(size_t x)
	{
		//00->01
		if (!bt1.test(x) && !bt2.test(x))
		{
			bt1.reset(x);
			bt2.set(x);
		}
		//01->10
		else if(!bt1.test(x) &&bt2.test(x))
		{
			bt1.set(x);
			bt2.reset(x);
		}
	}

	bool is_once(size_t x)
	{
		return !bt1.test(x) && bt2.test(x);
	}
private:
	BitSet<N> bt1;
	BitSet<N> bt2;
};

对于每个整数,用2个二进制位表示其出现次数的状态:(上面代码就是通过更新x的状态)
-  00 :未出现;

-  01 :出现1次;

-  10 :出现≥2次;

-  11 :保留(或无意义)。

面试题3

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

方案:

一个文件所有值映射到一个位图,另一个文件判断在不在?这样行不行?

这种方法:空间是够的,但是还是会存在一些问题:出来的交集,需要再次去重。

怎么解决它呢?

法一:

法二:

找到了就reset一下也可以

面试题4

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

方案:

类似面试题2

变形:如果出现负数(可能会越界)所以得用BitSet<-1>,强转回(int)i;严格来说不影响。

当然也可以搞一个相对映射

位图的应用

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

2. 排序 + 去重

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

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

布隆过滤器

引入:

对于一个想要查找公司名字的情景:

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

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

万一存在上面的情况:在转字符串的时候,存在冲突?这会导致结果可能不正确?

针对上面的情况,我们有了一种方法---布隆过滤器。

概念:

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

应用场景:

1.不需要精确的的场景,eg:快速判断昵称是否被注册过。(容忍误判)

此时就可以把它放到布隆过滤器里。

2.需要精确的的场景:eg:快速判断昵称是否被注册过(精确)

此时也可以把它放到布隆过滤器里,减少误判。而不是直接放到数据库那里查找,这就有利于减少数据库的查询负载压力,提高效率。

干讲抽象,我们还是先自行模拟一下,知道它大概的过程,再来进行讲解会好点:

模拟实现:

在模拟实现之前,先知道几个算法(这里我们并不会去学习它,只是用来测试而已)

1.BKDRHash  2.APHash  3.DJBHash

#include<string>
using namespace std;
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<size_t N,class K=string,class Hash1= BKDRHash,class Hash2= APHash,class Hash3= DJBHash>
class BloomFilter
{
public:
    void Set(const K& key)
    {
        size_t x = Hash1()(key) % N;
        bts.set(x);
        size_t y = Hash2()(key) % N;
        bts.set(y);
        size_t z = Hash3()(key) % N;
        bts.set(z);
    }

    bool test(const K&key)
    {
        size_t x = Hash1()(key) % N;
        if (bts.test(x) == false) return false;
        size_t y = Hash2()(key) % N;
        if (bts.test(y) == false) return false;
        size_t z = Hash3()(key) % N;
        if (bts.test(z) == false) return false;
    }
private:
	bitset<N> bts;
};

布隆过滤器:

1.通过多映射几个,来降低冲突。

2.上面我们通过了仿函数的形式来对算法使用。

3.一般情况下,布隆过滤器不支持删除:因为映射了相同的值,reset后,会把别的映射在同位置的也会影响。

比如说:上面的,如果你删除了一个的话,另一个可能也映射到了该位置,也会被删了,严重时,结果直接没了。

4.布隆过滤器的思想是将一个元素用多个哈希函数映射到一个位图中,因此被映射到的位置的比特位一定为1

5.查找:分别计算每个哈希值对应的比特位置存储的是否为零,只要有一个为零,代表该元素一定不在哈希表中,否则可能在哈希表中

注意:是0一定不存在,但是1是可能存在(存在误判)。

布隆过滤器:

优点:

1.增加和查询元素的时间复杂度为:O(K)(K为哈希函数的个数,一般比较小)与数据量大小无关

2. 哈希函数相互之间没有关系,方便硬件并行运算
3. 布隆过滤器不需要存储元素本身,在某些对保密要求比较严格的场合有很大优势
4. 在能够承受一定的误判时,布隆过滤器比其他数据结构有这很大的空间优势
eg:红黑树,哈希空间大
5. 数据量很大时,布隆过滤器可以表示全集,其他数据结构不能

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

缺陷:

1. 有误判率,即存在假阳性(False Position),即不能准确判断元素是否在集合中(补救方法再

建立一个白名单,存储可能会误判的数据)

2. 不能获取元素本身
3. 一般情况下不能从布隆过滤器中删除元素

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

布隆过滤器的拓展:

1.如何拓展布隆过滤器使得它支持删除元素的操作:

多个位标识一个值,引用计数。

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

精确算法和近似算法?

近似算法:用一个布隆过滤器,其中一个文件放进,另一个文件看在不在,如果在就是交集。

精确算法:

记住:这里是哈希切分,而不是平均切分。

这样做有什么好处呢?

以前这个小文件A0可能跟相同的B后面所有的小文件都需要找一下,而现在不用了,直接找交集。

也就是:对应的小文件找交集即可,如果平均切分,查询可能在任意位置,那么A0可能需要全部遍历一遍,A1......也是,切小的意思是:只有1GB内存,那么你在磁盘中找太慢了,那么我在内存中来,对一个小的跟你找可能会好找一些,比如是把一个小的放到一个哈希表里面去,剩下的挨着遍历,看在不在哈希表,在就是交集,不在就不是。

现在是编号相同的找一次即可,为什么?

理论支持:哈希切分:A和B相同的query一定会分别进入Ai和Bi编号相同的小文件,因为我们用的是同一个哈希函数,所有不可能一个进不同的编号,如果相同(重复)。

所以,大部分都可用哈希切分解决。

-->核心:内存太大了,我解决不了,那么我给你切小就可以解决,但是我平均切分的话(还是暴力求解),每个小文件都要去比对一下,不划算,所以哈希切分:数据分流进对应的编号上。

额外拓展:(简单了解了解,等到后面更新Linux的文件系统部分就清楚了)

那么 有人可能会疑惑了,那么文件读取又是怎么切分的?它难道可以在磁盘中切吗?

(其实等到后面Linux中的文件管理部分的内容你就会有焕然大悟的感觉)

它比如是不可能在磁盘中切分的,它是读取到内存里面运算,运算到编号是几,就写到对应的文件(文件描述符等等知识),走一遍内存,但是它是几乎是没有内存消耗的,因为你不是一个一个的读取数据的,一般是一段一段读,加载到文件缓冲区里,若读的数据还在缓冲区,它就不会到磁盘里去找。

携带的其他问题:

1.重复问题:去重一下(放进两个set)或者找到就删一个

找交集,Ai读出来放到一个set,在依次读取Bi的query,在不在,在就是交集并且删掉,就可以找出Ai和Bi的交集了。

2.不是平均切分,会不会存在某一个内存特别大?

什么意思?即因为冲突太多,导致某个Ai文件太大,甚至超过1GB怎么办?

两种场景:eg:Ai有5GB

1.4GB都是相同的query,1GB冲突

2.大多数是冲突的。

解决方案:

1.先把Ai的query读到一个set,如果set的insert报错抛异常(bad_alloc)那么就说明大多数query都是冲突的,如果能够全部读出来,insert到里面,那么说明Ai有大量相同的query。

2.如果抛异常,说明有大量的冲突,再换一个哈希函数,再进行二次切分。

哈希切割的应用:

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

方案:

把它切分成多个小文件,相同ip一定进入了相同的小文件,然后用map去分别统计每个小文件ip出现的次数既可以

如何找到top K的IP

1.切分多个小文件

2.哈希表统计出现的次数

3.建立一个堆结构(小根堆)例如,要找top 10的IP,就维护一个大小为10的小根堆,遍历小文件中IP的次数时,若当前IP的次数大于堆顶元素的次数,就替换堆顶并调整堆。

4.处理完小文件后,堆中的元素就是top K的IP。

如何直接用Linux系统命令实现?(如果没学的话可先不看)

'{print $1}' 获取每一行文本中第1个字段
awk          提取IP列,
sort         排序
uniq -c      统计每个IP出现的次数
sort -rn     按次数降序排序
head -K      取前K个(比如取前10个,就用 head -10 )。

awk '{print $1}' log_file | sort | uniq -c | sort -rn | head -K

好了,关于哈希应用及拓展就分享到这里了,希望对大家有所进步!

最后,到了本次鸡汤环节:

希望好运会降临~


网站公告

今日签到

点亮在社区的每一天
去签到