在 Python 中,in 运算符几乎每天都会用到,但它的实际开销并不固定。同一行 item in container,如果 container 是列表,解释器会执行线性扫描;如果 container 是集合,则走哈希查找。这种差异源于两者底层数据结构完全不同,进而影响程序在大数据量下的表现。

底层数据结构决定成员检查方式
列表在 CPython 中由动态数组实现,元素按插入顺序连续存储。数组结构给索引访问、切片操作带来很大优势,例如 items[3] 可以直接通过偏移量定位元素。但成员检查没有这样的捷径,因为目标值可能在列表的任意位置,解释器无法依据值直接计算位置,只能从第一个元素开始逐一进行相等比较。
可以看下面的简单示例:
my_list = [10, 20, 30, 40, 50] print(30 in my_list) # True print(60 in my_list) # False
当执行 30 in my_list 时,Python 会依次取出 10、20、30,直到比较到第三个元素才返回 True。如果目标值不在列表中,就必须扫描完全部元素,这也是最坏 O(n) 复杂度的直观体现。
集合则完全不同,它基于哈希表实现。CPython 的集合内部维护一个稀疏数组作为存储槽,插入元素时先对元素调用 hash 函数得到哈希值,再根据哈希值映射到某个槽位。成员检查同样先计算目标值的哈希,直接定位到候选槽位,然后用 == 判断是否相等。理想情况下,一次定位就能完成查找,平均时间复杂度为 O(1)。
my_set = {10, 20, 30, 40, 50}
print(30 in my_set) # True
print(60 in my_set) # False
当然,哈希表可能发生碰撞,即不同对象被映射到同一槽位。CPython 采用开放寻址法处理碰撞,如果目标槽位被占用且值不相等,会按探测序列继续查找后续槽位。只要哈希函数分布均匀且装载因子合理,碰撞不会显著影响平均性能。
性能差异与实测对比
理论复杂度是一回事,实际耗时更能说明问题。下面这段代码分别构造不同规模的列表和集合,把查找目标设为不存在的值,这样列表会走最坏情况,集合也会完成一次完整的哈希查找。计时只包含成员判断过程,不包含构建容器的开销。
import time
sizes = [10_000, 100_000, 1_000_000]
target = -1
for n in sizes:
data = list(range(n))
data_set = set(data)
start = time.perf_counter()
target in data
list_time = time.perf_counter() - start
start = time.perf_counter()
target in data_set
set_time = time.perf_counter() - start
print(f"n={n:>8} list: {list_time:.6f}s set: {set_time:.6f}s")
运行后可以看到,列表耗时随元素数量近似线性增长,从一万规模到一百万规模耗时大约增加一百倍;集合耗时则几乎保持在同一数量级。实际数值受机器性能影响,但数量级差异非常稳定。如果在业务代码中频繁执行成员判断,这个差距会迅速积累。
不过也要注意构建成本。集合在创建时需要逐个元素计算哈希并写入槽位,这一步本身是 O(n) 操作。如果程序里只做一次成员检查,把列表转换成集合往往不划算,因为转换时间可能远超一次线性扫描。通常的经验是:如果同一份数据会被反复查询,且查询次数足够多,先构建集合再查询是值得的;反之,如果只是零星的单次判断,直接使用列表或元组即可。
可哈希性与元素类型限制
列表对元素没有任何哈希要求,任何 Python 对象都可以作为列表元素,成员检查只依赖相等比较。只要两个对象通过 == 比较相等,列表中的 in 就会返回 True。例如下面的列表同时包含列表、字典和字符串:
my_list = [[1, 2], {"a": 1}, "text"]
print([1, 2] in my_list) # True
集合的要求严格得多。任何元素在写入集合前都必须计算哈希值,因此列表、字典、集合这些可变对象不能被放入集合。尝试这样做会直接抛出 TypeError:
try:
my_set = {[1, 2], [3, 4]}
except TypeError as e:
print(e)
原因在于集合依赖哈希值定位槽位。如果允许列表作为元素,一旦列表内容发生变化,它的哈希值也会随之改变,但存储位置不会自动迁移,整个哈希表结构就会被破坏。元组是不可变类型,且内部元素都可哈希时,可以作为集合元素;如果元组里包了一个列表,同样会因为不可哈希而被拒绝。
对于自定义类,默认情况下对象是可哈希的,哈希值通常基于 id。但如果重写了 __eq__ 而没有重写 __hash__,Python 会把 __hash__ 设为 None,导致对象不可哈希。这类对象可以用在列表中,但无法加入集合。
场景选择与常见误区
选择列表还是集合,不能只看成员检查速度。列表保留插入顺序,允许重复元素,支持索引和切片,适合需要按顺序处理数据的场景。集合不保留顺序,自动去重,适合快速去重、成员判断和集合运算。如果业务既需要快速查找,又需要保持顺序,可以考虑维护一个列表保存数据,同时维护一个集合作为索引。
一个常见误区是,在需要保持顺序和重复元素时仍然为了快速查找而使用集合。比如一个日志系统需要按时间顺序输出所有事件,同时统计某个事件是否出现过。直接把事件放入集合会丢失重复信息和顺序,正确的做法是保留原列表,并用集合记录已经出现的事件类型。
另一个误区是以为集合支持索引访问。集合是无序结构,内部槽位顺序与插入顺序没有稳定关系,因此不能使用 my_set[0] 这样的语法。需要有序唯一元素时,可以使用字典的键来模拟有序集合,Python 3.7 之后字典保持插入顺序;或者使用 collections.OrderedDict 的键视图。
最后,如果数据完全固定且要求不可变,可以使用 frozenset。它和集合一样基于哈希表,但创建后不能添加或删除元素,因此可以作为字典的键,也可以放入另一个集合。理解这些限制和适用边界,能帮助你在不同场景下正确组合列表与集合,避免性能隐患和运行时错误。

