【C++/STL】:哈希 -- 线性探测&哈希桶

24k纯甄 2024-08-16 08:05:07 阅读 58

目录

💡前言一,unordered系列容器二,哈希2.1 哈希的概念2.2 哈希函数2.3 哈希冲突

三,哈希冲突解决(重点)3.1 开放定址法3.2 哈希桶(重点)

四,线性探测的实现4.1 线性探测的基本框架4.2 插入操作4.3 查找操作4.4 删除操作

五,哈希桶的实现(重点)5.1 哈希桶的基本框架5.2 插入操作5.3 查找操作5.4 删除操作

六,优化思考

点击跳转至文章:

【C++/STL】:set和map的介绍及基本使用

💡前言

在C++98中,STL提供了底层为红黑树结构的一系列关联式容器,在查询时效率可达到

l

o

g

2

N

log_2 N

log2​N,即最差情况下需要比较红黑树的高度次。

在C++11中,STL又提供了4个unordered系列的关联式容器,这四个容器与红黑树结构的关联式容器使用方式基本类似,只是其底层结构不同,unordered系列的底层是哈希表

本篇文章的内容是学习unordered系列的容器,重点学习什么是哈希,知道什么是哈希冲突,并且掌握解决哈希冲突的两种常用方法:线性探测&哈希桶。

一,unordered系列容器

unordered系列容器有4个:

(1) unordered_map

(2) unordered_set

(3) unordered_multimap

(4) unordered_multiset

它们与 map/set 系列容器的核心功能的重叠度90%,使用方法基本类似,这里不再重复演示。最主要的区别是:遍历unordered系列容器中存储的数据是无序的,并且就性能而言,unordered系列容器在插入,查找,删除方面时间复杂度是 O(1)因此在平时,我们更加推荐使用unordered系列容器

二,哈希

2.1 哈希的概念

构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与元素之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素

该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表)

2.2 哈希函数

这里介绍两种常见的哈希函数:直接定址法除留余数法

(1) 直接定址法:用 key 值直接在哈希表中映射一个绝对位置或相对位置

优点:快,无冲突

缺点:要事先知道 key 值的分步情况,并且适用于范围集中的数据

(2) 除留余数法:用 key 值模(%)表的大小 N,利用得到的余数(0 ~ N-1)把 key 值映射到表中的位置

这种方法适用任何类型的数据(一些非整形类型可以通过某种方法转换),但是这也会导致一个不可避免的问题:产生哈希冲突

2.3 哈希冲突

不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞

如果用上面的除留余数法来通俗的解释,就是不同的 key 值,取模后映射到了相同的位置

三,哈希冲突解决(重点)

解决哈希冲突两种常见的方法是:开放地址法哈希桶

3.1 开放定址法

当发生哈希冲突时(自己的位置被占了),如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把 key 值存放到冲突位置中的"下一个" 空位置中去

那如何寻找下一个空位置呢?

(1) 线性探测:从发生冲突的位置开始,按 +1,+2,+3……依次向后探测,直到寻找到下一个空位置为止

比如下表中现在需要插入元素44,先通过哈希函数计算出映射位置为4,但是4位置被占,所以要从这个位置开始,依次向后探测,找到8空位置。

在这里插入图片描述

线性探测优点:实现非常简单,

线性探测缺点:一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”,即:不同 key 值占据了可利用的空位置,使得寻找某 key 值的位置需要许多次比较,导致搜索效率降低。如何缓解呢?

(2) 二次探测:从发生冲突的位置开始,按 +1 ^ 2 , + 2 ^ 2,+3 ^ 2……依次向后探测,直到寻找到下一个空位置为止。

3.2 哈希桶(重点)

哈希桶也叫拉链法,首先对 key 值用哈希函数计算出位置,具有相同位置的 key 值归于同一集合,每一个集合称为一个桶各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中

unordered系列的底层哈希表解决哈希冲突的结构就是哈希桶

从下图可以看出,哈希表中每个桶中放的都是发生哈希冲突的元素

在这里插入图片描述

四,线性探测的实现

4.1 线性探测的基本框架

哈希表中的每一个位置给一个状态标记。这样可以避免值存在但是由于冲突位置删除后置空导致查找不到的问题

<code>//枚举三种状态

enum State

{ -- -->

EMPTY, //空

EXIST, //存在元素

DELETE //元素删除

};

template<class K, class V>

struct HashData

{

pair<K, V> _kv;

State _state = EMPTY;

};

template<class K, class V, class Hash = HashFunc<K>>

class HashTable

{

public:

HashTable()

{

_tables.resize(10); //初始化10个空间

}

//插入,查找,删除等功能……

private:

vector<HashData<K, V>> _tables;

size_t _n = 0; //表中的数据个数

};

4.2 插入操作

(1) 计算出映射下标,这里用除留余数法要除以数据的个数size,而不是容量的大小capacity,因为要用下标访问,如果除以容量用下标访问会越界。

(2) 插入时发生哈希冲突,用线性探测往后找空位置。

(3) 扩容负载因子:表中的元素个数 / 表的长度 ,一般控制在0.7~0.8

(4) 去重操作,插入时可以复用Find进行判断,值存在,插入失败。

bool Insert(const pair<K, V>& kv)

