第8章 8.1 collections 高级数据结构

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

上一章我们做了一个银行账户管理系统,用 Python 的基础列表和字典存储用户数据。你有没有发现:查一个用户要遍历整个列表、统计不同账户类型要写一堆循环、数据结构嵌套太深自己都晕了?

痛点来了:如果你管理的是 100 万用户的银行数据,用普通列表查一个人要遍历 100 万次,够慢的吧?

这一章我们要学一个新武器——collections 模块。它是 Python 内置的「高级数据结构军火库」,专门解决「数据太多不好管」的问题。学完你能:

  • Counter 秒秒钟统计词频、统计商品销量
  • defaultdict 不用每次判断 key 是否存在
  • namedtuple 让数据像有名字的表格一样好读

配图1 - 配图1


🧱 基础 25 分钟:核心概念

8.1.1 Counter:数数神器

是什么:专门用来「计数」的数据结构,像个高级版的字典。

生活类比:想象你开了一家奶茶店,营业结束后要数一下今天卖了多少杯不同口味的饮品。Counter 就是你的「收银小助手」,自动帮你按口味分类统计。

为什么要用:比手动写字典计数简洁 10 倍,还自带一堆实用方法。

from collections import Counter

# 场景:统计一篇文章里出现最多的单词
words = ["apple", "banana", "apple", "cherry", "banana", "apple"]
counter = Counter(words)

print(counter)
# 输出:Counter({'apple': 3, 'banana': 2, 'cherry': 1})

Counter 接收任何可迭代对象,自动统计每个元素出现次数。上面代码把单词列表丢进去,马上就知道 apple 出现了 3 次。

常用方法

# 找出出现最多的 2 个
print(counter.most_common(2))
# 输出:[('apple', 3), ('banana', 2)]

# 计数运算:合并两个 Counter
c1 = Counter(["a", "b", "a"])
c2 = Counter(["a", "c", "a", "a"])
print(c1 + c2)
# 输出:Counter({'a': 4, 'b': 1, 'c': 1})

8.1.2 defaultdict:不用判断 key 是否存在的字典

是什么:一种「自带默认值」的字典,访问不存在的 key 不会报错。

生活类比:就像一个贴心的助理,你问「小王今天汇报了吗」,即使小王还没汇报过,助理也不会愣住,而是给你一个「暂无汇报」的默认回复,而不是报错。

为什么要用:再也不用写 if key in dict 的繁琐判断了。

from collections import defaultdict

# 场景:按部门分组统计员工数量
departments = defaultdict(list)  # 默认值是空列表

departments["技术部"].append("张三")
departments["技术部"].append("李四")
departments["市场部"].append("王五")

print(departments)
# 输出:defaultdict(<class 'list'>, {'技术部': ['张三', '李四'], '市场部': ['王五']})

注意这里 departments["财务部"] 从没被赋值过,但直接访问也不会报错,会返回空列表。

设置默认工厂

# 统计每个部门的人数(用 int 作为默认值,默认是 0)
dept_count = defaultdict(int)
dept_count["技术部"] += 5
dept_count["技术部"] += 3
dept_count["市场部"] += 2

print(dept_count["财务部"])  # 不存在,返回默认值 0
# 输出:0

8.1.3 namedtuple:让数据像表格一样有名字

是什么:创建一个「像元组一样不可变、又像对象一样有属性名」的数据类型。

生活类比:普通元组像一排没有标签的抽屉,my_tuple[0]my_tuple[1] 完全靠脑子记哪个抽屉放什么。namedtuple 像给每个抽屉贴了标签,my_point.x 一眼就知道是坐标。

为什么要用:代码可读性爆炸式提升,再也不用猜 data[0] 是什么意思。

from collections import namedtuple

# 定义一个「学生」数据类型
Student = namedtuple("Student", ["name", "age", "grade"])

# 创建实例
xiaoming = Student("小明", 15, "高一")

print(xiaoming.name)
print(xiaoming.age)
print(xiaoming.grade)
# 输出:小明
#        15
#        高一

# 像普通元组一样支持索引和解包
print(xiaoming[0])  # 输出:小明
name, age, grade = xiaoming  # 解包

配图2 - 配图2

8.1.4 deque:双端队列,两头都能操作

是什么:一种可以在两端快速添加/删除元素的队列。

生活类比:像排队买奶茶的队伍,deque 允许你从队伍前面(优先通道)或后面(新来的人)快速加入。从前面插入叫 appendleft,从后面插入叫 append

