Python数据结构全解析:四大内置容器与collections实战指南
发布时间:2026/9/29 16:13:16来源:尧图网络
先写点题外话我见过太多人学 Python 学到一半卡在“数据结构”这三个字上不是因为难而是因为教程要么太抽象要么太堆砌。一提数据结构就联想到厚厚的算法书、复杂的 C 语言指针好像不啃完严蔚敏那本就不配写代码。但真实情况是日常开发里你碰到的绝大多数数据管理需求Python 内置的 list、tuple、dict、set 这四大容器加上 collections 模块的几个高级容器就已经能覆盖九成以上场景。这篇文章我打算换个讲法——不按教科书顺序罗列概念而是按照“这个东西到底解决什么问题、底层大概怎么回事、实际代码怎么写、有哪些坑”来拆解。每一个数据结构我都会给可直接运行的示例并且把选型逻辑、性能差异、常见翻车现场一起交代清楚。适合刚入门想系统补齐 Python 数据结构基础的人也适合写过一阵子代码但从来没认真梳理过这些容器差异的同学。看完你会发现数据结构没那么神它就是把一堆数据收拾得井井有条的“收纳工具”关键是选对箱子。1. 先搞清楚Python 数据结构到底指什么1.1 从“数据结构”到“内置容器”数据结构这个词说白了就是组织数据的方式。同样是存几个数字你是排成一溜放还是两两配对放还是只留不重复的最终面对的问题和能做的操作完全不同。Python 官方没有要求你自己从零写链表、写哈希表而是直接给你几个已经实现好的容器类型你拿来就能用。这就是内置数据结构。拿生活打比方衣橱、抽屉、药盒、档案袋都能装东西但用途差别很大。衣服挂衣橱里方便看全貌零碎小物件放抽屉里方便翻找药品按功效分格才不会拿错档案按类别归档才能快速检索。Python 里的 list、tuple、dict、set 就是这四种不同形态的“收纳工具”各自有擅长干的事。理解这层之后你学数据结构的目标就从“背定义”变成“会选型”这比记住任何 API 都重要。1.2 为什么优先用内置结构而不是自己造轮子你可能会问学校里面不都在教用 C 语言手写链表、手写栈吗Python 里要不要也自己实现一遍我的观点是学习阶段可以手写一遍练逻辑工程阶段千万别。原因有三。第一性能。Python 内置的 list 底层是 C 实现的动态数组dict 底层是 C 实现的哈希表。你自己用 Python 类去模拟这些结构每一层操作都要经过 Python 解释器的开销性能差出一个数量级很正常。第二正确性。哈希表怎么处理冲突、动态数组怎么扩容缩容这些边界问题早被 CPython 的开发者打磨过无数轮了。你自己实现一个大概率会在某个极端输入下翻车。第三生态。内置容器可以直接和标准库、第三方库无缝配合比如 JSON 序列化、Pandas 转换、数据库 ORM 映射它们全部默认支持 dict、list、tuple、set 这些类型。你要是自定义一个“自制链表”所有生态工具都会对你关上大门。所以下面所有内容都基于一个前提优先用 Python 内置的数据结构把它们用到熟再考虑要不要深入学习底层原理。2. 四大内置容器每个都要拿捏准2.1 list最灵活的“动态数组”list 是我日常用得最多的容器没有之一。它的核心特点是有序、可变、可以存任意类型。在 CPython 的底层实现里list 是一个动态数组。什么意思就是你 append 的时候它不需要像 C 的固定数组那样提前定死长度而是会按需扩容——通常一次性多分配一些内存避免每次新增元素都重新分配所以均摊下来 append 操作的时间复杂度是 O(1)。实际操作上我建议你记住这几个高频操作scores [90, 85, 77, 92, 88] # 尾部追加和弹出O(1) scores.append(100) last scores.pop() # 按下标访问与修改O(1) scores[0] 95 # 插入到指定位置O(n)因为后面元素要挪位 scores.insert(2, 89) # 切片返回新列表 top3 scores[:3] # 列表推导式最常用的快速生成方式 doubled [x * 2 for x in scores if x 80]这里有个细节值得单独说insert(0, element)在 Python 里是 O(n) 操作因为要把整个数组向后挪。如果你频繁在头部插入数据list 不是好选择后面会讲到 deque。踩坑提醒不要在用 for 循环遍历 list 的时候直接删除元素。Python 的循环是基于下标的删掉一个元素后后面的元素会整体前移导致你漏掉原本该处理的数据。# 错误示范想移除所有小于 85 的分数但会漏掉元素 nums [90, 80, 70, 85, 60] for x in nums: if x 85: nums.remove(x) print(nums) # 输出[90, 70, 85] 70 没被删掉正确做法是遍历列表的拷贝比如for x in nums[:]或者用推导式直接构建新列表。这两者都比边遍历边删除安全得多。2.2 tuple不可变带来的“安全感”tuple 和 list 长得几乎一样核心区别就一条tuple 创建后不可修改。正是这个“不可变”让 tuple 在三种场景里特别吃香。第一种存储固定结构的数据。比如一个点的三维坐标point (10, 20, 30)用 tuple 可以避免别处代码手滑把坐标篡改了从语法层面杜绝这类 bug。第二种作为字典的键。只有不可变对象才能被哈希才能当 dict 的 key。tuple 可以当 keylist 不行。如果你要表达一个复合键比如“(城市, 月份)”作为一个组合标识tuple 是标准答案。第三种函数返回值。Python 的函数一次可以返回多个值本质上就是返回一个 tuple。def get_user(): name Alice age 28 return name, age # 实际返回的是 tuple name, age get_user() # 解包这里可以延伸一下“解包”这个概念。Python 的解包非常强大交换两个变量不用中间变量a, b b, a还能用星号接收多余元素first, *rest, last [1, 2, 3, 4, 5] # first1, rest[2,3,4], last5有人觉得 tuple 用起来像“只读列表”但我想强调tuple 的不可变是浅层的。如果 tuple 里放了一个 listlist 本身的内容还是可以被修改的。这是新手比较容易误解的地方。2.3 dict哈希表加持下的“快速查找”dict 是 Python 里最重要的数据结构之一。它解决的核心问题是根据一个键快速找到对应的值。底层实现是哈希表键经过哈希函数计算得到一个桶的位置直接定位到值不需要遍历全表所以平均查找时间是 O(1)。这就解释了为什么 dict 查找比 list 逐个比对快那么多。我举一个日常例子假设你有一个装着 10 万个用户对象的列表现在要按用户 ID 查姓名。用 list 的时候每次查找都要从头遍历最坏情况要比较 10 万次如果先把数据放进 dict键是用户 ID值是用户信息那么任何一次查找都只需要计算一次哈希直达结果。# 用 list 存时 users [{id: 1, name: Alice}, {id: 2, name: Bob}] # 想查 id999 的用户需要 for 循环遍历 # 改成 dict 索引 user_map {u[id]: u[name] for u in users} print(user_map[1]) # 直接命中dict 的注意点也不少。第一键必须是可哈希对象。不可变的内置类型str、int、float、tuple基本都能当键list、dict 这种可变类型不能当键。如果你硬写Python 会报TypeError: unhashable type: list。第二Python 3.7 之后 dict 保持插入顺序。也就是说你按什么顺序放进去遍历就按什么顺序出来这个特性在序列化、构造有序 JSON 时很好用。第三不要随便在遍历 dict 时删除元素。推荐做法是取到键列表后遍历或者直接用字典推导式重建。# 安全地删除满足条件的键值对 data {a: 1, b: 2, c: 3, d: 4} data {k: v for k, v in data.items() if v % 2 0} print(data) # {b: 2, d: 4}还有两个方法值得养成习惯get(key, default)代替直接dict[key]取值可以避免键不存在时的 KeyErrorsetdefault(key, default)可以在键不存在时先设置默认值再返回写计数器累加时干净很多。2.4 set去重与集合运算的“标准答案”set 底层也基于哈希表但它存的是“只有键没有值”的数据。它最重要的特性是元素不重复、无序、查找 O(1)。去重是 set 最经典的应用场景。一行代码清理一个列表的重复元素ids [101, 102, 101, 103, 102, 104] unique_ids list(set(ids)) print(unique_ids) # 顺序不保证结果是 {101,102,103,104} 的某种排列注意 set 转换后顺序会被打乱如果需要保留原始顺序可以用一个小技巧ids [101, 102, 101, 103, 102, 104] seen set() result [] for x in ids: if x not in seen: seen.add(x) result.append(x) print(result) # [101, 102, 103, 104]顺序保持集合运算也是 set 的拿手好戏。交集、并集、差集、对称差集一个符号搞定a {python, java, go} b {go, rust, python, c} print(a b) # 交集{python, go} print(a | b) # 并集 print(a - b) # 差集a 有 b 没有的 print(a ^ b) # 对称差集只在一边出现的这些运算用 list 做起来非常麻烦用 set 就是 O(n)。比如统计两个系统的共同用户、判断权限组的包含关系直接上 set 运算代码短、语义清楚。同样要注意set 里的元素必须是可哈希对象。如果你想去重一个包含 list 的列表会直接报错得先把 list 转成 tuple 再放进 set。3. collections 模块内置容器的“高级配件包”如果说 list、tuple、dict、set 是标准工具箱那么collections模块就是官方给你配的一套专用工具。它里面几个容器解决的是标准容器用起来很别扭的场景。3.1 deque队列和栈的正确打开方式我前面提到过list 在头部做 insert 和 pop 是 O(n)。如果你需要在两端频繁操作比如维护一个近期操作记录、搞一个消息缓冲队列list 会越跑越慢。collections.deque是双端队列两端 append 和 pop 都是 O(1)因为底层是双向链表加块状存储。它在使用上几乎可以替代 list 用于队列/栈场景。from collections import deque dq deque(maxlen3) # 限制最大长度超过会自动挤掉最老的元素 dq.append(1) dq.append(2) dq.append(3) dq.append(4) print(dq) # deque([2, 3, 4])1 被挤出 # 当栈用右侧压入和弹出 dq.append(5) top dq.pop() # 当队列用右侧进左侧出 dq.append(6) front dq.popleft()maxlen这个参数特别实用。比如记录用户最近浏览的 10 个商品、保留最近 100 条日志用deque(maxlen100)一行搞定不用自己判断长度和删除。3.2 Counter一行代码完成计数统计一个列表里各个元素出现的次数是再常见不过的需求。用普通 dict 写是这样words [apple, banana, apple, orange, banana, apple] # 用 dict 手写 count {} for w in words: count[w] count.get(w, 0) 1用collections.Counter更简洁而且自带很多能力from collections import Counter words [apple, banana, apple, orange, banana, apple] counter Counter(words) print(counter) # Counter({apple: 3, banana: 2, orange: 1}) print(counter.most_common(1)) # [(apple, 3)]出现最多的前 1 个Counter 本质上就是 dict 的子类所以你可以直接counter[apple]取值。它还支持加减运算、交集并集比如计算两个文本的词频差异、合并多个计数结果只要写counter1 counter2就能按元素合并。写爬虫统计热词、做日志分析统计接口调用次数Counter 都是利器。3.3 defaultdict告别“先判断键是否存在”用 dict 做分组统计时最常见的问题就是键不存在。比如把学生按班级分组写成普通 dictstudents [(A班, 张三), (B班, 李四), (A班, 王五)] groups {} for stu_class, name in students: if stu_class not in groups: groups[stu_class] [] groups[stu_class].append(name)每次都要先判断键在不在代码啰嗦容易漏。defaultdict会在你访问不存在的键时自动创建默认值from collections import defaultdict groups defaultdict(list) for stu_class, name in students: groups[stu_class].append(name) print(groups) # defaultdict(class list, {A班: [张三, 王五], B班: [李四]})同理defaultdict(int)适合做计数defaultdict(set)适合存不重复的元素集合。这是实实在在每天都能用到的优化写出来的代码也更接近“先想好数据结构模型再做填充”的思维方式。3.4 namedtuple让元组不再靠猜tuple 有一个缺点数据全靠下标访问point[0]、point[1]写多了自己都晕。namedtuple给 tuple 的每个位置赋予字段名既保留了 tuple 的不可变和轻量又极大提升了可读性。from collections import namedtuple Point namedtuple(Point, [x, y, z]) p Point(10, 20, 30) print(p.x, p.y, p.z) # 10 20 30 print(p[0]) # 10依然兼容下标访问 # 转字典 print(p._asdict()) # {x: 10, y: 20, z: 30}在从数据库读一行记录、读取 CSV 每一行、处理坐标点这类“字段名固定”的场景里namedtuple 比普通 tuple 清楚很多又比定义一整个 class 轻量得多。我在写量化策略的时候经常用 namedtuple 表示一笔交易记录字段一目了然调试时打印出来非常舒服。4. 数据结构选型懂性能才叫会写4.1 场景和容器的快速对照表写代码时最怕的不是语法不会而是不知道该用哪个容器。我把最典型的选型场景整理成一张表照着选基本不会错数据特征与操作需求推荐容器核心原因按顺序存储数据并且会频繁追加list尾部操作 O(1)插入中部也能接受数据定下来之后就不动作为整体传递tuple不可变安全且可作为 dict 键需要通过唯一标识快速查数据dict哈希查找 O(1)按键直接命中只关心元素在不在不关心顺序和次数set自动去重查找和集合运算快需要在两端同时做添加删除deque两端操作都是 O(1)统计元素出现次数Counter专门的计数实现自带排序方法按某个键分组聚合数据defaultdict自动初始化默认值避免判空一批固定字段的记录数据namedtuple不可变 字段名可读性好这张表背后的逻辑只有一个你想对数据做什么操作就选能让这个操作最快的容器。想清楚这一点数据结构学习的大半就通了。4.2 复杂度思维把 O(n) 变成 O(1)数据结构选型的本质是时间复杂度的取舍。我讲一个最典型的例子。判断一个元素是否在集合中用 list 是 O(n)用 set 是 O(1)。当数据量小的时候完全没感觉数据量一大就天壤之别。import time big_list list(range(10000000)) # 1000 万个数字 big_set set(big_list) # 在 list 里查找 start time.time() print(9999999 in big_list) print(list 查找耗时, time.time() - start) # 在 set 里查找 start time.time() print(9999999 in big_set) print(set 查找耗时, time.time() - start)实测下来list 的查找可能要几百毫秒set 几乎瞬间完成。这就是 O(n) 和 O(1) 的差距。再比如你做一个统计词频的功能如果用 list 存储所有单词然后每次 count()复杂度是 O(n²)用 dict 累加是 O(n)用 Counter 同样 O(n) 但代码更短。数据量从 1000 涨到 10 万O(n²) 的性能劣化是灾难性的。实际工程里90% 的性能问题都不是语法问题而是用了不合适的数据结构。看到 for 循环套 for 循环先停一下想想能不能用 dict 或者 set 把内层循环干掉。这比任何优化技巧都重要。4.3 排序场景list.sort 和 sorted 的取舍热词里有很多人搜“数据结构排序算法”这里顺带讲一下 Python 里排序 API 的正确姿势免得提到排序就想着冒泡排序、快排手写。面试或者考试会考算法原理但真正写业务代码直接用内置排序。nums [5, 2, 9, 1, 7] # 原地排序不产生新列表 nums.sort() print(nums) # [1, 2, 5, 7, 9] # sorted 返回新列表原列表不变 nums2 sorted([5, 2, 9, 1, 7]) print(nums2) # 按自定义规则排序 words [banana, apple, cherry, date] by_len sorted(words, keylen) # 按长度排 by_len_desc sorted(words, keylen, reverseTrue) # 按长度降序 # 按对象某个字段排序 students [{name: 张三, score: 85}, {name: 李四, score: 92}] ranked sorted(students, keylambda s: s[score], reverseTrue)list.sort()用的是 Timsort 算法结合了归并和插入排序的优点对真实世界的数据部分已有序有很好的优化效果。日常业务排序交给它就够了你完全不需要自己手写快速排序。当然如果你要处理的是自定义对象数组想按对象内部的属性排序keylambda x: x.attribute是标准写法比自己去实现__lt__方便得多。5. 完整示例用数据结构组合解决实际问题理论说太多容易飘我放三个可以直接抄的实战场景。5.1 学生成绩统计dict list Counter 的组合拳假设现在有一份学生成绩数据我们需要算班级名称、按成绩排名、统计分数段人数。只用一种数据结构肯定别扭组合起来就很顺手。from collections import defaultdict, Counter raw_scores [ (A班, 张三, 85), (B班, 李四, 92), (A班, 王五, 78), (B班, 赵六, 88), (A班, 孙七, 96), ] # 按班级分组成绩 class_scores defaultdict(list) for cls, name, score in raw_scores: class_scores[cls].append((name, score)) # 每个班内部按成绩降序排名 for cls, items in class_scores.items(): class_scores[cls] sorted(items, keylambda x: x[1], reverseTrue) print(dict(class_scores)) # 统计所有成绩的分数段 all_scores [score for _, _, score in raw_scores] segments Counter(优秀 if s 90 else 及格 if s 60 else 不及格 for s in all_scores) print(segments)这个例子充分展示了为什么学数据结构要组合使用defaultdict 负责按班级分组sorted 负责排名列表推导式负责提取数据Counter 负责打分数段标签。每种结构干自己最擅长的事代码加起来不到 15 行。5.2 日志分析统计 PV 和 UV做 Web 开发的同学一定遇到过这个需求给一份访问日志统计每个接口被调用几次、多少个独立用户访问过。from collections import defaultdict, Counter logs [ (2024-01-01 10:00, GET /api/orders, user_1), (2024-01-01 10:01, GET /api/orders, user_2), (2024-01-01 10:02, POST /api/login, user_1), (2024-01-01 10:03, GET /api/orders, user_1), ] # 统计接口调用次数 PV pv Counter(log[1] for log in logs) print(PV:, pv) # 统计每个接口的独立用户数 UV uv defaultdict(set) for _, api, user in logs: uv[api].add(user) print(UV:, {api: len(users) for api, users in uv.items()})Counter 一行统计调用次数defaultdict(set) 自动去重用户最后再用字典推导式把 set 转成长度。这就是数据结构在真实业务里的典型姿势——从原始数据到聚合结果全程结构清晰、执行高效。5.3 用 deque 实现“最近浏览记录”再给一个贴近生活的例子实现一个只保留最近 5 条浏览记录的功能。from collections import deque history deque(maxlen5) def visit(page): history.append(page) visit(首页) visit(商品列表) visit(商品详情) visit(支付页面) visit(订单页) visit(个人中心) # 此时“首页”被挤出 print(history) # deque([商品列表, 商品详情, 支付页面, 订单页, 个人中心], maxlen5)一行deque(maxlen5)就实现了“收割最旧记录、自动扩容上限”的需求不需要任何 if 判断。这种容器自带的特性用 list 写反而更容易出错。5.4 namedtuple 在导入数据里的用法最后补一个很常见的场景程序从 CSV 里读数据每一行是一个固定结构的记录。用 namedtuple 定义字段后后续代码可读性会好很多。from collections import namedtuple StockPrice namedtuple(StockPrice, [code, date, open, close, volume]) prices [ StockPrice(600001, 2024-03-01, 10.5, 10.8, 1200000), StockPrice(600001, 2024-03-02, 10.8, 10.6, 980000), ] for p in prices: change p.close - p.open print(f{p.code} {p.date} 涨跌 {change:.2f})字段名直接挂在对象上比row[3]这种写法清晰太多。如果有人觉得定义 class 太重namedtuple 就是那个薄而合适的替代方案。6. 常见问题与排查技巧实录6.1 为什么会出现“unhashable type”报错要使用 dict 的键或者 set 的元素对象必须可哈希。所谓可哈希就是对象有固定的哈希值且不可变。list、dict 本身是可变对象所以不能放进 set也不能作为 dict 的键。调试时遇到TypeError: unhashable type: list第一反应是看自己是不是把可变类型传到了 set 或 dict 键的位置。解决方式通常是把内部 list 转成 tupledata [[1, 2], [1, 2], [3, 4]] # 转 tuple 再去重 unique set(tuple(x) for x in data) print(unique) # {(1, 2), (3, 4)}反过来如果你发现“两个明明看起来一样的 list却匹配不上”也要检查是不是 list 没转 tuple导致哈希值对不上。6.2 拷贝陷阱改了一个“副本”原数据也变了新手最容易翻车的点把一个 list 赋给另一个变量修改新变量结果原变量也被改了。a [1, 2, 3] b a # 这只是复制了引用两个名字指向同一块内存 b.append(4) print(a) # [1, 2, 3, 4]原因在于 Python 的变量名是“贴纸”不是“盒子”。b a只是把 b 这张贴纸贴到 a 指向的对象上。想要真正的独立副本用切片或 copya [1, 2, 3] b a[:] # 浅拷贝新列表 b.append(4) print(a) # [1, 2, 3]注意浅拷贝只拷贝外层如果列表里嵌套了 list内层 list 还是共享的。需要完全独立时可以用copy.deepcopy。dict 的拷贝同理dict(d)是浅拷贝copy.deepcopy(d)才是深拷贝。6.3 for 循环遍历时修改容器的标准解法我前面简单提过遍历 list 时删除元素的问题这里再补一个 dict 的版本。scores {Alice: 85, Bob: 55, Charlie: 92, David: 48} # 错误示范遍历中删除会报 RuntimeError # for name, score in scores.items(): # if score 60: # del scores[name] # 正确做法基于键列表遍历 for name in list(scores.keys()): if scores[name] 60: del scores[name] print(scores) # {Alice: 85, Charlie: 92}list(scores.keys())先把所有键取出来放到一个新的 list 里再基于这个备份进行删除就不会影响遍历过程。或者干脆用字典推导式重建一个过滤后的新字典。6.4 万金油技巧该用 in 的时候别用 count判断一个元素是否在容器里很多人习惯用list.count(x) 0这是低效写法。count 会把整个列表遍历完统计次数而x in list找到第一个匹配就返回了。如果是 set 和 dictin操作更是 O(1)。# 低效 if nums.count(99) 0: print(存在) # 高效 if 99 in nums: print(存在) # 如果是 set查找更是瞬间完成 s set(nums) if 99 in s: print(存在)写代码时形成这个习惯数据量上来之后能避免很多无谓的卡顿。6.5 面试和考试中容易混淆的考点后台很多人搜“数据结构与算法知识点归纳”“408 数据结构考研知识点”我顺带说几个 Python 语境下最容易把人绕晕的考点对照区分就不会错对比项关键结论list vs tuple可变 vs 不可变tuple 可作为 dict 键list vs dequelist 头部插入 O(n)deque 两端 O(1)dict vs set同样是哈希表dict 有键值对set 只有键浅拷贝 vs 深拷贝浅拷贝共享内层对象深拷贝完全独立sort()vssorted()sort 原地改sorted 返回新列表推导式 vs 循环推导式更简洁但复杂逻辑还是用循环更可读如果你能在不用查资料的情况下把这几个对比用通俗的语言讲清楚说明你已经真正掌握了 Python 数据结构的基础。而不是只背住了答案遇到实际问题还是不知道选哪个。最后说点掏心窝的体会。我在实际项目里踩过最亏的一次坑就是早期拿 Python 的 list 去模拟队列在头部频繁insert(0, x)结果数据量到几万条的时候程序明显卡顿当时还以为是业务逻辑有问题排查半天才发现是数据结构选错了。换成 deque 之后同一段逻辑从肉眼可见的延迟变成瞬间完成。那次之后我彻底明白数据结构不是用来应付考试的概念而是写代码时最先要想清楚的决策。下次再遇到性能问题别急着优化算法细节先回头看看手上的数据到底是什么结构、有没有更合适的容器。多留个心眼少踩一个坑。
网站建设高端定制企业官网