第4章 4.4 集合 set 与去重

🎯 开场 3 分钟:为什么要学这个?

上一章我们学会了字典这位"超级管家",它能存储键值对、帮我们用名字查信息,简直是数据管理的神器。

但你有没有遇到过这种情况:

场景一:你的程序从数据库里查出了 1000 条订单记录,其中有些是重复的(比如用户刷新了页面),你想去掉重复的怎么办?

场景二:你有两个名单,A 班同学和 B 班同学,现在想知道哪些同学同时选了 A 和 B 的课?哪些只在 A 班?

如果用列表来做,你会发现:去重要写循环、查交集要嵌套循环、代码又臭又长……

集合(set) 就是来解决这个问题的!它天生就会去重,天生就擅长做"有没有""交并差"这类运算。学会它,你的代码能少写一半。

学完这章,你就能:
- 用集合给数据去重,速度比列表快 10 倍
- 轻松做交集、并集、差集运算
- 用 frozenset 保护你的数据不被意外修改


🧱 基础 25 分钟:核心概念

什么是集合?—— 水果篮子的故事

想象你有一个水果篮子
- 每个位置只能放一种水果
- 不能放两个一样的苹果
- 你可以随时往里加水果,或者看看篮子里有没有西瓜

集合(set) 就是这个水果篮子:
- 无序:水果在里面不按顺序排(没有 index)
- 去重:自动去掉重复的水果
- 可变:可以随时添加删除元素

# 定义一个集合
fruits = {"苹果", "香蕉", "橙子", "苹果"}  # 注意:有两个苹果

print(fruits)  # 输出:{'香蕉', '橙子', '苹果'}  # 自动去重了!
print(len(fruits))  # 输出:3  # 只有3种水果

一句话解释{"苹果", "香蕉"} 这叫集合字面量,里面放什么都行,但每样只能有一个。

为什么不用列表去重?

你说用列表也能去重啊,我遍历一遍就行了。来看对比:

# 用列表去重(慢)
names = ["张三", "李四", "王五", "张三", "赵六", "李四"]
unique_names = []
for name in names:
if name not in unique_names:  # 每次都要遍历整个列表
    unique_names.append(name)
print(unique_names)  # ['张三', '李四', '王五', '赵六']

# 用集合去重(快)
unique_names = list(set(names))  # 一行搞定!
print(unique_names)  # ['张三', '李四', '王五', '赵六']

区别在哪?

方式 原理 1000 条数据耗时
列表 逐个比较,O(n²)
集合 哈希查找,O(1)

一句话解释in 判断在列表里是"挨家挨户敲门",在集合里是"直接查户口本"。

配图1 - 配图1

集合的增删改查

集合是可变的,可以动态操作:

# 创建空集合(注意:不能用 {},那是字典!)
empty_set = set()  # 正确
not_empty = {"apple"}  # 这才是有内容的集合

# 添加元素
colors = {"红色", "蓝色"}
colors.add("绿色")  # 添加一个
print(colors)  # {'红色', '蓝色', '绿色'}

# 添加多个
colors.update(["黄色", "紫色"])  # 一次加一堆
print(colors)  # {'红色', '蓝色', '绿色', '黄色', '紫色'}

# 删除元素(不存在会报错)
colors.remove("蓝色")  # 删除指定元素
print(colors)  # {'红色', '绿色', '黄色', '紫色'}

# 删除元素(不存在不报错)
colors.discard("白色")  # 删不存在的元素也不报错
print(colors)  # 保持原样

# 随机弹出一个(集合无序,所以是"随机")
popped = colors.pop()
print(f"弹出了:{popped}")
print(f"剩下:{colors}")

# 清空
colors.clear()
print(colors)  # set()

一句话解释:添加用 add(一次一个)或 update(一次一群),删除用 remove(会报错)或 discard(不报错)。

查成员——有没有?

这是集合最常用的操作,问"这个东西在不在里面":

students = {"小明", "小红", "小刚", "小丽"}

# 判断是否存在(返回 True/False)
print("小明" in students)      # True
print("小华" in students)      # False
print("小华" not in students)  # True

一句话解释in 在集合里比在列表里快得多,因为它用的是哈希查找。

集合的四种运算——交并差对称差

这是集合的精华部分!用生活例子讲:

  • 交集(&):同时出现在两个集合的元素。A 班和 B 班都选了的课
  • 并集(|):两个集合合并。A 班或 B 班任何一人选了的课
  • 差集(-):只在第一个集合里有的。A 班独有的课
  • 对称差集(^):两个集合互相没有的元素。A 班和 B 班互相独有的课
# A 班同学
class_a = {"小明", "小红", "小刚", "小美"}
# B 班同学
class_b = {"小红", "小刚", "小丽", "小强"}

