C++ 哈希

💓博主CSDN主页:麻辣韭菜💓

⏩专栏分类:C++知识分享

🚚代码仓库:C++高阶🚚

🌹关注我🫵带你学习更多C++知识
  🔝🔝

前言

 1. unordered系列关联式容器

1.1 unordered_map

1.1.1 unordered_map的文档介绍

1.1.2 unordered_map的接口说明

插入与访问元素

删除与查找元素

其他操作

默认构造函数

默认析构函数

默认拷贝和移动构造函数,以及赋值运算符

迭代器类型

迭代器获取

迭代器使用示例

1.2 unordered_set  

插入与访问元素

删除元素

其他操作

默认构造函数

列表初始化构造函数

区间构造函数

拷贝构造函数

移动构造函数

迭代器类型

迭代器获取

迭代器使用示例

注意事项

2. 底层结构 

2.1 哈希概念  

2.2 哈希冲突

2.3 哈希函数

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

闭散列 

 闭散列 代码实现

开散列

开散列代码实现  

 开散列增容

开散列的思考  

开散列与闭散列比较 

3.哈希的应用 

3.1 位图

3.1.1 位图概念 

位图的实现  

3.1.3 位图的应用

 3.2 布隆过滤器

3.2.1布隆过滤器提出

3.2.2布隆过滤器概念

 布隆过滤器代码实现

3.2.4 布隆过滤器的查找

3.2.5 布隆过滤器删除

3.2.6 布隆过滤器优点

3.2.7 布隆过滤器缺陷

 4. 海量数据面试题


前言

如果你会用map和set,那么你就会用哈希表这种数据结构底层实现的unordered_map 和unordered_set。看名字unordered无序,而map和set是有序的。数据结构也是不同,map和set是搜索二叉树,而unordered_map 和unordered_set是哈希表(哈希桶)。

 1. unordered系列关联式容器

C++98 中, STL 提供了底层为红黑树结构的一系列关联式容器,在查询时效率可达到 $log_2
N$ ,即最差情况下需要比较红黑树的高度次,当树中的节点非常多时,查询效率也不理想。最好
的查询是,进行很少的比较次数就能够将元素找到,因此在 C++11 中, STL 又提供了 4
unordered 系列的关联式容器,这四个容器与红黑树结构的关联式容器使用方式基本类似,只是
其底层结构不同,本文中只对 unordered_map unordered_set 进行介绍。

1.1 unordered_map

1.1.1 unordered_map的文档介绍

unordered_map 在线文档说明
1. unordered_map 是存储 <key, value> 键值对的关联式容器,其允许通过 keys 快速的索引到与
其对应的 value
2. unordered_map 中,键值通常用于惟一地标识元素,而映射值是一个对象,其内容与此
键关联。键和映射值的类型可能不同。
3. 在内部 ,unordered_map 没有对 <kye, value> 按照任何特定的顺序排序 , 为了能在常数范围内
找到 key 所对应的 value unordered_map 将相同哈希值的键值对放在相同的桶中。
4. unordered_map 容器通过 key 访问单个元素要比 map 快,但它通常在遍历元素子集的范围迭
代方面效率较低。
5. unordered_maps 实现了直接访问操作符 (operator[]) ,它允许使用 key 作为参数直接访问
value
6. 它的迭代器至少是前向迭代器。

1.1.2 unordered_map的接口说明

插入与访问元素

  1. operator[]

    • 通过键访问或插入元素,并返回对应的值
    • 如果键存在,则返回对应值的引用;如果不存在,则插入新元素并返回默认构造的值
    std::unordered_map<std::string, int> myMap;
    myMap["one"] = 1;  // 插入键值对
    int value = myMap["one"];  // 访问键对应的值
    
  2. insert

    • 插入指定键值对
    • 返回一个 pair 对象,其 .second 成员指示插入是否成功,.first 指向已存在的元素(如果有)
    std::unordered_map<std::string, int> myMap;
    auto result = myMap.insert(std::make_pair("two", 2));  // 插入并获取结果
    if (result.second) {
        std::cout << "Insertion successful!" << std::endl;
    }
    

