MIT-6-0001-Python-入门笔记-全-
MIT 6.0001 Python 入门笔记(全)


课程 P1:L1.1 - 什么是计算科学 🧮

以下内容基于知识共享许可协议提供。您的支持将帮助麻省理工学院开放课程持续提供高质量的免费教育资源。如需捐款或查看来自数百门麻省理工学院课程的其他材料,请访问相关网站。

我们开始吧。正如我之前提到的,本次讲座将被录制用于开放课程。因此,在未来的讲座中,如果您不希望自己的后脑勺出现在视频中,请不要坐在前排区域。
首先,多么庞大的听众群。你们终于进入了6.0001课程,并且规模很大。下午好,欢迎来到6.0001和6.00课程的第一堂课。我的名字是安娜贝尔,名安娜,姓贝尔。我是EECS系的讲师,今天我将与稍后在本学期授课的埃里克·格里姆森教授一起进行部分讲座。

今天我们将介绍一些基本的行政事务和课程信息,然后我们将讨论什么是计算。我们将在高层次上讨论计算机做什么,以确保我们都在同一理解层面上。接着,我们将直接深入Python基础,讨论一些可以用Python进行的数学运算,然后讨论Python变量和类型。
正如我在介绍性电子邮件中提到的,讲座中涉及的所有幻灯片和代码都将在讲座前发布。我强烈建议您下载并打开它们。我们将进行一些课堂练习,这些练习将在幻灯片上提供。这样做很有趣,并且在代码上做笔记也很有益,以便将来参考。这门课程节奏非常快,我们会迅速提升难度,因此我们希望为您在这门课程中取得成功做好准备。


当我编写这些内容时,我试图回想自己刚开始学习编程时,是什么帮助我完成了第一门编程课程。以下是一个很好的清单:首先,我尽早阅读问题集,确保术语能够逐渐理解。然后在讲座中,如果讲师谈到某个内容,我突然想起在问题集中见过那个词但当时不明白,现在我就知道它是什么了。所以,先通读一遍,如果您是编程新手,我认为关键词是练习。就像数学或阅读一样,练习得越多,就越熟练。仅仅观看我编写程序是无法吸收编程知识的,因为我已经知道如何编程。你们需要练习。所以在讲座前下载代码,跟着我输入的内容进行输入。我认为另一件重要的事情是,如果您是编程新手,可能会担心弄坏电脑。仅仅运行Anaconda并输入一些命令是不会弄坏电脑的。不要害怕输入一些内容看看会发生什么。最坏的情况就是重启电脑。所以,不要害怕。
这基本上是6.0001或6.00课程的全部路线图。我们希望您从这门课程中获得三样东西:第一是概念知识,这几乎适用于您将学习的任何课程;第二是编程技能;最后,我认为这是这门课程的真正伟大之处,我们教您如何解决问题,这是通过问题集实现的。这就是我对这门课程路线图的看法,而所有这些的基础都是练习。您必须多输入、多编码,才能在这门课程中取得成功。
那么,我们在这门课程中将学习什么?我认为可以分为三个主要部分:第一部分与编程相关,学习如何编程,包括创建什么对象、如何用数据结构表示知识,以及程序的控制流。第二部分更抽象,涉及如何编写风格良好、可读性强的代码。当您编写代码时,希望它易于他人阅读和使用。因此,代码需要组织良好、模块化且易于理解。不仅他人会阅读您的代码,明年您可能选修另一门课程时,也会回顾您在这门课程中编写的问题集。如果代码混乱,您可能无法理解当时在做什么。因此,编写可读性强且组织良好的代码也是重要部分。最后一部分主要涉及计算机科学,讨论如何比较Python程序,如何知道一个程序比另一个更好、更高效,以及如何知道一种算法比另一种更好。这就是我们将在课程最后部分讨论的内容。
以上就是课程管理部分的内容。现在,让我们从高层次开始讨论计算机做什么。从根本上说,计算机做两件事:执行计算和存储结果。计算机执行大量计算,现在的计算机速度非常快,每秒数十亿次计算可能并不夸张。计算机还需要将结果存储在内存中。现在,拥有数百GB存储空间的计算机并不少见。
计算机执行的计算有两种类型:一种是内置在语言中的低级计算,如加法、减法、乘法等;另一种是程序员可以将这些基本计算类型组合起来,定义自己的计算,创建新的计算类型,计算机也能执行这些计算。我想强调的一点是,计算机只知道您告诉它的内容。计算机只做您告诉它做的事情,它们不是神奇的,没有思想,只是知道如何非常快速地执行计算。但您必须告诉它们执行什么计算。计算机什么都不知道。
接下来,我们讨论知识的类型。第一种是陈述性知识,即事实陈述。例如,今天讲座的一个事实陈述是:在课程结束前,有人将赢得奖品,奖品是Google Cardboard。这是一个事实陈述。但假设我是一台机器,除了您告诉我的内容,我什么都不知道。您告诉我这个陈述,我会想:但如何在课程结束前有人赢得Google Cardboard呢?这就是过程性知识的作用。过程性知识是食谱、方法或步骤序列。如果我是机器,您需要告诉我如何让某人在课程结束前赢得Google Cardboard。如果我遵循这些步骤,理论上我应该得出结论。第一步,我们已经完成了,想报名的人已经报名了。现在我将打开我的IDE,基本上像机器一样遵循您告诉我的步骤。我们在这门课程中使用的IDE叫做Anaconda。我将滚动到底部,希望从零开始。我已经打开了IDE,我将遵循下一组指令:在第一个和最后一个响应者之间选择一个随机数。现在我将使用Python来实现,这也是一个例子,说明如何在日常生活中使用计算机或编程来完成简单任务。因为如果我选择一个随机数,可能会有偏见,例如我可能喜欢数字8。为了选择一个随机数,我将使用Python,导入随机数包,选择一个在16到272之间的随机数。我选择了随机数75,然后在响应者列表中查找编号75,是Lauren CoV。很好,您在这里。这是一个我作为机器的例子,同时也是在日常生活中使用Python的例子,仅仅是为了选择一个随机数。所以,尽可能多地使用Python,这会给你练习的机会。
这很有趣,但在麻省理工学院,我们热爱数字。这是一个展示陈述性知识和过程性知识区别的数值例子。我想找到一个数的平方根。陈述性知识的例子是:数字X的平方根是Y,使得Y乘以Y等于X。这是一个事实陈述,它是正确的。但计算机不知道如何处理这个陈述。计算机知道如何遵循食谱。这是一个著名的算法,用于找到数字X的平方根。假设X最初是16。如果计算机遵循这个算法,它将从一个猜测值G开始,比如3。我们试图找到16的平方根,计算G乘以G得到9。然后我们问:G乘以G是否足够接近X?如果是,停止并说G是答案。但9并不接近16,所以我不停止,继续。如果不够接近,那么我将创建一个新的猜测,取G和X/G的平均值。然后使用新的猜测重复这个过程。我们不断重复,直到决定足够接近。我们在前面的数值例子中看到的过程性知识是找到X平方根的食谱。食谱的三个部分是:简单的步骤序列、控制流以及停止的方式。在编程中,您不希望程序永远运行,必须有一种停止的方式。在这个例子中,停止的方式是我们决定足够接近,可能是差值在0.01或0.0001以内。这个食谱就是一个算法,这就是我们在这门课程中要学习的内容。
我们处理计算机,实际上希望将食谱捕获到计算机内部。计算机是一个机械过程。历史上,有两种类型的计算机:最初是固定程序计算机,它们只知道如何执行特定任务,如加法、乘法、减法、除法。如果您想做其他事情,必须创建另一个固定程序计算机。这不太理想。于是,存储程序计算机出现了。这些机器可以存储指令序列,执行这些序列,并且可以更改指令序列以执行不同的任务。这就是我们现在所知的计算机。中央处理单元是所有决策发生的地方。基本的机器架构包括四个主要部分:内存、输入/输出、算术逻辑单元和控制单元。ALU可以执行非常原始的操作,如加法、减法等。内存包含数据和指令序列。控制单元与ALU交互,包含一个程序计数器。当您加载指令序列时,程序计数器从第一个指令开始,获取指令并发送给ALU。ALU询问操作对象是什么,可能会从内存获取数据,执行操作,并将数据存储回内存。完成后,ALU返回,程序计数器增加1,意味着我们将转到指令集中的下一个序列。它线性地逐条执行指令。可能有某个特定指令执行某种测试,例如判断某个值是否大于、等于或小于另一个值。测试将返回真或假,根据测试结果,您可能会转到下一条指令,或者将程序计数器设置回开头等。因此,您不仅仅是线性地逐步执行所有指令,可能涉及一些控制流,您可能会跳过指令或从头开始等。当您执行完最后一条指令时,可能会输出一些内容。这就是计算机工作的基本方式。
回顾一下,存储程序计算机包含这些指令序列,它可以执行的原始操作包括加法、减法、逻辑操作、测试以及数据存储和数据移动等。解释器遍历每条指令,决定是转到下一条指令、跳过指令还是重复指令等。我们讨论了原始操作。事实上,伟大的计算机科学家艾伦·图灵证明了可以使用六种原始操作计算任何东西。这六种原始操作是:左移、右移、读、写、扫描和不操作。使用这六种指令和一条纸带,他证明了可以计算任何东西。基于这六种指令,编程语言出现了,它们创建了更方便的原始操作集。因此,您不必仅用这六种命令编程。从这六种原始操作中产生的一个非常重要的事情是:如果您可以用Python编写程序计算某物,理论上您可以用任何其他语言编写程序计算完全相同的东西。这是一个非常强大的陈述。


一旦您为特定语言设置了原始操作集,就可以开始创建表达式。这些表达式将是编程语言中原始操作的组合,它们将具有某种值,并在编程语言中具有某种含义。让我们与英语做一个类比,以便您理解我的意思。在英语中,原始结构是单词。在Python编程中,也有原始结构,但数量很多,例如浮点数、布尔值、数字、字符串以及加法、减法等简单运算符。使用这些原始结构,我们可以开始创建英语短语和句子,在编程语言中也是如此。在英语中,我们可以说“cat dog boy”,但这不是语法有效的。具有良好语法的英语是“名词动词名词”,例如“cat hugs boy”在语法上是有效的。类似地,在编程语言中,像“word”和数字5这样的组合没有意义,语法无效。但像“运算符 操作数 运算符”这样的结构是可以的。一旦创建了语法有效的短语或表达式,就必须考虑短语的静态语义。例如,在英语中,“I are hungry”语法正确,但说起来很奇怪。我们有代词和形容词,但不太合理。“I am hungry”更好,这在静态语义上是正确的。类似地,在编程语言中,随着练习增多,您会掌握这一点。例如,“3.2 * 5”是可以的,但“word + 5”是什么意思?将单词与数字相加没有意义。它的语法没问题,因为您有运算符、操作数和运算符,但将数字与单词相加没有意义。

一旦创建了语法正确且静态语义正确的表达式,在英语中,您需要考虑语义,即短语的含义。在英语中,一个短语可以有多种含义。例如,“Flying planes can be dangerous”可以有两种含义:驾驶飞机是危险的,或者正在飞行的飞机是危险的。另一个例子是:“This reading lamp hasn’t uttered a word since I bought it.” 这也有两种含义,它是在玩弄“reading lamp”这个词。在编程中,情况不同。在编程语言中,您编写的一组指令只有一个含义。记住,计算机只做您告诉它做的事情。它不会突然决定添加另一个变量。它只会执行您编写的语句。在编程语言中,只有一个含义,但问题在于,这个含义可能不是程序员所期望的。这就是可能出错的地方。课程后面会有关于调试的讲座,但这里只是告诉您,如果在程序中看到错误弹出,例如一些文本显示错误,比如如果我们这样做,语法不正确,您会看到一些愤怒的文本。随着编程经验增加,您会逐渐掌握阅读这些错误。这基本上是在告诉我,我编写的这一行语法不正确,并指向确切的行,说这是错误的,因此我可以返回修复它。语法错误实际上很容易被Python捕获。静态语义错误也可以被Python捕获,只要您的程序需要做出决策,并且您已经进入了发生静态语义错误的分支。这可能最令人沮丧,尤其是刚开始时。程序可能会执行与预期不同的操作,或者程序可能崩溃,这意味着它们停止运行。这没关系,只需返回代码并找出问题所在。另一个与预期含义不同的例子是程序可能停止,这也没关系,除了重启电脑,还有其他方法可以停止它。




Python程序将是定义和命令的序列。我们将有被求值的表达式,以及告诉解释器执行某些操作的命令。如果您完成了问题集零,您会看到可以直接在shell中键入命令,我在右侧进行了一些非常简单的操作,如“2 + 4”,或者您可以在左侧编辑器键入命令并运行程序。请注意,右侧通常用于编写非常简单的命令进行测试,而左侧编辑器用于编写更多行和更复杂的程序。
现在我们将开始讨论Python。在Python中,一切都是对象。Python程序操作这些数据对象。Python中的所有对象都有一个类型,类型告诉Python可以对这些对象执行哪些操作。例如,如果对象是数字5,您可以将该数字与另一个数字相加、相减、求幂等。更一般的例子是:我是人类,这是我的类型,我可以走路、说英语等。楚巴卡是伍基人类型,他可以走路、发出我无法发出的声音等。一旦有了这些Python对象,一切都是对象。实际上有两种类型的对象:一种是标量对象,这些是Python中非常基本的对象,无法再细分;另一种是非标量对象,这些对象具有内部结构。例如,数字5是标量对象,因为它无法再细分,但数字列表[5, 6, 7, 8]将是非标量对象,因为可以细分它,可以找到它的部分,它由一系列数字组成。

以下是Python中所有标量对象的列表:整数(所有整数)、浮点数(所有十进制实数)、布尔值(只有两个值:True和False,注意大写T和F),以及一种称为NoneType的特殊类型,它只有一个值None,表示类型的缺失,有时在某些程序中很有用。如果您想查找对象的类型,可以使用特殊命令type(),在括号中放入要查找类型的对象。例如,在shell中键入type(5),shell会告诉您那是整数。如果您想在不同类型之间转换,Python允许您这样做。为此,您使用要转换到的类型,放在要转换的对象之前。例如,float(3)将整数3转换为浮点数3.0。同样,您可以将任何浮点数转换为整数,转换为整数会截断小数部分,不进行四舍五入,只保留整数部分。



在编程中,最重要的事情之一是打印输出。打印输出是您与用户交互的方式。要打印内容,请使用print命令。如果您在shell中,只需键入3 + 2,您会看到值5,但这并不是真正打印出来。当您在编辑器中键入内容时,这一点变得明显。如果您只做3 + 2并运行程序,您会在右侧看到程序运行,但并未实际打印任何内容。如果您在控制台中键入此内容,它会显示该值,但这只是作为程序员窥视该值,并不是实际打印给任何人。如果您想打印某些内容,必须使用print语句。在这种情况下,这将把数字5打印到控制台。这基本上是我的设置,它只是在shell内交互,不与任何其他人交互。如果没有输出,意味着它被打印到控制台。


我们讨论了对象。一旦有了对象,可以将对象和运算符组合起来形成表达式。每个表达式都有一个值,表达式求值为一个值。表达式的语法是“对象 运算符 对象”。这些是可以在整数和浮点数上执行的一些运算符:典型的加法、减法、乘法和除法。对于前三种运算,得到的答案类型取决于变量的类型。如果两个操作数都是整数,则得到的结果是整数类型;但如果至少有一个是浮点数,则得到的结果是浮点数类型。除法有点特殊,无论操作数是什么,结果总是浮点数。其他有用的操作包括取余运算符%,i % j给出i除以j的余数;求幂运算符**,i ** j表示i的j次方。这些操作具有典型的数学优先级,如果您想为其他操作赋予优先级,可以使用括号。
我们有创建表达式的方法,也有对对象执行的操作,但能够将值保存到某个名称将非常有用。名称是您选择的,应该是描述性的。当您将值保存到变量名时,将能够在程序后面访问该值。要将值保存到变量名,请使用等号。等号是赋值操作,它将右侧的值分配给左侧的变量名。例如,我将浮点数3.14159分配给变量pi。在第二行,我将表达式22 / 7求值,得到一个小数,并将其保存到变量pi_approx。值存储在内存中,在Python中,我们说赋值将名称绑定到值。当您稍后在程序中使用该名称时,将引用内存中的值。如果您想稍后引用该值,只需键入您分配给的变量名。
为什么我们要给表达式命名?我们希望重用名称而不是值,这使您的代码看起来更好。这是一段计算圆面积的代码。请注意,我已将变量pi赋值为3.14159,将另一个变量radius赋值为2.2。然后,在代码后面,我有另一行area = pi * (radius ** 2)。这是一个赋值给这个表达式,这个表达式引用了这些变量名pi和radius。它将查找它们在内存中的值,用这些值替换变量名,并为我进行计算。最后,整个表达式将被一个数字替换,那就是浮点数。
在讨论这页幻灯片时,我想提一下编程与数学的区别。在数学中,您经常遇到需要求解X的问题,例如X + Y = Z,求解X。但计算机不知道如何处理这个问题,计算机需要被告知该做什么。在编程中,如果您想求解X,需要确切地告诉计算机如何求解X,需要找出需要给计算机什么公式才能求解X。这意味着在编程中,右侧总是一个表达式,它将被求值为一个值,而左侧总是一个变量。因此,等号表示赋值,不像数学中那样等号两边可以有很多东西。等号左边只有一样东西,那就是变量。等号代表赋值。


一旦我们创建了表达式并有了这些赋值,就可以使用新的赋值语句重新绑定变量名。让我们看一个例子。假设这是我们的内存。让我们重新输入计算半径的例子。假设pi = 3.14,在内存中,我们创建值3.14,并将其绑定到变量名pi。下一行radius = 2.2,在内存中,我们创建值2.2,并将其绑定到变量名radius。然后我们有这个表达式area = pi * (radius ** 2),它将用内存中pi和radius的值替换,

课程 P10:L2.6 - for循环 🌀
在本节课中,我们将学习 for 循环的基本概念,并通过一个具体的代码示例来理解循环的执行流程,特别是 break 语句在循环中的作用。

循环与累加求和
上一节我们介绍了循环的基本结构,本节中我们来看看一个结合了累加和条件判断的循环示例。
以下代码演示了如何使用 for 循环遍历一个数字列表,并在满足特定条件时使用 break 语句提前退出循环。
my_sum = 0
for value in [5, 7, 9, 11]:
my_sum = my_sum + value
if my_sum == 5:
break
print(my_sum)
代码执行步骤解析
以下是上述代码的逐步执行过程:
- 初始化变量
my_sum为0。 - 进入
for循环,第一次迭代时,value的值为5。 - 执行
my_sum = my_sum + value,此时my_sum的值变为5。 - 判断
if my_sum == 5条件,结果为True。 - 执行
break语句,立即终止当前循环。 - 跳转到循环体之后的语句,即执行
print(my_sum),输出结果为5。
关键概念:break 语句
break 语句的作用是立即终止它所在的最内层循环,并将程序控制流跳转到该循环之后的语句。在上面的例子中,当 my_sum 的值第一次等于 5 时,循环就被提前终止了。

本节课中我们一起学习了 for 循环与 break 语句的配合使用。通过一个累加求和的例子,我们清晰地看到了循环的执行顺序以及 break 如何中断循环流程。理解这些控制流语句是掌握编程逻辑的重要一步。



课程 P11:L3.1 - 字符串操作、近似、二分查找等 🧵🔍



以下内容基于知识共享许可协议提供。您的支持将帮助 MIT OpenCourseWare 继续免费提供高质量的教育资源。如需捐款或查看来自数百门 MIT 课程的其他材料,请访问相关网站。







大家好,我们开始上课。下午好,这是 6.0001 和 6.00 的第三讲。和往常一样,请下载幻灯片和代码以便跟上进度。

首先快速回顾一下上节课的内容。上节课我们讨论了字符串作为一种新的对象类型,即字符序列。然后我们引入了两个新概念,使我们能够编写稍微复杂一些的程序。我们引入了使用 if、elif、else 关键字的分支结构,分支允许我们作为程序员在程序中引入决策。接着我们介绍了两种不同的循环:while 循环和 for 循环,这些也为我们的程序增加了一些复杂性。


今天,我们将更深入地讨论字符串。我们将看到可以对字符串和字符串对象进行的更多操作。然后,我们将讨论三种不同的算法:猜测试错算法、近似解算法和二分查找算法。



字符串进阶操作

我们将首先讨论字符串。我们将字符串视为字符序列,正如我们在上节课编写的程序中看到的,它们是区分大小写的。字符串是对象,我们可以对字符串对象执行所有这些操作,例如测试它们是否相等、小于、大于等。

实际上,我们不仅可以连接两个字符串或对它们进行这些小测试。我们将开始引入函数或过程的概念,下节课我们将更多地了解函数以及如何编写自己的函数。但今天,你可以将函数视为为你执行某些操作的“过程”。





字符串长度


我们要看的第一个函数非常常用。当应用于字符串时,这个名为 len 的函数会告诉你字符串的长度。它会告诉你字符串中有多少个字符,字符包括字母、数字、特殊字符、空格等。它只是计算字符串中有多少个字符。

例如,如果字符串 s = "abc"(记住字符串用引号括起来),那么当我写下表达式 len(s) 时,由于它是一个表达式,它有一个值。根据定义,它会告诉我字符串的长度,即三个字符。







字符串索引




我们可以对字符串做的另一件事是,由于它们是字符序列,我可能想知道某个位置的字符是什么。我们使用一个叫做“索引”的术语来实现这一点。索引到字符串中基本上意味着你要告诉 Python:我想知道字符串中某个特定位置或索引的字符。

再次使用字符串 s = "abc"。在计算机科学中,我们习惯从 0 开始计数,Python 也不例外。在 Python 中,你从位置 0 开始索引。所以字符串中的第一个字符我们说在位置 0 或索引 0,字符串中的下一个字符在索引 1,再下一个在索引 2。


在 Python 中,你还可以使用负数进行索引。例如,如果你用 -1 索引到字符串中,这意味着你想要字符串中的最后一个字符。字符串中的最后一个字符总是在位置 -1,倒数第二个字符在 -2,倒数第三个在 -3,依此类推。


索引的表示方法是:s[0] 将得到值 'a',s[1] 将得到值 'b',依此类推。我们也可以进行负索引。
如果你尝试索引超出字符串限制的位置,例如 s[20],而你的字符串只有长度 3,那么你会得到一个错误。在 Python 中,最后几行会告诉你哪一行有问题,以及具体的错误类型,这里是索引错误,意味着我试图索引得太远,因为字符串只有三个字符。




字符串切片

能够从字符串中获取单个字符很好,但有时我可能想获取一个子字符串。例如,我想从第一个字符开始,取到字符串的一半,或者取中间的几个字符,或者跳过字符串中的每隔一个字母等。如果我们想与字符串进行这种稍微复杂的交互,我们称之为“切片”。



这里的表示法应该有点熟悉,因为我们在上节课使用 range 时见过它。我们有起始值、停止值和步长。表示法略有不同(括号和逗号),但除此之外,它的工作原理基本相同。起始值是你想要开始切片的索引(从 0 开始),停止值是你将切片到停止索引减一的位置,步长是间隔。

这是完整的表示法:s[start:stop:step]。但有时你可以不提供第三个数字。如果你只提供两个数字,那么对 Python 来说,那代表起始值和停止值,默认步长为 1。你还可以用字符串做很多其他事情,比如省略数字只留冒号。根据定义,如果你省略数字,它将等同于这些默认值。

我们使用方括号进行切片,就像索引一样,只是现在我们可以给它两个或三个数字。


以字符串 s = "abcdefgh" 为例,位置是 0, 1, 2, 3, 4, 5, 6, 7。s[3:6] 将从索引 3(即 'd')开始,然后取 'e',然后取 'f'。因为我们切片到 stop-1,所以我们不会取位置 6 的 'g'。
下一个例子 s[3:6:2] 是每隔一个取一个。我们从 3 开始,然后跳过每隔一个,所以我们取 'd',不取 'e',然后取 'f',然后停止。


如果你写 s[:],即 s 后跟冒号,起始、停止、步长都为空,那将等同于字符串本身,相当于 s[0:len(s):1]。

[::-1] 这个切片可能实际上很有用,它会自动反转你的字符串。通过这一小行代码,你可以得到字符串的逆序,这相当于从末尾开始,每次后退一个字母。


更复杂的切片也不难理解。


字符串的不可变性

我想提一件事,把它记在心里,随着我们开始讨论更复杂的对象类型,我们会回到这一点:字符串是不可变的。我的意思是,一个字符串对象一旦创建就不能被修改。


例如,假设我有字符串 s = "hello"。在内存中,我有一个对象 "hello",它绑定到变量 s。现在我可以使用变量 s 访问对象 "hello"。你可能会想,既然我可以索引到字符串,我或许可以写 s[0] = 'y',这样就把 'h' 改成了 'y',得到对象 "yello"。

但字符串是不可变的,这意味着在 Python 中,你实际上不允许这样做,如果你尝试,会得到一个错误。如果你希望变量 s 指向字符串 "yello",你可以直接写 s = "y" + s[1:]。这个操作将 'y' 与字符串 s 从位置 1 开始的所有元素(即 "ello")连接起来,从而得到 "yello"。



在内部,当我写这行代码时,Python 会说:好的,我将与原始对象 "hello" 断开绑定,将我的字符串变量 s 绑定到新对象 "yello"。那个旧对象仍然在内存中的某个地方,但它是一个完全不同的对象。

这一点现在可能不重要,但请把它记在心里:字符串是不可变的。



在字符串上使用 For 循环

接下来我想稍微回顾一下 for 循环,我们将看到如何非常容易地将 for 循环应用于字符串,从而编写出非常易读的代码。
记住,for 循环有一个循环变量。在这个特定例子中,我的循环变量是 var,它可以是任何你想要的变量名。在这个特定情况下,它遍历数字序列 0, 1, 2, 3, 4。循环第一次运行时,var 的值为 0,执行循环内的所有表达式。完成后,var 取值 1,再次执行循环内的所有表达式,然后 var 取值 2,一直到最后一次循环,var 等于 3。我们说过,我们可以自定义 range 以便从自定义值开始,在不同的值结束,并跳过某些数字。


到目前为止,我们只在使用 for 循环遍历数字序列,但实际上 for 循环比这强大得多。你可以使用它们来迭代任何值的序列,而不仅仅是数字,还包括字符串。


这里有两段代码,它们做完全相同的事情。对我来说,可能对你来说,下面这段看起来比上面这段易读得多。第一眼看去,使用关键字和变量,读起来会像破碎的英语,但你可以破译我想说什么:对于字符串 s 中的每个字符 char,如果 char 等于 'i' 或 char 等于 'u',则打印“有一个 i 或 u”。听起来很奇怪,但你大概能看出我想在这里做什么。


而在上面这段代码中,要理解我在做什么稍微复杂一些。你必须思考一下:对于在 0 到字符串 s 长度范围内的某个索引 index,如果 s[index] 是 'i' 或 s[index] 是 'u',则打印“有一个 i 或 u”。

