hash的由来和概念
1.hash的由来:
①:由于在树形结构中,不管怎样去查找一个数据,都会存在去比较,通过一次次的比较来找到那个数,顺序查找的时间复杂度为O(N),而树形结构的平均查找时间复杂度为log2(N),搜索效率取决于比较的次数。
②:所以我们需要一个结构,这样的结构可以不经过任何比较,一次就可以得到想要搜索的数据,所以我们引出了哈希结构。
2.哈希的概念:
通过哈希可以使元素的存储位置与它的关键码形成一一映射的关系,那么在查找的时候就可以快速的找到需要的元素。
hash的介绍
通过上述的概念,所以我们对哈希结构的存储应该具有以下两种的需求:
1:插入元素
通过所插入元素的关键码,通过哈希函数找到其应该插入的位置,然后进行插入操作。
2:搜索元素
对搜索的元素进行与插入同样的哈希函数的操作,得到元素插入的位置,然后在其位置找元素进行一一比较,最后搜索得到元素。
哈希函数
哈希函数和哈希冲突是哈希结构中非常重要的两个角色,他们的设定对哈希函数的计算效率有着很大的影响下面我们先看哈希函数:
1.哈希函数的设计规则:
- 哈希表的定义域必须包含所需要存储数据的所有关键码,其中如果有m个关键码需要插入,那么就表就必须有0~m-1个位置。
- 哈希函数计算出来的数据能均匀分布在表中的每一个位置。
- 哈希函数的设定应该简单。
一般情况下,哈希函数的设定可以减少哈希冲突的产生。
2.常见的哈希函数:
①:除留余数法:假设表中的数据为m个,那么我们取一个不大于m但是接近于m的一个质数p来充当除数,将每个需要插入的关键码对p进行取余,余数即为该数要插入的表的下标。(取质数的原因是经过大量的数据检验,取质数的时候,数据的分布很均匀,能更好的取利用内存)
②:直接定址法:取关键字的某个线性函数为地址:
Hash(key) = A*key+B 其中A和B是常数,通过这样一个函数直接定位数据应该插入的位置。(用于查找比较小且连续的情况)
③:平方取中法:对关键值取平方,然后取其中间的数为哈希地址:如关键码是1234,它的平方是1522756,那么就取227为哈希地址插入。(该方法适用于不知道关键字的分布,但是位数又不是很大)
④:折叠法:将关键字从左到右分割成几个小部分,最后一部分可以小一点,然后将这几个小部分进行相加,然后按散列表的表长,取最后几位作为哈希地址。(适用于不知道关键字的分布,并且关键字的位数比较大)
⑤:随机数法:选择一个随机函数,取关键字的随机函数值作为其哈希地址。(关键字长度不等的时候适用)
⑥:数学分析法:假设关键码有n位,但是总会有那么几位在所有关键码中出现的次数均匀,所以我们可以根据这几个均匀的码和表的大小,提取出哈希地址。(适用于关键字位数比较大,事先知道关键字和关键字中数值的分布情况)
其中注意的是:
- 哈希函数设定的越精妙,哈希冲突的情况就会减少,但是不可能避免。
- 其中对前两种方法使用的比较多(除留余数法和直接定址法)。
哈希冲突以及解决办法
1.哈希冲突:对于两个截然不同的数据,通过哈希函数的计算得到相同的哈希地址。
2.哈希冲突的解决办法分为以下两种:闭散列和开散列。
闭散列
1.闭散列:也叫开放地址法,当出现哈希冲突时,如果哈希表没有满,那就一定有空位置,此时可以将其插入到“下一个”空位置中去。
而寻找下一个位置有以下两种方法:
①:线性探测:
方法:当发生哈希冲突,则从需要插入的位置依次向后寻找空位进行插入。
插入:
- 通过哈希函数找到哈希地址码。
- 如果该位置没有元素那么直接插入,如果有元素那么就往后依次寻找空位进行插入。
删除:
- 使用这个方法删除的时候并不是真正的删除,因为如果存在哈希冲突,对一个数据进行删除后,那么查找其与被删除位置冲突的元素的时候那么就会出现错误,所以我们应该采用伪删除的方法进行删除操作。
线性探测的代码实现:
#include<iostream>
using namespace std;
const int PRIMECOUNT = 28;
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
};
// 注意:假如实现的哈希表中元素唯一,即key相同的元素不再进行插入
// 为了实现简单,此哈希表中我们将比较直接与元素绑定在一起
namespace Close_Hash
{
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), _totalSize(0)
{
for (size_t i = 0; i < capacity; ++i)
_ht[i]._state = EMPTY;
}
// 插入
bool Insert(const pair<K, V>& val)
{
CheckCapacity();
int index = HashFunc(val.first);
while (_ht[index]._state != EMPTY)
{
if (_ht[hashAddr]._state == EXIST && _ht[hashAddr]._val.first == key)
return false;
index++;
if (index >= _totalSize)
index = 0;
}
_ht[index]._val = val;
_ht[index]._state = EXIST;
_size++;
_totalSize++;
return true;
}
// 查找
size_t Find(const K& key)
{
int index = HashFunc(key);
while (_ht[index]._state != EMPTY && _ht[index]._val.first != key)
{
index++;
if (index >= _totalSize)
index = 0;
}
if (_ht[index]._state == EMPTY)
return -1;
else
return index;
}
// 删除
bool Erase(const K& key)
{
int index = Find(key);
if (index != -1)
{
_ht[index]._state = DELETE;
--_size;
return true;
}
return false;
}
size_t Size()const
{
return _size;
}
bool Empty() const
{
return _size == 0;
}
void Swap(HashTable<K, V>& ht)
{
swap(_size, ht._size);
swap(_totalSize, ht._totalSize);
_ht.swap(ht._ht);
}
private:
size_t HashFunc(const K& key)
{
return key % _ht.capacity();
}
HashTable<K, V> GetNextPrime(int cp)
{
size_t i = 0;
for (; i < PRIMECOUNT; ++i)
{
if (primeList[i] > cp)
{
return primeList[i];
}
}
return primeList[i];
}
void CheckCapacity()
{
// 负载因子达到70%以上时,扩容
if (_totalSize * 10 / _ht.capacity() >= 7)
{
HashTable<K, V> newHT(GetNextPrime(_ht.capacity()));
// 此处只需将有效元素搬移到新哈希表中
// 已删除的元素不用处理
for (size_t i = 0; i < _ht.capacity(); ++i)
{
if (_ht[i]._state == EXIST)
{
newHT.Insert(_ht[i]._val);
}
}
this->Swap(newHT);
}
}
private:
vector<Elem> _ht;
size_t _size;
size_t _totalSize; // 哈希表中的所有元素:有效和已删除, 扩容时候要用到
};
}
其中上述代码中出现了哈希表的扩容,哈希表的扩容方法如下;
在哈希表中定义了一个扩容因子a,用来表示表中的数据是否需要扩容,其中,a = 插入表中的元素个数/表的大小。一般情况下,当a的大小为0.7左右,因为a的值越大,产生的冲突的几率就会越大。
线性探测的优点:实现非常简单。
线性探测的缺点:容易造成冲突的堆积,如果产生的冲突数量比较大,那么查找的效率就会下降。
②:二次探测
二次探测的产生的原因:由于线性探测的查找方式是按照下一个位置去进行逐个查找的,所以容易产生冲突的堆积,所以有了二次探测。
二次探测的方法:主要是改变了找寻插入的下一个空位,其方法如下:
H = (H0 + i)% m,或者:H = (H0 - i)% m。(其中H为最后插入的哈希表的位置,而H0为最开始位置,i = 1,2,3…)。
优点:可以减少比较的次数,如果插入一个数的后面几乎没有空位了,而前面有一个很近的位置,那么该插入方法就可以极大的减少比较的次数。
缺点:当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容。
所以闭散列最大的缺点就是空间利用率太低了。
开散列
1.开散列的概念:
开散列法又叫链地址法,意思是对关键码先用哈希函数计算找到需要插入的位置,而具有相同关键码的作为一个集合,每一个子集称为一个桶,然后用链表的形式将这些桶链接起来,而各个链表的表头地址存在哈希表中。
如图:
2.开散列的实现:
#include<iostream>
using namespace std;
const int PRIMECOUNT = 28;
const size_t primeList[PRIMECOUNT] =
{
7ul , 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
};
namespace OpenHash
{
template<class T>
class HashFunc
{
public:
size_t operator()(const T& val)
{
return val;
}
};
template<>
class HashFunc<string>
{
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;
}
};
template<class V>
struct HashBucketNode
{
HashBucketNode(const V& data)
: _pNext(nullptr), _data(data)
{}
HashBucketNode<V>* _pNext;
V _data;
};
// 本文所实现的哈希桶中key是唯一的
template<class V, class HF = HashFunc<V>>
class HashBucket
{
typedef HashBucketNode<V> Node;
typedef Node* PNode;
typedef HashBucket<V, HF> Self;
public:
HashBucket(size_t capacity = 0)
: _table(GetNextPrime(capacity))
, _size(0)
{}
~HashBucket()
{
Clear();
}
// 哈希桶中的元素不能重复
Node* Insert(const V& data)
{
CheckCapacity();
Node* p = Find(data);
if (p != nullptr)
{
return nullptr;
}
int index = HashFunc(data);
Node* s = new Node(data);
s->_pNext = _table[index];
_table[index] = s;
_size++;
return s;
}
// 删除哈希桶中为data的元素(data不会重复)
bool Erase(const V& data)
{
int index = HashFunc(data);
if (_table[index] != nullptr)
{
Node* p = _table[index];
Node* q = nullptr;
while (p != nullptr && p->_data != data)
{
q = p;
p = p->_pNext;
}
if (p == nullptr)
return false;
else
{
if (q != nullptr)
{
q->_pNext = p->_pNext;
}
else
{
_table[index] = p->_pNext;
}
delete p;
_size--;
return true;
}
}
return false;
}
Node* Find(const V& data)
{
int index = HashFunc(data);
Node* p = _table[index];
while (p != nullptr)
{
if (p->_data == data)
{
return p;
}
p = p->_pNext;
}
return nullptr;
}
size_t Size()const
{
return _size;
}
bool Empty()const
{
return 0 == _size;
}
void Clear()
{
for (int i = 0; i < _table.size(); ++i)
{
if (_table[i] != nullptr)
{
Node* p = _table[i];
Node* q = nullptr;
while (p != nullptr)
{
q = p->_pNext;
delete p;
p = q;
}
}
}
_size = 0;
}
size_t BucketCount()const
{
return _table.capacity();
}
void Swap(Self& ht)
{
_table.swap(ht._table);
swap(_size, ht._size);
}
private:
size_t HashFunc(const V& data)
{
return HF()(data) % _table.capacity();
}
int GetNextPrime(int ca)
{
int i = 0;
for (; i < PRIMECOUNT; ++i)
{
if (primeList[i] > ca)
return primeList[i];
}
return primeList[i];
}
void CheckCapacity()
{
if (_size == _table.capacity())
{
#if 0
HashBucket<T> ht(_size * 2);
// 将旧哈希桶中的元素向新哈希桶中进行搬移
// 搬移所有旧哈希桶中的元素
for (size_t i = 0; i < _table.capacity(); ++i)
{
Node* pCur = _table[i];
while (pCur)
{
ht.Insert(pCur->_data); // new 节点
pCur = pCur->_pNext;
}
}
Swap(ht);
#endif
Self ht(GetNextPrime(_size));
// 将旧哈希桶中的节点直接向新哈希桶中搬移
for (size_t i = 0; i < _table.capacity(); ++i)
{
Node* pCur = _table[i];
while (pCur)
{
// 将pCur节点从旧哈希桶搬移到新哈希桶
// 1. 将pCur节点从旧链表中删除
_table[i] = pCur->_pNext;
// 2. 将pCur节点插入到新链表中
size_t bucketNo = ht.HashFunc(pCur->_data);
// 3. 插入节点--->头插
pCur->_pNext = ht._table[bucketNo];
ht._table[bucketNo] = pCur;
}
}
this->Swap(ht);
}
}
private:
vector<Node*> _table;
size_t _size; // 哈希表中有效元素的个数
};
}
开散列与闭散列的比较:
应用链地址法处理溢出,需要增设链接指针,看似加大了内存开销。
但是:相比于开散列,由于其必须保证有大量的空闲位置,当a>=0.5的时候就要进行扩容,所以有大量的空位置占着,反而相比与链地址法,链地址法就内存开销便很小了。
hash的应用
哈希的应用的实现主要为位图和布隆过滤器两种,如下:
位图
1.概念:用存储数的一个位来存放某种状态,适用于海量数据,数据无重复的场景。通常是用来判断一个数存在不存在。
2.具体位图的实现:
class bitset
{
public:
bitset(size_t count) :_bit((count >> 5) + 1), bit_count(count)
{}
~bitset()
{}
public:
void set(size_t count)
{
if (count > bit_count)
return;
size_t index = count >> 5;
size_t pos = count % 32;
_bit[index] |= (1 << pos);
}
void reset(size_t count)
{
if (count > bit_count)
return;
size_t index = count >> 5;
size_t pos = count % 32;
_bit[index] &= ~(1 << pos);//~的意思是取将该位变为0其他位全为1,也可也这样表示(1<<pos)位表示是2的pos次方的位置,而加了~表示的是-(2的pos次方)-1;
}
bool test(size_t count)
{
if (count > bit_count)
{
return false;
}
size_t index = count >> 5;
size_t pos = count % 32;
return (1 << pos & _bit[index]);
}
size_t size()const//返回整个位图比特位的个数
{
return bit_count;
}
size_t Count()const//图中比特为1的个数
{
int bitCnttable[256] = {
0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4, 1, 2, 2, 3, 2, 3, 3, 4, 2,
3, 3, 4, 3, 4, 4, 5, 1, 2, 2, 3, 2, 3, 3, 4, 2, 3, 3, 4, 3, 4, 4, 5, 2, 3,
3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6, 1, 2, 2, 3, 2, 3, 3, 4, 2, 3, 3,
4, 3, 4, 4, 5, 2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6, 2, 3, 3, 4,
3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6, 3, 4, 4, 5, 4, 5, 5, 6, 4, 5, 5, 6, 5,
6, 6, 7, 1, 2, 2, 3, 2, 3, 3, 4, 2, 3, 3, 4, 3, 4, 4, 5, 2, 3, 3, 4, 3, 4,
4, 5, 3, 4, 4, 5, 4, 5, 5, 6, 2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5,
6, 3, 4, 4, 5, 4, 5, 5, 6, 4, 5, 5, 6, 5, 6, 6, 7, 2, 3, 3, 4, 3, 4, 4, 5,
3, 4, 4, 5, 4, 5, 5, 6, 3, 4, 4, 5, 4, 5, 5, 6, 4, 5, 5, 6, 5, 6, 6, 7, 3,
4, 4, 5, 4, 5, 5, 6, 4, 5, 5, 6, 5, 6, 6, 7, 4, 5, 5, 6, 5, 6, 6, 7, 5, 6,
6, 7, 6, 7, 7, 8 };
size_t size = _bit.size();
size_t nums = 0;
for (int i = 0; i < size; ++i)
{
int value = _bit[i];
int j = 0;
while (j < sizeof(_bit[0]))
{
unsigned char c = value;
nums += bitCnttable[c];
value >> 8;
j++;
}
}
}
private:
vector<int> _bit;
size_t bit_count;
};
上述主要是实现了一些常用的操作接口。
3.位图的主要应用:
- 快速查找某个数据是否在一个集合中
- 排序
- 求两个集合的交集,并集
- 操作系统中的磁盘块标记
另外对于处理海量数据我们可以最先想到的方法为以下三种:
①:位图
②:堆排
③:布隆过滤器
布隆过滤器
布隆过滤器,也是为了处理大量数据而产生的。
1.布隆过滤器出现的原因:
- 用哈希表存储数据,会出现大量的内存不能被很好的利用
- 用位图处理数据,不能很好的解决冲突问题
所以将哈希与位图合璧就出现了布隆过滤器。
2.布隆过滤器的概念:
①:是一种紧凑和巧妙的概率性数据结构,但是它的插入操作非常的高效,它对一个数是否存在这个集合中是不确定的,只能判断出可能存在和绝对不存在这两种情况。
②:将位图和哈希巧妙的结合在一起,是用多个哈希函数,将一个数映射到位图中,这种方法不仅可以提高查询效率,更提高了内存的利用率。
如下图:
如图,假设{x,y,z}为哈希表中的数据,而下面的表格为位图结构,那么会当哈希中的数据通过哈希函数映射到位图表中,实际上,布隆过滤器的原理就是这样。
3.布隆过滤器的插入操作:
假设布隆过滤器的模板数据是K,调用了五个哈希函数,这个哈希函数会将一个数据映射在位图的不同位置,简单实现代码如下:
其中具体的哈希函数方法我们在上面讲过,下面就不具体实现了:
template<class T, class KToInt1 = KeyToInt1, class KToInt2 = KeyToInt2,
class KToInt3 = KeyToInt3, class KToInt4 = KeyToInt4,
class KToInt5 = KeyToInt5>
class Bloomfilter
{
public:
Bloomfilter(size_t count) :bs(count*2), size(0)
{}
public:
bool Insert(const T& key)
{
size_t bitCount = _bs.Size();
size_t index1 = KToInt1()(key) % bitCount;
size_t index2 = KToInt2()(key) % bitCount;
size_t index3 = KToInt3()(key) % bitCount;
size_t index4 = KToInt4()(key) % bitCount;
size_t index5 = KToInt5()(key) % bitCount;
_bs.set(index1); _bs.set(index2); _bs.set(index3);
_bs.set(index4); _bs.set(index5);
_size++;
}
private:
size_t size;//实际元素的个数
bitset bs;
};
4.布隆过滤器的查找:
①:原理:通过上述的布隆过滤器的插入操作我们可以看出来,对于一个数据的插入,我们会将其分布在位图的不同位置上,那么查找的时候,我们检查这些位置就可以了,如果有一个位置的数据不为1,那么就可以确定这个数据绝对不在这个集合中。
②:简单实现:
bool Find(const T& key)
{
size_t bitCount = _bmp.Size();
size_t index1 = KToInt1()(key) % bitCount;
if (!_bmp.Test(index1))
return false;
size_t index2 = KToInt2()(key) % bitCount;
if (!_bmp.Test(index2))
return false;
size_t index3 = KToInt3()(key) % bitCount;
if (!_bmp.Test(index3))
return false;
size_t index4 = KToInt4()(key) % bitCount;
if (!_bmp.Test(index4))
return false;
size_t index5 = KToInt5()(key) % bitCount;
if (!_bmp.Test(index5))
return false;
return true; //有可能在
}
其中Test函数为我们在上面实现的位图的函数,为检查某一一个比特位是否为1。
注意的是:布隆过滤器在判断一个数据是否在一个集合中,只能判断这个数据一定不在和可能存在。
5.布隆过滤器的删除:
①:由于上面插入的操作和查找的操作,所以我们在对布隆过滤器进行删除的时候,不能直接删除,因为一些避免不了的哈希冲突会让一个位置可能存在两个及以上的元素,如果我们删除了这个元素,那么影响的就不一定是一个数了。
②:所以我们提供了一个删除的方法:给位图的每一位提供一个计数器,当有新的比特位放在这个位置时,让其计数器加一,当删除时,只需让计数器减少即可,如果计数器减为0,那么就将其删除即可。
③:但是还是有缺点:
存在计数环绕;还有就是无法准确的确认数据是否就在这个集合中。
6.布隆过滤器的优缺点:
①:优点
- 增加和查询数据的时间复杂度为:O(K),K为哈希表的大小,与数据量的大小无关。
- 哈希函数相互之间木有关系,适合电脑硬件进行运算。
- 布隆过滤器自己本身不需要去存储数据,这样提高了保密性。
- 在存在相同误差的情况下,布隆过滤器较其他数据结构对空间利用率很高。
- 数据量较大的情况下,布隆过滤器可以保持数据的全部,而其他数据结构不可以。
- 使用同一组散列函数的布隆过滤器可以进行交、并、差运算。
②:缺点:
- 有误判率,不可以准确的得到一个数据的具体在这个集合中(补救方法就是另外建立一个表,用来存储可能发生误判的数据)。
- 不能获取本身的元素。
- 布隆过滤器中删除数据会出现很多麻烦。
- 如果采用计数删除,那么可能存在计数环绕。