Python数据结构操作实战:列表、字典、集合与deque的选型和性能优化
发布时间:2026/10/2 8:36:41来源:尧图网络
Python 的数据结构操作听起来好像就是列表、字典、元组、集合这几个东西来回倒腾。但真要在项目里用得顺手你会发现光是“选哪种结构、什么时候改结构、怎么避免改出 bug”就够写一篇长文。这篇算是我「韦奇」系列里数据结构操作的实践总结适合刚啃完 Python 基础、开始写实际脚本的人也适合那些用列表一把梭、回头被性能打脸的初学者。我会把平时写业务代码、刷题、做数据清洗时真正高频的操作拆开讲包括列表、字典、集合、元组、双端队列的实战用法再配合排序、查找和一个小项目实例每一段都有可以直接抄走的代码和需要避开的坑。1. Python 数据结构操作练的不是 API是“选型”很多人以为数据结构操作就是记住append、pop、keys这几个方法。我早期也是这样后来在一个从 Excel 读了几十万行数据的任务里翻车才发现真正的分水岭是你知不知道在什么场景下用哪种结构以及为什么。1.1 从“会用”到“会选”中间差一个复杂度意识Python 内置数据结构都有各自的存取特点。最核心的差异是序列类型和散列类型。列表list是有序、连续内存的序列按下标访问是 O(1)但查找一个元素是否存在要 O(n)。字典dict是散列表结构按键取值是 O(1)但它是无序的虽然插入顺序在 Python 3.7 被保留。集合set本质上是一个只有键、没有值的字典去重和成员判断是 O(1)。元组tuple是不可变序列适合做固定结构的数据载体。我在实际选择时有一条很朴素的经验如果你的核心操作是“判断某个东西在不在”优先用集合如果是“根据一个条件找到对应数据”优先用字典只有“保持顺序逐条处理”才用列表。这个经验在数据量小的时候看不出差距一旦数据到了几十万条速度差异可能就是秒级和分钟级的区别。1.2 内置结构不够用怎么办看看 collections 生态标准库里的collections模块提供了几个常用增强结构其中最值得上手的是deque双端队列、defaultdict带默认值的字典和Counter计数器。它们没有改变 Python 内置模型只是补上了某些场景的短板。比如defaultdict在统计词频、分组时特别好用不用每次判断键是否存在from collections import defaultdict count defaultdict(int) for word in [python, data, python, struct]: count[word] 1 print(count[python]) # 2 print(count[not_exist]) # 0不会抛 KeyErrorCounter则是专门做计数统计的一行代码就能拿到出现次数最多的元素from collections import Counter cnt Counter([a, b, a, c, b, a]) print(cnt.most_common(2)) # [(a, 3), (b, 2)]这些结构不是我随便推的而是在处理日志分析、数据清洗时反复用到后得出的结论。数据结构操作的核心技巧之一就是知道标准库里已经帮你实现了哪些细节不重复造轮子。2. 四个最常上手的操作场景拆解2.1 列表的“有序”和“可变”两个双刃剑列表最直观的特点是“能改、有序”但这两个优点在特定操作会变成陷阱。我举一个最常见的例子在遍历列表时删除元素。nums [1, 2, 3, 4, 5] for n in nums: if n % 2 0: nums.remove(n) print(nums) # [1, 3, 5] 期望是这个但实际可能是 [1, 3, 4] 之类的原因在于remove是在迭代过程中修改列表长度导致当前索引后边的元素整体前移循环变量却还按原来的位置走所以会漏掉元素。标准解法是遍历副本或者用列表推导式生成新列表nums [1, 2, 3, 4, 5] nums [n for n in nums if n % 2 ! 0] print(nums) # [1, 3, 5]列表推导式不仅是写法简洁它本质上是“创建一个新列表”没有在迭代中动老长度的风险。我自己平时能用推导式就先推导式它比filter加lambda更直观。列表的另一个高频操作是切片和逆序。切片的误区是容易忘记它产生的是新列表而不是视图。比如b a[:]能复制出一个新列表但b a只是引用同一个对象。2.2 字典的键值匹配和默认值技巧字典是 Python 数据结构操作里的“万能胶水”。无论是 JSON 解析、配置管理还是缓存几乎都会碰到字典。我遇到最多的新手问题是“如何安全地取一个不一定存在的键”。config {host: localhost, port: 8080} # 方式1先判断 if timeout in config: timeout config[timeout] else: timeout 30 # 方式2get 带默认值 timeout config.get(timeout, 30) # 方式3setdefault取不到时还顺便写入默认值 config.setdefault(timeout, 30)三种写法都能拿到30但区别在副作用setdefault会在键不存在时把默认值写进字典里。如果你只是临时取一下不希望污染原字典用get更干净。如果你希望“取不到就初始化一个空列表/空字典”setdefault就很顺手不过在多线程或逻辑复杂的时候要小心并发写。字典操作里还有一个容易忽略的点字典的键必须是可哈希的不可变对象。所以可以用元组做键但不能用列表做键。我在实现一个“二维坐标点计数”时就用过元组键from collections import Counter points Counter() points[(1, 2)] 1 points[(3, 4)] 1 print(points[(1, 2)]) # 1这种用法在图形处理、矩阵统计里非常常见也是“数据结构选型”的经典体现如果你需要用一个组合条件来索引数据元组键是最廉价方案。2.3 集合的去重和集合运算集合操作往往被低估因为日常开发里“去重”用得最多但集合的并集、交集、差集其实能优雅解决很多数据对比问题。我举个例子线上有两份用户 ID 列表一份是已注册用户一份是当天活跃用户想找出“活跃但未注册”的人registered {u001, u002, u003} active {u002, u004, u005} # 活跃但不在注册名单里 unregistered_active active - registered print(unregistered_active) # {u004, u005}一个-号就完成了可能要写好几层循环的逻辑。如果数据是列表先转成集合再运算会比两两比较高效得多。当然要注意集合里存的元素也必须是可哈希的所以如果元素本身是列表得先转成元组。集合还有一个容易被误解的点remove在元素不存在时会抛KeyError而discard会安静地忽略。在批量清理数据时我一般用discard因为我不关心某个元素是不是已经在集合外只关心最终结果是对集合的差集。2.4 元组的不可变与解包元组常被当成“不能变的列表”但它的真正用途是表示一组固定数量、固定顺序的数据。比如一个平面坐标横纵坐标合在一起就是一个元组一个时间点年月日时分秒也可以用一个元组。它的不可变性让它可以安全地作为字典键并且能用来做多变量赋值。# 交换两个变量 a, b b, a # 函数返回多个结果 def min_max(nums): return min(nums), max(nums) lo, hi min_max([3, 1, 4, 1, 5])解包操作我非常推荐在写数据处理代码时多用它能让代码读起来像在描述数据本身而不是一串下标操作。真正要注意的是元组里的元素如果是可变对象那“不可变”只是表面现象t ([1, 2], 3) t[0].append(99) print(t) # ([1, 2, 99], 3)因为元组存的是列表的引用列表本身还是能改。这里没有魔法只是引用和值的关系但新手经常踩。3. 双端队列Python 数据结构操作里被低估的尖兵3.1 collections.deque 能解决什么问题列表在头部插入或删除元素是 O(n)因为后续元素要整体移动。如果只是反复在两端添加、弹出用列表会非常亏。Python 的collections.deque是一个双端队列两端的插入和弹出都是 O(1)非常适合做队列、栈、滑动窗口、缓存淘汰这类操作。我一开始也不太理解 deque 的必要性直到我写了一个实时展示最近 5 条日志的小工具。如果每次有新日志就把旧日志删掉用列表list.pop(0)在数据量小时还能忍但日志一多就明显卡顿。换成 deque 后代码不但更快还更简单from collections import deque recent_logs deque(maxlen5) for log in [log1, log2, log3, log4, log5, log6]: recent_logs.append(log) print(list(recent_logs)) # [log2, log3, log4, log5, log6]指定maxlen之后左端元素会自动被弹出不需要手动popleft。这个特性在做限流、保留历史记录时特别好用。3.2 用双端队列实现一个固定长度缓存除了日志deque 还可以当“最近 N 个元素”的缓存。比如在线监控里保留最近 10 秒的 CPU 使用率每来一个新值就自动丢掉最老的一个from collections import deque cpu_samples deque(maxlen10) for sample in [12.3, 13.1, 12.8, 11.9, 13.4, 12.0]: cpu_samples.append(sample) # 此时队列里只有最后 5 个 print(list(cpu_samples))要注意的是deque虽然是双端队列但它中间插入的效率一般如果想在中间做随机访问还是用列表。选型原则很简单“只要是在两端进出的队列场景先想 deque”。4. 排序和查找数据结构操作里的高频动作4.1 排序算法的选择与实现数据结构操作里排序是绕不开的。Python 内置的list.sort()使用的是 Timsort 算法它结合了归并和插入排序的优点在大多时候已经是最优选择。所以我不建议你在业务代码里手写快排除非是单纯练算法。但你需要知道不同数据特性下的选择几乎有序的数据Timsort 会很快不用你自己优化。需要排序后保留原列表用sorted()它返回新列表。需要按自定义规则排序用key参数避免写cmp那种老式比较函数。items [(b, 3), (a, 1), (c, 2)] items.sort(keylambda x: x[1]) print(items) # [(a, 1), (c, 2), (b, 3)]还有一个例子按字符串长度排序只需要keylen而不是写复杂的 lambdawords [python, data, struct, deque] sorted_words sorted(words, keylen) print(sorted_words) # [data, deque, struct, python]排序的复杂度是 O(n log n)所以如果排序前能先用集合去重、或者用字典做分组把 n 缩小整体会快很多。4.2 查找操作从线性到二分查找在数据结构操作里同样高频。最简单的查找是in运算符但它的效率取决于容器类型。在列表里in是 O(n)在集合和字典里是 O(1)。如果你反复判断一个元素是否存在一定要把列表转成集合再做查找# 数据量较大的场景 all_ids [i for i in range(100000)] id_set set(all_ids) print(99999 in id_set) # 立刻返回如果数据本身是有序的还可以用二分查找标准库的bisect模块可以帮你找到插入点而不必手写二分。比如在一个有序列表里找最短的插入位置import bisect scores [60, 70, 80, 90] pos bisect.bisect_left(scores, 85) print(pos) # 3应该在 index3 的位置插入这个在维护排行榜或订单列表时很有用。不过要记住bisect要求列表本身有序否则结果没意义。5. 实战用数据结构操作写一个“成绩统计”小工具5.1 需求拆解与数据结构选型纸上谈兵容易我把一个实际小需求完整拆一遍。假设你有一个班级的学生成绩记录格式是(学号, 科目, 分数)可能同一个人有多个科目。要统计这样几个结果每个学生的总分和平均分全班最高分的学生是谁所有科目去重后有哪些先做选型用defaultdict(list)按学号分组方便存多个分数。用dict存学号和姓名映射。用set存科目集合。这样每个需求都有对应的强力结构不需要反复遍历。5.2 实现步骤和代码注释数据先按小规模模拟records [ (001, 王, 语文, 88), (001, 王, 数学, 92), (002, 李, 语文, 75), (002, 李, 英语, 82), (003, 张, 数学, 95), (003, 张, 语文, 91), ]第一步建立学号到姓名、学号到分数列表的映射。我习惯把姓名和分数分开避免在分数列表里混入姓名这样后续元组解包更干净。from collections import defaultdict name_by_id {} scores_by_id defaultdict(list) subjects set() for sid, name, subject, score in records: name_by_id[sid] name scores_by_id[sid].append(score) subjects.add(subject)第二步计算总分和平均分。这里直接用字典遍历total_score {} average_score {} for sid, scores in scores_by_id.items(): total_score[sid] sum(scores) average_score[sid] round(sum(scores) / len(scores), 2)第三步找最高分学生。可以用max(total_score, keytotal_score.get)这个表达式很简洁但要注意max返回的是 key不是 value。top_student max(total_score, keytotal_score.get) print(name_by_id[top_student], total_score[top_student]) # 输出王 180第四步输出科目清单print(subjects) # {语文, 数学, 英语}5.3 过程反思这个小工具没多少行代码但每一个统计需求都对应了数据结构选型分组用defaultdict(list)去重用set取最大值用dict配合key参数。如果全用列表存原始数据实现逻辑会复杂得多而且可读性很差。这就是为什么我觉得单独练数据结构操作比背函数 API 更值得。6. 我踩过的坑和排查方法6.1 可变对象作为默认参数这个坑很经典我早期写函数时经常踩def add_item(item, container[]): container.append(item) return container print(add_item(1)) # [1] print(add_item(2)) # [1, 2] 而不是 [2]原因很简单container[]只在函数定义时创建一次之后所有调用都共享同一个列表。正确做法是默认参数设置为None函数体内做初始化def add_item(item, containerNone): if container is None: container [] container.append(item) return container这算是 Python 数据结构相关的第一课写库代码的时候必须注意。6.2 遍历字典时修改字典在遍历字典的过程中删除键或修改键会触发RuntimeError或者导致漏遍历。如果你需要筛选字典里的某些项请直接构造新字典origin {a: 1, b: 2, c: 3} filtered {k: v for k, v in origin.items() if v 1} print(filtered) # {b: 2, c: 3}不要用类似for k in origin: del origin[k]的写法除非你明确知道自己在做while循环。新字典在数据处理时更安全性能也不会差。6.3 字典合并的版本差异不同 Python 版本的字典合并方式不一样。Python 3.5 之前得用update3.5 可以用**展开3.9 才可以直接用|运算符dict1 {a: 1} dict2 {b: 2} # Python 3.9 merged dict1 | dict2 # Python 3.5 merged {**dict1, **dict2}我平时会先确认项目跑在哪个版本别只顾着写最花哨的写法。如果你要兼容旧版本update永远是最保守的。6.4 性能对比列表与集合查找的实测感受我之前处理过一个去重任务原始数据是一个 10 万条左右的列表需要判断另一批数据是否在里面。最初我用列表的in跑了大概十几秒换成集合后只有不到 0.1 秒。这不是理论空谈而是实际改完代码前后的对比。虽然这种性能差距在小数据量上感知不到但当你做数据清洗、日志分析时必须提前规避。如果你不确定哪个容器适合可以自己用timeit测一下import timeit lst list(range(100000)) s set(lst) t_list timeit.timeit(lambda: -1 in lst, number1000) t_set timeit.timeit(lambda: -1 in s, number1000) print(t_list, t_set) # 差距一眼可见数据结构操作的本质就是让你用合适的容器配合合适的算法把代码的时间复杂度和空间复杂度控制到合理范围。7. 分享两个让数据结构操作更顺手的小习惯第一个习惯把“集合去重”当成默认动作。只要发现自己在写if x not in list这种反复判断就先想想能不能把列表转成集合。这会让代码更短也更快。第二个习惯多写类型注释和结构化的解包。比如sid, name, subject, score record比record[0]、record[1]清晰得多。后续维护时你看代码不用数下标减少出错概率。我在实际工作中还养成一个习惯每学一个新的数据结构相关技巧就把它记到一个带示例的本地笔记里。这个「韦奇」系列其实就是这种习惯的产物。数据结构操作没什么高深玄机无非是多用、多踩坑、多回头看代码的复杂度。你练得越频繁选型就越自然最后写出来的脚本就像搭积木一样顺畅。
网站建设高端定制企业官网