哈希表
哈希表通过哈希函数为各个值重新分配物理索引,哈希函数通常与取模密切相关。例如,给定哈希函数 i n d e x = k e y m o d m a x _ s i z e index= key \space mod\space max\_size index=key mod max_size,其中 m a x _ s i z e max\_size max_size为哈希表的存储容量。
假若容量为15,则哈希函数为 f ( x ) = x m o d 15 f(x)=x \space mod \space 15 f(x)=x mod 15,插入 k e y = 3 , v a l = 5 key=3,val=5 key=3,val=5和 k e y = 15 , v a l = 1 key=15,val=1 key=15,val=1,则得到 H a s h t a b l e [ 3 ] = 3 , H a s h t a b l e [ 18 ] = H a s h t a b l e [ 0 ] = 1 Hashtable[3]=3,Hashtable[18]=Hashtable[0]=1 Hashtable[3]=3,Hashtable[18]=Hashtable[0]=1,且 v a l u e [ 3 ] = 5 , v a l u e [ 18 ] = 1 value[3]=5,value[18]=1 value[3]=5,value[18]=1.
使用哈希表实际上是对数据的离散化,由此数据范围很大,但数据个数不多时可以不用开数据范围那么大的数组,可以用很小的存储空间存储,并且随机访问效率接近于数组,优于链表。
哈希冲突
接上文,如果又出现了 k e y = 18 , v a l = 4 key=18,val=4 key=18,val=4,由于 k e y m o d 15 = 3 key \space mod \space 15=3 key mod 15=3,而 H a s h t a b l e [ 3 ] Hashtable[3] Hashtable[3]已经被占用了,故出现哈希冲突,此时需要其他方法安顿 ( 18 , 4 ) (18,4) (18,4).
解决哈希冲突有开散列法和闭散列法两种方法。
开散列法
将每个存储槽展开为单链表。例如,现在 H a s h t a b l e [ 3 ] Hashtable[3] Hashtable[3]是一个链表,它的头节点存储了索引3,当 ( 18 , 4 ) (18,4) (18,4)遇到已经有值的 H a s h t a b l e [ 3 ] Hashtable[3] Hashtable[3]头结点时,其沿着链表向下访问,遇到空的链表节点就安顿下来。访问时,键入 18 18 18, 18 m o d 15 = 3 18 \space mod \space 15 = 3 18 mod 15=3沿着 H a s h t a b l e [ 3 ] Hashtable[3] Hashtable[3],发现头结点是3,非18, 故继续向下,访问到第二个为18,则找到值。
开散列法随机访问的时间复杂度为 O ( D ) O(D) O(D),其中 D D D为各个存储槽的最长链表长度。当数据较为极端时,开散列法将可能退化为链表,访问复杂度达到 O ( N ) O(N) O(N).
闭散列法
线性探查
与开散列法不同,闭散列法不使用链表,而是通过冲突解决策略寻找其他空槽。最简单的闭散列法是线性探查法。即当前槽被占用时,沿着散列数组向后继续找,找到一个空槽就马上停下并存储。(当找到边界了还没找到,就从数组开头开始继续找)。线性探查的操作过程保证其会访问数组中所有可能的空槽。
简单的线性探查哈希表实现
#include <bits/stdc++.h>
#define int long long
using namespace std;
struct Hash
{
private:
int size;
int* table; // 计算结果
int* value; // 值
public:
Hash( int sz = 6151 ) {
size = sz;
table = new int[(size << 2) + 3];
value = new int[(size << 2) + 3];
for(int i = 0; i < size; ++i) table[i] = 0;
}
~Hash(){ delete[] table; delete[] value; }
void set(int key, int val){ // 修改值
int p = key % size;
int cnt = 0;
while(table[p] != key && cnt < size) p = (p + 1) % size, cnt++;
if(cnt == size) return;
value[p] = val;
}
void add(int key, int val){ // 新建
// 线性探查
int p = key % size;
int cnt = 0;
while(table[p] && cnt < size) p = (p + 1) % size, cnt++;
if(cnt == size) return;
table[p] = key, value[p] = val;
}
const int& operator[](int key) const{ // 通过重载[]实现随机访问
int p = key % size;
int cnt = 0;
while(table[p] != key && cnt < size) p = (p + 1) % size, cnt++;
if(cnt == size){
cout << "Illegal Index!" << endl;
}
return value[p];
}
};
signed main()
{
// 主函数用于测试
Hash t;
t.add(1, 5);
t.add((int)1e9 + 7, 123);
cout << t[1] << endl;
cout << t[(int)1e9 + 7] << endl;
return 0;
}
二次探查
显然,在冲突次数增多后,尤其是在同一个槽发生的冲突增多后,线性探查的探查时间会比较长,甚至可能比闭散列还要差,需要设计更好的冲突解决策略。二次探查函数规定,遇到冲突后,指针每次偏移 c 1 i 2 + c 2 i + c 3 c_1i^2+c_2i+c_3 c1i2+c2i+c3,其中 i i i是探查次数。二次探查可以避免线性探查导致的关键码聚集。
二次探查的缺陷是,它或许并不能像线性探查那样访问每一个可能的空槽,因此可能在哈希表还有空间未用时就插入失败了。
随机探查
除了二次探查,还可以通过生成随机的排列来进行伪随机探查。设有随机序列 p p p,则探查时每次指针偏移 p [ i ] p[i] p[i].
双散列探查
继续考虑以上两种探查方案。由于每个槽的探查点由探查函数确定好了,而二次探查函数和伪随机探查序列都可能在某个槽“聚集”,产生类似于线性探查的较差结果。为了避免其发生,可以再设置一个散列函数 h h h, h [ i ] h[i] h[i]的值可以由随机序列产生,也可以由其他方法产生。最后 p = i ∗ h ( k ) p=i*h(k) p=i∗h(k),各个关键码之间就很难再共享一个探查序列了。
哈希表大小的设置
由于哈希函数多为取模函数,如果给一个随机序列,其间隔为1,那么经过哈希函数的映射序列间隔也为1。但是是用哈希表多是为了离散化,间隔为1的情况不多。
若序列之间的间隔为哈希表长度的因子大小,则将有机会产生冲突。
故将哈希表的大小设置为素数,可以保证哈希表的因子最少,最不容易产生关键码集群或哈希冲突。通常会取一个素数 N N N,然后设哈希表大小为 4 N + 3 4N+3 4N+3.