# 交集:两个班都有的同学
both = class_a & class_b
print(f"两个班都有:{both}")  # {'小红', '小刚'}

# 并集:至少在一个班出现的同学
all_students = class_a | class_b
print(f"所有同学:{all_students}")  # {'小明', '小红', '小刚', '小美', '小丽', '小强'}

# 差集:A 班独有的同学
only_a = class_a - class_b
print(f"A班独有:{only_a}")  # {'小明', '小美'}

# 对称差集:只在一个班出现的同学
only_one = class_a ^ class_b
print(f"只在一个班:{only_one}")  # {'小明', '小美', '小丽', '小强'}

一句话解释:交集是"共同爱好",并集是"朋友圈合并",差集是"A 的私有物品",对称差集是"互相看不顺眼的"。

配图2 - 配图2

frozenset——不可变的集合

有时候你不希望集合被修改,比如放在字典里当 key,或者需要线程安全。

frozenset 就是"冻住"的集合,不能 add、不能 remove、不能 update:

# 普通集合可变
mutable = {1, 2, 3}
mutable.add(4)
print(mutable)  # {1, 2, 3, 4}

# frozenset 不可变
frozen = frozenset([1, 2, 3])
# frozen.add(4)  # 报错!AttributeError

# frozenset 可以当字典的 key
grade_map = {
frozenset(["数学", "物理"]): "理科学霸",
frozenset(["语文", "英语"]): "文科学霸"
}
print(grade_map)  # {frozenset({'数学', '物理'}): '理科学霸', ...}

一句话解释:frozenset 就像装在玻璃框里的水果标本,只能看,不能动。


🔥 实战 35 分钟:3 个递进的小项目

项目 1:学生成绩去重(5 分钟)

需求:一份成绩单里有重复的学号,需要统计有多少不重复的学生。

# 成绩记录(学号, 姓名, 分数)
grade_records = [
("2024001", "小明", 85),
("2024002", "小红", 92),
("2024001", "小明", 85),  # 重复!
("2024003", "小刚", 78),
("2024002", "小红", 92),  # 重复!
("2024004", "小美", 88),
]

# 用集合提取不重复的学号
unique_ids = set()
for record in grade_records:
student_id = record[0]
unique_ids.add(student_id)

print(f"不重复学生数:{len(unique_ids)}")
print(f"学号列表:{sorted(unique_ids)}")

# 预期输出:
# 不重复学生数:4
# 学号列表:['2024001', '2024002', '2024003', '2024004']

一句话解释:每次遍历成绩单,就把学号扔进集合里,重复的自动被过滤掉了。


项目 2:电商数据分析——找出活跃用户(15 分钟)

需求:有两个月的用户访问记录 CSV,找出:
1. 两个月都活跃的用户(交集)
2. 至少一个月活跃的用户(并集)
3. 只在上个月活跃的用户(差集)

# 模拟两个月的访问日志(实际项目中从 CSV 读取)
march_log = ["user_101", "user_102", "user_103", "user_104", "user_105",
         "user_101", "user_102", "user_103"]  # user_101 访问了2次
april_log = ["user_102", "user_103", "user_106", "user_107", "user_102"]

# 转成集合去重
march_users = set(march_log)
april_users = set(april_log)

print(f"3月活跃用户:{march_users}")
print(f"4月活跃用户:{april_users}")

# 交集:两个月都活跃
active_both = march_users & april_users
print(f"\n两个月都活跃:{active_both}")
print(f"人数:{len(active_both)}")

# 并集:至少一个月活跃
active_any = march_users | april_users
print(f"\n至少一个月活跃:{active_any}")
print(f"总人数:{len(active_any)}")

# 差集:只在上个月活跃
only_march = march_users - april_users
print(f"\n只在上月活跃:{only_march}")

# 预期输出:
# 3月活跃用户:{'user_101', 'user_102', 'user_103', 'user_104', 'user_105'}
# 4月活跃用户:{'user_102', 'user_103', 'user_106', 'user_107'}
#
# 两个月都活跃:{'user_102', 'user_103'}
# 人数:2
#
# 至少一个月活跃:{'user_101', 'user_102', 'user_103', 'user_104', 'user_105', 'user_106', 'user_107'}
# 总人数:7
#
# 只在上月活跃:{'user_101', 'user_104', 'user_105'}

一句话解释:集合运算让你用 1 行代码就完成了过去要几十行循环才能搞定的事情。


项目 3:新闻关键词提取工具(15 分钟)

需求:做一个简单的关键词提取工具:
1. 从多篇文章中提取所有不重复的关键词
2. 找出两篇文章的共同关键词
3. 找出某篇文章独有的关键词

