哈希
- 1. 哈希概念
- 2. 哈希冲突
- 3. 哈希冲突解决
- 3.1 哈希表的闭散列
- 3.2 哈希表的开散列
- 4. 哈希的应用
- 4.1 位图
- 4.2 布隆过滤器
哈希(Hash)是一种将任意长度的二进制明文映射为较短的二进制串的算法。它是一种重要的存储方式,也是一种常见的检索方法。哈希函数通过特定方式(hash函数)处理输入,生成一个值。这个值等同于存放数据的地址,这个地址里面再把输入的数据进行存储。 哈希算法是一种以较短的信息来保证文件唯一性的标志,这种标志与文件的每一个字节都相关,而且难以找到逆向规律。因此,当原文件发生改变时,其标志的位置也会发生改变,此时的对应方式就不再适应,需要将数据重新进行标对应的位置。
顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即O(
l
o
g
2
N
log_2 N
log2N),搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。
1. 哈希概念
如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。
当向该结构中:
- 插入元素:根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放。
- 搜索元素:对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功。
该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表。散列方法的主要思想是根据结点的关键码值来确定其存储地址:以关键码值K为自变量,通过一定的函数关系h (K) (称为散列函数),计算出对应的函数值来,把这个值解释为结点的存储地址,将结点存入到此存储单元中。 检索时,用同样的方法计算地址,然后到相应的单元里去取要找的结点。 通过散列方法可以对结点进行快速检索。
用该方法进行搜索不必进行多次关键码的比较,因此搜索的速度比较快。
如上图,如果再插入14,会出现什么问题?会出现哈希冲突。
2. 哈希冲突
哈希冲突是指两个或多个不同的键值被哈希函数映射到了同一个地址中的情况。这种情况下,一个地址对应多个键值对,而查找时只能找到其中一个键值对,因此会导致查找失败。
引起哈希冲突的一个原因可能是:哈希函数设计不够合理。
哈希函数设计原则:1.哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0到m-1之间;2.哈希函数计算出来的地址能均匀分布在整个空间中。
常见哈希函数
- 直接定址法–(常用)
取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B
优点:简单、均匀;缺点:需要事先知道关键字的分布情况;使用场景:适合查找比较小且连续的情况 - 除留余数法–(常用)
设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址 - 平方取中法–(了解)
假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址;再比如关键字为4321,对它平方就是18671041,抽取中间的3位671(或710)作为哈希地址;平方取中法比较适合的情况:不知道关键字的分布,而位数又不是很大的情况。
3. 哈希冲突解决
哈希冲突是哈希表中常见的问题,解决哈希冲突的方法有很多种,两种常见的方法是:闭散列和开散列。
3.1 哈希表的闭散列
闭散列是一种解决哈希冲突的方法,它将所有的关键字都保存在散列表中,而不是像开放地址法那样只保存一部分。在闭散列中,每个桶都是一个链表,当发生哈希冲突时,新的元素会被插入到对应桶的链表中。这种方法可以避免开放地址法中的聚集现象,并且可以在空间充足的情况下实现快速查找。
线性探索
如上图的场景,现在需要插入元素14,先通过哈希函数计算哈希地址,hashAddr为4,因此14理论上应该插在该位置,但是该位置已经放了值为4的元素,即发生哈希冲突。
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
插入:通过哈希函数获取待插入元素在哈希表中的位置
如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素。
删除:采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。比如删除元素4,如果直接删除掉,14查找起来可能会受影
响。因此线性探测采用标记的伪删除法来删除一个元素。
哈希表扩容是指在哈希表中插入新元素时,如果桶的数量不足以容纳新元素,就需要增加桶的数量。哈希表扩容的过程包括以下几个步骤:
1.创建一个新的桶数组,大小为原来的两倍;2.将原来的桶数组中的元素重新哈希到新的桶数组中;3.释放原来的桶数组。
在哈希表扩容的过程中,需要重新计算每个元素在新桶数组中的位置,这个过程需要消耗一定的时间。因此,在设计哈希表时,需要根据实际情况选择合适的桶数量,以避免频繁扩容。
哈希表的负载因子是指哈希表中已经存储的元素个数与容量的数量之比。负载因子越大,哈希冲突的概率就越大,查找、插入和删除操作的效率也会降低。一般来说,当负载因子超过某个阈值时,就需要对哈希表进行扩容,以保证哈希表的性能。
代码如下:
namespace OpenAddress
{
enum State
{
EMPTY,
EXIST,
DELETE
};
template<class K, class V>
struct HashData
{
pair<K, V> _kv;
State _state = EMPTY;
};
template<class K, class V>
class HashTable
{
public:
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
return false;
// 负载因子超过0.7就扩容
//if ((double)_n / (double)_tables.size() >= 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;
}
HashData<K, V>* 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)
{
HashData<K, V>* ret = Find(key);
if (ret)
{
ret->_state = DELETE;
--_n;
return true;
}
else
{
return false;
}
}
private:
vector<HashData<K, V>> _tables;
size_t _n = 0; // 存储的数据个数
};
}
线性探测优点:实现非常简单,
线性探测缺点:一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”,即:不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低。如何缓解呢?可以使用二次探索,三次探索等等。当所求出key的位置被占用,不去填入key+1的位置,而是填入key+2或key+3的位置,这种方式叫做二次探索,三次探索,这样可以让表更加稀松一些,就能提高效率。
当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容。
3.2 哈希表的开散列
开散列(Open Hashing)是另一种解决哈希冲突的方法,也称为链地址法(Chaining)。在开散列中,哈希表中的每个桶都是一个链表,当发生哈希冲突时,新的元素会被插入到对应桶的链表中。这种方法可以避免开放地址法中的聚集现象,并且可以在空间充足的情况下实现快速查找 。
开散列增容:桶的个数是一定的,随着元素的不断插入,每个桶中元素的个数不断增多,极端情况下,可
能会导致一个桶中链表节点非常多,会影响的哈希表的性能,因此在一定条件下需要对哈希表进行增容,开散列最好的情况是:每个哈希桶中刚好挂一个节点,再继续插入元素时,每一次都会发生哈希冲突,因此,在元素个数刚好等于桶的个数时,可以给哈希表增容。
只能存储key为整形的元素,其他类型怎么解决?key为字符串类型,需要将其转化为整形。在将字符串类型转换为整数类型时,可以使用以下方法:
- 将字符串中的每个字符转换为其ASCII码值,然后将这些值相加得到一个整数。
- 将字符串中的每个字符转换为其ASCII码值,然后将这些值相乘得到一个整数。
- 将字符串中的每个字符转换为其ASCII码值,然后将这些值按位异或得到一个整数。
代码如下:
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>
struct HashFunc
{
size_t operator()(const K& key)
{
return key;
}
};
// 特化,将字符串的情况进行一些处理
template<>
struct HashFunc<string>
{
size_t operator()(const string& s)
{
size_t hash = 0;
for (auto ch : s)
{
hash += ch;
hash *= 31;
}
return hash;
}
};
template<class K, class V, class Hash = HashFunc<K>>
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;
Hash hash;
size_t hashi = hash(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)
{
Hash hash;
size_t hashi = hash(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;
}
//扩容扩质数的2倍左右
size_t GetNextPrime(size_t prime)
{
static const int __stl_num_primes = 28;
static const unsigned long __stl_prime_list[__stl_num_primes] =
{
53, 97, 193, 389, 769,
1543, 3079, 6151, 12289, 24593,
49157, 98317, 196613, 393241, 786433,
1572869, 3145739, 6291469, 12582917, 25165843,
50331653, 100663319, 201326611, 402653189, 805306457,
1610612741, 3221225473, 4294967291
};
size_t i = 0;
for (; i < __stl_num_primes; ++i)
{
if (__stl_prime_list[i] > prime)
return __stl_prime_list[i];
}
return __stl_prime_list[i];
}
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
{
return false;
}
Hash hash;
// 负载因因子==1时扩容
if (_n == _tables.size())
{
size_t newsize = GetNextPrime(_tables.size());
vector<Node*> newtables(newsize, nullptr);
for (auto& cur : _tables)
{
while (cur)
{
Node* next = cur->_next;
size_t hashi = hash(cur->_kv.first) % newtables.size();
// 头插到新表
cur->_next = newtables[hashi];
newtables[hashi] = cur;
cur = next;
}
}
_tables.swap(newtables);
}
size_t hashi = hash(kv.first) % _tables.size();
// 头插
Node* newnode = new Node(kv);
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_n;
return true;
}
size_t MaxBucketSize()
{
size_t max = 0;
for (size_t i = 0; i < _tables.size(); ++i)
{
auto cur = _tables[i];
size_t size = 0;
while (cur)
{
++size;
cur = cur->_next;
}
if (size > max)
{
max = size;
}
}
return max;
}
private:
vector<Node*> _tables; // 指针数组
size_t _n = 0; // 存储有效数据个数
};
}
4. 哈希的应用
4.1 位图
位图(Bitmap)是一种数据结构,用于表示一个二进制向量。位图中的每个元素都只有两个可能的取值:0和1。位图可以用于压缩数据,减少存储空间的使用,也可以用于快速查找和访问元素。
在位图中,每个元素都只占用一个二进制位,因此可以使用一个整数来表示多个元素。例如,一个32位的整数可以表示32个元素。这种方法可以大大减少存储空间的使用,并且可以在常数时间内访问和修改元素。
位图常用于处理大量数据的问题,例如在搜索引擎中用于记录网页的索引信息。它还可以用于计算机网络中的路由表、缓存和防火墙等。
例如求解给40亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在这40亿个数中。
数据是否在给定的整形数据中,结果是在或者不在,刚好是两种状态,那么可以使用一个二进制比特位来代表数据是否存在的信息,如果二进制比特位为1,代表存在,为0代表不存在。
位图代码如下:
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;
};
4.2 布隆过滤器
当查找大量与字符串有关的数据时,过滤掉那些已经存在的记录。 如何快速查找呢?
- 用哈希表存储用户记录,缺点:浪费空间
- 用位图存储用户记录,缺点:位图一般只能处理整形,如果内容编号是字符串,就无法处理了。
- 将哈希与位图结合,即布隆过滤器
布隆过滤器(Bloom Filter)是一种空间效率高、误判率低的概率型数据结构,用于判断一个元素是否在一个集合中。它由一个位数组和多个哈希函数组成。位数组的长度为m,哈希函数的个数为k。当一个元素被加入集合时,它会被k个哈希函数映射成位数组中的k个位置,将这些位置设为1。当判断一个元素是否在集合中时,将这个元素进行k次哈希,得到k个位置。 如果这k个位置都是1,则说明这个元素在集合中;如果这k个位置中有任意一个位置是0,则说明这个元素不在集合中。
布隆过滤器的优点是空间效率高、查询时间短,缺点是有一定的误判率和删除困难。它常用于大规模数据处理中,例如网络爬虫、垃圾邮件过滤等。
布隆过滤器的查找
布隆过滤器的思想是将一个元素用多个哈希函数映射到一个位图中,因此被映射到的位置的比特位一定为1。所以可以按照以下方式进行查找:分别计算每个哈希值对应的比特位置存储的是否为零,只要有一个为零,代表该元素一定不在哈希表中,否则可能在哈希表中。
注意:布隆过滤器如果说某个元素不存在时,该元素一定不存在,如果该元素存在时,该元素可能存在,因为有些哈希函数存在一定的误判。
比如:在布隆过滤器中查找"alibaba"时,假设3个哈希函数计算的哈希值为:1、3、7,刚好和其他元素的比特位重叠,此时布隆过滤器告诉该元素存在,但实该元素是不存在的。
布隆过滤器删除
布隆过滤器不能直接支持删除工作,因为在删除一个元素时,可能会影响其他元素。
比如:删除上图中"tencent"元素,如果直接将该元素所对应的二进制比特位置0,“baidu”元素也被删除了,因为这两个元素在多个哈希函数计算出的比特位上刚好有重叠。
一种支持删除的方法:将布隆过滤器中的每个比特位扩展成一个小的计数器,插入元素时给k个计数器(k个哈希函数计算出的哈希地址)加一,删除元素时,给k个计数器减一,通过多占用几倍存储空间的代价来增加删除操作。
布隆过滤器优点
- 增加和查询元素的时间复杂度为:O(K), (K为哈希函数的个数,一般比较小),与数据量大小无关
- 哈希函数相互之间没有关系,方便硬件并行运算
- 布隆过滤器不需要存储元素本身,在某些对保密要求比较严格的场合有很大优势
- 在能够承受一定的误判时,布隆过滤器比其他数据结构有这很大的空间优势
- 数据量很大时,布隆过滤器可以表示全集,其他数据结构不能
- 使用同一组散列函数的布隆过滤器可以进行交、并、差运算
布隆过滤器缺陷
- 有误判率,即存在假阳性(False Position),即不能准确判断元素是否在集合中(补救方法:再建立一个白名单,存储可能会误判的数据)
- 不能获取元素本身
- 一般情况下不能从布隆过滤器中删除元素
- 如果采用计数方式删除,可能会存在计数回绕问题
//不同的哈希映射方式
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 hash1 = Hash1()(key) % N;
_bs.set(hash1);
size_t hash2 = Hash2()(key) % N;
_bs.set(hash2);
size_t hash3 = Hash3()(key) % N;
_bs.set(hash3);
}
bool test(const K& key)
{
size_t hash1 = Hash1()(key) % N;
if (!_bs.test(hash1))
{
return false;
}
size_t hash2 = Hash2()(key) % N;
if (!_bs.test(hash2))
{
return false;
}
size_t hash3 = Hash3()(key) % N;
if (!_bs.test(hash3))
{
return false;
}
return true;
}
private:
bitset<N> _bs;
};