回顾与引出
我们在上一节用闭散列的开放定址法实现了哈希表。不难看出这种方法有明显的缺点:一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”,即:不同 关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低。
为了解决数据堆积导致寻找位置的次数增加造成的效率下降,我们这里讲解实现哈希表的另一种方法:拉链法(哈希桶)。
数组中的每一个位置不再存储有效的数据,而是存储一个指针,指向一个有效的元素。如果冲突的元素很多,结果就是哈希桶很长。哈希桶太长查找效率同样也会下降,因此在用这种方式实现哈希表的时候,也会维护指针数组的扩容逻辑,以保证哈希表的整体效率。
基本结构
-
成员变量
哈希表中的每一个元素是通过链表链接起来的,每个链表的头结点与数组的位置关联。因此,你可能会在
HashTable
z中使用vector<list>
作为类的成员来维护哈希表。这样做完全没有任何问题,但是对于哈希表来说还是有点冗余了,因为拉链法(哈希桶)中的链表只是单链表,在插入一个新元素的时候头插就可以了。但是list
中有prev
指针,用list
效率反而会下降。size_t _n
用来记录哈希表中存储了多少个有效的数据,这与哈希表的扩容逻辑相关。类比开散列实现哈希表的size_t _n
。 -
哈希表节点
节点存储的数据是一个
key-value
的结构,因为后面我们要用哈希表封装unordered_map
和unordered_set
先这么写。节点中需要包含一个节点的指针,用来指向单链表中的下一个节点。
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 HashTable
{
public:
typedef HashNode<K, V> Node;
private:
vector<Node*> _table;
size_t _n;
};
}
构造函数
在构造函数中,我们需要给哈希表申请一些空间,并初始化为 nullptr
。nullptr
表示数组中的这个位置没有存储元素。当然在构造函数中,你也可以不给哈希表申请空间,只不过在 Insert
函数中就必须特殊判断 _table
的 size
是否为 0。这么对比下来,还是在构造函数中给哈希表提前申请一些空间比较香。
HashTable()
{
_table.resize(10, nullptr);
}
bool Insert(const pair<K, V>& kv)
插入元素之前,我们需要通过哈希函数找到新插入元素应该插入到数组的哪一个位置。哈希函数还是选择除留余数法,用 kv.first % _table.size()
找到新元素在数组中的位置之后。我们就要将新元素插入到单链表中:那我们应该是头插还是尾插呢?毫无疑问当然是头插哈。尾插需要找尾,效率比较低,就算维护尾节点的指针,也会有空间消耗,不如头插来得快。
size_t hashi = kv.first % _table.size();
//头插
Node* newNode = new Node(kv);
newNode->_next = _table[hashi];
_table[hashi] = newNode;
上面就是头插的核心代码啦!如果你不理解,可以尝试举一个例子:
如果我们只管向哈希表中插入数据,不扩容,某些桶越来越长,效率就得不到保障。想闭闭散列实现的哈希表,开散列的哈希表可以将负载因子适当放大,我们就取 1 吧。平均下来每个桶一个数据。查找的效率就是接近 O(1) 啦!扩容之后,每个元素映射到数组中的下标也可能会改变,因此扩容还有降低桶长度的效果!
扩容之后原来的数据怎么映射到新的哈希表中呢?你可能会想到复用 Insert
函数。和闭散列实现的哈希表不同,开散列的 Insert
函数中会在堆区申请节点。如果直接复用 Insert
接口,就需要释放原来的节点,重新开辟新的节点插入到新的哈希表中。仔细想想,我们既然已经有原哈希表中的节点了,为何不直接将他链接到新的哈希表中呢?既不需要释放节点,也不需要开辟新的节点。
bool Insert(const pair<K, V>& kv)
{
if (_n >= _table.size())
{
size_t newSize = _table.size() * 2;
HashTable<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 hashi = cur->_kv.first % newSize;
cur->_next = newTable[i];
newTable[hashi] = cur;
cur = next;
}
_table[i] = nullptr; //旧表置空
}
_table.swap(newTable);
}
size_t hashi = kv.first % _table.size();
//头插
Node* newNode = new Node(kv);
newNode->_next = _table[hashi];
_table[hashi] = newNode;
_n++;
return true;
}
将旧表的节点插入到新的哈希表:首先遍历旧表,如果某个下标的值不为 nullptr
说明这个下标存储了有效数据。然后遍历该下标对应的哈希桶,计算新的 hashi
之后映射到新的哈希表即可。
Node* Find(const K& key)
这个函数用于在哈希表中查找一个元素。在开散列实现的哈希表中,就相当于在链表中查找一个元素。比较简单!
Node* Find(const K& key)
{
size_t hashi = k % _table.size();
Node* cur = _table[hashi];
while (cur)
{
if (cur->_kv.first == key)
{
return cur;
}
cur = cur->_next;
}
return nullptr;
}
这就是 Find
函数的实现,我们这里实现的哈希表是不允许插入两个相同的元素的。因此,你还需要在 Insert
函数的开头加上判断,使得 Insert
不能插入两个相同的元素。
bool Erase(const K& key)
Erase
函数用来在哈希表中除值为 key
的元素。首先我们需要根据除留余数法找到 key
对应的下标。然后,在这个下标的位置进行一个单链表的删除就行啦!初始化 prev
指针为 nullptr
,用 cur
指针遍历哈希桶(单链表),如果说,cur
节点存储的 key
值就是待删除的元素,令 prev
的 next
指向 cur
的 next
就行啦。最后记得释放掉要删除的节点哦!
我们来看一个特殊的例子:如果要删除的节点就是单链表的头结点,如下图中的 4 。此时 prev
为 nullptr
在用 prev->next
链接 cur->next
就会发生空指针的解引用。因此需要特殊判断一下,删除的是不是单链表(哈希桶)的头结点。
bool Erase(const K& key)
{
size_t hashi = 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;
}
delete cur;
return true;
}
prev = cur;
cur = cur->_next;
}
return false;
}
析构函数
我们实现的哈希表是开散列实现的,存储数据的节点都是在堆上申请的。不同于闭散列实现的哈希表,开散列实现的哈希表需要我们自己写析构函数。
析构函数的写法:遍历 _table
数组,将每个哈希桶(单链表)逐一释放即可!
~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;
}
}
支持字符串作为哈希表存储的数据
解决方法与开散列实现的哈希表是一样的。因为字符串是不能直接对整数取模的,因此我们需要通过字符串的哈希算法将字符串转换为整数。然后对字符串转换出来的整形取模就可以啦!关于哈希函数可以自行百度搜索。
对 key
值,我们用仿函数套一层,这样当传入 string
类型的时候,就能通过调用仿函数获得正确的 key
值了!
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
size_t hash = 0;
for (auto ch : str)
{
hash *= 131;
hash += ch;
}
return hash;
}
};
在 DefaultHashFunc
这个类中,我们重载了圆括号运算符。对于那些可以进行取模运算的类型,我们直接将 key
返回即可,对于不能进行取模运算的 string
通过模板的特化,进行特殊处理即可。这里使用到的字符串哈希算法是:BKDR 哈希算法。是一种比较常用的字符串哈希算法。通过哈希算法,将字符串转化为可以取模的整数。
部分修改
既然要同时适配 string
作为哈希表的存储类型,在之前实现哈希表的代码中,就不能直接对 key
进行取模啦!而是要传入 key
值,通过仿函数来获取正确的 key
值。
完整代码:
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;
}
}
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
{
return false;
}
HashFunc hf;
// 负载因子到1就扩容
if (_n == _table.size())
{
// 16:03继续
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 hashi = hf(cur->_kv.first) % newSize;
cur->_next = newTable[hashi];
newTable[hashi] = 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;
}
delete cur;
return true;
}
prev = cur;
cur = cur->_next;
}
return false;
}
void Print()
{
for (size_t i = 0; i < _table.size(); i++)
{
printf("[%d]->", i);
Node* cur = _table[i];
while (cur)
{
cout << cur->_kv.first << ":" << cur->_kv.second << "->";
cur = cur->_next;
}
printf("NULL\n");
}
cout << endl;
}
private:
vector<Node*> _table; // 指针数组
size_t _n = 0; // 存储了多少个有效数据
};
}