程序猿的读书历程:x语言入门—>x语言应用实践—>x语言高阶编程—>x语言的科学与艺术—>编程之美—>编程之道—>编程之禅—>颈椎病康复指南。
哈希表(Hash Table)是一种高效的键值对存储数据结构,广泛应用于各种需要快速查找的场景,如数据库索引、缓存系统、集合等。它的基本思想是通过哈希函数将键映射到哈希表中的一个位置,从而实现快速的数据插入、删除和查找操作。下面我们将详细介绍哈希表的工作原理、实现方式、优缺点以及应用场景。
哈希是一种思想,普遍是通过一个哈希数组来存储数据的。学哈希思想,最重要的就是抓住映射两个字,它是一个无序的数据结构,所以想要找到存储的数据,就必须通过相对应的哈希关系来寻找。
这样,我们就能通过这个映射关系,可以不经过任何比较,一次直接从表中得到要搜索的元素。
但是通过上面的介绍,相信不少童鞋已经发现了,一个下标只能存储一个数据,如果我们有两个数,转换后的下标相同呢?
即:不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。
倘若数据中发生了哈希冲突,我们应该怎么做呢?
常见的哈希函数主要是有两种,一种是直接定址法:
哈希函数采用处理余数法,被模的key必须要为整形才可以处理,我们之前的思路只能解决int类型的存储问题,如果那个值是string,是char,我们又应该怎么解决呢?
string与char类型不能被取余,我们想到,那就把它转化为int类型不就可以了吗。
由于字符串长度我们不能确定,但abcd与acbd两个字符串的ASCII码值确实一样,如果光是ASCII码值之和来计算,难免会出现比较离谱的存储结果。据此,通过研究,我们可以通过一些条件来减少ASCII码值的巧合:
class Str_to_Int
{
public:
size_t operator()(const string& s)
{
const char* str = s.c_str();
unsigned int seed = 131; // 31 131 1313 13131 131313
unsigned int hash = 0;
while (*str)
{
hash = hash * seed + (*str++);
}
return (hash & 0x7FFFFFFF);
}
};
通过这种处理,就能明显减少巧合的发生,将其分配到正确的地址上。
我们先写一个简单的哈希表的闭散列实现来理解一下哈希表的底层逻辑。
#pragma once
#include
// 哈希函数采用除留余数法
template
struct HashFunc
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
// 哈希表中支持字符串的操作
template<>//这是对前面模板HashFunc的string特化类型
struct HashFunc
{
size_t operator()(const string& key)
{
size_t hash = 0;
for (auto e : key)
{
hash *= 31;//防止abcd与dcba的ASCII码值之和相同
hash += e;
}
return hash;
}
};
// 以下采用开放定址法,即线性探测解决冲突
namespace open_address
{
enum State
{
EXIST,
EMPTY,
DELETE
};
template
struct HashData
{
pair _kv;
State _state = EMPTY;
};
template>
class HashTable
{
public:
HashTable()
{
_tables.resize(10);
}
bool Insert(const pair& kv)
{
if (Find(kv.first))//如果以前插入过相同键值
{
return false;
}
if ((_n * 10) / _tables.size() >= 7)//扩容
{
HashTablenewh;
newh._tables.resize(2 * _tables.size());
for (int i = 0; i < _tables.size(); ++i)
{
if (_tables[i]._state == EXIST)
{
newh.Insert(_tables[i]._kv);
}
}
_tables.swap(newh._tables);
}
Hash h;
size_t index = h(kv.first) % _tables.size();//确定插入下标
while (_tables[index]._state == EXIST)
{
++index;
index = index % _tables.size();
}
_tables[index]._state = EXIST;
_tables[index]._kv = kv;
++_n;
return true;
}
HashData* Find(const K& key)
{
Hash h;
size_t index = h(key) % _tables.size();//确定查找下标
while (_tables[index]._state != EMPTY)
{
if ( key == _tables[index]._kv.first)
{
return &_tables[index];
}
++index;
index %= _tables.size();
}
return nullptr;
}
bool Erase(const K& key)
{
HashData* ret = find(key);
if (ret)
{
ret->_state = DELETE;
return true;
}
else
{
return false;
}
}
private:
vector> _tables;
size_t _n = 0; // 表中存储数据个数
};
}
慢慢看这层代码。
我们用K代表key值,V代表Value值,用Hash来代表一个模板函数,这个函数是为了实现我们的转化key值的作用(就是string类型的key转化为int值)。
我们首先实现了哈希函数的模板,让任意类型的K值得以转化为int类型的参数。注意:对于能够转化为int类型的内置类型,我们直接使用强制转化就行,但是对于经常常用到的string,却又不能直接转换为int,我们就可以写一个特化,要求当K为string时直接调用我们的特化函数就行了。
随后在我们的作用于中定义一个枚举类型,代表上面说的三个状态:存在,空,删除。
寻常的内置类型自然不会包含我们才定义的枚举状态,自然就需要定义一个自定义类型。于是HashData出世了。
随后就是平常的接口的编写:
对于find接口,如果我们找到了对应的值,就需要返回这个值的指针HashData
对于insert插入接口,我们先判断是否已经插入过相同键值,然后在判断是否达到扩容标准,如果达到了,就进行扩容操作(创建一个新的哈希数组,随后复用insert进行插入,最后交换两个哈希数组就行,新创建的会自动进行销毁)。扩容后,也是先通过Hash,来找到初始的键值,但我们这次应该通过线性探测来查找空位置或者删除的位置。
对于erase接口,我们可以先复用find找到相应的位置,随后把其的_state属性改为delete就行,不必进行数据内容上的修改。我们访问任意一个地址,都是先判断其state属性是否满足条件。
先看代码:
#pragma once
#include
template
struct HashFunc//哈希函数,把K类型转化为int
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
template<>
struct HashFunc//当K类型时string时的特化函数
{
size_t operator()(const string& s)
{
size_t ret = 0;
for (auto &it : s)//我们这里对string的每个字母采用乘以31再相加的方法
{
ret *= 31;
ret += it;
}
return ret;
}
};
namespace hash_bucket
{
template
struct HashNode//哈希桶存储的单链表的节点结构
{
T _data;
HashNode* next;
HashNode(const T&data)
:_data(data)
,next(nullptr)
{}
};
template>
class HashTable
{
struct keyofT//我这里的实现方法有些特殊,多增加了一个keyofT函数,这个函数时为了后面用哈希桶实现unordered_map与unordered_set
//而实现的,由于那个时候哈希桶才是底层,所以现在只使用底层代码就会变得奇怪
{
const K& operator()(const T&kv)//传递一个string ,int类型的参数就得
//HashTable>hash;
{
return kv.first;
}
};
public:
typedef HashNode Node;
HashTable()
:_n(0)
{
_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;
}
}
bool insert(const T& data)
{
Hash h;
if (_n >= _tables.size())//扩容
{
vector newtables(_tables.size() * 2, nullptr);
for (int i = 0; i < _tables.size(); ++i)
{
Node* cur = _tables[i];
while (cur)
{
Node* next = cur->next;
size_t newindex = h(keyofT()(cur->_data)) % newtables.size();
cur->next = newtables[newindex];
newtables[newindex] = cur;
cur = next;
}
_tables[i] = nullptr;
}
_tables.swap(newtables);
}
Node* newnode = new Node(data);
size_t index = h(keyofT()(data)) % _tables.size();
newnode->next = _tables[index];
_tables[index] = newnode;
++_n;
return true;
}
Node* find(const K& key)
{
Hash h;
size_t index = h(key) % _tables.size();
Node* cur = _tables[index];
while (cur)
{
if (cur->_data.first == key)
{
return cur;
}
else
{
cur = cur->next;
}
}
return nullptr;
}
bool erase(const K& key)
{
Hash h;
size_t index = h(key) % _tables.size();
Node* prev = nullptr;
Node* cur = _tables[index];
while (cur)
{
if (cur->_data.first == key)
{
if (prev == nullptr)
{
_tables[index] = cur->next;
}
else
{
prev->next = cur->next;
}
delete cur;
cur = nullptr;
return true;
}
else
{
prev = cur;
cur = cur->next;
}
}
return false;
}
private:
vector_tables;
size_t _n;
};
};
相较于闭散列,stl库里实现unordered_map与unordered_set两个容器时底层都用的开散列,所以我这里的开散列实现的有些奇怪,增加的keyofT函数更有利于后续的封装容器。
但是大体结构仍然没有改变,同样用到了Hash来解决不同类型转化为int的问题。唯一值得一提的就是由于我们的节点是指针的链接方式,所以扩容时,我们不需要再赋值节点,只需要把每个节点指针插入到新的哈希table里进行交换就行。
哈希表的性能主要取决于哈希函数的设计和哈希冲突的处理方式。哈希表在最理想的情况下,即哈希函数将元素均匀分布到哈希表中时,查找、插入、删除操作的时间复杂度为 O(1)O(1)O(1)。但当发生大量哈希冲突时,时间复杂度可能退化到 O(n)O(n)O(n),这是最坏情况。为了优化性能,我们可以从以下几个方面着手:
设计良好的哈希函数:哈希函数应尽可能均匀地将元素分布到哈希表中,避免哈希冲突。对数据的特性进行分析,选择合适的哈希函数,如前文提到的直接定址法、除留余数法等。
扩容:当哈希表中存储的元素个数接近表容量时,哈希冲突的概率会增加,因此需要动态扩容,保持较低的装载因子(如装载因子不超过0.7)。
合理选择哈希冲突解决策略:开散列(链地址法)通常比闭散列(开放定址法)表现更好,尤其是在高装载因子的情况下,链表法通过链表的结构减少了冲突对性能的影响。
哈希表作为一种高效的数据结构,应用非常广泛,特别是在需要快速查找的场景中。例如:
数据库索引:哈希表在数据库系统中用于索引结构,能够快速查找数据。
缓存系统:例如Redis等内存缓存系统广泛使用哈希表存储键值对,实现高效的数据存取。
集合类操作:哈希表在语言标准库中的实现,如C++的unordered_map
、unordered_set
,用于高效的查找和去重操作。
字典查找:哈希表是构建字典和符号表的基础,广泛用于自然语言处理、编译器等场景。
优点:
缺点:
哈希表作为一种重要的数据结构,提供了高效的查找、插入和删除操作。通过设计良好的哈希函数和适当的冲突解决策略,可以最大化哈希表的性能。了解哈希表的工作原理和实现方式,有助于在实际应用中选择合适的解决方案,并有效提升系统的性能。
希望本篇文章对大家有所帮助!