删除与查找元素

  1. erase

    • 删除指定键对应的元素
    std::unordered_map<std::string, int> myMap = {{"one", 1}, {"two", 2}};
    myMap.erase("two");  // 删除键为 "two" 的元素
    
  2. find

    • 查找指定键的元素,返回指向该元素的迭代器
    • 如果未找到,则返回指向 end() 的迭代器
    std::unordered_map<std::string, int> myMap = {{"one", 1}, {"two", 2}};
    auto it = myMap.find("one");
    if (it != myMap.end()) {
        std::cout << "Found: " << it->second << std::endl;
    }
    

其他操作

  1. clear

    • 清空哈希表,移除所有元素
    std::unordered_map<std::string, int> myMap = {{"one", 1}, {"two", 2}};
    myMap.clear();  // 清空哈希表
    
  2. size

    • 返回哈希表中元素的数量
    std::unordered_map<std::string, int> myMap = {{"one", 1}, {"two", 2}};
    std::cout << "Size: " << myMap.size() << std::endl;

默认构造函数

如果我们创建一个没有指定显式构造函数参数的 std::unordered_map 对象,那么编译器会为其生成默认构造函数。这个默认构造函数会创建一个空的哈希表。

std::unordered_map<int, std::string> myMap;  // 调用默认构造函数创建空的哈希表

默认析构函数

当 std::unordered_map 对象超出其作用域,或者通过 delete 运算符显式销毁时,编译器会为其生成默认析构函数。这个默认析构函数会释放哈希表占用的内存空间。

{
    std::unordered_map<int, std::string> myMap;  // 对象超出作用域,会调用默认析构函数自动释放内存
}  // myMap 被销毁

默认拷贝和移动构造函数,以及赋值运算符

std::unordered_map 也会涉及到默认的拷贝和移动构造函数,以及拷贝和移动赋值运算符。这些默认实现会对键值对进行浅复制或移动操作。

std::unordered_map<int, std::string> myMap1 = {{1, "one"}, {2, "two"}};
std::unordered_map<int, std::string> myMap2 = myMap1;  // 调用默认的拷贝构造函数
std::unordered_map<int, std::string> myMap3 = std::move(myMap1);  // 调用默认的移动构造函数
myMap3 = myMap2;  // 调用默认的拷贝赋值运算符
myMap3 = std::move(myMap2);  // 调用默认的移动赋值运算符

迭代器类型

  1. iterator

    • 用于遍历可修改 std::unordered_map 中的元素
  2. const_iterator

    • 用于遍历 const 修饰的 std::unordered_map,其指向的元素不可被修改

迭代器获取

std::unordered_map<int, std::string> myMap = {{1, "one"}, {2, "two"}, {3, "three"}};

// 获取起始迭代器
auto it = myMap.begin();  // 返回指向第一个元素的迭代器
auto cit = myMap.cbegin();  // 返回指向第一个元素的 const 迭代器

// 获取结束迭代器
auto end = myMap.end();  // 返回指向最后一个元素之后位置的迭代器
auto cend = myMap.cend();  // 返回指向最后一个元素之后位置的 const 迭代器

迭代器使用示例

使用迭代器可以遍历 std::unordered_map 中的元素,并访问每个元素的键和值。

std::unordered_map<int, std::string> myMap = {{1, "one"}, {2, "two"}, {3, "three"}};

// 遍历并打印键值对
for (auto it = myMap.begin(); it != myMap.end(); ++it) {
    std::cout << "Key: " << it->first << ", Value: " << it->second << std::endl;
}

1.2 unordered_set  

参见 unordered_set 在线文档说明

插入与访问元素

  1. insert

    • 将新元素插入到无序集合中
    • 返回一个 pair 对象,包含一个迭代器指向新元素的位置以及一个 bool 值,指示是否插入成功
    std::unordered_set<int> mySet;
    auto result = mySet.insert(42);
    if (result.second) {
        std::cout << "Insertion successful!" << std::endl;
    }
    
  2. emplace

    • 在集合中构造一个新元素
    • 返回一个 pair 对象,其中 .first 是迭代器指向新元素的位置,.second 是指示是否插入成功的 bool 值
    std::unordered_set<std::string> mySet;
    auto result = mySet.emplace("hello");
    if (result.second) {
        std::cout << "Insertion successful!" << std::endl;
    }
    
  3. find

    • 查找集合中是否存在指定的元素
    • 返回指向匹配元素位置的迭代器,如果没找到则返回指向 end() 的迭代器
    std::unordered_set<int> mySet = {1, 2, 3};
    auto it = mySet.find(2);
    if (it != mySet.end()) {
        std::cout << "Found: " << *it << std::endl;
    }
    

