文章目录
1 哈希概念
当向该结构中:
-
插入元素
根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放。 -
搜索元素
对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功。
该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表)
例如:数据集合{1,7,6,4,5,9};
哈希函数设置为:hash(key) = key % capacity; capacity为存储元素底层空间总的大小。
但是我们再插入一个数,例如14 ,会出现什么问题?
是不是就和之前的4给冲突了,那我们应该怎样解决哈希冲突呢?
2 哈希冲突
把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。
引起哈希冲突的一个原因可能是:哈希函数设计不够合理。
哈希函数设计原则:
-
哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值 域必须在0到m-1之间.
-
哈希函数计算出来的地址能均匀分布在整个空间中 哈希函数应该比较简单.
常见哈希函数
2.1 直接定址法 (常用)
2.2 除留余数法 (常用)
2.3 平方取中法
2.4 折叠法
2.5 随机数法
2.6 数学分析法
注意:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突。
解决哈希冲突两种常见的方法是:
3 闭散列
3.1 线性探测
比如2上面中的场景,现在需要插入元素14,先通过哈希函数计算哈希地址,hashAddr为4,因此44理论上应该插在该位置,但是该位置已经放了值为4的元素,即发生哈希冲突。那我们就将14插入到hashAddr为4后面没有被使用的地方(如果超出了表的长度就需要重新从头开始查找)。
但是这样做了还有一个小问题:我们删除了一个数再次查找时应该怎样查找呢?
如果只是查找为空位置为止的话那么删除元素后面的数据是不是都找不到了,所以为了方便区分各个元素的状态,我们不妨将元素的状态分为:空,存在,删除。
那我们就可以先把基本框架先搭好:
namespace CloseHash
{
enum State
{
DELETE,
EMPTY,
EXIST
};
template<class K, class V>
struct HashData
{
pair<K, V> _kv;
State _st = EMPTY;
};
template<class K>
struct HashTranfor
{
size_t operator()(const K& k)
{
return (size_t)k;
}
};
//特化一个版本出来
template<>
struct HashTranfor<string>
{
// BKDR
size_t operator()(const string& key)
{
size_t val = 0;
for (auto ch : key)
{
val *= 131;
val += ch;
}
return val;
}
};
template<class K,class V,class Hash=HashTranfor<K>>
class HashTable
{
private:
vector<HashData<K,V>> _tables;
size_t _size=0;
public:
};
大家发现了没有,除了模板参数我们除了给出了K,V关系外,我们还给了一个仿函数,为什么我们要给出仿函数呢?
其实也很好理解,因为我们插入查询的关键值可能不仅仅是能够直接取模的,比如常见的我们还可能插入字符串等等,所以我们的要有一个能够将关键值转化为整形的仿函数,因为字符串是比较常见的,所以我们特化了一个版本出来,字符串哈希的方式有很多种,想要进一步了解的老哥可以移步下面的文章:【字符串哈希算法】
插入:通过之前的分析我们知道:当表中空间中有大量位置已经有数据时再次插入数据发生冲突的可能性也会更大,那我们可以通过来控制表中数据量,一般负载因子给出在0.7~0.8之间比较合适,但是最大的问题是扩容咋办?这也是闭散列的劣势所在,扩容了后原来位置的映射关系可能发生了改变,所以之前旧表的元素得重新映射一遍,而扩容是闭散列效率低下的主要原因。
那应该如何扩容呢?比较常见的做法是我们在开一个空间更大的vector,然后在重复一遍之前插入的动作,但是我们可以采用一下更为巧妙的做法:
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
return false;
//扩容
if (_tables.size() == 0 || (_size * 10 / _tables.size() >= 7))
{
//负载因子满了就扩容,一般负载因子给到0.7
size_t newCapacity = _tables.size() == 0 ? 10 : _tables.size() * 2;
HashTable<K, V> newHash;//这里也可以考虑直接用一个vector,
//但是没必要,创建一个新的哈希表直接调用Insert即可
newHash._tables.resize(newCapacity);
for (auto& e : _tables)
{
if (e._st == EXIST)
newHash.Insert(e._kv);
}
newHash._tables.swap(_tables);
}
Hash hash;
//1 线性探测
size_t hashi =hash(kv.first) % _tables.size();
while (_tables[hashi]._st == EXIST)
{
++hashi;
hashi %= _tables.size();
}
_tables[hashi]._kv = kv;
_tables[hashi]._st = EXIST;
++_size;
return true;
注意:
- 代码中我们直接创建了一张新表,通过新表直接复用Insert即可,然后再交换旧表与新表的vector即可。
- 这里面还有一个小细节是每次模表的长度时我们模的都是size(),而不是capacity,这是由于我们要用的空间必须是已经开好了可以直接通过下标访问的。
查询:
HashData<K, V>* Find(const K& k)
{
if (_tables.size() == 0)//出错控制
return nullptr;
Hash hash;
//size_t hashi = k % _tables.size();//负数也可以处理,会将k先转换成无符号数再取模
//注意这里用二次探测的时候也是用的是一个一个向后查找
size_t hashi = hash(k) % _tables.size();
size_t start = hashi;
while (_tables[hashi]._st != EMPTY)
{
if (_tables[hashi]._st == EXIST && _tables[hashi]._kv.first == k)
return &_tables[hashi];
++hashi;
hashi %= _tables.size();
if (hashi == start)//防止表中全部剩的是删除的元素或者大部分是删除状态和存在状态而导致死循环
break;
}
return nullptr;
}
查询时有一个特别容易让人忽略的地方:当表中元素全部都是删除状态和存在状态时上面代码也能够正常处理,我们记录了最开始的位置当我们重新回到了原位置时就退出循环。
删除:
bool erase(const K& k)
{
if (Find(k) == nullptr)
return false;
auto ptr=Find(k);
ptr->_st = EMPTY;
--_size;
return true;
}
线性探测优点:实现非常简单;
线性探测缺点:一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”,即:不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低。如何缓解呢?
我们可以用二次探测来缓解线性探测踩踏问题。
3.2 二次探测
二次探测的实现也很简单,但是无论是线性探测还是二次探测都不能解决闭散列扩容时的代价。
二次探测插入的实现:
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
return false;
//扩容
if (_tables.size() == 0 || (_size * 10 / _tables.size() >= 7))
{
//负载因子满了就扩容,一般负载因子给到0.7
size_t newCapacity = _tables.size() == 0 ? 10 : _tables.size() * 2;
HashTable<K, V> newHash;//这里也可以考虑直接用一个vector,
//但是没必要,创建一个新的哈希表直接调用Insert即可
newHash._tables.resize(newCapacity);
for (auto& e : _tables)
{
if (e._st == EXIST)
newHash.Insert(e._kv);
}
newHash._tables.swap(_tables);
}
Hash hash;
//2 二次探测
size_t hashi = hash(kv.first) % _tables.size();
int i = 0;
while (_tables[hashi]._st == EXIST)
{
++i;
hashi = i * i;
hashi %= _tables.size();
}
_tables[hashi]._kv = kv;
_tables[hashi]._st = EXIST;
++_size;
return true;
}
查找的代码大家可以参考线性探测的代码再修改修改,这里我就不在多说了,很简单。
4 开散列
4.1 开散列概念
我们可以将结点像桶似的一个一个挂起来:
这样冲突的元素就在同一条链子上。
4.2哈希桶的模拟实现
开散列的模拟实现:
namespace BucketHash
{
template<class K,class V>
struct BucketHashNode
{
BucketHashNode* _next;
pair<K, V> _kv;
BucketHashNode(const pair<K,V>& kv)
:_next(nullptr)
,_kv(kv)
{}
};
template<class K>
struct HashTranfor
{
size_t operator()(const K& k)
{
return (size_t)k;
}
};
//特化一个版本出来
template<>
struct HashTranfor<string>
{
// BKDR
size_t operator()(const string& key)
{
size_t val = 0;
for (auto ch : key)
{
val *= 131;
val += ch;
}
return val;
}
};
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
};
template<class K, class V,class Hash=HashTranfor<K>>
class HashTable
{
private:
typedef BucketHashNode<K, V> Node;
vector<Node*> _tables;
size_t _size = 0;
size_t GetCapacity(size_t sz)
{
int i = 0;
for (; i < __stl_num_primes; ++i)
{
if (sz < __stl_prime_list[i]) break;
}
return __stl_prime_list[i];
}
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;
}
}
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
return false;
Hash hash;
//扩容,负载因子为1
if (_size == _tables.size())
{
//int newcapacity = _tables.size() == 0 ? 10 : _tables.size() * 2;
int newcapacity =GetCapacity(_tables.size());
vector<Node*> newTables;//这里直接用vector的目的是可以直接用原表的结点直接链接即可
//不必多拷贝结点
newTables.resize(newcapacity);
for (int i = 0; i < _tables.size(); ++i)
{
Node* cur = _tables[i];
//头插
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[i] = nullptr;
}
_tables.swap(newTables);
}
size_t hashi = hash(kv.first) % _tables.size();
Node* newNode = new Node(kv);
newNode->_next = _tables[hashi];
_tables[hashi] = newNode;
++_size;
return true;
}
Node* Find(const K& k)
{
if (_tables.size()==0)
return nullptr;
Hash hash;
size_t hashi = hash(k) % _tables.size();
Node* cur = _tables[hashi];
while (cur)
{
if (cur->_kv.first == k)
return cur;
cur = cur->_next;
}
return nullptr;
}
bool Erase(const K& k)
{
if (Find(k) == nullptr)
return false;
Hash hash;
size_t hashi = hash(k) % _tables.size();
if (_tables[hashi]->_kv.first == k)
{
Node* del = _tables[hashi];
_tables[hashi] = del->_next;
delete del;
return true;
}
Node* cur = _tables[hashi];
while (cur->_next && cur->_next->_kv.first!=k)
{
cur = cur->_next;
}
Node* del = cur->_next;
cur->_next = del->_next;
delete del;
return true;
}
size_t Size()const
{
return _tables.size();
}
size_t BucketSize()const
{
int cnt = 0;
for (int i = 0; i < Size(); ++i)
{
if (_tables[i])
++cnt;
}
return cnt;
}
size_t MaxBucketSize()const
{
int maxLen = 0;
for (int i = 0; i < Size(); ++i)
{
Node* cur = _tables[i];
int len = 0;
while (cur)
{
++len;
cur = cur->_next;
}
maxLen = max(maxLen, len);
}
return maxLen;
}
};
注意:
至于其他地方我相信大家学了链表的增删查改后都是小意思了。
至于验证的话大家可以找一组随机数来测测哈希冲突的概率:
void test1()
{
srand(time(0));
HashTable<int, int> sh;
int N = 100;
for (int i = 0; i < N; ++i)
{
int x = rand();
sh.Insert(make_pair(x,x));
}
cout << sh.BucketSize() << endl;
cout << sh.MaxBucketSize() << endl;
cout << sh.Size() << endl;
cout << "负载因子" << (double)sh.BucketSize() / (double)sh.Size() << endl;
}
4.3 开散列与闭散列的比较
好了,今天的内容就到这里了,如果该文章对你有帮助的话能不能一件三连博主呢?💗💗💗