#散列表 hlist [hash_link_addr]: http://data.linuxtoy.cn/image/hash_link_address.png "链接地址法" [list]: http://data.linuxtoy.cn/image/list.jpg "双向链表" [hlist]: http://data.linuxtoy.cn/image/hlist.jpg "哈希表" [hlist_del_first_node]: http://data.linuxtoy.cn/image/hlist_del_first_node.jpg "哈希表删除第一个节点" [hlist_del_second_node]: http://data.linuxtoy.cn/image/hlist_del_second_node.jpg "哈希表删除第二个节点" hash表是为快速查找而设计的 设计思想: 通过某个函数,使得 存储位置=f(关键字) 只需要通过查找关键字而不需要比较就可以获得存储位置。这是一种散列技术,也是一种典型的空间换时间的做法。 散列技术在存储位置和它的关键字之间建立一个确定的映射f,使得每个关键字key对应一个存储位置f(key) 这种映射f称为散列函数,又称为hash函数。 散列函数将记录存储在一块连续的存储空间中,这块连续的存储空间称为散列表或者hash表. # 散列表查找步骤 1. 存储 存储时,通过散列函数计算关键字对应的散列地址,并按此地址存储该记录。 2. 查找 查找记录时,使用同样的散列函数计算记录的散列地址,使用该散列地址访问该记录。 所以说散列技术即是一种存储方法,也是一种查找方法。 散列技术适合求解的问题是查找与给定关键值相等的记录,所以关键值必须要唯一。 散列冲突: > 理想情况下,每个关键值通过散列函数计算出来的地址都是不一样的,但实际上,经常会遇到两个关键字通过散列函数 > 计算得到的地址相同: key1 ≠ key2 ,却有f(key1) = f(key2) > 这种现象称为冲突, 并把key1 和 key2 称为这个散列函数的同义词(synonym) # 构造散列函数的方法 散列函数设计的原则: 1. 计算简单 由于散列查找的目的是提高查找效率,如果散列函数需要复杂的计算,会消耗很多时间,那么就会大大降低查找效率。 2. 散列地址分布均匀 尽量让散列地址均匀分布在存储空间中,保证存储空间的有效利用,并减少为处理冲突而耗费的时间。 ## 直接定址法 关键字的某个线性函数值为散列地址 $$f(x) = a \times x$$ 这种散列函数简单,均匀,不容易产生冲突, 但是需要预先知道关键字的分布情况,适合查找表较小且连续的情况。 ## 数字分析法 如果关键字是位数较多的数字, 例如手机号码 "130xxxx1234" , 前3位是接入号,一般对应不同运营商的子品牌. 中间4位表示用户的归属地,后四位才是用户号 如果要存储某家公司员工登记表,极有可能出现前7位都是相同的,那么可以选择后4位数作为散列地址。 这种抽取的方法使用关键字的一部分来计算散列地址,在散列函数中使用得较多. 适用于处理关键字位数较大的情况.如果事先知道关键字的分布且关键字的若干位分布较均匀,可以使用这种方法。 ## 平方取中法 假设关键字是1234, 它的平法是1522756, 再抽取中间的3位数227作为散列地址。 这种方式适合不知道关键字的分布情况,而位数又不是很大的情况. ## 折叠法 将关键字从左到右分隔成位数相等的几部分,将这几部分累加求和,并按散列表表长,取后几位作为散列地址。 例如关键字为: 9876543210, 散列表表长为3位, 将这个数字分为4组, 然后求和: 987 + 654 + 321 + 0 = 1962 在取后3为得到962 适用于: 事先不知道关键字的分布,适合关键字位数较多的情况. ## 除留余数法 这个方法为常用的构造散列函数的方法。 对于散列表长为m的散列函数公式为: `f(key) = key mod p` mode表示取模(求余数), 也可以和前几种方法联合使用,例如:折叠,平方区中后再取模 这种方法的关键点在于p, 如果p选择得不好,就很容易产生同义词。 例如对于有12个记录的关键字构造散列表,使用 f(key) = key mod 12 12 15 16 21 22 25 29 38 47 56 67 78 | 下标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | | 关键字 | 12 | 25 | 38 | 15 | 16 | 29 | 78 | 67 | 56 | 21 | 22 | 47 | 这也由冲突的可能性,因为 $12 = 2 \times 6 = 3 \times 4$ 如果关键字中有像18(3x6) 30(5x6) 42(7x6), 他们的余数都为6,也就和78对应的下标位置冲突了 对于下标中的数,如果都让p为12的话,就可能出现所有关键字都得到了0这个地址数 | 下标 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | | 关键字 | 12 | 24 | 36 | 48 | 60 | 72 | 84 | 96 | 108 | 120 | 132 | 144 | 如果选p=11, 得到的散列表如下: | 下标 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 0 | 0 | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | | 关键字 | 12 | 24 | 36 | 48 | 60 | 72 | 84 | 96 | 108 | 120 | 132 | 144 | 根据经验值,p通常小于等于表长(最红啊接近于p)的最小质数。 ## 随机数法 [[由于随机函数生成的数是伪随机数,所以可以取关键字的随机函数值作为它的散列地址。]] $f(key) = random(key)$ 这里的random是随机函数, 当关键字的长度不能确定时,可以采用这个方法构造散列函数. 选择随机函数可以考虑到下列因数: 1. 计算散列地址所需要的时间 2. 关键字的长度 2. 散列表的大小 2. 关键字的分布情况 2. 记录查找的频率 # 处理散列冲突 设计得再好的散列函数也不可能完全避免冲突.当发现$key_1 != key_2$ ,但是却有$f(key_1) = f(key_2)$, 这就称为冲突. ## 开放定址法 一旦发生了冲突,就寻找下一个空的散列地址,只要散列表足够大,总能找到空的散列地址。 $$f_i(key) = ( f(key) + d_i ) MOD m (d_i=1,2,3,...,m-1)$$ 例如关键字集合为{12,67,56,16,25,37,22,29,15,47,48,34}, 表长为12, 散列函数为$f(key) = key MOD 12$ 当计算前5个时,都没有冲突,直接存入,如下表 | 下标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | | 关键字 | 12 | 25 | | | 16 | | | 67 | 56 | | | | 当计算第6个(37)时, f(37) = 1, 此时与25所在的位置冲突,于是使用上面的公式 f(37) = ( f(37) + 1 ) mod 12 = 2 于是就将37存入下标为2的位置。如下表. | 下标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | | 关键字 | 12 | 25 | 37 | | 16 | | | 67 | 56 | | | | 接下来, 22, 29, 15, 47都没有冲突 ,正常存入 | 下标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | | 关键字 | 12 | 25 | 37 | 15 | 16 | 29 | | 67 | 56 | | 10 | 47 | key=48, f(48) = 0, 与12所在的位置0冲突了,根据上述解决冲突的公式计算: f(48) = (f(48) + 1) mod 12 = 1, 又与25所在的位置1有冲突,继续使用开放定址 f(48) = (f(48) + 2) mod 12 = 2, 还是冲突... 一直到 f(48) = (f(48) + 6) mod 12 = 6, 才有空位 | 下标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | | 关键字 | 12 | 25 | 37 | 15 | 16 | 29 | 48 | 67 | 56 | | 22 | 47 | 把这种解决冲突的开放定址法称为线性探测法. 从这个例子可以看出,在解决冲突时,会遇到如48和37这种本来不是同义词,却要争夺同一个地址的情况,称为堆积。 由于堆积的出现,需要不断处理冲突,导致存入和查找的效率大大降低。 考虑到这样一种情况: 对于最好一个key=34, f(key) = 10, 与22所在的位置冲突,可以22后面已经没有位置了,反而它前面后一个空位置, 尽管可以不断求余数得到最后结果,但效率太差了. 因此可以改进 $d_i = 1^2, -1^2, 2^2, -2^2, ..., q^2, -q^2, q \le m/2$ 还有另外一种方法,处理冲突时,位移量di 采用随机函数计算得到。这种方法称之为随机探测法。 这里的随机函数生成的其实是伪随机数,只要使用相同的seed, 则得到的随机数序列是确定的. $$f(key) = (f(key) + d_i) MOD m (m是一个 随机数列)$$ ## 再散列函数法 准备多个散列函数。 $$f_i(key) = RH_i(key) (i=1,2,..,k)$$ 这里的RHi就是不同的散列函数. 每当发生散列冲突时,就换一个散列函数计算. ## 链地址法 换一个思路,为什么冲突了就要换地址呢,直接在原地想办法不是也可以吗? 将所有关键字为同义词的记录存在一个单链表中,称这种表为同义词子表. 在散列表中只存储所有同义词子表的头指针. 例如对于关键字集合 {12,67,56,16,25,37,22,29,15,47,48,34}, 使用除留余数法,除数为12, 可得到如下图的结构: ![链地址法示例][hash_link_addr] 链地址法对于可能造成很多冲突的散列函数来说,解决了堆积的问题,但是也带来了查找是需要遍历同义词子表的性能损耗。 如果同义词子表比较长,那么也说明了散列函数不够好. # kernel中的hash表hlist ## hlist的数据结构 ![内核hash表][hlist] 这是采用链地址法处理散列冲突的哈希表,左边是一个hlist_head类型的数组,右边是一个双向链表,每个节点的类型为hlist_node hlist_head和hlist_node的定义如下: ``` struct hlist_head { struct hlist_node *first; // 存放同义词子表的第一个节点的地址 }; struct hlist_node { struct hlist_node *next; // 存放下一个节点的地址 struct hlist_node **pprev; // 存放上一个节点的next成员的地址 }; ``` hlist的数据结构为什么要这样设计呢? 1. 哈希表的目的是快速查找,所以hash桶的数量通常比较大,否则冲突的概率会很大. 2. 采用链地址法是为了解决散列冲突,设计的hash函数应保证冲突越少越好,所以同义词子表的长度应该越小越好. 为了做到既能维护一张大表,又能节省内存,所以头节点和普通节点使用了不同的数据结构。 由于hash桶的数量比较大,所以头节点中只有包含一个指针. 而由于同义词子表的长度不会太大,所以双向链表带来的内存开销占的权重不大. hlist_node为何要让pprev指向上一个节点的next成员的地址呢? 采用传统的next, prev指针的链表如下图 ![传统双向链表][list] 对第一个节点和后面的节点的处理会不一致,也会影响效率. 例如删除非第一个节点的代码如下: ``` void list_del(struct list_head* entry) { struct list_head *prev, *next; prev = entry->prev; next = entry->next; next->prev = prev; prev->next = next; } ``` 由于头节点和其他节点才有不同的数据类型,所以第1个节点的prev不能指向头节点,也就无法构成一个完整的双向链表. 如果要删除的节点是第1个节点,那么上述代码就无法工作,需要使用另外的接口才能完成. hlist巧妙地将pprev指向前一个节点的next指针的地址。对于节点提供了一致的操作. ``` void hlist_del(hlist_node* entry) { struct hlist_node *next, **pprev; next = entry->next; pprev = entry->pprev; *pprev = next; if (next) { next->pprev = entry->pprev; } } ``` hlist删除非第一个节点: ![hlist删除非第一个节点][hlist_del_second_node] ![hlist删除第一个节点][hlist_del_first_node] pprev指向了头节点中first的地址,所以通过*pprev可以改变first的指向. ## hlist operations ### 初始化节点 定义hlist头节点: `#define HLIST_HEAD(name) struct hlist_head name = { .first = NULL }` 初始化hlist普通节点: ``` static inline void INIT_HLIST_NODE(struct hlist_node *h) { h->next = NULL; h->pprev = NULL; } ``` 判断节点是否在hash链表中 ``` static inline int hlist_unhashed(const struct hlist_node *h) { return !h->pprev; } ``` 判断链表为空 ``` static inline int hlist_empty(const struct hlist_head *h) { return !h->first; } ``` ### 插入节点 插入到head后面(第一个节点) ``` static inline void hlist_add_head(struct hlist_node *n, struct hlist_head *h) { struct hlist_node *first = h->first; n->next = first; if (first) { first->pprev = &n->next; } h->first = n; n->pprev = &h->first; } ``` 在节点next之前插入 ``` static inline void hlist_add_before(struct hlist_node *n, struct hlist_node *next) { n->pprev = next->pprev; n->next = next; next->pprev = &n->next; *(n->pprev) = n; } ``` 在节点prev之后插入 ``` static inline void hlist_add_behind(struct hlist_node *n, struct hlist_node *prev) { n->next = prev->next; prev->next = n; n->pprev = &prev->next; if (n->next) { n->next->pprev = &n->next; } } ``` ### 删除节点 从链表中删除节点 ``` static inline void __hlist_del(struct hlist_node *n) { struct hlist_node **prev = n->pprev; struct hlist_node *next = n->next; *pprev = next; if (next) { next->pprev = pprev; } } ``` ``` static inline void hlist_del(struct hlist_node *n) { __hlist_del(n); n->next = LIST_POSITION1; n->pprev = LIST_POSITION2; } ``` 删除节点,并重新初始化节点 ``` static inline void hlist_del_init(struct hlist_node *n) { if (!hlist_unhashed(n)) { __hlist_del(n); INIT_HLIST_NODE(n); } } ``` ### 遍历hlist ``` #define hlist_for_each(pos, head) \ for (pos = (head)->first; pos; pos = pos->next) #define hlist_for_each_safe(pos, n, head) \ for (pos = (head)->first; pos && ({n = pos->next; 1;}); pos = n ) ``` hlist_for_each_safe是hlist_for_each的安全版本。 如果在hlist_for_each的循环体内执行hlist_del操作,会导致pos的next或者prev指针指向undefined state, 出现kernel panic. hlist_for_each_safe使用了中间变量n来保存pos->next, 在本次循环结束后在将n赋值给pos ({n = pos->next; 1;}) 的值始终为true, 不影响原来的循环条件. ``` #define hlist_entry(ptr, type, member) container_of(ptr,type,member) #define hlist_entry_safe(ptr, type, member) \ ({ typeof(ptr) ____ptr = (ptr); \ ____ptr ? hlist_entry(____ptr, type, member) : NULL; \ }) ``` hlist_entry_safe是hlist_entry的安全版本,保证了ptr不会为空指针.