删除元素

  1. erase

    • 从集合中移除指定值或指定位置的元素,或者指定范围的元素
    std::unordered_set<int> mySet = {1, 2, 3};
    mySet.erase(2);  // 移除值为 2 的元素
    

其他操作

  1. clear

    • 清空集合,移除所有元素
    std::unordered_set<int> mySet = {1, 2, 3};
    mySet.clear();  // 清空集合
    
  2. size

    • 返回集合中元素的数量
    std::unordered_set<int> mySet = {1, 2, 3};
    std::cout << "Size: " << mySet.size() << std::endl;

默认构造函数

std::unordered_set<T> mySet;

这是 std::unordered_set 的默认构造函数,创建一个空的无序集合。

列表初始化构造函数

std::unordered_set<T> mySet = {val1, val2, ...};

使用大括号进行列表初始化,可以在创建无序集合的同时插入元素。

区间构造函数

std::unordered_set<T> mySet(otherSet.begin(), otherSet.end());

使用另一个无序集合的迭代器范围进行构造。复制范围内的元素到新的无序集合。

拷贝构造函数

std::unordered_set<T> mySet(otherSet);

通过另一个无序集合进行拷贝构造,复制另一个无序集合的内容到新的无序集合。

移动构造函数

std::unordered_set<T> mySet(std::move(otherSet));

通过移动语义实现的构造函数,将另一个无序集合的内容移动到新的无序集合中,另一个无序集合会变为空。

迭代器类型

  1. iterator

    • 用于遍历可修改 std::unordered_set 中的元素
  2. const_iterator

    • 用于遍历 const 修饰的 std::unordered_set,其指向的元素不可被修改

迭代器获取

std::unordered_set<int> mySet = {1, 2, 3, 4};

// 获取起始迭代器
auto it = mySet.begin();  // 返回指向第一个元素的迭代器
auto cit = mySet.cbegin();  // 返回指向第一个元素的 const 迭代器

// 获取结束迭代器
auto end = mySet.end();  // 返回指向最后一个元素之后位置的迭代器
auto cend = mySet.cend();  // 返回指向最后一个元素之后位置的 const 迭代器

迭代器使用示例

使用迭代器可以遍历 std::unordered_set 中的元素。

std::unordered_set<int> mySet = {1, 2, 3};

// 遍历并打印元素
for (auto it = mySet.begin(); it != mySet.end(); ++it) {
    std::cout << "Element: " << *it << std::endl;
}

注意事项

在 C++11 及以上版本,也可以使用范围-based for 循环来遍历 std::unordered_set

for (const auto& element : mySet) {
    std::cout << "Element: " << element << std::endl;
}

2. 底层结构 

unordered 系列的关联式容器之所以效率比较高,是因为其底层使用了哈希结构。

2.1 哈希概念  

顺序结构以及平衡树 中,元素关键码与其存储位置之间没有对应的关系,因此在 查找一个元素
时,必须要经过关键码的多次比较 顺序查找时间复杂度为 O(N) ,平衡树中为树的高度,即
O($log_2 N$) ,搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法:可以 不经过任何比较,一次直接从表中得到要搜索的元素
如果构造一种存储结构,通过某种函数 (hashFunc) 使元素的存储位置与它的关键码之间能够建立
一一映射的关系,那么在查找时通过该函数可以很快找到该元素
当向该结构中:
插入元素
根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放
搜索元素
对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置
取元素比较,若关键码相等,则搜索成功
该方式即为哈希 ( 散列 ) 方法, 哈希方法中使用的转换函数称为哈希 ( 散列 ) 函数,构造出来的结构称
为哈希表 (Hash Table)( 或者称散列表 )
例如:数据集合 {1 7 6 4 5 9}
哈希函数设置为: hash(key) = key % capacity ; capacity为存储元素底层空间总的大小。

 

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

2.2 哈希冲突

对于两个数据元素的关键字 $k_i$ $k_j$(i != j) ,有 $k_i$ != $k_j$ ,但有: Hash($k_i$) ==
Hash($k_j$) ,即: 不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突
或哈希碰撞
把具有不同关键码而具有相同哈希地址的数据元素称为 同义词
发生哈希冲突该如何处理呢?

2.3 哈希函数

引起哈希冲突的一个原因可能是: 哈希函数设计不够合理
哈希函数设计原则
哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有 m 个地址时,其值
域必须在 0 m-1 之间
哈希函数计算出来的地址能均匀分布在整个空间中
哈希函数应该比较简单
常见哈希函数

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

