时隔几月,终于有空继续来分析学习编程时的笔记过程了。
接上次更新上一篇讲完hash表的模拟后,本次,我们接着来继续对hash桶的认识!
前言:
上一篇我们用到的开放定址法:有个缺点,就是冲突会相互影响,如下图,4,5,6,7位置映射的值都会相互影响。
哈希桶是什么?
所以,我们现在有另一种方法:拉链法/哈希桶。
(教材中把它叫做开散列法。)
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
什么叫哈希桶呢?单看概念有点晦涩难懂,那我们就用图片来帮助理解:
优势:由图可以看出来开散列中每个桶中放的都是发生哈希冲突的元素
那么,下面我们直接通过模拟实现来进一步理解哈希桶的细节。
注意:我们主要目的是对于哈希桶的进一步理解。我们这里主要实现的是哈希桶的增删查改这几个常见的接口。
搭建框架
namespace hash_bucket
{
template<class T>
struct HashNode
{
T _data;
HashNode<T>* _next;
HashNode(const T& data)
:_data(data)
,_next(nullptr)
{}
};
template<class K, class T>
class HashTable
{
typedef HashNode<T> Node;
public:
private:
vector<Node*> _table; // 指针数组
size_t _n = 0; // 存储了多少个有效数据
}
}
这里,我们需要自己创建结点,创建单链表。
对于变量名vector<Node*>,有人可能会问了:为啥不用vector<list>?没必要,我们直到list属于任意位置插入,每个结点多了prev,多了头哨兵位结点,此时用list的话就没那么好实现后续的迭代器了。
构造函数
HashTable() { _table.resize(10, nullptr); }析构函数
~HashTable() { 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; } }定位到该位置,利用循环进行对链表结点的逐一释放。
插入部分
(有坑,后面改)
bool Insert(const T& data)
{
if(Find(data))
{
return false;
}
// 负载因子到1就扩容
if (_n == _table.size())
{
size_t newSize = _table.size()*2;
vector<Node*> newTable;
newTable.resize(newSize, nullptr);
// 遍历旧表,顺手牵羊,把节点牵下来挂到新表
for (size_t i = 0; i < _table.size(); i++)
{
Node* cur = _table[i];
while (cur)
{
Node* next = cur->_next;
// 头插到新表
size_t hashi = cur->_data % newSize;
cur->_next = newTable[hashi];
newTable[hashi] = cur;
cur = next;
}
_table[i] = nullptr;
}
_table.swap(newTable);
}
size_t hashi = data % _table.size();
// 头插
Node* newnode = new Node(data);
newnode->_next = _table[hashi];
_table[hashi] = newnode;
++_n;
return true;
我们先来个图分析分析:

我们插入过程是这样的:
newnode->next=table[hashi];
table[hashi]=newnode;
我们位置存的是指针(第一个结点的指针),链表连的是把你的地址给给我。
1.首先,我们先思考一个问题:哈希桶插入时要不要扩容呢?
答案是要的。插入数据越多,不扩容的话,某些桶会越来越长,那么效率得不到保证。
而且,我们的负载因子适当放大一些,一般负载因子控制在1,平均下来,每一个桶一个数据(理想状态下)。
而我们的扩容起到了冲突分散的作用。eg:原来冲突的->不冲突。(虽然也有不冲突的->冲突)但至少概率上分散了(100个位置->200个位置)。
2.开放定址法的做法:(如下)
那么,哈希桶这里,也像开放定址法一样复用insert好不好?
答案是不好;insert进来肯定不会扩容,因为空间之间已经开好的。关键是它会再次去newnode。
eg:首先遇到1,去新开一个结点,下一个111,它会到11位置去新开一个结点的。
新开结点后,你要把旧表释放,把一个个结点释放,相当于扩容逻辑中,把结点新开一遍(浪费了资源,你insert的是值。),旧结点释放一遍。而先在有了结点,我们不妨使用另一种方法
之前为什么需要复用?如果不用的话,你就要把探测的逻辑重新写一遍(因为,它并不存在挂结点问题)。
那么,我们这里定义一个newtable,在resize,此时,由于存在挂结点了,那么这时候我们就可以做到遍历旧表即可顺手牵羊的把结点牵下来,挂到新表了。
vector<Node*> newTable; newTable.resize(newSize, nullptr);
3.另外,我们需要保存起来下一个(方便头插),不然就找不到了。
// 头插 Node* newnode = new Node(data); newnode->_next = _table[hashi]; _table[hashi] = newnode;此外,针对哈希表遇到扩容就会变慢,这个我们在开放定址法也说过了,没有大方法解决它,但总体上问题不大。
4.vector会不会释放结点?(链表)(vector<Node*> newTable;)
答案是不会的,你swap交换后,由于你之前new table,要调用析构函数释放,但并不会去释放结点,因为选取调用delete[],但这个表上每个对象是什么类型?
内置类型(指针),所以它没有默认的析构函数,需要单独去写析构函数(这也是为什么上面我们有写析构函数的原因。)
ps:若忘了什么时候有默认的析构函数的情况,可以去下面文章复习复习噢。
_table.swap(newTable);
下一步,我们来讲解一下关于封装unordered_map与unordered_set的细节问题:
我们改用HashNode<T>来确认是K还是<K,V>:
unordered_set:是K
unordered_map:<K,V>
但是,到后面你会发现,set中是<K,K>?那么,我们来思考思考,明明我们并没有需要用到后面的value,那么我们为什么还要保留下来,并设置为<K,K>?
(为什么set不需要用,map又需要用到?)
这个我们之前也讲到过,在find这个函数接口中,map可不能只用T。
因此,为了更好结合这两个一起封装,我们将set弄成<K,K>
这种方法叫做:泛型:针对的是广泛(两种以上)的类型,代码不是针对某种具体的类型。
而模板是实现泛型的一种方式。
提取K与KV
那么问题又来了,我们既然采用这种方式,那怎么样将K,KV都同时跑起来呢?
这儿我们采用:提取出来。
取KofT模板,把T里面的K取出来(利用仿函数),它不是针对(K,K)(K,V)来写的。
unordered_set部分
namespace bai //防止与库里面的unordered_set冲突 { template<class K> class unordered_set { struct SetKeyOfT //提取K,采用仿函数策略 { const K& operator()(const K& key) { return key; } }; public: private: hash_bucket::HashTable<K, K, SetKeyOfT> _ht; }; }unordered_map部分
namespace bai { template<class K, class V> class unordered_map { struct MapKeyOfT { const K& operator()(const pair<K, V>& kv) { return kv.first; } }; public: private: hash_bucket::HashTable<K, pair<K, V>, MapKeyOfT> _ht; }; }
好,下一步,我们继续回到哈希桶的函数:
我们会发现,我们的insert函数需要针对这两种进行区分,不同情况不同提取,所以我们需要另外写一个模板(提取方法,即我们在unordered_set,unordered_map中写的,参杂进去),除了这个之外,我们在上一篇也讲到过,我们data的类型不仅仅是整形,它也可能是浮点型,字符串.....因此像开放定址法那样引入类模板。
类模板
template<class K, class T, class KeyOfT, class HashFunc = DefaultHashFunc<K>>
template<class K>
struct DefaultHashFunc
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
//模板特例化,针对string
template<>
struct DefaultHashFunc<string>
{
size_t operator()(const string& str)
{
// BKDR
size_t hash = 0;
for (auto ch : str)
{
hash *= 131;
hash += ch;
}
return hash;
}
};
改进insert部分(一)
bool Insert(const T& data)
{
KeyOfT kot;
if(Find(kot(data)))
{
return false;
}
HashFunc hf;
// 负载因子到1就扩容
if (_n == _table.size())
{
size_t newSize = _table.size()*2;
vector<Node*> newTable;
newTable.resize(newSize, nullptr);
// 遍历旧表,顺手牵羊,把节点牵下来挂到新表
for (size_t i = 0; i < _table.size(); i++)
{
Node* cur = _table[i];
while (cur)
{
Node* next = cur->_next;
// 头插到新表
size_t hashi = hf(kot(cur->_data)) % newSize;
cur->_next = newTable[hashi];
newTable[hashi] = cur;
cur = next;
}
_table[i] = nullptr;
}
_table.swap(newTable);
}
size_t hashi = hf(kot(data)) % _table.size();
// 头插
Node* newnode = new Node(data);
newnode->_next = _table[hashi];
_table[hashi] = newnode;
++_n;
return true;
}
Find部分
Node* Find(const K& key) { HashFunc hf; KeyOfT kot; size_t hashi = hf(key) % _table.size(); Node* cur = _table[hashi]; while (cur) { if (kot(cur->_data) == key) { return cur; } cur = cur->_next; } return nullptr; }代码逻辑简单,就不讲解了。
Erase部分
bool Erase(const K& key) { HashFunc hf; KeyOfT kot; size_t hashi = hf(key) % _table.size(); Node* prev = nullptr; Node* cur = _table[hashi]; while (cur) { if (kot(cur->_data) == key) { //如果prev为空,说明删除结点就在第一个位置 if (prev == nullptr) { _table[hashi] = cur->_next; } else { prev->_next = cur->_next; } --_n; delete cur; return true; } prev = cur; cur = cur->_next; } return false; }如果不理解,可以画一下图辅助一下
迭代器部分
接下来,我们就开始写begin,end接口,所以需要用到迭代器。我们先来完成迭代器。
搭建框架
// 前置声明 template<class K, class T, class KeyOfT, class HashFunc> class HashTable; template<class K, class T, class KeyOfT, class HashFunc> struct HTIterator { Node* _node; HashTable<K, T, KeyOfT, HashFunc>* _pht; HTIterator(Node* node, HashTable<K, T, KeyOfT, HashFunc>* pht) :_node(node) ,_pht(pht) {} };1.我们的迭代器需要用到HashTable,所以需要进行前置声明,否则会报错。
ps:前置声明:告诉编译器已经声明了,相当于打招呼。
2.另外,你仔细观察一下,这是迭代器部分与HashTable部分两者进行了相互使用:
即:哈希表使用迭代器,迭代器使用哈希表。
那么,迭代器为什么需要用到HashTable呢?迭代器需要持有执行哈希表的指针,另外增强了代码的复用性:后续对哈希表的底层进行优化,重要哈希表提供的接口不变,迭代器大概率可以继续使用,减少了代码的重复开发。
operator*
operator&
operator!=
T& operator*() { return _node->_data; } T* operator->() { return &_node->_data; } bool operator!=(const Self& s) { return _node != s._node; }
operator++
Self& operator++() { if (_node->_next) { // 当前桶还没完 _node = _node->_next; } else { KeyOfT kot; HashFunc hf; size_t hashi = hf(kot(_node->_data)) % _pht->_table.size(); // 从下一个位置查找查找下一个不为空的桶 ++hashi; while (hashi < _pht->_table.size()) { if (_pht->_table[hashi]) { _node = _pht->_table[hashi]; return *this; } else { ++hashi; } } _node = nullptr; } return *this; }逻辑:算自己是几号桶,除了知道下标,还要知道哈希表对象,找到下标,先++,进入循环,遇到不为空,跳出循环,说明找到了,反正++hash,走到结尾都找不到下一个桶
总结:这个前置 ++ 操作的目的是:让迭代器从当前位置,移动到哈希表中的“下一个有效元素”(优先在当前桶内找后续节点;桶内找完,就找数组中后续的非空桶)。当所有元素遍历完,迭代器会被置为 nullptr ,表示遍历结束。
注意:在写迭代器时,会有报错:
这里需要用到模板友元(简单理解-->):我想访问你的私有,所以让我成为你的朋友。
再写模板友元的时候,要把模板参数带上,让迭代器成为哈希表的朋友
template<class K, class T>
class HashTable
{
typedef HashNode<T> Node;
// 友元声明
template<class K, class T, class KeyOfT, class HashFunc>
friend struct HTIterator;
public:
//让后续写begin,end更简洁
typedef HTIterator<K, T, KeyOfT, HashFunc> iterator;
.......
.......
.......
private:
vector<Node*> _table; // 指针数组
size_t _n = 0; // 存储了多少个有效数据
}
好了,完了迭代器部分,我们正式进入begin,end的接口编写:
begin
iterator begin()
{
// 找第一个桶
for (size_t i = 0; i < _table.size(); i++)
{
Node* cur = _table[i];
if (cur)
{
return iterator(cur, this);
}
}
return iterator(nullptr, this);
}
复习this指针概念:
1. this 指针的基本概念
当一个对象调用其成员函数时,编译器会自动将该对象的地址作为 this 指针传递给成员函数。在成员函数内部,可以通过 this 指针来访问当前对象的成员(包括成员变量和成员函数)。
2. 在 HashTable 的 begin 函数中在 HashTable 类的 begin 函数里,有return iterator(cur, this); 这里的 this 指向当前调用 begin 函数的 HashTable 对象。
- 通过将 this (当前哈希表对象的地址)传递给迭代器的构造函数,迭代器就持有了指向该哈希表对象的指针(迭代器的 _pht 成员),从而可以在迭代器的操作中,基于这个指针访问对应的哈希表的结构和数据。
****this指针可以关联到具体的哈希表对象。*****
end
iterator end()
{
return iterator(nullptr, this);
}
写完哈希表接口后,我们继续回去到unordered_set,unordered_map封装那里。
unordered_set部分
namespace bai //防止与库里面的unordered_set冲突 { template<class K> class unordered_set { struct SetKeyOfT //提取K,采用仿函数策略 { const K& operator()(const K& key) { return key; } }; public: typedef typename hash_bucket::HashTable<K, K, SetKeyOfT>::const_iterator iterator; typedef typename hash_bucket::HashTable<K, K, SetKeyOfT>::const_iterator const_iterator; const_iterator begin() const { return _ht.begin(); } const_iterator end() const { return _ht.end(); } private: hash_bucket::HashTable<K, K, SetKeyOfT> _ht; }; }1.问题:这里迭代器能不能只定义const_iterator?
不能,平时用的还是iterator,只是它的底层是const而已。
2.要取类模板里面的内嵌类型,不能区分静态变量还是类型,所以要加上typename,这个在后面在封装时对迭代器的部分有体现到。
3.此时如果你这样写的话,编译器会编译不过,为什么呢?
那之前为啥没遇到过?以前的迭代器要不要传哈希表过去?
eg:红黑树不需要传呀,它只要传结点的指针过去就可。
那么我们该怎么改?
1.把vector传过去可以吗?不可以,因为const修饰*this,指向的内容也会有问题。
2.重载一个?不可以,这只顾了一边,实参可传形参,形参初始化成员?所以不可以的
3.所以我们的做法是:不如把成员改成const,迭代器里不会修改哈希表的内容,而是通过node修改的,类模板只要给不同的模板参数,就是不同类型。
原: HTIterator(Node* node, HashTable<K, T, KeyOfT, HashFunc>* pht) :_node(node) , _pht(pht) {} 改: HTIterator(Node* node, const HashTable<K, T, KeyOfT, HashFunc>* pht) :_node(node) , _pht(pht) {}
unordered_map部分
namespace bit { template<class K, class V> class unordered_map { public: typedef typename hash_bucket::HashTable<K, pair<const K, V>, MapKeyOfT>::iterator iterator; typedef typename hash_bucket::HashTable<K, pair<const K, V>, MapKeyOfT>::const_iterator const_iterator; iterator begin() { return _ht.begin(); } iterator end() { return _ht.end(); } const_iterator begin() const { return _ht.begin(); } const_iterator end() const { return _ht.end(); } private: hash_bucket::HashTable<K, pair<const K, V>, MapKeyOfT> _ht; }; }跟上面的unordered_set差不多.
封装insert部分
我们这里结合起来讲解:
bool insert(const pair<K, V>& kv) { return _ht.Insert(kv); }可能一开始会这么写,但是现在我们引入了迭代器了,随机我们的返回值也需要随之改变
变成:
unordered_set: pair<const_iterator,bool> unordered_map: pair<iterator,bool>pair是两个不同的模板参数,是不同的类型,自定义类型,
注意:这里没有缩小概念,只要指针与引用才有这概念。
类模板给不同的模板参数,就是不同类型,如果不在同一个类,会存在私有问题。
但是,当你写了这步骤后,你又会发现,编译不过去?此时该怎么办呢?
在迭代器那里写一个构造函数
// 普通迭代器时,他是拷贝构造 // const迭代器时,他是构造 HTIterator(const Iterator& it) :_node(it._node) , _pht(it._pht) {}本来,迭代器是不需要写拷贝构造的,因为它是浅拷贝,就是那个结点的指针拷贝
而现在写了后,当它充当是普通迭代器时,这个函数就是拷贝构造,充当const迭代器时,const_iterator这个函数是构造,支持普通迭代器转化成const迭代器的构造。
此时,unordered_map的insert部分
pair<iterator, bool> insert(const pair<K, V>& kv)
{
return _ht.Insert(kv);
}
unordered_set部分
pair<const_iterator, bool> insert(const K& key) { //return _ht.Insert(key); <typename hash_bucket::HashTable<K, K, SetKeyOfT>::iterator, bool> ret = _ht.Insert(key); return pair<const_iterator, bool>(ret.first, ret.second); }大家可能会发现unordered_set的return没有直接//return _ht.Insert(key);
因为如果那么做的话,它会有不确定性,有可能编译通过,有可能编译不过。
而大家可能会有疑惑,为什么之前在实现Myset.h的时候可以直接//return _ht.Insert(key)?
因为:相当于把这两个参数提取出来,当独立去调pair的构造,ret.first是普通迭代器,在pair的初始化列表,对自定义类型要调谁---直接调构造
unordered_map实现operator[]重载
这也是为什么insert我们要return pair的原因之一。
V& operator[](const K& key) { pair<iterator, bool> ret = _ht.Insert(make_pair(key, V())); return ret.first->second; }自行去HashTable改返回值
pair<iterator, bool> Insert(const T& data)改进insert部分(二)
pair<iterator, bool> Insert(const T& data) { KeyOfT kot; iterator it = Find(kot(data)); if(it != end()) { return make_pair(it, false); } HashFunc hf; // 负载因子到1就扩容 if (_n == _table.size()) { //size_t newSize = _table.size() * 2; size_t newSize = GetNextPrime(_table.size()); vector<Node*> newTable; newTable.resize(newSize, nullptr); // 遍历旧表,顺手牵羊,把节点牵下来挂到新表 for (size_t i = 0; i < _table.size(); i++) { Node* cur = _table[i]; while (cur) { Node* next = cur->_next; // 头插到新表 size_t hashi = hf(kot(cur->_data)) % newSize; cur->_next = newTable[hashi]; newTable[hashi] = cur; cur = next; } _table[i] = nullptr; } _table.swap(newTable); } size_t hashi = hf(kot(data)) % _table.size(); // 头插 Node* newnode = new Node(data); newnode->_next = _table[hashi]; _table[hashi] = newnode; ++_n; //分析1: return make_pair(iterator(newnode, this), true); }分析1.如果按照之前的情况,就发送错误了,原因如下
所以我们还需要传它是const迭代器还是普通迭代器?
优化:链表与树的转化
有方法将链表转化为树的效率:
伪代码:
insert hash (哈希方式插入)和 insert tree (树方式插入)分开,当单个桶里元素过多(比如达到1000个),会考虑从链表转换为树(比如红黑树)来优化查找效率(因为树的查找时间复杂度是 O(\log n),优于链表的 O(n))。
如果树要遍历链表,是不是要改变结构?
不需要。可以遍历链表,把链表元素全部插入到新建的树中,根,左子树,右子树,然后原来DELETE的结点给root,(将树的根节点赋值给对应的存储位置)(比如通过 root 指针),这样就完成了从链表到树的转换,同时不影响整体结构的逻辑一致性。
总结:哈希表处理冲突时,链表与树两种存储结构的配合与动态转换,以平衡空间和时间效率。
除留余数法:
减少hash冲突的概率---数据结构--(大概了解)
*****************“字符串的hash算法”*****************************
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 < PRIMECOUNT; ++i) { if (primeList[i] > prime) return primeList[i]; } return primeList[i]; }
好了,终于对于哈希桶的简单分享完成了,希望大家都有所收获!!!
最后,来到了本次鸡汤环节:
想全是问题,做全是答案!










707

被折叠的 条评论
为什么被折叠?



