MIT-6-00SC-计算机科学与编程导论笔记-全-
MIT 6.00SC 计算机科学与编程导论笔记(全)
001:课程介绍与计算基础 🎓

在本节课中,我们将学习课程的基本信息、计算的核心概念以及编程语言的基础知识。课程旨在帮助初学者理解如何将问题转化为计算框架,并掌握编写程序的基本技能。
课程概述与目标
本课程是麻省理工学院6.00SC《计算机科学与编程导论》的第一讲。课程旨在为编程经验较少的学生提供一个温和的入门,帮助他们为后续的计算机科学课程做好准备。
课程的战略目标是帮助学生建立编写中小型程序的合理自信。课程的核心主题是学习如何将问题映射到计算框架中,尤其侧重于科学问题,但也会涉及一些非科学问题。
课程的所有习题集都涉及使用Python编程。第一个习题集主要是安装Python。请注意,前两个习题集看似简单,但课程难度会迅速提升。
课程政策与资源
以下是关于课程协作、教材和笔记的重要信息:
- 协作政策:本课程的协作政策非常宽松。学生可以在任何习题集上与任何人合作(不包括测验)。目标是促进学习,而非防止作弊。在习题集上不存在“作弊”的概念。
- 教材与阅读材料:没有指定教材。课程网站上会发布阅读材料,主要是网站链接,偶尔会有自编材料。虽然可以购买Python教材用于测验时快速查阅,但许多学生不买教材也能学得很好。
- 课堂笔记:为了遵循更好的教学法,促进学生主动学习,课程不会定期分发详细的课堂讲义。但会提供包含代码的讲义,这些材料需结合课堂讲解来理解。
本课程的主要目的是帮助你熟练掌握让计算机执行你所需任务的能力。一旦掌握这项技能,面对许多任务时,你的第一反应将是编写程序来完成它。编程本身也充满乐趣。
什么是计算?🧮
上一节我们介绍了课程目标,本节中我们来看看计算的核心概念。
知识主要分为两种:陈述性知识和命令性知识。
- 陈述性知识:由事实陈述组成。例如,“一个好的医疗保健计划能在节省资金的同时提高医疗质量”。它陈述了目标,但没有说明如何实现。在数学上,
y是x的平方根当且仅当y * y = x。这个陈述定义了平方根,但没有说明如何找到它。 - 命令性知识:告诉你如何解决问题或完成某事,就像食谱一样。它是一系列可执行的步骤。
许多问题无法精确求解,但可以找到满足实际需求的近似解,这称为近似算法。
算法示例:求平方根
以下是一个古老的求平方根近似值的算法(例如求25的平方根):

- 从一个猜测值
g开始(例如g = 3)。 - 检查
g * g是否足够接近目标值x(这里是25)。如果是,停止。 - 如果不是,通过公式
g_new = (g_old + x / g_old) / 2生成一个新的猜测值g_new。 - 使用新的
g_new返回步骤2。
让我们快速演算一下:
- 初始猜测
g = 3,3*3=9,不接近25。 - 新猜测
g = (3 + 25/3) / 2 ≈ 5.6666。 5.6666 * 5.6666 ≈ 32.04,仍不接近25。- 再次计算新猜测
g ≈ 5.04。 5.04 * 5.04 ≈ 25.4,足够接近25,算法停止(收敛)。
这个例子包含了算法的关键要素:可执行的指令集、控制流(执行顺序,包括循环)和终止条件。

从固定程序到存储程序计算机 💻

如何将算法“食谱”转化为机械过程?一种方法是设计专门执行该算法的机器,即固定程序计算机。早期的计算机就是如此,它们只能执行特定任务(如解线性方程、破译密码)。
重大的突破是存储程序计算机的发明。其核心思想是:指令与数据没有区别。程序(算法步骤)和数据(如输入值25)都存储在相同的内存中。这使得机器变得无限灵活,程序可以随时更改,甚至程序可以生成其他程序。
在这种范式下,计算机本身可以被视为一种特殊的程序——解释器。解释器是一个可以执行任何合法指令集的程序,因此可以用来描述和完成计算机能做的任何事情。
一台存储程序计算机大致包含:内存、控制单元、算术逻辑单元(ALU) 以及输入/输出设备。关键在于只有一种内存,不区分程序内存和数据内存。
给定一小套基本指令(通常只有几十条),通过巧妙组合,可以完成任意复杂的任务。图灵曾证明,仅用6条对单个比特进行操作的基本指令,就能实现计算机所能完成的一切。

编程语言与Python 🐍
上一节我们了解了计算机的通用性,本节中我们来看看如何与计算机沟通——即编程语言。

编程语言提供了一套基本指令和控制结构(控制指令执行顺序的机制)。不同语言的区别在于这些指令、控制结构以及组合机制是什么。

编程最显著的特点是:计算机会完全按照你的指令执行。这既赋予了你强大的能力,也意味着如果程序出错,责任在于你自己。
本课程使用的编程语言是Python。需要强调的是,本课程的重点是计算方法,Python只是一个教学工具。一旦学会用Python编程,将技能转移到其他语言会很容易。

任何编程语言都由以下部分定义:
- 语法:规定哪些字符和符号序列构成格式良好的字符串。例如,
x = 3 + 4语法正确;x = 3 4则不正确。这类似于英语中判断句子结构是否完整。 - 静态语义:规定哪些格式良好的字符串具有意义。例如,在Python中,
3 / "abc"语法上看似是“值 运算符 值”,但静态语义上无意义(数字除以字符串),会报错。这类似于英语中“I are big”结构正确但语义错误。 - 语义:为那些既语法正确又静态语义正确的字符串赋予确切的含义。与自然语言不同,设计良好的编程语言没有歧义,每个合法程序都有唯一确定的含义。

程序可能出错的情况 🐛
当我们编写的程序不按预期运行时,可能会出现以下情况:
- 崩溃:程序停止运行,并给出明显的错误指示。在良好设计的系统中,单个程序的崩溃不应损害整个系统。
- 永不停止(无限循环):程序一直运行,无法正常结束。编写程序时,应对其运行时间有预期,以便识别这种情况。
- 产生错误答案:程序运行完毕,但输出结果是错误的。这是最糟糕的情况,因为难以察觉,可能导致严重后果(如医疗或工程事故)。
本课程将花时间探讨如何避免编写出有上述问题的程序,包括如何测试和编写更可靠的代码。
为什么选择Python?
既然Python在静态语义检查方面并非最强(介于Java和C语言之间),为何本课程还要使用它?它有以下优点:
- 易于学习:比Java等语言更简单,学习曲线更陡峭,能更快上手。
- 应用广泛:在科学领域,特别是生命科学中非常流行,已成为生物学等领域最常用的语言。
- 易于调试:Python是解释型语言。解释器直接读取并执行你编写的源代码。如果出错,它能用源代码的语境给出错误描述,便于理解。
- 相比之下,编译型语言(如C)先将源代码翻译成目标代码,再由硬件执行。如果出错,错误信息可能指向晦涩的目标代码,难以调试。编译型语言的优点是运行效率通常更高。Python也可以编译以获得高效版本,但其设计初衷是便于解释执行。


本节课中我们一起学习了课程的基本框架、计算与算法的核心概念、计算机从固定程序到存储程序的发展,以及编程语言(特别是Python)的基础知识。理解这些概念是后续深入学习计算问题解决方法的基石。
002:Python基础概念与编程结构 🐍

在本节课中,我们将学习Python编程语言的基础元素,包括对象、类型、表达式、变量、条件语句和循环。这些概念是几乎所有编程语言的核心,理解它们对于后续学习至关重要。

概述

本节课将介绍Python编程的基本构件。我们将从理解Python中的“对象”这一核心概念开始,然后探讨不同的数据类型、如何通过表达式进行计算、如何使用变量存储信息,以及如何通过条件分支和循环来控制程序的执行流程。
对象与类型

在Python中,一切皆为对象。每个对象都有一个类型,它定义了该对象的种类以及我们可以对其执行的操作。Python提供了一个内置函数 type() 来查看对象的类型。
type(3) # 输出: <class 'int'>
Python的类型主要分为两大类:标量类型和非标量类型。标量类型是“不可分割”的,可以看作是编程语言中的原子。
以下是Python的核心标量类型:
- 整数 (
int): 用于表示整数,例如3。 - 浮点数 (
float): 用于表示实数(近似值),例如3.2或3.0。注意,3是int,而3.0是float。 - 布尔值 (
bool): 只有两个值:True和False。 - 空值 (
NoneType): 只有一个值None,通常用作占位符。
值得注意的是,Python没有单独的“字符”类型。单个字符用长度为1的字符串 (str) 表示。字符串字面量可以用单引号或双引号定义。


type('a') # 输出: <class 'str'>
type("a") # 输出: <class 'str'>
type('123') # 输出: <class 'str'> (这是一个字符串)
type(123) # 输出: <class 'int'> (这是一个整数)
表达式与运算符


表达式是操作数和运算符的组合,用于计算一个值。在交互式环境中输入表达式时,Python会直接计算并输出结果。
3 + 2 # 输出: 5
3 / 2 # 输出: 1 (在Python 2.x中,整数相除得到整数商)
3.0 / 2.0 # 输出: 1.5
Python中的运算符通常是重载的,这意味着同一个运算符(如 +)根据操作数的类型执行不同的操作。
3 + 2 # 输出: 5 (整数加法)
‘a’ + ‘b’ # 输出: ‘ab’ (字符串拼接)
‘a’ + 3 # 报错: TypeError (不能拼接字符串和整数)
我们可以使用类型名作为转换函数,在不同类型之间进行转换。
str(3) # 将整数3转换为字符串 ‘3’
int(‘3’) # 将字符串 ‘3’ 转换为整数 3
int(3.0) # 将浮点数3.0转换为整数 3 (截断)
int(‘3.0’)# 报错: ValueError (无法转换)
编写脚本与基本命令
上一节我们介绍了在交互式环境中直接输入代码。本节中我们来看看如何编写可重复执行的脚本程序。
在脚本中,一行代码如果以 # 开头,则表示注释,不会被解释器执行。注释用于解释代码的逻辑和算法,而不是解释Python语法本身。
与交互式环境不同,在脚本中直接写一个表达式不会自动打印结果。需要使用 print 命令来输出内容。
# 这是一个注释
print(type(3)) # 这会打印出 <class ‘int’>
一个脚本程序由一系列命令组成。一个关键的命令是赋值语句,它用于将名称(变量)绑定到对象上。
x = 3 # 将变量 x 绑定到整数对象 3
x = x * x # 计算表达式 x*x 的值 (9),然后将 x 重新绑定到这个新值
print(x) # 输出: 9
在Python中,变量是对象的名称。赋值 (=) 就是建立或改变这种绑定关系。
要从用户那里获取输入,可以使用 raw_input 函数(在Python 3中是 input)。它总是将用户的输入作为字符串返回。
y = raw_input(‘Enter a number: ‘) # y 是一个字符串
y = float(raw_input(‘Enter a number: ‘)) # 将输入转换为浮点数
条件分支
到目前为止,我们看到的程序都是直线程序,即所有命令按顺序执行且只执行一次。为了编写更有用的程序,我们需要引入决策能力,即条件语句。
条件语句使用 if、elif (else if) 和 else 关键字。它根据测试表达式的结果是 True 还是 False 来决定执行哪一块代码。
x = int(raw_input(‘Enter an integer: ‘))
if x % 2 == 0: # % 是求余运算符,== 用于比较相等性
print(‘Even‘)
else:
print(‘Odd‘)
缩进在Python中至关重要。它定义了代码块的结构。if 或 else 后面缩进的代码属于相应的分支。这种强制缩进保证了代码的视觉结构与逻辑结构一致,是一种优秀的设计。
if x % 2 == 0:
print(‘Even‘)
print(‘This is still inside the if block‘)
print(‘This is outside and always runs‘)
循环迭代
直线程序和分支程序的运行时间都受限于程序本身的长度。但对于处理大量数据的问题(如计算所有学生的平均分),我们希望程序的运行时间能与输入数据的规模成正比。这就需要循环(或称迭代)结构。
循环允许我们重复执行一段代码。结合条件判断,我们可以实现复杂的控制流。拥有循环结构的编程语言被称为图灵完备的,理论上可以计算任何可计算的问题。
下面是一个使用 while 循环查找完全立方数立方根的例子:
# 寻找完全立方数的立方根
x = int(raw_input(‘Enter an integer: ‘))
ans = 0
while ans**3 < abs(x):
ans = ans + 1
# print(‘Current guess:‘, ans) # 调试语句
if ans**3 != abs(x):
print(x, ‘is not a perfect cube‘)
else:
if x < 0:
ans = -ans
print(‘Cube root of‘, x, ‘is‘, ans)
while 循环会反复检查条件 (ans**3 < abs(x)),只要条件为 True,就执行其缩进块内的代码。这允许 ans 的值不断递增,直到找到满足条件的值或超过目标。
总结

本节课中我们一起学习了Python编程的基础核心概念。我们从对象和类型出发,理解了Python如何看待数据。接着,我们学习了通过表达式和运算符进行计算,并使用变量通过赋值语句来存储和操作数据。为了超越简单的直线程序,我们引入了条件分支 (if/else) 来让程序做决策,最后通过循环 (while) 赋予了程序重复执行和应对任意规模数据的能力。掌握这些基础是后续学习更复杂编程概念的关键。下节课的辅导课将详细讲解这些概念,特别是循环的工作原理。
003:Python编程基础入门 🐍

在本节课中,我们将学习编程的核心概念,特别是Python语言的基础知识。我们将从计算机如何执行程序开始,逐步深入到Python的语法、数据类型、运算符以及控制流结构。
计算机与程序模型 💻
上一节我们介绍了课程的目标,本节中我们来看看计算机执行程序的基本模型。
计算机是一个非常简单的模型,它由CPU和内存组成,可能还有一些输入和输出设备。

程序是一系列加载到计算机内存中的指令。内存可以被划分为许多小单元。当CPU开始运行程序时,它会查看内存位置中的内容,并将其视为可以处理的指令,然后执行它。例如,一条指令可能是将两个数字相加并产生结果。
问题是,虽然计算机可以完美地理解这些指令,但对我们来说,这看起来像是乱码。因此,我们需要编程语言。
编程语言的作用 📝
编程语言允许我们在更高的抽象层次上与计算机交流。我们可以使用类似 x = 1 或 (x + 5) * 2 的语句,这比机器指令更容易理解。Python就是这样一种语言,它让我们能够以人类可读的方式表达计算逻辑。
语法与语义
当我们使用编程语言时,代码必须按照特定的方式组织。
语法
语法指的是语言各部分的组合方式。例如,变量 + 变量 在Python中是有效的语法。而 变量 变量 + 则是无效的语法。
静态语义
静态语义指的是语法上有效且有意义的语句。例如,如果 a = 5 且 b = 2,那么 a / b 是有效的。但如果 c 是数字,d 是字符串,那么 c / d 就没有意义,因为数字除以字符串是未定义的。
语法和静态语义是编译器和解释器可以明确检查的规则。
语义
语义关注的是程序作为一个整体是否能正确工作。即使每个语句的语法和静态语义都正确,整个程序也可能出错。例如,a = 6; c = 0; a / c 会导致除以零的错误。
程序是非常明确的,它会严格地执行你告诉它的事情,不多也不少。因此,当程序行为不符合预期时,你需要学会阅读代码,并在脑海中逐步推演其执行过程。
Python语言基础
现在,我们进入Python的具体内容,这些知识将在接下来的课程中至关重要。
Python是一种通用编程语言,用于Web开发、小型设备开发、桌面程序等。它是一种解释型语言,这意味着你可以直接运行代码并立即看到结果,无需额外的编译步骤。Python语法简单,应用广泛。
表达式与操作
Python程序由表达式序列组成。表达式由操作数和运算符(或函数)构成。
- 操作数:是语言中的“事物”,例如字面量(如
5)和变量(如x)。 - 运算符:是对操作数进行操作的符号。例如,赋值运算符
=表示将右侧的值赋予左侧的变量名。
数据类型
在Python中,一切都是对象,每个对象都有类型。以下是几种基本数据类型:
- 整数:例如
10、7、0、-1。 - 浮点数:带小数点的数字,例如
3.14、-0.5。 - 字符串:字符序列,例如
"hello"。可以用单引号或双引号定义。 - 布尔值:只有两个值:
True或False。 - None类型:表示“空”或“无”,类似于占位符。
注意:当你在代码中写下 0 时,Python会推断其类型为整数。写下 0.0 时,类型则为浮点数。在处理数学运算时,了解操作数的类型非常重要。例如,在Python中,两个整数相除 5 / 2 的结果是 2(整数除法会截断小数部分),而 5.0 / 2 的结果是 2.5(浮点数)。
运算符
以下是不同数据类型支持的运算符:
数字类型(整数和浮点数):
- 加法
+、减法-、乘法*、除法/ - 整数特有:指数运算
**、取模% - 浮点数没有取模运算。
字符串:
- 拼接
+:将两个字符串连接起来。
比较运算符(返回布尔值):
- 小于
<、大于>、等于==、不等于!=、小于等于<=、大于等于>=
布尔运算符:
- 与
and:仅当两个操作数都为True时返回True。 - 或
or:只要有一个操作数为True就返回True。 - 非
not:反转布尔值。
这些运算符可以组合成复杂的表达式,例如 (d < e) and (e < f) 可以用来检查数字是否按顺序排列。
控制程序流程
到目前为止,我们只能编写按顺序逐行执行的直线程序。为了实现更复杂的功能,我们需要控制程序的流程。
分支(if语句)
if 语句允许程序根据条件执行不同的代码块。
if 条件1:
# 如果条件1为真,执行这里的代码块
elif 条件2:
# 如果条件1为假且条件2为真,执行这里的代码块
else:
# 如果以上条件都为假,执行这里的代码块
在Python中,代码块通过缩进来表示。if、elif、else 后面的缩进代码属于相应的分支。
循环
循环允许我们重复执行一段代码。
for循环:用于遍历一个有限的元素集合(例如一个数字范围)。
for i in range(1, 10): # range(1,10) 生成数字 1 到 9
print(i) # 这个代码块会执行9次,i的值依次为1,2,...,9
while循环:只要条件为真,就会一直执行。
while 条件:
# 只要条件为真,就重复执行这里的代码块
while 循环适用于你不知道需要循环多少次,但知道在某个条件满足时需要持续执行的情况。
代码示例解析
让我们通过两个例子来巩固所学知识。
示例1:寻找立方根
这个程序要求用户输入一个整数,然后判断它是否是一个完全立方数,并找出其立方根。
# 获取用户输入,输入的内容是字符串类型
x = int(raw_input('Enter an integer: '))
ans = 0
# 使用while循环进行猜测
while abs(ans**3) < abs(x):
ans = ans + 1
# 循环结束后,检查是否找到了完美的立方根
if ans**3 != abs(x):
print(str(x) + ' is not a perfect cube')
else:
if x < 0:
ans = -ans
print('Cube root of ' + str(x) + ' is ' + str(ans))
关键点:
raw_input返回字符串,需要用int()转换为整数。- 使用
while循环递增猜测值ans,直到其立方不小于x的绝对值。 abs()函数用于处理负数输入。- 循环结束后,通过
if语句判断是否找到了精确的立方根,并处理正负数情况。
示例2:FizzBuzz游戏
这是一个经典编程问题:打印1到100的数字,但遇到3的倍数打印“Fizz”,5的倍数打印“Buzz”,同时是3和5的倍数则打印“FizzBuzz”。
for i in range(1, 101): # 遍历1到100
output = '' # 初始化输出字符串
if i % 3 == 0: # 检查是否能被3整除
output += 'Fizz'
if i % 5 == 0: # 检查是否能被5整除
output += 'Buzz'
if output == '': # 如果既不是3也不是5的倍数
output = str(i) # 输出数字本身
print(output)
关键点:
- 使用
for循环遍历固定范围。 %是取模运算符,i % 3 == 0用来判断i是否能被3整除。- 通过字符串拼接
+=来构建输出。 - 使用
str()函数将数字转换为字符串。
总结 🎯


本节课中我们一起学习了编程的核心基础。我们从计算机执行指令的底层模型开始,理解了为何需要高级编程语言。我们重点探讨了Python语言的语法、静态语义和动态语义。接着,我们深入学习了Python的基本构建块:包括整数、浮点数、字符串、布尔值和None在内的数据类型,以及作用于它们的各种运算符。最后,我们掌握了控制程序流程的关键结构——使用 if/elif/else 进行条件分支,以及使用 for 和 while 循环进行重复操作。通过分析“寻找立方根”和“FizzBuzz”两个实例,我们看到了如何将这些概念组合起来解决实际问题。记住,编程是一项实践技能,多读代码、多写代码是进步的关键。
004:循环、算法与函数入门




在本节课中,我们将学习循环结构的工作原理、如何分析算法效率,并初步了解函数的概念。我们将通过寻找立方根和平方根的具体例子,探讨“穷举枚举”和“二分查找”这两种核心算法思想。


循环终止与递减函数
上一节我们介绍了寻找完全立方数立方根的代码。现在,我们来看看如何判断一个循环是否会终止。
对于任何循环,我们可以通过思考一个“递减函数”来理解其终止性。递减函数需要满足以下四个属性:
- 它将程序中的某些变量映射到一个整数。
- 在首次进入循环或首次遇到循环测试时,其值为非负数。
- 当其值小于或等于0时,循环终止。
- 每次执行循环体时,其值都会减小。
如果存在这样的函数,那么循环就保证会终止。对于寻找立方根的程序,其递减函数是 abs(x) - ans**3。这个值从 abs(x) 开始(非负),每次循环因 ans 增加而减小,最终会变为0或负数,从而终止循环。
算法策略:穷举枚举
我们刚刚分析的立方根查找算法是一种“猜测与检查”策略,具体称为穷举枚举。
以下是穷举枚举的特点:
- 它系统地遍历所有可能的答案(猜测)。
- 检查每个猜测是否满足条件。
- 如果遍历完整个可能的答案空间仍未找到解,则说明解不存在。
这种方法虽然简单,但得益于计算机极高的运算速度(现代计算机每秒可执行数亿条指令),对于许多问题而言,它通常是可行且有效的解决方案,我们常称之为“暴力破解”法。

循环结构:for 循环
while 循环功能强大,但在遍历一系列值(如整数序列)时,for 循环提供了更简洁的写法。

以下是 for 循环的关键点:
range(start, stop)函数生成一个从start到stop-1的整数序列。for循环会为序列中的每个值执行一次循环体。- 可以使用
break语句提前退出循环。 - 循环可以嵌套,
break会退出其所在的最内层循环。
在遍历整数或其它序列时,for 循环比 while 循环更方便,在编程中也会更频繁地使用。
近似解与算法效率

在实际问题中,我们常常无法或不需要找到精确解,而是寻找一个满足精度要求的近似解。例如,求平方根时,我们可以定义误差范围 epsilon,目标是找到一个 y,使得 y*y 与 x 的差在 epsilon 之内。

我们首先尝试用穷举枚举来寻找平方根的近似解。程序会从0开始,以很小的步长(如0.001)增加猜测值,直到找到一个满足精度要求的 y。
然而,这种方法的运行时间取决于几个因素:
- 实际平方根与起始点的距离。
- 要求的精度
epsilon的大小。 - 每次迭代的步长增量。

当 x 很大或 epsilon 很小时,由于步长很小,可能需要数十万甚至上百万次猜测,虽然计算机很快,但效率依然低下。这引出了对更优算法的需求。


更优算法:二分查找
二分查找是一种通过每次将搜索空间减半来快速定位答案的算法。
以下是二分查找的基本思想:
- 确定搜索范围的下限
low和上限high。 - 猜测值为中点
guess = (high + low) / 2。 - 检查
guess**2与x的关系:- 如果
guess**2 > x,说明猜测值太大,将high设为guess。 - 如果
guess**2 < x,说明猜测值太小,将low设为guess。
- 如果
- 重复步骤2-3,直到
guess**2与x的差小于epsilon。
与线性穷举相比,二分查找的效率有质的飞跃。如果初始搜索空间有 N 种可能,线性搜索最坏需要 N 步,而二分查找最坏仅需 log₂(N) 步。例如,在之前的例子中,二分查找仅用26次猜测就完成了任务。



通过算法分析,我们可以预测程序的运行时间,从而决定是满足于当前算法,还是需要寻找更优的方案。
代码复用与函数引入
当前的二分查找代码专门用于求平方根。如果想要求立方根,我们需要修改代码中的两处指数(将2改为3)。但如果想要求四次方根、五次方根……每次都复制修改代码会非常繁琐。
这引出了下一个重要的编程概念:函数。函数允许我们将一段完成特定任务的代码块封装起来,通过输入不同的参数(如求根的次数 n)来重复使用,而无需重写代码。这将是下节课的核心内容。
总结


本节课我们一起学习了:
- 循环终止性:通过“递减函数”的概念理解并保证循环会结束。
- 算法策略:认识了简单直接的“穷举枚举”法和更高效的“二分查找”法。
- 近似解:理解了在计算机中求解问题往往意味着找到满足精度要求的近似答案。
- 算法效率:分析了算法运行时间依赖的因素,并看到二分查找如何指数级提升效率。
- 抽象的必要:为了代码复用和解决更通用的问题(如求任意次方根),我们需要引入“函数”这一工具。


在下一讲中,我们将深入探讨函数的概念、定义与使用。
005:函数与字符串处理 🧮

在本节课中,我们将学习两个核心概念:函数和字符串处理。我们将首先回顾二分查找算法中的缺陷,并学习如何调试程序。接着,我们将深入探讨函数如何通过分解和抽象来帮助我们编写更简洁、更可重用的代码。最后,我们将了解Python中字符串的基本操作。
调试二分查找算法 🔍
上一节我们介绍了二分查找的实现。本节中我们来看看之前代码中的一个问题。
以下是一个简化的求平方根二分查找代码:
x = 0.5
epsilon = 0.01
low = 0.0
high = x
ans = (high + low) / 2.0
while abs(ans**2 - x) >= epsilon:
if ans**2 < x:
low = ans
else:
high = ans
ans = (high + low) / 2.0
print(ans)
运行此代码寻找0.5的平方根时,程序会陷入无限循环。为了找出问题,我们需要进行调试。
以下是调试程序的核心步骤:
- 使用打印语句:在循环内部打印关键变量的值,观察其变化。
- 避免懒惰:清晰地标记每个打印值,例如
print(‘ans =‘, ans),而不是简单地print(ans, low, high)。
通过打印,我们发现变量 ans、low 和 high 的值都固定在了0.5,不再变化。这是因为当 x=0.5 时,初始搜索区间 [0, 0.5] 并不包含其平方根(约为0.707)。算法在一个不包含答案的区间内搜索,因此无法找到正确结果。

解决方案是确保搜索区间包含答案。一个简单的修复方法是让 high 的初始值为 max(x, 1.0)。这样,对于小于1的数,搜索区间将从 [0, 1] 开始。
high = max(x, 1.0)
引入函数:分解与抽象 🧩
虽然我们修复了代码,但它仍不理想。每次计算不同数的平方根都需要修改源代码,并且无法在大型程序中方便地复用这段代码。编写更多代码通常意味着更高的出错概率。

为了解决这个问题,我们引入函数。函数主要提供两种好处:分解和抽象。


- 分解:将程序拆分为独立的、可复用的模块(如函数)。这创造了结构。
- 抽象:将代码当作黑盒使用。使用者只需知道它做什么(通过规格说明),而无需知道它如何做(内部实现)。这允许我们轻松使用他人或自己编写的代码。

函数让我们能够扩展语言,添加可以像内置操作符一样使用的新“原语”。


函数详解 ⚙️

让我们通过一个例子来理解函数的组成部分。




def withinEpsilon(x, y, epsilon):
"""
参数 x, y, epsilon 均为浮点数,且 epsilon > 0。
如果 x 在 y 的 epsilon 范围内,则返回 True,否则返回 False。
"""
return abs(x - y) <= epsilon


- 定义关键字:
def表示开始定义一个函数。 - 函数名:
withinEpsilon,应选择有意义的名称。 - 形式参数:
x,y,epsilon,是函数接收输入的变量。 - 函数体:缩进的代码块,包含函数要执行的逻辑。
- 返回语句:
return将计算结果返回给调用者。 - 文档字符串:三引号内的文本,描述了函数的规格说明,这是实现抽象的关键。用户应通过阅读它来理解函数功能,而非直接阅读代码。


调用函数的方式如下:


print(withinEpsilon(2, 3, 1)) # 输出:False
val = withinEpsilon(2, 3, 1.5) # val 的值为 True
常见错误:如果在函数中计算了值却忘记使用 return 语句,函数将返回特殊值 None。如果你在代码中意外看到 None,请检查是否遗漏了 return。

作用域与栈帧 🗺️

理解函数调用时变量的作用域至关重要。关键概念是:形式参数和函数内部定义的变量与函数外部的同名变量无关。



def f(x):
x = x + 1
print(‘x in f:‘, x)
return x

x = 3
z = f(x)
print(‘z:‘, z)
print(‘x in main:‘, x)
输出结果为:
x in f: 4
z: 4
x in main: 3




执行过程分析:


- 调用
f(x)时,形式参数x被绑定到实际参数的值(整数3的对象)。 - 进入函数
f,创建一个新的作用域(或栈帧)。作用域是一个从名字到对象的映射。 - 在
f的作用域中,x首先指向整数3,然后执行x = x + 1,使得f作用域中的x指向新的整数4。这完全不影响主作用域中的x(仍指向3)。 - 函数返回后,其作用域被销毁。

当函数多层调用时,会形成一系列的栈帧,其创建和销毁遵循后进先出的原则,因此被称为“调用栈”。调试器中的“堆栈查看器”可以帮助我们查看每一层栈帧中的变量状态。
断言 assert 是一个有用的调试工具。assert 后跟一个表达式,如果表达式为 True,程序继续;如果为 False,程序立即停止并报错。它常用于“防御性编程”,在函数开始检查参数是否满足前提条件。

def findRoot(power, val, epsilon):
"""假设 power 是整数,val 和 epsilon 是正浮点数。
返回一个浮点数 y,使得 y**power 在 val 的 epsilon 范围内。
如果不存在这样的浮点数,则返回 None。"""
assert power > 0 and isinstance(power, int), ‘power must be positive integer‘
assert epsilon > 0, ‘epsilon must be positive‘
# ... 函数其余部分 ...

字符串处理 📝

到目前为止,我们主要处理数字。字符串是我们接触的第一个非标量值,即可以被分解处理的值。
遍历字符串:for 循环可以遍历任何可枚举其元素的类型,如字符串。
total = 0
for c in str(1952): # 将数字转换为字符串 ‘1‘,‘9‘,‘5‘,‘2‘
total += int(c) # 将字符转换回整数并相加
print(total) # 输出:17

索引与切片:可以访问字符串的单个字符或子串。
s = ‘ABC‘
print(s[0]) # 输出:‘A‘
print(s[0:1]) # 输出:‘A‘ (切片包含起始索引,不包含结束索引)
print(s[0:2]) # 输出:‘AB‘
切片操作会创建原字符串的一个新副本(子串)。

字符串方法:Python提供了许多字符串操作方法。
s = ‘ABC‘
print(s.find(‘B‘)) # 输出:1 (返回子串‘B‘的起始索引)

建议查阅Python官方文档以了解所有可用的字符串方法。

元组:这是另一个重要的标量类型,它类似于不可变的列表。我们将在下次课中详细讨论。
总结 📚

本节课中我们一起学习了:
- 调试技巧:通过打印语句系统性地定位程序中的无限循环等问题。
- 函数:理解了函数如何通过分解和抽象来创建可重用、易读的代码模块。我们学习了函数的定义、调用、参数传递、返回值以及作用域的核心概念。
- 字符串基础操作:包括遍历、索引、切片和使用内置方法,这些是处理文本数据的基础。




掌握这些概念将使你能够构建更结构化、更强大的程序。
006:循环、元组、字符串与函数 🧑💻

在本节课中,我们将学习Python编程中的几个核心概念:循环结构、元组、字符串以及函数的定义与使用。我们将通过具体的代码示例来理解这些概念,并掌握它们在实际编程中的应用。
循环结构 🔁
上一节我们介绍了编程的基本概念,本节中我们来看看两种主要的循环结构:while循环和for循环。
While循环
while循环的语法结构如下:
while 条件:
# 代码块
这里的“条件”是一个布尔表达式。只要条件为真,循环就会持续执行其内部的代码块。
以下是一个while循环的示例,它打印出2到10之间的偶数:
a = 2
while a <= 10:
print(a)
a += 2
这段代码的递减函数是 a += 2,它使变量a的值不断增大,最终满足退出条件 a > 10。变量名a不是一个好的选择,更好的命名是even_number,这样能清晰地表达其用途。
For循环与range函数
for循环用于遍历可迭代对象。其基本语法是:
for 变量 in 可迭代对象:
# 代码块
例如,遍历一个元组:
for i in (2, 4, 6, 8, 10):
print(i)
为了更方便地生成数字序列,Python提供了range()函数。它返回一个整数列表。
range(stop): 生成从0到stop-1的整数。range(start, stop): 生成从start到stop-1的整数。range(start, stop, step): 以step为步长,生成从start到stop-1的整数。
因此,上面的循环可以改写为:
for i in range(2, 11, 2):
print(i)
range()函数只接受整数参数。如果传入浮点数,它会被截断为整数。
元组 📦
在学习了循环之后,我们来看一种重要的数据结构:元组。
元组是一种不可变的、非标量的数据类型,可以存储多个元素。它使用圆括号()定义,元素之间用逗号分隔。
创建与访问
以下是创建元组的示例:
tuple_of_numbers = (3.14159, 2, -100, 0, 1, 7)
tuple_of_strings = (‘MIT’, ‘is’, ‘name’, ‘my’)
元组可以包含不同类型的数据,甚至是其他元组:
mixed_tuple = (3.14, ‘hello’, ‘world’)
tuple_of_tuples = ((‘stuff’, ‘just’), (‘got’,), (‘real’,))
访问元组元素使用索引,索引从0开始:
print(tuple_of_numbers[0]) # 输出: 3.14159
print(tuple_of_strings[-1]) # 输出: ‘my’ (负索引表示从末尾开始)
尝试访问不存在的索引会导致IndexError。可以使用len()函数获取元组长度以避免此错误。
切片操作
元组支持切片操作,用于获取子序列:
print(tuple_of_numbers[1:3]) # 输出: (2, -100)
print(tuple_of_numbers[:3]) # 输出: (3.14159, 2, -100)
print(tuple_of_numbers[1:]) # 输出: (2, -100, 0, 1, 7)
切片语法为 [start:stop],包含start索引,不包含stop索引。
不可变性与迭代
元组是不可变的,意味着创建后不能修改其元素。尝试修改会引发TypeError。
# tuple_of_numbers[0] = 2.71 # 这行代码会报错
但是,可以通过创建新元组来实现“修改”:
tuple_of_numbers = tuple_of_numbers + (8, 9) # 创建了一个新元组并重新赋值
元组是可迭代的,因此可以在for循环中使用:
for number in tuple_of_numbers:
print(number)
注意:创建只包含一个元素的元组时,必须在元素后加一个逗号,否则Python会将其解释为括号内的普通表达式。
single_element_tuple = (50,) # 这是一个元组
not_a_tuple = (50) # 这是一个整数 50
字符串 📝
字符串在很多方面与元组相似:它们也是不可变的、非标量的,并且支持索引、切片和迭代。
基本操作
name = ‘MIT’
print(name[0]) # 输出: ‘M’
# name[0] = ‘P’ # 不可变,会报错
for char in name:
print(char) # 逐行输出: M, I, T
字符串也支持切片:
print(name[1:3]) # 输出: ‘IT’
字符串方法
字符串对象有许多有用的内置方法:
print(name.upper()) # 输出: ‘MIT’
print(name.lower()) # 输出: ‘mit’
print(name.find(‘I’)) # 输出: 1 (返回子串的索引)
print(name.find(‘TECH’)) # 输出: -1 (未找到)
replace方法用于替换子串,它会返回一个新的字符串,原字符串不变:
new_name = name.replace(‘M’, ‘P’)
print(new_name) # 输出: ‘PIT’
要获取字符串对象所有可用方法的列表,可以在交互式环境中使用dir(str)或help(str)命令。
循环控制:break语句 ⏹️
在深入函数之前,我们先了解一个循环控制语句:break。
break语句用于立即终止当前所在的最内层循环,并跳出该循环体继续执行后续代码。
以下是一个寻找完美立方数立方根的函数,使用break重写:
def cube_root_break(x):
for ans in range(0, abs(x)+1):
if ans**3 >= abs(x):
break
if ans**3 != abs(x):
print(x, ‘is not a perfect cube’)
return None
else:
if x < 0:
ans = -ans
return ans
在这个例子中,当ans的三次方大于或等于x的绝对值时,break会立刻停止for循环。
函数 🏗️
函数是编程中组织和复用代码的核心工具。它是一段命名的代码块,接受输入(参数),执行特定操作,并返回一个结果。
定义与调用
在Python中,使用def关键字定义函数:
def cube(number):
“”“返回一个数字的立方。”“”
return number ** 3
函数定义包含:
def关键字。- 函数名(应具有描述性)。
- 括号内的参数列表。
- 可选的文档字符串(用于说明函数功能)。
- 函数体(通过缩进标识)。
return语句(用于返回结果)。
调用函数时,使用函数名和括号,并传入参数:
result = cube(3)
print(result) # 输出: 27
返回值
函数必须使用return语句来返回一个值。如果没有return语句,函数默认返回None。
def double_bad(number):
answer = number * 2
# 没有return,返回None
def double_good(number):
answer = number * 2
return answer
变量作用域
函数内部定义的变量具有局部作用域,只在函数内有效。函数外部定义的变量具有全局作用域。
all_hope = ‘global variable‘
def illustrate_scope(param):
my_variable = ‘local variable‘
print(param) # 可以访问参数
print(all_hope) # 可以访问全局变量
print(my_variable) # 可以访问局部变量
# print(my_variable) # 错误!在函数外无法访问局部变量
函数参数传递的是值的副本(对于不可变对象)或引用(对于可变对象,后续课程会讲到)。在函数内修改不可变类型的参数(如数字、字符串、元组)不会影响函数外的原始变量。
常见注意事项
print不是return:print()函数只是在屏幕上输出内容,它不返回值。在函数中打印结果与返回结果是两回事。- 函数是对象:在Python中,函数名本身是一个对象引用。要调用函数,必须在函数名后加括号
()。如果只写函数名而不加括号,Python不会报错,但不会执行函数。
总结 📚
本节课中我们一起学习了Python的四个关键部分:
- 循环:掌握了使用
while和for(配合range)进行迭代的方法,以及使用break控制循环流程。 - 元组:理解了这种不可变序列的创建、访问、切片和迭代操作,并注意到了单元素元组的特殊语法。
- 字符串:学习了字符串的不可变性、类元组操作以及一些实用的内置方法,如
upper(),find(),replace()。 - 函数:掌握了如何定义和调用函数,理解了参数传递、返回值的重要性以及变量作用域的概念,并识别了函数使用中的一些常见陷阱。


