08_Python字典与集合:键值存储、去重与快速查找
Python字典与集合:键值存储、去重与快速查找
假设你在图书馆找一本书。如果不按分类、书名、作者来检索,而是从第一排书架的第一本书开始逐本翻——这个图书馆有十万本书——等你找到想看的书时,图书馆可能已经关门了。
列表就像逐本翻阅:你要找某个元素,最坏情况下需要遍历全部。而字典就像图书馆的检索系统:你给出书名,系统立刻告诉你"它在第 3 排第 2 层"。不管图书馆有一万本还是一百万本书,查找时间几乎不变。
这就是字典的力量。它背后的数据结构叫哈希表,是计算机科学里最重要的发明之一。这篇把字典和它的近亲集合彻底讲清楚。
字典是什么
字典是一组键值对的集合。每个键对应一个值,通过键来查找值——而不是通过位置。
# 字典用花括号 {}
student = {
"name": "小明",
"age": 20,
"score": 85,
"is_passed": True
}
# 通过键来访问值
print(student["name"]) # "小明"
print(student["score"]) # 85
用真实的例子来感受字典和列表的差别:
# 列表:按位置存储,要知道"第几个"
students_list = ["小明", "小红", "小刚"]
print(students_list[0]) # 小明——你得记住位置
# 字典:按键存储,用有意义的名字
students_dict = {"001": "小明", "002": "小红", "003": "小刚"}
print(students_dict["001"]) # 小明——用学号直接查,语义清晰
列表是"第一个、第二个、第三个"。字典是"名字叫XXX的那个"。大多数情况下,字典的表达方式更符合人的思维方式——我们找人时说的是"找小明",不是"找第 3 个人"。
创建字典
# 直接写
d1 = {"a": 1, "b": 2}
# dict() 构造函数
d2 = dict(name="小明", age=20) # 关键字参数
d3 = dict([("a", 1), ("b", 2)]) # 从键值对列表
d4 = dict(zip(["a", "b"], [1, 2])) # 从两个列表配对
d5 = {k: k ** 2 for k in range(5)} # 字典推导式
# 空字典
d6 = {}
字典的核心操作
student = {"name": "小明", "age": 20}
# 访问
print(student["name"]) # 键不存在时报 KeyError
print(student.get("score")) # 键不存在时返回 None(不报错)
print(student.get("score", 0)) # 键不存在时返回默认值 0
# 添加或修改
student["score"] = 85 # 键不存在就添加,存在就修改
student["age"] = 21 # 修改已有键的值
# 删除
del student["age"] # 删除键值对
value = student.pop("score") # 删除并返回被删的值
value = student.pop("score", 0) # 键不存在时返回默认值 0
# 检查键是否存在
if "name" in student:
print("有名字字段")
get() 是处理"可能不存在的键"的最佳方式。新手常写 if key in dict: value = dict[key],但实际上 value = dict.get(key, default) 一行搞定。
遍历字典
student = {"name": "小明", "age": 20, "score": 85}
# 遍历键(默认)
for key in student:
print(key, student[key])
# 遍历值
for value in student.values():
print(value)
# 遍历键值对(最常用)
for key, value in student.items():
print(f"{key} = {value}")

