朋友们、伙计们,我们又见面了,本期来给大家解读一下有关哈希和哈希桶的知识点,如果看完之后对你有一定的启发,那么请留下你的三连,祝大家心想事成!
C 语 言 专 栏:C语言:从入门到精通
数据结构专栏:数据结构
个 人 主 页 :stackY、
C + + 专 栏 :C++
Linux 专 栏 :Linux
目录
1. 哈希概念
2. 哈希表的插入
2.1 哈希冲突
2.2 哈希冲突的解决
2.2.1 闭散列(开放定址法)
2.2.2 开散列 (哈希桶/拉链法)
2.3 闭散列的代码实现
2.3.1 闭散列代码优化
2.3.2 闭散列完整代码
2.4 开散列的代码实现
2.4.1 开散列代码优化
2.4.2 开散列完整代码
1. 哈希概念
顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即,搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。
如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一 一映射的关系,那么在查找时通过该函数可以很快找到该元素。
2. 哈希表的插入
1. 直接定址法
根据所要插入的关键值在哈希表中所对应的存储位置建立起的一种一 一映射关系。
这种方法所适合的场景:数据范围集中,数据量较小。与存储位置的关系是一对一的关系,不存在哈希冲突。
2. 除留余数法
用所要插入的元素和哈希表的大小进行取模操作,这样就可以将大范围的数据缩小,根据对应关系存储在哈希表中。
这种方法所适合的场景:数据范围不集中,且数据量较多。
与存储位置的关系是多对一的关系,并且存在哈希冲突。
2.1 哈希冲突
我们经常使用除留余数法,但是会面临哈希冲突,那么什么是哈希冲突呢?
不同的关键值通过映射关系所得到的存储位置是一样的,这种情况被叫做哈希冲突或者哈希碰撞。
2.2 哈希冲突的解决
2.2.1 闭散列(开放定址法)
闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。
那如何寻找下一个空位置呢?
1. 线性探测从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
hashi + i (i >= 0)
2. 二次探测
从发生冲突的位置开始,依次向后探测,探测的距离是i的平方,直到寻找到下一个空位置为止。
hashi + i^2 (i >= 0)
2.2.2 开散列 (哈希桶/拉链法)
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地
址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链
接起来,各链表的头结点存储在哈希表中。从上图可以看出,开散列中每个桶中放的都是发生哈希冲突的元素。
2.3 闭散列的代码实现
1. 基本构造
为了区分哈希表中的每一个位置值的情况,所以可以设置一个枚举状态值,这个节点的状态值包含:空、存在、删除。
我们采用Key_Value结构来实现哈希表
namespace open_address { //哈希节点状态 enum Status { EMPTY, //空 EXIST, //存在 DELETE //删除 }; //哈希节点 template<class K, class V> struct HashData { pair<K, V> _kv; Status _s; //哈希节点的状态 }; //哈希表 template<class K, class V> class HashTable { public: //构造 HashTable() { _tables.resize(10); //初始化先给10个空间 } private: vector<HashData<K, V>> _tables; // size_t _n = 0; //存储关键字的个数 }; }
2. 查找
通过传递关键之key进行查找,返回该关键值的指针,查找的逻辑首先得用key与存储位置的映射关系进行查找,如果查找到了空还没有找到对应的关键值,那么就返回空,映射关系我们采用的是除留余数法。
那么为什么找到空就结束了呢?首先我们根据除留余数法找到key存储的位置,那么这个位置有可能是被哈希冲突所占用的位置,那么本该存储在这个位置的值就需要继续向后面找空位置填充上去,那么key这个值的前面的位置的状态都是存在,所以可以一直向后面找,直到为空就停止。
//查找 HashData<K, V>* Find(const K& key) { size_t hashi = key % _tables.size(); //找对应位置 //不为空继续查找 while (_tables[hashi]._s != EMPTY) { if (_tables[hashi]._s == EXIST &&_tables[hashi]._kv.first == key) //_kv.first存在且等于key表示找到了 { return &_tables[hashi]; } ++hashi; hashi %= _tables.size(); } return NULL; }
3. 插入
插入首先需要查找要插入的key有没有在表中存在,若不存在既可以插入,若存在则不能插入。
插入我们采用除留余数法,用key与表的大小取模得到的映射关系,如果映射到的的位置的状态是存在,那么就需要继续向后面找空余的位置,如果映射到的位置的状态是删除或者空,那么就可以插入,如果映射到的位置后面的空间已满,那么就与表的大小取模,从最前面开始找空余位置。
//插入 bool Insert(const pair<K, V>& kv) { //先查找 if (Find(kv.first)) return false; //线性探测 size_t hashi = kv.first % _tables.size(); //找到对应的映射 while (_tables[hashi]._s == EXIST) { hashi++; //找到后面空着的位置 hashi %= _tables.size(); //形成一个圈 } _tables[hashi]._kv = kv; //插入 _tables[hashi]._s = EXIST; //修改状态 ++_n; //关键字个数++ return true; }
写到这里,那么存在一个问题,如果空间满了就需要扩容,那么哪种方式的扩容最容易呢?
可以使用在模拟实现string时,string的拷贝构造的现代写法,那么这种现代写法怎么运用到哈希表中呢?
首先要设置一个负载因子,表示空间使用率,这个负载因子我们设置为百分之70,如果使用率超过了百分之70,我们就需要进行扩容,扩容我们采用2倍扩容的方式,首先将创建一个新的哈希表,大小为原来表的2倍,然后依次遍历旧表,将旧表中的数据依次插入到新表,这时,新的哈希表就是我们想要的,然后将它与旧表交换即可,然后将新表删除即可。
//插入 bool Insert(const pair<K, V>& kv) { //先查找 if (Find(kv.first)) return false; //负载因子设置为0.7 if (_n * 10 / _tables.size() == 7) { //扩容 size_t NewSize = _tables.size() * 2; //2倍扩容 HashTable<K, V> NewHash; //创建新的哈希表 NewHash._tables.resize(NewSize); //设置为原来2倍大小 //遍历旧表将旧表中的内容插入新表 for (int i = 0; i < _tables.size(); ++i) { if (_tables[i] == EXIST) { NewHash.Insert(_tables[i]._kv); } } //交换 _tables.swap(NewHash._tables); } //线性探测 size_t hashi = kv.first % _tables.size(); //找到对应的映射 while (_tables[hashi]._s == EXIST) { hashi++; //找到后面空着的位置 hashi %= _tables.size(); //形成一个圈 } _tables[hashi]._kv = kv; //插入 _tables[hashi]._s = EXIST; //修改状态 ++_n; //关键字个数++ return true; }
新表是一个局部变量,出了作用域自动调用它的析构函数,析构函数内置类型不做处理,自定义类型vector会去自动调用vector的析构函数。
4. 删除
这里采用的是伪删除的方法,首先找到需要删除的key,然后将其对应的状态改为删除即可,然后将关键值个数减一。
//伪删除法 bool Erase(const K& key) { HashData<K, V>* ret = Find(key); if (ret) { ret->_s = DELETE; //修改状态即可 _n--; //个数-- return true; } else return false; }
2.3.1 闭散列代码优化
上述代码能看到很明显的局限性,我们在取模的时候给的是整型,但是遇到浮点类型、string类型就不能进行取模操作了,因此需要优化一下,利用仿函数,对与浮点数进行类型强转,然后特化出一份专门处理string类型的,可以将string类型的字符串所有字符的ASCII码加起来进行映射操作。
//仿函数 template<class K> struct HashFunc { size_t operator()(const K& key) { return (size_t)key; } }; //特化处理string类型 template<> struct HashFunc<string> { size_t operator()(const string& s) { size_t ret = 0; for (auto e : s) { ret *= 31; //防止字符一样顺序不一样 ret += e; //字符ASCII加起来 } return ret; } };
2.3.2 闭散列完整代码
#pragma once #include <vector> //开放定址法 namespace open_address { //仿函数 template<class K> struct HashFunc { size_t operator()(const K& key) { return (size_t)key; } }; //特化处理string类型 template<> struct HashFunc<string> { size_t operator()(const string& s) { size_t ret = 0; for (auto e : s) { ret *= 31; //防止字符一样顺序不一样 ret += e; //字符ASCII加起来 } return ret; } }; //哈希节点状态 enum Status { EMPTY, //空 EXIST, //存在 DELETE //删除 }; //哈希节点 template<class K, class V> struct HashData { pair<K, V> _kv; Status _s; //哈希节点的状态 }; //哈希表 template<class K, class V, class Hash = HashFunc<K>> class HashTable { public: //构造 HashTable() { _tables.resize(10); //初始化先给10个空间 } //插入 bool Insert(const pair<K, V>& kv) { //先查找 if (Find(kv.first)) return false; //负载因子设置为0.7 if (_n * 10 / _tables.size() == 7) { //扩容 size_t NewSize = _tables.size() * 2; //2倍扩容 HashTable<K, V> NewHash; //创建新的哈希表 NewHash._tables.resize(NewSize); //设置为原来2倍大小 //遍历旧表将旧表中的内容插入新表 for (size_t i = 0; i < _tables.size(); ++i) { if (_tables[i]._s == EXIST) { NewHash.Insert(_tables[i]._kv); } } //交换 _tables.swap(NewHash._tables); } //仿函数 Hash hs; //线性探测 size_t hashi = hs(kv.first) % _tables.size(); //找到对应的映射 while (_tables[hashi]._s == EXIST) { hashi++; //找到后面空着的位置 hashi %= _tables.size(); //形成一个圈 } _tables[hashi]._kv = kv; //插入 _tables[hashi]._s = EXIST; //修改状态 ++_n; //关键字个数++ return true; } //查找 HashData<K, V>* Find(const K& key) { Hash hs; size_t hashi = hs(key) % _tables.size(); //找对应位置 //不为空继续查找 while (_tables[hashi]._s != EMPTY) { if (_tables[hashi]._s == EXIST &&_tables[hashi]._kv.first == key) //_kv.first存在且等于key表示找到了 { return &_tables[hashi]; } ++hashi; hashi %= _tables.size(); } return NULL; } //伪删除法 bool Erase(const K& key) { HashData<K, V>* ret = Find(key); if (ret) { ret->_s = DELETE; //修改状态即可 _n--; //个数-- return true; } else return false; } private: vector<HashData<K, V>> _tables; // size_t _n = 0; //存储关键字的个数 }; }
2.4 开散列的代码实现
1. 基本构造
开散列又叫做哈希桶,这些桶也就是单链表所链接的节点,将存在哈希冲突的都放在同一个桶中,那么也避免不了冲突过多导致一个桶过长,因此,在部分编程语言中规定了如果一个桶的长度超过了8,那么就将原来挂的单链表转挂成红黑树。由于存在哈希思想,所以哈希桶的平均时间复杂度是O(1)。
同样的我们采用Key_Value的结构来定义哈希节点和哈希表,那么关于哈希表的实现我们可以采用两种方法:
① 使用库中的forward_list来现用,并且还可以根据桶的长度加上红黑树(吃现成)
namespace hash_bucket { template<class K, class T> class HashTable { private: struct bucket { forwad_list<pair<K, V>> _lt; //单链表 set<pair<K, V>> _rbtree; //红黑树 size_t len = 0; // 超过8,放到红黑树 }; vector<bucket> _tables; int _n; }; }
② 我们自己实现桶的节点(方便封装迭代器)
namespace hash_bucket { template<class K, class T> class HashNode { HashNode(const pair<K, T>& kv) :_kv(kv) ,_next(nullptr) {} pair<K, T> _kv; HashNode* _next; }; template<class K, class T> class HashTable { public: HashTable() { _tables.resize(10); } public: typedef HashNode<K, T> Node; private: vector<Node*> _tables; size_t _n; }; }
我们采用第二种方法实现,便于后面封装unordered_set和unordered_map。
2. 查找
首先用key取模哈希表的大小,从而找到hashi确定在哪个桶,然后再遍历该桶找key
//查找 Node* Find(const K& key) { //确定在哪一个桶中 size_t hashi = key % _tables.size(); Node* cur = _tables[hashi]; while (cur) { if (cur->_kv.first == key) return cur; else cur = cur->_next; } return nulptr; }
3. 插入
哈希桶的插入需要借助于Find,如果这个桶里面存在相同的数据,则不能插入(类似于set机制),如果没有方可插入,哈希桶的负载因子我们一般设置为1,那么哈希桶的插入有两种:尾插和头插,两种方式均可以插入,因为尾插需要找尾,所以我们一般选择头插。
那么该怎么对哈希桶进行扩容呢?
可以参考在实现闭散列时的方法:先创建一个容量为旧表容量二倍的新表,采用的遍历旧表将哈希桶中的节点头插到新表的映射位置,然后将旧表与新表实现交换,这样就拿到了新表的数据,也就完成了扩容的操作。
注意:虽然将旧表的桶挪到新表中,但是旧表中的节点还是指向该桶,所以每挪完一个桶需要将旧表的节点置为nullptr。
//插入 bool Insert(const pair<K, T>& kv) { if (Find(kv.first)) return false; //扩容 //负载因子为1再扩容 if (_n == _tables.size()) { //创建新的哈希表 vector<Node*> newTables; newTables.resize(_tables.size() * 2, nullptr); //遍历旧表 for (int i = 0; i < _tables.size(); i++) { //将旧表的节点依次遍历插入到新表 Node* cur = _tables[i]; while (cur) { Node* next = cur->_next; //记录下一个节点 size_t hashi = cur->kv.first % newTables.size(); //找到在新表里面的映射 cur->_next = newTables[hashi]; //链接 newTables[hashi] = cur; cur = next; //找下一个 } _tables[i] = nullptr; //断开链接 } _tables.swap(newTables); //拿到新表的数据 } //头插 size_t hashi = kv.first % _tables.size(); Node* newnode = new Node(kv); newnode->_next = _tables[hashi]; _tables[hashi] = newnode; _n++; return true; }
4. 删除
先通过key的映射查看在哪个桶中,然后遍历该桶,将需要删除的节点的前一个节点prev的next指向要删除节点的下一个节点,然后delete该节点即可完成删除,这个过程需要注意如果prev为空,那么直接将_tables[hashi]指向要删除节点的下一个节点即可。
//删除 bool Erease(const K& key) { size_t hashi = key % _tables.size(); Node* cur = _tables[hashi]; Node* prev = nullptr; while (cur) { if (cur->_kv.first == key) { if (prev == nullptr) { _tables[hashi] = cur->_next; } else { prev->_next = cur->_next; delete cur; return true; } } prev = cur; cur = cur->_next; } return false; }
5. 析构函数
由于我们自主实现了一个单链表,所以在析构时需要我们手动的进行释放哈希桶中的一系列节点。
~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; } }
2.4.1 开散列代码优化
根据闭散列的经验,我们还需要给开散列也同样的设置转化整数的仿函数,防止string类型不能转化为整数的缺陷,那么同样的需要用到模板特化,在浮点数转化整型的基础上特化一份转化string类型的仿函数。
//仿函数 template<class K> struct HashFunc { size_t operator()(const K& key) { return (size_t)key; } }; //特化处理string类型 template<> struct HashFunc<string> { size_t operator()(const string& s) { size_t ret = 0; for (auto e : s) { ret *= 31; //防止字符一样顺序不一样 ret += e; //字符ASCII加起来 } return ret; } };
2.4.2 开散列完整代码
// 开散列 namespace hash_bucket { //仿函数 template<class K> struct HashFunc { size_t operator()(const K& key) { return (size_t)key; } }; //特化处理string类型 template<> struct HashFunc<string> { size_t operator()(const string& s) { size_t ret = 0; for (auto e : s) { ret *= 31; //防止字符一样顺序不一样 ret += e; //字符ASCII加起来 } return ret; } }; template<class K, class T> class HashNode { public: HashNode(const pair<K, T>& kv) :_kv(kv) ,_next(nullptr) {} pair<K, T> _kv; HashNode* _next; }; template<class K, class T, class Hash = HashFunc<K>> class HashTable { public: HashTable() { _tables.resize(10); } ~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; } } public: typedef HashNode<K, T> Node; Hash hf; //仿函数用于转化整型 //查找 Node* Find(const K& key) { size_t hashi = hf(key) % _tables.size(); Node* cur = _tables[hashi]; while (cur) { if (cur->_kv.first == key) return cur; else cur = cur->_next; } return nullptr; } //插入 bool Insert(const pair<K, T>& kv) { if (Find(kv.first)) return false; //扩容 //负载因子为1再扩容 if (_n == _tables.size()) { //创建新的哈希表 vector<Node*> newTables; newTables.resize(_tables.size() * 2, nullptr); //遍历旧表 for (size_t i = 0; i < _tables.size(); i++) { //将旧表的节点依次遍历插入到新表 Node* cur = _tables[i]; while (cur) { Node* next = cur->_next; //记录下一个节点 size_t hashi = hf(cur->_kv.first) % newTables.size(); //找到在新表里面的映射 cur->_next = newTables[hashi]; //链接 newTables[hashi] = cur; cur = next; //找下一个 } _tables[i] = nullptr; //断开链接 } _tables.swap(newTables); //拿到新表的数据 } //头插 size_t hashi = hf(kv.first) % _tables.size(); Node* newnode = new Node(kv); newnode->_next = _tables[hashi]; _tables[hashi] = newnode; _n++; return true; } //删除 bool Erase(const K& key) { size_t hashi = hf(key) % _tables.size(); Node* cur = _tables[hashi]; Node* prev = nullptr; while (cur) { if (cur->_kv.first == key) { if (prev == nullptr) { _tables[hashi] = cur->_next; } else { prev->_next = cur->_next; delete cur; return true; } } prev = cur; cur = cur->_next; } return false; } private: vector<Node*> _tables; size_t _n; }; }
朋友们、伙计们,美好的时光总是短暂的,我们本期的的分享就到此结束,欲知后事如何,请听下回分解~,最后看完别忘了留下你们弥足珍贵的三连喔,感谢大家的支持!