这些概念是构建更复杂程序的基础,熟练掌握它们对后续的学习至关重要。
007:Python数据结构详解 📚

在本节课中,我们将要学习Python中用于收集数据的三种核心数据结构:元组、列表和字典。我们将了解它们的特点、区别以及如何在实际编程中使用它们。

元组:有序且不可变的序列 📦
元组是最简单的数据结构之一。它是一个有序的、不可变的对象序列。这意味着一旦创建了一个元组,你就不能修改它的内容。


创建与访问元组

以下是创建和访问元组的基本方法:


test = (1, 2, 3, 4, 5)
print(test[0]) # 输出第一个元素:1
print(test[-1]) # 输出最后一个元素:5
print(len(test))# 输出元组长度:5


使用元组收集数据

我们可以使用元组来收集数据,例如,找出一个数的所有除数:
divisors = ()
for i in range(1, 101):
if 100 % i == 0:
divisors = divisors + (i,) # 注意单元素元组的语法
print(divisors)

元组切片

你可以获取元组的一部分,这称为切片:
divisors = (1, 2, 4, 5, 10, 20, 25, 50, 100)
print(divisors[1:3]) # 输出索引1到2的元素:(2, 4)
上一节我们介绍了有序且不可变的元组,本节中我们来看看功能更强大的列表。

列表:有序且可变的序列 🔄
列表与元组类似,也是有序的序列。但最关键的区别在于,列表是可变的。这意味着你可以在创建列表后修改其内容。

创建与修改列表
以下是列表的基本操作:
text = ['MIT', 'Caltech']
ivys = []
unis = []
unis.append(text) # 将整个列表作为一个元素添加
print(unis) # 输出:[['MIT', 'Caltech']]
注意:append 是一个方法,它会直接修改原列表(产生副作用),而不是返回一个新列表。
列表的别名与副作用

由于列表是可变的,当多个变量指向同一个列表对象时,通过一个变量修改列表会影响所有变量:
L1 = [2]
L2 = [L1, L1]
print(L2) # 输出:[[2], [2]]
L1[0] = 3
print(L2) # 输出:[[3], [3]],L2也改变了
这是一个需要特别注意的地方,因为它可能导致难以调试的错误。
列表的其他操作
列表支持多种操作,例如连接、排序和删除元素:
flat = text + ['Harvard'] # 连接列表
art_schools = ['RISD', 'Harvard']
for school in art_schools:
if school in flat:
flat.remove(school) # 删除元素
flat.sort() # 排序列表
print(flat)


上一节我们探讨了有序的序列(元组和列表),本节中我们来看看一种不同的、基于键值对的数据结构。



字典:无序的键值对集合 🗂️
字典与列表和元组有两大根本区别:
- 元素是无序的。
- 索引(称为键)可以是任何不可变类型(如整数、字符串、元组),而不仅仅是整数。
创建与访问字典
字典使用花括号 {} 创建,键值对用冒号 : 分隔:
D = {1: 'one', 'two': 2, 'pi': 3.14159}
print(D['pi']) # 输出键'pi'对应的值:3.14159
字典的键必须是不可变的
字典的键必须是不可变类型,这是为了支持其底层高效的“哈希”实现。
字典的常用操作

你可以遍历字典的键,并访问或修改对应的值:
EtoF = {'one': 'un', 'soccer': 'football', 'never': 'jamais'}
for key in EtoF.keys():
print(key, '->', EtoF[key])
del EtoF['one'] # 删除键值对
字典的应用示例:简单翻译

字典非常适合用于映射关系,例如构建一个简单的单词翻译器:
def translate_word(word, dictionary):
if word in dictionary:
return dictionary[word]
else:
return word
def translate_sentence(sentence, dictionary):
translation = ''
word = ''
for c in sentence:
if c != ' ':
word = word + c
else:
translation = translation + ' ' + translate_word(word, dictionary)
word = ''
return translation[1:] + ' ' + translate_word(word, dictionary)
print(translate_sentence('I never play soccer', EtoF))
总结 📝
本节课中我们一起学习了Python中三种核心的数据结构:
- 元组:有序、不可变的序列,适用于存储不应更改的数据集合。
- 列表:有序、可变的序列,功能强大但需要注意由可变性带来的别名和副作用问题。
- 字典:无序的键值对集合,通过键(必须是不可变类型)来高效访问值,非常适合表示映射关系。

理解这些数据结构的特性和区别,是编写高效、正确Python程序的基础。在接下来的问题集中,你将有机会实践这些概念。
008:递归与分治法 🧩

在本节课中,我们将要学习一种强大的问题解决策略——分治法,并重点介绍其核心实现技术之一:递归。我们将通过多个实例,理解如何将复杂问题分解为更小的同类问题,并组合其解决方案。
字典回顾与效率思考
上一节我们介绍了字典这一强大的数据结构。它允许我们将键与几乎任何类型的值关联起来。
一个自然的问题是:如果 Python 没有内置字典,我们能否实现类似的功能?答案是肯定的。我们可以用列表来模拟。
以下是一个用列表实现简单键值查找的示例:
def keySearch(L, k):
for elem in L:
if elem[0] == k:
return elem[1]
return None
这个函数遍历列表,检查每个元素的第一个部分是否等于目标键 k,如果找到则返回对应的值。
然而,这种方法存在效率问题。要判断一个键是否在“字典”中,平均需要检查一半的列表元素,最坏情况下需要检查整个列表。如果列表很长,效率会很低。
相比之下,Python 内置字典的查找操作是常数时间的,即查找时间与字典大小无关。这凸显了不同数据结构具有不同的效率特性,而字典在实现关联查找方面非常高效。
一个翻译示例
上一讲末尾,Guttag 教授展示了一个简单的翻译函数示例。它使用字典将英文单词映射为法文单词。
让我们深入分析这个例子。首先,我们有一个将单个单词翻译成法文的函数:

def translateWord(word, dictionary):
if word in dictionary:
return dictionary[word]
else:
return word


这个函数检查单词是否在字典中,如果在则返回翻译,否则返回原单词。


接下来是翻译整个句子的核心函数:
def translate(sentence):
translation = ''
word = ''
for c in sentence:
if c != ' ':
word = word + c
else:
translation = translation + ' ' + translateWord(word, dictionary)
word = ''
return translation[1:] + ' ' + translateWord(word, dictionary)

这个函数遍历句子中的每个字符。当字符不是空格时,它累积字符以形成单词。当遇到空格时,它认为一个单词结束,调用 translateWord 进行翻译,并将结果添加到 translation 字符串中。循环结束后,还需要处理最后一个单词(因为句子末尾通常没有空格)。
这个例子引出了两个重要的编程概念:
- 代码复用:将
translateWord分离为独立函数,避免了代码重复,便于调试。 - 模块化抽象:将特定功能(单词翻译)隔离在一个地方。如果需要修改翻译逻辑,只需修改这一个函数,而不必搜索整个代码库。这体现了“分治法”的思想。

分治法:化繁为简的哲学 🧱
分治法的核心思想是:将一个困难的问题分解成若干个更简单的子问题。
这些子问题需要具备两个关键属性:
- 子问题比原问题更容易解决。
- 子问题的解决方案能够容易地组合起来,以解决原问题。



这是一个古老而强大的思想。从凯撒大帝的“分而治之”到美国《宪法》对“本土出生公民”的递归定义,都体现了这种思维模式。
在《宪法》定义中:
- 基础情况:在美国境内出生的人,是本土出生公民。
- 递归情况:在美国境外出生的人,如果其父母都是美国公民,且至少一方在美国居住过,那么他也是本土出生公民。(而判断其父母是否为公民,可能又需要追溯其祖父母的情况,如此递归下去。)
这正是递归定义的典型结构:
- 基础情况:直接给出最简单情况的答案。
- 递归(归纳)情况:将问题简化为一个或多个更小版本的同一问题,再结合一些简单操作得到答案。
递归实例解析
下面我们通过几个例子,具体看看如何用递归解决问题。
实例一:幂运算
计算 b 的 n 次方(仅使用乘法)。
递归思路:
- 基础情况:当
n == 0时,b**0 = 1。 - 递归情况:当
n > 0时,b**n = b * (b**(n-1))。
def simple_exp(b, n):
if n == 0:
return 1
else:
return b * simple_exp(b, n-1)
实例二:汉诺塔问题
汉诺塔问题要求将一叠盘子从一根柱子移动到另一根柱子,每次只能移动一个盘子,且大盘子不能放在小盘子上。
递归思路(移动 n 个盘子从 from 柱到 to 柱,借助 spare 柱):
- 基础情况:如果只有一个盘子(n==1),直接将其从
from移到to。 - 递归情况:
- 将上面的 n-1 个盘子从
from移到spare(借助to)。 - 将最大的第 n 个盘子从
from移到to。 - 将 n-1 个盘子从
spare移到to(借助from)。
- 将上面的 n-1 个盘子从
def hanoi(n, from_stack, to_stack, spare_stack):
if n == 1:
print('Move from', from_stack, 'to', to_stack)
else:
hanoi(n-1, from_stack, spare_stack, to_stack)
hanoi(1, from_stack, to_stack, spare_stack)
hanoi(n-1, spare_stack, to_stack, from_stack)
实例三:判断回文串
回文串是正读反读都一样的字符串。
递归思路:
- 基础情况:如果字符串长度为 0 或 1,那么它是回文串。
- 递归情况:检查字符串的首尾字符。
- 如果首尾字符不同,则不是回文串。
- 如果首尾字符相同,则问题转化为判断“去掉首尾字符后的子串”是否是回文串。
首先,我们需要一个辅助函数来预处理字符串(忽略空格和大小写):
def toChars(s):
import string
s = s.lower()
ans = ''
for c in s:
if c in string.ascii_lowercase:
ans = ans + c
return ans
然后是核心的递归判断函数:
def isPal(s):
if len(s) <= 1:
return True
else:
return s[0] == s[-1] and isPal(s[1:-1])
最后,组合两个函数:
def isPalindrome(s):
return isPal(toChars(s))
为了更直观地展示递归过程,我们可以为 isPal 函数添加打印语句,观察其如何层层深入(递归调用)和层层返回(组合结果)。
实例四:斐波那契数列

斐波那契数列描述了一种理想化的兔子繁殖模型,其定义如下:
递归定义:
- 基础情况:
fib(0) = 1,fib(1) = 1。 - 递归情况:
fib(n) = fib(n-1) + fib(n-2)(当 n > 1 时)。
这个定义意味着,第 n 个月的兔子数量等于前两个月兔子数量之和。
def fib(x):
if x == 0 or x == 1:
return 1
else:
return fib(x-1) + fib(x-2)

注意,这个递归解法包含了多个递归调用(fib(n-1) 和 fib(n-2)),并且有多个基础情况(n==0 和 n==1)。虽然这个直接递归实现的效率不高(存在大量重复计算),但它清晰地展示了如何将问题分解为更小的同类问题。
斐波那契数列在自然界中广泛存在,例如许多花朵的花瓣数目是斐波那契数。当 n 趋向无穷大时,相邻两项的比值 fib(n)/fib(n-1) 会趋近于黄金比例 (1+√5)/2。

总结
本节课我们一起学习了分治法与递归这一核心问题解决策略。
我们了解到:
- 分治法的精髓在于将大问题分解为更易解决的小问题,并能将小问题的解轻松组合。
- 递归是实现分治法的自然工具,它通过“自我调用”来解决问题,包含基础情况和递归情况两个关键部分。
- 通过幂运算、汉诺塔、回文判断和斐波那契数列等多个实例,我们看到了递归如何让复杂问题的代码变得清晰、简洁。
- 递归不仅是一种编程技巧,更是一种描述和定义问题的思维方式。


掌握递归思维,你将拥有一个强大的工具,用于分解和解决许多复杂的计算问题。
009:数据结构与递归

在本节课中,我们将学习Python中的三种核心数据结构:元组、列表和字典,并探讨递归的基本概念。我们将通过具体的例子来理解它们的特点、用法以及在实际编程中的应用。
元组
元组是一种不可变的有序序列。这意味着一旦创建,其内容就不能被修改。元组可以包含不同类型的元素,甚至可以包含列表。
访问元组元素
我们可以通过索引来访问元组中的元素。索引从0开始。
tuple_a = (1, 2, 3)
print(tuple_a[0]) # 输出第一个元素:1
print(tuple_a[-1]) # 输出最后一个元素:3
元组切片
切片操作允许我们获取元组的一部分。
tuple_a = (1, 2, 3)
print(tuple_a[0:1]) # 输出 (1,)
print(tuple_a[0:2]) # 输出 (1, 2)
遍历元组
我们可以使用 for 循环来遍历元组中的每个元素。
tuple_a = (1, 2, 3)
for item in tuple_a:
print(item)
列表
列表是一种可变的有序序列。与元组不同,列表创建后可以修改其内容。列表提供了许多方法来操作其中的元素。
列表的基本操作
以下是列表的一些基本操作:
- 添加元素:使用
append()方法在列表末尾添加元素。 - 移除元素:使用
pop()方法移除并返回列表的最后一个元素,或使用remove()方法移除第一个匹配的指定值。 - 扩展列表:使用
extend()方法将另一个列表的所有元素添加到当前列表末尾。
my_list = [1, 2]
my_list.append(3) # my_list 变为 [1, 2, 3]
my_list.pop() # 返回 3,my_list 变为 [1, 2]
my_list.remove(1) # my_list 变为 [2]
my_list.extend([4, 5]) # my_list 变为 [2, 4, 5]
创建多维列表
列表可以用来创建矩阵等复杂数据结构。
matrix = [[1, 2], [3, 4]] # 一个2x2的矩阵
print(matrix[0][1]) # 访问第一行第二列的元素:2
列表的别名与复制
由于列表是可变对象,直接赋值只会创建对同一列表的引用(别名),而不是复制。修改一个别名会影响所有引用该列表的变量。
list_x = [1, 2, 3]
list_y = list_x # list_y 是 list_x 的别名
list_x[0] = 99
print(list_y) # 输出 [99, 2, 3]
要创建列表的独立副本,可以使用切片操作 [:]。
list_z = list_x[:] # list_z 是 list_x 的完整副本
list_x[0] = 100
print(list_z) # 输出 [99, 2, 3],不受影响
类型转换
元组和列表之间可以相互转换。
my_tuple = (1, 2, 3)
my_list = list(my_tuple) # 元组转列表
new_tuple = tuple(my_list) # 列表转元组
字典
字典是一种可变的无序集合,用于存储键值对。键必须是不可变对象(如字符串、数字、元组),而值可以是任何类型的对象。
字典的基本操作
以下是字典的一些基本操作:
- 访问与修改:通过键来访问或修改对应的值。
- 添加新项:为新的键赋值即可添加新项。
- 检查键是否存在:使用
in关键字。 - 获取所有键:使用
keys()方法。 - 获取所有键值对:使用
items()方法。
my_dict = {'name': 'Alice', 'age': 25}
print(my_dict['name']) # 访问:'Alice'
my_dict['age'] = 26 # 修改
my_dict['city'] = 'New York' # 添加
print('name' in my_dict) # 检查键:True
print(my_dict.keys()) # 获取所有键
print(my_dict.items()) # 获取所有键值对
遍历字典
我们可以遍历字典的键、值或键值对。
# 遍历键
for key in my_dict:
print(key, my_dict[key])
# 遍历键值对(推荐)
for key, value in my_dict.items():
print(key, value)
递归
递归是一种编程技巧,函数通过调用自身来解决问题。递归的核心思想是将一个复杂问题分解为一个或多个相同类型的、但规模更小的子问题。
递归的两个关键部分
- 基线条件:这是递归的终止条件,防止函数无限调用自身。它通常对应问题的最小、最简单的情况。
- 递归条件:这是函数调用自身的部分,它将问题规模缩小,逐步向基线条件靠近。
递归示例:阶乘
计算阶乘 n! 的递归定义是:n! = n * (n-1)!,且 0! = 1。
def factorial(n):
if n == 0: # 基线条件
return 1
else: # 递归条件
return n * factorial(n - 1)
递归示例:汉诺塔
汉诺塔问题是一个经典的递归问题。目标是将所有盘子从源柱移动到目标柱,期间可以借助缓冲柱,且任何时候大盘子都不能放在小盘子上面。
解决思路是:
- 将
n-1个盘子从源柱移动到缓冲柱(借助目标柱)。 - 将第
n个(最大的)盘子从源柱直接移动到目标柱。 - 将
n-1个盘子从缓冲柱移动到目标柱(借助源柱)。
def hanoi(n, source, target, buffer):
if n > 0:
# 将 n-1 个盘子从 source 移动到 buffer,以 target 作为缓冲
hanoi(n-1, source, buffer, target)
# 将第 n 个盘子从 source 移动到 target
print(f"Move disk from {source} to {target}")
# 将 n-1 个盘子从 buffer 移动到 target,以 source 作为缓冲
hanoi(n-1, buffer, target, source)
# 调用函数,移动3个盘子
hanoi(3, 'A', 'C', 'B')
总结


本节课我们一起学习了Python中三种重要的数据结构:元组(不可变序列)、列表(可变序列)和字典(键值对集合)。我们了解了它们各自的特点、操作方法以及在实际编程中的注意事项,例如列表的别名问题。最后,我们探讨了递归的核心思想,并通过阶乘和汉诺塔的例子,学习了如何设计递归函数来优雅地解决复杂问题。理解这些概念是进行更高级编程的基础。
010:浮点数与调试
在本节课中,我们将学习两个核心主题:浮点数在计算机中的表示方式及其可能带来的问题,以及系统化调试程序的方法。理解这些概念对于编写正确、健壮的代码至关重要。
浮点数:二进制与十进制的差异
上一节我们介绍了课程安排。本节中,我们来看看计算机如何处理数字,特别是浮点数。
计算机内部使用二进制(基数为2)表示所有数据,而人类习惯于使用十进制(基数为10)。对于整数,这种差异通常不会造成问题。然而,对于小数(浮点数),二进制无法精确表示某些十进制分数,这会导致微小的精度误差。
二进制数字基础
要理解浮点数,首先需要理解二进制数字。

- 在十进制中,数字由0-9的数码表示。最右边的位是10⁰位,接着是10¹位,依此类推。
- 例如,数字302表示:
3 * 100 + 0 * 10 + 2 * 1
- 例如,数字302表示:
- 在二进制中,数字仅由0和1两个数码表示。最右边的位是2⁰位,接着是2¹位,依此类推。
- 例如,二进制数101表示:
1 * 4 + 0 * 2 + 1 * 1 = 5
- 例如,二进制数101表示:


一个重要的区别是,表示相同的数值,二进制通常需要比十进制更多的位数。


为什么浮点数会出问题?

问题根源在于,某些简单的十进制小数在二进制中是无限循环小数。

- 十进制数
0.125(即1/8)可以精确转换为二进制:0.001。 - 然而,十进制数
0.1(即1/10)在二进制中是一个无限循环小数:0.0001100110011...




计算机内存有限,因此只能存储这个无限小数的有限位近似值。这就导致了精度误差。
在Python中,你可以通过 repr() 函数查看变量内部存储的真实近似值,而 print() 函数会自动进行舍入,可能隐藏问题。
x = 0.1
print(x) # 输出: 0.1 (经过舍入)
print(repr(x)) # 输出: 0.1000000000000000055511151231257827021181583404541015625

误差累积与比较陷阱
当进行大量运算时,微小的误差可能会累积,导致明显错误。

考虑以下代码,它将0.1累加10,000次:
x = 0.0
for i in range(10000):
x = x + 0.1
print(x) # 打印结果看似是1000.0
print(x == 1000.0) # 但比较结果却是 False
print(repr(x)) # 查看内部表示,发现并非精确的1000.0

核心教训:永远不要直接使用 == 来比较两个浮点数是否相等。

正确的做法是检查两个数的差值是否小于一个可接受的微小误差范围(epsilon)。

def close_enough(x, y, epsilon=0.0000001):
return abs(x - y) < epsilon
# 使用方式
result = close_enough(x, 1000.0)
系统化调试:从“除虫”传说讲起
上一节我们探讨了浮点数的特性。本节中,我们将转向另一个关键技能:如何高效地查找和修复程序中的错误,即“调试”。
关于“调试”(Debugging)一词有个著名的传说:1947年,Grace Hopper在哈佛Mark II计算机的继电器里发现了一只飞蛾(bug),导致程序故障,移除飞蛾后程序恢复运行,这被认为是“调试”的起源。虽然这个词的实际使用更早,但这故事生动地描述了排除故障的过程。
调试的核心思想
首先,需要摒弃两个误区:
- 错误不是自己“爬”进程式的,而是程序员引入的。
- 错误不会在程序中“繁殖”,多个错误意味着程序员犯了多个错误。
调试的目标不是“快速消灭一个错误”,而是系统性地向一个无错误的程序迈进。调试是一项可以通过学习掌握的技能,其方法论可以应用于解决许多其他领域的问题。
调试工具:print语句与科学方法

最强大、最常用的调试工具是 print 语句。通过在有疑问的地方打印变量的值,可以观察程序的实际执行路径。
高效的调试遵循类似于科学方法的步骤:
- 收集数据:程序代码、产生错误输出的测试用例。
- 提出假设:基于现有数据,对错误原因形成一个可验证的猜想。
- 设计并运行可重复的实验:通过添加
print语句或编写测试代码来验证假设。实验必须有可能证伪你的假设。 - 分析结果,修正假设或代码:根据实验结果,要么修正代码,要么提出新的假设并重复过程。

调试策略:二分查找法

在程序中寻找错误点,可以借鉴二分查找的策略。
- 首先,尝试找到能触发错误的最简单、最小的输入。这减少了测试的复杂性。
- 在程序中选取一个中间点,插入
print语句。 - 检查此时程序的状态是否符合预期。
- 如果符合,则错误在后半部分。
- 如果不符合,则错误在前半部分或就是这个点本身。
- 在包含错误的那一半程序中,重复步骤2和3,不断缩小范围,直到定位错误。
实践示例:调试一个回文判断程序
让我们通过一个故意包含错误的程序来实践上述方法。程序 silly 要求用户输入n个字符串,然后判断这些字符串组成的列表是否为回文。
def is_pal(x):
temp = x
temp.reverse
if temp == x:
return True
else:
return False
def silly(n):
result = []
for i in range(n):
elem = input('Enter element: ')
result.append(elem)
if is_pal(result):
print('Yes')
else:
print('No')


运行 silly(2) 并输入 A, B,程序错误地输出 Yes。
调试过程:
- 简化输入:已使用最小错误用例
silly(2)。 - 二分查找,定位模块:在
silly函数中,is_pal调用前打印result,发现列表正确为[‘A‘, ‘B‘]。错误很可能在is_pal函数内部。 - 深入
is_pal:- 首先发现
temp.reverse缺少括号(),这导致反转操作并未执行。 - 修复后,发现反转操作同时改变了原始列表
x,这是因为temp = x使得两个变量指向同一个列表对象(别名)。
- 首先发现
- 最终修复:需要创建列表的副本再进行反转。
def is_pal(x): temp = x[:] # 创建列表x的副本 temp.reverse() return temp == x


重要提示:在调试时,print 语句中应同时输出你期望的值和实际值,便于快速比对。此外,编写独立的测试代码来调用被调试函数,可以避免反复进行手动输入,提高效率。
总结



本节课中我们一起学习了两个重要主题。
首先,我们探讨了浮点数在计算机中的二进制表示方式,理解了为何 0.1 这样的十进制小数无法被精确存储,以及由此带来的精度误差。我们掌握了核心原则:不要直接比较浮点数是否相等,而应检查它们是否足够接近。
其次,我们深入学习了系统化的调试方法。我们了解了调试的目标和科学方法般的步骤,重点实践了使用 print 语句和二分查找策略来定位程序错误。记住,调试的关键在于提出可验证的假设,并通过可重复的实验来缩小错误范围。掌握这些技能将极大地提升你解决问题的能力。
011:递归、浮点数与调试

在本节课中,我们将要学习递归、浮点数精度以及程序调试的核心概念。我们将通过对比递归与迭代的实现方式,理解浮点数的特性,并掌握使用伪代码和测试框架来设计和验证程序的方法。
递归与迭代的对比
上一节我们介绍了递归的基本概念。本节中我们来看看如何将递归函数改写为迭代形式,并比较两者的优劣。


递归是一种通过函数调用自身来解决问题的分治技术。它包含一个基础情况(最小的子问题)和一个递归情况(将问题分解为更小的子问题)。
例如,一个递归的乘法函数可以这样定义:
def recursive_multiply(m, n):
if n == 0:
return 0
elif n > 0:
return m + recursive_multiply(m, n-1)
else:
return -m + recursive_multiply(m, n+1)
以下是该函数的迭代版本:
def iterative_multiply(m, n):
if n == 0 or m == 0:
return 0
result = 0
if n > 0:
while n > 0:
result += m
n -= 1
else:
while n < 0:
result -= m
n += 1
return result
对于乘法问题,迭代版本通常更直观易懂。
然而,对于某些问题,递归版本则更加清晰。以斐波那契数列为例:
def recursive_fibonacci(n):
if n == 0 or n == 1:
return n
else:
return recursive_fibonacci(n-1) + recursive_fibonacci(n-2)

其迭代版本需要更多的“簿记”工作来跟踪状态:
def iterative_fibonacci(n):
if n == 0 or n == 1:
return n
prev, curr = 0, 1
for i in range(2, n+1):
prev, curr = curr, prev + curr
return curr
在这个例子中,递归版本更直接地反映了数学定义,可能更容易理解。
选择递归还是迭代,往往取决于问题的性质以及代码的可读性。有时为了效率,我们可能需要将直观的递归算法改写为迭代形式。
浮点数的精度问题
理解了代码结构的选择后,我们需要关注计算机进行数值计算时的内在限制,即浮点数的精度问题。


浮点数在计算机中的表示是不精确的。这意味着你不应该直接比较两个浮点数是否完全相等。
例如,在数学上,0.1 + 0.9 应该等于 1.0。但在计算机中:
tenth = 1.0/10.0
thousandths = 1.0/1000.0
nine_hundredths = 9.0/100.0
print(tenth == thousandths + nine_hundredths) # 输出:False
这是因为 0.1 在二进制中无法被精确表示。


解决方案是定义一个误差容忍度 epsilon,并比较两个数的差值是否小于这个 epsilon:
def close_enough(x, y, epsilon=0.0001):
return abs(x - y) < epsilon
print(close_enough(tenth, thousandths + nine_hundredths)) # 输出:True
如何选择 epsilon 的值取决于具体的应用场景和对精度的要求。
尽管浮点数不精确,但其运算是一致的。这意味着如果你用同一个不精确的值进行运算,结果在计算机内部是可靠的:
temp = nine_hundredths + thousandths
print(close_enough(nine_hundredths, temp - thousandths)) # 输出:True
使用伪代码设计算法
在深入编码之前,用人类语言描述算法步骤是很好的实践,这被称为伪代码。它帮助我们在思考逻辑时,不被具体的编程语法所束缚。
以下是设计一个“猜词游戏”(Hangman)的伪代码步骤:
- 选择一个随机单词。
- 告诉玩家单词的字母数量。
- 显示单词的掩码形式(如
_ _ _ _)。 - 当还有剩余猜测次数 且 单词未被猜出时,重复以下步骤:
- 请玩家猜一个字母。
- 检查字母是否在单词中。
- 如果在,更新显示的掩码单词。
- 如果不在,减少剩余猜测次数并告知玩家。
伪代码既可用于个人理清思路,也便于向他人解释算法逻辑。
系统化调试与测试
最后,当程序出现错误(bug)时,系统化的调试方法至关重要。调试时,目标不是快速尝试,而是理解代码为何产生当前输出。


一个有效的方法是构建一个测试框架。为你的函数设计一系列测试用例,包括:
- 边界情况:输入的最小、最大或特殊值。
- 典型情况:预期的正常输入。
例如,测试一个判断回文串的函数:
def is_palindrome(s):
# 函数实现...
pass
# 测试框架
test_cases = [
("", True), # 空字符串
("a", True), # 单字符
("ab", False), # 非回文
("aba", True), # 奇数长度回文
("abba", True), # 偶数长度回文
]

all_passed = True
for input_str, expected in test_cases:
result = is_palindrome(input_str)
if result != expected:
print(f"测试失败: 输入 '{input_str}', 期望 {expected}, 得到 {result}")
all_passed = False
if all_passed:
print("所有测试通过!")
每次修改代码后都运行测试框架,可以快速发现引入的新错误。调试时,一次只做最小改动,并利用 print 语句或调试器观察程序状态。
总结



本节课中我们一起学习了几个核心编程概念。我们对比了递归与迭代的实现方式,认识到根据问题选择合适方法的重要性。我们探讨了浮点数的不精确性,并学会了使用 epsilon 进行“足够接近”的比较。我们介绍了使用伪代码在编码前设计算法逻辑的技巧。最后,我们掌握了通过构建测试框架来系统化调试和验证程序正确性的方法。这些技能将帮助你更清晰、更稳健地构建和修复复杂的程序。
012:算法效率分析 🚀

在本节课中,我们将要学习算法效率分析。我们将探讨为什么效率很重要,如何衡量算法的运行时间,以及如何使用大O符号来描述算法的复杂度。我们还将通过具体例子来理解不同复杂度类别的实际意义。
为什么效率很重要?🤔
此前,我们主要关注如何让程序正确运行。从今天开始,我们将讨论如何让程序运行得足够快,以满足实际需求。在实际编程中,效率通常是设计程序时一个非常重要的考量因素。
我们的目标不是让你成为这方面的专家,而是希望你建立起关于算法效率的直觉,理解为什么有些程序运行得比另一些慢得多,以及如何编写能在合理时间内完成的程序。
效率之所以重要,是因为我们面临的计算问题规模可能非常巨大。例如,在我的研究小组中,我们有一个包含大约15亿次心跳的数据库,常规计算需要运行两周。如果不是因为我们对效率非常小心,这些计算可能需要运行两年。随着问题规模的扩大,效率问题变得越来越重要。
需要记住的关键点是:效率通常关乎算法选择,而非编码细节。聪明的算法很难发明,一个成功的计算机科学家在其整个职业生涯中可能也只发明一个重要的算法。因此,我们更依赖于问题归约:当遇到一个新问题时,我们思考如何将其转化为一个已有解决方案的问题。
如何思考效率?⏱️
当我们思考效率时,通常从两个维度考虑:空间和时间。在本课程接下来的几讲中,我们将主要关注时间复杂度,因为这是当前处理复杂度问题时人们最关心的方面。
那么,如何回答“某个算法需要运行多长时间”这个问题呢?一个糟糕的方法是直接在特定计算机上运行并计时,因为:
- 它受机器速度影响。
- 它受Python具体实现的影响。
- 最重要的是,它依赖于具体的输入数据。
因此,我们需要一种更抽象的方式来讨论效率。
计算模型与基本步骤 📊
我们通过计算基本步骤的数量来分析效率。我们定义一个函数,例如 time(n),其中参数 n 代表输入规模的大小,函数结果是对该规模输入进行计算所需的步骤数。
一个步骤是指耗时恒定的操作,例如赋值、比较、访问列表元素等。
在本课程中,我们使用随机存取机模型。在这个模型中:
- 指令是顺序执行的。
- 我们假设访问内存中任何对象所需的时间是恒定的。
虽然现代计算机有内存层次结构,但如果我们深入这些细节,往往会“只见树木,不见森林”。因此,几乎所有人分析算法时都使用这个模型,它对于理解算法已经足够好。

最好、最坏与平均情况 📈
当我们分析算法运行时间时,可以从几个不同角度看待:
- 最好情况:在所有可能输入中运行时间的最小值。
- 最坏情况:在给定规模的所有可能输入中运行时间的最大值。
- 平均情况:算法在典型情况下的期望运行时间。
复杂度分析几乎总是关注最坏情况。 原因如下:
- 最坏情况提供了一个上界,保证了不会有更糟的情况发生。
- 平均情况通常很难分析,因为它需要知道输入数据的详细分布模型。
- 最坏情况往往经常发生。
例如,对于线性搜索,最坏情况(元素不在列表中)会导致检查列表中的每一个元素。

渐近分析与大O符号 📐
我们并不关心运行时间函数中的具体常数项,而是关注随着输入规模增长,运行时间是如何增长的。我们使用大O符号来描述算法的渐近复杂度。
例如,如果一个算法的运行时间函数是 T(n) = 2 + 3n + 1,我们会忽略常数项和低阶项,称其复杂度为 O(n),即线性复杂度。
大O符号给出了函数增长率的上界。形式化地说,如果 f(x) 是 O(g(x)),意味着 f(x) 的增长速度不快于 g(x)。
以下是常见的复杂度类别:
- O(1):常数时间。
- O(log n):对数时间。
- O(n):线性时间。
- O(n log n):线性对数时间。
- O(n^c):多项式时间(例如
O(n^2)是平方时间)。 - O(c^n):指数时间。

为了直观理解这些类别的差异,我们来看一些对比图。可以看到,对数算法比线性算法增长慢得多,而线性算法又比平方算法快得多。指数算法则增长得如此之快,对于稍大的输入就变得完全不实用。
经验法则:尽可能避免使用比线性对数更差的算法。
复杂度分析实例 🔍
上一节我们介绍了大O符号和常见复杂度类别,本节中我们来看看如何具体分析代码的复杂度。
示例1:迭代阶乘
def factorial_iter(n):
assert n >= 0
answer = 1
while n > 1:
answer *= n
n -= 1
return answer
分析:
- 忽略常数操作(断言、初始化、返回)。
- 循环体执行3个操作(测试、乘法、减法)。
- 循环执行
n次。 - 总复杂度为 O(n)。
示例2:递归阶乘
def factorial_recur(n):
assert n >= 0
if n <= 1:
return 1
else:
return n * factorial_recur(n-1)
分析:
- 递归调用次数为
n次。 - 每次调用执行常数操作。
- 总复杂度同样为 O(n)。
递归和迭代版本具有相同的渐近复杂度。选择哪种方式取决于编码便利性,而非效率。
示例3:嵌套循环
def g(n):
x = 0
for i in range(n):
for j in range(n):
x += 1
return x
分析:
- 内层循环执行
n次操作。 - 外层循环执行
n次,每次启动一次内层循环。 - 总复杂度为 O(n * n) = O(n^2)。
分析嵌套结构时,通常从最内层开始,向外逐层分析。
示例4:数字求和
def h(x):
s = str(x)
answer = 0
for c in s:
answer += int(c)
return answer
分析:
- 循环次数取决于整数
x的十进制位数。 - 十进制位数等于
log10(x)。 - 因此,复杂度是 O(log x),而不是
O(x)或O(len(s))。
关键点:必须仔细定义复杂度表达式中的变量含义,并且要用算法的输入参数来表示复杂度。
搜索算法复杂度对比 🔎
上一节我们分析了几个简单函数的复杂度,本节中我们来看看两个经典搜索算法的复杂度。
线性搜索
def linear_search(L, e):
for i in range(len(L)):
if L[i] == e:
return True
return False
- 最坏情况下需要检查列表中的每一个元素。
- 复杂度为 O(len(L))。
二分搜索
def binary_search(L, e):
def bsearch(L, e, low, high):
if high == low:
return L[low] == e
mid = (low + high) // 2
if L[mid] == e:
return True
elif L[mid] > e:
if low == mid:
return False
else:
return bsearch(L, e, low, mid - 1)
else:
return bsearch(L, e, mid + 1, high)
if len(L) == 0:
return False
else:
return bsearch(L, e, 0, len(L) - 1)
- 每次递归调用都将搜索范围减半。
- 复杂度为 O(log len(L))。
二分搜索的效率优势非常明显:当列表规模翻倍时,只需要增加一步比较。这是一种对数增长的算法。

