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 # 方式2:get 带默认值 timeout = config.get("timeout", 30) # 方式3:setdefault,取不到时还顺便写入默认值 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(maxlen=5) 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(maxlen=10) 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(key=lambda x: x[1]) print(items) # [('a', 1), ('c', 2), ('b', 3)]还有一个例子:按字符串长度排序,只需要key=len,而不是写复杂的 lambda:
words = ["python", "data", "struct", "deque"] sorted_words = sorted(words, key=len) 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,应该在 index=3 的位置插入这个在维护排行榜或订单列表时很有用。不过要记住,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, key=total_score.get),这个表达式很简洁,但要注意max返回的是 key,不是 value。
top_student = max(total_score, key=total_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, container=None): 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 之前得用update,3.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, number=1000) t_set = timeit.timeit(lambda: -1 in s, number=1000) print(t_list, t_set) # 差距一眼可见数据结构操作的本质,就是让你用合适的容器配合合适的算法,把代码的时间复杂度和空间复杂度控制到合理范围。
7. 分享两个让数据结构操作更顺手的小习惯
第一个习惯:把“集合去重”当成默认动作。只要发现自己在写if x not in list这种反复判断,就先想想能不能把列表转成集合。这会让代码更短,也更快。
第二个习惯:多写类型注释和结构化的解包。比如sid, name, subject, score = record比record[0]、record[1]清晰得多。后续维护时,你看代码不用数下标,减少出错概率。
我在实际工作中还养成一个习惯:每学一个新的数据结构相关技巧,就把它记到一个带示例的本地笔记里。这个「韦奇」系列其实就是这种习惯的产物。数据结构操作没什么高深玄机,无非是多用、多踩坑、多回头看代码的复杂度。你练得越频繁,选型就越自然,最后写出来的脚本就像搭积木一样顺畅。