博客主页:【夜泉_ly】
本文专栏:【数据结构】
欢迎点赞👍收藏⭐关注❤️
文章目录
- [📚 前言](#📚 前言)
- [⏫ 直接寻址表](#⏫ 直接寻址表)
-
- [📖 简介](#📖 简介)
- [💻 代码实现](#💻 代码实现)
- [🗃️ 散列表](#🗃️ 散列表)
-
- [📖 简介](#📖 简介)
- [🔒 闭散列](#🔒 闭散列)
-
- [📖 简介](#📖 简介)
- [💻 代码实现](#💻 代码实现)
- [🔓 开散列](#🔓 开散列)
-
- [📖 简介](#📖 简介)
- [💻 代码实现](#💻 代码实现)
📚 前言
本文主要内容:
hash
的意思是散列,如果音译的话就是哈希。
不过,今天讲的是hash
的一部分:hash table
,即哈希表。
这两个有什么区别?
- 哈希 可以认为是一种方法,可以认为是一种思想:将存储的值和存储的位置建立出一种对应的关系。
- 哈希表是哈希的一种具体应用,是一种支持插入、查找、删除等字典操作的数据结构。
在AVL和红黑树中,查找的效率被提升至了 O ( l o g 2 N ) O(log_2N) O(log2N),而哈希表更进一步,将平均查找效率提升到 O ( 1 ) O(1) O(1)。虽然在特定的情况下,哈希表的最坏时间复杂度为 O ( N ) O(N) O(N),但在实际中哈希表的查找性能是很好的。
哈希表是普通数组的推广 。
普通数组一般采用直接寻址,即将key
直接作为数组下标。问题是如果key
可能的取值范围过大,而存入的元素又过少,就会产生大量的空间浪费。
哈希表则用一个哈希函数,将key
和下标建立了对应的关系。但如果不同的key
在哈希函数处理后进了同一个下标,这就造成了哈希冲突。
哈希冲突 的解决方式有很多,最主要有的两种:开放寻址法、链表法。
⏫ 直接寻址表
📖 简介
直接寻址表DirectAddressTable
直接寻址表就是数组,将key
直接作为数组下标。(说实话感觉和计数排序有点像)
适用的情况,数据的全域 U U U不大,且不同数据的key
也不同。
简单点就是这么个样子:
💻 代码实现
实现也非常简单:
有时,key
就是value
;有时,key
对应value
。我们不管这么多,直接传KV:
cpp
template <typename K, typename V>
class DirectAddressTable {
而成员,刚刚说了,是个数组,那我们当然毫不客气的使用vector
:
cpp
private:
std::vector<std::pair<K, V>*> table; // 用于存储键值对的数组
};
至于这里为什么用pair*
,因为算法导论是这么说的,所以我就这么写了。
再来补充一下函数,首先是构造:
cpp
public:
// 构造函数,初始化表大小
DirectAddressTable(size_t size)
: table(size, nullptr)
{}
然后是查找、插入、删除,注意不能让key
越界:
cpp
std::pair<K, V>* search(const K& key)
{
if (key >= table.size() || table[key] == nullptr)
return nullptr;
return table[key];
}
void insert(const K& key, const V& value)
{
assert(key < table.size());
table[key] = new std::pair<K, V>(key, value);
}
void erase(const K& key)
{
assert(key < table.size());
delete table[key];
table[key] = nullptr;
}
从此处可以看见,几个字典操作都只需要O(1)
的时间。
最后是析构函数,这里需注意的是vector
的析构不会释放我们申请的pair
,所以需要手动delete
一下。而有时我们也需要手动清理一下,因此可以加个clear
:
cpp
void directAddressClear()
{
for (auto& e : table)
delete e;
table.clear();
}
~DirectAddressTable()
{
clear();
}
一个简单的直接寻址表就写完了。
当然,还有种更简单的直接寻址表:
cpp
int arr[256] = {0}; // 可以用来统计字符串中每个字符出现的次数
这种情况下,数组的槽(slot
)中,直接存的就是key
。
🗃️ 散列表
📖 简介
散列表即哈希表,用来解决关键字全域U过大,而实际关键字集合K过小的情况。
这时关键字key
就不是直接对应下标了,而是利用哈希函数(hash function
)hf ,通过关键字计算出下标。
例如有这样一个数组:{1,2,103,104,10005,10006}。
如果是直接寻址,我们需要开一个大小为10007的数组来存储:
如果我们引入一个hf
:hf(key) = key % 10
计算出相对的位置,那么我们的数组就只需开7个空间:
而不同的key
可能会被搞到同一个位置,这就发生了碰撞。
显然,碰撞的次数和我们选的hf
有直接关系,但即便hf
选的很好,根据抽屉原理,碰撞是不可避免的,因此还需要有方法解决碰撞。在本文,我将介绍两种解决哈希冲突的方法:开放寻址法、链表法。
至于hf
,就用个最简单的吧,毕竟其它的我也看不懂:hf(k) = k mod capacity
。
除了哈希函数以及解决哈希冲突的方法,我们还需要注意一个东西:装载因子(load factor
)lf
。装载因子就是哈希表的存放元素的个数n
除以哈希表的槽位m
: l f = n / m lf = n/m lf=n/m。显然装载因子如果过小,那么就代表浪费的空间增多;如果过大,就代表冲突的可能增多。因此装载因子也是一个需要研究的点。(但不是我该研究的点)
🔒 闭散列
📖 简介
闭散列 就是用开放寻址法实现的哈希表,这种方法将所有的元素都存放在散列表里,所以是闭 。
其插入的过程,可以简单理解为碰撞了就往后挪一挪。
例如,我们先开个大小为10的数组,然后依次插入8,88,888:
这就是最简单的插入操作。总结一下就是从hf(k)
开始,被占了就往后挪挪,找到空了就插入。
因此,这时每个槽有两个状态------空 和 存在。
算法导论上说,开放寻址法最好不要删除,但我们还是可以实现一下,虽然会麻烦一点。
还是这个数组:
删除8
:
由于之前的碰撞,导致一些元素并没有存在对应的hf(k)
上,因此在删除后必须标记一个新的状态 删除,不然删了一个元素就可能导致后面的元素找不到了。
删除88
,888
同理:
这时的插入也需要改一改,改成遇到 删除也需要插入。
至于查找就比较简单了,从hf(k)
开始,找到了就返回对象,挪动到状态为就返回空。
💻 代码实现
首先,我们需要定义在槽中存放的数据,与直接寻址法不同,这里还需要加一个状态,而状态分为三种:空 、 存在 以及 删除,根据 effective C++中的条款02,以后我尽量就不用#define了,用enum:
cpp
enum STATUS { EMPTY, EXIST, ERASE };
template<class K, class V>
struct Data
{
std::pair<K, V> _data;
STATUS _status = EMPTY;
};
存的是一个pair
,初始的状态是EMPTY
,用的是struct
。应该没问题。
然后是哈希表,我比较懒,所以第一步必写typedef
:
cpp
template<class K, class V>
class HashTable
{
typedef Data<K, V> Data;
成员直接用vector
吧,还得再加一个变量_n
------用来记录现在存了多少个元素,以便计算负载因子:
cpp
private:
vector<Data> _table;
size_t _n = 0;
};
给_n
个初始值,这样就可以直接用编译器提供的默认构造了。吗?
管他的,先把插入写了(暂不考虑扩容):
cpp
void insert(const pair<K, V>& kv)
{
size_t index = kv.first % _table.size(); // hf(k) = k mod capacity
while (_table[index]._status == EXIST) // 找到空位
{
index = (index + 1) % _table.size();
}
_table[index]._data = kv; // 插入
_table[index]._status = EXIST; // 改状态!!!
}
这里一定要注意,插入后要改_status
,你也不想辛辛苦苦插入了很多数据最后发现全被覆盖了吧?
写了个测试函数,调用插入时直接报错:
当然,这里我用的hf
是:hf(k) = k mod capacity
。这个capacity
又直接用的vector
的size
。因此为0
很正常,你或许回想,改成其他的数不就好了,比如:size_t index = kv.first % 10;
很遗憾,还是报错了,因为我们的vector没开空间,所以一插入就会报错。
补个构造:
cpp
HashTable() { _table.resize(10); }
再来看看效果:
HashTable<int, int> ht;
ht.insert({8,666});
ht.insert({88,666});
ht.insert({888,666});
和预期的一样。那我们继续写删除:
cpp
bool erase(const K& k)
{
size_t index = k % _table.size();
while (_table[index]._status != EMPTY)
{
if (_table[index]._status == EXIST && _table[index]._data.first == k)
{
_table[index]._status = ERASE;
return true;
}
index = (index + 1) % _table.size();
}
return false;
}
在这里,可以发现查找的代码重复了,于是可以写个private
的_find
,返回的是下标:
cpp
size_t _find(const K& k)
{
size_t index = k % _table.size();
while (_table[index]._status != EMPTY)
{
if (_table[index]._status == EXIST && _table[index]._data.first == k)
{
return index;
}
index = (index + 1) % _table.size();
}
return index;
}
然后就可以改改删除函数:
cpp
bool erase(const K& k)
{
size_t index = _find(k);
if(_table[index]._status == EMPTY)
return false;
_table[index]._status = ERASE;
return true;
}
再尝试一下:
HashTable<int, int> ht;
ht.insert({8,666});
ht.insert({88,666});
ht.insert({888,666});
cout << ht.erase(8) << endl;
cout << ht.erase(888) << endl;
cout << ht.erase(88) << endl;
cout << ht.erase(0) << endl;
输出是:1110。三次成功一次失败,没有问题。
再看监视窗口:
状态都改了,也没问题。
那就来写查找,由于刚刚写了个_find
,因此,如果不介意码风的话,甚至可以两行搞定:
cpp
Data* find(const K& k)
{
size_t index = _find(k);
return _table[index]._status == EMPTY ? nullptr : &_table[index];
}
什么?你说我返回元素的key
可以被修改?!
你说得对。但是unordered_map是由STL提供的一款容器,它的find返回的是迭代器,后面忘了。
实在不行,你可以强转一下,我这里就不改了。不然typedef就不能用了
到了这里,这个闭散列就差不多写完了,只需要再处理亿点点小问题。
比如:_n
哪儿去了?负载因子呢?Key不是整型怎么办?
没关系,一个一个的解决:
首先是_n
,这个好说,插入成功++,删除成功- -。(刚刚忘写了)
然后是负载因子,这个对应的就是扩容问题,我就在负载因子为0.7的时候扩容吧。
如何扩容?首先,直接扩是万万不能的,这会导致关系混乱:
如果我想找 17
(标红那个),index = hf(17) = 17 % size = 17
,而 index = 17
对应的槽状态为空,因此 17
不存在?
正确的解决方式(之一)是------再建个哈希表插入就行了:
cpp
void insert(const pair<K, V>& kv)
{
if (_n * 10 >= _table.size() * 7)
{
size_t newSize = _table.size() * 2;
HashTable<K,V> newtable;
newtable._table.resize(newSize);
for (size_t i = 0; i < _table.size(); i++)
if (_table[i]._status == EXIST)
newtable.insert(_table[i]._data);
_table.swap(newtable._table);
}
最后一步又捡便宜了,直接用vector
的swap
,简单省事👍。
现在还剩一个问题:如果key
不是整型呢?
这时,仿函数又派上用场了。
普通类型,转成无符号整型就好:
cpp
template<class K>
struct defaultHashFunc
{
size_t operator()(const K& k)
{
return (size_t)k;
}
};
由于string
做key
比较比较常见,可以开个后门,来个特化:
cpp
template<>
struct defaultHashFunc<string>
{
size_t operator()(const string& str)
{
size_t hash = 0;
for (auto ch : str)
{
hash *= 131;
hash += ch;
}
return hash;
}
};
这里用的是BKDR的处理方法,具体原理未知,能用就行。
其他的,就让用的人传吧:
cpp
template<class K, class V, class HF = defaultHashFunc<K>>
class HashTable
{
由于我把查找放在_find里了,所以只用改这一个地方:
cpp
size_t _find(const K& k)
{
HF hf;
size_t index = hf(k) % _table.size();
表的完整代码:
cpp
template<class K, class V, class HF = defaultHashFunc<K>>
class HashTable
{
typedef Data<K, V> Data;
public:
HashTable() { _table.resize(10); }
void insert(const pair<K, V>& kv)
{
if (_n * 10 >= _table.size() * 7)
{
size_t newSize = _table.size() * 2;
HashTable<K,V> newtable;
newtable._table.resize(newSize);
for (size_t i = 0; i < _table.size(); i++)
if (_table[i]._status == EXIST) newtable.insert(_table[i]._data);
_table.swap(newtable._table);
}
size_t index = _find(kv.first);
_table[index]._data = kv;
_table[index]._status = EXIST;
_n++;
}
bool erase(const K& k)
{
size_t index = _find(k);
if(_table[index]._status == EMPTY) return false;
_table[index]._status = ERASE;
_n--;
return true;
}
Data* find(const K& k)
{
size_t index = _find(k);
return _table[index]._status == EMPTY ? nullptr : &_table[index];
}
private:
size_t _find(const K& k)
{
HF hf;
size_t index = hf(k) % _table.size();
while (_table[index]._status != EMPTY)
{
if (_table[index]._status == EXIST && _table[index]._data.first == k) return index;
index = (index + 1) % _table.size();
}
return index;
}
vector<Data> _table;
size_t _n = 0;
};
🔓 开散列
📖 简介
开散列 是用链表法实现的哈希表,这种方法是将元素挂在槽外的,所以是开 。
链表法就是把 hf(k)
相同的 k
全部挂在 index == k
的槽上:
这时,槽里面就不用存状态了,存个指针就行。因此, 代表 nullptr
, 代表 挂有数据。
💻 代码实现
首先是挂的节点:
cpp
template<class K, class V>
struct Node
{
pair<K, V> _data;
Node<K, V>* _pNext;
Node(const pair<K, V>& kv)
:_data(kv)
{}
};
这是我最开始写的,然后一个插入就让我找了十分钟bug😂。
为什么不对,只看这一块代码其实是显而易见的:_pNext
没有初始化。
但是在使用时,我会认为Node
既然提供了构造函数,那么_pNext
应该是初始化了的。
在effective C++的"条款04:确定对象被使用前已被初始化"中,作者指出:规定总是在初始化列表中列出所有成员变量,以免还得记住哪些成员变量可以无需初值。
改正后:
cpp
template<class K, class V>
struct Node
{
pair<K, V> _data;
Node<K, V>* _pNext;
Node(const pair<K, V>& kv)
:_data(kv)
,_pNext(nullptr)
{}
};
哈希表的框架,这里的HF
就可以用刚刚的了:
cpp
template<class K, class V, class HF = defaultHashFunc<K>>
class HashTable
{
typedef Node<K, V> Node;
public:
HashTable() { _table.resize(10, nullptr); }
private:
vector<Node*> _table;
size_t _n = 0;
};
依然是开头就typedef
,依然是构造中resize
数组,不过这里的vector
中存Node*
就行了。
然后写插入的时候,遇到了个问题,_find
返回什么?
cpp
bool insert(const pair<K, V>& kv)
{
? cur = _find(kv.first);
返回size_t
的下标吗,那好像也没有缩短多少代码,之后还要在挂着的链表里面继续找;返回Node*
吗,感觉没有什么意义。
这时,我突然想到了二叉搜索树的递归写法,那里传的参数是个指针的引用!这样可以直接改变一个节点的_pNext
。
那这里能不能用引用呢?我想了想,好像可以!
首先是_find
:
cpp
Node*& _find(const K& k)
{
HF hf;
size_t index = hf(k) % _table.size();
return __find(_table[index],k);
}
这里主要是计算出index
,然后交给__find
去完成真正的查找:
cpp
Node*& __find(Node*& cur, const K& k)
{
return (!cur || cur->_data.first == k) ? cur : __find(cur->_pNext, k);
}
有点抽象,不过能跑,为了便于讲解,我把__find
改改:
cpp
Node*& __find(Node*& cur, const K& k)
{
if(cur == nullptr) return cur;
if(cur->_data.first == k) return cur;
return __find(cur->_pNext, k);
}
如果 cur
为空,返回 cur
。
如果 cur
的 k
和 传入的一样,说明重复了,也返回 cur
。
如果不是上面两种情况,就找cur->_pNext
。
这里的参数和返回值都是指针的引用,到时候就可以直接改了,非常方便。
然后插入函数就很好写了:
cpp
bool insert(const pair<K, V>& kv)
{
Node*& cur = _find(kv.first);
if (cur) return false;
cur = new Node(kv);
_n++;
return true;
}
这里相当于还附加了一个去重的效果。(C++是真香,C语言绝对办不到
来分析一下具体的情况吧:
cpp
HashTable<int, int> ht;
cout << ht.insert({ 1,1 });
cout << ht.insert({ 1,1 });
cout << ht.insert({ 11,1 });
第一次插入,插入 1
:
__find
接收_table[index]
的引用,发现_table[index]
为空,返回_table[index]
的引用给_find
,_find
再返回_table[index]
的引用给cur
,cur
是什么?是指针的引用。哪个指针的引用?_table[index]
的引用。改变cur
是改变什么?改变_table[index]
!!我靠C++是真帅。
第二次插入,插入 1
:
__find
会找到刚刚插入的 1
,然后将该节点指针的引用层层返回,cur
接收到了这个指针的引用,在判断中,发现 cur
不为空,即重复了,直接返回false
。
第三次插入,插入 11
:
同理,最后 cur
是 1
这个节点的 _pNext
的引用,改变cur
就是改变 1
的 _pNext
!!
去重加插入就这么解决了。
再写写扩容,由于一个槽可以挂多个数据,开散列的装载因子并没有强制要求小于1
,这里取1
就行。
而实现的思路与闭散列有所不同,这里不能再建一个新哈希表再插入,因为新建和释放节点都要时间,这里可以用一种顺手牵羊的思路,直接把原哈希表上挂的节点挂到新的哈希表上:
cpp
bool insert(const pair<K, V>& kv)
{
Node*& cur = _find(kv.first);
if (cur) return false;
cur = new Node(kv);
_n++;
前面操作不变,先插入成功再扩容:
cpp
if (_n >= _table.size())
{
HF hf;
size_t newSize = _table.size() * 2;
vector<Node*> newTable;
newTable.resize(newSize, nullptr);
for (int i = 0; i < _table.size(); i++)
{
Node* c = _table[i];
while (c)
{
Node* next = c->_pNext;
size_t index = hf(c->_data.first) % newSize;
c->_pNext = newTable[index];
newTable[index] = c;
c = next;
}
_table[i] = nullptr;
}
_table.swap(newTable);
}
return true;
}
删除也很简单:
cpp
bool erase(const K& k)
{
Node*& cur = _find(k);
if (cur == nullptr)return false;
Node* tmp = cur;
cur = cur->_pNext;
delete tmp;
return true;
}
如果找到了cur
,用一个tmp
保存一下,然后让cur = cur->_pNext
,最后释放 tmp
:
查找更简单了:
欸不对,查找写过了,不过写的是private。
那就再套一层吧,前面的懒得改了:
cpp
Node* find(const K& k) { return _find(k); }
表的完整代码:
cpp
template<class K, class V, class HF = defaultHashFunc<K>>
class HashTable
{
typedef Node<K, V> Node;
public:
HashTable() { _table.resize(10, nullptr); }
bool insert(const pair<K, V>& kv)
{
Node*& cur = _find(kv.first);
if (cur) return false;
cur = new Node(kv);
_n++;
if (_n >= _table.size())
{
HF hf;
size_t newSize = _table.size() * 2;
vector<Node*> newTable;
newTable.resize(newSize, nullptr);
for (int i = 0; i < _table.size(); i++)
{
Node* c = _table[i];
while (c)
{
Node* next = c->_pNext;
size_t index = hf(c->_data.first) % newSize;
c->_pNext = newTable[index];
newTable[index] = c;
c = next;
}
_table[i] = nullptr;
}
_table.swap(newTable);
}
return true;
}
bool erase(const K& k)
{
Node*& cur = _find(k);
if (cur == nullptr)return false;
Node* tmp = cur;
cur = cur->_pNext;
delete tmp;
return true;
}
Node* find(const K& k) { return _find(k); }
private:
Node*& _find(const K& k)
{
HF hf;
size_t index = hf(k) % _table.size();
return __find(_table[index],k);
}
Node*& __find(Node*& cur, const K& k)
{
return (!cur || cur->_data.first == k) ? cur : __find(cur->_pNext, k);
}
vector<Node*> _table;
size_t _n = 0;
};
希望本篇文章对你有所帮助!并激发你进一步探索编程的兴趣!
本人仅是个C语言初学者,如果你有任何疑问或建议,欢迎随时留言讨论!让我们一起学习,共同进步!