|
95 | 95 | <li class="toctree-l2"><a href="#_1">哈希表</a></li>
|
96 | 96 |
|
97 | 97 |
|
98 |
| - <li class="toctree-l2"><a href="#o1">如何在 O(1) 时间内查找</a></li> |
| 98 | + <li class="toctree-l2"><a href="#_2">哈希表的工作过程</a></li> |
| 99 | + |
| 100 | + |
| 101 | + <li class="toctree-l2"><a href="#collision">哈希冲突 (collision)</a></li> |
| 102 | + |
| 103 | + |
| 104 | + <li class="toctree-l2"><a href="#_3">哈希函数</a></li> |
| 105 | + |
| 106 | + |
| 107 | + <li class="toctree-l2"><a href="#load-factor">装载因子(load factor)</a></li> |
| 108 | + |
| 109 | + |
| 110 | + <li class="toctree-l2"><a href="#rehashing">重哈希(Rehashing)</a></li> |
| 111 | + |
| 112 | + |
| 113 | + <li class="toctree-l2"><a href="#hashtalbe-adt">HashTalbe ADT</a></li> |
| 114 | + |
| 115 | + |
| 116 | + <li class="toctree-l2"><a href="#_4">延伸阅读</a></li> |
99 | 117 |
|
100 | 118 |
|
101 | 119 | </ul>
|
|
135 | 153 |
|
136 | 154 | <h1 id="_1">哈希表</h1>
|
137 | 155 | <p>不知道你有没有好奇过为什么 Python 里的 dict 和 set 查找速度这么快呢,用了什么黑魔法吗?
|
138 |
| -经常听别人说哈希表,究竟什么是哈希表呢?这一章我们来介绍哈希表,后续章节我们会看到 Python 中的字典和集合是如何实现的。</p> |
139 |
| -<h1 id="o1">如何在 O(1) 时间内查找</h1> |
| 156 | +经常听别人说哈希表(也叫做散列表),究竟什么是哈希表呢?这一章我们来介绍哈希表,后续章节我们会看到 Python 中的字典和集合是如何实现的。</p> |
| 157 | +<h1 id="_2">哈希表的工作过程</h1> |
140 | 158 | <p>前面我们已经讲到了数组和链表,数组能通过下标 O(1) 访问,但是删除一个中间元素却要移动其他元素,时间 O(n)。
|
141 |
| -循环双端链表倒是可以在知道一个节点的情况下迅速删除它,但是吧查找又成了 O(n)。 |
142 |
| -难道就没有一种方法可以快速定位和删除元素吗?似乎想要快速找到一个元素除了知道下标之外别无他法,于是乎聪明的计算机科学家又想到了一种方法。 |
143 |
| -能不能给每个元素一种『逻辑下标』,然后直接找到它呢,哈希表就是这种实现。它通过一个函数来计算一个元素应该放在数组哪个位置,当然对于一个 |
144 |
| -特定的元素,哈希函数每次计算的下标必须要一样才可以:</p> |
145 |
| -<pre><code>hash(element) = index |
| 159 | +循环双端链表倒是可以在知道一个节点的情况下迅速删除它,但是吧查找又成了 O(n)。</p> |
| 160 | +<p>难道就没有一种方法可以快速定位和删除元素吗?似乎想要快速找到一个元素除了知道下标之外别无他法,于是乎聪明的计算机科学家又想到了一种方法。 |
| 161 | +能不能给每个元素一种『逻辑下标』,然后直接找到它呢,哈希表就是这种实现。它通过一个哈希函数来计算一个元素应该放在数组哪个位置,当然对于一个 |
| 162 | +特定的元素,哈希函数每次计算的下标必须要一样才可以,而且范围不能超过给定的数组长度。</p> |
| 163 | +<p>我们还是以书中的例子说明,假如我们有一个数组 T,包含 M=13 个元素,我们可以定义一个简单的哈希函数 h</p> |
| 164 | +<pre><code>h(key) = key % M |
| 165 | +</code></pre> |
| 166 | + |
| 167 | +<p>这里取模运算使得 h(key) 的结果不会超过数组的长度下标。我们来分别插入以下元素:</p> |
| 168 | +<p>765, 431, 96, 142, 579, 226, 903, 388</p> |
| 169 | +<p>先来计算下它们应用哈希函数后的结果:</p> |
| 170 | +<pre><code>M = 13 |
| 171 | +h(765) = 765 % M = 11 |
| 172 | +h(431) = 431 % M = 2 |
| 173 | +h(96) = 96 % M = 5 |
| 174 | +h(142) = 142 % M = 12 |
| 175 | +h(579) = 579 % M = 7 |
| 176 | +h(226) = 226 % M = 5 |
| 177 | +h(903) = 903 % M = 6 |
| 178 | +h(388) = 388 % M = 11 |
| 179 | +</code></pre> |
| 180 | + |
| 181 | +<p>下边我画个图演示整个插入过程:</p> |
| 182 | +<p><img alt="" src="../insert_hash.png" /></p> |
| 183 | +<h1 id="collision">哈希冲突 (collision)</h1> |
| 184 | +<p>这里到插入 226 这个元素的时候,不幸地发现 h(226) = h(96) = 5,不同的 key 通过我们的哈希函数计算后得到的下标一样, |
| 185 | +这种情况成为哈希冲突。怎么办呢?聪明的计算机科学家又想到了办法,其实一种直观的想法是如果冲突了我能不能让数组中 |
| 186 | +对应的槽变成一个链式结构呢?这就是其中一种解决方法,叫做 <strong>链接法(chaining)</strong>。如果我们用链接法来处理冲突,后边的插入是这样的:</p> |
| 187 | +<p><img alt="" src="../insert_hash_chaining.png" /></p> |
| 188 | +<p>这样就用链表解决了冲突问题,但是如果哈希函数选不好的话,可能就导致冲突太多一个链变得太长,这样查找就不再是 O(1) 的了。 |
| 189 | +还有一种叫做开放寻址法(open addressing),它的基本思想是当一个槽被占用的时候,采用一种方式来寻找下一个可用的槽。 |
| 190 | +(这里槽指的是数组中的一个位置),根据找下一个槽的方式不同,分为:</p> |
| 191 | +<ul> |
| 192 | +<li>线性探查(linear probing): 当一个槽被占用,找下一个可用的槽。 <script type="math/tex"> h(k, i) = (h^\prime(k) + i) \% m, i = 0,1,...,m-1 </script> |
| 193 | +</li> |
| 194 | +<li>二次探查(quadratic probing): 当一个槽被占用,以二次方作为偏移量。 <script type="math/tex"> h(k, i) = (h^\prime(k) + c_1 + c_2i^2) \% m , i=0,1,...,m-1 </script> |
| 195 | +</li> |
| 196 | +<li>双重散列(double hashing): 重新计算 hash 结果。 <script type="math/tex"> h(k,i) = (h_1(k) + ih_2(k)) \% m </script> |
| 197 | +</li> |
| 198 | +</ul> |
| 199 | +<p>cpython 使用的是二次探查,这里我们也使用二次探查, 我们选一个简单的二次探查函数 <script type="math/tex"> h(k, i) = (home + i^2) \% m </script>,它的意思是如果 |
| 200 | +遇到了冲突,我们就在原始计算的位置不断加上 i 的平方。我写了段代码来模拟整个计算下标的过程:</p> |
| 201 | +<pre><code class="py">inserted_index_set = set() |
| 202 | +M = 13 |
| 203 | + |
| 204 | +def h(key, M=13): |
| 205 | + return key % M |
| 206 | + |
| 207 | +to_insert = [765, 431, 96, 142, 579, 226, 903, 388] |
| 208 | +for number in to_insert: |
| 209 | + index = h(number) |
| 210 | + first_index = index |
| 211 | + i = 1 |
| 212 | + while index in inserted_index_set: # 如果计算发现已经占用,继续计算得到下一个可用槽的位置 |
| 213 | + print('\th({number}) = {number} % M = {index} collision'.format(number=number, index=index)) |
| 214 | + index = (first_index + i*i) % M |
| 215 | + i += 1 |
| 216 | + else: |
| 217 | + print('h({number}) = {number} % M = {index}'.format(number=number, index=index)) |
| 218 | + inserted_index_set.add(index) |
146 | 219 | </code></pre>
|
| 220 | + |
| 221 | +<p>这段代码输出的结果如下:</p> |
| 222 | +<pre><code>h(765) = 765 % M = 11 |
| 223 | +h(431) = 431 % M = 2 |
| 224 | +h(96) = 96 % M = 5 |
| 225 | +h(142) = 142 % M = 12 |
| 226 | +h(579) = 579 % M = 7 |
| 227 | + h(226) = 226 % M = 5 collision |
| 228 | +h(226) = 226 % M = 6 |
| 229 | + h(903) = 903 % M = 6 collision |
| 230 | + h(903) = 903 % M = 7 collision |
| 231 | +h(903) = 903 % M = 10 |
| 232 | + h(388) = 388 % M = 11 collision |
| 233 | + h(388) = 388 % M = 12 collision |
| 234 | + h(388) = 388 % M = 2 collision |
| 235 | + h(388) = 388 % M = 7 collision |
| 236 | +h(388) = 388 % M = 1 |
| 237 | +</code></pre> |
| 238 | + |
| 239 | +<p>遇到冲突之后会重新计算,每个待插入元素最终的下标就是:</p> |
| 240 | +<p><img alt="" src="../quadratic_hash.png" /></p> |
| 241 | +<p><img alt="" src="../quadratic_result.png" /></p> |
| 242 | +<h1 id="_3">哈希函数</h1> |
| 243 | +<p>到这里你应该明白哈希表插入的工作原理了,不过有个重要的问题之前没提到,就是 hash 函数怎么选? |
| 244 | +当然是散列得到的冲突越来越小就好啦,也就是说每个 key 都能尽量被等可能地散列到 m 个槽中的任何一个,并且与其他 key 被散列到哪个槽位无关。 |
| 245 | +如果你感兴趣,可以阅读后边提到的一些参考资料。</p> |
| 246 | +<h1 id="load-factor">装载因子(load factor)</h1> |
| 247 | +<p>如果继续往我们的哈希表里塞东西会发生什么?空间不够用。这里我们定义一个负载因子的概念(load factor),其实很简单,就是已经使用的槽数比哈希表大小。 |
| 248 | +比如我们上边的例子插入了 8 个元素,哈希表总大小是 13, 它的 load factor 就是 <script type="math/tex"> 8/13 \approx 0.62 </script>。当我们继续往哈希表插入数据的时候,很快就不够用了。 |
| 249 | +通常当负载因子开始超过 0.8 的时候,就要新开辟空间了并且重新进行散列了。</p> |
| 250 | +<h1 id="rehashing">重哈希(Rehashing)</h1> |
| 251 | +<p>当负载因子超过 0.8 的时候,需要进行 rehashing 操作了。步骤就是重新开辟一块新的空间,开多大呢?感兴趣的话可以看下 cpython 的 dictobject.c 文件然后搜索 |
| 252 | +GROWTH_RATE 这个关键字,你会发现不同版本的 cpython 使用了不同的策略。python3.3 的策略是扩大为已经使用的槽数目的两倍。开辟了新空间以后,会把原来哈希表里 |
| 253 | +不为空槽的数据重新插入到新的哈希表里,插入方式和之前一样。这就是 rehashing 操作。</p> |
| 254 | +<h1 id="hashtalbe-adt">HashTalbe ADT</h1> |
| 255 | +<p>这里我们来实现一个简化版的哈希表 ADT,主要是为了让你更好地了解它的工作原理,有了它,后边实现起 dict 和 set 来就小菜一碟了。</p> |
| 256 | +<h1 id="_4">延伸阅读</h1> |
| 257 | +<ul> |
| 258 | +<li>《Data Structures and Algorithms in Python》11 章 Hash Tables</li> |
| 259 | +<li>《算法导论》第三版 11 章散列表</li> |
| 260 | +<li>介绍 c 解释器如何实现的 python dict对象:<a href="http://www.laurentluce.com/posts/python-dictionary-implementation/">Python dictionary implementation</a></li> |
| 261 | +</ul> |
147 | 262 |
|
148 | 263 | </div>
|
149 | 264 | </div>
|
|
0 commit comments