ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python内置数据结构选型:从list、dict到deque的底层逻辑与实战

Python内置数据结构选型:从list、dict到deque的底层逻辑与实战 很多人学Python学完循环学函数学完函数学类回头一算才发现真正拉开代码质量差距的其实是数据结构。不是你不会用list和dict而是遇到一个具体问题时你选择哪个容器、怎么组织数据直接决定了程序跑得快不快、代码好不好维护、面试答得顺不顺。这篇博文不打算堆概念我用实际项目里反复用到的一组示例把Python内置数据结构的关键细节、选型逻辑和踩坑经验串一遍适合正在学Python基础、准备数据结构考试、或者刚开始用Python写点真实需求的读者。Python的容器类型看起来就那么几种list、dict、set、tuple、deque可真到了用的时候很多人靠的是“感觉”而不是“依据”。这篇文章的价值在于帮你把底层差异、时间复杂度、内存开销、实战选型逻辑全部理清以后再遇到“这个需求该用什么数据结构”的问题你能直接给出有根据的答案而不是碰运气。1. Python数据结构的“形态”决定了它的“脾气”1.1 先理解底层实现动态数组、哈希表、双向链表Python的list看起来简单但它在CPython里其实是一个动态数组。什么叫动态数组它内部维护一段连续的内存空间索引访问靠“起始地址 偏移量”直接计算所以list[i]的时间复杂度是O(1)。但插入和删除就麻烦了在中间插一个元素后面的所有元素都得整体往后挪删除同理挪动成本是O(n)。这也解释了为什么list在尾部append很快但在头部insert(0, x)很慢。dict和set的底层都是哈希表。哈希表把键通过哈希函数映射到槽位找到对应位置后直接存取所以平均情况下查找是O(1)。当然哈希表也有代价它需要预留大量空闲槽位来减少冲突内存开销比list大得多。有人用“超市储物柜”类比哈希表——你根据号码直接走到对应柜子前不用一排排找但柜子本身占了很大空间list则像“电影院连排座位”座位是连续的从头走到尾要花时间但空间利用率高。deque是个容易被忽略的结构它底层是双向链表加上块状内存分配。好处是两端append和pop都是O(1)但中间访问比list慢因为要通过指针逐步走。CPU缓存对连续内存友好所以当你只需要“顺序遍历”一个列表时list往往比deque更快哪怕理论上看起来差不多。1.2 时间复杂度和内存开销别只凭感觉我在面试里经常问到一个问题一个数组里有10万个数你反复判断某个数在不在里面用list还是set很多人不假思索说“set”但让他解释原因就含糊了。标准答案是list的成员判断是O(n)set是O(1)平均10万次判断下来list可能要扫整个数组set却基本是常数时间。这不是“快一点”的区别是数量级的差距真实需求里可能从几秒变成几毫秒。内存方面有个经典结论Python的int对象本身就是个28字节左右的完整对象不是C语言那种4字节裸整数。list只是存指针的数组指向这些int对象dict存键和值还要维护哈希表的额外开销。一个空的dict比一个空的list大得多实测大约一个空dict占64字节空list占56字节看起来差不多但存入大量键值对后差异会迅速放大。下表整理了常见操作的时间复杂度建议收藏操作listtupledictsetdeque索引访问O(1)O(1)--O(n)查找元素O(n)O(n)--O(n)键查找--O(1)平均--成员测试O(n)O(n)O(1)平均O(1)平均O(n)尾部插入O(1)均摊不可变O(1)均摊O(1)均摊O(1)头部插入O(n)不可变--O(1)中间插入O(n)不可变--O(n)删除键/元素O(n)不可变O(1)平均O(1)平均O(n)提示dict和set的O(1)是“平均情况”依赖哈希函数分布和负载因子。一旦哈希冲突严重极端情况下会退化到O(n)只是Python的哈希实现已经做了很多优化日常基本遇不到退化场景。1.3 面试最爱问的差异list, dict, set, tuple的内在差别tuple和list的最大区别不是“能不能改”而是它内部用的是不可变数组结构创建后长度和内容都不能变所以它可以被哈希能当dict的键。很多人以为tuple“比list快”严谨地说因为不可变tuple在创建和访问上有轻微优势但主要价值在语义一组不应该变的数用tuple表达意图最清晰。dict在Python 3.7之后保持插入顺序这是一个非常重要的变化。早期dict是无序的后来先是3.6的CPython实现变成有序3.7版本正式把“保持插入顺序”写进了语言规范。这个特性让OrderedDict在绝大多数场景里都变得多余但它还在标准库里主要原因是为了兼容老代码和少数需要“移动键到末尾”的场景。set和dict共享哈希表实现区别在于set只有键没有值。set的典型用法是去重和成员测试但要注意set本身是可变的所以set不能作为另一个dict的键也不能放进另一个set。如果需要不可变版本用frozenset。2. 选型不是猜谜从一次词频统计倒推容器选择2.1 手写dict统计词频哪里不如Counter假设你有一篇文章要统计每个单词出现的次数找出频率最高的20个词。这个需求太常见了实习生的第一版代码通常是text 你的一段长文本内容 word_count {} for word in text.split(): if word in word_count: word_count[word] 1 else: word_count[word] 1这段代码逻辑没错但胜在啰嗦。当你有collections.Counter时两行就搞定from collections import Counter word_count Counter(text.split()) top20 word_count.most_common(20)Counter本质上就是一个dict的子类但它补了很多细节访问不存在的键返回0而不是KeyErrormost_common(n)直接按次数降序取前n个还能用update方法合并多份数据。更重要的是它把意图表达得很清楚别人一看就知道这里是做计数而不是去读一串if语句。有人问我“Counter会不会很慢”实测不会。Counter内部还是dict的哈希查询和手写dict的统计复杂度完全一致只是代码更短、边界处理更周全。如果你需要统计的是“同时出现的单词对”那才需要自己拼dict嵌套结构。2.2 想保持顺序list、deque、OrderedDict怎么取舍词频统计完你要按出现次数从高到低展示Counter的most_common返回的是list of tuple顺序天然排好。但有些场景需要你自己管理顺序比如维护一个“最近访问记录”新数据加到末尾满了就从头部丢弃。用list实现history [] history.append(new_item) # 尾部加O(1) old_item history.pop(0) # 头部丢O(n)问题就在pop(0)序列越长越慢O(n)的代价在大数据量下完全不能接受。用deque就舒服了from collections import deque history deque(maxlen100) history.append(new_item) # 尾部加O(1) old_item history.popleft() # 头部丢O(1)deque的maxlen参数是另一个宝藏超出长度时从另一端自动丢弃元素非常适合做滑动窗口、缓存队列这类需求。至于OrderedDict现在dict本身有序只有在需要“把某个已有键挪到末尾”这类额外操作时才值得专门引入。2.3 set的去重边界什么时候“去重”会翻车去重是最常见的需求之一把列表转为set再去回listunique_items list(set(items))但它有边界条件。第一set要求元素可哈希list、dict、set这种可变对象放进去直接报TypeError。第二set去重不保证顺序去重后元素的顺序是哈希表决定的和原列表顺序无关。如果你既要保留顺序又要去重得换个思路seen set() result [] for item in items: if item not in seen: seen.add(item) result.append(item)这个写法用set做O(1)的成员测试用list保存顺序结果兼顾两边需求。很多时候面试官问“怎么去重并且保留顺序”要的就是这个解法。3. 常用容器的进阶操作每个技巧都能改写你的代码3.1 推导式一行顶五行但别硬挤列表推导式大概是Python里最让人上瘾的语法了squares [x * x for x in range(100)] even_squares [x * x for x in range(100) if x % 2 0]它本质上就是for循环 append的语法糖但因为C层面的优化多数时候比手动循环快一点更重要的是可读性。字典推导式同样实用word_length {word: len(word) for word in word_list}生成器表达式也值得养成习惯。把方括号换成圆括号它不会立刻生成完整列表而是惰性求值处理大文件时内存占用天差地别total sum(len(line) for line in open(huge.txt))但推导式也有陷阱嵌套三层以上的推导式可读性断崖式下跌。我的经验是超过两层就拆成普通for循环或定义辅助函数不要为了一行炫技让同事翻车。3.2 切片与反转步长、复制、负索引的细节切片是list和tuple的高频操作最简单的用法是提取子序列lst [0, 1, 2, 3, 4, 5] part lst[1:4] # [1, 2, 3] reversed_lst lst[::-1] # [5, 4, 3, 2, 1, 0]lst[::-1] 的整体反转是很多新手不知道的技巧它等价于reversed(lst)但返回的是新列表。注意步长为0会直接报错步长为负数时切片方向会反转理解成“从start走到end每步走abs(step)个位置”更准确。切片还有一个隐含作用复制。lst[:] 会创建一个新列表而不是引用原列表。这个细节在嵌套结构里尤其重要original [[1, 2], [3, 4]] copy_fake original[:] # 外层是新列表内层还是同一批子列表 copy_fake[0][0] 99 print(original) # [[99, 2], [3, 4]]很多人以为lst[:]是“深拷贝”其实它只拷贝了最外层容器。至于什么时候需要真正深拷贝后面专门讲。3.3 序列解包和星号表达式解包是Python里被低估的语法。交换两个变量a, b b, a这一行背后是Python先把右边打包成tuple再解包赋值比C系语言写临时变量优雅得多。函数返回多个值时同样好用def get_user(): return 张三, 25 name, age get_user()星号表达式解决“只要头和尾”的问题first, *middle, last [1, 2, 3, 4, 5] # first1, middle[2,3,4], last5在处理不定长数据、拆分日志字段时这个语法能省掉大量下标操作。还有双星号解包用于合并字典下面专门说。3.4 字典合并的四种姿势字典合并看似简单写法却有讲究。最传统的是updated1 {a: 1, b: 2} d2 {b: 3, c: 4} d1.update(d2) # d1变成 {a:1, b:3, c:4}但update会修改原字典如果不希望原字典被改要先复制。Python 3.5可以用双星号解包merged {**d1, **d2}Python 3.9起还引入了并集运算符merged d1 | d2这几种写法的区别主要在于语义清晰度和版本兼容性。|操作符看起来像数学里的并集含义直观双星号解包则在元组列表转字典时非常方便my_dict dict([(name, 李四), (age, 20)])没有语法层面的性能差异选自己团队统一推荐的那种就行。4. deque和排序双端队列背后的算法题与工程场景4.1 从pop(0)说起deque的两端O(1)操作deque这个词容易被望文生义成“双端队列”但它的本质价值是需要从两端进出数据时它比list高效得多。我之前写一个任务队列接收方不断从左侧取任务处理完的任务信息追加到右侧结果列表。第一版用了list任务量一上来立刻变卡原因是list.pop(0)每次都要把整个序列前移一位。换成deque后popleft()和append()都是O(1)同样数据量下耗时从几秒降到几十毫秒。配合maxlen限制deque还天然适合做固定窗口from collections import deque price_window deque(maxlen20) for price in price_stream: price_window.append(price) # 此时price_window最多保留最近20个价格数据流处理、日志滚动、最近消息记录这类“永远只关心最近N条”的场景deque就是最合适的容器。4.2 固定长度队列缓存与淘汰场景固定长度队列在工程里的典型应用是缓存淘汰。假设你缓存最近的100个搜索记录新记录进来时要把最旧的挤出去。list实现需要判断长度、手动pop(0)而deque的maxlen直接帮你做了“满了就自动挤掉另一端”的操作。但要注意一个容易被忽略的点deque是线程安全的但它是通过加锁实现的“线程安全”级别。多个线程同时append和popleft能保证不出现结构损坏但不保证业务逻辑的原子性。比如“判断队列空再取数据”这个组合操作中间可能被其他线程插入数据需要自己加锁或用Queue模块。4.3 sorted的稳定排序与key设计Python内置的sorted和list.sort()都用Timsort算法这种算法是稳定的。稳定排序意味着相同关键字的元素排序前后相对位置不变。这个性质在“按主关键字排序后再按次关键字排序”时非常有用students [ {name: Alice, grade: 90, age: 20}, {name: Bob, grade: 85, age: 19}, {name: Cathy, grade: 90, age: 18}, ] students.sort(keylambda x: x[age]) students.sort(keylambda x: x[grade]) # 最终结果grade相同的人age小的排前面因为第二次排序是稳定的第一次排好age顺序会被保留下来。这就是“两次稳定排序实现多关键字排序”的经典技巧。更正规的做法是让key返回一个元组students.sort(keylambda x: (x[grade], -x[age]))元组作为key时Python会依次比较每个位置的元素。加个负号还能实现“grade升序、age降序”虽然对整数很实用但遇到字符串等无法取负的类型就尴尬了此时回到两次稳定排序才是通用方案。4.4 当内置排序不够用bisect和自定义对象排序如果数据本身几乎排好序又需要频繁插入新元素并保持有序直接用list.insert再sort的复杂度是O(n log n)起步。这时候用bisect模块维护“有序列表”更高效from bisect import insort sorted_list [1, 3, 5] insort(sorted_list, 4) # sorted_list 变为 [1, 3, 4, 5]insort先二分查找插入位置再在某个位置插入虽然插入本身仍是O(n)但省去了每次全量排序的开销。需要注意list插入带来的数组搬迁是物理无法避免的所以这个方案适合“元素数量不太大、但查找插入位置次数非常多”的场景。自定义对象排序时我见过很多新手写lambda x: x.attr其中attr本身是None一排序就TypeError。更稳妥的做法是给类实现__lt__方法class Student: def __init__(self, name, grade): self.name name self.grade grade def __lt__(self, other): return self.grade other.grade实现__lt__之后sorted(students)会直接用这个逻辑。比起每次写复杂的key lambda对象自己知道自己怎么比代码更内聚。5. 数据结构在数据科学里的投影pandas、numpy与图遍历5.1 DataFrame是一堆Series拼起来的字典学过pandas的人都知道DataFrame的每一列是一个Series而Series内部是一个NumPy数组加上一个索引对象。如果你从纯Python数据结构的角度去理解DataFrame就是一个“列名到Series”的字典Series就是一个“索引到值”的映射。所以处理DataFrame时很多直觉可以复用按列取数据是dict式的键查找按行遍历是低效的因为每一次索引都涉及哈希计算和数组切片。用内置数据结构建模这个概念能帮新手少走弯路。比如用Python的dict构建一个DataFrameimport pandas as pd data { 姓名: [张三, 李四, 王五], 成绩: [88, 92, 79], } df pd.DataFrame(data)看起来完全像dict of list。但一旦数据量大起来用纯Python循环逐行往DataFrame里插入数据就非常慢因为pandas要为每个新行做索引对齐和类型推断。正确做法是一次性构建好完整的list/ndarray结构再交给DataFrame需要追加时先把所有数据收集到list里最后一次创建DataFrame。5.2 用list和dict手写邻接矩阵与BFS虽然pandas提供了很多便捷工具但底层数据结构的基本功在算法和面试题里还是绕不开。比如图的邻接矩阵用“list of list”表达最自然graph [ [0, 1, 1, 0], [1, 0, 0, 1], [1, 0, 0, 1], [0, 1, 1, 0], ]邻接表用“dict of list”更省空间graph { A: [B, C], B: [A, D], C: [A, D], D: [B, C], }做BFS广度优先遍历时deque终于派上大用场from collections import deque def bfs(graph, start): visited set() queue deque([start]) visited.add(start) result [] while queue: node queue.popleft() result.append(node) for neighbor in graph.get(node, []): if neighbor not in visited: visited.add(neighbor) queue.append(neighbor) return result这段代码把前面讲到的set去重与O(1)成员测试、deque双端进出、dict邻接表存储全用上了。它本质上就是一道非常经典的算法题标准解法。5.3 数据处理的性能瓶颈藏在数据结构选择里在实际数据处理里数据量一旦上到几十万行数据结构的选择差异会被无限放大。一个典型的反面案例是用两层for循环去查找两个大列表的交集复杂度O(n*m)数据稍微大一点程序就卡死。改成set求交集一行代码解决问题common_items set(list_a) set(list_b)另一个常见瓶颈是逐行处理。很多人习惯循环DataFrame的每一行去做字符串处理这等于把dict的批量操作退化成逐行哈希查找加Python层循环效率低得离谱。正确思路是把apply或向量化操作拉到NumPy层面做本质上是“用连续数组的内存布局换取CPU缓存友好”。所以从数据结构角度看数据清洗的优化路线很清晰能批量就批量能并行就并行能哈希就不遍历能保持有序就少排序。这个思路比死记硬背pandas API更底层、更通用。6. 实测中最容易踩的五个坑面试和工程里都见过6.1 可变对象当默认参数明明是新列表却被共享这大概是Python最经典的坑没有之一def append_item(value, target[]): target.append(value) return target print(append_item(1)) # [1] print(append_item(2)) # [1, 2]不是 [2]问题出在默认参数target[]只在函数定义时求值一次之后每次调用都复用同一个列表对象。修复很简单def append_item(value, targetNone): if target is None: target [] target.append(value) return target我见过不止一个线上故障是因为这个细节初始数据看着正常多调用几次后之前的数据莫名其妙混了进来。凡是默认参数里出现list、dict、set这类可变对象一律用None占位再用逻辑初始化。6.2 copy与deepcopy嵌套列表复制出一堆幽灵引用拷贝问题在嵌套结构面前特别容易翻车。直接用复制是引用修改新变量会影响原变量这个很多人知道。但用copy.copy也只能复制最外层import copy matrix [[1, 2], [3, 4]] shallow copy.copy(matrix) shallow[0][0] 99 print(matrix) # [[99, 2], [3, 4]]原结构被改了因为内层子列表还是同一批对象。真要完全独立复制用copy.deepcopydeep copy.deepcopy(matrix) deep[0][0] 42 print(matrix) # [[1, 2], [3, 4]]不受影响deepcopy的开销比shallow大不少因为它需要递归复制整棵对象图还可能处理循环引用。日常选择很简单结构只有一层用list切片或copy.copy就够了有嵌套用deepcopy能不改原数据就新建尽量新建。6.3 为什么list不能当dict的键这个坑在初学阶段特别容易踩到。list是可变对象同一个列表对象可以在不同时刻内容不同。如果把list当dict的键第一次存进去时哈希值对应一个槽位然后你修改列表内容哈希值变了再查找时Python按新的哈希值去找槽位找不到于是KeyError。演示一下try: d {} lst [1, 2] d[lst] value except TypeError as e: print(e) # unhashable type: list所以list、dict、set不能做键。tuple可以做键前提是它里面的每个元素也都不可变。如果tuple里嵌套了list照样unhashable。需要可变集合的哈希版本时用frozenset。6.4 遍历时删除元素删着删着就跳过了这个坑在实际数据处理里特别常见。你写了一个循环遍历列表发现某些元素要删除直接用removelst [1, 2, 3, 4, 5] for num in lst: if num % 2 0: lst.remove(num)结果print(lst) # 可能会得到 [1, 3, 5]但有时候留下脏数据原因是在遍历过程中删除元素列表长度变化游标索引把后面的元素自动前移导致“跳过了下一个本来要检查的元素”。对短列表看起来还能碰巧得到正确答案对长列表就直接错。安全做法是遍历副本或倒序删除# 方式一遍历副本 for num in lst[:]: if num % 2 0: lst.remove(num) # 方式二倒序遍历 for i in range(len(lst) - 1, -1, -1): if lst[i] % 2 0: lst.pop(i)更推荐一行列表推导式替代整个循环lst [num for num in lst if num % 2 ! 0]6.5 排序的坑None混排、类型不匹配、稳定排序的底层排序看起来简单实际坑也不少。最常见的是列表里混着None和数字直接sorted会报TypeError。如果业务想“None排最后”要自己写keydata [3, None, 1, None, 2] sorted_data sorted(data, keylambda x: (x is None, x)) # None的排序键是(True, None)数字的排序键是(False, 数字) # False True所以数字全部排在None前面另一个坑是“数字字符串混排”。[10, 9]按字符串排序会得到[10, 9]因为1的ASCII比9小这不是数值顺序。要按数值排keyint即可但前提是能转int。至于稳定排序前面说过的“两次排序实现多关键字”依赖它。还有一个容易被忽略的点list.sort()是原地排序sorted是返回新列表。原地排序省内存但会把原顺序破坏掉若后续逻辑依赖原始顺序必须用sorted。我自己在被这些坑反复教育之后养成了一个习惯涉及容器操作先想“这个操作会不会改变原对象”再想“这个对象能不能被哈希”最后才动手写代码。Python的数据结构平时不会出大问题一出问题就是这些角落里的小差异。数据结构这东西光看书总觉得都懂了真正在代码里跑一遍才明白每个细节为什么存在。我希望你读完这篇不是记住几个结论而是能带着“底层是什么结构”的意识去写下一段代码。下次犹豫用list还是set用deque还是list时花一分钟想清楚时间复杂度和内存模型代码的质量会明显不一样。
返回列表