# 模拟新闻文章(实际项目中从 API/文件读取)
article_1 = """
Python 编程语言越来越受欢迎,越来越多的开发者开始学习 Python。
Python 的简洁语法让编程变得更加容易,Python 适合初学者入门。
"""
article_2 = """
JavaScript 是前端开发的主流语言,Python 也可以用于 Web 开发。
Python 在数据科学领域表现突出,越来越多的人开始使用 Python。
"""

# 简单的中文分词(实际项目用 jieba 库)
def simple_tokenize(text):
"""模拟分词:把文本按空格和标点符号分割"""

# 去除标点符号
for punc in ",。、!…—":
    text = text.replace(punc, " ")
# 分割并过滤空字符串
words = [w.strip() for w in text.split() if w.strip()]
return words

# 提取两篇文章的关键词
keywords_1 = set(simple_tokenize(article_1))
keywords_2 = set(simple_tokenize(article_2))

print(f"文章1关键词:{keywords_1}")
print(f"文章2关键词:{keywords_2}")

# 找出共同关键词
common = keywords_1 & keywords_2
print(f"\n共同关键词:{common}")

# 找出文章1独有的关键词
unique_1 = keywords_1 - keywords_2
print(f"文章1独有:{unique_1}")

# 找出文章2独有的关键词
unique_2 = keywords_2 - keywords_1
print(f"文章2独有:{unique_2}")

# 所有关键词(去重后)
all_keywords = keywords_1 | keywords_2
print(f"\n全部关键词数:{len(all_keywords)}")
print(f"去重后关键词:{sorted(all_keywords)}")

# 预期输出:
# 文章1关键词:{'Python', '编程', '语言', '越来越', '受欢迎', '的', ...}
# 文章2关键词:{'JavaScript', '前端', '开发', '主流', '语言', 'Python', ...}
#
# 共同关键词:{'Python', '语言', '越来越', '的', ...}
# 文章1独有:{'编程', '简洁', '语法', '让', '变得', '容易', '适合', '初学者', '入门'}
# 文章2独有:{'JavaScript', '前端', '开发', '主流', '在', '数据', '科学', '领域', '表现', '突出', '越来越', '多', '的', '人', '开始', '使用'}

# 统计关键词出现频率
keyword_count = {}
for keyword in all_keywords:
count_1 = keywords_1.count(keyword) if keyword in keywords_1 else 0
count_2 = keywords_2.count(keyword) if keyword in keywords_2 else 0
keyword_count[keyword] = {"文章1": count_1, "文章2": count_2}

print("\n关键词统计:")
for kw, counts in sorted(keyword_count.items()):
print(f"  {kw}: 文章1={counts['文章1']}次, 文章2={counts['文章2']}次")

一句话解释:用集合的去重能力处理文本,先分词再转集合,两篇文章的关键词对比一目了然。


💪 进阶 20 分钟:常见坑 + 性能小贴士

坑 1:空集合不能用 {}

# ❌ 错误写法
empty = {}  # 这是字典,不是集合!
print(type(empty))  # <class 'dict'>

# ✅ 正确写法
empty = set()
print(type(empty))  # <class 'set'>

一句话解释{} 在 Python 里是字典的专属符号,想要空集合必须用 set()


坑 2:集合里的元素必须是可哈希的

集合元素必须是不可变类型(能 hash 的),列表和字典不能放进去:

# ❌ 错误:列表是可变的,不能放集合里
bad_set = {{1, 2}, {3, 4}}  # 报错!TypeError

# ❌ 错误:字典也是可变的
bad_set = {{"a": 1}}  # 报错!

# ✅ 正确:元组是不可变的,可以放
good_set = {(1, 2), (3, 4)}
print(good_set)  # {(1, 2), (3, 4)}

# ✅ 正确:字符串、数字都可以
mixed = {"hello", 123, (1, 2, 3)}
print(mixed)  # {'hello', 123, (1, 2, 3)}

一句话解释:集合是"水果篮子",只能放"完整的水果"(不可变对象),不能放"水果拼盘"(可变对象)。


坑 3:集合的 + 不是合并

# ❌ 错误:集合不支持 + 运算符
a = {1, 2, 3}
b = {4, 5, 6}
# result = a + b  # 报错!TypeError

# ✅ 正确:用 | 或 update
result = a | b  # 返回新集合
print(result)  # {1, 2, 3, 4, 5, 6}

# 或者原地合并
a.update(b)
print(a)  # {1, 2, 3, 4, 5, 6}

一句话解释:数学里用 + 表示合并,但 Python 集合里要用 | 管道符。


坑 4:集合的 in 找不到会报错?

