C++之哈希
1. unordered系列关联式容器
在C++98中,STL提供了底层为红黑树结构的一系列关联式容器,在查询时效率可达到log2N,即最差情况下需要比较红黑树的高度次,当树中的节点非常多时,查询效率也不理想。最好 的查询是,进行很少的比较次数就能够将元素找到,因此在C++11中,STL又提供了4个 unordered系列的关联式容器,这四个容器与红黑树结构的关联式容器使用方式基本类似,只是 其底层结构不同,本文中只对unordered_map和unordered_set进行介绍。
1.1 unordered_map
1.1.1 unordered_map的文档介绍
unordered_map在线文档说明
- unordered_map是存储键值对的关联式容器,其允许通过keys快速的索引到与其对应的value。
- 在unordered_map中,键值通常用于惟一地标识元素,而映射值是一个对象,其内容与此键关联。键和映射值的类型可能不同。
- 在内部,unordered_map没有对按照任何特定的顺序排序, 为了能在常数范围内找到key所对应的value,unordered_map将相同哈希值的键值对放在相同的桶中。
- unordered_map容器通过key访问单个元素要比map快,但它通常在遍历元素子集的范围迭代方面效率较低。
- unordered_maps实现了直接访问操作符(operator[]),它允许使用key作为参数直接访问 value。
- 它的迭代器至少是前向迭代器。
1.1.2 unordered_map的接口说明
-
unordered_map的构造
函数声明 功能介绍 unordered_map 构造不同格式的unordered_map对象 -
unordered_map的容量
函数声明 功能介绍 bool empty() const 检测unordered_map是否为空 size_t size() const 获取unordered_map的有效元素个数 -
unordered_map的迭代器
函数声明 功能介绍 begin 返回unordered_map第一个元素的迭代器 end 返回unordered_map最后一个元素下一个位置的迭代器 cbegin 返回unordered_map第一个元素的const迭代器 cend 返回unordered_map最后一个元素下一个位置的const迭代器 注意:unordered_map的迭代器是单向的,而map的迭代器是双向的。
-
unordered_map的元素访问
函数声明 功能介绍 operator[] 返回与key对应的value,没有一个默认值 注意:该函数中实际调用哈希桶的插入操作,用参数key与V()构造一个默认值往底层哈希桶中插入,如果key不在哈希桶中,插入成功,返回V(),插入失败,说明key已经在哈希桶中, 将key对应的value返回。
-
unordered_map的查询
函数声明 功能介绍 iterator find(const K& key) 返回key在哈希桶中的位置 size_t count(const K& key) 返回哈希桶中关键码为key的键值对的个数 注意:unordered_map中key是不能重复的,因此count函数的返回值最大为1
-
unordered_map的修改操作
函数声明 功能介绍 insert 向容器中插入键值对 erase 删除容器中的键值对 void clear() 清空容器中有效元素个数 void swap(unordered map&) 交换两个容器中的元素 -
unordered_map的桶操作
函数声明 功能介绍 size_t bucket_count() const noexcept 返回哈希桶中桶的总个数 size_t bucket_size ( size_t n ) const 返回n号桶中有效元素的总个数 size_t bucket ( const K& k ) const 返回元素key所在的桶号
1.2 unordered_set
参见unordered_set在线文档说明
和set的区别:
- 无序
- 单向迭代器
- 访问效率高一些(O(1))
性能比较:(1000w次随机数)
1.3 在线OJ
重复n次的元素
代码:
class Solution {
public:
int repeatedNTimes(vector<int>& nums)
{
size_t N = nums.size() / 2;
//用unordered_map统计每个元素出现的次数
unordered_map<int, int> m;
for(auto e : nums)
{
m[e]++;
}
//找出出现次数为N的元素
for(auto& e : m)
{
if(e.second == N)
{
return e.first;
}
}
return 0;
}
};
2. 底层结构
unordered系列的关联式容器之所以效率比较高,是因为其底层使用了哈希结构。
2.1 哈希概念
顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即 O(log2N),搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。 如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立 一一映射的关系,那么在查找时通过该函数可以很快找到该元素。
当向该结构中:
-
插入元素
根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放
-
搜索元素
对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功
该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表)
直接建立映射问题:
1、数据范围分布很广、不集中
2、key的数据不是整数,是字符串怎么办?是自定义类型怎么办?
例如:数据集合{1,7,6,4,5,9};capacity为存储元素底层空间总的大小。
用该方法进行搜索不必进行多次关键码的比较,因此搜索的速度比较快
问题:按照上述哈希方式,向集合中插入元素44,会出现什么问题?
答:会出现冲突,因为44应该放到下标为4的位置上去。
2.2 哈希冲突
对于两个数据元素的关键字k_i和k_j(i != j),有k_i!=k_j,但有:Hash(k_i) == Hash(k_j),即:不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。
把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。
发生哈希冲突该如何处理呢?
2.3 哈希函数
引起哈希冲突的一个原因可能是:哈希函数设计不够合理。
哈希函数设计原则:
- 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0到m-1之间
- 哈希函数计算出来的地址能均匀分布在整个空间中
- 哈希函数应该比较简单
常见哈希函数
-
直接定址法–(常用)
取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B
优点:简单、均匀
缺点:需要事先知道关键字的分布情况
使用场景:适合查找比较小且连续的情况
面试题:
字符串中第一个只出现一次字符
-
除留余数法–(常用)
设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数, 按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址
-
平方取中法–(了解)
假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址; 再比如关键字为4321,对它平方就是18671041,抽取中间的3位671(或710)作为哈希地址。平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况
-
折叠法–(了解)
折叠法是将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。
折叠法适合事先不需要知道关键字的分布,适合关键字位数比较多的情况
-
随机数法–(了解)
选择一个随机函数,取关键字的随机函数值为它的哈希地址,即H(key) = random(key),其中 random为随机数函数。
通常应用于关键字长度不等时采用此法
-
数学分析法–(了解)
设有n个d位数,每一位可能有r种不同的符号,这r种不同的符号在各位上出现的频率不一定 相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只有某几种符号经常出现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散 列地址。例如:
假设要存储某家公司员工登记表,如果用手机号作为关键字,那么极有可能前7位都是相同的,那么我们可以选择后面的四位作为散列地址,如果这样的抽取工作还容易出现 冲突,还 可以对抽取出来的数字进行反转(如1234改成4321)、右环位移(如1234改成4123)、左环移位、前两数与后两数叠加(如1234改成12+34=46)等方法。
数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布较均匀的情况
注意:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突
2.4 哈希冲突解决
解决哈希冲突两种常见的方法是:闭散列和开散列
2.4.1 闭散列
闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。那如何寻找下一个空位置呢?
-
线性探测
比如2.1中的场景,现在需要插入元素44,先通过哈希函数计算哈希地址,hashAddr为4, 因此44理论上应该插在该位置,但是该位置已经放了值为4的元素,即发生哈希冲突。
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
缺点:会占用其它元素该占用的位置,出现拥堵现象。
-
插入
- 通过哈希函数获取待插入元素在哈希表中的位置
- 如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突, 使用线性探测找到下一个空位置,插入新元素
-
删除
采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。比如删除元素4,如果直接删除掉,44查找起来可能会受影响。因此线性探测采用标记的伪删除法来删除一个元素。
// 哈希表每个空间给个标记 // EMPTY此位置空, EXIST此位置已经有元素, DELETE元素已经删除 enum State{EMPTY, EXIST, DELETE};
线性探测的实现:
#pragma once #include<iostream> #include<vector> using namespace std; enum State { EMPTY, EXITS, DELETE }; template<class K, class V> struct HashData { pair<K, V> _kv; State _state = EMPTY; }; template<class K> struct DefaultHash { size_t operator()(const K&key) { return (size_t)key; } }; //特化 template<> struct DefaultHash<string> { size_t operator()(const string& key) { size_t hash = 0; for (auto e : key) { hash = hash * 131 + e; } return hash; } }; template<class K, class V, class HashFunc = DefaultHash<K>> class HashTable { typedef HashData<K, V> Data; public: 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 : 2 * _tables.size(); //扩容以后就要重新映射 HashTable<K, V, HashFunc> newHT; newHT._tables.resize(newSize); for (auto& e : _tables) { if (e._state == EXITS) { newHT.Insert(e._kv); } } newHT._tables.swap(_tables); } HashFunc hf; size_t starti = hf(kv.first); starti %= _tables.size();//问:为什么此处不是%capacity?答:因为[]会对下标进行检查 //线性探测/二次探测 size_t hashi = starti; int i = 1; while (_tables[hashi]._state == EXITS) { hashi = starti + i; ++i; hashi %= _tables.size(); } _tables[hashi]._kv = kv; _tables[hashi]._state = EXITS; _n++; return true; } Data* Find(const K& key) { if (_n == 0) { return nullptr; } HashFunc hf; size_t starti = hf(key); starti %= _tables.size(); //线性探测/二次探测 size_t hashi = starti; int i = 1; while (_tables[hashi]._state != EMPTY) { if (_tables[hashi]._state != DELETE && _tables[hashi]._kv.first == key ) { return &_tables[hashi]; } hashi = starti + i; ++i; hashi %= _tables.size(); } return nullptr; } bool Erase(const K& key) { Data* ret = Find(key); if (ret) { ret->_state = DELETE; _n--; return true; } else { return false; } } private: vector<Data> _tables; size_t _n = 0;//有效数据的个数 };
思考:哈希表什么情况下进行扩容?如何扩容?
散列表的载荷因子定义为:α = 填入表中的元素个数 / 散列的长度
线性探测优点:实现非常简单。
线性探测缺点:一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”,即:不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低。
-
-
二次探测
线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法 为:H_i = (H_0 + i2 )% m, 或者:H_i = (H_0 - i2 )% m。其中:i = 1,2,3…, H_0是通过散列函数Hash(x)对元素的关键码 key 进行计算得到的位置,m是表的大小。
对于2.1中如果要插入44,产生冲突,使用解决后的情况为:
研究表明:当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容。
因此:闭散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷。
2.4.2 开散列
-
开散列概念
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
注意:
从上图可以看出,开散列中每个桶中放的都是发生哈希冲突的元素。
-
开散列实现
//默认存储为整型的情况 struct DefaultHash { size_t operator()(const K& key) { return key; } }; //存储的数据不是整数类型而是字符串类型时就要使用字符串哈希算法,即将字符串类型转换成整型 //同样的,其它自定义类型也要如此,即将自定义类型按照一定的哈希算法将自定义类型转换成整型 template<> struct DefaultHash<string> { size_t operator()(const string& key) { size_t hash = 0; for (auto e : key) { hash = hash * 131 + e; } return hash; } }; namespace Bucket { template<class K, class V> struct HashNode { HashNode(const pair<K,V> kv) :_kv(kv) ,_next(nullptr) {} pair<K, V> _kv; HashNode<K, V>* _next; }; template<class K, class V, class HashFunc = DefaultHash<K>> class HashTable { typedef HashNode<K, V> Node; public: ~HashTable() { for (size_t i = 0; i < _tables.size(); ++i) { Node* cur = _tables[i]; while (cur) { Node* next = cur->_next; delete cur; cur = next; } _tables[i] = nullptr; } } HashTable() {} HashTable<K, V>& operator=(const HashTable<K, V>& ht) { HashTable<K, V> tmp(ht); _tables.swap(tmp._tables); return *this; } HashTable(const HashTable<K, V>& ht) { _tables.resize(ht._tables.size(), nullptr); for (size_t i = 0; i < ht._tables.size(); i++) { Node* cur = ht._tables[i]; while (cur) { Insert(cur->_kv); cur = cur->_next; } } } bool Insert(const pair<K, V>& kv) { if (Find(kv.first)) { return false; } HashFunc hf; //空间不够,进行扩容 if (_tables.size() == _n) { size_t newSize = _tables.size() == 0 ? 10 : 2 * _tables.size(); //思路一:拷贝节点 //HashTable newHT; //newHT._tables.resize(newSize, nullptr); //for (size_t i = 0; i < _tables.size(); i++) //{ // Node* cur = _tables[i]; // while (cur) // { // newHT.Insert(cur->_kv); // cur = cur->_next; // } //} //newHT._tables.swap(_tables); //思路二:转移节点 vector<Node*> newTables; newTables.resize(newSize, nullptr); for (size_t i = 0; i < _tables.size(); i++) { Node* cur = _tables[i]; while (cur) { Node* next = cur->_next; size_t hashi = hf(cur->_kv.first); hashi %= newSize; cur->_next = newTables[hashi]; newTables[hashi] = cur; cur = next; } _tables[i] = nullptr; } newTables.swap(_tables); } size_t hashi = hf(kv.first); hashi %= _tables.size(); Node* newNode = new Node(kv); newNode->_next = _tables[hashi]; _tables[hashi] = newNode; ++_ return true; } Node* Find(const K& key) { if (_tables.size() == 0) { return nullptr; } HashFunc hf; size_t hashi = hf(key); hashi %= _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) { //问:如果不用prev指针,还能用什么方法? //答:有另外两种方法,但是依旧只推荐使用prev指针的方法 //1.判断cur->_next->kv.first是否等于key //2.替换法删除,将cur节点和_tables[hashi]替换(替换kv即可),然后删除_tables[hashi] if (_tables.size() == 0) return false; HashFunc hf; size_t hashi = hf(key); hashi %= _tables.size(); Node* cur = _tables[hashi]; Node* prev = nullptr; while (cur) { if (cur->_kv.first == key) { //删除节点 if (prev == nullptr)//也可以写成prev == nullptr的情况 { _tables[hashi] = cur->_next; } else { prev->_next = cur->_next; } delete cur; return true; } prev = cur; cur = cur->_next; } return false; } private: //指针数组 vector<Node*> _tables; size_t _n = 0; }; }
-
开散列与闭散列比较
应用链地址法处理溢出,需要增设链接指针,似乎增加了存储开销。事实上: 由于开地址法必须保持大量的空闲空间以确保搜索效率,如二次探查法要求装载因子a <= 0.7,而表项所占空间又比指针大的多,所以使用链地址法反而比开地址法节省存储空间。
-
字符串哈希算法
字符串哈希算法
3. 模拟实现
哈希表的改造
Hash.h文件
#pragma once
#include<iostream>
#include<vector>
using namespace std;
template<class K>
struct DefaultHash
{
size_t operator()(const K& key)
{
return key;
}
};
template<>
struct DefaultHash<string>
{
size_t operator()(const string& key)
{
size_t hash = 0;
for (auto e : key)
{
hash = hash * 131 + e;
}
return hash;
}
};
template<class T>
struct HashNode
{
HashNode(const T& data)
:_data(data)
, _next(nullptr)
{}
T _data;
HashNode<T>* _next;
};
template<class K, class T, class KeyOfT, class HashFunc>
class HashTable;
template<class K, class T, class KeyOfT, class HashFunc>
class __HTIterator
{
typedef HashNode<T> Node;
typedef __HTIterator<K, T, KeyOfT, HashFunc> Self;
public:
Node* _node;
HashTable<K, T, KeyOfT, HashFunc>* _pht;
__HTIterator(){}
__HTIterator(Node* node, HashTable<K, T, KeyOfT, HashFunc>* pht)
:_node(node)
,_pht(pht)
{}
Self& operator++()
{
if (_node->_next)
{
_node = _node->_next;
}
else
{
KeyOfT kot;
HashFunc hf;
size_t hashi = hf(kot(_node->_data));
hashi %= _pht->_tables.size();
++hashi;
//找下一个不为空的桶
for (; hashi < _pht->_tables.size(); ++hashi)
{
if (_pht->_tables[hashi])
{
_node = _pht->_tables[hashi];
break;
}
}
//没有找到不为空的桶,用nullptr标识
if (hashi >= _pht->_tables.size())
{
_node = nullptr;
}
}
return *this;
}
bool operator!=(const Self& s )const
{
return _node != s._node;
}
bool operator==(const Self& s)const
{
return _node == s._node;
}
T& operator*()
{
return _node->_data;
}
T* operator->()
{
return &_node->_data;
}
};
//set:HashTableK, K, SetKeyOfT, HashFunc = DefaultHash<K>>
//map:HashTable<K, pari<K, V>, MapKeyOfT, HashFunc = DefaultHash<K>>
template<class K, class T, class KeyOfT, class HashFunc>
class HashTable
{
template<class K, class T, class KeyOfT, class HashFunc>
friend class __HTIterator;
public:
typedef HashNode<T> Node;
typedef __HTIterator<K, T, KeyOfT, HashFunc> iterator;
iterator begin()
{
for (size_t i = 0; i < _tables.size(); i++)
{
Node* cur = _tables[i];
if (cur)
{
return iterator(cur, this);
}
}
return end();
}
iterator end()
{
return iterator(nullptr, this);
}
~HashTable()
{
for (size_t i = 0; i < _tables.size(); ++i)
{
Node* cur = _tables[i];
while (cur)
{
Node* next = cur->_next;
delete cur;
cur = next;
}
_tables[i] = nullptr;
}
}
HashTable()
{}
HashTable<K, T, KeyOfT, HashFunc>& operator=(const HashTable<K, T, KeyOfT, HashFunc>& ht)
{
HashTable<K, T, KeyOfT, HashFunc> tmp(ht);
_tables.swap(tmp._tables);
return *this;
}
HashTable(const HashTable<K, T, KeyOfT, HashFunc>& ht)
{
_tables.resize(ht._tables.size(), nullptr);
for (size_t i = 0; i < ht._tables.size(); i++)
{
Node* cur = ht._tables[i];
while (cur)
{
Insert(cur->_kv);
cur = cur->_next;
}
}
}
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
};
//获取比当前值(prime)大的素数
size_t i = 0;
for (; i < PRIMECOUNT; ++i)
{
if (primeList[i] > prime)
return primeList[i];
}
return primeList[i];
}
pair<iterator, bool> Insert(const T& data)
{
HashFunc hf;
KeyOfT kot;
iterator pos = Find(kot(data));
if (pos != end())
{
return make_pair(pos, false);
}
//空间不够,进行扩容
if (_tables.size() == _n)
{
//size_t newSize = _tables.size() == 0 ? 10 : 2 * _tables.size();
size_t newSize = GetNextPrime(_tables.size());
//用素数来作为哈希表的容量大小可以一定的减少哈希冲突
vector<Node*> newTables;
newTables.resize(newSize, nullptr);
for (size_t i = 0; i < _tables.size(); i++)
{
Node* cur = _tables[i];
while (cur)
{
Node* next = cur->_next;
size_t hashi = hf(kot(cur->_data));
hashi %= newSize;
cur->_next = newTables[hashi];
newTables[hashi] = cur;
cur = next;
}
_tables[i] = nullptr;
}
newTables.swap(_tables);
}
size_t hashi = hf(kot(data));
hashi %= _tables.size();
Node * newNode = new Node(data);
newNode->_next = _tables[hashi];
_tables[hashi] = newNode;
++_n;
return make_pair(iterator(newNode, this), true);
}
iterator Find(const K& key)
{
if (_tables.size() == 0)
{
return iterator(nullptr, this);
}
HashFunc hf;
KeyOfT kot;
size_t hashi = hf(key);
hashi %= _tables.size();
Node* cur = _tables[hashi];
while (cur)
{
if (kot(cur->_data) == key)
{
return iterator(cur, this);
}
cur = cur->_next;
}
return iterator(nullptr, this);
}
bool Erase(const K& key)
{
//问:如果不用prev指针,还能用什么方法?
//答:有另外两种方法,但是依旧只推荐使用prev指针的方法
//1.判断cur->_next->kv.first是否等于key
//2.替换法删除,将cur节点和_tables[hashi]替换(替换kv即可),然后删除_tables[hashi]
if (_tables.size() == 0)
return false;
HashFunc hf;
size_t hashi = hf(key);
hashi %= _tables.size();
Node* cur = _tables[hashi];
Node* prev = nullptr;
while (cur)
{
if (cur->_kv.first == key)
{
//删除节点
if (prev == nullptr)//也可以写成prev == nullptr的情况
{
_tables[hashi] = cur->_next;
}
else
{
prev->_next = cur->_next;
}
delete cur;
return true;
}
prev = cur;
cur = cur->_next;
}
return false;
}
private:
//指针数组
vector<Node*> _tables;
size_t _n = 0;
};
UnorderedMap.h文件
#pragma once
#include"Hash.h"
namespace Test
{
template<class K, class V, class HashFunc = DefaultHash<K>>
class UnorderedMap
{
struct MapKeyOfT
{
const K& operator()(const pair<K, V>& key)
{
return key.first;
}
};
public:
typedef typename HashTable<K, pair<K, V>, MapKeyOfT, HashFunc>::iterator iterator;
pair<iterator, bool> insert(const pair<K, V>& kv)
{
return _ht.Insert(kv);
}
iterator find(const K& key)
{
return _ht.Find(key);
}
iterator begin()
{
return _ht.begin();
}
iterator end()
{
return _ht.end();
}
iterator erase(const K& key)
{
return _ht.Erase(key);
}
V& operator[](const K& key)
{
pair<iterator, bool> ret = insert(make_pair(key, V()));
return ret.first->second;
}
private:
HashTable<K, pair<K, V>, MapKeyOfT, HashFunc> _ht;
};
}
UnorderedSet.h文件
#pragma once
#include"Hash.h"
namespace Test
{
template<class K, class HashFunc = DefaultHash<K>>
class UnorderedSet
{
public:
struct SetKeyOfT
{
const K& operator()(const K& key)
{
return key;
}
};
typedef typename HashTable<K, K, SetKeyOfT, HashFunc>::iterator iterator;
pair<iterator, bool> insert(const K& key)
{
return _ht.Insert(key);
}
//问:此处为什么要加typename?
//答:因为编译器无法区分这个是变量还是内嵌类型
iterator begin()
{
return _ht.begin();
}
iterator end()
{
return _ht.end();
}
iterator find(const K& key)
{
return _ht.Find(key);
}
iterator erase(const K& key)
{
return _ht.Erase(key);
}
private:
HashTable<K, K, SetKeyOfT, HashFunc> _ht;
};
}
4. 哈希的应用
4.1 位图
4.1.1 位图概念
-
面试题
给40亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在这40亿个数中。
-
遍历,时间复杂度O(N)
-
排序(O(NlogN)),利用二分查找: logN(外排序不能很好的支持随机访问)
-
位图解决
数据是否在给定的整形数据中,结果是在或者不在,刚好是两种状态,那么可以使用一 个二进制比特位来代表数据是否存在的信息,如果二进制比特位为1,代表存在,为0代表不存在。比如:
-
-
位图概念
所谓位图,就是用每一位来存放某种状态,适用于海量数据,数据无重复的场景。通常是用来判断某个数据存不存在的。
4.1.2 位图的模拟实现
1GB = 1024MB
1024MB = 1024 * 1024 KB
1024 * 1024KB = 1024 * 1024 * 1024Byte = 10亿字节 = 230字节 = 80亿比特位
即最大无符号整数个比特位需要512M内存。
namespace Test
{
//N个比特位的位图
template<size_t N>
class bitset
{
public:
bitset()
{
//+1保证足够的特位,最多浪费8个比特位
_bits.resize(N / 8 + 1, 0);
}
//x映射的比特位标记成1
void set(size_t x)
{
//i是x映射的比特位在第几个字节
size_t i = x / 8;
//x在char的第几个比特位
size_t j = x % 8;
_bits[i] |= (1 << j);
}
void reset(size_t x)
{
//i是x映射的比特位在第几个字节
size_t i = x / 8;
//x在char的第几个比特位
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;
};
}
注意:INT_MAX宏是整数的最大值(+2147483647)。INT_MIN是整数的最小值(-2147483648)。
问:如何表示无符号整数的最大值?
答:
unsigned(-1)
或者0XFFFFFFFF
4.1.3 位图的应用
- 快速查找某个数据是否在一个集合中
- 排序 + 去重
- 求两个集合的交集、并集等
- 操作系统中磁盘块标记
4.1.4 C++库中的位图
接口函数:
当然,最关键的接口还是set、reset和test。
4.2 布隆过滤器
4.2.1 布隆过滤器提出
我们在使用新闻客户端看新闻时,它会给我们不停地推荐新的内容,它每次推荐时要去重,去掉那些已经看过的内容。问题来了,新闻客户端推荐系统如何实现推送去重的? 用服务器记录了用 户看过的所有历史记录,当推荐系统推荐新闻时会从每个用户的历史记录里进行筛选,过滤掉那 些已经存在的记录。 如何快速查找呢?
- 用哈希表存储用户记录,缺点:浪费空间
- 用位图存储用户记录,缺点:位图一般只能处理整形,如果内容编号是字符串,就无法处理 了。
- 将哈希与位图结合,即布隆过滤器
布隆过滤器对上面的这种解决方案进行了改进。
4.2.2布隆过滤器概念
布隆过滤器是由布隆(Burton Howard Bloom)在1970年提出的 一种紧凑型的、比较巧妙的概率型数据结构,特点是高效地插入和查询,可以用来告诉你 “某样东西一定不存在或者可能存在”,它是用多个哈希函数,将一个数据映射到位图结构中。此种方式不仅可以提升查询效率,也可以节省大量的内存空间。
https://zhuanlan.zhihu.com/p/43263751/
4.2.3 布隆过滤器的模拟实现
#include<bitset>
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<class K, size_t M, class HashFunc1, class HashFunc2, class HashFunc3>
class BloomFilter
{
public:
void Set(const K& key)
{
size_t hash1 = HashFunc1()(key) % M;
size_t hash2 = HashFunc2()(key) % M;
size_t hash3 = HashFunc3()(key) % M;
_bs.set(hash1);
_bs.set(hash2);
_bs.set(hash3);
}
bool Test(const K& key)
{
size_t hash1 = HashFunc1()(key) % M;
size_t hash2 = HashFunc2()(key) % M;
size_t hash3 = HashFunc3()(key) % M;
return _bs.test(hash1) && _bs.test(hash2) && _bs.test(hash3);
}
private:
bitset<M> _bs;
};
4.2.4 布隆过滤器的查找
布隆过滤器的思想是将一个元素用多个哈希函数映射到一个位图中,因此被映射到的位置的比特 位一定为1。所以可以按照以下方式进行查找:分别计算每个哈希值对应的比特位置存储的是否为零,只要有一个为零,代表该元素一定不在哈希表中,否则可能在哈希表中。
注意:布隆过滤器如果说某个元素不存在时,该元素一定不存在,如果该元素存在时,该元素可 能存在,因为有些哈希函数存在一定的误判。
比如:在布隆过滤器中查找"alibaba"时,假设3个哈希函数计算的哈希值为:1、3、7,刚好和其 他元素的比特位重叠,此时布隆过滤器告诉该元素存在,但实该元素是不存在的。
4.2.5 布隆过滤器删除
布隆过滤器不能直接支持删除工作,因为在删除一个元素时,可能会影响其他元素。
一种支持删除的方法:将布隆过滤器中的每个比特位扩展成一个小的计数器,插入元素时给k个计 数器(k个哈希函数计算出的哈希地址)加一,删除元素时,给k个计数器减一,通过多占用几倍存储空间的代价来增加删除操作(此时布隆过滤器的每一个位上存在的不是在与不在,而是计数,即出现的次数)。
注意:删除后这个值仍然存在,这是误判造成的,而不是删除造成的。
缺陷:
- 无法确认元素是否真正在布隆过滤器中
- 存在计数回绕
4.2.6 布隆过滤器优点
- 增加和查询元素的时间复杂度为:O(K), (K为哈希函数的个数,一般比较小),与数据量大小无 关
- 哈希函数相互之间没有关系,方便硬件并行运算
- 布隆过滤器不需要存储元素本身,在某些对保密要求比较严格的场合有很大优势
- 在能够承受一定的误判时,布隆过滤器比其他数据结构有这很大的空间优势
- 数据量很大时,布隆过滤器可以表示全集,其他数据结构不能
- 使用同一组散列函数的布隆过滤器可以进行交、并、差运算
4.2.7 布隆过滤器缺陷
- 有误判率,即存在假阳性(False Position),即不能准确判断元素是否在集合中(补救方法:再建立一个白名单,存储可能会误判的数据)
- 不能获取元素本身
- 一般情况下不能从布隆过滤器中删除元素
- 如果采用计数方式删除,可能会存在计数回绕问题
4.2.8 布隆过滤器的应用场景
布隆过滤器主要是作为过滤器快速判断一个数据是否不存在。
- 注册用户名昵称的时候,快速判断一个昵称或者用户名是否不存在,判断存在时会出现误判,所以如果判断已经存在的情况,还需要到数据库中重新筛查判断
- 黑名单,判断某个用户是否不在黑名单中,决定是否放行某个用户,如果不在就放行,如果在就再次去系统进行确认
5. 海量数据面试题
5.1 哈希切割
给一个超过100G大小的log file, log中存着IP地址, 设计算法找到出现次数最多的IP地址? 与上题条件相同,如何找到top K的IP?如何直接用Linux系统命令实现?
5.2 位图应用
-
给定100亿个整数,设计算法找到只出现一次的整数?
注意:虽然给定的是100亿个整数,但是位图的应用并不是将100亿个整数存储在内存中,而是用1G的空间来表示0到最大无符号整数出现次数的三种状态:出现0次,出现1次,出现2次及两次以上。
00表示未出现过,01表示出现一次,10表示出现一次及一次以上。
模拟实现:
template<size_t N> class two_bitset { public: void set(size_t x) { bool in1 = _bs1.test(x); bool in2 = _bs2.test(x); if (in1 == false && in2 == false) { _bs2.set(x); } else if (in1 == false || in2 == true) { _bs1.set(x); _bs2.reset(x); } } bool is_once(size_t x) { return _bs1.test(x) == false && _bs2.test(x) == true; } private: //用两个位图去表示两个比特位,实现对前面位图的复用 bitset<N> _bs1; bitset<N> _bs2; };
-
给两个文件,分别有100亿个整数,我们只有1G内存,如何找到两个文件交集?
-
位图应用变形:1个文件有100亿个int,1G内存,设计算法找到出现次数不超过2次的所有整数
位图应用1的变形,出现0次为0,出现1次为01,两次为10,三次及以上为11,实现与位图的应用1类似。
5.3 布隆过滤器
-
给两个文件,分别有100亿个query,我们只有1G内存,如何找到两个文件交集?分别给出 精确算法和近似算法
query:SQL网络请求,是一个字符串
近似算法:布隆过滤器
精确算法:
假设每个query是30个字节,100亿query就是300G(10亿字节是1G)的空间。
使用哈希切分的思想:
-
如何扩展BloomFilter使得它支持删除元素的操作
计数器。
6. 分布式存储(哈希的应用)
一致性哈希:
一致性hash算法正是为了解决此类问题的方法,它可以保证当机器增加或者减少时,节点之间的数据迁移只限于两个节点之间,不会造成全局的网络问题。
1. 环形Hash空间
按照常用的hash算法来将对应的key哈希到一个具有232次方个桶的空间中,即0~(232)-1的数字空间中。现在我们可以将这些数字头尾相连,想象成一个闭合的环形。如下图:
2. 将数据通过hash算法映射到环上
将object1、object2、object3、object4四个对象通过特定的Hash函数计算出对应的key值,然后散列到Hash环上。如下图:
Hash(object1) = key1;
Hash(object2) = key2;
Hash(object3) = key3;
Hash(object4) = key4;
3. 将机器通过hash算法映射到环上
假设现在有NODE1,NODE2,NODE3三台机器,通过Hash算法(机器IP或机器的唯一的名称作为输入)得到对应的KEY值,映射到环中,其示意图如下:
Hash(NODE1) = KEY1;
Hash(NODE2) = KEY2;
Hash(NODE3) = KEY3;
4. 将数据存储到机器上
通过上图可以看出对象与机器处于同一哈希空间中,这样按顺时针转动object1存储到了NODE1中,object3存储到了NODE2中,object2、object4存储到了NODE3中。
5. 机器的添加与删除
-
向集群中添加一台新机器
向集群中增加机器c4,c4经过hash函数后映射到机器c2和c3之间。这时根据顺时针存储的规则,数据m4从机器c2迁移到机器c4。数据的移动仅发生在c2和c4之间,其他机器上的数据并未受到影响。 -
从集群中删除一台机器
从集群中删除机器c1,这时只有c1原有的数据需要迁移到机器c3,其他数据并未受到影响。相比于之前的简单取模方法中动态增删集群中机器的数量时,造成全局的数据迁移,使用一致性哈希算法将大大改善这种情况,减轻了网络通信的压力。
存在的问题:
当集群中的节点数量较少时,可能会出现节点在哈希空间中分布不平衡的问题。如下图所示,图中节点A、B、C分布较为集中,造成hash环的倾斜。数据1、2、3、4、6全部被存储到了节点A上,节点B上只存储了数据5,而节点C上什么数据都没有存储。A、B、C三台机器的负载极其不均衡。
在极端情况下,假如A节点出现故障,存储在A上的数据要全部转移到B上,大量的数据导可能会导致节点B的崩溃,之后A和B上所有的数据向节点C迁移,导致节点C也崩溃,由此导致整个集群宕机。这种情况被称为雪崩效应。
解决方法——虚拟节点
解决哈希环偏斜问题的方法就是,让集群中的节点尽可能的多,从而让各个节点均匀的分布在哈希空间中。在现实情境下,机器的数量一般都是固定的,所以我们只能将现有的物理节通过虚拟的方法复制多个出来,这些由实际节点虚拟复制而来的节点被称为虚拟节点。加入虚拟节点后的情况如下图所示:
从上图可得:加入虚拟节点后,节点A存储数据1、3;节点B存储5、4;节点C存储2、6。节点的负载很均衡。