订阅更新

列表推导式比for循环快3倍——list/dict/set底层原理

Python手册 第 2 题 约 8 分钟读完

本文目录
  1. 先说结论
  2. list的底层——动态数组
  3. dict的底层——哈希表
  4. set的底层——无值的哈希表
  5. 列表推导式为什么比for循环快?
  6. 大数据脚本中的数据结构选型
  7. 实战经验:数据质量校验脚本优化
  8. 面试高频追问
  9. 总结

先说结论

Python的三大核心数据结构:list(动态数组)、dict(哈希表)、set(哈希集合)。list的随机访问O(1)但查找O(N),dict和set的查找O(1)但内存开销大。列表推导式比for循环快2-3倍(因为减少了Python字节码的执行开销)。大数据脚本中选对数据结构和写法,性能差距可达10-100倍。

先把这段讲顺;面试官追问时,再展开下面的原理和细节。

详细讲解

list的底层——动态数组

# list底层是一个C语言的动态数组(PyObject*指针数组)
# 存储的是对象的引用(指针),不是对象本身
 
nums = [1, 2, 3, 4, 5]
# 内存布局:[ptr1, ptr2, ptr3, ptr4, ptr5, _, _, _]
# 每个ptr指向一个Python int对象
# 预分配了额外空间(_),用于append时不频繁扩容
 
# 时间复杂度:
# 随机访问 nums[i]: O(1)(指针数组直接索引)
# append: O(1)均摊(偶尔扩容是O(N))
# insert(0, x): O(N)(所有元素后移)
# x in nums: O(N)(线性查找!)
# 删除 remove(x): O(N)
 
# 扩容策略:约1.125倍(比Java ArrayList的1.5倍更保守)
# 8个元素时预分配到8+8//8+6=9个位置

dict的底层——哈希表

# dict底层是开放地址法的哈希表
# CPython 3.6+保证了插入顺序(实现细节变成了语言规范3.7+)
 
user = {'name': 'Wood', 'age': 30, 'city': 'Shanghai'}
 
# 内部结构(CPython 3.6+优化后):
# 索引表(紧凑数组): [_, 0, _, 2, 1, _, _, _]
# 数据表(有序数组): [(hash, 'name', 'Wood'),
#                     (hash, 'age', 30),
#                     (hash, 'city', 'Shanghai')]
 
# 时间复杂度:
# 查找 user['name']: O(1)(hash定位)
# 插入 user['x'] = y: O(1)
# 删除 del user['x']: O(1)
# 遍历: O(N)(按插入顺序)
 
# dict比list的查找快100倍!
# 100万个元素:list查找约50ms,dict查找约0.05ms

set的底层——无值的哈希表

# set底层和dict几乎一样(哈希表),只是没有value
# 所以set的查找也是O(1)
 
unique_ids = {1001, 1002, 1003, 1004}
 
# set的核心用途:
# 1. 去重
unique = set(id_list)  # 比用list去重快100倍
 
# 2. 成员判断(in操作)
if user_id in blocked_set:  # O(1)
    pass
# vs
if user_id in blocked_list:  # O(N) 慢!
    pass
 
# 3. 集合运算(交集/并集/差集)
common = set_a & set_b      # 交集
all_users = set_a | set_b   # 并集
new_users = set_a - set_b   # 差集

列表推导式为什么比for循环快?

# for循环(慢)
result = []
for x in range(1000000):
    if x % 2 == 0:
        result.append(x * 2)
# 耗时约120ms
 
# 列表推导式(快3倍)
result = [x * 2 for x in range(1000000) if x % 2 == 0]
# 耗时约40ms
 
# 为什么快?
# 1. 列表推导式在CPython内部有专门的字节码优化
#    不需要每次循环都查找append方法
# 2. 减少了Python字节码的LOAD/STORE指令数量
# 3. 内存预分配更智能(知道大概要生成多少元素)
 
# 字典推导式和集合推导式也一样快
d = {k: v for k, v in pairs}
s = {x for x in data if x > 0}

大数据脚本中的数据结构选型

场景错误选择正确选择性能差距
判断元素是否存在x in listx in set100倍
键值查找遍历list找匹配dict[key]100倍
去重手动for循环去重set(list)10-50倍
批量过滤for循环+if+append列表推导式3倍
大量数值计算for循环逐个计算NumPy向量化100倍
统计频次手动计数collections.Counter5倍

实战经验:数据质量校验脚本优化

在某知名出海硬件品牌,有一个Python脚本每天检查ETL产出的数据质量——100万行数据检查null值、值域范围、格式合法性。原来的写法用for循环逐行检查,跑了8秒。

# 优化前:for循环逐行检查(8秒)
errors = []
for index, row in df.iterrows():  # iterrows极慢!
    if row['amount'] is None:
        errors.append(index)
    if row['country'] not in valid_countries:  # list查找O(N)!
        errors.append(index)
 
# 优化第一步:set替代list(2.5秒)
valid_set = set(valid_countries)  # list转set
errors = [i for i, row in df.iterrows()
          if row['amount'] is None or row['country'] not in valid_set]
 
# 优化第二步:Pandas向量化(0.3秒)
null_mask = df['amount'].isna()
invalid_mask = ~df['country'].isin(valid_set)
errors = df[null_mask | invalid_mask].index.tolist()
 
# 从8秒到0.3秒,快了27倍
# 核心优化:避免iterrows + set替代list + 向量化替代循环

教训:Python数据处理的性能优化三板斧:① 不要用iterrows/for循环遍历DataFrame(用向量化操作);② 查找操作用set/dict而不是list;③ 能用推导式就不用for+append。

面试高频追问

追问一:dict的hash冲突怎么解决?

CPython用的是开放地址法(Open Addressing)——如果hash定位到的位置已经被占用了,就往后探测下一个空位置。具体用的是一种改进的探测方式(不是简单的线性探测),减少聚集。和Java HashMap的链表法不同——Python没有链表,全靠探测。

追问二:tuple和list有什么区别?性能差多少?

tuple是不可变的list——创建后不能修改元素。性能上tuple比list快约5-10%(因为不可变意味着CPython可以做更多优化,比如缓存小tuple对象)。内存上tuple也更小(没有预留扩容空间)。函数返回多个值用tuple而不是list是最佳实践。

追问三:OrderedDict和普通dict有什么区别?

Python 3.7+的普通dict已经保证插入顺序了,所以大部分场景不需要OrderedDict。OrderedDict的额外功能是:move_to_end()方法和基于顺序的==比较(两个dict只要key-value一样就相等,OrderedDict还要求顺序一样)。实际大数据开发中很少用OrderedDict。

总结

Python数据结构面试讲清楚三层:① 三大数据结构的底层(list=动态数组O(1)访问O(N)查找、dict=哈希表O(1)查找、set=无值哈希表O(1)判存);② 列表推导式为什么比for循环快(字节码优化+减少方法查找+内存预分配);③ 大数据脚本的选型原则(查找用set/dict、遍历用向量化、推导式替代for+append)。能讲出数据校验脚本从8秒优化到0.3秒的实际案例,面试官会觉得你注意过Python性能细节。

下一篇聊Python的内存模型:一个字典居然占了200MB——Python对象到底占多少内存?

你们Python脚本做过什么性能优化?评论区聊聊~

关注「Wood聊大数据」| 9年大数据老兵,只讲大数据面试干货

刷题进度保存在当前浏览器