为什么要用:普通列表在头部插入/删除是 O(n) 慢操作,deque 是 O(1) 极速。

from collections import deque

# 场景:模拟浏览器前进/后退功能
history = deque(["page1", "page2", "page3"], maxlen=5)  # 最多保存5个

history.append("page4")
history.appendleft("page0")  # 前面插入

print(history)
# 输出:deque(['page0', 'page1', 'page2', 'page3', 'page4'], maxlen=5)

# 双向操作
history.pop()      # 从右边弹出
history.popleft()  # 从左边弹出

8.1.5 OrderedDict:记得插入顺序的字典

是什么:一个会记住 key 插入顺序的字典(Python 3.7+ 普通 dict 已经有序,这个了解即可)。

为什么要用:在 Python 3.7 之前,只有 OrderedDict 能保证字典顺序。现在更多是语义化表达「我需要有序」时使用。

from collections import OrderedDict

# 场景:保存配置项的顺序
config = OrderedDict()
config["theme"] = "dark"
config["language"] = "zh"
config["font_size"] = 14

print(list(config.keys()))
# 输出:['theme', 'language', 'font_size'](按插入顺序)

8.1.6 ChainMap:把多个字典串成一个

是什么:把多个字典「链」在一起,假装是一个大字典。

生活类比:像多本字典放书架上,ChainMap 让你查词时先翻第一本,没有再去第二本,一直找到为止。

为什么要用:合并多个配置源(比如命令行参数 + 环境变量 + 默认配置),不用真的合并。

from collections import ChainMap

# 场景:多层配置优先级
default_config = {"theme": "light", "debug": False}
user_config = {"theme": "dark"}  # 用户配置覆盖默认配置
cli_config = {"debug": True}     # 命令行最高优先级

# 按优先级从低到高排列
combined = ChainMap(cli_config, user_config, default_config)

print(combined["theme"])  # dark(用户覆盖了默认)
print(combined["debug"])  # True(命令行最高)
print(combined["font"])   # KeyError(所有来源都没有)

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

项目 1:单词计数器(5 分钟)

场景:统计一段文字里出现最多的 5 个单词。

from collections import Counter
import re

text = """
Python is a powerful programming language Python is easy to learn
Python is versatile Python is used in data science machine learning
web development automation and more Python has a large standard library
"""

# 简单分词:转小写、提取英文单词
words = re.findall(r'[a-z]+', text.lower())

# 统计
counter = Counter(words)

# 输出最常见的 5 个
for word, count in counter.most_common(5):
print(f"{word}: {count}次")

预期输出

python: 9次
is: 4次
a: 3次
and: 2次
in: 1次

一句话解释most_common() 方法按出现次数降序排列,一行搞定排名统计。


项目 2:班级成绩管理系统(15 分钟)

场景:从 CSV 文件读取学生成绩,按科目统计平均分、最高分,找出需要帮助的学生。

from collections import defaultdict, Counter

# 模拟 CSV 数据(实际项目从文件读入)
csv_data = """姓名,科目,成绩
张三,数学,85
李四,数学,45
王五,数学,92
张三,语文,78
李四,语文,60
王五,语文,88
张三,英语,90
李四,英语,35
王五,英语,95
"""

# 解析 CSV
lines = csv_data.strip().split('\n')
header = lines[0].split(',')
students = defaultdict(dict)

for line in lines[1:]:
name, subject, score = line.split(',')
students[name][subject] = int(score)

# 统计每个科目的平均分和最高分
subject_stats = defaultdict(list)
for name, scores in students.items():
for subject, score in scores.items():
    subject_stats[subject].append(score)

print("=" * 40)
print("各科目成绩统计")
print("=" * 40)

for subject, scores in subject_stats.items():
avg = sum(scores) / len(scores)
max_score = max(scores)
print(f"{subject}:平均分 {avg:.1f},最高分 {max_score}")

# 找出需要帮助的学生(单科低于 60 分)
print("\n" + "=" * 40)
print("需要重点关注的学生")
print("=" * 40)

for name, scores in students.items():
weak_subjects = [sub for sub, score in scores.items() if score < 60]
if weak_subjects:
    print(f"{name}:{', '.join(weak_subjects)} 需要加强")

预期输出

========================================
各科目成绩统计
========================================
数学:平均分 74.0,最高分 92
语文:平均分 75.3,最高分 88
英语:平均分 73.3,最高分 95

========================================
需要重点关注的学生
========================================
李四:数学 需要加强
李四:英语 需要加强

