浅析Python是如何实现集合的
作者:古明地觉
楔子
有几天没有更新 Python 文章了,本次我们来聊一下 Python 的集合是怎么实现的?之前我们介绍过字典的实现原理,它底层是基于哈希表实现的,而集合也是如此。
并且字典和集合实现的哈希表是一样的,在计算哈希值、解决索引冲突等方面,两者没有任何区别。唯一的区别就是存储的 entry 不同,字典的 entry 里面包含了 key、value 和 key 的哈希值,而集合的 entry 里面只包含 key 和 key 的哈希值。
事实上,集合就类似于没有value的字典。
集合的使用场景
那么集合都有哪些用处呢?
1)去重
chars = ["a", "b", "a", "c", "c"] print( list(set(chars)) ) # ['b', 'a', 'c']
再比如你需要监听一个队列,处理接收到的消息,但每一条消息都有一个编号,要保证具有相同编号的消息只能被处理一次,要怎么做呢?
显然集合此时就派上用场了,我们可以创建一个集合,每来一条消息,就检测它的编号是否在集合中。如果存在,则说明消息已经被处理过了,忽略掉;如果不存在,说明消息还没有被处理,那么就将它的编号添加到集合中,然后处理消息。
这里暂时不考虑消费失败等情况,我们假设每条消息都是能处理成功的。
2)判断某个序列是否包含指定的多个元素
data = ["S", "A", "T", "O", "R", "I"] # 现在要判断 data 是否包含 "T"、"R" 和 "I" # 如果使用列表的话 print( "T" in data and "R" in data and "I" in data ) # True # 显然这是比较麻烦的,于是我们可以使用集合 print( set(data) >= {"T", "R", "I"} ) # True
同理,基于此方式,我们也可以检测一个字典是否包含指定的多个 key。
data = { "name": "satori", "age": 17, "gender": "female" } # 判断字典是否包含 name、age、gender 三个 key print( data.keys() >= {"name", "age", "gender"} ) # True # 字典的 keys 方法会返回一个 dict_keys 对象 # 该对象具备集合的性质,可以直接和集合进行运算
显然对于这种需求,有了集合就方便多了。
集合的 API
然后我们来罗列一下集合支持的 API,在使用集合的时候要做到心中有数。
# 如果要创建一个空集合,那么要使用 set() # 写成 {} 的话,解释器会认为这是一个空字典 s = {1, 2, 3} # 添加元素,时间复杂度是 O(1) s.add(4) print(s) # {1, 2, 3, 4} # 删除指定的元素,如果元素不存在则报出 KeyError # 时间复杂度为 O(1) s.remove(2) print(s) # {1, 3, 4} # 删除指定的元素,如果元素不存在则什么也不做 # 时间复杂度为 O(1) s.discard(666) print(s) # {1, 3, 4} # 随机弹出一个元素并返回 # 时间复杂度为 O(1) print(s.pop()) # 1 print(s) # {3, 4} # 清空一个集合 s.clear() print(s) # set() # 还有一些 API,但我们更推荐使用操作符的方式 # 两个集合取交集 print({1, 2} & {2, 3}) # {2} # 两个集合取并集 print({1, 2} | {2, 3}) # {1, 2, 3} # 两个集合取差集 # s1 - s2,返回在 s1、但不在 s2 当中的元素 print({1, 2, 3} - {2, 3, 4}) # {1} # 两个集合取对称差集 # s1 ^ s2,返回既不在 s1、也不在 s2 当中的元素 print({1, 2, 3} ^ {2, 3, 4}) # {1, 4} # 判断两个集合是否相等,也就是内部的元素是否完全一致 # 顺序无所谓,只比较元素是否全部相同 print({1, 2, 3} == {3, 2, 1}) # True print({1, 2, 3} == {1, 2, 4}) # False # 判断一个集合是否包含另一个集合的所有元素 # 假设有两个集合 s1 和 s2: # 如果 s1 的元素都在 s2 中,那么 s2 >= s1; # 如果 s2 的元素都在 s1 中,那么 s1 >= s2; # 如果 s1 和元素和 s2 全部相同,那么 s1 == s2; print({1, 2, 3} > {1, 2}) # True print({1, 2, 3} >= {1, 2, 3}) # True
以上就是集合支持的一些 API,还是很简单的,下面来重点看一下集合的底层结构。
集合的底层结构
集合的数据结构定义在 setobject.h 中,那么它长什么样子呢?
typedef struct { PyObject_HEAD Py_ssize_t fill; Py_ssize_t used; Py_ssize_t mask; setentry *table; Py_hash_t hash; Py_ssize_t finger; setentry smalltable[PySet_MINSIZE]; PyObject *weakreflist; } PySetObject;
解释一下这些字段的含义:
- PyObject_HEAD:定长对象的头部信息,但集合显然是一个变长对象。所以和字典一样,肯定有其它字段充当 ob_size;
- fill:等于 active 态的 entry 数量加上 dummy 态的 entry 数量。和字典类似,一个 entry 就是哈希表里面的一个元素,类型为 setentry,因此在集合里面一个 entry 就是一个 setentry 结构体实例;
- used:等于 active 态的 entry 数量,显然这个 used 充当了 ob_size,也就是集合的元素个数;
- mask:在看字典源码的时候,我们也见到了 mask,它用于和哈希值进行按位与、计算索引,并且这个 mask 等于哈希表的容量减 1,为什么呢?假设哈希值等于 v,哈希表容量是 n,那么通过 v 对 n 取模即可得到一个位于 0 到 n-1 之间的数。但是取模运算的效率不高,而 v&(n-1) 的作用等价于 v%n,并且速度更快,所以 mask 的值要等于哈希表的容量减 1。但是注意,只有在 n 为 2 的幂次方的时候, v&(n-1) 和 v%n 才是完全等价的,所以哈希表的容量要求是 2 的幂次方,就是为了将取模运算优化成按位与运算。
- table:指向 setentry 数组的指针,而这个 setentry 数组可以是下面的 smalltable,也可以是单独申请的一块内存;
- hash:集合的哈希值,只适用于不可变集合;
- finger:用于 pop 一个元素;
- smalltable:一个 setentry 类型的数组,集合的元素就存在里面。但我们知道,变长对象的内部不会存储具体元素,而是会存储一个指针,该指针指向的内存区域才是用来存储具体元素的。这样当扩容的时候,只需要让指针指向新的内存区域即可,从而方便维护。没错,对于集合而言,只有在容量不超过 8 的时候,元素才会存在里面;而一旦超过了8,那么会使用 malloc 单独申请内存;
- weakreflist:弱引用列表,不做深入讨论;
有了字典的经验,再看集合会简单很多。然后是 setentry,用于承载集合内的元素,那么它的结构长什么样呢?相信你能够猜到。
typedef struct { PyObject *key; Py_hash_t hash; } setentry;
相比字典少了一个 value,这是显而易见的。因此集合的结构很清晰了,假设有一个集合 {3.14, "abc", 666},那么它的结构如下:
由于集合里面只有三个元素,所以它们都会存在 smalltable 数组里面,我们通过 ctypes 来证明这一点。
from ctypes import * class PyObject(Structure): _fields_ = [ ("ob_refcnt", c_ssize_t), ("ob_type", c_void_p), ] class SetEntry(Structure): _fields_ = [ ("key", POINTER(PyObject)), ("hash", c_longlong) ] class PySetObject(PyObject): _fields_ = [ ("fill", c_ssize_t), ("used", c_ssize_t), ("mask", c_ssize_t), ("table", POINTER(SetEntry)), ("hash", c_long), ("finger", c_ssize_t), ("smalltable", (SetEntry * 8)), ("weakreflist", POINTER(PyObject)), ] s = {3.14, "abc", 666} # 先来打印一下哈希值 print('hash(3.14) =', hash(3.14)) print('hash("abc") =', hash("abc")) print('hash(666) =', hash(666)) """ hash(3.14) = 322818021289917443 hash("abc") = 8036038346376407734 hash(666) = 666 """ # 获取PySetObject结构体实例 py_set_obj = PySetObject.from_address(id(s)) # 遍历smalltable,打印索引、和哈希值 for index, entry in enumerate(py_set_obj.smalltable): print(index, entry.hash) """ 0 0 1 0 2 666 3 322818021289917443 4 0 5 0 6 8036038346376407734 7 0 """
根据输出的哈希值我们可以断定,这三个元素确实存在了 smalltable 数组里面,并且 666 存在了数组索引为 2 的位置、3.14 存在了数组索引为 3 的位置、"abc" 存在了数组索引为 6 的位置。
当然,由于哈希值是随机的,所以每次执行之后打印的结果都可能不一样,但是整数除外,它的哈希值就是它本身。既然哈希值不一样,那么每次映射出的索引也可能不同,但总之这三个元素是存在 smalltable 数组里面的。
然后我们再考察一下其它的字段:
s = {3.14, "abc", 666} py_set_obj = PySetObject.from_address(id(s)) # 集合里面有 3 个元素,所以 fill 和 used 都是 3 print(py_set_obj.fill) # 3 print(py_set_obj.used) # 3 # 将集合元素全部删除 # 这里不能用 s.clear(),原因一会儿说 for _ in range(len(s)): s.pop() # 我们知道哈希表在删除元素的时候是伪删除 # 所以 fill 不变,但是 used 每次会减 1 print(py_set_obj.fill) # 3 print(py_set_obj.used) # 0
fill 成员维护的是 active 态的 entry 数量加上 dummy 态的 entry 数量,所以删除元素时它的大小是不变的;但 used 成员的值每次会减 1,因为它维护的是 active 态的 entry 的数量。所以只要不涉及元素的删除,那么这两者的大小是相等的。
然后我们上面说不能用 s.clear(),因为该方法表示清空集合,此时会重置为初始状态,然后 fill 和 used 都会是 0,我们就观察不到想要的现象了。
删除集合所有元素之后,我们再往里面添加元素,看看是什么效果:
s = {3.14, "abc", 666} py_set_obj = PySetObject.from_address(id(s)) for _ in range(len(s)): s.pop() # 添加一个元素 s.add(0) print(py_set_obj.fill) # 3 print(py_set_obj.used) # 1
多次执行的话,会发现打印的结果可能是 3、1,也有可能是 4、1。至于原因,有了字典的经验,相信你肯定能猜到。
首先添加元素之后,used 肯定为 1。至于 fill,如果添加元素的时候,正好撞上了一个 dummy 态的 entry,那么将其替换掉,此时 fill 不变,仍然是 3;如果没有撞上 dummy 态的 entry,而是添加在了新的位置,那么 fill 就是 4。
for i in range(1, 10): s.add(i) print(py_set_obj.fill) # 10 print(py_set_obj.used) # 10 s.pop() print(py_set_obj.fill) # 10 print(py_set_obj.used) # 9
在之前代码的基础上,继续添加 9 个元素,然后 used 变成了10,这很好理解,因为此时集合有 10 个元素。但 fill 也是10,这是为什么?很简单,因为哈希表扩容了,扩容时会删除 dummy 态的 entry,所以 fill 和 used 是相等的。同理,如果再继续 pop,那么 fill 和 used 就又变得不相等了。
集合的创建
集合的结构我们已经清楚了,再来看看它的初始化过程。我们调用类 set,传入一个可迭代对象,便可创建一个集合,这个过程是怎样的呢?
PyObject * PySet_New(PyObject *iterable) { //底层调用了make_new_set return make_new_set(&PySet_Type, iterable); }
底层提供了PySet_New函数用于创建一个集合,接收一个可迭代对象,然后调用 make_new_set 进行创建。
static PyObject * make_new_set(PyTypeObject *type, PyObject *iterable) { // PySetObject *指针 PySetObject *so; // 申请集合所需要的内存 so = (PySetObject *)type->tp_alloc(type, 0); //申请失败,返回 NULL if (so == NULL) return NULL; // fill 和 used 初始都为 0 so->fill = 0; so->used = 0; // PySet_MINSIZE 默认为 8 // 而 mask 等于哈希表容量减 1,所以初始值是 7 so->mask = PySet_MINSIZE - 1; // 初始化的时候,setentry 数组显然是 smalltable // 所以让 table 指向 smalltable 数组 so->table = so->smalltable; // 初始 hash 值为 -1 so->hash = -1; // finger为0 so->finger = 0; // 弱引用列表为NULL so->weakreflist = NULL; //以上只是初始化,如果可迭代对象不为 NULL //那么把元素依次设置到集合中 if (iterable != NULL) { //该过程是通过 set_update_internal 函数实现的 //该函数内部会遍历 iterable,将迭代出的元素依次添加到集合里面 if (set_update_internal(so, iterable)) { Py_DECREF(so); return NULL; } } //返回初始化完成的set return (PyObject *)so; }
整个过程没什么难度,非常好理解。
小结
以上就是集合相关的内容,它的效率也是非常高的,能够以O(1)的复杂度去查找某个元素。最关键的是,它用起来也特别的方便。
此外 Python 里面还有一个 frozenset,也就是不可变的集合。但 frozenset 对象和 set 对象都是同一个结构体,只有 PySetObject,没有 PyFrozenSetObject。
我们在看 PySetObject的时候,发现该对象里面也有个 hash 成员,如果是不可变集合,那么 hash 值是不为 -1 的,因为它不可以添加、删除元素,是不可变对象。
到此这篇关于浅析Python是如何实现集合的的文章就介绍到这了,更多相关Python集合内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!