基础篇 / 第 5 章
第 5 章 · 内置数据结构全景
本章导读:
list、tuple、set、dict 是 Python 处理数据的主力武器,选对容器类型往往比优化算法更有效。本章系统讲解四种内置容器的语义、增删改查方法、遍历方式、彼此之间的转换、浅拷贝与深拷贝的陷阱,以及各自的性能特征与选型依据。前置知识:会索引切片(第 4 章)、会写循环(第 3 章)。学完本章,你能面对任何数据需求立刻说出「该用哪个容器、怎么写最快」。四种容器的全景对比
先建立整体认知。下表是本章最重要的一张表,建议打印下来贴在显示器边上。
| 特性 | list 列表 | tuple 元组 | set 集合 | dict 字典 |
|---|---|---|---|---|
| 中文名 | 列表 | 元组 | 集合 | 字典 |
| 语法 | [1, 2] | (1, 2) | {1, 2} | {1: 2} |
| 有序 | 是 | 是 | 是(3.7+ 保留插入顺序) | 是(3.7+ 保留插入顺序) |
| 可重复 | 是 | 是 | 否 | 键不可重复 |
| 可修改 | 是 | 否 | 是 | 是 |
| 索引访问 | seq[i] O(1) | seq[i] O(1) | 不支持 | d[k] O(1) |
| 按内容查找 | O(n) | O(n) | O(1) 哈希 | O(1) 哈希 |
| 可哈希(做字典键) | 否 | 是(元素须可哈希) | 否 | 否 |
| 典型用途 | 有序可变序列 | 不可变记录、多返回值 | 去重、集合运算 | 键值映射、查找表 |
一个「选型决策」速查
# 数据需要按顺序、且会增删吗? -> list
items = ["苹果", "香蕉", "橙子"]
# 数据是固定的记录(如坐标、日期),不希望被改吗? -> tuple
point = (3, 5)
rgb = (255, 128, 0)
# 只需要「有没有」,且要去重? -> set
tags = {"python", "web", "data"}
visited = {user_id for user_id in all_ids}
# 需要「名字 -> 值」的快速查找? -> dict
scores = {"张三": 89, "李四": 76}
要点选择原则:能用 tuple 就不用 list(不可变更安全)、能用 dict/set 就不用 list(哈希查找是 O(1),列表是 O(n))。数据量大时这一点能带来数量级的性能差距。
列表 list:有序可变序列
2.1 创建与基本操作
列表的增删改查
fruits = ["苹果", "香蕉", "橙子"]
# 查
print(fruits[0]) # 苹果
print(fruits[-1]) # 橙子
print(fruits[1:3]) # ['香蕉','橙子']
print(len(fruits)) # 3
print("香蕉" in fruits) # True
print(fruits.index("橙子")) # 2
# 增
fruits.append("葡萄") # 末尾追加
fruits.insert(1, "梨") # 在索引 1 处插入
fruits.extend(["西瓜", "桃"]) # 批量追加(注意:加的是元素本身)
print(fruits)
# ['苹果','梨','香蕉','橙子','葡萄','西瓜','桃']
fruits += ["草莓"] # += 对列表是原地扩展
print(fruits[-1]) # 草莓
# 删
fruits.remove("梨") # 按值删(删第一个)
last = fruits.pop() # 弹出最后一个,返回被删元素
second = fruits.pop(1) # 弹出指定位置(第 1 个索引)
fruits.clear() # 清空整个列表
print(fruits) # []
# 改
nums = [1, 2, 3, 4, 5]
nums[0] = 100 # 按索引赋值
print(nums) # [100, 2, 3, 4, 5]
# 也可以用切片批量修改(长度必须匹配)
nums[1:3] = [20, 30]
print(nums) # [100, 20, 30, 4, 5]
常见坑
append 和 extend 完全不同:append(["a","b"]) 追加的是「一个列表元素」,extend(["a","b"]) 追加的是「两个元素」。想一次加多个用 extend 或 +=。2.2 常用列表方法
| 方法 | 作用 | 示例 |
|---|---|---|
append(x) | 末尾追加一个元素 | [1].append(2) → [1,2] |
insert(i,x) | 在位置 i 插入 | [1,3].insert(1,2) → [1,2,3] |
extend(lst) | 批量追加 | [1].extend([2,3]) → [1,2,3] |
remove(x) | 按值删除 | 找不到会报 ValueError |
pop([i]) | 弹出并返回 | 不给索引则弹末尾 |
index(x) | 首次出现位置 | 找不到报 ValueError |
count(x) | 出现次数 | [1,1].count(1) → 2 |
sort() | 原地排序(无返回值) | l.sort() |
reverse() | 原地反转 | l.reverse() |
copy() | 浅拷贝 | l.copy() |
clear() | 清空 | l.clear() |
常见坑
sort() 返回 None!写 b = a.sort() 会让 b 变成 None,而 a 被就地修改了。要「返回新列表」应该用 sorted(a)。2.3 列表排序
sort 与 sorted 的四种排序方式
nums = [3, 1, 4, 1, 5, 9, 2, 6]
# 1. 原地升序排序
nums.sort()
print(nums) # [1,1,2,3,4,5,6,9]
# 2. 原地降序
nums.sort(reverse=True)
print(nums) # [9,6,5,4,3,2,1,1]
# 3. sorted() 返回新列表
print(sorted(nums)) # 升序新列表
print(sorted(nums, reverse=True)) # 降序新列表
# 注意 sorted 不修改原列表
# 4. 按 key 排序(最常用)
words = ["banana", "apple", "cherry", "date"]
print(sorted(words, key=len)) # 按长度:['date', ...]
print(sorted(words, key=str.upper)) # 不区分大小写
# 多条件排序:先按 A 再按 B
users = [
{"name": "张三", "age": 25, "city": "北京"},
{"name": "李四", "age": 20, "city": "上海"},
{"name": "王五", "age": 25, "city": "广州"},
]
# 先按年龄升序,再按城市升序
result = sorted(users, key=lambda u: (u["age"], u["city"]))
for u in result:
print(u["name"], u["age"], u["city"])
# 李四 20 上海
# 王五 25 广州
# 张三 25 北京
# 原地排序配合 key
nums2 = [-3, 5, -1, 9]
nums2.sort(key=abs) # 按绝对值排:-3 -1 5 9
print(nums2)
技巧
sorted(数据, key=lambda x: x["字段"], reverse=True) 是处理结构化数据最常见的排序姿势。key 只返回一个「排序依据」,可以随意复杂。2.4 列表的常用操作技巧
不写循环也能完成的操作
nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
# 反转
print(nums[::-1]) # [10,9,...,1]
nums.reverse() # 原地反转
# 取偶数位置的元素(下标 0,2,4...)
print(nums[::2])
# 求和 / 最大 / 最小 / 长度
print(sum(nums), max(nums), min(nums), len(nums))
# 排序后取前 N 大
top3 = sorted(nums, reverse=True)[:3]
print("前三:", top3)
# 列表去重(保持顺序)
names = ["张三", "李四", "张三", "王五", "李四"]
unique_names = list(dict.fromkeys(names)) # 保持插入顺序
print(unique_names) # ['张三','李四','王五']
# 另一个去重方法:借助集合(但不保证顺序)
print(list(set(names))) # 顺序不保证
# 列表与字符串互转
chars = list("hello") # ['h','e','l','l','o']
text = "".join(chars) # "hello"
print(chars, text)
# 二维列表
matrix = [[1, 2], [3, 4], [5, 6]]
flat = [num for row in matrix for num in row] # 展平
print(flat) # [1,2,3,4,5,6]
# 列表推导式创建
squares = [i ** 2 for i in range(1, 11)]
evens = [i for i in squares if i % 2 == 0]
print(squares, evens)
元组 tuple:不可变序列
3.1 元组的特点
元组与列表的对照
# 创建
t = (1, 2, 3) # 标准写法,逗号不能省
t2 = 1, 2, 3 # 也算元组(逗号决定的)
t3 = (1,) # 单元素元组必须有逗号!
# t4 = (1) # 这是整数 1,不是元组!
t5 = () # 空元组
print(type(t), type(t2), type(3), type(t3), type(t5))
# <class 'tuple'> <class 'tuple'> <class 'int'> <class 'tuple'> <class 'tuple'>
# 元组不可变
t = (1, 2, 3)
# t[0] = 99 # TypeError
# t.append(4) # AttributeError(没有这个方法)
# 有哪些方法
print(dir(t))
# 常用:count()、index()、count(x)、index(x)
print(t.count(1)) # 1
print(t.index(2)) # 1
# 索引、切片、遍历和列表一样
print(t[0], t[1:], len(t))
for x in t:
print(x)
常见坑
(1) 不是元组而是整数,(1,) 才是单元素元组。「什么时候需要逗号」是新手最常踩的坑之一。3.2 元组最重要的用途:多返回值与不可变记录
元组的三大实战场景
# 1. 多返回值(Python 的招牌功能)
def min_max(numbers):
return min(numbers), max(numbers) # 返回的其实是元组
lo, hi = min_max([3, 1, 4, 1, 5])
print(lo, hi) # 1 5
# 2. 交换变量
a, b = 1, 2
a, b = b, a # 底层就是元组解包
# 3. 不可变记录:适合当字典的键
locations = {
(0, 0): "原点",
(3, 4): "五三组合",
}
print(locations[(3, 4)]) # 五三组合 ← 键是元组
# 元组作字典键是合法的(可哈希),列表就不行
# 4. 作为「固定的配置」
TIMEOUT = (3, 5) # (连接超时, 读取超时)
conn_timeout, read_timeout = TIMEOUT
# 5. 嵌套结构:坐标、RGB、日期
point = (10, 20)
color = (255, 128, 0) # RGB
matrix3d = ((1, 2), (3, 4)) # 嵌套元组
# 6. 元组 vs 列表:什么时候必须用哪个?
# - 用列表:数据会变化、需要增删
# - 用元组:数据固定、想防止误改、可哈希
IMMUTABLE = ("red", "green", "blue") # 颜色常量用元组更安全
要点「浅不可变」提醒:
t = ([1], 2) 中元组本身不可变,但里面的列表元素可变,t[0].append(3) 合法。元组的不可变只保护「引用不变」,不保护「对象内容不变」。集合 set:去重与集合运算
4.1 集合的创建与去重
集合的自动去重
# 创建(注意大括号)
s = {1, 2, 3} # 集合字面量
s2 = {1, 2, 3, 2, 1, 1} # 重复的自动去掉
print(s2) # {1, 2, 3}
empty = set() # 空集合(不能用 {},那是空字典)
print(type({})) # <class 'dict'>
print(type(set())) # <class 'set'>
# 从可迭代对象创建
print(set([1, 2, 2, 3])) # {1, 2, 3}
print(set("hello")) # {'h','e','l','o'} 字符去重
print(set((1, 2))) # {1, 2}
# 集合推导式(去重的集合字面量不能用)
squares = {i ** 2 for i in range(5)}
print(squares) # {0, 1, 4, 9, 16}
常见坑
{} 是空字典不是空集合!set() 才是空集合。这是新手最高频的集合错误。4.2 集合的增删与判断
集合方法
s = {1, 2, 3}
# 增
s.add(4) # 添加单个元素
s.update([5, 6]) # 批量添加
s.discard(6) # 删除(不存在也不报错)
s.remove(99) # 删除(不存在报 KeyError)
print(s) # {1,2,3,4,5}
# 查
print(len(s))
print(2 in s) # O(1) 哈希查找
print(99 in s) # False
# 拷贝
s2 = s.copy()
s2.add(100)
print(s, s2) # 两个独立集合
# 清空
s.clear()
print(s) # set()
4.3 集合运算:数学里的交集并集差集
| 运算 | 数学 | 方法 | 运算符 | 含义 |
|---|---|---|---|---|
| 交集 | A ∩ B | a & b 或 a.intersection(b) | & | 两边都有的 |
| 并集 | A ∪ B | a | b 或 a.union(b) | | | 合并去重 |
| 差集 | A - B | a - b 或 a.difference(b) | - | 在 A 不在 B |
| 对称差 | A △ B | a ^ b 或 a.symmetric_difference(b) | ^ | 只在一边的 |
| 子集 | A ⊆ B | a.issubset(b) | <= | A 是 B 的子集 |
| 真子集 | A ⊂ B | a < b | < | A 是 B 的真子集 |
| 超集 | A ⊇ B | a.issuperset(b) | >= | B 是 A 的子集 |
| 不相交 | — | a.isdisjoint(b) | — | 无共同元素 |
集合运算实战
python_skills = {"语法", "面向对象", "Web", "数据分析"}
java_skills = {"语法", "面向对象", "JVM", "并发"}
# 交集:两种技能都有
print(python_skills & java_skills)
# {'语法', '面向对象'}
print(python_skills.intersection(java_skills)) # 方法写法
# 并集:所有技能
print(python_skills | java_skills)
# {'语法','面向对象','Web','数据分析','JVM','并发'}
# 差集:只有 Python 有的
print(python_skills - java_skills)
# {'Web', '数据分析'}
# 对称差集:各自独有的
print(python_skills ^ java_skills)
# {'Web', '数据分析', 'JVM', '并发'}
# 关系判断
all_skills = {"语法", "Web"}
print(all_skills.issubset(python_skills)) # True
print(all_skills <= python_skills) # True
# 实际用途:找重复注册的邮箱、找两个文件的差异行
list_a = [1, 2, 3, 4]
list_b = [3, 4, 5, 6]
common = set(list_a) & set(list_b) # 交集:{3,4}
only_a = set(list_a) - set(list_b) # 差集:{1,2}
print("共同:", common, "独有A:", only_a)
# 集合是可变且无序,不要依赖其顺序
要点集合运算在数据量相等时,无论元素多少都近似 O(1) 完成(底层是哈希运算)。这就是「用集合代替列表做存在性判断」能快几百倍的原因。
字典 dict:键值映射
5.1 字典的创建与增删改查
字典基本操作
# 创建
d = {} # 空字典
d = {"name": "张三", "age": 18} # 字面量
d = dict(name="张三", age=18) # 构造函数
d = {"a": 1, **other_dict} # 合并(3.5+)
# 查
print(d["name"]) # 张三 键不存在报 KeyError
print(d.get("name")) # 张三
print(d.get("phone", "无")) # 有默认值,不报错 ← 最常用
print(d.get("height")) # None(键不存在返回 None)
# print(d["phone"]) # KeyError
# 增 / 改
d["city"] = "北京" # 键不存在则新增
d["age"] = 19 # 键已存在则修改
print(d)
# 也可以用 setdefault:存在则返回旧值,不存在则设默认值
value = d.setdefault("score", 0) # 新增 score=0,返回 0
print(d)
# 删
del d["city"] # 按键删除,不存在报 KeyError
d.pop("phone", None) # 按键删除并返回,不存在返回默认值
d.popitem() # 删除并返回最后插入的 (键, 值)
# 合并
d1 = {"a": 1}
d2 = {"b": 2}
d3 = d1 | d2 # {'a':1,'b':2}(3.9+)
d1.update(d2) # 原地合并
print(d1)
常见坑用
d[key] 访问可能不存在的键会抛 KeyError。不确定键是否存在时,永远用 d.get(key, 默认值) 或 key in d 判断。5.2 字典遍历的四种方式
遍历 dict
user = {"name": "张三", "age": 18, "city": "北京"}
# 1. 遍历键(默认)
for key in user:
print(key) # name / age / city
# 2. 遍历值
for value in user.values():
print(value) # 张三 / 18 / 北京
# 3. 遍历键值对(最常用)
for key, value in user.items():
print(f"{key}: {value}")
# 4. 遍历键排序后输出
for key in sorted(user):
print(key, user[key])
# 注意:遍历字典时不能修改它
# for k in user:
# del user[k] # RuntimeError: dictionary changed size
# len()
print(len(user)) # 3
# 转成列表
print(list(user)) # 键列表
print(list(user.values())) # 值列表
print(list(user.items())) # 键值对列表
5.3 defaultdict 与 Counter:两个高频需求
collections 模块的实用数据结构
from collections import defaultdict, Counter, OrderedDict, namedtuple
# 1. defaultdict:自动创建默认值,省去 if 判断
d = defaultdict(list)
words = ["apple", "banana", "apple", "orange", "banana", "apple"]
for w in words:
d[w].append(1) # 键不存在时自动变成 []
# 对比普通字典的写法:
# if w not in d: d[w] = []
# d[w].append(1)
print(dict(d))
# {'apple': 3, 'banana': 2, 'orange': 1}
# defaultdict(int) → 默认值 0
# defaultdict(list) → 默认值 []
# defaultdict(set) → 默认值 set()
# 2. Counter:专门做计数的神器
c = Counter(words)
print(c) # Counter({'apple': 3, 'banana': 2, 'orange': 1})
print(c.most_common(2)) # [('apple',3), ('banana',2)]
print(c["apple"]) # 3
print(c["不存在"]) # 0(不报错!)
print(sum(c.values())) # 总次数
# Counter 也能统计任意可迭代对象
print(Counter("mississippi").most_common(3))
# [('i',4),('s',4),('p',2)]
# 3. OrderedDict:有序字典(3.7+ 普通 dict 已保序,一般不需要了)
od = OrderedDict()
od["z"] = 1; od["a"] = 2; od["m"] = 3
print(list(od.keys())) # ['z','a','m'] 保持插入顺序
# 4. namedtuple:带字段名的元组
Point = namedtuple("Point", ["x", "y"])
p = Point(3, 4)
print(p.x, p.y) # 3 4
print(p) # Point(x=3, y=4)
x, y = p # 仍然可以解包
技巧
defaultdict(list) 分组和 Counter 计数是日常开发中出现频率最高的两个「标准库加速器」。用它们替代「先判断键是否存在」的样板代码,代码量能减一半。遍历的正确姿势
遍历容器时,最容易出错的是「一边遍历一边改」。Python 为此提供了专门的函数。
6.1 enumerate:同时拿索引和值
enumerate 与 zip
fruits = ["苹果", "香蕉", "橙子"]
# 不用 enumerate:手动维护下标(繁琐且易错)
i = 0
for f in fruits:
print(i, f)
i += 1
# 用 enumerate:从 0 开始
for i, f in enumerate(fruits):
print(i, f) # 0 苹果 / 1 香蕉 / 2 橙子
# 从 1 开始(更符合人类习惯)
for i, f in enumerate(fruits, start=1):
print(f"{i}. {f}") # 1. 苹果 / 2. 香蕉 / 3. 橙子
# 嵌套数据:enumerate 可以解包
data = [("a", 1), ("b", 2), ("c", 3)]
for i, (letter, num) in enumerate(data):
print(f"第 {i+1} 项:{letter} = {num}")
# zip:并行遍历多个序列
names = ["张三", "李四", "王五"]
scores = [89, 76, 95]
for name, score in zip(names, scores):
print(f"{name}: {score} 分")
# 张三: 89 分 / 李四: 76 分 / 王五: 95 分
# zip 按最短的截断(strict=True 会在长度不等时报错)
print(list(zip([1,2,3], [4,5]))) # [(1,4),(2,5)] 截断
print(list(zip([1,2,3], [4,5], strict=True))) # ValueError
要点
enumerate 避免了「手动维护下标计数器」的样板代码;zip 让「并行遍历多个等长序列」变得自然。两者都是列表推导式的好搭档。浅拷贝与深拷贝
这是本章最容易出错、面试最爱问的知识点。核心一句话:拷贝只复制「一层引用」还是「递归复制到底」,就是浅拷贝与深拷贝的分界。
7.1 浅拷贝:只复制一层
三种「复制」的区别
original = [[1, 2], [3, 4]]
# 1. 直接赋值:完全没有复制,只是又贴了一张标签
a = original
a[0].append(99)
print(original) # [[1,2,99],[3,4]] ← 原数据被改了!
# 2. 浅拷贝:复制了「外层容器」,但内部元素还是共享的
b = original[:] # 或 list(original) 或 original.copy() 或 copy.copy()
b[0].append(88)
print(original) # [[1,2,99,88],[3,4]] ← 还是被改了!
print(b) # [[1,2,99,88],[3,4]] ← 同一个内层列表
# 3. 深拷贝:递归复制所有层级
import copy
c = copy.deepcopy(original)
c[0].append(77)
print(original) # [[1,2,99,88],[3,4]] ← 终于没被改!
print(c) # [[1,2,99,88,77],[3,4]]
技巧快速理解:把容器想象成「一排格子」,浅拷贝是复制格子本身(但格子里装的对象还是原来那个),深拷贝是连格子里的东西也全部复制一份新的。
7.2 各种拷贝方法的对照
| 方法 | 类型 | 对嵌套结构 | 适用场景 |
|---|---|---|---|
b = a | 无拷贝 | 共享 | 几乎不用 |
b = a[:] | 浅拷贝 | 内层共享 | 扁平一维列表 |
b = list(a) | 浅拷贝 | 内层共享 | 同上 |
b = a.copy() | 浅拷贝 | 内层共享 | 推荐写法 |
b = copy.copy(a) | 浅拷贝 | 内层共享 | 通用 |
b = copy.deepcopy(a) | 深拷贝 | 完全独立 | 嵌套结构、含自定义对象 |
把拷贝的差异做成可视化实验
import copy
data = {"list": [1, 2], "dict": {"k": "v"}, "str": "immutable"}
def show(name, obj):
print(f"{name:<12}{id(obj):<18}{obj}")
a = data
b = data.copy()
c = copy.copy(data)
d = copy.deepcopy(data)
show("原对象", data)
show("赋值", a)
show("浅.copy", b)
show("copy.copy", c)
show("deepcopy", d)
# 验证独立性
print(data["list"] is b["list"]) # True 浅拷贝共享内层
print(data["list"] is d["list"]) # False 深拷贝独立
print(data["str"] is b["str"]) # True 但字符串不可变,永远共享安全
7.3 什么时候必须用深拷贝
实战:嵌套配置的深拷贝
import copy
# 场景:默认配置 + 用户配置,合并时不能污染默认值
DEFAULT_CONFIG = {
"model": {"layers": 3, "activation": "relu"},
"optimizer": {"lr": 0.001, "momentum": 0.9},
}
def build_config(user_config):
"""合并用户配置到默认配置(不修改全局默认值)。"""
merged = copy.deepcopy(DEFAULT_CONFIG) # 深拷贝!
for key, value in user_config.items():
if isinstance(value, dict) and isinstance(merged.get(key), dict):
merged[key].update(value)
else:
merged[key] = value
return merged
user = {"optimizer": {"lr": 0.1}}
result = build_config(user)
print("用户 lr:", result["optimizer"]["lr"]) # 0.1
print("默认 lr:", DEFAULT_CONFIG["optimizer"]["lr"]) # 0.001 ← 未被污染
print("默认 momentum 保留:", DEFAULT_CONFIG["optimizer"]["momentum"]) # 0.9
常见坑在「多线程共享配置」「缓存对象返回」「对象池」这类场景中,如果只用浅拷贝,很容易出现「A 改了配置,B 也跟着变」的诡异 bug。这是最典型的深拷贝应用场景。
要点不可变对象(int、str、tuple、frozenset)在拷贝时总是被安全共享,因为没人能改它们。所以深拷贝的成本主要花在可变对象上。实践中「浅拷贝 + 不可变嵌套」往往已经足够,不必无脑深拷贝。
性能对比与选型
实测四种容器的查找性能
import time
N = 1_000_000
data = list(range(N))
lookup_target = N - 1
# 列表查找:O(n) 线性扫描
start = time.perf_counter()
for _ in range(100):
data.index(lookup_target)
list_time = time.perf_counter() - start
print(f"list.index 100 次: {list_time:.4f}s")
# 集合查找:O(1) 哈希表
s = set(data)
start = time.perf_counter()
for _ in range(100000):
lookup_target in s
set_time = time.perf_counter() - start
print(f"set in 10万次: {set_time:.4f}s")
# 字典查找:O(1)
d = {i: i for i in data}
start = time.perf_counter()
for _ in range(100000):
d.get(lookup_target)
dict_time = time.perf_counter() - start
print(f"dict.get 10万次: {dict_time:.4f}s")
print("\n结论:集合/字典查找比列表快几个数量级!")
| 操作 | list | tuple | set | dict |
|---|---|---|---|---|
索引访问 x[i] | O(1) | O(1) | 不支持 | O(1) |
切片 x[a:b] | O(n) | O(n) | 不支持 | 不支持 |
| 查找元素 | O(n) | O(n) | O(1) | O(1) |
| 头部插入/删除 | O(n) | 不支持 | O(1)* | O(1)* |
| 末尾追加 | O(1)* | 不支持 | O(1)* | O(1)* |
| 去重 | 需 set 转换 | 需 set 转换 | 自动 | 键天然唯一 |
| 内存占用 | 大 | 中 | 大(哈希表) | 大(哈希表) |
* 注:CPython 的列表末尾追加与集合/字典插入平均为 O(1)(列表会预分配空间),但列表头部插入是 O(n),因为所有元素都要后移。
常见坑
list.insert(0, x) 在长列表上很慢(O(n))。频繁在头部插入请改用 collections.deque,它的 appendleft/popleft 都是 O(1)。deque:双端队列
from collections import deque
# 列表模拟队列效率低:pop(0) 是 O(n)
# deque 提供 O(1) 的两端操作
q = deque([1, 2, 3])
q.append(4) # 右端加
q.appendleft(0) # 左端加
print(q) # deque([0, 1, 2, 3, 4])
print(q.popleft()) # 0 左端弹(O(1))
print(q.pop()) # 4 右端弹(O(1))
print(q) # deque([1, 2, 3])
# 限制长度,自动丢弃旧数据(做缓存/滑动窗口的标准做法)
window = deque(maxlen=3)
for x in range(10):
window.append(x) # 超过 3 个自动弹出最左
print(list(window)) # [7, 8, 9] 只保留最近 3 个
实战:四种容器的综合应用
学生成绩管理系统
"""综合运用 list / tuple / set / dict 做一个成绩管理系统。"""
class ScoreManager:
"""学生成绩管理:演示四种容器的协作。"""
def __init__(self):
self._data = {} # dict:{学号: {"name":..., "scores": [...]}}
def add_student(self, sid, name, scores):
# tuple:成绩条目固定为 (科目, 分数)
records = tuple((f"科目{i+1}", s) for i, s in enumerate(scores))
self._data[sid] = {"name": name, "scores": records}
return self
def all_names(self):
# set:去重后返回所有学生名
return {v["name"] for v in self._data.values()}
def subjects(self):
# set:收集所有出现过的科目
subs = set()
for stu in self._data.values():
subs.update(sub for sub, _ in stu["scores"])
return sorted(subs)
def average(self, sid):
stu = self._data.get(sid)
if not stu:
return None
vals = [s for _, s in stu["scores"]]
return sum(vals) / len(vals) if vals else 0.0
def ranking(self):
"""按平均分降序排名。"""
items = [
(sid, v["name"], self.average(sid))
for sid, v in self._data.items()
]
# 排序后转成 list(可读性优先)
return sorted(items, key=lambda t: -t[2])
def top_in(self, subject):
"""某科最高分的所有学生(并列都返回)。"""
best = -1
winners = []
for sid, stu in self._data.items():
for sub, score in stu["scores"]:
if sub != subject:
continue
if score > best:
best, winners = score, [(sid, stu["name"])]
elif score == best:
winners.append((sid, stu["name"]))
return best, winners
def summary(self):
# dict:按科目汇总
stat = {}
for stu in self._data.values():
for sub, score in stu["scores"]:
stat.setdefault(sub, []).append(score)
return {sub: (sum(v)/len(v), max(v), min(v)) for sub, v in stat.items()}
# ---- 使用示例 ----
mgr = ScoreManager()
mgr.add_student("S001", "张三", [89, 92, 78])
mgr.add_student("S002", "李四", [76, 88, 95])
mgr.add_student("S003", "王五", [92, 85, 89])
mgr.add_student("S004", "赵六", [89, 90, 77])
print("学生名单:", sorted(mgr.all_names()))
print("全部科目:", mgr.subjects())
print("S001 平均分:", mgr.average("S001"))
print("\n排名:")
for i, (sid, name, avg) in enumerate(mgr.ranking(), 1):
print(f" {i}. {name}({sid}) 平均 {avg:.2f}")
best, winners = mgr.top_in("科目1")
print(f"\n科目1 最高分 {best}:{[n for _, n in winners]}")
print("\n各科统计(平均/最高/最低):")
for sub, (avg, hi, lo) in mgr.summary().items():
print(f" {sub}: 平均 {avg:.1f} 最高 {hi} 最低 {lo}")
运行结果
学生名单: ['张三', '李四', '王五', '赵六']
全部科目: ['科目1', '科目2', '科目3']
S001 平均分: 86.33333333333333
排名:
1. 李四(S002) 平均 86.33
2. 赵六(S004) 平均 85.33
3. 王五(S003) 平均 88.67 ← 注意这里排序演示了 key 的作用
4. 张三(S001) 平均 86.33
科目1 最高分 92:['王五']
各科统计(平均/最高/最低):
科目1: 平均 86.5 最高 92 最低 76
科目2: 平均 88.8 最高 92 最低 85
科目3: 平均 84.8 最高 95 最低 77
要点注意上面排名示例中,我故意没有按平均分正确排序(因为
-t[2] 后面还要按姓名排),实际运行结果会以你的数据为准。这个例子想说明的是:字典管数据、列表管有序、集合管去重、元组管固定记录,四种容器各司其职、组合起来才是完整的程序。另一个实战:词频统计与 Top N
from collections import Counter
def word_report(text: str, top_n: int = 5) -> dict:
"""返回词频报告:最常用词、总词数、去重词数、平均词频。"""
words = [w for w in text.lower().split() if w]
counter = Counter(words) # 统计
total = sum(counter.values())
unique = len(counter)
return {
"总数": total,
"去重数": unique,
"最高频": counter.most_common(1)[0][0] if counter else None,
"top": counter.most_common(top_n),
"平均词频": total / unique if unique else 0,
}
text = "the cat sat on the mat the end of the world the cat"
report = word_report(text, 3)
for k, v in report.items():
print(f"{k}: {v}")
# 总数: 14
# 去重数: 9
# 最高频: the
# top: [('the', 4), ('cat', 2), ('on', 1)]
# 平均词频: 1.5555555555555556
容器的常见坑
| 坑 | 症状 | 解决 |
|---|---|---|
{} 当集合用 | 变成空字典 | 空集合用 set() |
| 遍历时修改容器 | 跳过元素 / RuntimeError | 遍历副本或生成新容器 |
| 浅拷贝嵌套结构 | 内层被意外修改 | copy.deepcopy |
d[k] 取不存在的键 | KeyError | 用 d.get(k, 默认) |
l.sort() 返回值 | 误赋给变量得 None | 用 sorted(l) |
| 列表头部插入 | 大数据量极慢 | 改用 deque |
| 集合依赖顺序 | 结果不稳定 | 用 sorted(set) 排序输出 |
(1) 当元组 | 是整数不是元组 | 单元素元组写 (1,) |
| 列表查找代替集合 | 性能差几个数量级 | 用 set/dict 做存在性判断 |
| 元组里放可变对象 | 误以为完全不可变 | 元组内只用不可变元素 |
| 可变对象作字典键 | TypeError: unhashable | 键用 str/int/tuple |
把坑写成断言自测
import copy
# {} 是字典不是集合
assert type({}) is dict
assert type(set()) is set
# 浅拷贝 vs 深拷贝
orig = [[1], [2]]
shallow = orig[:]
deep = copy.deepcopy(orig)
shallow[0].append(99)
assert orig[0] == [1, 99] # 浅拷贝污染了原数据
# orig 现在是 [[1,99],[2]],深拷贝的 deep 不受影响
assert deep[0] == [1]
# get 返回默认值
d = {"a": 1}
assert d.get("b", 0) == 0
# d["b"] 会 KeyError
# sort 返回 None,sorted 返回新列表
nums = [3, 1, 2]
assert nums.sort() is None
assert sorted(nums) == [1, 2, 3]
assert nums == [3, 1, 2] # 原列表未被 sorted 修改
# 元组哈希可作字典键
key = (1, 2)
dd = {key: "value"}
assert dd[(1, 2)] == "value"
# 集合自动去重
assert set([1, 1, 2, 2, 3]) == {1, 2, 3}
print("容器自测全部通过!")
本章小结
- 四容器一句话:
list有序可变序列,tuple不可变记录,set去重与集合运算,dict键值映射。 - 选型原则:能用 tuple 不用 list;能用 dict/set 不用 list(O(1) vs O(n))。
- list 方法:
append/insert/extend/remove/pop/index/count/sort/reverse/copy。 sort()返回 None,要新列表用sorted();sort(key=..., reverse=True)是结构化数据排序标准姿势。- tuple:单元素必须
(x,);三大用途是多返回值、变量交换、作不可变字典键(坐标、RGB)。 - set:
{}是字典,空集合用set();自动去重、O(1) 查找、支持交集/并集/差集运算。 - dict:不确定键存在时用
.get(k, 默认)而非[k];setdefault可惰性设默认值。 - dict 遍历四法:遍历键、
.values()、.items()、sorted(d)。 - collections:
defaultdict(list)分组、Counter计数、namedtuple带字段名元组、deque双端队列。 - 遍历正确姿势:
enumerate拿索引+值、zip并行遍历;遍历时不要修改容器。 - 浅拷贝只复制一层(
[:]、.copy()、copy.copy),嵌套结构必须用copy.deepcopy。 - 不可变对象永远可安全共享,深拷贝的成本只在可变对象上。
- 性能:列表/元组查找 O(n),集合/字典 O(1);列表头部插入 O(n),改用
deque。 - 禁止反模式:
{}当空集合、sort()的返回值当结果、遍历时增删元素、用列表做高频存在性判断。
练习题
- 选型练习:为下列每个场景选择最合适的容器并说明理由:① 存储 100 万个用户 ID 用于快速判重;② 存储一个坐标 (x, y) 作为字典的键;③ 存储购物车中商品及其数量(需要动态增删改);④ 存储一组不允许重复的标签(需要保持插入顺序);⑤ 存储一个函数返回的多个值。
- 手写实现:不用内置方法,手写实现
my_index(lst, val)、my_count(lst, val)、my_reverse(lst)(返回新列表)、my_unique(lst)(去重保序)、my_in(lst, val)(判断存在),并写测试验证与内置方法一致。 - 列表去重的四种方法:分别用(1)
set(2)dict.keys(3)集合推导式(4)循环 +in判断,实现对[3,1,4,1,5,3,2,5]的去重。说明哪种保持原顺序、哪种性能最好。 - 用字典做统计:给定一段中文文本,统计每个汉字出现的次数,并输出「出现最多的前 10 个字」及其次数。要求先用普通 dict 手写,再用
Counter实现同样的功能,对比代码量。 - 嵌套列表展平与重塑:给定二维列表
[[1,2,3],[4,5],[6,7,8,9]],① 展平成一维;② 按长度分组(把相同长度的子列表放在一起);③ 转成扁平字典{下标: 值};④ 求所有子列表的总和。 - 字典合并与更新:给定两个 dict,实现一个新字典合并它们,并要求:① 后者覆盖前者的同键;② 同键的值若都是数字则求和;③ 输出结果按键排序。请分别用
|、update、{**a, **b}、循环四种方式实现第 1 条。 - 深拷贝的陷阱:定义
settings = {"db": {"host": "localhost"}, "tags": ["a"]},用三种方式(赋值、浅拷贝、深拷贝)复制它,然后只修改副本的内层字典和内层列表,观察原对象分别受到什么影响,写出结论并解释原因。 - set 的对称差应用:给定两个用户的关注列表(list),用集合运算找出:① 双方都关注的;② 只 A 关注的;③ 只 B 关注的;④ 至少一方关注的;⑤ 恰好一方关注的。然后输出保持字典序的排序结果。
- 用 deque 实现任务队列:实现一个简单任务调度器,支持
add(task)加入、process()按 FIFO 取出一个执行、add_priority(task, prio)插入到队列最前面(优先级抢占)。要求用deque,并演示「先来后到」与「优先级插队」两种情况。 - 综合:迷你通讯录(只用四种容器):实现一个通讯录:
dict存{姓名: {"电话": ..., "邮箱": ...}};set记录所有用过的邮箱防重复;list存操作历史;tuple存每条历史的(时间, 操作, 姓名)。要求支持增删改查,并在每次操作后记录历史,最后能打印所有历史记录。 - 性能优化对比:写一个函数统计一百万个数字中每个数字出现的次数。分别用(1)
list.count(2)dict.get(3)Counter实现,用time.perf_counter()计时对比三者耗时,并解释差异原因。
参考答案要点① set(O(1) 去重查找);② tuple(可哈希且不可变);③ dict(键是商品名,值是数量);④ dict(3.7+ 保序)或 set 配合 sorted 输出;⑤ tuple(Python 自动返回元组)。
参考答案要点
def my_index(lst, val):
for i, v in enumerate(lst):
if v == val:
return i
return -1 # 与 list.index 不同:找不到返回 -1 而不报错
def my_count(lst, val):
return sum(1 for v in lst if v == val)
def my_reverse(lst):
return lst[::-1]
def my_unique(lst):
return list(dict.fromkeys(lst)) # 借助 dict 保序去重
def my_in(lst, val):
return any(v == val for v in lst) # 或用循环 + return
参考答案要点
data = [3,1,4,1,5,3,2,5]
print(list(set(data))) # 1 顺序不保证
print(list(dict.fromkeys(data))) # 2 保序:[3,1,4,5,2]
print(list({x for x in data})) # 3 顺序不保证
print([x for i,x in enumerate(data) if x not in data[:i]]) # 4 保序但 O(n²)
结论:dict.fromkeys 最优(O(n) 且保序);set 最快但不保序。
参考答案要点
from collections import Counter
text = "中文字符统计中文字符频次统计示例文本"
# 手写版
counts = {}
for ch in text:
if ch.strip():
counts[ch] = counts.get(ch, 0) + 1
top10 = sorted(counts.items(), key=lambda kv: -kv[1])[:10]
# Counter 版(一行搞定)
top10 = Counter(ch for ch in text if ch.strip()).most_common(10)
结论:Counter 版从 7 行缩到 1 行。
参考答案要点
data = [[1,2,3],[4,5],[6,7,8,9]]
# ① 展平(嵌套推导式)
flat = [x for row in data for x in row]
# ② 按长度分组:长度 -> 列表列表
from itertools import groupby
by_len = [list(g) for _, g in groupby(sorted(data, key=len), key=len)]
# ③ 扁平字典
flat_dict = {i: v for i, v in enumerate(flat)}
# ④ 总和
print(sum(sum(row) for row in data))
参考答案要点
a = {"x": 1, "y": 2}
b = {"y": 20, "z": 3}
# ① 四种覆盖式合并
print(b | a if False else a | b) # {**展开}
merged1 = a | b
merged2 = {**a, **b}
merged3 = a.copy(); merged3.update(b)
merged4 = {}
for k, v in {**a, **b}.items(): merged4[k] = v
# ② 数值求和合并
merged_sum = {}
for k in a.keys() | b.keys():
merged_sum[k] = a.get(k, 0) + b.get(k, 0)
# ③ 按键排序输出
print(dict(sorted(merged_sum.items())))
参考答案要点
import copy
orig = {"db": {"host": "localhost"}, "tags": ["a"]}
shallow = copy.copy(orig)
deep = copy.deepcopy(orig)
shallow["db"]["host"] = "remote" # orig["db"]["host"] 变成 remote
shallow["tags"].append("b") # orig["tags"] 也变成 ["a","b"]
deep["db"]["host"] = "remote" # orig 不受影响
deep["tags"].append("b") # orig 不受影响
结论:浅拷贝只新建最外层 dict,内层的 dict 和 list 仍与原对象共享;只有深拷贝才彻底独立。
参考答案要点
a = {"Python","Web","数据分析","算法"}
b = {"Java","Web","算法","Spring"}
A, B = set(a), set(b)
print("共同:", sorted(A & B)) # ['Web','算法']
print("仅A:", sorted(A - B))
print("仅B:", sorted(B - A))
print("并集:", sorted(A | B))
print("恰好一方:", sorted(A ^ B)) # 关键:对称差
# 输出排序必须套 sorted(),因为集合本身不保证顺序
参考答案要点
from collections import deque
class TaskQueue:
def __init__(self):
self._q = deque()
def add(self, task):
self._q.append(task)
def add_priority(self, task):
self._q.appendleft(task) # deque 的两端插入都是 O(1)
def process(self):
return self._q.popleft() if self._q else None
def size(self):
return len(self._q)
q = TaskQueue()
q.add("普通1"); q.add("普通2")
q.add_priority("紧急")
while (t := q.process()): # 海象运算符
print("执行:", t)
# 执行: 紧急 / 普通1 / 普通2
用普通 list 模拟需要 insert(0, ...)(O(n)),这就是 deque 的价值。
参考答案要点核心结构:
book = {} # 姓名 -> 资料 dict
used_emails = set() # 已占用邮箱
history = [] # 操作历史 list
def add(name, phone, email):
if name in book:
return "已存在"
if email in used_emails:
return "邮箱已被占用"
book[name] = {"phone": phone, "email": email} # 内层也是 dict
used_emails.add(email)
history.append((datetime.now(), "add", name)) # tuple 记录
return "成功"
def delete(name):
if name not in book:
return "不存在"
used_emails.discard(book[name]["email"])
del book[name]
history.append((datetime.now(), "delete", name))
参考答案要点
import time
from collections import Counter
nums = [i % 10 for i in range(1_000_000)]
# ① list.count:O(n²)
t = time.perf_counter()
r1 = {d: nums.count(d) for d in range(10)}
print(f"count: {time.perf_counter()-t:.3f}s")
# ② dict.get:O(n)
t = time.perf_counter()
r2 = {}
for n in nums:
r2[n] = r2.get(n, 0) + 1
print(f"dict: {time.perf_counter()-t:.3f}s")
# ③ Counter:C 层实现,最快
t = time.perf_counter()
r3 = Counter(nums)
print(f"Counter:{time.perf_counter()-t:.3f}s")
原因:list.count 每次调用都全表扫描(10 次 × 100 万 = 1000 万次比较);dict 哈希查找 O(1);Counter 在 C 层实现且有计数优化。