MIT-6-100L-计算机科学与-Python-编程笔记-全-
MIT 6.100L 计算机科学与 Python 编程笔记(全)
001:课程介绍与编程基础 🐍

在本节课中,我们将要学习计算机科学的基本概念,了解计算机如何工作,并开始学习Python编程语言的基础知识,包括对象、类型、表达式和变量。
计算机与算法 🧠
上一节我们介绍了课程概述,本节中我们来看看计算机科学的核心思想。
计算机是执行算法的机器。算法是一系列明确的步骤,用于解决特定问题或完成特定任务。计算机本身并不“聪明”,它们只是严格遵循我们给出的指令序列。
计算机擅长两件事:
- 存储大量数据。
- 以极快的速度执行操作。
但计算机只会做你告诉它去做的事情。这是编程中的一个核心理念。
声明式知识与命令式知识


在数学中,我们常处理声明式知识,即对事实的陈述。例如:
数字x的平方根是y,满足 y * y = x。






然而,计算机不理解这种陈述。我们需要提供命令式知识,即一个“食谱”或算法,告诉计算机如何一步步找到答案。




例如,求16的平方根的一个简单算法:
- 设定一个初始猜测值
guess(例如3)。 - 如果
guess * guess足够接近16,则停止,guess就是答案。 - 否则,计算新的猜测值:
new_guess = (guess + 16/guess) / 2。 - 用
new_guess替换guess,并重复步骤2。
这个算法包含了控制流(如“如果...否则...”和“重复”)和一个终止条件。





Python编程基础 ⚙️

上一节我们讨论了算法,本节中我们来看看如何在Python中实现这些想法。
编程语言像人类语言一样,由基本构件(原语)组成,并通过语法规则组合起来。




对象与类型


在Python中,我们通过创建和操作对象来编程。每个对象都有一个类型,类型决定了你可以对该对象执行哪些操作。
Python中的基本(标量)对象类型包括:
- 整数 (
int): 如5,0,-100 - 浮点数 (
float): 如3.27,2.0,-3.14159 - 布尔值 (
bool): 只有两个值:True和False - 空类型 (
NoneType): 只有一个值:None

使用 type() 函数可以查看对象的类型:
type(7) # 输出: <class 'int'>
type(7.0) # 输出: <class 'float'>
type(True) # 输出: <class 'bool'>



类型转换
你可以将一个对象转换为另一种类型,但这会创建一个新对象,而不是修改原对象。
float(3) # 将整数3转换为浮点数3.0
int(5.9) # 将浮点数5.9转换为整数5(直接截断,不四舍五入)
round(5.9) # 对5.9四舍五入,结果为整数6


表达式与运算 ➕


上一节我们学会了创建对象,本节中我们来看看如何组合它们。


表达式由对象和运算符组成,Python会计算表达式并得到一个单一的值。表达式本身不会被存储,只有计算结果会被存储。




以下是表达式的例子及其运算规则:




基本算术运算
3 + 2 # 加法,结果为 5 (int)
5 / 3 # 除法,结果总是 float,约为 1.666...
5 // 3 # 整数除法(取整),结果为 1 (int)
5 % 3 # 取模(求余数),结果为 2 (int)
2 ** 3 # 幂运算,2的3次方,结果为 8



类型决定结果
运算结果的类型由操作数决定:
int与int进行加、减、乘运算,结果仍是int。- 只要有一个操作数是
float,加、减、乘、整数除法、取模、幂运算的结果就是float。 - 除法 (
/) 的结果总是float。

运算符优先级
Python遵循标准的数学运算符优先级:
- 括号
() - 幂运算
** - 乘法
*、除法/、整数除法//、取模% - 加法
+、减法-
你可以用括号来改变运算顺序。
变量与赋值 💾
上一节我们学习了表达式,本节中我们来看看如何给值命名以便重复使用。



变量是一个名称,它被绑定到一个值。这不同于数学中的变量。在编程中,等号 = 是赋值运算符,表示“将右边的值赋予左边的变量名”。

赋值过程:
- 计算
=右边的表达式,得到一个值。 - 将这个值与
=左边的变量名绑定。
pi = 3.14159 # 将值3.14159绑定到变量名 pi
radius = 2.2 # 将值2.2绑定到变量名 radius
area = pi * radius ** 2 # 计算表达式,将结果绑定到 area




变量命名与代码风格
选择有意义的变量名能使代码更易读。避免使用单字母或无意义的名称。
注释以 # 开头,用于解释代码块的功能,而不是描述每一行在做什么。
# 好的风格:描述性变量名和块注释
pi = 3.14159
radius = 2.2
# 计算圆的面积和周长
area = pi * radius ** 2
circumference = 2 * pi * radius
重新绑定变量
你可以改变一个变量名所绑定的值。
x = 10
x = x + 1 # 计算右边 x+1 得到11,然后将x重新绑定到11。现在x的值是11。
重要提示:程序按顺序逐行执行。如果先计算了 area,然后改变了 radius,area 的值不会自动更新,除非你重新计算它。



交换两个变量的值
这是一个常见的编程练习,展示了如何使用临时变量。
x = 1
y = 2
# 错误的方法(会导致两个变量值相同):
# y = x
# x = y

# 正确的方法(使用临时变量):
temp = y # 将y的值(2)保存到temp
y = x # 将x的值(1)赋给y
x = temp # 将temp的值(2)赋给x
# 现在 x=2, y=1



总结 📚

本节课中我们一起学习了:
- 计算机科学基础:计算机执行算法,它们严格遵循指令。
- Python对象与类型:对象(如数字、布尔值)是程序的基本构件,其类型决定了可执行的操作。
- 表达式与运算:通过运算符组合对象形成表达式,表达式会被计算为一个值。
- 变量与赋值:使用变量为值命名,便于在程序中引用和修改。赋值语句 (
=) 将值绑定到变量名。


记住,计算机只会做你告诉它去做的事情。你的程序就是给计算机的一系列精确指令。在接下来的课程中,我们将学习如何让程序做出决策,从而编写更强大的代码。
002:字符串、输入输出与分支


概述

在本节课中,我们将要学习三个核心主题:一种新的对象类型——字符串;如何编写与用户交互的程序,即获取输入和显示输出;以及如何编写能够根据代码中的决策做出判断的程序。


快速回顾
上一节我们介绍了对象的概念。在开始新内容之前,我们先快速回顾一下。

在Python中,每个对象都有特定的类型。类型决定了可以对该对象执行哪些操作。我们可以将对象赋值给变量,变量本质上是将名称绑定到内存中的对象。通过组合对象,我们可以创建表达式。表达式可以是数学运算,也可以是类似命令的操作。

例如,以下代码展示了变量的创建和赋值过程:
pi = 3.14
radius = 2.2
area = pi * radius ** 2
在内存中,pi 绑定到浮点数 3.14,radius 绑定到浮点数 2.2。当计算 area 时,Python会获取这些值,执行运算,并将结果 15.1976 作为一个新对象绑定到变量 area。
我们还可以重新为变量赋值,例如 radius = radius + 1。这并不会修改原始对象 2.2,而是创建一个新对象 3.2 并重新绑定给 radius。





字符串对象

上一节我们简要提到了字符串。本节中我们来看看它的具体细节。

字符串是一个字符序列。字符可以是字母、数字、键盘上的特殊符号,甚至是换行符或制表符。我们通过将字符放在引号内来告诉Python创建一个字符串对象。


a = "me"
z = "you"


在内存中,字符串 "me" 被绑定到变量 a。



字符串操作


以下是我们可以对字符串执行的一些常见操作。

拼接与重复

我们可以使用 + 运算符拼接字符串,使用 * 运算符重复字符串。

b = "myself"
c = a + b # 结果为 "memyself"
d = a + " " + b # 结果为 "me myself"
silly = a * 3 # 结果为 "mememe"


注意,拼接不会自动插入空格,需要手动添加。
获取长度
我们可以使用 len() 函数获取字符串的长度。
s = "abc"
chars = len(s) # chars 的值为 3
索引与切片

我们可以通过索引获取字符串中的单个字符。在Python中,索引从0开始。

s = "abc"
first_char = s[0] # 结果为 'a'
second_char = s[1] # 结果为 'b'
last_char = s[-1] # 结果为 'c',负索引表示从右向左数
如果索引超出范围,例如 s[3],Python会抛出 IndexError。
除了获取单个字符,我们还可以通过切片获取子字符串。切片语法为 [start:stop:step]。
start:起始索引(包含)。stop:结束索引(不包含)。step:步长,决定跳过多少字符。

s = "abcdefgh"
sub1 = s[3:6] # 结果为 "def",从索引3到5
sub2 = s[3:6:2] # 结果为 "df",从索引3到5,步长为2
reverse = s[::-1] # 结果为 "hgfedcba",步长为-1表示反向

如果省略 step,默认为1。如果 start 大于 stop 且步长为正,则返回空字符串 ""。

字符串的不可变性

字符串是不可变对象。这意味着一旦创建,就无法修改。任何看似修改的操作,实际上都是创建了一个新的字符串对象。
s = "car"
# s[0] = "b" # 这行代码会报错,因为不能修改字符串
new_s = "b" + s[1:] # 正确做法:创建一个新字符串 "bar"


输入与输出




到目前为止,我们编写的程序都是静态的,缺乏与用户的交互。本节我们来看看如何实现输入和输出。




输出:print() 函数
在文件编辑器中,Python不会自动显示表达式的值。我们必须使用 print() 函数来显式输出内容。


s = "hello"
print(len(s)) # 输出:5
print(s[-3]) # 输出:l


print() 会在输出内容后自动换行。如果想在同一行打印多个对象,可以用逗号分隔,它们之间会以空格分开。
a = "the"
b = 3
c = "musketeers"
print(a, b, c) # 输出:the 3 musketeers
如果想拼接输出(不加空格),需要确保所有部分都是字符串,必要时进行类型转换。
print(a + str(b) + c) # 输出:the3musketeers
输入:input() 函数
我们可以使用 input() 函数获取用户输入。它接受一个字符串参数作为提示信息,并返回用户输入的内容(始终作为字符串)。
text = input("Type anything: ")
print(5 * text) # 如果输入"hi",则输出"hihihihihi"
如果希望输入的是数字,需要进行类型转换。
num_str = input("Type a number: ") # 输入"3",num_str 是字符串 "3"
num_int = int(num_str) # num_int 是整数 3
print(5 * num_int) # 输出 15
格式化字符串(f-string)
f-string 提供了一种更便捷的方式来混合输出文本和表达式。在字符串前加 f 或 F,并在字符串内用花括号 {} 包裹表达式。
num = 10
fraction = 0.3
print(f"{num * fraction}% of {num}") # 输出:3.0% of 10
布尔值与分支
现在,我们的程序都是线性执行的。本节我们将引入决策点,让程序可以根据条件执行不同的代码块。
布尔表达式与比较运算符
布尔类型只有两个值:True 和 False。我们可以通过比较运算符创建布尔表达式。
print(2 < 3) # 输出:True
print(5 == 5) # 输出:True
print(5 != 5) # 输出:False
print("a" == "A") # 输出:False,区分大小写
我们还可以使用逻辑运算符 and、or、not 组合布尔表达式。
print((2 < 3) and (3 < 4)) # 输出:True
print((2 < 3) or (5 < 4)) # 输出:True
print(not (2 < 3)) # 输出:False
条件语句:if、elif、else
条件语句允许我们根据布尔表达式的值来决定执行哪部分代码。Python使用缩进来定义代码块。
简单的 if 语句



如果条件为 True,则执行缩进块内的代码;否则跳过。



x = 10
if x > 5:
print("x is greater than 5")
print("This always prints.")
if-else 语句
如果 if 条件为 True,执行第一个代码块;否则执行 else 后的代码块。


guess = int(input("Guess a number: "))
secret = 5
if guess == secret:
print("Correct!")
else:
print("Wrong!")


if-elif-else 语句
可以检查多个条件。elif 是 else if 的缩写。Python会按顺序检查条件,执行第一个为 True 的代码块。如果所有条件都不为 True,则执行 else 块(如果存在)。


peace = 22
sleep = 2
total = peace + sleep




if total > 24:
print("Impossible schedule")
elif total == 24:
print("Full schedule")
else:
remaining = 24 - total
print(f"Have {remaining} hours left")
print("End of day")
嵌套条件语句
条件语句内部可以包含其他条件语句。

x = 10
y = 10
if x == y:
print("x and y are equal")
if y != 0:
print("and y is not zero")
elif x > y:
print("x is greater than y")
else:
print("x is less than y")
重要:缩进决定了代码的归属。不正确的缩进会导致逻辑错误。

总结
本节课我们一起学习了三个核心内容:
- 字符串:一种表示文本序列的数据类型,支持拼接、重复、索引、切片等操作,并且是不可变的。
- 输入与输出:使用
input()函数获取用户输入(始终为字符串),使用print()函数输出结果,并介绍了方便的 f-string 格式化方法。 - 分支:通过
if、elif、else语句和布尔表达式,我们可以在程序中引入决策点,让代码根据条件执行不同的路径,从而编写出更具交互性和灵活性的程序。


下一节课,我们将进一步探讨分支,并开始学习循环,这是让程序重复执行某些操作的关键。
003:迭代

在本节课中,我们将要学习一种新的控制流机制——迭代。我们将探讨如何使用 while 循环和 for 循环来重复执行代码块,并理解它们在不同场景下的应用。
回顾:分支结构



上一节我们介绍了如何使用分支结构(if、elif、else)来控制程序流程。分支允许程序根据条件是否为真,选择性地执行不同的代码块。

以下是分支结构的关键点:
if语句:如果条件为真,则执行其缩进的代码块。if-else语句:如果if条件为真,执行其代码块;否则,执行else的代码块。if-elif-else语句:可以检查多个条件。Python 会按顺序检查,并执行第一个为真的条件对应的代码块。如果所有条件都为假,则执行else块(如果存在)。
代码块通过缩进来定义,这在 Python 中是强制性的。
引入迭代的动机



现在,我们来看一个简单的游戏场景:角色进入“迷失森林”,需要选择向左或向右走。如果选择向右,会再次看到相同的场景;只有选择向左,才能成功离开。



如果我们只用分支结构来编写这个游戏,会遇到一个问题:我们无法预知用户会连续选择多少次“向右”。因此,我们需要嵌套无数层 if 语句,这显然是不可行的。



这种情况非常适合使用迭代:只要用户选择“向右”这个条件为真,就重复显示森林场景并询问方向。一旦条件变为假(用户选择“向左”),就跳出循环,显示出口。



While 循环




while 循环正是用于处理这种“当某个条件为真时,重复执行代码”的情况。
其语法结构如下:
while <条件>:
# 当条件为真时要执行的代码块
while是关键字。<条件>是一个表达式,其结果为布尔值(True或False)。- 冒号
:表示代码块开始。 - 缩进的代码是循环体,会在条件为真时重复执行。
执行流程:
- 检查
<条件>是否为真。 - 如果为真,执行循环体内的所有缩进代码。
- 执行完毕后,自动返回第一步重新检查条件。
- 如果条件为假,则跳过循环体,继续执行后续代码。


示例:迷失森林游戏


让我们用 while 循环实现这个游戏:
where = input("你在迷失森林中,想向左走还是向右走?")
while where == "right":
where = input("你在迷失森林中,想向左走还是向右走?")
print("你成功走出了森林!")
注意:字符串比较是区分大小写的。如果用户输入 "RIGHT",条件 where == "right" 将为假,循环会立即结束。
示例:打印多个字符


我们也可以用循环来处理数字:
n = int(input("请输入一个整数:"))
while n > 0:
print("X")
n = n - 1 # 或者写作 n -= 1
这段代码会根据用户输入的数字 n,打印相应次数的 "X"。关键在于循环体内必须修改 n 的值(n = n - 1),使其逐渐向条件 n > 0 为假的方向变化,否则循环将无限进行下去。


避免无限循环
如果循环条件永远为真,程序将陷入无限循环。在开发环境中,你可以通过点击停止按钮、按 Ctrl+C(Windows/Linux)或 Command+C(Mac)来中断程序。


