UCB-CS10-计算之美与乐趣-全-
UCB CS10 计算之美与乐趣(全)
1:课程介绍与抽象概念入门 🚀








在本节课中,我们将学习CS10课程的整体结构、核心目标,并初步接触计算机科学中最重要的概念之一:抽象。我们将了解如何使用Snap!编程语言进行简单的编程,并理解抽象如何帮助我们管理复杂性。


课程概述与人员介绍 👥
大家好,欢迎来到春季学期的CS10课程。由于Zoom链接设置问题,我们稍晚几分钟开始,感谢大家的耐心等待。
CS10是一门关于计算之美与乐趣的课程。学习计算机科学有很多原因:它影响力巨大,在各个研究领域(如生物学、物理学、历史学、数据科学等)都极具实用性,并且能提供丰富的就业机会。本课程虽然不涉及大量数学,但会探讨一些更具理论性的计算机科学概念和技巧,它们可能初看起来有些奇特,但能让你编写出非常酷的程序。
我是本课程的讲师Michael。我在伯克利读大一时就选修了CS10,之后一直参与课程的维护工作,例如维护Snap!编程语言的云端后端基础设施。Snap!是我们将在课程中使用的一种编程语言,它专为CS10这类旨在提供有趣编程体验的课程而设计。
我们的课程团队还包括:
- 头助教Maddie:负责讨论课和课程后勤。
- 讨论课助教Dea:负责周二和周三的讨论课。
- 实验课助教Ben:负责周二周四下午4-6点的实验课,并管理学术实习生。
- 其他助教和读者:他们将在实验课、办公时间和项目评分中提供帮助。
- 学术实习生:他们是往届的CS10学生,将在实验课中提供志愿帮助。
课程内容与结构 📚
上一节我们认识了课程团队,本节中我们来看看课程将涵盖哪些内容以及如何组织。
CS10课程将围绕几个核心大概念展开:
- 抽象:隐藏复杂性,使事物更易于理解。
- 算法:如何概念化和设计解决问题的步骤。
- 递归:一个非常酷且需要动脑的概念。
- 函数即数据:我们将探讨这意味着什么。
- 数据表示:如何思考大大小小的数据。


此外,课程的一个重要部分是探讨计算的社会影响。学习编程是一项强大的技能,可以用来做好事,也可能被滥用。我们希望通过阅读和讲座,让大家理解这一点。
课程的主要组成部分如下:
- 讲座:每周一和周三进行,时长约一小时,介绍核心概念并进行演示。
- 实验课:每周二和周四进行,时长约两小时,是培养编程技能的主要场所。我们强烈鼓励结对编程。
- 讨论课:每周五进行,时长一小时,用于深入探讨棘手概念、大想法和社会议题。
- 作业与项目:共6个作业,其中2个是自创项目,1个是与社会影响相关的写作项目。
- 考试:包括第4周的“探索性测试”、第9周的期中考试和期末考试。
所有课程资料、日程和链接都发布在课程网站 cs10.org 上。我们将使用Ed进行讨论,使用Gradescope提交作业,并使用一个名为OEA的互动平台进行远程实验课和办公小时。
课程政策与 logistics ⚙️
了解了课程内容后,我们还需要了解一些重要的课程政策。
以下是关于课程安排和评分的关键信息:
- 报名:实验课和讨论课的分组报名通过课程网站或Ed上的链接进行,而不是CalCentral。
- 考勤与弹性:讲座出勤和测验共计25分,但会有超过25分的机会,因此有一定弹性。实验课和讨论课会各去掉最低的几次成绩。
- 迟交政策:你有8个“宽限日”,可以在整个学期中无理由延迟提交作业(项目和作业),无需事先申请。
- 考试政策:考试计划线下进行,但会为远程学生提供在线监考版本。期末考试成绩可以按一定规则覆盖期中或探索性测试中较低的成绩。
- 候补名单:课程可能会扩容约20个名额。通常在前几周会有10-15%的学生退课。建议候补学生先参与课程,等待几周。
核心概念:抽象 🧩
现在,让我们深入本课的第一个核心概念:抽象。抽象是计算机科学中最重要的思想之一,它让我们能够通过隐藏复杂性来使事物更易于理解。
抽象的核心是细节移除。以现代汽车为例,无论内部技术如何变化(燃油喷射、防抱死刹车系统、电动汽车),其操作界面(油门踏板和刹车踏板)近百年来基本保持不变。驾驶员不需要理解引擎控制模块如何工作,只需知道踩下踏板的程度与汽车加速的关系即可。这就像一个复杂的系统被简化为一个简单的接口。
在编程中,我们做同样的事情。让我们用Snap!来演示。假设我们要画一个正方形。
最初,我们可能会写下一系列重复的指令:
移动 20 步
右转 90 度
移动 20 步
右转 90 度
移动 20 步
右转 90 度
移动 20 步
在Snap!中,我们可以使用 重复 块来抽象这个过程:
重复 4 次
移动 20 步
右转 90 度

通过这个 重复 块,我们将“重复执行某个动作4次”这一概念抽象了出来。这使得代码更简洁、更易读,也更容易修改(例如,要画六边形,只需将“4”改为“6”)。这就是抽象的力量:管理复杂性,避免不必要的重复工作。
总结与预告 🎯

本节课中,我们一起学习了CS10课程的概况、教学团队、课程结构与核心政策。我们重点介绍了计算机科学的基石——抽象的概念,并通过Snap!编程演示了如何使用重复块来抽象重复性任务,从而简化代码。
这只是开始。在接下来的实验课中,你将亲自探索Snap!,并开始构建自己的抽象。下周,我们将继续深入探讨抽象和其他强大的计算思想。

记住,CS10的成功取决于你的参与。请充分利用实验课、讨论课和办公时间。祝大家学习愉快,我们下周见!
2:抽象与函数

在本节课中,我们将学习计算机科学中的两个核心概念:抽象和函数。我们将探讨如何通过创建可重用的代码块(在Snap!中称为“积木”)来构建程序,并理解不同类型的函数如何工作。



抽象:隐藏复杂性


上一节我们介绍了计算思维的基本概念。本节中,我们来看看抽象——它是如何让我们通过隐藏底层细节来管理复杂性的。
抽象的核心思想是创建一个清晰的接口。我们只关心某个功能做什么,而不必关心它如何做到。例如,汽车的油门和刹车就是抽象:你踩下踏板,汽车就会加速或减速,但你不需要知道发动机或液压系统内部的具体工作原理。

在编程中,我们通过创建积木来实现抽象。以下是我们创建的一个“在指定位置画正方形”的积木:


定义 在 (x) (y) 位置画正方形
移动到 x: (x) y: (y)
落笔
重复执行 (4) 次
移动 (50) 步
右转 (90) 度
结束
抬笔

这个积木定义了一个接口:在 (x) (y) 位置画正方形。使用者只需要提供x和y坐标,积木就会在舞台的对应位置画出一个正方形。至于它是如何通过移动和转向来实现的,使用者无需关心。这就是抽象屏障——它将“做什么”(接口)与“如何做”(实现)分离开来。
函数:进行计算与报告结果

理解了抽象的基本概念后,我们现在来深入探讨一种特殊的抽象:函数。在Snap!中,函数主要体现为命令和报告器两种积木。



命令积木



命令积木执行一个动作,但不返回任何值。它们通常用于产生“副作用”,比如在舞台上画画、让角色说话或移动。

例如,我们之前创建的“画正方形”积木就是一个命令。它完成了画图的任务,但我们无法将其结果(比如正方形的面积)直接用于下一步计算。

报告器积木



报告器积木则不同,它们会计算并返回一个值。这个值可以被其他积木使用,从而实现功能的组合。

让我们创建一个报告器积木来计算一个数的立方:

定义 立方 (n)
报告 ( (n) * (n) * (n) )


当我们使用 立方 (2) 时,这个积木会计算 2 * 2 * 2 并报告结果 8。这个结果 8 可以像数字一样被使用,例如放入另一个积木的输入槽中。


以下是报告器积木的强大之处:
- 可组合性:你可以将一个报告器的结果作为另一个报告器或命令的输入。例如:
说 (立方 (立方 (2)))会先计算立方(2)=8,再计算立方(8)=512,最后让角色说出“512”。 - 命名与清晰度:给一个计算过程起名(如“立方”)能让代码更易读,比直接写
n * n * n更清晰地表达了意图。

纯函数与副作用

并非所有函数都是一样的。一个重要的分类是区分纯函数和非纯函数。
什么是纯函数?
纯函数具有以下三个特征:
- 接受零个或多个输入。
- 对于相同的输入,总是返回相同的输出。
- 没有副作用(即不修改或依赖函数外部的任何状态)。

我们之前创建的 立方 (n) 积木就是一个纯函数。无论何时调用 立方 (2),它永远返回 8,并且不会改变舞台、变量或其他任何东西。


非纯函数与副作用
现在,考虑一个不同的积木 按 (增量) 计数:

定义 按 (增量) 计数
将 [计数器 v] 增加 (增量)
报告 (计数器)
这个积木不是纯函数,因为它违反了纯函数的规则:
- 副作用:它修改了一个全局变量
计数器。 - 输出不一致:第一次调用
按 (3) 计数可能报告3,第二次调用按 (3) 计数则会报告6,因为计数器的值被记住了。


这种依赖于外部状态(记忆功能)的函数就是非纯函数。虽然它们在编程中很有用,但会增加程序的复杂性,因为要理解它的行为,你必须知道它被调用时的外部状态。

在CS10中,当我们强调“函数”时,通常指的是纯函数。理解这种区别有助于我们编写更可预测、更易于调试的代码。

课程总结

本节课中我们一起学习了:
- 抽象:通过创建具有清晰接口的积木来隐藏实现细节,是管理程序复杂性的关键。
- 函数类型:区分了执行动作的命令积木和计算并返回值的报告器积木。
- 纯函数:理解了纯函数(相同输入总是产生相同输出,且无副作用)的概念及其重要性,并认识了具有副作用的非纯函数。

通过构建和使用这些积木,你已经开始像计算机科学家一样思考,将复杂问题分解为可重用、可组合的模块。在接下来的课程中,我们将继续探索函数的更多特性,例如定义域和值域。
3:数字表示与抽象

在本节课中,我们将要学习数字表示法,理解数字作为一种抽象概念的意义,并探讨不同的数制系统(如十进制、八进制、二进制和十六进制)是如何工作的。我们还将了解数据类型的初步概念,以及为什么理解数据的“含义”取决于程序或用户如何解释它。
数据类型、定义域与值域
上一节我们介绍了SNAP编程环境中的基本概念。本节中,我们来看看编程中一个核心思想:数据类型,以及与之相关的定义域和值域。
在数学和编程中,每个函数或代码块都有其可接受的输入集合(定义域)和可能产生的输出集合(值域)。
- 定义域:指一个函数可以接受的输入值的集合。例如,
平方根函数通常只接受非负数作为输入。 - 值域:指一个函数所有可能的输出值的集合。例如,
平方根函数的输出是非负数,长度函数的输出是非负整数。
在SNAP中,块的形状和颜色直观地传达了其数据类型:
- 圆角输入槽:通常只接受数字。
- 六边形块:是谓词,它们总是返回布尔值(
true或false)。 - 较宽的矩形输入槽:通常接受文本。
- 三个堆叠的矩形:代表列表,这是一种可以容纳多个数据项的特殊数据类型。
理解数据类型有助于我们正确使用代码块,并让代码(包括给未来的自己)更易读。
数字作为一种抽象
我们每天都在使用数字,但很少思考它们是如何被表示的。数字“10”本身只是一个符号序列,它的具体含义(10个苹果?10点钟?)取决于我们赋予它的上下文。这就是数字的抽象性。
计算机存储和处理所有信息(包括数字、文本、颜色)的基础,最终都是一系列的0和1。如何解释这串0和1,决定了它代表什么。本节课的核心就是理解数字在不同“基底”下的表示方法。
理解数制:从十进制到其他进制
我们最熟悉的是十进制(基数为10)。在十进制中,我们使用0到9这十个数字,每个数位代表10的幂次。
例如,数字 7091 可以表示为:
7 * 10³ + 0 * 10² + 9 * 10¹ + 1 * 10⁰ = 7000 + 0 + 90 + 1 = 7091
这种“按位计数”的思想可以推广到任何基数。
八进制(基数为8)
八进制使用0到7这八个数字。
示例:将八进制数 15₈ 转换为十进制。
1 * 8¹ + 5 * 8⁰ = 8 + 5 = 13
因此,15₈ 等于 13₁₀。它们是同一个值的不同表示。
二进制(基数为2)
二进制是计算机的“母语”,只使用0和1两个数字。每个二进制位称为一个 比特(bit)。
示例:将二进制数 110₂ 转换为十进制。
1 * 2² + 1 * 2¹ + 0 * 2⁰ = 4 + 2 + 0 = 6
因此,110₂ 等于 6₁₀。
为了方便阅读,二进制数常以 0b 为前缀,如 0b110。
十六进制(基数为16)
十六进制使用0-9和A-F(代表10-15)共十六个符号。它常用于更紧凑地表示二进制数。
示例:将十六进制数 A5₁₆ 转换为十进制。
A 代表 10,所以计算为:10 * 16¹ + 5 * 16⁰ = 160 + 5 = 165
因此,A5₁₆ 等于 165₁₀。十六进制数常以 0x 为前缀,如 0xA5。
为什么使用不同的数制?
- 二进制:直接对应计算机硬件中电路的“开”(1)和“关”(0)状态,是信息表示的基础。
- 十六进制:与二进制转换非常方便(每4位二进制数对应1位十六进制数),为人类提供了一种更简洁的查看和书写二进制数据的方式。
- 理解抽象:学习不同数制能深刻体会到,相同的数据(比特序列)可以根据不同的解释规则,代表完全不同的东西(数字、颜色、字母等)。
核心概念总结
本节课中我们一起学习了:
- 数据类型、定义域和值域:理解了代码块对输入输出的约束,以及SNAP中形状/颜色的含义。
- 数字的抽象性:认识到数字“10”的含义依赖于上下文。
- 数制系统:掌握了十进制、八进制、二进制和十六进制的基本原理和相互转换的思想。
- 比特与字节:了解了信息存储的基本单位(
比特->字节)。 - 数据的解释:领悟到比特序列本身没有内在含义,其意义由处理它的程序或协议定义。同一串
0xE6F21,可以被解释为一个颜色、几个字符或一个数字。



记住,编程不仅仅是写代码,更是关于如何创造性地组织和解释数据。下一讲,我们将继续深入数制转换,并开始运用这些概念。
4:变量与列表基础


在本节课中,我们将学习编程中的两个核心概念:变量与列表。我们将探讨变量的作用域、不同类型变量的区别,并初步了解列表这一强大的数据结构。
变量:数据的容器
变量是编程中用于存储和引用数据的基本工具。在Snap!中,变量有多种形式,其核心作用是抽象和共享数据。
变量的类型与使用
在Snap!中,变量通过 set 块来赋值,并通过橙色的变量名块来使用。每个变量都有其类型,例如文本或数字。将错误类型的值放入操作中(例如将文本与数字相加)会导致错误或 NaN(非数字)结果。
公式示例:set [hometown v] to [Honolulu] 将文本“Honolulu”赋值给变量 hometown。

变量的作用域

变量的作用域决定了它在代码中的哪些位置可以被访问和使用。理解作用域是避免错误的关键。
以下是Snap!中几种主要的变量作用域:
- 脚本变量:使用“script variables”块创建。这类变量仅存在于直接连接在该块下方的脚本中。尝试在独立的脚本中使用它会引发“变量不存在”的错误。
- 块变量(参数):在自定义块中作为输入参数定义的变量。这类变量仅存在于该块的定义内部。
- 循环变量:在
for或for each循环中使用的变量(如i)。这类变量仅存在于该循环的内部。 - 全局变量:通过“Make a variable”按钮创建的变量。这类变量可以在整个项目的任何地方被访问和修改,但过度使用容易导致代码混乱,通常应谨慎使用。





作用域规则帮助我们在程序变得复杂时,清晰地管理数据,防止变量在不该被访问的地方被意外修改。
深入理解:变量作用域实战

上一节我们介绍了变量的基本概念和作用域,本节中我们通过一个具体例子来看看作用域如何影响程序运行。
我们分析以下脚本:
- 创建脚本变量
walrus并设为10。 - 显示“before 10”。
- 调用自定义块
say and increment,传入walrus。 - 显示“after 10”。
自定义块 say and increment 的定义是:
- 输入参数为
w。 - 将
w设为w + 1。 - 显示“during”和
w的新值。
运行这个脚本,输出顺序是:“before 10”, “during 11”, “after 10”。最终,脚本变量 walrus 的值仍然是 10。

核心原理:当我们将一个简单数据(如数字、文本)作为参数传递给一个块时,Snap! 传递的是该数据值的一个副本。块内部的参数变量(如 w)与外部变量(如 walrus)是相互独立的。在块内修改 w 不会影响外部的 walrus。
代码对比:
say and increment [walrus v]在调用时,实际执行的是say and increment (10)。- 块内的
set [w v] to ((w) + (1))操作的是局部变量w的副本。
这个例子清晰地展示了块参数变量的局部性。然而,列表的行为将是一个例外,我们会在后面看到。

列表:存储多个元素的数据结构
变量擅长存储单个值,但当我们需要存储一系列相关的数据项时,就需要用到列表。列表是编程中极其重要的数据结构。

列表的基本操作
列表可以存储零个到任意多个项目。以下是列表的基本操作:

- 创建列表:使用
list块。可以点击左右箭头增加或减少列表项。 - 访问元素:使用
item [ ] of [list v]块。可以通过索引(如1, 2, 3)获取特定位置的元素,也可以选择“random”、“last”等选项。 - 修改列表:
add [thing] to [list v]:将新元素添加到列表末尾。delete [ ] of [list v]:删除列表中指定位置的元素。

列表的应用场景非常广泛,例如:购物车商品列表、交易记录、待办事项、游戏中的排行榜或地图格子等。
列表的特殊性

与数字和文本不同,当列表作为参数传递给一个块时,Snap! 传递的是对该列表的引用,而非副本。这意味着在块内部对列表进行的修改(如添加、删除元素)会直接影响到原始的列表变量。这是列表与简单数据类型在行为上的一个关键区别,我们将在下节课详细探讨。

