12 周课程 / 第 4 周 8 分钟阅读

第 4 周:字典、集合与数据组织

当数据需要“按名字查值”“统计次数”或“按类别分组”时,列表不再是最合适的结构。本周学习用字典保存键值对应关系,用集合去重和做成员判断,用 zip() 把成组数据配对。

建议用时:8-10 小时。

本周目标#

学完本周,你应该能够:

  • 创建、访问、增加、修改和删除字典元素。
  • 区分 d[key] 与 d.get(key)。
  • 遍历字典的键、值和键值对。
  • 使用字典完成计数、分组和映射。
  • 使用集合去重和判断成员。
  • 理解集合无序,不依赖其输出顺序。
  • 使用 zip() 合并多个序列,并处理长度不一致。
  • 在一组学生记录上完成排序和按班级汇总。

4.1 字典是什么#

字典保存“键到值”的映射。键用来查找,值是与键对应的数据。

student = {
    "student_id": "S001",
    "name": "张晨",
    "score": 88,
}

print(student["name"])   # 张晨
print(student["score"])  # 88

结构规则:

  • 外层使用花括号 {}。
  • 每个元素写成 键: 值。
  • 元素之间用逗号分隔。
  • 键通常使用字符串或数字。
  • 同一个字典中的键不能重复。
  • 值的类型可以相同,也可以不同。

如果重复写同一个键,后面的值会覆盖前面的值:

data = {"name": "张晨", "name": "李雨"}
print(data)  # {'name': '李雨'}

4.2 访问、增加和修改#

student = {"name": "张晨", "score": 82}

print(student["name"])

student["score"] = 88       # 键已存在,修改值
student["class_name"] = "高一(1)班"  # 键不存在,增加新键值对

print(student)
# {'name': '张晨', 'score': 88, 'class_name': '高一(1)班'}

使用 [] 读取不存在的键会报 KeyError:

# print(student["phone"])

4.3 get():安全读取#

student = {"name": "张晨", "score": 82}

print(student.get("name"))          # 张晨
print(student.get("phone"))         # None
print(student.get("phone", "未填写"))  # 未填写

get() 在键不存在时不会报错,而是返回默认值。第二个参数省略时默认是 None。

注意:get() 只负责读取,不会自动把默认值写回字典:

student.get("phone", "未填写")
print("phone" in student)  # False

如果希望新增键,需要显式赋值:

student["phone"] = "未填写"

4.4 删除元素#

student = {"name": "张晨", "score": 82, "phone": "123"}

score = student.pop("score")
print(score)        # 82
print(student)      # {'name': '张晨', 'phone': '123'}

del student["phone"]
print(student)      # {'name': '张晨'}

pop() 返回被删除的值。若键不存在,它会报 KeyError,可以给默认值避免异常:

phone = student.pop("phone", "没有该字段")

4.5 字典遍历#

scores = {"语文": 88, "数学": 95, "英语": 80}

for subject in scores:
    print(subject)

直接遍历字典时,得到的是键。

同时访问键和值:

for subject, score in scores.items():
    print(subject, score)

只遍历键:

for subject in scores.keys():
    print(subject)

只遍历值:

for score in scores.values():
    print(score)

keys()、values()、items() 返回的是动态视图。修改字典后,再遍历这些视图会看到新的内容。基础题中主要掌握它们可以用于 for 和 list()。

4.6 用字典做计数#

统计每个单词出现次数:

words = ["apple", "banana", "apple", "orange", "banana", "apple"]
counts = {}

for word in words:
    if word in counts:
        counts[word] += 1
    else:
        counts[word] = 1

print(counts)
# {'apple': 3, 'banana': 2, 'orange': 1}

也可以用 get() 简化:

counts = {}
for word in words:
    counts[word] = counts.get(word, 0) + 1

counts.get(word, 0) 表示“如果已有这个词,取当前次数;否则从 0 开始”,加 1 后再保存回字典。

如果直接写:

# counts[word] += 1

第一次遇到新词时会报 KeyError,因为键还不存在。

4.7 用字典做分组#

把学生姓名按班级分组:

students = [
    ("高一(1)班", "张晨"),
    ("高一(2)班", "李雨"),
    ("高一(1)班", "王浩"),
    ("高一(2)班", "陈欣"),
]

