🪐🪐🪐欢迎来到程序员餐厅💫💫💫
主厨:邪王真眼
主厨的主页:Chef‘s blog
所属专栏:c++大冒险
总有光环在陨落,总有新星在闪烁
引言:
我们之前学习了红黑树及其应用,它增删查改的时间复杂度仅仅只有log N,然而,今天我们会学习的哈希直接把增删查改的时间复杂度降低到了O(1)!心动了对吧?那就快来学习吧。
一、哈希概念
理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。
二、 哈希函数
哈希方法中使用的转换函数称为哈希(散列)函数
- 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0到m-1之间
- 哈希函数计算出来的地址能均匀分布在整个空间中
- 哈希函数应该比较简单
常见哈希函数
2.1 直接定址法--(常用)
Hash(Key)= A*Key + B
- 优点:简单、均匀
- 缺点:需要事先知道关键字的分布情况
- 使用场景:适合查找比较小且连续的情况
2.2. 除留余数法--(常用)
Hash(key) = key% p(p<=p<=m)
-
优点:不需要事先知道key的分布情况
-
缺点:会产生哈希冲突
-
选质数作为除数可以减少哈希冲突,原因:这是因为素数具有较好的分布性质,能够更均匀地将输入数据映射到不同的哈希值上。相比于选择非素数的除数,选择素数可以降低哈希地址集中在某些特定值上的可能性,从而减少冲突的发生。
以下方法不常用,感兴趣的朋友可以了解一下
2.3. 平方取中法--(了解)
2.4. 折叠法--(了解)
2.5. 随机数法--(了解)
三、哈希冲突
哈希冲突是指在哈希表中,不同的键值经过哈希函数计算后得到相同的哈希值,导致它们被映射到同一个槽位或桶中。这种情况下,就会发生冲突。
哈希冲突可能会导致以下问题:
- 数据丢失:当两个不同的键值被映射到同一个槽位时,其中一个键值的数据可能会被覆盖。
- 查找效率下降:在发生冲突的槽位中,需要通过额外的操作来查找目标键值,这会增加查找的时间复杂度。
- 哈希表性能下降:频繁的哈希冲突会导致哈希表的装载因子增加,使得哈希表的性能下降。
四、哈希冲突解决
4.1 闭散列——开放地址法
4.1.1. 线性探测
4.1.2存储数据类型:
enum State
{EMPTY,//该位置为空EXIST,//该位置已经有元素DLETE//该位置元素已被删除
};template<class K, class V>
struct HashData
{State _state = EMPTY;//标记当前位置的状态pair < K, V> _kv;
};
注意事项:
可能有朋友疑问问什么有三个状态,而不是直接存在和不存在两种,这点在后面查找中你就明白了
4.1.3哈希表成员变量
template<class K,class V>
class HashTable
{
protected:vector<HashData> _tables;size_t size = 0;//存储数据个数
};
注意事项:
- 哈希表底层一般使用数组(vector)
- 哈希表的有效数据个数_n与vector的size不同
4.1.4构造函数:
HashTable(size_t size=10)
{_tables.resize(size);
}
注意事项:
不要把缺省值给个0,不然后面还要对0的情况进行讨论,不如直接开10个空间
4.1.5查找:
HashData<K,V>* Find(const K&key)
{size_t hash = key % _tables.size();size_t pos = hash;size_t i = 1;while (_tables[pos]._state != EMPTY){if (_tables[pos].state == EXIST && _tables[pos]._kv.first == key)return &_tables[pos];pos += i;if (pos >= _tables.size())return nullptr;}return nullptr;
}
流程:
- key取模vector的size(不是capacity!!不然可能越界访问),得到哈希地址hashi
- 沿当前位置向后找,直到该位置状态为空或超出数组边界,则返回空指针,表示没有该数据
- 如果该位置状态为存在且key相等,则找到了并返回对应数据指针
4.1.6插入
bool Insert(pair<K,V>& kv)
{if (Find(kv.first))return false;size_t hash = key % _tables.size();size_t pos = hash;size_t i = 1;while (_tables[pos]._state == EXIST){pos += i;if (pos >= _tables.size())return false;}_tables[pos]._kv = kv;_tables[pos]._states = EXIST;return true;_size++;
}
- 查找当前是否存在该值,如果存在,则不插入(哈希表不存同样的数据)
- 通过哈希函数获取待插入元素在哈希表中的位置,得到哈希地址hashi
- 如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测沿当前位置向后找,直到状态为空或删除,才插入
但是,上述情况是哈希表未满时,如果满了如何扩容?还有,一定要满了才扩容吗?
我们引入负载因子的概念:α = 有效数据个数 / 哈希表长度
当负载因子越大,哈希冲突的概率就越大,同时发生哈希踩踏的概率也越大,对于开放定址法,应该控制负载因子小于0.7,超过0.7则扩容。
if (_size * 10 / _tables.size() > 7)//扩容{vector<HashData> newtables(_tables.size() * 2);for (auto hashdata : _tables){pair<K, V>kv = hashdata._kv.first;size_t hash = key % _tables.size();size_t pos = hash;size_t i = 1;while (newtables[pos]._state == EXIST){pos += i;}_tables[pos]._kv = kv;_tables[pos]._states = EXIST;}_tables.swap(newtables);}
流程:
- 判断时左右同乘以10,避免比较浮点数而带来误差
- 扩容为原本的2倍(本来应该是接近2倍的素数,这里简单起见没实现)
- 将原哈希表中的元素一一映射到新表中
- 最后交换旧表和新表(类似于拷贝构造的现代写法)
4.1.7删除
bool Erase(const K& key){HashData<K, V>* ptr = Find(key);if (ptr){(*ptr)._state = DELETE;--_size;return true;}return false;}
注意事项:
- 先查找当前是否存在该值,如果存在,则删除
- 这里的删除,只用将状态变量改为删除即可,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。因此线性探测采用标记的伪删除法来删除一个元素。
- 线性探测优点:实现非常简单,
- 线性探测缺点:一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”,即:不同 关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降 低。如何缓解呢?
4.1.8. 二次探测
线性探测(一个一个往后找),这种探测方法可能会造成大量的哈希冲突。
那么,有没有什么探测方法能缓解哈希冲突呢?有,那就是二次探测!,因此二次探测为了避免该问题,找下一个空位置的方法为:
while (newtables[pos]._state == EXIST)
{pos = hashi + i*i;//二次探测++i;
}
4.2 开散列——链地址法(拉链法)
4.2.1. 开散列概念
4.2.2节点:
template<class K,class V>
struct HashNode
{HashNode<K, V> _next;pair<K, v> _kv;HashNode(pair<K,V> kv=pair<K,V>()):_next(nullptr),_kv(kv){}};
4.2.3成员变量:
template<class K, class V>
class HashTable
{typedef HashNode<K, V> Node;
protected:vector<Node*> _tables;size_t _size = 0;
};
template<class K, class V,class Hash=HashFunck<K>>
class HashTable
{typedef HashNode<K, V> Node;
protected:vector<Node*> _tables;size_t _size = 0;
};
4.2.4构造函数
HashTable(size_t size=10)
{_tables.resize(size);
}
4.2.5析构函数
~HashTable()
{for (auto hash_node : tables){while (hash_node){Node* new_node = hash_node->_next;delete hash_node;hash_node = new_node;}}
}
注意事项:
每条链表都要手动释放
4.2.6查找:
Node* Find(const K&key)
{size_t hash = Hash(key)%_tables.size();Node* cur = _tables[hash];while (cur){if (cur->_kv.first == key)return cur;cur = cur->_next;}return nullptr;
}
细流程:
- 先取模计算出哈希地址
- 再沿当前单链表向下查找
- 进行头插
4.2.7插入
bool Insert(pair<K, V>& kv){if (Find(kv.first))return false;size_t hash = Hash(key) % _tables.size();Node*cur = _tables[hash];Node* p(kv);p->_next=cur;_tables[hash] = p;_size++;return true;}
运用开链法后,虽然没有哈希冲突了,但是链表长度过长也会影响效率。所以,哈希表也需要通过扩容来使链表长度变短,理想的状态是负载因子为1时扩容。
if (_size == _tables.size())
{vector<Node*> new_tables(_size*2);for (auto node : _tables){while (node){Node* next = node->_next;size_t hash = Hash(node->_kv.firsh) % new_tables.size();node->_next=new_tables[hash];new_tables[hash] = node;node = next;}}_tables.swap(new_tables);
}
流程:
- 二倍扩容(本来应该是接近2倍的素数,这里简单起见没实现)
- 遍历旧表,将旧表结点重新映射到新表上
- 最后交换旧表和新表
4.2.8 删除
bool Erase(const K& key)
{size_t hash = Hash(key) % _tables.size();Node* cur = _tables[hash];Node* pre = nullptr;while (cur){if (cur->_kv.first == key)break;pre = cur;cur = cur->_next;}if (cur == nullptr)return false;if (pre == nullptr)_tables[hash] = cur->_next;elsepre->_next = cur->_next;delete cur;return true;
}
注意事项:
- 要设置前驱指针pre
- 根据pre是否为空,分类处理
4.2.9 哈希化
由于除留余数法涉及到取模运算,而只有整型才能取模。所以针对非整型的数据,需要将其转化为整型,这一过程称为哈希化。
template<class K>
struct HashFunck
{size_t operator()(K s){return s;}
};
template<>
struct HashFunck<string>
{size_t operator()(const string& s){size_t number=0;
int multiply=31;for (auto ch : s)number = number * multiply + ch;//multiply可以取这些值131, 31 131 1313 13131 131313return number;}
};
4.3. 开散列与闭散列比较
五、哈希表和红黑树的优劣
5.1哈希表优劣
哈希表是一种基于哈希函数的数据结构,它将键映射到存储位置,以实现快速的查找和插入操作。哈希表的优点:
- 高效的查找和插入操作:通过哈希函数计算键的存储位置,可以在平均情况下以常数时间复杂度进行查找和插入。
- 空间利用率高:哈希表可以根据实际需求动态调整存储空间,避免了不必要的内存浪费。
哈希表的缺点:
- 哈希冲突:不同的键可能映射到相同的存储位置,导致冲突。解决冲突的方法包括链地址法和开放地址法,但这些方法可能会增加查找的时间复杂度。
- 不支持有序性操作:哈希表中的键是无序的,如果需要按照键的顺序进行操作,就需要额外的处理。
5.2红黑树优劣
红黑树是一种自平衡的二叉搜索树,它具有以下特点:
- 平衡性:红黑树通过保持特定的性质,使得树的高度保持在一个较小的范围内,从而保证了查找、插入和删除操作的时间复杂度都是对数级别的。
- 有序性:红黑树中的节点按照键的大小有序排列,支持按序遍历和范围查询等操作。
红黑树的优点:
- 支持有序性操作:红黑树中的节点按照键的大小有序排列,可以方便地进行按序遍历、范围查询等操作。
- 自平衡性:红黑树通过自动调整节点的颜色和旋转操作,保持树的平衡,避免了极端情况下的性能退化。
红黑树的缺点:
- 相对于哈希表,红黑树的查找和插入操作的平均时间复杂度较高,尤其是在数据量较大时。
- 红黑树需要额外的存储空间来存储节点的颜色信息,相比于哈希表可能会占用更多的内存。
综上所述,
哈希表适用于需要快速查找和插入操作,并且不要求有序性的场景;而红黑树适用于需要有序性操作,并且对平衡性有要求的场景。具体选择哪种数据结构,需要根据实际需求和场景来进行权衡和选择。
创作不易,点赞关注支持一下吧