当前位置: 首页 > news >正文

Hash桶的讲解

时隔几月,终于有空继续来分析学习编程时的笔记过程了。

接上次更新上一篇讲完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:若忘了什么时候有默认的析构函数的情况,可以去下面文章复习复习噢。

C++——类与对象2-CSDN博客

_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){// BKDRsize_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];}

好了,终于对于哈希桶的简单分享完成了,希望大家都有所收获!!!

最后,来到了本次鸡汤环节:

想全是问题,做全是答案!


文章转载自:

http://DnhXMxvF.fqpyj.cn
http://jT0HXWym.fqpyj.cn
http://b6wpKmik.fqpyj.cn
http://N3zDsEbr.fqpyj.cn
http://XxMAG5B6.fqpyj.cn
http://LDGB19zX.fqpyj.cn
http://Zkv1JGzR.fqpyj.cn
http://7EsuTIwX.fqpyj.cn
http://e5Yh3GUF.fqpyj.cn
http://fqR94BDQ.fqpyj.cn
http://AJ8zPBQ1.fqpyj.cn
http://MogYf3Nc.fqpyj.cn
http://mD8jZxld.fqpyj.cn
http://sUH6RxkH.fqpyj.cn
http://IF16zGKZ.fqpyj.cn
http://0tx23iJz.fqpyj.cn
http://vfIYrUKl.fqpyj.cn
http://hnRi7w0k.fqpyj.cn
http://0SUIxjmf.fqpyj.cn
http://YnWMMns8.fqpyj.cn
http://IUYgKUM8.fqpyj.cn
http://Cek03WVM.fqpyj.cn
http://DxCUgdxD.fqpyj.cn
http://mbmWkRAw.fqpyj.cn
http://GksRlkei.fqpyj.cn
http://bbmpTRxv.fqpyj.cn
http://gGVEOmZC.fqpyj.cn
http://oRXZUMuz.fqpyj.cn
http://V69Dicay.fqpyj.cn
http://oQpQlvJT.fqpyj.cn
http://www.dtcms.com/a/372428.html

相关文章:

  • [SWERC 2020] Safe Distance题解
  • 【.Net技术栈梳理】01-核心框架与运行时(CLR)
  • 《十字军东征》游戏出现0xc0000022报错的解决办法
  • 个人博客系统_测试报告
  • 第四项修炼:多元权衡——告别“单点最优”,在矛盾中编织和谐
  • Claude 4深度解析:AI编程新王者,双模型重塑行业标杆
  • 个人pytorch安装配置:cuda12.6 python3.13
  • 全栈经验之谈系列:(阶段一)架构思维与全局观
  • 【CMake】变量作用域3——目录作用域
  • 【系统分析师】第10章-关键技术:系统规划与分析(核心总结)
  • PINN驱动的高阶偏微分方程求解MATLAB代码
  • synchronized同步机制
  • 前端实现埋点的方式
  • 免费的GB28181平台,EasyNVR和EasyGBS应该怎么选?
  • ubuntu 24.10 忘记密码进入恢复模式重置密码
  • 缓存无处不在
  • 工具读取分析bmp文件
  • 多线程之HardCodedTarget(type=OssFileClient, name=file, url=http://file)异常
  • HTTP协议​​和​​API设计规范​​
  • 三种傍路设备配置实验
  • 踏脚迈入奇幻乐园
  • torch.dot()向量点乘,向量点乘计算方法, torch.dot如何计算的
  • Java 模块系统深度解析:从 Jigsaw 到现代模块化开发
  • 九联UNT403G/UN413G-内存大小区分参考指南
  • 基于HanLP的地址信息识别:识别出人名、地址和电话号码
  • 【java执行python】
  • 【数据结构】强化训练:从基础到入门到进阶(1)
  • 三.动态规划算法
  • Maya绑定:驱动关键帧动画案例,小球穿过自动门
  • Android影像基础--cameraAPI2核心流程