取关键字的某个线性函数为散列地址: Hash Key = A*Key + B
优点:简单、均匀
缺点:需要事先知道关键字的分布情况
使用场景:适合查找比较小且连续的情况
前面说的如果取模的余数和之前已经插入的数的取模余数是相等的,那么会出现哈希冲突,
解决哈希冲突两种常用的方法:闭散列和开散列
我们先用闭散列
闭散列 
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止
插入
通过哈希函数获取待插入元素在哈希表中的位置
如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,
使用线性探测找到下一个空位置,插入新元素

 

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

 闭散列 代码实现

哈希结构

#pragma once
#include <vector>
enum  State
{
	MEPTY, //空
	EXIST, //存在
	DELETE //删除
};

template <class	K, class V>
struct hashData
{
	pair<K, V> _kv;
	State _state = MPETY;
};

template <class K, class V>
class hashTable
{
	typedef hashData<K, V> Node;
private:
	vector<Node> _tables;
	size_t n;//记录元素个数
};

 插入函数

  

bool Insert(const pair<K, V>& kv)
	{
        if (Find(kv.first))
				return false;
		//当负载因子为大于0.7时就扩容
		if (_tables.size() == 0 || _n * 10 / _tables.size() >= 7)
		{
			size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
			hashTable <K, V> newht;
			newht._tables.resize(newsize);
			//遍历旧表,重新映射到新表
			for (auto& data : _tables)
			{
				if (data._state == EXIST)
				{
					newht.Insert(data._kv);
				}
			}
			_tables.swap(newht._tables);
		}
		size_t hashi = kv.first % _tables.size();
		//线性探测
		size_t i = 1;
		size_t index = hashi;
		while (_tables[index]._state == EXIST)
		{
			index = hashi + i;
			index %= _tables.size();
			++i;
		}
		_tables[index]._kv = kv;
		_tables[index]._state = EXIST;
		_n++;
		return true;
	}

这里解释一下关于扩容后为什么要新创建哈希桶,因为扩容后,映射位置变了,假设以前的size为10 扩容后为20,那之前的插入的值就找不到了,所以我们需要重新创建一个哈希桶,然后根据映射位置重新插入到新的哈希桶中。最后再交换。

 查找函数

Node* Find(const K& key)
		{
			if (_tables.size() == 0)
			{
				return false;
			}

			size_t hashi = key % _tables.size();

			// 线性探测
			size_t i = 1;
			size_t index = hashi;
			while (_tables[index]._state != EMPTY)
			{
				if (_tables[index]._state == EXIST
					&& _tables[index]._kv.first == key)
				{
					return &_tables[index];
				}

				index = hashi + i;
				index %= _tables.size();
				++i;

				// 如果已经查找一圈,那么说明全是存在+删除
				if (index == hashi)
				{
					break;
				}
			}

			return nullptr;
		}

 删除函数

bool Erase(const K& key)
		{
			Node* ret = Find(key);
			if (ret)
			{
				ret->_state = DELETE;
				--_n;
				return true;
			}
			else
			{
				return false;
			}
		}

 这里删除函数并不是真正意义上的删除,如果真的删除了,那么其他没有被删除的数都会受到影响,所以我们标记这个映射位置为DELETE,等下次插入的数映射位置和这个位置一样时,直接覆盖。

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

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

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

开散列代码实现  
namespace HashBucket
{
	template<class K, class V>
	struct HashNode
	{
		HashNode<K, V>* _next;
		pair<K, V> _kv;

		HashNode(const pair<K, V>& kv)
			:_next(nullptr)
			, _kv(kv)
		{}
	};

	template<class K, class V>
	class HashTable
	{
		typedef HashNode<K, V> Node;
	public:
		~HashTable()
		{
			for (auto& cur : _tables)
			{
				while (cur)
				{
					Node* next = cur->_next;
					delete cur;
					cur = next;
				}

				cur = nullptr;
			}
		}

		Node* Find(const K& key)
		{
			if (_tables.size() == 0)
				return nullptr;

			size_t hashi = key % _tables.size();
			Node* cur = _tables[hashi];
			while (cur)
			{
				if (cur->_kv.first == key)
				{
					return cur;
				}

				cur = cur->_next;
			}

			return nullptr;
		}