这两段代码都只是遍历字符串 s,如果遇到字母 'i' 或 'u',就会打印出这个字符串。但下面这段代码更“Pythonic”(这是 Python 社区创造的一个词),它看起来更漂亮,你可以看出这段代码应该做什么。


这是一个在字符序列上使用 for 循环的示例。char 仍然是一个循环变量,但循环变量不是迭代一组数字,而是直接迭代 s 中的每个字符,char 将是一个特定的字符,一个字母。





示例:啦啦队机器人

这里有一个更复杂的例子。我几年前写了这段代码,是我创建机器人啦啦队的尝试,因为我需要一些动力。昨晚我搜索了“机器人啦啦队”,结果没有让我失望,创建了这个动图,看起来很棒,看起来他们有点剽窃了我的想法,不过没关系。


让我们看看这段代码应该做什么。我来运行它,然后我们逐步分析。


它打印出:“我会为你加油。输入一个单词:” 我喜欢机器人,所以我输入 robots。“你对机器人有多热情?(1-10):” 假设是 6。
然后它会打印出:给我一个 R!R!给我一个 O!O!给我一个 B!B!…… 这拼出来是什么?Robots!它会打印六次,因为我对机器人的热情是 6/10。

这就是这段代码应该做的。你可以用我们目前学到的知识来编写它。现在让我们逐步分析一下,我将向你展示使用 for 循环遍历字符来转换这段代码是多么容易。

目前,它的作用是询问用户输入一个单词和一个数字,然后执行这里的操作。首先,它使用一个 while 循环;其次,它使用索引。提示你它使用索引的地方是它使用了 []。显然,它使用了 while 循环,并且必须首先创建一个计数器并初始化它,然后在 while 循环内部递增它。如果你还记得,这就是 while 循环需要做的。


它将从 0 开始,基本上遍历索引 i = 0, 1, 2, 3, 4,一直遍历到单词的末尾(无论用户输入什么,在这个例子中是 robots)。它将获取该位置的字符,word[i] 将是一个字符。

这里的这一行只是为了啦啦队听起来合理,它处理那些使用“an”有意义的字母。例如,“给我一个 B”可以,但“给我一个 unbe”没有意义。所以这只是检查字符(例如,robots 中的 'r')是否在 vowels 字符串中,我在这里定义了 vowels,即所有这些字母。所以如果在该字母前使用“an”有意义,就使用“an”,否则只使用“a”。

完成后,我说“那拼出来是什么?”,然后只是一个 for 循环,运行 times 次,打印出单词和感叹号。

这段代码如果我最初用 for 循环编写可能会更直观一些。这里的这部分,即 while 循环、索引和创建原始计数器,我们可以去掉它,用 for char in word: 替换。
我原本使用 char,所以我可以用 char 作为我的循环变量。简单地说,我将直接遍历单词本身。现在,我不再有这一堆混乱的代码,而是有一行代码说:对于我的单词中的每个字符,执行这里的所有操作。其余部分保持不变。然后我甚至不需要递增计数器变量,因为我不再使用 while 循环,只使用 for 循环。

代码变成:删除那个,写 for char in word:,然后删除那个和那个。它做完全相同的事情,而且可读性高得多。







算法介绍

这是我们课程开始时的工具箱。我们已经上了两节半课,这些是我们添加到工具箱中的东西:我们知道整数、浮点数、布尔值;我们知道一些字符串操作和数学运算;我们最近添加了这些条件语句和分支,以编写稍微有趣的程序;现在我们有了 for 和 while 循环,以添加有趣和更复杂的程序。



本节课的第二部分将着眼于三种不同的算法。这是本课程中计算机科学的部分:“使用 Python 进行计算机科学和编程导论”。不要让“算法”这个词吓到你,它们并不那么复杂,你只需要稍微思考一下,就能掌握它们。



我们将研究三种算法,都是在解决一个问题的背景下:求立方根。第一种算法是猜测试错,然后我们将看一种近似算法,最后是二分查找。







算法一:猜测试错



第一种是猜测试错方法。你可能在高中数学中做过这个。猜测试错方法有时也称为穷举枚举,你会明白为什么。






给定一个问题,比如求一个数的立方根,你可以猜测一个解的起始值。如果你能够检查你的解是否正确,猜测试错方法就有效。所以,如果你的初始猜测是 0,你可以问:0 的立方等于我试图求立方根的那个数的立方吗?例如,如果我试图求 8 的立方根,0 的立方等于 8 吗?不。所以解不正确。如果不正确,猜测另一个值,系统地执行,直到找到解,或者你已经猜遍了所有可能的值,穷尽了所有可能的搜索空间。



这里有一个非常简单的猜测试错代码,用于找到一个数的立方根。我试图求 8 的立方根,所以我的 cube 是 8。我将有一个 for 循环,从 0 开始,一直遍历到 8。对于每一个数字,我会问:我的猜测值的三次方等于 cube 8 吗?如果是,我就打印出这条消息。

然而,这段代码对用户不太友好。如果用户想求 9 的立方根,他们将得不到任何输出,因为我们从未在立方数不是完全立方的情况下打印任何东西。


所以我们可以稍微修改一下代码,添加两个额外功能:第一,我们将能够处理负立方数;第二,如果立方数不是完全立方,我们将告诉用户。



让我们逐步分析这段代码。首先,我们有一个 for 循环,和之前一样,我们将遍历 0 到 8(在这个例子中)。我们使用绝对值是因为我们可能想求负数的立方根。

我们做的第一件事是进行这个检查,而不是猜测猜测值的三次方是否等于立方数,我们将检查它是否大于或等于。我们这样做是出于以下原因:例如,如果我们试图求 8 的立方根与 9 的立方根,这是 8,这是 9。


这段代码会做什么?它首先猜测 0,0 的立方不大于等于 8;1 的立方不大于等于 8;2 的立方大于等于 8。所以一旦我们猜到 2,我们就跳出循环,因为我们找到了一个有效的数字,没有必要继续寻找。一旦我们找到了这个数 8 的立方根,就没有必要继续搜索剩下的 3、4、5、6、7、8。


同样的想法,当我们试图求 9 的立方根时,我们从 0 开始,0 的三次方小于 9,1 的三次方小于 9,2 的三次方小于 9。当我们到达 3 的三次方时,将大于 9。所以这段代码告诉我们,一旦我们选择了一个超出我们立方数合理立方根的数字,我们就应该停止,因为继续搜索没有意义。因为如果 3 的三次方已经大于 9,那么 4 的三次方也会大于 9,依此类推。



所以一旦我们在这里跳出循环,guess 要么是 2,要么是 3,取决于我们试图求哪个数的立方根。如果 guess 的三次方不等于 cube,那么显然这个立方数不是完全立方。这就是这里的情况,如果我们看 9 的立方根。否则,这部分只是看我们应该使它成为正数还是负数立方根。如果我们原始的 cube 小于 0,那么显然负数的立方根将是负数,否则它就是我们的猜测值。


这就是猜测试错方法,一个功能稍丰富的求立方根的程序。但它只告诉我们完全立方数的立方根,并没有给我们任何其他更多信息。


算法二:近似解


有时你可能想说:我不在乎 9 不是完全立方,给我一个足够接近的答案就行。这就是近似解出现的地方。我们满足于有一个“足够好”的解。


为了做到这一点,我们将从一个猜测开始,然后以某个小值递增该猜测。从 0 开始,

课程 P12:L3.2 - 字符串操作 🧵

以下内容基于知识共享许可协议提供。您的支持将帮助 MIT OpenCourseWare 继续免费提供高质量的教育资源。如需捐款或查看来自数百门 MIT 课程的其他材料,请访问相关网站。

在本节课中,我们将学习字符串的基本操作,包括字符串的创建、连接、索引和切片。我们将通过一个具体的例子,逐步解析如何通过组合不同的字符串片段来构建一个新的字符串。
字符串构建示例
假设我们有一个字符串 s,其内容为 "600 is six triple one and six triple two"。我们的目标是创建一个新的字符串。初始时,新字符串是一个空字符串。
首先需要说明的是,+= 操作符。例如,a += 1 等价于 a = a + 1。我们在之前的课程中已经见过几次这种用法。
逐步解析构建过程
以下是构建新字符串 new_str 的代码逻辑分析。我们将逐行解释每步操作。
初始时,new_str 是一个空字符串:new_str = ""。
第一行代码是:
new_str += s[2]
这表示我们将字符串 s 中索引为 2 的字符(从0开始计数)添加到 new_str 的末尾。在字符串 "600 is six triple one and six triple two" 中,索引 2 对应的字符是 '0'。因此,执行后 new_str 变为 "0"。
第二行代码是:
new_str += s[0]
这表示我们将字符串 s 中索引为 0 的字符(即第一个字符 '6')添加到 new_str 的末尾。此时 new_str 从 "0" 变为 "06"。
第三行代码涉及切片操作:
new_str += s[4:]
这表示我们将字符串 s 从索引 4 开始到末尾的所有字符添加到 new_str 的末尾。让我们计算一下索引:'6'是0,'0'是1,'0'是2,' '是3,'i'是4。因此,s[4:] 的结果是 "is six triple one and six triple two"。现在 new_str 变为 "06is six triple one and six triple two"。
第四行代码也是一个切片:
new_str += s[4:len(s):3]
这是一个带步长的切片。它从索引 4 开始,到字符串末尾(len(s))结束,步长为 3。这意味着我们每隔3个字符取一个。从索引4('i')开始:取 'i',然后跳过2个字符取索引7('s'),再取索引10('t'),依此类推,直到字符串结束。这个操作会提取出一系列字符。执行后,这些字符会被添加到 new_str 末尾。
第五行代码是另一个切片:
new_str += s[13:10:-1]
这是一个反向切片。它从索引 13 开始,反向移动到索引 10(但不包括索引10本身),步长为 -1。让我们找到这些索引:索引13是单词 "six" 中的 'x'(假设 "600 is six...",'i'在4,'s'在5,空格在6,'s'在7,'i'在8,'x'在9...需要仔细计数确认)。实际上,我们需要根据原字符串 "600 is six triple one and six triple two" 精确计算位置。假设经过计算,s[13:10:-1] 提取出的字符序列是 "100"(这只是一个示例,具体取决于原字符串索引)。这个反向切片的结果会被添加到 new_str 末尾。
最终结果
经过以上所有步骤的组合操作,最终构建出的新字符串是 "06is six triple one and six triple two" 加上后续切片添加的字符。根据课程中的提示,最终结果可能是 "26 100" 或类似形式。如果你将代码粘贴到 Python 解释器(如 Spyder)中运行,它应该会输出 "26 100"。

总结
本节课中,我们一起学习了字符串的几种核心操作:通过索引(如 s[0])获取单个字符,通过切片(如 s[4:]、s[4:len(s):3])获取子串,以及使用 += 操作符进行字符串连接。我们通过一个具体的例子,逐步分析了如何将这些操作组合起来,从原字符串中提取并拼接出新的字符串。理解字符串的索引和切片是进行文本处理的基础。

课程 P13:L3.3 - 字符串的for循环处理 🧵
在本节课中,我们将要学习如何使用嵌套的for循环来处理和比较两个字符串。我们将通过一个具体的例子,逐步分析代码的执行过程,理解循环如何遍历字符串中的每个字符,并在找到匹配时执行特定操作。


概述
我们有两个字符串 s1 和 s2。首先,我们将检查 s1 的长度是否等于 s2 的长度。如果长度相等,我们将进入一个if语句块。在这个块中,我们将使用嵌套的for循环来比较两个字符串中的每一个字符。当找到相同的字符时,程序会打印一条消息,并立即跳出内层循环。
字符串长度检查
首先,我们需要检查两个字符串的长度是否相等。这可以通过以下代码实现:
if len(s1) == len(s2):
在这个例子中,s1 和 s2 都包含10个字符,包括空格。因此,条件成立,程序将进入if语句块。
嵌套循环比较字符
进入if语句块后,我们将使用嵌套的for循环来比较两个字符串中的字符。
以下是嵌套循环的结构:
for char1 in s1:
for char2 in s2:
if char1 == char2:
print("Common letter")
break
外层循环遍历 s1 中的每一个字符,内层循环遍历 s2 中的每一个字符。对于每一对字符,我们检查它们是否相等。如果相等,则打印“Common letter”并执行 break 语句,跳出内层循环。
逐步执行过程
让我们逐步分析代码的执行过程。
首先,外层循环从 s1 中取出第一个字符 'M'。然后,内层循环开始遍历 s2 中的字符。
- 将
'M'与s2的第一个字符'I'比较,不相等。 - 将
'M'与s2的第二个字符' '(空格)比较,不相等。 - 将
'M'与s2的第三个字符'R'比较,不相等。 - 继续比较,直到将
'M'与s2中的'M'比较,此时相等。
当找到匹配的字符 'M' 时,程序打印“Common letter”,并执行 break 语句,立即跳出内层循环。这意味着不再继续比较 s2 中剩余的字符。
跳出内层循环后,程序返回到外层循环,取出 s1 中的下一个字符 'I',并重复上述过程。
结果分析
通过这种方式,程序会为 s1 和 s2 中每一个匹配的字符对打印一次“Common letter”。根据给定的字符串,程序总共会打印七次“Common letter”。
如果你得到了不同的结果,建议你回头逐步跟踪程序的执行过程,仔细检查每一步中变量的值,以确保理解正确。

总结
本节课中我们一起学习了如何使用嵌套的for循环来比较两个字符串中的字符。我们了解了如何通过 break 语句在找到匹配时提前退出内层循环,以及如何逐步跟踪程序的执行以理解其逻辑。掌握这些概念对于处理字符串和编写高效的循环结构至关重要。




🧩 P14:L4.1- 分解、抽象与函数



在本节课中,我们将学习如何通过函数来组织代码,实现分解与抽象,从而编写出更清晰、更易维护和可重用的程序。


📚 课程回顾

上一节课我们学习了更多字符串操作,并了解了如何直接对字符串使用 for 循环。我们还探讨了解决同一问题的不同方法,例如求立方根的“猜测与检查法”、“近似法”以及更高效的“二分查找法”。


本节中,我们将看看如何通过函数来构建程序结构。
🏗️ 为什么需要函数?

到目前为止,我们编写程序的方式是:打开一个文件,输入一系列指令(如赋值、循环、条件判断等)来解决特定问题。所有代码都写在一个文件中。

这对于我们目前遇到的小型问题来说是可以的。但是,当你开始编写大型程序时,代码会迅速变得混乱不堪。
例如,如果你在代码的某处使用了一个 for 循环,并发现它在另一处也很有用。将来调试时,如果你想修改这个循环的逻辑,就必须找出所有使用了类似循环的地方。

随着代码规模扩大,跟踪这些细节会变得越来越困难。这就是函数发挥作用的地方。


好的编程风格不在于添加大量代码行,而在于为程序增加更多功能。函数是实现这一目标的关键机制。


🎯 分解与抽象



函数是实现分解和抽象的机制。


分解


分解是指在代码中创建结构。在编程中,为了实现分解,你需要将代码划分为更小的模块。这些模块是自包含的,可以看作是小型程序:你输入一些数据,它们执行一个小任务,然后返回结果。


这些模块可以用来拆分你的代码,并且重要的是它们是可重用的。你只需编写和调试一次模块,就可以在不同的地方、使用不同的输入多次调用它。

这样做的好处是保持代码的组织性和连贯性。函数就是用来实现分解、创建代码结构的。在后续关于面向对象编程的课程中,你将看到如何通过类来实现分解。

抽象



抽象是指隐藏细节。在编程中,一旦你编写了一段执行特定任务的代码,你不需要多次重写它。你只需编写一次,并为其创建一个函数规范或文档字符串。
文档字符串是一段文本,它告诉未来任何想使用这个函数的人(包括你自己)三件事:
- 函数接受什么输入?
- 函数应该做什么?
- 函数会输出什么?

使用者无需知道函数内部是如何实现的,他们只需要知道输入、功能和输出。这就是抽象。


函数是可重用的代码块。在今天的课程中,我们将通过几个例子来学习如何编写和调用函数。





📝 函数的组成部分

当我们思考函数时,可以从两个角度出发:
- 编写者:你需要知道如何让函数工作。
- 使用者:你假设函数已被正确实现,只需使用它来完成某项任务。

函数具有以下特征:
- 名称:你需要为函数命名,名称应具有描述性。
- 参数:函数的输入,可以有零个或多个。
- 文档字符串:实现抽象的关键,用于说明函数的使用方法(可选但强烈推荐)。
- 函数体:函数的核心部分,包含执行任务的代码。
- 返回值:函数执行完毕后返回的结果。






✍️ 函数定义与调用

以下是函数定义和函数调用的示例:


def is_even(i):
"""
输入:整数 i
功能:判断 i 是否为偶数
输出:若 i 为偶数返回 True,否则返回 False
"""
remainder = i % 2
return remainder == 0

# 函数调用
result = is_even(3)
print(result) # 输出:False
def关键字告诉 Python 你要定义一个函数。is_even是函数名。(i)中的i是参数(形式参数)。- 三引号内的文本是文档字符串。
- 缩进的代码块是函数体。
return语句指定函数的返回值。

函数调用时,传入的值称为实际参数。例如,is_even(3) 中的 3 就是实际参数。

🔍 作用域详解


作用域是另一个表示“环境”的词。你可以将函数视为小型程序,函数的作用域与主程序的作用域是完全分离的。

当你进行函数调用时,Python 会:
- 离开当前主程序环境。
- 进入一个新的函数作用域环境,并创建一套全新的、仅存在于该环境中的变量。
- 执行函数体内的计算。
- 遇到
return语句时,获取返回值。 - 退出该函数作用域环境,携带返回值回到主程序。


从一个作用域进入另一个作用域时,你通过参数传递值给函数,函数通过返回值将结果传递回来。

让我们逐步分析一个程序,看看作用域背后发生了什么:



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


x = 3
z = f(x)


- 程序启动,创建全局作用域。Python 看到
def f(x): ...,知道有一个名为f的函数,但暂时不关心其内部代码。 - 执行
x = 3,在全局作用域中创建变量x,其值为3。 - 执行
z = f(x),这是一个函数调用。Python 创建一个新的作用域(f的作用域)。 - 将实际参数
x(值为3)映射给形式参数x。在f的作用域内,x现在是3。 - 执行
f的函数体:x = x + 1,f作用域内的x变为4。打印消息。 - 执行
return x,将值4返回给调用者。 - 函数调用结束,
f的作用域被销毁。回到全局作用域,f(x)被替换为返回值4。 - 因此,
z被赋值为4。


关于返回值的警告:每个函数都应该返回一些内容。如果你没有显式地使用 return 语句,Python 会为你隐式地添加 return None。None 是一个特殊类型(NoneType)的值,表示“没有值”。


🖨️ print 与 return 的区别


理解 print 和 return 的区别至关重要。


print:将信息输出到控制台,用于显示。return:指定函数的输出结果,用于将值传递回调用者。


一个函数可以打印内容,也可以返回值,或者两者都做。但如果你希望在其他地方使用函数的计算结果,就必须使用 return。
例如,在问题集中,如果你看到输出了很多 None,请检查是否在函数中只使用了 print 而忘记了 return。







♻️ 函数的强大之处:可重用性


函数的主要优势之一是可重用性。一旦你编写并调试好一个函数,就可以在代码中多次使用它,使代码看起来非常简洁。

例如,使用我们之前定义的 is_even 函数:
for i in range(20):
if is_even(i):
print(i, 'even')
else:
print(i, 'odd')
如果不使用函数,你就需要将判断偶数的逻辑(i % 2 == 0)直接写在循环里。使用函数使得代码更清晰、更易读,并且逻辑只在一处定义,便于维护。




🧠 函数也是对象

在 Python 中,一切都是对象。整数、浮点数是对象,字符串是对象,函数也是对象。


既然我们可以将对象作为参数传递给函数,那么我们也可以将其他函数作为参数传递。这为编程提供了极大的灵活性。

让我们分析一个传递函数作为参数的例子:

def func_a():
print('inside func_a')
# 隐式返回 None

def func_b(y):
print('inside func_b')
return y

def func_c(z):
print('inside func_c')
return z()


print(func_a()) # 输出:inside func_a \n None
print(5 + func_b(2)) # 输出:inside func_b \n 7
print(func_c(func_a)) # 输出:inside func_c \n inside func_a \n None




对于 func_c(func_a) 的调用:
- 进入
func_c的作用域,将实际参数func_a(函数对象)映射给形式参数z。 - 打印
'inside func_c'。 - 执行
return z()。此时z是func_a,所以这相当于return func_a()。 - 这引发了对
func_a的调用,创建新作用域。 func_a执行,打印'inside func_a',并隐式返回None。func_a()调用结束,返回值None传回给func_c。func_c返回这个None。- 最终,
print(func_c(func_a))打印出None。
逐步跟踪变量、形式参数和实际参数是理解作用域的关键。强烈建议在纸上画图或使用 Python Tutor 工具来可视化执行过程。


⚠️ 作用域与变量访问

在函数内部访问变量时,有三种常见情况:
-
局部变量:在函数内部定义并使用同名变量。这是最典型的情况,局部变量与外部全局变量互不干扰。
x = 5 def f(): x = 1 # 这是局部变量 x x += 1 return x print(f()) # 输出 2 print(x) # 输出 5,全局 x 未改变 -
访问外部变量:函数内部使用了一个未在内部定义的变量,Python 会向外层作用域查找。
x = 5 def g(): print(x) # 找到全局变量 x g() # 输出 5 -
在赋值前访问外部变量(错误):尝试在函数内部修改一个未在内部定义的变量会导致错误。
x = 5 def h(): x += 1 # UnboundLocalError! 尝试在赋值前使用局部变量 x可以使用
global关键字声明使用全局变量,但通常不鼓励这样做,因为它会破坏函数的封装性,使代码难以维护。





🎓 总结

本节课我们一起学习了函数的核心概念:


- 分解:通过将代码划分为更小、可重用的模块(函数)来创建程序结构。
- 抽象:通过文档字符串隐藏函数实现细节,只暴露其用途、输入和输出。
- 函数的定义与调用:使用
def定义函数,通过函数名和参数进行调用。 - 作用域:函数拥有独立的作用域,变量在其中生命周期有限,这是理解函数如何工作的关键。
return与print:return用于输出函数结果,print仅用于显示信息。- 函数作为对象:函数可以像其他数据一样被传递,这增加了代码的灵活性。



掌握函数是成为高效程序员的重要一步。它们能帮助你编写出更清晰、更模块化、更易于调试和维护的代码。

课程 P15:L4.2 - 函数调用 🧩

以下内容基于知识共享许可协议提供。您的支持将帮助 MIT OpenCourseWare 继续免费提供高质量的教育资源。如需捐款或查看来自数百门 MIT 课程的其他材料,请访问相关网站。
在本节课中,我们将通过一个具体的代码示例,学习函数调用、返回值以及 print 语句如何共同作用,最终决定程序在控制台输出的行数。我们将分析代码的执行流程,并计算出确切的输出行数。

代码示例分析
首先,我们来看提供的代码。这里定义了两个函数:add 和 multiply。
def add(x, y):
return x + y
def multiply(x, y):
print(x * y)
add 函数接收两个参数 x 和 y,并返回它们的和 x + y。multiply 函数也接收两个参数,但它并不返回值,而是使用 print 语句直接打印出 x * y 的结果。由于 multiply 函数内部没有 return 语句,它将隐式地返回 None。
接下来是函数调用的代码:
add(1, 2)
print(add(2, 3))
multiply(3, 4)
print(multiply(4, 5))
我们的目标是分析执行这四行代码后,控制台总共会显示多少行输出。
逐行执行过程
上一节我们介绍了两个函数的定义,本节中我们来看看每一行调用代码的具体执行过程。
以下是每一行代码执行时的输出分析:
-
add(1, 2):调用add函数,计算1 + 2得到结果3。由于这行代码只是调用函数而没有使用print,返回值3不会被显示在控制台。因此,这一行没有产生任何输出。 -
print(add(2, 3)):首先执行内部的add(2, 3),计算得到5。然后print语句将这个返回值5输出到控制台。因此,这一行产生一行输出:5。 -
multiply(3, 4):调用multiply函数。函数内部执行print(3 * 4),即print(12)。这会将12直接打印到控制台。函数本身返回None,但此处没有打印这个返回值。因此,这一行产生一行输出:12。 -
print(multiply(4, 5)):首先执行内部的multiply(4, 5)。函数内部执行print(4 * 5),即print(20),这产生了第一行输出20。接着,multiply函数执行完毕,返回None。外层的print语句接收到这个返回值None,并将其打印出来,这产生了第二行输出(通常是None)。因此,这一行产生两行输出:20和None。
总结输出行数
综合以上分析,我们将各步骤产生的输出行数汇总:

以下是输出行数的统计列表:
- 第 2 行代码输出:
5(1行) - 第 3 行代码输出:
12(1行) - 第 4 行代码输出:
20和None(2行)
总计输出行数为:1 + 1 + 2 = 4 行。

本节课中我们一起学习了如何跟踪函数调用的执行过程,关键点在于区分函数的返回值和使用 print 语句产生的直接输出。我们分析了 add 和 multiply 两个函数的不同行为,并逐步推导出四行调用代码最终在控制台产生了 4 行输出。理解这些概念对于调试程序和预测代码行为至关重要。

课程 P16:L4.3 - 函数参数 🧩

以下内容基于知识共享许可协议提供。您的支持将帮助 MIT OpenCourseWare 继续免费提供高质量的教育资源。如需捐款或查看来自数百门 MIT 课程的其他材料,请访问相关网站。


概述
在本节课中,我们将学习函数参数传递的机制,特别是当一个函数作为参数传递给另一个函数时,程序是如何执行的。我们将通过一个具体的代码示例,逐步分析其执行流程。
代码示例与分析
首先,我们来看一段代码。这里定义了两个函数。
第一个函数名为 sq,它接收两个参数。第二个函数名为 f,它接收一个参数。定义完函数后,程序执行了两行代码:第一行调用了函数 sq,第二行打印了一个值。
目前,我们暂时不关心函数内部的具体实现,因为函数调用尚未发生。让我们先关注第一个函数调用:calc = sq(f, 2)。
在函数 sq 内部,参数 func 将被映射为 f,参数 x 将被映射为 2。参数是按顺序进行映射的。
函数 sq 执行的第一件事是创建变量 y,并计算 y = x * x。此时 x 是 2,所以 y 等于 4。
接着,函数 sq 执行 return func(y)。这意味着它将调用 func 函数,并传入 y 的值(即 4)。所以,这里实际上是在计算 f(4)。
现在,程序需要知道 f 函数是什么。f 函数是在之前定义的,其功能是返回 x 的平方。
在 f 函数内部,参数 x 被映射为我们传入的值 4。因此,f(4) 将计算并返回 4 * 4,结果是 16。
f(4) 的计算结果 16 将返回给调用者,也就是 sq 函数中的 return func(y) 这一行。
因此,sq(f, 2) 这个函数调用最终返回的值是 16。这个返回值被赋给了变量 calc。

最后,程序执行 print(calc),这行代码会打印出变量 calc 的值,也就是 16。
核心概念总结
通过这个例子,我们可以理解以下几个核心概念:
- 参数映射:函数调用时,实参按顺序传递给形参。
- 函数作为参数:一个函数(如
f)可以作为参数传递给另一个函数(如sq)。 - 作用域与返回:函数执行完毕后,返回值会传递给调用它的上下文。
执行流程简述
以下是代码执行的简化步骤:

- 定义函数
sq(func, x)和f(x)。 - 调用
sq(f, 2)。 - 在
sq内,func绑定为f,x绑定为2。 - 计算
y = 2 * 2,得到4。 - 执行
return f(4),即调用函数f。 - 在
f内,x绑定为4,计算并返回4 * 4 = 16。 sq函数接收到16并将其返回。- 返回值
16被赋给变量calc。 - 打印
calc,输出16。
总结
本节课中,我们一起学习了函数参数的传递过程,特别是高阶函数(以函数为参数的函数)的执行机制。我们通过跟踪一个具体示例中变量的绑定和函数调用流程,理解了代码如何从外层函数进入内层函数,并最终将结果返回。掌握这一流程对于理解复杂的程序逻辑至关重要。




课程 P17:L5.1 - 元组、列表、重命名、元素更改与复制 📚




以下内容基于知识共享许可协议提供。您的支持将帮助 MIT OpenCourseWare 继续免费提供高质量的教育资源。如需捐款或查看来自数百门 MIT 课程的其他材料,请访问相关网站。







首先,快速回顾一下上节课的内容。上次我们介绍了分解和抽象的概念,并开始将这些概念应用到我们的程序中。这些是高级概念,我们通过程序中的具体事物——函数——来实现它们。函数使我们能够创建结构清晰、可重用的代码。从现在起,在问题集和讲座中,我将大量使用函数,因此请确保你理解它们的工作原理和所有细节。

今天,我们将介绍两种新的数据类型,它们被称为复合数据类型,因为它们实际上是由其他数据类型(特别是整数、浮点数、布尔值和字符串,甚至其他数据类型)组成的数据类型。这就是为什么它们被称为复合数据类型。



我们将首先学习一种叫做元组的新数据类型,然后是你已经接触过的列表数据类型,接着我们将讨论与列表相关的一些概念。


元组 📦


如果你还记得,字符串是字符的序列。元组与字符串类似,也是某种事物的序列。但元组不仅仅是字符序列,它们可以是任何事物的序列。元组是一个数据集合,其中的数据可以是任何类型。因此,一个元组可以包含整数、浮点数、字符串等元素。

元组是不可变的。我们之前在讨论字符串时提到过这个词。这意味着一旦你创建了一个元组对象,你就不能修改它,就像你创建了一个字符串对象后无法修改它一样。

我们使用圆括号来创建元组。这不应该与函数调用混淆,因为函数调用在括号前会有函数名。这里只是我们表示元组的方式。一个简单的空括号 () 表示一个空元组,其长度为0,里面没有任何内容。

你可以通过用逗号分隔每个元素来创建一个包含多个元素的元组。例如:

t = (2, ‘hi‘, 3)

在这个例子中,变量 t 指向一个包含三个元素的元组:第一个是整数,第二个是字符串,第三个是另一个整数。

与字符串类似,我们可以通过索引来访问元组中特定位置的值。例如,t[0] 表示元组 t 在位置0的值,计算结果为 2。在计算机科学中,我们从0开始计数,所以这给出了第一个元素。

与字符串类似,我们可以将元组连接在一起,这意味着将它们相加。例如,将两个元组相加,我们会得到一个更大的元组,它只是将这两个元组的元素合并在一起。


同样,与字符串类似,我们可以对元组进行切片。例如,t[1:2] 表示从索引1到索引2(不包括索引2)的切片,这只会给出一个元素。

一个额外的逗号实际上代表一个元组对象。如果没有这个逗号,括号就没有实际意义,但这里的逗号让 Python 清楚地知道这是一个只包含一个元素的元组。

我们可以进一步切片以获取包含两个元素的元组,并且可以执行常规操作,如获取元组的长度,它表示元组中有多少个元素。len(t) 的计算结果为 3,因为元组中有三个元素,每个元素由逗号分隔。

与字符串一样,如果我们尝试更改元组内部的值,例如尝试将第二个元素的值更改为 4,Python 不允许这样做,因为元组是不可变的。

为什么使用元组? 🤔


元组在几种不同的场景中实际上很有用。

回想几节课前,我们看了这段代码,我们试图交换变量 x 和 y 的值,第一段代码实际上不起作用,因为你覆盖了 x 的值。相反,我们最终创建了一个临时变量来存储 x 的值,然后覆盖它,再使用临时变量。事实证明,这段三行代码实际上可以使用元组在一行内完成:

x, y = y, x

Python 会执行这个操作:将 y 的值赋给 x,然后将 x 的值赋给 y。
在此基础上,我们实际上可以使用元组从函数返回多个值。函数只允许返回一个对象,但如果我们使用一个元组对象作为返回值,我们就可以绕过这个规则,将任意多的值放入元组对象中,然后返回任意多的值。
在这个具体例子中,我试图计算 x 除以 y 的商和余数。这是一个函数定义,下面我用 4 和 5 调用这个函数。当进行函数调用时,4 被赋值给 x,5 被赋值给 y。然后 q 将是 x 除以 y 的整数除法,双斜杠 // 意味着将结果转换为整数,保留整数部分,删除小数点后的所有内容。所以当 4 除以 5 时,q 实际上是 0。余数使用取模运算符 % 计算,当 4 除以 5 时,余数将是 4。注意,我将返回 q 和 r,这两个值是在函数内部计算的,我将在元组的上下文中返回它们。所以我只返回一个对象,即一个元组,只是碰巧我用几个不同的值填充了这个对象。


当函数返回时,这将返回元组 (0, 4)。然后,quot, rem = (0, 4) 这行代码基本上是将 quot 赋值为 0,rem 赋值为 4。因此,我们可以使用元组从函数返回多个值,这非常有用。



元组很棒,起初可能看起来有点令人困惑,但它们实际上非常有用,因为它们可以保存数据集合。这里我写了一个函数,可以应用于任何数据集。我将解释这个函数的作用,然后我们可以将其应用于一些数据,你会看到你可以从收集的任何数据集中提取一些非常基本的信息。
这是一个名为 get_data 的函数,它执行这里的所有操作。在与讲座相关的实际代码中,我说明了元组的条件:它必须是一个看起来特定的元组。外部括号表示它是一个元组,而这个元组的元素实际上是其他元组。每个内部元组对象包含两个元素:第一个是整数,第二个是字符串。这是函数能够工作的前提条件。


给定一个看起来像那样的元组,函数将做什么?首先,它创建两个空元组:一个叫 nums,一个叫 words。然后有一个 for 循环。注意,这里的 for 循环将遍历元组中的每个元素。在字符串中,我们能够使用 for 循环直接遍历字符,而不是遍历索引。这里我们做同样的事情:我们将遍历元组对象在每个位置的值。



第一次循环时,t 将是第一个元组;第二次循环时,t 将是第二个元组;第三次循环时,t 将是第三个元组对象。每次循环时,我将有一个 nums 元组,我会不断向其中添加元素。每次循环时,我都会创建一个新对象并将其重新赋值给变量 nums。每次循环时,我查看 nums 的前一个值(即我之前的元组),并将其与这个单元素元组(即 t[0])相加。所以如果 t 是这个元组元素,那么 t[0] 将是整数部分。随着循环进行,nums 将被填充所有内部元组对象中的整数。


同时,我也在填充 words 元组。words 元组有点不同,因为我不是添加每一个字符串对象。t[1] 是内部元组的字符串部分,只有当它不在我的 words 列表中时,我才添加这个字符串部分。所以这里我基本上是从列表中获取所有唯一的字符串。

最后这几行代码只是进行了一些算术运算:现在我有了所有的数字,这些数字中的最小值是多少?最大值是多少?然后 unique_words 变量告诉我原始元组中有多少个唯一的单词。
这感觉很通用,所以让我们在一些数据上运行它。这里我用一些测试数据进行了测试,然后得到了一些实际数据。我想分析的实际数据是泰勒·斯威夫特的数据,元组的整数部分代表年份,字符串部分代表她在那一年为谁写了一首歌。

有了这些数据,我可以将其插入到我上面写的这个函数中。实际上,我会注释掉这部分,这是我调用函数的地方。我用这个数据调用函数,t_swift 是这个元组的元组。我得到的是第38行的函数返回,它是一个大元组,然后我将这个大元组分配给我程序中的另一个元组,然后我只是打印出一些语句。我得到了最小年份、最大年份以及人数。我可以向你展示它是如何工作的:如果我把其中一个名字换成另一个我已经有的名字,那么她就会为四个人写歌,而不是五个人。



所以这就是元组。请记住,元组是不可变的。现在我们将看一个与元组非常相似的数据结构,叫做列表。列表与元组不同,列表是可变对象。


列表 📝



与元组类似,列表可以包含任何类型的元素或对象。你使用方括号而不是圆括号来表示列表,区别在于列表是可变对象,而不是不可变对象。

创建一个空列表,你只需使用空方括号 []。你可以有一个包含不同类型元素的列表,甚至是一个列表的列表。通常,你可以在列表上应用 len 函数,它会告诉你列表中有多少个元素。它会告诉你列表 l 中有多少个元素,但不会进一步查看。所以它会说这是一个整数,这是一个字符串,这是一个整数,这是一个列表,但不会说这个列表中有多少个元素。它只是查看元素的外壳。
索引和切片的工作方式相同。l[0] 给你值 2。你可以索引到一个列表中,然后对返回的值进行操作。例如,l[2] 是那个值,然后加 1。l[3] 将是这个列表。你不能在列表长度之外进行索引,否则会出错。你也可以在索引中使用表达式,Python 只是将 i 替换为 2,然后说 l[1] 是什么,并从那里获取。

与我们在字符串和元组上看到的操作非常相似。也许一个区别,也是我们这节课剩余部分要关注的是,列表是可变对象。
可变性意味着什么? 🔄

这意味着什么?内部上,这意味着假设我们有一个列表 l,我们将其赋值给一个变量。例如,变量 l 指向一个包含三个元素 [2, 1, 3] 的列表。当我们处理元组或字符串时,如果我们尝试执行 l[1] = 5 这行代码,我们会得到一个错误。但对于列表,这实际上是允许的。当你执行那行代码时,Python 将查看中间的那个元素,并将其值从 1 更改为 5。这只是由于列表的可变性。

现在注意,这个列表变量,变量 l,最初指向这个列表,现在仍然指向完全相同的列表。我们并没有在内存中创建一个新对象,我们只是在修改内存中的同一个对象。当你看到可能发生的副作用时,你会明白为什么这很重要。

我之前说过几次,但如果你尝试直接遍历列表元素,生活会容易得多。这是一种更“Pythonic”的方式。这是一种常见的模式,你将看到直接遍历列表元素。我们在元组和字符串上都这样做过。这些是相同的代码,它们做完全相同的事情,除了在左边,你遍历 0, 1, 2, 3 等等,然后索引到每个数字以获取元素值;而在右边,这个循环变量 i 将直接拥有元素值本身。所以右边的代码更简洁。


列表操作 🛠️

现在让我们看看可以对列表进行的一些操作。由于列表的可变性,我们可以对列表进行比元组或字符串更多的操作。

以下是一些操作,它们将利用可变性概念。我们可以使用这种看起来有点奇怪的符号 l.append() 直接将元素添加到列表的末尾。这个操作会改变列表。例如,如果 l = [2, 1, 3],然后我追加元素 5 到末尾,那么同一个 l 将指向同一个对象,只是末尾多了一个数字 5。


这个点 . 是什么?我们以前没有真正见过这个。在几节课后,它的含义会变得明显,但目前你可以把它看作一个操作。这就像应用一个函数,只是你应用的函数只能对某些类型的对象工作。在这种情况下,append 是我们试图应用的函数,我们想将它应用到点之前的东西,也就是对象。append 只被定义为对列表对象工作,这就是为什么我们在这里使用点。例如,我们不能在整数上使用 append,因为这种函数没有在整数上定义。目前,你必须记住哪些函数与点一起使用,哪些函数像 len 那样不与点一起使用。但我保证,在几节课后,这会清晰得多。目前,只需将点之前的东西视为你正在应用函数的对象,点之后的东西视为你应用于对象的函数。

我们还可以使用加号运算符组合列表。加号运算符不会改变列表,相反,它给你一个新的列表,是那两个列表的组合。例如,如果 l1 = [2, 1, 3],l2 = [4, 5, 6],当我们将这两个列表相加时,会得到一个全新的列表,而 l1 和 l2 保持不变。这就是为什么我们必须将加法的结果赋值给一个新列表,否则结果会丢失。
如果你想直接改变一个列表,使其通过另一个列表中的元素变长,那么你可以使用 extend 函数或方法。这将直接改变 l1。例如,如果 l1 = [2, 1, 3],你用列表 [0, 6] 扩展它,它就会直接将 0 和 6 附加到 l1 上。


从列表中删除元素 🗑️
我们也可以从列表中删除元素。我们不想一直向列表中添加元素,因为那样它们会变得非常大。让我们看看如何从列表中删除一些项目。



有几种方法。第一种是使用 del 函数。del l[索引] 表示从列表 l 中删除指定索引处的元素。你可以给出索引 0, 1, 2 或任何你想删除元素的索引。

如果你只想删除列表末尾的元素,也就是最右边的元素,你可以使用 l.pop()。如果你想删除一个特定的元素,例如你知道列表中某处有数字 5,你想从列表中删除它,那么你可以使用 l.remove(5),这只删除它的第一次出现。所以如果你的列表中有两个 5,它只会删除第一个。

让我们看一下这一系列命令。首先 l 等于这个长列表。我想提一下,所有这些操作都会改变我们的列表,这就是为什么我在这里写了这个注释,假设你是按顺序执行这些操作的。当你按顺序执行时,你将改变你的列表,如果你在改变列表,你必须记住你正在使用这个新改变的列表。

我们要做的第一件事是从列表中删除 2。当你删除 2 时,它会查找值为 2 的元素并将其从列表中移除。这是第一个 2,所以我们剩下的列表就是它之后的所有内容。然后我想从列表中删除 3。注意有两个 3:这里一个,这里一个。我们只删除第一个,也就是这个。所以我们剩下的列表是 [1, 6, 3, 7, 0]。然后我们将删除列表 l 中位置 1 的元素。从 0 开始计数,位置 1 的元素是这个,所以我们删除了它,剩下 [1, 3, 7, 0]。然后当我们执行 l.pop() 时,它将删除最右边的元素,即列表末尾的 0。然后我们只剩下 [1, 3, 7]。


l.pop() 通常很有用,因为它返回被删除的值。在这种情况下,它将返回 0。但我想提一下,这些函数都会改变列表,你必须小心返回值。你可以把这些都看作操作列表的函数,只是这些函数接收列表并修改它。但作为函数,它们显然会向调用者返回一些东西,而且通常它们会返回值 None。例如,如果你执行 l.remove(2) 并打印出来,可能会为你打印出 None。所以你不能只是将这个值赋给一个变量并期望它是改变后的列表。被改变的列表是传入这里的列表。我们将在几页幻灯片后看一个例子来说明这一点。


列表与字符串的转换 🔄

另一件我们经常在处理数据时做的事情是将字符串转换为列表,以及将列表转换为字符串。有时将字符串作为列表处理,或者反之,可能很有用。


第一行 list(s) 接收一个字符串并将其转换为列表。就像我们将浮点数转换为整数一样,你只是在这里将字符串转换为列表。当你这样做时,如果这是你的字符串 s,那么 list(s) 将给你一个这样的列表,其中 s 中的每个字符都将成为自己的元素。这意味着每个字符都将是一个字符串,并且将被分隔开。

有时你不希望列表中的每个字符都是自己的元素。例如,如果你有一个句子,你可能希望空格之间的所有内容成为自己的元素,这样就会给你句子中的每个单词。在这种情况下,你将使用 split。在这个例子中,我根据小于号进行分割,但如果你处理句子,你可能希望根据空格分割。这将根据你感兴趣的符号(在这种情况下是小于号)之间的所有内容,将其设置为列表中的单独元素。这就是如何将字符串转换为列表。

有时你有一个列表,可能想将其转换为字符串。这时 join 方法或函数就很有用。这是一个空字符串,所以它只是直接连接列表中的每个元素,返回字符串 "abc"。然后你可以在任何你想要的字符上连接。在这种情况下,你可以在下划线上连接。所以你可以在这里放任何字符,放在列表中每个元素之间。这是非常有用的函数。


列表排序与反转 🔀


我们可以对列表进行的其他一些操作也很有用,比如对列表排序和反转列表,Python 文档中还有许多其他操作。sort 和 sorted 都对列表排序,但其中一个会改变列表,另一个不会。有时使用其中一个很有用,有时使用另一个很有用。


如果我有这个列表 l = [9, 6, 0, 3],sorted 可以看作“给我 l 的排序版本”,它返回列表的排序版本,但不会改变原列表。所以它保持原列表不变。这将替换为列表的排序版本,你可以将其赋值给一个变量,然后做任何你想做的事情,比如 l2 = sorted(l)。它保持原列表不变。



另一方面,如果你只想改变 l,并且不关心获得另一个排序副本,

课程 P18:L5.2 - 元组 🧩

以下内容基于知识共享许可协议提供。您的支持将帮助 MIT OpenCourseWare 继续免费提供高质量的教育资源。如需捐款或查看来自数百门 MIT 课程的其他材料,请访问相关网站。


概述
在本节课中,我们将通过一个具体的代码示例来学习元组(tuple)的基本概念和操作。我们将分析一个名为 always_sunny 的函数,理解元组与字符串在定义和索引上的区别,并逐步推导代码的执行结果。
代码分析
我们有一个名为 always_sunny 的函数,它接收两个变量 t1 和 t2。我们使用参数 cloudy 和 cold 来调用这个函数。
def always_sunny(t1, t2):
# 函数体
当进行函数调用时,t1 被赋值为字符串 "cloudy",t2 被赋值为元组 ("cold",)。这里的关键在于,代码中 t2 的赋值形式是 cold,,根据 Python 语法,末尾的逗号会将单个元素定义为元组,而没有逗号的 cloudy 则是一个字符串。
所以初始赋值如下:
t1 = "cloudy"(字符串)t2 = ("cold",)(元组)
变量赋值与索引
接下来,代码执行 sun = ("sunny",)。这同样定义了一个包含单个字符串 "sunny" 的元组。
然后,代码计算变量 first 的值:
first = t1[0] + t2[0]
我们来分解这个表达式:
t1[0]:由于t1是字符串"cloudy",索引[0]获取其第一个字符,即"c"。t2[0]:由于t2是元组("cold",),索引[0]获取其第一个(也是唯一一个)元素,即字符串"cold"。- 将两者使用
+连接,first的结果是字符串"ccold"。
返回值
最后,函数返回一个元组:
return (sun[0], first)
这个返回的元组包含两个元素:
sun[0]:从元组sun中获取第一个元素,即字符串"sunny"。first:即我们上一步计算出的字符串"ccold"。
因此,函数的最终返回值是元组 ("sunny", "ccold")。
核心概念总结

本节课中我们一起学习了元组的关键特性:
- 元组的定义:在 Python 中,逗号是创建元组的关键,括号通常可省略。例如
t = 1,或t = (1,)创建单元素元组,而t = 1只是一个整数。 - 字符串与元组的区别:字符串是字符序列,用引号定义;元组是任意对象的序列,用逗号定义。它们的索引操作
[0]行为一致,都是获取第一个元素。 - 类型的重要性:理解变量是字符串还是元组,对预测代码行为至关重要。在本例中,
t1和t2初始类型的差异直接影响了first变量的计算结果。
通过这个例子,我们巩固了对元组基本语法和操作的理解。记住逗号在定义元组时的作用,就能准确区分元组和其他数据类型。

课程 P19:L5.3 - 简单列表操作详解 🧾
在本节课中,我们将学习如何通过循环遍历列表,并根据条件修改列表中的元素。我们将通过一个具体的例子,详细解析每一步的操作过程,帮助你理解列表在循环中的动态变化。
概述 📋

本节教程将展示一个对列表进行遍历和修改的完整过程。我们将从一个初始列表开始,通过循环逐个检查其元素,并在满足特定条件时修改列表对应位置的值。这个过程将清晰地展示列表索引、元素访问以及条件判断在编程中的实际应用。
初始列表与循环结构
首先,我们有一个初始列表 L,其内容如下:
L = ["life", "answer", 42, 0]
在这个例子中,列表包含了字符串 "life"、字符串 "answer"、数字 42 和数字 0。数字 42 是一个有趣的彩蛋,它来源于科幻作品《银河系漫游指南》。
接下来,我们编写一个循环来直接遍历列表 L 中的每一个元素:
for thing in L:
# 循环体内的操作
在循环的每一次迭代中,变量 thing 会依次代表列表中的一个元素。第一次迭代时,thing 是 "life";第二次是 "answer";第三次是 42;第四次是 0。
循环内的条件判断与修改
现在,我们来看看循环内部的具体操作。代码结构如下:
for thing in L:
if thing == 0:
L[thing] = "universe"
else:
L[1] = "everything"
循环体内包含一个条件判断。它的逻辑是:如果当前元素 thing 等于 0,则执行一个操作;否则,执行另一个操作。
让我们一步步跟踪这个循环的执行过程:
-
第一次迭代:
thing = "life"- 条件
thing == 0为False(因为"life"是字符串,不等于数字 0)。 - 因此执行
else分支:L[1] = "everything"。 - 此时,列表
L变为:["life", "everything", 42, 0]。位置 1(即第二个元素)被修改为"everything"。
- 条件
-
第二次迭代:
thing = "answer"(注意:此时thing取的是原始列表中第二个位置的值,即修改前的"answer")- 条件
thing == 0为False。 - 执行
else分支:L[1] = "everything"。 - 列表
L再次被修改,但位置 1 的值已经是"everything",所以结果不变:["life", "everything", 42, 0]。
- 条件
-
第三次迭代:
thing = 42- 条件
thing == 0为False。 - 执行
else分支:L[1] = "everything"。 - 列表
L保持不变:["life", "everything", 42, 0]。
- 条件
-
第四次迭代:
thing = 0- 条件
thing == 0为True。 - 执行
if分支:L[thing] = "universe"。由于thing的值是0,所以这行代码等同于L[0] = "universe"。 - 此时,列表
L的第一个元素(位置 0)被修改。列表最终变为:["universe", "everything", 42, 0]。
- 条件
关键操作解析
上一节我们逐步跟踪了循环,本节中我们重点分析代码中的两个关键修改操作:
以下是修改操作的详细说明:
- 操作
L[1] = "everything":此操作直接将列表L中索引为 1 的元素(即第二个元素)的值更改为字符串"everything"。无论thing是什么值,只要不满足thing == 0,就会执行此操作。 - 操作
L[thing] = "universe":此操作使用当前元素thing的值作为索引来修改列表。仅当thing == 0为真时执行。此时,thing的值为0,因此它修改的是L[0],即列表的第一个元素。
最终结果总结
本节课中我们一起学习了如何遍历列表并基于条件修改其元素。通过上面的逐步分析,我们可以清晰地看到列表 L 的完整变化轨迹:
- 初始列表:
["life", "answer", 42, 0] - 第一次迭代后:
["life", "everything", 42, 0] - 第二次迭代后:
["life", "everything", 42, 0](无变化) - 第三次迭代后:
["life", "everything", 42, 0](无变化) - 第四次迭代后:
["universe", "everything", 42, 0]
因此,整个代码段运行结束后,列表 L 的最终状态是 ["universe", "everything", 42, 0]。

这个例子演示了在循环中直接修改正在遍历的列表时需要特别注意执行顺序和索引变化,否则可能会得到与直觉不符的结果。理解每一步的状态变化是掌握列表操作的关键。

课程 P2:L1.2 - Shell 与编辑器 🖥️

以下内容基于知识共享许可协议提供。您的支持将帮助 MIT OpenCourseWare 继续免费提供高质量的教育资源。如需捐款或查看来自数百门 MIT 课程的其他材料,请访问相关网站。
在本节课中,我们将学习如何通过 Shell 和编辑器来运行和测试代码,并通过一个具体的例子来理解代码执行与输出的关系。

代码执行与输出示例
上一节我们介绍了编程环境的基本概念,本节中我们来看看一个具体的代码示例及其输出结果。

问题是:如果你有以下两行代码(我将它们放大显示以便更清楚):
type(5)
print(3.0 - 1)
输出将会是什么?
如果我在编辑器中运行这段代码,对于这类问题,你总是可以自己进行验证。这引出了一个要点:如果你是编程新手,不要害怕尝试。不要问我,也不要问你的邻居,只需将其输入到 Shell 中并执行,答案就会揭晓。
所以,这个问题的答案将是 2.0。让我们看看我们是否正确。是的,很好,75% 的人答对了。
如果你没有答对,这里再次解释一下原因:type(5) 这一行之所以没有打印出任何内容,是因为我们实际上从未使用 print 语句来输出它。


如果你想在屏幕上显示某些内容,你必须明确地使用 print 函数。
核心要点总结
本节课中我们一起学习了如何通过 Shell 直接测试代码片段,并理解了 print 函数在输出结果中的关键作用。记住,实践是学习编程的最佳方式,不要犹豫,大胆尝试运行你的代码。

课程 P20:L5.4 - 列表操作 🧩

在本节课中,我们将学习Python中列表的基本操作,包括列表的拼接、扩展、排序和删除元素。我们会通过一个具体的例子,一步步分析代码的执行过程,以理解列表是如何被修改的。

初始列表定义
首先,我们定义了四个列表:
l1 = ['Ray']l2 = ['Me']l3 = ['Doh']l4 = ['Ray', 'Me']
列表拼接与扩展
上一节我们定义了初始列表,本节中我们来看看如何操作它们。
以下是列表的拼接操作:
l1 + l2会创建一个新的列表,其结果为['Ray', 'Me']。这个操作不会改变l1或l2。
接着,我们使用 extend 方法:
l3.extend(l4)会修改(mutate)l3列表。l3最初是['Doh'],被l4(即['Ray', 'Me'])扩展后,变为['Doh', 'Ray', 'Me']。原始的l3列表已经不存在了。
列表排序与删除
在列表被扩展之后,我们继续对其进行排序和删除操作。
以下是排序操作:
l3.sort()会修改l3列表,将其元素按字母顺序排列。['Doh', 'Ray', 'Me']排序后变为['Doh', 'Me', 'Ray']。同样,排序前的l3版本被覆盖。
接着是删除操作:
del l3[0]会修改l3列表,删除索引为0的元素。因此,['Doh', 'Me', 'Ray']在删除'Doh'后,变为['Me', 'Ray']。
列表追加操作

最后,我们对列表进行追加操作。
以下是追加操作:
l3.append(['Fa', 'La'])会修改l3列表。注意,append方法是将整个参数作为一个元素添加到列表末尾。因此,当前的l3(即['Me', 'Ray'])会变为['Me', 'Ray', ['Fa', 'La']]。最终结果是一个包含两个字符串和一个子列表的列表。
总结