groups = {}

for class_name, name in students:
    if class_name not in groups:
        groups[class_name] = []
    groups[class_name].append(name)

print(groups)

结果:

{
    "高一(1)班": ["张晨", "王浩"],
    "高一(2)班": ["李雨", "陈欣"],
}

分组时第一次遇到某个键,必须先创建空列表,再追加数据。否则 groups[class_name].append(...) 会因为列表还不存在而报错。

4.8 字典保存复杂记录#

一个学生有多项字段时,可以用字典:

students = [
    {"student_id": "S001", "name": "张晨", "score": 88},
    {"student_id": "S002", "name": "李雨", "score": 95},
    {"student_id": "S003", "name": "王浩", "score": 72},
]

for student in students:
    print(
        student["student_id"],
        student["name"],
        student["score"],
    )

按分数从高到低排序:

ranked = sorted(
    students,
    key=lambda student: student["score"],
    reverse=True,
)

for student in ranked:
    print(student["name"], student["score"])

key=lambda student: student["score"] 的意思是:排序时不直接比较整个字典,而是先对每条记录取出 score 作为排序依据。lambda 可以理解成一个临时的小函数。

并列排序的规则虽然还没要求全面掌握,但要知道:如果两个分数相同,当前 sorted 会保留它们在原列表中的相对顺序。

4.9 zip():把多个序列配对#

names = ["张晨", "李雨", "王浩"]
scores = [88, 95, 72]

pairs = list(zip(names, scores))
print(pairs)
# [('张晨', 88), ('李雨', 95), ('王浩', 72)]

在循环中逐个解包:

for name, score in zip(names, scores):
    print(f"{name}: {score}")

构建字典:

score_map = dict(zip(names, scores))
print(score_map)  # {'张晨': 88, '李雨': 95, '王浩': 72}

zip() 在最短的序列结束时停止:

print(list(zip([1, 2, 3], ["A", "B"])))
# [(1, 'A'), (2, 'B')]

如果要求所有数据一一对应,应先检查两个序列长度是否相等,而不是默默丢掉多余数据。

第一阶段常见推演#

d = {"a": 1, "b": 2}
print(list(zip(d.keys(), d.values())))

结果是:

[("a", 1), ("b", 2)]

zip() 把第一个键与第一个值、第二个键与第二个值分别配对,而不是把键合成一个列表、值合成另一个列表。

4.10 集合#

集合用花括号表示,保存不重复元素:

numbers = {1, 2, 2, 3, 3, 4}
print(numbers)  # {1, 2, 3, 4}

创建空集合必须使用 set():

empty_set = set()
print(type(empty_set).__name__)  # set

empty_dict = {}
print(type(empty_dict).__name__) # dict

{} 创建的是空字典,不是空集合。

去重#

names = ["张晨", "李雨", "张晨", "王浩", "李雨"]
unique_names = list(set(names))
print(unique_names)

数学上集合没有固定顺序,所以 unique_names 的元素先后顺序不应作为程序结果来依赖。正式题若要求“保持原顺序去重”,可以使用字典:

unique_names = list(dict.fromkeys(names))
print(unique_names)  # ['张晨', '李雨', '王浩']

从 Python 3.7 起,字典会保留键的插入顺序,因此这种写法同时完成“去重”和“保留首次出现顺序”。目标环境是 Python 3.11,可以这样使用。

如果需要按值排序:

unique_sorted = sorted(set(names))

先让集合完成去重,再用 sorted() 明确排序,不依赖集合自己的显示顺序。

成员判断#

valid_levels = {"优秀", "良好", "及格", "不及格"}
level = "良好"

print(level in valid_levels)  # True

集合的成员判断通常比列表更快,因为集合可以直接按值定位,而不必从头逐个查找。初学阶段先掌握语义,不必过早优化所有代码。

集合运算#

python_group = {"张晨", "李雨", "王浩"}
excel_group = {"李雨", "王浩", "陈欣"}

print(python_group & excel_group)  # 交集
print(python_group | excel_group)  # 并集
print(python_group - excel_group)  # 差集

三种结果分别表示“两组都参加”“至少参加一组”“只参加第一组”。

集合元素必须可哈希#

列表和字典不能直接放进集合,因为它们可以修改:

