我是knight-n,本篇文章我们将通过模拟实现哈希表来帮助大家理解哈希表的原理
哈希表简单介绍
哈希表也叫散列表,是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做哈希表。
哈希表实现原理
哈希表本质上是一个支持动态增长的数组,其增删查的效率均为O(1)。之所以如此高效是因为其插入和搜索的方式,当插入时我们根据待插入元素的关键码,以散列函数计算出该元素的存储位置并按此位置进行存放。搜素元素时我们对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功。通过这样的方法我们可以不经过任何比较,一次直接从表中得到要搜索的元素。
哈希冲突
哈希冲突是指当两个或多个不同的输入值经过哈希函数处理后,产生了相同的哈希值。这种冲突在哈希表等数据结构中尤其重要,因为它们依赖于哈希函数将输入值映射到特定的存储位置。以上图为例,如果插入同时插入24,48这两个数那么这两个数将会被映射到同一个位置。
为了解决哈希冲突,可以采用以下两种方法:
开放地址方法:
线性探测法:按顺序决定值时,如果某数据的值已经存在,则在原来值的基础上往后加一个单位,直至不发生哈希冲突。 再平方探测法:按顺序决定值时,如果某数据的值已经存在,则在原来值的基础上先加1的平方个单位,若仍然存在则减1的平方个单位。随之是2的平方,3的平方等等,直至不发生哈希冲突。
哈希桶:
对于相同的哈希值,使用链表进行连接。这样,所有哈希地址相同的记录都链接在同一链表中。
哈希表的模拟实现
我们分别用开放地址法和哈希桶对哈希表进行模拟实现
开放地址法
我们创建一个类,因为哈希表的底层是动态增长的数组,所以我们直接使用vector,这样数组动态增长时可以直接调用vector的函数。代码如下
//哈希表中储存的结点
enum STATE { EMPTY, EXIST, DELETE };
template<class K, class V>
class HashData
{
std::pair<K, V> _kv;
STATE _state;
};
template<class K, class V>
class HashTable
{
public:
typedef HashData<K, V> HashData;
//构造函数
HashTable(size_t capacity = 10)
: _ht(capacity), _size(0)
{
for (size_t i = 0; i < capacity; ++i)
_ht[i]._state = EMPTY;
}
private:
std::vector<HashData> _ht;
size_t _size; //数据的数量
};
哈希表的插入
首先我们要通过哈希函数计算键的哈希值。然后在使用计算出的哈希值确定键值对在哈希表中的位置,如果该位置的状态为EMPTY,, DELETE 说明该位置为空没有冲突,则直接将新键值对插入该位置。如果该位置状态为EXIST说明已经有其他键值对,我们向后寻找空位置插入并将插入位置状态设为EXIST。插入数据的数据越多,发生哈希冲突的概率越大,所以我们设定一个负载因子,当哈希表元素数量超过指定负载因子时,自动增加哈希表的大小,并将现有元素重新散列到新的哈希表中,以保证查询和插入操作的效率。代码如下:
bool insert(const std::pair<K,V>& kv)
{
//检查容量
if (_size * 10 / _ht.size() >= 7)
{
//扩容
size_t newcapacity = _ht.size() * 2;
std::vector<HashData> _newht(newcapacity);
for (int i = 0; i < _ht.size(); i++)
{
size_t index = _ht[i]._kv.first % newcapacity;
if (_newht[index]._state == EMPTY)
{
_newht[index] = _ht[i];
}
else
{
while (true)
{
index++;
index %= newcapacity;
if (_newht[index]._state == EMPTY)
{
_newht[index] = _ht[i];
break;
}
}
}
}
_ht, swap(_newht);
}
//插入
size_t newindex = kv.first % _ht.size();
if (_ht[newindex]._state == EMPTY || _ht[newindex]._state == DELETE)
{
_ht[newindex]._kv =kv;
_ht[newindex]._state = EXIST;
}
else
{
while (true)
{
newindex++;
newindex %= _ht.size();;
if (_ht[newindex]._state == EMPTY|| _ht[newindex]._state == DELETE)
{
_ht[newindex]._kv = kv;
_ht[newindex]._state = EXIST;
break;
}
}
}
_size++;
return true;
}
哈希表的查找
当你想要查找一个键时,首先使用哈希函数计算该键的哈希值。用于确定键在哈希表中的位置。由于哈希函数可能会产生冲突,因为我们使用线性探测法解决哈希冲突,所以如果发生冲突,我们用同样的方法查找,即向后探测。当我们向后探测到位置状态为EMPTY时我们停止探测,查找失败。
代码如下:
size_t find(const K& key)
{
size_t index = key % _ht.size();
while (_ht[index]._state != EMPTY)
{
if (_ht[index]._kv.first == key && _ht[index]._state == EXIST)
{
return index;
}
index++;
index %= _ht.size();
}
return -1;
}
哈希表的删除
删除一个数的操作十分简单,先查找到这个数的位置,然后将该位置的状态设为DELETE即可。代码如下:
bool earse(const K& key)
{
size_t index = find(key);
if (index == -1)
{
return false;
}
_ht[index]._state = DELETE;
_size--;
return true;
}
哈希桶
上面我们用线性探测法解决了哈希冲突,但我们不难发现这种方法存在弊端,其核心思想是我的位置被占了,我就去占据其他人的位置。这会导致恶性循环,使哈希冲突发生的概率大大增加。哈希桶则为我们提供另外一个思路。在哈希桶中,每个桶都是一个独立的数据结构,通常是一个链表或其他类型的集合。当发生哈希冲突时,新的键值对会被插入到对应的桶中,形成一个链表或其他类型的集合。这样,即使多个键值对具有相同的哈希值,它们也可以被存储在同一个桶中,而不会相互覆盖。也就是将数组中存储的数据改为链表,哈希值相同的都会被储存在这个链表中。
//哈希表中存储的结点
template<class K,class V>
struct HashData
{
T _data;
HashData* _next;
};
template<class K,class V>
class HashTable
{
typedef HashData<K,V> Node;
//构造函数
HashTable()
:_num(0)
{
_table.resize(10);
}
//析构函数
~HashTable()
{
Clear();
}
//清理哈希表中的数据
void Clear()
{
for (int i = -0; i < _table.size(); i++)
{
if (_table[i])
{
Node* cur = _table[i];
Node* next = cur->_next;
while (cur)
{
delete cur;
cur = next;
if (cur)
{
next = cur->_next;
}
}
_table[i] = nullptr;
}
}
}
private:
std::vector<Node*> _table;
size_t _num = 0;
};
哈希表的插入
当插入一个新的键值对时,使用哈希函数计算键的哈希值,以确定要插入的键值对应该放在哪个哈希桶中。根据计算出的哈希值,找到对应的哈希桶。遍历哈希桶中的链表,检查是否已存在相同的键。如果链表中不存在相同的键,则在链表的末尾插入新的键值对节点。如果哈希表的负载因子过高,需要进行扩容操作,以减少哈希冲突并提高性能。
bool insert(const pair<K,V>& data)
{
Hash hash;
//检查负载因子
if (_num*10 /_table.size() >= 7)
{
//扩容
std::vector<Node*> newtable;
size_t newsize = _table.size() * 2;
newtable.resize(newsize);
for (int i = 0; i < _table.size(); i++)
{
Node* cur = _table[i];
while(cur)
{
Node* next = cur->_next;
size_t newindex = cur->_data->first % _table.size();
cur->_next = newtable[newindex];
newtable[newindex] = cur->_next;
cur = next;
}
_table[i] = nullptr;
}
_table.swap(newtable);
}
//算出映射值
size_t index = cur->_data->first % _table.size();
Node* cur = _table[index];
while (cur)
{
if (cur->_data = data)
{
return false;
}
cur = cur->_next;
}
//头插
Node* newnode = new Node(data);
newnode->_next = _table[index];
_table[index] = newnode;
//改变计数器
_num++;
return true;
}
哈希表的查找
通过哈希函数将待查找的键值转换成对应的哈希地址。查找对应位置的链表,如果为空,那么查找失败。不为空则遍历链表,找到对应的数据。遍历后没有找到则查找失败。代码如下
bool find(const K& key)
{
size_t index = key % _table.size();
Node* cur = _table[index];
while(cur)
{
if kofv(cur->_data.first == key)
{
return true;
}
else
{
cur = cur->_next;
}
}
return false;
}
哈希表的删除
与线性探测法相似,我们同样通过哈希函数计算出待删除键值对应的哈希地址。在该哈希桶对应的单链表上进行查找,找到待删除的节点。然后对结点进行删除。代码如下
bool earse(const K& key)
{
size_t index = key % _table.size();
Node* cur = _table[index];
Node* prev = nullptr;
while(cur)
{
if (cur->_data->first) == key && prev==nullptr)
{
_table[index] = cur->_next;
delete cur;
--_num;
return true;
}
else if (cur->_data->first) == key && prev != nullptr)
{
prev->_next = cur->_next;
delete cur;
--_num;
return true;
}
else
{
prev = cur;
cur = cur->_next;
}
}
return false;
}
总结
总之,哈希表是一种高效的数据结构,通过键值对的方式存储数据,并通过哈希函数实现快速查找、插入和删除操作。虽然它有一些缺点,但通过一些优化策略可以克服这些问题,使得哈希表在实际应用中非常有用。
感谢观看,我是knight-n,我们下期见!