关于代码结构的说明:
- 我们通常将
binary_search包装在search接口内,以保持调用方式的一致性(只接收列表和元素参数)。 - 示例中使用
global关键字来声明全局变量num_calls以计数递归调用次数,但这在通常实践中应谨慎使用。
一个重要假设:上述复杂度分析基于一个前提,即从列表中通过索引获取元素(如 L[i])以及比较操作(如 ==, >)都能够在常数时间内完成。在后续课程中,我们将更深入地探讨这个前提。

总结 📝
本节课中我们一起学习了算法效率分析的基础知识。
我们了解到效率在解决大规模问题时至关重要,并且效率提升的关键在于选择正确的算法,而非微小的编码优化。我们引入了使用基本步骤计数和随机存取机模型来分析算法运行时间的方法,并强调应关注最坏情况复杂度。

我们学习了使用大O符号来描述算法的渐近复杂度,认识了从常数时间 O(1) 到指数时间 O(c^n) 的不同复杂度类别,并通过图表直观感受了它们增长率的巨大差异。
最后,我们通过多个代码示例实践了复杂度分析,包括迭代与递归阶乘、嵌套循环、数字求和以及线性搜索与二分搜索。我们特别强调了在表达复杂度时必须明确变量的含义,并且要基于算法的输入参数进行分析。
记住,目标是编写出不仅正确,而且能在可接受时间内完成计算的程序。在接下来的课程中,我们将继续应用这些概念来设计和分析更复杂的算法。
013:列表、搜索与排序
在本节课中,我们将要学习列表在计算机内存中的实现原理,理解二分搜索高效性的基础,并探讨如何对列表进行排序。我们将从列表的底层存储机制开始,逐步深入到排序算法的复杂度分析。
列表的内存实现
上一节我们介绍了二分搜索算法,其高效性依赖于能够快速访问列表中的任意元素。本节中我们来看看列表在内存中是如何组织的,以实现这种快速访问。
一个整数列表很容易在恒定时间内访问任何元素。因为一个整数总是占用相同大小的内存空间。例如,假设一个整数占用4个单位的内存。要访问列表 L 中的第 i 个元素(L[i]),如果我们知道列表的起始内存地址(由标识符 L 指向),那么该元素的位置就是:
公式:位置 = 起始地址 + 4 * i
这种方法要求列表中的每个元素大小相同。对于整数、浮点数等固定大小的数据类型,这种方法很有效。
然而,Python 列表可以包含不同类型和大小的元素,如整数、字符串、列表等。因此,Python 不能使用上述简单的连续内存块模型。一种古老的解决方案是链表。
以下是链表的工作原理:
- 每个列表元素都是一个包含两部分的结构:一个指向下一个元素的指针,以及元素的实际值。
- 要访问第
i个元素,必须从第一个元素开始,沿着指针逐个访问,直到第i个。这需要i步操作,时间复杂度为 O(i),对于搜索来说效率太低。
Python 采用了一种更巧妙的方法,称为间接寻址。其核心思想是将指针与值分开存储。
- Python 列表本身在内存中是一个连续的、大小固定的对象数组。
- 数组中的每个对象都是一个指针,指向列表中某个元素的实际值所在的内存位置。
- 由于指针大小固定(例如4个字节),我们可以用之前的公式快速找到第
i个指针:起始地址 + 4 * i。 - 找到指针后,只需一步操作(跟随指针)即可访问到实际值,无论该值有多大。
因此,即使在 Python 这种元素大小不固定的列表中,我们也能在恒定时间内访问任何元素。间接寻址是面向对象编程语言中一项非常强大且常用的技术。
二分搜索的前提与排序的必要性
我们已确信二分搜索的时间复杂度是 O(log n),这非常高效。但它有一个关键前提:列表必须是有序的。

这就引出了一个问题:如果列表未排序,我们是否应该先排序再使用二分搜索?这取决于效率。
- 如果列表未排序,我们总是可以使用线性搜索,其复杂度为 O(n)。
- 如果先排序再二分搜索,总复杂度为:
排序复杂度 + O(log n)。
只有当 排序复杂度 + O(log n) < O(n) 时,先排序才有意义。然而,任何排序算法都至少需要查看一次列表中的每个元素,因此排序的下界是 Ω(n)。这意味着 Ω(n) + O(log n) 的复杂度仍然是 O(n),并不比线性搜索更好。
那么,我们为什么还要关心二分搜索和排序呢?原因在于摊还复杂度。
如果我们只对列表排序一次,然后进行多次(K 次)搜索,那么单次排序的成本可以分摊到多次搜索中。总成本对比为:
- 不排序:进行
K次线性搜索,成本为K * O(n)。 - 先排序:成本为
排序复杂度 + K * O(log n)。
当搜索次数 K 非常大时(例如,学生信息被频繁查询),即使排序成本较高,先排序再使用高效的二分搜索总体上也更划算。这是一种常见的实践模式。
排序算法简介

既然排序如此重要,本节我们来看看如何实现排序。首先,我们看一个简单但低效的算法。
选择排序


选择排序是一种直观但效率不高的排序算法。它基于一个不变式来工作:将列表分为已排序的前缀和未排序的后缀。


算法步骤如下:
- 初始时,前缀为空,后缀为整个列表。
- 在未排序的后缀中找到最小(或最大)的元素。
- 将该元素与后缀中的第一个元素交换位置。此时,该元素被纳入前缀,前缀长度增加1,且保持有序。
- 重复步骤2和3,直到后缀为空,前缀包含所有已排序的元素。
以下是选择排序的核心逻辑(伪代码):
def selection_sort(L):
for i in range(len(L)):
# 找到后缀中最小元素的索引
min_index = i
for j in range(i+1, len(L)):
if L[j] < L[min_index]:
min_index = j
# 将最小元素交换到当前位置 i
L[i], L[min_index] = L[min_index], L[i]
复杂度分析:在每次迭代 i 中,我们需要比较后缀中的 n - i 个元素以找到最小值。总的比较次数约为 n + (n-1) + ... + 1 = n(n-1)/2。因此,选择排序的时间复杂度是 O(n²)。
归并排序
我们能做得比 O(n²) 更好吗?答案是肯定的。归并排序是一种采用分治策略的高效排序算法,由约翰·冯·诺依曼在20世纪40年代提出。

分治策略的一般步骤是:
- 选择阈值:确定最小问题规模
n0,当问题规模小于n0时直接解决。 - 分解:将大问题分解为若干个更小的子问题。
- 合并:设计算法将子问题的解合并为原问题的解。
归并排序正是如此:
- 分解:递归地将列表分成两半,直到每个子列表只剩下一个元素(一个元素的列表自然是有序的)。
- 合并:将两个已排序的子列表合并成一个新的有序列表。合并操作是线性的,即 O(n),其中
n是两个子列表的长度之和。
合并两个有序列表 A 和 B 的过程很简单:
- 比较
A和B的第一个元素。 - 将较小的元素取出,放入结果列表。
- 重复上述步骤,直到其中一个列表为空,然后将另一个列表的剩余部分全部追加到结果列表。


由于列表每次被分成两半,递归的深度是 O(log n)。在每一层递归中,我们需要合并所有该层的子列表,而每一层的总合并工作量是 O(n)。因此,归并排序的总时间复杂度是 O(n log n)。
归并排序的另一个优点是稳定性(相等元素的相对顺序不变)和适用于大数据集(外部排序)。Python 内置的 sorted() 函数和列表的 .sort() 方法使用的是一种名为 Timsort 的混合算法,它结合了归并排序和插入排序的思想,在实践中非常高效。
总结
本节课中我们一起学习了:
- 列表的内存模型:了解了固定大小元素的连续存储、链表以及 Python 使用的间接寻址模型,后者使得在列表中随机访问元素成为常数时间操作。
- 二分搜索的效率前提:认识到二分搜索要求列表有序,并分析了在多次搜索场景下,通过摊还成本,先排序再使用二分搜索是值得的。
- 排序算法:
- 介绍了选择排序,其时间复杂度为 O(n²),实现简单但效率较低。
- 重点学习了归并排序,这是一种采用分治策略的 O(n log n) 算法,通过递归分解和线性合并操作实现高效排序。


理解这些底层原理和算法复杂度,有助于我们编写出更高效的程序,并明智地选择合适的数据结构和算法来解决实际问题。
014:哈希与异常处理 🚀
在本节课中,我们将要学习两个重要的编程概念:哈希和异常处理。哈希是一种高效的数据存储与查找技术,而异常处理则帮助我们编写更健壮、不易崩溃的程序。我们将从哈希的基本原理开始,了解它如何实现快速的查找,然后探讨Python中如何通过异常处理机制来优雅地处理程序运行时的错误。
哈希:用空间换取时间 ⚡

上一节我们介绍了搜索和排序算法,本节中我们来看看哈希。哈希是Python中字典实现的基础,它能在大多数情况下提供非常高效的搜索,但这是以消耗更多存储空间为代价的。这是一个典型的“用空间换取时间”的例子。
哈希的基本思想

哈希的基本思想是:我们取一个整数 I,通过一个哈希函数将其转换为另一个整数,这个整数通常在一个固定的范围内,例如 0 到 K。然后,我们使用这个转换后的整数作为索引,去访问一个列表的列表(即哈希表)。这个列表中的每个子列表被称为一个桶。
核心公式:hash(I) = I % num_buckets,其中 % 是取模运算符。
当我们想查询某个整数是否在集合中时,我们先哈希它,直接定位到对应的桶,然后在该桶的列表中搜索。如果每个桶的列表足够短,这个操作就会非常高效。
哈希的实现代码

以下是实现一个简单整数集合哈希表的代码框架:
num_buckets = 47 # 全局变量,定义桶的数量
def create():
"""创建一个哈希表(一个列表,包含num_buckets个空列表)"""
global num_buckets
hash_table = []
for i in range(num_buckets):
hash_table.append([])
return hash_table

def hash_elem(e):
"""哈希函数:计算元素e应放入哪个桶"""
global num_buckets
return e % num_buckets
def insert(hash_table, i):
"""向哈希表中插入元素i"""
bucket = hash_elem(i)
hash_table[bucket].append(i) # 可能存在重复元素


def member(hash_table, i):
"""检查元素i是否在哈希表中"""
bucket = hash_elem(i)
return i in hash_table[bucket]

哈希冲突与性能

哈希函数的一个关键特性是多对一。由于桶的数量有限(例如47个),而可能的整数是无限的,因此许多不同的整数会哈希到同一个桶中。当两个不同的元素哈希到同一个桶时,就发生了冲突。
处理冲突的方法有很多,上面代码中使用的是最简单的一种,称为链地址法(或线性再哈希),即每个桶本身就是一个列表,冲突的元素被追加到列表末尾。
查找的复杂度大致取决于目标桶中列表的长度。这又取决于桶的数量与插入元素数量的比例关系:
- 如果桶的数量远大于元素数量,每个桶的列表会很短,查找近似于常数时间
O(1)。 - 如果桶的数量很少(极端情况是1个桶),那么查找就退化为线性搜索
O(n)。
因此,在使用哈希表时,通常会分配足够大的空间(足够多的桶),使得查找操作在绝大多数情况下是常数时间的。Python的字典正是这样实现的:它哈希键,并选择一个足够大的表,使得查找几乎是常数时间。如果后续发现表太小(因为插入了太多元素),它会自动重新调整大小(再哈希)到一个更大的表。

哈希更复杂的对象
哈希不仅限于整数。实际上,任何不可变的对象都可以被哈希。这就是为什么Python字典的键必须是不可变的——因为哈希值需要在对象的生命周期内保持不变。如果使用可变对象(如列表)作为键,哈希后对象发生改变,其哈希值也会变,导致无法再次找到它。

要将字符串等对象哈希为整数,常见的做法是将其内部表示(比特位)转换为一个整数。以下是一个可以处理整数和字符串的哈希函数示例:
def hash_string(s):
"""一个简单的字符串哈希函数示例"""
h = 0
for c in s:
# 结合字符的ASCII码(或Unicode码点)
h = (h * 31 + ord(c)) % num_buckets # 31是一个常用的质数
return h
总结:哈希是一种极其强大且常用的技术。一个好的哈希函数应能将输入值广泛地分散到不同的桶中。虽然存在多种哈希函数和冲突解决策略,但对于大多数应用,使用取模运算等简单方法已经足够。



异常处理:让程序更健壮 🛡️

现在,让我们从算法转向Python语言本身,探讨最后一个重要的语言概念:异常。我们其实已经见过很多异常了。

什么是异常?


异常是程序运行时发生的错误事件。例如:

- 索引错误:访问列表不存在的索引
list[12]。 - 类型错误:尝试将不兼容的类型进行转换
int('abc')。 - 名称错误:访问未定义的变量。
任何以 Error 结尾的标识符都是Python内置的一种异常类型。当这些异常未被处理时,会导致程序崩溃(停止运行),这被称为未处理异常。

捕获和处理异常:try-except

