哈希表
转自:算法设计与实现 (主编 陈宇 吴昊)
ACM/ICPC 算法训练教程 (主编 余立功)
一,引入
对于排序算法,最低复杂度是O(nlogn),但对于一些特殊情况可以更快:现有 N(N>1000)个整数,范围在 0~1000,如何排序?
1,可以建立数组 int num[M] ( M = 1001 ),初始化为 0,num[i] 表示 N 个数中有多少个数等于 i。这样每读入一个数 x,有 num[x]++。最后从 num[0] ~num[1000] 依次取出这些数,复杂度为 O(n)。这就是 Hash 的思想——将某个对象(关键字)映射到一个 int 类型的数中作为下标 (我们称该映射为哈希函数),放入表中 (哈希表),之后查找时就可以根据要查找的对象和哈希函数 直接算出该对象的存储位置。例如在上面的例子中,对象为 x,哈希函数为 f(x) = x,所以 x 的存储下标为 x,存储在哈希表 num[x] 中
2,如果数据范围大于数组大小,则可以对数据取模数组长度 M,再插入表中 (可以用与运算避免取模运算慢的问题)。但这样就会产生一个问题,两个不同的对象映射到同一地址,如 0 和 M,即冲突
二,哈希表概述
1,哈希表也称为散列表,散列即指哈希
哈希表是一种高效的数据结构,主要体现在数据的查找上,几乎可以认为是常数时间
设所有可能出现的关键字 (对象) 集合为 U,哈希就是通过哈希函数 h 将 U 映射到 哈希表 T[0,,, m-1 ] ( m 为哈希表的长度 ) 的下标上,这样以 h 为函数,以关键字为自变量的运算结果就是对应对象的存储地址 (就是上面第一点引入的思想 ╮(๑•́ ₃•̀๑)╭),从而达到在 O(1) 时间内的查找
2,冲突
两个不同的关键字,由于哈希值相同因而被映射到哈希表的同一位置,该现象称为冲突,也叫碰撞,发生冲突的两个关键字称为该哈希函数的同义词。显然,当 |U| (集合 U 的元素个数) 的个数大于 m 时,是不可能完全避免冲突的
解决冲突的方法
① 通过设计 哈希函数使元素均匀分布在哈希表中,使冲突最少
② 对于冲突的元素,采用开放地址法 和拉链法
影响冲突的因素
① 哈希函数
② 哈希表的装填因子 a,即 哈希表中填入的 元素个数 / 表长。a 越大,代表表越满,则冲突的机会越大
三,哈希函数
哈希函数要求简单,均匀,简单指函数的计算要简单快速,均匀指对于集合U 中的任一关键字,哈希函数能以等概率映射到表中的任意一个位置,使得冲突最小化。这里提供两个哈希函数,只会用,不懂得 why
这里说明一点,哈希值是指:return 的值,哈希函数的值,哈希地址,对应哈希表中的地址
1,对于数值(MOD 指表的长度)
int H(int key) // 哈希函数 { int seed = (key >> 1) + (key << 1); return (seed & 0x7FFFFFFF) % MOD; }
2,对于字符串
/* 逻辑移位,简单理解就是物理上按位进行的左右移动,两头用0进行补充,不关心数值的符号问题。 算术移位,同样也是物理上按位进行的左右移动,两头用0进行补充,但必须确保符号位不改变。 c 语言中:对于 << 和 >> 有符号数移位 采用 算术右移,无符号移位 采用 逻辑移位 */ int ELFhash(char* key) // 使用位运算 使得 每一个字符都会对 最后的哈希值 产生影响。 { // unsigned long 8个字节,32位,char 1个字节,4位 // 所以 h,g 的二进制 表示都有 32 位 // 所以 h 可以存 8个char类型的数据 unsigned long h = 0; while (*key) // 循环 key 字符串 { h = (h << 4) + *key++; //h 左移4位,把当前字符ASCII值 存入 h 的低四位。 // 0xf0000000L 的二进制的 为 1111|0000|0000|0000 unsigned long g = h & 0xf0000000L; // 保留 h 的高四位,将 h 的低28位 全化为 0 if (g) // 如果 h 的高四位不全为 0 h ^= g >> 24; // 用 h 的高四位 异或上自己的 低5~8位,其他清零 // >> 的运算优先级 大于 ^= h &= ~g; } return h; }
四,解决冲突的方法
有两种办法解决冲突的办法:开放地址法和拉链法。前者是将所有结点均存放在 哈希表T[] 中;后者通常将互为同义词的结点连成一个单链表,再将该链表的头指针放在哈希表T[] 中
1,开放地址法
① 概述
在插入时,当冲突发生时,使用某种探查函数在哈希表中形成一个探查序列,沿着此序列逐个查找,直到找到一个开放的地址(即该地址为空),则可以将元素插入该地址。查找的时候,还是用该探查方法,沿着探查序列逐个查找,直到找到给定的关键字(找到了),或者找到开放的地址(找不到,即表中无此关键字)。
注意:建表前,要将表中所有单元置空,就是用不会出现的关键字来表示空单元。如,非负整数用-1;字符串用空串
② 装填因子的要求
开放地址法要求散列表的装填因子a 取 0.5~0.9 之间的某个值为宜
③ 探查函数
大体上可以分为线性探查法和非线性探查法
(1) 线性探查法
struct Hash_table { int a[MOD]; int H(int key) // 哈希函数 { int seed = (key >> 1) + (key << 1); return (seed & 0x7fffffff) % MOD; } void next_pos(int d, int &hash) // 得到下一个哈希值 { hash = (hash + d) % MOD; } void insert(int key) // 插入 { int d = 1, hash = H(key); while (a[hash]) { next_pos(d, hash); } a[hash] = key; } int find(int key) // 追踪 { int d = 1, hash = H(key); while (a[hash]) { if (a[hash] == key) return key; next_pos(d, hash); } return 0; } void clear() { memset(a, 0, sizeof(a)); } }st;
其一般形式为: hi = ( h(key) + di ) % m,0<=i<=m-1
其中,h(key) 为哈希函数,对应代码中,第一次调用该函数的形参hash;di 为 增量序列,对应代码中的 d;m 为表长,对应代码中的 MOD。其中,d 的初始值为 1,并设 hash = h(key),有探查序列:hash+1,hash+2,.....,m-1,0,1,........,hash-1,hash。若一直探查到 T[hash],则说明不管是插入还是查找都失败
(2) 非线性探查法中的双重散列法
线性探查法是有局限性的:当表中 i,i+1,...,i+k 已经有元素时,而 i+k+1 为空时,则哈希值 (注意理解这个哈希值是指什么) 为 i,i+1,...,i+k 的元素都将按探查序列查找下来,然后插入在 T[ i+k+1 ] 。我们把这种哈希值不同的元素(不是同义词) 争夺同一哈希地址的现象称为聚集或堆积。而这将会增加探查序列的长度,即增加查找时间。(线性探查法或者效果差的非线性探查法或者填装因子过大都会造成堆积现象)
所以才有了非线性的探查法的使用,其探查序列不是顺序的地址序列,而是跳跃式地散列在整个哈希表中
struct Hash_table { int a[MOD]; int H(int key) // 哈希函数 { int seed = (key >> 1) + (key << 1); return (seed & 0x7fffffff) % MOD; } void next_pos(int d, int &hash, int hash1) // 得到下一个哈希值 { // 双重散列法 hash = (hash + d*hash1) % MOD; } void insert(int key) // 插入 { int d = 1, hash = H(key), hash1 = key % (MOD - 2) + 1; while (a[hash]) { next_pos(d, hash, hash1); } a[hash] = key; } int find(int key) // 追踪 { int d = 1, hash = H(key), hash1 = key % (MOD - 2) + 1; while (a[hash]) { if (a[hash] == key) return key; next_pos(d, hash, hash1); } return 0; } void clear() { memset(a, 0, sizeof(a)); } }st;
双重散列法是开放地址法中最好的方法之一,其一般形式为:hi = ( h(key) + i*h1(key) ) % m,0<=i<=m-1 (注意,h1(key) 和 当i == 1 时的 hi 不是同一个函数 )
其中用了两个哈希函数,h(key) 和 h1(key),故也称为双散列函数探查法。其中 h(key) 对应代码中的 H(key),h1(key) 对应代码中的 hash1 = key % (MOD - 2) + 1;
探查序列为:h(key)+h1(key),h(key)+2*h1(key),h(key)+3*h1(key),.....,h(key)+i*h1(key)
注意
定义 h1(key) 的方法有很多,但必须使 h1(key) 和 m 互质,才能使发生冲突的同义词地址均匀地分布在整个哈希表中,否则可能造成同义词地址的循环计算。若 m 为质数,则 h1(key) 取 1~m-1 之间的任何数都与 m 互质,所以可简单的定义 h1(key) = key % (m-1) +1 (+1 是函数值不能取 0 的意思)
100003 和 1000003 是素数
若 m 为 2 的方幂,则 h1(key) 可取 1~m-1 之间的任何奇数
2,拉链法
① 概述
将所有关键字为同义词的结点链接到同一单链表中,若选定的哈希表的长度为 m,则可以将哈希表定义为 一个 m 个头指针组成的指针数组 T[0...m-1]。凡是哈希地址为 i 的元素,均插入到 以 T[i] 为头指针的单链表中。T 中各元素的初始值应为空指针
② 装填因子的要求
在拉链法中,装填因子a 可以大于 1,但一般取 a<=1
③ 代码实现
用链表实现
struct Hash_table { typedef struct Link { int index; struct Link* next; }st; st* lk[MOD]; int H(int key) // 哈希函数 { int seed = (key >> 1) + (key << 1); return (seed & 0x7FFFFFFF) % MOD; } void insert(int key, int i) { int hash = H(key); // 头插法 st* p = (st*)malloc(sizeof(st)); p->index = i; p->next = lk[hash]; lk[hash] = p; } int find(int key) { int hash = H(key); st* p = lk[hash]; while (p != NULL) { if (找到了) { 巴拉巴拉 return 1; } p = p->next; } return 0; } void clear() { for (int i = 0; i < MOD; i++) lk[i] = NULL; } }ht;
用数组代替链表,因为链表的清空不如数组的快
struct Hash_table { int lk[MOD], next[MOD]; int H(int key) // 哈希函数 { int seed = (key >> 1) + (key << 1); return (seed & 0x7FFFFFFF) % MOD; } void insert(int key, int i) { int hash = H(key); next[i] = lk[hash]; // 头插法 lk[hash] = i; } int find(int key) { int hash = H(key); int p = lk[hash]; while (p != -1) { if (找到了) { 巴拉巴拉 return 1; } p = p[next]; } return 0; } void clear() // 链表数组的清零麻烦,所以用数组代替链表 { memset(lk, -1, sizeof(lk)); } }ht;
④ 优缺点
优点
(1)拉链法无堆积现象,即非同义词绝不会发生冲突,因此平均查找长度较短
(2)由于拉链法的结点空间是动态申请的 ,故它更适合造表前无法确定表长的情况
(3)当元素规模较大时,拉链法比开放地址法更省空间,因为拉链法的装填因子可以比开放地址法大
(4)拉链法构造的哈希表中,删除元素的操作容易实现,只要删除链表上的相应结点,而对于开放地址法构造的哈希表,删除元素并不能简单的将该元素对应的地址置为空,否则将截断之后填入哈希表的同义词的查找路径。只能在要删除的元素上做标记,不能真正删除该元素
缺点
当元素规模较小时,拉链法比开放地址法更浪费空间,因为指针需要额外的空间
========== ========== ======= ======= ====== ===== ==== === == =
《借我》 木心
借我一个暮年
借我碎片
借我瞻前与顾后
借我执拗如少年
借我后天长成的先天
借我变如不曾改变
借我素淡的世故和明白的愚
借我可预知的脸
借我悲怆的磊落
借我温软的鲁莽和玩笑的庄严
借我最初与最终的不敢
借我言而不喻的不见
借我一场秋啊
可你说这已是冬天