文章目录
哈希
在之前介绍的数据结构中,元素与其所存储的位置之间没有对应的关系,所以在查找的时候就需要经过多次的比较,才能找到具体的位置。对于顺序结构来说,这样查找的时间复杂度一般都是O(N),而对于树形结构的如搜索树等则也需要O(logN)。
但是,还存在着这样一种数据结构,他通过某种方法的处理,使得元素存储的位置与元素本身建立起了映射关系,此时如果要查找改数据,就可以直接到对应的位置去,使得时间复杂度达到了O(1),这就是哈希(散列)。
哈希函数
哈希函数就是建立起元素与其存储位置的映射关系。
对于哈希函数来说,必须具有以下特点;
- 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0 到m-1之间
- 哈希函数计算出来的地址能均匀分布在整个空间中(防止产生密集的哈希冲突)
- 哈希函数应该比较简单
哈希冲突大量出现往往都是因为哈希函数设计的不够合理,但是即使再优秀的哈希函数,也只能减少哈希冲突的次数,无法避免哈希冲突
常见的哈希函数
- 直接定址法(常见)
哈希函数:Hash(Key)= A*Key + B;
这是最简单的哈希函数,直接取关键字本身或者他的线性函数来作为散列地址。 - 除留余数法(常见)
哈希函数 :Hash(key) = key % capacity
几乎是最常用的哈希函数,用一个数来对key取模,一般来说这个数都是容量。 - 平方取中法
对关键字进行平方,然后取中间的几位来作为地址。 - 折叠法
折叠法是将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这几部分叠加
求和,并按散列表表长,取后几位作为散列地址。
折叠法适合事先不需要知道关键字的分布,适合关键字位数比较多的情况** - 随机数法
选择一个随机函数,取关键字的随机函数值为它的哈希地址,即H(key) = random(key),其中random为
随机数函数。
通常应用于关键字长度不等时采用此法 - 数学分析法
设有n个d位数,每一位可能有r种不同的符号,这r种不同的符号在各位上出现的频率不一定相同,可能
在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只有某几种符号经常出
现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散列地址
字符串哈希函数
因为哈希函数的常用方法如直接定址、除留余数、平方取中等方法需要用的key值为整型,而大部分时候我们的key都是string,对于string来说,上面的方法都行不通,因为无法对string进行算数运算,所以需要考虑新的方法。
常见的字符串哈希算法有BKD,SDB,RS等,这些算法大多通过一些公式来对字符串每一个字符的ascii值或者字符串的大小进行计算,来推导出一个不容易产生冲突的key值。
例如BKDHash
struct _Hash<std::string>
{
const size_t& operator()(const std::string& key)
{
//BKDR字符串哈希函数
size_t hash = 0;
for (size_t i = 0; i < key.size(); i++)
{
hash *= 131;
hash += key[i];
}
return hash;
}
};
这里推荐两篇文章,一篇具体对比各类字符串哈希函数的效率,一篇是实现。
哈希冲突
哈希冲突就是两个不同的数据通过同一个哈希函数计算出了相同的位置,这种现象就是哈希冲突。
哈希冲突使得多个数据映射的位置相同,但是每个位置又只能存储一个数据,所以就需要通过某种方法来解决哈希冲突。
对于哈希冲突的解决方法,一般根据不同的结构,分为以下几种
闭散列的解决方法
因为闭散列是顺序的结构,所以可以通过遍历哈希表,来将冲突的数据放到空的位置上
-
线性探测
线性探测即为从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
这种方法实现起来极为简单,但是效率也不高,因为如果同一位置产生了大量的哈希冲突,就会导致每次都在同一个位置进行探测,例如我在10这里连续冲突100次,此时所有探测的次数加起来就会高达100!,这样方法效率十分的低下。 -
二次探测
线性探测即为从发生冲突的位置开始,每次往后探测i^2个位置,如1, 2, 4, 8等,这样的话就将每次探测的效率从O(N)提升到了O(logN),即使有着大量的冲突堆积,也不会导致效率过低。
开散列的解决方法
因为开散列本身就是一种链式的结构,所以其本身就是一种解决方法,这种方法也叫做链地址法
链地址法
链地址法在每一个映射位置都建立起一个链表(数据过多时可能会转为建立红黑树),将每次插入的数据都直接连接上这个链表,这样就不会像闭散列一样进行大量的探测,但是如果链表过长也会导致效率低下。
负载因子以及增容
哈希冲突出现的较为密集,往往代表着此时数据过多,而能够映射的地址过少,这就导致了随着数据的增多,冲突的次数就越来越多,而要想解决这个问题,就需要通过负载因子的判断来进行增容。
负载因子的大小 = 表中数据个数 / 表的容量
对于闭散列
对于闭散列来说,因为其是一种线性的结构,所以一旦负载因子过高,就很容易出现哈希冲突的堆积,所以当负载因子达到一定程度时就需要进行增容,并且增容后,为了保证映射关系,还需要将数据重新映射到新位置。
经过算法科学家的计算, 负载因子应当严格的控制在0.7-0.8以下,所以一旦负载因子到达这个范围,就需要进行增容。
因为除留余数法等方法通常是按照表的容量来计算,所以科学家的计算,当对一个质数取模时,冲突的几率会大大的降低,并且因为增容的区间一般是1.5-2倍,所以算法科学家列出了一个增容质数表,按照这样的规律增容,冲突的几率会大大的降低。
这也是STL中unordered_map/unordered_set使用的增容方法。
//算法科学家总结出的一个增容质数表,按照这样增容的效率更高
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
};
对于开散列结构
因为哈希桶是开散列的链式结构,发生了哈希冲突是直接在对应位置位置进行头插,而桶的个数是固定的,而插入的数据会不断增多,随着数据的增多,就可能会导致某一个桶过重,使得效率过低。
所以最理想的情况,就是每个桶都有一个数据。这种情况下,如果往任何一个地方插入,都会产生哈希冲突,所以当数据个数与桶的个数相同时,也就是负载因子为1时就需要进行扩容
具体实现
哈希表(闭散列)
对于闭散列,我们需要通过状态来记录一个数据是否在表中,所以这里会使用枚举来实现。
enum State
{
EMPTY,//空
EXITS,//存在
DELETE,//已经删除
};
template<class T>
struct HashData
{
HashData(const T& data = T(), const State& state = EMPTY)
: _data(data)
, _state(state)
{}
T _data;
State _state;
};
插入
插入的思路很简单,计算出映射的地址后,开始遍历判断下面几种状态
- 如果映射位置已存在数据,并且状态为存在,则说明产生冲突,继续往后查找
- 如果映射位置的数据与插入的数据相同,并且状态为存在,则说明此时数据已经插入过,此时就不需要再次插入
- 如果映射位置的状态为删除或者空,则代表着此时表中没有这个数据,在这个位置插入即可
bool Insert(const T& data)
{
KeyOfT koft;
//判断此时是否需要增容
//当装填因子大于0.7时增容
if (_size * 10 / _table.size() >= 7)
{
//增容的大小按照别人算好的近似两倍的素数来增,这样效率更高,也可以直接2倍或者1.5倍。
std::vector<HashData> newTable(getNextPrime(_size));
for (size_t i = 0; i < _table.size(); i++)
{
//将旧表中的数据全部重新映射到新表中
if (_table[i]._state == EXITS)
{
//如果产生冲突,则找到一个合适的位置
size_t index = HashFunc(koft(_table[i]._data));
while (newTable[i]._state == EXITS)
{
i++;
if (i == _table.size())
{
i = 0;
}
}
newTable[i] = _table[i];
}
}
//最后直接将数据进行交换即可,原来的数据会随着函数栈帧一起销毁
_table.swap(newTable);
}
//用哈希函数计算出映射的位置
size_t index = HashFunc(koft(data));
//从那个位置开始探测, 如果该位置已经存在时,有两种情况,一种是已经存在,一种是冲突,这里使用的是线性探测
while (_table[index]._state == EXITS)
{
//如果已经存在了,则说明不用插入
if (koft(_table[index]._data) == koft(data))
{
return false;
}
else
{
index++;
index = HashFunc(index);
}
}
//如果走到这里,说明这个位置是空的或者已经被删除的位置,可以在这里插入
_table[index]._data = data;
_table[index]._state = EXITS;
_size++;
return true;
}
查找
查找也分几种情况
- 如果映射的位置为空,则说明查找失败
- 如果映射的位置的数据不同,并且状态为存在,则说明产生冲突,继续向后查找
- 如果映射的位置的数据相同,如果状态为删除,则说明数据已经删除,查找失败,而如果数据为存在,则说明查找成功。
HashData* Find(const K& key)
{
KeyOfT koft;
size_t index = HashFunc(key);
//遍历,如果查找的位置为空,则说明查找失败
while (_table[index]._state != EMPTY)
{
//此时判断这个位置的数据是否相同,如果不同则说明出现哈希冲突,继续往后查找
if (koft(_table[index]._data) == key)
{
//此时有两个状态,一种是数据已经被删除,一种是数据存在。
if (_table[index]._state == EXITS)
{
return &_table[index];
}
else if (_table[index]._state == DELETE)
{
return nullptr;
}
}
index++;
//如果index越界,则归零
if (index == _table.size())
{
index = 0;
}
}
return nullptr;
}
删除
直接遍历查找数据,如果找不到则说明已经被删除,如果找到了则直接将状态改为删除即可
bool Erase(const K& key)
{
HashData* del = Find(key);
//如果找不到则说明已经被删除
if (del == nullptr)
{
return false;
}
else
{
//找到了则直接更改状态即可
del->_state = DELETE;
_size--;
return true;
}
}
完整代码实现
#pragma once
#include<vector>
namespace lee
{
//算法科学家总结出的一个增容质数表,按照这样增容的效率更高
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
};
enum State
{
EMPTY,
EXITS,
DELETE,
};
template<class T>
struct HashData
{
HashData(const T& data = T(), const State& state = EMPTY)
: _data(data)
, _state(state)
{}
T _data;
State _state;
};
template<class K, class T, class KeyOfT>
class HashTable
{
public:
typedef HashData<T> HashData;
HashTable(size_t capacity = 10)
: _table(capacity)
, _size(0)
{}
size_t getNextPrime(size_t num)
{
size_t i = 0;
for (i = 0; i < PRIMECOUNT; i++)
{
//返回比那个数大的下一个质数
if (primeList[i] > num)
{
return primeList[i];
}
}
//如果比所有都大,还是返回最后一个,因为最后一个已经是32位最大容量
return primeList[PRIMECOUNT - 1];
}
//除留余数法
size_t HashFunc(const K& key)
{
return key % _table.size();
}
bool Insert(const T& data)
{
KeyOfT koft;
//判断此时是否需要增容
//当装填因子大于0.7时增容
if (_size * 10 / _table.size() >= 7)
{
//增容的大小按照别人算好的近似两倍的素数来增,这样效率更高,也可以直接2倍或者1.5倍。
std::vector<HashData> newTable(getNextPrime(_size));
for (size_t i = 0; i < _table.size(); i++)
{
//将旧表中的数据全部重新映射到新表中
if (_table[i]._state == EXITS)
{
//如果产生冲突,则找到一个合适的位置
size_t index = HashFunc(koft(_table[i]._data));
while (newTable[i]._state == EXITS)
{
i++;
if (i == _table.size())
{
i = 0;
}
}
newTable[i] = _table[i];
}
}
//最后直接将数据进行交换即可,原来的数据会随着函数栈帧一起销毁
_table.swap(newTable);
}
//用哈希函数计算出映射的位置
size_t index = HashFunc(koft(data));
//从那个位置开始探测, 如果该位置已经存在时,有两种情况,一种是已经存在,一种是冲突,这里使用的是线性探测
while (_table[index]._state == EXITS)
{
//如果已经存在了,则说明不用插入
if (koft(_table[index]._data) == koft(data))
{
return false;
}
else
{
index++;
index = HashFunc(index);
}
}
//如果走到这里,说明这个位置是空的或者已经被删除的位置,可以在这里插入
_table[index]._data = data;
_table[index]._state = EXITS;
_size++;
return true;
}
HashData* Find(const K& key)
{
KeyOfT koft;
size_t index = HashFunc(key);
//遍历,如果查找的位置为空,则说明查找失败
while (_table[index]._state != EMPTY)
{
//此时判断这个位置的数据是否相同,如果不同则说明出现哈希冲突,继续往后查找
if (koft(_table[index]._data) == key)
{
//此时有两个状态,一种是数据已经被删除,一种是数据存在。
if (_table[index]._state == EXITS)
{
return &_table[index];
}
else if (_table[index]._state == DELETE)
{
return nullptr;
}
}
index++;
//如果index越界,则归零
if (index == _table.size())
{
index = 0;
}
}
return nullptr;
}
bool Erase(const K& key)
{
HashData* del = Find(key);
//如果找不到则说明已经被删除
if (del == nullptr)
{
return false;
}
else
{
//找到了则直接更改状态即可
del->_state = DELETE;
_size--;
return true;
}
}
private:
std::vector<HashData> _table;
size_t _size;
};
};
哈希桶(开散列)
开散列也叫哈希桶,桶为每一个映射的位置,桶一般用链表或者红黑树实现(这里我用的是链表)。当我们通过映射的地址,找到存放数据的桶,再对桶进行插入或者删除操作即可。
插入
通过计算映射位置找到对应的桶,再判断数据是否存在后将数据头插进去即可(也可以尾插)
bool Insert(const T& data)
{
KeyofT koft;
/*
因为哈希桶是开散列的链式结构,发生了哈希冲突是直接在对应位置位置进行头插,而桶的个数是固定的,而插入的数据会不断增多,
随着数据的增多,就可能会导致某一个桶过重,使得效率过低。
所以最理想的情况,就是每个桶都有一个数据。这种情况下,如果往任何一个地方插入,都会产生哈希冲突,所以当数据个数与桶的个数相同时,也就是负载因子为1时就需要进行扩容。
*/
if (_size == _table.size())
{
//按照素数表来增容
size_t newSize = getNextPrime(_table.size());
size_t oldSize = _table.size();
std::vector<Node*> newTable(newSize);
_table.resize(newSize);
//接着将数据重新映射过去
for (size_t i = 0; i < oldSize; i++)
{
Node* cur = _table[i];
while (cur)
{
//重新计算映射的位置
size_t pos = HashFunc(koft(cur->_data));
//找到位置后头插进对应位置
Node* next = cur->_next;
cur->_next = newTable[pos];
newTable[pos] = cur;
cur = next;
}
//原数据置空
_table[i] == nullptr;
}
//直接和新表交换,交换过去的旧表会和函数栈帧一块销毁。
_table.swap(newTable);
}
size_t pos = HashFunc(koft(data));
Node* cur = _table[pos];
//因为哈希桶key值唯一,如果已经在桶中则返回false
while (cur)
{
if (koft(cur->_data) == koft(data))
{
return false;
}
else
{
cur = cur->_next;
}
}
//检查完成,此时开始插入,这里选择的是头插,这样就可以减少数据遍历的次数。
Node* newNode = new Node(data);
newNode->_next = _table[pos];
_table[pos] = newNode;
++_size;
return true;
}
查找
直接根据映射的位置到桶中查找数据即可
Node* Find(const K& key)
{
KeyofT koft;
size_t pos = HashFunc(key);
Node* cur = _table[pos];
while (cur)
{
if (koft(cur->_data) == key)
{
return cur;
}
else
{
cur = cur->_next;
}
}
return nullptr;
}
删除
bool Erase(const K& key)
{
KeyofT koft;
size_t pos = HashFunc(key);
Node* cur = _table[pos];
Node* prev = nullptr;
while (cur)
{
if (koft(cur->_data) == key)
{
//如果要删除的是第一个节点,就让下一个节点成为新的头节点,否则直接删除。
if (prev == nullptr)
{
_table[pos] = cur->_next;
}
else
{
prev->_next = cur->_next;
}
delete cur;
--_size;
return true;
}
else
{
prev = cur;
cur = cur->_next;
}
}
return false;
}
完整代码实现
#pragma once
#include<vector>
#include<string>
namespace lee
{
//算法科学家总结出的一个增容质数表,按照这样增容的效率更高
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值为整型,而大部分时候我们的key都是string,
或者某些自定义类型,这个时候就可以提供一个仿函数的接口给外部,让他自己处理如何将key转换成我们需要的整型
*/
template<class K>
struct Hash
{
const K& operator()(const K& key)
{
return key;
}
};
template<>
struct Hash<std::string>
{
const size_t & operator()(const std::string& key)
{
//BKDR字符串哈希函数
size_t hash = 0;
for (size_t i = 0; i < key.size(); i++)
{
hash *= 131;
hash += key[i];
}
return hash;
}
};
template<class T>
struct HashNode
{
HashNode(const T& data = T())
: _data(data)
, _next(nullptr)
{}
T _data;
HashNode<T>* _next;
};
template<class K, class T, class KeyofT, class Hash = Hash<K>>
class HashBucket
{
public:
typedef HashNode<T> Node;
HashBucket(size_t capacity = 10)
: _table(capacity)
, _size(0)
{}
~HashBucket()
{
Clear();
}
size_t getNextPrime(size_t num)
{
size_t i = 0;
for (i = 0; i < PRIMECOUNT; i++)
{
//返回比那个数大的下一个质数
if (primeList[i] > num)
{
return primeList[i];
}
}
//如果比所有都大,还是返回最后一个,因为最后一个已经是32位最大容量
return primeList[PRIMECOUNT - 1];
}
size_t HashFunc(const K& key)
{
Hash hash;
return hash(key) % _table.size();
}
bool Insert(const T& data)
{
KeyofT koft;
/*
因为哈希桶是开散列的链式结构,发生了哈希冲突是直接在对应位置位置进行头插,而桶的个数是固定的,而插入的数据会不断增多,
随着数据的增多,就可能会导致某一个桶过重,使得效率过低。
所以最理想的情况,就是每个桶都有一个数据。这种情况下,如果往任何一个地方插入,都会产生哈希冲突,所以当数据个数与桶的个数相同时,也就是负载因子为1时就需要进行扩容。
*/
if (_size == _table.size())
{
//按照素数表来增容
size_t newSize = getNextPrime(_table.size());
size_t oldSize = _table.size();
std::vector<Node*> newTable(newSize);
_table.resize(newSize);
//接着将数据重新映射过去
for (size_t i = 0; i < oldSize; i++)
{
Node* cur = _table[i];
while (cur)
{
//重新计算映射的位置
size_t pos = HashFunc(koft(cur->_data));
//找到位置后头插进对应位置
Node* next = cur->_next;
cur->_next = newTable[pos];
newTable[pos] = cur;
cur = next;
}
//原数据置空
_table[i] == nullptr;
}
//直接和新表交换,交换过去的旧表会和函数栈帧一块销毁。
_table.swap(newTable);
}
size_t pos = HashFunc(koft(data));
Node* cur = _table[pos];
//因为哈希桶key值唯一,如果已经在桶中则返回false
while (cur)
{
if (koft(cur->_data) == koft(data))
{
return false;
}
else
{
cur = cur->_next;
}
}
//检查完成,此时开始插入,这里选择的是头插,这样就可以减少数据遍历的次数。
Node* newNode = new Node(data);
newNode->_next = _table[pos];
_table[pos] = newNode;
++_size;
return true;
}
bool Erase(const K& key)
{
KeyofT koft;
size_t pos = HashFunc(key);
Node* cur = _table[pos];
Node* prev = nullptr;
while (cur)
{
if (koft(cur->_data) == key)
{
//如果要删除的是第一个节点,就让下一个节点成为新的头节点,否则直接删除。
if (prev == nullptr)
{
_table[pos] = cur->_next;
}
else
{
prev->_next = cur->_next;
}
delete cur;
--_size;
return true;
}
else
{
prev = cur;
cur = cur->_next;
}
}
return false;
}
Node* Find(const K& key)
{
KeyofT koft;
size_t pos = HashFunc(key);
Node* cur = _table[pos];
while (cur)
{
if (koft(cur->_data) == key)
{
return cur;
}
else
{
cur = cur->_next;
}
}
return nullptr;
}
void Clear()
{
//删除所有节点
for (size_t i = 0; i < _table.size(); i++)
{
Node* cur = _table[i];
while (cur)
{
Node* next = cur->_next;
delete cur;
cur = next;
}
_table[i] = nullptr;
}
}
private:
std::vector<Node*> _table;
size_t _size;
};
};