|
1 | 1 | # 哈希表
|
2 | 2 | 不知道你有没有好奇过为什么 Python 里的 dict 和 set 查找速度这么快呢,用了什么黑魔法吗?
|
3 |
| -经常听别人说哈希表,究竟什么是哈希表呢?这一章我们来介绍哈希表,后续章节我们会看到 Python 中的字典和集合是如何实现的。 |
| 3 | +经常听别人说哈希表(也叫做散列表),究竟什么是哈希表呢?这一章我们来介绍哈希表,后续章节我们会看到 Python 中的字典和集合是如何实现的。 |
4 | 4 |
|
5 |
| -# 如何在 O(1) 时间内查找 |
| 5 | +# 哈希表的工作过程 |
6 | 6 | 前面我们已经讲到了数组和链表,数组能通过下标 O(1) 访问,但是删除一个中间元素却要移动其他元素,时间 O(n)。
|
7 | 7 | 循环双端链表倒是可以在知道一个节点的情况下迅速删除它,但是吧查找又成了 O(n)。
|
| 8 | + |
8 | 9 | 难道就没有一种方法可以快速定位和删除元素吗?似乎想要快速找到一个元素除了知道下标之外别无他法,于是乎聪明的计算机科学家又想到了一种方法。
|
9 |
| -能不能给每个元素一种『逻辑下标』,然后直接找到它呢,哈希表就是这种实现。它通过一个函数来计算一个元素应该放在数组哪个位置,当然对于一个 |
10 |
| -特定的元素,哈希函数每次计算的下标必须要一样才可以: |
| 10 | +能不能给每个元素一种『逻辑下标』,然后直接找到它呢,哈希表就是这种实现。它通过一个哈希函数来计算一个元素应该放在数组哪个位置,当然对于一个 |
| 11 | +特定的元素,哈希函数每次计算的下标必须要一样才可以,而且范围不能超过给定的数组长度。 |
| 12 | + |
| 13 | +我们还是以书中的例子说明,假如我们有一个数组 T,包含 M=13 个元素,我们可以定义一个简单的哈希函数 h |
| 14 | + |
| 15 | +``` |
| 16 | +h(key) = key % M |
| 17 | +``` |
| 18 | + |
| 19 | +这里取模运算使得 h(key) 的结果不会超过数组的长度下标。我们来分别插入以下元素: |
| 20 | + |
| 21 | +765, 431, 96, 142, 579, 226, 903, 388 |
| 22 | + |
| 23 | +先来计算下它们应用哈希函数后的结果: |
| 24 | + |
| 25 | +``` |
| 26 | +M = 13 |
| 27 | +h(765) = 765 % M = 11 |
| 28 | +h(431) = 431 % M = 2 |
| 29 | +h(96) = 96 % M = 5 |
| 30 | +h(142) = 142 % M = 12 |
| 31 | +h(579) = 579 % M = 7 |
| 32 | +h(226) = 226 % M = 5 |
| 33 | +h(903) = 903 % M = 6 |
| 34 | +h(388) = 388 % M = 11 |
| 35 | +``` |
| 36 | +下边我画个图演示整个插入过程: |
| 37 | + |
| 38 | + |
| 39 | + |
| 40 | + |
| 41 | +# 哈希冲突 (collision) |
| 42 | +这里到插入 226 这个元素的时候,不幸地发现 h(226) = h(96) = 5,不同的 key 通过我们的哈希函数计算后得到的下标一样, |
| 43 | +这种情况成为哈希冲突。怎么办呢?聪明的计算机科学家又想到了办法,其实一种直观的想法是如果冲突了我能不能让数组中 |
| 44 | +对应的槽变成一个链式结构呢?这就是其中一种解决方法,叫做 **链接法(chaining)**。如果我们用链接法来处理冲突,后边的插入是这样的: |
| 45 | + |
| 46 | + |
| 47 | + |
| 48 | +这样就用链表解决了冲突问题,但是如果哈希函数选不好的话,可能就导致冲突太多一个链变得太长,这样查找就不再是 O(1) 的了。 |
| 49 | +还有一种叫做开放寻址法(open addressing),它的基本思想是当一个槽被占用的时候,采用一种方式来寻找下一个可用的槽。 |
| 50 | +(这里槽指的是数组中的一个位置),根据找下一个槽的方式不同,分为: |
11 | 51 |
|
| 52 | +- 线性探查(linear probing): 当一个槽被占用,找下一个可用的槽。 $ h(k, i) = (h^\prime(k) + i) \% m, i = 0,1,...,m-1 $ |
| 53 | +- 二次探查(quadratic probing): 当一个槽被占用,以二次方作为偏移量。 $ h(k, i) = (h^\prime(k) + c_1 + c_2i^2) \% m , i=0,1,...,m-1 $ |
| 54 | +- 双重散列(double hashing): 重新计算 hash 结果。 $ h(k,i) = (h_1(k) + ih_2(k)) \% m $ |
| 55 | + |
| 56 | +cpython 使用的是二次探查,这里我们也使用二次探查, 我们选一个简单的二次探查函数 $ h(k, i) = (home + i^2) \% m $,它的意思是如果 |
| 57 | +遇到了冲突,我们就在原始计算的位置不断加上 i 的平方。我写了段代码来模拟整个计算下标的过程: |
| 58 | + |
| 59 | +```py |
| 60 | +inserted_index_set = set() |
| 61 | +M = 13 |
| 62 | + |
| 63 | +def h(key, M=13): |
| 64 | + return key % M |
| 65 | + |
| 66 | +to_insert = [765, 431, 96, 142, 579, 226, 903, 388] |
| 67 | +for number in to_insert: |
| 68 | + index = h(number) |
| 69 | + first_index = index |
| 70 | + i = 1 |
| 71 | + while index in inserted_index_set: # 如果计算发现已经占用,继续计算得到下一个可用槽的位置 |
| 72 | + print('\th({number}) = {number} % M = {index} collision'.format(number=number, index=index)) |
| 73 | + index = (first_index + i*i) % M |
| 74 | + i += 1 |
| 75 | + else: |
| 76 | + print('h({number}) = {number} % M = {index}'.format(number=number, index=index)) |
| 77 | + inserted_index_set.add(index) |
12 | 78 | ```
|
13 |
| -hash(element) = index |
| 79 | +这段代码输出的结果如下: |
| 80 | + |
| 81 | +``` |
| 82 | +h(765) = 765 % M = 11 |
| 83 | +h(431) = 431 % M = 2 |
| 84 | +h(96) = 96 % M = 5 |
| 85 | +h(142) = 142 % M = 12 |
| 86 | +h(579) = 579 % M = 7 |
| 87 | + h(226) = 226 % M = 5 collision |
| 88 | +h(226) = 226 % M = 6 |
| 89 | + h(903) = 903 % M = 6 collision |
| 90 | + h(903) = 903 % M = 7 collision |
| 91 | +h(903) = 903 % M = 10 |
| 92 | + h(388) = 388 % M = 11 collision |
| 93 | + h(388) = 388 % M = 12 collision |
| 94 | + h(388) = 388 % M = 2 collision |
| 95 | + h(388) = 388 % M = 7 collision |
| 96 | +h(388) = 388 % M = 1 |
14 | 97 | ```
|
15 | 98 |
|
| 99 | +遇到冲突之后会重新计算,每个待插入元素最终的下标就是: |
| 100 | + |
| 101 | + |
| 102 | + |
| 103 | + |
| 104 | + |
| 105 | + |
| 106 | +# 哈希函数 |
| 107 | +到这里你应该明白哈希表插入的工作原理了,不过有个重要的问题之前没提到,就是 hash 函数怎么选? |
| 108 | +当然是散列得到的冲突越来越小就好啦,也就是说每个 key 都能尽量被等可能地散列到 m 个槽中的任何一个,并且与其他 key 被散列到哪个槽位无关。 |
| 109 | +如果你感兴趣,可以阅读后边提到的一些参考资料。 |
| 110 | + |
| 111 | + |
| 112 | +# 装载因子(load factor) |
| 113 | +如果继续往我们的哈希表里塞东西会发生什么?空间不够用。这里我们定义一个负载因子的概念(load factor),其实很简单,就是已经使用的槽数比哈希表大小。 |
| 114 | +比如我们上边的例子插入了 8 个元素,哈希表总大小是 13, 它的 load factor 就是 $ 8/13 \approx 0.62 $。当我们继续往哈希表插入数据的时候,很快就不够用了。 |
| 115 | +通常当负载因子开始超过 0.8 的时候,就要新开辟空间了并且重新进行散列了。 |
| 116 | + |
| 117 | + |
| 118 | +# 重哈希(Rehashing) |
| 119 | +当负载因子超过 0.8 的时候,需要进行 rehashing 操作了。步骤就是重新开辟一块新的空间,开多大呢?感兴趣的话可以看下 cpython 的 dictobject.c 文件然后搜索 |
| 120 | +GROWTH_RATE 这个关键字,你会发现不同版本的 cpython 使用了不同的策略。python3.3 的策略是扩大为已经使用的槽数目的两倍。开辟了新空间以后,会把原来哈希表里 |
| 121 | +不为空槽的数据重新插入到新的哈希表里,插入方式和之前一样。这就是 rehashing 操作。 |
| 122 | + |
| 123 | +# HashTalbe ADT |
| 124 | +这里我们来实现一个简化版的哈希表 ADT,主要是为了让你更好地了解它的工作原理,有了它,后边实现起 dict 和 set 来就小菜一碟了。 |
| 125 | + |
| 126 | +# 延伸阅读 |
| 127 | +- 《Data Structures and Algorithms in Python》11 章 Hash Tables |
| 128 | +- 《算法导论》第三版 11 章散列表 |
| 129 | +- 介绍 c 解释器如何实现的 python dict对象:[Python dictionary implementation](http://www.laurentluce.com/posts/python-dictionary-implementation/) |
0 commit comments