课程总结与预告
本节课中我们一起学习了:
- 变量是存储数据的容器,理解其作用域(脚本变量、块变量、全局变量)对于编写正确代码至关重要。
- 传递简单数据(数字/文本)给块时,传递的是值的副本,在块内修改参数不会影响外部变量。
- 列表是一种用于存储多个元素的数据结构,支持创建、访问、添加和删除等操作。
- 列表作为参数传递时具有特殊性,传递的是引用,在块内修改会直接影响原列表。


下节课我们将更深入地探索列表的威力,包括使用 for each 循环遍历列表,并进一步理解列表在函数间传递时的引用行为。请务必复习本节课关于变量作用域的例子,它为理解更复杂的编程概念打下了基础。
5:列表与高阶函数


在本节课中,我们将要学习列表的更多操作,并引入一个强大的新概念——高阶函数。我们将了解如何使用 map 和 combine 等函数来更简洁、更高效地处理和转换列表数据。
列表的可变性
上一节我们介绍了列表的基本概念。本节中我们来看看列表的一个重要特性:可变性。
列表是可变的。这意味着我们可以修改、添加、删除或更改同一个列表中的项目,而无需创建一个全新的列表。这对于处理包含成千上万个项目的列表非常有用,因为创建新副本可能非常耗时。


当然,可变性有时也会带来复杂性,例如当程序的多个部分同时修改同一个列表时,可能会引起混淆。
列表操作练习

以下是关于列表操作的一个自测问题。运行以下代码后,列表 gorp 的内容是什么?

set [gorp v] to [list v] // 创建一个空列表
repeat (5) // 循环5次
insert (i * 10) at (1 v) of [gorp v] // 将 i*10 插入列表的第一个位置
end
delete (3 v) of [gorp v] // 删除列表的第三项
让我们逐步分析这段代码:
- 首先,
gorp被初始化为空列表。 - 循环执行5次,
i的值从1到5。 - 每次循环,都将
i * 10的值插入到列表的第一个位置。 - 因此,插入顺序是:10, 20, 30, 40, 50。但由于每次都插在开头,列表最终顺序是:
[50, 40, 30, 20, 10]。 - 最后,删除第三项(即
30)。 - 最终列表内容是:
[50, 40, 20, 10]。
insert at (1) 块与 add 块不同,它总是在列表的指定位置(这里是开头)插入新项,导致列表顺序反转。
引入高阶函数
处理列表时,我们经常需要对其中的数据进行某种操作或转换。虽然可以使用 for 循环来完成,但代码可能会显得冗长。这时,高阶函数就派上用场了。
高阶函数是指以另一个函数作为输入的函数。它们是抽象思维的强大工具,能让我们用更通用、更简洁的方式解决问题。Snap! 语言的设计就深受其影响。
例如,假设我们需要一个函数,判断列表中的所有数字是否都在2到6之间。输入是一个数字列表,输出是一个布尔值(真/假)列表。
使用 for each 循环的解决方案需要创建新列表、遍历、判断并添加结果。虽然直观,但包含较多“样板代码”。
高阶函数 map 可以抽象掉这些重复步骤。

使用 map 转换列表
map 是一个高阶函数,它接受一个函数和一个列表作为输入,并返回一个新列表。
map 对输入列表中的每一个项目,应用你给定的函数,并将所有结果收集到一个新列表中。输入列表有多少项,输出列表就有多少项,这是一种“一对一”的转换。
以下是 map 的工作原理:
- 基本形式:
map [function] over [list] - 关键点:你提供给
map的函数中需要有一个空白输入槽(用灰色圆环表示)。map执行时,会依次将列表中的每个项目填入这个空白槽,计算函数结果。 - 示例1:取负数
这等价于依次计算:map (0 - []) over [list 1 2 4 9]
0-1,0-2,0-4,0-9
结果为:[-1, -2, -4, -9]

- 示例2:判断是否大于零
结果为:map ([] > [0]) over [list 1 -2 3][true, false, true]

- 示例3:获取城市名第二个字母
结果为:map (letter (2) of []) over [list [Berkeley] [San Francisco] [Oakland]][e, a, a](分别是 Berkeley 的 ‘e‘, San Francisco 和 Oakland 的 ‘a‘)

关于多参数函数:如果提供给 map 的函数有多个空白槽,Snap! 会将列表的同一个项目填入所有空白槽。例如 map ([] + []) 相当于将每个项目乘以2。

使用 combine 归约列表
map 用于“转换”列表,但有时我们需要将列表“归约”为单个值,例如求和、连接所有字符串等。这时就需要 combine。

combine 也是一个高阶函数,它接受一个函数和一个列表,并返回一个单一值。
combine 的工作方式是“累积”:
- 首先,它取列表的前两项,应用函数,得到一个结果。
- 然后,将这个结果与列表的下一项,再次应用函数。
- 重复此过程,直到遍历完整个列表。

示例1:求和
combine ([] + []) with [list 1 2 4 9]
计算步骤相当于:((1 + 2) + 4) + 9,结果为 16。
示例2:连接字符串
combine (join [] []) with [list [Hello ] [World ] [from ] [BJC]]
结果为:Hello World from BJC

理解 combine 的关键是,其函数的左输入槽用于接收累积至今的结果,右输入槽用于接收列表中的下一个新项目。Snap! 中的 combine 默认从左到右处理列表。

高阶函数的组合使用
map 和 combine 可以组合使用,以解决更复杂的问题。
例如,要计算一个列表中所有数字的平方和:
- 先用
map计算每个数字的平方,得到一个新列表。 - 再用
combine对这个新列表求和。
// 概念性代码,展示组合思路
set [squares v] to (map ([] * []) with [list 1 2 4 9]) // 得到 [1, 4, 16, 81]
set [sum of squares v] to (combine ([] + []) with [squares v]) // 得到 102
其他高阶函数与超块
除了 map 和 combine,Snap! 还有其他有用的高阶函数:
keep:过滤列表。只保留使给定函数返回true的项目。find:查找列表。返回第一个使给定函数返回true的项目。

最后,介绍一个更简洁的工具:超块。
从数据科学中汲取灵感,Snap! 的基础运算块(如 +, -, *, /)现在可以直接接受列表作为输入,并自动对每个元素执行操作。这本质上是 map 的语法糖,但写起来更快捷。

示例:
[list 1 2 3 4] + 5相当于map ([] + 5),结果是[6, 7, 8, 9]。[list 1 2 3] * [list 4 5 6]会进行逐元素相乘,结果是[4, 10, 18]。

超块不仅代码简洁,而且在处理大量数据(如图像像素)时,经过高度优化,速度极快。



总结

本节课中我们一起学习了:
- 列表的可变性:可以在原地修改列表内容。
- 高阶函数:以函数为输入的函数,是强大的抽象工具。
map函数:用于将列表中的每个元素转换为新形式,输入输出列表长度一致。combine函数:用于将列表中的所有元素归约为一个单一值,遵循累积计算模式。- 高阶函数的组合:可以串联使用,如先
map再combine,以构建复杂的数据处理流程。 - 超块:基础运算符处理列表的快捷方式,是
map的简洁写法,执行效率高。

掌握这些概念将帮助你更优雅、更高效地处理和转换数据。
6:期中测验复习与答疑 🎯



在本节课中,我们将回顾课程前半部分的核心概念,特别是高阶函数、超块和变量作用域,并为即将到来的期中测验(Quest)进行答疑。我们将通过具体示例和过往考题分析,帮助你巩固知识,建立解题思路。

高阶函数复习 🔄
上一节我们介绍了高阶函数的基本概念。本节中,我们来看看三个核心的高阶函数:map、keep 和 combine。理解它们的定义域和值域是掌握其用法的关键。
所有这三个函数都接受两个参数:一个函数和一个列表。combine 的参数顺序是相反的(先列表,后函数),但其工作原理相同。
map:对列表中的每一个元素应用给定函数,并返回一个由结果组成的新列表。其输出列表的长度与输入列表相同。- 公式:
map(F, [x1, x2, ..., xn])->[F(x1), F(x2), ..., F(xn)]
- 公式:
keep:使用一个谓词函数(返回true或false的函数)来过滤列表。只保留使该函数返回true的元素。- 公式:
keep(P, [x1, x2, ..., xn])->[xi | P(xi) == true]
- 公式:
combine:使用一个二元函数(接受两个输入的函数)将列表中的所有元素“归约”或“合并”成一个单一的值。- 公式:
combine(F, [x1, x2, ..., xn])->F(...F(F(x1, x2), x3)..., xn)
- 公式:
以下是 combine 函数的一些常见用例:
- 连接文本:将单词列表合并成一个句子。
- 代码示例:
combine(join, ["The", "University", "of", "California"])->"TheUniversityofCalifornia"(若join中间不加空格)
- 代码示例:
- 数学运算:寻找列表中的最小值、最大值,或对所有元素求和。
- 代码示例:
combine(min, [50, 49, ..., 20])->20 - 代码示例:
combine(and, [true, true, false, true])->false
- 代码示例:
combine 适用于那些可以连续对两个元素进行操作,并最终得到一个单一结果的场景。
超块(Hyperblocks)⚡
理解了显式的高阶函数后,我们来看看一种更便捷的隐式操作列表的方式:超块。
超块是 Snap! 中一些内置的运算符(主要是数学和比较运算符)的特性。当你将一个列表作为参数传递给这些运算符时,它们会自动对列表中的每一个元素执行操作。
以下是超块的一些典型应用:
- 对每个元素进行运算:
[1, 2, 3, 4, 5] + 5->[6, 7, 8, 9, 10]- 这等价于:
map((x) => x + 5, [1, 2, 3, 4, 5])
- 这等价于:
- 逐元素比较:
[1, 2, 3, 4, 5] < 3->[true, true, false, false, false] - 列表间运算:
[1, 2, 3] + [4, 5, 6]->[5, 7, 9](要求两个列表长度相同)
需要注意的是,并非所有块都支持超块特性。通常,命令块(如 move)和某些特殊的报告器块(如 split)不支持此功能。最安全的方式是在 Snap! 环境中亲自尝试。



变量作用域(Scoping)📦
在编写复杂程序时,理解变量在哪里可以被访问和修改至关重要,这就是变量的作用域。
在 Snap! 中,变量主要有以下几种作用域:
- 全局变量:在整个项目中都可被任何精灵或脚本访问和修改。
- 脚本变量:在某个
script variables块下方声明。它们仅在该脚本的后续部分中可用。 - 块变量:作为自定义块的输入参数。当调用块时,传入的是该变量的值的副本。在块内部对此变量的修改不会影响块外部的同名变量。
- 循环变量:例如
for或for each循环中的索引变量。其作用域仅限于该循环体内。
核心原则:当变量作为参数传递给一个自定义块时,块内接收到的是该值的一个独立副本。在块内修改这个参数,不会影响原始变量。
过往考题分析与备考建议 📚
为了帮助大家更好地准备,我们来分析一下过往考题的特点,并提供一些备考策略。
以下是一些有效的复习方法:
- 完成自测题:课程提供的自测题是很好的复习材料,完成后可以查看答案解析。
- 研究过往考题:在课程网站的资源页可以找到历年的期中测验题目。特别是近几年的题目,具有很高的参考价值。
- 理解而非死记:重点关注高阶函数、列表处理和变量作用域的核心概念,而不是记忆特定代码片段。
- 动手实践:在 Snap! 中重现题目中的场景,亲自测试不同的输入和函数,观察输出结果。

关于本次测验的提醒:
- 形式:50分钟,8道选择题(共15个小题)。
- 范围:涵盖截至第四周课程开始前的内容(注意:算法复杂度等后续内容本次不考)。
- 如果需要在远程参加考试,请务必提前填写相关表格。



本节课中我们一起回顾了高阶函数(map, keep, combine)的用法,了解了超块如何简化列表操作,并梳理了变量作用域的关键规则。通过分析过往试题,我们明确了复习方向。请记住,理解核心概念并辅以动手实践是成功的关键。祝大家在期中测验中取得好成绩!
7:算法入门 🧮


在本节课中,我们将要学习计算机科学的核心概念之一:算法。我们将探讨算法的定义、历史、基本属性以及如何表达和实现它们。通过简单的例子,你将理解算法如何作为解决问题的工具,并了解其基本构成。
什么是算法?🤔
算法是一个定义明确的计算过程,它接收一些值(输入)并产生一些值(输出)。因此,算法是一系列可以将输入转换为输出的计算步骤。
本质上,当你谈论一个程序或函数时,它是某个算法的具体实现,但算法本身更像是一个思想或蓝图。
我们可以区分计算问题和算法。计算问题是“我想做什么?”,例如“如何对一组数据进行排序?”。而算法则是解决这个问题的具体步骤集合,例如“如何通过一系列比较和交换操作将无序列表变为有序列表?”。
算法的历史与无处不在性 📜
算法的概念实际上比计算机古老得多,已有数千年的历史。
- 历史形式:舞蹈仪式、建筑图纸、烹饪食谱(如将面粉和水转化为面包的步骤)都可以看作是算法的雏形。
- 数学:巴比伦人的加减法、早期几何学等数学运算都是古老的算法。
- 学校教育:我们学过的竖式加法、竖式乘法都是手算算法。
- 自然:DNA本身也是一系列指导人体如何生长、修复的指令,可以看作是一种生物算法。
一个关键点是,同一个问题往往有多种不同的算法可以解决。例如,乘以10的快捷算法是“在数字末尾加一个0”,但这只适用于乘以10,而通用的竖式乘法算法则适用于任何数字。
算法示例:寻找最小值 🔍
让我们看一个具体的算法:在一个列表中寻找最小值的索引(位置)。
假设我们有一个列表:[32, 39, 9, 2, 19]。最小的数字是2,它在列表中的位置是4(在Snap中,列表通常从第1项开始计数)。
以下是该算法的一种思路:
- 从列表开头开始。目前我们只看到第一项,所以“最佳(最小)项”的索引暂时是1。
- 逐步检查列表中的每一项:
- 检查第二项(39)是否小于当前最佳项(32)?不是。所以最佳索引仍是1。
- 检查第三项(9)是否小于当前最佳项(32)?是。所以更新最佳索引为3。
- 检查第四项(2)是否小于当前最佳项(9)?是。所以更新最佳索引为4。
- 检查第五项(19)是否小于当前最佳项(2)?不是。所以最佳索引保持为4。
- 到达列表末尾,报告结果:4。
在Snap中,你可以使用for循环来实现这个算法,跟踪一个“当前最小索引”的变量。这类检查列表中每一项的算法非常常见。
算法示例:排序列表 📊
排序是一个经典的计算问题。给定一个无序列表,我们想得到一个元素按顺序排列的新列表(例如从小到大)。

有多种算法可以解决排序问题。以下是两种思路:
1. 插入排序(Insertion Sort)
- 思路:一次比较两个相邻元素,如果顺序不对就交换它们,并重复遍历列表,直到整个列表有序。
- 过程:就像整理一手扑克牌,你一次拿一张牌,并将其插入到手中有序部分的正确位置。
- 特点:实现简单,但对于很长的列表可能不是最快的。

2. 选择排序(Selection Sort)
- 思路:首先找到整个列表中的最小元素,将其与第一个位置的元素交换。然后在剩下的列表中找最小元素,与第二个位置交换,依此类推。
- 过程:就像为一场比赛选拔队员,你每次都从剩下的人中选最好的。
- 特点:同样直观,但通常也比一些更高级的算法慢。

这两种算法都能正确地对列表进行排序,它们是解决同一问题的不同“工具”。在后续课程和实验中,你将有机会探索和实现不同的排序算法。
算法的属性与组合 🧩

算法有一些重要的属性,使得它们非常强大:
- 可组合性:我们可以将已知正确的算法像积木一样组合起来,构建更复杂的算法。这利用了抽象和泛化的思想。例如,如果我们有一个可靠的“排序”算法,那么“找中位数”的问题就可以通过“先排序,再取中间元素”来解决。
- 多样性:同一个问题通常有多个有效的算法。研究这些不同的解决方案可以带来对问题本身的新见解。例如,历史上计算机科学家研究“如何在不完全排序的情况下高效找中位数”,就衍生出了有趣的数学理论。
- 精确性:算法必须被无歧义地定义。计算机非常擅长遵循指令,但不会推断你的意图。模糊的指令(如著名的“买面包”笑话:如果店里有鸡蛋,就买一打。结果程序员买了一打面包)会导致错误的结果。
如何表达算法?✍️
我们需要清晰无误地描述算法。有几种常见的方式:
- 自然语言:用精确的语言描述步骤。需要格外小心避免歧义。
- 流程图:使用图形化的方式展示决策和步骤流程,非常适合描述包含多个分支的过程。
- 伪代码:一种介于自然语言和编程语言之间的描述方式。它使用类似代码的结构(如
if、for),但忽略严格的语法细节,是设计算法时非常有用的中间步骤。 - 编程语言:最终,我们将算法转化为像Snap这样的编程语言中的具体代码(由积木块组成)。这个过程称为实现。
在Snap中,所有但第一个这样的积木块就是算法步骤“处理列表的剩余部分”的具体实现,它帮助我们消除了自然语言描述可能存在的模糊性。
算法的基本构建块 🧱
任何算法都可以分解为三种基本控制结构:

- 顺序:按特定顺序执行一系列步骤。
- 选择:根据条件决定执行哪条路径(例如
if...else块)。 - 重复:将一组步骤重复执行多次,直到满足某个条件(例如
repeat或for循环)。

一个有趣的理论是,所有算法本质上都可以仅用“顺序”和“选择”来构建(通过一种称为“递归”的技术来实现重复),但“重复”结构让我们的表达更加直观和方便。
算法的力量与语言无关性 💪
一个深刻而强大的思想是:所有足够完善的编程语言在能力上是等价的(图灵完备)。这意味着:
- 你在Snap中拥有的积木块,足以实现任何你能想到的算法思想。
- 任何用一种语言编写的程序,原则上都可以被翻译成另一种语言。
- 我们学习Snap,不仅仅是学习这个工具本身,更重要的是培养一种计算思维——将问题分解为可由计算机执行的步骤序列的能力。这种技能可以迁移到Python、JavaScript、Java等任何其他编程语言中。
总结 📝
本节课中,我们一起学习了算法的核心概念。我们了解到算法是解决问题的明确步骤序列,它历史悠久且无处不在。我们通过“寻找最小值”和“排序”的例子看到了算法的具体运作。我们讨论了算法的关键属性,如可组合性和多样性,并探索了表达算法的多种方式(从自然语言到代码实现)。最后,我们认识到所有算法都由顺序、选择和重复这三种基本结构构成,并且算法的思想是独立于任何特定编程语言的。