# invalid = {[1, 2], [3, 4]}  # TypeError
valid = {(1, 2), (3, 4)}

元组不可修改,在元素本身也合适时可以放进集合。

4.11 选择合适的结构#

需求 推荐结构 原因
保持一排有顺序的数据 列表 可修改、可索引
用一个键查一个值 字典 按关键字直接映射
只关心有哪些不重复元素 集合 自动去重、成员判断方便
保存不可修改的一组值 元组 结构稳定
一个学生有多个字段 字典或对象 字段名称明确
多条学生记录 字典列表 每条记录结构相同,便于遍历和排序

选择结构时先问“我要怎样取数据”,再看哪种结构最直接。

4.12 本周作品一:通讯录#

contacts = {}

while True:
    command = input("add 姓名 电话 / get 姓名 / delete 姓名 / list / quit:")
    parts = command.split()

    if not parts:
        continue

    action = parts[0]

    if action == "quit":
        break
    elif action == "add" and len(parts) == 3:
        name, phone = parts[1], parts[2]
        contacts[name] = phone
        print("已保存:", name)
    elif action == "get" and len(parts) == 2:
        name = parts[1]
        print(contacts.get(name, "联系人不存在"))
    elif action == "delete" and len(parts) == 2:
        name = parts[1]
        if name in contacts:
            del contacts[name]
            print("已删除:", name)
        else:
            print("联系人不存在")
    elif action == "list":
        for name, phone in contacts.items():
            print(name, phone)
    else:
        print("命令格式不正确")

重点检查:

  • 同名联系人再次 add 时会更新电话,而不是出现两个同名键。
  • 查询不存在的联系人时给出提示。
  • 删除前先用 in 判断,避免 KeyError。

4.13 本周作品二:投票统计#

votes = ["A", "B", "A", "C", "A", "B"]
counts = {}

for candidate in votes:
    counts[candidate] = counts.get(candidate, 0) + 1

print("得票:", counts)

winner = None
highest = -1

for candidate, count in counts.items():
    if count > highest:
        highest = count
        winner = candidate

print("最高票:", winner, highest)

这里使用严格大于号 >,所以出现并列最高票时,程序保留字典中先出现的候选人。题目若规定其他破同分规则,必须按题目修改。

4.14 本周作品三:按班级统计成绩#

students = [
    {"class_name": "高一(1)班", "score": 88},
    {"class_name": "高一(2)班", "score": 90},
    {"class_name": "高一(1)班", "score": 76},
    {"class_name": "高一(2)班", "score": 82},
]

class_scores = {}

for student in students:
    class_name = student["class_name"]
    score = student["score"]

    if class_name not in class_scores:
        class_scores[class_name] = []
    class_scores[class_name].append(score)

for class_name, scores in class_scores.items():
    average = sum(scores) / len(scores)
    print(f"{class_name} 平均分:{average:.2f}")

输出:

高一(1)班 平均分:82.00
高一(2)班 平均分:86.00

这个“先分组、再对每组计算”的模式会反复出现在阶段二和阶段三。阶段三还会把结果写入 Excel。

4.15 独立练习#

A. 快速判断题#

  1. d = {"a": 1}; print(d["b"]) 会发生什么?

  2. d.get("b") 返回什么?

  3. d.get("b", 0) 返回什么?

  4. 下面两行分别得到什么类型?

    a = {}
    b = set()
    
  5. list(zip([1, 2], ["A", "B"])) 是什么?

  6. zip([1, 2, 3], ["A", "B"]) 会配出几对?

  7. {"a": 1, "b": 2}.items() 遍历时每项包含什么?

  8. 集合中的元素可以有列表吗?

  9. list(set([3, 1, 3, 2])) 的结果一定要按 [1, 2, 3] 输出吗?

  10. 要按原顺序去重,哪种结构更合适?

B. 编程题#

  1. 输入一串英文单词,统计每个单词出现次数。
  2. 有一个姓名列表,输出去重后的姓名,并保持首次出现顺序。
  3. 有两个列表 names 和 scores,用 zip() 输出每名学生的分数。
  4. 用字典保存 5 个国家的首都,并让用户输入国家查询首都。
  5. 统计一段文字中每个字符出现的次数,忽略空格。
  6. 把学生记录列表按分数降序排序,输出姓名和分数。
  7. 按班级汇总学生总分、人数和平均分。
  8. 从两个名字集合中输出同时参加两个活动的学生,以及只参加第一个活动的学生。

