哈希或者散列是一种思想:即通过某个转换函数在某两种数据之间建立映射关系,利用这种思想建立的数据结构都可以看作是哈希结构,这个转换函数就被称为哈希函数。
这种思想在计算机中的一大应用就是哈希表:利用哈希函数建立数据的关键字和数据存储位置之间的映射关系,这样就可以快速地对某个数据进行查找。一般是先将数据关键字输入到哈希函数中得到一个哈希函数值,然后在哈希表中把数据存放到以该哈希函数值为下标的位置即可。
常见整型数据的哈希函数
由于我们不可能保证我们创建的哈希函数是单射的,这就意味着不同的关键字通过该哈希函数得到的函数值可能会相同,这会导致不同的关键字找到了同一个存储地址,这显然不是我们希望看到的,因此我们需要合理地设计哈希函数,尽量减少冲突。
哈希函数设计原则:
1.哈希函数定义域必须包括需要存储的数据的所有关键字
2.哈希函数计算出的地址应均匀的分布在存储空间中
3.哈希函数应尽可能简单
常见的整型数据的哈希函数如下(假设关键字的数据类型是size_t):
1.直接定址法–(常用)
直接取关键字的某个线性函数作为散列地址:Hash(Key) = A*Key+B
这种办法简单且得到的地址均匀,但只适用于关键字分布较为集中但不重叠的情况。
2.除余留数法–(常用)
假设散列表允许的地址数为m,取一个不大于m但接近m的数(一般取质数)p作为取余除数,即Hash(Key) = Key%p。
3.平方取中法–(了解)
假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址;再比如关键字为4321,对它平方就是18671041,抽取中间的3位671(或710)作为哈希地址,平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况。
4.折叠法–(了解)
折叠法是将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。折叠法适合事先不需要知道关键字的分布,关键字位数比较多的情况。
5.随机数法–(了解)
选择一个随机函数,取关键字的随机函数值为它的哈希地址,即H(key) = random(key),其中random为随机数函数。通常应用于关键字长度不等时采用此法。
6.数学分析法–(了解)
设有n个d位数,每一位可能有r种不同的符号,这r种不同的符号在各位上出现的频率不一定相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只有某几种符号经常出现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散列地址。例如:假设要存储某家公司员工登记表,如果用手机号作为关键字,那么极有可能前7位都是 相同的,那么我们可以选择后面的四位作为散列地址,如果这样的抽取工作还容易出现 冲突,还可以对抽取出来的数字进行反转(如1234改成4321)、右环位移(如1234改成4123)、左环移位、前两数与后两数叠加(如1234改成12+34=46)等方法。数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布较均匀的情况。
由于数据的关键字的数据类型不一定是size_t,我们还需要将数据的关键字映射到size_t上:即先用某个哈希函数将关键字key的数据类型映射到size_t上得到s_key,接着再用整型数据的哈希函数将s_key映射成哈希表的位置。
例如倘若关键字为字符串类型,我们常采用的哈希函数为:逐个相加每个字符的ASCAII码,并在每次相加后将结果乘以一个权值(一般取131),这样就将string类型映射到size_t上了。字符串Hash函数
需要注意的是,哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突。
哈希冲突的解决
无论哈希函数设计的多么巧妙,我们也无法保证其不会产生哈希冲突,一旦出现哈希冲突,就需要进行补救措施,常见的哈希冲突的解决办法有2种:闭散列和开散列。
这里补充一下负载因子的概念,哈希表的大小一般会大于数据个数,数据个数和哈希表的大小的比值被称为负载因子,为了减少哈希冲突但又不浪费空间,一般负载因子的大小在0.7左右比较合适。
闭散列
闭散列也称为开放地址法,由于我们不会让哈希表被填满,因此当哈希冲突产生时,表中一定还有空间没有被使用,我们只需要按照某种探测方法去寻找这些没有被使用的空间,一旦寻找到某个未被使用的空间就将数据存放到该空间中即可。在进行数据查找时,我们只需要再按照探测空间的方法去寻址,直到遇到未被使用的空间为止,如果该数据存在就一定可以找到。
常见的空间探测方法有2种:
1.线性探测
将哈希函数值作为下标在哈希表中找到对应位置,如果该位置未被使用,直接在该位置存放数据即可,如果该位置被使用了,从该位置开始,逐个位置往后探测,如果到了表尾,则跳到表头继续往后探测,直至遇到没有被使用的位置为止。这种方法实现简单,但当发生哈希冲突时其占用了其他哈希函数值对应位置的空间,引发“恶性循环”,导致某些关键码查找时需要多次寻址,降低数据查找效率。
2.二次探测
将哈希函数值作为下标在哈希表中找到对应位置,如果该位置未被使用,直接在该位置存放数据即可,如果该位置被使用了,其寻找下一个空位置的方法为:
H
i
H_i
Hi = (
H
0
H_0
H0 +
i
2
i^2
i2 )% m, 或者:
H
i
H_i
Hi = (
H
0
H_0
H0 -
i
2
i^2
i2 )% m。其中:i =
1,2,3…,
H
0
H_0
H0是通过哈希函数Hash(x)对元素的关键码 key 进行计算得到哈希函数值,m是表的大小。
研究表明:当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容。
二次探测只是在一定程度上让查找效率不降低得那么明显,在我们看来其效果依旧不够理想。
对于闭散列,还有其他的空间探测方法,这里不再展示,由上我们也可以看出,闭散列不仅空间利用率低,还会降低查找效率。
下面利用除余留数法作为哈希函数,采用线性探测的方法实现哈希表的查找、插入、删除功能。
#include<iostream>
#include<string>
#include<vector>
#include<utility>
using namespace std;
// 注意:实现的哈希表中元素唯一,即key相同的元素不再进行插入
//用于标记哈希表中位置的状态
enum State { EMPTY, EXIST, DELETE };
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),
_capacity(capacity)
{
for (size_t i = 0; i < capacity; ++i)
_ht[i]._state = EMPTY;
}
// 插入
bool Insert(const pair<K, V>& val)
{
if ((float)(_size+1) / _capacity >= 0.7)
{
Expand_Capacity();
}
size_t pos = HashFunction(val.first);
while (1)
{
if (EXIST != _ht[pos]._state)
{
_ht[pos]._val = val;
_ht[pos]._state = EXIST;
++_size;
return true;
}
else
{
if (_ht[pos]._val.first == val.first)
{
return false;
}
if (++pos == _capacity)
{
pos = 0;
}
}
}
return false;
}
// 查找
size_t Find(const K& key)
{
size_t pos = HashFunction(key);
while (EMPTY != _ht[pos]._state)
{
if (EXIST== _ht[pos]._state && key == _ht[pos]._val.first)
{
return pos;
}
if (++pos == _capacity)
{
pos = 0;
}
}
return -1;
}
// 删除
bool Erase(const K& key)
{
size_t pos = Find(key);
if (-1 != pos)
{
--_size;
_ht[pos]._state = DELETE;
return true;
}
return false;
}
void Swap(HashTable<K, V>&ht)
{
swap(_size, ht._size);
swap(_capacity, ht._capacity);
_ht.swap(ht._ht);
}
private:
//整型关键字的哈希函数
template<class K>
size_t HashFunction(const K & key) const
{
return key % _ht.capacity();
}
//string类型关键字的哈希函数
template<>
size_t HashFunction<string>(const string & key) const
{
size_t ret = 0;
size_t i = 0;
while (i < key.size())
{
ret += key[i];
ret *= 131;
++i;
}
return ret %_capacity;
}
void Expand_Capacity()
{
HashTable<K, V> ht(2 * _capacity);
size_t i = 0;
while (i < _capacity)
{
if (EXIST == _ht[i]._state)
{
ht.Insert(_ht[i]._val);
}
++i;
}
this->Swap(ht);
}
private:
vector<Elem> _ht;
size_t _size;
size_t _capacity;
};
开散列
开散列又叫链地址法,我们在哈希表中不直接存放数据,而是存放一个链表的头结点。首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。如果出现一些桶元素个数过多(一般是当元素个数大于8时),那么在哈希表中这些位置就不挂链表,而是挂红黑树。
为了减少哈希冲突,我们一般选择在哈希桶个数等于元素个数时进行扩容,且一般选择的容量是前一次容量的接近2倍的素数。
使用开散列需要每个数据增设链接指针,似乎增加了存储开销。事实上:由于闭散列必须保持大量的空闲空间以确保搜索效率,如二次探查法要求装载因子在0.5左右,而表项所占空间又比指针大的多,所以使用开散列反而比闭散列节省存储空间,且当哈希表下面不挂链表而是挂一些查找效率极高的数据结构如红黑树时,那么由于哈希冲突带来的查找效率下降的负面影响就会大大降低。
下面利用除余留数法作为哈希函数,采用开散列挂链表的方法实现哈希表的查找、插入、删除功能。
#include<iostream>
#include<utility>
#include<vector>
#include<string>
using namespace std;
//用于将整型数据哈希映射到整型上面,实现这个哈希函数是为了接口统一性
template<class K>
class HashFunc
{
public:
size_t operator()(const K& val)
{
return val;
}
};
//用于将string类型数据映射到size_t类型上
template<>
class HashFunc<string>
{
public:
size_t operator()(const string& s)
{
size_t weight = 131; // 31 131 1313 13131 131313
size_t hash = 0;
int i = 0;
while (i<s.size())
{
hash = hash * weight + s[i++];
}
return hash;
}
};
//元素节点
template<class K,class V>
struct HashBucketNode
{
HashBucketNode(const pair<K,V>& data)
: _next(nullptr)
, _data(data)
{}
HashBucketNode<K,V>* _next;
pair<K,V> _data;
};
template<class K,class V, class HF = HashFunc<K>>
class HashTable
{
typedef HashBucketNode<K,V> Node;
typedef Node* pNode;
typedef HashTable<K,V, HF> Self;
public:
HashTable(size_t capacity=0)
: _table(GetNextPrime(capacity))
, _size(0)
{}
~HashTable()
{
Clear();
}
// 插入
bool Insert(const K& key,const V& val)
{
if (Size() == BucketCount())
{
ExpandCapacity();
}
size_t pos = HashFunction(key);
pNode newpNode = new Node(pair<K,V>(key,val));
if (nullptr == _table[pos])
{
++_size;
_table[pos] = newpNode;
return true;
}
pNode cur = _table[pos];
while (cur->_next)
{
if (cur->_data.first == key)
{
return false;
}
cur = cur->_next;
}
if (cur->_data.first == key)
{
return false;
}
cur->_next = newpNode;
++_size;
return true;
}
// 删除
bool Erase(const K& key)
{
size_t pos = HashFunction(key);
pNode cur = _table[pos];
pNode pre = nullptr;
while (cur)
{
if (cur->_data.first == key)
{
if (pre)
{
pre->_next = cur->_next;
}
else
{
_table[pos] = nullptr;
}
--_size;
delete cur;
return true;
}
pre = cur;
cur = cur->_next;
}
return false;
}
//查找
Node* Find(const K& key)
{
size_t pos = HashFunction(key);
pNode cur = _table[pos];
while (cur)
{
if (cur->_data.first == key)
{
return cur;
}
cur = cur->_next;
}
return nullptr;
}
size_t Size()const
{
return _size;
}
size_t BucketCount()const
{
return _table.capacity();
}
void Clear()
{
int i = 0;
while (i < _table.size())
{
pNode cur = _table[i];
while (cur)
{
pNode tmp = cur->_next;
delete cur;
cur = tmp;
}
_table[i] = nullptr;
++i;
}
}
void Swap(Self& ht)
{
_table.swap(ht._table);
std::swap(_size, ht._size);
}
private:
void ExpandCapacity()
{
size_t capacity = GetNextPrime(BucketCount());
HashTable<K, V, HF> ht(capacity-1);
int i = 0;
while (i < BucketCount())
{
pNode cur = _table[i++];
while (cur)
{
ht.Insert(cur->_data.first, cur->_data.second);
cur = cur->_next;
}
}
Swap(ht);
}
size_t HashFunction(const K& key)
{
return HF()(key) % _table.size();
}
size_t GetNextPrime(size_t capacity)
{
int i = 0;
while (i < _prime_table.size())
{
if (capacity < _prime_table[i])
{
return _prime_table[i];
}
++i;
}
return -1;
}
private:
vector<pNode> _table;
size_t _size; // 哈希表中有效元素的个数
static vector<size_t> _prime_table;//素数表
};
//素数表,可以根据需求开得很大
template<class K, class V, class HF>
vector<size_t> HashTable<K, V, HF>::_prime_table = { 5, 11, 23, 47, 97, 199, 397, 797 };
哈希的应用
哈希思想的应用除了哈希表外,还有位图、布隆过滤器、基数树等。
位图
位图就是用一个比特位存放某种状态,适用于海量数据且数据不重复的场景,数据类型只能为整型。其通常有以下应用:
1.快速查找某个数据是否在某个集合中
2.对数据进行排序和去重
3.求两个集合的交集、并集等
4.操作系统中磁盘块标记
C++也提供了位图容器:bitset
为了简便,假设数据类型为无符号整型,模拟位图:
#include<iostream>
#include<vector>
using namespace std;
template<size_t N>
class Bit_Map
{
public:
Bit_Map()
{
_bitmap.resize(N / 8 + 1, 0);
}
//将pos位置1
void Set(size_t pos)
{
char c = 1;
size_t tmp1 = pos / 8;
size_t tmp2 = 7-pos % 8;
_bitmap[tmp1] = _bitmap[tmp1] | (c << tmp2);
}
//将pos位置0
void Reset(size_t pos)
{
char c = 1;
size_t tmp1 = pos / 8;
size_t tmp2 = 7 - pos % 8;
_bitmap[tmp1] = _bitmap[tmp1] & (~(c << tmp2));
}
//查看数据num是否存在
bool Test(size_t num)
{
char c = 1;
size_t tmp1 = num / 8;
size_t tmp2 = 7 - num % 8;
return _bitmap[tmp1] & (c << tmp2);
}
private:
vector<char> _bitmap;
};
如果数据有多种状态,可以给每个数据分配多个比特位,直至可以标记这些状态为止。
位图面试题:
- 给定100亿个整数,设计算法找到只出现一次的整数?
思路:
由于数据的状态有3种:
①出现0次
②出现1次
③出现1次以上
因此我们需要2个比特位标记数据的状态,由于整型数据范围有限,可以使用两个比特位位数大小都为整型数据最大范围的位图,两个位图对应位置的比特位合起来就是这个位置的数据的状态。所以我们需要遍历一遍这100亿个数据将其状态标记到位图中,再遍历一遍位图即可找到只出现一次的数据。
- 给两个文件,分别有100亿个int型整数,我们只有1G内存,如何找到两个文件交集?
思路:1G内存有10亿多个字节,即80多亿个比特位,意味着1G内存可以标记80亿个不同的数据的2种状态,int型整数只有42多亿和不重复数值,因此可以在内存中开一个42多亿个比特位大小的位图,读取第一的文件数据,将其状态标记到位图中,再读取第二个文件,将第二个文件的数据也映射到位图中,如果该位置已被标记为存在,则这个数据属于两个文件的交集,同时为了避免数据重复,需要将该位置标记成不存在。
- 位图应用变形:1个文件有100亿个int,1G内存,设计算法找到出现次数不超过2次的所有整数
思路:显然我们需要2个比特位标记数据的4种状态
①出现0次
②出现1次
③出现2次
④出现2次以上
1G内存刚好可以存放2个大小为42多亿个比特位大小位图,与1类似,我们需要遍历一遍这100亿个数据将其状态标记到位图中,再遍历一遍位图即可找到出现次数不超过2次的数据。
布隆过滤器
布隆过滤器是一种概率型数据结构,用于确定某个数据一定不存在或者可能存在,其进行插入和查询十分高效。其通过使用多个哈希函数将一个数据映射到位图结构中的多个位置上,我们进行查询数据是否存在时只需将数据输入各个哈希函数得到对应的输出值,然后再到位图对应位置查找就可以了。尽管在位图中显示了所有哈希函数的输出值都存在,但由于哈希冲突的存在,我们依然不能确定该数据是否存在,但当某个哈希函数的输出值在位图中显示不存在时,我们一定可以确定这个数据不存在。因此布隆过滤器存在误报率,我们使用的哈希函数越多,那么该数据在位图中映射的位置越多,误报率就越低,这也意味着位图需要开更大的空间,其长度一般为L=K*N*ln2,其中L为布隆过滤器长度,K为哈希函数个数,N为数据个数。
布隆过滤器在实际中的应用主要是起到数据过滤作用,例如当用户想要大量查询某些数据在不在时,如果直接到底层数据库进行查找,由于数据库查询比较缓慢,这种查询方式的效率较低;但如果我们先用布隆过滤器对数据先简单过滤一下,倘若布隆过滤器显示该数据不在,那么该数据一定不在,倘若隆过滤器显示该数据存在,我们再到底层数据库去查询该数据是否存在,那么查询效率将会有很大的改善。
template<size_t N>
class Bit_Map
{
public:
Bit_Map()
{
_bitmap.resize(N / 8 + 1, 0);
}
//将pos位置1
bool Set(size_t pos)
{
if (pos > N)
{
return false;
}
char c = 1;
size_t tmp1 = pos / 8;
size_t tmp2 = 7 - pos % 8;
_bitmap[tmp1] = _bitmap[tmp1] | (c << tmp2);
return true;
}
//将pos位置0
bool Reset(size_t pos) const
{
if (pos > N)
{
return false;
}
char c = 1;
size_t tmp1 = pos / 8;
size_t tmp2 = 7 - pos % 8;
_bitmap[tmp1] = _bitmap[tmp1] & (~(c << tmp2));
return true;
}
//查看数据num是否存在
bool Test(size_t num)
{
char c = 1;
size_t tmp1 = num / 8;
size_t tmp2 = 7 - num % 8;
return _bitmap[tmp1] & (c << tmp2);
}
private:
vector<char> _bitmap;
};
//假设数据类型为string,用3个哈希函数将数据映射到位图上
template<size_t N>
class Bloom_Filter
{
public:
bool Insert(const string& s)
{
size_t pos1 = HashFun(HashFunction1(s));
size_t pos2 = HashFun(HashFunction2(s));
size_t pos3 = HashFun(HashFunction3(s));
return _bt.Set(pos1) && _bt.Set(pos2) && _bt.Set(pos3);
}
bool Find(const string& s)
{
size_t pos1 = HashFun(HashFunction1(s));
size_t pos2 = HashFun(HashFunction2(s));
size_t pos3 = HashFun(HashFunction3(s));
return _bt.Test(pos1) && _bt.Test(pos2) && _bt.Test(pos3);
}
private:
//将整型数据映射到位图上
size_t HashFun(size_t num)
{
return num % (5*N);
}
//将string型数据映射到整型上
size_t HashFunction1(const string& s)
{
size_t weight = 131;
size_t hash = 0;
int i = 0;
while (i < s.size())
{
hash = hash * weight + s[i++];
}
return hash;
}
size_t HashFunction2(const string& s)
{
size_t hash = 0;
int i;
for (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;
}
size_t HashFunction3(const string& s)
{
size_t hash = 5381;
int i = 0;
while (i<s.size())
{
hash += (hash << 5) + s[i++];
}
return hash;
}
Bit_Map<3*N> _bt;
};
布隆过滤器不能直接支持删除工作,因为不同元素在多个哈希函数计算出的比特位上会有重叠,在删除一个元素时可能会影响其他元素。
一种支持删除的方法是:将布隆过滤器中的每个比特位扩展成一个小的计数器,插入元素时给k个计数器(k个哈希函数计算出的哈希地址)加一,删除元素时,给k个计数器减一,通过多占用几倍存储空间的代价来增加删除操作。
布隆过滤器优点
- 增加和查询元素的时间复杂度为:O(K), (K为哈希函数的个数,一般比较小),与数据量大小无关
- 哈希函数相互之间没有关系,方便硬件并行运算
- 布隆过滤器不需要存储元素本身,在某些对保密要求比较严格的场合有很大优势
- 在能够承受一定的误判时,布隆过滤器比其他数据结构有很大的空间优势
- 数据量很大时,布隆过滤器可以表示全集,其他数据结构不能
- 使用同一组散列函数的布隆过滤器可以进行交、并、差运算
布隆过滤器缺陷
- 有误判率,即存在假阳性(False Position),不能准确判断元素是否在集合中(补救方法:再建立一个白名单,存储可能会误判的数据)
- 不能获取元素本身
- 一般情况下不能从布隆过滤器中删除元素
- 如果采用计数方式删除,可能会存在计数回绕问题
哈希切割
哈希思想除了创造出一些具有特定功能的哈希结构之外,还提供了解决一些问题的方法。
面试题:
(一)给一个超过100G大小的log file, log中存着IP地址, 设计算法找到出现次数最多的IP地址?
思路:100G的数据显然是无法直接加载到内存进行大小比较的,我们需要对这100G的文件进行拆解,假设我们将其分解成1000个小文件,现对每个文件进行编号,编号为1到1000,我们这需要一个哈希函数Hash=(hash(log)+1)%1000,保证哈希函数值为1到1000,接着读取这100G大小的文件,将数据存放到与其哈希函数值对应的文件中,那么相同的数据一定会被放到同一个文件中,此时我们只需将被这些小文件逐个加载到内存中,得到每个文件的中出现次数最多的IP地址,接着再对得到的1000个个数比较一次就可以了。由于对文件的拆分不是均等拆分,可能会有些拆解出的文件依旧过大无法放入内存,这时我们需要对这些大文件进行一下分类:
-
倘若该文件中没有数据的大量重复
我们只需要换另一个哈希函数再对这些过大的文件进行拆解即可 -
倘若该文件中拥有大量重复数据(极端情况下全部是重复数据)
我们只需要将数据放入map容器即可,因为map容器会对数据进行去重并记录重复数据的个数
此时如果还有大文件存在,只需重复以上步骤直至文件可以被加载到内存进行比较即可。
综上:我们只需先从大文件中读取数据插入到map中,如果map出现了异常,就换另一个哈希函数对文件进行拆解,如此递归式重复以上步骤直至文件可以被加载到内存进行比较为止。
与上题条件相同,如何找到top K的IP?
同上对文件进行哈希切割,此时只需要建一个K个元素的小堆,统计出每个文件的IP及其出现次数,以IP的出现次数为关键字插入小堆即可,程序执行完毕后小堆里存的就是出现次数为top K的IP。
(二) 给两个文件,分别有100亿个query,我们只有1G内存,如何找到两个文件交集?分别给出精确算法和近似算法
思路:
精确算法:假设两个文件为A、B,只需要使用同一个哈希函数分别对这两个文件进行哈希切割,如果某个数据是交集数据,那么其在A中被切割到的文件编号一定与其在B文件被切割到的文件编号相同,因此我们只需要在编号相同的文件中找交集,接着将所有交集合并就是A、B文件的交集,而找两个文件的交集我们只需要利用位图即可。
近似算法:如果只是近似求解,我们只需用布隆过滤器先将一个文件的数据映射到存储中,接着再将第二个文件的数据也映射到存储中,如果该数据在映射时发现对应位置都显示该数据已经存在,则可以近似认为该数据属于交集。由于此时布隆过滤器的比特位的个数只有1G内存的比特位个数,因此误判率可能较高。