不会!集合的 in 操作符永远返回 True/False,不会抛异常:

s = {1, 2, 3}
print(100 in s)  # False,不会报错

一句话解释:集合的成员判断是"安全的",要么找到返回 True,要么找不到返回 False。


坑 5:集合元素顺序不可依赖

# ❌ 错误:依赖集合的顺序
s = {3, 1, 2}
print(s)  # 可能输出 {1, 2, 3},也可能不是!

# ✅ 正确:如果需要顺序,转成列表
s = {3, 1, 2}
ordered = sorted(s)  # [1, 2, 3]
print(ordered)

一句话解释:集合是"水果篮子",水果在里面乱放,如果要按顺序拿,得先排好队(sorted)。


性能小贴士:批量添加用 update

# ❌ 慢:逐个添加
s = set()
for item in range(10000):
s.add(item)

# ✅ 快:批量添加
s = set(range(10000))  # 一步到位

一句话解释:一次性往篮子里放 100 个水果,比一个一个放快得多。


调试技巧:用 print 检查集合状态

def find_common_users(active_users, blocked_users):
"""找活跃且未拉黑的用户"""
print(f"活跃用户:{active_users}")
print(f"拉黑用户:{blocked_users}")

# 先看差集
not_blocked = active_users - blocked_users
print(f"活跃且未拉黑:{not_blocked}")

return not_blocked

users = {"小明", "小红", "小刚"}
blocked = {"小刚"}
result = find_common_users(users, blocked)
# 输出:
# 活跃用户:{'小明', '小红', '小刚'}
# 拉黑用户:{'小刚'}
# 活跃且未拉黑:{'小明', '小红'}

一句话解释:在关键步骤打印集合,能快速定位问题在哪一步。


✏️ 练习题 + 作业题

练习题(5 道,10 分钟内完成)

练习 1(1 分钟):基础——添加元素
- 输入:s = {1, 2, 3},添加数字 4
- 预期输出:{1, 2, 3, 4}
- 提示:add() 方法

练习 2(2 分钟):基础——去重判断
- 输入:["苹果", "香蕉", "苹果", "橙子", "香蕉", "苹果"]
- 预期输出:{'苹果', '香蕉', '橙子'}
- 提示:转成 set 再转回 list

练习 3(2 分钟):进阶——集合运算
- 输入:A = {1, 2, 3, 4},B = {3, 4, 5, 6},求交集
- 预期输出:{3, 4}
- 提示:用 & 运算符

练习 4(3 分钟):进阶——用户名单对比
- 输入:old_users = {"小明", "小红"}new_users = {"小红", "小刚"}
- 问题:哪些是新来的?哪些走了?
- 预期输出:新来:{'小刚'},离开:{'小明'}
- 提示:用差集 new - old 是新来的,old - new 是离开的

练习 5(2 分钟):挑战——找错
- 以下代码报错,找出问题并修复:

my_set = {1, 2, [3, 4]}  # 报错!
my_set.add(5)
print(my_set)
  • 预期输出:{1, 2, [3, 4], 5} 或类似结果
  • 提示:列表不能放集合里,改成元组

作业题(30 分钟 - 2 小时)

作业:做一个「重复代码检测器」

需求描述
你是一个代码审查员,写了一个检查文件夹里有没有重复代码的小工具。

功能点
1. 读取多个 Python 文件(模拟几个文件内容)
2. 提取每个文件的所有代码行(去空格空行)
3. 找出哪些行在多个文件里出现过
4. 输出每个重复行的"出现在哪些文件"

加分项
1. 统计每个文件有多少行代码,多少行是重复的
2. 找出完全一模一样的文件(所有行都相同)

验收标准
- 能跑起来
- 输出结果符合预期
- 代码有注释(每段干嘛的写清楚)

提交方式:评论区贴代码或 GitHub 链接


📚 总结 + 资源

本章核心 3 点

  1. 集合是天然去重的"水果篮子":用 set() 创建,add() 添加,in 判断成员
  2. 四种运算解决"有没有"的问题:交 &、并 |、差 -、对称差 ^
  3. frozenset 不可变,可当字典 key:需要哈希的安全数据就用它

延伸学习资源

  • Python 官方文档 - set:权威文档,例子丰富
  • 《Python编程:从入门到实践》第 4 章:项目驱动,适合动手党
  • 视频:B 站「小甲鱼」Python 教程第 38 讲——集合部分讲得很生动

互动钩子

你在处理用户数据时遇到过重复记录吗?是怎么处理的?评论区聊聊你是用列表还是集合,老粉优先回复!


下一章我们要做一个通讯录管理系统,会综合用到字典和集合:字典存储联系人详情,集合处理标签分类。敬请期待!

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。