图8-1 字典哈希表结构:键通过哈希函数映射到存储位置,查找时间接近常数,与字典大小几乎无关。
字典的键为什么必须是不可变的
字典的键必须是**不可变的(可哈希的)**类型:字符串、数字、元组可以作为键;列表、字典、集合不能。
# 合法的键
d = {
"name": "小明", # 字符串
42: "答案", # 数字
(116.4, 39.9): "北京" # 元组
}
# 不合法的键
# d = {[1, 2]: "value"} # TypeError: unhashable type: 'list'
原因在于字典的底层实现。Python 对键做哈希运算,算出存储位置。如果键是可变的(比如列表),存入后键发生了变化,哈希值也变了,Python 就找不到原来的数据了。不可变类型保证了键的哈希值永远不会变——这是字典高效查找的根本保障。
从使用角度你只需要记住:能用字符串当键就优先用字符串——最直观、最不容易出错、最利于 JSON 序列化。
collections 里的字典增强工具
defaultdict:不用手动初始化
普通字典在不存在的键上累加时会报错:
# 普通字典计数——很啰嗦
text = "hello world"
char_count = {}
for char in text:
if char not in char_count:
char_count[char] = 0
char_count[char] += 1
defaultdict 让你指定默认值类型,不存在的键自动用默认值:
from collections import defaultdict
char_count = defaultdict(int) # 默认值 0
for char in "hello world":
if char != " ":
char_count[char] += 1
print(dict(char_count)) # {'h': 1, 'e': 1, 'l': 3, 'o': 2, 'w': 1, 'r': 1, 'd': 1}
defaultdict(list) 是最常用的模式之一——分组数据时不用判断键是否存在:
# 把学生按班级分组
students = [
("小明", "1班"), ("小红", "2班"), ("小刚", "1班"), ("小美", "2班")
]
by_class = defaultdict(list)
for name, cls in students:
by_class[cls].append(name)
print(dict(by_class))
# {'1班': ['小明', '小刚'], '2班': ['小红', '小美']}
Counter:专门用于计数的字典
from collections import Counter
# 统计单词频率
words = ["apple", "banana", "apple", "orange", "banana", "apple"]
word_count = Counter(words)
print(word_count) # Counter({'apple': 3, 'banana': 2, 'orange': 1})
print(word_count.most_common(2)) # [('apple', 3), ('banana', 2)] 最常见的 2 个
# Counter 可以直接相加
c1 = Counter(["a", "b", "a"])
c2 = Counter(["a", "c"])
print(c1 + c2) # Counter({'a': 3, 'b': 1, 'c': 1})
手写一个词频统计需要七八行代码,Counter 一行搞定。处理日志分析、投票统计、文本处理时它是第一选择。
字典推导式
# 从两个列表构建字典
keys = ["name", "age", "score"]
values = ["小明", 20, 85]
student = {k: v for k, v in zip(keys, values)}
print(student) # {'name': '小明', 'age': 20, 'score': 85}
# 交换键和值
original = {"a": 1, "b": 2, "c": 3}
swapped = {v: k for k, v in original.items()}
print(swapped) # {1: 'a', 2: 'b', 3: 'c'}
# 条件过滤
scores = {"小明": 85, "小红": 92, "小刚": 78, "小美": 95}
top_students = {name: score for name, score in scores.items() if score >= 90}
print(top_students) # {'小红': 92, '小美': 95}
集合:无重复、无序的数据容器
集合是一种特殊的数据结构——它只关心"有没有",不关心"第几个"或"出现了几次"。集合里的元素是唯一的、无序的。
# 创建集合
fruits = {"苹果", "香蕉", "橙子"} # 花括号,但没有键值对
chars = set("hello") # {'h', 'e', 'l', 'o'} 自动去重
nums = set([1, 2, 3, 2, 1, 3]) # {1, 2, 3} 自动去重
# 核心操作
fruits = {"苹果", "香蕉", "橙子"}
fruits.add("葡萄") # 添加
fruits.remove("香蕉") # 删除(不存在会报错)
fruits.discard("西瓜") # 删除(不存在也不报错)
print("苹果" in fruits) # True 快速成员检查
print(len(fruits)) # 集合的大小
集合运算——数学里的交集并集差集
这是集合最强大的功能。你不需要写嵌套循环去对比两个列表——用集合运算一行搞定:
python_students = {"小明", "小红", "小刚", "小美"}
java_students = {"小刚", "小美", "小李", "小王"}
# 交集:两门都报了的人
both = python_students & java_students # {'小刚', '小美'}
both = python_students.intersection(java_students)
# 并集:所有报过课的人
all_students = python_students | java_students
# {'小明', '小红', '小刚', '小美', '小李', '小王'}
# 差集:只报了 Python 没报 Java 的人
only_python = python_students - java_students # {'小明', '小红'}
# 对称差集:只报了一门的人
only_one = python_students ^ java_students # {'小明', '小红', '小李', '小王'}

