Skip to content

Commit 3e74fbf

Browse files
committed
hashtable
1 parent c81ebc6 commit 3e74fbf

File tree

6 files changed

+120
-6
lines changed

6 files changed

+120
-6
lines changed

docs/7_哈希表/hashtable.md

Lines changed: 119 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,129 @@
11
# 哈希表
22
不知道你有没有好奇过为什么 Python 里的 dict 和 set 查找速度这么快呢,用了什么黑魔法吗?
3-
经常听别人说哈希表,究竟什么是哈希表呢?这一章我们来介绍哈希表,后续章节我们会看到 Python 中的字典和集合是如何实现的。
3+
经常听别人说哈希表(也叫做散列表),究竟什么是哈希表呢?这一章我们来介绍哈希表,后续章节我们会看到 Python 中的字典和集合是如何实现的。
44

5-
# 如何在 O(1) 时间内查找
5+
# 哈希表的工作过程
66
前面我们已经讲到了数组和链表,数组能通过下标 O(1) 访问,但是删除一个中间元素却要移动其他元素,时间 O(n)。
77
循环双端链表倒是可以在知道一个节点的情况下迅速删除它,但是吧查找又成了 O(n)。
8+
89
难道就没有一种方法可以快速定位和删除元素吗?似乎想要快速找到一个元素除了知道下标之外别无他法,于是乎聪明的计算机科学家又想到了一种方法。
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+
![](./insert_hash.png)
39+
40+
41+
# 哈希冲突 (collision)
42+
这里到插入 226 这个元素的时候,不幸地发现 h(226) = h(96) = 5,不同的 key 通过我们的哈希函数计算后得到的下标一样,
43+
这种情况成为哈希冲突。怎么办呢?聪明的计算机科学家又想到了办法,其实一种直观的想法是如果冲突了我能不能让数组中
44+
对应的槽变成一个链式结构呢?这就是其中一种解决方法,叫做 **链接法(chaining)**。如果我们用链接法来处理冲突,后边的插入是这样的:
45+
46+
![](./insert_hash_chaining.png)
47+
48+
这样就用链表解决了冲突问题,但是如果哈希函数选不好的话,可能就导致冲突太多一个链变得太长,这样查找就不再是 O(1) 的了。
49+
还有一种叫做开放寻址法(open addressing),它的基本思想是当一个槽被占用的时候,采用一种方式来寻找下一个可用的槽。
50+
(这里槽指的是数组中的一个位置),根据找下一个槽的方式不同,分为:
1151

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)
1278
```
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
1497
```
1598

99+
遇到冲突之后会重新计算,每个待插入元素最终的下标就是:
100+
101+
![](quadratic_hash.png)
102+
103+
![](quadratic_result.png)
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/)

docs/7_哈希表/insert_hash.png

163 KB
Loading
222 KB
Loading

docs/7_哈希表/quadratic_hash.png

26.4 KB
Loading

docs/7_哈希表/quadratic_result.png

21 KB
Loading

docs/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ Python 抽象程度比较高, 我们能用更少的代码来实现功能,同
8484
[《Data Structures and Algorithms in Python》]( https://book.douban.com/subject/10607365/): 适合对 Python
8585
和算法比较熟悉的同学,或者是有其他语言编程经验的同学。英文版,缺点是书中错误真的很多,代码有些无法运行
8686

87-
[《算法导论》]( https://book.douban.com/subject/20432061/): 喜欢数学证明和板砖书的同学可以参考,有很多高级主题。使用伪代码
87+
[《算法导论》]( https://book.douban.com/subject/20432061/): 喜欢数学证明和板砖书的同学可以参考,有很多高级主题。使用伪代码表示,对新手来说不够通俗。目前最新是第三版
8888

8989

9090
## 讲课形式

0 commit comments

Comments
 (0)