本节课中我们一起学习了Python列表的几种关键操作。我们了解到 + 操作符用于拼接并生成新列表,而 extend()、sort()、del 和 append() 方法则会直接修改原始列表。通过逐步追踪代码,我们清晰地看到了列表在每一步操作后的状态变化,这对于理解列表的可变性(mutability)至关重要。

课程 P21:L5.5 - 列表重命名与元素更改 🧾

以下内容基于知识共享许可协议提供。您的支持将帮助 MIT OpenCourseWare 继续免费提供高质量的教育资源。如需捐款或查看来自数百门 MIT 课程的其他材料,请访问相关网站。
在本节课中,我们将学习 Python 中列表的别名现象以及如何通过方法修改列表元素。理解这些概念对于掌握列表的可变性至关重要。


首先,我们创建两个列表。第一个列表 L1 包含 'bacon' 和 'eggs'。第二个列表 L2 包含 'toast' 和 'jam'。
L1 = ['bacon', 'eggs']
L2 = ['toast', 'jam']
接着,我们创建一个名为 brunch 的新变量,并让它等于 L1。这被称为“别名”,意味着 brunch 将指向 L1 所指向的同一个列表对象。
brunch = L1
现在,我们通过 append 方法修改 L1,为其添加一个新元素 'juice'。L1 现在变成了 ['bacon', 'eggs', 'juice']。
L1.append('juice')
由于 brunch 是 L1 的别名,它们指向同一个列表对象。因此,当我们修改 L1 时,brunch 所看到的内容也随之改变。此时,brunch 的值也是 ['bacon', 'eggs', 'juice']。


接下来,我们对 brunch 使用 extend 方法,将 L2 中的所有元素添加到 brunch 列表的末尾。
brunch.extend(L2)
执行此操作后,brunch 列表(也就是 L1 所指向的列表)将包含五个元素:'bacon', 'eggs', 'juice', 'toast', 'jam'。
这个例子清晰地展示了由别名引起的“副作用”问题:因为 brunch 和 L1 指向同一个对象,所以通过任何一个变量对列表进行的修改,都会影响到另一个变量。
本节课中我们一起学习了列表的别名赋值以及如何使用 append 和 extend 方法修改列表。关键点在于理解多个变量可以指向同一个可变对象,对对象的修改会通过所有引用它的变量反映出来。



课程 P22:L6 - 递归与字典 🧠📚
在本节课中,我们将要学习两个核心概念:递归和字典。递归是一种通过将问题分解为更小的相同问题来解决问题的强大编程技术。字典则是Python中一种灵活的数据结构,允许我们使用键值对来存储和访问数据。我们将通过多个实例来理解它们的工作原理和应用方式。
递归:什么是递归? 🤔
上一节我们介绍了课程的整体目标,本节中我们来看看递归的具体含义。
从抽象或算法层面看,递归通常被称为“分而治之”或“减而治之”。其核心思想是:将一个待解决的问题,简化为一个更简单的相同问题,再加上一些已知的、可以直接处理的部分。然后,对这个更简单的问题重复此过程,直到达到一个可以直接解决的简单情况(称为基例)。
从语义或编程层面看,递归通常表现为一个函数在其定义体内调用自身。只要我们能确保存在一个或多个易于解决的基例,并且每次递归调用都向基例靠近,就可以避免无限递归。
递归示例:整数乘法 ✖️
为了理解递归,我们先看一个迭代算法的例子:仅使用加法来实现整数乘法。迭代算法通常由一组状态变量来描述,这些变量记录了计算的精确状态。
以下是使用迭代方法(while循环)实现乘法的代码:
def mult_iter(a, b):
result = 0
while b > 0:
result += a
b -= 1
return result
现在,让我们用递归的视角来看待同一个问题。a * b 等价于 a 加上 a * (b-1)。这听起来像是文字游戏,但它至关重要,因为它将原问题 a * b 简化为了一个更小的相同问题 a * (b-1),再加上一个已知操作(加法)。我们可以不断重复这个过程,直到达到基例(例如 b == 1 时,答案为 a)。
以下是递归实现的代码:
def mult_recursive(a, b):
if b == 1: # 基例
return a
else: # 递归步骤
return a + mult_recursive(a, b-1)
这个递归定义清晰地将问题简化为更小的版本。
递归示例:阶乘函数 🔢
阶乘是另一个经典的递归问题。n的阶乘(n!)定义为从1到n所有正整数的乘积。
我们可以这样思考递归方案:
- 基例:当
n == 1时,1! = 1。 - 递归步骤:
n! = n * (n-1)!
根据这个思路,我们可以轻松写出递归代码:
def fact(n):
if n == 1:
return 1
else:
return n * fact(n-1)
为了理解递归调用的执行过程,我们可以跟踪函数调用栈。每次递归调用都会创建一个新的栈帧,其中包含该次调用独有的变量绑定。计算会不断“展开”,直到达到基例,然后结果会沿着调用栈“回溯”并组合,最终得到答案。
数学归纳法与递归正确性 ✅
我们如何确信递归代码是正确的?数学归纳法是一个强大的工具。要证明一个关于整数n的命题对所有n都成立,我们需要:
- 基础步骤:证明命题对最小的n(如n=0或1)成立。
- 归纳步骤:假设命题对某个任意值k成立(归纳假设),然后证明在此假设下,命题对k+1也成立。
这与递归编程的思想完全一致:
- 基础步骤对应基例,我们直接验证代码能返回正确结果。
- 归纳步骤对应递归调用,我们假设函数对更小的输入(如
n-1)能正确工作,然后验证利用这个结果能计算出n的正确结果。
通过归纳法,我们可以逻辑上证明递归函数的正确性。
递归示例:汉诺塔 🗼
汉诺塔问题是一个著名的递归应用实例。问题描述:有三根柱子,其中一根上有N个从大到小叠放的圆盘。目标是将所有圆盘移动到另一根柱子上,每次只能移动一个圆盘,且任何时候都不能将大盘子放在小盘子上。
其递归解决方案非常优雅:
- 将上面N-1个盘子从起始柱移动到辅助柱(这是一个更小的汉诺塔问题)。
- 将最大的第N个盘子从起始柱直接移动到目标柱。
- 再将那N-1个盘子从辅助柱移动到目标柱(这又是一个更小的汉诺塔问题)。
基例是当只需要移动一个盘子(N=1)时,直接移动即可。
代码如下:
def printMove(fr, to):
print('move from ' + str(fr) + ' to ' + str(to))
def Towers(n, fr, to, spare):
if n == 1:
printMove(fr, to)
else:
Towers(n-1, fr, spare, to) # 步骤1
Towers(1, fr, to, spare) # 步骤2
Towers(n-1, spare, to, fr) # 步骤3
这个例子展示了递归如何让一个看似复杂的问题变得清晰易懂。
递归示例:斐波那契数列与回文检测 📈🔤
斐波那契数列是另一个递归例子,但它有两个基例,并且递归步骤包含两个不同的递归调用:
fib(0) = 1fib(1) = 1fib(n) = fib(n-1) + fib(n-2) (n>1)
代码如下:
def fib(x):
if x == 0 or x == 1:
return 1
else:
return fib(x-1) + fib(x-2)
我们也可以对非数值数据使用递归,例如判断一个字符串是否是回文(正读反读都一样):
- 基例:长度为0或1的字符串是回文。
- 递归步骤:检查字符串首尾字符是否相同,如果相同,则递归检查去掉首尾字符后的子串是否是回文。


字典:一种灵活的数据结构 📖

现在,让我们转向另一种复合数据类型:字典。字典是可变的,它存储的是键-值对的映射关系,而不是像列表那样通过整数索引访问元素。
想象一个记录学生成绩的场景。使用列表,我们可能需要维护多个并行列表(姓名、成绩、课程),并通过索引来关联它们。字典提供了更直观的方式:我们可以直接用学生姓名(键)来查找其成绩(值)。


字典的基本操作 🛠️
以下是字典的核心操作:
创建字典:
grades = {} # 空字典
grades = {'Anna':'B', 'John':'A+', 'Denise':'A'} # 初始化字典
访问元素:
grades['John'] # 返回 'A+'
添加或修改元素:
grades['Sylvan'] = 'A' # 添加新条目
检查键是否存在:
'John' in grades # 返回 True
删除元素:
del grades['Anna']
获取所有键或值:
grades.keys() # 返回一个包含所有键的“可迭代对象”
grades.values() # 返回一个包含所有值的“可迭代对象”
关于字典需要记住的要点:
- 值可以是任何类型(可变或不可变),甚至可以重复。
- 键必须是唯一的和不可变的(如整数、浮点数、字符串、元组、布尔值)。
- 字典中的条目是无序存储的。
字典应用示例:歌词词频分析 🎵
让我们看一个结合循环和字典的实际例子:分析歌词中单词的出现频率。
思路是遍历歌词单词列表,使用字典记录每个单词出现的次数:
def lyrics_to_frequencies(lyrics):
myDict = {}
for word in lyrics:
if word in myDict: # 如果单词已在字典中
myDict[word] += 1 # 计数加1
else: # 如果单词是第一次出现
myDict[word] = 1 # 在字典中创建新条目,计数为1
return myDict
有了频率字典后,我们可以进一步分析,例如找出出现次数最多的单词:
def most_common_words(freqs):
best = max(freqs.values()) # 找到最高频率
words = []
for k in freqs: # 遍历所有键(单词)
if freqs[k] == best: # 如果该单词频率等于最高频率
words.append(k) # 将其加入列表
return (words, best) # 返回单词列表和该频率
我们还可以编写函数,找出所有出现频率超过某个阈值的单词,并在找出后将其从字典中删除,以便进行下一轮查找。
递归优化:使用字典进行记忆化(Memoization)⚡
最后,我们看看如何用字典提升递归效率。以斐波那契数列为例,朴素的递归fib(n)会进行大量重复计算(例如fib(3)会被计算多次)。
记忆化技术通过字典来存储已经计算过的结果,避免重复计算:
def fib_memo(n, memo={0:1, 1:1}): # 初始化字典存储基例
if n in memo: # 如果结果已经计算过
return memo[n] # 直接从字典返回
else: # 否则进行计算
memo[n] = fib_memo(n-1, memo) + fib_memo(n-2, memo) # 计算并存储结果
return memo[n] # 返回结果
对于fib(30),朴素递归需要超过1100万次调用,而记忆化版本仅需约60次调用,速度差异巨大。这展示了字典作为缓存工具的威力。
总结 📝

本节课中我们一起学习了:
- 递归的核心思想:将问题分解为更小的相同问题,并通过基例终止递归。我们通过阶乘、汉诺塔、斐波那契数列和回文检测等例子进行了实践。
- 字典的基本概念和操作:字典是一种可变的键值对映射数据结构,提供了基于键的高效数据访问。我们学习了如何创建、访问、修改字典,并分析了歌词词频。
- 递归与字典的结合:我们看到了如何使用字典进行记忆化,来显著优化存在重叠子问题的递归算法(如斐波那契数列),这体现了将不同编程工具结合起来的强大力量。

递归和字典是Python编程中极为重要的工具,掌握它们将帮助你更优雅、更高效地解决复杂问题。

🐛 P23:L7.1 - 测试与调试、异常处理与断言



以下内容采用知识共享许可协议。您的支持将帮助我们持续提供高质量内容。

如需捐款,或从数百门MIT课程中学习,请访问 ocw.mit.edu。





教授:好的,各位。下午好。我们开始吧。



今天的课程将讲解异常和断言。


在开始之前,我们先回到现实生活思考一下。

我以前煮过汤。或许你也煮过汤。

假设你正在煮汤。结果发现虫子从天花板上掉进汤里。向观众提个问题。

如果你遇到这种情况会怎么做?观众:[七嘴八舌]。

教授:好的。一个一个来。


有人有想法吗?观众:吃掉它。教授:吃掉它。你想吃掉它。好吧。
我们是在打比方,我不知道你会怎么做,我猜你可能会把汤端出去,然后他们会抱怨。

但是。好吧。还有什么?观众:[听不清],教授:盖上汤。这是个好建议。
你可以盖上锅盖。有时你必须打开盖子,对吧,为了检查味道、添加调料。

所以虫子可能会掉进去。但盖上锅盖可以防止这种情况。观众:调试它。教授:调试它。我希望我有东西能调试。
这是个好答案。是的。观众:清理所有东西,这样就没有——没有东西了,教授:所以清理所有东西。
这样就没有东西会掉进去了。这有点像大规模清洁。这是个好主意。

这有点像从源头上消除问题。观众:决定,并宣布它是一个特色。教授:决定。
并宣布它是一个特色。这可能是很多软件公司会做的事。酷。我希望计算机调试也这么简单。
那么我们决定了什么?我们可以检查汤。保持锅盖关闭,这样虫子进不去。清理你的厨房。
这相当于在编程中,就是直接扔掉有问题的代码。我会用拖把清理,但那样也行。所以我们可以画一些。

与计算机编程的类比。检查汤实际上就是测试。你有一锅汤,测试它。

确保没有虫子。继续。保持锅盖关闭。这有点像防御性编程的概念。
所以确保虫子不会进入。有时你不得不打开锅盖。

以确保汤的味道正确。这相当于在编程中,尝试不要有错误。
但它们可能仍然会出现。清理厨房是从源头上消除虫子。

这实际上非常困难。但你仍然可以尝试去做。好的。那么我们来谈谈。


到目前为止在60001和600课程中的情况。所以你期望,真的,你也许做一点调试,然后它就完美了。对吗?
你一下子就搞定了。但现实中你写了这段代码,然后去运行,结果出错了。对吗?
这在我身上发生过很多次。在你身上也发生过很多次。



这就是现实。好的。今天的课程将讲解测试和调试,以及你如何能更好地处理。

当你编写像这个小女孩一样的代码时。失望至极。好的。那么防御性编程的核心。
从防御性编程开始。好的。这回到了我们之前讨论过的抽象和模块化。

在函数那节课讲过。对吗?所以尝试从两个原则开始。

如果你编写代码时,为每个函数编写文档,你更有可能理解代码,以后调试起来也会。

容易得多。说到测试,一旦你写了一个函数,你仍然需要测试它。
测试的过程包括,想出输入。弄清楚预期的输出是什么。然后运行你的程序。


程序产生的输出是否与预期匹配?如果匹配,很好,你完成了。但如果不匹配,你就需要调试。


调试步骤是,找出程序为什么没有产生你预期的输出。



正如我提到的,第一步是进行防御性编程,你想为自己打下良好的基础。

这实际上来自于确保你编写的代码是模块化和文档化的。所以编写尽可能多的测试。

记录函数的功能。记录它们的约束条件。这会让你的生活更轻松,当你需要调试时。你什么时候想测试?

首先你必须确保代码没有语法错误,顺便说一下,Python会帮你检查语法错误。

一旦你确保没有语法错误,那么你就想设计测试用例。所以这是配对,输入和预期输出。
一旦你有了测试用例,你就可以开始测试了。测试有三种通用类型。
第一种叫做单元测试。


如果你写过函数,单元测试确保每个函数都按照规范工作。
所以你要多次这样做。当你测试每个函数时。在那个时候,你进行回归测试。想出一个测试用例。
并运行所有不同的函数,以确保在修复一个错误时,你不会重新引入新的错误。

你已经运行过的测试。所以你要这样做很多次。你做一点单元测试,做一点回归测试。

在某个时候,你准备好进行集成测试。这意味着,测试你的整个程序。整个程序是否正常工作?所以这是。
所有独立单元测试之后。集成测试确保不同部分之间的交互按预期工作。

如果一切正常,很好,你就完成了。



但如果不行,你可能需要回到单元测试,并修复问题。所以这实际上是一个循环过程。


那么有哪些设计测试用例的策略呢?第一个,这可能是。

涉及数字的代码,有一些自然的边界条件。抱歉。例如,如果我有一个比较 x 和 y 的代码。
那么一些自然的边界条件是,如果 x 小于 y,x 等于 y。也许加上小于等于,或大于等于,等等。

所以这只是一个直观的想法。可能你的代码没有自然的分区。在这种情况下,你可能会。
进行随机测试,你测试得越多,发现错误的可能性就越大。但实际上还有另外两种策略。
一种是黑盒测试,另一种是白盒测试。


在黑盒测试中,你只有函数的规范。那就是文档字符串。你只看规范。
想出一些测试用例。在白盒测试中,你看代码本身,你设计测试用例来覆盖代码的所有路径。

让我们看一个例子。

我正在寻找一个数的平方根,给定一个 epsilon 值。这里的想法是,根据规范给出这个函数如何工作。
想法是,你基于规范设计测试用例。


黑盒测试的好处是,无论实现者如何实现这个函数。


无论他们希望用什么方式,他们可以用二分法。


你想出的测试用例将是相同的。无论实现如何。


对于这个特定的函数,我们检查边界条件,我们可以检查一些数字。

我们可以检查无理数。所以当 epsilon 非常大,或者非常小,或者 x 非常大。

以及所有可能的情况。


重要的是,你只基于规范进行测试。白盒测试则不同,你使用代码结构来指导你的测试用例。


所以如果你有一段代码,你设计一个测试用例,该用例通过特定的输入组合,遍历代码的某条路径,那么这个测试就覆盖了那条路径。
这种方法的问题是,例如,你需要覆盖所有可能的路径。每条可能的路径。

也许代码没有遍历某条路径,或者遍历了一次,三次,四次,对吗?这可能是一个问题。


因此,在处理控制流结构时,有一些指导原则。对于分支,当你使用 if 语句时,重要的是确保覆盖所有分支。
所以确保你的测试用例经过每个部分。对于 for 循环,确保循环根本不进入、进入一次、进入多次。

对于 while 循环,类似,但要确保覆盖所有可能的退出方式。所以如果 while 循环条件为假,或者循环内部有 break 语句。
在这个例子中,我们有一个函数。这是它的规范,以及某人决定实现的代码。
一个路径完备的测试套件,是你希望覆盖每个分支的测试。所以如果 x 小于 0,返回 -1。这很好。
否则,这意味着选择 x >= 0 的情况。所以 2 和 -2。产生路径完备——产生覆盖。

但请注意,虽然我们已经遍历了这段代码,我们还没有测试 x 等于 0 的情况。



-1。所以这段代码错误地将 0 的平方根返回为 -1。所以对于白盒测试,确保你遍历了代码的所有路径。


并确保你发现了任何边界条件。在这种情况下,对于 x=0 是一个边界条件。


所以你创建了一个测试套件,很可能你发现了一个错误。现在你该怎么做?好的。
快速绕道谈谈调试的历史。


调试的历史。1947年,这台计算机。


它可以做加法、乘法、取对数等事情。所以比计算尺快。
但对于现代标准来说相当慢。一组人正在运行一个程序,该程序应该找到导弹的轨迹。
其中有一位是 Grace Hopper。他们发现程序没有正确运行。

所以他们检查了计算机的所有部件,在面板 F 的继电器 70 里,他们发现了一只蛾子。
就卡在那里。我想它已经死了。但它是一只导致短路的蛾子。

我不知道你是否能读懂,他们做了一个笔记,上面写着“第一个实际发现的错误”。

我觉得这真的很可爱。所以他们实际上是在进行物理调试。对吧。所以你不会做物理调试。
你会做一种虚拟的调试。这同样不那么有趣。但你仍然必须做。

所以调试,正如你可能已经猜到的,有点艺术性。显然你的目标是找到并修复错误。
为了实现这个目标,有一些工具可以帮助你。有一些工具内置于你的 IDE 中。

或者你一直在用的任何 IDE。我知道你们有些人一直在用 PyCharm,它很棒。print 语句也可以。

但除此之外,当你试图找出错误时,系统地思考非常重要。我想稍微谈谈。
以及你如何使用它们,Python tutor,如果你知道它,你可能无法使用它。如果你不知道如何使用它。

你不需要学习。但 print 语句是通用的,你总是可以插入它们。它们真的很好用。

所以放置 print 语句的好地方。



是在函数内部。在循环内部,例如,打印循环变量的值。函数返回什么值。
所以你可以确保函数返回正确的值。在代码的不同部分之间。我会提到你可以使用。
二分查找法进行调试。这很有趣。所以如果你取一段代码,大致找到错误的位置。
打印出你认为在那个点应该有的值。所有可能的值。如果一切如你所料。

在你代码的那个点。这意味着代码到目前为止是正确的。然而,那意味着错误在后面,对吗?
所以既然你放了一个 print 语句,并且你认为那个点之前是正确的,那么就在后面放一个 print 语句。

看看值是否符合预期。如果符合,很好。然后在更后面放一个 print 语句。这样你可以缩小范围。
到一个行或一组行,是导致问题的。所以一般的调试步骤是。



不要问“什么错了”,那是测试部分。所以你的测试用例会告诉你什么错了。调试过程是。
弄清楚错误结果是如何产生的。既然编程是一门科学,你也应该科学地调试。

所以查看所有数据。提出一个假设。也许说,哦,也许我在处理列表时索引错了。



设计一个实验来测试你的假设。然后选择一个你可以测试假设的点。所以当你调试时,你。



你会遇到错误消息。这些错误消息通常很容易理解。它们很容易修复。
例如,访问列表越界会给你索引错误。尝试转换类型,在这个例子中,会给你类型错误。访问未定义的变量。
会给你名称错误。等等。语法错误也很容易发现,如果你忘记了一个括号。



或者类似的东西。所以错误消息很有帮助。Python 解释器会告诉你错误在哪里,然后你可以修复它。
逻辑错误更难发现。逻辑错误是你将花费最多时间的地方。为此我建议。

休息一下,去吃点东西。有时候你会盯着代码看很久,坐下来仔细看一段代码。
你想解决问题。如果你查一下“橡皮鸭调试法”,这个词就出现了。这是一个实际的术语。


当程序员向一个橡皮鸭子解释他们的代码时。左边是我在向鸭子解释。你应该总是。
向别人解释你的代码,或者向任何不太懂的人解释。因为这会迫使你仔细检查代码。
当你这样做时,你可能会发现问题。我通过这种方式解决了我的问题。


所以回到基础。


快速总结一下该做和不该做的事。不要写完整个程序再测试。

我知道这很诱人,我也总是这样做。但不要这样做。因为你会引入很多错误。

并且很难隔离是哪个错误导致的。这会导致很多挫折。相反,进行单元测试。所以写一个函数。

测试它,确保它工作,然后再写下一个,等等。



做一点回归测试,做一点集成测试,这样更有系统性。这会减少你的调试时间。


如果你在更改代码,记得备份你的代码。所以如果你有一个工作版本。
不要只是修改那个版本。[听不清] 你有很多存储空间,备份一下没有坏处。
记录什么版本有效。然后复制一份,再进行修改。


这是关于测试和调试的快速介绍。课程的其余部分将讨论异常,或者你在运行程序时遇到的错误。

所以当你的函数——或者当你运行程序时,程序执行可能会停止。也许它遇到了。
一些意外情况。当这种情况发生时,程序会引发一个错误。这个错误被称为异常。
与程序预期的情况不同。所以之前提到的所有错误,都是异常的例子。

实际上有很多类型的异常,你会在。

60002 课程中了解更多。


那么我们如何处理异常呢?在 Python 中,你实际上可以。

捕获异常。所以如果你知道一段代码可能引发错误。例如,这里我正在处理用户输入。
用户真的很不可预测。你告诉他们输入一个数字,他们可能会给你他们的名字。你对此无能为力吗?还是可以?是的,可以。
所以在你的程序中,你可以将你认为可能引发错误的代码行放在一个 try 块中。所以你说 try:,然后。

放上你认为可能出错的代码。



如果这些代码行都没有引发错误,那么很好。Python 不会做任何其他事情。它把它们当作。

常规程序的一部分。但如果确实发生了错误,如果有人没有输入数字,而是输入了他们的名字。



课程 P24:L7.2 - 黑盒与白盒测试 🧪
在本节课中,我们将学习黑盒测试与白盒测试的核心概念,并通过一个具体的函数示例来理解如何设计测试用例以确保代码的正确性。
概述
我们有一个名为 is_even 的函数,其实现如下:
if n is positive and n divided by two's remainder is 0:
return true
if n is negative and divisible by 2:
return true
otherwise:
return false

我们的目标是分析这个实现,并回答关于测试集完整性和潜在错误的问题。
测试集路径完整性分析
上一节我们介绍了函数的基本实现,本节中我们来看看给定的测试集是否能够覆盖所有可能的执行路径。
测试集包含两个值:4 和 -4。
以下是分析过程:
4是正数且能被2整除,因此会执行第一个if语句并返回true。-4是负数且能被2整除,因此会执行第二个if语句并返回true。- 测试集没有包含会触发
else分支(即返回false)的用例,例如一个奇数。
因此,该测试集不是路径完整的,因为它未能覆盖所有可能的代码执行路径(缺少对 else 分支的测试)。
识别程序错误标签
在分析了测试覆盖后,我们接下来需要找出程序可能错误标记的输入值。
程序逻辑存在一个缺陷:它没有处理 n = 0 的情况。
以下是具体分析:
- 根据代码,第一个条件
n is positive对0不成立。 - 第二个条件
n is negative对0也不成立。 - 因此,输入
0会落入else分支,返回false。
然而,0 是一个偶数。所以,程序会将偶数 0 错误地标记为“非偶数”。
对于其他边界值,例如非常大的数或非常小的数(只要它们是偶数),程序逻辑仍然有效。
总结

本节课中我们一起学习了如何基于给定的代码实现进行白盒测试分析。我们首先检查了测试集的路径覆盖完整性,发现其缺失了对 else 分支的测试。接着,我们通过分析代码逻辑,识别出程序在处理输入 n = 0 时会返回错误结果,因为它既不是正数也不是负数,从而落入了返回 false 的默认分支。这个例子强调了考虑所有边界条件(特别是像 0 这样的特殊值)在测试中的重要性。

课程 P25:L7.3 - 错误处理 🐛

以下内容基于知识共享许可协议提供。您的支持将帮助 MIT OpenCourseWare 继续免费提供高质量的教育资源。如需捐款或查看来自数百门 MIT 课程的其他材料,请访问相关网站。

在本节课中,我们将学习如何识别和处理 Python 编程中常见的错误。通过分析具体的错误案例,我们将理解错误信息的结构,并学会如何修正代码。

上一节我们介绍了错误的基本概念,本节中我们来看看一个具体的错误案例。
请看这段代码:
L = 3
for I in range(length L)
print I
运行这段代码后,我们得到了一个错误信息。这个错误信息通常会告诉我们出错的文件名、行号、具体的错误代码行,以及一个错误类型描述。在这个例子中,错误类型是 TypeError。
以下是关于这个错误的几个可能原因,请判断哪个是正确的:
- A.
range的参数数量不对。 - B.
length不是一个函数。 - C.
L没有被定义。 - D.
I需要在引号内。


通过分析代码和错误信息,我们可以找到问题的根源。如果你逐一检查其他选项,会发现那些操作在 Python 中实际上是可行的,你甚至可以亲自测试一下。


本节课中我们一起学习了如何解读 Python 的错误信息。我们通过一个 TypeError 的实例,了解到错误信息会提供文件、行号和错误类型等关键信息,这对于我们调试代码至关重要。记住,仔细阅读错误描述是解决问题的第一步。

课程 P26:L7.4 - 异常处理 🛡️