图8-2 集合运算:交集(&)、并集(|)、差集(-)、对称差集(^)四种运算的韦恩图对比。
列表去重的最快方式
items = [1, 2, 2, 3, 3, 3, 4, 4, 4, 4]
unique = list(set(items))
print(unique) # [1, 2, 3, 4] 顺序可能不保持!
注意:set() 不保证顺序。如果你需要去重且保持原顺序,用 dict.fromkeys():
items = [3, 1, 2, 1, 3, 2, 4]
unique_ordered = list(dict.fromkeys(items))
print(unique_ordered) # [3, 1, 2, 4] 保持第一次出现的顺序
集合运算的实际应用——比你想的更常见
集合的交并差运算不是数学课上的抽象概念——它们在日常编程中比你想象中更频繁地出现。社交网络中的"共同好友"功能本质上就是两个集合的交集。推荐系统中的"猜你喜欢"有时候就是"与你相似的人喜欢了什么"——集合的差集。黑名单过滤就是"发送名单减去黑名单"。
用列表来实现同样的逻辑需要嵌套循环——外层遍历一个列表,内层检查是否在另一个列表中。时间复杂度 O(N×M)。用集合的交并差,Python 在底层用哈希表实现,时间复杂度接近 O(N+M)。数据量越大,这个差异越明显。这就是选择正确数据结构带来的实实在在的性能红利。
任何时候你发现自己在写"遍历 A,检查是否在 B 中"的模式,停下来想一想——是不是应该把 B 转成集合?在 1000 条数据里,性能差距可能是 1000 倍。养成这个习惯,你的代码从"能跑"升级到"跑得快"。
集合操作实战——两个实际场景的完整代码
场景一:找出两个班级报相同社团的学生
class_a_clubs = {"小明": {"编程", "篮球"}, "小红": {"编程", "舞蹈"}, "小刚": {"篮球", "音乐"}}
class_b_clubs = {"小李": {"编程", "足球"}, "小王": {"舞蹈", "音乐"}, "小赵": {"编程", "篮球"}}
# 找出所有社团(并集)
all_clubs = set()
for clubs in class_a_clubs.values():
all_clubs |= clubs
for clubs in class_b_clubs.values():
all_clubs |= clubs
print(f"所有社团:{all_clubs}")
# 找出两个班都有的社团(交集)
clubs_a = set().union(*class_a_clubs.values())
clubs_b = set().union(*class_b_clubs.values())
common_clubs = clubs_a & clubs_b
print(f"两个班都有的社团:{common_clubs}")
场景二:用集合去重并保持顺序
def deduplicate_keep_order(items):
"""去重但保持第一次出现的顺序"""
seen = set()
result = []
for item in items:
if item not in seen:
seen.add(item)
result.append(item)
return result
data = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5]
print(deduplicate_keep_order(data)) # [3, 1, 4, 5, 9, 2, 6]
这个模式在数据清洗中非常常见——你需要去重,但顺序对后续处理很重要。Python 3.7+ 也可以用 list(dict.fromkeys(data)) 一行搞定,因为字典保持了插入顺序。
这个选型决策每天都会遇到。一个简单的决策框架:
| 需求 | 用什么 | 为什么 |
|---|---|---|
| 按位置访问,顺序重要 | 列表 | 列表是有序的,索引天然表达位置 |
| 按键名查找,键有意义 | 字典 | 字典通过键查找,时间复杂度 O(1) |
| 只要不重复,不关心顺序 | 集合 | 自动去重 + 快速成员检查 |
| 需要统计频次 | Counter | 专门为计数优化的字典 |
| 需要分组 | defaultdict(list) | 省去手动初始化的代码 |
| 需要一个简单的记录结构 | 字典 / namedtuple | namedtuple 更省内存,字典更灵活 |
日常编码中最常见的搭配是:列表存"一组相似的数据",字典存"需要按键查找的记录",集合在做"去重"和"交并差"运算时登场。
实战:用字典和集合解决实际问题
程序一:学生信息管理
def student_manager():
"""用字典管理学生信息,实现查找和统计"""
students = {
"001": {"name": "小明", "age": 20, "scores": [85, 92, 78]},
"002": {"name": "小红", "age": 21, "scores": [92, 95, 88]},
"003": {"name": "小刚", "age": 20, "scores": [78, 82, 80]},
}
# 查找学生
sid = input("输入学号:").strip()
student = students.get(sid)
if student is None:
print("学号不存在")
else:
avg = sum(student["scores"]) / len(student["scores"])
print(f"{student['name']},{student['age']}岁,平均分 {avg:.1f}")
# 统计各班人数
by_age = defaultdict(list)
for sid, info in students.items():
by_age[info["age"]].append(info["name"])
for age, names in sorted(by_age.items()):
print(f"{age}岁:{', '.join(names)}")
# student_manager()
程序二:共同好友查找
def find_mutual_friends():
"""找出两个人的共同好友"""
alice_friends = {"Bob", "Charlie", "David", "Eve"}
bob_friends = {"Charlie", "Eve", "Frank", "Grace"}
mutual = alice_friends & bob_friends
only_alice = alice_friends - bob_friends
only_bob = bob_friends - alice_friends
all_friends = alice_friends | bob_friends
print(f"共同好友:{mutual}")
print(f"Alice 独有:{only_alice}")
print(f"Bob 独有:{only_bob}")
print(f"所有好友:{all_friends}")
print(f"好友总数:{len(all_friends)}")
# find_mutual_friends()
程序三:词频统计器
from collections import Counter
import re
def word_frequency(text):
"""统计英文文本中的词频"""
words = re.findall(r'\b\w+\b', text.lower())
freq = Counter(words)
print("最常见的 5 个单词:")
for word, count in freq.most_common(5):
print(f" {word}: {count} 次")
return freq
sample = """
Python is a programming language. Python is easy to learn.
It is widely used in data science and web development.
"""
word_frequency(sample)
字典在 Python 3.7+ 中是有序的
从 Python 3.7 开始,字典保证插入顺序——你按什么顺序添加键,遍历时就按什么顺序出来。这在实际编码中非常有用——你不再需要 collections.OrderedDict 来保持顺序。
d = {}
d["c"] = 3
d["a"] = 1
d["b"] = 2
print(list(d.keys())) # ['c', 'a', 'b'] 按插入顺序
不过要注意:不是说字典"像列表一样有序"——你不能通过索引来访问字典。它只是保证迭代顺序和插入顺序一致。这个特性是 CPython 3.6 的实现细节,3.7 起成为语言规范。
集合的性能优势
集合和字典一样基于哈希表,所以它的成员检查速度也是 O(1)。当你需要反复检查"某个元素在不在"时,集合远比列表高效:
# 在 10 万个数据中检查 1000 个目标
big_list = list(range(100000))
big_set = set(range(100000))
targets = range(0, 1000)
# 用列表:每次都要遍历查找,慢
# for t in targets:
# if t in big_list:
# pass
# 用集合:每次查找都是 O(1),快得多
# for t in targets:
# if t in big_set:
# pass
当你的程序在处理成百上千次成员检查时,把列表转换成集合能带来数量级的性能提升。