下一讲,我们将探讨如何评价算法的好坏,超越“正确性”,去思考什么是“高效”的算法。请关注今天晚些时候发布的测验成绩,我们将在下周的实验和讲座中再见!
8:算法复杂度分析



在本节课中,我们将学习如何分析算法的性能。我们将探讨如何判断一个算法是否比另一个算法更好,并学习一种称为“增长阶”的理论分析方法,它可以帮助我们理解算法在处理不同规模数据时的表现。



算法性能比较


上一节我们介绍了算法是解决计算问题的一系列明确定义的步骤。对于一个特定问题,可能存在多种不同的算法。那么,随之而来的问题是:我们如何决定使用哪个算法?哪个算法更好?
为了回答这个问题,我们首先来看一个具体的例子:在列表中查找一个数字。
以下是两种常见的查找算法:
- 线性查找:从列表的第一个元素开始,逐个检查每个元素,直到找到目标或检查完所有元素。
- 二分查找:要求列表已排序。它通过反复将待查找区间对半分割来快速定位目标。
通过实际测试(例如,在一个包含300个数字的列表中查找数字301),我们可以观察到二分查找的速度明显快于线性查找。这种通过实际运行代码并计时来分析性能的方法,称为实证分析。


然而,实证分析有其局限性。它依赖于特定的测试数据和运行环境,并且对于复杂或未实现的算法难以应用。因此,我们需要一种更通用、理论化的分析方法。
理论分析:增长阶
本节中,我们来看看如何从理论上分析算法的性能。核心思想是:算法的运行时间如何随着输入数据规模(记为 n)的增长而增长。
我们通常关注最坏情况下的性能。这是因为:
- 系统需要为最坏情况做好准备。
- 在实际中,平均情况的运行时间往往更接近最坏情况。
为了描述这种增长关系,我们引入 R(n) 函数,它表示算法在处理规模为 n 的输入时所需要的时间(或步骤数)。
通过绘制输入规模 n 与运行时间 R(n) 的关系图,我们可以识别出算法时间复杂度的“形状”。常见的增长阶包括:
- 常数时间 O(1):运行时间不随 n 变化。例如,获取列表的第一个元素。
R(n) = 1
- 对数时间 O(log n):运行时间随 n 增长,但增长非常缓慢。二分查找就是一个例子。
R(n) = log₂(n)
- 线性时间 O(n):运行时间与 n 成正比。线性查找是典型代表。
R(n) = n
- 平方时间 O(n²):运行时间与 n 的平方成正比。通常出现在嵌套循环中。
R(n) = n²
- 指数时间 O(2ⁿ):运行时间随 n 增长呈爆炸性增长,通常不实用。
R(n) = 2ⁿ
在比较两个算法时,我们关注当 n 变得非常大 时的趋势。此时,常数系数和低阶项的影响变得微不足道,增长阶(如 n 与 n²)决定了算法的长期性能。例如,无论线性算法 O(n) 前面的常数有多大,当 n 足够大时,它总会比平方算法 O(n²) 更快。
代码复杂度分析
现在,我们学习如何通过观察代码结构来判断其时间复杂度。
对于一个简单的线性查找算法,其伪代码如下:
for 列表中的每一个元素:
if 当前元素等于目标值:
return true
return false
这个算法包含一个遍历整个列表的循环。列表有 n 个元素,循环就执行大约 n 次。因此,这是一个 O(n) 的线性时间算法。
让我们看一个更复杂的例子:检查一个房间中是否有两个人同一天生日。
以下是该算法的思路:
for 房间中的每一个人A:
for 房间中的每一个人B(且B不是A):
if A的生日 == B的生日:
return true
return false
这个算法使用了嵌套循环。对于列表中的第一个人,我们需要与其余 n-1 个人比较;对于第二个人,需要与剩下的 n-2 个人比较,以此类推。总的比较次数大约是 n * (n-1) / 2。当 n 很大时,这近似于 n² 的数量级。因此,这是一个 O(n²) 的平方时间算法。
核心规律:当你看到代码中出现嵌套循环,并且每个循环都与输入规模 n 相关时,就应该警惕其时间复杂度可能是 O(n²) 或更高。
总结

本节课中,我们一起学习了算法复杂度分析的基础知识。我们了解到:
- 可以通过实证分析(实际运行计时)来比较算法,但该方法有局限。
- 更强大的方法是理论分析,即研究算法运行时间随输入规模 n 增长的增长阶,如 O(1), O(log n), O(n), O(n²) 等。
- 我们关注最坏情况和大规模输入(n 很大)下的性能,此时增长阶决定了算法的效率。
- 通过分析代码结构(特别是循环的嵌套),我们可以初步判断算法的时间复杂度。

理解算法复杂度对于选择高效算法、设计可扩展的系统至关重要。在接下来的课程中,我们将继续运用这些概念分析更复杂的算法。
9:计算的社会影响 I



在本节课中,我们将完成对算法复杂度的讨论,并介绍课程的第一个项目——构建经典游戏《2048》。我们将学习如何分析算法的运行时间,并实践将复杂问题分解为更小、更易管理的部分。
算法复杂度回顾
上一节我们介绍了算法复杂度分析的基本概念,本节中我们来看看如何具体分析一些常见算法的运行时间。
对数级复杂度:二分查找
二分查找是一种高效的搜索算法,其核心思想是每次都将有序数据集一分为二。其运行时间呈对数级增长,记作 O(log n)。
公式:T(n) = O(log n)
这意味着,当数据规模(n)翻倍时,算法所需的额外步骤仅增加一个。例如,从100个元素到200个元素,再到400个元素,所需的步骤数增长非常缓慢。
二次方复杂度:选择排序
选择排序是一种简单的排序算法。其过程是:首先找到列表中最小的元素,将其移到最前面,然后对剩余元素重复此过程。
分析其运行时间:
- 找到最小元素需要线性扫描,时间复杂度为 O(n)。
- 将元素移到前面是常数时间操作,记作 O(1)。
- 我们需要对 n 个元素重复此过程。
因此,总的时间复杂度是 O(n) * O(n) = O(n²),即二次方复杂度。
公式:T(n) = O(n²)
组合算法的复杂度分析
考虑一个算法:先对一个未排序的列表进行排序(假设使用选择排序,O(n²)),然后使用二分查找(O(log n))来查找元素。
在这种情况下,决定整体运行时间的是最慢的步骤。由于 O(n²) 远慢于 O(log n),因此整个算法的复杂度由排序步骤主导,仍然是 O(n²)。
大O符号总结
大O符号用于描述算法在最坏情况下的运行时间增长趋势。以下是一些常见的复杂度类别:
- O(1):常数时间。运行时间不随输入规模变化。
- O(log n):对数时间。运行时间随输入规模对数增长。
- O(n):线性时间。运行时间与输入规模成正比。
- O(n²):二次方时间。运行时间与输入规模的平方成正比。
- O(2^n):指数时间。运行时间随输入规模指数增长。
分析算法时,我们关注的是主导项,即增长最快的部分。
项目介绍:构建《2048》游戏
现在,让我们转向本节课的实践部分:第一个课程项目《2048》。这是一个将复杂问题分解(Decomposition)的绝佳练习。
游戏规则简介
《2048》是一个滑块拼图游戏。玩家通过向上、下、左、右四个方向滑动棋盘上的数字方块。每次滑动后,所有方块会沿该方向移动到底部,相邻且数字相同的方块会合并为它们的和,同时会在随机空位生成一个新的方块(2或4)。游戏目标是合并出“2048”这个方块。
项目分解

为了构建这个游戏,我们将其分解为五个核心功能块。这种分解方法体现了软件工程中“分而治之”的核心思想。
以下是需要完成的五个主要模块:
add-two-or-four:以75%的概率在棋盘随机空位添加一个“2”,以25%的概率添加一个“4”。rotate-board-clockwise:将棋盘顺时针旋转90度。这是一个巧妙的设计,可以简化后续滑动合并的逻辑。merge-column-up:处理单列方块的向上合并与移动。这是项目中最具挑战性的部分之一。merge-up:利用merge-column-up函数,合并整个棋盘的所有列。no-moves-left?:判断游戏是否结束(即棋盘已满且无法进行任何合并)。
通过先解决 merge-column-up 这样的小问题,再构建 merge-up 这样的大功能,分解使得复杂任务变得易于管理和实现。
开始实践:实现 add-two-or-four
让我们通过实现第一个模块来感受一下项目的开发过程。我们将创建一个辅助函数来随机选择数字2或4。
代码示例:创建 two-or-four 辅助函数
定义 two-or-four
如果 (在 (1) 到 (2) 间随机选一个数) = 1
那么
报告 (2)
否则
报告 (4)
这个函数模拟了75%概率出2,25%概率出4的随机行为(通过后续逻辑实现)。我们可以通过多次点击该函数块来测试它是否随机返回2或4。
测试的重要性
在项目中,我们将鼓励大家为每个功能块编写测试。与其等待提交后由评分系统反馈错误,不如主动验证代码的正确性。这就像开发手机应用时,不能等到用户给出差评才发现问题。
项目资料中会提供用于测试的辅助函数和示例棋盘,帮助你逐步验证每个模块的功能,最终确保整个游戏运行无误。
总结

本节课中我们一起学习了算法复杂度分析的收尾,重点理解了对数级和二次方复杂度的含义及其在算法选择中的重要性。随后,我们介绍了课程项目《2048》,这是一个实践问题分解和函数构建的绝佳机会。通过将游戏拆解为五个核心功能模块,我们将学习如何管理复杂性,并开始培养编写可测试代码的良好习惯。请关注课程页面,项目详细说明即将发布。
10:递归 I


在本节课中,我们将要学习递归这一核心编程概念。递归是一种函数调用自身来解决问题的方法,它能够将复杂问题分解为更小、更相似的子问题。我们将通过直观的例子和简单的代码来理解递归的基本原理。



概述:什么是递归?

上一节我们介绍了递归的基本概念,它是一种强大的问题解决策略。简单来说,递归就是函数直接或间接地调用自身。它通常用于解决那些可以被分解为结构相同但规模更小的子问题的问题。
递归的直观感受:V 程序

为了直观地感受递归,我们先来看一个名为 V 的程序。这个程序可以绘制简单的图形,其有趣之处在于,它绘制的图形本身又可能包含更小的 V 图形。
以下是 V 程序的核心逻辑描述:
当绿旗被点击
重复执行:
移动到位置 (x, y)
从 [正方形, 圆形, 三角形, V] 中随机选取一个形状
如果 选取的形状 = V
那么 调用“绘制V”函数
否则
绘制选取的形状






这个程序的关键在于,列表中的可选形状包含了 V 本身。因此,当程序选择绘制 V 时,它会再次调用绘制 V 的函数,从而可能产生一层套一层的复杂图案。这生动地展示了自我相似性,即整体由与自身结构相似的更小部分组成。



构建递归函数:Down Up 示例

理解了递归的直观概念后,本节中我们来看看如何从零开始构建一个递归函数。我们将创建一个名为 down up 的块(函数),它接收一个单词,然后按特定模式输出。
函数目标:对于输入单词(例如 “hello”),输出应为:
hello
ello
llo
lo
o
lo
llo
ello
hello
以下是构建 down up 函数的步骤:
- 定义函数:创建一个名为
down up的块,它接受一个名为word的输入。 - 识别基本情况:递归必须有一个停止条件,即基本情况。对于本问题,当单词长度小于等于1时,我们只需报告这个单词本身。
如果 (word) 的长度 ≤ 1 那么 报告 (word) - 定义递归情况:如果单词长度大于1,我们需要将问题分解。
- 分解:将单词分解为其第一个字母和剩余部分。我们可以使用
第一个字母 of (word)和除第一个字母外 of (word)这两个块。 - 调用:对剩余部分(即更小的子问题)递归调用
down up函数。 - 组合:将第一个字母、递归调用的结果以及再次出现的第一个字母组合起来,形成最终输出。
否则 报告 (连接 (word) (空格) (down up (除第一个字母外 of (word))) (空格) (word)) - 分解:将单词分解为其第一个字母和剩余部分。我们可以使用
将以上逻辑组合,完整的 down up 函数定义如下:
定义 down up (word)
如果 (word) 的长度 ≤ 1
那么 报告 (word)
否则
报告 (连接 (word) (空格) (down up (除第一个字母外 of (word))) (空格) (word))
运行 down up (hello) 即可得到预期的输出。这个例子展示了递归函数的典型结构:一个处理最简单情况的基本情况,和一个将问题分解并调用自身的递归情况。
递归的核心组成部分
通过 down up 的例子,我们可以总结出每个递归解决方案都包含两个关键部分:
- 基本情况:这是递归的终止条件。它处理最简单、不可再分的问题实例,防止函数无限调用自身。在
down up中,基本情况是单词长度 ≤ 1。 - 递归情况:这是函数调用自身的部分。它通常包含三个步骤:
- 分解:将原始问题分解成一个或多个更小、更简单的同类子问题。
- 调用:递归地调用函数自身来解决这些子问题。
- 组合:将子问题的解决方案组合起来,形成原始问题的解决方案。




总结与预告


本节课中我们一起学习了递归的基本概念。我们通过 V 程序感受了递归创造的自我相似图案,并通过动手构建 down up 函数,理解了递归函数的两个核心部分:基本情况和递归情况。


递归是一种强大的思维工具,它允许我们用简洁的代码解决复杂的问题。关键在于识别问题是否可以分解为相似的子问题,并明确定义递归停止的条件。

在接下来的实验和课程中,我们将继续探索递归的更多应用,例如绘制更复杂的分形图形,并练习如何让递归函数返回计算结果。
11:计算的社会影响



在本节课中,我们将探讨当前流行技术及其社会影响。我们将涵盖虚拟现实、人工智能、在线隐私、技术与社交支持,以及技术与自然等主题。我们的目标是描绘一幅全景图,引导大家思考当代技术带来的益处与挑战。
为什么社会影响很重要?🤔
你编写的软件可能对世界产生巨大影响。如今,社会的几乎每个关键部分都依赖于代码,例如选举投票、驾驶汽车、经济运行等。所有这些都以某种方式与软件相关。正因如此,你创造的软件可以瞬间传递到世界任何角落,只需按下一个按钮。
然而,这背后存在一个严峻问题:如果你编写的代码存在错误,由于其无处不在的特性,可能导致大规模的连锁反应,甚至影响整个世界。因此,在编写代码和进行复杂项目时,需要考虑所有可能发生的情况,尤其要意识到当前世界和技术中存在的各种社会困境,以避免重蹈覆辙。
例如,在2020年,谷歌云视觉的图像识别软件会将一名手持温度计的黑人男性识别为持枪者。虽然该问题后来得到解决,但图像识别中的种族偏见问题依然存在。另一个例子是使用人工智能进行医疗诊断。如果一位医生持续误诊,可能只影响少数人;但如果AI诊断软件存在错误,由于其全球范围的广泛使用,可能影响成千上万的人。
虚拟现实(VR)🌐


上一节我们介绍了技术社会影响的普遍重要性,本节中我们来看看虚拟现实技术。





虚拟现实本质上是一种头戴式设备,它能阻挡所有外部光线,并通过镜头向你投射视频,让你感觉仿佛身临其境。它还具有传感器,可以追踪头部运动,从而模拟视频中的移动。近年来,VR的普及度和可及性不断提高。
VR技术也在不断发展,例如触觉手套可以检测单个手指的运动,还有防水VR设备,甚至有一种面具可以在嘴部模拟加热、吹风等天气感觉。由于VR的沉浸感极强,它比电视、视频游戏或手机更能显著影响你的大脑,因为VR的目标是模拟你真实在场的感觉,这使得大脑更难区分现实与虚拟世界。
这既带来积极影响,也有消极影响。但在此之前,让我们观察一些例子。
从这些视频中可以看出,VR比电视或手机更容易引发用户的情感反应。尽管VR有许多酷炫的应用,但也存在一些负面影响。常见的问题包括眼睛疲劳、头痛和恶心。VR最大的威胁是现实世界中的伤害,这通常发生在使用者混淆现实与虚拟世界,并做出虚拟世界中的相关动作时。
一些VR设备正试图通过引入安全区功能来解决这个问题,你可以在VR空间中划定一个安全区域,当你接近边界时会收到警报。
除了游戏和娱乐,VR在教育领域也有许多应用。研究表明,“临场感”能提高学习和记忆效果,而VR的整个目标就是构建更好的临场感。许多高中甚至大学课程已经开始引入VR实验室,让学生可以进行通常无法完成的实验。
沃尔玛使用VR培训员工应对“黑色星期五”,体育团队也利用VR练习比赛场景。医疗保健领域同样在利用VR,外科医生通过VR练习不同的手术方案。一项研究显示,新手使用VR模拟进行腹腔镜手术,其表现水平能提高到相当于有经验的中级外科医生的程度。
VR在消除偏见方面也有出色应用。斯坦福大学的虚拟人类交互实验室发现,以老年人虚拟形象度过一段时间,会显著影响一个人对老年人的态度。加州大学旧金山分校的“Cultivate”项目正在研究这方面,该项目让你体验一位经历疼痛和健康问题的中年黑人女性的虚拟形象。


VR在治疗方面也非常有效,特别是针对恐惧症,因为它可以让用户暴露在恐惧中而不必承担实际风险。它还能有效减少老年人的抑郁、焦虑和记忆障碍。
最后不能不提“元宇宙”。它基本上是一个大规模的虚拟世界,你可以将其视为一个虚拟的互联网空间,用于获取信息、玩游戏、看电影等。许多人认为这将是下一个重大趋势。


人工智能(AI)🤖

上一节我们探讨了虚拟现实,本节中我们来看看人工智能。
人工智能正被用于取代大量劳动力。一些专家预测,在未来15年内,AI将取代或淘汰40%的工作岗位。这种影响主要集中在重复性、技能要求较低的工作上。
人工智能的另一个热门话题是自动驾驶汽车。驾驶行为本身对计算机来说可能并不极其困难,但其中涉及许多瞬间的道德决策。一个重大的道德困境是:如果一辆自动驾驶汽车即将撞上某物,它有两个选择:要么撞上去导致车内人员死亡,要么转向避开但可能撞死一群行人。如何将这种道德决策编码到计算机中是一个大问题。
人工智能的另一个重要问题是其变得越来越具有侵入性,能够从已知信息中推断出更多关于我们的信息。例如,通过分析Instagram帖子来诊断用户是否患有抑郁症。人工智能最令人惊叹的应用之一是脑机接口,即将设备植入大脑,让你能够控制外部技术。
然而,当涉及AI和隐私时,情况可能变得可怕。如果这些信息被黑客入侵怎么办?随着我们对AI的依赖加深,拥有良好的计算机安全变得至关重要。
在线隐私与数据🔒