一个常见的无限循环例子是:
while True:
print("陷入循环")
因为条件 True 永远为真。
练习:增强版迷失森林
现在,尝试修改迷失森林游戏,增加一个功能:当用户连续选择“向右”超过两次后,下次询问时先打印一个悲伤的表情 :(。
思路是引入一个计数器变量:
count = 0
where = input("你在迷失森林中,想向左走还是向右走?")
while where == "right":
count = count + 1 # 或者 count += 1
if count > 2:
print(":(")
where = input("你在迷失森林中,想向左走还是向右走?")
print("你成功走出了森林!")


For 循环
while 循环适用于不确定循环次数的情况。但当我们需要遍历一个已知的序列(例如一系列数字)时,for 循环是更简洁、更不易出错的选择。
回顾一下之前用 while 循环打印数字的模式:
# while 循环版本
n = 0
while n < 5:
print(n)
n = n + 1
这种“初始化变量 -> 检查条件 -> 执行操作 -> 更新变量”的模式非常常见。for 循环可以将其简化为:
# for 循环版本
for n in range(5):
print(n)
For 循环语法
for <变量> in <序列>:
# 对序列中每个元素要执行的代码
for和in是关键字。<变量>是一个变量名,在每次循环中会被自动赋值为序列中的下一个元素。<序列>可以是数字序列、字符串、列表等。- 冒号和缩进规则与
while循环相同。




执行流程:<变量> 会依次取 <序列> 中的每一个值,并对每个值执行一次循环体内的代码。




Range 函数




range() 函数用于生成一个数字序列,常与 for 循环搭配使用。

range() 有三种用法:
range(stop):生成从0到stop-1的整数序列。range(start, stop):生成从start到stop-1的整数序列。range(start, stop, step):生成从start到stop-1,步长为step的整数序列。

示例:
for i in range(4): # i: 0, 1, 2, 3
print(i)
for j in range(3, 5): # j: 3, 4
print(j)
for k in range(1, 6, 2): # k: 1, 3, 5
print(k)
for l in range(4, 0, -1): # l: 4, 3, 2, 1
print("$" * l)


示例:计算序列和
使用 for 循环计算从 0 到 9 的和:
my_sum = 0
for i in range(10):
my_sum += i # 等价于 my_sum = my_sum + i
print(my_sum) # 输出 45





练习:计算指定范围的累加和



假设我们想计算从 start 到 end(包含两端)的整数和。以下代码有一个小错误,请修复它:
start = 3
end = 5
total = 0
for num in range(start, end):
total += num
print(total)
这段代码只会计算 3+4=7,因为它没有包含 end(5)。修复方法是让 range 的停止参数为 end + 1:
for num in range(start, end + 1):
total += num
在调试时,可以在循环内添加 print 语句来查看 num 的实际取值,这非常有用。


对比:计算阶乘




最后,让我们用 for 循环重写之前计算阶乘的 while 循环代码,感受其简洁性:
# while 循环计算 4!
x = 4
factorial = 1
i = 1
while i <= x:
factorial *= i
i += 1
print(f"{x}! = {factorial}")
# for 循环计算 4!
x = 4
factorial = 1
for i in range(1, x + 1):
factorial *= i
print(f"{x}! = {factorial}")
for 循环将变量的初始化、条件检查和更新都浓缩在了一行之内。


总结




本节课我们一起学习了两种迭代结构:
while循环:在条件为真时重复执行代码块。适用于循环次数未知的场景,但需注意避免无限循环。for循环:遍历一个序列(如range()生成的数字序列),对其中每个元素执行一次代码块。适用于循环次数已知或需要遍历集合的场景。




理解循环的执行流程(特别是变量如何变化)是编写正确代码的关键。请务必通过课后练习来巩固这些概念。
004:字符串循环、猜测试算法与二进制


在本节课中,我们将继续学习循环,并了解循环的一些其他细节。然后,我们将开始学习第一个算法——猜测试算法。最后,我们将开始讨论二进制数,为学习下一个算法做准备。

循环回顾与break语句

上一节我们介绍了while循环和for循环,它们是控制代码执行流程的两种方式,可以自动重复执行某些代码行。while循环在某个条件为真时重复执行代码,而for循环则针对一系列值重复执行代码。今天我们将看到,for循环中的值序列也可以是非数值的。


这两种循环类型都会在特定时刻结束:while循环在条件变为假时结束,for循环在值序列耗尽时结束。但有时我们希望提前退出循环,而不是等待条件变为假或序列耗尽。

为了在自然结束之前退出循环,我们可以使用break语句。break语句允许我们退出循环,退出的将是紧邻包围该break语句的循环。
以下是一个嵌套while循环的例子:



while condition1:
while condition2:
expression_A
break
expression_B
expression_C
当Python看到break语句时,它会立即退出包围该break语句的循环(即条件为condition2的循环)。它甚至不会返回去检查condition2,这意味着expression_B将永远不会被执行。这是一个糟糕的代码示例,仅用于展示break语句的影响。expression_C则只会在condition1为真时执行。
break语句只退出直接包围它的循环。例如,在嵌套循环中,它只退出内层循环,外层循环会继续执行。
循环遍历字符串
上一节我们主要使用for循环遍历数字序列。实际上,for循环可以遍历任何序列的值,包括字符串中的字符。
以下是两种遍历字符串并检查特定字符的方法:

# 方法一:通过索引遍历
s = "demo string"
for index in range(len(s)):
if s[index] == 'i' or s[index] == 'u':
print("There's an i or u")

# 方法二:直接遍历字符
s = "demo string"
for char in s:
if char == 'i' or char == 'u':
print("There's an i or u")


第二种方法更简洁,因为它直接遍历字符串中的每个字符,无需通过索引间接访问。
此外,我们还可以使用in关键字来简化条件判断:


s = "demo string"
for char in s:
if char in 'iu':
print("There's an i or u")
这种方法在需要检查字符是否属于一个较长的字符集合时尤其有用,可以避免编写冗长的or条件。
实践:编写加油机器人程序
现在,让我们编写一个稍微复杂一点的程序,这是一个加油机器人程序的初级版本。
该程序接收一个单词和一个热情等级,然后进行拼写加油,并重复打印该单词多次。
以下是代码示例:
word = input("Enter a word: ")
times = int(input("Enter enthusiasm level: "))
# 拼写部分
vowels = 'aeiou'
for w in word:
if w in vowels:
print(f"Give me an {w}!")
else:
print(f"Give me a {w}!")
print(f"What does that spell? {word.upper()}!")
# 重复部分
for i in range(times):
print(f"{word.upper()}!!!")
这个程序包含两个独立的循环:一个用于拼写单词,另一个用于根据热情等级重复打印单词。注意,在第二个循环中,我们并没有使用循环变量i,这完全没问题,因为我们只是需要重复执行某个动作特定的次数。
实践:计算字符串中唯一字母的数量
接下来,我们尝试编写一个程序,计算给定字符串中唯一字母的数量。假设字符串只包含小写字母。
例如,对于字符串"abca",唯一字母是a、b、c,因此数量为3。
我们可以使用一个额外的字符串变量来记录已经见过的字母,然后遍历字符串中的每个字符,如果该字符不在已见字符串中,则将其添加进去并计数。
以下是实现代码:
s = "abca"
seen = ""
count = 0
for char in s:
if char not in seen:
seen += char
count += 1
print(f"Number of unique letters: {count}")
或者,我们可以直接利用seen字符串的长度来得到唯一字母的数量,从而省略计数器:
s = "abca"
seen = ""
for char in s:
if char not in seen:
seen += char
print(f"Number of unique letters: {len(seen)}")
这种方法通过一个额外的变量来跟踪状态,是解决此类问题的常见模式。
猜测试算法
到目前为止,我们已经学习了对象、变量、表达式、分支和循环。这些工具足以让我们开始编写有趣的算法。第一个要学习的算法是猜测试算法,也称为穷举枚举法。
猜测试算法的基本思想是:给定一个问题,我们猜测一个可能的解,然后测试这个猜测是否正确。如果正确,我们就找到了解;如果不正确,我们继续猜测,直到穷尽所有可能的猜测。这样,我们要么找到解,要么确定在给定的猜测范围内没有解。
应用:寻找完全平方根
让我们以寻找完全平方根为例。假设我们想判断一个数x是否为完全平方数,如果是,找出它的平方根;如果不是,则说明它不是完全平方数。

我们可以系统地从0开始猜测,依次检查每个数的平方是否等于x。如果某个数的平方等于x,我们就找到了平方根。如果某个数的平方超过了x,我们就可以停止,因为更大的数的平方只会更大。



以下是使用while循环实现的代码:


x = int(input("Enter an integer: "))
guess = 0




while guess**2 < x:
guess += 1
if guess**2 == x:
print(f"Square root of {x} is {guess}")
else:
print(f"{x} is not a perfect square")
这段代码对于正数有效,但对于负数,循环不会执行,因为0的平方不小于负数。我们可以通过添加一个标志变量来处理负数情况:

x = int(input("Enter an integer: "))
neg_flag = False



if x < 0:
neg_flag = True
x = abs(x)

guess = 0
while guess**2 < x:
guess += 1



if guess**2 == x:
if neg_flag:
print(f"Square root of {-x} is {guess}i")
else:
print(f"Square root of {x} is {guess}")
else:
print(f"{x} is not a perfect square")
if neg_flag:
print("Just checking. Did you mean", abs(x), "?")
通过使用布尔标志,我们可以跟踪用户输入是否为负数,并在最后进行相应的处理。


使用for循环实现猜测试


猜测试算法本质上是在遍历一系列可能的值,因此使用for循环可能更直观。以下是使用for循环寻找完全立方根的示例:
cube = int(input("Enter an integer: "))
for guess in range(abs(cube) + 1):
if guess**3 == abs(cube):
break
if guess**3 != abs(cube):
print(f"{cube} is not a perfect cube")
else:
if cube < 0:
guess = -guess
print(f"Cube root of {cube} is {guess}")
这段代码通过break语句在找到解时提前退出循环,然后检查退出循环的原因,以判断是否找到了解。
布尔标志
在上述例子中,我们使用了布尔变量(通常称为“标志”)来跟踪某些事件是否发生。布尔标志是一个只能取两个值(通常是True或False)的变量,用于在代码中标记某个状态或事件。
例如,在寻找完全平方根的程序中,我们使用neg_flag来标记输入是否为负数。在检查秘密数字是否在范围内的程序中,我们使用found标志来标记是否找到了数字。
使用布尔标志可以使代码更清晰,特别是在需要在循环结束后根据之前发生的事件做出决策时。


应用:解决数学问题


猜测试算法不仅可以用于数学计算,还可以解决实际生活中的问题,例如分配问题。
假设Alyssa、Ben和Cindy在卖筹款活动的门票。Ben比Alyssa少卖2张,Cindy卖的是Alyssa的两倍。总共卖了10张票。问Alyssa卖了多少张?
我们可以通过遍历所有可能的组合来解决这个问题:
for a in range(11): # Alyssa could sell 0 to 10 tickets
for b in range(11): # Ben could sell 0 to 10 tickets
for c in range(11): # Cindy could sell 0 to 10 tickets
if a + b + c == 10 and b == a - 2 and c == 2 * a:
print(f"Alyssa sold {a}, Ben sold {b}, Cindy sold {c}")


然而,这种方法在数字较大时会非常慢。我们可以结合代数知识,只遍历Alyssa的可能票数,然后根据关系式计算Ben和Cindy的票数,从而大大提高效率:




for a in range(11):
b = a - 2
c = 2 * a
if a + b + c == 10:
print(f"Alyssa sold {a}, Ben sold {b}, Cindy sold {c}")

这种方法展示了计算思维的力量:通过结合计算和代数,我们可以高效地解决问题。

二进制数


在编程中,我们处理两种主要的数字类型:整数和浮点数。整数是完整的数字,浮点数是实数。然而,在处理浮点数时,可能会出现一些意想不到的问题。
例如,考虑以下代码:
x = 0.0
for i in range(10):
x += 0.1
print(x == 1.0) # 输出 False
print(x) # 输出 0.9999999999999999
尽管我们加了10次0.1,期望得到1.0,但由于浮点数在计算机中的表示方式,实际结果略有误差。这种误差在多次操作后会累积,导致令人惊讶的结果。
计算机使用二进制(基数为2)表示数字,因为硬件更容易处理两种状态(如高电压和低电压)。人类通常使用十进制(基数为10),但计算机内部使用二进制。
整数转换
将十进制整数转换为二进制相对简单。例如,将1507转换为二进制:
- 找到最大的2的幂,使其不超过1507(2^10 = 1024)。
- 从1507中减去1024,得到483。
- 找到下一个最大的2的幂,使其不超过483(2^8 = 256)。
- 重复此过程,直到余数为0。
最终,1507的二进制表示为10111100011。
转换算法
我们可以通过连续除以2并记录余数的方法,系统地將十进制数转换为二进制。以下是一个示例算法:

num = 1507
result = ""

if num == 0:
result = "0"
else:
while num > 0:
remainder = num % 2
result = str(remainder) + result
num = num // 2

print(result) # 输出 10111100011

这个算法通过循环不断将数字除以2,并将余数添加到结果字符串的前面,从而得到二进制表示。
总结

本节课中,我们一起学习了以下内容:
break语句:用于提前退出循环,只退出直接包围它的循环。- 循环遍历字符串:
for循环可以直接遍历字符串中的字符,无需通过索引。 - 猜测试算法:通过系统性地猜测和检查来解决问题,可以使用
while循环或for循环实现。 - 布尔标志:使用布尔变量跟踪代码中的事件或状态,使逻辑更清晰。
- 二进制数:计算机使用二进制表示数字,浮点数运算可能导致微小误差,了解二进制有助于理解这些误差的来源。



通过结合循环、条件判断和布尔标志,我们可以解决各种实际问题,从数学计算到逻辑推理。在接下来的课程中,我们将学习近似算法,以更高效地处理浮点数误差和其他复杂问题。
005:浮点数与近似方法



在本节课中,我们将学习计算机如何表示非整数(浮点数),理解为何直接比较浮点数可能导致错误,并介绍一种新的算法——近似方法,用于求解平方根等问题的近似解。


回顾:二进制表示法


上一节我们介绍了如何将十进制整数转换为二进制表示。本节中,我们来看看如何处理小数部分。
将十进制整数转换为二进制的算法核心如下:
result = ''
while num > 0:
remainder = num % 2
result = str(remainder) + result
num = num // 2
该算法通过不断除以2并记录余数,从右向左构建二进制字符串。


小数的二进制表示



对于小数,其二进制表示的原理与十进制类似。在十进制中,0.ABC 表示 A/10 + B/100 + C/1000。在二进制中,0.ABC(其中A、B、C为0或1)则表示 A/2 + B/4 + C/8。



将十进制小数转换为二进制表示的核心思想是:找到一个2的幂次,使得该小数乘以这个幂次后得到一个整数。然后,将该整数转换为二进制,最后将小数点向左移动相应的位数。



以下是转换十进制小数0.375的示例步骤:
- 0.375 = 3/8。
- 乘以 2³ (即8),得到整数 3。
- 将整数3转换为二进制:
11。 - 因为之前乘以了 2³,现在需要除以 2³,即在二进制中将小数点左移三位:
0.011。
因此,0.375 的二进制表示为0.011。





然而,并非所有小数都能找到一个有限的2的幂次使其变为整数。例如,0.1 在二进制中是一个无限循环小数,计算机只能存储其有限精度的近似值。

计算机中的浮点数表示

计算机使用浮点数格式来近似表示实数。它主要由两部分组成:
- 有效数字 (Significand/Mantissa):表示数字的精度的部分。
- 指数 (Exponent):表示小数点需要移动的位数。
一个浮点数可以理解为:有效数字 × 2^指数。
例如,在二进制中:
(1.1, 1)表示1.1× 2¹ =11.0(即十进制的3.0)。(1.1, -1)表示1.1× 2⁻¹ =0.11(即十进制的0.75)。

由于计算机内存有限(通常为32位或64位),只能存储有限长度的二进制数。对于像0.1这样需要无限位表示的数,计算机必须进行截断,这就引入了微小的表示误差。

浮点数比较的陷阱


由于存在表示误差,直接使用等号 (==) 比较两个浮点数是否相等是不可靠的。
考虑以下代码:
x = 0.0
for i in range(10):
x += 0.1
print(x == 1.0) # 输出 False
print(x) # 输出 0.9999999999999999
累加0.1十次,理论上应得到1.0,但由于0.1在计算机中并非精确值,累加后的结果是一个极其接近但不等于1.0的数。
因此,比较两个浮点数 a 和 b 是否“足够接近”的正确方法是检查它们的绝对差是否小于一个很小的容差值 epsilon (ε):
abs(a - b) < epsilon
近似方法算法

上一节介绍的“猜测与验证”方法只能找到完全平方根。本节介绍的近似方法则可以找到任意数的平方根近似值。




近似方法的核心思想是:从一个过小的猜测值开始,以很小的步长递增,直到猜测值的平方与目标数 x 的差落在允许的误差范围 epsilon 内。

算法步骤如下:
- 初始化猜测值
guess = 0,设定步长increment和容差epsilon。 - 循环执行以下步骤,直到满足条件:
- 如果
abs(guess^2 - x) < epsilon,则猜测值足够接近,成功结束。 - 如果
guess^2 > x,说明猜测值已经过大,不可能再进入容差范围,失败结束。 - 否则,将猜测值增加一个步长:
guess += increment。
- 如果
以下是该算法的Python实现框架:
x = 25
epsilon = 0.01
increment = 0.0001
guess = 0.0
num_guesses = 0



while abs(guess**2 - x) >= epsilon and guess**2 <= x:
guess += increment
num_guesses += 1

if abs(guess**2 - x) < epsilon:
print(f'近似平方根为: {guess}')
else:
print(f'未能找到平方根,最后猜测值为: {guess}')
算法的问题与优化

上述基础实现存在一个问题:由于步长是固定的,猜测值可能会“跳过”容差范围 epsilon,导致 guess^2 从略小于 x 直接变为大于 x,从而陷入无限循环(因为此时 abs(guess^2 - x) 可能始终大于 epsilon)。



我们在循环条件中增加了 guess**2 <= x 作为第二个终止条件,防止猜测值过大后仍持续递增。
算法的性能与精度存在权衡:
- 减小步长 (
increment):可以提高精度,减少“跳过”风险,但会增加迭代次数,降低速度。 - 增大容差 (
epsilon):可以加快程序终止速度,但会降低结果的精度。
总结
本节课中我们一起学习了:
- 浮点数的二进制表示原理:计算机使用有限精度的有效数字和指数来近似表示实数,这导致了表示误差。
- 浮点数比较的正确方法:不应直接使用
==,而应检查两数之差是否小于一个很小的容差值epsilon。 - 近似方法算法:通过以小步长递增猜测值并检查其平方是否接近目标值,来求解平方根的近似解。我们需要注意算法可能因“跳过”解而失败,并通过添加额外条件来避免无限循环。
- 精度与速度的权衡:在近似算法中,步长和容差的选择会影响结果的精度和程序的运行速度。


虽然近似方法比简单的“猜测与验证”更强大,但其线性搜索的方式可能仍然较慢。在下一讲中,我们将学习一种更高效的方法——二分查找法。
006:二分查找算法 🎯

在本节课中,我们将要学习一种更高效的算法——二分查找算法,用于解决近似求解平方根等问题。我们将回顾上节课的近似算法,理解其局限性,然后深入探讨二分查找的原理、适用条件及其实现方式。
回顾:近似算法与浮点数
上一节我们介绍了近似算法和浮点数。我们讨论了如何通过小步长增量来逼近平方根,并引入了 epsilon 作为允许的误差范围。然而,这种方法存在效率问题,例如在寻找 54321 的平方根时,即使步长很小,也可能需要数百万次猜测,耗时很长。
近似算法的核心循环条件如下:
while abs(guess**2 - x) >= epsilon and guess <= x:
guess += increment
当猜测值的平方与目标值 x 的差的绝对值大于等于 epsilon,并且猜测值本身没有超过 x 时,循环继续。我们添加了 guess <= x 这个“合理性检查”来防止无限循环。
引入二分查找算法 🧠
本节中我们来看看二分查找算法。这是一种更高效的搜索方法,特别适用于有序的搜索空间。其核心思想是每次猜测都排除一半的候选值。



以下是二分查找算法的基本步骤:
- 确定搜索区间的下限
low和上限high。 - 计算中点
guess = (high + low) / 2。 - 检查
guess是否满足条件(例如,guess**2是否接近x)。 - 根据反馈(太大或太小),将
low或high更新为guess,从而将搜索区间减半。 - 重复步骤 2-4,直到找到满足条件的解或区间足够小。









与线性增长的近似算法不同,二分查找是对数级增长的。这意味着即使搜索空间翻倍,也只需要增加很少的猜测次数。



将二分查找应用于平方根问题 🔢



现在,让我们将二分查找算法应用到寻找平方根近似值的问题上。我们知道,对于一个正数 x,其平方根位于区间 [0, x] 内(对于 0 < x < 1 的情况需要特殊处理,我们稍后讨论)。
以下是实现二分查找寻找平方根的代码框架:
x = 54321
epsilon = 0.01
num_guesses = 0
low = 0.0
high = max(1.0, x) # 处理 x<1 的情况
guess = (high + low) / 2.0
while abs(guess**2 - x) >= epsilon:
num_guesses += 1
if guess**2 < x:
low = guess # 猜测值太小,提升下限
else:
high = guess # 猜测值太大,降低上限
guess = (high + low) / 2.0
print(f'猜测次数: {num_guesses}')
print(f'{guess} 接近 {x} 的平方根')
对于 x = 54321,这个算法仅用了约 30 次 猜测就得到了结果,而之前的近似算法需要 230 万次。这展示了二分查找的巨大效率优势。
算法的局限性与边界情况 ⚠️
二分查找虽然强大,但并非万能。它要求搜索空间是有序的,并且我们能够获得“太大”或“太小”的明确反馈。例如,猜一个4位手机PIN码(只有对错反馈)就无法有效使用二分查找。

此外,对于寻找 0 到 1 之间数字的平方根,初始区间 [0, x] 是不正确的,因为平方根会比原数大。我们需要将初始区间调整为 [x, 1]。在代码中,我们可以通过一个条件判断来处理:
if x >= 1:
low = 0.0
high = x
else:
low = x
high = 1.0













其他算法简介:牛顿拉弗森法 ⚡












最后,我们简要介绍另一种专门用于求函数根(如平方根)的算法——牛顿拉弗森法。对于求 k 的平方根,即求解 f(x) = x^2 - k = 0,其迭代公式为:
guess = guess - (guess**2 - k) / (2 * guess)
这个算法通常收敛速度非常快,但仅限于解决此类特定的数学问题,不像二分查找那样具有通用性。

总结与前瞻 🚀
本节课中我们一起学习了:
- 回顾了近似算法的效率和浮点数比较的注意事项。
- 引入了二分查找算法,理解了其“每次排除一半”的核心思想和对数级增长的高效率。
- 实现了用二分查找求平方根,并处理了
x<1的边界情况。 - 讨论了算法的适用条件:需要有序搜索空间和大小反馈。
- 简介了牛顿拉弗森法作为另一种高效但专用的求根算法。


这些“猜测-检查”类算法(穷举、近似、二分、牛顿法)都包含三个要素:循环、生成猜测的方法、检查猜测的方法。它们丰富了我们的算法工具箱。





在接下来的课程中,我们将学习分解和抽象这两个重要的编程概念,通过函数来编写更模块化、更易维护和重用的代码,从而管理更复杂的程序。
007:分解、抽象与函数 🧩

在本节课中,我们将学习编程中的两个核心思想:分解与抽象,并了解如何通过函数在Python中实现它们。我们将从现实世界的例子出发,逐步学习如何编写、定义和调用函数,从而构建更清晰、更易维护的代码。
现实世界的例子:智能手机 📱
上一讲我们开始讨论分解和抽象的概念。今天,我们将通过一个现实世界的例子来具体理解它们,然后看看如何在编程中实现。
智能手机对我们许多人来说就像一个“黑盒子”。我们通过其输入(如按键、触摸)和输出(如屏幕显示、声音)与它交互,但并不需要了解其内部硬件和软件是如何协同工作的。这种隐藏复杂内部细节,只暴露必要接口的方式,就是抽象。
抽象使得分解成为可能。手机由数百个不同的部件组成,不同的制造商可以独立生产这些部件。他们只需要遵循一套标准化的接口规范(即输入和输出的定义),而无需知道其他部件是如何工作的。只要接口匹配,这些独立的部件就能组合在一起,共同实现手机的功能。
这个原理不仅适用于硬件,同样适用于软件。在编程中,我们将通过函数来实现分解和抽象。
编程中的抽象与函数
在编程中,我们也希望隐藏细节。我们通过编写函数来实现这一点。函数是一段执行特定有用任务的代码块。一旦我们编写并调试好一个函数,就可以将其视为一个“黑盒子”。之后,我们只需要知道它的输入(参数)和输出(返回值),而无需关心其内部实现。
事实上,我们已经使用过Python内置的函数,例如:
max(a, b):输入两个值,返回其中较大的一个。abs(x):输入一个数字,返回其绝对值。len(s):输入一个字符串,返回其长度。
本节课的重点是学习如何编写你自己的函数。


函数的组成部分
一个函数具有以下特征:
- 名称:一个描述其功能的标识符(通常是动词)。
- 参数:函数接收的输入(可以有零个或多个)。
- 文档字符串:对函数的说明,是一种“契约”,描述了输入、功能以及输出。
- 函数体:执行具体任务的Python代码。
- 返回语句:使用
return关键字将结果返回给调用者。


以下是编写函数时的最佳实践步骤:
- 理解问题:明确函数需要完成什么任务。
- 确定接口:为函数起名,并确定它需要哪些输入(参数)。
- 编写文档字符串:在
"""之间写明输入、功能和输出。 - 编写函数体:用代码实现功能。
- 返回结果:使用
return语句输出最终值。
定义与调用函数
理解定义函数和调用函数的区别至关重要。


定义函数只是告诉Python这段代码的存在,并不会立即执行它。其语法如下:
def is_even(i):
"""
输入:i,一个正整数。
功能:判断数字是否为偶数。
返回:如果i是偶数则返回True,否则返回False。
"""
return i % 2 == 0


调用函数才是实际执行函数体内的代码。调用时,需要提供函数名和具体的参数值。函数调用本身是一个表达式,它会被其返回值所替代。
result = is_even(3) # 函数调用被替换为 False,然后赋值给 result
print(result) # 输出:False
print(is_even(4)) # 输出:True


当调用 is_even(3) 时,Python会执行以下步骤:
- 将形式参数
i绑定到实际参数3。 - 进入函数体,执行代码
3 % 2 == 0,得到结果False。 - 遇到
return False,立即退出函数,并将False返回给调用处。 - 整个调用表达式
is_even(3)在程序中被替换为值False。
函数在内存中的表示
我们可以将函数定义理解为在内存中创建了一个名为 is_even 的“变量”,它指向一段可执行的代码(函数对象)。在定义之后、调用之前,这段代码只是静静地待在那里。


只有当进行函数调用时,程序才会“跳转”到那段代码并执行它,然后将返回值带回调用点,用于后续的计算或赋值。


实战示例:求区间内奇数的和

让我们通过一个更复杂的例子来巩固所学,并展示渐进式开发和测试的良好实践。

问题:编写函数 sum_odds(a, b),求 a 和 b 之间(包含两端)所有奇数的和。
开发思路:
- 先用简单例子思考:例如 a=2, b=4。手动列出数字 2,3,4。我们的“配方”是:遍历每个数字,如果是奇数就加到总和里。
- 先解决一个更简单的问题:先编写求
a到b所有数字之和的代码,确保循环逻辑正确。def sum_all(a, b): total = 0 for i in range(a, b + 1): # 注意 range 的终点不包含,所以要 b+1 total += i return total - 测试简单版本:用
sum_all(2,4)测试,结果应为 9。 - 添加核心逻辑:在循环中加入判断,只累加奇数。
def sum_odds(a, b): total = 0 for i in range(a, b + 1): if i % 2 == 1: # 或者 if not is_even(i): total += i return total - 用更多例子测试:测试
sum_odds(2,7),结果应为 15 (3+5+7)。
这种分步的方法能帮助你将复杂问题分解,并及早发现和修复错误。
总结

本节课我们一起学习了:
- 分解:将大问题拆分成小的、独立的模块。
- 抽象:隐藏模块的内部细节,只暴露清晰的接口。
- 函数:在Python中实现分解和抽象的核心工具。函数通过
def定义,通过函数名和参数调用,并使用return返回值。 - 定义 vs 调用:定义函数是创建功能,调用函数是使用功能。
- 良好实践:为函数编写清晰的文档字符串,采用渐进式开发并勤于测试。



函数使代码更易于编写、阅读、调试和修改,是构建健壮程序的基础。
008:函数作为对象 🐍





在本节课中,我们将继续学习函数,并深入探讨如何将函数视为对象。我们将学习函数的作用域、环境以及如何将函数作为参数传递给其他函数。






函数定义与执行回顾
上一节我们介绍了函数的基本语法和动机。本节中,我们来看看函数定义的具体组成部分。
一个函数定义包含以下部分:
def关键字:告诉Python我们正在定义一个函数。- 函数名:我们为函数指定的名称。
- 参数列表:在括号内命名函数的所有输入。
- 冒号:标志着函数体的开始。
- 文档字符串(可选但推荐):用三引号包裹的长注释,描述了函数的契约(输入、功能、输出)。
- 函数体:缩进的代码块,包含要执行的语句。
return语句:结束函数执行,并将一个值传递回调用者。
代码示例:判断偶数的函数
def is_even(i):
"""
输入:整数 i
返回:如果 i 是偶数则返回 True,否则返回 False
"""
remainder = i % 2
return remainder == 0


所有函数都会返回一个值。如果函数体中没有显式的 return 语句,Python会在函数执行结束时自动返回 None。None 是 NoneType 类型的唯一值,通常用于表示“没有值”。






return 与 print 的区别
理解 return 和 print 的区别至关重要。
以下是两者的核心区别:
return:仅能在函数内部使用。它结束函数执行,并将一个值传递回调用者。函数调用本身是一个表达式,其值就是返回的值。print:可以在任何地方使用(函数内或函数外)。它将内容输出到控制台,但其本身不传递值给程序的其他部分。print函数本身返回None。
关键点:在函数内部打印一个值(使用 print)与返回一个值(使用 return)是两件不同的事。为了在其他代码中使用函数的计算结果,通常需要使用 return。









函数作用域与环境 🧠

当进行函数调用时,Python会创建一个新的环境(或作用域)。这个环境与调用它的程序(全局环境)是分离的。
以下是理解环境的关键规则:
- 参数映射:调用函数时,实参的值被映射到函数定义中的形参。
- 局部变量:在函数内部创建的变量只存在于该函数的环境(局部作用域)中。
- 环境隔离:不同环境中的变量,即使同名,也互不干扰。
- 环境生命周期:函数执行完毕并返回后,其对应的环境就会消失,其中的所有局部变量也随之销毁。
- 变量查找:如果函数内部引用了一个变量,Python会首先在本地环境中查找。如果找不到,它会向外层环境(例如全局环境)查找该变量。但是,函数不能直接修改外层作用域的变量(除非使用
global关键字,但初学者应避免使用)。
示例:作用域演示
def func_a(y):
x = 1 # 在 func_a 的作用域内创建局部变量 x
x = x + 1
print(x) # 打印 2
# 函数结束,返回 None,局部变量 x 和 y 消失
x = 5 # 在全局作用域创建变量 x
func_a(x) # 调用函数,传递 x 的值 5
print(x) # 打印全局变量 x,仍然是 5
输出将是 2 和 5,证明了局部变量 x 和全局变量 x 是独立的。
函数作为对象 🎯
在Python中,函数也是对象。这意味着函数名实际上是一个指向包含代码的函数对象的变量。
因为函数是对象,所以我们可以:
- 将函数赋值给另一个变量。
- 将函数作为参数传递给另一个函数。
- 从一个函数中返回另一个函数。
示例:函数赋值
def is_even(i):
return i % 2 == 0



my_func = is_even # 将 is_even 函数对象赋值给变量 my_func
# 现在 my_func 和 is_even 指向同一个函数
a = my_func(3) # 返回 False
b = is_even(4) # 返回 True





将函数作为参数传递






既然函数可以作为对象传递,我们就可以编写接收其他函数作为参数的“高阶函数”。这极大地增加了代码的灵活性和复用性。




示例:通用计算器函数
def add(a, b):
return a + b




def div(a, b):
print(f"Dividing {a} by {b}")
return a / b if b != 0 else None





def calc(op, x, y):
""" op 是一个函数,接受两个参数 """
return op(x, y)

result1 = calc(add, 2, 3) # 调用 calc,传入 add 函数,返回 5
result2 = calc(div, 6, 2) # 调用 calc,传入 div 函数,打印信息并返回 3.0
练习:编写一个应用条件的函数

让我们编写一个函数 apply_criteria,它接收一个判断函数 criteria 和一个整数 n,返回从 0 到 n(包含)之间有多少个数字满足 criteria 函数。









def apply_criteria(criteria, n):
"""
参数:
criteria: 一个函数,接受一个整数,返回布尔值。
n: 一个整数。
返回:
满足 criteria 函数的数字的个数 (从 0 到 n)。
"""
count = 0
for i in range(n + 1): # 遍历 0 到 n
if criteria(i): # 调用传入的判断函数
count += 1
return count


# 使用示例
def is_even(num):
return num % 2 == 0







def equals_five(num):
return num == 5






print(apply_criteria(is_even, 10)) # 输出 6 (0,2,4,6,8,10)
print(apply_criteria(equals_five, 10)) # 输出 1 (只有5)







这个 apply_criteria 函数非常强大,它可以与任何符合签名(接受整数,返回布尔值)的函数一起工作,实现了逻辑与数据的分离。












总结 📚
本节课中我们一起学习了:
- 函数中
return与print的根本区别。 - 函数作用域和环境的概念:每次函数调用都会创建独立的环境,变量在其中生命周期有限且互不干扰。
- 函数作为对象的特性:函数可以像其他数据(如整数、字符串)一样被赋值、传递和返回。
- 如何编写高阶函数,即接收其他函数作为参数的函数,这能极大提升代码的通用性和可复用性。


理解这些概念是编写模块化、清晰且强大代码的关键。在接下来的学习和问题集中,你会经常运用这些知识。
009:Lambda函数、元组与列表 🐍





在本节课中,我们将结束关于函数的讨论,学习一种创建匿名函数的方法——Lambda函数。随后,我们将介绍两种新的复合数据类型:元组和列表。





Lambda函数



上一节我们介绍了函数可以作为参数传递给其他函数。本节中,我们来看看如何创建一种简洁的匿名函数。



Lambda函数是一种无需使用 def 关键字定义、无需命名的函数,适用于简单、一次性使用的场景。其基本语法如下:

lambda <参数>: <表达式>
以下是Lambda函数的关键组成部分:
lambda是关键字,表示开始定义一个匿名函数。<参数>是函数的输入参数,多个参数用逗号分隔。- 冒号
:用于分隔参数列表和函数体。 <表达式>是函数体,其计算结果将作为函数的返回值。注意,这里没有return关键字。




例如,一个判断数字是否为偶数的函数,用常规方式定义为:
def is_even(x):
return x % 2 == 0
使用Lambda函数可以等价地写为:
lambda x: x % 2 == 0





Lambda函数的实用性在于,当你需要一个简单的、不打算复用的函数时,可以快速内联创建,而无需单独定义。





元组






现在,我们转向新的数据类型。元组是一种有序、可索引的对象序列。




创建元组






创建空元组使用一对圆括号 ()。
创建包含单个元素的元组需要在元素后加一个逗号,例如 (4,),以区别于普通的括号运算。
创建包含多个元素的元组,只需在括号内用逗号分隔各元素,例如 (2, "MIT", 3)。元组可以包含不同类型的对象。




元组操作
元组支持许多与字符串相同的操作,因为两者都是有序序列。
以下是元组的基本操作示例:
- 索引:使用方括号
[]和索引位置访问元素,索引从0开始。 - 拼接:使用加号
+可以将两个元组合并成一个新元组。 - 切片:使用切片语法
[start:end]可以获取元组的一部分。 - 长度:使用
len()函数获取元组中元素的数量。 - 最大值/最小值/求和:使用
max(),min(),sum()函数处理数值型元组。
一个重要的特性是,元组是不可变的。一旦创建,就无法修改其内部的元素,这与字符串类似。
嵌套元组与迭代
元组的元素可以是另一个元组,形成嵌套结构。例如:
seq = (2, "a", 4, (1, 2))
此时,len(seq) 的结果是4,因为第四个元素本身是一个元组对象。你可以通过链式索引访问嵌套元素,例如 seq[3][0] 会返回 1。
与字符串类似,你可以直接迭代元组的元素:
for e in seq:
print(e)
循环变量 e 会依次代表元组 seq 中的每个元素。
元组的应用




元组有两个非常实用的应用场景。



1. 交换变量值
无需使用临时变量,可以在一行代码内完成两个变量值的交换:
x, y = y, x
Python会先计算右侧 (y, x) 这个元组,然后按顺序将值分配给左侧的变量。


2. 函数返回多个值
函数只能返回一个对象,但我们可以利用元组打包多个值作为这一个对象返回。
def quotient_remainder(x, y):
q = x // y # 商
r = x % y # 余数
return (q, r) # 返回一个包含两个值的元组



result = quotient_remainder(10, 3) # result 是 (3, 1)
quot, rem = quotient_remainder(5, 2) # 元组解包:quot=2, rem=1



3. 接收可变数量的参数
在定义函数时,可以使用 *args 形式的参数来接收任意数量的实参,这些实参在函数内部会被自动封装成一个元组。
def my_mean(*args):
total = 0
for num in args: # args 是一个元组
total += num
return total / len(args)


print(my_mean(1, 2, 3, 4, 5)) # 输出 3.0
print(my_mean(60, 70, 80)) # 输出 70.0


列表

列表是另一种有序、可索引的序列,与元组的主要区别在于其创建语法和可变性(可变性将在下节课深入讨论)。


创建列表
创建空列表使用一对方括号 []。
创建包含元素的列表,在方括号内用逗号分隔各元素,例如 [8, 3, 5]。列表同样可以混合不同类型的元素。
列表操作(基础)




列表支持与元组和字符串相同的索引、切片、len()、max()/min()/sum()(针对数值列表)等操作。
直接迭代列表元素
与元组和字符串一样,可以直接迭代列表的元素,这通常比迭代索引更清晰、更“Pythonic”。


例如,计算列表元素和的两种方式:
# 方式一:迭代索引(稍显繁琐)
L = [8, 3, 5]
total = 0
for i in range(len(L)):
total += L[i]




# 方式二:直接迭代元素(推荐)
L = [8, 3, 5]
total = 0
for num in L: # num 依次为 8, 3, 5
total += num
将第二种方式封装成函数:
def list_sum(L):
total = 0
for num in L: # num 依次取列表 L 中的每个值
total += num
return total
这种模式使得代码易于适应不同的需求。例如,如果列表元素是字符串,想求所有字符串的长度之和,只需稍作修改:
def sum_of_lengths(L):
total = 0
for s in L: # s 依次为列表中的每个字符串
total += len(s)
return total


总结




本节课中我们一起学习了三个核心内容。
首先,我们掌握了 Lambda函数,它是一种创建简洁匿名函数的语法 lambda 参数: 表达式,适用于简单的单次函数逻辑。
接着,我们深入了解了 元组,这是一种使用圆括号 () 创建的有序、不可变序列,常用于数据打包、交换变量和函数返回多个值。
最后,我们认识了 列表,这是一种使用方括号 [] 创建的有序序列,其基础操作与元组类似,但具备可变性(下节课重点)。
通过直接迭代元组或列表的元素(for item in sequence:),我们可以写出更清晰、易读的代码。
010:列表与可变性 📚

在本节课中,我们将要学习列表(List)数据类型的核心概念——可变性(Mutability)。我们将探讨如何创建和操作列表,理解可变对象在内存中的行为,并学习一系列用于修改列表的内置方法。






列表基础回顾 🔄




上一讲我们介绍了元组(tuple)和列表(list)这两种复合数据类型。与元组和字符串类似,列表支持许多相同的操作,例如获取长度、索引、切片、连接和迭代。
以下是列表的一些基本操作示例:
L = [] # 创建一个空列表
L = [1, 'a', 2, [1, 2]] # 列表可以包含不同类型的元素
len(L) # 获取长度
L[0] # 索引访问
L[1:3] # 切片
L1 + L2 # 连接
max(L) # 获取最大值(如果元素可比较)
for e in L: # 直接迭代列表元素
print(e)


列表与元组和字符串的关键区别在于其可变性。这意味着我们可以在列表创建后修改其内容。







理解可变性:修改列表元素 ✏️



对于字符串和元组这样的不可变对象,一旦创建就无法更改。但列表允许我们修改其内部的元素。

以下是如何修改列表中特定索引处的元素:




L = [2, 4, 3]
L[1] = 5 # 将索引1处的元素(值4)修改为5
# 现在 L 变为 [2, 5, 3]

让我们通过内存图来理解这个过程。当执行 L[1] = 5 时,Python会:
- 跟随变量名
L找到内存中的列表对象。 - 定位到索引
1对应的位置。 - 用右侧的值(
5)覆盖该位置原有的值。
这个过程直接修改了内存中的列表对象本身,而不是创建一个新的副本。 这与元组的赋值操作形成对比:
T = (2, 4, 3)
T = (2, 5, 3) # 这创建了一个新的元组对象,并让 T 指向它。原元组 (2,4,3) 未被修改,只是失去了引用。
列表的常用修改操作 🛠️
除了直接修改元素,列表还提供了一系列用于修改自身的方法。这些方法通常没有返回值(返回 None),它们的作用在于其产生的“副作用”——即改变列表本身。
以下是几个核心的修改操作:
1. 添加元素:append()


append() 方法用于在列表末尾添加一个元素。
L = [2, 1, 3]
L.append(5) # 将 5 添加到列表末尾
# 现在 L 变为 [2, 1, 3, 5]




重要提示:append() 返回 None。一个常见的错误是尝试将结果保存回变量:


L = [2, 1, 3]
L = L.append(5) # 错误!这会使 L 变为 None
# 正确做法:直接调用 L.append(5)




2. 添加多个元素:extend()







extend() 方法用于将一个列表中的所有元素添加到另一个列表的末尾。



L1 = [2, 1, 3]
L2 = [0, 6]
L1.extend(L2) # 将 L2 中的所有元素添加到 L1 末尾
# 现在 L1 变为 [2, 1, 3, 0, 6]
注意 extend() 与连接操作 + 的区别:+ 会创建一个新的列表,而 extend() 是修改原列表。

3. 排序与反转:sort() 和 reverse()





sort() 和 reverse() 方法会直接修改列表,使其按顺序排序或反转。

L = [4, 2, 7]
L.sort() # 将 L 修改为 [2, 4, 7]
L.reverse() # 将 L 修改为 [7, 4, 2]

如果你希望得到一个排序后的新列表,同时保留原列表不变,可以使用内置的 sorted() 函数:
L = [4, 2, 7]
L_new = sorted(L) # L_new 是 [2, 4, 7],L 仍然是 [4, 2, 7]




4. 清空列表:clear()




clear() 方法用于移除列表中的所有元素,使其变为空列表。




L = [4, 5, 6]
L.clear() # 现在 L 变为 []




遍历并修改列表 🔄

当我们需要遍历列表并修改其中的元素时,通常需要获取元素的索引。因为修改元素的语法 L[i] = new_value 需要索引 i。

以下是几种常见的遍历方式:


方法一:遍历索引
这是最直接的方法,使用 range(len(L)) 生成索引序列。


def square_list(L):
"""将列表 L 中的每个元素替换为其平方(直接修改 L)"""
for i in range(len(L)):
L[i] = L[i] ** 2
# 函数没有 return 语句,默认返回 None




方法二:使用 enumerate()
enumerate() 函数可以在迭代时同时获得索引和元素值。






for index, element in enumerate(L):
# 使用 index 和 element
L[index] = element ** 2


字符串与列表的转换 🔀
列表和字符串之间可以方便地相互转换,这在处理文本时非常有用。
将字符串转换为列表:
list(s):将字符串s的每个字符(包括空格)变成列表的独立元素。s.split(sep):根据分隔符sep将字符串s分割成单词列表。常用的是按空格分割:s.split(‘ ‘)。




s = “I <3 CS @ NU”
list(s) # 结果: [‘I’, ‘ ‘, ‘<‘, ‘3’, ‘ ‘, ‘C’, ‘S’, ‘ ‘, ‘@’, ‘ ‘, ‘N’, ‘U’]
s.split(‘ ‘) # 结果: [‘I’, ‘<3’, ‘CS’, ‘@’, ‘NU’]
s.split(‘<‘) # 结果: [‘I ‘, ‘3 CS @ NU’]





将列表转换为字符串:
使用 join() 方法,它需要一个字符串作为连接符。







L = [‘A’, ‘B’, ‘C’]
‘‘.join(L) # 结果: ‘ABC’
‘_’.join(L) # 结果: ‘A_B_C’


注意:join() 要求列表中的所有元素都是字符串类型。








可变性带来的陷阱与思考 🤔


可变性虽然强大,但也可能引入一些意想不到的行为,尤其是在循环中修改列表时。理解“对象”与“对象名称”的区别至关重要。




关键概念:变量名(如 L)是对内存中某个对象的引用或绑定。L = new_value 操作是改变这个绑定,让它指向一个新的对象。而 L.append(x) 或 L[i] = y 是改变 L 当前所指向的那个对象本身。





我们可以使用 id() 函数来查看一个对象在内存中的唯一标识,从而判断两个变量是否指向同一个对象。



L = [4, 5, 6]
print(id(L)) # 输出一个代表内存地址的数字




L.append(8)
print(id(L)) # 输出相同的数字,说明是同一个对象被修改了




L = [] # 将名称 L 绑定到一个新的空列表对象
print(id(L)) # 输出不同的数字,说明 L 现在指向了另一个对象

总结 📝



本节课中我们一起学习了列表的核心特性——可变性。
- 列表是可变对象:创建后可以修改其内容(增、删、改元素),这使其适用于需要动态变化的数据集合(如员工名单、待办事项)。
- 元组是不可变对象:创建后内容不可更改,适用于表示固定不变的数据(如坐标、常量配置)。
- 修改列表的方法:如
append(),extend(),sort(),reverse(),clear()等,它们直接修改列表本身,通常返回None。 - 操作语法:使用
L[i] = value修改元素,使用L.method()调用修改方法。 - 重要区别:要清晰地区分“修改变量绑定”(
L = something)和“修改对象本身”(L.append(x))。前者可能丢失对原对象的引用,后者则是在原地改变对象。 - 转换工具:
split()和join()是字符串与列表间转换的利器。


理解可变性是掌握Python中复杂数据操作的关键。下一讲我们将继续探讨与可变性相关的更多细节和复杂情况。
011:别名与克隆 🧬

在本节课中,我们将要学习列表的可变性、如何创建列表的副本(克隆),以及多个变量如何指向同一个列表对象(别名)。我们还将探讨在遍历列表时修改列表可能带来的陷阱,并学习如何避免这些问题。






列表与可变性


上一节我们介绍了列表作为可变数据结构的概念。本节中,我们来看看如何创建列表的副本,以及为什么有时需要这样做。


有时,直接修改原始列表会带来不便,或者我们希望保留原始数据的同时操作一个副本。Python允许我们创建列表的副本。
创建列表副本的语法如下:


L_copy = L[:]
这行代码会创建一个名为 L_copy 的新列表对象,其元素与列表 L 完全相同。
内存中的副本
假设我们有以下代码:
L_original = [4, 5, 6]
L_new = L_original[:]
在内存中,现在存在两个独立的列表对象,分别由 L_original 和 L_new 引用。修改其中一个列表不会影响另一个。
练习:原地移除所有匹配元素
现在,我们来练习编写一个函数,它不创建新列表,而是直接修改(突变)传入的列表。
任务是编写一个名为 remove_all 的函数。它接收一个列表 L 和一个元素 E,并原地修改 L,移除其中所有值等于 E 的元素。
以下是实现此功能的一种方法:
- 首先,创建列表
L的一个副本。 - 然后,清空原始列表
L。 - 最后,遍历副本,将所有不等于
E的元素添加回L中。
def remove_all(L, E):
L_copy = L[:] # 步骤1:创建副本
L.clear() # 步骤2:清空原始列表
for item in L_copy: # 步骤3:遍历副本
if item != E:
L.append(item) # 将符合条件的元素添加回L

调用此函数后,传入的列表 L 将被直接修改,无需返回任何值。

列表的删除操作





除了整体修改,我们经常需要从列表中删除特定元素。Python提供了几种方法。





以下是三种主要的删除方式:

del L[index]:根据索引删除元素。L.pop():删除并返回列表的最后一个元素。L.remove(value):删除列表中第一个与指定值匹配的元素。



让我们通过一个例子来观察它们的行为:
L = [2, 1, 3, 6, 3, 7, 0]
L.remove(2) # 删除第一个2 -> L变为 [1, 3, 6, 3, 7, 0]
L.remove(3) # 删除第一个3 -> L变为 [1, 6, 3, 7, 0]
del L[1] # 删除索引1的元素(6)-> L变为 [1, 3, 7, 0]
last_item = L.pop() # 删除并返回最后一个元素(0)-> L变为 [1, 3, 7],last_item为0

注意,这些操作都会直接修改(突变)列表 L。pop() 方法比较特殊,它既有修改列表的副作用,也会返回被删除的值。





遍历时修改列表的陷阱


现在,我们尝试用 remove 方法重写之前的 remove_all 函数。一个直观的想法是遍历列表,遇到等于 E 的元素就删除它。




def remove_all_buggy(L, E):
for item in L:
if item == E:
L.remove(E)


这段代码存在一个严重问题。假设 L = [1, 2, 2, 2],我们要移除所有 2。




- 第一次循环,
item是1,不删除。 - 第二次循环,
item是第一个2,条件成立,删除它。列表变为[1, 2, 2],但循环的内部指针已经移到了下一个位置(索引2)。 - 第三次循环,
item是现在位于索引1的2(原列表的第三个2),我们跳过了原列表的第二个2。 - 最终,列表中可能仍残留未被删除的
2。




核心问题:在遍历列表的同时对其进行修改(尤其是删除),会导致内部迭代指针错位,从而跳过某些元素或产生不可预测的行为。
解决方案:遍历副本,修改原始列表
为了避免上述陷阱,一个可靠的方法是:遍历列表的副本,但修改操作作用于原始列表。


我们通过另一个函数 remove_duplicates 来演示。该函数接收两个列表 L1 和 L2,并修改 L1,移除其中所有也存在于 L2 中的元素。




以下是正确的实现:


def remove_duplicates(L1, L2):
L1_copy = L1[:] # 创建L1的副本
for item in L1_copy: # 遍历副本
if item in L2: # 如果该元素也在L2中
L1.remove(item) # 则从原始L1中移除它
通过遍历 L1_copy,我们获得了一个稳定的元素序列。即使 L1 在循环中被修改,也不会影响我们遍历 L1_copy 的过程,从而确保每个元素都被正确检查。





理解别名(Aliasing)





别名是指多个变量名指向内存中的同一个对象。


在Python中,简单的赋值操作 new_list = old_list 并不会创建副本,而只是创建了一个指向同一列表对象的别名。




old_list = [10, 20, 30, 40]
alias_list = old_list # alias_list 是 old_list 的别名,指向同一个列表
alias_list[0] = 999
print(old_list) # 输出:[999, 20, 30, 40],原始列表也被修改了


关键区别:
alias_list = old_list创建的是别名。copy_list = old_list[:]创建的是副本(克隆)。


在函数调用中,形参(parameter)会成为实参(argument)的别名。这意味着,如果向函数传递一个可变对象(如列表),并在函数内部修改它,那么函数外部的原始变量所指向的对象也会被改变。









浅拷贝与深拷贝

当列表的元素本身也是可变对象(如嵌套列表)时,拷贝行为会变得更加复杂。

- 浅拷贝(Shallow Copy):使用
list.copy()或list[:]创建。只复制最外层的列表结构,内部的子列表等可变元素仍然是引用(别名)。 - 深拷贝(Deep Copy):使用
copy.deepcopy()创建。递归地复制所有层级的对象,创建一个完全独立的新对象。




考虑以下例子:



import copy




old_list = [[1, 2], [3, 4], [5, 6]]




# 浅拷贝
shallow_copy = old_list[:]
# 深拷贝
deep_copy = copy.deepcopy(old_list)




# 修改原始列表的一个子元素
old_list[1][1] = 999
print(shallow_copy) # 输出:[[1, 2], [3, 999], [5, 6]],子列表被共享,因此被修改
print(deep_copy) # 输出:[[1, 2], [3, 4], [5, 6]],完全独立,未被修改
# 在原始列表顶层添加新元素
old_list.append([7, 8])
print(shallow_copy) # 输出:[[1, 2], [3, 999], [5, 6]],顶层结构独立,未添加[7,8]
选择依据:如果确定嵌套结构中的元素是不可变的,或者你希望共享子对象,使用浅拷贝。如果需要完全独立的副本,避免任何意外的联动修改,则使用深拷贝。


总结
本节课中我们一起学习了:
- 列表副本的创建:使用
L[:]语法。 - 列表的删除操作:
del、pop()和remove()的使用与区别。 - 遍历时修改的陷阱:在
for循环中直接修改正在遍历的列表可能导致元素被跳过。 - 安全的修改模式:通过遍历副本,修改原始列表来避免上述陷阱。
- 别名与克隆:赋值
=创建别名,切片[:]创建克隆。函数参数传递的是别名。 - 浅拷贝与深拷贝:处理嵌套可变对象时,理解
copy()与deepcopy()的区别至关重要。


掌握这些概念对于正确、高效地使用Python中的可变数据结构非常重要,能帮助你避免许多隐蔽的错误。
012:列表推导式、函数作为对象、测试与调试 🐍





在本节课中,我们将学习几个提升代码效率和可读性的高级概念,包括列表推导式、函数的默认参数以及函数作为对象进行传递和返回。最后,我们将探讨如何系统地测试和调试代码,以确保程序的正确性。










列表推导式 📝




上一节我们介绍了函数的基本概念。本节中,我们来看看一种创建列表的简洁方法——列表推导式。






在处理列表时,一个非常常见的模式是:创建一个新列表,其中每个元素都是对输入列表中每个元素应用某个函数的结果。例如,以下代码将列表 L 中的每个元素平方:
def square_list(L):
result = []
for e in L:
result.append(e**2)
return result



由于这种模式非常普遍,Python 提供了一种更简洁的写法——列表推导式。它可以将上述多行代码压缩为一行。







列表推导式的基本语法如下:
[expression for variable in sequence if condition]




expression:应用于每个元素的函数或表达式。variable:迭代变量。sequence:要迭代的序列(如列表、元组、range等)。condition(可选):一个条件,只有满足条件的元素才会被处理。
以下是使用列表推导式重写上述平方函数的例子:
L_new = [e**2 for e in L]





如果只想处理列表中的偶数,可以添加条件:





L_new = [e**2 for e in L if e % 2 == 0]
列表推导式不仅限于简单的数学运算,可以包含更复杂的表达式。例如,创建一个列表,其中每个元素都是一个包含原数及其平方的列表:




result = [[e, e**2] for e in range(4) if e % 2 != 0]
# 结果为 [[1, 1], [3, 9]]

使用列表推导式可以使代码更紧凑、更易读,尤其是在创建简单列表时。






默认参数与函数作为对象 🔧


上一节我们学习了列表推导式。本节中,我们来探讨函数的两个高级特性:默认参数和函数作为对象。





默认参数






有时我们希望函数的某些参数具有默认值,这样用户在调用时可以选择性地提供这些参数。这通过关键字参数(或称默认参数)实现。







以二分法求平方根的函数为例。最初,精度 epsilon 在函数内部被硬编码为 0.01。为了让用户能控制精度,我们可以将其添加为参数:



def bisection_root(x, epsilon):
# ... 函数体使用 epsilon ...







但是,大多数用户可能希望使用一个合理的默认精度。为此,我们可以在函数定义中为 epsilon 指定默认值:





def bisection_root(x, epsilon=0.01):
# ... 函数体使用 epsilon ...









现在,用户可以这样调用:
bisection_root(123):使用默认的epsilon=0.01。bisection_root(123, 0.5):使用自定义的epsilon=0.5。


规则:在函数定义中,所有带默认值的参数必须放在非默认参数之后。





函数作为对象






在 Python 中,函数也是对象。这意味着函数名可以像变量一样被赋值、传递给其他函数,甚至作为其他函数的返回值。





函数作为返回值:一个函数可以返回另一个函数对象。这在创建“函数工厂”或实现闭包时非常有用。考虑以下例子:







def make_prod(a):
def g(b):
return a * b
return g



doubler = make_prod(2) # doubler 现在是一个将输入乘以2的函数
value = doubler(3) # 结果为 6


在这个例子中,make_prod 返回了内部定义的函数 g。g 记住了它被创建时的环境(即参数 a 的值),即使 make_prod 已经执行完毕。这种技术允许我们动态地创建具有特定行为的函数。

理解函数作为对象,有助于我们编写更模块化、更灵活的代码。

测试与调试策略 🐛
上一节我们探讨了函数的高级用法。本节中,我们来看看如何确保代码正确运行的两种关键活动:测试与调试。
测试
测试是验证代码行为是否符合预期的过程。良好的测试策略能帮助我们在早期发现错误。
编写可测试的代码:
- 写好文档:为函数编写清晰的文档字符串(docstring),说明其输入、输出和行为。
- 模块化:将代码分解为小的、独立的函数。这样更容易对每个部分进行单独测试。
测试类型:
- 单元测试:针对单个函数或模块进行测试。为其设计一系列输入和预期输出。
- 回归测试:在修复一个错误后,重新运行所有之前的测试,确保修复没有引入新的错误。
- 集成测试:将所有模块组合在一起后进行的测试,确保它们能协同工作。
设计测试用例的方法:
- 黑盒测试:仅根据函数的规范(文档)设计测试用例,不关心内部实现。这样即使实现改变,测试用例依然有效。
- 玻璃盒测试:根据函数的代码逻辑设计测试用例。目标是让测试覆盖代码的每一条可能路径(如每个
if-else分支、循环的不同次数等)。同时要特别注意边界条件。


调试


调试是当测试失败时,定位并修复代码中错误的过程。这是一个需要经验和系统方法的活动。




常见的错误类型:
- 语法/静态错误:Python 解释器会直接指出,如
IndentationError,SyntaxError。 - 运行时错误:程序运行中崩溃,如
IndexError(索引越界)、TypeError(类型不匹配)、NameError(变量未定义)。 - 逻辑错误:代码能运行,但产生错误的结果。这是最难调试的一类错误。
调试策略与技巧:
- 科学方法:观察错误现象 -> 形成假设 -> 设计实验验证 -> 分析结果。
- 使用工具:
- Python Tutor:可视化代码执行过程,查看每一步的变量状态。
- 打印语句:在关键位置打印变量值,跟踪程序状态。
- 二分法调试:如果不知道错误在哪,可以在代码中间位置打印状态。如果状态正确,错误在后半部分;否则在前半部分。然后对有问题的那一半重复此过程,逐步缩小范围。
- 休息与解释:有时离开电脑休息一下,或者向他人(甚至橡皮鸭)解释你的代码,能帮助你从新角度发现问题。


一个调试示例:假设一个检查列表是否为回文的函数 is_pal 对输入 [‘A‘, ‘B‘] 错误地返回了 True。
- 在函数中间(如反转列表后)添加
print语句,发现反转操作没有正确执行。 - 进一步在反转操作前后添加
print,发现是因为错误地使用了list.reverse方法(应为list.reverse())且未创建列表的副本,导致原列表被修改。 - 修复这两个问题后,函数工作正常。
- 最后,重新运行之前通过的测试用例(如
[‘A‘, ‘B‘, ‘C‘, ‘B‘, ‘A‘]),确保修复没有破坏原有功能。
总结 📚

本节课中我们一起学习了几个重要的编程概念和最佳实践。
我们首先学习了列表推导式,这是一种快速、简洁地创建和转换列表的方法,可以替代常见的 for 循环加 append 的模式。
接着,我们探讨了函数的两个高级特性:默认参数允许我们为函数参数提供预设值,使函数调用更灵活;而函数作为对象的特性,使得函数可以被赋值、传递,甚至作为其他函数的返回值,这为编写高阶函数和实现闭包提供了可能。
最后,我们深入研究了测试与调试。我们了解了如何通过单元测试、回归测试和集成测试来系统化地验证代码。同时,我们学习了多种调试策略,包括使用打印语句、Python Tutor 可视化工具以及“二分法”定位错误,并强调了像科学家一样系统化地解决问题的重要性。


掌握这些技能将帮助你写出更高效、更健壮且更易于维护的代码。
013:异常与断言 🐛

在本节课中,我们将要学习Python中的异常与断言。它们是程序运行时出现的错误,但我们可以通过特定的代码结构来“捕获”并处理这些错误,而不是让程序直接崩溃。我们将学习如何使用try和except块来优雅地处理异常,以及如何使用assert语句来确保代码的假设条件得到满足。
什么是异常?🚨
当你的代码运行时,通常一切顺利。但有时,代码会遇到意外情况,这时就会引发一个“异常”。我们已经见过很多异常的例子,例如:
- 索引错误:当你试图访问列表中不存在的索引时。
- 类型错误:当你对不兼容的类型进行操作时。
- 语法错误:当代码的语法不正确时。
- 名称错误:当你引用一个未定义的变量时。
到目前为止,每当遇到异常,程序就会立即崩溃。但在Python中,我们可以编写代码来“处理”这些异常,让程序有机会从错误中恢复或优雅地终止。

处理异常:try 和 except 🛡️



我们使用try和except代码块来处理异常。其基本思想是:将可能出问题的代码放在try块中,如果这些代码成功执行,则跳过except块;如果try块中的代码引发了异常,程序会立即跳转到对应的except块中执行处理代码。


我们可以将其类比为if-else结构:
try块:相当于“尝试执行这些代码”。- 如果成功(无异常):相当于
if条件为真,不执行else部分。 - 如果失败(有异常):相当于
if条件为假,执行else(即except)部分来处理问题。
示例:对字符串中的数字求和




假设我们有一个函数sum_digits,它接收一个字符串,并返回其中所有数字字符的和。


不使用异常处理的版本:
def sum_digits(s):
total = 0
for char in s:
if char in ‘0123456789‘:
total += int(char)
return total
print(sum_digits(‘123‘)) # 输出:6
print(sum_digits(‘123abc‘)) # 输出:6 (非数字字符被if语句过滤)
如果去掉if判断,直接对每个字符进行int()转换,当遇到非数字字符(如‘a‘)时,程序会抛出ValueError并崩溃。
使用异常处理的版本:
def sum_digits_except(s):
total = 0
for char in s:
try:
total += int(char)
except ValueError:
print(f‘Could not convert character: {char}‘)
return total
print(sum_digits_except(‘123‘)) # 正常输出:6
print(sum_digits_except(‘123abc‘)) # 会打印无法转换的字符,但仍返回6
在这个版本中,try块尝试将每个字符转换为整数。如果转换成功,就加到总和里。如果转换失败(引发ValueError),则执行except块中的代码,打印一条提示信息,然后继续处理下一个字符,程序不会崩溃。

捕获特定类型的异常 🎯

我们可以为不同类型的异常编写不同的except块,从而进行更精细的错误处理。
示例:处理用户输入
考虑一个需要用户输入两个数字并进行除法的程序。
# 没有异常处理
a = int(input(‘Enter first number: ‘))
b = int(input(‘Enter second number: ‘))
print(f‘a / b = {a / b}‘)
这段代码可能遇到两种异常:
ValueError:如果用户输入的不是数字(如‘hello‘)。ZeroDivisionError:如果用户输入的第二个数字是0。
使用特定异常处理的版本:
try:
a = int(input(‘Enter first number: ‘))
b = int(input(‘Enter second number: ‘))
print(f‘a / b = {a / b}‘)
except ValueError:
print(‘Could not convert to a number.‘)
except ZeroDivisionError:
print(‘Cannot divide by zero.‘)
print(f‘a / b = Infinity‘)
print(f‘a + b = {a + b}‘)
except:
print(‘Something went very wrong!‘)
- 如果输入引发
ValueError,则执行第一个except块。 - 如果输入引发
ZeroDivisionError,则执行第二个except块。 - 最后一个
except块(不指定异常类型)会捕获所有其他未被前面捕获的异常,作为“安全网”。
主动引发异常 ⚠️
有时,我们希望在检测到某种错误条件时,主动停止程序,但使用我们自定义的错误信息。这可以通过raise语句实现。

示例:在异常处理中引发自定义异常

修改之前的sum_digits函数,如果字符串包含非数字字符,我们不是简单地跳过,而是主动引发一个带有清晰信息的ValueError。
def sum_digits_raise(s):
total = 0
for char in s:
try:
total += int(char)
except ValueError:
raise ValueError(‘String contained a non-digit character.‘)
return total




print(sum_digits_raise(‘123‘)) # 正常输出:6
print(sum_digits_raise(‘123a‘)) # 引发 ValueError: String contained a non-digit character.
这样,程序仍然会停止,但错误信息对我们和用户来说都更清晰、更有帮助。


断言:强制执行约定 ✅
断言是一种特殊的异常,用于“防御性编程”。它用于在代码中强制执行为函数或变量设定的“约定”或假设。如果断言的条件为False,程序会立即引发一个AssertionError并停止。
断言的语法是:
assert <condition>, ‘Error message if condition is False‘
示例:在函数中使用断言
继续使用sum_digits函数,假设我们的约定要求输入字符串s非空。
def sum_digits_assert(s):
assert len(s) > 0, ‘Input string cannot be empty‘
total = 0
for char in s:
if char in ‘0123456789‘:
total += int(char)
return total

print(sum_digits_assert(‘123‘)) # 正常输出:6
print(sum_digits_assert(‘‘)) # 引发 AssertionError: Input string cannot be empty
使用断言的好处是,它能在错误发生的源头就阻止程序继续执行,防止错误的值在后续代码中传播,使得调试更加容易。


综合练习:成对除法函数 ✍️
让我们编写一个函数pairwise_division,它接受两个等长的非空列表L_num和L_denom,返回一个新列表,其中每个元素是L_num[i] / L_denom[i]。
要求:
- 如果
L_denom中包含0,则引发一个ValueError。 - 使用断言来确保输入列表非空且长度相等。
以下是实现步骤:


首先,实现基本功能:
def pairwise_division(L_num, L_denom):
result = []
for i in range(len(L_num)):
result.append(L_num[i] / L_denom[i])
return result


然后,添加异常处理来检查分母是否为0:
def pairwise_division(L_num, L_denom):
result = []
for i in range(len(L_num)):
if L_denom[i] == 0:
raise ValueError(‘Denominator list contains zero.‘)
result.append(L_num[i] / L_denom[i])
return result



最后,添加断言来强制执行输入约定:
def pairwise_division(L_num, L_denom):
assert len(L_num) > 0 and len(L_denom) > 0, ‘Input lists cannot be empty.‘
assert len(L_num) == len(L_denom), ‘Input lists must have the same length.‘
result = []
for i in range(len(L_num)):
if L_denom[i] == 0:
raise ValueError(‘Denominator list contains zero.‘)
result.append(L_num[i] / L_denom[i])
return result



# 测试
print(pairwise_division([4,5,6], [1,2,3])) # 输出:[4.0, 2.5, 2.0]
print(pairwise_division([4,5], [1,2,3])) # 引发 AssertionError: Input lists must have the same length.
print(pairwise_division([4,5,6], [1,0,3])) # 引发 ValueError: Denominator list contains zero.




总结 📚
本节课中我们一起学习了Python中的异常与断言。
- 异常是程序运行时发生的错误。使用
try和except块可以“捕获”并处理异常,避免程序直接崩溃。你可以:- 打印友好的错误信息。
- 设置默认值。
- 或者使用
raise主动引发自定义的异常。
- 断言是一种特殊的异常,使用
assert语句。它用于在代码中强制执行假设和约定(例如函数参数必须满足的条件)。如果断言失败,程序会立即停止,这有助于在错误传播开之前就发现它们,是防御性编程的重要工具。


掌握异常和断言能让你编写出更健壮、更易于调试和维护的程序。
014:字典 📚

在本节课中,我们将要学习一种新的复合数据类型——Python字典。我们将了解为什么需要字典,学习其基本语法和操作,并通过实例理解如何有效地使用字典来组织和处理数据。

为什么需要字典? 🤔

在之前的课程中,我们学习了列表和元组。假设我们需要存储学生的信息,例如姓名和成绩。使用列表,一种方法可能是创建两个平行的列表:一个存储姓名,另一个存储成绩,并约定相同索引位置的信息属于同一个学生。

names = ['Anna', 'John', 'Eric']
grades = ['B', 'B', 'A']





要查找特定学生的成绩,我们需要编写一个函数,首先在names列表中查找该学生的索引,然后使用该索引从grades列表中获取成绩。这种方法在数据量很大或需要维护多个平行列表(如作业成绩、小测验成绩)时会变得非常繁琐且容易出错。


另一种方法是使用一个“主列表”,其中每个元素本身又是一个列表,包含一个学生的所有信息。但这会导致数据结构非常复杂,代码难以阅读和维护。



上一节我们介绍了使用列表存储关联数据的局限性,本节中我们来看看字典如何提供一种更优雅的解决方案。




什么是字典? 🔑

Python字典是一种映射类型的数据结构。它存储的是键值对。你可以将“键”想象成自定义的索引,将“值”想象成与该索引关联的数据。这就像一本真正的字典,将单词(键)映射到其定义(值)。





与列表不同,列表的“索引”必须是连续的整数(0, 1, 2...),而字典的“键”可以是任何不可变的对象(如整数、浮点数、字符串、元组)。



创建字典
字典使用花括号 {} 创建。


- 创建一个空字典:
my_dict = {} - 创建包含键值对的字典:
my_dict = {key1: value1, key2: value2}
键和值之间用冒号 : 分隔,每个键值对之间用逗号 , 分隔。


# 创建一个将学生姓名映射到成绩的字典
grades = {'Anna': 'B', 'Matt': 'A', 'John': 'B', 'Katie': 'A'}
print(grades) # 输出:{'Anna': 'B', 'Matt': 'A', 'John': 'B', 'Katie': 'A'}


字典的基本操作 🛠️




查找值


要获取与某个键关联的值,使用与列表索引类似的语法:字典名[键]。
grades = {'Anna': 'B', 'Matt': 'A', 'John': 'B'}
print(grades['John']) # 输出:B
如果查找的键不存在,Python会引发KeyError异常。
添加或修改条目
直接为字典中一个新的键赋值,即可添加新条目。如果该键已存在,则会修改其对应的值。


grades = {'Anna': 'B', 'Matt': 'A'}
grades['Grace'] = 'A' # 添加新条目:Grace -> A
print(grades) # 输出:{'Anna': 'B', 'Matt': 'A', 'Grace': 'A'}


grades['Grace'] = 'C' # 修改已有条目:Grace -> C
print(grades) # 输出:{'Anna': 'B', 'Matt': 'A', 'Grace': 'C'}





删除条目

使用del语句可以删除字典中的键值对。
grades = {'Anna': 'B', 'Matt': 'A', 'John': 'B'}
del grades['Anna']
print(grades) # 输出:{'Matt': 'A', 'John': 'B'}
检查键是否存在
使用in操作符可以检查一个键是否存在于字典中。注意:in只检查键,不检查值。

grades = {'Anna': 'B', 'Matt': 'A', 'John': 'B'}
print('John' in grades) # 输出:True
print('Daniel' in grades) # 输出:False
print('B' in grades) # 输出:False (因为'B'是值,不是键)

遍历字典 🔄


字典不是有序序列,但我们可以方便地遍历其所有内容。
遍历所有键

使用.keys()方法获取一个包含所有键的可迭代对象。



grades = {'Anna': 'B', 'Matt': 'A', 'John': 'B'}
for student in grades.keys():
print(student)
# 输出:
# Anna
# Matt
# John
遍历所有值
使用.values()方法获取一个包含所有值的可迭代对象。
for grade in grades.values():
print(grade)
# 输出:
# B
# A
# B
遍历所有键值对(条目)
使用.items()方法获取一个可迭代对象,其中每个元素是一个(键, 值)元组。这是最常用的遍历方式。
for student, grade in grades.items():
print(f"{student} got a {grade}")
# 输出:
# Anna got a B
# Matt got a A
# John got a B






字典的特性与限制 ⚠️




可变性
字典是可变对象。使用赋值=会创建别名,使用.copy()方法可以创建副本。
d1 = {'a': 1}
d2 = d1 # d2是d1的别名,指向同一个字典对象
d2['a'] = 99
print(d1) # 输出:{'a': 99}
d3 = d1.copy() # d3是d1的副本,是一个新对象
d3['a'] = 100
print(d1) # 输出:{'a': 99} (d1未被修改)
键的限制

- 唯一性:字典中的键必须是唯一的。如果为同一个键赋值两次,后一个值会覆盖前一个。
- 不可变性:键必须是不可变(可哈希)的类型,如整数、浮点数、字符串、元组。列表和字典等可变类型不能作为键。




为什么键必须不可变?
Python使用哈希函数将键转换为一个数字(哈希值),这个数字决定了键值对在内存中的存储位置。如果键是可变对象(如列表),其内容改变后,哈希值也可能改变,导致无法再通过原来的键找到存储的值。


值的自由性



字典的值可以是任何类型的对象,包括列表、其他字典等,并且值可以重复。


综合示例:分析歌词词频 🎵
让我们通过一个完整的例子来应用字典。我们的目标是找出歌曲歌词中出现频率最高的单词。
我们将问题分解为三个步骤:
- 创建频率字典:将歌词字符串转换为一个字典,记录每个单词出现的次数。
- 找出最高频单词:在频率字典中,找到出现次数最多的单词(可能有多个)。
- 找出所有高频单词:找出所有出现次数超过某个阈值的单词。
步骤1:创建频率字典
def generate_freq_dict(song):
"""接收一个歌词字符串,返回一个单词频率字典。"""
song = song.lower() # 转换为小写,避免大小写差异
words = song.split() # 按空格分割成单词列表
freq_dict = {}
for word in words:
if word in freq_dict: # 如果单词已在字典中
freq_dict[word] += 1 # 计数加1
else: # 如果单词是第一次出现
freq_dict[word] = 1 # 在字典中添加该单词,计数为1
return freq_dict


# 示例
lyrics = "Ro Ro Ro your boat gently down the stream"
freq = generate_freq_dict(lyrics)
print(freq) # 输出类似:{'ro': 3, 'your': 1, 'boat': 1, ...}
步骤2:找出最高频单词


def most_common_words(freq_dict):
"""接收频率字典,返回一个元组 (最高频单词列表, 最高频率)。"""
highest_freq = max(freq_dict.values()) # 找到最高的频率值
words = []
for word, count in freq_dict.items(): # 遍历所有条目
if count == highest_freq: # 如果频率等于最高值
words.append(word) # 将该单词加入列表
return (words, highest_freq)
# 接上例
common_words, freq_num = most_common_words(freq)
print(f"Most common word(s): {common_words}, appearing {freq_num} times.")


步骤3:找出所有高频单词(利用可变性和函数复用)




def words_with_freq_at_least(freq_dict, min_freq):
"""
接收频率字典和最小频率阈值。
返回一个列表,其中每个元素是一个元组 (单词列表, 频率),
代表所有频率至少为 min_freq 的单词组,按频率从高到低排序。
"""
result = []
# 重复查找当前字典中的最高频单词,直到最高频率低于阈值
while True:
common_words, current_freq = most_common_words(freq_dict)
if current_freq < min_freq:
break # 如果当前最高频率已低于阈值,停止循环
result.append((common_words, current_freq))
# 从字典中删除这些最高频单词,以便下一轮查找次高频的
for word in common_words:
del freq_dict[word]
return result

# 接上例 (注意:此函数会修改输入的字典)
freq_copy = freq.copy() # 使用副本,避免修改原数据
high_freq_words = words_with_freq_at_least(freq_copy, 2)
print(high_freq_words)



总结 📝
本节课中我们一起学习了Python字典。



- 字典是一种强大的映射类型数据结构,用于存储键值对。
- 与列表相比,字典的“键”可以是自定义的、不可变的对象,使得数据关联更加直观和高效。
- 我们掌握了字典的基本操作:创建、查找、添加/修改、删除条目,以及检查键是否存在。
- 我们学习了如何遍历字典的键、值和键值对。
- 我们了解了字典的关键特性:它是可变的,键必须唯一且不可变,而值可以是任意类型。
- 最后,通过分析歌词词频的综合示例,我们看到了字典在实际问题中的应用,以及如何将复杂任务分解并利用字典的特性(如
.items()遍历、可变性)来简化代码。

字典是Python中极其重要的数据结构,它将帮助你更优雅地处理许多需要建立关联关系的数据问题。
015:递归

在本节课中,我们将要学习一种新的编程技术——递归。递归是一种算法思想,它要求我们以完全不同的方式思考问题。我们将通过对比迭代算法,并分析乘法、阶乘等具体例子,来理解递归的核心概念和工作原理。
从迭代到递归
上一节我们介绍了迭代算法,它通过循环来重复执行任务。本节中我们来看看如何用递归思想解决同样的问题。
迭代算法的核心是使用循环(如 for 循环或 while 循环)。在循环中,一些变量会不断改变其值,以捕获计算的状态。循环结束时,我们得到一个累积或变化后的结果。
以下是使用迭代思想实现乘法(假设我们不知道 * 运算符)的两种方法:
使用 for 循环:
def mult_iter(a, b):
total = 0
for i in range(b):
total += a
return total
使用 while 循环:
def mult_iter2(a, b):
result = 0
while b > 0:
result += a
b -= 1
return result
在这两个例子中,变量 total 或 result 以及计数器 i 或 b 共同捕获了计算的状态。
递归模式
现在,让我们从递归的角度思考同一个乘法问题。递归的核心思想是将原始问题分解为一个或多个更小的、相似的子问题。
以计算 5 * 4 为例:
- 原始问题:
5 * 4 - 递归分解:
5 * 4等价于5 + (5 * 3) - 继续分解:
5 * 3等价于5 + (5 * 2) - 继续分解:
5 * 2等价于5 + (5 * 1) - 基础情况:
5 * 1我们知道结果就是5


这个模式可以总结为数学定义:
- 递归步骤:当
b > 1时,a * b = a + (a * (b-1)) - 基础情况:当
b = 1时,a * b = a


编写递归函数




根据上面的数学定义,我们可以直接将其翻译成 Python 代码。

def mult_recur(a, b):
if b == 1: # 基础情况
return a
else: # 递归步骤
return a + mult_recur(a, b-1)
这个函数的关键在于,它在函数体内部调用了自身(mult_recur),但每次调用时,参数 b 都会减小,最终会达到基础情况 b == 1,从而停止递归。
递归的执行过程
理解递归函数如何执行至关重要。当我们调用 mult_recur(5, 4) 时:

- 创建第一个函数环境,
a=5, b=4。由于b不为 1,它需要计算5 + mult_recur(5, 3),因此挂起等待。 - 创建第二个函数环境,
a=5, b=3。它需要计算5 + mult_recur(5, 2),同样挂起。 - 创建第三个函数环境,
a=5, b=2。它需要计算5 + mult_recur(5, 1),挂起。 - 创建第四个函数环境,
a=5, b=1。满足基础情况,立即返回5。 - 结果
5返回给第三步的调用,第三步计算出5 + 5 = 10并返回。 - 结果
10返回给第二步的调用,第二步计算出5 + 10 = 15并返回。 - 结果
15返回给第一步的调用,第一步计算出5 + 15 = 20并作为最终结果返回。
这个过程展示了递归的“递”和“归”:函数调用层层深入(递),直到基础情况;然后结果层层返回并组合(归),最终解决原始问题。
递归与迭代的思维对比
我们可以用一个现实世界的例子来对比两种思维:
- 迭代(学生主导):学生为了成绩复议,需要亲自依次去找讲师、助教、实验员、评分员,并自己累加每个人的反馈,最后得到总分。
- 递归(传递责任):学生只向讲师提出复议请求。讲师可能将部分问题交给助教,助教可能再交给实验员,如此传递,直到评分员处理完他的部分。然后,结果(分数)沿着请求链反向传递回来,每个经手人汇总自己部分的分数,最终讲师将总分返回给学生。
在递归中,早期的函数调用(如学生)只是发起请求并等待,实际工作是由后续更深层的调用完成的,结果也是由内向外组合而成的。
更多递归示例


上一节我们通过乘法理解了递归,本节中我们来看看阶乘和幂运算的例子。
阶乘的递归实现
阶乘的数学定义是:n! = n * (n-1) * ... * 1
其递归模式为:
- 基础情况:
0! = 1或1! = 1 - 递归步骤:
n! = n * (n-1)!
对应的 Python 代码如下:
def factorial_recur(n):
if n == 0 or n == 1: # 基础情况
return 1
else: # 递归步骤
return n * factorial_recur(n-1)


幂运算的递归实现
计算 n 的 p 次幂(n**p),假设不使用 ** 运算符。
其递归模式为:
- 基础情况1:
n**0 = 1 - 基础情况2:
n**1 = n - 递归步骤:
n**p = n * (n**(p-1))
对应的 Python 代码如下:
def power_recur(n, p):
if p == 0: # 基础情况1
return 1
elif p == 1: # 基础情况2
return n
else: # 递归步骤
return n * power_recur(n, p-1)
递归的重要特性
通过以上例子,我们可以总结出递归的几个关键特性:
- 独立的函数环境:每次递归调用都会创建一个全新的、独立的函数执行环境,拥有自己的参数和局部变量,互不干扰。
- 向基础情况推进:递归步骤必须改变参数,使问题规模不断减小,最终必然达到基础情况,否则会导致无限递归。
- 清晰的递归步骤:必须能够用更小规模的相同问题来定义当前问题的解。
- 信任链:编写递归函数时,我们相信递归调用能正确解决更小规模的问题,我们只需关心如何组合这些结果以及定义最简单的情况(基础情况)。
何时使用递归


对于阶乘、乘法等简单问题,迭代解法通常更直观。然而,递归在解决某些类型的问题时更具优势:
以下是递归更适用的场景:
- 问题具有自相似结构:如文件系统遍历,文件夹内可能包含子文件夹,深度未知。
- 问题可以自然分解:如解析嵌套的数学表达式(包含多层括号)。
- 数据结构是递归定义的:如下一讲将看到的嵌套列表(列表中的元素也可以是列表)。

在这些情况下,递归代码往往更简洁、更贴近问题的本质描述。
总结

本节课中我们一起学习了递归的基本概念。我们了解到递归是一种通过将问题分解为更小的、相似的子问题来解决问题的编程技术。关键要点包括:定义清晰的基础情况以终止递归,以及定义递归步骤将问题规模缩小。我们通过乘法、阶乘和幂运算的例子,分析了递归函数的编写方法和执行过程,并对比了递归与迭代思维的不同。最后,我们探讨了递归适用的场景。在下一讲中,我们将继续探索递归,并将其应用于更复杂的数据结构。
016:非数值递归 🧮➡️📋

在本节课中,我们将要学习递归在非数值数据上的应用,特别是列表。我们将从回顾数值递归开始,然后深入探讨如何将递归思想应用于列表操作,例如求和、查找元素、展平列表和深度反转列表。







数值递归回顾 🔄





上一节我们介绍了递归的基本思想,本节中我们来看看如何将其应用于斐波那契数列和篮球得分问题。







斐波那契数列







斐波那契数列的数学定义如下:
- 基础情况:
fib(1) = 1,fib(2) = 1 - 递归步骤:
fib(n) = fib(n-1) + fib(n-2)


对应的Python递归函数如下:




def fib(x):
if x == 1 or x == 2:
return 1
else:
return fib(x-1) + fib(x-2)




然而,这种实现效率低下,因为它会重复计算许多子问题。例如,计算 fib(6) 时,fib(3) 被计算了多次。






为了提高效率,我们可以使用记忆化技术,即用一个字典来存储已经计算过的结果。




def fib_efficient(n, d):
if n in d:
return d[n]
else:
ans = fib_efficient(n-1, d) + fib_efficient(n-2, d)
d[n] = ans
return ans


# 初始化字典,包含基础情况
d = {1:1, 2:1}
print(fib_efficient(6, d))
使用记忆化后,计算 fib(34) 的函数调用次数从约1150万次减少到仅65次,极大地提升了性能。
篮球得分问题 🏀
问题:篮球得分可以是1分、2分或3分。给定一个总分 x,计算有多少种不同的得分组合方式。

以下是递归思路:
- 基础情况:
score(1) = 1(仅一种方式:得1分)score(2) = 2(两种方式:1+1 或 2)score(3) = 4(四种方式:1+1+1, 1+2, 2+1, 3)
- 递归步骤:要得到总分
x,最后一步可以是得1分、2分或3分。因此,score(x) = score(x-1) + score(x-2) + score(x-3)。



对应的Python函数如下:
def score_count(x):
if x == 1:
return 1
elif x == 2:
return 2
elif x == 3:
return 4
else:
return score_count(x-1) + score_count(x-2) + score_count(x-3)




同样,这个函数也存在重复计算的问题,你可以尝试为其添加记忆化功能来优化。


列表上的递归 📋
现在,我们将递归思想应用于列表。列表具有天然的递归结构:一个列表可以看作是它的第一个元素与剩余元素组成的子列表的组合。






递归求列表元素和



问题:计算一个列表中所有数字元素的和。


递归思路:
- 基础情况:如果列表只有一个元素,和就是这个元素的值。
- 递归步骤:列表的总和 = 第一个元素 + 剩余列表的总和。
def total_recur(L):
if len(L) == 1:
return L[0]
else:
return L[0] + total_recur(L[1:])






练习:修改上述函数,计算列表中所有字符串元素的长度之和。
提示:基础情况返回 len(L[0]),递归步骤类似。


递归检查元素是否在列表中






问题:检查一个元素 e 是否存在于列表 L 中。


递归思路:
- 基础情况:如果列表为空,返回
False。如果列表只有一个元素,检查该元素是否等于e。 - 递归步骤:如果第一个元素等于
e,返回True;否则,在剩余列表中递归查找。



def in_list(L, e):
if len(L) == 0:
return False
elif L[0] == e:
return True
else:
return in_list(L[1:], e)




重要原则:递归函数中所有的 return 语句必须返回相同类型的值。在这个例子中,所有返回都是布尔值 (True/False)。







递归展平列表







问题:给定一个列表,其元素也是列表(仅一层嵌套),将其“展平”,即移除嵌套,将所有子列表的元素提取到顶层。



例如:[[1,2], [3,4], [9,8,7]] 展平后为 [1, 2, 3, 4, 9, 8, 7]。
递归思路:
- 基础情况:如果列表只有一个元素(该元素是一个子列表),直接返回这个子列表
L[0]。 - 递归步骤:将第一个子列表与展平后的剩余列表连接起来。
def flatten(L):
if len(L) == 1:
return L[0]
else:
return L[0] + flatten(L[1:])
练习:编写递归函数 in_lists_of_lists(L, e),检查元素 e 是否存在于一个由子列表构成的列表 L 的任何子列表中。
提示:基础情况检查 e in L[0];递归步骤中,如果 e 不在第一个子列表,则在剩余部分递归查找。
深度递归:处理嵌套列表 🔍
当列表包含多层嵌套时(列表中的列表,其中还可以有列表),迭代方法会变得复杂,而递归则能优雅地处理。





深度反转列表





问题:完全反转一个列表及其所有嵌套子列表中的元素顺序。


例如:深度反转 [1, [2, 3], [4, [5, 6]]] 应该得到 [[[6, 5], 4], [3, 2], 1]。




递归思路:我们需要区分当前处理的元素是普通值还是另一个列表。
- 基础情况:如果列表长度为1。
- 如果这个唯一元素不是列表,则列表已反转,直接返回
[元素]。 - 如果这个唯一元素是列表,则需要先深度反转这个子列表,然后返回结果。
- 如果这个唯一元素不是列表,则列表已反转,直接返回
- 递归步骤:列表有多个元素。
- 提取第一个元素
first。 - 对剩余列表
L[1:]进行深度反转,得到reversed_rest。 - 如果
first是列表,则先深度反转first,然后将结果附加到reversed_rest的末尾。 - 如果
first不是列表,则直接将[first]附加到reversed_rest的末尾。
- 提取第一个元素
def deep_reverse(L):
if len(L) == 0:
return []
elif len(L) == 1:
# 基础情况:只有一个元素
if isinstance(L[0], list):
# 如果它是列表,深度反转它
return [deep_reverse(L[0])]
else:
# 如果它不是列表,直接返回它(包装在列表中)
return [L[0]]
else:
# 递归步骤:多个元素
first = L[0]
rest = L[1:]
reversed_rest = deep_reverse(rest)
if isinstance(first, list):
# 如果第一个元素是列表,深度反转它后再拼接
return reversed_rest + [deep_reverse(first)]
else:
# 如果第一个元素不是列表,直接拼接
return reversed_rest + [first]




这个函数的核心思想是:在递归的每一层,不仅反转当前层的顺序,还要求任何是列表的元素也进行自我深度反转。


何时使用递归? 🤔
在以下情况,递归通常是更优或更直观的选择:
- 数据结构本身是递归的:例如文件目录树、嵌套列表、语法树等。
- 问题可以自然地分解为相同的子问题:例如分治算法(归并排序、快速排序)、遍历树或图。
- 迭代解决方案需要复杂的状态管理或嵌套循环,而递归能提供更清晰、更简洁的描述。


总结 📝


本节课中我们一起学习了:
- 递归的核心模式:定义基础情况(直接有解的问题)和递归步骤(将问题分解为更小的同类问题)。
- 记忆化:使用字典存储已计算结果,避免重复计算,显著提升递归函数效率(如斐波那契数列)。
- 将递归应用于列表:通过“第一个元素 + 剩余列表”的模式,我们实现了对列表的求和、查找、展平等操作。
- 深度递归:处理嵌套数据结构(如多层列表)时,递归能够简洁地描述“深入每一层进行处理”的逻辑,例如深度反转列表。
- 递归函数的设计原则:确保所有分支返回相同数据类型;可以从清晰(即使冗余)的逻辑开始,再逐步优化简化。





递归是一种强大的编程范式,它允许我们用简洁的代码描述复杂的重复性结构。掌握递归的关键在于练习识别问题的递归结构并信任函数能正确解决更小的子问题。
017:Python类 🐍



在本节课中,我们将要学习如何创建自己的对象类型。我们将通过定义Python类,将数据和操作数据的行为捆绑在一起,从而构建可重用的代码模块。








概述:什么是对象类型? 🤔
我们一直在使用各种对象,例如整数 1234、浮点数 3.14159、字符串 "hello"、列表 [1, 2, 3] 和字典 {'a': 1}。每个对象都有一个类型,类型决定了我们可以对该对象执行哪些操作。
当我们决定创建一个新的对象类型时,需要考虑两件事:
- 数据抽象:用什么数据来表示这个对象?
- 接口/行为:这个对象可以执行哪些操作?
例如,一个“汽车”对象的数据抽象可能包括长度、宽度、高度和颜色。其行为可能包括“改变颜色”、“鸣笛”和“从A点行驶到B点”。
定义类:蓝图 📐
上一节我们介绍了对象类型的基本概念,本节中我们来看看如何用Python代码来定义它。
我们通过类来定义新的对象类型。类就像一个蓝图,它描述了这类对象应该包含哪些数据(属性)以及可以执行哪些操作(方法)。
我们将创建一个表示二维平面坐标点的类 Coordinate。
类的结构
定义一个类的基本语法如下:


class ClassName(object):
def __init__(self, param1, param2, ...):
# 初始化对象属性的代码
self.attribute1 = param1
self.attribute2 = param2
def method1(self, ...):
# 定义对象行为的代码


class是定义类的关键字。ClassName是你为新对象类型起的名字(例如Coordinate)。(object)表示这个类继承自Python的基本对象类型,目前我们总是这样写。__init__是一个特殊方法,称为构造函数。当我们创建这个类的一个新实例(即一个具体的对象)时,会自动调用它来初始化对象。self是一个特殊的参数,它代表将来要创建的这个对象本身。在类定义内部,我们通过self来访问和设置对象的属性和方法。
定义Coordinate类
对于我们的 Coordinate 类:
- 数据抽象:一个坐标点需要两个数字来表示,即x坐标和y坐标。
- 行为:我们希望能够获取点的x、y值,并计算它到另一个点的距离。
以下是 Coordinate 类的定义代码:
class Coordinate(object):
"""表示二维平面上的一个点"""
def __init__(self, x, y):
"""初始化一个坐标点。
参数:
x (float): x坐标值
y (float): y坐标值
"""
self.x = x # 将传入的x值赋给这个对象的x属性
self.y = y # 将传入的y值赋给这个对象的y属性
def distance(self, other):
"""计算当前点到另一个点的欧几里得距离。
参数:
other (Coordinate): 另一个坐标点对象
返回:
float: 两点之间的距离
"""
x_diff_sq = (self.x - other.x) ** 2 # (x1 - x2)的平方
y_diff_sq = (self.y - other.y) ** 2 # (y1 - y2)的平方
return (x_diff_sq + y_diff_sq) ** 0.5 # 平方根
def get_x(self):
"""返回当前点的x坐标"""
return self.x
def get_y(self):
"""返回当前点的y坐标"""
return self.y
关键点解释:
- 在
__init__方法中,self.x = x创建了对象的一个数据属性。self.x会随着对象一直存在。 - 在
distance方法中,self代表调用这个方法的那个坐标点对象,other代表作为参数传入的另一个坐标点对象。我们通过self.x和other.x来访问各自的数据。
使用类:创建和操作实例 🛠️
上一节我们定义了 Coordinate 类的蓝图,本节中我们来看看如何根据这个蓝图创建具体的对象(称为实例)并与之交互。
创建实例


要创建一个 Coordinate 对象,我们像调用函数一样调用类名,并传入 __init__ 方法中除 self 外所需的参数。








# 创建两个Coordinate对象(实例)
c = Coordinate(3, 4) # 创建一个坐标为(3, 4)的点,变量c指向它
origin = Coordinate(0, 0) # 创建一个坐标为(0, 0)的点,变量origin指向它
执行 c = Coordinate(3, 4) 时,Python会:
- 在内存中创建一个新的
Coordinate对象。 - 自动调用
__init__(self, 3, 4)方法,其中self就是这个新创建的对象。 - 执行
self.x = 3和self.y = 4,为新对象设置属性。 - 返回这个新对象,并将其赋值给变量
c。
访问数据属性


使用点运算符 . 可以访问对象的数据属性。





print(c.x) # 输出: 3
print(origin.x) # 输出: 0
print(c.y) # 输出: 4

调用方法


同样使用点运算符 . 来调用对象的方法。调用方法时,不需要显式地为 self 参数传递值,self 会自动绑定到点运算符之前的那个对象上。




# 计算点c到点origin的距离
dist = c.distance(origin)
print(dist) # 输出: 5.0 (因为3-0=3, 4-0=4, 勾股定理 sqrt(3^2 + 4^2) = 5)




方法调用解析:
c.distance(origin)意味着:“在对象c上调用distance方法,并将origin作为参数传递”。- 在
distance方法内部,self的值就是c,other的值就是origin。 - 因此,代码计算的是
(c.x - origin.x)^2 + (c.y - origin.y)^2的平方根。






理解self的另一种方式


为了更清晰地理解 self,我们可以用另一种等价但更冗长的方式调用方法:
# 常规方式(推荐)
dist = c.distance(origin)
# 等价方式(显式传递self)
dist = Coordinate.distance(c, origin)
在第二种方式中,我们直接通过类名 Coordinate 来访问 distance 方法,并显式地将 c 作为第一个参数(即 self)传入。这证明了在常规调用中,self 是如何被自动绑定的。






总结 🎯
本节课中我们一起学习了Python面向对象编程的基础——类的定义和使用。
我们掌握了以下核心概念:
- 类是创建新对象类型的蓝图,它封装了数据属性和方法。
__init__(self, ...)方法是构造函数,用于初始化新创建对象的数据属性。使用self.attribute = value来定义属性。- 方法是定义在类内部的函数,第一个参数总是
self,代表调用该方法的对象实例。 - 使用
ClassName(...)来创建类的实例(具体对象)。 - 使用点运算符
.来访问对象的属性(obj.attribute)或调用其方法(obj.method(...))。 - 在方法调用中,点运算符之前的对象会自动绑定到方法的
self参数上。


通过创建 Coordinate 类,我们实践了如何设计一个简单对象的数据抽象(x和y坐标)和行为(计算距离)。这是构建更复杂程序模块的第一步。在接下来的课程中,我们将以此为基础,构建更丰富的类层次结构。
018:更多Python类方法 🐍



在本节课中,我们将要学习如何创建更复杂的自定义数据类型,并深入探讨类方法的实现。我们将从复习上一节课的坐标类开始,然后构建一个圆类和一个分数类,并学习如何使用特殊方法(Dunder方法)来使我们的类更加强大和易于使用。







课程概述



上一节我们介绍了如何创建自定义数据类型,本节中我们来看看如何为这些类型添加更多功能。我们将学习如何在一个类中使用另一个类的对象作为属性,以及如何实现特殊方法来支持Python的内置操作符(如+、*和print)。




复习:类的两种视角






在编写代码时,我们需要从两种不同的视角来考虑:类的实现者和类的使用者。
- 实现类:我们告诉Python这个新数据类型的存在,决定它的名称、构成对象的属性(数据)以及它的行为(方法)。
- 使用类:我们假设这个类定义已经存在,然后创建该类型的多个实例对象,并通过调用方法来操作这些对象。



当我们实现类时,是在抽象地定义数据类型的通用属性和行为。而当我们使用类时,是在创建具有特定属性值的具体对象。

构建坐标类 📍
让我们回顾上一节课创建的坐标类。



class Coordinate(object):
def __init__(self, x, y):
self.x = x
self.y = y
def distance(self, other):
x_diff_sq = (self.x - other.x) ** 2
y_diff_sq = (self.y - other.y) ** 2
return (x_diff_sq + y_diff_sq) ** 0.5
def to_origin(self):
self.x = 0
self.y = 0



__init__方法是一个构造函数,它告诉Python如何创建这种类型的对象。self参数代表即将创建的对象实例本身。distance方法计算当前坐标点与另一个坐标点之间的距离,使用了勾股定理公式:distance = sqrt((x1 - x2)^2 + (y1 - y2)^2)。to_origin方法将当前坐标点的x和y值重置为0,从而将其移动到原点。




以下是使用坐标类的示例:


c = Coordinate(3, 4)
origin = Coordinate(0, 0)



print(f"c.x is {c.x}, origin.x is {origin.x}")
print(f"Distance is {c.distance(origin)}")



c.to_origin()
print(f"After reset, c.x is {c.x}, c.y is {c.y}")




构建圆类 ⭕




现在,让我们利用坐标类来构建一个更复杂的圆类。我们将圆定义为由圆心(一个坐标对象)和半径(一个整数)组成。
class Circle(object):
def __init__(self, center, radius):
# 类型检查:确保center是Coordinate类型,radius是整数
if type(center) != Coordinate:
raise ValueError("Center must be a Coordinate object")
if type(radius) != int:
raise ValueError("Radius must be an integer")
self.center = center
self.radius = radius
def is_inside(self, point):
# 计算圆心到给定点的距离,并与半径比较
return self.center.distance(point) < self.radius





在__init__方法中,我们添加了类型检查来确保传入的参数符合预期。is_inside方法则利用坐标类的distance方法来判断一个点是否在圆内。







以下是使用圆类的示例:






my_circle = Circle(Coordinate(2, 2), 2)
p1 = Coordinate(1, 1)
p2 = Coordinate(10, 10)







print(my_circle.is_inside(p1)) # 输出: True
print(my_circle.is_inside(p2)) # 输出: False







构建分数类 🔢
接下来,我们将创建一个表示分数的类。一个分数由分子和分母两个整数组成。


简单分数类


首先,我们创建一个简单的分数类,并为其添加加法和乘法方法。


class SimpleFraction(object):
def __init__(self, numerator, denominator):
self.num = numerator
self.den = denominator
def times(self, other):
# 分数乘法:分子乘分子,分母乘分母
top = self.num * other.num
bottom = self.den * other.den
return top / bottom # 返回浮点数
def plus(self, other):
# 分数加法:(a/b) + (c/d) = (a*d + c*b) / (b*d)
top = self.num * other.den + other.num * self.den
bottom = self.den * other.den
return top / bottom # 返回浮点数
这个初步的实现有一个问题:times和plus方法返回的是浮点数,而不是分数对象。此外,直接打印分数对象会得到不友好的内存地址信息。



改进分数类:使用特殊方法

为了使我们的分数类更加强大和“Pythonic”,我们需要实现一些特殊方法(Dunder方法)。这些方法允许我们的类支持Python的内置操作。





以下是需要实现的一些核心方法:

__str__方法:定义当使用print()函数打印对象时的字符串表示形式。__mul__方法:定义当使用*运算符时的行为。__add__方法:定义当使用+运算符时的行为。__float__方法:定义当使用float()函数将对象转换为浮点数时的行为。




让我们来实现它们:






class Fraction(object):
def __init__(self, numerator, denominator):
self.num = numerator
self.den = denominator
def __str__(self):
# 美化打印输出,例如 "3/4"
return str(self.num) + "/" + str(self.den)
def __mul__(self, other):
# 分数乘法,返回一个新的分数对象
new_num = self.num * other.num
new_den = self.den * other.den
return Fraction(new_num, new_den)
def __add__(self, other):
# 分数加法,返回一个新的分数对象
new_num = self.num * other.den + other.num * self.den
new_den = self.den * other.den
return Fraction(new_num, new_den)
def __float__(self):
# 转换为浮点数
return self.num / self.den


现在,我们可以更自然地使用分数对象:


a = Fraction(1, 4)
b = Fraction(3, 4)
print(a) # 输出: 1/4
print(b) # 输出: 3/4







c = a * b # 使用 * 运算符
print(c) # 输出: 3/16
print(float(c)) # 输出: 0.1875





d = a + b # 使用 + 运算符
print(d) # 输出: 16/16 (即 1/1)


添加约分功能

为了得到最简分数形式,我们可以为分数类添加一个约分方法。这需要一个辅助函数来计算最大公约数(GCD)。



def gcd(a, b):
# 计算最大公约数的辅助函数
while b:
a, b = b, a % b
return a





class Fraction(object):
# ... 之前的 __init__, __str__, __mul__, __add__, __float__ 方法 ...
def reduce(self):
if self.den == 0:
return None
common_divisor = gcd(self.num, self.den)
new_num = self.num // common_divisor
new_den = self.den // common_divisor
return Fraction(new_num, new_den)





使用约分方法:


f = Fraction(2, 12)
print(f) # 输出: 2/12
print(f.reduce()) # 输出: 1/6


课程总结 🎯
本节课中我们一起学习了如何创建更复杂的自定义数据类型。我们回顾了坐标类,然后构建了一个使用坐标对象作为属性的圆类。接着,我们深入探讨了分数类,并学习了如何通过实现特殊方法(如__str__、__mul__、__add__和__float__)来使我们的类支持Python的内置操作符,从而让代码更简洁、更易读。


通过将数据和相关的行为捆绑在一起,我们创建了模块化、有组织的代码。这种面向对象编程的方法体现了分解和抽象的核心思想,使得代码更易于维护、扩展和复用。在下一节课中,我们将开始学习继承,探索如何创建具有父子关系的类。
019:继承

在本节课中,我们将要学习面向对象编程的核心概念之一:继承。我们将从回顾如何创建自定义数据类型开始,然后深入探讨如何通过继承来建立类之间的层次关系,实现代码的重用和扩展。
回顾:创建自定义数据类型
上一节我们介绍了如何通过Python类来创建自定义数据类型。其核心思想是模仿现实世界,将具有共同属性和行为的对象归为一类。

当我们定义一个类时,需要决定其属性。属性分为两类:
- 数据属性:构成对象的变量。
- 过程属性:通过方法实现,定义了如何操作对象。



例如,我们定义一个简单的 Animal 类:



class Animal(object):
def __init__(self, age):
self.age = age
self.name = None
def __str__(self):
return f"animal:{self.name}:{self.age}"
def get_age(self):
return self.age
def get_name(self):
return self.name
def set_age(self, newage):
self.age = newage
def set_name(self, newname=""):
self.name = newname



__init__方法用于初始化对象,创建数据属性self.age和self.name。__str__方法定义了打印对象时的字符串表示。get_age和get_name是获取器,用于返回数据属性的值。set_age和set_name是设置器,用于修改数据属性的值。
创建和使用对象的方法如下:

# 创建一个年龄为4的动物对象
my_animal = Animal(4)
# 使用设置器修改名字
my_animal.set_name("Fluffy")
# 打印对象,会调用 __str__ 方法
print(my_animal) # 输出:animal:Fluffy:4
# 使用获取器
print(my_animal.get_age()) # 输出:4



为何要使用获取器和设置器




直接访问数据属性(如 my_animal.age)虽然可行,但不是一个好的编程实践。使用获取器和设置器有以下好处:
- 隐藏内部实现:类的使用者无需关心数据在内部如何存储(例如,变量名是
age还是years)。 - 增强健壮性:可以在设置器中添加验证逻辑(例如,年龄不能为负数)。
- 便于维护:如果内部实现改变,只需修改类内部的方法,而不影响使用该类的其他代码。
重要原则:应始终通过类提供的方法(获取器、设置器)来访问和修改对象的属性,而不是直接操作数据属性。
引入继承:建立类层次结构
在现实世界中,对象可以进一步分类。例如,“猫”、“兔子”、“人”都是“动物”,但它们各自有独特的属性和行为。在编程中,我们使用继承来模拟这种“是一个”的关系。
继承允许我们创建一个新类(子类或派生类),它自动获得另一个类(父类、基类或超类)的所有属性和方法,并可以添加或覆盖它们。


创建子类:Cat


让我们创建一个 Cat 类,它继承自 Animal 类。
class Cat(Animal): # Cat 继承自 Animal
def speak(self):
print("meow")
def __str__(self):
return f"cat:{self.name}:{self.age}"
- 在类定义
class Cat(Animal)中,括号内的Animal指定了父类。 Cat类自动获得了Animal的所有方法和数据属性。- 它添加了一个新的方法
speak。 - 它覆盖了父类的
__str__方法,提供了更具体的字符串表示。
以下是使用 Cat 类的示例:
c = Cat(5)
c.set_name("Fluffy")
print(c) # 输出:cat:Fluffy:5 (使用Cat自己的__str__)
c.speak() # 输出:meow (Cat特有的方法)
print(c.get_age()) # 输出:5 (继承自Animal的方法)
方法解析顺序
当调用一个对象的方法时,Python遵循特定的顺序进行查找:
- 首先在对象自身的类中查找。
- 如果没找到,则去其父类中查找。
- 依此类推,直到找到方法或到达最顶层的基类(如
object)。 - 如果最终没找到,则引发
AttributeError。







在 Cat 的例子中:
- 调用
c.speak()时,在Cat类中直接找到。 - 调用
c.get_age()时,在Cat类中未找到,于是到父类Animal中查找并找到。 - 调用
print(c)时,在Cat类中找到__str__方法,因此使用它而不是Animal中的版本。



扩展子类:Person






现在,我们创建一个更复杂的子类 Person。假设创建 Person 对象时需要同时指定姓名和年龄,并且 Person 有自己特有的属性(如朋友列表)。
class Person(Animal):
def __init__(self, name, age):
# 首先调用父类的 __init__ 方法来初始化 age 和 name (name先设为None)
Animal.__init__(self, age)
# 然后使用设置器设置具体的名字
self.set_name(name)
# 添加Person特有的数据属性
self.friends = []
def get_friends(self):
return self.friends[:] # 返回副本以保护原始数据
def add_friend(self, fname):
if fname not in self.friends:
self.friends.append(fname)
def speak(self):
print("hello")
def age_diff(self, other):
diff = self.age - other.age
print(f"{abs(diff)} year difference")
def __str__(self):
return f"person:{self.name}:{self.age}"
Person定义了自己的__init__方法,因为它需要不同的初始化参数(name和age)。- 在
__init__中,首先使用Animal.__init__(self, age)调用父类的初始化方法,这设置了self.age并将self.name初始化为None。 - 然后,它调用
self.set_name(name)来设置具体的名字。 - 最后,它初始化了
Person特有的属性self.friends。 - 这种模式(先调用父类初始化,再添加子类特有内容)在继承中非常常见。
使用 Person 类:








p1 = Person("Jack", 30)
p2 = Person("Jill", 25)
print(p1) # 输出:person:Jack:30
p1.speak() # 输出:hello
p1.age_diff(p2) # 输出:5 year difference
p1.add_friend("Bob")
print(p1.get_friends()) # 输出:['Bob']






多层继承:Student




继承可以有多层。例如,Student 是 Person 的一种,它继承了 Person 的所有特性,并可能增加新的属性(如专业)或覆盖行为(如说话方式)。




import random
class Student(Person):
def __init__(self, name, age, major=None):
# 调用父类Person的初始化方法
Person.__init__(self, name, age)
# 添加Student特有的属性
self.major = major
def change_major(self, newmajor):
self.major = newmajor
def speak(self):
# 覆盖Person的speak方法,有随机性
r = random.random()
if r < 0.25:
print("i have homework")
elif 0.25 <= r < 0.5:
print("i need sleep")
elif 0.5 <= r < 0.75:
print("i should eat")
else:
print("i am watching tv")
def __str__(self):
return f"student:{self.name}:{self.age}:{self.major}"
Student.__init__调用Person.__init__,后者又会调用Animal.__init__。这体现了继承链。Student覆盖了speak方法,使其行为与Person不同。




类变量:共享的属性
到目前为止,我们使用的都是实例变量(如 self.age),它们属于单个对象实例。Python 还支持类变量,它属于类本身,被该类的所有实例共享。


类变量的一个典型用途是跟踪创建了多少个该类的实例。




class Rabbit(Animal):
# 类变量,在所有Rabbit实例间共享
tag = 1
def __init__(self, age, parent1=None, parent2=None):
Animal.__init__(self, age)
self.parent1 = parent1
self.parent2 = parent2
# 使用当前的类变量tag作为该兔子的ID
self.rid = Rabbit.tag
# 创建完成后,递增类变量tag,为下一个实例准备
Rabbit.tag += 1
def get_rid(self):
# 将ID格式化为固定长度,前面补零
return str(self.rid).zfill(5)
def get_parent1(self):
return self.parent1
def get_parent2(self):
return self.parent2
def __add__(self, other):
# 重载 + 运算符:两只兔子“相加”产生一只新的小兔子
# 新兔子的年龄为0,父母是参与运算的两只兔子
return Rabbit(0, self, other)
def __eq__(self, other):
# 重载 == 运算符:如果两只兔子有相同的父母(顺序无关),则认为它们相等
parents_same = self.parent1.rid == other.parent1.rid and self.parent2.rid == other.parent2.rid
parents_opposite = self.parent1.rid == other.parent2.rid and self.parent2.rid == other.parent1.rid
return parents_same or parents_opposite


tag = 1是一个类变量,它不属于任何实例,而是属于Rabbit类。- 在
__init__中,self.rid = Rabbit.tag将当前的tag值赋给实例的rid。 Rabbit.tag += 1将类变量tag加1。由于tag是共享的,下一个创建的Rabbit实例会获得新的ID。__add__方法重载了+运算符,使得rabbit1 + rabbit2返回一个新的Rabbit对象。__eq__方法重载了==运算符,定义了两只兔子“相等”的逻辑。





使用 Rabbit 类:


r1 = Rabbit(3)
r2 = Rabbit(4)
r3 = Rabbit(5)
print(f"r1 id: {r1.get_rid()}") # 输出:r1 id: 00001
print(f"r2 id: {r2.get_rid()}") # 输出:r2 id: 00002
print(f"r3 id: {r3.get_rid()}") # 输出:r3 id: 00003





r4 = r1 + r2 # 调用 __add__,创建父母为r1和r2的新兔子
print(f"r4 id: {r4.get_rid()}") # 输出:r4 id: 00004
print(f"r4's parent ids: {r4.get_parent1().get_rid()}, {r4.get_parent2().get_rid()}")




r5 = r2 + r1
print(r4 == r5) # 输出:True,因为父母相同(r1和r2)






总结
本节课中我们一起学习了面向对象编程中强大的工具——继承。我们回顾了如何定义类及其方法(__init__, __str__, 获取器,设置器)。然后,我们深入探讨了继承机制,它允许我们基于现有类创建新类,从而实现代码重用和逻辑分层。
我们学习了:
- 如何创建子类并指定其父类。
- 子类如何自动继承父类的属性和方法。
- 子类如何添加新的方法。
- 子类如何通过定义同名方法来覆盖父类的方法。
- 在子类的
__init__方法中如何正确调用父类的__init__方法。 - 方法解析的顺序。
- 引入了类变量的概念,它被类的所有实例共享,并演示了用它来为实例生成唯一ID。



通过 Animal、Cat、Person、Student 和 Rabbit 的示例,我们看到了如何利用继承来构建清晰、可维护且易于扩展的代码结构。在接下来的课程中,我们将应用这些概念来构建更实用的项目。
020:面向对象编程示例 - 健身追踪器 🏃♂️💻

在本节课中,我们将通过创建一个健身追踪器应用中的“锻炼”对象,深入学习面向对象编程。我们将从设计一个简单的锻炼类开始,逐步改进它,并最终利用继承的概念创建更具体的子类(如跑步锻炼)。我们将学习如何设计类、使用类变量、重写方法以及理解对象的状态字典。




设计蓝图与实例
在面向对象编程中,我们编写代码时有两种视角。第一种是设计视角,我们决定新对象(数据类型)的名称、属性(数据或过程)。第二种是使用视角,我们创建该类型的多个对象实例并操作它们。之前的课程我们主要在使用他人创建的对象,而现在我们学习如何创建自己的对象类型。


创建简单的锻炼类


首先,我们创建一个基础的 Workout 类。所有类型的锻炼(如跑步、游泳、骑行)都有一些共同属性,例如图标、种类、开始时间、结束时间、心率、距离和消耗的卡路里。在本教程中,我们的 Workout 类将实现其中核心的几个属性。




以下是 Workout 类的初始设计,包含数据属性和方法。


数据属性:
start_time:锻炼开始时间(字符串)。end_time:锻炼结束时间(字符串)。calories:消耗的卡路里数(数字)。
方法:
__init__:构造函数,用于初始化对象。get_calories,get_start_time,get_end_time:获取器方法。set_calories,set_start_time,set_end_time:设置器方法。__str__:用于以友好格式打印锻炼信息。
让我们开始定义这个类。


class Workout:
"""一个表示健身锻炼的类。"""
def __init__(self, start, end, calories):
self.start = start
self.end = end
self.calories = calories
self.icon = "💦" # 默认图标
self.kind = "Workout" # 默认种类
# 获取器方法
def get_calories(self):
return self.calories
def get_start_time(self):
return self.start
def get_end_time(self):
return self.end
# 设置器方法
def set_calories(self, new_cal):
self.calories = new_cal
def set_start_time(self, new_start):
self.start = new_start
def set_end_time(self, new_end):
self.end = new_end
创建对象实例:
my_workout = Workout("Sep 1, 2022 1:35 PM", "Sep 1, 2022 1:45 PM", 200)
探索类与对象的状态
在Python中,类定义本身也是一个对象,拥有一个“状态字典”(__dict__),其中存储了类中定义的所有方法和类变量。同样,每个对象实例也有自己的状态字典,存储其数据属性的具体值。
查看类的状态字典:
print(list(Workout.__dict__.keys()))
# 输出可能包含:['__module__', '__init__', 'get_calories', ... , '__dict__', '__weakref__']



查看对象实例的状态字典:
print(my_workout.__dict__)
# 输出:{'start': 'Sep 1, 2022 1:35 PM', 'end': 'Sep 1, 2022 1:45 PM', 'calories': 200, 'icon': '💦', 'kind': 'Workout'}






访问属性时,应优先使用获取器方法(如 my_workout.get_calories())而非直接访问数据属性(如 my_workout.calories)。这遵循了信息隐藏和抽象的原则,即使底层实现改变,使用方法的代码也无需修改。




改进锻炼类:类变量与智能卡路里估算
上一节我们创建了一个简单的锻炼类。本节中,我们将改进它,引入类变量,并让 get_calories 方法更智能:如果用户创建对象时未提供卡路里数,则根据锻炼时长进行估算。
主要改进点:
- 添加类变量
CAL_PER_HOUR,表示每小时消耗的卡路里基数。 - 修改
__init__方法,使calories参数可选(默认值为None)。 - 修改
get_calories方法:如果卡路里有具体值则直接返回;如果为None,则根据时长和CAL_PER_HOUR估算。 - 使用
datetime库更精确地处理时间,计算时长。
改进后的类定义如下:
from datetime import datetime
from dateutil import parser
class Workout:
"""一个改进后的健身锻炼类,支持卡路里估算。"""
CAL_PER_HOUR = 200 # 类变量:每小时消耗卡路里
def __init__(self, start, end, calories=None):
# 将字符串时间解析为datetime对象,便于计算
self.start = parser.parse(start)
self.end = parser.parse(end)
self.calories = calories # 可能是None
self.icon = "💦"
self.kind = "Workout"
def get_calories(self):
if self.calories is None:
# 估算卡路里:时长(小时) * CAL_PER_HOUR
duration = self.end - self.start # 得到timedelta对象
hours = duration.total_seconds() / 3600
estimated_cal = hours * Workout.CAL_PER_HOUR
return estimated_cal
else:
return self.calories
# ... 其他获取器和设置器方法保持不变 ...


关于 datetime 库:parser.parse() 函数可以将多种格式的日期时间字符串转换为 datetime 对象。两个 datetime 对象相减得到一个 timedelta 对象(表示时间间隔),其 total_seconds() 方法可以获取总秒数。


类变量 CAL_PER_HOUR 可以通过类名(Workout.CAL_PER_HOUR)或实例(my_workout.CAL_PER_HOUR)访问。但应注意,最佳实践是通过类内部的方法来访问或修改它,而不是直接在类外部操作,以保持封装性。




让我们实践一下,创建两个锻炼对象:
# 对象1:不提供卡路里,让其估算
w1 = Workout("Jan 1, 2001 3:30 PM", "Jan 1, 2001 4:00 PM")
print(w1.get_calories()) # 输出: 100.0 (0.5小时 * 200)
# 对象2:提供具体卡路里值
w2 = Workout("Jan 1, 2001 3:30 PM", "Jan 1, 2001 4:00 PM", 300)
print(w2.get_calories()) # 输出: 300





使用继承创建子类:RunWorkout



我们已经有了一个通用的 Workout 类。现在,假设我们需要一个专门针对跑步的锻炼类 RunWorkout。跑步锻炼具有通用锻炼的所有属性,但可能还有额外的属性(如海拔爬升)或不同的行为。这时,我们可以使用继承。
RunWorkout 类将:
- 继承自
Workout类(它是Workout的子类)。 - 在初始化时,调用父类的
__init__方法完成基础设置,然后覆盖图标和种类,并添加新的数据属性elevation(海拔)。 - 添加
get_elevation和set_elevation方法。 - 后续我们还会重写和添加其他方法。



以下是 RunWorkout 类的初步实现:



class RunWorkout(Workout): # 继承自Workout
"""表示跑步锻炼的子类。"""
def __init__(self, start, end, elevation=0, calories=None):
# 调用父类的__init__方法初始化通用属性
super().__init__(start, end, calories)
# 覆盖父类的图标和种类
self.icon = "🏃"
self.kind = "Running"
# 添加子类特有的属性
self.elevation = elevation
def get_elevation(self):
return self.elevation
def set_elevation(self, new_elev):
self.elevation = new_elev


关键点:
class RunWorkout(Workout):表示RunWorkout继承自Workout。super().__init__(start, end, calories)调用父类的构造函数,避免了代码重复。- 子类自动拥有父类的所有方法(如
get_calories),除非子类重写了它们。
创建跑步锻炼对象:
rw1 = RunWorkout("Sep 1, 2022 2:00 PM", "Sep 1, 2022 3:00 PM", elevation=150)
print(rw1.get_calories()) # 继承的方法,估算卡路里
print(rw1.get_elevation()) # 子类特有的方法


方法的重用、重写与添加




在继承体系中,子类可以以三种方式对待父类的方法:
- 完全重用:直接使用父类实现的方法。
- 重写:提供自己的实现,覆盖父类的方法。
- 添加:定义父类中没有的新方法。


1. 重用父类方法:__str__
我们可以在父类 Workout 中实现一个 __str__ 方法,用于以美观的格式打印锻炼信息。由于继承,RunWorkout 对象会自动使用这个 __str__ 方法,无需在子类中重写。
Workout 类中的 __str__ 方法(示例,非完整代码):
def __str__(self):
# 构建一个字符串,包含图标、种类、时长、卡路里等信息
duration = self.end - self.start
cal = self.get_calories()
return f"{self.icon} {self.kind}\nDuration: {duration}\nCalories: {cal:.0f}"
打印任何 Workout 或其子类的对象时,都会调用此方法。


2. 重写父类方法:get_calories
对于 RunWorkout,我们可能想用更精确的方式计算卡路里,例如根据GPS轨迹点计算总距离,然后乘以一个“每公里卡路里”系数。为此,我们可以在 RunWorkout 类中重写 get_calories 方法。
class RunWorkout(Workout):
CAL_PER_KM = 60 # 类变量:每公里消耗卡路里
def get_calories(self, gps_points=[]):
"""计算卡路里。如果提供了GPS点列表,则根据距离计算;否则使用父类方法估算。"""
if not gps_points:
# 没有GPS数据,则调用父类的估算方法
return super().get_calories()
else:
# 根据GPS点计算总距离(假设有辅助函数gps_distance)
total_km = 0
for i in range(len(gps_points)-1):
pt1 = gps_points[i]
pt2 = gps_points[i+1]
total_km += gps_distance(pt1, pt2) # 假设的函数
# 根据距离计算卡路里
estimated_cal = total_km * RunWorkout.CAL_PER_KM
return estimated_cal
关键点:
- 子类定义了与父类同名的方法
get_calories,这构成了重写。 - 在重写的方法中,可以通过
super().get_calories()调用父类原本的实现。 - 这样,
RunWorkout对象在调用get_calories时,会根据是否提供gps_points参数决定使用哪种计算方式。
3. 添加新方法并重写:__eq__
我们还可以添加父类中没有的方法。例如,我们想比较两个锻炼对象是否相等。可以在父类 Workout 中实现 __eq__ 方法,规定比较规则(如类型相同、开始时间、结束时间、卡路里均相同则相等)。然后在子类 RunWorkout 中重写它,在父类比较规则的基础上,额外要求 elevation 属性也相同。
Workout 类中的 __eq__ 方法:
def __eq__(self, other):
if type(self) != type(other):
return False
return (self.start == other.start and
self.end == other.end and
self.get_calories() == other.get_calories())
RunWorkout 类中重写的 __eq__ 方法:
def __eq__(self, other):
# 先使用父类的比较逻辑
parent_check = super().__eq__(other)
# 再额外检查海拔是否相等
return parent_check and (self.elevation == other.elevation)
这样,RunWorkout 对象的相等性判断既包含了通用规则,也包含了特有规则。
多态与类型检查
由于继承关系,RunWorkout 对象也是 Workout 类型。这意味着,一个期望接收 Workout 对象列表的函数,也可以安全地接收 RunWorkout 对象列表。这体现了“子类是父类”的多态思想。
例如,一个计算总卡路里的函数:
def total_calories(workout_list):
total = 0
for w in workout_list:
total += w.get_calories() # 无论w是Workout还是RunWorkout,都能调用get_calories
return total
但是,反过来则不成立。一个专门为 RunWorkout 设计、需要调用 get_elevation 的函数,如果传入普通的 Workout 对象,则会出错,因为 Workout 没有这个方法。
总结
本节课中,我们一起学习了如何通过一个健身追踪器的例子深入实践面向对象编程。
我们首先设计并实现了一个基础的 Workout 类,理解了构造函数、数据属性和方法。接着,我们改进了这个类,引入了类变量 CAL_PER_HOUR,并实现了更智能的 get_calories 方法,使其能够在用户未提供数据时进行估算,同时学习了使用 datetime 库处理时间。
然后,我们进入了继承的核心主题,创建了 RunWorkout 子类。我们看到了如何通过 super() 调用父类方法,如何添加子类特有的属性(elevation)和方法。我们还深入探讨了方法的三种处理方式:重用父类的 __str__ 方法、重写 get_calories 方法以支持基于GPS的精确计算,以及重写 __eq__ 方法来实现子类特有的相等性判断。
最后,我们讨论了多态的概念,理解了子类对象可以用于任何期望父类对象的上下文中,这提高了代码的灵活性和可复用性。


面向对象编程的核心思想——封装、继承和多态——通过这个逐步构建的示例得到了生动的体现。掌握这些概念后,你将能够设计出结构清晰、易于维护和扩展的复杂程序。
021:程序计时与操作计数 🕒

在本节课中,我们将学习如何评估程序的效率。我们将探讨两种方法:通过系统时钟直接计时程序运行时间,以及通过计数程序执行的基本操作来评估其效率。这两种方法将帮助我们理解程序性能,并为后续学习更抽象的算法效率概念打下基础。
概述
我们首先将介绍为什么需要关心程序效率,然后通过三个具体的函数示例来演示计时和计数操作的方法。最后,我们将比较这两种方法的优缺点。
为什么关心程序效率?
到目前为止,本课程主要强调程序的正确性。在问题集中,单元测试检查您编写的程序是否正确;在测验中,我们根据您通过的测试用例数量来评定成绩。
然而,如今我们需要处理大量数据。我们需要读取、分析和可视化这些数据。因此,我们编写的程序不仅必须正确,还必须快速。如果分析一批YouTube视频信息需要一年时间,没有人愿意等待那么久。
在接下来的三到四讲中,我们将重点讨论如何确定程序的效率。
当我们谈论效率时,可以讨论程序的时间效率和空间效率。通常,这两者之间存在权衡。如今,很难找到一种在时间和空间上都比现有算法更高效的算法。通常需要进行权衡。
最典型的例子是我们见过的斐波那契数列计算。我们看到了一个递归计算斐波那契数列的代码:
Fibonacci(n) = Fibonacci(n-1) + Fibonacci(n-2)
那个递归程序计算Fibonacci(30)大约需要3000万次递归调用,速度相当慢,运行需要几秒钟。
然后我们看到了一个使用记忆化的版本。记忆化的思想是,我们计算一些值,并在计算过程中将它们存储在备忘录(例如字典)中。在记忆化示例中,我们牺牲了一些内存来存储这些值,这样就不必重新计算它们。通过这样做,我们得到了一个运行速度非常快的程序,比最初的纯递归版本快得多。

因此,这里存在一个权衡:一个程序可能很快,但需要存储一些值并占用更多内存;或者一个程序不存储任何内容,但速度会较慢,因为它需要不断计算许多不同的值。
在本讲座中,我们将展示一种非常简单的方法来确定程序的效率:直接计时,然后计算这些程序执行的操作数量。但我们这样做时,心里要明白,存在更好的方法来评估程序的效率。
使用时间模块计时程序
我们将使用Python的time模块来计时程序运行时间。该模块允许我们访问系统时钟,以确定函数开始和结束的确切时间。



要使用模块中的函数,我们使用模块名.函数名的表示法。例如,要使用math模块中的sin函数,我们会写math.sin。

要分析的三个函数


我们将分析以下三个函数:
celsius_to_fahrenheit: 将摄氏温度转换为华氏温度。my_sum: 使用循环计算从0到x(包括x)的整数和。square: 使用两个嵌套循环计算n的平方(效率很低)。
以下是它们的代码:

def celsius_to_fahrenheit(celsius):
return celsius * 9/5 + 32
def my_sum(x):
total = 0
for i in range(x+1):
total += i
return total
def square(n):
square_sum = 0
for i in range(n):
for j in range(n):
square_sum += 1
return square_sum
计时方法
我们创建一个计时包装函数,它接受要运行的函数名和一系列输入值。对于每个输入,它记录开始时间,运行函数,记录结束时间,然后计算并打印耗时。

import time


def time_wrapper(func, inputs):
for inp in inputs:
t_start = time.time()
func(inp)
t_end = time.time()
dt = t_end - t_start
print(f"Input: {inp}, Time: {dt} seconds")
计时结果分析
运行计时后,我们观察到:
celsius_to_fahrenheit: 无论输入大小,耗时都显示为0秒,因为它只涉及简单计算,速度极快。my_sum: 随着输入增大,耗时增加。例如,输入为1,000,000时耗时约0.05秒,输入为10,000,000时约0.5秒,输入为100,000,000时约5秒。这表明耗时随输入线性增长。square: 由于是嵌套循环,耗时增长更快。输入为10,000时已需约6秒。可以推测,输入增大10倍,耗时大约增长100倍。
计时方法的局限性

虽然计时能直观感受程序速度,但它有几个缺点:
- 不同实现(如
for循环与while循环)的计时结果会不同,而我们更想评估算法本身。 - 运行时间因计算机性能不同而异。
- 运行时间因编程语言不同而异(如C比Python快)。
- 对于小输入,计时可能不准确,容易受系统其他活动干扰。
因此,计时并不完全符合我们评估算法的目标。
通过计数操作评估效率
为了更一致地评估效率,我们转向计数操作的方法。我们定义一组基本的Python操作(如数学运算、比较、赋值、内存访问),并假设每个操作消耗一个“单位时间”。然后,我们统计程序执行了多少个这样的操作。
定义基本操作单位
我们假设以下操作各消耗1个单位时间:
- 数学运算:
+,-,*,/,**等。 - 比较操作:
<,>,==等。 - 赋值语句:
a = 3。 - 内存访问: 访问对象属性或元素。
对三个函数进行操作计数
以下是每个函数的操作计数分析:
-
celsius_to_fahrenheit:- 操作:一次乘法、一次除法、一次加法。
- 总操作数:3(与输入无关)。
-
my_sum:total = 0: 1次赋值。for i in range(x+1):: 循环x+1次,每次i赋值算1次操作。total += i: 每次循环包含加法(total + i)和赋值(total = ...),共2次操作。- 总操作数:
1 + (x+1) * (1 + 2) = 3x + 4。
-
square:square_sum = 0: 1次赋值。- 外层循环
for i in range(n):执行n次,每次i赋值算1次操作。 - 内层循环
for j in range(n):对于每个i执行n次,每次j赋值算1次操作。 square_sum += 1: 每次内层循环包含加法和赋值,共2次操作。- 总操作数:
1 + n * [1 + n * (1 + 2)] = 3n^2 + 1。
实现操作计数
我们在每个函数中添加计数器,以编程方式统计操作数。
def my_sum_count(x):
counter = 1 # 对应 total = 0
total = 0
for i in range(x+1):
counter += 1 # 对应 i 赋值
total += i
counter += 2 # 对应 total += i (加法和赋值)
return counter, total
def square_count(n):
counter = 1 # 对应 square_sum = 0
square_sum = 0
for i in range(n):
counter += 1 # 对应 i 赋值
for j in range(n):
counter += 1 # 对应 j 赋值
square_sum += 1
counter += 2 # 对应 square_sum += 1
return counter, square_sum

计数结果分析

运行计数函数后,我们观察到:
celsius_to_fahrenheit: 操作数恒为3。my_sum: 当输入x很大时,操作数约为3x。输入增大10倍,操作数也约增大10倍(线性关系)。square: 当输入n很大时,操作数约为3n^2。输入增大10倍,操作数约增大100倍(平方关系)。
计数方法的优势
与计时相比,计数操作的方法具有以下优势:
- 消除了不同计算机性能带来的差异。
- 减少了不同编程语言实现的影响(只要逻辑相同,操作数相似)。
- 更能反映算法本质,而不是具体实现细节。
- 对于大输入,能更清晰地揭示操作数量与输入规模之间的关系(如线性、平方)。
然而,它仍有局限性:对于小输入,操作数可能受低阶项(如+4, +1)影响,规律不明显;并且需要明确定义哪些操作应该被计数(例如,return语句是否计入)。
总结
本节课中,我们一起学习了两种评估程序效率的初步方法:直接计时和计数基本操作。
我们首先了解了程序效率的重要性,特别是在处理大规模数据时。然后,我们通过三个具体函数示例,实践了使用time模块进行计时。我们发现,虽然计时直观,但受实现方式、硬件和语言等因素影响,不利于纯粹评估算法。
接着,我们引入了计数操作的方法。通过定义一组消耗单位时间的基本操作,并统计程序执行这些操作的次数,我们得到了更稳定、更接近算法本质的效率评估。分析表明,my_sum函数的操作数随输入线性增长,而square函数的操作数随输入平方增长。
计数方法为我们提供了一个更好的框架来思考算法效率。然而,我们的最终目标不仅仅是计数,而是理解当输入变得非常大时,操作数量是如何增长的,即算法的“增长阶数”。这将是我们下一讲的重点。


通过本节课的学习,你现在已经掌握了通过计时和计数来初步分析程序效率的工具,并理解了为什么我们需要更抽象的方法来比较和评估算法。
022:大O与Theta 🧮

在本节课中,我们将学习如何分析程序的运行时间。我们将通过计时和计数操作两种方法来评估不同函数的性能,并最终引入“增长阶”的概念,特别是大O(Big O)和Theta(Θ)表示法,用于描述算法效率随输入规模增长的变化趋势。


第一部分:程序计时 ⏱️




上一讲我们开始讨论如何计算程序的运行时间。今天,我们将继续这一主题,首先通过计时来观察不同程序的性能。


使用 perf_counter 进行精确计时




为了更精确地测量时间,我们将使用 time 模块中的 perf_counter 函数,而不是上一讲使用的 time 函数。perf_counter 能提供更高的精度(例如,可达10⁻⁸秒级别),使我们能够测量那些运行时间极短的程序。
计时方法如下:
- 调用
perf_counter()获取开始时间。 - 运行目标函数。
- 再次调用
perf_counter()获取结束时间。 - 计算时间差
dt = 结束时间 - 开始时间。
以下是计时代码的基本结构:
import time
start = time.perf_counter()
# 调用要计时的函数
stop = time.perf_counter()
dt = stop - start
print(f"运行时间: {dt} 秒")
分析示例函数
我们将分析两个函数,观察其运行时间如何随输入变化。


函数一:convert_to_kilometers
此函数将英里数转换为公里数。
def convert_to_kilometers(miles):
return miles * 1.60934

计时结果与观察:
无论输入 miles 的值是多少(例如1, 10, 1000000),该函数的运行时间都基本恒定,大约在 3×10⁻⁷ 秒左右。这表明其运行时间与输入参数无关。



函数二:compound
此函数计算按月定投的复利收益,类似于问题集1中的内容。
def compound(monthly_invest, monthly_rate, months):
total = 0
for month in range(months):
total = (total + monthly_invest) * monthly_rate
return total
我们分别测试改变三个参数(每月投资额 monthly_invest、月利率 monthly_rate、投资月数 months)对运行时间的影响。
计时结果与观察:
- 改变
monthly_invest或monthly_rate对运行时间几乎没有影响。 - 改变
months对运行时间有显著影响。当months增加10倍时,运行时间也大约增加10倍。 - 当输入规模(
months)很大时,这种线性增长关系更为明显和可预测。对于小规模输入,运行时间可能受计算机后台活动干扰而波动。
第二部分:处理列表作为输入的函数 📊
现在,我们来看输入为列表的函数。
函数三:sum_of
此函数计算列表中所有元素的总和。
def sum_of(L):
total = 0
for i in L:
total += i
return total
为了测试,我们创建不同长度的列表(例如长度为1, 10, 100, 1000, ...),列表内容为虚拟数据(如0到n-1)。
计时结果与观察:
- 函数的运行时间依赖于输入列表的长度
len(L)。 - 当列表长度增加10倍时,运行时间也大约增加10倍,呈现线性关系。
- 与
compound函数类似,对于大规模输入,这种线性关系更稳定。对于小规模输入,由于系统干扰,可能出现反直觉的结果(例如,更长的列表运行时间更短)。 - 在输入规模相当时(例如处理1亿个月或1亿个列表元素),
sum_of和compound函数的运行时间处于同一数量级(几秒)。这表明,尽管功能不同,但两者都包含一个遍历输入的循环,算法复杂度相似。
函数四:列表搜索算法比较

我们比较三种在列表中查找元素的方法:
- 暴力搜索 (
is_in):逐个检查列表元素。def is_in(L, e): for i in L: if i == e: return True return False - 二分查找 (
binary_search):要求列表有序,每次比较后排除一半的搜索空间。def binary_search(L, e): low = 0 high = len(L) - 1 while high >= low: mid = (low + high) // 2 if L[mid] == e: return True elif L[mid] < e: low = mid + 1 else: high = mid - 1 return False - 内置运算符
in:直接使用e in L。
为了公平比较,我们对每个函数测试三种情况(查找第一个元素、中间元素、最后一个元素)并取平均时间。

计时结果与观察:
is_in(暴力搜索) 和 内置in运算符都表现出线性关系:列表长度增加10倍,运行时间也增加约10倍。binary_search(二分查找) 的运行时间增长远低于线性。当输入增长10倍时,运行时间的增长倍数不固定(不是10倍)。其增长模式是对数级的。- 性能对比:
- 二分查找比暴力搜索快数个数量级(例如,对于1亿长度的列表,暴力搜索需约1.6秒,而二分搜索仅需约10⁻⁵秒)。
- 内置
in运算符虽然也是线性复杂度,但比我们自己写的暴力搜索函数快约10倍,说明其实现更优化。
函数五:diameter (计算点集最大距离)
此函数计算二维平面上一组点中任意两点之间的最大距离。
def diameter(points):
max_dist = 0
for i in range(len(points)):
for j in range(i+1, len(points)):
# 计算points[i]和points[j]之间的距离
dist = ((points[i][0]-points[j][0])**2 + (points[i][1]-points[j][1])**2)**0.5
if dist > max_dist:
max_dist = dist
return max_dist
该函数使用嵌套循环遍历所有不重复的点对。
计时结果与观察:
- 该函数整体运行非常慢。对于约6000个点,运行时间已达14秒,而之前线性算法处理1亿数据也仅需几秒。
- 运行时间与输入规模(点数
n)呈平方关系。当点数增加2倍时,运行时间增加约4倍;如果增加10倍,运行时间将增加约100倍。

可视化运行时间趋势

将上述函数的运行时间相对于输入规模绘图,可以清晰看到不同的增长趋势:
is_in和内置in:线性增长,图像为一条斜线。binary_search:对数增长,图像初期上升较快,随后趋于平缓。diameter:平方增长,图像为一条上升的曲线。
关于计时的总结


- 计时能给出程序运行的实际时间,对于预估等待时间很重要。
- 运行时间受具体计算机硬件影响,在不同机器上结果不同。
- 然而,运行时间随输入规模增长的相对关系(如线性、平方)在不同机器上是一致的。这是我们分析算法效率的关键。
第三部分:计数操作 🔢
计时给出了实际时间,但计数操作能帮助我们建立输入规模与计算量之间的公式化关系。
计数方法
我们将各种基本操作(数学运算、比较、索引、赋值等)视为消耗一个“单位时间”。通过统计函数执行的基本操作次数,可以得到一个关于输入规模的表达式。


示例分析




convert_to_kilometers:包含一次乘法和一次返回操作。操作数恒为2,与输入无关。公式:操作数 = 2。sum_of:total = 0:1次赋值。for i in L::循环len(L)次,每次包含1次索引取值和赋值给i。total += i:每次循环包含1次加法 (total + i) 和1次赋值 (total = ...)。return total:1次返回。- 公式:操作数 = 1 + len(L) * (1 + 2) + 1 = 3*len(L) + 2。


使用全局变量辅助计数
为了在函数内部计数,我们可以(仅用于调试和分析目的)使用全局变量。注意:在实际编程中应避免使用全局变量。
以下是给 is_in 函数添加计数器的示例:
count = 0 # 全局计数器
def is_in(L, e):
global count # 声明使用全局变量count
for i in L:
count += 2 # 索引L[i]和比较 (i == e)
if i == e:
count += 1 # return操作
return True
count += 1 # return False操作
return False
运行此函数并打印 count,可以验证操作次数与列表长度 len(L) 的线性关系。
对 binary_search 进行类似计数,并绘制“操作次数 vs 输入规模”图,会得到与计时相似的对数增长曲线。
计数与计时的关联
计数得到的公式(如 3n+2)和计时观察到的增长趋势(线性)是吻合的。计数为我们提供了理论依据。




第四部分:增长阶、大O与Theta表示法 📈




我们分析了多个函数,发现它们的复杂度可以归类为几种基本类型:常数、线性、对数、平方等。为了在数学上简洁地描述这种随着输入增长的趋势,并专注于主要影响因素,我们引入增长阶(Order of Growth)的概念,并使用大O (Big O) 和 Theta (Θ) 表示法。



核心思想




- 当输入规模
n变得非常大时,函数运行时间或操作数表达式中增长最快的项将主导整个增长趋势。 - 我们忽略常数系数和低阶项,只关注这个主导项与输入规模
n的关系。



大O (Big O) 表示法:上界




大O定义了一个函数增长率的上界(Upper Bound)。形式化定义如下:
如果存在正常数
c和n₀,使得对于所有n > n₀,都有f(n) ≤ c * g(n),则称f(n)是O(g(n))。
直观理解:当 n 足够大后,函数 f(n) 的增长速度不会超过 g(n) 的某个常数倍。
例子:f(n) = 3n² + 20n + 1。当 n 很大时,n² 项占主导。我们可以找到 c=4 和某个 n₀,使得 3n² + 20n + 1 ≤ 4n² 对所有 n > n₀ 成立。因此,f(n) 是 O(n²)。

注意:一个函数的上界可以有很多。f(n)=3n²+... 也是 O(n³), O(2ⁿ) 等,因为这些函数增长得更快。大O只保证“不超过”,但不一定“紧贴”。

Theta (Θ) 表示法:紧确界
Theta定义了一个函数增长率的紧确界(Tight Bound),它同时是上界和下界。形式化定义如下:
如果存在正常数
c₁,c₂和n₀,使得对于所有n > n₀,都有c₁ * g(n) ≤ f(n) ≤ c₂ * g(n),则称f(n)是Θ(g(n))。
直观理解:当 n 足够大后,函数 f(n) 的增长速度与 g(n) 同阶,被夹在两个常数倍之间。
例子:对于 f(n) = 3n² + 20n + 1,我们可以找到 c₁=2, c₂=4 和某个 n₀,使得 2n² ≤ 3n²+20n+1 ≤ 4n² 对所有 n > n₀ 成立。因此,f(n) 是 Θ(n²)。


关键:Theta表示法描述的是增长的主要趋势,是我们通常用来描述算法复杂度的方式。它比大O更精确地反映了函数的增长阶。
如何确定Theta
确定一个函数 f(n) 的 Θ,只需:
- 找出其关于输入规模
n的表达式中的最高阶项。 - 忽略该项的常数系数。
示例:
f(n) = n² + 2n + 2→ Θ(n²)f(n) = 3x² + 100000x + 10¹⁰→ Θ(x²) (输入变量是x)f(n) = 5a + 3→ Θ(a) (输入变量是a)f(n) = 2ᵇ + a³,输入为a和b→ Θ(2ᵇ + a³) (两个变量都影响)




分析程序代码的复杂度
对于程序,我们通过分析循环结构来确定其 Θ。


法则一:相加(顺序结构)


如果程序包含顺序执行的两个部分,其复杂度分别为 Θ(f(n)) 和 Θ(g(n)),则总复杂度为 Θ(f(n) + g(n)),再化简为其中增长更快的项。
示例:
for i in range(n): # Θ(n)
# 常数时间操作
for i in range(n): # Θ(n²),因为内层循环
for j in range(n):
# 常数时间操作
总复杂度:Θ(n + n²) = Θ(n²)。




法则二:相乘(嵌套结构)




如果程序包含嵌套结构(如循环嵌套),外层复杂度为 Θ(f(n)),内层复杂度为 Θ(g(n)),则总复杂度为 Θ(f(n) * g(n))。
示例:
for i in range(n): # Θ(n)
for j in range(i, n): # 平均来看也是 Θ(n)
# 常数时间操作
总复杂度:Θ(n * n) = Θ(n²)。
应用示例


- 线性搜索列表:
复杂度:Θ(len(L))。def linear_search(L, e): for i in L: # 循环 len(L) 次 → Θ(len(L)) if i == e: # 常数时间操作 return True return False


- 比较两个等长列表:
复杂度:def compare_lists(L1, L2): if len(L1) != len(L2): # 常数时间 return False for i in range(len(L1)): # 循环 len(L1) 次 → Θ(len(L1)) if L1[i] != L2[i]: # 常数时间 return False return TrueΘ(1) + Θ(len(L1)) = Θ(len(L1))。
常见的复杂度类别
算法复杂度通常可以归类为以下几种常见类别(按效率从高到低排列):
- 常数时间:Θ(1) - 运行时间与输入规模无关。
- 对数时间:Θ(log n) - 如二分查找。
- 线性时间:Θ(n) - 如遍历列表。
- 线性对数时间:Θ(n log n) - 许多高效排序算法(如归并排序)。
- 多项式时间:Θ(nᶜ),其中c为常数。常见的有:
- 平方时间:Θ(n²) - 如嵌套循环比较所有元素对。
- 立方时间:Θ(n³)
- 指数时间:Θ(cⁿ),其中c>1为常数。如穷举所有子集。这类算法在输入稍大时就变得不可行。


在设计和分析算法时,我们应努力使算法复杂度尽可能位于列表的上方(更高效)。




总结 🎯
本节课中我们一起学习了:
- 程序计时:使用
perf_counter测量实际运行时间,观察了不同函数(如单位转换、复利计算、列表求和、搜索、最大距离计算)的运行时间如何随输入规模变化,识别出线性、对数、平方等增长模式。 - 操作计数:通过统计基本操作次数,建立了输入规模与计算量之间的公式关系,从理论上验证了计时观察到的趋势。
- 增长阶与渐近表示法:引入了大O (O) 和 Theta (Θ) 表示法。
- 大O 描述算法运行时间的上界。
- Theta 描述算法运行时间的紧确界,是我们用于刻画算法复杂度的主要工具。
- 复杂度分析:学习了如何通过分析代码结构(特别是循环)来确定算法的 Θ 复杂度,并介绍了常见复杂度类别。


掌握这些概念,你将能够预估算法的效率,并在设计程序时做出更明智的选择,避免编写出输入稍大就运行极慢的代码。下一讲我们将深入探讨更多不同复杂度类别的具体算法示例。
023:复杂度类别示例 📊

在本节课中,我们将学习如何分析代码并确定其复杂度类别。我们将回顾Θ表示法,并通过大量代码示例来识别不同复杂度类别的特征,包括常数、线性、多项式、对数和指数复杂度。
回顾:Θ表示法与复杂度分析
上一节我们介绍了Θ表示法,它用于标记特定函数或代码段的增长阶数。我们更倾向于使用Θ而不是大O表示法,因为Θ为我们提供了函数最坏情况运行时间的渐近上界。
给定一个函数,其Θ值将是该函数的主导项。如果有多个项,我们关注增长最快的那一项,并舍弃任何加法常数、乘法常数以及增长不如主导项快的其他项。
为了得到代码的Θ值,我们首先识别函数的输入参数,然后找出代码中所有依赖于这些输入参数的部分。这包括直接依赖(如遍历输入的循环)或间接依赖。





常见的复杂度类别

在上一讲的结尾,我们总结出了一些常见的算法复杂度类别。以下是这些类别,其中n代表输入规模:
- Θ(1):常数复杂度
- Θ(log n):对数复杂度
- Θ(n):线性复杂度
- Θ(n log n):线性对数复杂度
- Θ(nᶜ):多项式复杂度(c为常数,如n², n³)
- Θ(cⁿ):指数复杂度(c为常数,如2ⁿ, 3ⁿ)


我们的目标是编写属于前几个类别的算法。如果发现代码是指数级的,通常需要重新思考解决方案。
接下来,我们将通过具体代码示例来探索这些类别。


常数复杂度 Θ(1) ⏱️



如果代码属于常数复杂度类别,意味着其运行时间不依赖于输入规模。代码中可以有循环或递归结构,只要这些结构不依赖于输入即可。

一些内置操作是常数时间的,例如:
- 列表索引
L[i] - 列表追加
L.append(x) - 字典键值查找
D[key]
以下是常数复杂度的代码示例:

示例1:简单加法
def add(x, y):
return x + y
此函数没有循环或递归,复杂度为 Θ(1)。


示例2:单位转换
def km_to_miles(km):
return km * 0.621371
此函数仅进行一次乘法运算,复杂度为 Θ(1)。

示例3:包含循环但不依赖输入
def add_x_to_100(x):
total = 0
for i in range(100): # 循环次数固定为100,与输入x无关
total += x
return total
此函数虽然包含循环,但循环次数固定(100次),不随输入x变化,因此复杂度为 Θ(1)。






线性复杂度 Θ(n) 📈
线性复杂度的函数通常由一个或多个线性依赖于输入n的循环(或递归调用)构成。这些循环可以是串联的(使用加法法则)。


需要注意的是,一些内置操作是线性时间的,在分析时不能忽略:
- 检查元素是否在列表中
e in L - 复制列表
L.copy()或L[:] - 检查两个列表是否相等
L1 == L2 - 删除列表中的元素
del L[i]



以下是线性复杂度的代码示例:





示例1:乘法(循环依赖一个输入)
def multiply(x, y):
result = 0
for i in range(y): # 循环次数依赖于y
result += x
return result
此函数复杂度相对于y是 Θ(y),相对于x是 Θ(1)。总体复杂度为 Θ(y)。



示例2:字符串数字求和
def sum_digits(s):
total = 0
for c in s: # 循环遍历字符串s的每个字符
total += int(c)
return total
此函数有一个循环,遍历输入字符串s的每个字符。运行时间取决于字符串的长度,因此复杂度为 Θ(len(s))。





示例3:迭代计算阶乘
def factorial_iterative(n):
result = 1
for i in range(2, n+1): # 循环n-1次
result *= i
return result
此函数有一个循环,从2迭代到n,循环次数线性依赖于n,因此复杂度为 Θ(n)。






示例4:递归计算阶乘
def factorial_recursive(n):
if n == 0:
return 1
else:
return n * factorial_recursive(n-1)
对于递归函数,我们关注函数被调用的次数。此函数会递归调用自身n次,每次调用的开销是常数(减法操作),因此总体复杂度为 Θ(n)。
示例5:复利计算
def compound(monthly_amt, monthly_rate, num_months):
total = 0
for m in range(num_months): # 循环次数依赖于num_months
total += monthly_amt
total *= (1 + monthly_rate)
return total
此函数有一个循环,迭代num_months次。循环内部的操作是常数时间。因此,复杂度为 Θ(num_months)。其他参数不影响复杂度。


示例6:迭代计算斐波那契数
def fib_iterative(n):
if n == 0 or n == 1:
return n
else:
fib_i_minus_2 = 0
fib_i_minus_1 = 1
for i in range(2, n+1): # 循环n-1次
fib_i = fib_i_minus_1 + fib_i_minus_2
fib_i_minus_2 = fib_i_minus_1
fib_i_minus_1 = fib_i
return fib_i
此函数有一个循环,迭代约n次。循环内部是常数时间操作。因此,复杂度为 Θ(n)。







多项式复杂度 Θ(nᶜ) 📊

多项式复杂度通常涉及嵌套循环。如果存在两个线性依赖于输入的嵌套循环,复杂度通常是Θ(n²);如果是三个,则是Θ(n³),依此类推。
以下是多项式复杂度的代码示例:

示例1:简单嵌套循环
def g(n):
total = 0
for i in range(n): # 外层循环 Θ(n)
for j in range(n): # 内层循环 Θ(n)
total += i * j
return total
外层循环迭代n次,内层循环也为每次外层迭代迭代n次。根据乘法法则,总体复杂度为 Θ(n) * Θ(n) = Θ(n²)。

示例2:判断子集
def is_subset(L1, L2):
"""假设L1和L2是列表。如果L1的每个元素都在L2中,则返回True,否则返回False。"""
for e1 in L1: # 外层循环 Θ(len(L1))
matched = False
for e2 in L2: # 内层循环 Θ(len(L2))
if e1 == e2:
matched = True
break
if not matched:
return False
return True
此函数检查L1是否为L2的子集。外层循环遍历L1,内层循环(在最坏情况下)遍历整个L2。根据乘法法则,复杂度为 Θ(len(L1) * len(L2))。如果L1和L2长度相同(设为n),则复杂度为 Θ(n²)。

示例3:求列表交集(保留唯一值)
def intersect(L1, L2):
"""假设L1和L2是列表。返回一个列表,包含同时出现在L1和L2中的元素(无重复)。"""
# 构建包含所有共同元素的临时列表(可能有重复)
tmp = []
for e1 in L1: # 外层循环 Θ(len(L1))
for e2 in L2: # 内层循环 Θ(len(L2))
if e1 == e2:
tmp.append(e1)
break
# 从临时列表中提取唯一元素
result = []
for e in tmp: # 遍历tmp,最坏情况大小为 len(L1)*len(L2)
if e not in result: # 检查e是否在result中,最坏情况为Θ(len(result))
result.append(e)
return result
此函数分为两部分:
- 第一部分是嵌套循环,用于找到所有共同元素(可能重复),复杂度为 Θ(len(L1) * len(L2))。
- 第二部分遍历临时列表
tmp(在最坏情况下,其大小为len(L1)*len(L2)),并为每个元素检查是否已在结果列表中(这也是一个线性检查)。因此,第二部分的复杂度在最坏情况下也是 Θ(len(L1) * len(L2))。


总体复杂度为两部分之和,即 Θ(len(L1) * len(L2))。



示例4:计算点集直径
def diameter(points):
"""假设points是(x, y)坐标对的列表。返回任意两点之间的最大距离。"""
max_dist = 0
for i in range(len(points)): # 外层循环 Θ(len(points))
for j in range(i+1, len(points)): # 内层循环,平均约 Θ(len(points)/2)
dist = ((points[i][0] - points[j][0])**2 +
(points[i][1] - points[j][1])**2)**0.5
if dist > max_dist:
max_dist = dist
return max_dist
外层循环遍历列表points的每个索引。内层循环从i+1开始,到列表末尾结束。内层循环的迭代次数不是固定的n,而是从n-1, n-2, ..., 1递减。其总和约为n(n-1)/2,这仍然是 Θ(n²)。因此,总体复杂度为 Θ(n²),其中n是points的长度。








指数复杂度 Θ(cⁿ) 🚀
指数复杂度增长非常迅速,我们应尽量避免编写此类算法。然而,有些问题本质上就需要指数级的时间来解决。


示例1:递归计算斐波那契数
def fib_recursive(n):
if n == 0 or n == 1:
return n
else:
return fib_recursive(n-1) + fib_recursive(n-2)
此递归版本的斐波那契函数会生成一个递归调用树。每个调用(除了叶子节点)会产生两个新的递归调用。因此,总的函数调用次数大约是2ⁿ量级,复杂度为 Θ(2ⁿ)。
示例2:生成列表的所有子集
def gen_subsets(L):
"""假设L是列表。返回一个包含L所有子集的列表。"""
if len(L) == 0:
return [[]] # 空列表的唯一子集是空列表本身
else:
extra = L[-1:] # 获取最后一个元素
smaller = gen_subsets(L[:-1]) # 递归生成不包含最后一个元素的所有子集
new = []
for small in smaller: # 遍历smaller中的每个子集
new.append(small + extra) # 将最后一个元素添加到每个子集中,形成新的子集
return smaller + new # 合并原有子集和新子集
此递归函数生成列表L的所有子集。分析其复杂度:
- 递归调用次数:函数会递归调用自身n次(每次列表长度减1),直到基例。
- 每次调用的开销:在每次递归调用中,有一个循环遍历
smaller列表(即前一步生成的所有子集),并将extra元素添加到每个子集中。smaller列表的大小随着递归深度指数增长(第k层约有2ᵏ个子集)。此外,small + extra操作涉及列表复制,其开销与子集大小成线性关系。



综合来看,总工作量大致为 Θ(n * 2ⁿ),这比纯指数级更差。



对数复杂度 Θ(log n) 📉
对数复杂度通常出现在每次迭代都将问题规模除以一个常数的算法中,例如二分搜索。这种依赖关系可能不像线性循环那样直接。
示例:数字各位求和(通过字符串转换)
def digit_sum_str(n):
"""假设n是非负整数。返回n的各位数字之和。"""
total = 0
s = str(n) # 将整数转换为字符串
for c in s: # 遍历字符串的每个字符
total += int(c)
return total
此函数遍历字符串s的每个字符,因此复杂度相对于字符串长度是线性的,即 Θ(len(s))。但输入是整数n,字符串长度len(s)与n的关系是什么?len(s)实际上是n的位数,约等于 log₁₀ n。因此,该函数的复杂度为 Θ(log n)。在复杂度分析中,我们通常忽略对数的底数。









搜索算法复杂度分析 🔍



上一节我们介绍了Θ表示法和复杂度类别。本节中,我们来看看搜索算法的具体复杂度分析,比较线性搜索和二分搜索。


线性搜索

1. 无序列表的线性搜索
def linear_search_unsorted(L, e):
found = False
for i in range(len(L)): # 循环 Θ(len(L)) 次
if L[i] == e:
found = True
return found
在最坏情况下(元素不存在),需要遍历整个列表,复杂度为 Θ(len(L))。

2. 优化版(提前返回)
def linear_search_unsorted_early(L, e):
for i in range(len(L)): # 循环 Θ(len(L)) 次
if L[i] == e:
return True
return False
尽管找到元素时可以提前返回,但在最坏情况下(元素不存在),仍然需要遍历整个列表,因此最坏情况复杂度仍为 Θ(len(L))。
3. 有序列表的线性搜索(提前终止)
def linear_search_sorted(L, e):
for i in range(len(L)): # 循环 Θ(len(L)) 次
if L[i] == e:
return True
if L[i] > e: # 利用有序性提前终止
return False
return False
即使列表有序,可以在遇到大于目标元素的元素时提前终止,但在最坏情况下(目标元素大于所有列表元素或不存在),仍然需要遍历整个列表。因此,最坏情况复杂度仍为 Θ(len(L))。
二分搜索(折半搜索)
二分搜索要求列表是有序的。其基本思想是:比较中间元素与目标值,如果相等则找到;如果目标值更小,则在左半部分继续搜索;否则在右半部分继续搜索。每次比较都将搜索范围减半。
复杂度推导:
假设列表长度为n。每次递归调用后,问题规模减半。设需要k次递归调用(或迭代)才能将规模降至1(即找到元素或确定不存在)。则有:
n / 2ᵏ ≈ 1 => 2ᵏ ≈ n => k ≈ log₂ n
因此,二分搜索的复杂度为 Θ(log n)。


1. 二分搜索实现(复制列表)
def bisect_search_copy(L, e):
if L == []:
return False
elif len(L) == 1:
return L[0] == e
else:
half = len(L) // 2
if L[half] > e:
return bisect_search_copy(L[:half], e) # 复制左半部分列表
else:
return bisect_search_copy(L[half:], e) # 复制右半部分列表
此实现虽然递归深度是Θ(log n),但每次递归调用都通过切片L[:half]或L[half:]复制了列表的一部分。复制列表的操作是线性时间Θ(k)(k为切片长度)。总复制开销大致为n + n/2 + n/4 + ... ≈ 2n,即Θ(n)。因此,总体复杂度为 Θ(n)(由于复制)而不是Θ(log n)。
2. 二分搜索实现(使用索引,避免复制)
def bisect_search(L, e):
def bisect_search_helper(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 bisect_search_helper(L, e, low, mid - 1)
else:
return bisect_search_helper(L, e, mid + 1, high)
if len(L) == 0:
return False
else:
return bisect_search_helper(L, e, 0, len(L) - 1)
此实现通过传递索引low和high来界定搜索范围,避免了列表复制。每次递归调用只进行常数时间的操作(计算中点、比较)。因此,真正的二分搜索复杂度得以实现,为 Θ(log n)。


何时使用二分搜索?
二分搜索(Θ(log n))比线性搜索(Θ(n))快得多,但前提是列表必须有序。那么,对于一个无序列表,是先排序再二分搜索,还是直接线性搜索更划算?

考虑对无序列表进行一次搜索:
- 方案A(排序+二分搜索):排序至少需要Θ(n log n)时间,加上二分搜索Θ(log n),总时间 ≈ Θ(n log n)。
- 方案B(线性搜索):需要Θ(n)时间。



显然,对于单次搜索,Θ(n) < Θ(n log n),直接线性搜索更快。
但是,如果我们需要对同一个数据集进行k次搜索:
- 方案A:排序一次Θ(n log n),然后k次二分搜索Θ(k log n)。总时间 ≈ Θ(n log n + k log n)。
- 方案B:k次线性搜索Θ(k n)。
当k很大时,方案A可能更优,因为排序的一次性成本被分摊了。具体来说,当 k >> log n 时,方案A更具优势。




总结 📝


本节课中,我们一起学习了如何分析代码的复杂度并将其归类到不同的复杂度类别中。我们通过大量示例探讨了:
- 常数复杂度 Θ(1):运行时间不依赖于输入规模。
- 线性复杂度 Θ(n):通常包含单个线性循环或递归。
- 多项式复杂度 Θ(nᶜ):通常由嵌套循环导致。
- 指数复杂度 Θ(cⁿ):通常出现在递归调用产生分支的算法中,应尽量避免。
- 对数复杂度 Θ(log n):通常出现在每次迭代将问题规模除以常数的算法中,如二分搜索。


我们还重点比较了线性搜索和二分搜索的复杂度,并讨论了在无序列表上何时使用二分搜索是合理的。理解这些复杂度类别有助于我们评估算法效率,并指导我们选择或设计更优的算法。下一讲,我们将探讨各种排序算法。
024:排序算法 🧮

在本节课中,我们将要学习几种不同的排序算法。我们会从一些效率较低的算法开始,逐步介绍到一种被认为是最优的算法之一。理解这些算法的复杂度分析,将帮助我们设计出更高效的程序。
概述
在上一讲中,我们探讨了在列表中搜索元素的算法,特别是线性搜索和二分搜索。我们了解到,二分搜索虽然高效(时间复杂度为 θ(log n)),但要求列表必须是已排序的。这就引出了一个关键问题:如果我们需要对数据集进行多次搜索,那么先对列表进行一次排序,然后使用二分搜索,其总成本可能会低于多次使用线性搜索。因此,排序算法的效率至关重要。
本节中,我们将看看几种排序算法,分析它们的复杂度,并理解为何某些方法比其他方法更优。


排序算法介绍
我们将从一种非常低效的算法开始,逐步过渡到更高效的算法。

猴子排序 (Bogo Sort)
首先,我们来看一个非常糟糕的排序算法,称为猴子排序(Bogo Sort),也叫随机排序。

猴子排序的思路是利用随机性来排序列表。例如,要对一副扑克牌排序,算法会反复将牌抛向空中,然后捡起它们,并检查是否已排序。如果已排序,则完成;如果没有,则重复此过程。


以下是猴子排序的伪代码表示:
while not is_sorted(list):
shuffle(list)
复杂度分析:
- 最佳情况:如果输入列表已经排序,算法只需检查一次,复杂度为 θ(n)。
- 最坏情况:算法可能永远无法得到排序结果,因此最坏情况复杂度是无界的(无穷大)。
显然,这不是一个实用的排序算法。
冒泡排序 (Bubble Sort) 🫧
接下来,我们看一个更知名的算法——冒泡排序。它之所以流行,并非因为高效,而是因为它易于理解且常被用作教学例子。
冒泡排序的核心思想是反复遍历列表,比较相邻元素,如果它们的顺序错误就交换它们。通过每一轮遍历,最大的元素会像气泡一样“浮”到列表末端。


以下是冒泡排序的步骤演示(假设初始列表为 [8, 4, 1, 6, 5, 9, 2, 0, 11]):
- 第一轮遍历,比较并交换相邻元素,使
11“冒泡”到末尾。 - 第二轮遍历,使
9“冒泡”到倒数第二的位置。 - 重复此过程,直到某一轮遍历中没有发生任何交换,此时列表已排序。


冒泡排序的代码结构通常包含一个外层的 while 循环(检查是否发生交换)和一个内层的 for 循环(遍历并比较相邻元素)。
复杂度分析:
内层 for 循环总是遍历整个列表,复杂度为 θ(n)。在最坏情况下(列表完全逆序),外层 while 循环也需要执行 n 次。因此,冒泡排序的最坏情况时间复杂度是 θ(n²)。
选择排序 (Selection Sort) 🔍
选择排序是另一种简单的排序算法,它通过不断选择剩余元素中的最小值来构建有序序列。
算法步骤如下:
- 找到列表中最小(或最大)的元素。
- 将其与列表第一个位置的元素交换。
- 接着在剩余的元素中寻找最小(或最大)元素,将其与第二个位置的元素交换。
- 重复此过程,直到所有元素均排序完毕。
选择排序的一个变体是,在遍历过程中只记录最小元素的索引,在内层循环结束后才进行一次交换,而不是每次找到更小的元素都交换。但这并不改变其渐近复杂度。
复杂度分析:
外层循环执行 n 次。内层循环用于在未排序部分中寻找最小元素,第一次需要比较 n 次,第二次 n-1 次,依此类推。总的比较次数是 n + (n-1) + ... + 1,其和为 n(n+1)/2。因此,选择排序的时间复杂度也是 θ(n²)。
更高效的算法:归并排序 (Merge Sort) ⚡
上述迭代方法(嵌套循环)无法将复杂度降低一个数量级。为了获得更快的算法,我们需要换一种思路,采用“分治法”,灵感来源于二分搜索。
归并排序的核心思想
归并排序是一种递归算法,其步骤如下:
- 分解:递归地将当前列表平分成两个子列表。
- 解决:递归地排序两个子列表。
- 合并:将两个已排序的子列表合并成一个完整的已排序列表。
关键在于合并步骤。如果我们有两个已排序的子列表,合并它们非常高效:只需要比较两个子列表最前面的元素,将较小的那个放入结果列表,然后移动相应子列表的指针。这个过程只需遍历每个元素一次。
合并步骤详解与复杂度
假设有两个已排序的子列表 [0, 2, 8, 11] 和 [1, 4, 5, 6]。
合并过程如下:
- 比较
0和1,取0。 - 比较
2和1,取1。 - 比较
2和4,取2。 - 以此类推,直到一个子列表为空,然后将另一个子列表剩余元素全部追加到结果中。


这个合并过程对两个总长度为 n 的列表只需进行一次遍历,因此其时间复杂度为 θ(n)。
归并排序的递归过程与复杂度分析

整个归并排序的递归过程类似于计算斐波那契数列,会先深入分解左半部分,直到达到基线条件(列表长度为1或0,此时自然已排序),然后返回并合并。

复杂度分析:
- 分解层级:一个包含 n 个元素的列表,每次分解成两半,需要 log₂ n 层才能达到基线条件。
- 每层工作量:在每一层,我们需要合并该层所有子列表。虽然子列表数量翻倍,但每个子列表的长度减半。每一层所有合并操作的总工作量仍然是 θ(n)。
- 总复杂度:层数 (θ(log n)) 乘以每层的工作量 (θ(n)),得到归并排序的总时间复杂度为 θ(n log n)。
事实证明,θ(n log n) 是基于比较的排序算法所能达到的最优时间复杂度。


总结


本节课我们一起学习了多种排序算法:
- 猴子排序:一种低效的随机算法,用于理解最坏情况复杂度。
- 冒泡排序和选择排序:简单的迭代算法,时间复杂度为 θ(n²),适用于理解基础排序概念。
- 归并排序:采用分治法的递归算法,时间复杂度为 θ(n log n),是效率最高的通用排序算法之一。


进行算法复杂度分析的目的是指导程序设计。如果你发现正在构思的程序包含多层嵌套循环,很可能效率不高,这时就应该考虑像归并排序这样更优的算法设计。理解这些原理,是成为优秀程序员的关键一步。
025:数据可视化 📊

在本节课中,我们将要学习如何使用Python的matplotlib库进行数据绘图。数据可视化是理解和分析数据的关键步骤,无论是在学术研究、项目报告还是日常工作中都非常有用。我们将从基础绘图开始,逐步学习如何自定义图表、处理真实数据集,并通过可视化来发现数据中的模式和趋势。
导入库与基础概念
首先,我们需要导入绘图库。在Python中,最常用的基础绘图库是matplotlib。为了简化代码,我们通常会给它起一个简短的别名。
import matplotlib.pyplot as plt

上述代码将matplotlib.pyplot模块导入,并重命名为plt。这样,我们就可以使用plt.来调用库中的所有函数,使代码更加简洁。

绘制基础图形

要绘制一个图形,我们需要提供两组数据:X轴的值和Y轴的值。这两组数据通常是长度相同的列表,列表中的每个索引位置共同构成一个坐标点。

以下是创建和绘制几组基础数据的示例:
# 创建X轴数据:0到29的整数
x_vals = list(range(30))
# 创建不同的Y轴数据:线性、二次方、三次方和指数函数
linear = [n for n in x_vals]
quadratic = [n**2 for n in x_vals]
cubic = [n**3 for n in x_vals]
exponential = [1.5**n for n in x_vals]
# 绘制线性图
plt.plot(x_vals, linear)
plt.show()
运行上述代码会弹出一个窗口,显示一条从(0,0)到(29,29)的直线。matplotlib会自动调整坐标轴的范围,使图形能够完整且美观地显示在画布中。
默认情况下,plt.plot()会用线段连接所有的数据点。如果数据点的顺序是混乱的,连接线可能会显得杂乱无章。
散点图与数据点顺序
当数据点没有特定的顺序,或者你只想观察数据点的分布而非趋势时,散点图是更好的选择。

# 创建无序的X和Y数据
test_x = [0, 5, 3, 2, 8, 1]
test_y = [0, 25, 9, 4, 64, 1] # 对应x值的平方

# 绘制散点图
plt.scatter(test_x, test_y)
plt.show()


使用plt.scatter()可以只绘制数据点而不进行连接,这样即使数据无序,图形也能正确显示。
在同一图形中绘制多条线
你可以通过连续调用plt.plot()命令,将多条线绘制在同一个图形窗口中。
plt.plot(x_vals, linear)
plt.plot(x_vals, quadratic)
plt.plot(x_vals, cubic)
plt.plot(x_vals, exponential)
plt.show()
所有线条都会被添加到当前激活的图形中。但是,当不同线条的数据尺度差异很大时(比如线性增长和指数增长),将它们放在同一张图上可能不利于观察细节。
创建多个图形窗口

为了更清晰地比较不同尺度的数据,我们可以创建多个独立的图形窗口。

# 创建第一个图形窗口并绘制指数数据
plt.figure('Exponential')
plt.plot(x_vals, exponential)


# 创建第二个图形窗口并绘制线性数据
plt.figure('Linear')
plt.plot(x_vals, linear)
# 再次激活第一个窗口,并添加另一条指数曲线
plt.figure('Exponential')
plt.plot(x_vals, [1.6**n for n in x_vals])
plt.show()
plt.figure(‘窗口名称’)命令可以创建或激活一个指定名称的图形窗口。后续的绘图命令会作用于当前激活的窗口。
添加图表标签和自定义坐标轴


一个专业的图表必须包含清晰的标题和坐标轴标签。我们还可以自定义坐标轴的范围和刻度。
# 模拟月度温度数据
months = range(1, 13)
temps = [30, 35, 40, 50, 60, 70, 75, 73, 65, 55, 45, 35]
plt.plot(months, temps)
# 添加标题和坐标轴标签
plt.title('Average Monthly Temperature')
plt.xlabel('Month')
plt.ylabel('Temperature (F)')
# 设置X轴范围,去掉两边的空白
plt.xlim(1, 12)




# 设置X轴刻度为每个月,并用月份名称标注
month_names = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
plt.xticks(months, month_names)


plt.show()
通过这些自定义,图表变得信息丰富且易于理解。
绘制多条数据线并添加图例



当一张图中有多条数据线时,添加图例至关重要。我们需要在绘图时为每条线指定一个标签,然后调用plt.legend()来显示图例。

boston_temps = [30, 32, 40, 50, 60, 70, 75, 73, 65, 55, 45, 35]
phoenix_temps = [55, 58, 65, 73, 82, 92, 95, 93, 88, 78, 65, 56]




plt.plot(months, boston_temps, label='Boston')
plt.plot(months, phoenix_temps, label='Phoenix')

plt.title('Average Monthly Temperature Comparison')
plt.xlabel('Month')
plt.ylabel('Temperature (F)')
plt.xticks(months, month_names)
plt.legend(loc='best') # 自动选择最佳位置放置图例
plt.show()



自定义线条样式、颜色和标记




matplotlib允许你精细控制线条的外观,包括颜色、线型和数据点标记。
以下是自定义样式的两种方法:




1. 简写参数法:
plt.plot(months, boston_temps, ‘b-‘, label=‘Boston‘) # 蓝色实线
plt.plot(months, phoenix_temps, ‘r--‘, label=‘Phoenix‘) # 红色虚线
2. 显式参数法:
plt.plot(months, boston_temps, color=‘b‘, linestyle=‘-‘, label=‘Boston‘)
plt.plot(months, phoenix_temps, color=‘r‘, linestyle=‘--‘, label=‘Phoenix‘)
你还可以添加数据点标记和调整线宽:


plt.plot(months, boston_temps, ‘b.-‘, linewidth=1, label=‘Boston‘) # 蓝色实线,带圆点标记,细线
plt.plot(months, phoenix_temps, ‘ro--‘, linewidth=3, label=‘Phoenix‘) # 红色虚线,带圆圈标记,粗线


创建子图




子图允许你在一个图形窗口中并排显示多个图表,便于比较。


# 创建一个2行1列的子图布局,并激活第一个子图(顶部)
plt.subplot(2, 1, 1)
plt.plot(months, boston_temps, ‘b-‘)
plt.title(‘Boston‘)
plt.ylim(0, 100) # 统一Y轴范围以便比较


# 激活第二个子图(底部)
plt.subplot(2, 1, 2)
plt.plot(months, phoenix_temps, ‘r--‘)
plt.title(‘Phoenix‘)
plt.ylim(0, 100)
plt.tight_layout() # 自动调整子图参数,使之填充整个图像区域
plt.show()
plt.subplot(行数, 列数, 序号)用于指定子图的位置。序号从1开始,按行优先计数。
分析真实数据集:美国人口
现在,让我们将所学应用于真实数据。我们将读取一个包含美国400年间每十年人口数据的文件,并绘制图表。
首先,我们需要编写函数来读取和处理数据文件:
def read_population_data(filename):
"""读取人口数据文件,返回年份列表和人口列表。"""
dates = []
populations = []
with open(filename, ‘r‘) as f:
for line in f:
# 移除非数字字符(如逗号),只保留数字和空格
line = ‘‘.join([c for c in line if c.isdigit() or c == ‘ ‘])
parts = line.split()
if len(parts) == 2:
year, pop = parts
dates.append(int(year))
populations.append(int(pop))
return dates, populations


# 使用函数读取数据并绘图
years, population = read_population_data(‘us_population.txt‘)
plt.plot(years, population)
plt.title(‘US Population Over Time‘)
plt.xlabel(‘Year‘)
plt.ylabel(‘Population‘)
plt.show()
通过可视化,我们可以轻松发现历史事件对人口的影响,例如战争导致的人口增长放缓。


对数尺度绘图



对于呈指数级增长的数据,使用对数尺度(Log Scale)Y轴可以更清晰地观察增长趋势。




plt.plot(years, population)
plt.yscale(‘log‘) # 将Y轴设置为对数尺度
plt.title(‘US Population Over Time (Log Scale)‘)
plt.xlabel(‘Year‘)
plt.ylabel(‘Population (Log)‘)
plt.show()
在对数图上,指数增长会显示为一条直线,这使得我们能够识别出数据中不同阶段的增长速率。
深入分析:本福特定律

我们来看一个有趣的数据集:各国人口排名。我们将分析各国人口数字的首位数字的分布。
def read_country_populations(filename):
"""读取国家人口文件,返回人口数字列表。"""
populations = []
with open(filename, ‘r‘) as f:
for line in f:
parts = line.split(‘\t‘) # 制表符分隔
if len(parts) >= 3:
pop_str = parts[2] # 人口数据在第三列
# 移除逗号并转换为整数
pop_num = int(‘‘.join([c for c in pop_str if c.isdigit()]))
populations.append(pop_num)
return populations
# 获取人口数据并提取首位数字
pops = read_country_populations(‘country_populations.txt‘)
first_digits = [int(str(p)[0]) for p in pops] # 提取每个数字的首位
# 绘制首位数字的直方图
plt.hist(first_digits, bins=range(1, 11), edgecolor=‘black‘, align=‘left‘)
plt.title(‘First Digit Distribution of Country Populations‘)
plt.xlabel(‘First Digit‘)
plt.ylabel(‘Frequency‘)
plt.xticks(range(1, 10))
plt.show()




令人惊讶的是,首位数字的分布并不均匀。数字1出现的频率最高(约30%),而数字9出现的频率最低。这符合本福特定律,该定律描述了在许多自然产生的数据集中,较小数字作为首位数字出现的概率更高。其公式为:

P(d) = log₁₀(1 + 1/d)
其中,d是1到9的数字,P(d)是该数字作为首位出现的概率。
分析城市温度趋势
最后,我们分析一个更复杂的数据集:多个城市长达55年的每日温度数据。我们将计算每个城市的年平均温度,并观察其变化趋势。
def get_yearly_averages(filename, city_name):
"""获取指定城市每年的平均温度。"""
yearly_temps = {}
with open(filename, ‘r‘) as f:
for line in f:
parts = line.strip().split(‘,‘)
if len(parts) == 3:
city, temp_str, date_str = parts
if city == city_name:
year = date_str[:4] # 从日期中提取年份
temp_f = celsius_to_fahrenheit(float(temp_str)) # 假设有转换函数
yearly_temps.setdefault(year, []).append(temp_f)
# 计算每年的平均温度
avg_temps = {year: sum(temps)/len(temps) for year, temps in yearly_temps.items()}
# 按年份排序并返回列表
sorted_years = sorted(avg_temps.keys())
sorted_avgs = [avg_temps[yr] for yr in sorted_years]
return sorted_years, sorted_avgs

# 比较四个城市的温度趋势
cities = [‘Boston‘, ‘San Diego‘, ‘Phoenix‘, ‘Miami‘]
for city in cities:
years, avgs = get_yearly_averages(‘global_temperatures.csv‘, city)
plt.plot(years, avgs, label=city)
plt.title(‘Average Yearly Temperature Trend‘)
plt.xlabel(‘Year‘)
plt.ylabel(‘Temperature (F)‘)
plt.legend()
plt.show()

通过可视化,我们可以直观地比较不同城市的变暖趋势和温度波动范围。

总结
本节课中我们一起学习了使用matplotlib库进行数据可视化的核心技能。我们从最基本的绘制线条和散点图开始,逐步学会了如何添加标签、图例,自定义线条样式,以及创建子图来组织多个图表。更重要的是,我们应用这些技能分析了真实世界的数据集,如美国人口变化和各国人口的首位数字分布(本福特定律),并通过可视化发现了数据中隐藏的模式和趋势。


记住,可视化的目的不仅仅是生成漂亮的图片,更是为了探索数据、提出问题并传达信息。当你面对新的数据集时,第一步就应该是将其可视化,这通常会为你带来最初也是最关键的洞察。
026:列表访问、哈希、模拟与课程总结 🎓



在本节课中,我们将学习三个核心主题。首先,我们将深入探讨列表在内存中的实现方式,理解为何列表访问是常数时间复杂度。接着,我们将揭示字典(哈希表)实现高效查找的秘密——哈希函数。最后,我们将学习如何使用模拟(Simulation)这一强大的计算工具,通过编写简单的代码来近似解决复杂的现实世界问题。








列表访问与内存实现 📊
上一节我们介绍了课程的整体安排。本节中,我们来看看列表(List)是如何在计算机内存中存储和访问的。


列表是对象的序列。我们之前讨论过列表操作的渐近复杂度(Asymptotic Complexity),其中访问列表中的特定元素是常数时间复杂度 Θ(1)。这意味着无论列表有多长,获取第 i 个元素所需的时间是恒定的。



这是如何实现的呢?为了简化,我们先假设列表只存储整数。




当创建一个初始长度为 L 的列表时,Python 会分配一个连续的内存块,包含 L 个内存位置。如果每个整数占用 4 字节(Byte),那么要访问第 i 个元素,只需进行简单的数学计算:从列表的起始内存地址,加上 i * 4 字节的偏移量,就能直接定位到该元素。

公式:元素地址 = 列表起始地址 + i * 元素大小(字节)



由于内存是连续分配的,这个地址计算是纯粹的数学运算(一次乘法和一次加法),其时间与列表长度 n 无关,因此是常数时间 Θ(1)。


然而,列表可以存储各种对象,如其他列表、元组、字典等,这些对象的大小可能不固定。Python 的解决方案是:不在列表的连续内存块中直接存储对象本身,而是存储指向这些对象的指针。指针本质上也是一个数字(内存地址),它告诉 Python 该去哪里找到实际的对象。






因此,即使存储复杂对象,访问列表元素的过程依然是:起始地址 + i * 指针大小。由于指针大小固定(例如 4 或 8 字节),计算地址的时间仍然是常数 Θ(1)。



字典与哈希表 🔑




上一节我们了解了列表的快速访问机制。本节中我们来看看字典(Dictionary)这种无序的键值对数据结构,是如何实现高效查找的。








列表的“键”是顺序的索引(0, 1, 2...)。字典的键则可以是任何不可变对象,没有固定顺序。一种幼稚的实现方式是将每个键值对存储为一个包含两个元素的列表 [key, value],然后将所有这些小列表放入一个大列表中。在这种实现下,要查找某个键对应的值,必须遍历整个大列表,时间复杂度为 Θ(n),效率很低。










但我们在之前的课程中看到,字典的平均访问时间是常数 Θ(1)。这是如何做到的呢?答案是哈希表。



哈希表的核心思想是使用一个哈希函数。这个函数接收一个键(必须是可哈希的不可变对象,如整数、字符串、元组),并输出一个整数。这个整数被用作索引,来访问一个类似数组的“哈希表”。

关键点:由于我们知道了索引(通过哈希函数计算得出),而通过索引访问数组是常数时间 Θ(1)。如果哈希函数本身的计算也是常数时间,那么整个字典查找操作的平均时间复杂度就是 Θ(1)。

以下是Python中哈希函数的例子:
hash(123) # 返回 123
hash("hello") # 返回一个整数,如 -1182655620
hash((1, 2)) # 返回一个整数





哈希碰撞与哈希表大小
一个理想的哈希函数会将每个不同的键映射到哈希表中唯一的位置。但如果我们想为所有可能的键(例如所有20字符长的名字)都预留唯一位置,需要的哈希表将极其巨大(2^160 个位置),而实际存储的键可能只有几千个,造成巨大的空间浪费。


因此,实际的解决方案是使用一个大小合理的哈希表(例如10000个位置),并允许哈希碰撞——即不同的键经过哈希函数计算后,得到了相同的索引。
当发生碰撞时,多个键值对会被存储在哈希表的同一个“桶”中,通常以链表形式组织。查找时,先通过哈希函数定位到桶,然后在桶内的链表中线性搜索目标键。
优秀哈希函数的特点
以下是设计优秀哈希函数和哈希表的一些原则:


- 均匀分布:哈希函数应将输入均匀地映射到哈希表的所有桶中,避免大量键聚集在少数桶内。
- 确定性:对同一个键,哈希函数必须始终返回相同的值,否则无法正确查找。
- 高效性:哈希函数的计算本身应该是快速的。
- 利用全部输入:哈希函数应使用键的全部信息进行计算,以减少碰撞。


在最坏情况下,如果所有键都哈希到同一个桶,查找就退化为在链表中线性搜索,时间复杂度为 Θ(n)。但在平均情况下,拥有良好哈希函数和合适大小的哈希表,字典的查找、插入和删除操作都能达到 Θ(1) 的时间复杂度,这使得字典成为处理大量数据时极其高效的工具。






计算模拟 🎲
上一节我们探讨了哈希表的工作原理。本节中,我们将学习如何使用模拟这一强大的计算技术来解决实际问题。
模拟允许我们用计算来描述和复现现实世界的事件。其基本流程是:
- 定义事件:明确你要模拟的现实世界场景。
- 设计计算实验:用代码构建该事件的模型,通常引入随机性。
- 重复实验:使用循环多次运行该实验。
- 跟踪结果:记录你感兴趣的特定结果发生的次数。
- 分析报告:根据重复实验的结果,计算并报告目标值(如概率、平均值)。


示例1:模拟掷骰子






我们想估算掷一个公平的六面骰子,得到点数4的概率。
设计实验:用列表表示骰子的六个面,使用 random.choice() 函数随机选择一面,模拟一次掷骰子。
重复实验:用 for 循环重复此过程,例如10,000次。
跟踪结果:每次掷骰后,检查结果是否为4,如果是,则计数器加1。
报告结果:实验结束后,用 计数器 / 总实验次数 估算概率。





import random


def dice_probability(side_of_interest, num_trials=10000):
dice_faces = ['.', '..', '...', '....', '.....', '......'] # 代表1到6点
count = 0
for _ in range(num_trials):
roll = random.choice(dice_faces) # 模拟一次掷骰
if roll == side_of_interest:
count += 1
probability = count / num_trials
return probability







print(dice_probability('....')) # 估算得到4点的概率
通过增加 num_trials(如到1,000,000次),我们可以得到更接近理论值(1/6 ≈ 0.1667)的估计。







示例2:更复杂的问题——水池注水时间

一个更有趣的问题是:水以随机流速(1到3加仑/分钟)注入一个600加仑的水池,注满水池的平均时间是多少?
数学求解需要计算积分,较为复杂。但用模拟则非常简单:
- 设计实验:在1到3之间随机生成一个流速
flow_rate。 - 计算单次结果:注满时间
time = 600 / flow_rate。 - 重复实验:将此过程重复大量次数(如10,000次)。
- 报告结果:计算所有
time的平均值。
import random
def fill_pool_simulation(size=600, num_trials=10000):
fill_times = []
for _ in range(num_trials):
# 生成1到3之间的随机流速
flow_rate = 1 + 2 * random.random()
time_to_fill = size / flow_rate
fill_times.append(time_to_fill)
average_time = sum(fill_times) / num_trials
return average_time
print(fill_pool_simulation()) # 输出平均注满时间,约为329
模拟结果显示平均注满时间约为329分钟,这既不是简单平均值300分钟(600/2),也不是时间范围的中间值400分钟。模拟通过几行代码就给出了一个复杂问题的近似解,展示了计算在解决跨学科问题中的强大能力。
课程总结与展望 🚀
本节课中我们一起学习了列表内存模型、哈希表原理以及计算模拟的应用。现在,让我们对整个课程进行回顾。
在本课程中,我们共同学习了:
- Python编程基础:语法、变量、运算符。
- 控制流:条件分支(
if/elif/else)、循环(for、while)、异常处理。 - 数据结构:列表、字典、元组等及其操作。
- 代码组织:通过函数实现分解与抽象,通过类进行面向对象编程,将数据与行为绑定。
- 算法:如二分查找,展示了算法设计对效率的巨大影响。
- 计算复杂度:使用大O(大Θ)表示法分析算法效率。
对于未来的学习,你可以考虑:
- 6.100B:下半学期的课程,聚焦数据科学,涵盖优化算法、高级模拟和机器学习基础。
- 6.101:编程基础,深入Python编程,处理真实数据集,强调编写高效、健壮的代码。
- 6.102:软件构建,学习使用TypeScript等语言,注重编写安全、易理解、易维护的代码,包含团队合作项目。
- 其他方向如机器学习、算法等课程也是很好的进阶选择。
如果你暂时不继续学习编程课程,但希望保持技能,建议每周花少量时间(如30分钟)进行编程练习,防止生疏。


感谢大家在本课程中的努力与参与!编程是一项强大的技能,希望你们能享受用它来探索和解决问题的过程。祝大家考试顺利,假期愉快!

浙公网安备 33010602011771号