python

关注公众号 jb51net

关闭
首页 > 脚本专栏 > python > Python集合

浅析Python是如何实现集合的

作者:古明地觉

之前我们介绍过字典的实现原理,它底层是基于哈希表实现的,而集合也是如此。本次我们来聊一下 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;

解释一下这些字段的含义:

有了字典的经验,再看集合会简单很多。然后是 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集合内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

您可能感兴趣的文章:
阅读全文