
做数据管理这件事我在Python里折腾了挺多年最常被问起的其实就是两个基础数据结构字典和集合。很多人觉得dict只是“能存键值对的列表”set只是“会自动去重的列表”这么用当然没错但你只发挥出了它们不到一半的功力。字典和集合共同建立在哈希表之上这才是一旦理解之后写代码整个手感都不一样的东西查找、去重、分组、比对都变得干净利落。这篇文章就写写我怎么理解Python字典与集合怎么把它们的特性用在实际数据场景里。适合刚学Python想搞懂底层逻辑的初学者也适合写了几年Python但发现“明明用了字典代码还是很慢”的进阶玩家。我会把原理、常用方法、踩过的坑和可以直接复制的代码段落都放进去尽量用大白话把事情讲透。1. 为什么字典和集合是“高效数据管理”的核心1.1 从“查找”这个操作说起数据管理绕不开增删改查。列表和元组擅长顺序访问但查找这件事做得并不好。举个例子你有个一万条订单信息的列表要找出订单号是A10086的那条列表会从头到尾一个个比运气好第一个就是运气差最后一个才是平均下来要比较五千次。当数据量从一万涨到一百万这个线性扫描的耗时也跟着暴涨很快到用户没法忍的程度。字典和集合不一样。它们背后是哈希表一个键进来先算哈希值再通过哈希值直接算出数据存放的位置压根不需要从头遍历。这个差距在数量上来之后极其恐怖列表找一百万条数据可能要好几十毫秒甚至更久字典和集合通常是微秒级快几个数量级。我做爬虫去重、用户标签快速匹配时整个性能瓶颈往往不在“找”而在“要不要找”只要决定用set还是dict问题就解决了一半。用生活类比的话列表查找就像你在图书馆里没有目录索引只能从A区一本一本翻到Z区字典查找则是管理员按索书号直接告诉你第几排第几格。后者当然快。也正因为这个差异任何需要“根据某个键快速拿到对应值”的场景第一反应都应该是字典而不是列表。1.2 哈希表字典和集合共同的底层秘密很多教程会把“哈希表”和“字典”混着讲其实它们的关系是哈希表是一种底层数据结构字典是建立在哈希表之上的键值映射容器集合则是只存键不存值的哈希表。你也可以把集合理解成一个“只关心键是否存在不关心值”的字典。哈希表的工作原理并不复杂。想象你有一排固定数量的抽屉每个抽屉有编号。往哈希表里放apple: 5时Python会先对这个字符串调用内置哈希函数得到一个整数再用这个整数对抽屉数量取模决定放进第几个抽屉。取的时候再做一遍同样的计算直接去对应抽屉拿。抽屉数量如果不够了就扩容重排相当于换一排更大的柜子把东西都挪过去。这里有一个避不开的话题哈希冲突。两个不同的键可能算出同一个抽屉编号比如a和某个字符串恰好哈希后取模落到同一个格子。Python在字典和集合的实现里对冲突的处理是相当成熟的简单说就是按一定规则继续探测下一个空位保证数据不会丢。冲突多了会影响速度但工程上哈希函数足够均匀正常使用时我们基本不用操心。只是极端情况下如果有人恶意构造大量哈希值相同的字符串字典查找就会退化成接近O(n)的级别这也是为什么有些安全场景要小心不可信输入的原因——不过日常业务代码里这种概率低到可以忽略。弄懂哈希表之后你会自动明白很多现象为什么字典的键必须是不可变对象因为哈希值在存进去之后如果变了下次就算不出来了。为什么集合是无序的因为存储位置是由哈希值决定的不是由插入顺序决定的。这些都不是设定上的“毛病”而是哈希表本质带来的特性理解了原理你就能接受并利用它。1.3 字典、集合和列表元组的职责划分做一个简单粗暴的对比表方便选型数据结构底层实现查找复杂度是否有序是否可重复典型场景列表动态数组O(n)是允许保持顺序的序列、遍历元组不可变数组O(n)是允许固定字段记录、字典键字典哈希表O(1)平均3.7保持插入序键唯一键值映射、快速查找集合哈希表O(1)平均否元素唯一去重、交并差运算这个表能解决大多数选型纠结。需要顺序或按下标访问列表需要不可变固定结构元组需要按名字快速取值字典需要快速判断元素是否出现、不关心重复集合。在实际工作里我经常用一句话做决策“要查值用字典要查存在用集合要遍历且保持顺序用列表。”这句话简单但非常管用。2. 字典实操从创建到用对方法2.1 创建字典的几种方式字典的创建方式比很多人以为的要多挑合适的使用代码可读性和效率都不一样。# 方式1字面量最直观 user {name: 张三, age: 30} # 方式2dict() 构造 user2 dict(name李四, age25) # 方式3fromkeys批量造默认值 keys [a, b, c] default_dict dict.fromkeys(keys, 0) # {a: 0, b: 0, c: 0} # 方式4由成对序列构建 pairs [(x, 1), (y, 2)] d1 dict(pairs) # 方式5zip 两个列表 names [王五, 赵六] ages [28, 22] d2 dict(zip(names, ages)) # 方式6字典推导式 squares {i: i**2 for i in range(1, 6)}我日常用得最多的是字面量和推导式。fromkeys在初始化计数器或分组结构时特别好用不过要小心如果你给的值是可变对象比如dict.fromkeys(keys, [])那所有键会共享同一个列表往一个键里添加元素其他键也会看到变化这是经典坑。想要每个键独立列表请用后面的defaultdict或者字典推导式。2.2 键的选择比你想的更讲究字典的键必须是可哈希的也就是不可变对象。字符串、整数、浮点数、元组、frozenset都可以列表、字典、集合这些可变容器不行。因为键的哈希值在存入后一旦改变整个字典就会乱套。你试一下就会看到报错bad {} bad[[1, 2]] x # TypeError: unhashable type: list这个报错很多新手一看到就慌其实意思很明白list 不能当键。解决方案一般是把列表转成元组把集合转成frozenset或者干脆换一个键类型。还有一个非常隐蔽的坑布尔值和整数共享哈希槽。在Python里True和1、False和0的哈希值是相同的键视为相等。所以如果你写了{True: yes, 1: ok}后一个会覆盖前一个最终只有一个键值对。这种问题在数据清洗时特别容易出现比如你的数据里既有0又有False想分别统计结果统计串了。规避办法是统一类型要么全用整数要么全用字符串别混。2.3 取值的几种写法差别很大直接d[key]最直观但如果键不存在会直接抛KeyError程序就挂了。很多场景下你并不确定键存在这时候有get和setdefault两个方法。data {a: 1} # get不存在返回默认值不修改原字典 v1 data.get(a, 0) # 1 v2 data.get(not_exist, 0) # 0 # setdefault不存在时设置默认值并返回存在则返回原值 v3 data.setdefault(b, []) v3.append(1) # 此时 data {a: 1, b: [1]}get适合只读场景比如配置项读取setdefault适合“需要初始化容器再操作”的场景。举个例子你想把商品按分类装进列表用setdefault可以免去先判断键在不在的啰嗦代码groups {} for product in products: cat product[category] groups.setdefault(cat, []).append(product)不过说实话在需要大量“缺省即初始化”的场景我更推荐第三章讲的defaultdict它能从语法层面把这个逻辑藏起来。2.4 字典的顺序、合并与更新很多人还停留在“字典无序”的旧观念。CPython在3.6版本里实现上已经做到了保持插入顺序到了3.7更是把“字典保持插入顺序”写进了语言规范。所以现在就别再说“字典是无序的”了——我说的是普通字典不是集合。哈希表本身是散列的但Python额外用了一个机制来保留插入顺序代价只是多一点内存换来了迭代结果可预测这对调试和日志输出非常友好。合并字典也有新写法。3.9开始可以用|运算符base {host: localhost, port: 3306} extra {port: 5432, user: root} merged base | extra # {host: localhost, port: 5432, user: root}|返回新字典|则是原地更新。更早的版本一般用{**base, **extra}或update。区别是update会直接修改原字典适合一段代码里把配置逐步补全的场景而|适合纯函数式地合并两个配置不污染原对象。3. 集合把“是否存在”和“数据比对”做到极致3.1 集合与列表的本质差异集合的三个核心特性是元素唯一、元素必须可哈希、不保证顺序。这意味着它生来就是为了处理“存在性判断”和“去重”的。我经常看到有人用列表做去重写一段if item not in result: result.append(item)。数据量小没问题可一旦数据量大列表的in是O(n)整体复杂度O(n²)直接卡死。换成集合一行代码搞定unique_items list(set(items))当然有个小代价顺序会丢。如果去重后还要保留原始顺序用字典的插入有序特性来实现list(dict.fromkeys(items))。这里利用了“字典键唯一且有插入序”两个特性既不丢顺序又完成了去重是我在数据清洗里最常用的招之一。3.2 交并差补集一行代码完成数据比对集合最让列表羡慕的是内建的交并差运算。两个列表要找出都出现过哪些元素你写循环比对要写半天集合只需要一个符号a {admin, alice, bob, charlie} b {alice, charlie, dave} print(a b) # 交集 {alice, charlie} print(a | b) # 并集 {admin, alice, bob, charlie, dave} print(a - b) # 差集在a但不在b {admin, bob} print(a ^ b) # 对称差只在其中一个集合里的 {admin, bob, dave}这在实际数据管理中太常用了。比如你有授权用户表和白名单表求合法交集你有两份日志的IP求共同出现的IP你有两批商品的SKU找出只在旧批次没在新批次出现的SKU等等。以前这些比对逻辑要用多层循环现在一个运算符就好了读代码的人也一目了然。还有子集判断a b判断a是不是b的子集a b是严格子集权限校验时非常直观。需要注意这些运算返回的是新集合不会修改原来的集合。如果你想原地更新用update、intersection_update、difference_update这些方法。大部分场景我不建议原地更新因为会丢失原数据但内存紧张的时候确实能省点空间。3.3 集合去重时的几个注意点第一大量不可哈希的数据不能直接进集合。比如你的数据里包含列表结构那set(data)会报unhashable type。这时候要么把内层列表转成元组要么明确放弃去重逻辑。第二集合的去重依赖哈希值和相等性如果对象实现了__eq__但没有正确实现__hash__会出现你意想不到的“以为一样但去不掉”或者“看起来不一样但被去掉”的情况。自定义类做集合成员时有条件就尽量用数据类(dataclass(eqTrue, frozenTrue))让框架帮你生成正确的哈希比自己手写省太多事。3.4 frozenset 的不可变妙用集合本身是可变的所以不能做另一个集合的元素也不能做字典的键。当你需要“一组固定的值作为一个整体来当键或去重”时用frozenset它是不可变集合可以哈希可以放心放到字典里或者嵌套进另一个集合。举个例子你要给用户打标签一个用户可以有多个标签你想知道“同时拥有{‘VIP’, ‘新手’}这两个标签”的用户有几个直接把这个标签组合作为键vip_newbie frozenset({VIP, 新手}) user_tags[vip_newbie] 1这样一层映射就把组合统计做完了不用先排序再拼字符串那种拐弯抹角的写法。4. collections模块字典和集合的“增强包”标准库的collections模块里有几个类型使用频率极高它们本质是字典和集合基础上的增强版本能帮你把很多“模板代码”省到只剩一行。4.1 defaultdict从此不再纠结 KeyErrordefaultdict在创建时指定一个工厂函数当访问不存在的键时它会自动创建默认值再返回不会抛KeyError。最常见的三个用法from collections import defaultdict # 1. 按分类分组 groups defaultdict(list) for product in products: groups[product[category]].append(product) # 2. 计数 count defaultdict(int) for word in words: count[word] 1 # 3. 嵌套字典 nested defaultdict(dict) nested[user1][age] 30 # 不会因为user1不存在而报错注意嵌套默认值有个连环坑如果写成defaultdict(defaultdict)里面的字典没有工厂函数还是会报错。多层结构一般用defaultdict(lambda: defaultdict(list))或者直接defaultdict(lambda: defaultdict(int))用lambda把每一层的工厂函数给全。defaultdict还有一个细节访问不存在的键会创建它所以用if x in dd判断键是否存在时并不会创建但直接dd[x]哪怕只是读取也会创建默认值。这可能导致字典里出现一些你本不想加的垃圾键。如果不想有这种副作用用get或不带默认值的普通字典会更稳妥。4.2 Counter一键做词频统计Counter是专门为计数设计的字典子类。传统计数你要么手写循环加判断要么用defaultdict(int)有了Counter直接一步from collections import Counter words [apple, banana, apple, orange, apple] c Counter(words) print(c[apple]) # 3 print(c.most_common(2)) # [(apple, 3), (banana, 1)]Counter还支持加减运算两个Counter相加会把相同键的计数合并做多份报表汇总时特别方便。most_common在取TopN场景里是最常用的方法。处理日志分析、词频、标签统计时我基本不手写计数字典了。要小心的是Counter访问不存在的键不会报错返回0这是好事但如果你把它当作普通字典想通过d[key] 0来显式清零它可以做到只是在迭代时那些计数为0的键会被忽略这可能带来一些微妙差异。另外Counter并不是为了存储负数而设计的内部逻辑虽然允许most_common和加减运算在负数时行为会比较奇怪建议计数类数据永远保持非负。4.3 OrderedDict 与 ChainMap 的适用场景前面说了Python 3.7之后普通字典已经有序这会让很多人觉得OrderedDict没用了。其实它还保留了两个普通字典没有的能力move_to_end方法用来把某个键移到末尾这是实现LRU缓存、最近访问列表时的高手工具。比如你要做一个“最近访问的项目”列表每次访问某个项目就把它移到末尾最后从头部弹出的就是最久没访问的。from collections import OrderedDict od OrderedDict() od[a] 1 od[b] 2 od.move_to_end(a) # 现在顺序是 b, aChainMap则是把多个字典包成一个可查询的视图适合做“多级配置覆盖”场景。默认配置是一份字典用户配置是一份字典查配置时优先用户配置没有再看默认配置ChainMap(user_config, default_config)一行搞定而且修改原字典时ChainMap会实时看到变化不用手工同步。5. 实战把字典和集合用到真正的数据场景里前面聊了原理和API这一章放几个可落地的实战场景都是我在真实项目里用过、跑过、优化过的代码模式。5.1 爬虫和采集场景的 URL 去重爬虫最基础的需求是别重复下载同一页面。最简单的方案是维护一个setvisited set() def should_fetch(url): if url in visited: return False visited.add(url) return Trueurl in visited是O(1)几百万URL也能扛得住。如果还想知道每个URL是何时被抓的、抓了多少次那就升级成字典visit_info {} # url - {count, last_time} def record_visit(url, now): info visit_info.get(url) if info: info[count] 1 info[last_time] now else: visit_info[url] {count: 1, last_time: now}这里能看到字典和集合的分工只判断“有没有”用集合需要关联额外信息用字典。很多同学一上来就用列表配合in判断几万条数据后就开始卡换成集合后立刻流畅这种优化几乎是零成本的。5.2 数据清洗按多字段分组统计清洗业务数据时常需要按多个维度做分组。比如订单数据里有城市和状态两个字段想统计每个城市每种状态的数量。低效写法是两层循环加无数条件判断高效写法是嵌套字典加defaultdict:from collections import defaultdict stats defaultdict(lambda: defaultdict(int)) for order in orders: city order[city] status order[status] stats[city][status] 1 # 读取数据 print(stats[上海][已完成])如果分组维度更多可以继续嵌套但嵌套超过三层建议改用元组做键的普通字典stats2 defaultdict(int) for order in orders: key (order[city], order[status], order[payment_method]) stats2[key] 1元组做键的好处是结构扁平生成报表时直接for key, count in stats2.items()很方便。相比之下嵌套字典取值时要连写stats[a][b][c]中间某个键不存在就容易报错还得加判断。用元组作为复合键是我做多维度统计时最推荐的方式。5.3 文本词频统计与 TopN统计一篇长文本里哪些词出现最多代码不超过五行from collections import Counter import re text open(article.txt, encodingutf-8).read() words re.findall(r\w, text.lower()) word_counts Counter(words) print(word_counts.most_common(10))这个例子虽然简短但背后体现的正是“字典是数据管理核心”的思想Counter继承了字典的一切能力加上了计数逻辑most_common内部本质就是对“键值对”按值排序再取前N。如果你追求快Counter已经足够如果追求更省内存更极致性能可以用heapq.nlargest但那是后话。日常处理几千篇文章的词频Counter完全不是瓶颈。5.4 简易缓存设计与函数结果复用缓存是字典的另一个高光用法。函数输入相同输出大概率相同那把输入映射到输出存起来下次直接查表。手动写是这样cache {} def calculate(key): if key in cache: return cache[key] result do_expensive_work(key) cache[key] result return resultPython标准库已经给了现成的装饰器functools.lru_cache它内部就是用一个有序字典实现了LRU淘汰策略。你只要加一行装饰器就能让递归、重计算型函数自动带缓存from functools import lru_cache lru_cache(maxsize128) def fib(n): if n 2: return n return fib(n-1) fib(n-2)在不涉及版权或安全的风险下凡是你发现同一个函数反复传同样的参数做同样的计算先考虑加缓存。实现上也可以看到lru_cache的内部有字典、也有有序性管理帮你把“高效数据管理”这几个字落到实处。6. 常见问题与避坑实录6.1 可变对象不能做键本质是什么报错TypeError: unhashable type: list是新手高频问题。本质原因前面讲过键的哈希值要稳定。列表可以随时增删元素哈希值没法稳定如果硬让它当键存进去之后哈希值变了下次查找就找不到。解决方法是转元组d {} d[tuple([1, 2])] ok不要觉得这个报错麻烦它恰恰是Python保护你不犯错的设计。写出这种代码通常意味着数据结构没想清楚回调一下思路用元组、字符串或frozenset问题自然解决。6.2 迭代字典或集合时千万别直接改这是另一个极常见崩溃d {a: 1, b: 2, c: 3} for k in d: if k b: del d[k] # RuntimeError: dictionary changed size during iteration原因很直接迭代器是基于当前哈希表的状态推进的边遍历边增删会让迭代器无所适从。正确做法是先把要删除的键收集到列表迭代结束后统一删或者直接迭代list(d.keys())创造快照for k in list(d): if k b: del d[k]集合也是一样的逻辑修改时要么新建集合要么先收集再批量操作。6.3 True、False 与 1、0 的哈希冲突这个坑隐蔽且伤害大。看看这段代码d {True: yes, 1: number} # 结果只有 {True: number}因为True 1成立hash(True) hash(1)在字典看来它们是同一个键后来的覆盖了先来的。反过来说错误示范也可能更致命你本来想统计0和False两种情况结果全部记到同一个键下。解决办法是统一类型。如果你统计的数据来自多个系统有的传0有的传False清洗的第一步就应把它们都转成同一种表示否则不只是字典任何基于等值语义的操作都会出问题。6.4 别用“in 列表”代替集合有一个我反复提醒别人的反模式if item not in huge_list: huge_list.append(item)你说它错吗逻辑没错。但它在大数据量下是灾难——in对列表是线性扫描每次判断都要从头走到尾。一万条时忍忍过去了十万条开始肉眼可见地卡百万条直接卡死。正确的做法是引入一个辅助集合seen set() unique_items [] for item in huge_list: if item not in seen: seen.add(item) unique_items.append(item)这是“用一个O(1)的集合来辅助O(n)的列表”用少量内存换取数量级上的速度提升。我之前在一条几千万行的日志里提取唯一用户ID这个模式的耗时从十几分钟降到几秒。数据管理的第一课就是别在不该用列表的场景里硬用列表。6.5 字典的浅拷贝陷阱d.copy()常被误当成深拷贝。实际它只复制了最外层嵌套的列表或字典还是原对象。改内层内容原字典也跟着变。测试一下a {x: [1, 2]} b a.copy() b[x].append(3) print(a) # {x: [1, 2, 3]}需要完全独立副本时用copy.deepcopy。不过在数据量特别大时深拷贝的成本很高与其到处深拷贝不如在设计数据结构时就把嵌套层次扁平化用元组键避免嵌套能从根上少踩这种坑。6.6 常见操作复杂度速查表操作字典集合插入O(1)平均O(1)平均删除O(1)平均O(1)平均查找O(1)平均O(1)平均遍历O(n)O(n)判断存在O(1)平均O(1)平均交并差-O(n)级别视运算内存占用较大含哈希表开销较小少存一份值看到这你应该明白字典和集合的“高效”不是玄学是底层选型的必然结果。代价是它们比列表更占内存因为哈希表需要额外空间来维持散列与冲突处理。数据量极大而内存紧张时需要在这两者之间权衡但绝大多数业务场景这个内存开销是完全值得的。最后分享一个我对字典和集合最深的体会写代码前先想清楚你的核心操作是“按键取数”还是“判断存在”还是“保持顺序遍历”选对数据结构后面所有逻辑都会顺手很多。我自己现在写任何脚本第一件事就是看有哪些数据要频繁查找只要出现“if x in something”的地方something基本就该是集合或字典的键而不是列表。还有一个小习惯不确定性能差异时就写个一万条数据的benchmark跑一下实测永远比猜可靠。