图8-3 字典与集合应用场景:字典适合键值查找和结构化记录,集合适合去重和集合运算。
从零实现一个"词频统计器"——感受字典的真正力量
学了一堆字典操作,不如写一个真正有用的程序。搜索引擎、推荐系统、文本分析的底层逻辑,都依赖于"统计每个词出现了多少次":
from collections import Counter
import re
def analyze_text(text):
words = re.findall(r'\b\w+\b', text.lower())
freq = Counter(words)
print(f"总词数:{len(words)},不重复词数:{len(freq)}")
print(f"\n最常见的 10 个单词:")
for word, count in freq.most_common(10):
print(f" {word:<15} {'█' * min(count, 30)} {count}")
return freq
sample = "Python is powerful and fast. Python is easy to learn. Python everywhere."
analyze_text(sample)
运行后你会看到 “python” 出现了 3 次、“is” 出现了 2 次……这个简单的统计背后,Counter 内部维护了一个键为单词、值为计数的字典。没有 Counter 之前,你需要写 if word in freq: freq[word] += 1 else: freq[word] = 1,现在一行搞定。选择正确的数据结构,代码就是你意图的直译而不是充满了索引和循环细节。
动手练习
-
字典合并实验:用三种方式合并两个字典(
update()、{**d1, **d2}、d1 | d2(Python 3.9+)),分别计时 100 万次合并操作,比较三种方式的性能差异。然后用sys.getsizeof()比较合并前后的内存占用。 -
字母频次统计:读取一段中文或英文文本,用
Counter统计每个字符(或字母)的出现次数,输出前 10 个最高频的。 -
选课冲突检测:给两个学生各自选了哪些课(用集合表示),找出他们选了相同的课(交集)、只有 A 学生选的课(差集)、两人选的所有课(并集)。
-
电话号码簿:用字典实现一个电话簿,支持添加联系人、按名字查找号码、按号码查找名字、删除联系人。用
get()优雅地处理不存在的联系人。 -
用 defaultdict 分组:有一组商品数据,每个商品包含名称和分类。用
defaultdict(list)按分类分组商品,输出每个分类下的商品列表。 -
集合去重性能对比:创建一个包含 10 万个元素的列表(用
list(range(50000)) * 2生成大量重复),分别用"循环 + 列表"和set()两种方式去重。用time.perf_counter()计时,比较两者速度差距。你会直观感受到为什么能选集合就不要手写循环。
字典的"瑞士军刀"——get、setdefault、update 和字典合并
除了 get(),字典还有几个不常用但威力巨大的方法。掌握它们能让很多常见操作从四五行代码变成一行:
# setdefault:如果键不存在,设置并返回默认值;如果存在,返回现有值
word_count = {}
for word in "hello world hello python".split():
word_count.setdefault(word, 0)
word_count[word] += 1
# update:批量更新字典(覆盖已有键,添加新键)
config = {"host": "localhost", "port": 8080}
config.update({"port": 9090, "debug": True}) # port 被覆盖,debug 被添加
print(config) # {'host': 'localhost', 'port': 9090, 'debug': True}
# Python 3.9+ 字典合并运算符
d1 = {"a": 1, "b": 2}
d2 = {"b": 3, "c": 4}
merged = d1 | d2 # {'a': 1, 'b': 3, 'c': 4} ——后者覆盖前者
d1 |= d2 # d1 现在被合并了(原地修改)
setdefault 在处理嵌套字典时尤其强大。比如你要给一个不存在的键初始化一个列表——data.setdefault("students", []).append("小明"),一行代码完成了"如果键不存在则创建空列表,然后添加元素"的完整逻辑。相比传统的 if key not in data: data[key] = []; data[key].append(item),简洁度和可读性都提升了不止一个档次。
字典和列表的性能差异——为什么"用空间换时间"是值得的
列表查找一个元素需要从头到尾逐个比对,最坏情况下检查完所有元素才能确认"不存在"。如果你有 10 万条数据,用列表做成员检查可能要做 10 万次对比。而字典和集合在查找时只做一次哈希计算和一次索引查找,几乎和数据量无关——1 条数据和 100 万条数据,"在不在"这个问题的回答速度几乎一样快。
字典的这个能力不是免费的——它用更多的内存来换取查找速度。这就是经典的计算机科学权衡:空间换时间。你的手机通讯录用的是类似字典的结构——输入"妈妈"立刻跳出号码,而不是从第一个联系人逐个翻找。
在日常编程中,这个差异意味着:当你需要频繁查找(“这个用户是否在黑名单里”“这个商品编号是否已存在”),第一时间应该想到集合或字典。用列表来干同样的事,在小数据量时没有问题,但数据量一上去就会成为性能瓶颈。养成用正确数据结构的好习惯,比后期优化代码要省力得多。实际上,许多程序性能问题的根源都不是"算法不够优化",而是"用错了数据结构"——把应该用字典的查找任务交给了列表,把应该用集合的去重任务交给了嵌套循环。花一点时间在选对数据结构上,比你后期花大量时间在优化算法上要高效得多。数据结构选对了,代码几乎自然就是高效的。
现实中的字典应用——三个你每天在用的例子
你可能意识不到,但你每天都在使用基于字典数据结构的服务。微信的通讯录——名字到微信号的映射,就是字典。浏览器的缓存——URL 到网页内容的映射,也是字典。数据库的索引——字段值到存储位置的映射,本质上还是字典的各种变种。
在 Python 编程中,字典最常见的三个应用场景是:数据聚合(按某个字段分组统计)、缓存(存下计算结果避免重复计算)、配置管理(键值对形式的应用参数)。学到第 12 篇处理 JSON 数据、第 18 篇使用第三方 API 时,你会反复用到字典。它是你编程工具箱里使用频率仅次于列表的数据结构。
本篇要点回顾:
- 字典是键值对集合,用键来查找值 O(1),适合"按名字找数据"的场景
- 字典的键必须是不可变类型——字符串、数字、元组;列表不能做键
dict.get(key, default)比if key in dict更优雅defaultdict省去手动初始化,Counter是计数的瑞士军刀- 集合是无重复元素的容器,成员检查和去重极快
- 集合支持数学运算:交集
&、并集|、差集-、对称差^ - Python 3.7+ 字典保持插入顺序
下一篇我们攻克字符串处理——切片进阶、格式化全解、编码问题、正则表达式入门,以及二十几个你必须掌握的字符串方法。
更多推荐



所有评论(0)