顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为 O ( N ) O(N) O(N),平衡树中为树的高度,即 O ( l o g N ) O(logN) O(logN),搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。 如果构造一种存储结构,通过某种函数(hashFunc
)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。
当向该结构中:
该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table
)(或者称散列表)<\font>
例如:数据集合 {1,7,6,4,5,9};
哈希函数设置为:hash(key) = key % capacity;
capacity
为存储元素底层空间总的大小。
用该方法进行搜索不必进行多次关键码的比较,因此搜索的速度比较快
其查找效率达到 O ( 1 ) O(1) O(1),但是它是一张不满的表,即离散表,也就造成了空间浪费,这就是很常见的以空间换时间的做法。<\font>
问题:按照上述哈希方式,向集合中插入元素 44,会出现什么问题?
对于两个数据元素的关键字 k i k_i ki 和 k j k_j kj ( i ! = j ) (i != j) (i!=j),有 k i ! = k j k_i!=k_j ki!=kj ,但有: H a s h ( k i ) ! = H a s h ( k j ) Hash(k_i)!=Hash(k_j) Hash(ki)!=Hash(kj)。即:不同关键字通过
相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。
把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。
发生哈希冲突该如何处理呢?
引起哈希冲突的一个原因可能是:哈希函数设计不够合理。 哈希函数设计原则:
m
个地址时,其值域必须在 0 到 m-1
之间常见哈希函数
Hash(Key)= A*Key + B
m
,但最接近或者等于 m
的质数 p
作为除数,按照哈希函数:Hash(key) = key% p(p<=m)
,将关键码转换成哈希地址H(key) = random(key)
,其中 random
为随机数函数。n
个 d
位数,每一位可能有 r
种不同的符号,这 r
种不同的符号在各位上出现的频率不一定相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只有某几种符号经常出12+34=46
)等方法。注意:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突
解决哈希冲突两种常见的方法是:闭散列 和 开散列
闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。那如何寻找下一个空位置呢?
比如以下这个场景:
例如:数据集合 {1,7,6,4,5,9};
现在需要插入元素 44,先通过哈希函数计算哈希地址,
hashAddr
为 4,因此 44 理论上应该插在该位置,但是该位置已经放了值为 4 的元素,即发生哈希冲突。
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
插入
查找
hashAddr
,直接进行查找,若找到了直接返回即可删除
采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。 比如删除元素 4,如果直接删除掉,44 查找起来可能会受影响。因此线性探测采用标记的伪删除法来删除一个元素。
#pragma once
#include
#include
using namespace std;
// 哈希表每个空间给个标记
// EMPTY此位置空, EXIST此位置已经有元素, DELETE元素已经删除
enum State{
EMPTY,
EXIST,
DELETE
};
class dealInt
{
public:
int operator()(int n)
{
return n;
}
};
class dealString
{
public:
int operator()(const string & n)
{
int sum = 0;
int seed = 131;
for (const char & c : n)
{
sum = sum * seed + c;
}
return sum & 0x7FFFFFFF;
}
};
// 注意:假如实现的哈希表中元素唯一,即key相同的元素不再进行插入
// 为了实现简单,此哈希表中我们将比较直接与元素绑定在一起
template<class K, class V, class SW = dealInt>
class hashTable
{
struct elem
{
pair<K, V> m_val;
State m_state;
elem(const K & key = K(), const V & val = V(), State state = EMPTY):
m_val(key, val),
m_state(state)
{}
};
vector<elem> m_table;
size_t m_size;
static long long s_m_primeTable[30];
int m_primePos;
public:
hashTable(size_t capacity = s_m_primeTable[0]) :
m_table(capacity),
m_size(0),
m_primePos(0)
{}
size_t capacity()
{
return m_table.size();
}
private:
int hashFunc(const K & key)
{
SW func;
return func(key) % capacity();
}
void reserve()
{
vector<elem> tmp;
m_table.swap(tmp);
m_table.resize(s_m_primeTable[++m_primePos]);
m_size = 0;
for (auto & e : tmp)
{
if (e.m_state == EXIST)
{
insert(e.m_val);
}
}
}
public:
bool insert(const pair<K, V> &val)
{
if ((long long)size() * 100 / capacity() >= 75)
{
reserve();
}
int n = hashFunc(val.first);
while (m_table[n].m_state == EXIST)
{
if (m_table[n].m_val.first == val.first)
{
return false;
}
n++;
if (n == capacity())
{
n = 0;
}
}
m_table[n].m_val = val;
m_table[n].m_state = EXIST;
m_size++;
return true;
}
int find(const K & key)
{
int n = hashFunc(key);
while (m_table[n].m_state != EMPTY)
{
if (m_table[n].m_state == EXIST && m_table[n].m_val.first == key)
{
return n;
}
n++;
if (n == capacity())
{
n = 0;
}
}
return -1;
}
bool erase(const K & key)
{
int ret = find(key);
if (ret < 0)
{
return false;
}
else
{
m_table[ret].m_state = DELETE;
m_size--;
}
}
size_t size()
{
return m_size;
}
bool empty()
{
return m_size == 0;
}
void Swap(hashTable<K, V>& ht)
{
m_table.swap(ht.m_table);
size_t tmp;
tmp = m_size;
m_size = ht.m_size;
ht.m_size = tmp;
}
};
template<class K, class V, class SW>
long long hashTable<K, V, SW>::s_m_primeTable[30] = {
11, 23, 47, 89, 179,
353, 709, 1409, 2819, 5639,
11273, 22531, 45061, 90121, 180233,
360457, 720899, 1441807, 2883593, 5767169,
11534351, 23068673, 46137359, 92274737, 184549429,
369098771, 738197549, 1476395029, 2952790016u, 4294967291u
};
上面的 HashTable
的简单实现考虑了 key
不为整形的情况,并提供了转整形的方法。哈希函数采用处理余数法,被模的 key
必须要为整形才可以处理。
除留余数法,最好模一个素数,如何每次快速取一个类似两倍关系的素数?我们在上面给出了一张素数表,进行筛选即可。
这个是个重点,结合 vector
等各类容器增容特点会经常考到:
通常我们设定载荷因子为 0.75,当增容过后,里面所有的元素必须重新插入。
// 在此载荷因子设置为 0.7
void CheckCapacity()
{
if(_size * 10 / _ht.capacity() >= 7)
{
HashTable<K, V, HF> newHt(GetNextPrime(ht.capacity));
for(size_t i = 0; i < _ht.capacity(); ++i)
{
if(_ht[i]._state == EXIST)
newHt.Insert(_ht[i]._val);
}
Swap(newHt);
}
}
如何缓解呢?
线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为: H i = ( H 0 + i 2 ) H_i= (H_0 +i^2 )% m Hi=(H0+i2),或者: H i = ( H 0 − i 2 ) H_i= (H_0 -i^2 )% m Hi=(H0−i2)。就是跳着找,类似于算法竞赛中的 倍增 这个概念。可以进行前后倍增式的查找,以提高效率,其中: i = 1 , 2 , 3 … i = 1,2,3… i=1,2,3…, H 0 H_0 H0 是通过散列函数 H a s h ( x ) Hash(x) Hash(x) 对元素的关键码 k e y key key 进行计算得到的位置,m
是表的大小。 对于以下情况中如果要插入 44,产生冲突,使用解决后的情况为:
研究表明:当表的长度为质数且表装载因子 a
不超过 0.5 时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子 a
不超过 0.5,如果超出必须考虑增容。
因此:闭散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷。
为了解决 闭散列 所存在的缺陷,大佬们发明出对应的 开散列, unordered_map
与 unordered_set
的底层实现就依靠 开散列。
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
上图即为简单的开散列结构,其容量为 10,就相当于一个容量大小为 10 的数组,其存放的 10 个元素就是存放的 10 个链表的头结点,后面挂的就是这 10 个单链表,这个数组的每一个元素就被称为 桶。
当我们对开散列进行插入的时候,能够我们首先需要遍历对应桶中的所有元素,若不存在,则直接插入即可(在此先不考虑是头插还是尾插),若存在,由于 map
不允许重复元素的出现,则直接返回 false
即可。
从上图可以看出,开散列中每个桶中放的都是发生哈希冲突的元素。
桶的个数是一定的,随着元素的不断插入,每个桶中元素的个数不断增多,极端情况下,可能会导致一个桶中链表节点非常多,会影响的哈希表的性能,因此在一定条件下需要对哈希表进行增容,那该条件怎么确认呢?
开散列最好的情况是:每个哈希桶中刚好挂一个节点,再继续插入元素时,每一次都会发生哈希冲突,因此,在元素个数刚好等于桶的个数时,可以给哈希表增容。
在闭散列中,当哈希表增容,我们需要将原有哈希表中的所有元素进行重新插入。相同的是,开散列在此进行预闭散列相同的操作,它也需要将原有表中数据统一进行重新插入。因为映射后的位置发生变化,必须进行这样的操作。这个时间成本的浪费是比不可少的。
#pragma once
#include
using namespace std;
// 模板类
template<class T>
class HashBucketNode
{
T m_val;
HashBucketNode<T> * m_next;
HashBucketNode(const T & val = T()) :
m_val(val),
m_next(nullptr)
{}
template<class T, class SW>
friend class HashSet;
};
// 处理 int
class dealInt
{
public:
int operator()(int n)
{
return n;
}
};
// 键值类型 T 和处理方式,不传入则默认为 int
template<class T, class SW = dealInt>
class HashSet
{
vector<HashBucketNode<T> *> m_data;
size_t m_size;
static long long s_m_primeTable[30];
// 标记素数表位置
int m_primePos;
public:
// 初始大小,素数表第一个元素
HashSet(size_t capacity = s_m_primeTable[0]) :
m_data(capacity, nullptr),
m_size(0),
m_primePos(0)
{}
private:
int hashFunc(const T & key)
{
SW func;
return func(key) % capacity();
}
void checkCapacity()
{
if (m_size == capacity())
{
// 保存原有的capacity
int mcapa = capacity();
// ++m_primePos确定下一个素数表中素数大小
vector<HashBucketNode<T> *> tmp(s_m_primeTable[++m_primePos], nullptr);
// tmp成为新的m_data,交换两vector容器的元素,swap方法注意使用
m_data.swap(tmp);
m_size = 0;
int i;
HashBucketNode<T> * cur;
// 双重遍历,遍历数组中的所有链表元素
// 遍历旧vector所有桶
for (i = 0; i < mcapa; i++)
{
// 遍历桶中链表的所有元素进行重新插入
for (cur = tmp[i]; cur; cur = cur->m_next)
{
insert(cur->m_val);
}
}
}
}
public:
bool insert(const T & val)
{
// 插入条件
// 1. 桶为空直接进行头插
// 2. 桶不为空查看hashnum是否已经在桶中
checkCapacity();
int hashnum = hashFunc(val);
HashBucketNode<T> * tmp;
if (m_data[hashnum])
{
// 遍历链表
for (tmp = m_data[hashnum]; tmp; tmp = tmp->m_next)
{
if (tmp->m_val == val)
{
return false;
}
}
}
// 链表头插
tmp = new HashBucketNode<T>(val);
tmp->m_next = m_data[hashnum];
m_data[hashnum] = tmp;
m_size++;
return true;
}
bool erase(const T & val)
{
// 删除条件
// 1. 若桶为空,则直接返回即可
// 2. 若为头删,执行链表头删即可
// 3. 若为后删,执行链表正常删除即可
int hashnum = hashFunc(val);
HashBucketNode<T> * tmp;
if (!m_data[hashnum])
{
return false;
}
// 头删
if (m_data[hashnum]->m_val == val)
{
tmp = m_data[hashnum];
m_data[hashnum] = tmp->m_next;
delete tmp;
m_size--;
return true;
}
// 后删,遍历链表,删除
else
{
for (tmp = m_data[hashnum]; tmp->m_next; tmp = tmp->m_next)
{
if (tmp->m_next->m_val == val)
{
HashBucketNode<T> * cur;
cur = tmp->m_next;
tmp->m_next = cur->m_next;
delete cur;
m_size--;
return true;
}
}
return false;
}
}
HashBucketNode<T> * find(const T & val)
{
int hashnum = hashFunc(val);
HashBucketNode<T> * cur;
for (cur = m_data[hashnum]; cur; cur = cur->m_next)
{
if (cur->m_val == val)
{
return cur;
}
}
return nullptr;
}
void clear()
{
// 清理两种方法
// 1. 一直进行链表头删即可
// 2. 一直进行后删,最后进行头删即可
HashBucketNode<T> * tmp;
for (auto & head : m_data)
{
while (head)
{
tmp = head;
head = head->m_next;
delete tmp;
}
}
m_size = 0;
}
size_t capacity()
{
return s_m_primeTable[m_primePos];
}
};
template<class T, class SW>
long long HashSet<T, SW>::s_m_primeTable[30] = {
11, 23, 47, 89, 179,
353, 709, 1409, 2819, 5639,
11273, 22531, 45061, 90121, 180233,
360457, 720899, 1441807, 2883593, 5767169,
11534351, 23068673, 46137359, 92274737, 184549429,
369098771, 738197549, 1476395029, 2952790016u, 4294967291u
};
开散列应用链地址法处理溢出,需要增设链接指针,似乎增加了存储开销。事实上: 由于闭散列开地址法必须保持大量的空闲空间以确保搜索效率,如二次探查法要求装载因子 a <= 0.7
,而表项所占空间又比指针大的多,而开散列的 size == capacity
时才进行扩容,所以使用开散列链地址法反而比开地址法节省存储空间,这也是哈希容器底层实现所采用的方式。