以下内容基于知识共享许可协议提供。您的支持将帮助 MIT OpenCourseWare 继续免费提供高质量的教育资源。如需捐款或查看来自数百门 MIT 课程的其他材料,请访问相关网站。
在本节课中,我们将学习 Python 中的异常处理机制。异常处理是编写健壮程序的关键,它允许我们优雅地处理程序运行时可能出现的错误,而不是让程序直接崩溃。我们将通过一个具体的代码示例来理解 try、except 语句块的工作原理。

代码示例分析

下面的代码初看可能有点令人却步,但其实并不复杂。真正执行功能的部分是这里的几行代码。
try:
age = int(input("How old are you? "))
percent = round(age * 100 / 80, 1)
print(f"You've gone through {percent}% of your life.")
except ValueError:
print("Oops, you must enter a number.")
except ZeroDivisionError:
print("Divided by zero error.")
except:
print("Something went very wrong.")
这段代码的核心逻辑是:
- 从用户获取一个输入,询问年龄。
- 将输入转换为整数。
- 假设预期寿命为 80 岁,计算已度过生命的百分比:
(年龄 / 80) * 100。 - 将结果四舍五入到一位小数并打印。
由于用户输入是不可预测的,我们使用异常处理来捕获可能发生的错误。
异常捕获逻辑
上一节我们介绍了代码的整体结构,本节中我们来看看具体的异常捕获逻辑。代码中设置了三个 except 块来捕获不同类型的错误。
以下是各个 except 块的作用:
except ValueError::当用户输入的内容无法转换为整数(例如输入了字母)时触发。程序会打印 “Oops, you must enter a number.”。except ZeroDivisionError::当发生除零错误时触发。在这个特定计算中,除非分母80被改为0,否则不会触发。程序会打印 “Divided by zero error.”。except::这是一个通用的异常捕获块,会捕获所有前面未指定的其他异常。程序会打印 “Something went very wrong.”。
场景测试与分析
现在,让我们通过几个测试场景来深入理解程序的执行流程。
场景一:用户输入 20
如果用户输入字符串 "20",会发生什么?
输入被作为字符串接收,int() 函数成功将其转换为整数 20。随后计算 20 * 100 / 80 得到 25.0,四舍五入后打印 “You‘ve gone through 25.0% of your life.”。整个过程没有触发任何异常。
场景二:用户输入 "twenty"
如果用户输入的是非数字字符串 "twenty",会发生什么?
int("twenty") 转换会失败,并引发 ValueError 异常。程序流程会跳转到第一个 except ValueError: 块,执行其中的代码,打印 “Oops, you must enter a number.”。
场景三:用户输入 0
这是一个容易混淆的场景。如果用户输入 0,会发生什么?
程序会计算 0 * 100 / 80,结果是 0。这里进行的是 0 除以 80,而不是 80 除以 0,因此不会引发 ZeroDivisionError。计算正常进行,四舍五入后打印 “You‘ve gone through 0.0% of your life.”。只有当你试图用某个数除以 0 时,才会触发零除错误。

零除错误仅在尝试将某个数除以零时发生。


总结

本节课中我们一起学习了 Python 异常处理的基础知识。我们通过一个计算生命百分比的程序,实践了如何使用 try-except 语句结构来捕获和处理 ValueError 与 ZeroDivisionError 等特定异常,同时也了解了通用异常捕获块的使用。关键点在于理解异常处理能让程序更友好、更稳定地应对意外输入或运行时错误,而不是直接崩溃。记住,清晰的异常处理是高质量代码的重要组成部分。





🐍 P27:L8.1- 面向对象编程教程


在本节课中,我们将要学习面向对象编程(OOP)的核心概念。我们将了解什么是对象,如何在Python中定义自己的对象类型(类),以及如何通过属性和方法与这些对象进行交互。课程内容将涵盖从基本概念到实际代码实现的完整流程,并通过坐标(Coordinate)和分数(Fraction)两个具体示例来加深理解。






🧱 什么是对象?


在Python中,一切皆对象。我们之前见过的整数、浮点数、字符串、列表和字典都是对象。每个对象都有三个关键部分:
- 类型:标识对象的种类,例如整数、列表。
- 数据表示:Python在内部如何存储和表示这个对象。
- 接口:我们可以通过哪些方式与这个对象进行交互(例如,对列表使用
.append()方法)。



面向对象编程的核心思想就是将数据(表示)和对数据的操作(方法)捆绑在一起,形成一个独立的“包”,即对象。




🏗️ 创建自定义类型:类

上一节我们介绍了对象的基本概念,本节中我们来看看如何创建自己的对象类型,这需要通过定义 类 来实现。
类是创建对象的蓝图。定义类时,我们需要决定两件事:
- 数据属性:对象由哪些数据构成。
- 方法:可以对对象进行哪些操作。


以下是我们定义一个简单 Coordinate(坐标)类的开始:

class Coordinate(object):
def __init__(self, x, y):
self.x = x
self.y = y



class Coordinate(object):声明一个名为Coordinate的新类,它继承自最基本的object类型。__init__是一个特殊方法,在创建类的新实例(对象)时自动调用。它用于初始化对象的数据属性。self参数代表正在创建的实例对象本身。通过self.x和self.y,我们将传入的x和y值绑定为该实例的属性。







🎯 使用类创建对象



定义好类之后,我们就可以像使用内置类型一样使用它来创建对象,这称为 实例化。

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



Coordinate(3, 4)会调用__init__方法,self被自动设置为新对象c,x参数为3,y参数为4。- 我们可以使用点号
.来访问对象的属性:


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



🤝 定义方法:让对象“做事”



仅有数据属性还不够,我们需要定义方法来描述对象的行为。方法是属于类的函数。

让我们为 Coordinate 类添加一个计算两点间距离的方法:


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

distance方法的第一个参数永远是self,代表调用该方法的实例对象。other参数代表另一个坐标对象。- 在方法内部,我们通过
self.x和other.x来访问不同对象的数据属性。


调用这个方法:
c = Coordinate(3, 4)
zero = Coordinate(0, 0)
print(c.distance(zero)) # 输出:5.0
# 等价于:
print(Coordinate.distance(c, zero)) # 输出:5.0






📝 特殊方法:__str__


当我们尝试打印一个自定义类的对象时,Python默认的输出信息可读性很差。我们可以定义 __str__ 这个特殊方法来定制打印内容。


class Coordinate(object):
# ... __init__ 和 distance 方法 ...
def __str__(self):
return "<" + str(self.x) + "," + str(self.y) + ">"

现在,打印坐标对象会得到更友好的输出:


c = Coordinate(3, 4)
print(c) # 输出:<3,4>







➕ 更多特殊方法:实现运算符重载




Python允许我们通过实现特殊方法来定义对象之间如何使用标准运算符(如 +, -, ==)进行交互。





让我们通过一个更复杂的 Fraction(分数)类来演示:


class Fraction(object):
def __init__(self, num, denom):
self.numerator = num
self.denominator = denom
def __str__(self):
return str(self.numerator) + "/" + str(self.denominator)
def __add__(self, other):
new_num = self.numerator * other.denominator + self.denominator * other.numerator
new_den = self.denominator * other.denominator
return Fraction(new_num, new_den)
def __float__(self):
return self.numerator / self.denominator


__add__定义了+运算符的行为。__float__定义了当使用float()函数转换该对象时的行为。


使用这个类:
a = Fraction(1, 4)
b = Fraction(3, 4)
c = a + b # 调用 __add__,c 是一个新的 Fraction 对象
print(c) # 输出:16/16
print(float(c)) # 调用 __float__,输出:1.0


📚 总结

本节课中我们一起学习了面向对象编程的基础知识。我们了解到:
- 对象将 数据 和 操作 封装在一起。
- 类 是创建对象的蓝图,通过
class关键字定义。 __init__方法用于初始化新对象的状态(数据属性)。- 方法 是定义在类中的函数,用于描述对象的行为,其第一个参数通常是
self。 - 特殊方法如
__str__和__add__允许我们定制对象的打印输出和运算符行为。


面向对象编程通过这种封装和抽象,帮助我们构建更模块化、可重用和易于维护的代码。

🚗 Python课程 P28:L8.2 - 类的定义

以下内容基于知识共享许可协议提供。您的支持将帮助MIT OpenCourseWare继续免费提供高质量的教育资源。如需捐款或查看来自数百门MIT课程的其他材料,请访问相关网站。
在本节课中,我们将学习如何在Python中定义一个类。我们将通过一个具体的例子来理解什么是有效的类定义,并区分正确的定义与不合适的定义。

上一节我们介绍了面向对象编程的基本概念。本节中,我们来看看如何具体定义一个类。

以下哪个是用于表示“汽车”的、良好且有效的类定义?
以下是四个选项:
class Car(object):def Car(object):class Car():class a():
大多数人都答对了,正确答案是标红的那个,即 class Car(object):。这个定义非常完美。
让我们逐一分析其他选项为何不合适:
def Car(object):这个语句定义的是一个函数,而不是一个类。class Car():这个语句虽然定义了一个类,但括号内为空,在Python 3中虽然有效,但明确继承自object是更清晰、更符合旧版本兼容性的写法。class a():这个语句也定义了一个类,但类名a完全不具备描述性,不是一个好的命名。
因此,正确定义一个表示汽车的类,应该使用 class Car(object): 这样的语法结构。


本节课中我们一起学习了Python中类的定义方法。我们了解到,定义类需要使用 class 关键字,后跟一个具有描述性的类名,并通常指定其继承自 object 基类。同时,我们区分了类定义与函数定义的区别,并强调了为类选择有意义名称的重要性。

🚗 Python课程 P29:L8.3 - 类的实例
在本节课中,我们将学习如何根据类定义创建具体的对象实例,并理解初始化方法 __init__ 中参数与对象属性之间的关系。

上一节我们介绍了类的定义,本节中我们来看看如何创建类的实例。
以下是根据提供的类定义创建新 Car 对象的正确方法。
class Car:
def __init__(self, W, D):
self.wheels = W
self.doors = D
self.color = ""
要创建一个新的 Car 对象,需要调用类名并传入 __init__ 方法中除 self 外的所有参数。
以下是创建新 Car 对象的步骤说明。
- 调用类名
Car。 - 传入第一个参数
4,对应__init__方法中的W参数,它将被赋值给对象的wheels属性。 - 传入第二个参数
2,对应__init__方法中的D参数,它将被赋值给对象的doors属性。
因此,创建具有四个轮子和两扇门的新 Car 对象的正确代码是:
my_car = Car(4, 2)


本节课中我们一起学习了如何根据类定义实例化对象。我们明确了在调用类创建实例时,需要提供的参数与 __init__ 方法中定义的参数(除 self 外)一一对应,这些参数值会在初始化过程中被赋给对象的数据属性。

课程P3:L1.3 - Python与数学 🐍➗

以下内容基于知识共享许可协议提供。您的支持将帮助MIT OpenCourseWare继续免费提供高质量的教育资源。如需捐款或查看来自数百门MIT课程的其他材料,请访问相关网站。
在本节课中,我们将通过一个具体的练习,来学习Python中变量赋值的基本规则,并澄清Python语法与数学表达式之间的一个重要区别。
理解Python的赋值语句