4.16 练习答案与解释#

A. 快速判断题答案#

  1. 报 KeyError,因为键 "b" 不存在。
  2. None。
  3. 0。
  4. a 是空字典 dict,b 是空集合 set。
  5. [(1, "A"), (2, "B")]。
  6. 两对,zip() 到最短序列结束。
  7. 每项是一个“键、值”二元组。
  8. 不可以,列表可修改且不可哈希,会报 TypeError。
  9. 不一定。集合本身不保证按这个顺序呈现,需要排序时必须显式 sorted()。
  10. 字典,可使用 dict.fromkeys() 并保留插入顺序。

B. 编程题参考答案#

  1. 单词计数:

    words = input().split()
    counts = {}
    
    for word in words:
        counts[word] = counts.get(word, 0) + 1
    
    print(counts)
    
  2. 保持首次出现顺序去重:

    names = input().split()
    unique_names = list(dict.fromkeys(names))
    print(unique_names)
    
  3. 配对姓名和分数:

    names = ["张晨", "李雨", "王浩"]
    scores = [88, 95, 72]
    
    for name, score in zip(names, scores):
        print(name, score)
    
  4. 国家首都查询:

    capitals = {
        "中国": "北京",
        "日本": "东京",
        "法国": "巴黎",
        "德国": "柏林",
        "意大利": "罗马",
    }
    
    country = input()
    print(capitals.get(country, "没有该国家的记录"))
    
  5. 字符计数:

    text = input()
    counts = {}
    
    for char in text:
        if char == " ":
            continue
        counts[char] = counts.get(char, 0) + 1
    
    print(counts)
    
  6. 按分数排序:

    students = [
        {"name": "张晨", "score": 88},
        {"name": "李雨", "score": 95},
        {"name": "王浩", "score": 72},
    ]
    
    ranked = sorted(
        students,
        key=lambda student: student["score"],
        reverse=True,
    )
    
    for student in ranked:
        print(student["name"], student["score"])
    
  7. 按班级汇总:

    students = [
        {"class_name": "高一(1)班", "score": 88},
        {"class_name": "高一(2)班", "score": 90},
        {"class_name": "高一(1)班", "score": 76},
    ]
    summary = {}
    
    for student in students:
        class_name = student["class_name"]
        score = student["score"]
    
        if class_name not in summary:
            summary[class_name] = {"total": 0, "count": 0}
    
        summary[class_name]["total"] += score
        summary[class_name]["count"] += 1
    
    for class_name, data in summary.items():
        average = data["total"] / data["count"]
        print(class_name, data["total"], data["count"], f"{average:.2f}")
    
  8. 集合运算:

    first = {"张晨", "李雨", "王浩"}
    second = {"李雨", "王浩", "陈欣"}
    
    print("两个都参加:", first & second)
    print("只参加第一个:", first - second)
    

4.17 常见错误清单#

  • 使用不存在的键访问字典,导致 KeyError。
  • 以为 get() 会把默认值新增到字典。
  • 遍历字典时误以为得到的是值。
  • 计数时没有初始化新键。
  • 分组时没有先创建空列表。
  • zip() 长度不一致时静默丢失数据。
  • 依赖集合的显示顺序。
  • 把空花括号当成空集合。
  • 把列表放入集合或作为字典键。
  • 排序时忘了指定按哪个字段排序。

4.18 过关检查#

  • 能使用 [] 和 get() 读写字典并解释差异。
  • 能遍历键、值和键值对。
  • 能独立写出字典计数模式。
  • 能独立写出“先创建空列表、再追加”的分组模式。
  • 能解释 list(zip(d.keys(), d.values())) 的结果。
  • 能完成集合去重,并明确是否需要保持原顺序。
  • 能用字典列表保存 3 条完整记录。
  • 能按某个字段排序并解释 key=lambda 的作用。
  • 独立完成通讯录和班级汇总。
  • 能在不运行代码的情况下推演 zip、字典修改和集合去重结果。

本周进度

完成讲义、练习和验收后再标记。