上一节我们讨论了人工智能的潜力与风险,本节中我们来看看与之紧密相关的在线隐私问题。
我们生活在一个“监控资本主义”的时代。公司非常重视我们的数据,并积极存储、出售它们。谷歌会追踪你通过其服务进行的所有购买。三星智能电视的语音识别功能会捕获并传输包含个人或敏感信息的语音数据给第三方。
有一篇有趣的博客文章提到,作者试图利用Facebook拥有的数据来创建广告,以展示Facebook对你的了解程度。这些广告显示了你的位置、生活方式、习惯等信息。
另一个例子涉及大学招生。许多大学网站会追踪访客数据,记录你查看了哪些页面,并计算出一个“亲和力指数”,即你入读该校的可能性。这可能会对需要经济援助的潜在学生产生偏见。
当然,数据收集也有其益处。以下是关于数据收集利弊的一些思考:
弊端:
- 数据泄露风险: 如果公司拥有我们所有的数据,黑客就有可能获取并利用这些信息。
- 个性化信息茧房: 算法可能会向你展示越来越符合你现有观点的内容,限制你接触不同信息。
益处:
- 个性化体验与服务: 例如,社交媒体上的广告可能更贴合你的兴趣。
- 紧急情况下的应用: 位置追踪可用于在自然灾害或绑架等紧急情况下寻找人员。
技术与社交支持📱
现在我们已经讨论了隐私问题,让我们谈谈技术与社交支持。
研究发现,幸福感的首要预测因素是我们与主要相处对象的关系,以及我们在这些关系中感受到的被接纳程度。这更多地强调关系的质量而非数量。这对于压力时期的大学生尤其重要。
然而,在线交流可能无法提供我们获得幸福和应对压力所需的强大社交支持。一项研究发现,面对面交流与生活质量呈正相关,而在线交流则呈负相关。考虑到我们有两年的时间几乎完全依赖在线交流,这一点尤其值得深思。
既然面对面的社交沟通如此重要,为什么手机会如此令人上瘾?许多手机应用实际上是模仿赌场老丨虎丨机设计的,它们使用明亮色彩吸引你,下拉刷新就像中奖一样带来新信息。


手机现在是否是社会的必要组成部分?没有手机,一个人能否真正生活下去?思考一下一天不用手机有多难。这可能对于学业成功和正常工作来说是必要的。
手机令人上瘾的另一个原因是,应用程序有经济义务让你停留在它们的产品上。大多数科技产品通过应用内的广告获利,因此屏幕使用时间越长,公司赚的钱就越多。例如,YouTube的自动播放功能、利用机器学习推送你感兴趣的内容等,都是为了延长你的使用时间。
技术与自然🌳
上一节我们探讨了技术与社交的关系,本节我们来看看技术与自然的联系。
近年来,抑郁和焦虑的发病率与过去80年相比有所上升。很多人问这是为什么。想想100年前,疾病、饥饿、饥荒和死亡比今天普遍得多,但抑郁和焦虑的发病率却低得多。你认为这是为什么?
许多人提出,原因之一是我们祖先与自然的接触。研究表明,心理健康状况较差的人从自然漫步中获得的益处大于在城市环境中行走。自然对那些压力更大的人更具恢复作用。那些在自然中行走90分钟的人,其大脑中与“反刍思维”(反复思考负面经历)相关的区域活动会减少。
总结
在本节课中,我们一起探讨了计算技术广泛的社会影响。我们了解了虚拟现实如何提供沉浸式体验但也带来风险,人工智能如何改变就业和引发伦理困境,在线隐私如何被权衡以换取便利,技术如何影响我们的社交幸福感,以及接触自然对心理健康的重要性。
关键要点是,在创造和使用技术时,我们需要有意识地权衡其利弊。试着减少盯着屏幕的时间,多去享受外面的世界。如果你对这些话题感兴趣,可以考虑选修相关课程以深入了解。





本节课中我们一起学习了:
- 计算技术社会影响的普遍重要性。
- 虚拟现实(VR)的应用、益处与风险。
- 人工智能(AI)在自动化、伦理和脑机接口方面的进展与挑战。
- 在线隐私的数据收集实践及其利弊。
- 技术对社交支持质量和心理健康的影响。
- 接触自然对心理健康的积极益处。
12:递归 II - 找零问题









在本节课中,我们将深入学习递归,特别是被称为“树递归”的进阶概念。我们将通过一个经典的“找零问题”来理解如何在一个函数中进行多次递归调用,并探讨递归相对于迭代的优势。课程最后,我们还会简要介绍分形,作为递归的一种可视化应用。
概述:什么是递归?
上一节我们介绍了递归的基本概念。简单来说,递归是一种在函数定义中调用自身的技术。它通过将复杂问题分解为更小的、结构相似的子问题来解决原问题。递归通常包含一个或多个基础情况(直接返回结果,无需进一步递归)和一个或多个递归情况(调用自身处理更小的输入)。
递归的核心:基础情况与递归情况
在编写递归函数时,遵循清晰的步骤至关重要。
第一步:确定基础情况
基础情况是问题的最简单形式,无需进一步递归即可直接求解。例如,在计算列表元素之和时,空列表的和就是0;在计算阶乘时,1的阶乘就是1。基础情况是递归的“停止点”,确保函数不会无限循环。
第二步:设计递归情况
递归情况处理所有非基础情况。其核心思想是:假设函数已经能够解决更小版本的同一问题(递归的“信仰之跃”),然后利用这个结果来构建原问题的解。这通常可以分解为三个子步骤:
- 拆分:将原问题分解为一个或多个更小的子问题。
- 调用:递归地调用函数自身来解决这些子问题。
- 合并:将子问题的解组合起来,得到原问题的解。
示例一:阶乘函数
让我们通过阶乘函数 n! 来具体理解这些步骤。其数学定义 n! = n * (n-1) * ... * 1 本身就具有递归性,因为 n! = n * (n-1)!。
基础情况:当 n = 1 时,1! = 1。这是最简单的情况。
递归情况:对于 n > 1,我们可以利用 n! = n * (n-1)! 这个关系。
- 拆分:将计算
n!拆分为计算n和(n-1)!。 - 调用:递归调用
factorial函数计算(n-1)!。 - 合并:将
n与(n-1)!的结果相乘。
在 Snap! 中的代码实现如下:
报告 如果 (n = 1) 那么 (1) 否则 (n * factorial(n - 1))
树递归与找零问题
上一节我们介绍了单一路径的递归。本节中我们来看看树递归,即在一个递归函数中多次调用自身,形成像树一样的调用结构。
问题定义:找零问题
找零问题是:给定一个金额(例如 15 美分)和一组硬币面额(例如 [25, 10, 5, 1] 代表 quarter, dime, nickel, penny),计算有多少种不同的方式可以用这些硬币凑出该金额。假设每种硬币数量无限。
例如,用硬币 [1, 5, 10, 25] 凑 5 美分,有两种方式:1个5美分,或5个1美分。
解决思路:分类与递归分解
解决此问题的关键在于对所有可能的找零方式进行分类。对于任意金额和硬币列表,任何一种找零方案必然属于以下两类之一:
- 使用了列表中的第一种硬币。
- 完全没有使用列表中的第一种硬币。
这两类情况覆盖了所有可能性,且没有重叠。因此,总方案数等于这两类情况的方案数之和。
递归实现
基于上述分类,我们可以设计递归函数 count_change(amount, coins)。
基础情况:
- 如果
amount = 0,这表示我们已经恰好凑齐了金额,存在 1 种方案(即不使用任何硬币)。 - 如果
amount < 0,这表示我们超额使用了硬币,是无效方案,返回 0。 - 如果
coins列表为空,表示没有可用的硬币了,无法凑出任何正数金额,返回 0。
递归情况:
我们需要计算上述两类情况的方案数之和。
- 不使用第一种硬币:方案数等于用剩余的硬币(
all but first of coins)凑出原金额amount的方案数。即count_change(amount, all but first of coins)。 - 使用第一种硬币:如果我们决定使用一个面值为
first(coins)的硬币,那么剩余的金额就变成了amount - first(coins)。方案数等于用所有硬币(因为硬币数量无限,第一种硬币可以继续使用)凑出这个新金额的方案数。即count_change(amount - first(coins), coins)。
因此,总方案数为两者之和。
在 Snap! 中的核心递归逻辑如下:
报告 (count_change(amount - first(coins), coins) + count_change(amount, all but first of coins))
为何这是树递归?
因为每次递归调用都会产生两个新的递归调用分支(一个“使用”,一个“不使用”),这些调用又会继续分叉,直到全部到达基础情况。整个调用过程形如一棵树。
递归的威力
试想如果用循环迭代来解决此问题,我们需要遍历所有硬币组合的可能性,代码会非常复杂且低效。递归优雅地将“遍历所有组合”这个复杂任务,抽象为对两个更小子问题的求解,极大地简化了思维和代码。
递归的应用:分形简介
递归不仅用于计算问题,还能生成美丽的图形,例如分形。分形是一种在不同尺度上重复出现相似模式的几何形状,例如雪花、海岸线等。
在 Snap! 中,我们可以使用 pen 绘图指令和递归来绘制分形。通常,分形函数接受 size(大小)和 level(递归深度)作为参数。当 level 为 0 时,绘制一个基础图形(如一条线段);当 level > 0 时,则用更小的尺寸多次递归调用自身来绘制更精细的结构。这生动地体现了递归“自相似”和“分解”的思想。
总结
本节课中我们一起深入学习了递归。
- 我们回顾了递归的基础情况与递归情况,并通过阶乘例子巩固了“拆分-调用-合并”的思维模式。
- 我们重点探讨了树递归,通过经典的找零问题,学习了如何通过将问题分解为“使用第一个元素”和“不使用第一个元素”两类互斥的子问题来设计递归解决方案。
- 我们理解了递归在解决某些复杂问题(如组合计数)时,相比迭代具有思维和代码上的简洁性优势。
- 最后,我们简要了解了递归在生成分形图形中的应用,看到了递归思想的视觉体现。

掌握递归的关键在于信任“递归的信仰之跃”——相信函数能正确解决更小的子问题,并专注于如何利用这些子问题的解来构建原问题的解。
13:理解与构建高阶函数

在本节课中,我们将要学习高阶函数的核心概念。我们将不仅探讨如何使用像 map、keep 和 combine 这样的高阶函数,更重要的是,我们将学习如何自己构建高阶函数。我们将使用 Snap! 中的 run 和 call 块来实现这一点,并理解“灰环”在延迟计算中的作用。
高阶函数与纯函数
上一节我们介绍了高阶函数的基本概念。本节中,我们来看看高阶函数与纯函数之间的关系。
到目前为止,我们见过的高阶函数,如 map、keep、combine 以及 2048 项目中的测试块,它们都有一个共同点:它们都接受一个函数作为参数。
这些高阶函数本身也是纯函数。只要传递给它们的函数是纯函数(例如 + 块),并且输入相同,它们总是返回相同的结果。它们没有副作用,不修改外部世界。
然而,这里有一个技巧:如果我们传入一个非纯函数,例如 pick random 块,结果就会不同。pick random 块在相同输入下会产生不同的输出。但重要的是,map 块本身的行为仍然是纯的——它只是忠实地执行我们给它的函数。
关键工具:run 与 call 块
为了构建自己的高阶函数,我们需要两个关键工具:run 和 call 块。
run用于执行命令块。call用于执行报告器块(谓词是一种特殊的报告器,所以也可以用call)。
它们的作用是:接受一个放在灰环 () 中的函数,并“解开”这个环,让我们可以计算该表达式的结果。
以下是 call 块的工作方式:
call ( () + () ) with inputs (3) and (5) // 结果为 8
当我们点击 call 块旁边的小箭头时,可以指定输入。call 块会将输入值(3 和 5)填入被调用函数(这里是 +)对应的空白输入槽中。

类似地,对于一个画正方形的命令块 draw square (size),我们可以使用:
run (draw square ()) with input (100)
一个需要注意的特性是:如果一个函数有多个空白输入槽(如 () * ()),但我们只传递一个参数,Snap! 会将这个参数应用到所有空白槽中。
call ( () * () ) with input (5) // 相当于计算 5 * 5,结果为 25
这在某些情况下很有用,但需要留意。

构建高阶函数:抽象模式
之前我们学习过,构建函数是为了抽象和通用化任务。例如,我们可以创建一个 draw square (size) 函数来画任意大小的正方形。
但如果我想画不同样式的正方形(如实线、虚线、波浪线)呢?为每种样式都写一个独立的函数会很冗余。观察代码会发现,它们只有“画线”这一步不同。
这时,高阶函数的强大之处就显现出来了。我们可以将 draw square 改造成一个高阶函数,让它接受一个“画线方法”作为参数。
改造后的函数定义如下:
draw square (line drawer) (size)
在函数内部,我们使用 run 块来执行传入的 line drawer 函数:
repeat (4)
run (line drawer) with input (size)
turn (90) degrees
end
现在,我们可以传入 draw solid line、draw dashed line 或 draw wavy line 等函数给 draw square,从而用同一段代码画出不同风格的正方形。这是一种极其强大的抽象模式,在后续的计算机科学课程中你会经常用到。
动手实践:构建自己的 keep 函数
理论介绍完毕,现在让我们动手实践,从头构建一个类似 keep 的高阶函数。我们将其命名为 keep items such that (predicate) from (list)。



我们的目标是:遍历列表,只保留那些使谓词函数 predicate 返回 true 的项。
以下是实现步骤:
- 创建一个新的自定义块,添加两个输入:一个类型为“谓词”(
predicate),另一个类型为“列表”(list)。 - 初始化一个空列表
result用于存放结果。 - 使用
for each块遍历输入列表的每一项。 - 在循环内,使用
call块将当前项item作为输入,调用谓词函数predicate。 - 如果
call的结果为true,则将该项item添加到result列表中。 - 循环结束后,报告
result列表。
关键代码片段如下:
set (result) to ()
for each (item) in (list)
if (call (predicate) with input (item))
add (item) to (result)
end
end
report (result)
通过这个例子,你可以清晰地看到,我们构建的高阶函数 keep items such that 如何接受另一个函数(predicate)作为参数,并在其内部使用 call 来执行它。你可以用类似的思路去构建自己的 map 或 combine 函数。
call/run 与 map/keep 的核心区别在于:call/run 是单次调用一个函数,而 map/keep 本质上是将 call 的概念通用化到整个列表上,即多次调用。
理解“灰环”的奥秘
我们一直在使用灰环 (),它究竟代表什么?
在 Snap! 中,灰环表示“延迟计算”。它将一个表达式包裹起来,使其不立即执行,而是作为一个可以传递的“函数理念”或“数据”。

例如:
x - 3会立即计算出一个数值。( () - (3) )则代表“减去3”这个操作本身,它是一个等待被调用的函数。
当我们把带有灰环的函数作为参数传递给像 map 或我们自建的 keep 函数时,我们传递的是这个操作本身。然后,在接收方(如 map 或 call 块内部),call 块会“解开”灰环,填入具体的输入值,并执行计算。

这种“延迟计算”的能力,让我们可以灵活地控制函数在何时、以何种参数执行,这是实现高阶函数和强大抽象的基础。



总结
本节课中我们一起学习了:
- 高阶函数与纯函数:高阶函数可以接受函数作为参数,它们本身也可以是纯函数。
- 核心工具
run和call:它们用于执行被灰环包裹的函数,是实现高阶函数操作的基础。 - 抽象模式:通过将特定步骤(如“如何画线”)抽象为函数参数,我们可以写出更通用、更强大的高阶函数(如通用画正方形函数)。
- 动手构建:我们一步步构建了自己的
keep函数,深入理解了高阶函数如何接收并调用另一个函数。 - 灰环的本质:灰环代表延迟计算,它允许我们将函数作为数据传递,并在未来需要时再执行。

理解并掌握高阶函数,是提升编程抽象能力的关键一步。它们让你能编写出更简洁、更灵活、更易于维护的代码。
14:编程范式

在本节课中,我们将要学习编程范式。编程范式是编写代码的不同风格或哲学。理解这些范式有助于我们选择最适合特定任务的工具,并更好地理解他人编写的代码。我们将探讨几种主要的编程范式,包括命令式、函数式、数组式、面向对象式和声明式,并通过实例来理解它们之间的区别。
概述
编程范式是编程的一般方法、方向或哲学。它不仅仅是关于单行代码的写法,更是关于如何组织整个程序、函数和文件的思考方式。大多数现代编程语言(如Snap!、Python、Java)都是多范式语言,它们支持多种风格,并融合了各种范式的优点。
命令式编程
上一节我们介绍了编程范式的概念,本节中我们来看看命令式编程。命令式编程的核心思想是:程序由一系列明确的指令组成,计算机按顺序执行这些指令。这通常涉及使用循环和修改变量状态。
以下是命令式编程的一个典型例子,它通过一系列步骤将一个短语转换为首字母缩写词:

设置 `acronym` 为 空字符串
对于 `phrase` 按空格分割后的列表中的每一个 `word`:
将 `word` 的第一个字母连接到 `acronym` 末尾
报告 `acronym`

这种风格的优点是步骤清晰,易于理解每一步在做什么。其标志性特点是使用循环和通过修改变量(如acronym)来跟踪程序状态。
函数式编程
接下来,我们看看函数式编程。函数式编程强调使用纯函数,避免改变状态和可变数据。程序被视为一系列函数的组合,一个函数的输出作为另一个函数的输入。
以下是使用函数式风格解决同样问题(生成首字母缩写词)的方法:
报告 连接(
映射(取首字母,
保留(长度 > 3,
句子转列表(`phrase`)
)
)
)
这个版本没有使用任何变量来存储中间状态。它只是一个表达式,其中每个函数(句子转列表、保留、映射、连接)的结果直接传递给下一个函数。虽然代码更短,但理解其执行顺序(从内到外)可能需要一些适应。
函数式编程的优点包括:代码更易于调试(因为纯函数不依赖外部状态)、更易于并行化处理,并且通常更简洁。
数组式编程
现在,我们探讨数组式编程。这种范式是函数式编程的一个特例或独立分支,它专门设计用于高效处理列表(或数组)数据。在Snap!中,许多内置块和库(如APL原语库)都支持这种风格。
在数组式编程中,我们通常将整个列表视为一个整体进行操作,而不是显式地遍历列表中的每个元素。例如,在Snap!中,乘法块*可以直接接受一个数字列表作为参数,并返回所有数字的乘积,这体现了数组式思维。
面向对象编程