一句话解释defaultdict(dict) 让嵌套数据操作变得零负担,不用先判断 key 是否存在。


项目 3:命令行待办清单工具(15 分钟)

场景:做一个带优先级的待办清单,支持添加、完成、查看统计。

from collections import deque, Counter

class TodoList:
def __init__(self):
    # 前面是紧急的,后面是不紧急的
    self.items = deque()
    self.completed = []

def add(self, task, priority=0):
    """添加任务,priority 越大越紧急"""
    # 扫描是否已存在
    for i, (t, p) in enumerate(self.items):
        if t == task:
            print(f"'{task}' 已在清单中")
            return

    # 插入到正确位置
    inserted = False
    for i in range(len(self.items)):
        if self.items[i][1] < priority:
            self.items.insert(i, (task, priority))
            inserted = True
            break

    if not inserted:
        self.items.append((task, priority))

    print(f"添加成功:{task}(优先级: {priority})")

def complete(self):
    """完成第一个任务"""
    if not self.items:
        print("清单是空的!")
        return

    task, priority = self.items.popleft()
    self.completed.append((task, priority))
    print(f"已完成:{task}")

def show(self):
    """显示当前清单"""
    if not self.items:
        print("清单是空的!")
        return

    print("\n📋 待办清单")
    print("-" * 30)
    for i, (task, priority) in enumerate(self.items):
        tag = "🔥" if priority >= 5 else "  "
        print(f"{tag}{i+1}. {task}")

def stats(self):
    """显示统计"""
    print(f"\n📊 统计")
    print(f"待完成:{len(self.items)} 项")
    print(f"已完成:{len(self.completed)} 项")

    # 统计已完成任务的优先级分布
    if self.completed:
        priorities = [p for _, p in self.completed]
        counter = Counter(priorities)
        print("完成优先级分布:", dict(counter))

# 使用演示
if __name__ == "__main__":
todo = TodoList()

todo.add("写周报", priority=3)
todo.add("回复邮件", priority=5)
todo.add("开会", priority=8)
todo.add("写代码", priority=6)
todo.add("写周报")  # 测试重复

todo.show()
todo.complete()
todo.complete()
todo.stats()
todo.show()

预期输出

添加成功:写周报(优先级: 3)
添加成功:回复邮件(优先级: 5)
添加成功:开会(优先级: 8)
添加成功:写代码(优先级: 6)
'写周报' 已在清单中

📋 待办清单
------------------------------
🔥1. 开会
🔥2. 写代码
3. 回复邮件
4. 写周报
已完成:开会
已完成:写代码

📊 统计
待完成:2 项
已完成:2 项
完成优先级分布:{8: 1, 6: 1}

📋 待办清单
------------------------------
1. 回复邮件
2. 写周报

一句话解释dequepopleft() 让完成任务的取出操作是 O(1) 极速,比列表的 pop(0) 快得多。


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

坑 1:Counter 加法 vs 减法

# ❌ 错误示例:想删除某些元素,用了加法
c1 = Counter(["a", "b", "c"])
c2 = Counter(["b"])
c3 = c1 + c2  # 这样是合并,不是删除
print(c3)  # Counter({'a': 1, 'b': 2, 'c': 1})
# ✅ 正确示例:用减法删除计数
c1 = Counter(["a", "b", "b", "c"])
c2 = Counter(["b"])
c3 = c1 - c2  # 相减,计数为0或负数的会被删除
print(c3)  # Counter({'a': 1, 'b': 1, 'c': 1})

坑 2:defaultdict 访问不存在的 key

# ❌ 错误示例:期望返回 None 或报错
from collections import defaultdict
d = defaultdict(int)
print(d["不存在"])  # 返回 0,不是 None

# ✅ 正确示例:如果确实需要 None,用 list 作为默认值
d = defaultdict(list)
print(d["不存在"])  # 返回空列表 []

坑 3:namedtuple 的不可变性

# ❌ 错误示例:尝试修改 namedtuple
from collections import namedtuple
Point = namedtuple("Point", ["x", "y"])
p = Point(1, 2)
p.x = 10  # 报错:AttributeError: can't set attribute
# ✅ 正确示例:用 _replace 创建修改后的副本
p = Point(1, 2)
p2 = p._replace(x=10)  # 返回新的 Point(10, 2)
print(p2.x)  # 10
print(p.x)   # 1,原来的不变

坑 4:deque 的 maxlen 满了之后的行为

