本章gitee代码仓库:Hash
文章目录
- 1. 哈希概念
- 2. 哈希冲突
- 3. 哈希函数
- 3.1 哈希函数设计原则
- 3.2 常见哈希函数
- 4. 哈希冲突解决方案
- 4.1 闭散列——开放定址法
- 4.11 负载因子
- 4.12 字符串哈希算法
- 4.13 代码实现
- 4.2 开散列——哈希桶
- 4.21 代码实现
我们对元素进行搜索有几种方式:
暴力查找,直接遍历元素,时间复杂度为O(N)
二分查找,时间复杂度为O(logN)
但二分查找有2个弊端:
- 必须为有序
- 增删查改不方便
这两个弊端导致二分查找只是一个理想的查找方式,并不是很现实
平衡搜索树,增删查改的时间复杂度都是O(logN),总体性能都很不错
这些结构中的元素关键码和存储位置都没有对应的关系,而有一种方法名为哈希(也叫散列),它提供了一种与这些完全不同的存储和查找方式,即将存储的值和存储的位置建立出一个对应的函数关系。
有一种排序名为计数排序,将不同的值映射到对应的位置,这本质上就是哈希
不了解的可以看下这篇文章——非比较排序——计数排序
我们的通讯录,按照名字的首字母进行分类,本质也是哈希
以数组{1,8,6,3}
为例,假设hashi = key % 6
,那则有如下对应关系
这样就能通过取模直接定位到该元素的位置
但是如果进行插入元素,例如插入
2
,2%6=2
,这就会导致和8
的位置一样
不同的关键字通过哈希函数计算出了相同的地址,值和位置出现了多对一的关系,这种线性称之为哈希冲突(哈希碰撞)。
解决方案:
- 选择合理的哈希函数
- 拟定冲突方案
出现哈希碰撞的原因之一可能就是哈希函数设计的不合理
m
个地址时,其值域必须是[0,m-1]
之间直接定址法(值的范围集中)
取某个线性函数作为散列的地址:
Hash(key) = A*key + B
除留余数法(值的范围分散)
设散列表中允许的地址数为
m
,取一个不大于m
,但最接近或者等于m
的质数p
作为除数,按照哈希函数:
Hash(key) = key% p(p<=m)
,将关键码转换成哈希地址
如果当前位置被占用,按照规则找到下一个位置(占用其他元素的位置)
我们删除元素通常都不是直接删除,而采用覆盖的方式,而这里无法很好的覆盖
例如我们删除元素
1
,如果挪动后面的数据,这就导致映射关系全部乱了,如果不直接覆盖,那么之后又元素插入进来的时候,1
这个位置还是有元素的,无法插入所有这里采用状态的方式进行标记:
- 存在——
EXIST
- 空——
EMPTY
- 删除——
DELETE
哈希表定义了一个载荷因子:α = 填入表中元素个数 / 哈希表的长度
,这个是表示哈希表装满长度的标志因子
如果负载因子设计的大,那么哈希冲突的概率就越大(空间利用率高)
如果负载因子设计的小,那么哈希冲突的概率就越小(空间利用率低)
对于开放定址法,经过测算,负载因子应该控制在0.7 ~ 0.8
,下面代码实现采用0.7
面对字符串的哈希函数,我们采用BKDRHash
函数
有兴趣可查看此篇文章——各种字符串Hash函数
template<class K>
struct DefaultHashFunc
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
//模板特化
template<>
struct DefaultHashFunc<string>
{
size_t operator()(const string& str)
{
//BKDR hash
size_t hash = 0;
for (auto ch : str)
{
hash *= 131;
hash += ch;
}
return (size_t)str[0];
}
};
//开放定址法
namespace open_address
{
enum STATE
{
EXIST,
EMPTY,
DELETE
};
template<class K, class V>
struct HashDate
{
pair<K, V> _kv;
STATE _state = EMPTY;
};
template<class K, class V, class HashFunc = DefaultHashFunc<K>>
class HashTable
{
public:
HashTable()
{
_table.resize(10); //预先开好10个空间
}
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
return false;
//扩容
if (_n * 10 / _table.size() >= 7) //设负载因子为0.7
{
size_t newSize = _table.size() * 2;
//扩容之后关系改变,需要重新映射
HashTable<K, V> newHT;
newHT._table.resize(newSize);
//旧表数据插入到新标
for (size_t i = 0; i < _table.size(); i++)
{
if (_table[i]._state == EXIST)
{
newHT.Insert(_table[i]._kv);
}
}
//新标和旧表交换
_table.swap(newHT._table);
}
//不能取模capacity,虽然空间有,但访问还是要看size的大小,不然会发生越界
HashFunc hf;
size_t hashi = hf(kv.first) % _table.size();
while (_table[hashi]._state == EXIST)
{
//线性探测
hashi++;
hashi %= _table.size();
}
_table[hashi]._kv = kv;
_table[hashi]._state = EXIST;
++_n;
return true;
}
HashDate<const K, V>* Find(const K& key)
{
HashFunc hf;
size_t hashi = hf(key) % _table.size();
while (_table[hashi]._state != EMPTY)
{
if (_table[hashi]._state == EXIST && _table[hashi]._kv.first == key)
{
return (HashDate<const K, V>*) & _table[hashi];
}
++hashi;
hashi %= _table.size();
}
return nullptr;
}
bool Erase(const K& key)
{
HashDate<const K, V>* ret = Find(key);
if (ret)
{
ret->_state = DELETE;
--_n;
return true;
}
return false;
}
private:
vector<HashDate<K, V>> _table; //哈希表
size_t _n = 0; //有效元素个数
};
}
线性探测会导致一片拥堵,为此还有一种方法为二次探测
例如线性探测是:
hashi = key % n; //如果有值了 i>=0 hashi+=i;
而二次探测则是:
hashi = key % n; //如果有值 i>=0 hashi + i^2;
这样就能在一定程度上减少拥堵
开放定址法的缺陷就是冲突会相互影响。而哈希桶的做法是,设置一个指针数组,如果发现冲突,则内部消化
这里桶的结构其实就是链式结构,对每个桶的管理就相当于对于链表的管理,下面的代码采用的是单链表
这里也是需要进行扩容,如果不扩容,就会导致在某种情况下,桶越来越长,这样查找数据就变成了对链表数据的查找,时间复杂度为O(N),所以还是需要进行扩容。
这里的负载因子可以适当放大一点,一般负载因子控制在
1
,平均下来每个桶都有数据
这里的桶因为是自定义的链式结构,所以需要我们自己写拷贝构造和析构函数
//哈希桶
namespace hash_bucket
{
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 HashFunc = DefaultHashFunc<K>>
class HashTable
{
typedef HashNode<K, V> Node;
public:
HashTable()
{
_table.resize(10, nullptr);
}
~HashTable()
{
for (size_t i = 0; i <_table.size(); i++)
{
Node* cur = _table[i];
while (cur)
{
Node* next = cur->_next;
delete cur;
cur = next;
}
_table[i] = nullptr;
}
}
//拷贝构造
HashTable(const HashTable& ht)
{
_table.resize(ht._table.size(), nullptr);
HashFunc hf;
for (size_t i = 0; i < ht._table.size(); i++)
{
Node* cur = ht._table[i];
while (cur)
{
Node* newNode = new Node(cur->_kv);
size_t hashi = hf(cur->_kv.first) % ht._table.size();
//头插
newNode->_next = _table[hashi];
_table[hashi] = newNode;
cur = cur->_next;
}
}
_n = ht._n;
}
void Print()
{
for (size_t i = 0; i < _table.size(); i++)
{
printf("[%d]->", (int)i);
Node* cur = _table[i];
while (cur)
{
cout << cur->_kv.first << "->";
cur = cur->_next;
}
cout << "NULL" << endl;
}
cout << endl;
}
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
return false;
HashFunc hf;
//扩容 -- 扩容的时候会稍微慢一点 ---^(扩容)-----^(扩容)----------^(扩容)-----.....
//这里的扩容不能和开放定址法一样采用将旧表元素重新插入新表
//因为这里涉及到开节点,新表开新节点,旧表释放旧节点,浪费
if (_n == _table.size())
{
size_t newSize = _table.size() * 2;
vector<Node*> newTable;
newTable.resize(newSize,nullptr);
//遍历旧表,将节点牵过来
for (size_t i = 0; i < _table.size(); i++)
{
Node* cur = _table[i];
while (cur)
{
Node* next = cur->_next;
//头插到新表
size_t newHashi = hf(cur->_kv.first) % newSize;
cur->_next = newTable[newHashi];
newTable[newHashi] = cur;
cur = next;
}
_table[i] = nullptr;
}
_table.swap(newTable);
}
size_t hashi = hf(kv.first) % _table.size();
//头插
Node* newNode = new Node(kv);
newNode->_next = _table[hashi];
_table[hashi] = newNode;
++_n;
return true;
}
Node* Find(const K& key)
{
HashFunc hf;
size_t hashi = hf(key) % _table.size();
Node* cur = _table[hashi];
while (cur)
{
if (cur->_kv.first == key)
{
return cur;
}
cur = cur->_next;
}
return nullptr;
}
bool Erase(const K& key)
{
HashFunc hf;
size_t hashi = hf(key) % _table.size();
Node* prev = nullptr;
Node* cur = _table[hashi];
while (cur)
{
if (cur->_kv.first == key)
{
//头删
if (prev == nullptr)
{
_table[hashi] = cur->_next;
}
else
{
prev->_next = cur->_next;
}
--_n;
delete cur;
return true;
}
prev = cur;
cur = cur->_next;
}
return false;
}
private:
vector<Node*> _table; //指针数组
size_t _n = 0; //有效元素
};
}
本次参考了《数据结构(用面向对象方法与C++语言描述)》,详细的内容可以参考此书
那么本期的分享就到这里咯,我们下期再见,如果还有下期的话。