上一节我们介绍了Python的基本运算,本节中我们来看看如何将计算结果存储起来,这就要用到赋值语句。在Python中,赋值语句的左侧必须是一个变量名,右侧是一个表达式。其核心形式可以用以下代码描述:
变量名 = 表达式
接下来,我们将通过分析几个具体的例子,来巩固对这一规则的理解。
练习:判断合法的Python语句
以下是一个练习,要求判断哪些是Python中允许的赋值语句。你可以将这些代码输入Anaconda(我们课程使用的集成开发环境)中,亲自验证它们是否能正常运行。
以下是具体的选项分析:
-
X + y = 2
- 这个语句不正确。因为等号左侧是一个表达式(
X + y),而不是一个单一的变量名。在Python中,不能给一个表达式直接赋值。
- 这个语句不正确。因为等号左侧是一个表达式(
-
2 = X + y
- 这个语句不正确。因为等号左侧是字面量数字
2,它不是一个有效的变量名。赋值操作只能将值存储到变量中,而不能存储到一个固定的数值里。
- 这个语句不正确。因为等号左侧是字面量数字
-
XY = 2
- 这个语句是正确的。
XY在这里被解释为一个单一的变量名,而不是数学中X乘以Y的含义。在Python中,当两个字母紧挨在一起时,它们构成一个变量名(例如XY,score,name)。乘法必须使用明确的*运算符,例如X * Y。
- 这个语句是正确的。
所以,在上述选项中,只有最后一个 XY = 2 是合法的Python赋值语句。

核心概念澄清:变量命名与数学乘法的区别
最后,我们再次强调这个关键点,以确保大家理解清楚:在Python中,XY 并不像在数学中那样表示 x 乘以 y。它被解释为一个名为“XY”的变量。数学中的乘法在Python中必须使用星号 * 明确表示,即 X * Y。

本节课中我们一起学习了Python赋值语句的基本规则,重点区分了变量命名(如XY)与数学乘法运算(X*Y)在语法上的不同。记住,赋值号=的左边必须是一个变量名,这是写出正确Python代码的基础。

🚗 Python课程 P30:L8.4 - 类方法

以下内容基于知识共享许可协议提供。您的支持将帮助MIT OpenCourseWare继续免费提供高质量的教育资源。如需捐款或查看来自数百门MIT课程的其他材料,请访问相关网站。


上一节我们介绍了类的定义和实例的创建。本节中,我们来看看如何为类添加一个能够修改实例属性的方法。
我们被提供了以下Car类的定义,这个定义在之前的幻灯片中已经见过。现在,我想添加一个方法来改变汽车的颜色。以下是四个选项,看起来大家正在逐渐掌握要领,这很棒。
为了定义一个能改变汽车颜色的方法,我们需要知道self必须是第一个参数。因此,我们可以立即排除选项A和C。现在,选择就在B和D之间。
请记住,我们必须清楚要访问的是谁的数据属性。在这个例子中,我们想要改变一个特定汽车实例的颜色。因此,我们必须使用self.color来引用该实例的color属性,而不是仅仅使用color。

如果我们只写color,那么color将仅仅指向一个局部变量或全局变量,而不是实例的属性。这不会达到修改特定实例颜色的目的。

以下是定义此类方法的正确方式:

def change_color(self, new_color):
self.color = new_color
在这个方法中:
self参数代表调用该方法的实例本身。new_color是传入的新颜色值。self.color = new_color这行代码将实例的color属性更新为新的值。

本节课中我们一起学习了如何为类定义实例方法,特别是如何正确地使用self参数来访问和修改特定实例的属性。理解self的指向是编写有效类方法的关键。

课程 P31:L8.5 - 方法调用 🚗
在本节课中,我们将学习如何在面向对象编程中调用方法。我们将通过一个具体的例子,理解如何正确地使用对象来调用其方法,并改变对象的属性。
上一节我们介绍了类的定义和方法的基本概念。本节中,我们来看看如何实际调用一个对象的方法。

假设我们有一个 Car 类,其定义如下:
class Car:
def __init__(self, wheels, doors):
self.wheels = wheels
self.doors = doors
self.color = "unknown"
def paint(self, new_color):
self.color = new_color
这个类包含一个初始化方法 __init__ 和一个用于改变颜色的 paint 方法。
现在,我们使用以下代码创建了一个 Car 对象:
my_car = Car(4, 2)
这行代码初始化了一辆有4个轮子和2扇门的汽车,其初始颜色为 "unknown"。
问题是:哪一行代码可以将汽车的颜色从初始值改为红色?
以下是几个选项:
Car.paint("red")my_car.paint(red)my_car.paint("red")my_car.paint(self, "red")
让我们逐一分析这些选项。
第一个选项 Car.paint("red") 试图使用类名直接调用方法,就像幻灯片右侧展示的那样。但这种方法缺少了 self 参数,我们不知道要对哪个对象执行操作,因此这个选项不正确。
第二个选项 my_car.paint(red) 看起来更接近,因为它是在对象 my_car 上调用方法。然而,这里的 red 被当作一个变量名,而不是表示颜色的字符串 "red"。如果之前没有定义名为 red 的变量,这行代码将无法工作。
第三个选项 my_car.paint("red") 是正确的。它正确地调用了 my_car 对象的 paint 方法,并传递了字符串 "red" 作为参数,这会将汽车的 color 属性设置为红色。
第四个选项 my_car.paint(self, "red") 是错误的。当我们使用 对象名.方法名() 的格式调用方法时,Python 会自动将 self 参数绑定到该对象上。因此,在调用时显式地传递 self 参数是不必要且不正确的。


本节课中我们一起学习了如何正确地调用对象的方法。关键在于理解 self 参数的隐式传递机制,以及确保传递给方法的参数类型和数量都正确。记住,通常我们使用 对象.方法(参数) 的格式来调用方法。

🚗 Python课程 P32:L8.6 - 特殊函数(方法)

以下内容基于知识共享许可协议提供。您的支持将帮助MIT OpenCourseWare继续免费提供高质量的教育资源。如需捐款或查看来自数百门MIT课程的其他材料,请访问相关网站。


概述
在本节课中,我们将学习如何为自定义类实现一个特殊的函数(方法),即 __eq__ 方法。这个方法用于定义两个对象之间“相等”的比较逻辑。我们将通过一个“汽车”类的例子,来具体说明如何实现和使用它。
实现 __eq__ 方法
我们已经完成了类的其他部分,现在要添加这个特殊函数。我们实现的是 __eq__ 方法。实现这个方法将允许我们使用 == 运算符来比较两个自定义类的对象。
我决定比较两种汽车类型的方式是:如果两辆汽车具有相同的轮子数量、相同的颜色和相同的车门数量,那么它们就是相等的。
以下是实现逻辑:如果所有这些属性都相等,则返回 True,否则返回 False。
在具体的程序中,我创建了一辆有四个轮子、两个门的汽车,并将其颜色改为红色。接着,我创建了另一辆有四个轮子、两个门的汽车。默认情况下,这辆新车(你的车)的颜色是空字符串,因为这是新车的初始化方式。
因此,我的车和你的车之间的区别在于颜色。它们的轮子数量和车门数量是相同的。由于我在代码中实现了 __eq__ 方法,使用 == 进行比较时不会抛出错误。程序会比较轮子数量(4与4,匹配),比较车门数量(2与2,匹配),然后比较颜色(不匹配),所以最终会返回 False。
代码示例
以下是实现 __eq__ 方法的代码示例:
class Car:
def __init__(self, wheels, doors, color=""):
self.wheels = wheels
self.doors = doors
self.color = color
def __eq__(self, other):
# 比较轮子数量、车门数量和颜色
if self.wheels == other.wheels and self.doors == other.doors and self.color == other.color:
return True
else:
return False
# 创建两个Car对象进行测试
my_car = Car(4, 2)
my_car.color = "red"
your_car = Car(4, 2) # 颜色默认为空字符串
# 使用 == 进行比较
print(my_car == your_car) # 输出: False
总结

本节课中,我们一起学习了如何为自定义类实现 __eq__ 特殊方法。通过定义这个方法,我们可以自定义两个对象使用 == 运算符进行比较时的行为。我们以“汽车”类为例,展示了如何通过比较轮子数量、车门数量和颜色来判断两辆汽车是否相等。掌握特殊方法的使用,能让你的类更加灵活和强大。



🐍 P33:L9.1 - Python类与继承教程


在本节课中,我们将要学习Python面向对象编程的核心概念,包括类的定义、信息隐藏、继承以及类变量。我们将通过具体的代码示例,帮助你理解如何创建和使用自己的数据类型,以及如何通过继承构建复杂的类层次结构。




📚 课程回顾与概述
上一节我们介绍了面向对象编程的基本概念,并学习了如何使用Python类来实现抽象数据类型。我们看到了坐标和分数的例子。本节中,我们将进一步探讨面向对象编程和类的更多细节,包括信息隐藏和类变量。在课程的后半部分,我们将讨论继承的概念,学习如何使用面向对象编程来模拟现实世界中的继承关系。



🏗️ 类的两种视角

在面向对象编程中,我们可以从两个不同的视角来编写代码。



以下是实现类的视角:

- 作为类的实现者,你需要定义自己的对象类型。
- 你需要决定定义对象的数据属性,即对象由哪些数据组成。
- 除了数据属性,你还需要定义方法,这些方法告诉使用者如何与你的数据类型进行交互。




以下是使用类的视角:

- 作为类的使用者,你需要使用已经编写好的类。
- 这涉及到创建对象的实例。
- 一旦创建了对象实例,你就可以对它们进行操作,查看类实现者添加了哪些方法,并使用这些方法。




📍 坐标类示例详解


让我们通过坐标类的例子更详细地理解这些概念。
我们有一个对象类型的类定义,这包括决定类名。类名告诉Python这个对象的类型是什么。在这个例子中,我们决定创建一个坐标对象,因此这个对象的类型就是坐标。
我们以一种通用的方式定义类。我们需要一种方法来访问任何实例的数据属性。我们使用self变量来引用任何实例的数据属性,这是一种通用的方式,而不特指某个具体实例。

每当我们访问数据属性时,我们会使用类似self.x的语法。如果我们想访问一个方法,我们会使用self.后跟方法名,例如self.distance。


类定义的核心在于,你的类定义了所有实例共有的数据属性和方法。你创建的特定对象类型的任何实例都将具有完全相同的结构。不同之处在于,每个实例的值将是不同的。


当创建类的实例时,你可以创建同一个类的多个实例。每个坐标对象都将具有不同的数据属性值。每个坐标对象都有一个x值和一个y值,但不同实例之间的x和y值会有所不同。


这就是定义类和查看类的特定实例之间的区别。实例具有类的结构,对于坐标类,所有实例都有一个x值和一个y值,但实际的值在不同实例之间会变化。






🐱🐰 面向对象编程的优势
我们为什么要使用面向对象编程?到目前为止,我们看到的例子都是数值型的,比如坐标和分数。但使用面向对象编程,你可以创建模拟现实生活的对象。



例如,如果你想创建定义猫的对象和定义兔子的对象,你可以使用面向对象编程来实现。作为程序员,你需要决定为这些对象组分配哪些数据和方法。
使用面向对象编程,每一个都被视为一个不同的对象。作为一个不同的对象,我可以决定猫将有一个名字、年龄和可能与之相关的颜色。右边的这三只兔子也是对象,我将决定用年龄和颜色来表示一只兔子。


通过面向对象编程,我可以使用这些属性将这些对象分组在一起。我将具有相同属性的对象集合分组在一起。属性有两种形式:数据属性和过程属性。


数据属性基本上是定义对象是什么的东西。例如,如何将一个猫表示为一个对象,这由你作为程序员来决定。对于坐标,这很简单,你有一个x值和一个y值。如果我们表示更抽象的东西,比如动物,那么我可能会说我将用年龄和名字来表示一个动物。这真的取决于你决定如何表示你的对象。




过程属性也被称为方法。方法本质上是询问你的对象能做什么。对于坐标,我们看到你可以找到两个坐标之间的距离。对于一个更抽象的动物对象,方法可能是向屏幕打印东西。





🧩 如何创建一个类

这一页幻灯片也是关于如何创建类的回顾,以确保在我们继续之前每个人都在同一页上。
我们使用class关键字定义一个类,并指定类的名称。现在我们将创建一个更抽象的动物类。我们将在课程的后半部分讨论在括号中放入其他东西意味着什么,但现在我们只说动物是Python中的一个对象。



这意味着它将具有Python中任何其他对象的所有属性。当我们创建这个动物时,我们将定义如何创建这个类的实例。我们使用def __init__这个特殊方法来告诉Python如何创建一个对象。
在括号内,我们有self变量,我们用它来引用类的任何实例。我们没有特定的实例在脑海中,我们只是希望能够引用任何实例。所以我们使用这个self变量。这里的第二个参数将代表我们用来初始化对象的数据。


在这个例子中,我将说我用一个年龄来初始化一个动物对象。当我创建一个动物时,我需要给它一个年龄。在__init__内部,我进行任何我想做的初始化。第一件事是我要分配一个实例变量age,这将是数据属性age,它被赋值为传入的值。

然后我在这里进行另一个赋值,我将数据属性name最初赋值为None。稍后在代码中,当我想创建一个动物对象时,我说出类名,然后传入它需要的任何参数,在这个例子中是年龄,并将其分配给这个实例。


🔧 Getter与Setter方法
现在我们有了这个动物类,我们已经完成了第一部分,即初始化类。我们告诉了Python如何创建这种类型的对象。接下来我实现了一些其他方法,我们称之为getter,之后的两个我们称之为setter。
Getter和setter在实现类时非常常用。Getter本质上是返回任何数据属性的值。如果你仔细看,get_age只是返回self.age,get_name只是返回self.name。它们是非常简单的方法。

类似地,set_age和set_name我们将在接下来的几张幻灯片中看到这个有趣的等号在做什么,但setter做类似的事情,它们将数据属性设置为传入的任何值。

最后一个是__str__方法,这个方法用于告诉Python如何打印这种类型的动物对象。如果你没有这个__str__方法,你会得到一些消息,说这是一个类型为动物的对象,位于某个内存位置,这是非常不直观的。所以你在这里实现这个方法,告诉Python如何打印这种类型的对象。


这一页幻灯片的重点是,你应该为你的类使用getter和setter,你应该实现getter和setter。我们将在接下来的几张幻灯片中看到具体原因,但基本上它们将防止在以后如果有人决定更改实现时出现错误。


🛡️ 信息隐藏的重要性
我们看到了如何实现动物类,这里我们可以看到如何创建这个对象的实例。我们可以说a = Animal(3),这将创建一个年龄为3的新动物对象。我们可以通过变量a和点符号来访问对象。


点符号是你访问类的数据属性和方法的一种方式。你可以在程序后面说a.age,这是允许的,它会尝试访问这个特定类实例的age数据属性。这将给你3。


然而,实际上不建议直接访问数据属性。这就是原因。你将在下一页幻灯片上看到为什么我们要使用getter和setter。你应该使用get_age这个getter方法来获取动物的年龄。这也将返回3。

这两者将做同样的事情。你希望使用getter和setter的原因是信息隐藏的概念。我们使用类和面向对象编程的全部原因是为了你可以向用户抽象某些数据。你应该抽象的事情之一就是这些数据属性。



用户实际上不需要知道类是如何实现的,他们应该只知道如何使用类。考虑以下情况:假设编写动物类的人想要更改实现,他们决定不再将数据属性称为age,而是想称之为years。


当他们初始化一个动物时,他们说self.years = age。一个动物仍然通过其年龄初始化,年龄被传递到一个名为years的数据属性中。由于我正在实现这个类,我想要一个getter,它将返回self.years。我不再返回self.age,因为age不再是我使用的数据属性。


有了这个新的实现,如果有人在使用这个实现,并且直接访问age数据属性,那么在这个新实现下,他们实际上会得到一个错误。因为这个使用我的旧实现创建的动物不再有一个名为age的属性,所以Python会抛出一个错误,说未找到属性之类的。
如果他们使用的是getter a.get_age(),那么实现类的人重新实现了get_age,使其与他们的新数据属性years正确工作,而不是age。所以如果我使用getter get_age,我就不会遇到这个错误。
需要记住的事情:为你的类使用getter和setter,然后在你的代码后面使用getter和setter来防止错误,并促进易于维护的代码。


⚠️ Python与信息隐藏
信息隐藏很好,但话虽如此,Python实际上并不擅长信息隐藏。Python允许你做一些你永远不应该做的事情。



第一件事是从类外部访问数据属性。如果我说a.age,Python允许我这样做,而不使用getter和setter。Python还允许你从类外部写入数据属性。
如果我实现的动物类假设age是一个整数,并且只要age是一个整数,我所有的方法都能工作,但有人决定自作聪明,在类外部将age设置为字符串“infinite”,这可能会导致代码崩溃。Python允许你这样做,但现在你破坏了age必须是整数的事实。
所以现在方法可能应该一直检查age是否是整数。另一件你被允许做的事情是在类定义之外创建数据属性。如果我想为这个特定实例创建一个名为size的新数据属性,Python也允许我这样做,我可以将其设置为我想要的任何值。
Python允许你做所有这些事情,但实际上做其中任何一件事都不是好风格。所以就是不要做。
🎛️ 默认参数

关于类我想提到的最后一件事,在我们继续讨论继承之前,是这个叫做默认参数的概念。默认参数被传递给方法,由于方法是函数,你也可以将默认参数传递给函数。
例如,这个set_name方法有self,然后这个new_name等于这个空字符串。我们以前没有见过这个,但这被称为默认参数。第一种使用方式是我们可以用这行代码a = Animal(3)创建一个动物类型对象的新实例。
然后我们可以说a.set_name(),这会调用setter方法来设置名称。注意我们总是说除了self之外,你必须为所有东西放入参数,但这里我们没有传入任何参数,但这没关系,因为new_name实际上有一个默认参数。

这告诉Python,如果没有为这个特定的形式参数传递参数,那么默认使用这里的任何值。如果我没有传递参数a.set_name(),a.set_name将把名称设置为空字符串,因为这是默认参数。
所以在下一行,当我打印a.get_name()时,这将只打印空字符串。如果你确实想传递一个参数,你可以像平常那样做。你可以说a = Animal(3),a.set_name(),然后在这里传递一个参数,然后new_name将被分配给你传递的任何参数。

你传递的任何内容都会覆盖默认参数,一切正常。所以当我打印a.get_name()时,这将打印出你传入的名称。关于如果你不为new_name提供默认值的问题:如果你不为new_name提供默认参数,并且你在这里做这种情况,那么会给你一个错误。

所以Python会说类似“期望一个参数,得到零个”的话。
🌳 类层次结构

让我们继续讨论层次结构的概念。面向对象编程的伟大之处在于它允许我们为代码添加抽象层。我们不需要知道非常低层次的东西是如何实现的就可以使用它们,并且我们可以构建我们的代码,使其在使用这些不同的抽象时变得越来越复杂。
考虑这张幻灯片上的每一个东西都是一个独立的对象。根据我们对动物的实现,动物有一件事是年龄。这可能是真的,这里的每一个东西都有一个年龄。但现在我想在此基础上进行构建,并创建独立的组。


我在动物之上创建的每一个独立组都将有自己的功能。它们将更加具体和专门化。所以我现在可以创建这三个组:猫、兔子和人组。例如,它们都是动物,它们都有年龄,但例如,一个人可能有一个朋友列表,而猫和兔子没有。


也许猫有一个数据属性表示它们还剩下多少条命,而人和兔子没有。所以你可以考虑为这些子组中的每一个添加更多专门化的功能。我们将越来越专门化,但它们都保留了它们是动物的事实,它们都有一个年龄。


在这些之上,我们可以添加另一层,说学生是一个人,也是一个动物。但除了有年龄和可能有一个朋友列表之外,学生可能还有一个专业,或者他们很年轻,所以也许他们最喜欢的学校科目。这就是层次结构的一般概念。

我们可以将上一张幻灯片抽象到这一张,说我们有父类和子类。动物类就像我们的父类,从动物类继承,我们有这些子类。动物能做的任何事情,人都能做;动物能做的任何事情,猫都能做;动物能做的任何事情,兔子都能做。

那就是有一个年龄和一些非常基本的功能。但在人、猫和兔子之间,它们能做的事情种类会有很大差异。但它们都能做动物能做的任何事情。所以子类继承了其父类的所有数据属性和所有方法或行为。
但子类可以添加更多信息,例如,一个人可以有一个朋友列表,而一般的动物则没有。它可以添加更多行为,比如也许猫可以爬树,而人和兔子不能。或者你也可以覆盖行为。

在之前的例子中,我们有动物、人、学生。也许动物根本不会说话,但一个人可以说话,这是为人添加的功能。也许一个人只能说“hello”,但当我们和学生说话时,我们可以覆盖人的说话方法,说学生可以说“我有作业”或“我需要睡眠”之类的话。
所以我们为人都有相同的说话方法,因为两者都能说话,但学生会覆盖他们说“hello”的事实,而说些别的。

🐈 继承示例:Cat类

让我们看一些代码来理解这一点。我们之前见过这个动物类,这是父类。它继承自object,这意味着Python中基本对象能做的任何事情,动物都能做,比如绑定变量等非常低层次的事情。


我们已经看到了__init__,我们看到了两个getter、setter和用于打印动物类型对象的字符串方法。现在让我们创建一个动物的子类,我们称之为Cat。我们创建一个名为Cat的类,在括号中,我们现在放入Animal,而不是object,这告诉Python猫的父类是动物。

所以动物能做的任何事情,猫都能做。这包括所有属性,即年龄和名称,以及所有方法,所以所有getter、setter、__str__、__init__,动物拥有的一切,现在猫类都有。在猫类中,我们将添加两个不同的方法。

第一个是speak,所以speak将是一个方法,它只接受self,没有其他参数,它所做的只是向屏幕打印“meow”。非常简单。所以通过speak,我们为类添加了新功能。此外,通过这里的__str__方法,我们覆盖了动物的__str__。
如果我们回到上一张幻灯片,我们可以看到动物的__str__是“animal:”加上名称加上年龄,而猫的__str__现在说“cat:”名称和年龄。这只是我选择实现的方式。我在这里覆盖了动物类的__str__方法。
注意这个类没有__init__,这没关系,因为Python实际上会说,如果这个特定类中没有__init__,那么看看我的父类,说我的父类是否有__init__,如果有,就使用那个__init__。这对于任何其他方法都是如此。
这里的想法是,当你拥有层次结构时,你有一个父类,你有一个子类,你可以有一个子类的子类,依此类推。所以你可以有多个继承级别。当你创建一个类型为某个类的对象时,这个类型是子类的子类的子类,当你对该对象调用方法时会发生什么?
Python会说,这个方法名是否存在于我当前的类定义中?如果存在,就使用它。但如果不存在,那么看看我的父类,我的父类知道怎么做吗?如果知道,就使用它。如果不知道,再看看它们的父类,依此类推。所以你正在追溯你的祖先,以弄清楚你是否能做这个方法。

👤 继承示例:Person类

让我们看一个稍微复杂一点的例子。我们有一个名为Person的类,它将从Animal继承。在这个Person内部,我将创建自己的__init__方法,__init__方法将做一些与动物的__init__方法不同的事情。
它将像往常一样接受self,并将接受两个参数,而不是一个:名称和年龄。__init__方法所做的第一件事是调用动物的__init__方法。我为什么要这样做?理论上,我可以在这个方法中初始化动物初始化的名称和年龄数据属性。


但我利用了我已经编写了初始化这两个数据属性的代码,所以为什么不直接使用它呢?这里这行代码说,我将调用Animal类,我将调用它的__init__方法,我将让你——不是作为类的你,而是作为程序运行时的你——来弄清楚如何用这个特定的年龄初始化一个动物,以及如何命名它。
所以Python说,是的,我知道怎么做,所以我会为你做这件事。现在它说Person是一个动物,我已经为你初始化了年龄和名称。我在__init__中做的下一件事是,我将把名称设置为传入的任何名称。


注意,在__init__中,我可以做任何我想做的事情,包括调用函数、调用方法。我在这里做的最后一件事是,我将为一个人创建一个新的数据属性,即一个朋友列表。所以动物没有朋友列表,但一个人将有。
接下来的四个方法,这里一个是getter,所以它将返回朋友列表。这个将把一个朋友追加到我的列表末尾。我想指出,我实际上没有写一个删除朋友的方法,所以一旦你有了一个朋友,他们就是终身的朋友,但这没关系。

这里的下一个方法是speak,它将向屏幕打印“hello”。最后一个方法将获取两个人之间的年龄差,这基本上就是减去他们的年龄,并说这是五岁的年龄差或其他什么。在这里我有一个__str__方法,我从动物那里覆盖了它,它不再打印“animal: name”,而是打印“person: name”。

我们可以运行这段代码。这里我创建了一个新的人,我给了它一个名字和年龄。我创建了另一个人,然后给了名字和年龄。这里我只是对它运行了一些方法,即get_name、get_age、get_name、get_age,针对这两个人。

所以那打印了“jack is 30, jill is 25”。如果我打印p1,这将使用Person的__str__方法,所以它将打印“person: 他们的名字,然后是他们的年龄”。p1.speak()只是说“hello”。然后p1和p2之间的年龄差只是5,所以那只是相减,然后打印到屏幕上。


🎓 继承示例:Student类

这是我的Person类。让我们添加另一个类,这个类将是一个学生,它将是Person的一个子类。由于它是Person的一个子类,学生将继承Person的所有属性,因此也继承Animal的所有属性。


学生的__init__方法将与Person的__init__方法略有不同。我们将给它一个名字、年龄和一个专业。注意我们在这里使用默认参数,所以

🚗 Python课程 P34:L9.2 - get与set系列处理方法
在本节课中,我们将学习Python中用于获取和设置对象属性的getter与setter方法。这些方法是面向对象编程中的重要概念,能帮助我们更安全、更灵活地管理对象的数据。
🛠️ 类的定义与初始化

我们首先定义一个名为Car的类。与往常一样,__init__方法接受self作为第一个参数。Car类在初始化时接收两个参数,并将第一个参数赋值给wheels数据属性,第二个参数赋值给doors数据属性。此外,我们还将color数据属性初始化为空字符串。
以下是Car类的初始化代码示例:
class Car:
def __init__(self, wheels, doors):
self.wheels = wheels
self.doors = doors
self.color = ""
🔍 理解Getter方法
Getter方法用于获取对象的数据属性。它是一个类的方法,本质上是一个函数。在Python中,我们通常使用self来引用当前实例的属性。
以下是四个选项,问题是:哪一个选项是用于获取车轮数量的getter方法?
def get_wheels(): return wheelsdef get_wheels(self): return wheelsdef get_wheels(): return self.wheelsdef get_wheels(self): return self.wheels
✅ 正确选项分析
由于getter方法是类的方法,它必须包含self参数。因此,选项1和选项3被排除。接下来,我们需要返回当前实例的wheels属性,而不是一个未定义的变量。因此,正确的getter方法应该使用self.wheels来引用属性。
以下是正确的getter方法:
def get_wheels(self):
return self.wheels
📝 总结

本节课中,我们一起学习了Python中getter方法的基本概念和实现方式。通过定义Car类,我们了解了如何使用self参数来访问实例的属性,并正确实现了获取车轮数量的getter方法。掌握这些知识将帮助你在面向对象编程中更有效地管理对象数据。

课程 P35:L9.3 - 子类 🐕


以下内容基于知识共享许可协议提供。您的支持将帮助麻省理工学院开放课程持续免费提供高质量的教育资源。如需捐款或查看来自数百门麻省理工学院课程的其他材料,请访问相关网站。
在本节课中,我们将学习如何创建子类,即一个类如何从另一个类(称为父类)继承属性和方法。我们将通过一个具体的例子来理解继承的概念和实际应用。

首先,我们看到一段代码的初始部分。这里有一些空行,然后定义了一个名为 speak 的方法。题目要求我们写一行代码来替换空白处,以创建一个从 Animal 类继承的 Dog 类。
我注意到我需要写一个类定义。从选项来看,要么是第一个,要么是第三个。因为我想从 Animal 继承,而不是从 object 继承,所以第三个选项 class Dog(Animal): 是完美的选择。
接下来,题目问:使用这个 Dog 类的定义,运行包含以下三行代码的程序会发生什么?这是我们的类定义,下面是三行测试代码。
第一行代码是 d = Dog(7),它尝试创建一个年龄为7的 Dog 对象。这一行会抛出错误吗?这行代码会寻找 __init__ 方法。我们当前的 Dog 类定义中没有 __init__ 方法,但是,我继承了 Animal 类。Animal 类有 __init__ 方法吗?正如我们在幻灯片中看到的,它有。因此,这一行会成功创建一个名为 None、年龄为7的 Dog 对象,不会抛出错误。
第二行代码是 d.set_name(‘Ruffles’)。同样,这个特定的 Dog 类中没有 set_name 方法,但我的父类 Animal 有这个方法吗?是的,它有。所以会调用父类的方法,这一行也不会抛出错误。
第三行代码是 d.speak()。这将导致 Python 在当前类定义中查找 speak 方法。它发现这里定义了一个名为 speak 的方法,因此会使用这个方法。它将打印出 ruff ruff,因为它是一只狗。

是的,你可以像函数一样直接打印内容。

核心概念总结
在本节课中,我们一起学习了子类和继承。我们看到了如何通过 class Dog(Animal): 这样的语法让 Dog 类继承 Animal 类的所有功能。当子类中没有某个方法时,Python 会自动到父类中去寻找。我们还通过实例操作,验证了即使子类没有定义 __init__ 或 set_name 方法,也能成功创建对象和设置属性,因为继承了父类的这些方法。同时,子类可以重写父类的方法(如 speak 方法),以实现自己特有的行为。

关键代码示例:
class Dog(Animal):
def speak(self):
print(‘ruff ruff’)

本节课的核心在于理解继承机制如何提高代码的复用性和组织性。




📊 课程 P36:L10 - 程序效率分析 1


以下内容基于知识共享许可协议提供。您的支持将帮助 MIT OpenCourseWare 继续免费提供高质量的教育资源。如需捐款或查看来自数百门 MIT 课程的其他材料,请访问相关网站。





欢迎回来。希望你们在没有课程的漫长周末过得愉快,并且赶上了所有悄悄逼近的习题集。在讨论今天的话题之前,我想花点时间来设定背景,并请大家停下来思考一下到目前为止在本课程中看到的内容。我们即将结束课程的第一部分,你们已经学到了很多:开始学习计算的基础知识,看到了不同种类的数据结构(包括可变和不可变的,如元组、列表、字典),以及将它们组合在一起的不同方式。你们接触了一系列算法,从简单的线性代码到循环、for 和 while 循环,看到了迭代算法、递归算法,以及分治、贪心算法、二分搜索等算法类别。最近,你们还学习了如何通过类将数据和操作这些数据的方法或过程组合在一起。



实际上,你们已经相当全面地覆盖了许多计算的基础知识,并开始准备好解决一系列相当有趣的问题。今天和周一,我们将从一个略有不同的角度看待计算,因为既然你们已经掌握了开始构建自己工具库的工具,我们想提出几个重要的问题。其中最主要的一个是:我的算法效率如何?我们将看到,效率既指空间也指时间,但主要指的是时间。我们想知道我的算法运行速度有多快,以及如何推理其性能。这就是我们今天要讨论的主题:我们将讨论增长阶,稍后定义其含义;我们将讨论所谓的“大 O 表示法”,并开始探索不同类别的算法。
在开始之前,我们先谈谈为什么这很重要。我认为有两个原因需要考虑。第一个问题是:我们如何推理自己编写的算法,以预测解决特定规模问题需要多少时间?我可能在小型示例上测试我的代码,但我想知道如果在一个非常大的问题上运行,它将花费多长时间?我能预测吗?尤其是在现实世界中,时间至关重要的情况下,我能估算出解决这个问题需要多少时间吗?
同样重要的是另一个方向:我们希望你们开始推理自己编写的算法,能够说出算法设计中的某些选择如何影响所需的时间。如果我选择递归实现,这与迭代实现会不同吗?如果我在算法中选择了特定类型的结构,那对所需时间意味着什么?你们将看到算法类别与其内部结构之间存在很好的关联。特别是,我们想提出一些基本问题:是否存在解决特定问题的基本时间限制,无论我围绕此设计何种算法?我们将看到这方面存在一些有趣的挑战。

这就是我们接下来两天要做的事情。但在开始之前,也许我们应该问一个显而易见的问题:为什么要在乎?可能是因为它会出现在测验中,对你很重要。但更好的理由是,它确实有影响。我这么说是因为它对你们来说可能不像对老一辈人那样明显。所以,像我这样头发花白(或者说剩下的花白头发)的人喜欢讲故事。我会长话短说:我 45 年前开始编程,用的是打孔卡(除非你去过博物馆,否则你可能不知道那是什么),机器占了半个房间,而它完成的计算,现在在你的手机上只需一瞬间就能完成。这告诉你,你生活在一个伟大的时代。我的观点是,是的,我讲老故事,我是个老家伙,但你们可能会争辩说:看,计算机变得这么快,这真的重要吗?我想对你们说,也许这对你们来说很明显:是的,绝对重要。因为在我们获得更快计算机的同时,我们想要分析的问题规模和数据集也在变得非常庞大。





让我举个例子:我从谷歌上找到的数据(2014年,我没有更新的数字)显示,谷歌索引了大约 30 万亿个网页,覆盖了 1 亿千兆字节的数据。我建议你们思考一下,如果你想在网络上找到一条信息,你能写一个简单的小搜索算法,在合理的时间内顺序遍历所有页面来找到任何东西吗?可能不行,对吧?数据增长得太快了。顺便说一下,这就是为什么谷歌通过他们的 MapReduce 网络搜索算法赚了很多钱,该算法是由一位 MIT 毕业生(也是一位 MIT 在校学生的家长)共同编写的。所以这里有个不错的联系,不过谷歌可不会为此向 MIT 支付版税。

玩笑归玩笑,搜索谷歌需要大量时间,搜索基因组数据集也需要大量时间。数据集增长如此之快,如果你为美国政府工作,想利用来自世界各地的图像监控追踪恐怖分子,数据也在极其迅速地增长。随便挑一个问题,数据集增长得如此之快,即使计算机速度加快,你仍然需要考虑如何找到有效的方法来解决这些问题。因此,我想告诉你们,虽然简单的解决方案有时很棒,也更容易编写,但有时你需要更复杂的方案。因此,我们想要推理如何衡量效率,以及如何将算法设计选择与相关的成本联系起来。
好的,即使我们这样做,我们也有一个选择要做,因为我们可以从时间或空间(即计算机内部需要多少存储空间)两方面来讨论效率。这之所以相关,是因为在许多情况下,这两者之间存在权衡。你们实际上已经见过一个例子,可能记得也可能不记得:当我们介绍字典时,我展示了一个变体,你可以使用字典来跟踪中间值计算斐波那契数列,我们下周会看到,这实际上极大地降低了时间复杂度。这被称为权衡,因为有时我可以预先计算部分答案并存储起来,这样当我尝试计算更大版本的答案时,我只需查找这些部分。所以这里会有一个权衡。为了本讲座和下一次讲座的目的,我们将重点关注时间效率:我们的算法解决问题需要多少时间。


好的,在这样做之前有哪些挑战?实际上,这将引导我们进入实际工具。第一个挑战是,即使我决定了一个算法,也有许多实现它的方法。while 循环和 for 循环可能有略微不同的行为。我可以选择使用临时变量或直接替换。有很多小的选择,所以一个算法可以用许多不同的方式实现。我如何衡量算法的实际效率?




第二个挑战是,对于给定的问题,我可能有不同的算法选择:递归解决方案与迭代解决方案,使用分治法与直接搜索。我们将看到一些这样的例子。所以我必须以某种方式将这些部分分开,特别是我想将实现的选择与算法的选择分开。我想衡量算法的难度,而不是我是否能想出一种稍微更有效的实现方式。

这里有三种我可能采用的方法。我将非常简要地看一下每一种。最明显的一种是,我们可以像科学家一样:计时。编写代码,运行一堆测试用例,使用计时器,尝试以此来估算效率。我们会看到一些挑战。
稍微抽象一点,我们可以计算操作次数。我们可以说,这里有一组基本操作:数学运算、比较、赋值、检索值,然后简单地说,作为输入大小的函数,我在算法中使用了多少次这些操作?这可以用来给我们一个效率的概念。我们将看到这两种方法都有缺陷,第一种比第二种更严重一些。因此,我们将把第二种方法抽象为一个更抽象的概念,我们称之为“增长阶”,我们将在几分钟后回到这个概念。这是我们将要重点关注的,也是计算机科学家使用的方法,它引出了我们所谓的“复杂度类”。


所以,增长阶或大 O 表示法是一种抽象描述算法行为的方式,特别是不同算法的等价性。但让我们先看看那些计时方法。Python 提供了一个时间模块。你可以导入 time 模块,然后调用 clock 方法(如图所示)。它会给出一个数字,表示当前的秒数(或几分之一秒)。然后我可以调用函数,再次调用 clock,取差值来告诉我执行这个函数花了多少时间。这将是一个非常小的时间量。然后我当然可以打印出一些统计数据。我可以在大量运行、不同输入大小的情况下进行此操作,从而得出需要多少时间的概念。





这里的问题是:这不是一个坏主意,但我的目标是评估算法。不同的算法是否关联着不同的时间量?好消息是,如果我测量运行时间,它肯定会随着算法的变化而变化——这正是我想要测量的。但问题之一是,它也会随着实现的变化而变化。如果我在一个算法中使用了一个内部有更多步骤的循环,它会改变时间,而我并不真正关心这种差异。所以我混淆了实现对时间的影响和算法对时间的影响,这不太好。



更糟糕的是,计时将取决于计算机。我这里的 Mac 相当旧了(大约五年),而你们中的一些人可能有更新得多的 Mac 或其他机器,速度可能与我的不同。这对我尝试测量没有帮助。即使我可以在小规模问题上测量,也不一定能预测在真正大规模问题上会发生什么,因为涉及到从内存中取出数据并带回计算机等问题。


所以,计时确实会根据我想要测量的内容而变化,但它也取决于许多其他因素,而且并不是那么有价值。

好的,排除了第一种方法。让我们抽象一下。我将做出以下假设:我将确定一组基本操作。我可以决定它们是什么,但明显的是,我说机器为我自动执行什么操作?这将是诸如算术或数学运算(乘法、除法、减法)、比较(等于、大于、小于)、赋值(将名称设置为值)以及从内存中检索等操作。我将假设所有这些操作在我的机器内部花费大致相同的时间。这里的好处是,那么无论我使用哪台机器都没关系。我通过计算算法内部执行了多少次此类操作来衡量算法花费的时间。我将使用该计数来得出作为输入大小函数的操作执行次数。如果幸运的话,这将让我了解算法的效率。



所以,这个例子(摄氏转华氏)很无聊,它有三个步骤:一个乘法、一个除法和一个加法(如果你把返回也算上)。但如果我有一个将 0 到 X 的整数相加的小函数,里面有一个小循环,我可以计算操作。在这种情况下,正如我所说,这里有三个操作。这里我有一个操作(赋值),然后在这里,本质上有一个操作将 i 设置为迭代器的值(最初是 0,然后是 1,依此类推)。这里实际上是两个操作(很好的 Python 简写,但那个操作是什么?它说取 total 的值和 i 的值,将它们相加——这是一个操作,然后将该值(或者说将名称 total 设置为新值)——所以是第二个操作。所以你可以看到,这里有三个操作。我还有什么?我将循环 X 次。所以我会运行那个循环 X 次。如果我把这些放在一起,我得到一个很好的小表达式:1 + 3X。实际上我可能作弊了,我可能应该把返回算作一个操作,所以那将是 1 + 3X + 1 或 3X + 2 个操作。




为什么要在乎?这更接近我想要的,因为现在我有了一个表达式,可以告诉我随着问题规模的变化,这将花费多少时间。如果 X 等于 10,我将进行 32 次操作;如果 X 等于 100,302 次操作;如果 X 等于 1000,3002 次操作。如果我想知道实际时间,我只需将其乘以每个操作所需的恒定时间量。这听起来不错,虽然不是我们想要的,但很接近。
所以,如果我计算操作次数,我能说什么?首先,它肯定取决于算法——这很好。操作次数将直接关联到我试图衡量的算法,这正是我想要的。不幸的是,它仍然在一定程度上取决于实现。让我通过后退一步来展示我的意思。假设我把这个 for 循环改成 while 循环。我将在循环外设置 i = 0,然后当 i 小于 X+1 时,执行循环内的操作。这实际上会在循环内增加一个操作,因为我既要设置 i 的值,又要测试 i 的值,还要执行下面的其他操作。所以,不是 1 + 5n + 1,而是 1 + 6n + 1,因为我多了一个步骤。我提到这个是因为我想提醒你们,我不在乎实现上的差异。所以我想知道什么能捕捉到这两种行为。在大 O 表示法中,我说那是 O(n)——线性增长。无论我使用这个版本还是那个版本,如果我加倍 n 的大小,步骤数基本上会加倍。
现在你们可能会说,等等,5n + 2,如果 n 是 10,那是 52;如果 n 是 20,那是 102,这不完全是加倍。你们是对的,但请记住,我们真正关心的是渐近情况,当 n 变得非常大时,那些额外的小部分就不重要了。所以我们要做的是,在讨论增长阶时,忽略加法常数和乘法常数。



那么,O(n) 衡量什么?现在我总结一下:我们想要描述计算需要多少时间,或者更准确地说,解决问题所需的时间量如何随着问题规模本身的增长而增长。所以我们想要一个表达这种渐进行为的表达式。因此,我们将专注于增长最快的项。




这里有一些例子,如果你们跟着思考,可能已经看到了答案。我们这样做只是为了给你们一个概念。如果我计算操作并得出一个包含 n^2 + 2n + 2 操作的表达式,我说那个表达式是 O(n^2)。2 和 2n 无关紧要。想想如果你让 n 变得非常大,n^2 比其他项占主导地位得多。我们看到那是 O(n^2)。即使这个表达式,我们也说是 O(n^2)。在这种情况下,对于较低的 n 值,这一项将是步骤数中最大的一个。我不知道我怎么会写出如此低效的算法,以至于做某事需要十万步,但如果我有那个表达式,对于较小的 n 值,这很重要,这是一个非常大的数字。但当我关心增长时,占主导地位的是那一项。


你们开始看到这里的想法了。当我有表达式时,如果是多项式表达式,最高阶项就是捕捉复杂度的项。这两个都是二次的。这一项是 O(n),因为 n 比 log(n) 增长得快。这个看起来奇怪的项,即使那里看起来是个大数字,而且它确实是个大数字,我们说那个表达式是 O(n log n),因为同样,如果我绘制出随着 n 变得非常大时这个如何变化,这一项最终会占据主导地位。




那一个呢?那里的主要项是什么?有多少人认为是 n^30?举手。有多少人认为是 3^n?举手。谢谢,你们跟上了,也在注意听。有多少人认为我应该停止提问?没有举手。好吧,但你们是对的。指数比幂次糟糕得多。即使是像这样的东西,也需要很大的 n 值才能超过,但它确实会达到。顺便说一下,这很重要,因为我们将在学期后期看到,有些问题被认为所有的解决方案都是指数级的,这很痛苦,因为这意味着计算总是很昂贵。



这就是我们如何推理这些事情。为了直观地看到它,这里是这些不同类别之间的差异。常数:时间量不随输入大小变化。线性:如你所料,呈直线增长,行为良好。二次方开始增长得更快。对数总是比线性好,因为随着我们增加大小 n,它会减慢。n log n(对数线性)是一个奇怪的术语,但我们将看到它是计算机科学中非常有价值的算法非常常见的复杂度,并且它具有介于线性和二次方之间的良好行为。指数级爆炸式增长,只是为了提醒你们这一点。



好的,让我向你们展示我们将如何对此进行推理。我们已经看过一些代码,我开始通过计算操作的过程来推理。以下是我想让你们使用的工具:给定一段代码,你们将分别推理每个代码块。如果有顺序的代码块,那么增长阶的规则(称为加法法则)是:组合的增长阶是各个增长阶的组合。快速说十遍,但让我们看一个例子。



这里有两个循环,你们已经见过如何推理这些循环的例子。对于这个,它是 n 的线性——我将循环 n 次,每次做恒定数量的事情。正如我刚才展示的,那是 O(n)。这个同样,我在循环内做恒定数量的事情,但请注意,它是 n^2,所以那是 O(n^2)(n 乘以 n)。组合是:我必须做这个工作,然后做那个工作。所以我把它写成 O(n) + O(n^2)。但根据上面的规则,这等同于说 n + n^2 的增长阶是什么?哦,是的,我们刚刚看到,那是 n^2。所以加法法则让我推理出这将是一个 O(n^2) 算法。

其次,我们将使用所谓的乘法法则。这说的是,当我有嵌套语句或嵌套循环时,我需要推理这些。在这种情况下,我要论证(或者说陈述)的是,这里的增长阶是乘法。当我有嵌套的东西时,我计算出内部部分的增长阶是什么,外部部分的增长阶是什么,然后将这些增长阶相乘,得到整体的增长阶。如果你想一想,这是有道理的。看看我的小例子,这是一个微不足道的例子,但我循环 i 从 0 到 n,对于 i 的每个值,我循环 j 从 0 到 n,并打印出一些东西。我想确保你们还醒着。好的,你们明白了。但我想向你们展示的是,注意增长阶:那是 O(n),对吧?我要做 n 次。但我要为外部循环的每个值做这件事,外部循环也循环 n 次。所以对于 i 的每个值,我做 O(n) 的事情。所以我要做 O(n) 乘以 O(n) 步。根据那个法则,这等同于 O(n * n) 或 O(n^2)。所以这是一个二次表达式。





你们会经常看到这种情况:嵌套循环通常具有这种行为(并非总是,但通常如此)。所以你们将看到的是一组复杂度类,我们即将开始填充这些。O(1) 是常数,表示所需时间不依赖于问题大小。这些非常罕见,往往是微不足道的代码片段,但它们很有价值。O(log n) 反映对数运行时间。你们可以阅读其余部分。这些是我们要处理的事情。我们将在这里、这里和这里看到例子,稍后我们会回来看看这些,这些都是非常好的例子。


只是为了提醒你们为什么这些增长阶很重要(抱歉,这只是提醒你们它们的样子,我们已经做过了),这里是常数、对数、线性、对数线性、平方和指数在 n 等于 10、100、1000 或 100 万时的差异。我知道你们知道这个,但我想强调一下区别:常数非常棒,无论多大,时间不变。对数相当不错:问题规模增加十倍,它只增加大约两倍;再增加十倍,它只增加大约百分之五十;它只增加一点点。这是非常理想的问题类型。线性也不错:从 10 到 100 到 1000 到 100 万。对数线性也不差:这里增加十倍,那里只增加二十倍;那里增加十倍,这里只增加三十倍。所以对数线性增长得并不那么糟糕。但看看 n^2 和 2^n 之间的区别。我实际上想过打印这个,但 Python 计算这个需要很多页,我不想这么做。你们明白要点:指数总是糟糕得多,总是比二次阶或幂表达式糟糕得多。你们真的在这里看到了区别。


好的,我放上这个的原因是,当你们设计算法时,你们的目标是尽可能在这个列表中处于高位。越接近这个列表的顶部,你们就越好。如果你们的解决方案在下面这里,带上睡袋和咖啡,你们会在那里待上一段时间。如果可能,你们真的想尽量避免那样。



所以,现在我们想做的(包括今天剩下的 15 分钟和下周)是开始识别常见算法及其复杂度。正如我在本讲座开始时对你们说的(我肯定你们还记得),这不仅仅是为了能够识别复杂度,我希望你们看到算法设计中的选择将如何导致在成本方面的特定后果。这就是你们在这里的目标




📚 课程 P37:L11 - 程序效率分析 2



以下内容在知识共享许可协议下提供。您的支持将帮助 MIT OpenCourseWare 继续免费提供高质量的教育资源。如需捐款或查看来自数百门 MIT 课程的其他材料,请访问相关网站。






概述


在本节课中,我们将继续学习算法复杂度分析。我们将快速回顾上次课的核心概念,并通过一系列具体示例,深入理解不同复杂度类别的算法特征。我们的目标是让你能够识别常见算法模式,并理解其设计选择对效率的影响。
复杂度回顾
上次我们开始讨论复杂度。我想快速回顾一下我们讨论的内容,因为今天我们将进一步探讨这个话题。



当我提到复杂度时,我们关心的是能否估算算法解决特定规模问题所需的资源量(通常是时间)。我们既讨论了如何估算所需时间,也讨论了如何从另一个方向思考:算法的设计选择如何影响其成本。
大O表示法



我们引入了大O表示法的概念,这是一种衡量复杂度的增长阶方法。我们开始讨论不同类别的算法。今天,我将快速回顾这些基本概念,并通过标准算法类别的示例,让你开始识别算法属于哪个类别。


核心目标
我们想要一种机制或方法,能够估算或推理算法解决特定规模问题需要多少时间,特别是当算法输入规模增大时,所需时间会如何增加。我们不关心精确的数值,而是关心其增长趋势。我们将重点关注为这种增长设定一个上界,即一个增长速度至少与算法成本一样快的表达式。


当然,你可以选择一个非常大的上界,但这没有太大帮助。因此,我们通常尝试使用尽可能紧的上界,即算法所属的类别。但正如我们之前所见,我们关心的是增长阶,而不是精确值。例如,如果某物以 2^n + 5n 增长,我们不关心 5n,因为当 n 变大时,2^n 才是主导因素。因此,在思考复杂度时,我们关注最大的影响因素。


算法复杂度类别

基于上述思想,算法存在不同的复杂度类别。



- O(1):常数阶。成本不随输入规模变化。这是最理想的情况。
- O(log n):对数阶。成本随输入规模对数增长,增长非常缓慢。
- O(n):线性阶。我们见过很多线性运行时间的例子。
- O(n log n):线性对数阶。
- O(n^c):多项式阶(例如 O(n^2) 是二次方)。
- O(c^n):指数阶。

理想情况下,我们希望算法尽可能接近这个分类的顶部,因为顶部的算法效率更高。常数阶算法(除非常数时间是几个世纪)看起来非常好。线性增长也不错。而指数阶增长则意味着算法会非常耗时。




复杂度增长图示
我们可以从图形上直观地看到这一点。如果绘制时间作为输入规模的函数:
- 常数阶:时间不变化。
- 对数阶:增长非常缓慢。
- 线性阶:显然呈线性增长。

上次我口误了,我说线性意味着输入规模翻倍,时间也翻倍。这并不完全准确。更准确的说法是,时间的增量与输入规模的增量成正比。例如,从 10 到 100 的时间增量,与从 100 到 1000 的时间增量,其比例关系是恒定的(取决于常数因子)。这种增长是线性的。




当然,当我们看到指数阶时,增长非常快。我们希望算法位于这个列表的顶部。



复杂度增长示例表



以下是一个小图表,展示了不同复杂度随输入规模 n 从 10 增长到 100 万时,所需操作数量的增长情况:
- 常数阶:保持不变。
- 对数阶:增长非常缓慢。
- 线性阶:线性增长。
- 线性对数阶:
n log n增长。 - 二次方阶:
n^2增长。 - 指数阶:
2^n增长。

你可以看到为什么我们希望位于图表的顶部。对数阶算法的时间增长非常缓慢。而底部的指数阶算法,时间会急剧增加。


各类算法示例分析



有了这个背景,我今天要做的是通过示例来填充这个图表的大部分内容。我们已经见过一些线性阶和二次方阶的例子。我想做的是展示如何开始识别一个算法属于哪个复杂度类别。


常数复杂度 O(1)



常数复杂度的算法往往比较简单,因为代码运行时间基本不依赖于输入规模。

需要注意的是,这并不意味着代码中不能有循环或递归调用。只是这些循环不能依赖于输入规模。因此,没有太多有趣的算法属于此类。我们会在分析中看到符合这一特征的代码片段,但常数复杂度的算法意味着其成本独立于输入规模。

对数复杂度 O(log n)



对数复杂度的算法更有趣(实际上是非常有趣),因为其成本随输入规模的对数增长。你在学期初安娜展示二分查找时见过一个例子,那是在具有特定属性的数字中搜索。



我想展示另一个例子,既让你识别算法的形式,也特别展示我们如何推理其增长。这个技巧称为二分查找,它是二分搜索的一个版本。




二分查找示例



假设我给你一个整数列表,我想知道某个特定元素是否在列表中。上次我们看到,你可以直接遍历整个列表,查看它是否存在。在最坏情况下(这是我们关心的),这将是线性的,因为你需要查看列表中的每个元素,直到末尾。所以复杂度是线性的。



然后我们说,假设我们知道列表是有序的(从小到大排序)。简单的算法仍然是遍历列表检查,但当你遇到一个比你要找的元素大的元素时,就可以停止了,因为后面的元素肯定都更大。平均情况下,这比无序列表搜索更快,但复杂度仍然是线性的,因为在最坏情况下,我仍然需要遍历整个列表。



那么,我们能做得更好吗?答案是肯定的。



改进的二分查找

方法如下:我假设列表已排序。我选择一个将列表分成两半的索引(例如中点),并检查该点的值。
- 如果它正好是我要找的元素,那么完成。
- 如果不是,则判断它比我找的元素大还是小。
- 根据判断,我将搜索列表的前半部分或后半部分。



这很好,因为在简单的线性算法中,每一步都将问题规模减少 1(从 n 到 n-1 到 n-2)。而在这里,我一步就将规模为 n 的问题减少到 n/2,因为我可以丢弃一半的列表。这是一种分治策略。

复杂度分析


假设我有一个大小为 n 的列表。我查看中间元素。如果不是我要找的,根据比较结果,我丢弃一半的列表。现在只需要查看剩下的一半。我重复这个过程:查看剩余部分的中间元素,比较,再丢弃一半。每一步,我都在查看中间元素,并丢弃左半部分或右半部分。

经过 i 步后,我得到的列表大小为 n / 2^i。最坏情况是元素不在列表中,我必须持续这个过程,直到列表大小变为 1。此时如果还不是我要找的,我就知道完成了。

注意我是如何将问题规模每次减半的。那么,在最坏情况下,我需要多少步?当 n / 2^i = 1 时,即只剩下一个元素。解这个方程得到 i = log₂ n。所以,步数是对数级的。


这很好,比查看列表中所有元素要好得多。实际上,我并没有查看列表中的所有元素,而是每次丢弃一半。


代码实现与陷阱


让我们看看实现二分查找的代码,然后分析它。



def bisect_search(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(L[:half], e)
else:
return bisect_search(L[half:], e)


if L == []和elif len(L) == 1:这些是常数操作。half = len(L) // 2和比较操作:这些也是常数操作。- 递归调用
bisect_search:我们知道会有O(log n)次递归调用。


但是,这里有一个问题。在递归调用中,我使用了列表切片 L[:half] 和 L[half:]。在Python中,切片操作会创建列表的副本。这意味着每次递归调用我都在复制列表。初始列表长度为 n,所以原则上,设置递归调用需要 O(n) 的工作量。



因此,总复杂度是递归调用次数 O(log n) 乘以每次调用内部的 O(n) 工作(由于复制),结果是 O(n log n)。这并非我们想要的 O(log n)。


更仔细地思考,实际上我并不是每次都复制整个列表,而是复制一半,然后四分之一,然后八分之一……将所有复制工作量加起来,你会发现总和是 O(n)。所以总复杂度是 O(n + log n),而 n 占主导,因此仍然是线性的。


优化版本


我们能修复这个问题吗?当然可以。我们不需要复制整个列表,而是可以跟踪列表的搜索范围。


我们可以这样做:传入列表以及搜索范围的起始和结束索引。测试中间点后,根据比较结果,更新起始或结束索引,而不是复制列表。这样,我们仍然每一步将问题规模减半,但只需跟踪正在搜索的列表部分,从而避免了复制。


def bisect_search2(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指针跟踪搜索范围。 - 计算中点
mid是常数操作。 - 每次递归调用只传递索引值,是常数操作。
- 递归调用次数是
O(log n)。



因此,这是一个真正的对数复杂度算法。

这里需要注意,虽然我们通常不关心具体实现而关注算法复杂度,但在这个例子中,实现方式确实影响了复杂度。我们需要在设计算法时意识到这一点。




对数算法的关键特征是:每一步都将问题规模减少一个常数因子(例如减半)。

另一个对数复杂度例子

另一个展示对数特征的小例子是将整数转换为字符串。我知道可以直接调用 str(),但机器内部可能如何实现呢?


这是一个不错的小算法:

def intToStr(i):
digits = '0123456789'
if i == 0:
return '0'
result = ''
while i > 0:
result = digits[i % 10] + result
i = i // 10
return result


我们关心的是增长阶。循环内部是常数操作。那么,循环会执行多少次?我能将 i 除以 10 多少次?那就是 log₁₀ i。所以,复杂度不是 i 本身,而是 i 的位数。这是对数复杂度的另一个好例子。


关键特征:将问题规模按常数因子(这里是10)减少。



线性复杂度 O(n)



我们上次见过线性复杂度的例子,比如顺序搜索列表。事实上,我们上次看到的大多数例子都是带有迭代循环的。



例如,迭代计算阶乘:


def fact_iter(n):
prod = 1
for i in range(1, n+1):
prod *= i
return prod


这个循环会执行 n 次。循环内部是常数操作(乘法和赋值)。所以复杂度是 O(n)。



递归版本呢?


def fact_recur(n):
if n <= 1:
return 1
else:
return n * fact_recur(n-1)



递归调用内部的成本是常数(一次减法,一次乘法)。递归调用会进行 n 次(从 n 到 n-1 到 n-2...)。所以这也是线性的。



虽然计时可能会显示差异(递归调用需要设置栈帧,可能更慢),但就我们关心的增长阶而言,它们是相同的,都是 O(n)。


关键特征:每一步将问题规模减少 1,通常指示线性增长。如果是嵌套循环,可能会更复杂,但通常是线性的。


线性对数复杂度 O(n log n)

我们将在下次课看到这个例子。这是一种非常强大的算法,称为归并排序,是一种常见的排序算法,具有线性对数的性质。我们下次再详细讨论。


多项式复杂度 O(n^c)
我们上次也见过这个,通常出现在嵌套循环或嵌套递归调用中。
嵌套循环意味着我循环某个变量,而在其内部还有另一个循环。我们看到,如果外层循环是标准的迭代,将是线性的,但循环内部每次又进行线性量的工作,所以变成 n * n,即 O(n^2)。


指数复杂度 O(c^n)


这些是我们希望避免的算法,但有时无法避免。常见的特征是在递归函数中,每一步有多个递归调用。




汉诺塔示例


还记得汉诺塔问题吗?要移动一个包含 n 个不同大小盘子的塔,从一根柱子到另一根,每次只能移动最上面的盘子,且不能将大盘子放在小盘子上。


我们有一个优雅的递归解决方案:要移动大小为 n 的塔,需要:
- 将大小为
n-1的塔移到备用柱子上。 - 移动最底下的盘子到目标柱子。
- 将大小为
n-1的塔从备用柱子移到目标柱子。


那么它的复杂度是多少?我们可以使用一种称为递归关系的技巧来分析。


递归关系分析



设 T(n) 表示移动大小为 n 的塔所需的时间。根据算法:
T(n) = 2 * T(n-1) + 1 (移动两个 n-1 的塔,加上移动底部盘子的一次操作)。

我们可以展开这个关系:
T(n) = 2 * T(n-1) + 1T(n-1) = 2 * T(n-2) + 1,代入得T(n) = 4 * T(n-2) + 2 + 1T(n-2) = 2 * T(n-3) + 1,代入得T(n) = 8 * T(n-3) + 4 + 2 + 1


可以看到模式:经过 k 步展开后,T(n) = 2^k * T(n-k) + (1 + 2 + 4 + ... + 2^{k-1})。



当 k = n 时,T(n-k) = T(0) 是常数(移动空塔)。所以我们需要计算几何级数 1 + 2 + 4 + ... + 2^{n-1} 的和。



计算这个和有一个技巧:设 S = 1 + 2 + 4 + ... + 2^{n-1}。两边乘以 2 得 2S = 2 + 4 + ... + 2^n。两式相减得 S = 2^n - 1。


因此,T(n) = 2^n * 常数 + (2^n - 1) = O(2^n),是指数级的。


关键特征:在递归步骤中有多个(这里是两个)递归调用,这通常是指数增长的特征。



幂集示例




另一个展示指数增长特征的例子是幂集问题。

假设我有一个不重复的整数集合,例如 {1, 2, 3, 4}。我想生成所有可能的子集(包括空集和自身)。如何生成所有子集?



有一个优雅的递归解法:要生成集合 {1,...,n} 的幂集,假设我可以生成 {1,...,n-1} 的幂集。那么,{1,...,n} 的幂集包括:
{1,...,n-1}的所有子集(这些都不包含n)。- 上述每个子集加上元素
n(这些都包含n)。



这样,我们就从较小问题的解构造出了较大问题的解。你可以看到,集合的大小每一步都在翻倍。



幂集代码与复杂度分析


def genSubsets(L):
if len(L) == 0:
return [[]] # 包含空集的列表
smaller = genSubsets(L[:-1]) # 所有不包含最后一个元素的子集
extra = L[-1:] # 最后一个元素构成的列表
new = []
for small in smaller:
new.append(small + extra) # 创建包含最后一个元素的新子集
return smaller + new # 合并包含和不包含最后一个元素的子集


这段代码很简洁。它先解决较小的问题(L[:-1] 的幂集),然后为其中每个子集添加最后一个元素,形成新的子集,最后合并两部分。
我们来分析复杂度:
- 基础情况是常数操作。
- 递归调用
genSubsets(L[:-1])会调用自身n次(因为每次减少一个元素)。 - 循环
for small in smaller:依赖于smaller的大小。而smaller是L[:-1]的幂集,其大小为2^{n-1}。所以这个循环的大小是指数增长的。

因此,总复杂度是指数级的 O(2^n)。特征在于:虽然只有一个递归调用,但循环的大小每一步都在增长。
算法特征总结



我希望你们开始认识到,算法的设计选择会导致特定的复杂度类别。以下是一些常见特征:


- 常数 O(1):代码不依赖于问题规模。
- 对数 O(log n):每一步将问题规模减少




课程 P38:L12 - 搜索与排序算法 🧠
在本节课中,我们将学习搜索与排序算法。我们将回顾线性搜索和二分搜索,并深入探讨几种排序算法,包括冒泡排序、选择排序和归并排序。我们将分析它们的复杂度,并理解在何种情况下排序后再进行搜索是更优的策略。



概述



在之前的课程中,我们讨论了算法分析、复杂度以及增长阶。我们看到了常数、对数、线性、二次和指数级算法的例子。今天,我们将填补一个空白:对数线性算法,并利用它来讨论一类非常有价值的算法——搜索与排序算法。



搜索算法用于从集合中查找一个或多个项目。这个集合可以是隐式的(例如,寻找平方根时所有可能的数字区间),也可以是显式的(例如,一个学生记录列表)。我们将主要关注在列表上进行搜索。



搜索算法回顾


线性搜索

线性搜索是一种暴力方法,它简单地遍历列表中的每个元素,直到找到目标或到达列表末尾。这种方法不要求列表有序。

以下是线性搜索的代码示例:


def linear_search(lst, e):
found = False
for i in range(len(lst)):
if lst[i] == e:
found = True
return found


复杂度分析:在最坏情况下(目标元素不在列表中),我们需要检查列表中的每一个元素。循环部分执行 n 次(n 为列表长度),循环内的操作是常数时间。因此,线性搜索的最坏情况复杂度是 O(n),即线性复杂度。


二分搜索(折半查找)


二分搜索要求列表是有序的。它通过反复将待搜索区间减半来工作:比较中间元素与目标值,根据比较结果决定搜索左半部分或右半部分。

以下是二分搜索的递归实现示例:

def bisection_search(lst, e):
def helper(lst, e, low, high):
if high == low:
return lst[low] == e
mid = (low + high) // 2
if lst[mid] == e:
return True
elif lst[mid] > e:
if low == mid:
return False
else:
return helper(lst, e, low, mid - 1)
else:
return helper(lst, e, mid + 1, high)
if len(lst) == 0:
return False
else:
return helper(lst, e, 0, len(lst) - 1)

复杂度分析:在每一步,问题规模减半。因此,所需的步骤数(即递归调用次数)是 O(log n)。每次递归调用内部是常数时间操作(仅传递指针,不复制列表)。因此,二分搜索的复杂度是对数级 O(log n)。







排序的价值:何时先排序再搜索?



既然二分搜索如此高效(O(log n)),一个自然的想法是:是否应该先对列表排序,然后再进行搜索?


设 Sort 为排序的成本,Search 为搜索的成本。
- 单次线性搜索成本:O(n)
- 排序后单次二分搜索成本:Sort + O(log n)


我们希望 Sort + O(log n) < O(n)。然而,任何排序算法都至少需要查看列表中的每个元素一次,因此排序的复杂度至少是 O(n)。这使得上述不等式在单次搜索的场景下似乎不成立。


但是,关键在于摊销成本。如果我们计划对同一个列表进行 k 次 搜索,那么比较就变成了:
- k 次线性搜索成本:k * O(n)
- 一次排序 + k 次二分搜索成本:Sort + k * O(log n)




对于较大的 k 值,只要排序算法足够高效,先排序再多次搜索的策略就会更具优势。这就引出了我们的下一个主题:如何高效地排序?




排序算法

我们的目标是将一个列表中的元素按升序(从小到大)排列。




1. 冒泡排序



冒泡排序通过重复遍历列表,比较相邻元素,如果顺序错误就交换它们。每一轮遍历都会将当前未排序部分的最大元素“冒泡”到正确位置。
算法过程:
- 从列表开头开始,比较每对相邻元素。
- 如果前一个元素大于后一个元素,则交换它们。
- 完成一轮遍历后,最大的元素已位于列表末尾。
- 重复上述步骤,每次忽略已排序好的末尾部分,直到某一轮遍历中没有发生任何交换。




以下是冒泡排序的代码示例:
def bubble_sort(lst):
swap = False
while not swap:
swap = True
for j in range(1, len(lst)):
if lst[j-1] > lst[j]:
swap = False
lst[j-1], lst[j] = lst[j], lst[j-1]
复杂度分析:
- 外层
while循环:在最坏情况下(列表完全逆序),需要遍历n轮。 - 内层
for循环:每轮遍历大约n次比较/交换。 - 因此,冒泡排序的最坏情况和平均情况复杂度是 O(n²),即二次复杂度。



2. 选择排序


选择排序在每次迭代中找到未排序部分的最小元素,并将其与未排序部分的第一个元素交换。这样,列表的前端逐渐构建起有序序列。

算法过程:
- 找到列表中最小的元素,将其与索引 0 位置的元素交换。
- 在剩余列表(索引 1 到 n-1)中找到最小元素,将其与索引 1 位置的元素交换。
- 重复此过程,每次将未排序部分的第一个元素与其中的最小元素交换,直到整个列表有序。

循环不变式:在执行了 i 次迭代后,列表的前 i 个元素(前缀)是已排序的,并且前缀中的任何元素都不大于后缀(剩余未排序部分)中的最小元素。



以下是选择排序的代码示例:

def selection_sort(lst):
suffix_start = 0
while suffix_start != len(lst):
for i in range(suffix_start, len(lst)):
if lst[i] < lst[suffix_start]:
lst[suffix_start], lst[i] = lst[i], lst[suffix_start]
suffix_start += 1



复杂度分析:
- 外层
while循环:执行n次。 - 内层
for循环:第一次执行n次比较,第二次n-1次,依此类推。 - 总的比较次数约为 n + (n-1) + ... + 1 = n(n+1)/2。
- 因此,选择排序的复杂度也是 O(n²)。



3. 归并排序
归并排序采用分治策略。它将列表递归地分成两半,分别对两半进行排序,然后将两个已排序的子列表合并成一个完整的已排序列表。合并两个已排序列表是一个高效的操作。


算法过程:
- 分:如果列表长度为 0 或 1,则它已经有序,直接返回。否则,找到中点,将列表分成左右两个子列表。
- 治:递归地对左子列表和右子列表调用归并排序。
- 合:将两个已排序的子列表合并成一个新的已排序列表。合并时,比较两个子列表前端的元素,将较小的元素放入结果列表,并移动相应子列表的指针,直到一个子列表为空,然后将另一个子列表的剩余部分全部追加到结果中。

以下是归并排序的代码示例:


def merge(left, right):
result = []
i, j = 0, 0
while i < len(left) and j < len(right):
if left[i] < right[j]:
result.append(left[i])
i += 1
else:
result.append(right[j])
j += 1
while i < len(left):
result.append(left[i])
i += 1
while j < len(right):
result.append(right[j])
j += 1
return result
def merge_sort(lst):
if len(lst) < 2:
return lst[:]
else:
middle = len(lst) // 2
left = merge_sort(lst[:middle])
right = merge_sort(lst[middle:])
return merge(left, right)
复杂度分析:
- 合并操作
merge:需要遍历两个子列表的所有元素各一次。如果两个子列表总长度为n,则合并操作的复杂度为 O(n)。 - 递归树:归并排序将问题不断对半分割,形成一棵深度为 log₂ n 的递归树。
- 每层工作量:在递归树的每一层,我们需要合并所有该层的子问题。虽然子问题规模变小,但子问题数量增多。关键点在于,递归树的每一层需要处理的元素总数都是
n。因此,每一层的总工作量是 O(n)。 - 总复杂度:有 O(log n) 层,每层工作量为 O(n)。因此,归并排序的复杂度是 O(n log n),即对数线性复杂度。这是基于比较的排序算法中,最坏情况下的最优复杂度之一。

总结


本节课我们一起学习了搜索与排序算法。



- 我们回顾了线性搜索(O(n))和二分搜索(O(log n)),后者要求列表有序。
- 我们探讨了先排序再多次搜索的摊销成本思想,并认识到高效的排序算法是实现这一策略的关键。
- 我们深入分析了三种排序算法:
- 冒泡排序和选择排序,它们简单直观,但复杂度为 O(n²),适用于小规模数据。
- 归并排序,它采用分治策略,具有优异的 O(n log n) 复杂度,是处理大规模数据的高效选择。


通过理解这些算法的原理和复杂度,我们能够根据具体问题(如数据规模、搜索频率)选择最合适的策略。归并排序的 O(n log n) 复杂度使其成为将排序成本摊销到多次高效搜索中的理想基础。至此,我们已经涵盖了常数、对数、线性、对数线性、二次和指数级等主要的算法复杂度类型。
课程 P4:L1.4 - 连接(Bindings) 🧠


以下内容基于知识共享许可协议提供。您的支持将帮助 MIT OpenCourseWare 继续免费提供高质量的教育资源。如需捐款或查看来自数百门 MIT 课程的其他材料,请访问相关网站。
在本节课中,我们将学习编程中的“连接”(Bindings)概念,并通过一个具体的代码示例来理解变量赋值与计算的关系。我们将看到,程序中的计算是静态的,除非我们明确指示,否则它不会自动更新。
代码示例分析 📝

在上一节中,我们介绍了变量的基本概念。本节中,我们来看看一个具体的练习代码,它演示了连接(变量绑定)的一个重要特性。
以下是练习中的代码:

USA_gold = 46
UK_gold = 27
Romania_gold = 1
total_gold = USA_gold + UK_gold + Romania_gold
print(total_gold)
Romania_gold += 1
print(total_gold)
这段代码首先定义了三个变量,分别存储美国、英国和罗马尼亚的金牌数。然后,它计算了金牌总数并将其存储在变量 total_gold 中。接着,程序增加了罗马尼亚的金牌数,并再次打印 total_gold 的值。
关键概念解析 🔑
现在,我们来分析这段代码的执行逻辑。核心在于理解变量 total_gold 的值是如何确定的。
当程序执行到 total_gold = USA_gold + UK_gold + Romania_gold 这一行时,它进行了一次计算。此时,USA_gold 是 46,UK_gold 是 27,Romania_gold 是 1。因此,计算结果是:
公式: 46 + 27 + 1 = 74
所以,变量 total_gold 被绑定(或称为赋值)为数值 74。这个绑定关系在赋值语句执行的那一刻就固定了。
程序行为与常见误区 ⚠️
上一节我们介绍了变量的赋值,本节中我们来看看一个常见的误区:认为变量会随着其组成部分的变化而自动更新。
代码随后执行 Romania_gold += 1,这行代码将 Romania_gold 的值从 1 增加到了 2。然而,变量 total_gold 的值并不会因此自动改变。因为 total_gold 存储的是第一次计算的结果(74),而不是一个动态的公式。
因此,两次 print(total_gold) 语句的输出都是 74。
如果要让第二次打印输出更新后的总数(75),我们需要在修改 Romania_gold 后,重新执行一次求和计算,并再次赋值给 total_gold。
以下是修改后的正确代码逻辑:
USA_gold = 46
UK_gold = 27
Romania_gold = 1
total_gold = USA_gold + UK_gold + Romania_gold
print(total_gold) # 输出 74
Romania_gold += 1
# 关键步骤:重新计算并绑定新值
total_gold = USA_gold + UK_gold + Romania_gold
print(total_gold) # 输出 75
练习回顾与总结 ✅

对于这个练习,如果你没有得到正确答案,请务必亲自尝试将代码放入 Python 环境中运行测试。多数人能够正确理解,如果你没有,请通过实践来加深理解。

本节课中我们一起学习了“连接”(Bindings)的核心概念。我们明白了:
- 变量是对一个值的绑定,这个绑定在赋值语句执行时发生。
- 绑定是静态的。一个变量(如
total_gold)的值不会因为其来源变量(如Romania_gold)的改变而自动更新。 - 要更新依赖于其他变量的结果,必须显式地重新执行计算和赋值操作。
记住,计算机严格按指令执行。它只会做你明确告诉它的事情,不会进行假设或自动推导。这是编程思维中需要建立的重要基础。




P5:L2.1- 分支与循环 🧭🔄



以下内容基于知识共享许可协议提供。您的支持将帮助MIT OpenCourseWare继续免费提供高质量的教育资源。如需捐款或查看来自数百门MIT课程的其他材料,请访问相关网站。





大家好,我们开始上课。下午好,欢迎来到6.0001和600课程的第二讲。和往常一样,如果你想跟着课堂内容学习,请在每天上课前至少一小时下载我将提供的幻灯片和代码。







首先快速回顾一下上节课的内容。上节课我们简单讨论了什么是计算机。我认为上节课的主要收获是,计算机只做被告知的事情。它不会自发地自己做决定。作为程序员,你必须通过编写程序来告诉它你想让它做什么。我们讨论了简单的对象,这些对象有不同的类型,比如整数、浮点数和布尔值。然后我们对它们进行了一些简单的操作。

今天,我们将学习一种新的对象类型——字符串。然后,我们将为编程工具箱添加两个强大的工具:如何在程序中进行分支,以及如何让计算机在程序中重复执行某些任务。


字符串对象 🔤


我们开始学习字符串。字符串是一种新的对象类型。到目前为止,我们见过整数(整数)、浮点数(小数)和布尔值(True和False)。字符串是字符序列,这些字符可以是字母、数字、特殊字符,也可以是空格。你通过将字符序列用引号括起来来告诉Python你在处理字符串对象。


例如,我创建一个值为“hello there”的对象。Python知道它是一个字符串对象,因为我们用引号将它括起来。引号可以是双引号或单引号,只要保持一致就可以。我们将这个对象绑定到名为hi的变量上,使用的是赋值运算符等号。从现在开始,每当我们引用变量hi时,Python就会知道它的值是那个字符序列。

今天我们将学习可以对字符串做的两件事:连接和重复。

字符串连接

连接是一个花哨的词,意思是使用加号运算符将字符串拼接在一起。例如,我有一个原始变量hi,然后创建一个新变量name,并将字符串“Anna”赋值给变量name。当我在hi和name这两个变量之间使用加号运算符时,Python会查看这两个变量的值,然后把它们拼在一起。

让我们在Spyder中看看这个例子。我有变量hi和变量name,然后连接它们并打印出来。运行代码,注意它打印出“hello thereAnna”。这里没有空格,因为连接运算符(加号)不会隐式地添加任何空格。这再次说明了计算机只做被告知的事情。如果我们想添加空格,必须手动插入空格。
这就是第8行代码。在这行中,我们将变量hi的值与一个空格(注意我们用引号括起来的空格)以及name连接起来。如果我们打印这个值,现在就能看到正确的问候语了。
字符串重复



接下来我们看看与字符串相关的另一个操作:星号运算符。Python允许你在字符串和数字之间使用星号运算符,它代表乘法。当你这样做时,Python解释器会将其解释为:将该字符串重复该数字的次数。

例如,我创建一个有趣的问候语,将hi的值(“hello there”)与空格加上name连接起来。注意,我在这里使用括号告诉Python先执行这个操作,然后将结果乘以3。如果我打印出来,它会将空格和我的名字重复三次,然后与“hello there”连接起来。打印结果正是如此。


打印输出与用户输入 💬


上节课我们简单讨论了print,今天我将讨论一些与print相关的细节。你使用print与用户交互,编写向用户输出内容的程序很酷。关键字是print,然后在括号里放入你想展示给用户的内容。


在这个小程序中,我创建一个名为x的变量,赋值为1,然后打印。这里我进行了类型转换,将整数1转换为字符串,稍后你会明白为什么。我想提请大家注意几件事。


在第一个print语句中,我到处使用逗号。根据定义,你可以在print的括号内使用逗号。如果使用逗号,Python会自动在两个逗号之间的值之间添加一个空格。所以“my fav num is”是第一项,逗号后的第二项是x的值。如果你使用逗号,Python会自动为你插入空格。
有时你可能想要空格,有时可能不想要。如果你不想要,可以使用连接操作符加号,将所有小片段加在一起形成一个大的字符串。使用逗号的好处是,逗号之间的对象不一定都是字符串;但缺点是,到处都会有空格。使用加号运算符的好处是Python完全按照你的指示去做,但所有东西都必须是字符串对象。所以“my fav num is”必须是字符串对象,你必须将所有数字转换为字符串对象等等。



这段代码几乎相同。这里我没有任何空格。你可以看到第一行到处都是逗号,所以我将在打印的每个东西之间都有空格。第二行是逗号和连接操作的组合,所以根据我使用逗号的位置,我会有一个额外的空格。


运行这段代码,注意第一行在所有对象之间都添加了空格。第二行在某些地方添加了空格,你可以追踪并查看空格具体添加在哪里。最后一行则没有。

获取用户输入


向控制台打印内容很好,但编写交互式程序的第二部分是从用户那里获取输入,这部分更有趣。如果你已经完成了问题集0,你可能已经尝试自己理解这一点。


从用户那里获取输入的方法是使用名为input的命令函数。在括号内,输入你想提示用户的内容。在我的例子中,我有input,然后提示“type anything”。用户将看到这段文本,然后程序将停止,等待用户输入内容并按回车键。一旦用户按下回车键,用户输入的任何内容都会变成一个字符串。如果用户输入一个数字,例如,那将成为字符串形式的数字。用户输入的所有内容都将作为字符串处理。

在这行代码中,无论用户输入什么都会变成一个字符串,我们将把这个字符串对象绑定到名为text的变量上。现在,在我的程序中,我可以对这个变量text做任何我想做的事情。在这个例子中,我将打印5 * text。例如,如果用户输入“ha”,我将打印“ha”五次。如果用户输入“5”,你认为会打印出什么?是25还是“5”重复五次?答案是“5”重复五次。
通常,你不想将数字作为字符串来处理,你想将数字作为数字来处理。所以你必须进行类型转换。我们上节课学过,你可以通过在input前加上类型转换来转换。你可以将其转换为你想要的任何类型。这里我将其转换为int,但如果你想处理浮点数,也可以转换为float。只要用户输入的是Python知道如何转换的数字,它就会将其转换为数字本身。
所以在这个例子中,如果用户给我“5”,我将打印出5乘以5的结果,即25。


比较运算符与逻辑运算符 ⚖️
接下来我们要学习如何在代码中添加测试。在能够添加测试之前,你需要能够进行实际的测试。这就是比较运算符的用武之地。


假设i和j是变量,以下比较将给你一个布尔值,即True或False。这就是你的测试。如果i和j是变量,你可以在整数与整数、浮点数与浮点数、字符串与字符串之间进行比较,也可以在整数和浮点数之间进行比较,但不能在字符串和数字之间进行比较。事实上,如果你在Python中尝试这样做,比如问字母“A”是否大于5,你会得到一些错误提示,这告诉我Python不理解其含义。
就像在数学中一样,我们可以进行这些常见的比较:大于、大于等于、小于、小于等于。我想提请大家注意相等性比较。单个等号是赋值,你将一个值赋给一个变量。但当你使用双等号时,这是相等性测试:变量i的值是否与变量j的值相同?这同样会给你一个布尔值True或False。你也可以用感叹号加等号测试不相等,意思是变量i的值是否不等于变量j的值?如果是,则为True;否则为False。

这些是用于整数、浮点数和字符串的比较运算符。对于布尔值,你可以使用一些逻辑运算符。最简单的是取反。如果a是一个具有布尔值的变量,那么not a就是将其取反。如果它是True,那么not a就是False,反之亦然。这个表格代表了我在这里所说的内容。你可以在布尔变量上使用and和or这两个Python关键字,并得到结果。a and b只有在a和b都为True时才为True。a or b只有在a和b都为False时才为False。这是完整的真值表。

分支结构 🌳
现在我们已经有了进行测试的方法,可以为我们的编程工具箱添加分支功能了。既然我们已经有了进行测试的方法,就可以在程序中添加一些分支了。
这是一张MIT的地图。我将通过一个小例子来说明为什么我们希望在代码中进行分支。我想在这节课之后,你将能够编写出我将解释的这个算法。


我们大多数人把MIT看作一个迷宫。当我第一次来到这里时,我显然注册了免费食品邮件列表。MIT就像一个迷宫,我不知道去免费食品的最短路径是什么。一种思考方式是,我只想去到有免费食品的地方。一个非常简单的算法是:我将伸出我的右手,并确保我的右手始终贴着一面墙。然后我将右手始终贴着墙在校园里走,最终我会到达有免费食品的地方。可能食物已经没了,但我会到达那里。
算法如下:如果我的右手必须始终贴着一面墙,那么如果我右侧没有墙,我就向右走,直到碰到墙。然后,如果我右侧有墙并且我可以向前走,我就继续向前走。如果我继续向前走,右侧和前面都有墙,我就转身向左走。然后,如果我右侧、前面和左侧都有墙,我就向后走。

通过这个相当简单的算法,我只是沿着路径走,始终让墙在我的右侧,最终我会到达我需要去的地方。注意,我用简单的英语使用了“如果……那么……”这样的结构。

在编程中,我们有相同的结构,这些相同的直观词语可以用来告诉Python做一件事或做另一件事,或者从一组不同的可能性中选择。这样,我们就可以让计算机为我们做决定。现在你可能会想,你说过计算机不能自己做决定。实际上,是我们程序员将这些决定构建到程序中,计算机所要做的就是到达决策点,然后说:“好的,这是一个决策点,我应该向左走还是向右走?我该选哪一个?”这类决定是由你作为程序员创建的,计算机只需要做出决定并选择一条路径。
在编程中,有三种简单的方法可以为程序添加控制流:做一个决定并选择是否执行某件事或执行另一件事。第一种是简单的if语句。给定一个只有线性执行语句的程序,每当我到达一个if语句时,你将检查条件。条件将被评估为True或False。如果我在这里到达条件,并且条件为True,那么我将额外执行这组表达式。但如果条件为False,我将继续执行程序,不执行那组额外的指令。
Python如何知道要执行哪些指令?它们将位于这个我们称为代码块的部分。代码块通过缩进来表示。所有缩进的内容都是该if代码块的一部分,通常是四个空格缩进。这就是你编写代码来决定是否执行这个额外内容的方式。
现在假设我不仅仅想执行一个额外的事情,我想到达一个条件点,然后说我要么走这条路,要么做其他事情。这就是if/else结构。这个if/else结构表示:这是我的代码,我在这里到达了我的决策点。如果这个if里面的条件为True,那么我将执行这组语句。但如果条件不为True,我将不执行那组语句,而是执行else下面的内容。使用这种结构,我要么执行一组表达式,要么执行另一组,但永远不会同时执行两者。在执行完一组或另一组之后,我将继续程序的常规执行。

我们能够选择一件事或另一件事,但如果我们想要有多个选择呢?例如,如果某个数字等于0,我想做这个;如果等于1,我想做那个;否则如果等于2,我想做这个,等等。这就是最后一个结构elif的用武之地。elif是else if的缩写形式。

首先,我们检查这个条件是否为True。我们正在执行程序,到达决策点。如果条件为True,我们将执行这组指令。如果条件不为True,我们将检查下一个条件(即elif部分)。如果那个条件为True,我们将执行一组不同的指令。你可以有多个elif,根据哪个条件为True,你将执行不同的指令。最后的else是一个包罗万象的条件,如果前面的条件都不为True,就执行这最后一组表达式。在这种情况下,你将在这些三个或四个(或任意多个)路径中选择一个。当你做出选择后,你将执行剩余的指令集。

这种方式的工作原理是:如果多个条件都为True,你实际上只会进入其中一个,并且是第一个评估为True的那个。所以你永远不会进入多个代码块,总是只进入一个,并且是第一个为True的那个。

注意,我们使用缩进来表示代码块。这实际上是我非常喜欢Python的一点,它迫使你编写漂亮、美观、易读的代码,因为它迫使你缩进所有属于代码块的内容。这样你可以很容易地看到控制流的走向、决策点等。


在这个特定的例子中,我们有一个if语句检查两个变量是否相等,还有一个if-elif-else结构。在这个例子中,根据变量x和y的值,我们将进入这个代码块、那个代码块或另一个代码块,并且只进入一个代码块,即第一个为True的那个。
注意,你可以有嵌套的条件语句。在这个第一个if里面,我们有另一个if。这个内部的if只有在我们进入外部的这个if时才会被检查。
我想指出一点:有时在检查相等性时,你可能会忘记使用双等号。如果你只使用一个等号,Python会给你一个错误,提示语法错误,并高亮显示这一行。然后你就会知道那里有错误,应该使用相等性比较,因为在if内部进行赋值是没有意义的。

循环结构 🔄

我们已经学习了分支和条件语句,让我们尝试将其应用到一个游戏中。剧透一下,我们还无法完全实现,需要学习一个新东西。

回到20世纪80年代,有《塞尔达传说》这款游戏,画面很酷。其中有一个场景是“迷失森林”。简单来说(如果有塞尔达铁杆粉丝请见谅),基本思想是:如果你从左边进入森林,然后只要你一直向右走,它会一遍又一遍地显示相同的屏幕。诀窍是你只需要向后走,然后就能退出森林。

使用我们目前所学的知识,我们可以这样编写代码:如果用户向右退出,则将背景设置为森林背景;否则将背景设置为退出背景。假设用户向右走了,你会向他们显示森林背景,然后再次询问他们想去哪里。如果他们向右退出,将背景设置为森林背景;否则将背景设置为退出背景,如此反复。你注意到这似乎没有尽头。你知道用户可能会一直向右走多少次吗?他们可能非常执着,也许一千次,也许一次就能走出森林。这些嵌套的if语句可能会非常深,我们不知道会有多深。
因此,仅凭我们目前所学的知识,我们无法真正编写出这个有趣的小游戏。这时就需要循环,特别是while循环。这段可能无限嵌套的if语句代码,可以用这三行while循环重写。


我们说:当用户向右退出时,将背景设置为森林背景。使用while循环,它将执行我们在循环内告诉它要做的事情,然后再次检查条件。只要条件为True,它就会继续执行那个小循环。一旦条件变为False,它就会停止循环,并执行while循环之后的代码。

这就是while循环的基本工作原理。我们有while这个关键字,条件是被评估为True或False的表达式。同样,我们有一个缩进的代码块,它告诉Python只要条件为True,就执行这些表达式。条件为True时,你执行代码块中的每个表达式。当你到达代码块末尾时,再次检查条件。如果仍然为True,你继续执行这些表达式。


这里有一个小游戏,通过这几行代码,我们能够编写出《塞尔达传说》中“迷失森林”的场景(顺便说一句,我编写的这个图形比原版《塞尔达传说》更差)。我打印出以下内容:“你在迷失森林,向左走还是向右走?”我的程序会说:“你在迷失森林,向左走还是向右走?”然后获取用户输入。只要用户继续输入“right”,就向他们显示这段文本并再次询问他们。我通过再次使用input来询问他们。如果用户不输入“right”,而是输入“left”,你将退出这个循环,并打印出“你走出了迷失森林”。


我必须向你们展示这个,因为我花了太多时间在上面。我决定改进幻灯片中的代码,并在这里写下了你们也可以改进的方法。如果我运行我的代码,你会看到“你在迷失森林,向左走还是向右走?”如果我说“left”,那么“耶,我走出了迷失森林!”但如果我输入“right”,那么我就被困住了。我砍倒了一些树,你可以看到这里没有树了。我做了一张桌子,然后把它掀翻了。

如果你想尝试,我在注释里写了扩展内容:尝试使用一个计数器。如果用户前两次输入“right”,就显示一个悲伤的表情。但如果用户输入超过两次,就让他们砍倒一些树,做一张桌子并掀翻它。如果你想测试自己是否理解了循环,这是一个有趣的小扩展。
while循环与for循环 🔁


到目前为止,我们已经使用while循环来获取用户输入,这实际上是使用while循环的一个合理场景,因为你实际上不知道用户会输入多少次。你也可以使用while循环来保持一个计数器,并编写计数的代码。但如果你这样做,有两件事需要注意:第一是循环计数器的初始化,第二是递增计数器的语句。


第二个之所以重要,是因为让我们看看这里的条件:while n < 5。如果你没有递增n的语句,每次循环你只会打印0,并且会陷入无限循环。不过我想展示一下,即使你真的陷入无限循环,也不是世界末日。我可以说while True: print(0)。这会给我一个无限循环。注意它只是在反复打印字母“P”。如果我让它继续运行,它会减慢计算机速度。所以我会按Ctrl+C(或Command+C),这将停止程序打印。


所以,万一你在程序中不小心进入了无限循环,只需转到控制台并按Ctrl+C,这就会停止它,

课程 P6:L2.2 - 字符串 🧵

以下内容基于知识共享许可协议提供。您的支持将帮助 MIT OpenCourseWare 继续免费提供高质量的教育资源。如需捐款或查看来自数百门 MIT 课程的其他材料,请访问相关网站。


在本节课中,我们将学习字符串的基本操作,特别是字符串的拼接与重复。我们将通过一个具体的例子来理解这些概念。
我有一个名为 name 的变量,其值为 "Br"。同时,我有一个名为 repeat 的变量,其值为 "ell"。现在,我创建了一个新变量 U,它是 name 与 repeat 重复四次的结果进行拼接。
具体来说,代码可以表示为:
name = "Br"
repeat = "ell"
U = name + repeat * 4
通过观察,大约 90% 的参与者已经理解了这一点,这个操作将得到结果 "Brellllll"。


本节课中,我们一起学习了字符串的拼接(使用 + 运算符)和重复(使用 * 运算符)。通过简单的变量赋值和运算,我们可以组合出新的字符串。理解这些基础操作是进行更复杂文本处理的第一步。

课程 P7:L2.3 - 程序中的「比较」逻辑 🧮

以下内容基于知识共享许可协议提供。您的支持将帮助 MIT OpenCourseWare 继续免费提供高质量的教育资源。如需捐款或查看来自数百门 MIT 课程的其他材料,请访问相关网站。
在本节课中,我们将学习如何在程序中使用「比较」逻辑。我们将通过创建变量、进行比较操作以及使用布尔运算符来理解程序是如何做出判断的。这些是编程中构建条件语句和决策逻辑的基础。

变量赋值与比较操作
首先,我们来看一个简单的例子,它演示了如何创建变量并进行比较。
我创建了一个名为 pset_time 的变量,并将其赋值为 15。接着,我创建了另一个名为 sleep_time 的变量,并将其赋值为 8。然后,我将打印下面这个表达式的值。
这个表达式是一个条件判断,内容是:sleep_time 是否大于 pset_time?在 Python 中,解释器会用变量的实际值来替换这些变量名。
所以,实际判断的是:8 是否大于 15?这个结果是 False(假)。
布尔运算
接下来,我们看看对布尔值(True 或 False)的操作。这里有一个关于 derive(推导)和 drink(喝)的运算。
derive 是 True(真),drink 是 False(假)。我使用 and(与)运算符来判断是否应该同时满足 drink 和 derive 两个条件。


本节课中,我们一起学习了程序中的比较逻辑。我们首先创建了变量并为其赋值,然后使用比较运算符(如 >)来评估条件表达式,得到布尔结果。最后,我们接触了布尔运算符 and,它用于组合多个布尔条件。理解这些基本概念是编写能够做出判断的程序的关键第一步。
课程 P8:L2.4 - 分支结构 🧭

在本节课中,我们将要学习编程中的分支结构。分支结构允许程序根据特定条件执行不同的代码块,这是实现决策逻辑的核心。我们将通过一个简单的例子,理解 if、elif 和 else 语句是如何工作的。
概述
分支结构是控制程序流程的基本方式。它通过检查一个或多个条件,决定执行哪一部分代码。这就像在岔路口选择方向,程序会根据条件判断的结果走向不同的路径。
条件判断流程
以下是一个典型的分支结构执行流程。我们假设用户输入了两个数字,分别存储在变量 X 和 Y 中。
首先,程序会检查第一个条件:X 是否等于 Y。
if X == Y:
# 执行某些操作

如果这个条件为真(True),程序将执行其对应的代码块,然后跳过后续的所有 elif 和 else 检查。
如果第一个条件为假(False),程序将继续检查下一个 elif 条件。
elif X < Y:
# 执行另一些操作
程序会按顺序检查每一个 elif 条件,直到找到第一个为真的条件,并执行其代码块。
如果所有 if 和 elif 条件都为假,程序将执行 else 块中的代码(如果存在的话)。
else:
# 当以上条件都不满足时执行
示例解析
让我们通过一个具体例子来理解这个过程。
假设用户输入了 X = 0 和 Y = 5。
- 程序首先检查条件:
X == Y(即0 == 5)。这个条件为假,因此跳过其代码块。 - 接着,程序检查下一个条件:
X < Y(即0 < 5)。这个条件为真。 - 由于这是第一个被满足的条件,程序将执行这个
elif块内的代码,例如打印"X is less than Y"。 - 执行完毕后,整个分支结构结束,后续的
elif或else都不会再被检查。
这个机制确保了只有第一个被满足的条件对应的代码会被执行。

总结
本节课中我们一起学习了分支结构。我们了解到程序通过 if、elif 和 else 语句实现条件判断,并且会顺序执行,直到找到第一个为真的条件。掌握分支结构是编写能够做出决策的智能程序的关键一步。

课程 P9:L2.5 - while循环 🌀

以下内容基于知识共享许可协议提供。您的支持将帮助 MIT OpenCourseWare 继续免费提供高质量的教育资源。如需捐款或查看来自数百门 MIT 课程的其他材料,请访问相关网站。


在本节课中,我们将学习 while 循环,并通过一个课堂练习来深入理解其工作原理。我们将分析一个具体的代码示例,探讨用户输入如何影响循环的执行,并学习如何使程序对不同的输入格式更加灵活。
课堂练习:迷失森林 🌲
让我们来看一个关于 while 循环的课堂练习示例。代码模拟了一个场景:你在一片迷失的森林中,需要选择向左或向右走。
以下是代码的核心逻辑:while 循环会检查用户的输入是否等于一个特定的字符串。如果是,程序会重复询问相同的问题,直到输入不符合条件为止。
while n == "right":
n = input("You are in the lost forest. Go left or right? ")
我的问题是:当你输入大写的 “R” 时会发生什么?我认为班上大多数同学都答对了,也许有些同学后来改变了答案,但你是对的。程序会再次询问“向左还是向右?”。这是因为 Python 对字符串匹配非常严格。我们告诉它,用户输入必须完全匹配这个字符串。因此,即使输入的是 “Right”,由于大小写不同,它也不匹配。
处理不同输入格式 🛠️
如果你想处理这种情况,让程序同时接受“right”和“Right”,你需要扩展循环中的条件判断。
以下是修改后的代码示例:
while n == "right" or n == "Right":
n = input("You are in the lost forest. Go left or right? ")
通过使用逻辑运算符 or,我们让循环在输入为“right”或“Right”时都继续执行。这样,程序就对用户输入的大小写不再敏感,变得更加灵活。

总结 📝

本节课我们一起学习了 while 循环的基本用法。我们通过“迷失森林”的例子,看到了循环如何根据条件重复执行代码块。重要的是,我们认识到 Python 中字符串比较是区分大小写的,并学会了如何使用逻辑运算符 or 来让条件判断更包容,从而处理不同格式的用户输入。理解这些概念对于编写健壮且用户友好的程序至关重要。


浙公网安备 33010602011771号