然后,我们转向面向对象编程。这种范式将程序组织成一系列“对象”,每个对象包含自己的数据(属性)和可以对这些数据执行的操作(方法)。
在Snap!中,每个精灵(sprite)都可以看作一个对象。精灵有自己的属性(如x坐标、y坐标、名称、造型),也有自己的方法(如“说”、“移动”)。我们可以让不同的精灵执行不同的脚本,对象之间可以通过“告诉”块进行通信。这种范式有助于将大型程序的结构分解为更小、更易于管理的部分。

声明式编程
最后,我们了解声明式编程。声明式编程描述的是“想要什么”(目标),而不是“如何实现”(具体步骤)。程序指定一组规则或约束,然后由计算机找出满足这些规则的解决方案。
一个经典的例子是使用Prolog语言为德国地图着色,要求相邻省份颜色不同。我们只需定义颜色集合、相邻关系以及“相邻区域颜色不能相同”的规则,Prolog引擎会自动找出一种有效的着色方案。SQL(数据库查询语言)是另一个声明式编程的例子,你只需描述想要的数据结果,数据库引擎会决定如何高效地获取这些数据。
总结


本节课中我们一起学习了五种主要的编程范式:命令式、函数式、数组式、面向对象式和声明式。每种范式都提供了独特的视角和工具来解决问题。没有一种范式是绝对优于其他范式的,它们各有优劣,适用于不同的场景。理解这些范式能帮助我们成为更全面、更灵活的程序员,能够为手头的任务选择最合适的工具,并更好地理解和构建复杂的软件系统。
15:从 Snap! 到 Python I




在本节课中,我们将学习如何从 Snap! 编程环境过渡到 Python 语言。我们将探讨两者的相似与不同之处,并通过具体示例展示如何将 Snap! 中的概念和代码翻译成 Python。我们的目标是理解编程的核心思想如何在不同语言间迁移,并为后续学习打下基础。
课程概述与目标
上一节我们介绍了课程的中期情况。本节中,我们来看看为何要学习 Python,以及它与 Snap! 的关系。
在 CS10 课程中,我们首先学习 Snap!,然后过渡到 Python,这具有重要的教育意义。与许多专注于特定技能的编程训练营不同,本课程旨在传授计算思维等核心理念。这些理念,如抽象(通过创建带参数的函数来移除细节、实现通用化)、精确性(拥有明确的规范)以及迭代开发(编写代码、查看结果、调试改进),在 Snap! 和 Python 中是相通的。
学习 Python 有非常实际的原因。对于希望继续学习计算机科学(如 Data 8, CS 61A)的同学,这几周的内容将为你提供一个良好的开端。即使计算机科学并非你的专业方向,但如果你想进行游戏开发、学术研究或数据分析,Python 也是一个强大的通用工具,广泛应用于物理、生物甚至艺术史等领域。
此外,我们还将初步接触终端(命令行界面)的使用,并了解 Python 庞大的开源库生态系统。Python 在某些任务上比 Snap! 更便捷(例如数据处理),而 Snap! 在图形和精灵交互方面则更直观。理解两者的优劣能帮助我们选择合适的工具。
编程语言类比:语法、习语与词汇
学习第二门编程语言在某些方面类似于学习第二门书面语言,但希望会更容易一些。
以下是两者的一些可比之处:
- 语法:编程语言中构成条件、循环、函数的结构在不同语言间非常相似。
- 习语与风格:特定编程语言的惯用风格和模式可能颇具挑战性。从“写出能运行的 Python 代码”到成为“精通 Python 的专家工程师”之间有很长的路要走,但 CS10 的目标是前者。
- 词汇:编程术语的差异通常比自然语言小。例如,Snap! 中的
map、keep和combine在 Python 中对应map、filter和reduce。 - 字符集:我们主要使用罗马字符集进行编程。
关键在于,在 Snap! 中学到的计算思维能够帮助你学习 Python,进而帮助你学习 Java、JavaScript 或任何其他语言。
Python 工作流程:终端与文件

在 Snap! 中,我们在浏览器中编写和运行代码。Python 则是一个安装在电脑上的程序。Python(以及许多其他编程语言)有两种主要工作模式。

以下是两种模式:
- 解释器模式:类似于 Snap! 的脚本区,可以逐行输入代码并立即看到结果。
- 脚本模式:更典型的模式,即编写并运行一个预先写好的代码文件。



我们将使用终端(或命令行)来与 Python 程序交互。通过终端,我们可以导航到文件所在目录,并使用 python3 文件名.py 这样的命令来运行 Python 脚本。

基础翻译:变量、运算与输出
现在,我们开始将 Snap! 代码片段翻译成 Python。让我们从最简单的脚本开始。

在 Snap! 中,我们使用 script variables 块来声明脚本变量。在 Python 中,我们可以直接赋值:
x = 10
y = 20
语句 A = B 表示将值 B 赋给名称 A,这等同于 Snap! 中的 set 块。我们可以进行运算:
x2 = x * x
在 Snap! 中,代码存在于精灵或舞台上,say 块会让精灵说出内容。在 Python 中,代码在文件中运行,并通过 print 函数将信息输出到屏幕(通常是终端):
print(x)
print 在功能上最接近 Snap! 的 say,但输出会持续显示在屏幕上,而不会消失。
基本的数学运算符在两者间是对应的:
- 加法:
+ - 减法:
- - 乘法:
* - 除法:
/

定义与调用函数

在编程中,构建自己的函数是最重要的活动之一。让我们看看在两种语言中如何操作。
在 Snap! 中,我们点击“制作新的积木”,命名,然后在编辑窗口中定义它。在 Python 中,函数定义在同一个代码文件中。我们使用 def 关键字:
def glorify(x, y):
x_squared = x * x
y_squared = y * y
return x_squared + y_squared
def后跟函数名和括号内的参数(x, y),然后是一个冒号:。- 缩进:函数体内的所有代码必须缩进(通常是 4 个空格)。在 Python 中,缩进极其重要,它定义了代码块的结构。不一致的缩进会导致错误。
- 返回:使用
return关键字来返回一个值,等同于 Snap! 中的report块。

调用函数时,在 Python 中是将参数放在函数名后的括号内:
result = glorify(3, 4)
print(result) # 输出 25
这与 Snap! 中调用带参数的积木类似,但注意:在 Snap! 中,参数可能被描述性文本隔开(如 say ... for ... secs);而在 Python 中,所有参数都连续地放在函数调用的末尾。

条件语句与缩进规则

条件判断是编程的核心。让我们看看 if-else 语句如何翻译。
在 Snap! 中,我们使用 C 形的 if-else 积木。在 Python 中,这对应着缩进的代码块:
def is_even(x):
if x % 2 == 0:
return True
else:
return False
if后面跟着一个条件表达式,然后是冒号:。- 属于
if分支的代码要缩进一级。 else后面同样跟冒号,其下的代码也要缩进。- 相等比较:在 Python 中,使用两个等号
==来检查是否相等。单个等号=是赋值操作。这是一个常见的错误来源。





我们可以简化这个函数,因为 if-else 本身返回的就是布尔值:
def is_even(x):
return x % 2 == 0
这体现了在 Python 中,只要表达式有效,就可以进行嵌套和组合,就像在 Snap! 的输入槽中嵌套积木一样。
用户输入与字符串拼接
与用户交互是程序的重要功能。我们来看看如何获取用户输入。
在 Snap! 中,我们使用 ask 积木提问,结果存储在 answer 变量中。在 Python 中,我们使用 input 函数一步完成:
name = input("What's your name? ")
input 函数显示提示信息,等待用户输入并按回车键,然后将输入的内容作为字符串返回,我们将其赋值给变量 name。
然后,我们可以使用 print 输出结果。为了组合文本(字符串),我们使用 + 操作符,这类似于 Snap! 中的 join 积木:
print("Hello " + name)
print 和 input 是控制台程序与用户交互的基础方式。更复杂的事件处理(如鼠标点击)则依赖于特定的图形库。
总结与展望

本节课中,我们一起学习了从 Snap! 过渡到 Python 的基础知识。我们探讨了学习第二门编程语言的意义,比较了 Snap! 和 Python 在工作流程、语法上的异同,并实践了变量、运算、函数定义、条件语句以及用户输入等核心概念的翻译。
关键要点包括:Python 使用缩进来定义代码块结构;使用 == 进行相等比较;使用 def 定义函数,用 return 返回值;使用 input 获取用户输入,用 print 进行输出。

记住,学习新语言不必一次性记住所有语法。就像学习 Snap! 一样,你会随着时间的推移逐渐掌握。如果你想在春假期间练习,可以打开电脑终端,输入 python3 进入交互模式,尝试本节课的例子。

下一节课,在我们春假回来后,将继续深入 Python 的学习,并在实验课上进行实践。祝大家春假愉快!
16:Python II 🐍

在本节课中,我们将继续学习Python,重点比较Snap!与Python在编程思想上的异同,并深入探讨Python中列表和字符串的面向对象操作方式。我们还将介绍Python中一个强大的特性:列表推导式。
课程概述与作业安排 📅
在深入今天的课程内容之前,我们先了解一下本周和下周的安排。我们将继续讨论Python,并会进行大量Snap!与Python的对比,思考如何将一种语言中的概念映射到另一种语言。
周三我们将发布作业三。这份作业介于家庭作业和项目之间,其核心目标是:我们将提供一个完整的Snap!项目和一个部分完成的Python项目。你的任务是通过阅读Snap!代码,理解其逻辑,并将其“翻译”成功能等价的Python代码。
在这个过程中,我们希望你能收获两点:
- 理解Snap!和Python在哪些方面相似,在哪些方面不同。例如,看到Snap!中的
for each循环,能想到如何映射到Python的for循环。 - 练习编程的核心流程:阅读代码、理解逻辑、编写新代码以及调试。不要试图一次性将整个脚本完美翻译。可以写一部分,测试是否工作,然后逐步完善。这个作业的目标是得到一个能工作的Python项目,翻译方式并非唯一。
与此同时,最终项目(项目三) 也将启动。本周你们需要完成项目提案。这是一个合作项目,目标是构建一个Snap!或Python程序。提案不需要非常复杂,但需要让助教了解你们计划在几周内完成什么。
关于最终项目的建议是:优先考虑构建一个能实际运行、可以展示给朋友的小而精的项目,而不是一个雄心勃勃但充满错误或无法运行的项目。正确性比野心更重要。
另外,期中考试的成绩将在明天或周三上午公布。
面向对象编程与Python操作方式 🔄
我们将继续探讨Python的优势及其与Snap!的不同之处。第一个核心概念是面向对象编程。
几周前在“编程范式”讲座中,我们提到在Snap!中,精灵可以是一个对象。但总的来说,Snap!并不容易深入探索现代意义上的面向对象编程。这与我们上次看到的一些代码会有所不同。
我们将重点看Python中精炼的两种数据类型:列表和字符串,以及如何对它们进行操作。
列表操作:从函数到方法
在Snap!中,我们有一个列表(可能是脚本变量或全局变量)。要向列表中添加内容,我们使用add (thing) to (list)函数块,并将列表作为参数传递给这个“添加”块。

在面向对象编程中,我们转换了思路:不再是向一个函数块传递参数,而是让某个函数或方法操作一个对象。
在Python中,对应的操作如下:
some_list.append(“ketchup”)
这里的some_list是一个列表对象。append是一个方法,它作用于some_list这个对象。我们可以理解为“这个列表对象知道如何执行一个叫append的操作”。
这种“A做B”的方式是面向对象编程的标志之一。对于Python内置的对象(如列表),有一整套可以自动调用的方法。在CS61A/B这样的课程中,你会学习创建拥有特定方法的自定义对象。
再次比较Snap!和Python:在Snap!中,我们的积木块大多是全局的,它们存在于左侧的调色板中,接收数据,输出数据或修改列表。而在面向对象编程中,我们使用的方法只对特定的对象有意义。Python中可能没有全局的append函数,我们必须先有一个列表对象,然后才能对它进行append操作。
这两种方式没有绝对的优劣,只是不同。我们将继续通过更多例子来体会。
利用Python官方文档 📚
在深入更多例子之前,我认为值得强调的是:使用像Python这样的语言,其最大优势之一就是丰富的在线资源和示例。学习如何做某件事的最佳途径之一就是查阅官方文档。
Python的文档非常全面。虽然有些部分从纯计算机科学的角度编写,较为深奥,但大部分内容组织良好,有方便的搜索功能和目录结构。
幻灯片中高亮了两部分:
- 数据结构章节:列表是一种数据结构,它让我们能将多条信息以易于修改的形式组合在一起。文档列出了可以对列表进行的各种操作,如
append,extend,insert,remove,pop(弹出并返回元素,Snap!中没有直接对应的块,但你可以自己构建),clear等。 - 字符串章节:字符串在Python中是独立的数据类型。文档列出了许多字符串方法,如
capitalize,center,count等。
你不需要通篇阅读文档,但要知道你可以搜索它,获取思路,了解对于特定类型的数据,Python内置了哪些功能。这是学习使用一门语言的优势。
字符串处理:输入与分割示例 🧵
现在让我们通过一些例子来继续比较Snap!和Python。
在Snap!中,我们有ask and wait以及answer功能。在Python中,我们使用特殊的input函数在终端中请求输入,类似于Snap!中弹出对话框询问。
假设我们通过input获得了一个由空格分隔的数字字符串,例如 "1 2 3 4"。这个numbers_text变量只是一个字符串。

问题是如何将其转换为数字列表?在Python中,当我们有一个字符串这样的数据时,我们可以对它进行某种操作。


在Snap!中,我们可以使用split积木块,按空格分割字符串。


在Python中,有一个类似的方法:
numbers_list = numbers_text.split(" ")
split是一个作用于字符串的方法。它的功能是将字符串分割成一系列独立的项。现在我们就得到了一个包含四个字符串的列表。

如何知道在Python中该怎么做?你可以思考:我想把一系列文本值(字符串)转换成列表项。在Snap!中我有什么操作?我有split块。如何在Python中分割文本?你可以直接搜索“how to split text in Python”或打开文档找到split方法。

numbers_text.split(" ")意味着这是一个作用于numbers_text值的方法,它接受一个参数来指定如何分割文本,这里就是在引号内传入空格字符。

在Python解释器中直接输入变量名会显示其值。如果在文件中运行,你可能需要使用print(numbers_list)来输出。

核心差异:类型区分

这里Snap!和Python代码功能几乎完全相同。主要区别在于:在Snap!中,数字和字符串可以自动转换,它并不严格区分数字1和字符"1"。而在Python中,"1"是文本,代表字符‘1’,1是整数。这是两者间一个重要的区别。
除此之外,如果使用脚本变量,这里展示的代码是基本等价的。Snap!中的answer是一个特殊的全局变量,而Python中没有直接对应的东西。

更多字符串方法示例 🆙
还有一些例子在Snap!中并非自动内置,但你可以选择自己编写。这些例子展示了拥有数百万用户使用的编程语言的优势:许多功能会被构建出来。
对于字符串,文档列出了许多可用的方法。这里仅举几例:
upper(): 将所有小写字母转为大写。count(‘a’): 计算字符串中某个字符(如‘a’)出现的次数。


另一个有用的操作是:如何将列表中的元素连接成一个字符串?在Snap!中,我们使用join积木块。

在Python中,有一个名为join的函数,但它的使用方式有点特别。join是一个属于字符串对象的方法。
你需要这样思考:separator.join(list)。分隔符字符串调用join方法,将要连接的列表作为参数传入。这意味着:“用这个分隔符去连接列表中的各项”。
例如:
", ".join(some_list)
输出会是类似 "apple, banana, cow" 的字符串。
这两种方式都产生了相同的结果,但它们以略微不同的方式解决问题。有时,当我们从Snap!翻译到Python时,需要思考在Python环境中是否存在更好的实现方式。
Python的强大工具:列表推导式 ⚡

接下来我们将探讨Python中一个相对独特但非常强大的概念:列表推导式。它与Snap!中的map和keep(filter)思想有联系,但在Python中以不同的格式呈现。

在介绍列表推导式之前,我们先看看map在Python中如何工作。

Python中的 Map
在Python中,我们可以定义一个函数,例如reverse,用于反转字符串。然后我们可以使用map函数将这个reverse函数作用于列表的每一项。

在Snap!中,我们使用map积木块。


在Python中:
map(reverse, L)
这里reverse是函数名(不加括号),类似于Snap!中的灰色光环。然而,Python的map函数返回一个叫做“map对象”的东西。这是Python为了高效而设计的特性:它不会立即计算结果,只在需要时才计算。

因此,要看到结果,我们需要显式地将其转换为列表:
list(map(reverse, L))
这样就能得到和Snap!中完全相同的结果。从一对一对比来看,需要多做一步转换工作。
引入列表推导式
但在Python中,更常见的做法是使用列表推导式。这是一种无需显式赋值新变量或编写完整for循环,就能将数据转换为新列表的特殊方式,也是Python中的首选风格。
其语法如下:
[reverse(x) for x in L]
方括号在Python中表示列表。在方括号内,我们写 reverse(x) for x in L,其中L是我们的列表,x类似于常规for循环中的迭代变量。

Python会执行这个表达式并返回一个新列表,其中每个元素都是原列表中对应元素经过reverse函数处理后的结果。
你可以这样理解:如果不返回列表,你可能会写一个for循环:
for item in L:
print(reverse(item))
列表推导式并不完全等同于一个完整的循环,Python在幕后做了不同的事情,但这不重要。重要的是,在Python中这个工具存在,并且它实现了与map相同的目标,只是方式略有不同。它不使用高阶函数,也不产生map对象,而是动态地使用方括号语法创建并返回列表。

列表推导式与 Filter
Python将map和filter的思想进行了扩展。filter是Python内置的另一个函数,类似于Snap!中的keep。
在Snap!中,我们使用keep积木块来保留满足条件的项。

