列表推导式比for循环快3倍——list/dict/set底层原理
先说结论
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 list | x in set | 100倍 |
| 键值查找 | 遍历list找匹配 | dict[key] | 100倍 |
| 去重 | 手动for循环去重 | set(list) | 10-50倍 |
| 批量过滤 | for循环+if+append | 列表推导式 | 3倍 |
| 大量数值计算 | for循环逐个计算 | NumPy向量化 | 100倍 |
| 统计频次 | 手动计数 | collections.Counter | 5倍 |
实战经验:数据质量校验脚本优化
在某知名出海硬件品牌,有一个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年大数据老兵,只讲大数据面试干货