		bool Erase(const K& key)
		{
			size_t hashi = key % _tables.size();
			Node* prev = nullptr;
			Node* cur = _tables[hashi];
			while (cur)
			{
				if (cur->_kv.first == key)
				{
					if (prev == nullptr)
					{
						_tables[hashi] = cur->_next;
					}
					else
					{
						prev->_next = cur->_next;
					}
					delete cur;

					return true;
				}
				else
				{
					prev = cur;
					cur = cur->_next;
				}
			}

			return false;
		}

		bool Insert(const pair<K, V>& kv)
		{
			if (Find(kv.first))
			{
				return false;
			}

			// 负载因因子==1时扩容
			if (_n == _tables.size())
			{
				/*size_t newsize = _tables.size() == 0 ? 10 : _tables.size()*2;
				HashTable<K, V> newht;
				newht.resize(newsize);
				for (auto cur : _tables)
				{
					while (cur)
					{
						newht.Insert(cur->_kv);
						cur = cur->_next;
					}
				}

				_tables.swap(newht._tables);*/

				size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
				vector<Node*> newtables(newsize, nullptr);
				//for (Node*& cur : _tables)
				for (auto& cur : _tables)
				{
					while (cur)
					{
						Node* next = cur->_next;

						size_t hashi = cur->_kv.first % newtables.size();

						// 头插到新表
						cur->_next = newtables[hashi];
						newtables[hashi] = cur;

						cur = next;
					}
				}

				_tables.swap(newtables);
			}

			size_t hashi = kv.first % _tables.size();
			// 头插
			Node* newnode = new Node(kv);
			newnode->_next = _tables[hashi];
			_tables[hashi] = newnode;

			++_n;
			return true;
		}

	private:
		vector<Node*> _tables; // 指针数组
		size_t _n = 0; // 存储有效数据个数
	};
 开散列增容
桶的个数是一定的,随着元素的不断插入,每个桶中元素的个数不断增多,极端情况下,可
能会导致一个桶中链表节点非常多,会影响的哈希表的性能,因此在一定条件下需要对哈希
表进行增容,那该条件怎么确认呢?开散列最好的情况是:每个哈希桶中刚好挂一个节点,
再继续插入元素时,每一次都会发生哈希冲突,因此,在元素个数刚好等于桶的个数时,可
以给哈希表增容。
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转化为
//整形的方法
// 整形数据不需要转化
template<class T>
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 V, class HF>
class HashBucket
{
    // ……
private:
    size_t HashFunc(const V& data)
   {
        return HF()(data.first)%_ht.capacity();
   }
};

除留余数法,最好模一个素数,如何每次快速取一个类似两倍关系的素数? 

size_t GetNextPrime(size_t prime)
 {
 const int PRIMECOUNT = 28;
 static const size_t primeList[PRIMECOUNT] =
 {
     53ul, 97ul, 193ul, 389ul, 769ul,
     1543ul, 3079ul, 6151ul, 12289ul, 24593ul,
     49157ul, 98317ul, 196613ul, 393241ul, 786433ul,
     1572869ul, 3145739ul, 6291469ul, 12582917ul, 
     25165843ul,
     50331653ul, 100663319ul, 201326611ul, 402653189ul, 
     805306457ul,
     1610612741ul, 3221225473ul, 4294967291ul
 };
 size_t i = 0;
 for (; i < PRIMECOUNT; ++i)
 {
     if (primeList[i] > prime)
     return primeList[i];
 }
开散列与闭散列比较 
应用链地址法处理溢出,需要增设链接指针,似乎增加了存储开销 。事实上:
由于开地址法必须保持大量的空闲空间以确保搜索效率,如二次探查法要求装载因子 a <=
0.7 ,而表项所占空间又比指针大的多,所以使用链地址法反而比开地址法节省存储空间。

3.哈希的应用 

3.1 位图

3.1.1 位图概念 

1. 面试题
40 亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在
40 亿个数中。【腾讯】
1. 遍历,时间复杂度 O(N)
2. 排序 (O(NlogN)) ,利用二分查找 : logN
3. 位图解决
数据是否在给定的整形数据中,结果是在或者不在,刚好是两种状态,那么可以使用一
个二进制比特位来代表数据是否存在的信息,如果二进制比特位为 1 ,代表存在,为 0
代表不存在。比如:

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

位图的实现  

#pragma once

#include <vector>
#include <string>
#include <time.h>

template<size_t N>
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<char> _bits;
};

void test_bitset1()
{
	bitset<100> bs;
	bs.set(10);
	bs.set(11);
	bs.set(15);
	cout << bs.test(10) << endl;
	cout << bs.test(15) << endl;

	bs.reset(10);

	cout << bs.test(10) << endl;
	cout << bs.test(15) << endl;

	bs.reset(10);
	bs.reset(15);

	cout << bs.test(10) << endl;
	cout << bs.test(15) << endl;
}

3.1.3 位图的应用

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