在Python中:
filter(longer_than_two, L)
然后可以再map reverse。同样,为了看到结果,需要转换为列表。
但Python也将列表推导式的概念进行了扩展,支持在表达式中加入条件。语法如下:
[reverse(x) for x in L if len(x) > 2]
这将只对长度大于2的项进行反转操作。
如果只想过滤而不映射,列表推导式也可以做到:
[x for x in L if len(x) > 2]
这等价于只使用filter或Snap!中的keep。
列表推导式是Python中一个超级有用且重要的特性,其语法是该语言特有的。它主要用于列表类数据。

编程语言的习惯用法 💬

我们最后要谈的一点是,这被称为编程语言中的习惯用法。每种编程语言除了拥有范式(如函数式、命令式、面向对象)外,还有代表其特定风格的习惯用法。

我们可以将其类比于口语中的习语。例如,“set the world on fire”形容某事迅速传播,我们并不是字面意思要点燃世界。每种语言都有习语,翻译它们是一项特定的任务。
我们想要展示的目标之一是:当用一种特定语言编程时,并非总是存在从A到B的完全直接翻译,通常存在多种正确的实现方式。这就是编程语言习惯用法的体现。

总结与预告 📝
本节课我们一起学习了:
- Python中面向对象编程的思想,特别是如何通过方法(如
list.append(),str.split())操作对象。 - 如何利用Python丰富的文档资源来学习和查找功能。
- Python中强大的列表推导式,它提供了一种简洁高效的方式来处理列表的映射和过滤操作,是Python特色的习惯用法。
- 认识到从Snap!翻译到Python时,有时需要寻找更符合Python风格的实现方式,而不仅仅是直接对应。
下节课(周三),我们将深入练习列表的使用,将列表视为可访问的数据序列。如果你在reverse函数中看到了[::-1]这种冒号和负数的语法,可以在接下来几天研究一下它的工作原理。我们将在周三详细讨论其含义和用法。
谢谢大家!


17:Python III 🐍

在本节课中,我们将继续学习Python编程,重点探讨Python中的序列数据类型、类型转换以及面向对象编程的初步概念。我们将看到Python如何以简洁高效的方式处理数据,并了解如何创建自定义的数据类型。

序列数据类型 📊



上一节我们介绍了Python的基本数据结构。本节中,我们来看看Python中几种重要的序列数据类型:字符串、列表和range对象。它们都支持类似的操作,如索引和切片。
字符串作为序列



在Python中,字符串是一种序列类型,可以像列表一样进行索引和切片操作。
course = "CS10"
print(course[0]) # 输出 'C'
print(course[3]) # 输出 '0'



字符串支持切片操作,其语法为[start:end],其中start是包含的,而end是排除的。
print(course[2:4]) # 输出 '10'



需要注意的是,字符串在Python中是不可变的(immutable)。这意味着一旦创建,就不能修改字符串中的单个字符。

# 以下操作会引发错误
# course[0] = 'D' # TypeError: 'str' object does not support item assignment
字符串拥有许多内置方法,例如计算长度、转换大小写等。

print(len(course)) # 输出 4
print(course.upper()) # 输出 'CS10'

序列的通用操作
字符串、列表和range对象作为序列,共享许多行为。例如,它们都支持in关键字来检查成员资格。
print("CS" in course) # 输出 True
print("data" in course) # 输出 False

它们也都可以在for循环中使用。

for letter in course:
print(letter)
Range对象

range函数生成一个数字序列,常用于循环。其结束值是排除的。

numbers = range(0, 10) # 包含 0 到 9
print(list(numbers)) # 输出 [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
print(5 in numbers) # 输出 True
print(10 in numbers) # 输出 False
类型转换与严格性 ⚙️


Python对数据类型有严格的规定,这与Snap等图形化语言不同。在Python中,不能直接将不同类型的值相加。
number = 5
text = "6"
# 以下操作会引发类型错误
# result = number + text # TypeError: unsupported operand type(s) for +: 'int' and 'str'

需要使用内置函数进行显式类型转换。
# 将数字转换为字符串后拼接
result_str = str(number) + text # 结果为字符串 "56"
# 将字符串转换为数字后相加
result_num = number + int(text) # 结果为数字 11
Python提供了多种类型转换函数:
int(): 将值转换为整数。str(): 将值转换为字符串。list(): 将可迭代对象转换为列表。
其他数据结构 🧩
除了列表和字符串,Python还提供了其他有用的数据结构。
元组
元组(tuple)使用圆括号()创建,与列表类似,但它是不可变的。
some_tuple = (1, 2, "hello")
# some_tuple[0] = 10 # 这行会报错,因为元组不可变
集合

集合(set)是一个无序且不包含重复元素的集合。它对于去重或成员检查非常高效。

unique_numbers = set([1, 2, 2, 3, 5])
print(unique_numbers) # 输出 {1, 2, 3, 5}
字典
字典(dictionary)是Python中极其有用的数据结构。它存储键值对,允许通过键快速访问值。


phone_book = {"Alice": "555-1234", "Bob": "555-5678"}
print(phone_book["Alice"]) # 输出 '555-1234'
print(phone_book.keys()) # 输出 dict_keys(['Alice', 'Bob'])
print(phone_book.values()) # 输出 dict_values(['555-1234', '555-5678'])

面向对象编程入门 🏗️
现在,让我们转向面向对象编程。其核心思想是创建自定义的数据类型(类),这些类型可以拥有自己的属性(数据)和方法(函数)。
创建简单的类


我们创建一个Time类来表示时间。


class Time:
pass # 暂时为空
t1 = Time()
t1.hour = 17
t1.minute = 10
t1.second = 22
格式化字符串


为了更清晰地输出对象信息,Python提供了强大的字符串格式化方法,特别是f-string。
# 使用 .format() 方法
print("Time is {}:{}:{}".format(t1.hour, t1.minute, t1.second))
# 使用更简洁的 f-string
print(f"Time is {t1.hour}:{t1.minute}:{t1.second}")



总结 📝

本节课中我们一起学习了:
- 序列数据类型:字符串、列表和
range对象都支持索引、切片和迭代。 - 类型严格性:Python要求显式类型转换,使用
int(),str(),list()等函数。 - 其他数据结构:了解了不可变的元组、用于去重的集合以及强大的键值对字典。
- 面向对象编程基础:初步了解了如何通过定义
class来创建自定义数据类型,并为其添加属性。


这些工具使我们能够更清晰、更高效地组织和处理数据,为编写更复杂的程序奠定了基础。下节课我们将深入探讨如何为类添加方法,使其功能更加强大。
18:面向对象编程进阶 🐍




在本节课中,我们将继续学习Python面向对象编程,重点探讨如何为自定义类添加功能,包括实例方法、特殊方法(如构造函数和字符串表示方法),以及如何比较对象。我们将通过构建一个Time类来演示这些概念。
概述 📋
上一节我们介绍了如何创建一个简单的Time类并为其添加属性。本节中,我们将为这个类添加实用的功能,使其不仅仅是一个数据容器。我们将学习如何定义实例方法来执行计算,如何使用特殊的构造函数来简化对象创建,以及如何利用Python的特殊方法(如__str__和__lt__)来定制对象的行为,使其更直观、更易用。

实例方法:为对象添加行为 🛠️


实例方法是属于类的函数,它们操作特定对象(实例)的数据。我们首先为Time类添加一个方法,用于计算从午夜到当前时间所经过的总秒数。

核心概念:实例方法
实例方法的第一个参数通常是self,它代表调用该方法的对象实例本身。Python会自动将调用该方法的对象作为self参数传入。
以下是计算秒数的方法定义:
class Time:
def time_in_seconds(self):
return self.hour * 3600 + self.minute * 60 + self.second
调用此方法有两种等效方式:
- 通过类调用:
Time.time_in_seconds(t1) - 通过实例调用(更常用、更自然):
t1.time_in_seconds()

在第二种方式中,t1自动成为了self参数的值。

构造函数:简化对象初始化 🏗️
之前,我们创建Time对象后,需要手动为其hour、minute、second属性赋值。这很繁琐。我们可以使用名为__init__的特殊方法(构造函数)来一次性完成初始化。
核心概念:构造函数 __init__
__init__方法在创建类的新实例时自动调用。我们可以在其中设置实例的初始状态。
以下是带有构造函数的Time类:
class Time:
def __init__(self, hour, minute, second):
self.hour = hour
self.minute = minute
self.second = second
现在,创建对象变得非常简洁:
t1 = Time(17, 10, 22) # 直接创建一个表示17:10:22的时间对象



特殊方法:定制对象行为 ✨
Python提供了许多“特殊方法”(也称为“魔术方法”),它们以双下划线开头和结尾。这些方法允许我们定义对象在特定操作下的行为,例如打印对象或进行比较。
__str__ 方法:定义对象的字符串表示
当我们使用print()函数或str()函数时,Python会尝试调用对象的__str__方法来获取其字符串表示。


以下是Time类的__str__方法:
class Time:
# ... __init__ 和其他方法 ...
def __str__(self):
return f"{self.hour}:{self.minute}:{self.second}"
定义此方法后,print(t1)将输出17:10:22,而不是默认的 <__main__.Time object at 0x...> 这种不友好的内存地址信息。

__lt__ 方法:定义“小于”比较
我们可能想比较两个Time对象哪个更早。通过定义__lt__(less than)方法,我们可以使用<运算符来比较。

以下是Time类的__lt__方法的一种实现:
class Time:
# ... 其他方法 ...
def __lt__(self, other):
if self.hour < other.hour:
return True
elif self.hour == other.hour:
if self.minute < other.minute:
return True
elif self.minute == other.minute:
return self.second < other.second
return False
定义此方法后,我们就可以直接写t1 < t2来判断t1是否早于t2。

更优雅的实现
由于我们已经有了time_in_seconds方法,可以更简洁地实现比较:
def __lt__(self, other):
return self.time_in_seconds() < other.time_in_seconds()
这体现了代码复用的优势。
带来的便利
定义了__lt__方法后,我们的Time对象就可以直接使用Python内置的sorted()函数进行排序:
time_list = [t1, t2, t3, t4]
sorted_times = sorted(time_list) # 这会根据我们定义的`__lt__`逻辑进行排序

类属性与注意事项 ⚠️

除了实例属性,Python还支持类属性。类属性属于类本身,被该类的所有实例共享。
class Time:
midnight = 0 # 这是一个类属性
def __init__(self, hour, minute, second):
# ... 实例属性初始化 ...
所有Time实例都可以访问Time.midnight或t1.midnight。

注意:需要谨慎使用类属性。因为实例也可以修改同名的属性,这可能会意外地覆盖类属性或导致混淆。在初学阶段,建议尽量将所有数据定义为实例属性,除非有明确的共享需求。
总结 🎯
本节课中我们一起学习了面向对象编程的几个核心进阶概念:
- 实例方法:我们学会了如何定义操作对象数据的方法,并使用
self参数来访问当前实例。 - 构造函数 (
__init__):我们使用这个特殊方法来简化对象的创建和初始化过程。 - 特殊方法:我们探索了
__str__和__lt__方法,它们让我们能够定制对象的字符串表示和比较行为,使自定义类用起来更像Python内置类型一样自然。 - 类属性:我们简要了解了类属性的概念及其潜在风险。


掌握这些知识后,你就能创建功能更强大、接口更友好的自定义类了。在接下来的课程中,我们将继续探索Python的其他有趣特性。
19:Python收尾与高级语法




在本节课中,我们将学习Python面向对象编程的最后几个概念,包括类属性、静态方法以及可变性与不可变性的设计思想。此外,我们还将介绍Python中两个非常实用的语法特性:默认参数/关键字参数和Lambda表达式。这些知识将帮助你更清晰、更高效地编写和阅读Python代码。
类属性与实例属性
上一节我们介绍了实例属性,即属于每个具体对象(实例)的属性。本节中,我们来看看另一种属性:类属性。
类属性是属于类本身的属性,被该类的所有实例共享。例如,我们可以在Time类中定义一个start_of_day属性来追踪一天的开始时间。
class Time:
start_of_day = 10 # 类属性,假设一天的开始是上午10点
def __init__(self, hour, minute, second):
self.hour = hour # 实例属性
self.minute = minute
self.second = second
你可以通过类名(Time.start_of_day)或实例名(t1.start_of_day)来访问类属性。然而,通过实例访问类属性可能会造成混淆,特别是当你尝试通过实例修改它时。
t1 = Time(17, 30, 0)
print(Time.start_of_day) # 输出: 10
print(t1.start_of_day) # 输出: 10
# 通过实例修改“类属性”会创建一个同名的实例属性,而不是修改类属性本身
t1.start_of_day = 12
print(t1.start_of_day) # 输出: 12 (这是t1的实例属性)
print(Time.start_of_day) # 输出: 10 (类属性并未改变)
因此,我们建议:
- 实例属性:使用
self.attribute_name在__init__或其他方法中定义。这是你应该主要使用的属性类型。 - 类属性:应谨慎使用,并避免通过实例来设置或修改它们,以免引起混淆。
静态方法
到目前为止,我们编写的方法都是实例方法,它们操作一个具体的对象实例(通过self参数)。但有时,我们需要一些与类相关,但不需要操作具体实例的功能。这时可以使用静态方法。
静态方法使用@staticmethod装饰器声明,它不接收表示实例的self参数。
假设我们想在Time类中添加一个方法,用于计算任意天数对应的总秒数。这个方法不依赖于任何特定的Time实例。
class Time:
# ... 其他代码 ...
@staticmethod
def seconds_in_days(n):
"""返回n天中的总秒数"""
return n * 24 * 60 * 60 # 更清晰,避免直接写86400




调用静态方法时,既可以通过类名,也可以通过实例名,但通过类名调用更清晰。


# 推荐:通过类名调用
total_seconds = Time.seconds_in_days(3)
print(total_seconds) # 输出: 259200
# 也可以(但不推荐)通过实例调用
t1 = Time(10, 0, 0)
total_seconds2 = t1.seconds_in_days(2) # 这看起来有些奇怪


静态方法是一种将相关功能组织在类内的好方法,同时明确表示该功能不依赖于对象的具体状态。
可变性与不可变性设计
面向对象编程是管理代码复杂性的工具。其中一个重要的设计决策是:对象应该是可变的还是不可变的?


- 可变对象:对象创建后,其内部状态可以被修改。
- 不可变对象:对象一旦创建,其状态就不能改变。任何“修改”操作都会返回一个全新的对象。
在Python中,我们可以选择如何设计我们的类。例如,为一个Time对象实现一个“四舍五入到最近小时”的功能。

可变版本(不推荐):直接修改当前对象。
def round_to_nearest_hour(self):
if self.minute >= 30:
self.hour = (self.hour + 1) % 24
self.minute = 0
self.second = 0
# 没有return,直接修改了self

不可变版本(推荐):返回一个新的Time对象。
def round_to_nearest_hour(self):
"""返回一个新的Time对象,表示四舍五入到最近小时的时间"""
if self.minute >= 30:
new_hour = (self.hour + 1) % 24
else:
new_hour = self.hour
# 返回一个新的实例,原对象self保持不变
return Time(new_hour, 0, 0)

不可变设计的优点在于它使代码的行为更可预测,减少了因共享对象状态而引发的隐蔽错误。当然,在处理大型数据(如很长的列表)时,出于性能考虑,使用可变操作(如list.append())是合理且必要的。但原则是:在可能且合理的情况下,优先选择不可变设计。
默认参数与关键字参数

为了让函数调用更清晰、更灵活,Python提供了默认参数和关键字参数。

默认参数允许你在定义函数时为某些参数指定默认值。调用函数时,如果省略这些参数,则使用默认值。
def triangle(n, step=1, symbol="*"):
"""打印一个由符号组成的三角形"""
for i in range(1, n+1, step):
print(symbol * i)
triangle(5) # 使用默认 step=1, symbol="*"
triangle(7, 2) # 使用默认 symbol="*"
triangle(7, 2, "#") # 提供所有参数
关键字参数允许你在调用函数时,通过参数名来指定值。这有两个好处:一是顺序可以打乱,二是让代码意图更清晰。
triangle(symbol="@", n=6, step=2) # 使用关键字参数,顺序无关
在Snap!中,由于参数被直接嵌入在积木块中并带有标签,其清晰度与关键字参数类似。Snap!同样支持为参数设置默认值。
Lambda表达式
有时我们需要一个简单的、只用一次的函数,为此专门用def去定义会显得繁琐。Python的lambda关键字允许我们创建匿名函数(即没有名字的函数)。
Lambda表达式的基本结构是:lambda 参数: 返回值表达式。

例如,我们之前用sorted函数排序时,可以传入一个key函数来指定排序依据。使用lambda可以简洁地在行内定义这个函数:
words = ["apple", "banana", "cherry", "date"]

# 按单词长度排序
sorted_by_length = sorted(words, key=lambda word: len(word))
print(sorted_by_length) # 输出: ['date', 'apple', 'banana', 'cherry']
# 按最后一个字母排序
sorted_by_last_letter = sorted(words, key=lambda w: w[-1])
print(sorted_by_last_letter) # 输出: ['banana', 'apple', 'date', 'cherry']


在Snap!中,类似的概念是“灰色环”的report块,它可以在行内定义一个计算并报告值的函数。Lambda是Python中实现函数式编程风格(如使用map, filter, sorted等高阶函数)的重要工具。



本节课中我们一起学习了Python面向对象编程的收尾概念,包括需要谨慎使用的类属性、不依赖实例的静态方法,以及提倡不可变对象的设计哲学。我们还探索了两种提升代码清晰度和灵活性的语法:默认/关键字参数和简洁的Lambda表达式。掌握这些概念将帮助你更好地构建结构清晰、易于维护的Python程序。
20:隐私、计算机与社会

在本节课中,我们将探讨计算机技术,特别是数据追踪技术,如何影响我们的隐私和社会关系。我们将从大学申请追踪、校园监控,到更广泛的网络数据收集与去匿名化,了解这些技术的运作方式及其带来的伦理与社会问题。


大学申请前的追踪
上一节我们介绍了课程概述,本节中我们来看看大学如何利用技术追踪潜在申请者。
一些大学使用名为“Capture”的工具来追踪访问其网站的用户。该工具通过“Cookie”来识别访客。Cookie是网站存储在您浏览器中的一小段数据,用于记住您的信息。
- Cookie的工作原理:每次用户访问网站时,服务器会发送一个包含唯一标识符的Cookie到用户的浏览器。当用户再次访问时,浏览器会发回这个Cookie,从而让网站识别出这是“回头客”。
- 信息收集:通过Cookie,大学可以收集用户的IP地址(与计算机互联网连接相关的唯一代码)、浏览的页面(例如对体育或经济援助页面的兴趣)以及停留时间。
- 身份关联:大学可以通过发送营销邮件来关联Cookie数据与真实身份。当学生点击邮件中的链接时,系统就能将邮箱地址与对应的IP地址及浏览历史关联起来。
这种技术有其积极用途,例如改善网站体验,但也引发了关于知情同意和隐私的担忧。
校园内的追踪
了解了入学前的追踪,我们再来看看学生入学后,校园内可能存在的追踪形式。
大学使用多种技术监控学生在校园内的活动,目的包括提高留存率、保障安全或提升活动参与度。


