计算从一个问题开始
书架上有四本书,你想知道《The Hobbit》在哪里。你可以沿着书架逐本阅读书名,找到后便停下来。这个简单过程已经包含计算的基本要素:信息、明确的问题、一系列步骤和结果。
计算是按照规则转换信息的过程。信息可以描述数字、文字、图像、动作或关系。计算机使用物理机器执行这些规则;人也可以用纸笔完成同一个小规模计算。计算机科学研究如何表示问题、构造并分析求解过程、组织系统,以及理解计算能做什么、不能做什么。
程序设计让我们用机器能够执行的形式表达一个过程。计算机科学还会追问:过程是否回答了正确的问题?是否一定结束?需要多少工作?如何融入更大的系统?即使换一种编程语言,这些问题仍然存在。
把问题说清楚
图书目录是一个有序列表:["Dune", "Foundation", "The Hobbit", "Dune"]。问题是:第一个与目标书名完全相等的项目,其索引是多少?索引从零开始。查询 "The Hobbit" 得到 2;查询 "Dune" 得到 0;查询 "Solaris" 则约定返回 -1,表示不存在。
我们已经做出几项决定:顺序有意义,匹配必须完全相等,允许重复书名,未找到也有明确结果。“找一本书”本身并没有说明这些要求。消除这种歧义是计算工作的起点。
统计书的数量是否属于计算?说明输入与输出。
完整解答
是。输入为有限目录,输出为项目数。每读一个项目便把计数器增加一。
明确输入、输出与假设
问题规约说明输入与输出之间应满足的关系。算法是实现这一关系的步骤。程序则用具有明确执行规则的编程语言实现这些步骤。同一个问题可以有多种算法,同一个算法也可以有多种程序实现。
| 组成部分 | 搜索操作的约定 |
|---|---|
| 输入 | 一个有限、有序的书名列表,以及一个目标书名。 |
| 假设 | 书名是字符串。搜索期间列表不变。使用区分大小写的精确比较。 |
| 存在时的输出 | 书名等于目标的最小索引。 |
| 不存在时的输出 | -1,空列表也如此。 |
| 副作用 | 搜索不会修改列表。 |
假设是前置条件:开始执行前必须成立的条件。承诺的输出和输入不变性是后置条件:返回后必须成立的条件。它们共同构成操作的约定,也称契约。后续模块会把这一思想用于数据结构、网络协议和数据库事务。
这里的 -1 是哨兵值,用一个约定的特殊值表示不存在。但 Python 允许负数索引:books[-1] 会读取最后一本书。因此,把搜索结果当作索引之前,必须先检查它是否为 -1。以后也可以改为返回 None;那是另一种约定。
用户可能希望“dune”也能匹配“Dune”。这需要新的匹配规则,例如不区分大小写。不能悄悄改变含义,却仍然声称遵守原来的约定。
空目录应返回什么?索引 0 是否表示不存在?
完整解答
空目录返回 -1。非空目录中的索引 0 是第一个有效位置。
描述算法并跟踪状态
线性搜索按给定顺序检查项目。伪代码表达步骤,而不依赖特定编程语言:
依次遍历从第一个到最后一个位置 i:
如果位置 i 的书名等于目标:
返回 i
返回 -1
最后一个返回语句仅在所有项目都检查完后执行。循环内部的返回会立即结束整个搜索。因此,即使书名重复出现,结果仍然是第一个匹配位置。
算法运行时具有状态,即描述当前执行位置所需的信息。本例中变化的状态包括当前位置;如果测量工作量,还包括比较次数。执行轨迹逐步记录这些状态。
| 位置 | 书名 | 等于 The Hobbit? | 下一步 |
|---|---|---|---|
| 0 | Dune | 否 | 向后移动 |
| 1 | Foundation | 否 | 向后移动 |
| 2 | The Hobbit | 是 | 返回 2 |
这次执行不会检查第四本书。运行代码之前先预测轨迹,可以帮助你区分“我认为它会做什么”与“它实际做了什么”。
进行三次实验:查询 Dune,解释为什么不会访问索引 3;查询 Solaris,统计比较次数;选择空目录,解释为什么无需比较任何书名就能返回 -1。演示把“列表已经耗尽”单独显示为一步;这一步不属于书名比较。
跟踪查询 Foundation 的过程。检查哪些位置?
完整解答
先检查 0,再检查 1。比较两次后返回 1,不检查 2 或 3。
正确性、终止性与工作量
例子展示某次具体执行,而推理需要覆盖满足约定的所有输入。后面会学习形式化证明;这里先使用一个简短的推理模式。
正确性:什么始终成立?
检查位置 i 之前,前面已经检查的位置都不包含目标。这是一条循环不变式,在循环推进时保持成立。开始检查索引 0 时,前面没有位置,因此成立。如果当前书名不匹配,把它加入已检查的前缀后,该性质仍成立。如果匹配,不变式说明前面没有更早的匹配,因此返回 i 满足约定。如果列表耗尽,说明所有书名都不匹配,返回 -1 也满足约定。
终止性:什么在推进?
对长度为 n 的列表,每次未成功匹配的迭代都前进一个位置。尚未检查的项目数减少一,并且不可能无限减少到零以下。找到匹配则会更早结束。因此,一个有限且搜索期间不变的列表能够保证终止。
工作量:先定义要统计的操作
我们把一次书名相等性检查作为统计单位。非空列表中,第一个位置就匹配时需要一次比较;最后一个位置匹配或目标不存在时,需要 n 次比较;空列表需要零次比较。项目数翻倍,最坏情况下的比较次数也翻倍。
这是一个模型,不是实际耗时。比较两个很长的字符串可能需要逐字符检查,因此当书名长度变化时,一次书名比较未必是常数时间。机器、实现方式和数据也会影响秒数。模块 05 将进一步区分这些概念并介绍大 O 表示法。现在要做到的是明确统计单位,并正确计数。
结果是否符合要求?过程是否会结束?需要多少工作?快速得到的错误答案仍然错误;永不结束的循环也无法交付它承诺的答案。
为什么空目录也符合不变式推理?
完整解答
没有更早的匹配,也没有待检查项目。因为根本没有项目,所以不存在匹配,返回 -1 正确。
抽象与计算机的层次
抽象保留任务所需的性质,同时隐藏其他细节。把目录看成有序列表,我们就能推理位置,而不必知道某台机器如何保存字符串。把搜索封装成函数,程序的其他部分就能使用结果,而不必重复搜索步骤。
抽象必须保留关键性质。若把有序列表换成没有有意义顺序的集合,“第一个索引”就失去含义。若只保留书名并丢弃作者,就无法在不增加信息的情况下查询作者。
- 问题与应用用户询问目标书名在目录中的位置。
- 算法与程序线性搜索描述步骤;Python 把它们表达为可执行代码。
- 语言实现Python 运行时执行列表和字符串上的操作。
- 操作系统管理进程、内存,以及对文件和设备的访问。
- 硬件CPU 指令对存储在内存中的数据进行操作。
运行脚本时,CPU 不会直接理解书名或“找到第一个匹配项”这句自然语言。语言实现和机器指令把这些含义连接到物理操作。你可以先学会推理搜索过程,再逐步学习底层机制。
这种分层也有助于定位故障:需求可能规定了错误的匹配规则;算法可能跳过索引 0;程序可能把 return -1 放进循环;运行环境可能无法打开文件。修改代码之前,先判断哪一层职责出了问题。
只包含书名的抽象能回答每本书的作者吗?为什么?
完整解答
一般不能,因为没有保留作者信息。需要扩展表示,或从其他来源获取。
常见误解
| 误解 | 应当检查什么 |
|---|---|
| “计算只是算术。” | 搜索文字和寻找路线同样按照规则转换信息。 |
| “算法就是一个 Python 文件。” | 区分步骤本身与特定语言的实现。 |
| “成功运行一次就证明正确。” | 检查边界情况,并推理所有有效输入。 |
| “索引零表示没找到。” | 零是有效位置。本例用 -1 表示不存在。 |
| “四本书就一定比较四次。” | 提前匹配会停止搜索,实际工作量取决于目标。 |
| “所有错误都出在算法。” | 分别检查需求、过程、实现和环境。 |
准备实验环境
准备 Python 3.11 或更新版本、文本编辑器和终端。这些实验仅使用 Python 的基本功能,不需要安装第三方包,也不需要下载数据集。下载下面的脚本,或把代码复制到同名文件。在该目录的终端中运行 python lab1_trace.py。Windows 也可使用 py -3 lab1_trace.py;若系统中的命令名是 python3,则使用该命令。
"Dune" 这样的带引号值是字符串;方括号表示列表。for 重复执行缩进的代码块;enumerate 提供索引和项目;if 在条件成立时执行代码块;== 比较值;= 给名称赋值。缩进是 Python 语法的一部分。这里仅介绍实验所需记法,模块 03 会系统讲解程序设计。
实验 1 — 阅读执行轨迹
目标:连接伪代码、状态与实际执行。时间:30 分钟。先预测会打印哪些行,再运行脚本,与下面实际运行捕获的输出比较。为方便对照,两种语言的页面使用相同的代码、书名和输出。
"""Module 01, Lab 1: trace a search, one comparison at a time."""
books = ["Dune", "Foundation", "The Hobbit", "Dune"]
target = "The Hobbit"
found = -1
for index, title in enumerate(books):
print(f"inspect {index}: {title}")
if title == target:
found = index
break
print(f"result: {found}")
inspect 0: Dune
inspect 1: Foundation
inspect 2: The Hobbit
result: 2
found = -1 在开始搜索前设置“未找到”结果。匹配后,break 退出循环,但最后的打印语句仍会执行。引号前的 f 允许把花括号中的值插入输出字符串。
- 把目标分别改为 Dune 和 Solaris。运行前先写预测轨迹。
- 设置
books = [],解释为什么循环体不执行。 - 删除
break后查询 Dune。为什么结果变成 3?违反了哪条约定? - 恢复原代码,解释“退出循环”与“从函数返回”的区别。
实验讨论
Dune 只检查索引 0;Solaris 检查全部四个索引,结果保持 -1。空列表只打印 result: -1。删除 break 后,两次出现 Dune 都会更新 found,最终保留最后一次出现的位置,违反第一个匹配的约定。break 离开循环;return 离开包含它的函数。
实验 2 — 把约定变成检查
目标:检查空列表、缺失目标、边界位置与重复值。时间:35 分钟。def 定义函数;return 提供结果并结束本次调用;assert 在条件不成立时终止脚本。正常运行,不要使用会禁用断言的 Python -O 选项。
"""Module 01, Lab 2: make a contract executable with examples."""
def find_first(items, target):
"""Return the first matching index, or -1 if target is absent."""
for index, item in enumerate(items):
if item == target:
return index
return -1
cases = [
([], "Dune", -1),
(["Dune"], "Dune", 0),
(["Dune"], "Solaris", -1),
(["Dune", "Solaris"], "Solaris", 1),
(["Dune", "Solaris", "Dune"], "Dune", 0),
]
for items, target, expected in cases:
actual = find_first(items, target)
assert actual == expected, (items, target, expected, actual)
print(f"{items!r}, {target!r} -> {actual}")
print("5 cases passed")
[], 'Dune' -> -1
['Dune'], 'Dune' -> 0
['Dune'], 'Solaris' -> -1
['Dune', 'Solaris'], 'Solaris' -> 1
['Dune', 'Solaris', 'Dune'], 'Dune' -> 0
5 cases passed
- 解释这五个案例分别覆盖了约定的哪些部分。
- 增加
(["Dune"], "dune", -1),确认区分大小写的精确匹配。 - 把
return -1移入循环,放在 if 代码块之后。先预测哪些检查失败或结果改变,再运行。 - 修复函数。增加一个检查,确认调用后输入列表没有改变。
实验讨论
案例覆盖空列表、第一个位置、目标缺失、最后一个位置和重复书名。return -1 放在循环内部时,空列表会直接运行到函数末尾并返回 None,所以第一个断言失败。较后位置的匹配也会被遗漏,因为第一个不匹配后就返回了。检查输入不变性时,先保存 before = items.copy(),调用函数,然后执行 assert items == before。这些例子是有用的证据,但覆盖所有有效输入仍需要不变式推理。
实验 3 — 测量工作量
目标:区分输入规模与一次执行的工作量。时间:35 分钟。脚本返回两个值:结果和比较次数。range(size) 产生从零到 size 之前的整数,不包含 size;list 把它们收集起来。同样的相等性搜索过程也适用于数字。+= 1 把计数器增加一。
"""Module 01, Lab 3: count comparisons instead of timing the computer."""
def search_with_cost(items, target):
comparisons = 0
for index, item in enumerate(items):
comparisons += 1
if item == target:
return index, comparisons
return -1, comparisons
for size in [0, 1, 4, 8, 16]:
items = list(range(size))
missing_index, missing_cost = search_with_cost(items, -1)
first_index, first_cost = search_with_cost(items, 0)
print(f"n={size:2}: absent={missing_cost:2}, first={first_cost:2}")
assert missing_index == -1 and missing_cost == size
assert first_index == (0 if size else -1)
assert first_cost == (1 if size else 0)
n= 0: absent= 0, first= 0
n= 1: absent= 1, first= 1
n= 4: absent= 4, first= 1
n= 8: absent= 8, first= 1
n=16: absent=16, first= 1
- 预测长度 32 的列表中两种查询的比较次数。
- 使用目标
size - 1增加最后一个位置的查询。size 为 0 时,目标仍不存在。 - 在长度 16 的列表中查询目标 8。为什么需要九次比较?
- 说明非空列表中最佳与最坏比较次数,并解释为什么单凭耗时不能证明这些公式。
实验讨论
长度 32 时,目标缺失需要 32 次比较,第一个位置匹配需要 1 次。最后位置匹配时,非空列表需要 size 次比较,空列表需要 0 次。索引 8 是第九个位置,因为从零计数。n 大于零时,最佳是 1 次,最坏是 n 次。实际耗时取决于机器与数据等因素;这些精确次数则直接来自算法步骤。
练习与完整解答
先尝试,再展开解答。★ 表示直接应用,★★ 表示结合多个概念,★★★ 表示修改约定或构造推理。
为统计目标书名出现次数写出约定,涵盖空列表与重复书名。
完整解答
输入为有限且不变的字符串列表与目标字符串。输出是与目标完全相等的项目数,为非负整数。空列表返回 0,每次重复出现都贡献一次,列表不被修改。
按照第一个匹配约定,在 [A, B, A] 中搜索 A。列出检查的索引和结果。最后一个匹配的约定有何不同?
完整解答
只检查 0 并返回 0。寻找最后一个匹配必须检查剩余项目;每次匹配都更新结果的完整正向扫描会比较三次并返回 2。
搜索在第一个不匹配后立即返回 -1。给出失败输入,并解释修复方法,涵盖空输入。
完整解答
输入 [A, B]、目标 B 会失败:A 不匹配,但 B 尚未检查。把表示不存在的返回移到循环之后,仅在全部检查后执行,也使空列表返回 -1,而不是运行到函数末尾返回 None。
列表有 12 项。第一个匹配在索引 7 时比较几次?确认不存在时几次?空列表呢?
完整解答
索引 7 需要 8 次比较(索引 0–7);不存在需要 12 次;空列表需要 0 次。这是相等性检查次数,不是全部机器指令数。
对于“检查 i 前,更早位置都不包含目标”,给出初始化、保持与退出时的推理。
完整解答
初始 i=0 时没有更早位置。i 不匹配后,i+1 前所有位置均不匹配。匹配时没有更早匹配,所以 i 最小;耗尽时所有项目均不匹配。有限长度与位置前进另行证明终止。
把目录替换为一个项目数量。它能回答哪些问题:有多少项、Dune 在哪里、第一本的书名是什么?
完整解答
只能回答项目数。多个不同目录可能有相同数量,因此数量无法还原书名或顺序。抽象是否合适取决于要回答的问题。
规定并实现一个忽略大小写的 ASCII 书名搜索,返回第一个匹配。哪些检查需要改变?
完整解答
要求字符串为 ASCII,在原循环中比较 item.lower() == target.lower(),不存在的返回仍放在循环之后。现在 [Dune] 查询 dune 返回 0;仍需检查空列表、缺失目标与重复值。原来的精确匹配约定已经改变;非 ASCII 文本匹配需要更谨慎的规则。
def find_first_ascii_ignore_case(items, target):
for index, item in enumerate(items):
if item.lower() == target.lower():
return index
return -1
assert find_first_ascii_ignore_case(["Dune"], "dune") == 0
assert find_first_ascii_ignore_case([], "dune") == -1分类这些故障:要求的匹配规则不对;伪代码跳过索引 0;return -1 缩进在循环内;脚本文件无法打开。
完整解答
分别属于规约、算法、实现和环境。相应地,应核实用户要求、修复过程、修复代码控制流,或检查命令与文件路径。故障可能涉及多层,但这一分类有助于确定首先调查哪里。
自测
选择答案查看反馈。分数统计已经回答的题目;重置后可以重做。如果禁用脚本,可以展开下方答案表。
哪个陈述描述问题要求,而不是算法?
本例搜索空列表返回什么?
[Dune, Foundation, Dune] 中第一个 Dune 的索引是多少?
9 个项目中确认目标不存在需要多少次相等性比较?
哪个事实保证本例搜索终止?
通过五个测试说明什么?
为什么用搜索结果索引 Python 列表前要检查 -1?
有序列表抽象必须保留什么?
答案表
- A — 它描述所需结果,没有规定执行步骤。
- B — 约定用 -1 表示不存在,包括空列表。
- C — 索引从零开始,算法在第一个匹配处停止。
- B — 九个项目都必须比较。最后的返回不属于书名比较。
- A — 剩余工作量递减到零;目标可以不存在。
- C — 测试为具体案例提供证据;不变式推理覆盖一般有效输入。
- B — 本例的哨兵约定与 Python 索引规则含义不同。
- A — 第一个匹配搜索需要顺序和项目比较,不需要这些物理细节。
引导阅读
阅读官方文档中的 Python 列表介绍,以及 for、range、break 和函数定义。重点阅读实验用到的结构,暂时跳过其他控制流特性。
写下为什么空列表执行零次循环,为什么 range 不包含终点,以及 return 的作用。然后解释这些语言规则如何支持搜索算法。这次阅读的重点是区分语言规则与问题要求。
复习与下一步
现在你能把模糊的搜索请求变成明确约定,跟踪过程,解释为什么返回第一个匹配,说明它如何终止,并统计比较次数。你也能指出这次计算在计算机运行层次中的位置。
关闭页面,从记忆中写出伪代码。分别跟踪空列表、重复匹配和目标缺失的情况。解释哪个假设保证终止。如果某个解释不确定,先回到相应章节,再勾选最后一个学习时段。
下一模块是信息表示:位如何编码整数与文字,为什么某些数字无法精确表示。课程概览链接到全部 14 个模块。
关键术语
| 术语 | 本模块中的含义 |
|---|---|
| 计算 | 按照规则转换信息。 |
| 规约 | 输入与输出必须满足的关系。 |
| 算法 / 程序 | 步骤过程 / 编程语言中的实现。 |
| 状态 / 轨迹 | 当前执行信息 / 对它的逐步记录。 |
| 契约 | 操作的前置条件与后置条件。 |
| 不变式 | 迭代过程中在指定位置保持成立的性质。 |
| 终止性 | 过程最终结束。 |
| 抽象 | 暴露相关性质、隐藏细节的模型。 |
| 哨兵值 | 表示不存在等特殊结果的约定值。 |