4-字典、元组、集合
约 7355 字大约 25 分钟
2026-10-30
在第三章中,学习了字符串和列表,能够用列表管理一批成绩、一批姓名,还开发了班级成绩管理小程序。但是回头看看例题3-2的代码,其实埋着两个隐患。第一个隐患:删除一名学生时,要同时执行names.pop(pos)和scores.pop(pos),把两个"平行列表"同步删一遍——万一程序里只删了其中一个,姓名和成绩就错位了,张三的成绩可能算到李四头上。第二个隐患:像学号、身份证号、出生日期这类信息,本来不应该被修改,可是列表拦不住手滑:一句student[0] = "2024002"就把学号改了,程序不会报任何错。
这两个隐患的根源是同一个:列表靠"位置"来组织数据,第0个是姓名、第1个是成绩,位置和数据之间的对应关系只存在于程序员的脑子里。本章要学习三种新的容器,正是从不同角度解决"数据组织"问题的:元组是"贴了封条"的序列,创建之后不可修改,从语法层面杜绝误改;字典用"键值对"组织数据,按名字存取,姓名和成绩永远绑在一起,再也不用维护两个平行列表;集合则像一份"不重复的花名册",天生擅长去重和关系统计。
本章按照"元组、字典、集合"的顺序展开:元组和列表是近亲,学起来最快;字典是本章的主角,也是后续学习的重中之重;集合压轴,用交并差运算收尾。第五章函数"返回多个值"、第六章模块的配置管理、第九章数据库的查询结果,都会大量用到本章的容器,务必学扎实。
【学习目标】
知识目标
理解元组的概念和不可变性,掌握元组的创建、访问与解包方法。
理解元组与列表的区别,掌握两者相互转换的方法。
理解字典的键值对概念,掌握字典的创建、访问、增加、修改和删除操作。
掌握字典的keys()、values()、items()三种遍历方式。
理解集合的无序性和唯一性,掌握集合的创建、增删与去重方法。
掌握集合的交集、并集、差集和对称差运算。
能力目标
能够根据数据的特点,在列表、元组、字典、集合中选对合适的容器。
能够运用字典管理"按名字存取"的数据,开发信息管理小程序。
能够运用嵌套字典描述多层的复杂数据结构。
能够运用集合完成名单去重和分组统计任务。
素养目标
培养数据安全意识,理解"不可变"带来的保护价值,学会用技术手段防止误操作。
培养工程思维,养成"先想清楚数据用什么容器装,再动手写代码"的习惯。
培养精益求精的工匠精神,体会"容器选得对,代码少一半"的编程智慧。
1.元组
1.1 什么是元组
第三章反复强调:列表是可变的,随时可以增删改,这很灵活,但有时反而是缺点。比如保存一名学生的学号、姓名、性别,这些信息一旦确定就不应该再变,可如果用列表来存,改起来毫无障碍——这就像把重要的文件放在一个不上锁的抽屉里,迟早出事。
元组(Tuple)就是为这种情况准备的。可以把元组想象成一个贴了封条的信封:把数据装进去、封好口之后,就再也不能增、删、改了,只能拆开来看。先来直观感受一下:
代码 4-1:初识元组
# 列表:随时可以改
scores = [85, 92, 78]
scores[0] = 90 # 没问题,随便改
# 元组:装进去就封存
student = ("2024001", "张三", "男")
print(student) # 输出:('2024001', '张三', '男')
print(type(student)) # 输出:<class 'tuple'>
# 想修改?直接报错
# student[1] = "李四" # 报错!TypeError: 'tuple' object does not support item assignment看到最后一行了吗?对元组的任何修改企图,Python都会当场拒绝并抛出TypeError。这个"不近人情"的设计恰恰是元组的价值:它把"这些数据不许改"从口头的约定变成了语法的强制,谁也改不了,包括三个月后忘了规矩的自己。
元组还有一个重要的隐藏优势:因为不可变,它可以作为字典的键(列表不行)。这一点等学到字典时再详细展开。此外,不可变还让元组更省内存、访问更快,不过对初学者来说,先把"保护数据"这个核心价值理解透就够了。
1.2 元组的创建与访问
元组的创建和列表几乎一模一样,只是把方括号换成圆括号。访问方式也完全通用——索引、切片、len()、in,第三章学的序列操作全部适用:
代码 4-2:元组的创建与访问
# 最常见:一对圆括号
point = (3, 5)
weekdays = ("周一", "周二", "周三", "周四", "周五")
# 陷阱:只有一个元素时,必须带逗号!
t1 = (1) # 这不是元组!只是带括号的数字1
t2 = (1,) # 这才是只有一个元素的元组
print(type(t1)) # 输出:<class 'int'>
print(type(t2)) # 输出:<class 'tuple'>
# 不写括号也能创建元组(靠逗号识别)
t3 = 1, 2, 3
print(t3) # 输出:(1, 2, 3)
# 访问:索引、切片、len、in,规则和列表完全一样
print(weekdays[0]) # 输出:周一
print(weekdays[-1]) # 输出:周五
print(weekdays[1:3]) # 输出:('周二', '周三')
print(len(weekdays)) # 输出:5
print("周六" in weekdays) # 输出:False
# 元组也有count()和index(),用法和列表相同
nums = (1, 2, 2, 3, 2)
print(nums.count(2)) # 输出:3
print(nums.index(3)) # 输出:3单元素的逗号陷阱是元组最著名的坑:(1)只是一个带括号的数字,(1,)才是元组。记住一个诀窍——决定"是不是元组"的其实是逗号,括号只是好看的包装。也正因为元组不可变,它没有append()、insert()、remove()这些修改类方法,只剩下count()和index()两个查询方法。
1.3 元组与列表的相互转换
元组和列表既然是一对近亲,互相转换自然很方便:list()把元组"拆封"成列表,tuple()把列表"重新封存"成元组。这套组合形成了一个实用的开发模式——数据平时用元组安全地存着,需要修改时转成列表改,改完再封回去:
代码 4-3:元组与列表的相互转换
# 元组 → 列表:需要修改时"拆封"
info = ("张三", 19, "计算机科学")
info_list = list(info)
info_list[1] = 20 # 变成列表后随便改
print(info_list) # 输出:['张三', 20, '计算机科学']
# 列表 → 元组:改完了"重新封存"
info_new = tuple(info_list)
print(info_new) # 输出:('张三', 20, '计算机科学')
# 字符串也能直接转元组:每个字符一个元素
chars = tuple("abc")
print(chars) # 输出:('a', 'b', 'c')学到这里,把元组和列表做一次全面对比,见表4-1。
表 4-1 元组与列表对比
| 对比项 | 列表 | 元组 |
|---|---|---|
| 定界符号 | 方括号 [ ] | 圆括号 ( ) |
| 可变性 | 可变,随时增删改 | 不可变,创建后封死 |
| 修改类方法 | append、insert、remove、pop等 | 没有修改类方法 |
| 查询方法 | count、index | count、index(相同) |
| 典型场景 | 动态数据:成绩单、购物车 | 固定数据:坐标、配置、学籍档案 |
| 作字典的键 | 不可以 | 可以(下一节讲到) |
选择标准一句话:数据要变用列表,数据不该变用元组。
1.4 元组的解包
元组有一个列表做不到的绝活——解包(Unpacking):把元组里的元素一次性、对号入座地分给多个变量,就像把信封里的东西一次倒在桌上,每样东西直接递到对应的人手里:
代码 4-4:元组解包
# 元组解包:一次取出所有元素,分给对应变量
point = (3, 5)
x, y = point
print(f"x = {x}, y = {y}") # 输出:x = 3, y = 5
# 等号右边的逗号表达式,本质上就是一个元组
a, b, c = 1, 2, 3 # 相当于 a, b, c = (1, 2, 3)
# 经典应用:一行代码交换两个变量的值
left, right = 10, 20
left, right = right, left # 右边先打包成元组,再解包给左边
print(left, right) # 输出:20 10
# 注意:变量个数必须和元素个数一致
# p = (1, 2, 3)
# x, y = p # 报错!ValueError: too many values to unpack解包的身影在后续章节会反复出现:第五章学习函数时会知道,函数"返回多个值",本质上返回的就是一个元组,接的时候用的就是解包;第六章还会见到pymysql连接数据库时传参的写法conn = pymysql.connect(db_config),那个也是解包的变形——把字典解包成关键字参数。现在把基本功练好,到时候就水到渠成了。
【例题4-1】学籍信息卡
用元组保存一名学生的固定学籍信息(学号、姓名、性别、出生日期),用解包取出各项信息,格式化输出一张学籍信息卡,并验证元组对误改的防护作用。
代码 4-5:学籍信息卡
# 学籍信息一旦确定就不应修改,用元组保存最合适
student = ("2024001", "张三", "男", "2006-05-12")
# 解包:一次取出所有信息
sid, name, gender, birthday = student
# 格式化输出档案卡
print("学籍信息卡".center(21, "="))
print("-" * 32)
print(f"学号:{sid}")
print(f"姓名:{name}")
print(f"性别:{gender}")
print(f"出生日期:{birthday}")
# 学号、出生日期不允许被修改
# student[0] = "2024002" # 报错!TypeError,从语法层面杜绝了误改程序运行效果如下:
========学籍信息卡========
--------------------------------
学号:2024001
姓名:张三
性别:男
出生日期:2006-05-12这个例题综合运用了元组的创建、解包和center()排版(第三章代码3-15学过)。可以看到,用元组保存固定信息,代码自带"防改保险":任何试图修改学号的代码都会在运行时立即暴露,而不是默默生效、留下一份错乱的数据。
2.字典
2.1 什么是字典
现在来正面解决开篇的第一个隐患:平行列表的错位问题。用列表存"姓名-成绩"这类成对的数据,就像用两本目录册查电话——先在第一本里查到"李四"排在第几位,再翻到第二本的同样位置去看他的号码,两本册子必须严格对齐,一旦有一本插进或划掉了一条,全部错位。
字典(Dictionary)的思路完全不同:它直接把"名字"和"数据"绑在一起存。查"李四"的成绩,就像查通讯录——翻到"李四"那一页,号码就写在名字旁边,根本不关心他排在第几行。来看对比:
代码 4-6:列表方案与字典方案对比
# 列表方案:先查位置,再用位置查数据
names = ["张三", "李四", "王五"]
scores = [85, 92, 78]
i = names.index("李四") # 第一步:找到"李四"的位置
print(scores[i]) # 第二步:用位置去查成绩,输出:92
# 字典方案:按名字一步到位
scores_dict = {"张三": 85, "李四": 92, "王五": 78}
print(scores_dict["李四"]) # 输出:92字典的基本单元是键值对(Key-Value Pair):键(Key)是数据的"名字",值(Value)是数据本身,中间用冒号连接。一个字典就是许多键值对的集合,用花括号{}包起来。字典有两条铁律:第一,键必须唯一,一个"李四"只能对应一个成绩(后写的会覆盖先写的);第二,键必须是不可变类型——字符串、数字、元组都行,列表不行,这也正是1.1节说"元组可以作字典的键"的原因。至于值,什么类型都可以,甚至可以又是一个字典(见2.6节)。
2.2 字典的创建与访问
代码 4-7:创建字典的三种方式
# 方式一:花括号字面创建,键:值 成对出现
scores = {"张三": 85, "李四": 92, "王五": 78}
print(scores) # 输出:{'张三': 85, '李四': 92, '王五': 78}
# 方式二:dict()函数,键作为关键字参数传入(此时键不用加引号)
person = dict(name="张三", age=19)
print(person) # 输出:{'name': '张三', 'age': 19}
# 方式三:从空字典开始,逐步填充(实际开发中最常用)
student = {}
student["姓名"] = "张三"
student["年龄"] = 19
print(student) # 输出:{'姓名': '张三', '年龄': 19}从Python 3.7开始,字典会记住键值对的写入顺序,输出时按写入顺序显示,所以上面最后一段先写"姓名"就先显示"姓名"。
访问字典要用"键"这个"名字"去取值。有两种方式:中括号加键,直接但脾气硬——键不存在就报错;get()方法,温和——键不存在时返回None或者指定的默认值:
代码 4-8:访问字典的两种方式
scores = {"张三": 85, "李四": 92, "王五": 78}
# 用[键]访问:最直接
print(scores["张三"]) # 输出:85
# 键不存在:当场报错
# print(scores["赵六"]) # 报错!KeyError: '赵六'
# get()方法:键不存在时返回None,不报错
print(scores.get("赵六")) # 输出:None
# get(键, 默认值):给个兜底值,更加友好
print(scores.get("赵六", 0)) # 输出:0
# len()统计键值对的个数
print(len(scores)) # 输出:3
# in判断"键"是否存在——注意查的是键,不是值
print("李四" in scores) # 输出:True
print(92 in scores) # 输出:False(92是值,不是键)两种访问方式怎么选?确定键存在就用中括号,写起来干脆;不确定键是否存在(比如查一个用户输入的名字),就用get(),程序更健壮。这和列表"先in再index"的道理是一样的:先判断、再操作。
2.3 字典的增加与修改
字典的增加和修改是同一个动作:用"键 = 值"赋值。键不存在,就是增加新键值对;键已存在,就是修改它的值。还以update()支持批量操作:
代码 4-9:字典的增加与修改
scores = {"张三": 85, "李四": 92}
# 键不存在:就是增加
scores["王五"] = 78
print(scores) # 输出:{'张三': 85, '李四': 92, '王五': 78}
# 键已存在:就是修改
scores["张三"] = 90
print(scores["张三"]) # 输出:90
# update():用另一个字典批量更新(有则改、无则加)
scores.update({"赵六": 88, "张三": 95})
print(scores) # 输出:{'张三': 95, '李四': 92, '王五': 78, '赵六': 88}对比一下列表就明白字典的体贴之处:列表想"按名字改成绩",要先index()找位置再赋值,两步操作;字典直接scores["张三"] = 90,一步到位,而且永远不用担心"找错位置"。
2.4 字典的删除
字典的删除和列表如出一辙:del语句按键删除,pop()按键删除并返回值,clear()全部清空:
代码 4-10:字典的删除
scores = {"张三": 85, "李四": 92, "王五": 78}
# del:删除指定的键值对
del scores["李四"]
print(scores) # 输出:{'张三': 85, '王五': 78}
# pop(键):删除并返回对应的值
removed = scores.pop("王五")
print(removed) # 输出:78
print(scores) # 输出:{'张三': 85}
# 删除不存在的键会报错,稳妥做法还是先判断
if "赵六" in scores:
scores.pop("赵六")
else:
print("赵六不在字典中")
# pop(键, 默认值):键不存在时返回默认值,不报错
print(scores.pop("赵六", "无此学生")) # 输出:无此学生
# clear():清空所有键值对
scores.clear()
print(scores) # 输出:{}注意一个重要区别:字典的删除按键进行,删掉"李四"这一个键值对,姓名和成绩同时消失——永远不会出现平行列表那种"名字删了、成绩还挂着"的错位问题。
2.5 字典的遍历
遍历字典有三种方式,分别取出键、值、键值对,实现在三个方法上。
keys()——遍历所有键
代码 4-11:用keys()遍历键
scores = {"张三": 85, "李四": 92, "王五": 78}
# keys()返回所有的键,逐个取出
for name in scores.keys():
print(name)
# 输出:
# 张三
# 李四
# 王五
# 补充:直接for遍历字典,默认遍历的就是键,效果相同
for name in scores:
print(name)values()——遍历所有值
代码 4-12:用values()遍历值
scores = {"张三": 85, "李四": 92, "王五": 78}
# values()返回所有的值,最适合做统计
total = 0
for score in scores.values():
total = total + score
print(f"总分:{total}") # 输出:总分:255
print(f"平均分:{total / len(scores):.2f}") # 输出:平均分:85.00items()——键值对一起遍历
代码 4-13:用items()遍历键值对
scores = {"张三": 85, "李四": 92, "王五": 78}
# items()把每个键值对打包成一个元组,配合1.4节的解包,一次拿到两个值
for name, score in scores.items():
print(f"{name}的成绩是{score}分")
# 输出:
# 张三的成绩是85分
# 李四的成绩是92分
# 王五的成绩是78分items()是三者中最常用的:键和值经常需要一起用,而它返回的每一项是一个(键, 值)元组,正好用元组解包接住——1.4节练的基本功在这里派上了用场。字典常用操作汇总见表4-2。
表 4-2 字典常用操作一览
| 操作 | 写法 | 说明 |
|---|---|---|
| 访问 | d[键] / d.get(键, 默认值) | 不存在时报错 / 不报错 |
| 增加、修改 | d[键] = 值 | 键不存在即新增,存在即修改 |
| 批量更新 | d.update(字典) | 有则改、无则加 |
| 删除 | del d[键] / d.pop(键) / d.clear() | pop会返回被删的值 |
| 遍历键 | d.keys() | 直接for d效果相同 |
| 遍历值 | d.values() | 常用于统计 |
| 遍历键值对 | d.items() | 配合解包使用 |
| 判断 | 键 in d | 只判断键 |
| 个数 | len(d) | 键值对的个数 |
2.6 嵌套字典
真实的数据往往不止一层。一个学生的信息有姓名、年龄、多门课成绩,一个班又有多个学生——这就要用到嵌套字典:字典的值可以又是一个字典(甚至再套列表)。用"学号"作外层的键,用"信息字典"作值,正好构成一本"花名册":
代码 4-14:嵌套字典
# 一个班的花名册:学号是键,学生信息字典是值,成绩再嵌套一个列表
students = {
"2024001": {"姓名": "张三", "年龄": 19, "成绩": [85, 92, 78]},
"2024002": {"姓名": "李四", "年龄": 20, "成绩": [90, 88, 95]},
"2024003": {"姓名": "王五", "年龄": 18, "成绩": [72, 65, 80]}
}
# 访问:一层一层定位,先学号、再字段
print(students["2024001"]["姓名"]) # 输出:张三
print(students["2024002"]["成绩"]) # 输出:[90, 88, 95]
# 修改:把张三的第三门课成绩改一下
students["2024001"]["成绩"][2] = 80
print(students["2024001"]["成绩"]) # 输出:[85, 92, 80]
# 遍历:外层items()解包出学号和信息,内层按键取字段
for sid, info in students.items():
total = info["成绩"][0] + info["成绩"][1] + info["成绩"][2]
print(f"{sid} {info['姓名']}:总分{total},平均分{total / 3:.2f}")
# 输出:
# 2024001 张三:总分257,平均分85.67
# 2024002 李四:总分273,平均分91.00
# 2024003 王五:总分217,平均分72.33这个"字典套字典套列表"的结构,在第三章嵌套列表的基础上又进了一步:不再依赖"第几行第几列",而是每一层都用有意义的名字定位。等学到第九章就会发现,用pymysql的字典游标查询数据库,返回的每条记录正是这样的字典——到时候本章就是现成的基础。
【例题4-2】用字典重构班级成绩管理
还记得例题3-2里删除学生时要同步执行两次pop的隐患吗?现在用字典重构那个程序:姓名作键、成绩作值,实现录入、显示、添加、删除、统计、评语的全流程。
代码 4-15:用字典重构班级成绩管理
# ---------- 1. 录入:姓名做键,成绩做值 ----------
scores = {}
name_text = input("请输入学生姓名(用逗号分隔):").strip()
score_text = input("请输入成绩(用逗号分隔,与姓名一一对应):").strip()
# 先解析成两个临时列表
names = []
for n in name_text.replace(",", ",").split(","):
names.append(n.strip())
score_list = []
for s in score_text.replace(",", ",").split(","):
score_list.append(int(s))
# 姓名和成绩配对写入字典:一个键值对就是一名学生
for i in range(len(names)):
scores[names[i]] = score_list[i]
# ---------- 2. 显示成绩单 ----------
print("-" * 32)
for name, score in scores.items():
print(f"{name}\t{score}分")
# ---------- 3. 添加转学生:一条语句 ----------
scores["赵六"] = 88
print(f"\n转学生赵六已加入,当前共{len(scores)}人")
# ---------- 4. 删除学生:还是一条语句 ----------
if "王五" in scores:
del scores["王五"]
print(f"王五已转出,当前共{len(scores)}人")
# ---------- 5. 统计:平均分与最高分得主 ----------
print("-" * 32)
total = 0
for score in scores.values():
total = total + score
print(f"平均分:{total / len(scores):.2f}")
# 遍历键值对"打擂台",找出最高分得主
best_name = ""
best_score = -1
for name, score in scores.items():
if score > best_score:
best_score = score
best_name = name
print(f"最高分:{best_name},{best_score}分")
# ---------- 6. 给每人一句评语 ----------
print("-" * 32)
for name, score in scores.items():
if score >= 90:
comment = "优秀"
elif score >= 60:
comment = "合格"
else:
comment = "需要努力"
print(f"{name}:{score}分,{comment}")程序运行效果如下:
请输入学生姓名(用逗号分隔):张三,李四,王五
请输入成绩(用逗号分隔,与姓名一一对应):85,92,78
--------------------------------
张三 85分
李四 92分
王五 78分
转学生赵六已加入,当前共4人
王五已转出,当前共3人
--------------------------------
平均分:88.33
最高分:李四,92分
--------------------------------
张三:85分,合格
李四:92分,优秀
赵六:88分,合格对照例题3-2,字典版的重构解决了原来的核心痛点:添加是scores["赵六"] = 88一条语句,删除是del scores["王五"]一条语句,姓名和成绩作为键值对永远绑定,绝不会错位;遍历用items()一次拿到姓名和成绩,也不用再维护"两个列表必须等长、必须对齐"的心智负担。这就是"容器选得对,代码少一半"的直观体现。程序还留下的提升空间——按成绩排序输出——需要用到第五章函数的知识(排序时指定比较规则),学到那里再回来完善它。
3.集合
3.1 什么是集合
处理名单类数据时还有一类高频需求:去重和关系统计。比如统计两个班的选课情况——"两门课都报了谁?""只报了一门课的谁?""一共多少人报名?"用列表写,得两层循环嵌套if逐个比对,既冗长又容易出错。集合(Set)就是为这类需求量身定制的容器。
集合可以理解为一份"不重复的花名册":往里登记名单,重名的自动忽略;它不讲究顺序(无序),也不允许重复(唯一)。从形式上看,集合就像一个"只剩键、没有值"的字典:
代码 4-16:初识集合
# 列表:允许重复,讲究顺序
names_list = ["张三", "李四", "王五", "李四", "张三"]
print(names_list) # 输出:['张三', '李四', '王五', '李四', '张三']
# 集合:自动去重,不讲顺序
names_set = {"张三", "李四", "王五", "李四", "张三"}
print(names_set) # 输出:{'张三', '李四', '王五'}(重复的进不来,显示顺序不确定)
print(len(names_set)) # 输出:3
# 集合没有索引,不能按位置访问
# print(names_set[0]) # 报错!TypeError: 'set' object is not subscriptable
# in判断的语法和列表一样,但速度更快
print("李四" in names_set) # 输出:True因为无序,集合打印出来的顺序是不确定的,每次运行可能不同,这一点要有心理预期。也因为没有索引,切片、按位置修改这些操作统统不存在——集合只关心"有没有",不关心"排第几"。
3.2 集合的创建与增删
代码 4-17:创建集合的几种方式
# 花括号直接创建(和字典的区别:里面是单个元素,没有冒号)
fruits = {"苹果", "香蕉", "橙子"}
# 陷阱:空的{}创建的是字典,不是集合!
empty = {}
print(type(empty)) # 输出:<class 'dict'>
# 创建空集合必须用set()
empty_set = set()
print(type(empty_set)) # 输出:<class 'set'>
# set()可以把列表、字符串转成集合(顺便就完成了去重)
nums = [1, 2, 2, 3, 3, 3]
print(set(nums)) # 输出:{1, 2, 3}
print(set("hello")) # 输出:{'h', 'e', 'l', 'o'}(重复的l只留一个,顺序不确定)空集合的陷阱值得单独强调:{}留给字典了,空集合必须用set()创建。另外,集合的元素和字典的键一样,必须是不可变类型——数字、字符串、元组可以,列表不行。
代码 4-18:集合的增删与去重套路
fruits = {"苹果", "香蕉"}
# add():添加一个元素
fruits.add("橙子")
print(fruits) # 输出:{'苹果', '香蕉', '橙子'}
# 重复添加:进不去,但也不报错
fruits.add("苹果")
print(len(fruits)) # 输出:3
# remove():删除指定元素,不存在则报错
fruits.remove("香蕉")
# discard():删除指定元素,不存在也不报错(更稳妥)
fruits.discard("西瓜")
print(fruits) # 输出:{'苹果', '橙子'}
# update():合并另一个集合(或列表)的所有元素
fruits.update({"葡萄", "西瓜"})
print(fruits) # 输出:{'苹果', '橙子', '葡萄', '西瓜'}
# 经典套路:列表去重一行搞定
scores = [85, 92, 78, 85, 92, 85]
unique = list(set(scores))
print(unique) # 输出:[78, 85, 92](注意:顺序可能变化)list(set(列表))是去重的标准姿势,但要注意副作用:转成集合再转回来,原来的顺序丢了。如果既要去重又要保序,就用"空列表+in判断+append"的老实办法循环一遍。
3.3 集合的运算
集合真正的杀手锏是运算:交集、并集、差集、对称差,分别对应四个运算符,像做数学题一样直观。以两个班的选课名单为例:
代码 4-19:集合的四种运算
# 两个班的选课名单(已去重)
python_class = {"张三", "李四", "王五", "赵六"} # 选了Python课的
java_class = {"李四", "王五", "钱七", "孙八"} # 选了Java课的
# 交集 &:两者共有的——两门课都选了的
print(python_class & java_class) # 输出:{'李四', '王五'}
# 并集 |:合并起来(自动去重)——所有选了课的学生
print(python_class | java_class) # 输出:{'张三', '李四', '王五', '赵六', '钱七', '孙八'}
print(len(python_class | java_class)) # 输出:6
# 差集 -:前者有、后者没有的——只选了Python没选Java的
print(python_class - java_class) # 输出:{'张三', '赵六'}
# 对称差 ^:只属于其中一方的——只选了一门课的
print(python_class ^ java_class) # 输出:{'张三', '赵六', '钱七', '孙八'}(集合的显示顺序不确定,实际输出中元素的先后可能不同。)四个运算符汇总见表4-3。
表 4-3 集合运算符一览
| 运算 | 运算符 | 等价方法 | 含义 | 选课场景 |
|---|---|---|---|---|
| 交集 | & | intersection() | 两者共有 | 两门课都选了的 |
| 并集 | | | union() | 合并去重 | 所有选课的学生 |
| 差集 | - | difference() | 前有后无 | 只选Python的 |
| 对称差 | ^ | symmetric_difference() | 只属其一 | 只选一门课的 |
如果用列表实现同样的统计,每种运算都要写一层循环加if判断;而集合一个运算符就完成了。这就是"选对容器"带来的效率——不只是代码量,更是思维负担的减轻。
【例题4-3】选课名单统计
两个班的同学分别报名了Python课和Java课,原始名单中有重复报名,还混有中文逗号。请统计:每门课去重后的报名人数、两门课都报的人数、只报一门课的人数、报名总人数。
代码 4-20:选课名单统计
# 两个班的原始报名数据(有重复、有中文逗号混入)
python_raw = "张三,李四,王五,李四,张三,赵六"
java_raw = "李四,王五,钱七,王五,孙八"
# 第一步:清洗字符串并转入集合(add时自动去重)
python_set = set()
for name in python_raw.replace(",", ",").split(","):
python_set.add(name.strip())
java_set = set()
for name in java_raw.replace(",", ",").split(","):
java_set.add(name.strip())
print(f"Python课报名人数(去重后):{len(python_set)}人")
print(f"Java课报名人数(去重后):{len(java_set)}人")
# 第二步:集合运算完成各项统计
both = python_set & java_set
only_python = python_set - java_set
only_java = java_set - python_set
all_students = python_set | java_set
print("-" * 32)
print(f"两门课都报了({len(both)}人):{'、'.join(sorted(both))}")
print(f"只报了Python({len(only_python)}人):{'、'.join(sorted(only_python))}")
print(f"只报了Java({len(only_java)}人):{'、'.join(sorted(only_java))}")
print(f"报名总人数:{len(all_students)}人")程序运行效果如下:
Python课报名人数(去重后):4人
Java课报名人数(去重后):4人
--------------------------------
两门课都报了(2人):李四、王五
只报了Python(2人):张三、赵六
只报了Java(2人):孙八、钱七
报名总人数:6人(sorted()对中文按Unicode编码排序,所以"孙八"排在"钱七"前面。)
这个例题是前几章知识的联合会战:replace()统一分隔符、split()拆分、strip()清洗(第三章),add()入集自动去重、&、-、|完成关系统计(本节),sorted()排序后join()拼接输出(第三章复习)。至此,列表、元组、字典、集合四种容器已经全部学完:动态批量用列表、固定不变用元组、按名存取用字典、去重统计用集合。下一章学习函数,把一段段重复的代码打包成可复用的"零件",程序世界的大门才真正打开。