 3.2 布隆过滤器

3.2.1布隆过滤器提出

我们在使用新闻客户端看新闻时,它会给我们不停地推荐新的内容,它每次推荐时要去重,去掉
那些已经看过的内容。问题来了,新闻客户端推荐系统如何实现推送去重的? 用服务器记录了用
户看过的所有历史记录,当推荐系统推荐新闻时会从每个用户的历史记录里进行筛选,过滤掉那
些已经存在的记录。 如何快速查找呢?
1. 用哈希表存储用户记录,缺点:浪费空间
2. 用位图存储用户记录,缺点:位图一般只能处理整形,如果内容编号是字符串,就无法处理
了。
3. 将哈希与位图结合,即布隆过滤器

3.2.2布隆过滤器概念

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

 

 

 布隆过滤器代码实现

struct BKDRHash
{
	size_t operator()(const string& s)
	{
		size_t hash = 0;
		for (auto ch : s)
		{
			hash += ch;
			hash *= 31;
		}

		return hash;
	}
};

struct APHash
{
	size_t operator()(const string& s)
	{
		size_t hash = 0;
		for (long i = 0; i < s.size(); i++)
		{
			size_t ch = s[i];
			if ((i & 1) == 0)
			{
				hash ^= ((hash << 7) ^ ch ^ (hash >> 3));
			}
			else
			{
				hash ^= (~((hash << 11) ^ ch ^ (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;
	}
};

// N最多会插入key数据的个数
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 len = N*_X;
		size_t hash1 = Hash1()(key) % len;
		_bs.set(hash1);

		size_t hash2 = Hash2()(key) % len;
		_bs.set(hash2);

		size_t hash3 = Hash3()(key) % len;
		_bs.set(hash3);

		//cout << hash1 << " " << hash2 << " " << hash3 << " " << endl << endl;
	 }

	bool test(const K& key)
	{
		size_t len = N*_X;

		size_t hash1 = Hash1()(key) % len;
		if (!_bs.test(hash1))
		{
			return false;
		}

		size_t hash2 = Hash2()(key) % len;
		if (!_bs.test(hash2))
		{
			return false;
		}

		size_t hash3 = Hash3()(key) % len;
		if (!_bs.test(hash3))
		{
			return false;
		}

		// 在      不准确的,存在误判
		// 不在    准确的

		return true;
	}
private:
	static const size_t _X = 6;
	bitset<N*_X> _bs;
};

3.2.4 布隆过滤器的查找

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

3.2.5 布隆过滤器删除

布隆过滤器不能直接支持删除工作,因为在删除一个元素时,可能会影响其他元素。
比如:删除上图中 "tencent" 元素,如果直接将该元素所对应的二进制比特位置 0 “baidu” 元素也
被删除了,因为这两个元素在多个哈希函数计算出的比特位上刚好有重叠。
一种支持删除的方法:将布隆过滤器中的每个比特位扩展成一个小的计数器,插入元素时给 k 个计
数器 (k 个哈希函数计算出的哈希地址 ) 加一,删除元素时,给 k 个计数器减一,通过多占用几倍存储
空间的代价来增加删除操作。
缺陷:
1. 无法确认元素是否真正在布隆过滤器中
2. 存在计数回绕

3.2.6 布隆过滤器优点

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

3.2.7 布隆过滤器缺陷

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

 4. 海量数据面试题

哈希切割
给一个超过 100G 大小的 log file, log 中存着 IP 地址 , 设计算法找到出现次数最多的 IP 地址?  
与上题条件相同,如何找到 top K IP ?如何直接用 Linux 系统命令实现?

 

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

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

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

 

布隆过滤器
1. 给两个文件,分别有 100 亿个 query ,我们只有 1G 内存,如何找到两个文件交集?分别给出
精确算法和近似算法
2. 如何扩展 BloomFilter 使得它支持删除元素的操作

  • 25
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值