然而,在程序调试完成后,我们不应让未处理异常发生。Python提供了 try-except 块来捕获并处理异常,这是一种有效的控制流机制。
其工作原理如下:
- 程序尝试执行
try块中的代码。 - 如果成功执行完毕,则跳过
except块,继续执行后面的代码。 - 如果在
try块中发生了异常,则立即停止执行try块中的剩余代码,并跳转到匹配的except块开始执行。 - 执行完
except块后,继续执行后面的代码。
以下是一个使用异常处理来构建健壮输入函数的例子:
def read_val(val_type, request_msg, error_msg):
"""一个健壮的输入函数,要求用户输入指定类型的值"""
num_tries = 0
while num_tries < 4:
val = input(request_msg)
try:
# 尝试将输入转换为目标类型
val = val_type(val)
return val # 转换成功,返回值
except ValueError: # 捕获转换失败引发的ValueError
print(error_msg)
num_tries += 1
# 如果尝试次数过多,主动抛出一个异常
raise TypeError(f"超过最大尝试次数 {num_tries}")
# 使用该函数,并捕获它可能抛出的异常
try:
user_int = read_val(int, "请输入一个整数:", "输入无效,请重试。")
print(f"你输入的是:{user_int}")
except TypeError as e: # 捕获read_val抛出的TypeError
print(f"程序因以下原因停止:{e}")
异常的更多用途
- 关联信息:当使用
raise抛出异常时,可以附带一个消息字符串(或其他参数),以便在捕获时提供更详细的错误信息。 - 捕获所有异常:可以使用不指定异常类型的
except:来捕获所有异常,但这通常不是好习惯,因为它可能掩盖未预料到的错误。 - 流程控制:异常不仅用于错误处理。例如,
assert语句实际上在条件为假时会抛出AssertionError。我们可以捕获这个异常并执行备用逻辑,而不是让程序直接崩溃。 - 常见场景:异常处理在需要与用户或外部系统交互的程序中非常有用,例如:
- 尝试打开一个不存在的文件(引发
FileNotFoundError)。 - 尝试写入一个已存在的文件(可以捕获异常并询问用户是否覆盖)。
- 尝试打开一个不存在的文件(引发
总结:异常处理是一个简单但强大的机制。它允许我们将错误处理代码与主业务逻辑分离,使程序结构更清晰,并能更优雅地从错误中恢复或向用户提供有意义的反馈。
课程预告:类与面向对象编程 🎯
本节课中我们一起学习了哈希表如何实现高效查找,以及如何使用异常处理来增强程序的鲁棒性。在接下来的课程中,我们将深入探讨Python中最核心的概念之一:类。


类将数据和操作这些数据的函数(称为方法)绑定在一起,形成一个新的类型。这被称为面向对象编程。通过类,我们可以扩展Python语言,创建像内置的 list、dict 一样易用但功能自定义的新数据类型。我们将从理解“对象”、“方法”、“属性”这些概念开始,逐步学习如何定义和使用自己的类。
015:抽象数据类型与面向对象编程
在本节课中,我们将学习抽象数据类型和面向对象编程的核心概念,特别是类在其中扮演的角色。我们将通过具体的代码示例,理解如何定义和使用类,以及如何利用继承来构建层次化的数据结构。
抽象数据类型与面向对象编程
很多人认为面向对象编程是一个新概念。事实上,它已经存在了至少35年,甚至更久。但直到大约15年前,它才被广泛实践。这个概念始于20世纪70年代中期,当时人们开始撰写文章解释这种编程方法的优势。大约在同一时期,施乐帕克研究中心开发了Smalltalk语言,麻省理工学院开发了CLU语言。它们是首批以优雅方式为这种编程风格提供语言支持的语言。
然而,直到Java语言的引入,面向对象编程才真正在公众中流行起来。Java是第一个支持面向对象编程的流行语言。之后,C++也以并非非常优雅但可用的方式支持了它。如今,Python可能是支持面向对象编程增长最快的语言,这也是我们在此教授它的原因之一。
核心概念:抽象数据类型
这一切最根本的概念是抽象数据类型。其核心思想是,我们可以通过添加用户定义的类型来扩展我们的编程语言,并且这些类型可以像任何内置类型一样轻松使用。
我们之所以称它们为“抽象”数据类型,是因为我们本质上要为每种类型定义一个接口。这个接口的作用是解释方法做什么。这里的“做什么”指的是在用户层面的功能,而不是“如何实现”。这正是内置类型的工作方式。在你理解Python如何实现字典之前,你只知道可以放入键值对并进行关联查找。这之所以可能,是因为Python的设计者提供了一个接口供你使用。我们将为抽象数据类型做同样的事情。
这里的关键思想是规范。规范是类型、函数或方法的说明,它告诉我们该事物做什么。从现在到学期结束,我们将努力在规范和实现之间保持非常清晰的区分。

示例:整数集合
让我们看一个熟悉的例子。在之前的课程中,我们探讨了如何使用哈希来实现一组整数。当时我们使用了一个全局变量,实现方式并不优雅。现在,我们将看到一个更优雅的方法。
我将定义一个新的抽象类型,名为 intset。

class intset(object):
"""整数集合"""
def __init__(self):
self.num_buckets = 47
self.vals = []
for i in range(self.num_buckets):
self.vals.append([])
我通过写关键字 class,后跟类名来定义它。(object) 表示它是 object 的子类。目前可以忽略这一点,我们稍后会讨论。从根本上说,它表示 intset 的每个实例都是一个对象。这在Python中并不特别,因为Python中的一切都是对象。

特殊方法:__init__
现在让我们看看方法。首先,我写了一个注释说明这是一个整数集合。然后,我看到了这个特殊的方法 __init__。在Python中,任何名称中包含双下划线的方法都有特殊地位。__init__ 方法允许我们进行优雅的语法操作。
每次我创建一个 intset 类型的新对象时,该对象的 __init__ 方法(或类的函数)将被执行。在这个例子中,它引入了对象的两个属性:num_buckets(现在替代了我们上次看到的全局变量,我任意选择了47)和 vals(这将是包含值的哈希表本身)。然后,就像我们上次做的那样,我将初始化 vals,使这个列表的每个元素现在都是一个空列表。
理解 self
让我们看看这个奇怪的 self 概念。看一个例子:
s = intset()
这将创建一个新的 intset 对象并执行 __init__。例如,如果我打印 self.num_buckets,会出错,因为 self 在这个环境中没有定义。self 是 __init__ 的局部变量,实际上是形式参数。但我可以写 s.num_buckets,我会看到它是47。num_buckets 和 vals 现在是实例 s 的属性。
看起来 __init__ 有一个形式参数,但我没有提供对应的实际参数。这就是语法的魔力:它会自动、隐式地传入一个对象,或者创建一个。__init__ 中的 self 用于引用正在创建的对象。我们稍后会更全面地讨论 self 的概念及其用法。
私有函数与接口
接下来我们看到 hash_e。这是我们之前看过的私有函数,我不打算在类外部使用它。所以,如果我们考虑这里的规范,即类的接口,它不包括 hash_e。这就是“私有”在这里的含义。这是一种约定,不是语言强制执行的。不幸的是,很多有用的东西都不是语言强制执行的。但尽管如此,优秀的程序员会遵循这些约定,我们也希望你们这样做。
insert 方法更有趣。它显然接受两个参数,形式参数名为 self 和 e,并将 e 插入 self.vals。然而,如果我们查看使用它的代码,例如在 test1 中,你会注意到我说 s.insert(i)。看起来我只用一个参数调用 insert。但正如我们上次讨论的,点号前的 s 实际上是方法 insert 的第一个参数。所以它实际上得到了两个参数。按照约定,这个隐式的第一个参数在Python中总是被称为 self。这不是强制性的,你可以叫它 George 或 Alice,但如果你这样做,会让任何阅读你代码的人(包括寻求帮助的助教)感到非常困惑。所以请使用 self。它只是一个名字,与哲学家、心理学家或伦理学家可能想到的“自我”这个崇高概念无关。
特殊方法:__str__
我可以插入一堆东西,然后打印 s。这很有趣。__str__ 是另一个特殊名称。代码很简单,它只是返回集合的字符串表示形式。我可以选择任何我想要的表示方式,我选择了一种表示集合的常规方式。有趣的是,它会被 print 自动调用。所以当我在 test1 中写命令 print(s) 时,Python解释器足够聪明,知道最好将 s 转换为字符串然后打印。它如何知道将其转换为字符串?它会自动调用 __str__ 方法。
另一个操作是 member,同样是我们之前看过的。但你会注意到,在使用 intset 的代码中,我没有直接引用类的数据属性。或者,我不应该。你会注意到我在 s.vals 旁边写了“邪恶”。Python会让我这样做,但我不应该。为什么我不应该这样做?

数据隐藏的重要性
为什么我说这是邪恶的?如果你收到一条消息说“Ile已更改,请下载新版本”,而新版本恰好有列表或字典的不同实现,导致你所有的程序停止工作,你会很不高兴。为什么这不会发生?因为你的程序不以任何方式依赖于人们选择实现这些内置类型的方式。因为你根据类型的规范编程,而不是根据实现。intset 的规范没有提到 vals 或 num_buckets。因此,作为该类的实现者,我有权回去更改它。我可能根本不用哈希表,而是用红黑树或其他花哨的实现。我允许这样做。如果我做了这个更改,vals 和 num_buckets 消失了,只要我仍然满足规范,你的程序应该继续工作。
一旦你直接访问类的变量(那些属性),你就使用了规范中没有出现的东西。如果我更改了实现,你的程序可能会崩溃。所以你不应该这样做。这对每个人都有意义吗?这是一个非常重要的概念,被称为数据隐藏。这确实是使抽象数据类型有用、使它们具有与内置类型相同地位的最重要发展。一旦你选择忽略这一点,你将自担风险。
一些编程语言如Java提供了强制数据隐藏的机制。Python的设计者出于我不理解的原因选择不这样做。我认为这是语言的一个缺陷。
实例变量与类变量
我们隐藏的是实例变量,即与类的每个实例相关联的变量。我们还应该隐藏类变量。我们还没有看到这些,稍后会看到。实例变量每次创建类的新实例(本例中为新的 intset)时都会获得一个新副本。类变量与类本身相关联,你只获得它们的一个副本。稍后,我们将看到一个类变量有用的例子。

让我们运行一下以确保它正常工作。它做了我们期望的事情:True,False,然后你会看到这个漂亮的集合字符串表示。然后,只是为了展示当你做邪恶的事情时会发生什么,我打印了实际产生的列表。
更复杂的例子:设计程序
现在让我们看一个更有趣的例子。我想传达的想法是我们如何使用类和抽象数据类型来设计程序。
想象一下,你正在编写一个程序来跟踪麻省理工学院的所有学生、教职员工。当然,不使用任何类也可以编写该程序。对于每个学生,你可能会给他们一个姓氏、名字、家庭地址、年份、成绩等。你可以使用列表和字典的复杂组合来做到这一点,但这不会很优雅。

因此,在编写该程序之前(我实际上不会编写那个程序,只会编写一些我们可以使用的类),我想退一步思考哪些抽象会有用。这种围绕抽象数据类型组织程序的编程风格说:在我们详细编写代码之前,我们先思考哪些类型会使编写代码变得容易。
例如,如果你是金融专业的学生,想编写一些处理市场的代码,你可能想要有政府债券的抽象、股票的抽象、看涨期权的抽象等。你会说,我想在那个抽象层次上思考,我不想考虑列表、字典和浮点数,我只想考虑具有执行价格和日期等属性的期权。
同样,当我为麻省理工学院处理这个数据库时,我想思考学生、教职员工的抽象。我还将使用所谓的继承来建立这些的层次结构。我这样做的原因是我希望能够共享代码。我知道学生和教职员工之间会有某些相似之处,当然也有不同之处。但我想首先说,什么是不不同的?什么是相似的?什么是相同的,这样我只需要实现一次并可以重用。
所以,如果我退一步说,是否存在一个涵盖学生、教职员工共享属性的抽象?我可能会说它是“人”。他们都是人。暂且假设每位教职员工都是人类。
定义 Person 类
我将从这个抽象“人”开始。

import datetime
class Person(object):
def __init__(self, name):
self.name = name
try:
last_blank = name.rindex(' ')
self.last_name = name[last_blank+1:]
except:
self.last_name = name
self.birthday = None
def get_last_name(self):
return self.last_name
def set_birthday(self, birthdate):
self.birthday = birthdate
def get_age(self):
if self.birthday == None:
raise ValueError
return (datetime.date.today() - self.birthday).days
def __lt__(self, other):
if self.last_name == other.last_name:
return self.name < other.name
return self.last_name < other.last_name
def __str__(self):
return self.name
我导入了 datetime。就像我们之前导入 math 一样,这是别人编写的一个类,以相当合理的方式处理日期和时间。
__init__ 方法将用名字创建一个 Person。我在这里做的是引入一个额外的属性 last_name。这是因为我想让生活更轻松,我想我经常需要找到姓氏,所以一劳永逸地获取它并放入某个东西中。我将 birthday 初始化为 None。
下一个方法是 get_last_name。我在这里有这个方法,因为我不希望这个抽象的用户甚至知道我有一个属性 self.last_name。这是实现的一部分。所以我用这个方法来获取它。
当你构建类时,你会经常看到名为 get 的东西,这些通常是返回类实例某些信息的方法。你还经常会有 set 方法,例如 set_birthday,它给类的实例赋值。
更有趣的是,我有 get_age,它方便地使用了 datetime 的一些内置操作,允许我减去一个日期与另一个日期,得到天数。这将允许我返回某人的年龄(以天为单位)。
然后,我有另一个我们还没见过的 __lt__ 方法。LT 代表“小于”,这并不奇怪。我将用它来排序名字或排序人。为什么我使用特殊运算符而不是直接放入方法 less_than?因为我希望能够在我的代码中写像 p1 < p2 这样的东西。Python会将其转换为 __lt__。更好的是,如果我有一个人的列表,我可以使用内置的 sort 运算符对该列表进行排序,它会足够聪明地知道在比较两个人进行排序时使用 __lt__。这是非常方便的事情。
使用 Person 类
让我们看一些代码。我将设置 me 为 John Guttag,him 为 Barack Hussein Obama,her 为 Madonna,并打印一些东西。我可以设置一些生日。让我们看看这一行:him.birthday = '8/4/61'。我们稍后会讨论,但我想让你思考一下。我们看到他们的年龄,实际上,我们看到Madonna更老。她看起来真的很老吗?你看看那个天数,也许她是。
现在这里会发生什么?我搞砸了。我搞砸是因为我直接访问了实例变量并分配了我认为是出生日期的合理表示。但我不应该这样做,因为那甚至不是适当的类型。它是一个字符串,而不是来自 datetime 的东西。所以,我们再次看到了我违反抽象边界、直接访问实例变量的影响。
我们可以做一些比较,我们看到我不小于Madonna,我想这没问题。我可以制作这些对象的列表并打印列表。它相当好地调用了 __str__ 运算符。你会注意到,将 Person 类型的对象放入列表与放入整数、浮点数或任何内置类型一样没有问题。一切都运行良好。然后我可以排序并打印,现在列表以不同的顺序出现,因为它使用 __lt__ 运算符对元素进行排序。
所有这些只是为了向你展示使用数据抽象编写代码是多么方便。
继承:创建 MITPerson 类
如果我们回头看代码,我们会再次看到 Person 是 object 的子类。这就是为什么我们可以用它做所有我们一直在做的事情。
但现在我将开始使用层次结构。麻省理工学院的人是特殊的。我讨厌这么说,因为我知道房间里有非麻省理工学院的人,但麻省理工学院的人至少有一个特殊属性:他们都有ID。
所以我现在要说 MITPerson 是 Person 的一个特殊子类。因此它具有 Person 的所有属性。我们描述这一点的方式是它继承了超类的属性,并添加了一个属性。我们现在可以分配一个ID号。
现在我们将看到我之前承诺要展示的东西:一个类变量。next_id_num 不与 MITPerson 的实例相关联,而是与类本身相关联。它是一个类变量。我可以这样做是因为对象(或类)本身就是对象。这样做的好处是,每次我获得这个类的新实例时,我都可以分配一个唯一的ID,类似于我们在学期初使用全局变量的方式。通常,一旦我们有了类,我们就会停止使用全局变量,因为我们在许多情况下可以使用这些类变量来达到非常相同的目的。
所以现在每次我获得一个新的 MITPerson,我给他们一个ID,然后递增ID号,这样下一个人会得到一个不同的ID。
我添加了一个属性 id_num,我通过 get_id_num 方法获取它。我还重写了一个现有属性。你会注意到我更改了 __lt__ 的定义。所以现在它说我们将根据ID号而不是名字来比较两个人。
使用 MITPerson 类
让我们看一些使用这个的代码。我们将获得一些名为 p1、p2 和 p3 的 MITPerson,并通过调用 get_id_num 打印他们的ID号。你可以看到Barbara Beaver得到0,第一个Sue得到1,第二个Sue的ID是2。我甚至可以创建第三个这样的人。
现在,想想这里会发生什么。我将打印 p1 是否小于 p2,以及 p3 是否小于 p2。我们应该得到什么?有人说了 True、False。确实,这是正确的,因为是基于ID。
假设我想比较名字。我可以选择这样做:Person.__lt__(p1, p2)。这表示不要使用子类的 __lt__,而是使用超类的那个来进行比较。所以它会上溯并使用超类的那个。
我可以做其他事情。我可以比较相等性。让我快速浏览所有这些,看看我们得到了什么。哦,有个意外。好吧,在我们看到那个意外之前(实际上不是意外,我不应该取消注释它),让我们看看其他的。我们可以说 p1 == p4,我们发现不是。这很好。我们可以说 p4 < p3,一切正常,它不是。但是,我不能说 p3 < p4。为什么我不能这样做?当我这样做时为什么会收到错误信息?
因为 p3 < p4 会查看第一个参数 p3,并说好的,与 p3 关联的 __lt__ 是什么?是与 MITPerson 关联的那个。然后它尝试检索 p4 的ID号,而 p4 不是 MITPerson,于是得到了错误信息。这没什么微妙的,它和我们一直看到的类型错误是同一类事情。在这种情况下,它被称为属性错误,因为我们试图访问一个不存在的实例属性。我们可以捕获它,它可以引发异常,我们可以像周二看到的那样捕获它,但我们不会这样做,因为当这种情况发生时,它实际上是一个编程错误。
继续层次结构:学生
我们继续我们的层次结构。我对学生感兴趣。所以我现在要引入 MITPerson 的一个子类,称为 UG(本科生)。
class UG(MITPerson):
def __init__(self, name):
MITPerson.__init__(self, name)
self.year = None
def set_year(self, year):
if year > 5:
raise OverflowError('Too many')
self.year = year
def get_year(self):
return self.year
__init__ 将调用 MITPerson.__init__,这将给 UG 一个ID号和名字。它还可以引入另一个实例属性或字段,称为 year,并说 year 最初是 None。然后我可以设置年份。如果我尝试将其设置为大于5的值,我将引发一个溢出错误,称为“太多”。没有本科生应该是大于5的年级。我可以获取年份。
让我们看看当我们这样做时会发生什么。我将有两个 UG,都叫Jane Doe,还有之前的同一个 MITPerson。运行这段代码看看。当我打印 u1 时会发生什么?它首先要做的是查看是否有与 UG 关联的 __str__。答案是没有。这没关系,因为我知道 UG 也是 MITPerson。如果我在最低级别的类中找不到它,我会向上查找,说好的,是否有与 MITPerson 关联的 __str__?没有,没关系,我再向上找一层。然后我会说,我知道 MITPerson 碰巧是 Person,然后会说,哦,好的,有一个与 Person 关联的 __str__,所以我会使用那个。所以它查看类,如果找不到,就去超类,再找不到,再去超类,一直向上直到最后,最坏的情况是使用打印对象的内置方法。因为记住,一切都是 object 的子类。你不想那样做,你希望有比“object at location XE345”或它会打印的任何东西更优雅的东西。
然后它会做一些比较。它会首先寻找最局部的,然后根据需要向上查找。
研究生类
让我们继续。我们将引入另一种人,称为研究生 G。
class G(MITPerson):
pass
pass 是什么意思?这意味着 G 是一个没有特殊属性的 MITPerson,具有 MITPerson 的所有常见属性。它没有年份,因为研究生可以在这里待更久或更短。当我这么说的时候,为什么我首先要引入这个类型?因为它让我可以进行类型检查。我现在可以检查一个人是否是研究生,因为 G 的实例将具有 MITPerson 的所有属性,但类型不同,类型将是 G。所以我现在可以问这个问题:这个对象的类型是 G 还是 MITPerson,并得到不同的答案。
所以我可以这样做。如果我看 type(g1),那很有趣。它说 class __main__.G。好的,它是一个类,该类定义在最外层,称为 __main__,类的名称是 G,正如我们所期望的。所以我可以写像 type(g1) == G 这样的东西,我得到 True。所以这是一个方便的事情。
添加方法:is_student
事实上,我现在要回到 MITPerson 并给它添加另一个方法。当我编程时,这种情况经常发生:我去一个类,以为我完成了,然后过了一段时间决定添加一些新的东西会很方便,另一个方法。
在这种情况下,我现在添加方法 is_student,它返回 type(self) == UG or type(self) == G。这将让我区分麻省理工学院的学生和另一种麻省理工学院的人。
例如,如果我想有一个课程列表,另一个类,这是 object 的子类。你注意到我有一个 add_student 方法,它接受 self 和 who。它说,如果不是 who.is_student(),则引发类型错误“不是学生”。现在我从中获得了一些好处。我不一定需要这样做,我本可以说 if not (type(who) == G or type(who) == UG),但我选择不这样做。我选择不这样做的原因是展望未来,我可能想添加其他学生,例如,我可能想添加特殊学生,或者我可能想添加交叉注册学生作为单独的类型。现在的好处是,我不必做很多更改。我知道在我的代码中只有一个地方定义了成为麻省理工学院学生的含义。我回去更改那个方法,即使我在100个不同的地方询问某人是否是学生,我也只需要进行一次更改来修复我的代码。所以我通过将方法与 MITPerson 类关联起来,而不是每次需要使用它时都写,获得了一些模块性。

总结

在本节课中,我们一起学习了抽象数据类型和面向对象编程的核心思想。我们了解了如何通过定义类来创建新的数据类型,如何使用特殊方法如 __init__ 和 __str__ 来定制对象的行为,以及如何利用继承来构建层次化的类结构以实现代码重用。我们还强调了数据隐藏的重要性,即通过接口(公共方法)与对象交互,而不是直接访问其内部属性,以确保程序的模块性和可维护性。通过 Person、MITPerson、UG 和 G 的示例,我们看到了如何将这些概念应用于实际程序设计中。
016:面向对象编程 🧩

在本节课中,我们将回顾上一次的测验,并深入探讨面向对象编程的核心概念。面向对象编程是一种强大的编程范式,它允许我们通过创建对象来模拟现实世界,从而更有效地组织和管理代码。
测验回顾 📝
上一节我们介绍了递归和迭代等概念,本节中我们来看看测验中的一些关键问题,以巩固理解。
问题一:递归与迭代的必要性
该问题探讨了某些问题是否必须使用递归或迭代来解决。答案是True。因为当输入规模可变时,例如需要遍历所有可能解的情况(如暴力搜索),通常需要迭代或递归。
问题二:代码阅读练习
以下是代码阅读练习,通过逐步执行代码来理解其输出。
def example_function(s):
if len(s) < 2:
return s
else:
return example_function(s[1:]) + example_function(s[:-1])
通过手动追踪输入(如 "ATM"),我们可以得到输出结果。
问题三:双重递归
这个问题要求理解双重递归函数的行为。关键在于逐步追踪每次递归调用,并观察字符串如何被分割和重组。
问题四:实现函数 find_words
该函数需要从一个单词列表中找出所有与给定字母集合存在一一映射关系的单词。一种高效的解决方案是:
- 对给定的字母集合
elster进行排序。 - 遍历单词列表,对每个单词的字母也进行排序。
- 比较排序后的字符串,如果相等,则该单词符合条件。
def find_words(word_list, elster):
sorted_elster = ''.join(sorted(elster))
result = []
for word in word_list:
if ''.join(sorted(word)) == sorted_elster:
result.append(word)
return result
问题五:代码规范符合性检查

这个问题要求检查一段代码是否符合给定的规范。规范包括:
- 返回两个向量的逐点求和列表。
- 处理长度不同的向量(求和至较短向量的长度,然后附加较长向量的剩余部分)。
- 两个空向量返回空列表。
- 不修改输入参数。
代码违反了第四条规范,因为它通过别名修改了输入列表。
问题六:字典与函数操作
这个问题涉及两个函数:add_up(对字典值求和)和 f(统计字符串中字符频率)。通过理解每个函数的功能,并逐步执行代码,可以得出正确结果。
问题七:二进制表示与复杂度分析
函数 f 打印整数的二进制表示。其内部循环运行次数与输入 n 的二进制位数(即 log₂(n))成正比。由于函数中还有一个 O(n) 的操作,整体复杂度由主导项决定,为 O(n)。
问题八:算法概念匹配
这个问题将算法概念(如大O符号、牛顿法)与其描述相匹配。大O符号描述的是最坏情况下的时间复杂度上界。
面向对象编程 🏗️
上一节我们回顾了测验,本节中我们来看看面向对象编程的核心思想。面向对象编程允许我们将数据(属性)和操作数据的方法封装在一起,形成“对象”。
什么是类与对象?
类 是创建对象的蓝图或模板。它定义了一类对象共有的属性和方法。
对象 是类的实例,拥有类中定义的属性和具体值。
例如,int, float, dict 都是Python内置的类。当我们使用它们时,就是在创建对象。
# ‘str’ 是一个类,‘s’ 是它的一个对象
s = "ABC"
# 调用对象 ‘s’ 的方法 ‘lower’
print(s.lower()) # 输出: abc
属性与方法
- 属性 是对象的状态或特征(如人的姓名、年龄)。
- 方法 是对象可以执行的操作(如人说话、行走)。
一个不使用类的例子
在引入类之前,我们可以用字典和函数来模拟一个人的信息:
def make_person(name, age, height, weight):
return {'name': name, 'age': age, 'height': height, 'weight': weight}
def get_name(person):
return person['name']
def set_age(person, new_age):
person['age'] = new_age
这种方式可行,但缺乏结构。person 本质上只是一个字典,Python无法区分它和其他字典。
使用类重构
使用类可以更优雅地实现同样的功能:
class Person:
def __init__(self, name, age, height, weight):
self.name = name
self.age = age
self.height = height
self.weight = weight
def get_name(self):
return self.name
def set_age(self, new_age):
self.age = new_age
# 创建Person类的对象(实例)
mitch = Person("Mitch", 32, 70, 200)
serena = Person("Serena", 25, 65, 130)
print(type(mitch)) # 输出: <class '__main__.Person'>
现在,mitch 和 serena 被明确标记为 Person 类型,而不仅仅是字典。
特殊方法与运算符重载
Python类中可以定义一些以双下划线开头和结尾的特殊方法,例如 __init__(构造函数)和 __eq__(定义相等性比较)。
class Person:
# ... __init__ 和其他方法同上 ...
def __eq__(self, other):
"""定义两个Person对象‘相等’的条件,例如姓名相同"""
return self.name == other.name
# 现在可以使用 ‘==‘ 运算符比较Person对象
print(mitch == serena) # 输出: False
当执行 mitch == serena 时,Python内部会将其转换为 mitch.__eq__(serena) 来调用。
self 参数
类中方法的第一个参数通常是 self,它代表对象实例本身。当通过 object.method() 调用方法时,Python会自动将 object 作为 self 参数传入。
# 这两种调用方式是等价的
mitch.set_age(25)
Person.set_age(mitch, 25) # 显式传递self,不常见但可行
继承与多态 🎭
继承 允许一个类(子类)继承另一个类(父类)的属性和方法。
多态 允许我们使用统一的接口处理不同子类的对象。
以下是一个几何形状的例子:
class Shape:
"""形状基类"""
def area(self):
raise NotImplementedError("子类必须实现此方法")
def __eq__(self, other):
return self.area() == other.area()
def __lt__(self, other):
return self.area() < other.area()
class Rectangle(Shape):
"""矩形类,继承自Shape"""
def __init__(self, length, width):
self.length = length
self.width = width
def area(self):
return self.length * self.width
def perimeter(self):
return 2 * (self.length + self.width)
class Circle(Shape):
"""圆形类,继承自Shape"""
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14159 * (self.radius ** 2)
def perimeter(self):
return 2 * 3.14159 * self.radius
class Square(Rectangle):
"""正方形类,继承自Rectangle(一种特殊的矩形)"""
def __init__(self, side):
# 调用父类的初始化方法
super().__init__(side, side)
# 使用多态:将不同子类对象放入同一列表,统一处理
shapes = [Circle(5), Square(4), Rectangle(3, 6)]
for shape in shapes:
print(f"Area: {shape.area()}") # 尽管形状不同,但接口相同
# 因为定义了 __lt__,所以可以按面积排序
shapes.sort()
print([type(s).__name__ for s in shapes])
在这个例子中:
Rectangle和Circle都继承自Shape,因此它们都是Shape类型。- 它们都实现了
area方法,但具体计算方式不同。 - 我们可以创建一个
Shape类型的列表,里面放入不同的图形对象,然后统一调用它们的area方法。这就是多态的魅力。 Square继承自Rectangle,表明“正方形是一种矩形”的关系,并复用了矩形的代码。
总结 🎯
本节课中我们一起学习了以下内容:
- 回顾了测验,涵盖了递归、代码阅读、算法复杂度分析和大O符号等关键点。
- 引入了面向对象编程,理解了类作为蓝图和对象作为实例的基本概念。
- 探讨了类的属性(数据)和方法(操作)。
- 学习了如何使用
__init__等特殊方法,以及神秘的self参数的含义。 - 掌握了继承的强大功能,它允许子类复用和扩展父类的功能。
- 体验了多态的便利,它让我们能够以统一的方式处理不同类型的对象。


面向对象编程提供了一种组织复杂程序的强大方式,通过模拟现实世界中的实体和关系,使代码更模块化、更易理解和维护。
017:模拟与随机漫步


在本节课中,我们将学习如何构建计算模型来模拟现实世界中的问题。我们将从回顾上一讲关于类的知识开始,然后引入一个新的语言特性——生成器。最后,我们将转向一个全新的主题:使用模拟方法,特别是随机漫步,来理解和预测复杂系统的行为。
回顾:类与继承
上一节我们介绍了如何使用类来构建对象。我们从一个基础的 Person 类开始,然后创建了它的一个特化版本 MITPerson。MITPerson 类有一个内部变量 idNum,用于为每个实例分配一个唯一的标识符。此外,我们还创建了 Undergrad 和 Grad 类作为 MITPerson 的进一步特化。
为了管理课程中的学生,我们创建了一个 CourseList 类。这个类包含一个学生列表,并提供了添加和移除学生的方法。在添加学生时,我们使用了防御性编程,确保只添加学生,并且不会重复添加同一个学生。
引入生成器
本节中,我们来看看一种更优雅地访问集合元素的方法。在 CourseList 类中,我们添加了一个名为 allStudents 的方法,但更重要的是,我们添加了一个名为 undergrads 的方法,它使用了一个新的关键字:yield。
yield 用于创建生成器。生成器是一种特殊的函数,它能记住函数体中的执行点以及所有局部变量的状态。与 return 不同,return 会返回值并结束函数,而 yield 会返回一个值,但会“暂停”函数的执行。当再次调用生成器时,它会从上次暂停的地方继续执行。
以下是 undergrads 方法的核心代码片段:
def undergrads(self):
index = 0
while index < len(self.students):
if type(self.students[index]) == Undergrad:
yield self.students[index]
index += 1
这个生成器会遍历学生列表,每次遇到一个 Undergrad 实例时,就“产出”它。这使得我们可以像遍历列表一样使用它,但不需要在内存中一次性构建整个列表。
我们可以这样使用生成器:
for s in course600.undergrads():
print(s)
生成器非常有用,因为它允许我们控制对集合的访问方式,并且可以高效地处理大型或无限序列。
转向新主题:模拟方法

现在,我们转向一个完全不同的主题,这将是接下来几节课的重点。这个主题是关于如何构建计算模型来解决实际问题。
在科学史的很大一部分时间里,人们专注于寻找解析方法。解析模型允许你基于初始条件和一组参数,精确地预测系统的行为。例如,牛顿物理学和弹簧常数模型就是解析模型。它们非常强大,但并非总是有效。
随着20世纪的发展,我们发现在某些情况下,解析方法并不适用,而模拟方法则更为有效。

为什么需要模拟方法?
以下是几个选择模拟方法的原因:
- 系统难以用数学精确建模:有些系统过于复杂,无法建立精确的物理模型来预测其行为。例如,天气预报。
- 通过迭代细化获得洞察:对于复杂系统,与其花费精力构建一个非常详细的解析模型,不如运行一系列模拟,逐步细化,以获得对系统行为的理解。
- 易于获取有用的中间结果:从模拟中提取有用的中间结果,通常比构建详细的解析模型更容易。
- 计算机使之成为可能:现代计算机的强大计算能力使得大规模模拟变得可行且经济。
模拟在金融、生物、物理和游戏等领域有数百万的应用实例,它是一种极其常见和有用的工具。
模拟的本质
模拟的核心思想是构建一个模型,该模型能够提供关于系统行为的有用信息。与精确预测的解析模型不同,模拟模型给出的是对现实的近似。它们是描述性的,而非规定性的。对于相同的初始场景,多次运行模拟可能会得到略有不同的结果,但这正是模拟的优势所在——它允许我们探索系统行为的统计特性。

示例:随机漫步

一个经典的模拟例子是随机漫步。我们将用它来构建我们的第一个模拟。
随机漫步的一个著名历史例子是布朗运动。1827年,苏格兰植物学家罗伯特·布朗观察到悬浮在水中的花粉颗粒似乎在做无规则运动。1905年,阿尔伯特·爱因斯坦首次为布朗运动建立了良好的数学模型,这证实了原子的存在。
随机漫步的基本思想是:一个对象(如花粉颗粒)在每个时间步长中,按照某种随机分布向某个方向移动。我们通过模拟大量这样的步骤,来观察系统的整体行为。
模拟场景:醉汉游走
为了具体说明,我们考虑一个经典的随机漫步问题:醉汉游走。
假设一个醉汉(我们姑且称他为哈佛学生)站在一个大田野的中央。每秒钟,他可以以相等的概率向四个基本方向(北、南、东、西)之一走一步。问题是:在1000步之后,他离起点有多远?
在构建模拟之前,我们可以先做一些简单的估算。经过一两步的手动计算,我们发现平均距离似乎随着步数的增加而增加。但这只是一个粗略的直觉,我们需要通过模拟来验证。
构建模拟的类
为了模拟这个场景,我们需要设计几个类来对应我们期望看到的事物:
- Location(位置):表示醉汉在田野中的坐标。
- Field(田野):一个可以容纳多个醉汉并跟踪其位置的环境。
- Drunk(醉汉):定义醉汉如何移动。
以下是核心类的设计思路:
Location 类:
- 存储 x 和 y 坐标(浮点数,以便未来扩展)。
- 提供
move方法,根据给定的dx和dy返回一个新的Location实例。 - 提供
distFrom方法,计算与另一个位置的距离(使用勾股定理)。
Field 类:
- 使用字典将
Drunk实例映射到其Location。 - 提供
addDrunk方法,将醉汉添加到田野的某个位置。 - 提供
moveDrunk方法,让指定的醉汉移动一步。它通过调用醉汉的takeStep方法来获取移动方向,然后更新该醉汉的位置。
Drunk 类:
- 有一个名字。
- 核心方法是
takeStep,它从一组固定的移动选择(如[(0,1), (0,-1), (1,0), (-1,0)])中随机选择一个,返回dx和dy。这定义了醉汉的移动模式。
运行模拟与发现错误
有了这些类,我们可以编写函数让醉汉行走一定步数,并计算其最终位置与起点的距离。然后,我们可以运行多次试验(模拟),计算平均距离。
然而,当我们首次运行模拟时,结果看起来不对劲:无论步数是10、100还是1000,平均距离都徘徊在2左右,并没有像我们直觉那样增长。
这是一个重要的教训:在构建模拟后,必须用已知答案的简单案例进行测试。例如,步数为0时,距离应该总是0;步数为1时,距离应该总是1。通过这种测试,我们发现了代码中的错误(视频中因时间关系未展示具体调试过程,但强调了这一步骤的重要性)。
总结
本节课中我们一起学习了两个主要部分。首先,我们回顾了类的使用,并引入了生成器这一强大的新工具,它通过 yield 关键字实现,能够记住函数状态,非常适合用于按需生成序列。
其次,我们开启了关于计算模拟的新篇章。我们比较了解析模型与模拟模型,理解了模拟模型在处理复杂、难以精确数学建模的系统时的优势。我们以随机漫步为例,介绍了如何通过设计 Location、Field 和 Drunk 等类来构建一个模拟,并强调了用简单案例验证模拟正确性的重要性。

在接下来的课程中,我们将修正模拟中的错误,并更深入地探索随机漫步及其广泛应用。
018:大O符号与面向对象编程概念详解

在本节课中,我们将深入探讨两个核心概念:大O符号(Big O Notation)和面向对象编程(Object-Oriented Programming)中的一些关键实践。我们将学习如何分析代码的时间复杂度,并理解在面向对象设计中封装和访问器方法的重要性。
大O符号:概念与重要性
大O符号为我们提供了一个算法运行时间上界的描述。它并非直接预测程序运行的具体时长,而是描述算法所需步骤数量如何随输入规模增长而变化。这一点至关重要,因为不同计算机的运行速度各异,但大O符号描述的步骤增长趋势是普适的。
我们特别关注函数的可扩展性。大O符号可能无法预测在极小输入规模下哪个算法最快,但它能清晰地告诉我们,当处理大规模数据(如基因组分析或天文望远镜数据)时,不同算法性能的差异。
常见的时间复杂度类别
以下是几种常见的时间复杂度类别及其含义:
- 常数时间 O(1):算法的运行时间不随输入规模
n变化。即使算法执行2^100步,只要步数是固定的,就属于 O(1)。公式表示为:T(n) = c。 - 对数时间 O(log n):算法的运行时间随输入规模呈对数增长。二分查找(Bisection Search)是典型的对数时间算法。公式表示为:
T(n) = c * log(n)。 - 线性时间 O(n):算法的运行时间与输入规模成正比。遍历一个列表或字符串通常属于此类。公式表示为:
T(n) = c * n。 - 线性对数时间 O(n log n):这是目前已知最快的比较排序算法的时间复杂度上界。
- 多项式时间 O(n^k):例如 O(n²)(平方时间)、O(n³)(立方时间)。当
k较大时,算法在处理大规模数据时会变得非常慢。 - 指数时间 O(k^n):例如 O(2^n)。这类算法的复杂度增长极为迅速,通常只适用于解决极小规模的问题。
理解大O符号的渐进特性
大O符号关注的是函数在输入规模 n 趋向无穷大时的渐进行为(Limiting Behavior)。这意味着常数系数和低阶项可以被忽略。
例如:
O(100n²)等于O(n²)。O(0.25n³)等于O(n³)。O(n + n)等于O(n)。O(100n² + 0.25n³)等于O(n³)。
这是因为当 n 非常大时,n³ 项的增长将完全主导 n² 项,无论 n² 前的系数有多大。因此,在分析复杂度时,我们只保留最高阶的项。
代码复杂度分析实例
上一节我们理解了大O符号的理论,本节中我们来看看如何将其应用于具体的代码分析。分析时,我们通常假设基本的数学运算和赋值是常数时间操作。
以下是几个代码片段的复杂度分析:
-
常数时间操作:一系列独立的常数时间语句,整体复杂度为 O(1)。
def bar(x, y): x = x + 1 # O(1) w = x + y # O(1) z = x * y # O(1) return z - w # O(1) # 总复杂度: O(1) -
单层循环:循环体执行次数依赖于输入
y,因此复杂度为 O(y)。若定义n = len(y),则复杂度为 O(n)。def multiply(x, y): result = 0 for i in range(y): # 循环 y 次 result += x # O(1) return result # 总复杂度: O(y) -
嵌套循环:外层循环执行
n次,内层循环(或一个复杂度为 O(m) 的操作)每次执行m次,总复杂度为 O(n * m)。def is_subset(a_str, b_str): for char in a_str: # 循环 n 次,n = len(a_str) if char not in b_str: # 最坏情况下需检查整个 b_str,O(m), m = len(b_str) return False return True # 总复杂度: O(n * m) -
递归函数:分析递归函数的关键是确定递归调用的次数。
- 线性递归:如阶乘函数,递归调用
n次,复杂度为 O(n)。 - 对数递归:如每次将问题规模减半(
n/2),复杂度为 O(log n)。 - 指数递归:如斐波那契数列的朴素递归实现,会产生一棵递归树,复杂度约为 O(2^n)。
- 线性递归:如阶乘函数,递归调用
重要提示:在分析时,明确定义 n 所指代的内容(如列表长度、字符串长度等)是非常好的习惯。并非所有操作都是常数时间,例如列表切片 (list[i:j]) 或字符串复制的复杂度取决于切片的大小。
面向对象编程:封装与访问器方法
现在,让我们将注意力转向面向对象编程。一个常见的问题是:既然可以直接访问对象的属性,为什么还需要定义 getter(访问器)方法?
考虑一个 Person 类:
class Person:
def __init__(self, name):
self.name = name
def get_name(self):
return self.name
我们可以直接 sally.name 来获取名字,那么 get_name() 方法似乎多余。然而,这里涉及防御性编程(Defensive Programming)的原则。
直接访问属性可能导致意外的修改:
sally = Person(“Sally”)
alias_to_name = sally.name # alias_to_name 现在是 sally.name 的一个别名(引用)
# ... 如果后续代码修改了 alias_to_name,实际上也修改了 sally.name!
而通过 getter 方法返回时,通常返回的是属性的一个副本(对于可变对象如列表尤为重要),或者至少提供了控制访问的入口点。这确保了对象内部状态不会被外部代码意外篡改,增强了代码的健壮性和安全性。修改属性的正确方式是通过类提供的特定方法(如 setter)。
程序员固然“懒惰”,但在“懒惰”和“安全”之间,应优先选择编写安全的、防御性的代码。
总结


本节课中我们一起学习了两个重要主题。首先,我们深入探讨了大O符号,理解了它如何描述算法复杂度随输入规模增长的渐进上界,并学习了分析简单循环、嵌套循环及递归函数复杂度的方法。其次,我们讨论了面向对象编程中封装的重要性,明白了使用访问器方法而非直接操作属性是一种关键的防御性编程实践,它能保护对象内部状态的一致性。掌握这些概念对于编写高效、健壮的软件至关重要。
019:随机过程与数据可视化 📊


在本节课中,我们将学习随机过程的基本概念,并探讨如何使用Python的Pylab库进行数据可视化。我们将从修复一个模拟程序开始,逐步理解随机性在计算中的作用,并学习如何创建清晰、有意义的图表。



修复模拟程序


上一节我们介绍了醉汉行走模拟,但发现结果不可信。本节中,我们来看看问题所在并进行修复。



模拟程序 SimWalk 中存在一个错误:我们错误地使用了 numTrials 参数,而不是 numSteps。这导致模拟没有意义。现在我们已经修复了它。
为了验证修复,我们首先在一些已知答案的小规模例子上运行程序。


# 示例:运行0步和1步的模拟
drunkTest(0, 100) # 预期结果:均值、最大值、最小值均为0
drunkTest(1, 100) # 预期结果:符合理论推导
运行结果显示,对于0步和1步的情况,模拟结果与预期完全一致。这并不能证明程序完美,但至少比之前的结果更可靠。
接下来,我们在更大规模的例子上运行模拟,例如10步、100步甚至100,000步。结果显示出我们期望的离散性:随着步数增加,平均距离增大,最大值和最小值之间的差距也变大。
可视化结果
观察数字本身可能不够直观,因此我们通过绘图来可视化数据。我们绘制了平均距离与步数的关系图。
从图中可以看出,距离大致随着步数的平方根增长。然而,我们不应过度解读这张图,因为每次模拟运行的结果都存在差异。例如,对于10,000步,不同次运行得到的平均距离可能分别是78、90、279或248。这种差异引出了本课程下一单元的核心问题:当程序本身是随机(随机)的时候,我们应如何理解和解释其结果?
计算中的随机性

随机性在计算中扮演着重要角色,这可能是你在其他初级课程中较少接触的概念。牛顿力学曾给我们带来确定性世界的安慰,但20世纪的量子物理(哥本哈根诠释)表明,在最基本的层面,物理世界的行为无法被完全预测,我们只能做出概率性陈述。

无论背后的哲学真相如何,在实际应用中,由于我们无法进行无限精确的测量,我们必须将世界视为非确定性的来处理。这引出了随机过程的概念。
一个过程是随机的,如果它的下一个状态既取决于之前的状态,也取决于某个随机因素。
大多数编程语言,包括Python,都提供了使用随机性的简单方法。在醉汉行走模拟中,我们使用了 random.choice() 函数。Python中几乎所有涉及随机性的函数,其底层都基于 random.random() 函数实现,该函数生成一个大于等于0.0且小于1.0的随机浮点数。
概率基础
让我们通过掷骰子的例子来理解概率。掷一个公平的六面骰子,每次结果是1到6之间的一个整数,且每次投掷是独立的。
以下是理解概率的关键点:
- 概率是分数:当我们谈论某个事件发生的概率时,我们实际上是在问,在所有可能的结果中,具有我们所测试属性的结果占多大比例。
- 范围:概率值总是在0到1之间(包含0和1)。0表示不可能发生,1表示必然发生。
- 互补概率:计算一个事件发生的概率,有时可以通过计算其不发生的概率,然后用1减去它来得到。即:
P(A) = 1 - P(非A)。
例如,投掷一枚均匀硬币10次,得到全部正面的概率是 (1/2)^10。得到任何其他特定序列(如 正反正正反...)的概率同样是 (1/2)^10。对于六面骰子,投掷10次得到全部1点的概率是 (1/6)^10。
数据可视化入门


为了更清晰地讨论随机过程和概率,我们需要借助数据可视化。在许多编程语言中,绘图是件麻烦事,但Python通过 Pylab 库使其变得简单。Pylab提供了类似MATLAB的功能。




要使用Pylab,需要先安装它(非标准Python发行版的一部分),并在代码中导入:
import pylab


以下是一个简单的绘图示例,展示了如何绘制两个向量:
pylab.plot([1, 2, 3, 4], [1, 4, 2, 3]) # 绘制点 (1,1), (2,4), (3,2), (4,3)
pylab.show() # 显示图形
注意:pylab.show() 通常应是程序的最后一步,且每个程序最好只调用一次。


默认情况下,plot 函数会连接各点形成线条,这可能具有误导性,让人误以为数据点是连续的。我们稍后会学习如何绘制离散点。

创建清晰的图表


一个常见的错误是生成没有标签和标题的图表,这使图表难以理解。所有图表都应具备:
- 信息丰富的标题:说明图表内容。
- 带标签的坐标轴:明确X轴和Y轴代表什么。


例如,在绘制复利增长图时,我们应该这样做:
pylab.title(‘5% Growth, Compounded Annually’)
pylab.xlabel(‘Years of Compounding’)
pylab.ylabel(‘Value of Principal ($)’)
pylab.plot(years, values)
pylab.show()
这样生成的图表能立刻让读者理解其含义。

总结

本节课中我们一起学习了:
- 调试与验证:通过修复醉汉行走模拟程序,我们学习了如何用已知的小规模测试用例验证程序。
- 随机过程:理解了随机过程的概念,即下一个状态依赖于历史状态和随机因素。
- 概率基础:回顾了概率的基本原理,包括概率作为分数的本质、其取值范围(0到1),以及利用互补事件计算概率的技巧。
- 数据可视化:介绍了使用Python的Pylab库进行绘图的基本方法,并强调了为图表添加清晰标题和轴标签的重要性,这是进行有效科学沟通的关键。


在接下来的课程中,我们将更深入地探讨概率、随机算法以及如何创建更具表现力和信息量的可视化图表。
020:绘图与概率基础 📊🎲


在本节课中,我们将首先学习如何绘制更规范的图表,然后深入探讨概率论的基础知识,这是理解随机性和模拟计算的关键。


绘图规范与示例 📈


上一节我们介绍了基本的绘图功能,本节中我们来看看如何让图表更具可读性和专业性。

一个常见的错误是绘制没有标题和坐标轴标签的图表。这样的图表信息不完整,无法有效传达数据含义。
以下是一个计算复利并绘图的简单程序示例:






# 计算复利增长
principal = 1000
rate = 0.05
years = 20
values = []
for year in range(years + 1):
values.append(principal)
principal += principal * rate




为了使图表清晰,必须添加标题和坐标轴标签:




import pylab
pylab.title('复利增长示例')
pylab.xlabel('投资年限(年)')
pylab.ylabel('本金(美元)')
pylab.plot(values)
pylab.show()






规范的图表应始终包含标题和带说明的坐标轴。在绘图工具中,通常有图标可以缩放或保存图表。其中一个保存图标是软盘的形状,这是一种早期的数据存储设备。





概率论基础入门 🎯






现在,让我们转向本节课的核心主题:随机性。要理解随机性,我们必须先理解概率。

假设我们投掷一个公平的六面骰子10次。没有一次掷出点数为1的概率是多少?
一个错误的计算方法是简单地将每次不掷出1的概率相加。每次不掷出1的概率是 5/6。如果相加10次,结果会超过1,而概率值永远不会大于1。
正确的思考方式是考虑所有可能的投掷结果序列。投掷10次,每次有6种可能,总共有 6^10 种不同的序列。我们关心的是其中不包含数字1的序列数量。
由于每次投掷是独立事件,第一次不掷出1的概率是 5/6,前两次都不掷出1的概率是 (5/6) * (5/6)。因此,连续10次不掷出1的概率是:

P(无1) = (5/6)^10


现在,考虑一个逆问题:投掷10次,至少有一次掷出点数为1的概率是多少?

由于所有可能事件的概率之和必须为1,而“无1”和“至少有一个1”这两个事件覆盖了所有可能性。因此:
P(至少有一个1) = 1 - P(无1) = 1 - (5/6)^10


这是一个计算概率的常用技巧:有时计算事件不发生的概率,然后用1减去它,比直接计算事件发生的概率更简单。

历史背景与模拟 🕰️💻
概率论的历史与赌博密切相关。早期许多概率论先驱,如卡尔达诺、帕斯卡、费马、伯努利、棣莫弗和拉普拉斯,都受到理解赌博游戏(尤其是骰子游戏)的欲望驱使。


一个著名的历史问题是“德梅雷问题”:投掷一对公平骰子24次,至少出现一次双六的概率是否有利可图?帕斯卡和费马曾通过书信讨论此问题。


对于一次投掷,出现双六的概率是 1/36,不出现的概率是 35/36。连续24次不出现双六的概率是 (35/36)^24,约等于0.51。因此,赌“不会出现双六”有微弱的优势。
我们可以通过编写程序来模拟这个实验,验证帕斯卡的计算:
import random

def simulate_dice_game(num_trials):
wins = 0
for _ in range(num_trials):
for _ in range(24):
d1 = random.randint(1, 6)
d2 = random.randint(1, 6)
if d1 == 6 and d2 == 6:
wins += 1
break
probability = wins / num_trials
return probability




运行10万次模拟得到的结果与理论值 (35/36)^24 非常接近。这种通过随机抽样来估计问题答案的方法被称为蒙特卡洛模拟。


蒙特卡洛模拟与统计推断 🔄
蒙特卡洛模拟以摩纳哥的赌城命名,由斯坦尼斯拉夫·乌拉姆和尼古拉斯·梅特罗波利斯在1949年提出。它是一种推断统计学的方法。

推断统计学基于一个核心原则:一个随机样本往往表现出与它所来自的总体相同的属性。例如,如果我们随机询问马萨诸塞州的1000名选民他们的投票意向,其结果的平均值应该与询问全体选民的结果相近。
在使用统计方法时,我们必须始终质疑样本是否真的具有代表性,是否存在抽样偏差。
抛硬币与大数定律 ⚖️




让我们用更简单的抛硬币实验来理解这些概念。抛一枚公平硬币,得到正面的概率是0.5。


如果连续抛掷100次都是正面,你可能会认为硬币有问题(比如两面都是正面),因为公平硬币出现这种情况的概率极低:(1/2)^100,这是一个极小的数字。
如果抛掷100次,得到52次正面和48次反面,你可能不会对下一次结果做出任何强烈的推断。
随着试验次数的增加,结果会变得更加稳定。这体现了大数定律(也称为伯努利定律)。
大数定律指出:在重复的、独立的试验中(每次试验结果发生的实际概率为 P),该结果发生的频率随着试验次数趋向于无穷大而收敛于 P。
重要的是,这一定律并不意味着如果开始出现了偏离预期行为的情况,未来的偏离会“抵消”之前的偏离。因为每次试验是独立的、无记忆的。认为“红色 overdue”的赌徒谬误就是错误地相信了这一点。
此外,大数定律是关于结果发生的比例趋近于P,而不是说正面与反面出现的绝对次数之差会随着试验增加而变小。
数据可视化与解读 📉
我们可以通过程序模拟抛硬币,并绘制正面与反面之差的绝对值以及正面与反面的比例。
在绘制图表时需要注意:
- 避免误导性连线:当数据点较少时,用线连接点可能会制造出一种不存在的趋势假象。最好只绘制数据点本身。
- 使用对数坐标轴:当数据范围很大时,使用对数坐标轴(
pylab.semilogx,pylab.semilogy)可以更清晰地展示数据在不同数量级上的变化。
通过观察对数坐标下的图表,我们可以更清楚地看到随着试验次数增加,正面与反面的比例如何趋近于1,以及绝对差值的变化趋势。
总结与下节预告 📚

本节课中我们一起学习了:
- 绘制规范图表的重要性,包括添加标题和坐标轴标签。
- 概率论的基本概念,如独立事件、计算“至少发生一次”事件的概率技巧。
- 概率论的历史背景及著名的“德梅雷问题”。
- 蒙特卡洛模拟的原理和应用,即通过随机抽样来估计复杂问题的答案。
- 推断统计学的核心原则与大数定律。
- 数据可视化中的注意事项,如避免误导性连线、合理使用对数坐标轴。

然而,通过抽样我们永远无法获得绝对的确定性,因为我们无法确保自己没有异常地幸运或不幸。下节课(周四)我们将探讨:可以使用哪些技术来做出如下陈述:“我有信心,模拟给出的答案在某个特定范围内很可能是正确的。” 我们将学习如何量化这种置信度。
021:置信度与正态分布


在本节课中,我们将要学习如何评估通过有限次随机试验(如抛硬币)所得结果的可靠性。我们将探讨方差、标准差等核心概念,并了解如何使用它们来建立置信区间。最后,我们将介绍正态分布及其在现实世界中的广泛应用。
方差与标准差
上一节我们讨论了通过有限次抛硬币来估计概率。本节中我们来看看如何评估这种估计的可信度。关键在于理解结果的方差。
方差是衡量一组可能结果离散程度的指标。为了计算方差,我们需要进行多次试验,每次试验得到一个不同的结果,然后观察这些结果之间的差异。
我们可以通过标准差来形式化地衡量这种离散程度。标准差衡量的是数值与均值之间的平均距离。
以下是标准差的公式:
标准差公式:
σ = sqrt( (1/|X|) * Σ (x - μ)² )
其中,X 是试验结果集合,μ 是均值,|X| 是试验次数。
对于更倾向于代码的读者,以下是计算标准差的Python实现:
标准差代码:
def stdDev(X):
mean = sum(X) / float(len(X))
total = 0.0
for x in X:
total += (x - mean)**2
return (total/len(X))**0.5
这段代码与上述公式执行的是相同的计算。
样本量与可信度

现在我们知道了标准差的含义,接下来我们将用它来研究样本数量与我们对该样本所得答案的置信度之间的关系。
我们通过一个抛硬币模拟程序来观察。该程序运行多组试验,每组试验包含不同次数的抛掷,并绘制出头像比例及其标准差的变化图。
运行模拟后,我们观察到两个关键现象:
- 随着抛掷次数增加,头像比例趋于稳定。
- 随着抛掷次数增加,头像比例的标准差逐渐减小。


标准差减小意味着不同试验结果之间的差异变小。这不仅使我们更接近正确答案,更重要的是,它为我们相信这个答案提供了证据。仅仅因为运气好而得到正确答案是不够的,必须有证据支持。
变异系数
在评估标准差时,我们不能只看其绝对值的大小,而必须考虑它相对于均值的大小。如果均值是100万,标准差是20,那么相对差异很小。如果均值是10,标准差是20,那么差异就非常巨大。
为此,我们引入变异系数的概念。
变异系数公式:
CV = σ / μ
其中,σ 是标准差,μ 是均值。
变异系数衡量的是相对离散度。通常,如果变异系数小于1,我们认为方差较低。但需要注意两点:
- 当均值接近0时,变异系数可能失去意义。
- 与标准差不同,变异系数不能用于构建置信区间。

数据可视化与直方图
为了更直观地比较不同样本量的结果分布,我们可以使用直方图。以下是使用Python的matplotlib库绘制直方图的简单示例:
直方图示例代码:
import pylab
L = [1, 2, 3, 3, 3, 4]
pylab.hist(L, bins=6)
pylab.show()
在比较多个图表时,确保它们使用相同的坐标轴范围非常重要,否则可能产生误导。我们可以使用 pylab.xlim() 和 pylab.ylim() 函数来手动设置坐标轴范围,以确保图表之间的公平比较。

当我们对大量抛硬币试验的结果绘制直方图时,其分布形状会接近正态分布。

正态分布与置信区间
正态分布,也常被称为钟形曲线,在自然界和实验中非常常见。它有两个重要特性:
- 数学性质良好:完全由均值(
μ)和标准差(σ)两个参数描述。 - 普遍存在:许多随机变量(如人的身高、测量误差)都近似服从正态分布。
正态分布的一个关键应用是构建置信区间。置信区间不是给出一个单一的估计值,而是给出一个范围,并声明我们有多大的信心(置信水平)认为真实值落在这个范围内。
例如,政治民调中“52% ± 4%”的表述,通常意味着在95%的置信水平下,真实得票率在48%到56%之间。这个计算隐含了数据服从正态分布的假设。
对于正态分布,有一个实用的经验法则:
- 约68%的数据落在均值 ± 1个标准差的范围内。
- 约95%的数据落在均值 ± 2个标准差的范围内。
- 约99.7%的数据落在均值 ± 3个标准差的范围内。
标准误差与民意调查

你可能会问,民调机构如何知道标准差?他们并不重复进行上百次调查,而是使用一个叫做标准误差的估计值。
标准误差公式(针对比例):
SE = sqrt( p * (100 - p) / n )
其中,p 是样本中的百分比,n 是样本大小。

这个公式在误差服从正态分布且样本量远小于总体时,可以很好地近似真实的标准差。通过一个模拟投票的Python程序,我们可以验证计算出的标准误差与实际测量得到的标准差非常接近,这解释了为什么民调在数学上是可靠的。
正态分布的应用

正态分布之所以有用,不仅因为其数学上的简洁性,更因为它准确地描述了许多现实情况。
- 自然现象:如人群的身高、体重分布。
- 测量误差:这是其最重要的应用之一。早在19世纪初,高斯在分析天文数据时,就假设测量误差服从正态分布(因此物理学家常称之为高斯分布)。如今,大多数科学领域在评估数据有效性时,都默认测量误差服从正态分布。

本节课中我们一起学习了如何通过方差和标准差来评估随机试验结果的可靠性。我们介绍了变异系数用于比较不同数据集的相对离散度,并深入探讨了正态分布的特性及其在构建置信区间中的核心作用。最后,我们了解了标准误差的概念以及正态分布在从民意调查到科学实验等众多领域的广泛应用。理解这些概念,有助于我们更批判性地看待数据与统计结论。
022:概率与统计学入门 📊

在本节课中,我们将要学习概率论与统计学的基础概念。我们会从理解概率的基本定义开始,逐步探讨如何计算简单和复杂事件的概率,并介绍统计学中用于描述数据分布的关键指标,如均值、方差和标准差。最后,我们将了解如何通过模拟(蒙特卡洛方法)来估计概率,并学习如何可视化数据。
概率基础
概率是表达某个特定事件发生可能性的一种方式。对于一个只能取两种结果的变量,我们称之为二元变量。
二元变量与概率
一个二元变量只能取两个值,例如:
- 正面(H)或反面(T)
- 开(1)或关(0)
- 是或否
我们用 P(A = H) 来表示变量A取值为H的概率。概率值始终在0到1之间(包含0和1)。概率的取值范围是连续的,而变量本身的值是离散的。
联合概率与独立事件
当我们考虑两个事件同时发生时,例如抛掷两枚硬币,我们关心的是联合概率。
假设有两枚硬币A和B,且都是均匀的(无偏),那么:
- P(A = H) = 0.5
- P(B = T) = 0.5
事件“A为正面且B为反面”的联合概率记为 P(A = H ∩ B = T)。由于两枚硬币的抛掷结果是相互独立的(即A的结果不影响B),我们可以简单地将两个概率相乘:
P(A = H ∩ B = T) = P(A = H) * P(B = T) = 0.5 * 0.5 = 0.25
事件的并集(“或”)
现在,考虑事件“A或B中至少有一枚是正面”。这包括了三种情况:A正B反、A反B正、A正B正。
在概率论中,这被称为事件的并集,记为 P(A = H ∪ B = H)。计算它的高效公式是:
P(A ∪ B) = P(A) + P(B) - P(A ∩ B)
这个公式的原理是,当我们把A发生的概率和B发生的概率相加时,A和B同时发生的区域被计算了两次,所以需要减去一次交集的部分。
计算概率的方法
上一节我们介绍了概率的基本运算,本节中我们来看看如何系统地计算更复杂情况的概率。主要有两种可视化工具可以帮助我们。
树状图
树状图通过分支来表示试验的所有可能结果。例如,抛掷第一枚硬币的结果(H或T)作为第一层分支,然后从每个分支再分出第二枚硬币的结果。
第一枚硬币
├── H (概率 0.5)
│ ├── H (概率 0.5) -> 结果 HH (概率 0.25)
│ └── T (概率 0.5) -> 结果 HT (概率 0.25)
└── T (概率 0.5)
├── H (概率 0.5) -> 结果 TH (概率 0.25)
└── T (概率 0.5) -> 结果 TT (概率 0.25)
通过树状图,我们可以清晰地枚举所有结果并计算其概率。
网格图
对于两个试验,我们可以使用网格图。横轴代表第一个试验的结果,纵轴代表第二个试验的结果。网格中的每个单元格代表一个联合结果。
例如,抛掷两个四面骰子,网格将是4x4的,共16个单元格,每个单元格代表一个结果组合,如(1,3)、(2,4)等。
以下是使用网格解决概率问题的例子:
- 事件“两次点数相同”:这对应于网格的对角线单元格。共有4个这样的单元格,总共有16个可能结果,所以概率是 4/16 = 1/4。
- 事件“点数之和为6”:需要找出所有和为6的组合,如(2,4)、(3,3)、(4,2)。共有3个这样的组合,概率为 3/16。
概率计算实例
掌握了基本工具后,让我们通过一些具体例子来巩固理解。
抛掷三枚硬币
抛掷三枚均匀硬币,共有 2^3 = 8 种等可能的结果。
- 特定结果的概率:例如“正正反”(HHT)。由于每种结果的概率相同,其概率为 1/8。
- 恰好出现两次正面的概率:我们需要枚举所有恰好包含两个H的结果:HHT, HTH, THH。共有3种这样的结果。因此概率为 3/8。
从扑克牌中抽牌
考虑一副标准的52张扑克牌。
- 抽到A的概率:一副牌有4张A,所以概率是 4/52 = 1/13。
- 抽到特定花色A(如红桃A)的概率:只有1张红桃A,概率是 1/52。
- 抽不到A的概率(逆概率):这等于1减去抽到A的概率,即 1 - 1/13 = 12/13。利用逆概率常常能简化计算。
从两副牌中抽牌
现在从两副不同的牌中各抽一张。
- 样本空间大小:从第一副牌有52种可能,对于每一种,第二副牌也有52种可能。总共有 52 * 52 种可能结果。
- 至少抽到一张A的概率:设事件E1为“从第一副牌抽到A”,E2为“从第二副牌抽到A”。我们要求P(E1 ∪ E2)。
- P(E1) = 1/13
- P(E2) = 1/13
- P(E1 ∩ E2) = (1/13) * (1/13) = 1/169
- 根据并集公式:P(E1 ∪ E2) = 1/13 + 1/13 - 1/169
- 抽到的两张牌花色相同的概率:使用网格思想。第一张牌可以是任意花色(概率1)。第二张牌必须与第一张同花色,在剩下的51张牌中有12张同花色牌,概率为12/51。所以总概率为 1 * (12/51) = 12/51。
从概率到统计:描述数据分布
前面的讨论基于我们知道事件的真实概率(如硬币是均匀的)。但如果我们不知道呢?我们可以通过多次重复试验(模拟)来估计概率。这就引入了统计学:我们通过收集到的数据(样本)来推断总体特性。
均值(期望)
均值是所有数据点的平均值,代表了分布的中心位置。公式为:
均值 μ = (Σx_i) / N
其中,x_i 是每个数据点,N 是数据点总数。
方差与标准差
仅有均值不足以描述数据。我们还需要知道数据的“分散程度”或变异性。
- 方差:衡量每个数据点与均值差异的平方的平均值。公式为:
方差 σ² = Σ(x_i - μ)² / N - 标准差:方差的平方根。它和原始数据有相同的单位,更易于解释。公式为:
标准差 σ = √方差
标准差小意味着数据点紧密聚集在均值周围;标准差大则意味着数据非常分散。
变异系数
为了比较不同均值的数据集的离散程度,我们使用变异系数,它是标准差与均值的比值:
变异系数 CV = σ / μ
例如,一个班级的考试分数均值为50,标准差为10,则CV=0.2。另一个班级的学生体重均值为150磅,标准差为10磅,则CV≈0.067。虽然标准差相同,但体重的分布相对更集中(一致性更高)。
标准差的应用与置信区间
在正态分布(钟形曲线)中,标准差有特别重要的意义。我们可以根据均值(μ)和标准差(σ)来确定数据落在某个范围内的概率。
- 大约 68% 的数据落在 μ ± 1σ 范围内。
- 大约 95% 的数据落在 μ ± 2σ 范围内。
- 大约 99.7% 的数据落在 μ ± 3σ 范围内。
这使我们能够构建置信区间。例如,如果我们估计某次考试的平均分μ=80,标准差σ=5,那么我们可以有约95%的把握说,一个随机学生的分数会在 80 ± 2*5,即70到90分之间。
模拟与可视化
在编程中,我们经常使用模拟(如蒙特卡洛方法)来估计概率和理解随机过程。
模拟抛硬币
我们可以用随机数生成器来模拟一个概率为p的事件。例如,模拟一枚均匀硬币(p=0.5):
import random
if random.random() < 0.5: # random() 生成一个[0,1)之间的均匀随机数
outcome = "H"
else:
outcome = "T"
通过重复这个实验很多次(比如1000次抛掷),计算出现正面的比例,我们就可以估计出P(H)。随着模拟次数增加,这个估计值会越来越接近真实的0.5。
数据可视化
将模拟或实验的结果可视化非常重要,可以帮助我们洞察数据的模式。
- 散点图:展示两个变量之间的关系。例如,将每次模拟的“正面概率估计值”与“模拟使用的抛掷次数”画成散点图,可以观察估计值如何随样本量增大而收敛。
- 直方图:展示单个变量的分布情况。例如,将多次模拟得到的“正面出现次数”画成直方图,可以看到它是否符合预期的分布(如二项分布)。
在绘图中,务必为图表添加清晰的标题、坐标轴标签和图例。


本节课中我们一起学习了概率论的基础,包括如何计算简单和复合事件的概率。我们探讨了使用树状图、网格图和文氏图等工具来辅助计算。接着,我们过渡到统计学,学习了如何使用均值、方差和标准差来描述数据分布的中心趋势和离散程度,并了解了标准差在构建置信区间中的作用。最后,我们介绍了通过计算机模拟来估计概率以及使用图表进行数据可视化的基本概念。这些知识是进行数据分析和理解随机现象的基础。
023:概率分布、模拟建模与蒙特卡洛方法

在本节课中,我们将学习几种重要的概率分布,探讨如何通过分析模型和模拟模型来理解现实世界,并介绍蒙特卡洛模拟这一强大的计算工具。


高斯分布与参数化建模

上一节我们介绍了高斯分布。高斯分布的一个有趣特性是,它可以完全由均值和标准差这两个参数来刻画。


这种用少量参数来描述一个曲线或分布的概念,是我们构建计算模型以理解物理系统的一种非常重要的方法。在本课程的这个阶段,我们将花大量时间探讨这个问题。
当我们能够建模时,我们倾向于将分布建模为高斯分布或正态分布,因为它们具有良好的参数化特性。我们有一些经验法则可以告诉我们数据点距离均值有多近。
非正态分布:均匀分布与指数分布
然而,理解并非所有分布都是正态分布这一点很重要。如果我们错误地假设一个非正态分布是正态的,可能会从模型中得出非常误导性的结果。

以下是两种常见的非正态分布:
- 均匀分布:考虑掷一个单一的骰子。六个结果中的每一个都是等概率的。我们不会期望在3或4处出现峰值,而在1处出现低谷。类似地,在马萨诸塞州彩票或任何公平的彩票中,每个号码出现的概率是相同的。这种分布被称为均匀分布。每个结果都等可能发生。我们可以用一个参数——它的范围——来完全描述一个均匀分布。均匀分布经常出现在人为设计的游戏中,但在自然界中几乎从未出现,通常对建模复杂系统不太有用。
- 指数分布:另一种经常出现的分布是指数分布。它们被用于许多不同的领域,例如规划高速公路系统时,用指数分布来模拟车辆的到达间隔时间。指数分布的关键特性是它具有无记忆性。事实上,它是唯一具有无记忆性的连续分布。
指数分布实例:药物清除模型

让我们看一个指数分布的例子。考虑药物在人体内的浓度。假设在每个时间步,每个分子被身体清除的概率是 P。这个系统是无记忆的,因为每个分子在特定时间步被清除的概率与之前发生的事件无关。

在时间 T = 1,分子仍然在体内的概率是 1 - P。在时间 T = 2,分子仍然在体内的概率是 (1 - P)^2。更一般地,在时间 T,分子仍然在体内的概率是 (1 - P)^T。
现在,假设在时间 T = 0 有 M0 个分子。我们可以编写一个简单的程序来模拟在任何时间 T 可能剩余的分子数量。

def clear(numMolecules, probClear, numSteps):
remaining = []
m = numMolecules
for t in range(numSteps):
m = m * (1 - probClear) # 应用指数衰减公式
remaining.append(m)
pylab.plot(remaining)
运行这个程序(例如,初始分子数=1000,清除概率=0.01,步数=500),我们会看到一条典型的指数衰减曲线:开始时下降非常快,然后逐渐趋近于0。如果我们在半对数坐标轴上绘制它,指数衰减会变成一条直线,这是判断一个分布是否是指数分布的一个简单有效的方法。
分析模型 vs. 模拟模型

上面我们通过物理模型推导出数学公式,并编写代码绘制了结果。让我们看看另一种方法:蒙特卡洛模拟。

我们可以编写一个模拟模型,直接模仿物理过程,而不是计算概率。
def clearSim(numMolecules, probClear, numSteps):
remaining = []
m = numMolecules
for t in range(numSteps):
for molecule in range(m):
if random.random() < probClear:
m -= 1
remaining.append(m)
pylab.plot(remaining)

如果我们比较分析模型和模拟模型的结果,会发现它们非常相似:分析模型产生一条平滑的曲线,而模拟模型由于随机性产生一条略有波动的曲线,但整体趋势一致。
那么,我们更喜欢哪个模型?这没有标准答案。评估模型时,我们应该考虑两个问题:
- 保真度/可信度:模型的结果是否可信?我们能否通过推理说服自己模型是准确的?
- 效用:模型能回答什么问题?



对于这个简单模型,很难说哪个更可信。但模拟模型通常能提供额外的效用,因为它更容易进行“假设分析”。例如,假设药物分子每100个时间步会自我复制一次。在分析模型中推导新公式会很困难,但在模拟模型中,我们只需添加几行代码就能观察结果,看到一种整体呈指数衰减但周期性跳跃的“锯齿状”分布。
蒙特卡洛模拟的应用:蒙提霍尔问题

在暂时离开概率论之前,让我们看一个著名的概率谜题:蒙提霍尔问题。这个问题展示了如何使用模拟模型来理解复杂或违反直觉的概率情景。
蒙提霍尔问题描述如下:
- 有三扇门,一扇后面有汽车(大奖),两扇后面是山羊(安慰奖)。
- 参赛者选择一扇门(比如1号门)。
- 知道汽车在哪里的主持人(蒙提)打开一扇有山羊的、参赛者未选择的门(比如3号门)。
- 主持人问参赛者:你是坚持原来的选择(1号门),还是换到另一扇未打开的门(2号门)?
问题是:参赛者应该换门吗?
通过概率分析可以得出:换门将获胜概率从 1/3 提高到 2/3。这是因为最初选择正确的概率是1/3,汽车在另外两扇门后的概率是2/3。主持人打开一扇有山羊的门后,剩下的那扇门拥有这2/3的概率。

我们可以编写一个模拟程序来验证这一点:
def montyChoose(guessDoor, prizeDoor):
# 蒙提的策略:打开一扇不是参赛者选择且没有奖品的门
if 1 != guessDoor and 1 != prizeDoor:
return 1
if 2 != guessDoor and 2 != prizeDoor:
return 2
return 3
def randomChoose(guessDoor, prizeDoor):
# 随机策略:从非参赛者选择的门中随机打开一扇(可能打开有奖品的门)
if guessDoor == 1:
return random.choice([2,3])
if guessDoor == 2:
return random.choice([1,3])
return random.choice([1,2])
def simMontyHall(numTrials, chooseFcn):
stickWins = 0
switchWins = 0
for t in range(numTrials):
prizeDoor = random.choice([1,2,3])
guessDoor = random.choice([1,2,3])
toOpen = chooseFcn(guessDoor, prizeDoor)
if toOpen == prizeDoor:
continue # 如果随机打开有奖品的门,此轮无效(对应随机主持人)
if guessDoor == prizeDoor:
stickWins += 1
else:
switchWins += 1
return stickWins, switchWins
运行模拟会发现,当主持人采用蒙提的策略时,换门的胜率约为2/3,坚持的胜率约为1/3。如果主持人随机开门,则换门与坚持的胜率相同。这说明了事件的独立性(或非独立性)对概率结果的巨大影响。

蒙特卡洛方法:估算圆周率 π
最后,我们探讨一个更广泛的概念:使用随机算法(蒙特卡洛方法)解决本身并不随机的问题。一个经典的例子是估算圆周率 π。
布丰投针实验的思路如下:
- 画一个边长为2的正方形,并在其中内接一个半径为1的圆。
- 向正方形内随机投掷大量“针”(点)。
- 统计落在圆内的针的数量和落在正方形内的针的总数。
根据几何关系,圆的面积是 π * r^2,这里 r=1,所以面积是 π。正方形的面积是 (2r)^2 = 4。
如果投掷是随机的,那么落在圆内的针的比例应等于圆的面积与正方形面积之比,即:
(落在圆内的针数) / (总针数) ≈ (圆的面积) / (正方形的面积) = π / 4
因此,π ≈ 4 * (落在圆内的针数) / (总针数)
我们可以用程序模拟这个过程:
def estPi(numPoints):
pointsInCircle = 0
for i in range(numPoints):
x = random.random() * 2 - 1 # 生成[-1, 1)之间的随机x坐标
y = random.random() * 2 - 1 # 生成[-1, 1)之间的随机y坐标
if x**2 + y**2 <= 1: # 检查点是否在半径为1的圆内
pointsInCircle += 1
return 4.0 * pointsInCircle / numPoints
投掷的点越多,估算值通常越接近真实的π。这展示了蒙特卡洛方法如何利用随机性来解决确定性的数学问题。
总结

本节课中我们一起学习了:
- 回顾了高斯分布,并引入了均匀分布和指数分布,理解了用少量参数描述分布的重要性。
- 通过药物清除的例子,对比了分析模型(基于公式推导)和模拟模型(蒙特卡洛模拟)的构建与评估,认识到模拟模型在“假设分析”中的灵活性。
- 深入探讨了著名的蒙提霍尔问题,利用概率分析和程序模拟验证了违反直觉的概率结果,理解了事件依赖关系对概率的影响。
- 最后,介绍了蒙特卡洛方法的核心思想,即利用随机性解决非随机问题,并通过估算圆周率π的实例演示了其应用。

通过这些内容,我们看到了计算模型,特别是模拟模型,在理解和预测复杂系统行为方面的强大能力。
024:模拟、模型与实验验证 🔬
在本节课中,我们将探讨计算机模拟、理论模型与物理现实之间的复杂关系。我们将通过具体的例子,学习如何构建和评估模型,并理解统计结论与真实情况之间的区别。
上一节我们讨论了模拟结果的统计可信度。本节中,我们来看看一个关键问题:即使统计结果看起来很好,我们如何确保模拟模型本身是正确的?
我以一个“谎言”结束上节课。我声称,当投掷足够多的针并进行足够多的试验后,我们可以通过观察标准差来判断结果的准确性,并以95%的置信度给出答案的范围。但这并不完全正确。
我混淆了统计上可靠的结论与绝对真理。任何统计检验的有效性都依赖于某些假设,例如数据的独立性。但最关键的一个假设是:我们的模拟确实是现实的一个准确模型。
回想一下设计模拟的过程:我们研究了布丰投针问题的数学原理,进行了一些代数推导,并据此编写了代码、运行了模拟、分析了统计结果。但假设我在编码时犯了一个错误。例如,在代数推导出的公式中,我错误地输入了数字 2 而不是正确的值。
现在,如果我们运行这个有错误的模拟,它可能仍然会快速收敛,给出一个很小的标准差,让我非常自信地认为π的值大约是1.569。然而,我们都知道π的真实值远非如此。问题不在于我的统计方法,而在于我的统计结果是关于模拟本身的,而不是关于π的。
这里的教训是:在相信任何模拟结果之前,我们必须确信我们的概念模型是正确的,并且我们已经正确地实现了这个模型。
那么,我们该如何做呢?一种方法是根据现实来检验我们的结果。如果我运行模拟后得出π约为1.57,我可以去画一个圆并粗略测量其周长,从而立即知道我的答案远非正确。这正是科学家们应该做的:当他们使用模拟模型推导出某个结果时,总会进行一些实验来验证其推导结果至少是合理的。统计检验有助于确保我们在细节上处理得当,但首先必须进行合理性检查。
这是一个非常重要的教训:不要被统计检验所迷惑,并将其与真理混为一谈。
现在,我想继续前进,看看更多与我们一直在做的事情类似的例子。实际上,我们将要探讨的是物理现实、理论模型和计算模型三者之间的相互作用。这正是现代科学与工程的运作方式。
我们从一个物理情境开始。这里的“物理”不一定指砖瓦或物理学、生物学,它可以是股票市场,即世界上的某种真实情境。我们使用一些理论来获得对该情境的洞察。当理论变得过于复杂或无法直接给出答案时,我们就使用计算。
现在,我想谈谈这三者是如何相互关联的。想象一下,你是一个聪明的高中生,正在学习生物、化学或物理。你可能都经历过这种情况:你尽最大努力完成了一个实验,但通过计算,你发现实验结果与理论并不完全吻合。
你应该怎么做?嗯,我猜你们都遇到过这种情况。你可以直接提交结果,但可能因实验技术不佳而受到批评。更可能的是,你计算出了“正确”的结果并提交了它们,但这又可能因“过于完美”而引人怀疑。但作为聪明人,我猜你们大多数人在高中时都这样做过:计算出正确结果,看看自己的实验结果,然后在两者之间取一个折中值,引入一点误差,但又不显得太愚蠢。
要正确处理这个问题,你需要有一种方法来建模,不仅是现实,还包括实验误差。通常,建模实验误差的最佳方法(即使我们并非有意作弊)是假设实际数据存在某种随机扰动。事实上,高斯的一大贡献就是指出,我们通常可以将实验误差建模为正态分布,即高斯分布。
让我们看一个例子:考虑一个弹簧。不是指季节或泉水,而是你在物理课上学到的那种弹簧,施加力可以压缩或拉伸它。弹簧非常有用,我们用在汽车、床垫、安全带以及发射抛射物上。事实上,我们稍后会看到,它们在生物学中也经常出现。
1676年,英国物理学家罗伯特·胡克提出了胡克定律来解释弹簧的行为。定律非常简单:
F = -Kx
换句话说,储存在弹簧中的力 F 与弹簧被压缩或拉伸的距离 x 成线性关系。这就是胡克定律。该定律适用于多种材料和系统,包括许多生物系统。当然,它不适用于任意大的力。所有弹簧都有一个弹性极限,如果拉伸超过这个极限,定律就会失效。
这里的比例常数 K 被称为弹簧常数。每个弹簧都有一个常数 K 来解释其行为。如果弹簧很硬(如汽车悬架),K 就大;如果弹簧不硬(如圆珠笔中的弹簧),K 就小。负号表示弹簧施加的力与位移方向相反。
知道弹簧的弹簧常数具有重要的实际意义,可用于校准秤、原子力显微镜等。事实上,最近人们开始考虑将DNA建模为弹簧,而找到DNA的弹簧常数在一些生物实验中非常有用。
一代又一代的学生通过一个非常简单的实验来估算弹簧常数。你很可能也做过:取一个弹簧,将其悬挂在某种装置上,然后在弹簧底部挂上一个已知质量的砝码,测量弹簧被拉伸的长度。
根据 F = -Kx,以及 F = m * a(质量乘以加速度),并且我们知道在地球上重力加速度约为 9.81 米/秒²,我们就可以通过代数计算 K。
如果我们没有实验误差,这一切都很好。但我们确实有误差。因此,人们通常不是只挂一个砝码,而是挂上不同质量的砝码,等待弹簧停止运动后进行测量。这样他们就得到了一系列数据点。他们假设误差是正态分布的,有些为正,有些为负。如果进行足够多的实验,误差会相互抵消,我们就能很好地估算出弹簧常数 K。
我做了这样一个实验,并将结果存入一个文件。文件的第一行说明了内容(距离以米为单位,质量以千克为单位),然后数据由空格分隔。
以下是读取数据的代码。通常,输入/输出操作应放在单独的函数中,这样如果数据格式改变,只需修改这部分代码。
def get_data(file_name):
data_file = open(file_name, 'r')
distances = []
masses = []
data_file.readline() # 丢弃标题行
for line in data_file:
d, m = line.split()
distances.append(float(d))
masses.append(float(m))
data_file.close()
return (distances, masses)
然后我绘制数据。这里我们使用了一种新类型:数组。数组由 PyLab(基于 NumPy)提供,类似于列表,但支持逐点运算,例如将数组乘以一个数会对每个元素进行乘法。
distances, masses = get_data('spring_data.txt')
distances = pylab.array(distances)
masses = pylab.array(masses)
forces = masses * 9.81
pylab.plot(forces, distances, 'bo')
pylab.xlabel('Force (Newtons)')
pylab.ylabel('Distance (meters)')
现在,我可以计算 K。但在计算之前,我想看看数据是否合理。我们有一个理论模型:数据应该大致落在一条直线上(考虑到实验误差)。我需要找到那条线,因为知道了线,我就能计算 K。K 与那条线的斜率成反比。
如何得到那条线?我要找到一条最接近所有数据点的线,即最佳拟合线。对于超过两个点的情况,我需要一个目标函数来衡量拟合的好坏,以便比较和选择最佳拟合。
一个标准且常用的度量是最小二乘拟合。其目标函数是:
Σ (observed_i - predicted_i)²
我们希望对所有数据点 i 求和。通过平方差值,我们忽略了点在线上方还是下方,只关心距离线的远近。总和越小,拟合越好。
如何找到最佳拟合?有几种方法,在某些条件下有解析解。好消息是,PyLab 内置了此功能。函数是 polyfit。
polyfit 接受三个参数:所有观测到的 x 值、所有观测到的 y 值,以及多项式的次数。它可以拟合任意次数的多项式(直线、抛物线、三次曲线等)。对于直线(一次多项式),它返回系数 A 和 B,其中直线方程为 y = Ax + B。
def fit_data(file_name):
distances, masses = get_data(file_name)
distances = pylab.array(distances)
masses = pylab.array(masses)
forces = masses * 9.81
pylab.plot(forces, distances, 'ko', label='Measured points')
# 拟合直线 (degree=1)
A, B = pylab.polyfit(forces, distances, 1)
predicted_distances = A * forces + B
pylab.plot(forces, predicted_distances, label='Linear fit')
# 计算弹簧常数 K (K = 1/A)
K = 1.0 / A
pylab.title('K = ' + str(round(K, 5)) + ' N/m')
pylab.legend(loc='best')
运行后,我们得到一条拟合直线,并计算出 K 约为 21。但我们应该满意吗?从图上来看,这些点离直线相当远。如果拟合不好,我就必须怀疑从这个拟合模型推导出的 K 值。
让我们尝试拟合一个三次曲线(degree=3)。
# 拟合三次曲线 (degree=3)
coeffs = pylab.polyfit(forces, distances, 3)
# coeffs 包含 [a, b, c, d],对应 ax^3 + bx^2 + cx + d
predicted_cubic = pylab.polyval(coeffs, forces)
pylab.plot(forces, predicted_cubic, 'r:', label='Cubic fit')
视觉上,三次曲线似乎是比直线好得多的数据描述。但是,我们应该为此高兴吗?我们构建模型的目的是为了更好地理解弹簧。我们经常用模型来预测我们无法在实验中测量的值。
假设我使用这个三次模型来预测挂上一个1.5公斤重物时弹簧的拉伸。模型预测弹簧不仅会停止拉伸,甚至会收缩到比原始位置还短。这在物理世界中极不可能发生。
这表明,虽然我可以轻松地用一条曲线拟合数据,并且拟合得很好,但它可能具有很差的预测价值。我之所以开始这项研究,是基于一个关于弹簧的理论(胡克定律),它应该是一个线性模型。仅仅因为我的数据可能不符合该理论,并不意味着我应该随意拟合一条曲线。
如果我们愿意使用足够高次数的多项式,几乎可以完美拟合任何数据,但这证明不了什么,也不实用。因此,让我们暂时忽略曲线,看看原始数据。数据在末端似乎变平了。这违反了胡克定律(线性关系)。我是否违反了胡克定律?还是我做错了什么?
胡克定律只适用于弹性极限内。很可能我在实验中超过了弹簧的弹性极限。如果这是真的,我就有理论依据可以丢弃最后那几个变平的数据点。
让我们回到代码,丢弃最后六个点(假设它们超出了弹性极限),然后重新进行线性拟合。
# 丢弃最后六个数据点(假设超出弹性极限)
forces_trimmed = forces[:-6]
distances_trimmed = distances[:-6]
A_trimmed, B_trimmed = pylab.polyfit(forces_trimmed, distances_trimmed, 1)
predicted_trimmed = A_trimmed * forces_trimmed + B_trimmed
pylab.plot(forces_trimmed, predicted_trimmed, 'g--', label='Trimmed linear fit')
K_trimmed = 1.0 / A_trimmed
现在,我们得到了视觉上更好的拟合,并且 K 值也大不相同。我们对此更满意。如果我对修剪后的数据拟合三次曲线,会发现三次曲线和直线看起来非常相似。
但问题又来了:我们怎么知道哪条线能更好地代表物理现实?毕竟,我可以只保留任意两个点,然后得到一条完美拟合的直线(均方误差为0)。所以,我们再次看到,这个问题不能仅靠统计学来回答。我必须回到理论。我的理论告诉我,关系应该是线性的,并且我有理论依据来丢弃最后那几个点(超过了弹性极限)。但我没有理论依据去随意删除中间任何我不喜欢的点。
让我们继续沿着这条路径,看另一个实验,同样涉及弹簧(但这是不同的“弹簧”)。这次是弓和箭。弓的臂本质上是一个弹簧。当你拉回弓弦时,你在臂上施加了力。当你释放时,弹簧(臂)回到其自然位置,将箭射出,使其沿一定轨迹飞行。
我对观察抛射物(箭)遵循的轨迹感兴趣。历史上,很多数学正是源于对抛射轨迹的研究(如火炮弹道)。我进行了一些实验,将数据存入文件,格式类似。
以下是读取和绘制轨迹数据的代码:
def get_trajectory_data(file_name):
data_file = open(file_name, 'r')
distances = []
heights1, heights2, heights3, heights4 = [], [], [], []
data_file.readline()
for line in data_file:
d, h1, h2, h3, h4 = line.split()
distances.append(float(d))
heights1.append(float(h1))
heights2.append(float(h2))
heights3.append(float(h3))
heights4.append(float(h4))
data_file.close()
return (distances, [heights1, heights2, heights3, heights4])
def process_trajectory(file_name):
distances, heights = get_trajectory_data(file_name)
# 计算平均高度
num_heights = len(heights)
total_heights = pylab.array([0]*len(distances))
for h in heights:
total_heights = total_heights + pylab.array(h)
mean_heights = total_heights / num_heights
distances = pylab.array(distances)
mean_heights = pylab.array(mean_heights)
pylab.plot(distances, mean_heights, 'bo', label='Measured')
pylab.xlabel('Distance from Launch Point (inches)')
pylab.ylabel('Height Above Launch Point (inches)')
# 尝试拟合直线
A_lin, B_lin = pylab.polyfit(distances, mean_heights, 1)
heights_pred_lin = A_lin * distances + B_lin
pylab.plot(distances, heights_pred_lin, 'r-', label='Linear Fit')
pylab.legend()
拟合直线后,我们发现拟合效果很差。这表明“箭沿直线飞行”的理论不是一个好模型。
现在,让我们尝试拟合一个二次曲线(抛物线,degree=2)。
# 尝试拟合二次曲线(抛物线)
coeffs_quad = pylab.polyfit(distances, mean_heights, 2)
heights_pred_quad = pylab.polyval(coeffs_quad, distances)
pylab.plot(distances, heights_pred_quad, 'g--', label='Quadratic Fit')
视觉上,抛物线拟合比直线好得多。这暗示箭可能沿抛物线而非直线飞行。
下一个问题是:我们的眼睛告诉我们它更好,但好多少?我们如何量化比较两种拟合的优劣?回想一下,polyfit 通过最小化均方误差来工作。因此,一种比较方法是计算直线的均方误差和抛物线的均方误差。显然,抛物线的误差会更小。
然而,均方误差虽然可以用于比较两个模型,但在绝对意义上衡量拟合优度并不理想。因为它有下界0,但没有上界,可以任意大。
因此,我们通常使用另一个指标:决定系数,通常写作 R²。
R² = 1 - (估计误差 / 测量数据的方差)
我们比较的是估计误差与数据原始变异性之间的比率。可以证明,R² 的值总是在 0 到 1 之间,这为我们提供了一个在绝对意义上思考拟合优度的好方法。
以下是计算 R² 的代码:
def r_squared(measured, predicted):
"""计算决定系数 R²。
measured: 测量值的列表或数组。
predicted: 模型预测值的列表或数组。"""
estimated_error = ((predicted - measured)**2).sum()
measured_mean = measured.sum() / float(len(measured))
measured_variance = ((measured - measured_mean)**2).sum()
return 1 - estimated_error / measured_variance
我们可以用这个函数来量化直线拟合和抛物线拟合的优劣。R² 越接近 1,说明模型解释数据变异性的能力越强。
本节课中我们一起学习了:
- 统计上可靠的模拟结果并不等同于物理真理,必须对概念模型和代码实现进行验证。
- 科学探索是物理现实、理论模型和计算模型三者不断交互、迭代验证的过程。
- 实验误差通常可以建模为正态分布(高斯分布)。
- 可以使用
polyfit进行多项式拟合(如线性回归),并通过最小二乘法找到最佳拟合。 - 高次多项式可能过度拟合数据,导致模型预测能力变差。必须用理论指导模型选择。
- 决定系数 R² 是一个在 0 到 1 范围内的指标,用于在绝对意义上评估模型的拟合优度,优于原始的均方误差。
理解模型、数据和理论之间的相互作用,是进行有意义的计算科学和工程分析的核心。
025:概率分布、蒙特卡洛方法与回归分析 📊

在本节课中,我们将学习三个核心主题:概率分布、蒙特卡洛方法以及回归分析。我们将探讨不同类型的分布,如何使用随机抽样来解决问题,以及如何通过回归分析来拟合数据并预测趋势。
概率分布 📈
上一节我们介绍了课程的整体结构,本节中我们来看看概率分布。概率分布描述了随机变量取值的可能性。我们已经学习了几种常见的分布。
以下是三种主要的概率分布:
- 均匀分布:在区间
[A, B]内,每个值出现的概率相等。其概率密度函数为P(x) = 1 / (B - A)。 - 正态分布(高斯分布):呈钟形曲线,由均值
μ和标准差σ两个参数完全定义。其概率密度函数为f(x) = (1 / (σ√(2π))) * e^(-(x-μ)²/(2σ²))。 - 指数分布:通常用于描述事件发生的时间间隔,其概率密度函数为
f(x) = λe^(-λx),其中λ > 0。
在Python中,我们可以使用随机数生成器来模拟这些分布,并通过绘制直方图来可视化它们。例如,使用 random.uniform(A, B) 生成均匀分布的随机数,使用 random.gauss(μ, σ) 生成正态分布的随机数。
蒙特卡洛方法 🎲
上一节我们介绍了不同的概率分布,本节中我们来看看蒙特卡洛方法。这是一种通过重复随机抽样来获得数值结果的计算方法。
以下是蒙特卡洛方法的几个应用实例:
- 蒙提霍尔问题:通过模拟证明,在游戏中选择“换门”策略的获胜概率是2/3。
- 估算圆周率π:通过在单位正方形内随机投点,并计算落在内切圆内的点的比例来估算π。公式为:
π ≈ 4 * (圆内点数 / 总点数)。 - 数值积分:通过在一个包围函数曲线的矩形区域内随机投点,估算曲线下的面积。公式为:
曲线下面积 ≈ 矩形面积 * (曲线下点数 / 总点数)。
蒙特卡洛方法的核心思想是利用大数定律,当抽样次数足够多时,随机模拟的结果会趋近于理论值。这种方法特别适用于解决解析方法难以处理的问题,例如计算复杂棋盘游戏(如大富翁)中各个位置被访问的概率。
回归分析 📉
上一节我们探讨了如何使用蒙特卡洛方法解决各种问题,本节中我们来看看回归分析。回归分析用于确定两种或多种变量间相互依赖的定量关系,常用于从带有噪声的实验数据中找出潜在的函数模型。
以下是回归分析的关键步骤和概念:
- 拟合模型:给定一组观测数据
(x, y),我们尝试用一个多项式函数y = f(x)来拟合它。在Python中,可以使用numpy.polyfit(x, y, degree)进行多项式拟合。 - 评估拟合优度:
- 均方误差:计算预测值与实际观测值之差的平方和的均值。MSE越小,拟合越好。
- 决定系数:表示模型对数据变异的解释程度。其值越接近1,说明模型拟合度越好。
- 过拟合问题:使用过高次数的多项式进行拟合可能会过度贴合训练数据中的噪声,导致模型在未知数据上表现变差。因此,需要选择合适的多项式次数。
通过比较不同次数多项式的MSE和决定系数,我们可以选择一个既能较好拟合数据,又不会过于复杂的模型。这使得我们能够利用找到的模型进行预测,填补数据缺口或推断趋势。
总结 🎯


本节课中我们一起学习了三个重要的数据分析概念。我们回顾了均匀分布、正态分布和指数分布的特性及其可视化方法。我们深入探讨了蒙特卡洛方法的原理,并通过估算π和数值积分等例子理解了其应用。最后,我们学习了回归分析,掌握了如何使用多项式拟合数据、评估拟合质量并警惕过拟合现象。这些工具是进行科学计算和数据分析的基础。
026:模型评估与优化问题入门 🎯

在本节课中,我们将要学习如何评估数据拟合模型的好坏,并初步了解一类重要的计算问题——优化问题。我们将从计算决定系数(R²)开始,然后探讨如何利用模型回答实际问题,最后引入经典的“0/1背包问题”作为优化问题的例子。
模型评估:决定系数(R²)
上一节我们介绍了如何用多项式拟合数据。本节中我们来看看如何量化一个拟合模型的“绝对好坏”,这可以通过一个叫做决定系数的指标来实现,通常写作 R²。


其公式非常简单:



R² = 1 - (估计误差 / 测量方差)



其中,估计误差是模型预测值与实际数据点之间的差异总和,测量方差是数据本身的变化程度。



R²的值总是在0和1之间。如果R²等于1,意味着我们构建的模型完美地解释了数据中的所有变化。如果R²等于0,则意味着模型预测值与实际数据之间完全没有线性关系,模型毫无价值。
以下是计算R²的示例代码,它清晰地展示了公式的含义:
def compute_r_squared(measured, predicted):
# 计算估计误差 (SSE)
sse = sum((measured[i] - predicted[i])**2 for i in range(len(measured)))
# 计算测量方差 (SST)
mean_measured = sum(measured) / len(measured)
sst = sum((y - mean_measured)**2 for y in measured)
# 计算 R²
r2 = 1 - (sse / sst)
return r2
应用模型:计算箭矢速度
既然我们有了一个评估模型好坏的方法,并且知道我们的二次模型拟合度很高(R² ≈ 0.98),那么一个自然的问题是:我们为什么要建立模型?模型能帮助我们回答哪些仅凭数据无法回答的问题?
例如,考虑射箭实验。我们有一组箭矢飞行轨迹的(x, y)坐标数据点。我们无法直接从这些离散点看出箭矢的飞行速度。但是,我们可以利用拟合出的抛物线模型,结合基础物理理论,来计算其平均速度。
具体步骤如下:
- 确定顶点:抛物线模型为 y = Ax² + Bx + C。抛物线的最高点(顶点)总是出现在x轴的中点位置,即 x_mid。因此,最高高度 y_peak = A * (x_mid)² + B * x_mid + C。
- 计算下落时间:箭矢从最高点落到靶子(地面)所经过的距离就是 y_peak。根据物理公式,物体自由下落高度 h 所需时间 t 为 t = √(2h / g),其中 g 是重力加速度。
- 计算水平速度:假设空气阻力等因素可忽略,箭矢从发射点到靶子的水平飞行时间与从顶点下落的时间相同。因此,平均水平速度 v_avg = (水平距离) / t。
通过这个例子,我们看到了一个典型的模式:实验 -> 数据 -> 计算(建模与评估)-> 理论与分析 -> 推导结论。这个模式在现代科学与工程中反复出现。
引入优化问题
现在,让我们转向一个新的主题:优化问题。
优化问题通常包含两个部分:
- 目标函数:需要最大化或最小化的量(例如,最小化成本、最大化利润)。
- 约束条件:解决问题时必须满足的限制(例如,时间上限、重量限制)。



现实中有大量问题可以这样表述,例如规划最短路径、分配有限资源、机器学习中的模型训练等。

经典案例:0/1背包问题

一个经典的优化问题是 0/1背包问题。其场景是:一个小偷有一个最大承重为 W 的背包,闯入一间放有各种物品的屋子。每件物品 i 都有其价值 v_i 和重量 w_i。小偷的目标是在背包承重限制下,使偷走的物品总价值最大。
“0/1”意味着对于每件物品,小偷只能做出二元选择:要么整个拿走(取1),要么不拿(取0),不能只拿一部分。
贪心算法尝试
面对这个问题,一个直观的解决思路是使用贪心算法。贪心算法在每一步都做出当前看来最优的(“最贪心的”)选择。
对于背包问题,至少有三种贪心策略:
- 按价值贪心:每次选择剩余物品中价值最高的。
- 按重量贪心:每次选择剩余物品中最轻的(以便能装更多件)。
- 按价值密度贪心:每次选择剩余物品中“价值/重量”比最高的。
以下是实现贪心算法的代码框架:
def greedy(items, max_weight, key_function):
""" 贪心算法解决背包问题
items: 物品列表
max_weight: 最大承重
key_function: 决定排序规则的函数,如按价值、重量或价值密度
"""
# 按指定规则排序,降序(最好的在前)
items_sorted = sorted(items, key=key_function, reverse=True)
result = []
total_value = 0.0
total_weight = 0.0
for item in items_sorted:
if (total_weight + item.get_weight()) <= max_weight:
result.append(item)
total_weight += item.get_weight()
total_value += item.get_value()
return (result, total_value)


贪心算法的优点是效率高,其时间复杂度主要来自排序操作,通常是 O(n log n),其中 n 是物品数量。然而,它的缺点是不能保证得到全局最优解。不同的贪心策略在不同的问题实例上可能产生不同的结果,且都可能不是最好的。

寻求最优解与复杂度挑战
如果我们不满足于近似解,坚持要找到绝对最优的组合,该怎么办?最直接的方法是穷举所有可能性。
我们可以将选择方案表示为一个二进制向量 V = [v1, v2, ..., vn],其中 vi = 1 表示拿取第 i 件物品,vi = 0 表示不拿。那么,所有可能的向量总数就是 2^n。
问题随之而来:当物品数量 n 增大时,穷举法所需的时间会呈指数级爆炸。例如,对于 n=50 件物品,即使每评估一个组合只需1微秒,穷举所有 2^50 种可能也需要大约36年!这显然是不切实际的。
因此,对于像0/1背包这样的NP难问题,我们常常需要在求解精度和计算时间之间做出权衡。贪心算法提供了一种快速的近似方案。在后续课程中,我们将探讨其他更智能的算法(如动态规划)来更有效地处理这类问题,或在可接受的时间内找到非常接近最优的解。
总结

本节课中我们一起学习了:
- 使用决定系数 R² 来量化评估数据拟合模型的质量。
- 理解了建立模型的意义:利用模型、理论和计算相结合,从实验数据中推导出新的结论(如计算箭速)。
- 认识了优化问题的基本结构:目标函数 + 约束条件。
- 以0/1背包问题为例,实践了用贪心算法寻找可行解,并分析了其高效但不保证最优的特性。
- 意识到了对于组合爆炸问题(复杂度为 O(2^n)),穷举搜索不可行,从而引出了对更优算法需求。

这为我们后续深入探讨算法设计与复杂度分析奠定了基础。
027:机器学习简介 🧠


在本节课中,我们将要学习机器学习的核心概念,包括监督学习与无监督学习,并重点探讨聚类算法。
概述
上一节我们介绍了背包问题及其暴力求解算法。本节中,我们将离开复杂度的话题,转而探讨另一类优化问题——机器学习。机器学习是当今计算机科学中最激动人心的领域之一,它旨在构建能够从数据中学习并做出智能决策的程序。
暴力求解背包问题回顾
首先,我们回顾一下用于求解0/1背包问题的暴力算法代码。理解其基本思想有助于我们认识寻找全局最优解与局部最优解的区别。
该算法的核心是生成所有可能的物品组合(幂集),然后从中选出在重量限制下价值最高的组合。
以下是生成二进制字符串(用于表示是否选择某物品)的辅助函数:
def dec_to_bin(n, num_digits):
"""返回长度为num_digits的二进制字符串,表示十进制数n。"""
result = ''
while n > 0:
result = str(n % 2) + result
n = n // 2
if len(result) > num_digits:
raise ValueError('位数不足')
for i in range(num_digits - len(result)):
result = '0' + result
return result
接下来是生成所有物品子集(幂集)的函数:
def gen_powerset(items):
"""生成物品列表的所有可能子集。"""
powerset = []
for i in range(0, 2**len(items)):
bin_str = dec_to_bin(i, len(items))
subset = []
for j in range(len(items)):
if bin_str[j] == '1':
subset.append(items[j])
powerset.append(subset)
return powerset
最后是选择最优组合的主函数:
def choose_best(powerset, max_weight, get_val, get_weight):
"""从幂集中选择在重量限制下总价值最高的子集。"""
best_val = 0.0
best_set = None
for items in powerset:
items_val = 0.0
items_weight = 0.0
for item in items:
items_val += get_val(item)
items_weight += get_weight(item)
if items_weight <= max_weight and items_val > best_val:
best_val = items_val
best_set = items
return (best_set, best_val)
运行此算法可以找到一个比贪心算法更优的解。这是因为贪心算法每一步只做局部最优选择,而无法保证达到全局最优。暴力算法通过检查所有可能性来寻找全局最优解。
然而,暴力算法的问题是计算成本过高。对于中等规模的问题,其运行时间可能长达数年。这引出了一个问题:是否存在更快的算法?从理论上讲,0/1背包问题的最坏情况时间复杂度是固有的指数级别。这意味着,任何保证找到最优解的算法,在最坏情况下都可能需要指数时间。
尽管如此,在实际应用中,存在一些技术(如动态规划、启发式算法)可以在合理时间内解决许多这类“固有指数”问题,或者找到接近最优的解。我们将在后续课程中探讨这些技术。
机器学习简介
现在,让我们转向今天的主要话题:机器学习。机器学习研究如何让计算机基于经验数据演化行为,其核心是从数据中自动识别复杂模式并做出智能决策。这个过程被称为归纳推理。
机器学习主要分为两大类:监督学习和无监督学习。
监督学习
在监督学习中,训练数据集中的每个样本都有一个标签,可以将其视为该样本的“答案”。
- 如果标签是离散的(例如“是/否”、“猫/狗”),我们称之为分类问题。
- 如果标签是连续值(例如房价、温度),我们称之为回归问题。我们之前学习的曲线拟合就是一个回归问题。
监督学习的目标是构建一个模型,能够根据训练集的统计特性,对未见过的数据做出预测。
让我们看一个分类问题的例子。假设我们有一些红点和蓝点,其特征是它们的 (x, y) 坐标,标签是颜色(红或蓝)。我们的任务是学习一个规则来区分红点和蓝点。


在构建模型时,我们需要考虑几个关键问题:
- 标签是否准确?现实数据中常有噪声或错误标签。
- 过去是否能代表未来?训练数据可能无法覆盖所有情况。
- 是否有足够的数据进行泛化?小训练集得出的结论不可靠。
- 应该使用哪些特征?特征选择至关重要。
- 拟合应该多紧密?这引出了过拟合问题。
例如,我们可以用一个复杂的形状(如三角形)完美分隔所有训练数据点,实现零训练误差。也可以用一条简单的直线来分隔,但可能会有个别点被错误分类(产生训练误差)。


哪个模型更好?复杂模型虽然训练误差小,但可能因为过度贴合训练数据中的噪声或异常值,而导致泛化能力差,对未来数据的预测效果不佳。简单模型(直线)虽然训练时有误差,但可能更能捕捉数据的总体趋势,泛化能力更强。避免过拟合是机器学习的核心挑战之一。

无监督学习

与监督学习不同,无监督学习的数据没有标签。我们只有一堆数据点,不知道每个点属于哪一类。
无监督学习的目标是发现数据中隐藏的规律或结构。最主流的无监督学习方法是聚类。

聚类是将数据点组织成组的过程,使得同一组内的成员彼此相似,而不同组之间的成员差异较大。这里的关键在于如何定义“相似”。

例如,如果我们有一组人的身高和体重数据,我们可以根据不同的相似性度量进行聚类:
- 如果只关心身高,可能会分成“高”和“矮”两组。
- 如果只关心体重,可能会分成“重”和“轻”两组。
- 如果同时考虑身高和体重,可能会得到四个聚类。


聚类应用广泛:
- 市场营销:发现具有相似行为的客户群体(如沃尔玛发现啤酒和尿布的购买关联)。
- 推荐系统:亚马逊根据购书习惯聚类用户和书籍,进行推荐。
- 生物学:根据特征对动植物或基因进行聚类分析。
- 保险:根据驾驶行为对司机进行聚类以评估风险。
聚类算法
我们可以将寻找最佳聚类形式化为一个优化问题。一个好的聚类应该满足:
- 组内差异小:同一聚类内的点非常相似。
- 组间差异大:不同聚类间的点非常不同。
我们可以用方差来衡量组内差异。对于聚类 C,其方差可定义为:
方差(C) = Σ (x ∈ C) (均值(C) - x)²
方差越小,说明组内点越相似。
然而,仅最小化“不良度”(如组内方差之和)会导致一个平凡解:将每个点单独作为一个聚类,此时每个聚类的方差为零,“不良度”最小,但这毫无意义。
因此,我们需要为优化问题添加约束,例如:
- 指定最大聚类数量 K。
- 指定聚类间的最小距离。
实际上,找到全局最优聚类通常是计算上不可行的。因此,实践中人们常用贪心算法。接下来我们介绍两种最常用的聚类算法:层次聚类和 K均值聚类。
层次聚类
层次聚类创建了一个聚类的层次结构(树状图)。它有两种主要策略:
- 凝聚式:从每个点作为一个聚类开始,逐步合并最相似的聚类。
- 分裂式:从所有点在一个聚类开始,逐步分裂。
我们重点看凝聚式层次聚类的步骤:
- 开始时,每个数据点自成一个聚类。
- 找到最相似的两个聚类,将它们合并。
- 重复步骤2,直到所有点合并为一个聚类,或达到预设的聚类数量。
关键问题在于:如何定义两个聚类之间的距离(相似度)?这被称为连接准则。常用的有:
- 单连接:两个聚类中最近的两个点之间的距离。
- 全连接:两个聚类中最远的两个点之间的距离。
- 平均连接:两个聚类中所有点对之间距离的平均值。

不同的连接准则会产生不同的聚类结果。


让我们看一个基于美国城市间飞行距离的例子。我们有一个距离矩阵:

应用层次聚类(单连接)的过程如下:



- 每个城市自成一类。
- 合并距离最近的波士顿和纽约。
- 合并芝加哥到{波士顿,纽约}集群(单连接下,芝加哥到纽约的距离是最近距离)。
- 合并旧金山和西雅图。
- 此时,丹佛既可以合并到东部集群,也可以合并到西部集群,这取决于连接准则(单连接可能将其归入东部,平均连接可能将其归入西部)。
- 最后合并所有集群。

如果我们想在某个层级停止,比如得到3个聚类,我们可以查看树状图在相应高度的划分。
层次聚类的缺点是计算量大,复杂度至少是 O(n²),其中 n 是数据点数量。同时,它每一步的合并是局部最优的,不保证得到全局最优的聚类结构。
特征空间与特征向量
无论是监督学习还是无监督学习,一个根本性的挑战是特征选择。我们如何表示一个数据对象?哪些属性(特征)是相关的?


在之前的城市例子中,我们只用了“飞行距离”这一个特征。现实中,一个城市可以用特征向量来表示,例如:
城市 = [经度, 纬度, 人口, 平均收入, ...]


当特征向量包含多个不同尺度、不同意义的维度(如坐标和人口)时,如何定义点之间的“距离”或“相似度”就变得复杂。我们需要处理特征标准化、加权以及选择合适距离度量(如欧氏距离、曼哈顿距离)等问题。构建能有效捕捉相似性的特征向量,是机器学习中最关键也最困难的任务之一。
总结

本节课中我们一起学习了:
- 回顾了暴力算法求解背包问题,理解了其保证全局最优但计算代价高的特点,以及问题固有的计算复杂性。
- 介绍了机器学习的基本概念,包括其目标——从数据中学习模式并预测未来。
- 区分了监督学习(有标签,包括分类和回归)和无监督学习(无标签,重点是发现结构)。
- 深入探讨了聚类这一无监督学习任务,其目标是将相似对象分组。
- 学习了层次聚类算法的步骤,以及不同的连接准则(单连接、全连接、平均连接)如何影响聚类结果。
- 认识到特征选择和特征向量构建是机器学习成功的核心,需要将现实对象转化为具有合适度量的数学表示。

下节课,我们将继续探索机器学习,特别是如何在实际问题中处理多维特征向量,并介绍另一种流行的聚类算法——K均值聚类。
028:复习课

概述
在本节课中,我们将一起复习课程的核心概念,涵盖算法分析、数据结构、面向对象编程、概率模拟等多个主题。我们将通过解析练习题、解释关键概念和演示代码示例,帮助你巩固知识,为后续学习或考试做好准备。
核心概念复习
算法与复杂度分析
上一节我们介绍了课程的整体框架,本节中我们来看看算法分析中的几个核心概念。
大O表示法用于描述算法的渐近时间复杂度,它关注输入规模增长时,算法运行时间的增长趋势。
以下是常见的时间复杂度:
- O(1): 常数时间复杂度,例如哈希表查找。
- O(log n): 对数时间复杂度,例如二分查找。
- O(n): 线性时间复杂度,例如遍历列表。
- O(n log n): 线性对数时间复杂度,例如归并排序。
- O(n²): 平方时间复杂度,例如简单的嵌套循环。
平摊分析考虑一系列操作的整体成本,而非单次操作的最坏情况。例如,虽然排序列表的成本是 O(n log n),但如果你需要进行大量(如一百万次)查找,先排序(O(n log n))再使用二分查找(每次 O(log n))的总成本,远低于进行一百万次线性查找(O(n) 每次)。
数据结构:哈希表
哈希表是一种高效的数据结构,它通过哈希函数将键映射到存储位置(或“桶”),从而实现平均情况下接近常数时间的查找、插入和删除操作。
在Python中,字典就是基于哈希表实现的。哈希函数 hash(key) 计算一个地址,用于快速定位值。
面向对象编程:多态与继承
多态允许我们使用统一的接口处理不同类型的对象。在类层次结构中,子类可以重写父类的方法。当调用一个方法时,Python会根据对象的实际类型来决定执行哪个版本的方法,调用者无需关心对象的具体类型。
例如,定义一个 Shape 基类,并让 Rectangle 和 Circle 继承它。两者都实现了 area() 方法。我们可以创建一个包含各种形状的列表,并统一调用它们的 area() 方法,而无需检查每个对象的类型。
class Shape:
def area(self):
raise NotImplementedError
class Rectangle(Shape):
def __init__(self, length, width):
self.length = length
self.width = width
def area(self):
return self.length * self.width


class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14 * self.radius ** 2
shapes = [Rectangle(3, 4), Circle(5)]
for shape in shapes:
print(shape.area()) # 多态:自动调用正确的area方法
概率与统计
标准差衡量数据分布的离散程度。变异系数是标准差与均值的比值,用于比较不同均值的数据集的相对离散程度。公式为:CV = σ / μ。
置信区间表示我们对一个统计估计值(如均值)的确定范围。对于一个正态分布,大约68%的数据落在均值的一个标准差内,95%落在两个标准差内,99.7%落在三个标准差内。
异常处理
异常处理机制允许程序在发生错误时进行优雅的恢复或报告,而不是直接崩溃。使用 try...except 块来捕获和处理异常。
try:
result = 10 / 0
except ZeroDivisionError as e:
print(f"发生了除零错误: {e}")
result = None # 或进行其他处理
pass 是一个空语句,用作占位符。continue 用于跳过当前循环的剩余语句,直接进入下一次迭代。
生成器与 yield
range() 函数返回一个列表(在Python 2中)或一个范围对象(在Python 3中,行为类似生成器)。xrange()(Python 2)或 range()(Python 3)返回一个生成器,它惰性地产生值,节省内存,特别适用于大范围迭代。
yield 关键字用于定义生成器函数,它每次产生一个值并暂停,下次从暂停处继续。
def my_xrange(n):
i = 0
while i < n:
yield i
i += 1
for num in my_xrange(5):
print(num) # 输出 0, 1, 2, 3, 4

蒙特卡洛方法
蒙特卡洛方法通过重复随机采样来获得数值结果。例如,通过随机投点来估算圆周率 π 的值。
总结
本节课中我们一起复习了计算机科学编程导论中的多个核心主题。我们回顾了算法复杂度分析(大O表示法、平摊分析)、哈希表的工作原理、面向对象编程中的多态与继承、关键的概率统计概念(标准差、变异系数、置信区间)、Python中的异常处理机制、生成器与 yield 语句,以及蒙特卡洛模拟的基本思想。理解这些概念对于编写高效、健壮和可维护的程序至关重要。
029:聚类分析进阶 🧮

在本节课中,我们将深入学习聚类分析,重点关注特征向量的处理、特征缩放的重要性,以及如何将聚类算法应用于更复杂的数据集。我们将通过具体的例子,如根据牙齿数据聚类哺乳动物和根据社会经济数据聚类美国各县,来理解这些概念的实际应用。

特征向量与距离度量
上一节我们介绍了基于简单距离(如城市间直线距离)的聚类。本节中我们来看看当特征变得更复杂时,我们该如何处理。

当我们需要用多个特征(如距离、温度)来描述一个对象(如城市)时,我们使用特征向量。特征向量是一个数字列表,每个数字代表一个特征的值。

为了比较两个特征向量,我们需要一个距离度量。一个常见的方法是计算它们之间的欧几里得距离。公式如下:

distance = sqrt((x1 - x2)^2 + (y1 - y2)^2 + ...)
然而,如果特征向量的元素单位不同(例如,距离以英里计,温度以摄氏度计),直接比较会产生误导。一个20英里的距离差异和一个20度的温度差异在物理意义和重要性上完全不同。
因此,我们必须考虑如何缩放特征向量中的各个元素,使它们具有可比性。

特征缩放的重要性
特征缩放,或称归一化,对聚类结果有巨大影响。即使特征单位相同,动态范围(数值变化的幅度)不同也会导致聚类偏向于动态范围更大的特征。
请看以下示例:假设我们根据身高(英寸)和体重(磅)来聚类人群。如果不进行缩放,身高的数值范围(例如从50到80英寸)可能远大于体重的数值范围(例如从100到200磅)。在计算距离时,身高的差异会主导结果,导致聚类主要依据身高进行划分。
如果我们对数据进行缩放,使身高和体重都归一化到0到1的范围内,那么两个特征在距离计算中就会具有同等的重要性,从而可能得到完全不同的、更合理的聚类结果。
核心要点:在进行任何机器学习(包括聚类)时,特征选择和缩放至关重要。这需要结合领域知识来思考我们试图学习什么,以及学习的目标是什么。
距离度量:闵可夫斯基度量

如何进行缩放和距离计算?通常使用闵可夫斯基度量的一个变体。两个向量 x1 和 x2 之间的距离公式为:

distance = ( Σ |x1_i - x2_i|^p )^(1/p)

其中,p 是一个参数。
- 当
p=2时,这就是我们熟悉的欧几里得距离(直线距离)。 - 当
p=1时,这被称为曼哈顿距离(网格距离)。想象在曼哈顿街区行走,你只能沿街道垂直或水平移动,不能走对角线。
曼哈顿距离在某些领域(如基因序列比较)中很常用。


处理分类数据
到目前为止,我们讨论的都是数值型、可比较的特征。但现实中,我们经常需要处理名义分类数据,即具有名称而非数字的类别(例如眼睛颜色:蓝色、棕色、绿色)。
处理这类数据,我们需要将其转换为数字,并定义这些数字之间的关系。这同样需要领域知识。例如:
- 我们可以将“蓝色”编码为0,“绿色”编码为0.5,“棕色”编码为1。这隐含地表示了我们认为蓝色和绿色比蓝色和棕色更相似。
一旦转换为数字,我们又回到了缩放(归一化) 的问题。通常,我们会将所有特征缩放到相同的范围(如0到1之间),以便比较。但请注意,有时某些特征可能更重要,需要赋予更大的权重。


实例分析:根据牙齿数据聚类哺乳动物 🦷
为了让概念更具体,我们来看一个实例:根据牙齿数据聚类哺乳动物。目标是推断它们的食性(草食、肉食等),但我们不直接使用“食性”这个标签数据。


假设是:动物的牙齿特征(各类牙齿的数量)与其食性相关。我们有一个数据库,记录了多种哺乳动物不同类型牙齿的数量。


我们使用层次聚类算法。代码结构设计如下:
- Point类:表示待聚类对象的基础抽象,包含名称、原始特征向量等。
- Cluster类:表示一个簇(一组点),包含计算簇间距离(单连接、全连接、平均连接)等方法。
- ClusterSet类:表示一组簇,包含合并最近簇等操作。
- Mammal类:
Point的子类,专门表示哺乳动物及其牙齿特征向量。
首次尝试(无缩放):直接使用原始牙齿数量进行聚类,结果不理想。例如,人类(杂食偏肉食)与一些典型草食动物被分在了一起。这是因为不同种类牙齿的数量范围差异很大,某些数量多的牙齿类型主导了距离计算。

第二次尝试(使用缩放):采用“1/最大值”的缩放方法,将每种牙齿的数量除以其在所有样本中的最大值,从而将所有特征归一化到相近的范围。
以下是缩放的核心代码逻辑:
def scale_features(features, scaling_method):
if scaling_method == 'one_over_max':
max_vals = [max(feature_i for feature_i in feature_vector) for feature_vector in all_vectors]
scaled_features = [f / max_val for f, max_val in zip(features, max_vals)]
return scaled_features
elif scaling_method == 'identity':
return features # 不进行缩放
结果:缩放后,聚类结果发生了显著变化,产生了看起来更合理的分组。一组可能对应草食动物,另一组可能对应肉食动物。尽管我们没有使用食性标签,但通过聚类,我们能够根据牙齿结构推断出可能存在不同的食性群体。

结论:这个例子清晰地表明,特征缩放至关重要,并且我们可以通过无监督学习(聚类)从结构数据中发现潜在的模式和分组。

实例分析:聚类美国各县 🗺️


现在,我们来看一个更丰富的数据集:美国所有县的社会经济数据(如房屋均价、贫困率、人口密度、65岁以上人口比例等)。

挑战:各特征的尺度差异极大(百分比是0-100,人口密度可能上万),缩放问题更加突出。
首次尝试(无缩放,仅新英格兰地区县):使用层次聚类。结果非常奇怪:马萨诸塞州的米德尔塞克斯县独自成一类,其他所有县成另一类。原因是该县的人口数量比其他县多出约60万,这个巨大的数值差异在没有缩放的情况下淹没了所有其他特征(教育水平、房价等)的影响。
第二次尝试(启用缩放):我们编写代码自动计算每个特征在所有县中的最大值,并进行“1/最大值”缩放。
# 在读取数据时动态计算最大值并缩放
max_vals = [0] * num_features
for county in counties:
features = read_features(county)
for i, val in enumerate(features):
if val > max_vals[i]:
max_vals[i] = val
scaled_counties = []
for county in counties:
features = read_features(county)
scaled_features = [f / max_vals[i] for i, f in enumerate(features)]
scaled_counties.append(Point(scaled_features, county.name))
结果:缩放后,米德尔塞克斯县与其他一些县(如康涅狄格州的费尔菲尔德县、哈特福德县等)聚在了一起,得到了一个不同的、可能更有意义的聚类结果。聚类结果的好坏取决于我们想通过聚类学习什么。
效率问题:层次聚类对于大数据集(如全美3100个县)计算量过大,因为其复杂度约为O(n²)。我们需要更高效的算法。

引入K均值聚类 ⚡
为了处理像全美各县这样的大数据集,我们引入K均值聚类算法。它的主要优点是速度快,复杂度大致为O(k * n * i),其中k是簇数,n是点数,i是迭代次数,通常远小于n。

K均值的工作步骤如下:
- 选择K:确定最终想要的簇的数量。
- 初始化质心:随机选择K个数据点作为初始质心。
- 分配点到最近质心:将每个数据点分配给距离它最近的质心,形成K个初始簇。
- 重新计算质心:对于每个簇,计算其所有点的平均值(或找到最接近平均值的点),作为新的质心。
- 重新分配点:根据新的质心,将所有点重新分配到最近的质心。
- 迭代:重复步骤4和5,直到质心的变化或点所属簇的变化非常小(即收敛)。
K均值的关键点:
- 需要预先指定K:这是该算法的一个主要参数,选择不当可能影响结果。
- 随机初始化:由于初始质心随机选择,多次运行可能得到不同结果。
- 高效:每次迭代的计算量与数据点数量成线性关系,适合大数据集。
在下节课中,我们将详细查看K均值聚类的代码,并将其应用于美国各县数据集,探索我们能从中学到什么。

本节课中我们一起学习了聚类分析中的高级主题。我们认识到将对象表示为特征向量的必要性,并深入探讨了特征缩放对聚类结果的决定性影响。通过哺乳动物牙齿和美国各县的实例,我们看到了如何在实际问题中应用聚类,以及如何处理不同尺度和类型的数据。最后,我们介绍了更高效的K均值聚类算法,为处理大规模数据集提供了工具。理解这些概念对于有效地从数据中提取信息至关重要。
030:聚类算法详解 🧮


在本节课中,我们将深入学习两种核心的聚类算法:层次聚类和K均值聚类。我们将探讨它们的工作原理、实现步骤、各自的优缺点以及适用场景。
层次聚类 🌳

上一节我们提到了数据分组的概念,本节中我们来看看层次聚类的具体步骤。层次聚类是一种自底向上的方法,它通过逐步合并最相似的簇来构建一个树状的聚类结构。

以下是层次聚类的基本步骤:
- 初始化:将数据集中的每个数据点视为一个独立的簇。
- 计算与合并:计算所有簇对之间的距离,找到距离最近的两个簇。
- 更新:将这两个簇合并为一个新的簇。
- 迭代:重复步骤2和3,直到达到预设的簇数量或满足其他停止条件。
在代码实现中,ClusterSet 类管理簇的集合,其核心方法是 mergeN。该方法循环调用 mergeOne,直到簇的数量达到目标值。

while len(self.members) > numClusters:
mergedPair = self.mergeOne(distMetric)

mergeOne 方法负责找到当前簇集合中距离最近的两个簇(通过 findClosest 方法),然后调用 mergeClusters 将它们合并。
每个 Cluster 对象包含一组点和一个质心。质心是簇中所有点的平均位置,计算公式为:
centroid = (sum(points)) / len(points)
簇间距离的计算有多种方式(即连接标准):
- 单连接:两个簇中任意两点之间的最小距离。
- 全连接:两个簇中任意两点之间的最大距离。
- 平均连接:两个簇中所有点对之间的平均距离。

层次聚类是确定性的,对于同一数据集,每次运行都会得到相同的结果。它的一个主要优点是能够展示数据在不同层次上的分组情况。
K均值聚类 🔢
上一节我们介绍了层次聚类,本节中我们来看看另一种截然不同的方法——K均值聚类。K均值是一种基于质心的迭代算法,其目标是将数据点划分到K个簇中,使得每个点到其所属簇质心的距离平方和最小。
以下是K均值聚类的基本步骤:

- 初始化:随机选择K个数据点作为初始质心。
- 分配点:对于数据集中的每个点,计算其到所有质心的距离,并将其分配到距离最近的质心所在的簇。
- 更新质心:对于每个簇,重新计算其质心(即该簇所有点的平均值)。
- 迭代:重复步骤2和3,直到质心的移动变化小于某个阈值,或达到最大迭代次数。

在代码中,主要循环如下:

while biggestChange > cutoff and iterations < maxIters:
newClusters = []
for p in points:
# 寻找距离p最近的质心对应的簇索引 smallestIndex
newClusters[smallestIndex].addPoint(p)
biggestChange = 0.0
for i in range(len(clusters)):
change = clusters[i].update(newClusters[i].points())
biggestChange = max(biggestChange, change)
iterations += 1


与层次聚类相比,K均值的主要优势在于计算效率高,尤其适用于大型数据集。然而,它也存在一些缺点:
- 结果非确定性:由于初始质心是随机选择的,多次运行可能得到不同的结果。因此,通常需要运行多次并选择最佳结果(例如,选择簇内误差平方和最小的那次)。
- 需要预先指定K:用户必须事先确定簇的数量K。
- 对异常值敏感:质心的计算受极端值影响较大。
- 可能收敛到局部最优:算法可能无法找到全局最优的聚类方案。
评估聚类质量的一个常见指标是簇的相干性,即所有点与其所属簇质心之间的最大距离。另一种更全面的指标是总误差,即所有点到其质心距离的平方和。

特征选择与缩放 🎛️


聚类结果很大程度上依赖于所使用的数据特征。在之前的例子中,County 类通过“过滤器”来选择用于计算距离的特征子集(例如,只使用财富相关特征,或只使用教育相关特征)。使用不同的特征集会导致完全不同的聚类结果。
此外,特征的尺度差异也会影响聚类。例如,如果“收入”的数值范围是数万,而“人口”的数值范围是数百万,那么“人口”特征将在距离计算中占据主导地位。为了解决这个问题,需要对特征进行缩放(或标准化),例如使用“1/最大值”方法将每个特征的值缩放到[0, 1]区间。

生成器与 yield 关键字 ⚙️
在讨论代码效率时,我们提到了生成器。与 range() 一次性生成所有数字的列表不同,xrange()(在Python 3中 range() 的行为类似 xrange)返回一个生成器对象。生成器使用 yield 关键字,它可以逐个产生值,而不需要一次性在内存中创建整个序列。
def my_xrange(n):
i = 0
while i < n:
yield i
i += 1


当函数执行到 yield 语句时,它会返回一个值并暂停,下次请求值时再从暂停处继续执行。这与 return 完全不同,return 会直接终止函数。在字典中,.iterkeys(), .itervalues(), .iteritems() 方法(在Python 3中为 .keys(), .values(), .items(),它们返回视图对象,行为类似生成器)也采用了类似的思想,以提高遍历效率。


本节课中我们一起学习了层次聚类和K均值聚类这两种核心的无监督学习算法。我们了解了它们从原理到代码的实现过程,比较了各自的优缺点和适用场景。同时,我们也探讨了影响聚类结果的关键因素,如特征选择和缩放,并介绍了 yield 和生成器这一提升代码效率的重要概念。掌握这些知识将为你在实际数据分析中应用聚类技术打下坚实的基础。
031:K均值聚类与图论基础 🧠


概述
在本节课中,我们将从K均值聚类的伪代码过渡到实际代码实现,并探讨其在真实数据集(如美国各县数据)上的应用。随后,我们将引入一种新的建模方式——图论,并学习其基本概念和代码实现。
从伪代码到真实代码 🔄
上一节我们介绍了K均值聚类的核心思想。本节中,我们来看看如何将其转化为可运行的Python代码。
以下代码实现了K均值聚类算法:
def kMeans(points, k, cutoff, pointType, maxIterations=100, printProgress=False):
# 1. 随机选择k个初始质心
initialCentroids = random.sample(points, k)
clusters = []
for p in initialCentroids:
clusters.append(Cluster([p], pointType))
biggestChange = cutoff
numIterations = 0
# 2. 迭代直到质心变化小于阈值
while biggestChange >= cutoff and numIterations < maxIterations:
newClusters = []
for i in range(k):
newClusters.append([])
for p in points:
# 找到距离p最近的质心
smallestDistance = p.distance(clusters[0].getCentroid())
index = 0
for i in range(k):
distance = p.distance(clusters[i].getCentroid())
if distance < smallestDistance:
smallestDistance = distance
index = i
# 将p添加到对应的新簇中
newClusters[index].append(p)
# 3. 更新簇并计算最大变化
biggestChange = 0.0
for i in range(k):
change = clusters[i].update(newClusters[i])
biggestChange = max(biggestChange, change)
numIterations += 1
# 4. 计算并返回统计信息
maxDiameter = 0.0
for c in clusters:
if c.diameter() > maxDiameter:
maxDiameter = c.diameter()
return clusters, maxDiameter, numIterations
代码的核心步骤包括:
- 随机初始化:从数据点中随机选择k个点作为初始质心。
- 分配点:将每个数据点分配到距离其最近的质心所在的簇。
- 更新质心:根据新分配的簇重新计算质心。
- 迭代:重复步骤2和3,直到质心的变化小于预设的阈值
cutoff。
由于初始质心是随机选择的,单次运行的结果可能不稳定。因此,通常的做法是进行多次试验(numTrials),并选择效果最好的一次(例如,簇内最大直径最小的那次)。
聚类美国各县数据 🗺️
现在,让我们将K均值算法应用于一个更大的数据集:美国各县数据。这里的关键在于特征的选择和标准化。
我们为County类添加了“过滤器”功能,以便灵活地选择用于聚类的特征。例如,我们可以只关注教育水平或性别比例。

class County(Point):
# 类变量,用于设置特征过滤器
attrFilter = None
def __init__(self, name, originalAttrs, normalizedAttrs = None):
Point.__init__(self, name, originalAttrs, normalizedAttrs)
# 如果是第一次创建County实例,则根据过滤器设置距离计算方式
if County.attrFilter is None:
County.attrFilter = list(range(len(originalAttrs)))
self.filteredAttrs = []
filterSet = County.attrFilter
for i in range(len(originalAttrs)):
if i in filterSet:
self.filteredAttrs.append(self.attrs[i])
# 重写距离计算方法,只考虑过滤后的特征
def distanceFrom(self, other):
result = 0.0
for i in range(len(self.filteredAttrs)):
result += (self.filteredAttrs[i] - other.filteredAttrs[i])**2
return result**0.5
通过设置不同的过滤器,我们可以进行实验。例如,仅使用“教育水平”特征进行聚类后,绘制各簇的平均收入,会发现其并非均匀分布,而是呈现出明显的相关性。这表明教育水平与收入之间存在关联,这正是无监督学习希望发现的“知识”。
核心要点:特征的选择至关重要。它们必须与希望探索的问题相关,并且通常需要进行标准化处理,以避免量纲差异带来的偏差。
引入图论:一种强大的建模工具 🕸️

我们的机器学习之旅是关于如何使用计算来理解世界信息的一部分,其核心在于找到有用的抽象方法来建立模型。现在,我们来看另一种极其流行的建模方式:图论。
图由节点(或称顶点)和连接节点的边组成。
- 如果边有方向(如单行道),则称为有向图。
- 如果边上带有权重(如距离、成本),则称为加权图。
图的首次著名应用是欧拉在1735年解决的柯尼斯堡七桥问题。欧拉的关键洞察是将地图抽象为图(用点代表陆地,线代表桥),从而将问题大大简化,并证明了一次走遍七桥且不重复是不可能的。
如今,图论模型应用广泛:
- 交通网络:用加权有向图表示公路或航线,可以解决最短路径、最低成本等问题。
- 万维网:用有向图表示网页间的链接关系,是搜索引擎排名算法的基础。
- 生物学:用于建模蛋白质相互作用网络、基因表达网络。
- 流行病学:用于模拟疾病传播路径。
图的代码实现 💻
让我们看看如何用Python类来实现图。以下是关键类的结构:
class Node:
def __init__(self, name):
self.name = name
class Edge:
def __init__(self, src, dest, weight=1):
self.src = src
self.dest = dest
self.weight = weight
class Digraph:
def __init__(self):
self.nodes = []
self.edges = {}
def addNode(self, node):
# 防止添加重复节点
if node in self.nodes:
raise ValueError('Duplicate node')
else:
self.nodes.append(node)
self.edges[node] = []
def addEdge(self, edge):
src = edge.src
dest = edge.dest
if not (src in self.nodes and dest in self.nodes):
raise ValueError('Node not in graph')
self.edges[src].append(edge)
def childrenOf(self, node):
return [edge.dest for edge in self.edges[node]]
class Graph(Digraph):
def addEdge(self, edge):
Digraph.addEdge(self, edge)
# 为无向图添加反向边
rev = Edge(edge.dest, edge.src, edge.weight)
Digraph.addEdge(self, rev)

设计选择:
- 继承关系:
Graph继承自Digraph。因为无向图可以看作是一种特殊的有向图(每条边都是双向的),而有向图的功能更通用。 - 数据结构:我们使用了邻接表来存储图。即,为每个节点维护一个列表,存储所有从该节点出发的边。这对于边比较稀疏的图来说更节省空间。另一种常见选择是邻接矩阵(一个N×N的矩阵),适用于边非常稠密的情况。

总结 🎯
本节课中我们一起学习了:
- K均值聚类的代码实现:理解了如何将迭代过程转化为代码,并通过多次试验选择最佳结果。
- 特征工程的重要性:在聚类县数据时,我们看到了特征选择、标准化和相关性分析的关键作用。
- 图论基础:认识了图(节点和边)作为建模复杂关系网络的强大工具,了解了其历史渊源和广泛应用。
- 图的面向对象实现:学习了如何使用
Node、Edge、Digraph和Graph类来构建和表示图结构,并理解了邻接表与邻接矩阵的区别。


下节课,我们将探索如何使用图来解决一些经典的计算问题。
032:图算法与动态编程 🧠


在本节课中,我们将学习图论中的几个核心问题,并重点介绍一种强大的优化技术——动态编程。我们将通过具体的例子和代码,理解如何高效地解决看似复杂的图搜索问题。
测试图与有向图
首先,我们通过一个简单的测试来观察图(Graph)与有向图(Digraph)的区别。我们构建一些节点,添加几条边,然后打印图结构。


以下是测试代码的核心部分:
# 构建节点和边
for name in range(10):
# 创建节点
# 添加边
pass
# 打印图
print(graph)
运行测试后,我们发现图与有向图的输出结果不同。在图(无向图)中,边是双向的;而在有向图中,边具有方向性。这个简单的测试验证了我们数据结构的基本功能。
常见的图论问题

上一节我们介绍了图的基本结构,本节中我们来看看几个在图论中非常常见且重要的问题。
以下是几个核心的图论问题:
- 最短路径问题:对于给定的一对节点 N1 和 N2,找到连接这两个节点的最短边序列。
- 公式:
最短路径 = min(所有从 N1 到 N2 的路径长度)
- 公式:
- 最短加权路径问题:这是最短路径问题的扩展,目标不是边数最少,而是路径上所有边的权重总和最小。
- 公式:
最短加权路径 = min(Σ 路径上每条边的权重)
- 公式:
- 团问题:寻找一个节点集合,使得集合中任意两个节点之间都存在路径相连。
- 最小割问题:给定一个图和两组节点,找到需要移除的最少数量的边,使得这两组节点之间不再连通。
这些问题在实际中有着广泛的应用,例如地图导航(最短加权路径)、社交网络分析(团、最短路径)和网络可靠性设计(最小割)。
实例分析:疾病传播网络
为了更具体地理解图论的应用,我们来看一个真实的案例——美国疾病控制与预防中心(CDC)在2003年对肺结核爆发的研究。

在这个模型中:
- 节点 代表人,并根据其肺结核状态(活跃、暴露阳性、阴性、未检测)用不同颜色标记。
- 边 代表人与人之间的联系,并且带有权重,表示联系的紧密程度(如密切接触、偶然接触)。
基于这个图模型,我们可以提出并形式化一些关键问题:
- 寻找零号病人:是否存在一个节点(病人),其本身患有肺结核,并且与图中所有其他肺结核患者节点都相连?这可以帮助判断疾病是否由单一源头引入社区。
- 优化疫苗分配:如果疫苗有限,应该为哪些未感染者接种,以最有效地阻断疾病传播?这可以形式化为一个最小割问题:将已感染者视为一组节点,未感染者视为另一组,找到连接这两组的关键边(即最薄弱环节),并对这些边附近的未感染者进行接种。

这个案例展示了如何将复杂的现实世界问题抽象为图模型,并利用图算法来寻找解决方案。
深度优先搜索与最短路径

现在,让我们聚焦于最短路径问题,并探讨一种基础的解决方法。我们将以社交网络(如Facebook)中的“六度分隔”理论为例。

假设我们想知道从自己到某个名人(如Donald Trump)之间最短的“朋友链”有多长。这等价于在朋友关系图中寻找最短路径。
我们使用深度优先搜索算法来解决这个问题。DFS会从起点开始,沿着一条路径尽可能深地探索,直到无法继续或找到目标,然后回溯并尝试其他路径。

以下是递归实现的DFS最短路径算法的核心代码:
def shortest_path(graph, start, end, visited=[]):
if start == end:
return [start] # 递归基础情况:已到达终点
shortest = None
for node in graph.children_of(start):
if node not in visited: # 避免循环
new_visited = visited + [node] # 创建新的已访问列表,避免副作用
new_path = shortest_path(graph, node, end, new_visited)
if new_path is not None:
if shortest is None or len(new_path) < len(shortest):
shortest = [start] + new_path
return shortest
该算法虽然正确,但在处理复杂图时效率很低。因为它会反复求解相同的子问题(例如,从某个中间节点到终点的最短路径),导致大量的重复计算。

动态编程:消除重复计算

上一节我们看到了深度优先搜索的效率问题,本节中我们来看看如何通过动态编程技术来大幅提升性能。

动态编程的核心思想是记忆化:存储已经计算过的子问题的解,当再次需要时直接查找,避免重复计算。
我们对之前的shortest_path函数进行改造,加入一个备忘录memo(字典):

def dp_shortest_path(graph, start, end, visited=[], memo={}):
if start == end:
return [start]
# 首先检查备忘录中是否已有从start到end的解
try:
return memo[(start, end)]
except KeyError:
pass # 没找到,继续计算
shortest = None
for node in graph.children_of(start):
if node not in visited:
new_visited = visited + [node]
new_path = dp_shortest_path(graph, node, end, new_visited, memo)
if new_path is not None:
if shortest is None or len(new_path) < len(shortest):
shortest = [start] + new_path
# 将计算结果存入备忘录
memo[(start, end)] = shortest
return shortest

通过对比测试,动态编程版本相比朴素DFS版本,递归调用次数从数十万次锐减到数千次,带来了巨大的性能提升。

动态编程的适用条件
动态编程并非万能钥匙,它适用于具有以下两个关键性质的问题:
- 最优子结构:一个问题的最优解包含其子问题的最优解。例如,从A到C的最短路径必然由从A到B的最短路径和从B到C的最短路径组成。
- 重叠子问题:在递归求解过程中,相同的子问题会被多次计算。这正是记忆化能够发挥作用的前提。
动态编程由理查德·贝尔曼在20世纪50年代提出,它能够将许多表面上看是指数复杂度的问题,转化为可用多项式时间高效求解的问题,是算法设计中极其重要的工具。


本节课中我们一起学习了图论中的几个经典问题(最短路径、团、最小割),并通过社交网络和疾病传播的实例加深了理解。我们重点剖析了深度优先搜索算法的低效根源,并引入了强大的动态编程技术。通过记忆化存储子问题的解,我们避免了重复计算,从而实现了算法性能的质的飞跃。记住动态编程的两个适用条件——最优子结构和重叠子问题,这将帮助你识别哪些问题可以用这种方法优雅地解决。
033:图与搜索算法

在本节课中,我们将学习图的基本概念,并深入探讨两种重要的图搜索算法:深度优先搜索和广度优先搜索。我们将通过简单的例子和代码来理解它们的工作原理。
图的基本概念
上一节我们介绍了课程背景,本节中我们来看看什么是图。
图是一种形式化表示,它包含顶点和边。顶点可以看作是一组事物,而边则表示这些事物之间的关系。例如,你和你的朋友以及朋友之间的友谊关系就可以构成一个图。
以下是图的核心组成部分:
- 顶点:也称为节点,代表图中的实体。
- 边:也称为弧,连接两个顶点,表示它们之间的关系。
图的类型
了解了图的基本构成后,我们来看看图的几种主要类型。
有向图与无向图
有向图中的边具有方向,只能沿着箭头的方向从一个节点移动到另一个节点。例如,从波士顿到蒙特利尔的路径可能只能按特定方向前进。
无向图中的边没有方向,可以在两个相连的节点之间自由移动。从波士顿到蒙特利尔可能只需一步。
加权图
加权图不仅表示顶点间存在关系,还为每条边关联了一个权重或成本。例如,在道路网络中,权重可以代表通行费或距离。一个常见的问题是找到两点之间的最小成本路径。
深度优先搜索
上一节我们介绍了图的类型,本节中我们来看看深度优先搜索算法。
深度优先搜索是一种探索图的策略,它沿着一条路径尽可能深地搜索,直到无法继续,然后回溯并尝试其他路径。其核心思想是从目标节点反向构建最短路径。
以下是深度优先搜索查找最短路径的伪代码逻辑:
def shortest_path_dfs(graph, start, end, visited=[]):
# 检查起点和终点是否在图中
if start not in graph or end not in graph:
return None
# 如果起点就是终点,路径只包含自身
if start == end:
return [start]
# 初始化最短路径
shortest = None
# 遍历当前节点的所有子节点
for child in graph.children_of(start):
if child not in visited:
# 标记为已访问,防止循环
new_visited = visited + [child]
# 递归查找从子节点到终点的最短路径
new_path = shortest_path_dfs(graph, child, end, new_visited)
# 如果找到路径,且比当前最短路径更短,则更新
if new_path:
if shortest is None or len(new_path) < len(shortest):
shortest = new_path
# 如果找到了从某个子节点到终点的路径,则将当前节点添加到路径前端
if shortest:
return [start] + shortest
else:
return None
算法从起点开始,询问每个子节点到目标节点的最短路径,然后选择最短的那条,并将自己添加到该路径的前面,从而从后向前构建出完整的最短路径。visited 参数用于记录已访问的节点,避免陷入循环。
广度优先搜索
上一节我们探讨了深度优先搜索,本节中我们来看看广度优先搜索算法。
广度优先搜索采用不同的策略:它从起点开始,逐层向外探索所有可能的路径。其核心思想是从起点向前构建路径,优先探索所有距离起点为一步的节点,然后是两步的节点,依此类推。
以下是广度优先搜索的算法步骤:
- 初始化一个队列,其中只包含从起点开始的路径(即只包含起点本身的路径)。
- 从队列中取出最短的路径(最初就是起点路径)。
- 检查该路径的最后一个节点:
- 如果它是目标节点,则找到最短路径,算法结束。
- 如果不是,则将该节点的所有未访问过的子节点,分别形成新的路径(原路径加上子节点),并添加到队列中。
- 重复步骤2和3,直到队列为空或找到目标节点。
在代码实现中,我们通常用一个列表来存储待探索的路径,并使用一个Lambda函数作为键对列表进行排序,以确保总是优先探索长度最短的路径。
# 示例:使用lambda函数按路径长度排序
paths = [ (['A', 'B'], 2), (['A'], 1) ]
paths_sorted = sorted(paths, key=lambda x: x[1]) # 按元组第二个元素(长度)排序
# 结果:[(['A'], 1), (['A', 'B'], 2)]
广度优先搜索保证当找到目标节点时,所发现的路径就是最短路径(在边没有权重的情况下)。与深度优先搜索相比,它需要更多的内存,因为需要同时存储许多部分路径。
总结


本节课中我们一起学习了图的基本概念,包括顶点、边、有向图、无向图和加权图。我们重点探讨了两种基础的图搜索算法:深度优先搜索和广度优先搜索。深度优先搜索通过递归深入探索单条路径,并从目标节点反向构建答案;而广度优先搜索通过队列逐层扩展,从起点正向构建并保证找到最短路径。理解这两种算法是解决许多图论问题的基础。
034:动态规划与0/1背包问题 🎒



在本节课中,我们将深入学习动态规划,并探讨如何将其应用于解决经典的0/1背包问题。我们将回顾动态规划的两个关键属性,并分析背包问题是否满足这些属性,从而理解动态规划为何能显著提升算法效率。
动态规划回顾
上一节我们介绍了动态规划,并展示了它如何为最短路径问题提供实用的解决方案。这种方案允许我们快速解决理论上复杂的问题。我们讨论了使动态规划成为可能的关键属性。
动态规划适用于具有以下两个属性的问题:

- 最优子结构:我们可以通过组合局部子问题的最优解,来构建全局最优解。
- 重叠子问题:在算法运行过程中,我们会多次遇到并需要解决相同的子问题。
例如,归并排序具有最优子结构,因为它通过先排序子列表再合并来排序整个列表。然而,它没有重叠子问题,因此在排序过程中不会遇到相同的子列表。所以,我们不能用动态规划来解决排序问题。
最短路径问题分析
那么,最短路径问题是否具备这两个属性呢?

首先看最优子结构。如果已知从A到C的最短路径是 A -> B -> D -> E -> C,那么这条路径上的任何子路径,例如从B到E的路径 B -> D -> E,也必须是B到E的最短路径。否则,从A到C的最短路径就会选择另一条路。这正是我们在上一讲中利用的动态规划子结构。

其次,最短路径问题也存在重叠子问题,因为我们在计算过程中会反复求解相同的中间节点对之间的最短路径。


因此,最短路径问题同时具备这两个属性,适合用动态规划解决,并且实际运行速度很快。

0/1背包问题分析



0/1背包问题同样具备这两个属性。我们之前通过回溯法(决策树)递归地解决了这个问题。




以下是该问题的一个简单示例,包含物品A、B、C、D及其价值和重量:
| 物品 | 价值 | 重量 |
|---|---|---|
| A | 6 | 3 |
| B | 7 | 3 |
| C | 8 | 2 |
| D | 9 | 5 |

假设背包容量为5。其决策树的一部分如下所示(为简化,未画出全部):
开始 (剩余物品: A,B,C,D, 剩余容量:5, 当前价值:0)
├── 拿A (剩余物品: B,C,D, 剩余容量:2, 当前价值:6)
│ ├── 拿B? (重量3 > 剩余容量2) -> 不可行
│ ├── 不拿B (剩余物品: C,D, 剩余容量:2, 当前价值:6)
│ │ ├── 拿C (剩余物品: D, 剩余容量:0, 当前价值:14) -> 叶节点
│ │ └── 不拿C (剩余物品: D, 剩余容量:2, 当前价值:6)
│ │ └── 拿D? (重量5 > 剩余容量2) -> 不可行
│ └── ...
└── 不拿A (剩余物品: B,C,D, 剩余容量:5, 当前价值:0)
└── ... (继续探索)

这种回溯算法在物品数量稍多时就会变得非常慢。对于N个物品,决策树最多有 2^N 个节点,是指数级复杂度。
最优子结构
在决策树和代码中,最优子结构是显而易见的。每个父节点通过比较两个子节点的解(拿或不拿当前物品)来选择更优的一个,从而向上传递最优解。代码中的关键部分正是“选择更好的分支”。
重叠子问题
重叠子问题则不那么明显。初看决策树,每个节点似乎都在解决不同的问题:剩余物品列表和可用容量构成的组合各不相同。
然而,关键在于:决定接下来拿什么物品,只取决于“剩余哪些物品”和“剩余多少容量”,而不取决于“已经拿了哪些物品”。可能存在许多不同的已拿物品组合,其总重量相同,从而留下完全相同的子问题需要解决。
例如,假设前四个物品的价值都是2。在决策树中,可能通过拿物品1和3用掉了4单位容量,也可能通过拿物品2和4用掉了4单位容量。当到达这个状态(剩余容量为某值,剩余物品列表为某后缀)时,解决后续子问题的最优方案与之前是如何用掉这4单位容量的无关。
因此,只要物品重量可能重复,使得不同物品组合能凑出相同的总重量,就会产生重叠子问题。这使得动态规划可以大显身手,通过记忆化避免重复计算。
动态规划实现:带记忆化的快速解法
现在,让我们看看如何将标准的递归解法 solve 改造成动态规划版本的 fastSolve。核心是为递归函数添加一个备忘录 memo,用于存储已计算过的子问题结果。
以下是实现时需要注意的两个技术细节:
- Python默认参数的可变性陷阱:在函数定义中,如果默认参数是可变对象(如列表、字典),它只会在函数定义时被创建一次。后续所有调用都会共享同一个对象,导致错误。正确的做法是使用
None作为默认值,在函数内部进行初始化。def fastSolve(toConsider, avail, memo=None): if memo is None: memo = {} # 每次调用都初始化为新的空字典 # ... 其余代码 - 递归深度限制:Python默认的递归深度限制可能较小。对于深度递归,可以使用
sys.setrecursionlimit()提高限制。import sys sys.setrecursionlimit(10000)
fastSolve 的核心逻辑如下:
- 检查备忘录
memo,如果当前(len(toConsider), avail)这个键已存在,则直接返回存储的结果。 - 如果
toConsider为空或avail为0,返回基础解(0, ())。 - 否则,考虑第一个物品:
- 如果太重(
weight > avail),则递归调用fastSolve,不考虑此物品。 - 否则,计算两种选择:
- 拿:递归调用,物品列表移除第一个,可用容量减去其重量,价值加上其价值。
- 不拿:递归调用,物品列表移除第一个,可用容量和价值不变。
- 比较两种选择的结果,取价值更高的一个。
- 如果太重(
- 将当前子问题的最优解存入
memo,然后返回该解。
性能对比与分析
我们通过实验对比了朴素递归解法 solve 和动态规划解法 fastSolve 的性能。

当物品重量为1到10之间的整数时:
solve在物品数达到16时(约2^16次调用)就已非常缓慢,在32时无法在合理时间内完成。fastSolve则能轻松处理1024个物品,仅耗时约2秒,且调用次数随物品数近似线性增长,性能提升巨大。
运行时间分析


fastSolve 的运行时间主要取决于需要计算并存入备忘录的不同子问题的数量。子问题的键是 (len(toConsider), avail)。
len(toConsider)的可能值最多为物品数量N。avail的可能值取决于初始容量和物品重量的组合情况。如果物品重量取值范围小,不同重量和就少,子问题数量就少,算法就快。反之,如果物品重量是连续浮点数,几乎每个子问题都不同,动态规划就退化成朴素递归,恢复指数级复杂度。



这种算法被称为伪多项式时间算法。它在输入数值(如重量)范围不大时表现优异,但在最坏情况下仍可能是指数级的。
总结
本节课我们一起深入探讨了动态规划。我们回顾了其两大基石——最优子结构和重叠子问题,并分析了最短路径问题和0/1背包问题如何满足这些条件。

我们重点将0/1背包问题的回溯解法改造为动态规划解法,通过添加备忘录避免重复计算,实现了从指数级到近似多项式级的巨大性能飞跃。同时,我们也了解了其局限性:当问题不具备真正的重叠子问题时,动态规划的优势将不复存在。
通过本讲,你应当掌握识别动态规划适用问题的能力,并学会实现基本的带记忆化的动态规划算法来解决类似优化问题。
035:统计学的陷阱与谬误

在本节课中,我们将探讨如何从统计数据和图表中得出有意义的结论,以及如何识别和避免常见的统计陷阱与谬误。我们将学习到,即使数据本身是真实的,统计方法的选择、图表的呈现方式以及逻辑推理的错误都可能导致完全错误的结论。
统计学的历史背景
上一节我们介绍了课程的基本框架,本节中我们来看看统计学的起源。在17世纪中叶之前,人们主要进行定性而非定量思考。他们缺乏统计学的概念,只能依靠直觉和轶事来理解世界。
这种情况在17世纪中叶发生了戏剧性的变化。一位名叫约翰·格兰特的英国人出版了《对死亡账单的自然与政治观察》。这是有记录的历史上第一部真正使用统计学的著作。他研究了伦敦市相当全面的死亡统计数据,并试图建立一个模型来预测瘟疫的传播。事实证明,他的模型相当有效。这彻底改变了人们的思维方式。
自那时起,人们开始使用统计学来提供信息,但不幸的是,有时也用来误导。有些人故意使用统计学来误导他人,而另一些人则仅仅是能力不足。这引出了我们今天要讨论的主题:统计学。
统计学的误导性
本杰明·迪斯雷利曾说过,世界上有三种谎言:谎言、该死的谎言和统计学。这句话不幸地包含了很多真理。更近一些,在20世纪50年代,达雷尔·哈夫写了一本很棒的书,名为《统计数字会撒谎》。书中有这样一句话:“如果你无法证明你想证明的东西,那就去证明别的东西,并假装它们是同一回事。在统计学与人类思维碰撞之后的日子里,几乎没有人会注意到其中的差异。”这似乎是真的。
因此,今天我想讨论几种可能使人从统计数据中得出不当结论的方式。我相信你们只会将这些信息用于好的方面,使自己成为更好的数据消费者和提供者,而不是更好的说谎者。但这取决于你们自己。
陷阱一:统计量无法讲述完整故事
首先,我们需要记住,无论你的统计数据有多好,统计量本身并不能讲述完整的故事。我们在学期早些时候已经看到过这样的例子。
从一个数据集中可以提取出大量的统计量。通过精心挑选和选择,你几乎可以就同一数据集传达任何你想要的印象。当然,最好的解药是查看数据本身。
1973年,统计学家约翰·安斯科姆发表了一篇论文,其中包含以下数据集。他给出了四组不同的X和Y值。有趣的是,从许多方面来看,这四个数据集的统计数据非常相似。
以下是计算这些统计量的代码示例:
# 假设 data_sets 是一个包含四个 (x, y) 数据对的列表
for i, data in enumerate(data_sets):
x = [point[0] for point in data]
y = [point[1] for point in data]
mean_x = sum(x) / len(x)
mean_y = sum(y) / len(y)
# ... 计算其他统计量,如中位数、方差、相关性、线性回归拟合等
它们具有相同的均值、中位数、方差,X和Y之间的相关性相同,甚至使用线性回归得到的拟合结果也非常相似。我可能会写一篇论文,告诉你从统计意义上讲,这些数据集都是相同的。我运行的每一个统计结果都说这些数据集无法区分。
然而,这些数据集真的无法区分吗?一种方法是实际绘制数据点。当我们这样做时,会看到相当戏剧性的结果。例如,图2和图1看起来并不相似。图3看起来完全不同,而图4则截然不同。将图4与图1进行比较,差异显著。
这里的教训很简单,我们之前也看到过:永远不要忽视数据本身。不要只看数据的统计量,要设法查看数据本身。
陷阱二:图表可能具有欺骗性

上一节我们强调了查看原始数据的重要性,本节中我们来看看图表的潜在误导性。毫无疑问,图表对于快速传达信息非常有用。然而,如果使用不慎或带有恶意,图表可能极具误导性。
让我们看一个关于中西部房价的图表。我们这里有一个图表,年份是2006、2007,然后在2008、2009年,我们使用了季度数据。你可能记得2008年房地产市场发生了一个事件,引发了全球金融危机。看这个图表,我们对这一时期中西部房价的印象是什么?我的印象是它们非常稳定。你可能会发表这个图表并说,看,房价真的没有太大变化,也许下降了一点,但没什么大不了的。
如果我们与另一个图表进行比较,它使用的是完全相同的数据。现在,我问你关于中西部房价的情况,你可能会告诉我它们非常不稳定。事实上,这里显然发生了某种可怕的事件。
完全相同的数据,两个图表,都是真实的,但对所发生事情的印象却截然不同。右边的图表(在你的讲义中)被设计成显示房价高度不稳定。那么区别是什么?我用了什么技巧来制作这两个图表?
其中一个技巧是,在第一个图表中,我使用了对数刻度绘制Y轴,这总是让变化看起来比线性刻度小。在这个图表中,我使用了线性图。另一个技巧是,我作弊了。我这里显示的是完整的年份,然后我转到了季度。所以,在我的图表中,部分X轴的分辨率很宽,是一整年,而另一部分则是一个季度。毫不奇怪,因为我们知道房价有季节性变化,春季和冬季的房价不同。一旦我开始绘制季度数据,即使没有发生崩盘,在后续年份中看起来也会不稳定得多,因为我改变了X轴的分辨率。
我没有撒谎,你可以从图例中看出我做了什么。但我肯定可以用这些图表愚弄很多人。

陷阱三:垃圾进,垃圾出
另一个常见的严重统计错误被称为“垃圾进,垃圾出”。它如此常见,以至于人们通常用其首字母缩写GIGO来指代。
一个典型的例子发生在1840年。美国人口普查显示,自由黑人和混血儿中的精神错乱发生率大约是 enslaved 黑人或混血儿的10倍。由此得出的结论是显而易见的。美国参议员、前副总统、后来的国务卿约翰·C·卡尔霍恩根据人口普查得出结论:“这次人口普查中揭示的关于精神健全的数据是无可辩驳的。我们的国家必须由此得出结论,废除奴隶制对非洲人来说将是一场灾难。”毕竟,如果你把他们从奴隶制中解放出来,他们都会发疯。统计数据是这么说的。
然而,很快人们就清楚,事实上那次人口普查充满了错误。马萨诸塞州居民、前副总统约翰·昆西·亚当斯回应卡尔霍恩说,不,这是一个荒谬的结论,人口普查充满了错误。非常有耐心的卡尔霍恩向亚当斯解释道:错误太多了,以至于它们相互抵消,得出了相同的结论,就好像它们都是正确的一样。错误刚好足够多,以至于你可以忽略它们。
他依赖的是什么?如果他想让这个陈述在数学上更精确,他应该说什么?他基本上暗示测量误差是无偏的且相互独立的。因此,误差几乎均匀地分布在均值两侧。
如果他做出了这个更精确的陈述,那么你就可以进行有意义的讨论(假设与约翰·卡尔霍恩进行有意义的讨论是可能的,这或许值得怀疑),讨论误差是否确实是独立的。因为如果它们不是,例如,如果它们代表了数据编译者的偏见,那么你就不能依赖统计方法来声称它们会相互抵消。
记得在高斯的时代,高斯在谈到正态分布时说过,如果我们进行这些天文测量,并假设我们的误差是独立且正态分布的,那么我们可以观察均值并假设它接近真实值。这些都是重要的假设,而在本例中,这些假设被证明是不正确的。事实上,后来表明误差并没有很好地相互抵消。今天,你可以说无法从那次普查中得出任何统计结论。

另一方面,最近,美国国家研究委员会(可能是美国最负盛名的学术组织)发布了一份全国所有大学的排名。后来表明,这份排名充满了垃圾输入。他们对最终被证明是错误的数据进行了广泛的统计分析并予以发布。这非常尴尬。好消息是麻省理工学院在这次分析中名列前茅。坏消息是我们不能断定它真的应该名列前茅,因为谁知道数据的质量如何。但这有点尴尬。
陷阱四:相关性与因果关系的混淆
另一个常见的用统计学撒谎的方式是利用所谓的“cum hoc ergo propter hoc”谬误。它的意思是“伴随此,因此由于此”。我不知道为什么,但统计学家像医生和律师一样,喜欢用拉丁语来炫耀。

例如,这是一个统计事实:包括麻省理工学院学生在内的大学生,定期听课的学生比只是偶尔听课的学生有更高的平均绩点。这告诉我们,在座的各位可能比那些没有来上6.00课程的学生的平均绩点更高。我希望这是真的。
现在,如果你是讲授这些课程的教授,你愿意相信这是因为讲座信息量极大,使得来上课的学生变得更聪明,因此他们表现得更好。所以我们想假设因果关系:因为我讲了精彩的课,你选择来听,你将在6.00课程中获得更好的成绩。
是的,存在相关性。这无疑是正确的。但很难直接跳到因果关系。例如,也许关键在于,那些费心来听课的学生也费心去做习题集,只是更认真负责。无论他们是否来听课,他们更认真负责这一事实会给他们带来更好的平均绩点。除了进行对照实验,我不知道有什么方法可以区分这两件事。也许每天把你们一半人赶出课堂,看看效果如何。但这很危险。
同样,你可能会读到像教职员通讯这样的东西,上面会谈论来听课是多么重要,因为你会做得更好。因为写那篇教职员通讯文章的人不理解这个谬误,或者只是一厢情愿。
另一个很好的例子,一个不久前出现在新闻中的例子,与流感有关。这是纽约州近年来的流感病例数。你会注意到2009年有一个高峰,那就是著名的猪流感疫情,我相信你们都记得。现在,如果你仔细看,甚至不用太仔细,你会注意到一个相关性:在学校上课期间,流感发生得更多。事实上,在学校上课的那些月份,流感病例比学校不上课的月份要多。高中、大学等等。事实上,相关性相当强。
这导致许多人得出结论:上学是感染流感的一个重要致病因素。所以,也许你不应该来听课,因为这样做只会让你感染流感。事实上,正因为如此,在猪流感疫情期间,许多家长不送孩子上学。事实上,在一些社区,许多学校因此关闭。
让我们想一想。正如你可以用这种相关性来得出结论说上学会导致猪流感,你也可以用它来证明流感导致你去上学,因为流感季节高峰期时在学校的人更多。因此,是流感的增长导致人们去上学。从这个数据来看,这是一个同样有效的统计假设。有点奇怪,但这是真的。就像我们可以得出结论说高平均绩点导致人们来听课一样。你每天早上查看你的平均绩点,如果足够高,你就来听课,否则就不来。你也可以从数据中得出这个结论。
这里的问题是你必须考虑是否存在所谓的潜伏变量。一些与其他两个变量相关的变量,也许那才是致病因素。例如,这里的一个潜伏变量是学校学期与夏季重合。事实上,如果你在实验室研究流感病毒,你会发现它在寒冷天气中比在炎热潮湿天气中存活时间更长。当天气寒冷干燥时,流感病毒在物体表面存活的时间更长。因此,事实上,也许是天气,而不是学校的存在,导致流感在一年中的某些时候更具传染性。事实上,这很可能是真的。所以有一个潜伏变量我们必须考虑,也许那才是致病因素。
这实际上可能导致世界上一些非常糟糕的决策。我对医疗保健和公共卫生相关的问题特别感兴趣。2002年,大约有600万美国妇女接受激素替代疗法,认为这会大大降低她们患心血管疾病的风险。有人认为,达到一定年龄的妇女,如果服用额外的激素,她们患心脏病的可能性就会降低。这一观点得到了几项发表在高度 reputable 期刊上的研究的支持,这些研究显示了接受激素替代疗法与未患心血管疾病之间的强相关性。这些数据已经存在一段时间了,正如我所说,到2002年,美国大约有600万妇女在接受这种疗法。
同年晚些时候,《美国医学会杂志》发表了一篇文章,断言事实上接受这种疗法会增加妇女患心血管疾病的风险,使你更有可能患心脏病。这怎么可能发生?在新的研究发表后,人们回过头重新分析了旧的研究,发现该研究中接受激素替代疗法的妇女比组内其他妇女更有可能拥有更好的饮食和锻炼习惯。事实上,她们是更注重健康的妇女。所以存在饮食、锻炼和其他方面的潜伏变量,这些实际上可能是健康状况更好的致病因素,而不是替代疗法。但在最初的数据分析中,这个潜伏变量没有被发现。

所以我们看到的是,接受激素替代疗法和改善心脏健康是一个共同原因(即注重健康)的 coincident effects。有点奇怪,但却是真的。这是一个悲伤的故事。
陷阱五:无应答偏差与非代表性样本
另一个需要警惕的是无应答偏差,以及相关的非代表性样本问题。你可能还记得,当我第一次开始谈论统计学和随机数的使用时,我说所有的统计技术都基于这样一个假设:通过对总体中的一个子集进行抽样,我们可以推断出整个总体的情况。这是真的。通常,因为如果使用随机抽样,你可以假设随机样本的结果分布(如果样本足够大)将与整个总体的结果分布相同。这就是为什么我们通常希望进行随机抽样。在我们看过的所有模拟中,我们都使用随机抽样来确保少量样本能代表总体,然后我们使用统计技术来回答需要多少随机样本的问题。
但这些技术只有在样本确实是随机的情况下才有效。否则,你可以随心所欲地分析,但你得出的任何结论都可能是谬误的。
不幸的是,许多研究,特别是在社会科学中,基于所谓的便利抽样。例如,如果你查看心理学期刊,你会发现许多心理学研究使用本科生群体作为研究对象。他们为什么这样做?是因为他们相信本科生能代表整个人口吗?不,是因为本科生是 captive audience,他们必须同意参与。如果你碰巧在大学里,对本科生做实验很方便。所以他们这样做,然后他们说,嗯,本科生和整个人口是一样的。你可能已经观察到,至少在这个机构,本科生可能不能代表整个人口。
一个众所周知的例子发生在第一次世界大战期间。每当一架盟军飞机从德国上空的轰炸任务返回时,飞机会被检查,看高射炮弹击中了哪里。所以飞机会飞过去投炸弹,德国人会用高射炮射击飞机,试图把它们从空中打下来。飞机返回英国后,会被检查。他们会说,平均而言,高射炮弹击中飞机这个部位比那个部位更频繁。因此,他们会加固飞机上他们预计高射炮会击中的那些部位的蒙皮,试图使飞机在未来的任务中(或泛指飞机)更不容易受损。
这有什么问题?他们抽样的不是那些从未从轰炸任务中返回的飞机。那些飞机无法被抽样。事实上,也许情况是,他们正在加固的是那些被高射炮击中无关紧要的部位,因为那不会导致飞机坠毁,而没有加固那些最容易因高射炮受损的部位。他们做了一个便利抽样,得出了结论,并且可能在他们选择加固飞机的部位上完全做错了。
这种特殊的错误被称为无应答偏差。例如,当你进行某种调查时,有些人不回应,因此你忽略了他们可能给出的答案。也许我们在做6.00课程的地下指南时会看到这种情况。事实上,我应该指出,它现在在线上了。如果你们每个人都能去评价这门课程、评价讲师、评价助教等等,那就太好了。我们确实会阅读这些评价,它会影响我们在后续学期中如何教授这门课程。但显然存在偏差。也许只有那些对课程有强烈感受的人(无论是积极还是消极)才会费心填写调查。我们得出的结论是存在双峰分布,没有人认为课程一般般,因为他们懒得回应。或者也许只有讨厌这门课的人回应,我们就认为每个人都讨厌这门课。谁知道呢?这是一个大问题。
我们今天在电话民意调查中也看到了这个问题,存在更多的便利抽样或非代表性样本。许多民意调查使用电话进行。根据法律,这些民意调查机构不能拨打手机。所以他们只拨打固定电话。你们中有多少人有固定电话?记录显示没有人。你们的父母有多少人有固定电话?记录显示几乎所有人都有。这意味着,在进行例如总统候选人提名的民意调查时,你的父母比你更有可能被抽样。因此,任何基于电话的这些民意调查都会有偏差。不幸的是,他们的民意调查可能只说“电话样本”,人们可能没有意识到这意味着整个人口中的一部分被抽样不足。这样的例子有很多。
陷阱六:数据增强与过度解读
我们经常看到的另一个问题是数据增强。人们很容易从数据中解读出比实际含义更多的东西,尤其是在脱离上下文的情况下。

例如,2009年4月29日,CNN报道称:“墨西哥卫生官员怀疑猪流感疫情已导致超过159人死亡,约2500人患病。”这在当时是相当可怕的事情,人们开始担心猪流感。另一方面,你们认为美国每年有多少死亡归因于常规的季节性流感?平均每年有36000人死于季节性流感。这从某种程度上说明了159例猪流感死亡也许不应该那么可怕。
但同样,人们通常不会同时报告这两个数据。另一个很棒的(且准确的)统计数据是:大多数汽车事故发生在离家10英里以内。我相信你们很多人都听说过。那么这意味着什么呢?几乎没有任何意义。大多数驾驶都是在离家10英里以内进行的。除此之外,这里的“家”是什么意思?“家”指的是汽车的注册地址。所以,如果我选择在阿拉斯加注册我的车,这是否意味着我在麻省理工学院附近开车时发生事故的可能性更小?我不这么认为。同样,这有点毫无意义。
这个问题的另一个方面是人们经常从数据中进行外推。我们可以看一个互联网使用率的例子,这个也很有趣。我在这里绘制的是美国互联网使用率占人口的百分比。我从1994年开始绘制。这里的蓝线是数据点,绿线是线性拟合。如果你看我的代码,你会看到我使用polyfit函数并设置参数为1来得到一条拟合线。你可以看到这是一个相当不错的拟合。
人们实际上看了这些东西并用它来外推未来的互联网使用率。我们可以这样做。现在,我们运行相同的代码,开启外推功能。图1和之前一样,相同的数据,相同的拟合。这是图2。你会注意到,截至去年,大约115%的美国人口在使用互联网。这可能不是真的。在体育运动中也许可以给出110%的努力,但在统计学中不行。
同样,当人们做这些预测时,你总是看到这种情况:他们拟合一些数据,然后外推到未来,而不理解为什么这可能不是一件好事。顺便说一下,我们在模拟弹簧时也看到了这一点,我们可以准确地线性预测,直到我们超过了弹性常数,此时我们的线性模型完全失效。所以你总是需要有一些理由(不仅仅是拟合数据)来相信你所做的事情实际上是有意义的。
陷阱七:德克萨斯神枪手谬误
最后我想讨论的,在文献中通常被称为德克萨斯神枪手谬误。这有时有点难以理解。这里有人来自德克萨斯吗?哦,很好。那么没有人会因此感到被冒犯。
想象一下,你正开车行驶在德克萨斯州的某条乡村道路上。你看到一个谷仓,那个谷仓上画着六个靶子,每个靶子的正中心都有一个弹孔。你开车经过,印象非常深刻,于是停下来,看到谷仓的主人,你说:“你一定是个神枪手。”他说:“当然,我从不失手。”就在这时,农夫的妻子走出来说:“没错,整个德克萨斯州没有哪个男人用喷漆枪比他更准了。”他做了什么?他朝谷仓开了六枪,枪法很差,子弹到处都是。然后他走过去,在每个弹孔周围画了一个靶子。看起来他就像个神枪手。
你可能会想,嗯,这太傻了。实际上没人会这么做。但事实上,在实践中这种情况经常发生。这类谬误的一个经典例子出现在2001年的《新科学家》杂志上。它报道说,由阿伯丁皇家康希尔医院的约翰·伊格尔斯领导的一个研究小组发现:“厌食症女性最有可能出生在春季或初夏,即3月至6月之间。”事实上,在这些月份出生的厌食症患者平均多出13%以上,而6月份出生的厌食症患者比平均水平高出30%。
现在,让我们看看这个令人担忧的统计数据。你们这里有女性是6月出生的吗?好吧,我不会询问你们的健康史。但也许你应该担心,或者也许不必。让我们看看他们是如何进行这项研究的。你可能会想知道为什么这么多有缺陷的研究都是关于女性健康的,也许是因为它们都是由男医生做的。
该团队研究了446名被诊断为厌食症的女性。如果你把它除以12,你会发现平均每个月应该有37名女性出生。事实上,在6月份,有48名厌食症女性出生。于是他们说,嗯,这种情况纯属偶然发生的可能性有多大?正如我在这种场合惯常做的那样,我检查了他们的分析。我写了一段代码来做这件事。
为了计算6月份有48名女性出生的概率,我运行了一个模拟。在模拟中,我模拟了446次出生,并随机选择了一个月份,然后观察概率。让我们看看运行结果。6月份至少有48次出生的概率是0.042。事实上,相当低。你可能会说,嗯,这种情况偶然发生的几率很小。因此,也许我们真的发现了什么。也许这与出生时的条件、天气或谁知道什么有关。
那么,这个分析有什么问题?一种看待它的方式是,如果研究人员从假设开始,即6月份出生的未来厌食症患者比其他任何月份都多,然后进行这个实验来检验它,并验证了它,那么这个分析将是完全有效的。所以,如果他们从假设开始,然后进行所谓的前瞻性研究,那么他们或许有正当理由相信该研究支持这个假设。
但这不是他们所做的。相反,他们做的是查看数据,然后选择一个与数据匹配的假设。这就是德克萨斯神枪手谬误。鉴于他们进行的实验,正确的问题不是问“6月份有48名未来厌食症患者出生的概率是多少”,而是问“在12个月中,至少有一个月有48名未来厌食症患者出生的概率是多少”,因为这才是他们真正在做的事情。
因此,我们真的应该运行这个模拟。与之前的模拟类似,这些也在你的讲义中。是否存在至少一个月,其中有48次出生?如果我们运行这个,我们会看到概率超过40%。不像4%那么令人印象深刻。所以,事实上,我们可能不应该得出任何结论。这种情况纯属偶然发生的概率几乎是50%。那么我们为什么要相信它有意义呢?
这又是德克萨斯神枪手谬误的一个例子,出现在文献中,很多人上当了。如果我们有更多时间,我会给你们更多例子,但我们没有时间了。我们周二再见,还有两节课。周二,我将讲解一些代码,这些代码是我要求你们为期末考试准备的。然后在周四,我们将进行总结。
总结

在本节课中,我们一起学习了从统计数据中得出可靠结论时需要注意的多种陷阱与谬误。我们了解到,统计量本身可能具有误导性,图表可以被操纵来呈现特定印象,低质量的数据输入会导致无意义的输出(GIGO)。我们重点区分了相关性与因果关系,认识到潜伏变量的存在可能完全改变对数据的解释。我们还探讨了无应答偏差和非代表性样本如何扭曲结果,以及过度解读数据和不当外推的危险。最后,我们剖析了德克萨斯神枪手谬误,即先有数据后构建假设的错误做法。


理解这些陷阱是成为负责任的统计数据消费者和生产者至关重要的一步。关键在于始终保持批判性思维,审视数据来源、分析方法及其呈现方式,并始终考虑是否存在其他合理的解释。
036:动态编程 🧠

在本节课中,我们将要学习动态编程的核心概念。动态编程是一种优化算法设计的技术,其核心思想是避免重复计算已经解决过的子问题。我们将通过几个经典例子来理解其工作原理和实现方式。
动态编程的核心思想
动态编程的关键在于,对于一个问题,你希望找到一种方法,使得每个计算只执行一次。这是一种“懒惰”的形式,旨在提高效率。
一个能够用动态编程解决的问题需要具备两个关键属性。
以下是这两个关键属性:
- 重叠子问题:这意味着我们可以将一个大问题分解为若干个更小的、相同类型的子问题。然后,我们可以利用这些较小子问题的解来构建原问题的解。
- 最优子结构:这指的是,如果我们能获得某个子问题的最优解,那么我们就可以利用这个最优解来得到原问题的最优解。
这两个属性紧密相关,但并不完全相同。
示例一:斐波那契数列 🔢

为了开始,我们先看一个大家熟悉的函数:斐波那契数列。
我们之前已经见过无数次了。很明显,这里存在重叠子问题。F(n) 的解是 F(n-1) 和 F(n-2) 的解之和。因此,如果我结合这两个较小斐波那契实例的解,就能得到较大斐波那契实例的解。
在屏幕上,你有一个之前见过的示例函数。我将运行这个斐波那契函数,从0到30(实际上是n=29)。我们将观察它执行所需的步骤数。

当计算到 Fibonacci(29) 时,大约需要160万步来计算这个值。观察图表,Y轴是对数坐标。蓝点代表函数实际执行的步骤数(即需要执行计算的次数)。蓝色实线代表 2^n。红线代表黄金比例 φ^n,这是该版本斐波那契数列的紧确上界。绿线是二次函数的一部分。这里没有什么特别令人惊讶的,我们之前已经确定它效率很低。


然而,我们可以通过动态编程使其更高效。让我们画出 F(5) 的调用树。我们看到在很多地方我们重复了计算:F(2) 被计算了三次,F(3) 被计算了两次。动态编程背后的思想是,与其一遍又一遍地重复这些计算,不如只计算一次。

观察这个递归斐波那契的实现,我们看到第一个递归调用会沿着树的这一侧向下进行。结果是,F(5)、F(4)、F(3)、F(2)、F(1)、F(0) 这些值都会在任何一个分支返回之前被计算出来。

因此,我们可以这样做:与其在这里重新计算 F(2),不如在这里计算它,然后将这个值保存在某个“大脑”(记忆存储)中。对 F(3) 和 F(4) 也做同样处理。当我们进行第二个递归调用时,例如在调用 Fibonacci(3) 并计算了 F(2) 和 F(1) 之后,我们返回到 Fibonacci(4)。下一个递归调用将是 Fibonacci(2)。但由于我们已经计算过它并将其保存在“大脑”中,我们可以直接查找这个值,而无需再次进行所有计算。

对于像这样非常小的树,好处并不巨大。但如果你有像 F(100) 这样的大树,这将产生巨大的差异。

让我们来看一下。这是一个带有额外参数 memo 的斐波那契实现。memo 就是我所说的“大脑”,我们将把计算过的值存放在这里,这样就不必再次计算它们。



当函数最初被调用时,如果 memo 是 None(即我们没有传入字典,或者这是对该函数的第一次调用),那么它会将键 0 设为 0,键 1 设为 1。这些对应 F(0) 和 F(1)。
完成初始化后,它会执行到这个 if 语句。它将检查 n 是否在 memo 中。这实际上是在问:我们在计算这个大斐波那契数的过程中,以前见过这个数字吗?如果它在这棵树的下方,并且正在查看 F(2),它会问:我之前进行这个计算时,见过 Fibonacci(2) 吗?如果它在这棵树中,那么答案是肯定的,因为它在这里见过。如果它没见过,那么它就必须做实际工作,即进行递归调用。这就是它沿着树的左侧分支向下遍历的时候。
我们要做的就是把值存储在这里。最后,我们只需返回 memo 中存储的值。让我们看看这个版本如何运行。旧的实现需要160万步,而现在只需要57步。事实上,它是线性的,恰好是 2n - 1 步。这为你节省了大量工作,因为一旦它计算了 F(4) 这整个子树,当它看到 F(3) 时,它会查看它的“大脑”,发现已经见过 F(3),于是它说:我已经见过这个了,所以我不需要在这里进行所有这些计算,不需要进行所有这些递归调用,我只需要返回字典中的值。这就是节省的来源。
示例二:机器人路径问题 🤖
让我们看另一个例子。重点是,如果你代码写得正确,可以获得巨大的节省。让我们看一个不同的问题。
假设我有一个机器人,它位于一个网格上。网格有 N 行和 M 列。机器人从这里出发,想要到达这里。但这个机器人非常笨,它只能向下和向右移动。问题是:在给定这些约束条件下,从左上角方格到右下角方格有多少条唯一的路径?
如果你尝试用分析方法来做,可能会很困难。我认为更简单的方法是意识到,要到达目标格子 G,它只能从两个地方来:可以从这里来,也可以从这里来。因此,进入 G 的唯一路径总数,等于进入这个格子的唯一路径总数加上进入那个格子的唯一路径总数。这就是你的重叠子问题。同时,如果你能算出这两个数字,你就能算出这个数字,这就是最优子结构。然后,对于这些格子,同样的条件也适用:如果我知道这两个数字,我就能算出这个数字;如果我知道这两个数字,我就能算出这个数字。

另外,如果我遇到一个 1 x M 的网格这种情况,从这里到这里有多少种不同的方式?只有1种。对于 N x 1 的网格也是如此。

以下是该问题的第一个实现尝试。这是一个递归函数。我们要做的就是:如果我们只有一行或一列,我们返回1。否则,我们查看 (n-1) x M 矩阵中的机器人路径数,以及 n x (M-1) 矩阵中的路径数。


让我们在 14 x 14 的网格上运行这个。这需要几秒钟。有大约1000万条唯一路径,但它花了2000万步才计算出来。

我们将对这个问题使用与斐波那契数列相同的技巧:我们将记忆化(或备忘)不同的路径或不同的解。只是我们的键会有点不同,它将是 (n, m)。同样,如果 (n, m) 不在 memo 中,那么我们需要计算它。如果 n 或 m 等于1,我们将其记为1。如果两者都大于1,那么我们将查看 (n-1, m) 和 (n, m-1) 的路径数。我们还知道解是对称的,所以 (n, m) 和 (m, n) 的解是相同的。然后返回解。

让我们试试这个。我们得到了相同的答案,但只用了104步。所以这是一个相当大的节省。

自顶向下与自底向上


上面的例子是自顶向下动态编程解决方案的一个例子。我们审视大问题,然后将其分解为两个较小的子问题,再将这些较小的子问题进一步分解。

我们也可以自底向上地进行。我们可能出于某种原因想要这样做,我稍后会展示。
自底向上的方法将从起点开始,而不是从终点回溯。例如,在机器人路径问题中,自顶向下是从终点问“从这两个格子有多少种方式到达我”,而自底向上是从起点问“有多少种方式到达这里”(答案是1),然后问“到达这里有多少种方式”。
想象我们只有一个 2 x 2 的网格。它将查看到达左边方格和上方方格的方式数,并将其相加到这里。在这个版本中,我们正在做的是朝着解决方案的方向“增长”我们的网格。
这个实现只是建立了一个矩阵。顶行初始化为1,第一列初始化为1。然后对于其他所有格子,我们只需添加上方的行和左边的列。最后返回右下角的值。
这样我们以不同的方向得到了相同的解。
现在,你可能想这样做是因为,想象一下,如果不是14,而是1400。递归版本应该会崩溃。Python 有最大递归深度限制。如果你有太多的递归调用,它会告诉你无法深入那么深,然后把你踢出去。我有1400行和1400列,那是大量的递归。但如果我们以迭代方式(没有递归)来做,那么这就是唯一路径的数量(一个相当大的数字)。这是你进行的调用次数,对于一个1400x1400的矩阵来说,这不算太差。
示例三:硬币找零问题 💰
刚才那个比较简单。现在我们要看一个稍微难一点的。
这是硬币找零问题。假设我有一种货币,其硬币有特定的面值。问题是,假设我有一个总金额,问题是:有多少种不同的硬币组合等于这个总金额?
分解这个问题的方法是:首先考虑,如果我使用集合中最大面值的硬币至少一次,组合数会是多少。另一个子问题是,如果不使用最大面值的硬币,组合数是多少。
这实际上变成了一个更简洁的子问题:对于 总金额 - 最大面值 这个新总额,有多少种组合?对于第二个子问题,我仍然有原来的总金额,但硬币集合现在去掉了最大面值的硬币(例如,原来最大是27分,现在最大是25分)。
这个大问题的解,是这两个子问题的解之和。
让我们看一下这个问题的第一个版本实现。函数参数是 total(我们想要的金额)和 coins(我们货币中的硬币集合)。我们首先检查 total 是否为零。这意味着我们试图找出等于零的硬币组合,当然只有一种组合:没有硬币。所以这返回1。第二种情况是,如果我们尝试使用一个对于当前总额来说太大的硬币,导致总额变为负数。这意味着我们不能为这个特定的总额使用那枚硬币。没有有效的组合。最后一种情况是,如果我们不再有硬币了(硬币集合为空),而我们仍然需要凑出一些金额(total 仍有值),那么这意味着我们正在尝试的这种特定组合也无效,我们返回0。


我们的第一个递归调用是寻找不使用最后一枚(最大)硬币的方式数。我们传入之前的总金额,以及除了最大一枚之外的所有硬币(每次递归调用去掉一枚)。第二种情况是,如果我们确实使用了至少一枚最大面值的硬币。在这种情况下,我们将从总金额中减去该面值,并原样传入硬币集合。然后我们返回两者之和。
这是没有任何记忆化的版本,只是使用递归,将其分解为两个子问题并找出解。

现在我们将使用动态编程来优化它。在这种情况下,我们有自己的记忆化字典。你注意到这里的模式了吗?我们将基于我们试图寻找的总金额和我们拥有的最大货币面值来进行记忆化。如果它不在我们的字典中,那么我们将计算它。一旦我们得到解,我们就将其记忆化并返回。
现在让我们尝试一个不同的表述。这个有点棘手。想象我有一个网格。在行方向上,我有从0到总金额的数字。在列方向上,我有不同的货币面值。
前两个实现是自顶向下的,现在我们要自底向上。阅读这个表格的方式是:如果我总金额为0,并且我的最大货币面值是5、10、25或27,那么等于这个总金额的硬币组合数是多少?在第一行(总金额为0),那是我的基本情况,都是1。在这里(第一列,硬币集合为空),我们知道这都将是0,因为如果我没有硬币。
自底向上的方法是,我们将填充这个表格。我们将遍历所有可能的总金额。对于每个总金额,我们将遍历所有最大的硬币面值。然后我们将进行两次检查。我们将查看当前正在查看的总金额,假设我们在第1行,所以总金额是1。我们将减去我们正在查看的最大面值(从5开始)。从1中减去5,结果小于0。当它小于0时,我们要做的就是将当前总金额的组合数“携带”到下一个较小的最大面值。所以如果我们在面值5这里,并且知道小于0,那么我们将把这个值(0)带过来。

然后我们将对所有值做同样的事情。现在让我们看一个有趣的情况。现在我们看总金额为5。当我们的最大硬币是5时,我们从5中减去它,结果不小于0。这意味着我们将进入 if 语句的第二个分支。同样,我们将查看使用最大硬币的方式数。为此,我们将使用我们的表格。我们将查看当前总金额(5)减去我们正在查看的最大硬币(5),结果是0。这将索引到总金额为0的这一行。然后我们查看当前硬币(5),所以这将是1。这是使用最大硬币的方式数。然后我们查看不使用最后一枚硬币的方式数。如果这是我们正在查看的最大硬币,那么没有它,我们拥有的硬币集合就少了它,所以那将是0。因此 0 + 1 = 1。
对于面值10,同样,这变成0。下一个有趣的是总金额10。这将是0,这将是 1 + 0,这将是 1 + 1。我们只是在填充这个表格。因为最小的面值是5,所以只有一种方法用5来凑出5。如果有1分的硬币,这里会是2。但因为我们只有5分,所以只有一种方法凑出5。如果有25分是最大的,你可以用5分。这可能会让人困惑,但这个疯狂的方法是有效的。
让我们扩展这个。使用所有三个版本。所有三个版本都给出相同的输出,只是它们所采取的步骤数不同:初始版本用了855步,带记忆化的版本用了209步,而不带记忆化但自底向上的版本只用了337步。这只是攻击同一问题的三种不同方式。客观地说,后两种比第一种好。我们可能会想到一些情况,我们更愿意使用基于表格的方法而不是递归方法,例如,如果我们要探索大量的组合,或者遇到递归深度太深导致Python无法处理而崩溃的情况,就像我们在机器人路径问题中遇到的那样。这将是选择哪种算法优于另一种的标准。
总结 📝


本节课中我们一起学习了动态编程。我们了解到动态编程通过避免重复计算子问题来优化算法,其适用的问题必须具有重叠子问题和最优子结构两个特性。我们通过斐波那契数列、机器人网格路径和硬币找零三个经典例子,分别探讨了自顶向下(带记忆化的递归)和自底向上(迭代填表)两种实现方式,并比较了它们的效率差异和适用场景。动态编程是一种强大的工具,能够将某些指数级复杂度的问题优化为多项式级,是算法设计中不可或缺的技术。
037:排队网络模拟 🚌


在本节课中,我们将学习排队网络模拟。排队网络为我们提供了一种研究等待现象在系统中扮演关键角色的形式化方法,例如超市结账、计算机任务调度和公共交通系统。我们将通过一个简化的MIT校车系统模拟示例,来探讨如何构建和分析此类系统。
概述 📋
排队网络模拟是分析复杂系统性能的重要工具。在本次课程中,我们将:
- 理解排队网络的基本组成部分和关键概念。
- 学习如何用代码构建一个离散事件模拟。
- 通过模拟MIT校车系统,分析不同设计决策(如车辆容量、速度和排队规则)对平均等待时间等性能指标的影响。
- 探讨模拟的局限性以及现实世界中的其他考量因素。
排队网络基础
上一节我们介绍了课程安排。本节中,我们来看看排队网络的基本模型。
所有排队系统的建模方式本质上相同。其核心流程如下:
- 作业 到达。
- 作业进入队列并等待。
- 作业离开队列,进入服务器接受服务。
- 作业完成服务后离开。
当然,现实系统可能更复杂,涉及多队列、多服务器和多个作业流,但模拟分析通常将其分解为上述基本组件。

系统组成部分

以下是构建排队网络模型时需要关注的几个方面:
1. 到达过程
作业如何出现?我们需要考虑:
- 到达方式:是单个到达还是成批到达?
- 时间分布:到达时间间隔是均匀的、固定的,还是随机的?最常用的是泊松过程,其到达时间间隔服从指数分布,具有无记忆性,并由平均到达率 (\lambda) 这一单一参数描述。


2. 服务机制
服务器如何工作?我们需要考虑:
- 服务时间分布:完成一个作业所需的时间,取决于作业本身的工作量和服务器的速度。
- 服务器数量:有多少个可用的服务器。
- 队列结构:是每个服务器有自己的专属队列,还是多个服务器共享一个公共队列?理论上,单队列能提供更好的平均服务。
- 是否允许抢占:服务器能否中断当前作业去处理另一个更紧急的作业?
3. 队列特性
队列本身的行为规则至关重要:
- 排队规则:如何从等待的作业中选择下一个服务对象?常见的规则有:
- FIFO:先进先出。
- LIFO:后进先出。
- SRPT:最短剩余处理时间优先。此规则能有效减少平均等待时间和队列拥堵,但可能导致“长作业”永远得不到服务的“饥饿”问题,因此需要考虑公平性。
关键性能指标
分析排队系统时,我们关心以下问题:
- 平均等待时间:作业在队列中等待的平均时长。
- 等待时间上限:作业等待时间超过某个阈值的概率。
- 平均队列长度:队列中作业的平均数量。
- 队列长度上限:为防止内存溢出等问题而设置的队列最大长度限制。
- 服务器利用率:服务器处于繁忙状态的预期时间比例。高利用率节省成本,但可能导致服务质量下降。
通过为这些指标分配相对成本,我们可以优化系统设计,在服务质量和资源利用之间找到平衡。

模拟方法 🖥️
上一节我们定义了排队网络的关键要素。本节中,我们来看看如何通过模拟来分析它们。
分析排队网络主要有两种方法:解析法和模拟法。由于现实系统通常过于复杂,难以用解析公式精确建模,因此离散事件模拟成为当今最常用的分析工具。接下来,我们将通过一个具体的校车系统示例来学习如何构建这样的模拟。
示例:MIT校车系统模拟
我们构建一个简化的MIT校车系统模型。校车沿环形路线运行,在多个站点停靠上下客。每辆车有最大容量,并以一定速度行驶。核心问题是:学生在某个站点等待上车的平均时间是多少?以及如何通过调整车辆容量、速度等参数来优化等待时间?
以下是模拟代码的核心类结构:
1. 作业类
作业代表需要服务的实体(如乘客)。我们假设到达时间间隔服从指数分布,服务时间(如上车耗时)服从高斯分布。

class Job(object):
def __init__(self, arrivalTime, work):
self._arrivalTime = arrivalTime
self._work = work
self._queueTime = None # 记录进入队列的时间
2. 乘客类
乘客是作业的一种特例。
class Passenger(Job):
# 到达率对应乘客到达公交站,工作量对应乘客上车所需时间
pass
3. 队列基类
定义队列的基本行为,如添加作业。具体的出队规则由于类实现。

class JobQueue(object):
def __init__(self):
self._queue = []
self._length = 0
def addJob(self, job):
self._queue.append(job)
self._length += 1


4. 具体队列规则
- FIFO队列:先进先出。
- SRPT队列:最短剩余处理时间优先。每次出队时,遍历队列找到工作量最小的作业。
class SRPT(JobQueue):
def getNextJob(self):
if self._length == 0:
raise IndexError('Queue is empty')
# 找到工作量最小的作业索引
minIndex = 0
for i in range(1, self._length):
if self._queue[i]._work < self._queue[minIndex]._work:
minIndex = i
# 弹出并返回该作业
nextJob = self._queue.pop(minIndex)
self._length -= 1
return nextJob
5. 公交站点类
公交站点本质上是一个队列,可以方便地切换不同的排队规则(如FIFO或SRPT)。
class BusStop(FIFO): # 初始使用FIFO,可改为SRPT
pass
6. 服务器类:公交车
公交车作为服务器,具有容量、速度等属性,并可以上下客。

class Bus(object):
def __init__(self, capacity, speed):
self._capacity = capacity
self._speed = speed
self._passengers = []
def enter(self, passenger):
if len(self._passengers) >= self._capacity:
raise ValueError('Bus is full')
self._passengers.append(passenger)
def leave(self):
if len(self._passengers) > 0:
return self._passengers.pop()


模拟主循环
模拟的核心是一个按时间步进的主循环,其伪代码如下:

创建所有公交站点
初始化统计变量(如总等待时间)
while 模拟时间未结束:
根据车速移动公交车
在每个站点,按概率生成新到达的乘客
if 公交车到达某个站点:
部分乘客下车
公交车在站期间,按排队规则让等待的乘客上车(同时时间继续流逝)
记录乘客的等待时间等数据
计算并输出平均等待时间、剩余乘客数等统计结果
在完整代码中,我们运行多次模拟试验,汇总结果,并绘制图表以分析不同参数组合(如容量=30,速度=10 vs 容量=15,速度=20)对系统性能的影响。

模拟结果与分析 📊
上一节我们构建了模拟的框架。本节中,我们来看看运行模拟后得到的结果,并分析其含义。
运行模拟后,我们可以观察不同设计选择的影响:
1. 容量与速度的权衡
- 当公交车容量较小但速度较快时,与容量较大但速度较慢时,可能产生相近的平均等待时间。
- 这表明在系统设计中存在权衡:可以通过提高速度来弥补容量的不足,反之亦然。


2. 排队规则的影响
- 将站点的排队规则从FIFO改为SRPT后,平均等待时间显著下降。这验证了SRPT在减少平均延迟方面的理论优势。
- 然而,SRPT的等待时间曲线可能出现波动和跳跃,这可能是由于“饥饿”效应或系统动态特性导致,值得进一步分析代码逻辑。
3. 模拟的局限性
模拟是对现实的简化。例如,在我们的模型中,乘客下车是随机的,且上车时间与旅行距离无关。虽然简化,但只要抓住关键因素,仍能帮助我们理解变量间的相对影响和趋势,为设计决策提供依据。
一个思考题


假设你需要雇佣6.00课程的助教,有两种选择:
- 雇佣1名MIT学生,时薪$15,每小时能解决
X个问题。 - 雇佣2名哈佛学生,每人时薪$7.5,每人每小时能解决
X/2个问题。
仅从排队网络模型看,两者的“服务能力/成本”比可能相同。但模拟或简单模型无法反映全部现实,例如MIT学生可能解答更准确。这提醒我们,在应用模型结论时,必须考虑模型未涵盖的实际因素。



总结 🎯

本节课中我们一起学习了排队网络模拟。
- 我们了解了排队系统的基本组成部分:到达过程、服务机制和队列特性。
- 我们探讨了关键的性能指标,如平均等待时间和服务器利用率,以及如何在它们之间取得平衡。
- 我们通过构建一个简化的MIT校车系统离散事件模拟,实践了如何用代码对复杂系统进行建模和分析。
- 我们分析了模拟结果,看到了容量与速度的权衡,以及SRPT排队规则在提升效率方面的作用及其潜在的公平性问题。
- 最后,我们认识到所有模型都是现实的简化,在解释和应用模拟结果时必须保持谨慎,并考虑更广泛的上下文。

排队网络模拟是一个强大的工具,可以帮助我们设计和优化从计算机系统到交通网络在内的各种现实世界系统。
038:课程总结与计算机科学家的世界 🌍


在本节课中,我们将回顾计算机科学家的工作范畴,并总结本学期课程的核心内容。我们将探讨计算思维的本质,并通过实际研究案例了解计算机科学如何应用于解决现实世界中的复杂问题,特别是医疗健康领域的挑战。
计算机科学家做什么?💻
上一节我们介绍了课程的整体框架,本节中我们来看看计算机科学家具体从事哪些工作。计算机科学家的工作范围极其广泛,几乎无所不包。
以下是计算机科学家涉足的一些领域:
- 确保飞机飞行安全的软件系统。
- 电影工业中的动画与3D特效制作。
- 机器人技术的研发。
- 医学影像分析,例如识别脑部肿瘤以辅助外科手术。
- 遗传学领域的研究。
- 维护互联网稳定运行的核心技术。

从根本上说,计算机科学家进行计算思维。这正是6.00课程的核心主题:如何将问题公式化并进行计算思考。这将是21世纪中叶乃至更早,每个人都需要掌握的关键技能,其重要性不亚于阅读、写作和算术。

计算思维的过程 🔄
计算思维的过程并非易事,但也并非不可企及。整个学期我们都在实践这个过程。


这个过程始于识别和发明有用的抽象。我们所做的一切都是对现实的抽象。在课程后半段,我们设计的几乎所有有趣计算都始于发明能提供有用数据抽象的类,或是能计算有用事物的函数。我们始于一组现有的抽象,并发明有助于思考当前问题的新抽象。
接着,我们将问题的解决方案公式化为一个计算实验。然后设计、构建一个足够高效的实现。它不必是最优的,只需足够高效以运行并获得答案。
在信任答案之前,如同对待任何实验装置(程序本身就是一种实验装置)一样,我们需要调试实验,说服自己当程序运行时,我们应该相信其结果。
当我们认为实验已正确组装后,便运行它。随后评估结果,并根据需要重复这一经典的、迭代式的科学过程。这与在化学、物理或生物实验室中设计任何实验的步骤相似,区别在于这些都是计算实验。
计算思维的两个“A” 🤔
我们认为计算思维有两个“A”:抽象和自动化。
你必须选择正确的抽象。我们通常在多个抽象层次上同时操作,这是计算思维的重要组成部分。我们需要能够不关心某些细节(例如浮点数是如何实现的),而只需假设它是实数的一个良好近似。在另一个抽象层次,我们思考公交车站队列,并且可能需要自己实现它们。关键在于思考各层次之间的关系。
计算抽象与其他许多抽象的不同之处在于我们可以自动化它们。我们不仅能够发明抽象,还能将其机械化。这得益于两个原因:第一,我们拥有精确而严格的符号来表达抽象、构建计算模型(本学期是Python、Pylab和NumPy,但也可能是Java、C++或Matlab)。第二,我们拥有可以执行这些符号所描述的计算的机器。这种结合——算法与可执行它们的机器——自20世纪40年代末以来彻底改变了世界。
计算思维实例 🧠
以下是一些计算思维的实例:
- 问题难度与最佳解法:理论计算机科学家致力于精确界定问题的难度并寻找最优解法。
- 递归思维:递归思维至关重要,其核心在于将一个看似困难的问题重新表述为一个我们已经知道如何解决的问题。我们探讨过几种方法:
- 归约:将问题归约为一个已解决的问题。
- 嵌入:将一个不同的问题嵌入到当前问题中,作为解决方案的一部分。
- 变换:将一个问题变换为另一个(通常更简单的)我们知道如何解决的问题。
- 模拟:使用模拟作为处理问题的机制。
研究案例:医疗健康领域的计算应用 🏥
现在,让我们通过一个特定计算机科学家(即讲师本人)的研究工作,具体了解计算思维的应用。我们的研究小组目标相当宏大:帮助人们延长寿命并提高生活质量。
我们主要通过与临床研究人员和执业医生合作,在医学领域开展工作。在此过程中,我们运用了大量本学期所学的技术思想。
我们致力于解决以下问题:
- 医疗远程呈现:解决早产儿护理问题。通过技术将社区医院与拥有专业新生儿护理知识的中心医院连接起来,改善早产儿的预后。这涉及一系列有趣的计算机网络和通信问题。
- 医疗相关感染:大约每20次住院就诊中,就有1次导致患者感染与入院原因无关的疾病。我们与微软合作,分析数百万次就诊数据,使用机器学习技术(如特征选择、加权、聚类、监督学习)来探究感染原因(如特定药物、病房清洁不足等),并提出预防措施。
- 从生理信号中提取信息:我们专注于心脏、大脑及相关解剖结构。主要研究两个方向:预测不良心脏事件和癫痫管理。
癫痫预测与管理 🧠
癫痫影响着全球约1%的人口,其特征是反复发作的癫痫发作。癫痫发作是大脑中产生并持续的异常电活动。

癫痫发作的挑战在于其看似不可预测性,这可能导致严重伤害甚至死亡(称为癫痫猝死)。我们的目标是尝试检测癫痫发作并提前发出警告。

研究发现,癫痫发作存在两个不同的起始时间:临床发作(出现临床症状)和更早的电图发作(大脑出现异常电活动)。我们希望早期检测电图活动。
困难在于,不同患者的脑电图差异巨大,且癫痫患者即使在未发作时,基线脑电图也异于常人。过去35年,人们试图构建适用于所有人的通用检测器,但效果不佳,误报率极高。
好消息是,对于特定个体而言,癫痫发作的起始模式相当一致。这提示我们应该构建患者特异性检测器,而非通用检测器。我们利用机器学习成功实现了这一点,并正在进行前瞻性研究,尝试在检测到发作时启动神经刺激器,以期减弱或阻止发作。
心脏事件预测与干预 ❤️

急性冠状动脉综合征(可理解为某种心脏病发作)在美国每年约发生125万例。其中15%-20%的患者在未来四年内会死于心脏相关事件。

如果能够识别出心脏骤停风险最高的人群,并为其植入心脏复律除颤器(ICD),就可以挽救生命。ICD能在检测到心脏停止或严重颤动时给予电击,使其恢复正常跳动。

然而,当前的问题是我们不知道哪些人属于高风险人群。因此,我们使用其他标准决定谁应植入ICD,但大多数时候我们是错误的。一项研究表明,在72个月的跟踪期内,植入ICD的患者与未植入的患者在生存率上差异不大。实际上,90%的植入者从未触发ICD,因此未获得医疗益处,反而承受了手术风险、不适和感染(如医院获得性感染)的可能。

因此,关键在于改进风险分层。我们采用了一种与课程内容紧密相关的方法:“托尔斯泰”式风险分层法。托尔斯泰曾说:“幸福的家庭都是相似的,不幸的家庭各有各的不幸。”我们将其引申为:健康的心脏都是相似的,而不健康的心脏各有各的不同。

我们通过量化不同人心脏电活动的差异来进行分析:
- 将心电活动转换为符号。
- 使用动态规划高效处理大量数据(约10亿次心跳)。
- 使用聚类(特别是层次聚类)来识别心脏活动模式相似的患者。
结果显示,通过聚类,我们可以识别出死亡风险显著不同的患者群体。例如,在一个数据集中,最大的集群(457名患者)死亡率低于1%,而另一个小集群(53名患者)死亡率约为3.77%,某些特定集群的风险则更高。这种方法有望用于预测哪些患者最可能从ICD等治疗中获益。

学期内容总结 📚
现在,让我们总结本学期的学习内容。希望你们能感受到自己的巨大进步。


我们探讨了六大主题:

- 计算表达符号:学习了Python。希望你们明白这不是唯一的符号,学习第二门编程语言(如Matlab、Java)会容易得多。
- 编写与调试程序的过程:通过实践学习了如何从问题陈述(如“校车应该更大还是更快以改善服务?”)过渡到问题的计算表述和解决方法。
- 基本算法与食谱:学习了动态规划、深度优先搜索、决策树等重要算法。好消息是,重要的核心算法并不多,掌握后便可反复应用。
- 使用模拟:重点学习了使用模拟来阐明那些不易获得封闭解的问题。这是计算日益重要的领域,尤其适用于处理现实世界中带有随机性的复杂问题。
- 使用计算工具建模和理解数据:学习了绘图、少量统计学知识、机器学习(监督学习、无监督学习、特征向量),并重点讨论了如何选择特征,因为这通常是机器学习成败的关键。
- 抽象与系统化问题解决:这是贯穿始终的核心主题。

为什么选择Python?🐍
- 易于学习:语法简单,是解释型语言,调试方便。
- 内存管理自动化:无需像C语言那样手动管理内存。
- 现代且支持面向对象:以优雅的方式支持类等面向对象编程概念。
- 流行且库丰富:在MIT和业界日益流行,拥有如Pylab、NumPy、random等强大的库。
编程、测试与调试心得 🛠️
- 循序渐进:理解问题,先思考整体结构和算法,再考虑如何用代码实现。
- 分解问题:将问题分解为小部分,识别有用的抽象,独立编码和测试每个单元。
- 先功能,后优化:先让程序正确工作,再考虑提高效率。
- 系统化调试:运用科学方法——提出假设,设计实验验证假设,运行实验,检查结果。要缓慢、仔细、有条理。
- 换位思考:当程序行为异常时,问“它为什么做了这些?”而不是“它为什么没做我想要的?”。从前者的角度更容易调试。
从问题陈述到计算的策略 💡
- 分解问题:将大问题分解为小问题。
- 关联已知问题:尝试将你的问题与别人(理想情况下是已经)解决的问题联系起来。例如,这是背包问题吗?是最短路径问题吗?
- 先设计输出:通常在设计程序之前,先思考希望看到什么样的输出或图表。
- 表述为优化问题:思考能否将其表述为在满足一定约束条件下,寻找目标函数的最小值或最大值。
- 接受近似解:有时无法完美解决问题,可以寻找一个更简单问题的解作为足够好的近似。或者,通过一系列逼近完美答案的解来解决问题(如牛顿法)。
算法与建模 📊
我们学习了大O表示法和各种特定算法,特别是优化问题算法。
在建模方面,我们牢记“所有模型都是错的,但有些是有用的”。它们提供了对现实的抽象。我们重点学习了两种模拟模型:蒙特卡洛模拟和排队网络;统计模型如线性回归;以及图论模型。
在理解数据方面,我们讨论了统计技术、绘图以及机器学习。
未来之路与结语 🚀
本学期你们付出了巨大努力,教学团队对此表示衷心感谢。希望你们觉得这份投资是值得的。
请记住,在未来的职业生涯中,你们现在已经可以编写程序来解决需要解决的问题,不要畏惧去这样做。
如果喜欢这门课,还有很多其他计算机科学课程可供选择。基于在6.00中学到的知识,你们已经有能力选修许多更高级的课程。你们可以考虑主修课程6(电气工程与计算机科学),或考虑新的计算机科学与分子生物学联合专业,并且绝对有资格去寻找涉及严肃编程的实习或工作。
最后,以一些“著名的遗言”作为结束,提醒大家保持谦逊和对世界的清醒认识。祝各位期末考试顺利,更重要的是,度过一个愉快的暑假!😊



本节课中我们一起学习了计算机科学家广泛的工作范畴、计算思维的核心过程(抽象与自动化),并通过癫痫预测和心脏事件风险分层等医疗研究案例,看到了本学期所学的编程、算法、建模和机器学习知识如何应用于解决现实世界的重大挑战。最后,我们系统回顾了本学期涵盖的Python编程、算法设计、模拟、数据分析和问题解决策略等核心主题,为未来的学习与应用奠定了基础。

浙公网安备 33010602011771号