以下是几种常见的校园追踪方式:
- 位置追踪:一些学校与公司合作,通过学生手机上的应用程序实时监控其在校园内的位置,以分析行为模式(如是否去上课、图书馆),并标记可能有辍学风险的学生。
- 紧急情况监控:有公司提议在紧急情况(如枪击案)下,接入学生手机的摄像头和麦克风,以收集信息并引导学生远离危险区域。
- 活动参与度追踪:例如,使用名为“Fanmaker”的应用和传感器来追踪学生参加体育赛事的情况,包括是否到场以及停留时长。数据可用于提升学校吸引力。
- 学习管理系统分析:如Canvas、bCourses等平台会记录学生是否观看课程视频、参与在线讨论以及提交作业的时间,这些数据对教师可见,用于了解学生学习参与度。
这些技术引发了关于伦理边界的讨论:大学在何种程度上追踪学生是合理的?追踪的目的应该是支持学生,而非单纯监控。
网络中的广泛追踪
校园追踪只是冰山一角。在更广阔的网络世界中,数据收集无处不在。
许多在线服务会收集用户数据,主要用于广告投放。用户往往在不知情或未完全理解的情况下提供了这些信息。
- Google位置历史:用户可以选择开启此功能,它会记录您去过的地点,形成时间线。这既有用(如回忆旅行),也意味着Google掌握了您多年的行踪。
- 浏览器指纹识别:即使禁用Cookie,网站也可以通过收集您浏览器的众多信息(如浏览器类型、版本、安装的插件、屏幕分辨率、字体等)来生成一个几乎唯一的“指纹”,从而在不同网站上追踪您。
- 隐私检测工具:像电子前沿基金会(EFF)提供的工具可以检测您的浏览器防止追踪的能力,并评估其独特性。
这些追踪机制有时很有用(如银行检测异地登录以防范欺诈),但同样被广泛用于商业广告等目的。
数据泄露与去匿名化

数据被收集后,面临的最大风险之一是泄露。即使数据经过匿名化处理,也可能被“去匿名化”。

当包含敏感信息的数据集被泄露时,会造成严重危害。例如,2016年,土耳其执政党数百万女性选民的家庭住址、私人信息等敏感数据被泄露,在政治紧张时期,这对相关人员的人身安全构成了威胁。
更令人惊讶的是,即使数据被移除了直接标识符(如姓名),也可能通过其他信息重新识别出个人。
- AOL搜索数据泄露事件(2006年):AOL发布了65万用户三个月内的搜索记录,并为每个用户分配了一个随机ID。然而,《纽约时报》记者通过分析搜索内容(如人名、地址、本地信息),并结合公共记录(如电话簿),成功识别出了其中一位用户的真实身份。
- 去匿名化的其他途径:
- 设备传感器:从照片中可能反推出拍摄设备的型号。
- 写作风格:个人在论坛或社交媒体上的写作风格具有独特性,可用于身份识别。
- 编程风格:程序员的编码习惯和模式也可能成为识别其身份的线索。
这表明,在数字时代,真正的匿名很难实现,零散的信息碎片经过拼凑,足以描绘出一个人的真实面貌。
总结
本节课中,我们一起学习了数据追踪技术在多个层面的应用与影响。我们从大学申请和校园生活开始,看到了追踪如何用于招生、学生支持和安全管理。接着,我们探讨了更广泛的网络追踪机制,如Cookie和浏览器指纹。最后,我们审视了数据泄露的严重后果以及“匿名”数据如何被重新识别,揭示了数字隐私的脆弱性。

这些技术如同一把双刃剑,在提供便利和支持的同时,也带来了关于隐私、知情同意和伦理的深刻挑战。理解这些机制是我们在数字世界中保护自己、并负责任地构建技术的第一步。
21:并发编程 🚀

在本节课中,我们将要学习并发编程的核心概念。并发是指计算机或程序同时处理多个任务的能力。我们将探讨并发的基本思想、它在现代计算中的重要性,以及如何在 Snap! 等编程环境中模拟并发行为。课程最后会介绍阿姆达尔定律,它帮助我们理解并行计算的理论速度极限。


什么是并发?🤔
上一节我们介绍了课程概述,本节中我们来看看并发的具体含义。
在早期计算中,程序通常一次只运行一个任务。计算机速度慢、成本高,且没有多处理器,因此人们需要排队使用计算机。然而,现代计算环境完全不同。
如今,多任务处理极其有用。我们的计算机需要同时处理键盘鼠标输入、硬盘数据读写、网络活动以及运行多个程序。操作系统通过快速在多个任务间切换来实现这一点,即使计算机有多个处理器核心,也常将多任务简化为让程序员按顺序思考。
在 Snap! 中,我们可以直观地看到并发。例如,让两个角色同时开始跳舞,或者同时执行计数和字母表朗读的脚本。虽然底层仍然是按某种顺序交替执行指令,但给人的感觉是它们在同时运行。


Snap! 中的并发机制 ⚙️


上一节我们了解了并发的概念,本节中我们来看看 Snap! 提供了哪些工具来实现并发控制。
Snap! 提供了几种关键机制来处理并发执行:
以下是 Snap! 中用于控制执行顺序的两个主要命令块:
启动:此命令会开始执行一个脚本,但不等待它完成就立即继续执行后续脚本。这允许两个脚本“同时”运行。运行:此命令会执行一个脚本,并等待它完全结束后才继续执行后续脚本。这确保了脚本的顺序执行。

此外,广播机制也涉及并发:

广播与广播并等待:这两个块类似于启动和运行。广播会发送消息并立即继续,允许其他角色同时响应;而广播并等待会等待所有接收到消息的脚本执行完毕后再继续。

这些工具让程序员可以自主选择让任务并行发生还是顺序发生。
并行计算的潜力与挑战 🏗️
上一节我们介绍了 Snap! 中的并发工具,本节中我们来看看在更广泛的计算机科学中,并行计算的优势与局限。
将一项大任务分解,由多个处理器或计算机同时处理,可以显著加快速度。这类似于多人合作盖房子。
然而,并非所有任务都能通过增加人手(或处理器)来线性提速。任务的可并行化程度是关键。
以下是不同类型任务对并行化的响应:
- 高度可并行任务:例如,建造长城或运行多条独立的生产线。增加资源可以近乎线性地提高产出或速度。
- 产出可扩展任务:例如,制造更多手机或分食披萨。增加资源可以增加总产出量,但不一定减少单个任务的时间。
- 难以并行任务:例如,单人驾驶汽车或跑马拉松。这些任务有固有的顺序依赖,增加资源对提速帮助甚微。
在计算领域,像亚马逊这样的网站服务是高度可并行的,可以通过增加服务器来同时响应成千上万的用户请求。而某些计算任务则存在依赖关系,难以分解。
硬件基础:从晶体管到多核时代 💻
上一节我们讨论了任务本身的并行性,本节中我们来看看计算机硬件的发展如何推动并发的必要性。
计算机处理器由数十亿个晶体管构成。晶体管通过电压的“开”(1)和“关”(0)来表示二进制数据,是计算的基础。
通过光刻等技术,我们能在芯片上集成越来越多的晶体管。英特尔联合创始人戈登·摩尔观察到了这一趋势,并提出了摩尔定律:芯片上可容纳的晶体管数量,约每隔两年便会增加一倍。这是一种指数级增长,它使得计算机性能持续快速提升。
然而,大约在2000年左右,单纯提高单个处理器速度遇到了瓶颈:功耗和散热问题变得无法承受。芯片的功率密度一度接近核反应堆的水平。
于是,行业方向从制造更快的单核处理器,转向了设计多核处理器。这意味着,要进一步提升计算能力,必须依靠多个处理器核心同时工作,即并发执行任务。这使得并发编程从一种可选技术变成了充分利用硬件性能的必备技能。
阿姆达尔定律:并行加速的理论极限 ⚖️
上一节我们了解了硬件向多核发展的原因,本节中我们用一个重要定律来量化并行计算所能带来的速度提升极限。
阿姆达尔定律描述了在固定负载下,并行化所能带来的最大理论加速比。
任何程序都可以分为两部分:
- 串行部分:必须按顺序执行的部分。
- 并行部分:可以分解并在多个处理器上同时执行的部分。
设 S 为程序串行部分所占的时间比例,P 为并行部分所占比例(S + P = 1),N 为处理器数量。

程序的最大加速比公式为:
加速比 = 1 / (S + P/N)

当处理器数量 N 趋近于无穷大时,公式简化为:
最大加速比 = 1 / S


这意味着,程序的最终加速比受限于其串行部分的比例。
举例说明:如果一个程序有 20% (S=0.2) 的代码是串行的,那么无论使用多少个处理器,其理论最大加速比上限为 1 / 0.2 = 5 倍。



总结 📚
本节课中我们一起学习了并发编程的核心知识。我们首先了解了并发在现代计算中的必要性,并探索了 Snap! 中实现并发的启动和运行等机制。接着,我们讨论了任务并行化的潜力与挑战,以及硬件从单核到多核发展的历程。最后,我们学习了阿姆达尔定律,它用公式 最大加速比 = 1 / S 清晰地表明,程序的并行加速存在理论极限,该极限取决于其串行部分的比例。


理解并发,能帮助我们写出更高效、更能利用现代计算硬件潜力的程序。
22:计算的极限 🚀



在本节课中,我们将学习计算的极限,特别是并行计算的加速潜力以及并发编程中可能遇到的问题。我们还将探讨不同算法的时间复杂度如何从根本上决定一个问题是否能在合理时间内解决。
并行计算的加速极限 ⚡
上一节我们介绍了并行计算的概念。本节中,我们来看看如何量化并行计算带来的速度提升,以及其理论上的极限。

阿姆达尔定律描述了并行计算中理论上的最大加速比。其核心思想是:一个程序的总运行时间由串行部分和可并行部分组成。

公式:
总时间 T_total = T_serial + T_parallel
使用 N 个处理器并行化后,新总时间为 T_new = T_serial + (T_parallel / N)
加速比 Speedup = T_total / T_new
关键在于,无论使用多少处理器,程序的运行时间都无法低于其串行部分的运行时间。这是并行加速的根本限制。
以下是理解该定律的一个例子:
- 假设一个程序总运行时间为10分钟。
- 其中20%(2分钟)是串行部分,80%(8分钟)是可并行部分。
- 如果我们使用4个处理器来处理可并行部分,那么这部分时间将减少为
8分钟 / 4 = 2分钟。 - 新的总运行时间为
2分钟(串行) + 2分钟(并行) = 4分钟。 - 因此,加速比为
10分钟 / 4分钟 = 2.5倍。 - 即使使用无限多的处理器,最短运行时间也只能无限接近2分钟(串行部分),而无法更短。
并发编程的挑战与竞态条件 ⚠️
当我们允许多个任务(或“精灵”)同时运行时,就需要面对并发编程的挑战。一个核心问题是竞态条件:由于任务执行顺序的不确定性,可能导致程序出现非预期的结果。
考虑一个绘制笑脸的程序,它包含三个步骤:
- 清空画布。
- 绘制左眼(一个圆)。
- 绘制右眼(一条线)。
- 绘制嘴巴。
如果我们将这三个步骤分配给三个不同的精灵同时执行,并且每个精灵都先执行“清空画布”,再绘制自己负责的部分,就会产生问题。后执行的精灵可能会清空先前精灵已经绘制好的图案。
通过在每个步骤间插入随机等待时间来模拟真实的并发环境,我们可以分析出所有可能的输出结果。
以下是所有可能出现的最终画面:
- 理想情况:三个精灵都清空画布后,再分别绘制,得到完整的笑脸。
- 只绘制了一个部分:可能只画了左眼、右眼或嘴巴。
- 只绘制了两个部分:可能组合有左眼+右眼、左眼+嘴巴、右眼+嘴巴。
因此,总共存在 7种 可能的输出结果。这说明了并发编程中顺序不确定性的影响。
为了解决这类问题,一种常见的方法是使用同步机制。例如,让每个精灵在完成“清空”步骤后,设置一个“我已准备就绪”的信号。在开始绘制前,等待所有其他精灵都发出就绪信号后再继续。然而,这又可能引入“死锁”的新问题。
在 Snap! 中,由于其在单线程浏览器环境中运行,使用多精灵并不会真正加速计算。但 warp 积木块可以告诉 Snap! 优先执行当前脚本中的代码,减少在精灵间切换的开销,从而在某些情况下加快循环执行速度,代价是屏幕更新会变慢。



问题的可解性:易解与难解问题 🧮
最后,我们探讨计算的根本极限:哪些问题是计算机可以高效解决的,哪些则不能。这通常由算法的时间复杂度决定。
我们根据输入规模 n 增大时,运行时间的增长级别来对问题分类:
易解问题:
这类问题的算法时间复杂度是多项式时间的,例如:
O(n)- 线性时间O(n²)- 平方时间O(n³)- 立方时间O(n log n)- 线性对数时间
即使像立方时间这样较慢的增长,当输入从1000增加到10000(10倍)时,所需计算资源或时间最多增加 10³ = 1000 倍。这在理论上仍然是可管理的。
难解问题:
这类问题的算法时间复杂度是指数时间的,例如 O(2ⁿ)。
- 当
n=30时,可能需要13天。 - 当
n=40时,可能需要36年。 - 当
n=50时,时间将长达370个世纪!
这种爆炸式的增长意味着,对于中等规模的输入,问题在宇宙寿命内都可能无法解决。除非我们找到更优的算法(例如,斐波那契数列的递归解法是指数级的,但存在线性的迭代解法),或者从根本上改变问题本身。
在计算机科学中,我们将能在多项式时间内解决的问题归为 P类问题。而还有一类被称为 NP 的问题,其特点是验证一个解的正确性可以在多项式时间内完成,但寻找一个解可能非常困难。我们将在下一讲继续探讨这个有趣的话题。
本节课中我们一起学习了:
- 阿姆达尔定律:理解了并行计算加速的理论上限取决于程序的串行部分。
- 并发挑战:认识了竞态条件以及同步机制的必要性与复杂性。
- 问题复杂度:了解了如何通过时间复杂度区分“易解”和“难解”问题,并认识到指数级增长问题是实际计算中的根本性障碍。

这些概念揭示了计算能力的边界,无论是在硬件并行层面还是在算法理论层面。
23:校友分享会











在本节课中,我们将聆听几位曾修读CS10课程的校友分享他们的个人经历、职业发展路径以及给在校学生们的宝贵建议。通过他们的故事,我们可以了解计算机科学知识在不同领域的应用,以及大学期间如何为未来做好准备。




校友介绍
首先,让我们认识一下今天参与分享的几位校友。
Samitita
Samitita于2018年秋季学期修读CS10,并于2021年毕业。她的专业是媒体研究和社会学,辅修教育学。尽管并非STEM专业,她非常享受CS10课程,并因此加入了课程助教团队,从学术实习生一路成长为课程主讲助教。她目前在一家医疗健康传播公司担任市场主管。

Max
Max于2017年春季修读CS10。他并未主修或辅修CS,而是通过跨学科研究构建了自己的专业方向,结合了计算机科学和环境科学。他现在在一家早期初创公司担任软件工程师。
Patricia
Patricia于2017年秋季修读CS10,当时她对计算机科学并无兴趣,但课程改变了她。她最终主修了CS,并成为CS10的学术实习生和讨论课助教,贯穿了整个本科阶段。她现在在Roblox公司担任软件工程师,主要负责网站的前端和后端开发。

E
E于2014年春季修读CS10,并因此转专业到CS。她曾在CS10担任了四个学期的助教。目前她在YouTube担任软件工程师,负责首页推荐算法。



主要建议与心得分享
上一节我们认识了各位校友,本节中我们来看看他们结合自身经历总结出的建议。


Samitita的建议:
- 充分利用选修课:在伯克利这样资源丰富的地方,广泛涉猎不同领域的课程可以极大地拓宽知识面。即使不是CS专业,相关的知识在未来工作中也非常有用。
- 通过兴趣结交朋友:在大学里,通过课程和活动找到志同道合的人建立友谊非常重要。这不仅能丰富大学生活,对未来的社交也很有帮助。
- 掌握基本生活技能:毕业后需要独立生活,学会烹饪几道拿手菜是一项非常实用的技能。




Max的建议:
- 探索校园周边:在伯克利的学习不仅发生在课堂内。探索本地商业、安全地结识新朋友、了解周围环境,也是认识自我和世界的重要部分。