{

//去冗余,值存在,插入失败

if (Find(kv.first))

return false;

// 扩容

//负载因子:表中的元素个数 / 表的长度 一般控制在0.7~0.8

if (_n * 10 / _tables.size() >= 7)

{

HashTable<K, V, Hash> newHT;

//不要在原表扩容,会破坏映射关系

//_tables.resize(_tables.size() * 2); //err

//重开一个新的哈希表

newHT._tables.resize(_tables.size() * 2);

//遍历旧表,复用Insert,(可避免重复写映射下标的逻辑)

// 把旧表中存在的数据插入新表

for (size_t i = 0; i < _tables.size(); i++)

{

if (_tables[i]._state == EXIST)

newHT.Insert(_tables[i]._kv);

}

//交换

_tables.swap(newHT._tables);

}

//计算出映射下标

//这里用除留余数法。要除以数据的个数size,而不是容量的大小capacity

//因为要用下标访问,如果除以容量用下标访问会越界

Hash hs;

size_t hashi = hs(kv.first) % _tables.size();

//哈希冲突,线性探测继续找下一个位置

while (_tables[hashi]._state == EXIST)

{

hashi++;

hashi %= _tables.size(); //解决回绕

}

_tables[hashi]._kv = kv;

_tables[hashi]._state = EXIST;

_n++;

return true;

}

4.3 查找操作

算出映射下标确定查找起点,再继续往后找,值相等且为存在状态就找到了,避免删除后还能找到

HashData<K, V>* Find(const K& key)

{

Hash hs;

size_t hashi = hs(key) % _tables.size();

while (_tables[hashi]._state != EMPTY)

{

//值相等且为存在状态,避免删除后还能找到

if (_tables[hashi]._kv.first == key

&& _tables[hashi]._state == EXIST)

{

return &_tables[hashi];

}

hashi++;

hashi %= _tables.size(); //解决回绕

}

return nullptr;

}

4.4 删除操作

复用Find,值存在就删除

bool Erase(const K& key)

{

HashData<K, V>* ret = Find(key);

if (ret == nullptr)

return false;

else

{

ret->_state = DELETE;

_n--;

return true;

}

}

五,哈希桶的实现(重点)

5.1 哈希桶的基本框架

哈希桶的结构通俗的说就是在顺序表中挂一个个链表,所以这里的顺序表是一个指针数组

在这里插入图片描述

<code>template <class K, class V>

struct HashNode

{ -- -->

pair<K, V> _kv;

HashNode<K, V>* _next;

HashNode(const pair<K,V>& kv)

:_kv(kv)

,_next(nullptr)

{ }

};

template <class K, class V, class Hash = HashFunc<K>>

class HashBucket

{

typedef HashNode<K, V> Node;

public:

HashBucket()

{

_tables.resize(10, nullptr); //初始化10个空间

}

//插入,删除,查找等功能……

private:

vector<Node*> _tables; // 指针数组

size_t _n = 0; // 表中储存的数据个数

};

5.2 插入操作

(1) 核心操作就是链表的头插

(2) 扩容:每个哈希桶中刚好挂一个节点,再继续插入元素时,每一次都会发生哈希冲突,因此,在元素个数刚好等于桶的个数时,可以给哈希表增容

在这里插入图片描述

<code>bool Insert(const pair<K, V>& kv)

{ -- -->

//去冗余,值存在,插入失败

if (Find(kv.first))

return false;

//负载因子==1,扩容

//方式1

//if(_n == _tables.size())

//{

//HashBucket<K, V> newHB;

//newHB._tables.resize(_tables.size() * 2);

////遍历旧表,复用Insert,(可避免重复写映射下标的逻辑)

//// 把旧表中存在的数据插入新表

//for (size_t i = 0; i < _tables.size(); i++)

//{

//Node* cur = _tables[i];

//if (cur)

//{

//newHB.Insert(cur->_kv);

//cur = cur->_next;

//}

//}

//_tables.swap(newHB._tables);

//}

//方式1的扩容逻辑消耗的空间太多,假设有一万个节点,就要重新new一万个

//又要销毁一万个,效率不高

//方式2

// 是在扩容后也重新利用旧表中的旧节点,把旧节点挪动到新表中

Hash hs;

if (_n == _tables.size())

{

vector<Node*> newtables(_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 = hs(cur->_kv.first) % newtables.size();

//头插到新表

cur->_next = newtables[hashi];

newtables[hashi] = cur;

cur = next;

}

_tables[i] = nullptr;

}

_tables.swap(newtables);

}

size_t hashi = hs(kv.first) % _tables.size(); //计算映射下标

//头插

Node* newnode = new Node(kv);

newnode->_next = _tables[hashi];

_tables[hashi] = newnode;

_n++;

return true;

}

5.3 查找操作

核心操作就是链表的查找

Node* Find(const K& key)

{

Hash hs;

size_t hashi = hs(key) % _tables.size(); //计算映射下标

Node* cur = _tables[hashi];

while (cur)

{

if (cur->_kv.first == key)

{

return cur;

}

cur = cur->_next;

}

return nullptr;

}

5.4 删除操作

核心操作就是链表的删除

在这里插入图片描述

<code>bool Erase(const K& key)

{ -- -->

Hash hs;

size_t hashi = hs(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;

_n--;

return true;

}

//没找到,继续向下走

prev = cur;

cur = cur->_next;

}

return false;

}

六,优化思考

只能存储key为整形的元素,其他类型怎么解决?

需要支持转整形的仿函数:

(1) 当key为浮点数,负数,指针等可以直接强转为无符号整形的类型

(2) 当key为 string 类(频繁使用)

//当key为浮点数,负数,指针等时,强转成无符号整形,再进行映射

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& key)

{

size_t n = 0;

for (auto& ch : key)

{

n *= 31; //尽量减少冲突

n += ch;

}

return n;

}

};



声明

本文内容仅代表作者观点,或转载于其他网站,本站不以此文作为商业用途
如有涉及侵权,请联系本站进行删除
转载本站原创文章,请注明来源及作者。