# ❌ 错误示例:以为满了会报错
d = deque([1, 2, 3], maxlen=3)
d.append(4)  # 不会报错,最左边的 1 被挤掉了
print(list(d))  # [2, 3, 4]
# ✅ 正确示例:满了自动挤出旧数据,不需要手动管理
d = deque(maxlen=5)
for i in range(10):
d.append(i)
print(list(d))  # [5, 6, 7, 8, 9],只保留最后5个

坑 5:ChainMap 不修改原始字典

# ❌ 错误示例:以为赋值会修改原始字典
from collections import ChainMap
d1 = {"a": 1}
d2 = {"b": 2}
cm = ChainMap(d1, d2)
cm["a"] = 100  # 实际上修改的是 d1
print(d1)  # {'a': 100}

# ✅ 正确示例:想整体修改,要直接操作原始字典
# ChainMap 只读,不写!如果需要写操作,手动合并

性能小贴士:deque vs list

# 性能对比:头部插入 10000 次
import time
from collections import deque

# 列表头部插入(慢)
start = time.time()
lst = []
for i in range(10000):
lst.insert(0, i)
print(f"list insert(0): {time.time() - start:.3f}s")

# deque 头部插入(快)
start = time.time()
dq = deque()
for i in range(10000):
dq.appendleft(i)
print(f"deque appendleft: {time.time() - start:.3f}s")
# deque 比 list 快 100 倍以上

调试技巧:快速查看数据结构

# 用 vars() 快速查看对象的所有属性
from collections import namedtuple
Point = namedtuple("Point", ["x", "y"])
p = Point(1, 2)
print(vars(p))  # {'x': 1, 'y': 2}

# 用 __len__ 和 __contains__ 检查内容
from collections import Counter
c = Counter(["a", "b", "a"])
print(len(c))       # 2(有2种不同元素)
print("a" in c)     # True
print(c["a"])       # 2

✏️ 练习题

练习 1(2 分钟):Counter 基础操作
- 输入:["红", "蓝", "红", "绿", "蓝", "红"]
- 预期输出:红: 3,蓝: 2,绿: 1
- 提示:用 most_common() 方法

练习 2(3 分钟):添加判断逻辑
- 输入:在 Counter 结果中,只打印出现次数大于 2 的元素
- 预期输出:只显示 "红: 3"
- 提示:遍历 most_common() 结果,加个 if count > 2 判断

练习 3(5 分钟):处理新数据
- 输入:统计句子 "python python java python ruby java python" 中单词频率
- 预期输出:{'python': 4, 'java': 2, 'ruby': 1}
- 提示:直接用空格 split 后传给 Counter

练习 4(8 分钟):组合两种数据结构
- 输入:用 defaultdict 统计多个学生各科成绩的平均分
- 预期输出:每个学生显示各科平均分
- 提示:defaultdict(list) 存所有成绩,最后算平均

练习 5(5 分钟):分析报错并修复
- 输入:运行以下代码,找出错误

from collections import namedtuple
Student = namedtuple("Student", ["name", "age"])
s = Student("小明", 15)
s.age = 16
  • 预期输出:修复后正确输出 Student(name='小明', age=16)
  • 提示:namedtuple 不可变,要用 _replace 方法

作业:做一个「班级信息管理系统」

  • 需求描述:管理一个班级的学生信息,每个学生有姓名、年龄、班级;支持按班级查看学生列表、统计各班人数、找出年龄最大的学生。

  • 功能点
    1. 添加学生(姓名、年龄、班级)
    2. 按班级查询学生列表
    3. 统计各班人数
    4. 找出年龄最大/最小的学生

  • 加分项
    1. 用 namedtuple 存储学生信息
    2. 用 Counter 统计各班人数分布

  • 验收标准:能跑起来、输出符合预期、代码有注释


📚 总结 + 资源

本文学了 3 个核心点
1. Counter 是计数神器,一行代码搞定词频统计
2. defaultdict 让你告别 if key in dict 的繁琐判断
3. namedtuple 让数据可读性爆炸提升,代码再也不乱

延伸学习资源
- Python 官方文档 - collections 模块(最权威的参考)
- 《Python 编程:从入门到实践》第 9 章「类」(深入面向对象)
- 视频:B 站小甲鱼《零基础入门学习 Python》第 45 讲

互动钩子:你在 XX 场景(数据分析、爬虫、游戏开发...)用过 collections 模块吗?评论区聊聊你的使用经验,老粉优先回复!

下一章我们要学习 itertools 迭代器工具,教你用几行代码搞定排列组合、循环迭代的骚操作,让你的代码「跑得更快、写得更少」!

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