Patricia的建议:
- 保持决心,不畏拒绝:不要因为害怕被拒绝而停止尝试。
- 勇于尝试新事物,培养爱好:不要只专注于学业。利用大学时光探索新爱好,这能帮助你结交朋友,获得更丰富的体验。
- 主动联系,大胆提问:向他人请教问题并非负担,大多数人乐于分享知识。这也能让你学到更多。
E的建议:
- 参加体育课:这是以最低成本尝试新运动的绝佳机会,对身心健康都大有裨益。
- 探索不同院系的课程:不要被内心的偏见限制。对你感兴趣的领域,都应该去尝试。
- 不要等到“完全合格”才申请:对于研究项目或实习机会,即使觉得自己不完全符合要求,也不妨先申请。
关于成为课程助教(AI/TA)
许多校友都曾担任CS10的学术实习生或助教。以下是他们对此的分享和看法。
为何选择成为助教?
大家的初衷各不相同,包括需要一份兼职工作、希望回馈CS10课程、受到之前助教的鼓舞、获得课程学分,或是同伴的影响。但共同点是,在参与教学的过程中,他们都感受到了帮助他人、见证学生“顿悟”时刻的巨大满足感,并且非常享受与课程团队成员共事的氛围。
需要完全理解所有内容吗?
完全不需要。教学本身就是一个持续学习的过程。即使教授也会在每学期教授时学习新东西。关键在于愿意学习、乐于帮助学生,并能够清晰地展示自己的思考过程。遇到不懂的问题时,完全可以坦诚地说“我不知道”,然后与学生一起寻找答案,或向其他助教求助。这种解决问题的过程往往比直接给出答案更有教学价值。
职业发展相关问答
校友们还回答了同学们关于职业发展的几个普遍问题。
计算机科学知识在非技术领域有用吗?
非常有用。当今世界充满数据,即使只具备CS10或61A级别的编程和计算思维基础,也能帮助你更高效地处理数据、理解技术逻辑,并在与技术团队沟通时更有共同语言。技术正渗透到各行各业,具备一定的技术素养能让你更好地理解所从事的领域。
如何找到第一份工作/实习?
- 广泛投递:这是一个数字游戏,需要海投简历。
- 利用校园资源:伯克利的招聘会、校友网络、社团活动都是宝贵的机会。
- 技能迁移:伯克利教育赋予的核心能力是快速学习。工作中需要的技能可能与作业不同,快速学习的能力至关重要。
- 保持信心,持续构建:求职过程可能漫长且令人沮丧,但请坚持下去。即使没有正式实习,通过自由职业、参与教授的项目或自己构建作品集,也能积累宝贵经验。
- 拓宽视野:并非只有传统的科技公司才有机会。每个行业都需要技术人才,也有很多科技公司的非技术岗位(如市场、产品管理)需要复合背景的人才。
地理位置(湾区)对学习CS有优势吗?
地理位置确实带来一些便利,例如更多公司参与校园招聘、浓厚的科技文化氛围以及丰富的线下社交机会。伯克利作为研究型大学,其学术环境和 peer pressure 也是巨大的推动力。然而,在互联网时代,知识本身是无地域限制的。决定性的因素还是个人的主动性和努力。
哪些课外活动对求职有显著帮助?
- 加入课程助教团队:这能锻炼公开演讲、技术沟通和解决问题的能力,这些软技能深受雇主青睐。
- 参与社团并承担技术角色:许多社团都需要人维护网站或技术设施,这是绝佳的实践机会。
- 广泛社交:你永远不知道会遇到谁,一次偶然的交谈可能会带来意想不到的机会。
- 坚持练习:对于软件工程师岗位,坚持在LeetCode等平台练习编码挑战对通过技术面试很有帮助。
- 保持身心健康:坚持锻炼等有益身心的活动,能提升整体状态,间接影响求职表现。
总结
本节课中,我们一起聆听了四位CS10校友的真诚分享。他们的经历告诉我们,计算机科学的学习之旅可以通向多种多样的职业道路,无论是技术核心岗位,还是技术与人文交叉的领域。大学期间,除了掌握专业知识,广泛探索兴趣、培养软技能、积极构建人际关系网络同样重要。不要畏惧尝试和可能的失败,主动抓住身边的机会,无论是成为课程助教、参加社团还是寻找实习。希望他们的经验能为你未来的选择提供一些启发和勇气。
24:结论与告别 🎓


在本节课中,我们将学习计算机科学中一个核心且迷人的领域:计算复杂性。我们将探讨计算机能解决和不能解决的问题类型,理解“难”问题的概念,并介绍P与NP这个著名的开放性问题。课程最后,我们将对这门课程进行总结与告别。
课程回顾与期末考试安排 📝
上一节我们讨论了“可处理”问题。本节中,我们首先回顾一下课程安排。
期末考试将于周五开始。在考试期间,请不要讨论任何与考试相关的内容。关于考试的后勤问题可以正常提问,但涉及考试细节的复习帖将被隐藏或移除。
考试形式如下:助教会提供一个链接,指向一份作业或实验。你需要按照指示完成,并将文件上传到Gradescope。考试的设计是,如果你在提供的示例上运行代码得到了与预期相同的结果,那么你很可能获得了满分。考试要求在某些部分使用高阶函数和递归,这是获得满分的必要条件。如果你不使用递归和高阶函数,而是用迭代方法解决,仍然可以获得部分分数。
下周的课程时间将用于复习。助教将在周一主持复习,我将在周三主持。周三的课程除了复习,还会对CS10课程进行总结,因为我们的进度比原计划慢了一讲。具体是线下还是线上进行待定,但教室已经预定。
难解问题与启发式方法 ⏳
现在,让我们从上周的内容继续。上周我们讨论了可处理问题,即我们知道如何解决的问题。这些问题可以在多项式时间内解决,例如排序列表、寻找中位数等。然而,计算机科学中还存在一整类计算机并不擅长解决的问题,我们称之为难解问题。
以国际象棋为例。给定一个棋盘状态,是否存在一步白棋的走法,能保证白棋最终获胜?这个问题答案非“是”即“否”。但在实践中,这个问题几乎不可能解决,因为可能的走法序列数量极其庞大,呈指数级增长。计算机下棋程序并非“保证”获胜,它们只是使用了相对较好的人工智能和启发式方法来近似求解。
背包问题:一个经典案例 🎒
以下是理解难解问题的一个经典例子:背包问题。
假设你有一个最大承重为15公斤的背包,以及无限数量的以下物品:
- 4公斤金块,价值10美元。
- 2公斤金块,价值2美元。
- 1公斤金块,价值1美元。
目标是:在不超过背包容量的前提下,最大化背包中物品的总价值。
如何思考这个问题?
大多数人会先选择单位重量价值最高的物品。这里,4公斤金块每公斤价值2.5美元(10/4),是最优选择。
计算过程:
- 取3个4公斤金块:总重12公斤,总价值30美元。
- 剩余容量3公斤。选择单位重量价值次高的2公斤金块?不,我们应该选择能填满剩余容量且价值最高的组合。实际上,选择3个1公斤金块只能增加3美元。但如果我们选择1个2公斤金块(2美元)和1个1公斤金块(1美元),总重15公斤,总价值33美元。然而,最优解是选择3个4公斤金块和3个1公斤金块吗?不,这样总重15公斤,价值30+3=33美元。但让我们仔细验算:3个4公斤金块(12公斤,30美元)加上3个1公斤金块(3公斤,3美元),总价值正是33美元。注意:幻灯片中给出的答案是40美元,但根据计算,33美元似乎是当前条件下的最大值。这提醒我们,即使对于简单问题,精确计算也很重要。在实际复杂情况下,找到绝对最优解需要尝试所有物品组合,所需时间随物品数量呈指数级增长,对于大量物品是不现实的。
近似算法:贪婪策略
因此,我们采用启发式方法或近似算法。贪婪算法就是一种常用策略:每一步都选择当前看来最优的物品(例如单位重量价值最高)。这种方法速度很快(接近线性时间),但不能保证总是得到最优解。不过,可以证明,对于背包问题,贪婪算法得到的结果至少能达到最优值的50%。在许多实际场景中,一个“足够好”的快速解比一个永远算不出来的“完美”解更有用。
P问题、NP问题与NP完全问题 🧩
计算机科学家将问题分为不同的复杂度类别,以便更好地理解它们的求解难度。
P类问题(多项式时间)
指那些可以在多项式时间内(如O(n), O(n²), O(n³))被确定性图灵机(可理解为常规计算机)解决的问题。例如排序、搜索。这类问题是“容易”或“可处理”的。
NP类问题(非确定性多项式时间)
指那些答案可以在多项式时间内被验证,但可能无法在多项式时间内被解决的问题。NP代表“非确定性多项式时间”。
关键特性:
- 它们是判定性问题,答案只有“是”或“否”。
- 如果有人给了我们一个答案为“是”的证书(例如,一个具体的解),我们可以在多项式时间内验证这个证书是否正确。
示例:子集和问题
给定一个数字集合,例如 [15, 10, -3, -2, 14, 7],问是否存在一个子集,其元素之和正好为0?
- 求解(难):可能需要尝试所有子集组合(指数时间)。
- 验证(易):如果有人声称子集
[15, -3, -10, -2]的和为0,我们只需做一次加法:15 + (-3) + (-10) + (-2) = 0,即可快速验证其正确性。
许多实际问题可以转化为NP问题,例如:
- 给定预算,是否存在一条飞机航线规划方案?
- 给定一个整数,是否存在两个质数相乘等于它?(这是RSA加密的基础)
- 给定一个能量值,是否存在一种蛋白质折叠构型?
NP完全问题
这是NP问题中“最难”的一类。如果任何一个NP完全问题能在多项式时间内解决,那么所有NP问题都能在多项式时间内解决。也就是说,解决了其中一个,就等于解决了所有。
归约是理解这一点的关键概念。如果我们能将问题A转化为问题B,并且解决问题B的方法也能用来解决问题A,那么问题A的难度就“归约”到了问题B。例如,“寻找中位数”问题可以归约到“排序”问题(先排序,再取中间值)。
子集和问题就是一个经典的NP完全问题。其他例子包括旅行商问题、布尔可满足性问题等。成千上万的问题被证明是NP完全的。
P = NP?百万美元问题 💰
由此引出了计算机科学中最著名的开放性问题:P 是否等于 NP?
- 如果 P = NP,意味着所有NP问题(包括那些极其困难的问题)实际上都有多项式时间的解法,只是我们尚未发现。这将彻底改变计算机科学、密码学、优化等众多领域。
- 如果 P ≠ NP,意味着NP完全问题本质上是难解的,不存在通用的高效算法。大多数科学家相信 P ≠ NP。
2000年,克雷数学研究所将“P vs NP问题”列为七个千禧年大奖难题之一,并为解决任何一个问题悬赏100万美元。至今无人能证明P是否等于NP。
不可解问题:停机问题 ⛔
除了难解问题,还存在一类不可解问题,即无论给予多少时间和资源,计算机都永远无法解决的问题。
最著名的例子是停机问题:能否编写一个程序H,它可以判断任意程序P在给定输入I下是否会终止(停机),还是会永远运行下去(死循环)?
艾伦·图灵通过巧妙的反证法证明:这样的程序H不可能存在。这表明计算机的能力存在根本性极限,并非所有问题都是可计算的。
课程总结与告别 👋
本节课中,我们一起探索了计算复杂性的广阔领域:
- 我们回顾了可处理问题与难解问题的区别。
- 通过背包问题,学习了使用贪婪算法等启发式方法获取近似解。
- 我们定义了P类(易解)、NP类(易验证)和NP完全问题(NP中最难的问题)。
- 我们探讨了P vs NP这个核心开放性问题及其深远意义。
- 最后,我们提到了像停机问题这样的不可解问题,认识到计算机能力的内在局限。
计算之美与乐趣不仅在于创造能做什么的程序,也在于理解计算的边界在哪里。希望这门课激发了你们对计算机科学的兴趣和思考。



祝大家在期末考试和最终项目中好运!感谢大家这一学期的参与。再见!
25:期中复习要点解析 (2022年3月14日)


在本节课中,我们将一起回顾期中考试的核心知识点。我们将重点讨论数字转换、真值表、编程范式、可变性、递归以及算法复杂度等主题。课程内容将基于往届考试和讨论课中的常见问题,旨在帮助你巩固理解,为考试做好准备。
关于期中考试的一般信息
首先,我们来了解一下期中考试的整体情况。本次期中考试的结构与之前的测验类似,但题目数量更多,共有13道题。前几道题是阅读类题目,之后会有一些非常简短的填空题,例如填写数字或脚本可能返回的字母。你不需要从头编写代码,但可能需要通过选择正确的代码块来完成脚本。考试的重点会更侧重于近期学习的主题。
你可以访问 CS10.org/resources 网站,在“exams”文件夹中找到往届的考试题目进行练习。虽然旧考题可能要求编写更多代码,但新考题在题型结构上(如阅读代码、追踪函数返回值、选择填空等)是相似的。
在考试中,如果遇到复杂问题,请充分利用草稿纸进行演算。研究表明,人脑同时处理的信息量有限,将思路写在纸上可以有效避免遗忘,让解题过程更清晰。

数字表示与转换
上一节我们介绍了考试的整体情况,本节中我们来看看数字表示与转换。这部分的核心是理解不同进制(如二进制、八进制、十六进制)如何转换为十进制。
转换的关键在于计算每一位的“位权”。对于一个以 b 为基数的数字,从右向左,第 n 位(从0开始计数)的值等于该位数字乘以 b^n。
公式:(数字)_b = d_n * b^n + d_{n-1} * b^{n-1} + ... + d_0 * b^0
例如,八进制数 34_8 转换为十进制的过程是:
4 * 8^0 + 3 * 8^1 = 4 + 24 = 28
考试中,我们主要关注从其他进制到十进制的转换,计算过程相对直接。
真值表与布尔逻辑
理解了数字转换后,我们进入逻辑推理部分:真值表与布尔逻辑。这是需要重点练习的内容。

真值表用于列出布尔表达式在所有可能输入组合下的输出结果。在 Snap! 中,我们主要使用 与(and) 和 或(or) 这两个运算符。
以下是 与(and) 和 或(or) 的真值表:
与(and) 运算:仅当所有输入都为 true 时,结果才为 true。
true and true=>truetrue and false=>falsefalse and true=>falsefalse and false=>false
或(or) 运算:只要有一个输入为 true,结果就为 true。
true or true=>truetrue or false=>truefalse or true=>truefalse or false=>false
简单记忆:对于 and,只有全真才为真;对于 or,只有全假才为假。

考试中可能会遇到更复杂的表达式,例如 (5 > 3) or (3 < 1)。解题方法是先独立计算每个子表达式(5 > 3 是 true,3 < 1 是 false),然后根据运算符规则(true or false 得 true)得出最终结果。也可能需要你反向推理,填入合适的值使整个表达式为真或假。
编程范式

在理清了布尔逻辑之后,我们转向一个更宏观的概念:编程范式。这是上周三课程的重点。
编程范式是编写代码的不同风格或方法论。在 CS10 中,我们介绍了五种主要范式:

- 函数式编程:核心是使用函数(尤其是高阶函数)来操作数据。例如
map、keep、combine等函数。 - 基于数组的编程:将数组作为基本数据类型进行操作,支持元素级的数学运算(如两个列表直接相加)。
- 命令式编程:通过一系列明确的步骤(语句)来改变程序状态,这是最直观的编程风格。
- 面向对象编程:围绕“对象”组织代码,对象包含数据(属性)和行为(方法)。例如在动画中操纵角色精灵。
- 声明式编程:描述“做什么”而非“怎么做”,设定规则让计算机自行寻找解决方案。例如为地图省份动态着色。

需要理解的是,没有一种范式绝对优于另一种。不同范式各有优劣,适用于不同的任务。在 CS10 中,你主要需要能够识别一段代码所体现的编程风格,并理解其基本思想。


可变性与作用域


讨论完编程范式,我们来看一个具体且容易出错的概念:可变性与作用域。我们将通过一个讨论课的例子来深入理解。
考虑以下两个脚本,问题是脚本执行后变量 x 和 y 的值是什么?


脚本 A:
设置 x 为 123
设置 y 为 列表 [1, 2, 3]
设置输入为 8
脚本 B:
设置 x 为 123
设置 y 为 列表 [1, 2, 3]
添加 6 到 y
在 脚本 A 中,我们将数字 123 传递给一个自定义块,该块内部执行 设置输入为 8。由于数字是不可变的基本数据类型,传递的是值的副本。块内部对 输入 的修改不会影响外部的 x。因此,x 仍然是 123,y 仍然是 [1, 2, 3]。

在 脚本 B 中,我们将列表 [1, 2, 3] 传递给一个自定义块,该块执行 添加 6 到 y。列表是可变数据类型。像 添加、删除、插入、替换 这样的命令会直接修改传入的列表本身。因此,执行后 y 变为 [1, 2, 3, 6],而 x 不变。
关键点:当在自定义块内部使用 设置...为... 时,会创建一个新的局部绑定,切断与原始参数的链接。但使用修改命令(如 添加)则会直接操作原始数据。
一个更复杂的例子是:在块内部先修改列表,再用 设置 将其指向一个新列表。此时,设置 之前的修改会生效,但之后的操作就与原始变量无关了。理解这种作用域和可变性的交互是掌握本主题的关键。

递归

接下来,我们探讨一个重要的编程技巧:递归。递归函数通过调用自身来解决问题。

我们以经典的斐波那契数列为例。斐波那契数列的定义是:fib(0) = 0, fib(1) = 1, 对于 n > 1, fib(n) = fib(n-1) + fib(n-2)。
在 Snap! 中,对应的递归函数可能这样实现:
如果 n < 2
报告 n
否则
报告 (fib(n-1) + fib(n-2))
分析递归的关键在于追踪调用过程。以计算 fib(5) 为例:
fib(5)调用fib(4)和fib(3)。fib(4)调用fib(3)和fib(2)。- 以此类推,直到达到基础情况
fib(1)=1和fib(0)=0。 - 然后逐层返回结果并相加:
fib(2) = 1+0 = 1,fib(3) = 1+1 = 2,fib(4) = 2+1 = 3, 最终fib(5) = 3+2 = 5。

解决递归问题时,建议在草稿纸上画出调用树,并记录每次调用的参数和返回值。递归虽然特殊,但每次函数调用在机制上与普通调用无异。

算法复杂度
最后,我们来复习算法复杂度分析。我们关注的是随着输入规模 n 的增长,算法所需步骤数(运行时间)的增长趋势,而非具体的执行时间。
在 CS10 中,我们主要接触以下几种复杂度类型:
- 常数时间 O(1):运行时间不随输入大小改变。例如,访问数组的第一个元素。
- 对数时间 O(log n):输入规模翻倍,运行时间只增加一步。例如,二分查找算法。
- 线性时间 O(n):运行时间与输入大小成正比。例如,遍历列表的
for each循环或map操作。 - 平方时间 O(n²):运行时间与输入大小的平方成正比。通常出现在嵌套循环中,例如处理一个
n x n的网格。 - 指数时间 O(2^n):输入每增加一,运行时间大约翻倍。例如,未优化的递归斐波那契算法,或者某些分形绘制(如树形分形,分支数随层级指数增长)。
理解这些复杂度类别的含义,并能根据代码结构(如循环、递归)判断其大致复杂度,是这部分的学习目标。


总结
本节课中我们一起学习了 CS10 期中考试的核心复习要点。我们回顾了数字转换的原理、布尔逻辑与真值表的用法、五种编程范式的特点、可变数据类型与作用域的注意事项、递归函数的分析与追踪方法,以及算法复杂度的基本分类。

希望本次复习课能帮助你梳理知识,建立信心。请务必利用课程资源中的往届试题进行练习,并在遇到问题时及时在 Ed 讨论区提问。祝大家在期中考试中取得好成绩!

浙公网安备 33010602011771号