哈佛-CS50-计算机科学导论笔记-全-
哈佛 CS50 计算机科学导论笔记(全)
哈佛 CS50-AI 1:课程内容介绍 🧠


在本节课中,我们将要学习哈佛大学CS50人工智能课程的整体框架与核心内容。这门课程由Brian老师主讲,旨在使用Python语言,带领我们探索现代人工智能的基础概念与关键技术。
我们将从人工智能如何解决问题开始,逐步深入到知识表示、优化、机器学习以及自然语言处理等核心领域。课程不仅讲解理论,更注重实践,会提供机会让我们亲手构建自己的人工智能程序。
上一段我们了解了课程的整体目标,接下来,我们来看看课程将要涵盖的具体技术模块。以下是课程的核心内容大纲:
- 搜索算法:人工智能如何寻找问题的解决方案,例如玩游戏或规划行车路线。
- 知识表示与推理:人工智能如何表示信息(包括确定性和不确定性的信息),并利用这些信息进行推理和得出结论。
- 优化问题:人工智能如何解决最大化利润、最小化成本或满足特定约束的优化问题。
- 机器学习:人工智能如何通过访问数据和经验来自主学习执行任务,而无需被明确告知具体解决方法。
- 神经网络:作为现代机器学习中最受欢迎的工具之一,我们将重点学习受人类大脑启发的神经网络如何学习和推理。
- 自然语言处理:人工智能如何学习理解、解释并与人类进行语言交流。

本节课中我们一起学习了哈佛 CS50人工智能课程(Python版)的宏伟蓝图。我们了解到,本课程将引导我们系统性地掌握从基础搜索到前沿自然语言处理的人工智能核心知识,并通过实践项目巩固所学。接下来的课程将逐一深入这些激动人心的主题。
哈佛 CS50-AI 2:L0- 搜索算法 1 (搜索问题,深度优先搜索) 🧠







在本节课中,我们将学习人工智能的基础思想、技术和算法。人工智能涵盖多种类型,例如计算机识别照片中的人脸、比人类更好地玩游戏或理解人类语言。这些都是人工智能技术的表现。我们将从搜索问题开始讨论,即让AI寻找解决特定问题的方案。

概述:什么是搜索问题? 🎯


搜索问题是指AI代理在特定环境中寻找解决方案的过程。例如,寻找从A点到B点的驾驶路线,或决定在井字游戏中如何走棋。AI需要从初始状态出发,通过一系列动作到达目标状态。
核心概念与术语 📖


为了使AI能够找到解决方案,我们需要引入一些核心术语。

代理 (Agent)
代理是能够感知环境并对其采取行动的实体。在驾驶导航中,代理可以是汽车;在15拼图中,代理可以是试图解决谜题的AI或人。


状态 (State)
状态是代理在其环境中的一种配置。例如,在15拼图中,状态是拼图块的某种排列方式。代理的起始位置称为初始状态。
动作 (Action)
动作是代理在特定状态下可以执行的操作。我们可以定义一个函数 actions(s),它接受一个状态 s 作为输入,并返回在该状态下所有可执行动作的集合。

代码示例:
def actions(state):
# 返回在给定状态下所有可能的动作列表
possible_actions = []
# ... 根据状态逻辑计算可能的动作
return possible_actions


转换模型 (Transition Model)
转换模型描述在执行某个动作后,状态如何变化。我们可以定义一个函数 result(s, a),它接受状态 s 和动作 a 作为输入,并返回执行动作后的新状态。
代码示例:
def result(state, action):
# 根据当前状态和执行的动作,返回新的状态
new_state = state.copy()
# ... 根据动作逻辑更新 new_state
return new_state
状态空间 (State Space)
状态空间是所有可以从初始状态通过任何动作序列到达的状态的集合。它可以被可视化为一个图,其中节点代表状态,边代表动作。



目标测试 (Goal Test)
目标测试是一种方法,用于确定给定状态是否为目标状态。例如,在驾驶导航中,目标测试是检查当前位置是否为目的地。
路径成本 (Path Cost)
路径成本函数为每条路径(动作序列)分配一个数值成本。AI的目标不仅是找到解决方案,而且是找到成本最低的解决方案(最优解)。

公式:
总路径成本 = 动作1的成本 + 动作2的成本 + ... + 动作N的成本
解决方案 (Solution)
解决方案是将我们从初始状态带到目标状态的一系列动作。最优解决方案是所有可能解决方案中路径成本最低的那个。
解决搜索问题的方法 ⚙️

上一节我们定义了搜索问题的各个组成部分,本节我们来看看如何系统地解决它。

我们将使用一种称为节点的数据结构来跟踪搜索过程中的信息。每个节点包含以下四个值:
- 状态:节点当前代表的状态。
- 父节点:导致当前节点的上一个节点。
- 动作:从父节点状态转移到当前节点状态所采取的动作。
- 路径成本:从初始状态到达当前节点的累积成本。
搜索算法框架
解决搜索问题的通用算法框架如下:
- 初始化一个称为前沿的数据结构,它包含代表初始状态的节点。
- 初始化一个已探索集合,用于记录已经访问过的状态(初始为空)。
- 进入循环:
- 如果前沿为空,则问题无解,算法终止。
- 从前沿中移除一个节点。
- 检查该节点的状态是否通过目标测试。如果是,则成功找到解决方案,算法终止。
- 将该节点的状态加入已探索集合。
- 扩展该节点:对其状态应用
actions函数,得到所有可能的后续状态。对于每个后续状态:- 如果该状态既不在前沿中,也不在已探索集合中,则根据该状态创建一个新节点,并将其加入前沿。
这个框架的关键在于如何从前沿中移除节点。不同的移除策略将导致不同的搜索算法。
深度优先搜索 (Depth-First Search) 🌲


如果我们把前沿数据结构实现为一个栈,那么这就是深度优先搜索。栈是“后进先出”的数据结构,这意味着我们总是优先探索最近加入前沿的节点。


以下是深度优先搜索的特点:
- 它倾向于在搜索树中一条路走到底(深入),直到无法继续(死胡同),然后回溯到上一个分支点,尝试另一条路径。
- 它不一定能找到最优解(最短路径),但通常能较快地找到一个解。
- 需要配合已探索集合来避免在状态间无限循环。


深度优先搜索示例
假设我们有一个简单的图搜索问题,目标是找到从A到E的路径。

步骤:
- 前沿初始化为
[A],已探索集合为{}。 - 移除A(栈顶),它不是目标。扩展A得到B。将B加入前沿。前沿变为
[B],已探索集合为{A}。 - 移除B,它不是目标。扩展B得到C和D。将C和D加入前沿(假设按C、D顺序)。前沿变为
[C, D],已探索集合为{A, B}。 - 移除D(栈顶),它不是目标。扩展D得到F。将F加入前沿。前沿变为
[C, F],已探索集合为{A, B, D}。 - 移除F,它不是目标。扩展F没有得到新状态(或新状态已被探索)。前沿变为
[C],已探索集合为{A, B, D, F}。 - 移除C,它不是目标。扩展C得到E。将E加入前沿。前沿变为
[E],已探索集合为{A, B, D, F, C}。 - 移除E,它是目标!算法成功,返回解决方案。
找到的路径是 A → B → C → E。注意,深度优先搜索找到的路径可能不是最短的(例如,如果存在 A → B → D → E 的路径,它可能更晚被发现或不被发现)。
总结 📝
本节课我们一起学习了人工智能中搜索问题的基础。
- 我们首先定义了搜索问题的关键组成部分:代理、状态、动作、转换模型、状态空间、目标测试和路径成本。
- 接着,我们介绍了一个通用的搜索算法框架,该框架使用前沿和已探索集合来系统地探索状态空间。
- 最后,我们深入探讨了深度优先搜索算法,该算法通过将前沿实现为栈,优先深入探索搜索树的分支。我们了解了它的工作原理、示例以及需要注意的循环避免问题。

深度优先搜索是众多搜索算法中的一种。在接下来的课程中,我们将看到其他策略(如广度优先搜索、代价一致搜索)如何以不同的方式探索状态空间,并可能带来更好的结果。
哈佛 CS50-AI 3:L0- 搜索算法 2 (广度优先搜索,贪心搜索,A*算法) 🧭

在本节课中,我们将要学习三种重要的搜索算法:广度优先搜索、贪心最佳优先搜索和 A*搜索。我们将通过迷宫求解的例子,理解它们的工作原理、优缺点以及适用场景。

概述

上一节我们介绍了深度优先搜索(DFS),它是一种无信息搜索算法。本节中我们来看看另外三种算法:广度优先搜索(BFS)、贪心最佳优先搜索(GBFS)和A*搜索。我们将探讨它们如何通过不同的策略来探索状态空间,并比较它们在寻找解决方案时的效率和最优性。
广度优先搜索(BFS)
广度优先搜索(BFS)的行为与深度优先搜索(DFS)相似,但有一个关键区别:它不会总是探索搜索树中最深的节点。相反,BFS总是会探索边界中最浅的节点。
这意味着我们不再使用栈(后进先出,LIFO)作为数据结构,而是使用队列(先进先出,FIFO)。队列中最早添加的项目将最先被探索。



BFS如何工作
假设我们有一个从A到E的路径寻找问题。


- 从节点A开始探索。
- 从A可以到达B,将B加入队列。
- 探索B,发现可以从B到达C和D。将C和D加入队列(假设按顺序加入C,然后D)。
- 由于队列是先进先出,接下来会探索C(因为它先于D加入)。
- 从C可以到达E,将E加入队列。
- 接下来探索D(它在C之后加入队列),从D可以到达F。
- 最后探索E,找到目标。
BFS的探索顺序像是从初始状态一层一层向外扩散。它先探索所有距离初始状态一步的节点,然后是两步的节点,依此类推。这与DFS不同,DFS会沿着一条路径深入到底,遇到死胡同时再回溯。
BFS在迷宫中的表现
考虑一个迷宫求解问题,代理需要从起点A移动到终点B。

以下是BFS在迷宫中的工作方式:
- BFS首先查看所有距离起点一步的节点。
- 然后查看所有距离起点两步的节点,依此类推。
- 它几乎同时探索所有可能的路径,但确保先探索更接近起点的路径。

在大多数情况下,BFS找到的路径是最优的,即从起点到终点的最短路径。然而,为了找到这条路径,BFS可能需要探索大量的状态,尤其是在目标较远或迷宫复杂时。

深度优先搜索(DFS)与广度优先搜索(BFS)的对比


我们已经了解了DFS和BFS的基本原理。现在我们来总结一下它们之间的权衡。


- 最优性:BFS保证能找到最短路径(最优解),而DFS不一定。
- 内存与效率:DFS可能在某些情况下比BFS节省内存,因为它不需要同时存储大量浅层节点。然而,DFS可能会探索一条很长的错误路径,导致效率低下。
- 探索方式:DFS是“纵向”深入探索,BFS是“横向”分层探索。


在实际的迷宫求解代码中,只需将数据结构从栈(用于DFS)改为队列(用于BFS),即可实现算法的切换。在示例中,将StackFrontier类替换为QueueFrontier类,后者通过从列表开头移除节点来实现先进先出。



# 队列前沿实现示例(关键部分)
def remove(self):
if self.empty():
raise Exception("empty frontier")
else:
node = self.frontier[0] # 获取队列中的第一个节点
self.frontier = self.frontier[1:] # 移除第一个节点
return node
运行结果显示,对于同一个迷宫,BFS(探索77个状态)比DFS(探索399个状态)需要探索的状态数量少得多,并且找到了相同的最优解。

有信息搜索:利用问题特定知识


BFS和DFS都是无信息搜索算法,它们不利用任何关于问题本身的知识(如目标的位置)。而人类在解决迷宫时,会本能地选择看似更接近目标的方向。

这引出了有信息搜索算法,它们利用特定问题的知识(启发式)来更有效地找到解决方案。接下来我们将介绍两种有信息搜索算法。


贪心最佳优先搜索(GBFS)


贪心最佳优先搜索(Greedy Best-First Search)始终扩展它认为离目标最近的节点。它使用一个启发式函数 h(n) 来估算从当前状态n到目标的距离。

启发式函数

在迷宫问题中,一个常用的启发式函数是曼哈顿距离。它计算从当前单元格到目标单元格在水平和垂直方向上的步数之和(忽略墙壁)。

例如,对于单元格C和D,我们计算它们到目标B的曼哈顿距离。距离更小的单元格被认为“更好”。


GBFS如何工作
GBFS在每一步都选择 h(n) 值最小的节点进行扩展。

- 从起点开始,计算每个可达邻居的启发值。
- 选择启发值最小的节点作为下一步。
- 重复此过程,直到到达目标。


通过利用“距离目标更近更好”的启发式知识,GBFS通常能比BFS探索更少的状态就找到一条路径。然而,它找到的路径不一定是最优的,因为它只关注当前看来最好的选择,缺乏全局规划,可能被局部最优引入歧途。



A*搜索算法

为了克服GBFS可能找不到最优解的缺点,A搜索算法被提出。A搜索结合了两方面的信息:
- g(n):从起点到当前节点n的实际成本(例如已走步数)。
- h(n):从当前节点n到目标的估计成本(启发值)。
A*搜索总是扩展 f(n) = g(n) + h(n) 值最小的节点。
A*搜索如何工作
f(n) 可以理解为“通过节点n到达目标的总估计成本”。A*搜索不仅考虑离目标还有多远(h(n)),还考虑已经走了多远(g(n))。

在决策点时,A*会计算每条选项的 g(n) + h(n)。它可能不会选择当前 h(n) 最小的节点,如果到达那个节点已经花费了很大的 g(n)。相反,它会选择一个总估计成本 f(n) 最小的路径。
A*搜索的最优性条件
A*搜索要保证找到最优解,其启发式函数 h(n) 必须满足以下条件:
- 可采纳性:
h(n)永远不会高估到达目标的真实成本。它必须是乐观的估计,要么等于真实成本,要么小于真实成本。 - 一致性:对于任意节点n及其后继节点n’,有
h(n) ≤ cost(n, n’) + h(n’)。这意味着启发值在节点间的变化不会超过实际步的成本,保证估计的一致性。
只要启发式函数满足这些条件,A*搜索就能保证找到最优解。它是最优且高效的搜索算法,在人工智能领域应用非常广泛。
总结
本节课中我们一起学习了三种重要的搜索算法:
- 广度优先搜索(BFS):使用队列,分层探索,保证找到最短路径,但可能消耗较多内存。
- 贪心最佳优先搜索(GBFS):使用启发式函数
h(n),总是选择当前离目标最近的节点。它通常很快,但不保证找到最优解。 - A*搜索:结合了实际成本
g(n)和启发式估计h(n),通过最小化f(n) = g(n) + h(n)来选择路径。在启发式函数可采纳且一致的条件下,它既能保证找到最优解,又比BFS更高效。
选择哪种算法取决于具体问题:如果只需要任何解且状态空间深,DFS可能合适;如果需要最短路径且状态空间分支因子不大,BFS可靠;如果有一个良好的启发式函数,GBFS可以很快;如果既要最优解又要效率,A*搜索是最佳选择。理解这些算法的权衡是设计高效AI系统的关键。
哈佛 CS50-AI 4:L0- 搜索算法 3 (极大极小算法,剪枝,深度限制) 🧠⚔️
在本节课中,我们将要学习对抗性搜索算法。与之前寻找从A点到B点路径的经典搜索不同,对抗性搜索模拟的是两个或多个具有对立目标的智能体之间的互动,例如棋类游戏。我们将重点探讨极大极小算法,它是处理此类问题的核心方法,并学习如何通过剪枝和深度限制来优化它,以应对更复杂的游戏。

对抗性搜索问题概述

上一节我们介绍了经典搜索问题,本节中我们来看看对抗性搜索。在这种情境中,存在一个试图做出智能决策的智能体(例如玩家),同时有另一个智能体(对手)正在对抗它,并且双方目标相反。一方试图成功,另一方则希望其失败。这种情况在游戏中非常常见。
以井字棋为例,我们有一个3x3的网格,X和O轮流在方格中落子。X玩家的目标是获得三个连续的X,O玩家的目标是获得三个连续的O。计算机在玩这类游戏时可以表现得相当出色。

井字棋的规则相对简单,但对于更复杂的游戏,智能决策会是什么样子呢?
游戏中的智能决策

假设在井字棋中,X先手将棋子放在中间,O随后落子。

那么,在这种情况下,智能的X应该如何移动?最终结果有几种可能性。如果AI以最佳方式游戏,它可能会选择右上方的位置。因为此时O的目标与X相反:O试图阻止X获得三连,而X则试图达成这个目标。
因此,O会在这里落子进行阻挡。但现在X可以做出一个相当聪明的移动:X可以进行这样的移动,从而制造出两个可能获胜的路径。X既可以通过横向获得三连,也可以通过纵向获得三连来赢得游戏。因此,O的下一步移动将无关紧要。例如,O可以在这里落子阻挡横向的三连,但随后X将通过纵向的三连赢得游戏。


要让计算机能够解决这类问题,需要一种与我们目前所见问题相似但又有区别的框架。这涉及到棋盘的状态和从一个动作到下一个动作的转变。这与传统搜索问题不同,因为现在这是一个对抗性搜索问题:一个玩家试图找到最佳移动,但知道有对手在阻止自己。
因此,我们需要一种专门的算法来处理这种对抗性的情况。

极大极小算法基础
我们要学习的算法叫做极大极小算法。它在确定性双人游戏中效果很好,也可以适用于其他类型的游戏。我们现在关注的是双方轮流行动、目标对立的游戏:一方试图赢,另一方也试图赢(即努力让对方输)。
为了将“赢”和“输”的人类概念转化为计算机可以处理的形式,我们需要将其数值化。计算机理解数字,因此我们需要一种方式将游戏结果(如井字棋的输、赢、平)转化为数值。
在井字棋中,可能的结果有三种:O赢、X赢或平局。我们可以为这些结果分配数值:
- O胜利:值设为 -1
- 平局:值设为 0
- X胜利:值设为 1
我们有两个玩家:X玩家和O玩家。在极大极小算法中,X玩家被称为最大化玩家,因为它旨在最大化最终得分(希望得到1)。O玩家被称为最小化玩家,因为它旨在最小化最终得分(希望得到-1)。
这样,赢、输和平局的概念就被简化为数学上的最大化或最小化分数问题。
游戏的形式化定义
为了让AI能够玩像井字棋这样的游戏,我们需要将游戏规则形式化,编码成计算机可以理解的结构。这需要以下几个组成部分:
以下是定义游戏所需的组件:

- 初始状态
s0:游戏开始时的状态。例如,一个空的井字棋棋盘。 - 玩家函数
Player(s):接受一个状态s作为输入,输出当前轮到哪个玩家行动(X或O)。 - 行动函数
Actions(s):接受一个状态s作为输入,输出在该状态下所有可能采取的行动集合。 - 转移模型
Result(s, a):接受一个状态s和一个行动a作为输入,输出采取行动a后得到的新状态。 - 终止测试
Terminal(s):检查一个状态s是否为终止状态(即游戏结束)。在井字棋中,当一方三连或棋盘填满时游戏终止。 - 效用函数
Utility(s):仅对终止状态s有效,返回该状态的数值分数(例如,X赢为1,O赢为-1,平局为0)。
通过这些函数,我们告诉AI游戏的规则、如何进行以及如何评估结果。

极大极小算法的工作原理

对于非终止状态,我们如何确定其价值并做出最佳决策?这就需要极大极小算法。其核心思想是:站在当前玩家的角度,考虑所有可能的行动,并递归地模拟对手会如何最优应对,一直推导到游戏结束的终止状态,然后反向推导出当前状态的价值。

以一个具体的中间棋盘状态为例,假设轮到O玩家行动。O有两个可能的落子点:左上角和下中间。算法会分别考虑这两个选择:
- 如果O选择左上角,那么接下来轮到X行动。在这个新状态下,X只有一个选择(完成三连),这将导致X获胜,效用为1。因此,从O的角度看,选择这个行动会导致一个价值为1的后续状态。
- 如果O选择下中间,那么接下来X也只能在左上角落子,这将导致平局,效用为0。因此,这个行动会导致一个价值为0的后续状态。
现在,作为最小化玩家的O,会在两个选择的价值(1和0)中,选择最小的那个,即0。因此,O的最佳移动是选择下中间,这个决策状态的价值也就被评估为0。
这个过程可以抽象成一棵极大极小树。树中的节点代表游戏状态,向上指的箭头(或标记为MAX的节点)代表最大化玩家的回合,它选择能带来最大价值的子节点;向下指的箭头(或标记为MIN的节点)代表最小化玩家的回合,它选择能带来最小价值的子节点。通过从终止节点的效用值反向传播,我们可以计算出根节点(当前状态)的价值,从而决定最佳行动。

算法伪代码实现

以下是极大极小算法的伪代码实现,包含两个核心函数:Max-Value(为最大化玩家计算状态价值)和 Min-Value(为最小化玩家计算状态价值)。


function Max-Value(state):
if Terminal(state):
return Utility(state)
v = -∞
for action in Actions(state):
v = max(v, Min-Value(Result(state, action)))
return v
function Min-Value(state):
if Terminal(state):
return Utility(state)
v = +∞
for action in Actions(state):
v = min(v, Max-Value(Result(state, action)))
return v
要做出决策,最大化玩家只需选择能产生最高 Min-Value 结果的行动。
优化一:Alpha-Beta 剪枝
极大极小算法需要探索整个游戏树,对于复杂游戏(如国际象棋)这是不可行的。Alpha-Beta 剪枝是一种优化技术,可以在不改变最终结果的情况下,剪除(跳过)树中不需要探索的分支,从而大幅减少计算量。
其核心思想是跟踪两个值:
- α:在当前路径上,最大化玩家至少能保证的分数(下界)。
- β:在当前路径上,最小化玩家至多能允许的分数(上界)。
在搜索过程中,如果发现某个节点的价值对于当前玩家来说已经不可能优于已知的最佳选择,就可以停止探索该节点的其余分支。
例如,在最大化玩家的回合,如果它已经发现一个行动能保证得分至少为4(α=4),而在评估另一个行动时,发现最小化玩家可以轻松地将得分限制在3或更低(即该分支的β ≤ 3)。由于3 < 4,这个分支无论如何都不会被最大化玩家选中,因此可以立即“剪枝”,不再探索该分支下的其他节点。
Alpha-Beta剪枝能极大提升搜索效率,但其效果依赖于节点探索顺序,好的顺序能带来更多的剪枝。
优化二:深度限制与评估函数

即使使用剪枝,对于像国际象棋这样分支因子巨大、步数很长的游戏,搜索到终局仍然不可能。解决方案是采用深度限制的极大极小算法。


我们不再搜索到游戏结束,而是设定一个最大搜索深度(例如,向前看10步)。当达到深度限制时,游戏很可能尚未结束,因此无法使用 Utility 函数。这时,我们需要一个评估函数 Evaluation Function。



评估函数的作用是:对于一个非终止状态,估计其对于最大化玩家的期望效用。它是一个启发式函数,质量高低直接决定AI的强弱。
例如,在国际象棋中,一个简单的评估函数可能基于棋子价值(后=9,车=5,象/马=3,兵=1),计算双方棋子总价值的差值。更复杂的评估函数会考虑棋子位置、国王安全性、控制中心等因素。



深度限制的极大极小算法在达到深度限制时,不再递归调用 Min-Value 或 Max-Value,而是直接返回评估函数对该状态的计算结果。


总结
本节课中我们一起学习了对抗性搜索的核心算法——极大极小算法。我们从对抗性问题的定义出发,学习了如何将游戏形式化,并详细阐述了极大极小算法通过递归模拟对手最优应对来进行决策的原理。
为了应对复杂游戏的计算挑战,我们介绍了两种关键优化技术:
- Alpha-Beta剪枝:通过跟踪α和β值来剪除不必要的搜索分支,提高效率。
- 深度限制与评估函数:通过限制搜索深度并在深层节点使用评估函数来估计局面价值,使算法能够处理像国际象棋这样庞大的状态空间。
这些算法是AI在棋类游戏等对抗性环境中做出智能决策的基础。虽然井字棋可以被完全求解,但更复杂的游戏需要这些优化技术和精心设计的评估函数,AI才能进行有效的决策。
哈佛 CS50-AI 5:L1- 知识系统与逻辑推理 🧠



在本节课中,我们将要学习人工智能中一个核心概念:知识。我们将探讨如何让AI像人类一样,基于已知的事实进行推理,并得出新的结论。这涉及到一种称为命题逻辑的形式化语言,我们将学习其基本构成和推理规则。


上一节我们讨论了搜索问题,AI代理在环境中通过行动来解决问题。本节中,我们将关注一个更普遍的概念:知识。许多智能行为都基于知识,我们人类知道关于世界的事实,并利用这些信息进行推理,从而得出结论或指导行动。
我们希望AI也能做到这一点。我们将要构建的AI被称为知识型代理,它能够在内部表示知识,并基于这些知识进行推理和行动。
从例子开始:基于知识的推理
让我们看一个简单的例子,理解什么是基于知识的推理。假设我们知道以下三个事实:
- 如果今天不下雨,哈利就会去拜访海格。
- 哈利今天要么拜访了海格,要么拜访了邓布利多,但不会同时拜访两人。
- 哈利今天拜访了邓布利多。
作为人类,我们可以进行推理:
- 根据事实2和3(哈利拜访了邓布利多,且只能拜访一人),我们可以得出结论:哈利今天没有拜访海格。
- 再结合事实1(如果不下雨,哈利就会拜访海格),既然哈利没有拜访海格,我们就可以进一步推断:今天下雨了。
这种基于已知信息,运用逻辑推导出新结论的过程,就是推理。本节课的目标就是让AI学会这种推理。

🔤 命题逻辑:AI的推理语言


为了让计算机进行逻辑推理,我们需要一种形式化的语言。我们将使用命题逻辑。它基于关于世界的命题(即可以判断真假的陈述)。

以下是构成命题逻辑的核心要素:

命题符号
命题符号通常用大写字母(如 P, Q, R)表示,它们代表一个基本的事实或陈述。
- 例如:
P可以代表“正在下雨”,Q可以代表“哈利拜访了海格”。
逻辑联结词
单独的命题符号不足以表达复杂关系。我们需要逻辑联结词将它们组合起来。以下是五个最重要的联结词及其含义:

- 非 (Not): 表示为
¬。它取反一个命题的真值。- 公式:
¬P - 真值表:如果
P为真,则¬P为假;如果P为假,则¬P为真。
- 公式:


与 (And): 表示为
∧。仅当两个命题都为真时,整个句子才为真。- 公式:
P ∧ Q - 真值表:仅当
P为真 且Q为真时,P ∧ Q为真。
- 公式:
或 (Or): 表示为
∨。只要至少一个命题为真,整个句子就为真(包含两者都为真的情况)。- 公式:
P ∨ Q - 真值表:只要
P为真 或Q为真(或两者),P ∨ Q就为真。
- 公式:
蕴含 (Implication): 表示为
→。读作“如果P,那么Q”。它声明:如果前提P为真,则结论Q必须为真。- 公式:
P → Q - 真值表:只有当
P为真而Q为假时,P → Q才为假。如果P为假,则无论Q真假,P → Q都被视为真(因为前提未发生,承诺未被打破)。
- 公式:
双条件 (Biconditional): 表示为
↔。读作“P当且仅当Q”。它意味着两者必须同真同假。- 公式:
P ↔ Q - 真值表:当
P和Q同时为真或同时为假时,P ↔ Q为真。
- 公式:
🌍 模型与知识库
有了表示知识的语言,我们还需要定义什么是“真实的世界”。
模型: 一个模型为每个命题符号分配一个真值(真或假)。它代表了一个可能的世界状态。
- 例如,对于符号
P(下雨)和Q(周二),一个模型可能是:{P: 真, Q: 假},表示“正在下雨且今天不是周二”。
- 例如,对于符号
知识库 (KB): 这是AI所知道的所有信息的集合,由一系列用命题逻辑写成的句子构成。
- 我们会将关于问题的初始事实告诉AI,AI将其存储在知识库中。
➡️ 蕴涵与推理
AI的目标是利用知识库中的信息进行推理,得出新结论。这涉及到蕴涵的概念。
- 蕴涵 (Entailment): 表示为
⊨。KB ⊨ α意味着:在所有使知识库KB中所有句子都为真的模型中,句子α也一定为真。- 换句话说,如果
KB的描述是真实的,那么α也必然是真实的。α是KB的一个逻辑结论。
- 换句话说,如果
推理的过程,就是找出知识库所蕴涵的那些句子(即新知识)。
💡 推理实例演练
让我们用命题逻辑重写开头的例子,并进行一次推理演示。

定义命题符号:
P: 今天下雨。Q: 哈利拜访海格。R: 哈利拜访邓布利多。
将知识输入知识库 KB:
¬P → Q(如果不下雨,则哈利拜访海格)(Q ∨ R) ∧ ¬(Q ∧ R)(哈利拜访了海格或邓布利多,但非两者)R(哈利拜访了邓布利多)

现在,AI可以从 KB 进行推理:
- 从句子2和3可知,既然
R为真,且不能同时为真,则Q必须为假。所以推导出:¬Q(哈利没拜访海格)。 - 将
¬Q与句子1结合:句子1¬P → Q只有在前提¬P为真时,才要求Q为真。但现在我们知道Q为假,因此前提¬P不可能为真(否则会矛盾)。所以推导出:P(今天下雨)。
通过这个过程,AI得出了 ¬Q 和 P 这两个新的结论,它们都被 KB 所蕴涵。
本节课中我们一起学习了知识型AI的基础。我们介绍了命题逻辑作为表示知识的语言,包括命题符号和五种逻辑联结词。我们理解了模型如何定义可能的世界,知识库如何存储AI已知的信息,以及蕴涵如何定义逻辑结论。最后,我们通过一个实例演示了如何基于知识库进行逐步推理。在接下来的课程中,我们将探讨让计算机自动执行这种推理的算法。
哈佛 CS50-AI 6:L1- 知识系统知识 2 (推断,知识工程) 🧠
在本节课中,我们将要学习知识系统中的核心概念:推断与知识工程。我们将探讨如何让计算机基于已知事实进行逻辑推理,并学习如何将现实世界的问题转化为计算机能够理解和处理的形式。

🔍 推理算法与蕴含
上一节我们介绍了知识表示,本节中我们来看看如何基于知识进行推理。推理算法是一种我们可以用来试图判断是否能够得出某种结论的过程。
这些推理算法最终要回答一个关于蕴含的核心问题:给定对世界的一些查询(我们称之为 alpha),我们想知道,基于知识库 K 中的信息,我们是否可以得出结论 alpha 是真的。
用公式表示,即判断 KB ⊨ α 是否成立。
🤖 模型检查算法
那么,我们该如何写一个算法,能够查看知识库并确定查询是否为真呢?其中一种最简单的算法被称为模型检查。

模型是将我们语言中所有命题符号分配给真值(真或假)的某种赋值。你可以将模型视为一个可能的世界。

模型检查算法的工作原理如下:如果我们想确定知识库 KB 是否蕴含查询 alpha,我们将枚举所有可能的模型(即考虑所有变量的所有真假赋值组合)。如果在每一个 KB 为真的模型中,alpha 也为真,那么我们就知道 KB ⊨ α。
以下是模型检查算法的核心逻辑:
def model_check(knowledge, query):
# 枚举所有可能的模型
# 检查是否在所有 knowledge 为真的模型中,query 也为真
# 如果是,则返回 True (蕴含成立)
# 否则返回 False

📝 模型检查实例分析
这有点抽象,让我们看一个例子。假设我们有三个命题符号:
P:今天是星期二。Q:正在下雨。R:哈利将去跑步。

我们的知识库 KB 包含:
P ∧ ¬Q → R(如果是星期二且没下雨,哈利就去跑步)P(今天是星期二)¬Q(没下雨)
我们的查询 α 是:R (哈利将去跑步)。
第一步是列举所有可能的模型。对于 P, Q, R 三个变量,共有 2³ = 8 种可能的真假组合。
我们检查每一个模型:
- 在
KB为真的模型中(即同时满足P为真、Q为假、且P ∧ ¬Q → R为真),我们发现R也必须为真。 - 在
KB为假的模型中,我们无需关心R的真假。
由于在所有 KB 为真的模型中,R 都为真,因此我们可以得出结论:KB ⊨ R。基于已知信息,哈利一定会去跑步。
💻 在Python中实现逻辑与推理

现在我们来看看如何在编程语言(如Python)中实现命题逻辑和模型检查。我们将使用一个预先编写好的逻辑库。


首先,我们需要表示逻辑符号和连接词:
from logic import *
# 创建命题符号
rain = Symbol(“rain”) # 正在下雨
hagrid = Symbol(“hagrid”) # 哈利拜访了海格
dumbledore = Symbol(“dumbledore”) # 哈利拜访了邓布利多
我们可以使用逻辑连接词组合这些符号,构建复杂的句子:


# 表示“下雨且哈利拜访了海格”
sentence = And(rain, hagrid)


现在,让我们编码一个具体的知识库并进行查询。假设我们知道:
- 如果没下雨,哈利就拜访海格:
¬rain → hagrid - 哈利拜访了海格或邓布利多:
hagrid ∨ dumbledore - 哈利没有同时拜访海格和邓布利多:
¬(hagrid ∧ dumbledore) - 哈利拜访了邓布利多:
dumbledore
我们想知道:现在下雨吗?(rain)
knowledge = And(
Implication(Not(rain), hagrid), # 如果没下雨,则拜访海格
Or(hagrid, dumbledore), # 拜访海格或邓布利多
Not(And(hagrid, dumbledore)), # 没有同时拜访两人
dumbledore # 拜访了邓布利多
)
# 查询:是否在下雨?
query = rain
# 使用模型检查算法进行推理
result = model_check(knowledge, query)
print(result) # 输出:True。可以推断出正在下雨。
运行此代码,计算机会基于模型检查得出结论:knowledge ⊨ rain。这意味着根据给定的知识,可以确定外面正在下雨。


🛠️ 知识工程:将问题转化为逻辑


这个过程——试图将一个问题转化为确定使用哪些命题符号以及如何用逻辑语句编码知识——被称为知识工程。人工智能工程师的任务就是把一个现实世界的问题,提炼成计算机能够理解和推理的知识表示。
如果我们能将任何一般性的问题转化为命题逻辑符号和公式,就可以利用模型检查等推理算法让计算机来解决它。

以下是几个知识工程的实例:
实例一:棋盘游戏《妙探寻凶》

在《妙探寻凶》游戏中,我们需要推断凶手、凶器和房间。我们可以为每种可能性创建命题符号。


以下是需要编码的核心知识:


- 初始知识:凶手是三人之一,凶器是三选一,房间是三选一。
mustard ∨ plum ∨ scarletballroom ∨ kitchen ∨ libraryknife ∨ revolver ∨ wrench
- 拥有卡片:如果我拥有“芥末上校”卡片,我就知道他不是凶手。
¬mustard
- 对手的猜测:如果对手猜测“斯卡雷特小姐在图书馆用扳手”,且有人展示了一张相关卡片,则我知道三件事至少有一件是假的。
¬scarlet ∨ ¬library ∨ ¬wrench


通过逐步向知识库添加此类信息,并运行模型检查进行查询,计算机就能像玩家一样推理出最终的答案。


实例二:逻辑谜题(学院分配问题)
问题:四个人(吉德罗、米勒娃、波莫娜、霍拉斯)被分配到四个学院(格兰芬多、赫奇帕奇、拉文克劳、斯莱特林),每人一个学院,且学院各不相同。已知:
- 吉德罗属于格兰芬多或拉文克劳。
- 波莫娜不属于斯莱特林。
- 米勒娃属于格兰芬多。
我们需要找出每个人的学院。

知识工程步骤:
- 创建符号:为每个人和学院的组合创建符号,如
GilderoyGryffindor,MinervaRavenclaw等。 - 编码约束:
- 每人属于且仅属于一个学院:对每个人,其所属学院的符号只有一个为真。
- 每个学院有且只有一人:对每个学院,属于该学院的人的符号只有一个为真。
- 添加已知事实:
GilderoyGryffindor ∨ GilderoyRavenclaw¬PomonaSlytherinMinervaGryffindor
将所有这些知识输入模型检查算法,计算机就能推导出每个人的正确学院。
实例三:猜颜色游戏(简化版)
在一个简化版的猜颜色游戏中,有红、蓝、绿、黄四种颜色,以某种顺序排列。玩家猜测后,会得到“有几个颜色在正确位置”的反馈。


我们可以为“颜色C在位置P”创建命题符号(如 RedPos0)。知识包括:
- 每个位置有一种颜色。
- 每种颜色在一个位置。
- 根据每次猜测的反馈,添加逻辑约束(例如,如果猜测
[红,蓝,绿,黄]得到“2个正确”,则需编码恰好有两个对应位置颜色正确的复杂逻辑)。
通过模型检查,计算机可以穷举所有可能的排列,并找出唯一满足所有反馈条件的顺序。

⚠️ 模型检查的局限性

你可能已经注意到,在上述猜颜色游戏的例子中,推理过程花费了相当长的时间。这是因为模型检查算法需要枚举所有可能的模型。


如果我有 n 个命题变量,就需要检查 2ⁿ 个可能的世界。对于变量较少的问题(如上述例子),这是可行的。但随着变量数量的增加,计算量会呈指数级增长,模型检查就变得不再适用。

因此,我们需要寻找更高效的推理方法,这将是后续课程的内容。


📚 本节课总结

在本节课中,我们一起学习了:
- 推理与蕴含:如何判断知识库是否蕴含某个查询(
KB ⊨ α)。 - 模型检查算法:通过枚举所有可能世界,检查
KB为真时α是否一定为真,从而实现推理。 - Python实现:如何使用面向对象编程表示命题逻辑,并实现模型检查函数。
- 知识工程:将现实问题(如《妙探寻凶》、逻辑谜题、猜颜色游戏)转化为命题符号和逻辑公式的关键过程。
- 局限性认识:了解了模型检查算法计算复杂度高(O(2ⁿ))的局限性,为学习更高效的算法打下基础。

通过将问题形式化为逻辑,并利用算法进行自动推理,我们赋予了计算机解决多种需要逻辑思维问题的能力。
哈佛 CS50-AI 7:L1-知识系统知识 3 (推断规则,解析) 📚
在本节课中,我们将要学习知识系统中的核心推理方法。我们将从基础的推断规则开始,了解如何从已有知识推导出新知识。接着,我们将探索一种强大的推理算法——归结,并学习如何将逻辑语句转换为标准形式以应用该算法。最后,我们会简要介绍比命题逻辑更强大的一阶逻辑,它如何帮助我们更简洁地表达复杂关系。
推断规则 🔍
推断规则是一种可以应用的逻辑规则,它能将已有的知识转化为新的知识形式。其通用结构是:一条水平线上方是已知为真的前提,下方则是应用逻辑后能够得出的结论。
上一节我们介绍了知识表示的基础,本节中我们来看看如何运用规则进行推理。我们将首先用英语展示这些规则,然后将其翻译到命题逻辑的世界中。
前件肯定 (Modus Ponens)
假设我们有两条信息:“如果下雨,那么哈利在里面”和“现在在下雨”。我们可以合理地得出结论:“哈利必须在里面”。
这条推断规则在逻辑中更正式的表述是:如果我们知道 α → β(如果 α 为真,那么 β 为真),并且我们也知道 α 为真,那么我们可以得出结论 β 也为真。
这与模型检查的方法完全不同。模型检查是查看所有可能的世界,而推断规则是基于已知的知识直接进行推导。
与消除 (And-Elimination)
例如,根据“哈利是罗恩和赫敏的朋友”这条信息,我们可以合理地得出“哈利是赫敏的朋友”。

这条规则的形式是:如果我们知道 α ∧ β(α 和 β 都为真),那么我们可以得出结论 α 为真,或者 β 为真。虽然这对人类来说显而易见,但计算机需要被告知这种规则才能应用。
双重否定消去 (Double Negation Elimination)
如果“哈利没有通过测试”这个说法是假的,那么合理的结论是“哈利通过了测试”。
其形式是:如果前提是 ¬(¬α),那么我们可以得出结论 α 为真。
蕴含消去 (Implication Elimination)
如果我知道“如果下雨,那么哈利在里面”,那么我可以得出结论:“要么没有下雨,要么哈利在里面”。
更正式地说,如果我有一个蕴含关系 α → β,那么我可以得出结论 ¬α ∨ β。这是一种将“如果-那么”语句转换为“或”语句的方法。
双条件消去 (Biconditional Elimination)

“当且仅当哈利在里面,那么下雨”意味着两个方向的蕴含:如果下雨则哈利在里面,并且如果哈利在里面则下雨。
我可以将双条件 A ↔ B 翻译成 (A → B) ∧ (B → A)。
德摩根定律 (De Morgan‘s Laws)

以下是德摩根定律的应用示例:
- 与转或:如果说“并非(哈利和罗恩都通过了测试)”,那么结论是“要么哈利没有通过,要么罗恩没有通过”。形式为:¬(α ∧ β) 等价于 ¬α ∨ ¬β。
- 或转与:如果说“并非(哈利或罗恩通过了测试)”,那么结论是“哈利没有通过测试,并且罗恩也没有通过测试”。形式为:¬(α ∨ β) 等价于 ¬α ∧ ¬β。
分配律 (Distributive Laws)
分配律允许我们在逻辑表达式中重新分配操作符。
- 与分配到或:α ∧ (β ∨ γ) 等价于 (α ∧ β) ∨ (α ∧ γ)。
- 或分配到与:α ∨ (β ∧ γ) 等价于 (α ∨ β) ∧ (α ∨ γ)。
将推理视为搜索问题 🧩
现在的问题是,我们如何使用这些推理规则来证明某些结论?我们可以将定理证明视为一种搜索问题。
- 初始状态:我们开始时的知识库,即所有已知句子的集合。
- 行动:在任何时候可以应用的推理规则。
- 转移模型:应用推理规则后,得到的新知识集合(旧知识加上新推导出的结论)。
- 目标测试:检查我们想要证明的陈述是否已出现在知识库中。
- 路径成本:试图最小化证明中所用推理规则的步骤数量。
这样,我们就能运用解决迷宫或路径规划等搜索问题的思路,来证明关于知识的定理。
归结推理 ⚙️
除了上述规则,还有一种更强大、更常见的推理方法,称为归结。它基于一条核心的推理规则。
单元归结 (Unit Resolution)

假设我知道“要么罗恩在大礼堂,要么赫敏在图书馆”,并且我还知道“罗恩不在大礼堂”。那么我可以得出结论“赫敏必须在图书馆”。

规则形式是:如果我们有 P ∨ Q 并且有 ¬P,那么我们可以得出结论 Q。
完全归结 (Full Resolution)
这是单元归结的推广。假设我知道“要么罗恩在大礼堂,要么赫敏在图书馆”,并且我还知道“要么罗恩不在大礼堂,要么哈利在睡觉”。那么我可以得出结论“要么赫敏在图书馆,要么哈利在睡觉”。

规则形式是:如果我们有 P ∨ Q 并且有 ¬P ∨ R,那么我们可以解决它们得到新的子句 Q ∨ R。
更一般地,如果我们有子句 (P ∨ q1 ∨ q2 ∨ … ∨ qn) 和 (¬P ∨ r1 ∨ r2 ∨ … ∨ rm),我们可以归结得到 (q1 ∨ q2 ∨ … ∨ qn ∨ r1 ∨ r2 ∨ … ∨ rm)。
定义:
- 文字:一个命题符号或其否定(如 P, ¬Q)。
- 子句:多个文字的析取(通过“或”连接),例如 P ∨ Q ∨ ¬R。
- 合取范式:多个子句的合取(通过“和”连接)。例如:(A ∨ B ∨ C) ∧ (D ∨ ¬E) ∧ (F ∨ G)。
任何逻辑句子都可以转换为合取范式(CNF),这使得应用归结规则变得容易。
转换为合取范式 (CNF) 的步骤
将逻辑公式转换为 CNF 的过程如下:
- 消除条件句和双条件句:使用推理规则将 → 和 ↔ 转换为只包含 ∧, ∨, ¬ 的表达式。
- α → β 变为 ¬α ∨ β
- α ↔ β 变为 (¬α ∨ β) ∧ (¬β ∨ α)
- 将否定移入:使用德摩根定律,确保 ¬ 只直接出现在文字前。
- ¬(α ∧ β) 变为 ¬α ∨ ¬β
- ¬(α ∨ β) 变为 ¬α ∧ ¬β
- 使用分配律:分配 ∨ 到 ∧ 上,确保最终形式是子句的合取。
- α ∨ (β ∧ γ) 变为 (α ∨ β) ∧ (α ∨ γ)
示例:将 (P ∨ Q) → R 转换为 CNF。
- 消除蕴含:¬(P ∨ Q) ∨ R
- 德摩根定律:(¬P ∧ ¬Q) ∨ R
- 分配律:(¬P ∨ R) ∧ (¬Q ∨ R) (这就是 CNF)
归结算法与反证法

我们如何用归结来证明知识库(KB)蕴含某个查询(α)?我们使用反证法。
- 假设查询为假:将 ¬α 加入知识库。
- 将整个知识库(KB ∧ ¬α)转换为合取范式(CNF),得到一组子句。
- 重复以下步骤,直到无法生成新的子句或出现矛盾:
- 选择两个包含互补文字的子句(例如一个子句有 P,另一个有 ¬P)。
- 对它们应用归结规则,生成一个新的子句(即消去互补对,合并剩余文字)。
- 如果新子句包含重复文字,进行因式分解去除重复。
- 将新子句加入子句集。
- 如果生成了空子句(即归结出 P 和 ¬P,得到什么都没有),说明出现了矛盾。根据反证法,原假设 ¬α 不成立,因此 KB ⊨ α(知识库蕴含α)。
- 如果无法再生成新子句且未出现空子句,则知识库不蕴含该查询。
空子句代表假(False),因为它源于不可能同时成立的 P 和 ¬P。
示例:证明知识库 (A ∨ B), (¬B ∨ C), (¬C) 蕴含 A。
- 将
¬A加入知识库,得到子句集:{A ∨ B, ¬B ∨ C, ¬C, ¬A}。 - 归结
¬B ∨ C和¬C,得到新子句¬B。 - 归结
A ∨ B和¬B,得到新子句A。 - 归结
A和¬A,得到空子句。 - 因此,出现矛盾,原知识库蕴含
A。
一阶逻辑简介 🌉
命题逻辑用符号表示原子事实,但在表达复杂关系时可能显得冗长。一阶逻辑提供了更强大的表达工具。

核心组件:
- 常量符号:代表特定对象(如
Minerva,Gryffindor)。 - 谓词符号:代表对象之间的关系或属性,其值为真或假(如
Person(Minerva),House(Gryffindor))。 - 量词:允许我们表达关于“某些”或“所有”对象的陈述。
- 全称量词 ∀:“对于所有...”。例如:
∀x (BelongsTo(x, Gryffindor) → ¬BelongsTo(x, Hufflepuff))意为“所有属于格兰芬多的人都不属于赫奇帕奇”。 - 存在量词 ∃:“存在...”。例如:
∃x (House(x) ∧ BelongsTo(Minerva, x))意为“存在一个学院,米涅瓦属于它”(即米涅瓦属于某个学院)。
- 全称量词 ∀:“对于所有...”。例如:
通过结合常量、谓词和量词,一阶逻辑可以用更少的符号更自然地表达诸如“每个人都属于一个学院”这样的复杂思想:∀x (Person(x) → ∃y (House(y) ∧ BelongsTo(x, y)))。
总结 📝
本节课中我们一起学习了知识推理的核心方法。我们首先了解了多种基础的推断规则,如前件肯定、与消除等,它们是将已知知识转化为新知识的工具。接着,我们探讨了如何将逻辑证明视为搜索问题。
然后,我们深入学习了强大的归结推理算法,包括如何将逻辑语句转换为合取范式,以及如何通过反证法和归结规则来证明一个查询是否被知识库所蕴含。
最后,我们简要介绍了一阶逻辑,它通过引入常量、谓词和量词,克服了命题逻辑在表达复杂关系时的局限性,为知识表示提供了更丰富、更简洁的语言。

所有这些技术都旨在让AI智能体能够有效地表示知识,并进行逻辑推理,从而得出新的结论。在接下来的课程中,我们将探索当知识具有不确定性时,如何让AI进行推理。
哈佛 CS50-AI 8:L2- 不确定性 1 (概率模型,条件概率,随机变量,贝叶斯规则) 🎲



在本节课中,我们将要学习人工智能如何处理不确定性。我们将探讨概率论的基础概念,包括概率模型、条件概率、随机变量以及贝叶斯规则。这些工具将帮助我们的人工智能在信息不完整或不确定的情况下,依然能够进行推理和决策。

📊 概率模型与基础概念
上一节我们讨论了AI如何用逻辑句子表示知识。然而,现实世界充满了不确定性。我们的机器很少能完全确定某件事。概率论为我们提供了一种量化不确定性的数学框架。


概率最终归结为“可能世界”的观点。我们用希腊字母 Ω 表示所有可能世界的集合。每个可能世界都有一定的发生概率。
我们用大写字母 P 表示概率,后面括号中放入我们想要的事件。例如,P(ω) 表示可能世界 ω 发生的概率。
以下是概率论的两个基本公理:


- 概率值范围:每个概率值必须在 0 到 1 之间(包含两端)。
- 0 代表不可能发生的事件。例如,掷一个标准骰子,结果为 7 的概率是 0。
- 1 代表必然发生的事件。例如,掷一个标准骰子,结果小于 10 的概率是 1。
- 概率总和:所有可能世界的概率之和等于 1。用公式表示为:
∑_{ω∈Ω} P(ω) = 1
例如,对于一个公平的六面骰子,每个面朝上的概率是 1/6。所有概率(1/6 + 1/6 + ... + 1/6)之和正好为 1。

🎯 条件概率
无条件概率是我们在没有任何额外证据的情况下,对某个命题的信念程度。但在现实中,我们通常拥有一些已知信息。条件概率 就是给定某些证据(已知信息)后,某个事件发生的概率。
条件概率的符号是 P(A | B),读作“在 B 发生的条件下 A 发生的概率”。其中 A 是我们关心的事件,B 是我们的证据。

例如:
- P(今天下雨 | 昨天下雨):在已知昨天下雨的条件下,今天下雨的概率。
- P(患者患病 | 检测阳性):在已知检测结果为阳性的条件下,患者患病的概率。
条件概率的计算公式如下:
P(A | B) = P(A ∧ B) / P(B)
这个公式直观地理解为:在 B 已经发生的所有可能情况中,A 也发生的情况所占的比例。

让我们看一个掷两个骰子的例子。假设我们想知道两个骰子点数之和为 12 的概率,已知红色骰子的点数是 6。
- P(红骰=6) = 1/6
- P(和=12 ∧ 红骰=6) = 1/36 (只有红6蓝6这一种情况)
- 因此,P(和=12 | 红骰=6) = (1/36) / (1/6) = 1/6
这个结果很直观:已知红骰是6,和要为12,蓝骰也必须为6,而蓝骰为6的概率正是1/6。

🔢 随机变量与概率分布
我们不仅关心事件是否发生,有时还需要表示具有多个可能取值的变量。在概率论中,这称为随机变量。

随机变量是一个变量,它有一定范围的可能取值。
- 例如,随机变量 天气 的可能取值是:{晴天, 阴天, 雨天, 刮风, 下雪}。
- 随机变量 交通 的可能取值是:{畅通, 缓行, 拥堵}。


概率分布 将随机变量的每个可能取值与其发生的概率对应起来。
例如,对于随机变量 航班状态,其概率分布可能如下:
- P(航班状态 = 按时) = 0.6
- P(航班状态 = 延误) = 0.3
- P(航班状态 = 取消) = 0.1
所有可能取值的概率之和为 1 (0.6 + 0.3 + 0.1 = 1)。
有时我们会用更简洁的向量符号来表示概率分布:
P(航班状态) = <0.6, 0.3, 0.1>
这个向量按顺序表示了“按时”、“延误”、“取消”的概率。
🔗 独立性与贝叶斯规则
独立性
如果知道一个事件的发生不会影响另一个事件发生的概率,则称这两个事件相互独立。

数学上,事件 A 和 B 独立当且仅当:
P(A ∧ B) = P(A) * P(B)

例如,掷一次红色骰子和掷一次蓝色骰子的结果是独立的。知道红骰的结果不会改变蓝骰结果的概率。
- P(红骰=6 ∧ 蓝骰=6) = P(红骰=6) * P(蓝骰=6) = (1/6)*(1/6) = 1/36
不独立的例子:对于同一个红色骰子的一次投掷,“掷出6”和“掷出4”是互斥的,不独立。
- P(红骰=6 ∧ 红骰=4) = 0,但 P(红骰=6) * P(红骰=4) = 1/36,两者不相等。

贝叶斯规则
贝叶斯规则是概率论中一个极其重要的定理,它描述了如何利用“反向”的条件概率来更新我们的信念。
我们可以从条件概率的定义推导出贝叶斯规则:
- 由定义:P(A ∧ B) = P(B) * P(A | B)
- 同样,交换A和B:P(A ∧ B) = P(A) * P(B | A)
- 令两个等式右侧相等:P(B) * P(A | B) = P(A) * P(B | A)
- 两边同时除以 P(A),得到贝叶斯规则:
P(B | A) = [P(A | B) * P(B)] / P(A)
贝叶斯规则的意义在于,它允许我们通过更容易获得或理解的概率 P(A | B),来计算我们更关心的概率 P(B | A)。



让我们看一个天气预测的例子:
- 已知信息:
- 80%的雨天下午,其早晨是多云的:P(早晨多云 | 下午下雨) = 0.8
- 40%的早晨是多云的:P(早晨多云) = 0.4
- 10%的日子下午会下雨:P(下午下雨) = 0.1
- 问题:如果早晨多云,下午下雨的概率是多少?即求 P(下午下雨 | 早晨多云)。

应用贝叶斯规则:
P(下雨 | 多云) = [P(多云 | 下雨) * P(下雨)] / P(多云)
= [0.8 * 0.1] / 0.4 = 0.08 / 0.4 = 0.2
因此,在早晨多云的情况下,下午下雨的概率是 20%。


贝叶斯规则在医学诊断、垃圾邮件过滤、机器学习等众多领域都有广泛应用。它帮助我们在观察到结果(如检测报告、邮件特征)后,推断其原因(如是否患病、是否为垃圾邮件)的可能性。

📝 总结

本节课中我们一起学习了处理不确定性的概率论基础。

我们首先了解了概率模型,它用可能世界和概率公理来描述不确定性。接着,我们学习了条件概率 P(A | B),它量化了在已知证据 B 的情况下,事件 A 发生的可能性。
为了更结构化地表示具有多种结果的不确定性,我们引入了随机变量和概率分布的概念。然后,我们探讨了事件间的独立性,即一个事件的发生是否影响另一个事件。
最后,我们推导并应用了强大的贝叶斯规则。这个规则使我们能够利用一种条件概率(如“假设原因,观察结果的概率”)来计算其反向的条件概率(如“观察到结果,推断原因的概率”),这是进行不确定性推理的核心工具。

掌握这些概念是构建能够在不完美信息下进行智能推理的人工智能系统的第一步。在接下来的课程中,我们将看到如何将这些概率工具应用于更复杂的AI模型和算法中。
哈佛 CS50-AI 9:L2- 不确定性 2 (联合概率,贝叶斯网络) 🧠





在本节课中,我们将要学习概率论中两个核心概念:联合概率与贝叶斯网络。我们将了解如何用联合概率描述多个事件同时发生的可能性,并学习如何使用贝叶斯网络这一强大的工具来建模和推理随机变量之间的复杂依赖关系。

📊 联合概率



上一节我们介绍了单一事件的概率。本节中我们来看看如何描述多个事件同时发生的可能性,这就是联合概率。

联合概率涉及同时考虑多个不同事件的可能性。例如,我们可能关心“早上阴天”和“下午下雨”这两个事件同时发生的概率。
假设我们有两个随机变量:
C(Cloudy,早上是否阴天):取值为阴天或不阴天。R(Rain,下午是否下雨):取值为下雨或不下雨。

我们可以为每个变量建立独立的概率分布:
P(C=阴天) = 0.4P(C=不阴天) = 0.6P(R=下雨) = 0.1P(R=不下雨) = 0.9

然而,这两个分布并未告诉我们两个变量之间的关系。联合概率分布则提供了更丰富的信息,它列出了所有可能组合的概率。


我们可以将联合概率整理成一个表格:



| 早上阴天 (C) | 下午下雨 (R) | 联合概率 P(C, R) |
|---|---|---|
| 是 | 是 | 0.08 |
| 是 | 否 | 0.32 |
| 否 | 是 | 0.02 |
| 否 | 否 | 0.58 |


这个表格告诉我们,例如,P(C=阴天, R=下雨) = 0.08,即早上阴天且下午下雨的概率是 8%。



利用联合概率,我们可以计算条件概率。例如,我们想知道“在已知下午下雨的条件下,早上阴天的概率”,即 P(C=阴天 | R=下雨)。
根据条件概率公式:
P(A | B) = P(A, B) / P(B)




我们可以计算:
P(C=阴天 | R=下雨) = P(C=阴天, R=下雨) / P(R=下雨) = 0.08 / 0.1 = 0.8




这里有一个关键思想:条件概率与联合概率成正比。我们常写作:
P(C | R=下雨) ∝ P(C, R=下雨)
其中 ∝ 表示“成正比”,我们只需要找到一个归一化常数 α,使得所有概率之和为 1。在上例中,P(C=阴天, R=下雨)=0.08,P(C=不阴天, R=下雨)=0.02,总和为 0.1。乘以 α=10 后,我们得到归一化的条件概率分布:{阴天: 0.8, 不阴天: 0.2}。

📚 重要的概率规则

基于联合概率和条件概率,有几个重要的概率规则对我们后续的分析非常有用。

以下是几个核心的概率规则:

否定规则:事件
A不发生的概率是 1 减去它发生的概率。
P(¬A) = 1 - P(A)加法规则(容斥原理):事件
A或 事件B发生的概率。
P(A ∨ B) = P(A) + P(B) - P(A ∧ B)
我们需要减去P(A ∧ B)以避免将A和B同时发生的情况重复计算两次。


边际化规则:通过求和“隐藏”变量所有可能取值下的联合概率,来计算某个变量的(无条件)概率。
- 对于事件:
P(A) = P(A ∧ B) + P(A ∧ ¬B) - 对于随机变量(更通用的形式):
P(X=x_i) = Σ_j P(X=x_i, Y=y_j)
其中Σ_j表示对随机变量Y所有可能的取值y_j进行求和。
例如,利用前面的联合概率表计算
P(C=阴天):
P(C=阴天) = P(C=阴天, R=下雨) + P(C=阴天, R=不下雨) = 0.08 + 0.32 = 0.4- 对于事件:
条件化规则:边际化规则的条件概率版本。
- 对于事件:
P(A) = P(A | B) * P(B) + P(A | ¬B) * P(¬B) - 对于随机变量:
P(X=x_i) = Σ_j P(X=x_i | Y=y_j) * P(Y=y_j)
- 对于事件:
🕸️ 贝叶斯网络

现实世界中的随机变量通常不是独立的,它们之间存在依赖关系。贝叶斯网络是一种用于表示这些依赖关系的概率图模型。

一个贝叶斯网络是一个有向无环图:
- 节点:代表随机变量(例如:天气、火车是否准点)。
- 边(箭头):表示变量间的依赖关系。如果有一条从节点
X指向节点Y的边,那么X被称为Y的父节点,表示Y的概率分布依赖于X的取值。 - 条件概率表:每个节点都附有一个条件概率分布
P(节点 | 父节点)。对于没有父节点的根节点,则是其无条件概率分布。
贝叶斯网络实例:赴约之旅

假设我们要去城外赴约,需要考虑以下因素:
- 降雨 (Rain):可能为
无雨、小雨、大雨。 - 轨道维护 (Maintenance):可能为
有或无。大雨可能减少维护的概率。 - 火车准点 (Train):可能为
准点或延误。受降雨和维护情况影响。 - 赴约 (Appointment):可能为
参加或错过。直接受火车是否准点影响。

我们可以构建如下贝叶斯网络:
Rain
/ \
/ \
Maintenance Train
\
\
Appointment
- 箭头从
Rain指向Maintenance和Train,表示这两个变量的概率受降雨影响。 - 箭头从
Maintenance指向Train,表示火车是否准点也受维护情况影响。 - 箭头从
Train指向Appointment,表示能否赴约直接取决于火车是否准点。
每个节点都有自己的(条件)概率表,例如:
P(Rain):根节点的无条件分布。{无雨: 0.7, 小雨: 0.2, 大雨: 0.1}P(Maintenance | Rain):条件概率表。
| Rain | P(Maintenance=有) | P(Maintenance=无) |
| :--- | :--- | :--- |
| 无雨 | 0.4 | 0.6 |
| 小雨 | 0.2 | 0.8 |
| 大雨 | 0.1 | 0.9 |P(Train | Rain, Maintenance):依赖于两个父节点的条件概率表。P(Appointment | Train):条件概率表。


使用贝叶斯网络进行推理

贝叶斯网络的核心用途是进行概率推理:在观察到某些变量(证据)后,计算其他变量(查询变量)的概率分布。
推理问题包含:
- 查询变量 (X):我们想了解其分布的变量(例如:
Appointment)。 - 证据变量 (E):我们已经观察到的变量及其取值(例如:
Rain=小雨,Maintenance=无)。 - 隐藏变量 (Y):既非查询也非证据的变量(例如:
Train)。
一种基础的推理算法是枚举推理。其核心思想是利用条件概率的定义和边际化规则:
P(X | e) ∝ Σ_y P(X, e, y)
其中,Σ_y 表示对所有隐藏变量 Y 的所有可能取值组合 y 进行求和。P(X, e, y) 是联合概率,可以通过贝叶斯网络中每个节点的条件概率表相乘得到。

最后,需要对结果进行归一化,使其总和为 1。
💻 Python 实现:使用 pomegranate 库

在实际应用中,我们可以使用 Python 库(如 pomegranate)来构建贝叶斯网络并进行推理,而无需手动实现复杂的数学计算。



以下是使用 pomegranate 库的基本步骤:

- 定义节点及其概率分布:
from pomegranate import * # 定义根节点:降雨 rain = DiscreteDistribution({'无雨': 0.7, '小雨': 0.2, '大雨': 0.1}) # 定义条件概率节点:轨道维护(依赖于降雨) maintenance = ConditionalProbabilityTable( [['无雨', '有', 0.4], ['无雨', '无', 0.6], ['小雨', '有', 0.2], ['小雨', '无', 0.8], ['大雨', '有', 0.1], ['大雨', '无', 0.9]], [rain]) # 类似地定义 Train 和 Appointment 节点... - 创建节点状态并构建网络:
s1 = State(rain, name="rain") s2 = State(maintenance, name="maintenance") # ... 创建其他状态 network = BayesianNetwork("赴约之旅") network.add_states(s1, s2, s3, s4) # 添加所有状态 network.add_edge(s1, s2) # Rain -> Maintenance network.add_edge(s1, s3) # Rain -> Train network.add_edge(s2, s3) # Maintenance -> Train network.add_edge(s3, s4) # Train -> Appointment network.bake() # 最终化模型 - 进行概率计算与推理:
# 计算联合概率:无雨、无维护、火车准点、能赴约 prob = network.probability([['无雨', '无', '准点', '参加']]) print(f"联合概率: {prob}") # 进行推理:已知火车延误,预测其他变量的分布 beliefs = network.predict_proba({'train': '延误'}) for state, belief in zip(network.states, beliefs): print(f"{state.name}: {belief}")
通过库函数,我们可以轻松计算复杂场景下的概率,并基于观察到的证据进行推理,得出其他未知变量的概率分布。

🎯 总结

本节课中我们一起学习了:
- 联合概率:用于描述多个事件同时发生的可能性,是计算条件概率的基础。
- 重要的概率规则:包括否定规则、加法规则、边际化规则和条件化规则,这些是进行概率推导的基石。
- 贝叶斯网络:一种用有向图表示随机变量间依赖关系的强大模型。每个节点关联一个条件概率表。
- 概率推理:在贝叶斯网络中,根据已知证据,计算查询变量的后验概率分布。枚举推理是一种基础方法。
- 实践工具:我们可以利用如
pomegranate这样的 Python 库来方便地构建贝叶斯网络并执行推理任务,从而将理论应用于实际问题。

贝叶斯网络为我们处理不确定性问题提供了一个结构化的框架,是人工智能中表示知识和进行推理的关键技术之一。
哈佛 CS50-AI 10:L2- 不确定性 3 (采样,马尔可夫,HMM) 🧠
📖 概述
在本节课中,我们将学习如何通过采样技术来近似计算复杂概率模型中的概率,并介绍两种重要的时序概率模型:马尔可夫链和隐马尔可夫模型。这些模型是处理随时间变化的不确定性问题的强大工具。



🔄 采样:一种近似推理方法
上一节我们介绍了贝叶斯网络和精确推理。然而,当网络复杂时,精确计算可能非常耗时。本节中,我们来看看如何通过采样来高效地近似概率。
采样是指按照概率分布,为网络中的每个变量随机生成一个值,从而得到一个可能世界的“样本”。通过生成大量样本,我们可以用样本中事件发生的频率来近似其概率。
以下是进行采样的基本步骤:

- 从根节点开始:对于没有父节点的变量,根据其无条件概率分布进行抽样。
- 按拓扑顺序抽样:对于有父节点的变量,根据其父节点的当前取值,从其条件概率分布中进行抽样。
- 重复生成:重复以上过程成千上万次,生成大量样本。
例如,在一个关于天气和交通的贝叶斯网络中,我们可能先根据 P(雨) 抽样得到“无雨”,然后根据“无雨”这个条件从 P(维护 | 无雨) 中抽样得到“是”,以此类推,最终得到一个完整的样本 {雨=无, 维护=是, 火车=准时, 约会=出席}。
📊 通过样本进行概率推断
生成大量样本后,我们可以通过统计来回答概率查询。

- 无条件概率:例如,要计算火车准时的概率
P(火车=准时),只需统计所有样本中“火车=准时”出现的频率。 - 条件概率:例如,要计算在已知火车准时的条件下有小雨的概率
P(雨=小雨 | 火车=准时),我们需要使用拒绝采样。
🚫 拒绝采样
拒绝采样是计算条件概率的一种方法。其核心思想是:只保留那些与已知证据(如“火车=准时”)相符的样本,忽略(拒绝)所有不符合证据的样本。然后,在剩下的样本中统计我们关心的事件(如“雨=小雨”)发生的频率。
公式表示:
P(雨=小雨 | 火车=准时) ≈ (满足“雨=小雨”且“火车=准时”的样本数) / (所有满足“火车=准时”的样本数)
⚠️ 拒绝采样的效率问题
如果证据事件本身发生的概率很低(例如 P(火车=延误)=0.01),那么绝大多数样本都会被拒绝,导致计算效率低下。为了解决这个问题,可以采用似然加权等其他更高效的采样方法。
⏳ 处理随时间变化的不确定性:马尔可夫模型
到目前为止,我们处理的都是静态变量的概率。但在现实中,很多变量(如天气、股价)的状态会随时间变化。我们需要新的模型来处理这种时序不确定性。
🔗 马尔可夫链
马尔可夫链是描述一系列随机变量(如每日天气)的模型。它基于一个关键假设——马尔可夫假设:当前状态的概率分布只依赖于前一个状态,而与更早的历史无关。
公式表示:
P(X_t | X_{t-1}, X_{t-2}, ...) = P(X_t | X_{t-1})

这个假设极大地简化了模型。我们只需要一个转移矩阵来描述从当前状态转移到下一个状态的概率。

转移矩阵示例(天气):

| 今天\明天 | 晴天 | 雨天 |
|---|---|---|
| 晴天 | 0.8 | 0.2 |
| 雨天 | 0.3 | 0.7 |
代码描述(使用伪代码):
# 转移矩阵
transition = {
‘晴天‘: {‘晴天‘: 0.8, ‘雨天‘: 0.2},
‘雨天‘: {‘晴天‘: 0.3, ‘雨天‘: 0.7}
}
# 给定今天是晴天,预测明天天气的分布
tomorrow_dist = transition[‘晴天‘] # {‘晴天‘: 0.8, ‘雨天‘: 0.2}
利用转移矩阵和初始状态分布,我们可以通过抽样来模拟一个状态序列(如:晴-晴-雨-雨-晴),这就是一个马尔可夫链。

👁️ 当状态不可见时:隐马尔可夫模型
在马尔可夫链中,我们假设每个时间步的状态(如天气)是直接可知的。但现实中,我们往往只能观察到与隐藏状态相关的证据,而无法直接看到状态本身。
🕵️ 隐马尔可夫模型的核心思想
隐马尔可夫模型包含两层:
- 隐藏状态层:我们真正关心但无法直接观测的变量序列(如真实的天气)。
- 观测层:我们可以直接测量到的、由隐藏状态“发射”出来的证据序列(如人们是否带伞)。
HMM 由三部分组成:
- 转移模型:描述隐藏状态之间的转移概率(和马尔可夫链相同)。
- 传感器模型(发射概率):描述在给定某个隐藏状态下,产生各个观测值的概率。
- 初始状态分布:起始时各个隐藏状态的概率。
示例:
- 隐藏状态:{晴天, 雨天}
- 观测:{带伞, 不带伞}
- 传感器模型:
P(带伞 | 晴天) = 0.2P(带伞 | 雨天) = 0.9
🎯 HMM 能解决的任务
给定一系列观测值(如:伞, 伞, 无伞, 伞),HMM 可以帮助我们解决以下问题:

- 滤波:根据到目前为止的所有观测,估计当前隐藏状态的概率分布。
- 预测:根据到目前为止的所有观测,预测未来某个时刻的隐藏状态分布。
- 平滑:根据完整的观测序列,估计过去某个时刻的隐藏状态分布。
- 最可能解释:找出最有可能产生当前观测序列的隐藏状态序列。这是语音识别等任务的核心。

最可能解释示例:
观测序列:[带伞, 带伞, 不带伞]
最可能的状态序列可能是:[雨天, 雨天, 晴天]。HMM 算法(如维特比算法)可以高效地计算出这个序列。

💎 总结
本节课中我们一起学习了处理不确定性的几种高级方法:

- 采样:通过随机生成样本来近似计算概率,是解决复杂推理问题的有效且高效的方法,包括简单的抽样和用于条件概率的拒绝采样。
- 马尔可夫链:基于“当前状态仅依赖于前一状态”的马尔可夫假设,用于建模随时间演变的随机过程。
- 隐马尔可夫模型:在马尔可夫链的基础上,引入了不可见的隐藏状态和可见的观测证据。它是连接隐藏世界(如真实意图、单词)和可观测世界(如传感器数据、音频波形)的桥梁,是语音识别、生物信息学等领域的基石。
通过将这些现实问题表述为贝叶斯网络、马尔可夫链或 HMM,我们就可以利用现有的强大算法和 Python 库(如 pomegranate),让 AI 在信息不完整的情况下也能进行有效的推理和预测。
哈佛 CS50-AI 11:L3- 优化算法 1 (优化,局部搜索,爬山算法) 🧗


在本节课中,我们将学习一类新的人工智能问题——优化问题。我们将了解什么是优化,并重点学习一种名为局部搜索的算法,特别是它的经典实现:爬山算法。我们会通过一个“医院选址”的例子来理解这些概念,并用Python代码进行实践。

概述:什么是优化问题?🎯
到目前为止,我们已经学习了几种不同类型的人工智能问题。我们看过经典的搜索问题,目标是找到从初始状态到目标状态的最佳路径。我们也学习了对抗性搜索,即游戏智能体试图做出最佳移动。此外,我们还接触了基于知识的推理和概率模型。
今天,我们将注意力转向优化问题。优化的核心是从一组可能的选项中选出最佳选项。我们已经在某些场景中见过优化的影子,比如在游戏中,AI需要从所有可能的移动中选出最佳的一步。但本节课,我们将系统地学习用于解决更广泛优化问题的算法。
我们将要学习的第一个算法是局部搜索。
局部搜索 vs. 传统搜索 🔄
局部搜索与我们之前学过的搜索算法(如广度优先搜索、A*搜索)有根本不同。
- 传统搜索算法:通常维护一个路径集合,同时探索多条路径以找到解决方案。它们关心的是到达目标的路径。
- 局部搜索算法:通常只维护一个当前状态(节点),然后在整个搜索过程中将自己移动到邻近的状态。它不关心路径,只关心最终的解决方案本身。
局部搜索适用于那些“目标是什么”本身就是挑战核心的问题,而不仅仅是“如何到达目标”。例如,在迷宫问题中,目标(出口)是明确的,难点在于找路。但在优化问题中,难点在于找出那个“最佳”的目标状态。
示例:医院选址问题 🏥
为了理解局部搜索,我们来看一个具体问题:医院选址。
假设我们有一个网格世界,里面散布着一些房屋(例如下图中的H)。我们的目标是在这个地图上放置两家医院(例如下图中的1和2)。

我们的优化目标是:最小化所有房屋到其最近医院的总距离。我们可以使用曼哈顿距离(网格上的行列移动步数)来计算距离。
对于上图中的配置,我们可以计算每个房屋到最近医院的距离:
- 左上房屋 → 医院1:距离3
- 左下房屋 → 医院1:距离6
- 右上房屋 → 医院2:距离4
- 右下房屋 → 医院2:距离4
总成本 = 3 + 6 + 4 + 4 = 17。
这个“总成本”就是我们衡量一个医院配置(即一个“状态”)好坏的指标。我们的任务就是搜索所有可能的医院摆放位置,找到总成本最低的那个配置。
状态空间与目标函数 📊
我们可以将这类问题抽象为在状态空间中搜索。
- 状态:一个可能的解决方案配置。例如,一种特定的医院摆放方式。
- 状态空间:所有可能状态的集合。
- 成本函数 / 目标函数:一个函数
f(state),它给每个状态打分。在最小化问题中,我们称之为成本函数,值越低越好。在最大化问题中,我们称之为目标函数,值越高越好。
我们的目标就是:
- 最小化问题:找到全局最小值,即成本函数值最低的状态。
- 最大化问题:找到全局最大值,即目标函数值最高的状态。

局部搜索的策略是:从一个当前状态出发,查看它的邻居状态(即通过微小改动能得到的状态),然后决定移动到哪个邻居,如此反复,试图找到最优解。
爬山算法 🧗
爬山算法是实现局部搜索思想的最简单算法。它的思路非常直观:就像爬山一样,每次都往“更高”的方向走(对于最大化问题),或者往“更低”的方向走(对于最小化问题)。
算法思想
假设我们在解决一个成本最小化问题(寻找山谷的最低点):
- 从某个随机初始状态开始。
- 查看当前状态的所有邻居状态。
- 如果存在成本更低的邻居,则移动到成本最低的那个邻居(最陡下降)。
- 重复步骤2-3。
- 当所有邻居的成本都不低于当前状态时,算法停止。此时,我们认为找到了一个局部最小值。
对于效益最大化问题(寻找山峰的最高点),则每次移动到效益最高的邻居。
伪代码
以下是最大化问题的爬山算法伪代码:
function hill_climb(problem):
current = problem.initial_state # 初始状态(可随机生成)
while True:
neighbors = get_neighbors(current) # 获取所有邻居状态
best_neighbor = max(neighbors, key=lambda state: value(state)) # 找出价值最高的邻居
if value(best_neighbor) <= value(current): # 如果没有更好的邻居
return current # 返回当前状态(局部最优)
current = best_neighbor # 否则,移动到更好的邻居
应用于医院问题
回到我们的医院问题,假设我们从一个随机配置开始(成本为17)。我们定义“邻居”为:将任意一家医院向上、下、左、右移动一格得到的新配置。
以下是算法可能采取的步骤:
- 检查当前配置的所有邻居。
- 发现将右侧医院向左移动一格,可以使总成本从17降低。
- 移动到该邻居状态。
- 继续检查新状态的邻居,可能发现将某家医院向下移动能进一步降低成本。
- 重复此过程,直到某个状态的所有邻居都无法提供更低的成本(例如成本降至11)。算法在此停止。


爬山算法的局限性:局部最优 😰
然而,爬山算法有一个主要缺陷:它很容易陷入局部最优解,而错过全局最优解。
- 局部最大值/最小值:一个状态,其值比所有直接邻居都高/低,但在整个状态空间中并非最高/最低。
- 全局最大值/最小值:整个状态空间中值最高/最低的状态。
在上面的医院例子中,算法停在了成本为11的状态。但实际上,存在一个更好的配置(例如,将一家医院斜角移动),成本仅为9。为什么算法没找到它?因为要到达那个全局最优解,需要先经过一个成本可能高于11的中间状态,而爬山算法拒绝向上走(对于最小化问题,是拒绝暂时增加成本)。

此外,算法还可能困在“高原”(一片值相等的区域)或“山脊”上,导致无法进步。

爬山算法的变体 🔧
为了克服标准爬山算法的一些缺点,人们提出了多种变体:
以下是几种常见的爬山算法变体:
- 最陡上升爬山:标准版本,总是选择价值最高/成本最低的邻居。
- 随机爬山:从所有更好的邻居中随机选择一个进行移动。这有助于在遇到“高原”时摆脱停滞。
- 首选爬山:一旦找到一个更好的邻居就立即移动过去,而不是检查完所有邻居。这在大状态空间中更高效。
- 随机重启爬山:这是应对局部最优最有效的方法之一。它不依赖于单一起点,而是:
- 随机生成一个初始状态,运行爬山算法得到一个局部最优。
- 重复上述过程多次(例如100次)。
- 从所有找到的局部最优中,选择最好的那个。
通过多次随机重启,找到全局最优的概率大大增加。
- 局部束搜索:同时跟踪
k个状态(而不仅仅是一个)。在每一步,从所有当前状态的邻居中选出最好的k个作为新的当前状态集合。这相当于并行进行了多次搜索。
代码实践:用Python实现医院选址优化 💻
上一节我们讨论了爬山算法的原理与变体,本节我们来看看如何用Python代码实现它,以解决医院选址问题。

我们来看一下核心的爬山算法实现代码框架:

# 伪代码框架
def hill_climb(space, houses, hospital_count, max_iterations=1000):
# 1. 随机初始化医院位置
hospitals = random_place_hospitals(space, hospital_count)
for i in range(max_iterations):
current_cost = total_cost(houses, hospitals)
# 2. 生成所有可能的邻居(移动一家医院到相邻格子)
best_neighbors = []
best_neighbor_cost = current_cost
for each hospital in hospitals:
for each neighbor_position in get_adjacent_positions(hospital):
new_hospitals = hospitals with hospital moved to neighbor_position
new_cost = total_cost(houses, new_hospitals)
if new_cost < best_neighbor_cost: # 寻找成本更低的邻居
best_neighbor_cost = new_cost
best_neighbors = [new_hospitals] # 重置最佳邻居列表
elif new_cost == best_neighbor_cost:
best_neighbors.append(new_hospitals) # 记录同等好的邻居
# 3. 判断是否达到局部最优
if best_neighbor_cost >= current_cost:
break # 没有更好的邻居,停止
# 4. 移动到最佳邻居(随机选择一个,如果多个同等好)
hospitals = random.choice(best_neighbors)
return hospitals

运行这个算法,从一个随机配置(成本72)开始,通过不断移动到成本更低的邻居状态,最终可能收敛到一个成本为53的局部最优解。通过可视化,我们可以看到医院的位置随着迭代向房屋密集区移动。


为了获得更好的解,我们可以实现随机重启爬山:
def random_restart_hill_climb(space, houses, hospital_count, restarts=20):
best_hospitals = None
best_cost = float('inf')
for _ in range(restarts):
# 每次重启都随机初始化并运行一次爬山
hospitals = hill_climb(space, houses, hospital_count)
cost = total_cost(houses, hospitals)
if cost < best_cost:
best_cost = cost
best_hospitals = hospitals
return best_hospitals, best_cost
通过运行随机重启爬山(例如重启20次),我们很可能找到比单次爬山更好的解。在示例中,最佳成本从单次爬山的56降低到了41。

总结 📝

本节课中,我们一起学习了优化问题和局部搜索算法。
- 我们首先明确了优化问题的目标是寻找最佳解决方案,而非路径。
- 我们学习了局部搜索的核心思想:维护当前状态,并通过移动到邻居状态来迭代改进。
- 爬山算法是局部搜索的经典代表,它简单高效,但容易陷入局部最优解。
- 我们探讨了爬山算法的多种变体,如随机爬山、首选爬山,特别是能有效缓解局部最优问题的随机重启爬山。
- 最后,我们通过医院选址的代码实例,实践了如何用Python实现最陡上升爬山和随机重启爬山,并观察了算法的运行过程与结果。

关键点在于:爬山算法从不接受使情况变差的移动,这既是其效率的来源,也是其可能错过全局最优的原因。随机重启通过增加探索的多样性,是提高找到全局最优概率的实用策略。
在接下来的课程中,我们将学习更强大的优化算法,如模拟退火和遗传算法,它们能够以一定的概率接受“坏”的移动,从而有望跳出局部最优,找到更好的解决方案。
哈佛 CS50-AI 12:L3- 优化算法 2(线性搜索,节点一致性)🚀


概述
在本节课中,我们将学习两种重要的优化算法:模拟退火和线性规划。同时,我们也会初步了解约束满足问题及其核心概念——节点一致性。这些方法帮助我们解决那些不关心具体解决路径,只关心最终最优配置的问题。

模拟退火算法 🔥

上一节我们介绍了局部搜索和爬山算法,它们容易陷入局部最优解。本节中我们来看看模拟退火算法,它通过引入随机性来增加找到全局最优解的概率。
模拟退火算法模拟了物理中的退火过程。在高温下,粒子系统能量高,随机运动频繁;随着系统冷却,粒子最终会稳定在某个低能状态。算法的核心思想是:在搜索初期允许接受一些“坏”的移动(即使目标函数值变差),从而有机会跳出局部最优;随着“温度”降低,算法逐渐减少接受坏移动的概率,最终稳定在一个较好的解上。
算法伪代码
以下是模拟退火算法的基本伪代码:

function SIMULATED-ANNEALING(problem, max):
current = problem.INITIAL
for t = 1 to max:
T = TEMPERATURE(t) # 计算当前温度,随时间下降
neighbor = random.choice(current.NEIGHBORS()) # 随机选择一个邻居状态
delta_e = neighbor.VALUE - current.VALUE # 计算能量差
if delta_e > 0: # 邻居状态更好
current = neighbor
else: # 邻居状态更差
# 以一定概率接受更差的状态
acceptance_probability = exp(delta_e / T)
if random.random() < acceptance_probability:
current = neighbor
return current
公式说明:
delta_e:邻居状态与当前状态的目标函数值之差。delta_e > 0表示邻居更好。T:温度参数,随时间递减。exp(delta_e / T):接受更差状态的概率。当delta_e负得不多(状态稍差)或T较高(搜索初期)时,此概率较大。
算法应用:旅行商问题
旅行商问题是一个经典的NP难问题,目标是找到访问所有城市并回到起点、总距离最短的路线。我们可以用模拟退火来寻找近似最优解。
定义邻居状态:一种常见方法是随机选择路径中的两条边并进行交换。例如,如果原路径中边 A-B 和 C-D 交叉,交换后可能变为 A-C 和 B-D,从而可能得到一条更短的路径。
通过不断生成并评估这样的邻居状态,模拟退火算法能够有效地为旅行商问题找到一个高质量的近似解。
线性规划 📈
上一节我们介绍了模拟退火这类基于随机搜索的优化方法。本节中我们来看看另一类截然不同的优化技术——线性规划。它适用于目标函数和约束条件都是决策变量线性组合的问题。
线性规划的目标是在一组线性不等式或等式约束下,最小化(或最大化)一个线性目标函数。
问题形式化
一个标准的线性规划问题可以表述如下:
最小化成本函数:
c₁x₁ + c₂x₂ + ... + cₙxₙ
满足约束条件:
a₁₁x₁ + a₁₂x₂ + ... + a₁ₙxₙ ≤ b₁
a₂₁x₁ + a₂₂x₂ + ... + a₂ₙxₙ ≤ b₂
...
xᵢ ≥ 0 (通常要求变量非负)
其中:
x₁, x₂, ..., xₙ是决策变量。c₁, c₂, ..., cₙ是目标函数系数。aᵢⱼ是约束条件系数。bᵢ是约束条件的边界值。
实例:生产优化问题
假设一家工厂有两台机器:
- 机器X1:运行成本为 50美元/小时,需要 5单位劳动/小时,产出 10单位产品/小时。
- 机器X2:运行成本为 80美元/小时,需要 2单位劳动/小时,产出 12单位产品/小时。
工厂共有 20单位劳动 可用,且需要至少 90单位产品。问题是如何安排两台机器的运行时间以最小化总成本。
建模:
- 决策变量:
x1= 机器X1运行小时数,x2= 机器X2运行小时数。 - 目标函数(最小化成本):
minimize 50*x1 + 80*x2 - 约束条件:
- 劳动约束:
5*x1 + 2*x2 ≤ 20 - 产出约束:
10*x1 + 12*x2 ≥ 90(可转化为-10*x1 - 12*x2 ≤ -90) - 非负约束:
x1 ≥ 0,x2 ≥ 0
- 劳动约束:
Python求解示例
我们可以使用SciPy库中的线性规划求解器来解决这个问题。
from scipy.optimize import linprog
# 目标函数系数 (最小化 50*x1 + 80*x2)
c = [50, 80]
# 不等式约束矩阵 A_ub * x <= b_ub
# 约束1: 5*x1 + 2*x2 <= 20
# 约束2: -10*x1 - 12*x2 <= -90 (由 10*x1 + 12*x2 >= 90 转换而来)
A_ub = [[5, 2], [-10, -12]]
b_ub = [20, -90]
# 变量边界 (x1 >= 0, x2 >= 0)
x_bounds = (0, None)
y_bounds = (0, None)
# 求解线性规划问题
result = linprog(c, A_ub=A_ub, b_ub=b_ub, bounds=[x_bounds, y_bounds], method='highs')
if result.success:
print(f"最优解: x1 = {result.x[0]:.2f} 小时, x2 = {result.x[1]:.2f} 小时")
print(f"最小总成本: ${result.fun:.2f}")
else:
print("未找到可行解。")
运行结果可能为:x1 = 1.5小时, x2 = 6.25小时,最小成本为 587.5美元。
求解器内部可能使用单纯形法或内点法等经典算法。对于使用者而言,关键在于将实际问题正确建模为线性规划形式。
约束满足问题与节点一致性 🧩
前面我们探讨了优化连续值或离散配置的算法。现在,我们转向另一类常见问题——约束满足问题。这类问题的目标是为一组变量赋值,同时满足变量之间的所有约束。
问题定义
一个CSP由三部分组成:
- 变量集合
X = {X₁, X₂, ..., Xₙ} - 值域集合
D = {D₁, D₂, ..., Dₙ},其中Dᵢ是变量Xᵢ可以取值的集合。 - 约束集合
C,定义了变量取值之间必须满足的关系(例如X₁ ≠ X₂)。
实例:考试安排问题
假设我们需要为课程安排考试时间(周一、周二、周三),约束是:任何学生不能在同一天参加两门考试。
建模:
- 变量:每门课程(A, B, C, ...)。
- 值域:每个变量的值域都是
{周一, 周二, 周三}。 - 约束:如果某学生同时选修了课程A和B,则需添加约束
A ≠ B。
我们可以用约束图来表示,节点是变量,边代表两个变量之间存在二元约束。
节点一致性
在深入解决CSP之前,我们可以先进行一种简单的预处理,称为强制执行节点一致性。
节点一致性:如果某个变量值域中的所有值都满足施加于该变量本身的所有一元约束,则称该变量是节点一致的。如果CSP中所有变量都是节点一致的,则称该CSP是节点一致的。
一元约束:只涉及单个变量的约束(例如,“课程A不能在周一考试”可表示为 A ≠ 周一)。

执行节点一致性的方法:遍历每个变量,检查其值域中的每个值。如果某个值违反了该变量的一元约束,则将其从值域中删除。

节点一致性示例
假设有两个变量(课程):
A的值域:{周一, 周二, 周三}B的值域:{周一, 周二, 周三}


约束条件:
A ≠ 周一(一元约束)B ≠ 周二(一元约束)B ≠ 周一(一元约束)A ≠ B(二元约束)

执行节点一致性过程:
- 检查变量
A:其值域中的“周一”违反约束A ≠ 周一,因此将“周一”从A的值域中删除。更新后A的值域为{周二, 周三}。 - 检查变量
B:其值域中的“周二”违反约束B ≠ 周二,“周一”违反约束B ≠ 周一。将这两个值删除。更新后B的值域为{周三}。
执行完毕后,CSP达到了节点一致性。注意,我们尚未处理二元约束 A ≠ B,那是下一步(弧一致性等)要解决的问题。节点一致性作为一个简单的预处理步骤,可以缩小搜索空间。
总结
本节课我们一起学习了三种重要的优化与问题求解范式:
- 模拟退火:一种受物理过程启发的随机优化算法,通过以一定概率接受“坏”的移动来逃离局部最优,随着“温度”降低逐渐收敛,常用于求解旅行商等组合优化问题。
- 线性规划:用于解决目标函数和约束条件均为决策变量线性组合的优化问题。关键在于将实际问题建模成标准形式,然后可利用现有求解器(如SciPy中的
linprog)高效求解。 - 约束满足问题与节点一致性:CSP关注于为变量赋值以满足所有约束。节点一致性是一个基础概念,指通过移除违反一元约束的值来预处理变量值域,为后续更复杂的推理步骤简化问题。
这些工具为我们解决人工智能中广泛的优化和配置问题提供了强大的基础。
哈佛 CS50-AI 13:L3- 优化算法 3 (回溯搜索等) 🧠
在本节课中,我们将要学习约束满足问题中的高级搜索与推理技术。我们将重点探讨如何通过弧一致性和回溯搜索等算法,更高效地找到满足所有约束的变量赋值方案。

🔗 弧一致性

上一节我们介绍了约束满足问题的基本概念。本节中我们来看看一种更强的一致性概念:弧一致性。它关注的是两个变量之间的二元约束。


弧一致性指的是,对于约束图中的一条边(即两个变量),其中一个变量域中的每一个值,都能在另一个变量的域中找到至少一个值,使得它们之间的二元约束得到满足。

更正式地说,为了使变量 X 与变量 Y 保持弧一致,我们需要从 X 的域中移除所有这样的值:对于该值,在 Y 的域中不存在任何值能满足 X 与 Y 之间的约束。
让我们看一个例子。假设变量 A 的域是 {星期二, 星期三},变量 B 的域是 {星期三},且约束为 A ≠ B。

- 对于 A 的域中的值
星期二,B 的域中存在值星期三满足星期二 ≠ 星期三。 - 对于 A 的域中的值
星期三,B 的域中不存在值能满足星期三 ≠ 星期三。
因此,为了保持 A 对 B 的弧一致,我们需要将 星期三 从 A 的域中移除。执行此操作后,A 的域变为 {星期二},B 的域为 {星期三},整个问题便得以解决。
⚙️ 实现弧一致性:REVISE 与 AC-3 算法
为了实现弧一致性,我们首先定义一个名为 REVISE 的函数。它的作用是使变量 X 相对于变量 Y 保持弧一致。
以下是 REVISE 函数的伪代码描述:

function REVISE(csp, X, Y):
revised = false
for each x in domain(X):
if no value y in domain(Y) allows (x, y) to satisfy constraint(X, Y):
delete x from domain(X)
revised = true
return revised
REVISE 函数检查 X 域中的每个值。如果某个值 x 在 Y 的域中找不到任何匹配值 y 来满足约束,则将该值 x 从 X 的域中删除,并标记已修订。

然而,我们通常希望对整个约束满足问题强制执行弧一致性,而不仅仅针对一对变量。AC-3 算法 实现了这一目标。
以下是 AC-3 算法的核心思想:
- 初始化一个队列,包含约束图中所有的弧(即所有存在二元约束的变量对)。
- 只要队列不为空,就从中取出一条弧 (X, Y)。
- 调用
REVISE(csp, X, Y)。如果 X 的域被修改(revised为真):- 如果 X 的域变为空,则问题无解,算法终止。
- 否则,对于 X 的所有邻居 Z(除了 Y),将弧 (Z, X) 加入队列,因为 X 域的缩小可能影响这些弧的一致性。
- 重复步骤 2-3,直到队列为空或检测到无解。
AC-3 算法通过不断传播约束,可以有效缩小变量的值域,有时甚至能直接解决问题。
🔍 回溯搜索
尽管弧一致性可以简化问题,但并非总能直接找到解。我们通常还需要结合搜索算法。回溯搜索 是一种专门用于解决约束满足问题的经典搜索算法。
回溯搜索的核心理念是:我们逐步为变量赋值,如果当前赋值导致后续无法满足任何约束(即进入“死胡同”),则撤销最近的部分赋值(回溯),并尝试其他可能的值。
以下是回溯搜索的基本框架:

function BACKTRACK(assignment, csp):
if assignment is complete:
return assignment
var = SELECT-UNASSIGNED-VARIABLE(assignment, csp)
for each value in ORDER-DOMAIN-VALUES(var, assignment, csp):
if value is consistent with assignment:
add {var = value} to assignment
result = BACKTRACK(assignment, csp)
if result != failure:
return result
remove {var = value} from assignment # 回溯
return failure
算法从空赋值开始,递归地进行:
- 选择未分配变量:使用启发式(如 MRV)选择一个变量。
- 遍历值域:按某种顺序(如 LCV)尝试该变量的所有可能值。
- 一致性检查:如果赋值与当前部分赋值一致(不违反任何约束),则将其加入。
- 递归搜索:在新的赋值基础上,递归调用自身。
- 回溯:如果递归调用返回失败,说明当前值的选择导致了死胡同,因此撤销该赋值,尝试下一个值。

让我们通过课程考试安排的例子来可视化这个过程。假设我们依次为变量 A, B, D, E, C 赋值。当尝试为 C 赋值时,发现其域 {星期一, 星期二, 星期三} 中的所有值都与已赋值变量冲突,导致失败。算法将回溯到对 E 的赋值,尝试其他值,并最终找到一组满足所有约束的完整赋值。


🚀 提升搜索效率:推理与启发式


基本的回溯搜索可能效率不高。我们可以通过两种主要策略来大幅提升其性能:在搜索中维护一致性 和 使用智能的变量/值选择启发式。

1. 前向检查与维护弧一致性
在搜索过程中,每当我们为一个变量 X 赋值后,可以立即运行推理(如前向检查或调用 AC-3 的变体),来检查这个新赋值对其未赋值邻居变量的值域所产生的影响。
例如,在为 A 赋值 星期一 后,我们可以推断出与 A 有约束的 C 不能是 星期一,从而将 星期一 从 C 的域中移除。这种即时的域缩减可以提前暴露矛盾,避免进入更深的无效搜索分支。
2. 变量选择启发式
以下是选择下一个赋值变量的有效启发式:
- 最小剩余值(MRV)启发式:选择值域最小的未赋值变量。这能最快地触达成功或失败,从而有效剪枝。
- 度启发式:作为 MRV 的补充,当多个变量值域大小相同时,选择约束图中度最高(即连接最多其他变量)的变量。先约束高度连接的变量能更大幅度地限制剩余搜索空间。
3. 值选择启发式
- 最少约束值(LCV)启发式:为选定的变量选择值时,优先选择那个排除其他变量可选值最少的值。这为后续的赋值保留了最大的灵活性,增加了找到解的可能性。
通过将回溯搜索、约束传播(如AC-3) 以及智能的变量/值排序启发式结合起来,我们得到了一个强大且高效的约束求解框架。许多现代的约束求解库都基于这些核心思想构建。
📝 总结
本节课中我们一起学习了解决约束满足问题的核心优化算法。
我们首先深入探讨了弧一致性这一比节点一致性更强的概念,并学习了通过 REVISE 函数和 AC-3 算法 在整个问题上强制执行弧一致性的方法。
接着,我们介绍了回溯搜索这一基础算法,它通过尝试赋值、遇到矛盾时回溯的机制来寻找解。

最后,我们探讨了如何大幅提升搜索效率:通过在搜索过程中交错进行约束传播(推理) 来提前缩减值域;以及使用最小剩余值(MRV)、度启发式 和 最少约束值(LCV) 等启发式方法来智能地决定下一个赋值的变量和值。

这些技术——将问题形式化为约束满足问题,并运用一致性检查、回溯搜索与启发式策略——构成了解决从数独、课程排班到资源分配等众多实际优化问题的强大工具箱。
哈佛 CS50-AI 14:L4- 模型学习 1 (机器学习,监督学习,感知器,SVM) 🧠


在本节课中,我们将要学习机器学习的核心概念,特别是监督学习。我们将探讨如何让计算机从数据中学习模式,而不是通过明确的指令来执行任务。课程将涵盖几种基础算法,包括最近邻分类、感知器和支持向量机,帮助你理解如何根据输入数据预测输出结果。
什么是机器学习? 🤔
到目前为止,我们已经利用AI解决了许多不同的问题,给出了如何寻找解决方案或如何满足某些约束条件的指示。从某个输入点到某个输出点,以解决某种问题。
今天我们将转向学习的世界,特别是机器学习的概念。它通常指的是我们不打算给计算机明确的执行任务的指示。我们并不是给计算机提供关于如何执行任务的明确指示,而是让计算机访问以数据或模式的形式存在的信息,让它尝试找出这些模式,理解这些数据,以便能够独立执行任务。
机器学习有许多不同的形式,这个领域非常广泛。今天我们将深入探讨一些基础算法和概念,这些概念在机器学习的不同领域中都起着重要作用。
监督学习概览 📊
其中一个最受欢迎的想法是监督学习。这种特定类型的任务是指,我们让计算机访问一个数据集,该数据集由输入/输出对组成,而我们希望计算机能够找出一些将输入映射到输出的函数。
因此我们有一整套数据。这通常由某种输入、证据或信息组成,计算机将能够访问这些信息。我们希望计算机根据这些输入信息预测某个输出将会是什么。我们将提供一些数据,以便计算机能够训练其模型,开始理解这些信息是如何工作的,输入和输出之间是如何关联的。
但最终我们希望我们的计算机能够找出一个函数,给定这些输入,能够得到这些输出。
分类任务 🏷️
在监督学习中有几种不同的任务,我们将重点关注其中之一。我们将首先讨论的是分类。分类问题是,如果我给你一堆输入,你需要找出将这些输入映射到离散类别的方法,而你可以决定这些类别是什么。计算机的工作是预测这些类别将如何定义。
例如,我给你关于某张钞票的信息,比如一美元钞票,我在询问你预测它是否属于真实钞票的类别,还是属于假钞的类别。你需要对输入进行分类,训练计算机来找出一些函数来进行这个计算。
另一个例子可能是我们在这门课上稍微提到的情况,我们想预测在某一天,知道那一天是否会下雨,以及是否会多云。那一天,之前我们看到如果我们真的给计算机所有的确切概率,比如如果这些是条件,降雨的概率是什么。
但通常我们没有访问到那些信息,不过我们确实拥有大量数据。所以如果我们希望能够预测一些事情,比如会不会下雨,我们会给计算机提供关于下雨和不下雨的历史信息,并让计算机寻找这些数据中的模式。
数据结构化 📈
那么这些数据可能是什么样的呢?我们可以像这样将数据结构化到一个表格中,这可能是我们表格的样子。对于任何特定的日子,我们有关于那天的湿度、空气压力的信息。重要的是我们有一个标签,某个人曾说过在这一天是下雨的或者是不下雨。
所以你可以用很多数据填充这个表格。而这之所以被称为监督学习的练习,是因为有人为每一个数据点标注了标签,说明在湿度和气压为这些值的那天是一个下雨的日子和这一天是一个不下雨的日子。
我们希望计算机能够根据这些输入,比如湿度和气压,来预测应该与那一天关联的标签。那一天看起来更像是会是一个下雨的日子,或者看起来更像是一个不会下雨的日子。
数学形式化 🧮
从数学上讲,可以把这看作是一个接受两个输入的函数。这些输入是我们计算机可以获取的数据点,比如湿度和气压。因此我们可以写一个函数 f,它以湿度和气压作为输入。输出将是我们为这些特定输入点所归类的类别,即我们会将什么标签与该输入关联。
因此,我们在这里看到了一些示例数据点,给出了这个湿度值和这个气压值,预测是会下雨还是不会下雨。我们从世界上刚收集的信息,在不同的日子里测量湿度和气压,观察在特定那天是否下雨。
这个函数 f 是我们希望近似的。现在计算机和我们人类并不确切知道这个函数f是如何工作的,它可能是一个相当复杂的函数,因此我们将尝试估计它。我们希望提出一个假设函数H,试图近似f的功能。
我们想要提出一些函数H也会接受相同的输入,并产生一个输出:有雨或没有雨。理想情况下,我们希望这两个函数尽可能一致。因此监督学习分类任务的目标是弄清楚函数H是什么样的。
可视化数据点 📍
那么,如何开始做这件事呢?在这种情况下,我有两个数值,合理的做法是尝试将其绘制在图上。在一个有两个轴的图表上,x轴和y轴。在这种情况下,我们将使用两个数值作为输入,但这些相同的想法在增加更多输入时也同样适用。
我们将在二维中绘制事物,但如我们所见,可以添加更多输入,想象事物在多个维度中。而我们人类在视觉上至少在三维之外的概念化方面存在困难,但计算机在尝试想象许多更多维度时没有问题。对计算机来说,每个维度只是一个独立的数字。计算机在十维或百维中思考并不是不合理的,这样能够尝试解决问题。
但现在我们只有两个输入,因此我们将在x轴上绘制事物,这里代表湿度,y轴在这里表示压力。我们可能会说,取所有下雨的天数,尝试在这个图表上绘制它们,看看它们在图表上的位置。你知道这里可能是所有的下雨天,每个下雨天用一个蓝点表示,代表一个特定的值针对湿度和特定压力值。
然后我可能会对不下雨的天做同样的事情,比如取所有不下雨的日子,弄清楚这两个输入的值,并继续在这个图表上绘制它们,上面用红色绘制,而这里的蓝色则代表下雨天和红色在这里代表的是一个不下雨的日子。
这就是我的计算机能够访问的所有输入。我希望计算机能够训练一个模型,使得如果我遇到一个没有标签的新输入时,能够开始估计基于所有这些信息和数据,应该将什么类别或标签分配给特定的数据点。
这里的白点,我想预测根据这两个输入的值,我们应该将其分类为蓝点(下雨天),还是将其分类为红点(不下雨的日子)。
最近邻分类算法 👫
如果你仅仅从图像上看,试图说好吧,这个白点看起来像什么?它属于蓝色类别还是看起来属于红色类别?我想大多数人会同意它可能属于蓝色类别。为什么呢?因为它看起来接近其他蓝点。这不是一个很正式的概念,但我们会在后面进行形式化。
稍等一下,因为它似乎接近这个蓝点,周围没有其他点比它更近,因此我们可能会说它应该被分类为蓝色。我认为这一天将会是雨天,基于这个输入,可能不是完全准确,但这是一个相当不错的猜测。
在这种算法中,做出相当不错的猜测实际上是一个非常流行的常见机器学习算法,称为最近邻分类。这是解决这些分类类型问题的算法。在最近邻分类中,它将执行这个算法。所做的是给定一个输入,它将选择与该输入最近的数据点的类别。
这里的类别我们只指雨天或非雨天、假冒或非假冒。我们根据最近的数据点选择类别或类别。最近的数据点是蓝点还是红点?根据这个问题的答案,我们能够做出某种判断,可以说我们认为它会是蓝色的,或者我们认为它会是红色的。
同样,我们可以将此应用于我们遇到的其他数据点。如果突然出现这个数据点,它最近的数据是红色的,所以我们将其分类为红点,不下雨。
K最近邻分类算法 🔢
但当你看看这里,问同样的问题时,事情会变得有点复杂。它应该属于蓝点(下雨天)类别,还是应该属于红点类别,而不是不下雨的日子。最近邻分类会说,解决这个问题的方法是看哪个点离那个点最近。你看这个最近的点,发现它是红色的,是个不下雨的日子。
因此,根据最近邻,对于这个未标记的点,我会说它也应该是红色的,它也应该被分类为不下雨的日子。但你的直觉可能会认为这是一个合理的判断,认为它最接近的东西是一个不下雨的日子,所以可以猜测它不下雨这一天。
但从更大的角度看事情也是合理的,因为可以说,最近的点确实是一个红点,但它被许多其他蓝点包围。因此从更大的角度来看,可以认为这个点实际上应该是蓝色的。而仅凭这些数据我们实际上并不确定,我们给出一些输入,试图预测的内容,而我们不一定知道输出将是什么。
因此在这种情况下,哪一个是正确的很难说。但通常考虑的不仅仅是一个最近邻,考虑多个邻居有时可以给我们更好的结果。因此存在一种称为K最近邻分类算法的变体,其中K是我们选择的一个参数,即我们希望查看多少个邻居。
我们要看的一个最近邻分类是我们之前看到的,选择最近的一个邻居并使用该类别。但在K最近邻分类中,K可能是三、五或七,表示查看三个、五个或七个与该点最近的数据点。这个点的工作方式略有不同,该算法会在给定输入的情况下,从K个最近的数据点中选择最常见的类别。
因此如果我们查看五个最近的点,知道其中三个说下雨,两个说没下雨,我们将选择三个而不是两个,因为每个点实际上都会对他们认为类别应是什么投票,最终你选择票数最多的类别。
因此K最近邻分类是一个相对简单易懂的算法,你只需查看邻居并找出答案可能是什么。事实证明这对于解决各种不同类型的分类问题非常有效。
算法的权衡 ⚖️
但并不是每个模型在每种情况下都能有效,因此今天我们特别要关注的一个方面是监督机器学习的背景。有许多不同的方法来进行机器学习,也有许多不同的算法可以应用,所有这些算法都在解决同一种类型的问题,都是一些分类问题,我们希望将输入数据组织成不同的类别。
而没有任何一个算法一定会比其他算法更好。每种算法都有其权衡,可能根据数据的不同,一种类型的算法会更适合对该信息进行建模。而这正是许多机器学习研究的终点。当你尝试应用机器学习技术时,往往不仅仅关注一个特定的算法,而是尝试多种不同的算法,看看哪个能够给你最好的结果,以预测将输入映射到输出的某个函数。
线性分类与决策边界 📉
那么,K 最近邻分类的缺点是什么呢?有几个。一个可能是,在一种天真的方法下,它可能会比较慢,因为必须遍历并测量一个点与这里每一个点之间的距离。现在有一些方法可以尝试解决这个问题。有数据结构可以帮助更快速地找到这些邻居,还有一些技术可以用来尝试修剪一些数据或删除一些数据点,以便仅保留相关数据点,从而使其更容易。
但最终,我们可能想要做的是想出另一种方法来进行分类。一种尝试分类的方法是查看邻近的点,但另一种方法可能是尝试查看所有数据,看看我们能否想出一些决策边界将雨天与非雨天分开。
在二维的情况下,我们可以通过绘制一条线来做到这一点。例如,我们可能想尝试找到某条线,找到某个分隔符,将雨天(这里的蓝点)与非雨天分开。雨天的红点在那边,我们现在正在尝试一种不同的方法。
与仅查看输入数据点周围的局部数据的最近邻方法相对,现在我们所做的是尝试使用一种称为线性回归的技术来寻找某种将两部分分开的线。现在有时实际上可能会得出一条完美分隔所有雨天和非雨天的线,但实际上这可能比许多数据集要干净得多。
通常数据会更混乱,存在离群值和特定系统中发生的随机噪声,我们希望仍然能够弄清楚一条线可能是什么样子。因此,实际上数据并不总是线性可分的。线性可分指的是我可以绘制一条线的一些数据集,为了完美地分开这两个部分。
而是可能会出现这样的情况:某些雨天点在这条线的一侧,而某些非雨天点在那条线的另一侧,并且可能没有一条线能够完美地分开输入的路径。另一半完美地区分了所有雨天和非雨天,但我们仍然可以说这条线做得相当不错。我们稍后会试图正式化一下,当我们说这样的线在尝试进行预测时做得相当不错的意思。
但现在让我们仅仅说我们正在寻找一条线,尽可能有效地将一类事物与另一类事物分开。
假设函数的数学表达 🧠
那么现在我们试着在数学上更正式地表达这一点。我们想要想出某种函数,某种定义这条线的方法。输入是像湿度和压力这样的东西,因此我们的输入可能称为x1,代表湿度,x2则代表压力。这些是我们将提供给机器学习算法的输入。
基于这些输入,我们希望我们的模型能够能够预测某种输出。我们将使用我们的假设函数来进行预测,我们称之为H。假设函数将以x1和x2(湿度和压力)作为输入。在这种情况下,你可以想象如果我们不仅有两个输入,而是有三个、四个、五个或更多输入,我们可以让这个假设函数将所有这些作为输入,我们稍后也会看到一些例子。
现在问题是,这个假设函数做什么呢?它实际上是在边界的一侧还是在另一侧?我们如何正式化这个边界呢?边界通常将是这些输入变量的线性组合,至少在这个特定的案例中。
那么我们所说的线性组合就是取每个输入并将其乘以一个我们需要弄明白的数字。我们通常称之为数字表示这些变量在试图确定答案时应该有多重要。因此我们将对这些变量加权。我们可能会再加上一个常数,以试图使这个函数有些不同。
结果我们只需比较,看看它是大于零还是小于零,以说它不属于一侧的线或另一侧的线。
那么,这个数学表达式可能看起来像这样:我们将取每个变量X1和X2,将它们乘以一些权重,我现在还不知道那个权重是什么,但它将是一些权重1。也许我们只想加上一些其他权重,因为函数可能要求我们将整个值上移或下移某个量。
然后我们只需比较,如果我们做所有这些映射,是否大于或等于0。如果是,我们可能将这个数据点分类为雨天。否则我们可能会说没有雨。
因此关键在于这个表达式是我们将如何计算是否是雨天。我们将进行一系列数学运算,将每个变量乘以一个权重,也许再加一个额外的权重,看看结果是否大于或等于0。并且利用这个表达式的结果,我们能够确定是否下雨。
这个表达式在这里的情况将仅指代某条线,如果你绘制图表,它将只是一些线。而这条线的实际样子取决于这些权重。x1和x2是输入,但这些权重实际上决定了那条线的形状、斜率以及那条线的实际样子。
因此我们想要弄清楚这些权重应该是什么。我们可以选择任何权重,但我们希望以这样的方式选择权重:如果你传入在雨天的湿度和压力下,你最终得到的结果是大于或等于0的。我们希望这样,如果我们输入到我们的假设函数中不是雨天,那么我们得到的输出应该是不下雨。
向量化表示与点积 ➕✖️
在到达那里之前,让我们尝试把这更正式化一些。从数学上讲,这样你就可以理解,如果你进一步深入监督学习并探索这个概念,你会经常看到这个。一件事是,通常对于这些类别,有时只会使用类别的名称,比如“下雨”和“不下雨”。通常在数学上如果我们在这些事物之间进行比较时,处理数字世界更容易。
所以我们可以说1和0,1代表下雨,0代表不下雨。因此我们做所有这些数学运算,如果结果大于或等于0,我们将继续说我们的假设函数输出1,意味着下雨。否则,输出为零,意味着不下雨。
通常这类表达会用向量数学来表示。而向量如果你不熟悉这个术语,是指数值序列。你可以在Python中用数值列表表示值或带有数值的元组。在这里我们有几个数值序列。我们的一个向量,数值序列之一是所有这些单独的权重w0、w1和w2。
因此我们可以构造一个我们称之为权重向量的东西,我们稍后将看到这有什么用,称为W。通常用加粗的W表示,这只是这三个权重的序列:权重0、权重1和权重2。
为了能够基于这些权重计算我们认为一天是下雨还是不下雨,我们将把每个权重与我们的输入变量之一相乘。权重将会乘以输入变量X2,W1将会乘以输入变量X1。而W0,嗯,它并没有被任何东西乘以,但为了确保向量长度一致,我们稍后会看到这为什么有用,我们只是说W0被乘以因为你可以乘以某个数,乘以1后最终得到的就是确切的相同数字。
所以除了权重之外,向量W还会有一个输入向量,我们称之为X,它有三个值。再一次,因为我们只是在将W零乘以1,最终然后是X1和X2。所以这里我们已经表示出来两个不同的向量,即我们需要以某种方式学习的权重。机器学习算法的目标是学习这个权重向量应该是什么。
我们可以选择任何任意的数字集,它将产生一个尝试预测是否下雨的函数,但它可能不会很有效。这可能不是很好。我们想要做的是提出这些权重的良好选择,以便我们能够进行准确的预测。
然后这个输入向量表示某个特定输入到函数中,即我们希望估计的一个数据点,看看那天是雨天还是非雨天。而且这将根据提供给我们函数的输入而有所不同,我们正在尝试估计什么。
然后为了进行计算,我们想要计算这个表达式。结果表明这个表达式就是我们所说的这两个向量的点积。两个向量的乘积仅意味着将向量中的每一项相乘,W0乘以X1,W1乘以X1,W2乘以X2。这就是为什么这些向量需要相同长度的原因。然后我们将所有结果相加。
所以W和X的点积,权重向量和我们的输入向量,只会是W0乘以1或者说是W0加上W1乘以X1,将这两个项相乘,再加上W2乘以X2,将这些项相乘。
所以我们有我们的权重向量,我们需要搞清楚我们需要我们的机器学习算法来弄明白什么权重应该是。我们有代表我们尝试预测的类别的数据点的输入向量,预测标签。我们能够通过计算这个点积来做到这一点。
这在向量形式中经常被表示,但如果你之前没有见过向量,你可以可以把它想象成与这个数学表达式完全相同,只是进行乘法运算,将结果相加,然后查看结果是否大于或等于0。这里的表达式与我们计算的表达式是完全相同的,以查看那个答案是否成立。

因此,你通常会看到假设函数写成像这样一个更简单的表示,其中假设以某些输入向量X(某天的湿度和压力)作为输入。我们想要预测一个输出,比如下雨或不下雨,或者选择1或0。数字表示事物的方式是通过计算权重和输入的点积。如果结果大于或等于零,我们将说输出为1;否则,输出为0。
这个假设被认为是由权重参数化的,具体取决于我们选择的权重。如果我们随机选择权重,最终得到的假设可能会有所不同,可能不会得到一个非常好的假设函数。我们会得到1或0,但这可能不会准确反映我们认为某一天是否会下雨。但如果我们正确选择权重,我们通常可以很好地估计函数的输出应该是1还是0。
感知器学习规则 🔄
那么问题是如何确定这些权重应该是什么,如何调整这些参数。有很多方法可以做到这一点,其中一种最常见的方法称为感知机学习规则,我们稍后会详细介绍。
感知机学习规则的思想是,对于我们希望从中学习的数据点来说,不会深入数学,我们主要是以概念性介绍,假设给定一些数据点。有一个输入X和一个输出Y,其中Y为1表示下雨,0表示不下雨。
然后我们将更新权重,稍后我们将看看公式,但大体思路是,我们可以从随机权重开始,然后像从数据中学习一样,逐个数据点处理。在所有数据点中找出好吧,我们需要在权重中更改哪些参数,以更好地匹配该输入点。
因此,拥有大量数据在监督学习算法中的价值在于,你逐个处理每个数据点,也许会查看。我们会多次尝试并不断确定是否需要调整权重,以便更好地创建一个能够正确或更准确地估计输出的权重向量。无论我们认为要下雨还是不下雨要下雨了。
那么在不深入数学的情况下,权重更新看起来像什么呢?
哈佛 CS50-AI 15:L4- 模型学习 2 (回归,损失函数,过拟合,正则化,强化学习,sklearn) 📈
在本节课中,我们将要学习监督学习中的回归问题,以及如何评估和优化机器学习模型。我们将探讨损失函数、过拟合现象及其解决方案(如正则化),并简要介绍强化学习的概念。最后,我们将学习如何使用 scikit-learn 库来快速实现和测试这些模型。
回归问题 🔄
上一节我们介绍了分类问题,本节中我们来看看回归问题。回归同样是监督学习问题,目标是学习一个将输入映射到输出的函数。
与分类不同,回归问题的输出不再是离散的类别(如下雨/不下雨),而是连续的实数值。例如,公司可能希望根据广告支出的金额来预测产品的销售额。
我们试图学习一个函数,其输入是广告支出金额,输出是预测的销售额。我们可以根据过去的数据(广告支出与销售额)来绘制散点图,并尝试找出一条线来近似表示两者之间的关系。
这条线就是我们的假设函数。给定一个新的广告预算,我们可以通过这条线来查找对应的预测销售额。这种方法被称为线性回归。
评估假设:损失函数 ⚖️
问题在于,我们如何评估不同假设函数的好坏?我们可以将这个过程看作一个优化问题,目标是最小化一个称为损失函数的成本。
损失函数量化了我们的预测与真实值之间的差距。对于每一个数据点,根据实际输出和预测输出,我们可以计算一个损失值。将所有数据点的损失相加,就得到了经验损失。
以下是几种常见的损失函数:

- 0-1损失函数:适用于分类问题。如果预测正确,损失为0;如果预测错误,损失为1。
- 公式:
L(y, ŷ) = 0 if y == ŷ else 1
- 公式:
- L1损失函数:适用于回归问题。计算实际值与预测值之差的绝对值。
- 公式:
L(y, ŷ) = |y - ŷ|
- 公式:
- L2损失函数:同样适用于回归问题。计算实际值与预测值之差的平方,会对较大的误差给予更严厉的惩罚。
- 公式:
L(y, ŷ) = (y - ŷ)²
- 公式:
选择哪种损失函数取决于具体问题和我们对误差的容忍度。
过拟合与正则化 🚧
如果我们只专注于最小化训练数据上的损失,可能会遇到过拟合问题。过拟合是指模型过于复杂,以至于“记住”了训练数据中的噪声和细节,导致其在未见过的数据上表现很差。
我们希望模型能够泛化,即在新的、未知的数据上也能做出准确预测。为了避免过拟合,我们可以在优化时不仅考虑损失,还考虑模型的复杂性。
这个过程称为正则化。我们在成本函数中添加一个正则化项,用于惩罚复杂的模型,从而倾向于选择更简单、泛化能力更强的假设。
通常,正则化后的成本函数可以表示为:
总成本 = 损失 + λ × 复杂性
其中,λ 是一个超参数,用于控制对复杂性的惩罚力度。λ 值越大,对复杂模型的惩罚越重。
验证模型:交叉验证 🧪
确保模型不发生过拟合的另一种方法是通过实验验证其泛化能力。通常,我们会将数据集分为两部分:
- 训练集:用于训练模型。
- 测试集:用于评估训练好的模型在未知数据上的表现。
这种方法称为留出法交叉验证。如果模型在训练集上表现很好,但在测试集上表现很差,很可能就是过拟合了。
为了更充分地利用数据,可以使用 k折交叉验证。其步骤如下:
- 将数据随机分为 k 个大小相似的子集。
- 进行 k 次训练和测试。每次使用一个子集作为测试集,其余 k-1 个子集作为训练集。
- 最终,计算 k 次测试结果的平均值,作为模型性能的估计。
实践:使用 scikit-learn 🛠️
在Python中,我们可以使用 scikit-learn 库来快速实现和测试各种机器学习算法,而无需从头编写。
以下是一个使用 scikit-learn 进行银行钞票真伪分类的简化示例流程:
# 导入必要的库和模型
from sklearn.model_selection import train_test_split
from sklearn.linear_model import Perceptron
from sklearn.metrics import accuracy_score
import pandas as pd
# 1. 加载数据
data = pd.read_csv('bank_notes.csv')
X = data[['feature1', 'feature2', 'feature3', 'feature4']] # 输入特征
y = data['label'] # 输出标签 (0: 真钞, 1: 伪钞)
# 2. 划分训练集和测试集
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.5, random_state=42)
# 3. 选择并训练模型
model = Perceptron()
model.fit(X_train, y_train)
# 4. 在测试集上进行预测
y_pred = model.predict(X_test)

# 5. 评估模型性能
accuracy = accuracy_score(y_test, y_pred)
print(f"模型准确率: {accuracy:.2%}")


通过简单地替换 model = Perceptron() 为其他模型(如 SVC() 支持向量机 或 KNeighborsClassifier(n_neighbors=3) K近邻),我们可以轻松比较不同算法的性能。

其他学习范式:强化学习 🎮
除了监督学习,机器学习还有另一种重要范式——强化学习。
在强化学习中,智能体(Agent)通过与环境互动来学习。其核心思想是:
- 智能体在环境的某个状态中。
- 智能体选择一个动作执行。
- 环境反馈给智能体一个新的状态和一个奖励(正数代表鼓励,负数代表惩罚)。
- 智能体的目标是学习一个策略,通过选择一系列动作来最大化长期获得的总奖励。
例如,训练一个机器人走路,每当它平稳前进就给予正奖励,摔倒则给予负奖励。通过大量试错,机器人最终学会走路的策略。强化学习在游戏AI、机器人控制等领域有广泛应用。
总结 📝
本节课中我们一起学习了:
- 回归问题:预测连续值的监督学习任务。
- 损失函数:用于量化模型预测误差的函数,如0-1损失、L1损失和L2损失。
- 过拟合:模型在训练数据上表现过好,但泛化能力差的问题。
- 正则化:通过惩罚模型复杂性来防止过拟合的技术。
- 交叉验证:将数据分为训练集和测试集,以评估模型泛化能力的方法。
- scikit-learn:一个强大的Python库,可以快速实现和测试多种机器学习算法。
- 强化学习:一种通过奖励和惩罚机制,让智能体从环境中学习行为策略的机器学习范式。
理解这些核心概念,是构建有效、稳健的机器学习模型的基础。
哈佛 CS50-AI 16:L4- 模型学习 3 (马尔可夫决策过程,Q学习,无监督,聚类) 🧠🤖
在本节课中,我们将要学习机器学习中几个核心的高级概念。我们将从强化学习开始,了解智能体如何通过与环境互动来学习最优策略,具体会学习马尔可夫决策过程和Q学习算法。之后,我们将转向无监督学习,并介绍一种经典的无监督学习技术——K均值聚类。
强化学习与马尔可夫决策过程 🧭
上一节我们讨论了智能体如何从经验中学习。为了形式化这一点,我们首先需要形式化状态和行动的概念。
我们将这种世界形式化为一个被称为马尔可夫决策过程的模型。马尔可夫决策过程是一个我们可以用来为智能体在其环境中做决策的模型。这是一个允许我们表示智能体可以处于的各种不同状态、可以采取的行动以及采取一种行动与采取另一种行动的奖励的模型。
马尔可夫决策过程看起来是什么样子呢?如果你还记得之前的马尔可夫链,它看起来有点像这样:我们有一大堆个体状态,每个状态根据某些概率分布过渡到另一个状态。但在那个原始模型中,没有智能体可以控制这个过程,它完全是基于概率的。
在马尔可夫决策过程中,我们进行了扩展。智能体在某个状态下可以选择一组行动。每个行动可能与其自身的概率分布相关联,通向各种不同的状态。此外,每当你从一个状态采取行动进入另一个状态时,我们可以将奖励与这个结果相关联。正值奖励意味着某种正反馈,负值奖励意味着某种形式的惩罚。
马尔可夫决策过程包含几个组成部分:
- 状态集合:智能体可以处于的所有状态。
- 行动集合:在给定状态下,智能体可以采取的所有行动。
- 转移模型:给定当前状态和采取的行动,到达下一个状态的概率。
- 奖励函数:给定当前状态、采取的行动和到达的下一个状态,所获得的奖励。
智能体从与特定环境的交互中获得的总奖励,可以使用这个马尔可夫决策过程进行建模。
Q学习算法:从经验中学习价值 📈
现在让我们尝试形式化智能体学习“在某个状态下采取某个行动是好是坏”的想法。强化学习有很多不同的模型,我们要看的方法称为 Q学习。
Q学习的核心在于学习一个函数 Q(s, a)。这个函数以状态 s 和行动 a 为输入,并输出一个价值估计,代表在该状态下采取该行动能获得多少奖励。
起初我们不知道这个Q函数应该是什么。但随着时间的推移,基于尝试和观察结果,我们想学习任意特定状态 s 和行动 a 的Q值。
以下是Q学习的基本步骤:
- 初始化:对所有状态 s 和所有行动 a,将 Q(s, a) 设为零。在拥有任何经验之前,我们假设所有价值都是零。
- 与环境互动:智能体在状态 s 下采取行动 a,观察到奖励 r,并进入新状态 s‘。
- 更新Q值:基于这次经验,我们更新对 Q(s, a) 的估计。新的估计基于当前获得的奖励和进入新状态后预期的未来奖励。
我们使用以下公式来更新Q值:
Q(s, a) <- Q(s, a) + α * [新价值估计 - Q(s, a)]
其中:
- α 是学习率,控制新信息的重要性(0到1之间)。α=1表示完全用新估计替换旧估计;α=0表示忽略新信息。
- 新价值估计 由两部分组成:立即获得的奖励 r,加上折现后的未来最大可能奖励 γ * max(Q(s‘, a‘))。γ 是折扣因子,表示未来奖励相对于当前奖励的价值。
更完整的更新公式是:
Q(s, a) <- Q(s, a) + α * [ r + γ * max(Q(s‘, a‘)) - Q(s, a) ]
通过反复经历和更新,Q函数会越来越准确地反映每个状态-行动对的实际价值。
探索与利用的平衡 ⚖️
一旦我们对每个状态和每个行动都有了较好的价值估计,我们可以实施一个贪婪决策策略:在状态 s 下,总是选择使 Q(s, a) 值最大的行动 a。
但这种方法有一个缺点:如果智能体总是采取它已知的最佳行动,它可能永远不会尝试未知的、但可能更好的行动。这被称为探索与利用的困境。
为了解决这个问题,我们使用 ε-贪婪算法:
- 以概率 1 - ε,选择当前估计的最佳行动(利用)。
- 以概率 ε,随机选择一个行动(探索)。
通过设置ε值(例如0.1),我们可以控制智能体探索新可能性的频率。通常,在训练初期使用较大的ε以鼓励探索,随后逐渐减小ε以更多地利用学到的知识。

无监督学习与聚类 🔍


机器学习的第三个主要类别是无监督学习。与有监督学习(数据有标签)和强化学习(通过奖励学习)不同,无监督学习发生在我们有数据但没有任何标签或额外反馈的情况下。
在无监督学习中,我们仍然希望从数据中发现一些潜在的模式或结构。一个常见的无监督学习任务是聚类。
聚类是指给定一组对象,将其组织成不同的簇,使得同一簇内的对象彼此相似,而不同簇的对象则相异。聚类应用广泛,例如基因分组、市场细分、图像分割等。
K均值聚类算法 🎯
一种经典的聚类技术是 K均值聚类 算法。该算法的目标是将所有数据点划分为 K 个不同的簇。
以下是K均值聚类的工作步骤:

- 初始化:随机选择K个点作为初始的簇中心(质心)。
- 分配点:将每个数据点分配给离它最近的簇中心。
- 更新中心:将所有点分配完毕后,重新计算每个簇的中心(即取该簇所有点的平均值)。
- 迭代:重复步骤2和步骤3,直到簇的分配不再发生变化,或达到最大迭代次数。
这个过程通过不断调整簇中心和点的归属,最终将数据点划分为K个紧凑的簇。
总结 🎓
本节课中我们一起学习了机器学习中几个关键的高级主题。
我们首先深入探讨了强化学习,通过马尔可夫决策过程形式化了智能体与环境互动学习的过程。接着,我们学习了Q学习这一具体的强化学习算法,它通过更新一个Q函数来学习状态-行动对的价值。我们还讨论了探索与利用的平衡,并介绍了ε-贪婪算法来解决这一问题。
最后,我们转向了无监督学习,介绍了聚类任务,并详细讲解了K均值聚类算法如何将未标记的数据点分组到不同的簇中。

这些概念——监督学习、强化学习和无监督学习——构成了现代机器学习的基础,使我们能够构建可以从数据中学习并执行复杂任务的智能系统。
哈佛 CS50-AI 17:L5- 神经网络 1 (神经网络,激活函数,梯度下降,多层网络) 🧠


在本节课中,我们将要学习神经网络的基础知识。神经网络是机器学习中最流行的技术之一,它受到人类大脑结构的启发,能够学习数据中的复杂模式并执行任务。我们将从最简单的神经网络模型开始,逐步了解其核心组件,包括激活函数、梯度下降算法,以及如何通过增加网络层数来构建更强大的模型。
神经网络简介 🧩

上一节我们介绍了机器学习的基本概念。本节中我们来看看一种重要的机器学习模型——神经网络。
神经网络的研究始于20世纪40年代。研究人员受到人类大脑学习方式的启发,尝试将类似的原理应用于计算机,模拟计算机以人类为基础进行学习。

大脑由许多相互连接的神经元组成。神经元接收来自其他神经元的电信号,处理这些输入信号,并在被激活时向其他神经元传播信号。

基于此生物学原理,我们设计出人工神经网络。人工神经网络是一个受生物神经网络启发的数学模型。它能够模拟某种数学函数,将特定的输入映射到特定的输出。网络的结构和内部神经元的参数决定了这个函数的具体形式。
人工神经元与简单网络 ⚙️
为了构建人工神经网络,我们使用称为“单元”或“人工神经元”的组件。在图中,我们用一个节点(例如蓝色圆圈)来表示一个单元。这些人工神经元可以彼此连接,边表示它们之间的连接关系。
我们可以将这种连接关系视为从输入到输出的映射。例如,一个单元连接到另一个单元,我们可以将一边视为输入,另一边视为输出。我们的目标是弄清楚如何建模一个数学函数来解决特定问题。
一个常见的问题是:给定输入变量 x1 和 x2(例如湿度和气压),我们想要预测是否会下雨(一个布尔分类问题)。我们希望通过一个假设函数 H 来处理输入,并做出判断。
我们使用输入变量的线性组合来定义假设函数。公式如下:

H(x) = w0 + w1*x1 + w2*x2
在这个公式中:
x1和x2是输入变量。w1和w2是权重,是与输入相乘的数字。w0是偏差(或偏置),用于上下移动函数的值。它有时被视为与一个固定值(如1)相乘的权重。
为了将线性组合的结果转化为分类(例如下雨或不下雨),我们需要一个激活函数。它决定神经元何时被“激活”并产生输出。
激活函数 📈
激活函数接收加权求和的结果,并产生最终的输出。
一种简单的激活函数是阶跃函数。它的定义是:如果输入大于等于0,则输出1;否则输出0。它在图形上表现为一条在阈值点从0跳跃到1的线。
step(x) = 1 if x >= 0 else 0
然而,我们有时不仅需要二元输出(0或1),还需要一个概率值。为此,我们可以使用逻辑Sigmoid函数。它的图形是一条S形曲线,输出值在0到1之间,可以解释为概率。
sigmoid(x) = 1 / (1 + e^(-x))
另一种流行的激活函数是整流线性单元。它的输出是输入和0之间的最大值。如果输入为正,则输出等于输入;如果输入为负或零,则输出为0。
ReLU(x) = max(0, x)
简而言之,激活函数 g 被应用于线性组合的结果 z(z = w0 + w1*x1 + w2*x2),从而得到最终输出 output = g(z)。这就是最简单的神经网络模型。
神经网络的图形表示 🖼️
我们可以用图形来表示这个数学模型。这是一个有两个输入(x1, x2)和一个输出的神经网络。
在这个结构中:
- 输入
x1和x2通过带有权重(w1,w2)的边连接到输出单元。 - 输出单元计算
z = w0 + w1*x1 + w2*x2。 - 然后将
z传递给激活函数g以产生最终输出。
这个网络的目标是学习权重 w0, w1, w2 和选择合适的激活函数,以便计算出我们想要的函数。
实例:建模逻辑函数 🔌
让我们看一个简单的例子:建模逻辑“或”函数。
“或”函数接受两个布尔输入(0或1)。只要有一个输入为1,输出就为1;仅当两个输入都为0时,输出才为0。


我们可以训练一个神经网络来学习这个函数。假设我们设置权重 w1=1, w2=1,偏差 w0=-1,并使用阶跃函数作为激活函数。
以下是计算过程:
- 输入
(0, 0):z = -1 + 1*0 + 1*0 = -1。阶跃函数在-1时输出0。✅ - 输入
(1, 0):z = -1 + 1*1 + 1*0 = 0。阶跃函数在0时输出1。✅ - 输入
(0, 1):输出同样为1。✅ - 输入
(1, 1):z = -1 + 1*1 + 1*1 = 1。阶跃函数输出1。✅
类似地,我们可以建模“与”函数(仅当两个输入都为1时输出1)。只需将偏差 w0 改为 -2,并使用相同的权重和激活函数即可验证。
更多输入与输出 🔄
上一节我们介绍了具有两个输入和一个输出的简单网络。本节中我们来看看如何扩展网络以处理更复杂的问题。
在实际问题中,我们通常有多个输入。神经网络可以轻松扩展以容纳任意数量的输入。每个输入都会乘以一个对应的权重,然后求和并加上偏差。
z = w0 + w1*x1 + w2*x2 + ... + wn*xn
同样,我们也可以有多个输出。例如,在天气预测中,我们可能希望将天气分类为“下雨”、“晴天”、“多云”或“下雪”四个类别,而不仅仅是“下雨/不下雨”。
具有多个输出的网络可以视为多个共享输入的独立神经网络的组合。每个输出单元都有自己的权重集。最终,所有输出值通常会通过一个函数(如Softmax)转化为概率分布,值最高的类别即为预测结果。
训练神经网络:梯度下降 📉
如何自动找到神经网络中合适的权重呢?我们无法总是像“与/或”函数那样手动设置。答案是使用一种称为梯度下降的优化算法。
梯度下降是一种用于最小化损失函数的算法。损失函数衡量了我们的模型预测值与真实值之间的差距。
其核心思想如下:
- 随机初始化所有权重。
- 重复以下过程:
- 计算梯度:基于当前所有权重和所有训练数据,计算损失函数的梯度。梯度指示了为了减少损失,权重应向哪个方向调整。
- 更新权重:沿着梯度的反方向,以一个小步长(学习率)更新所有权重。
新权重 = 旧权重 - 学习率 * 梯度
通过不断重复这个过程,权重会逐渐调整,使得损失函数的值减小,模型预测变得更准确。
梯度下降的变体 🚀
标准的梯度下降(批量梯度下降)在每次更新时需要使用全部训练数据计算梯度,这在数据量很大时计算成本很高。
为了提高效率,有两种常见的变体:
- 随机梯度下降:每次更新只随机使用一个训练数据点来计算梯度并更新权重。速度更快,但更新方向波动较大。
- 小批量梯度下降:每次更新使用一小批(例如32、64个)训练数据来计算梯度。这是实践中最常用的方法,它在计算效率和梯度稳定性之间取得了良好的平衡。
多层神经网络 🏗️
到目前为止,我们看到的网络都是输入直接连接到输出。这种单层网络(或称感知器)有一个根本性限制:它只能学习线性可分的模式。这意味着它只能用一条直线(或超平面)来划分数据。
然而,许多真实世界的数据是非线性可分的。例如,数据点可能呈环形分布,红点在内部,蓝点在外部,没有一条直线能完美分开它们。
解决方案是引入隐藏层,构建多层神经网络。
在一个多层神经网络中,除了输入层和输出层,中间还有一层或多层隐藏层。每个隐藏层的神经元都会根据其输入计算一个值(激活值)。
工作流程如下:
- 输入层的值乘以权重,传递给第一个隐藏层。
- 隐藏层的每个神经元计算其加权和并应用激活函数,得到激活值。
- 这些激活值作为新的“输入”,乘以另一组权重,传递给下一层(可能是另一个隐藏层或输出层)。
- 最终,输出层产生结果。
隐藏层的每个神经元可以学习输入数据的不同特征或模式。通过将这些特征组合起来,网络就能够学习非常复杂的非线性决策边界,解决单层网络无法处理的问题。
训练带有隐藏层的网络更为复杂,因为我们没有隐藏层节点的目标值。这需要通过反向传播算法来解决,该算法能够将输出层的误差反向传播到隐藏层,从而更新所有权重。这将是后续课程的重点。
总结 🎯
本节课中我们一起学习了神经网络的基础知识。
我们首先了解了神经网络受生物大脑启发的起源。然后,我们构建了最基本的人工神经元模型,它通过加权求和与激活函数将输入映射到输出。我们看到了如何使用简单的网络来建模“与”、“或”等逻辑函数。
为了处理多类别和更复杂的问题,我们探讨了如何扩展网络的输入和输出。接着,我们介绍了训练神经网络的梯度下降算法及其变体(随机、小批量),这是让网络自动学习正确权重的核心方法。
最后,我们认识到单层网络的局限性,并引入了多层神经网络的概念。通过增加隐藏层,网络获得了学习复杂非线性模式的能力,为解决现实世界中的各种问题(如图像识别、自然语言处理)奠定了基础。
在接下来的课程中,我们将深入探讨如何训练这些多层网络,并了解它们在人工智能领域的强大应用。
哈佛 CS50-AI 18:L5- 神经网络 2 (反向传播,过拟合,tensorflow,计算机视觉) 🧠
在本节课中,我们将深入学习神经网络的核心训练算法——反向传播,探讨模型复杂化带来的过拟合问题及其应对策略,并介绍如何使用强大的TensorFlow库快速构建神经网络。最后,我们将把神经网络的应用场景扩展到计算机视觉领域,了解图像处理的基本概念。
🔄 反向传播算法
上一节我们介绍了神经网络的基本结构和前向传播过程。本节中我们来看看如何通过反向传播算法来训练网络,即调整网络中的权重以最小化预测误差。
实际上,人们提出的策略是,如果你知道输出节点上的错误或损失是什么,那么基于这些权重,如果其中一个权重比另一个高,你可以计算出这个节点的错误在多大程度上是由于这个隐藏节点的部分,或者隐藏层的这一部分,或者隐藏层的这一部分造成的。
基于这些权重的值,实际上是在说,根据输出的错误,我可以反向传播错误,并弄清楚每个隐藏层节点的错误估计是什么。这里还有一些微积分,我们不会详细讨论。
这个算法的思想被称为反向传播,它是一个用于训练神经网络的算法,具有多个不同的隐藏层。
以下是使用反向传播进行梯度下降的伪代码流程:
- 初始化权重:从随机选择的权重开始。
- 重复训练过程:
- 计算输出层错误:我们知道输出应该是什么,也知道我们计算了什么,因此可以弄清楚有什么错误。
- 反向传播错误:对每一层进行重复,从输出层开始,向回移动到隐藏层,然后是之前的隐藏层(如果有多个隐藏层,将一直向回到最初的隐藏层)。每一层,无论输出的错误是什么,都基于权重的值计算前一层的错误。
- 更新权重:根据传播回来的错误更新每一层的权重。
从图形上来看,你可能会想到这一点:我们首先从输出开始,我们知道输出应该是什么,我们知道计算出的输出是什么,基于此我们可以弄清楚我们需要如何更新这些权重,将错误反向传播到这些节点,并利用它,我们可以弄清楚我们应该如何更新这些权重。
你可能想象一下,如果有多个层,我们可以重复这个过程一次又一次,以开始弄清楚所有这些权重在这个反向传播算法中应该如何更新。这确实是关键算法,它使得神经网络成为可能,它使得我们能够进行这些多层结构,能够训练这些结构,具体取决于这些权重的值,以弄清楚我们应该如何更新这些权重,从而创建一个能够最小化总损失的函数,找出一些好的权重设置将输入转换为我们期望的输出。
🏗️ 深度神经网络
正如我们所说,这不仅适用于单个隐藏层。您可以想象多个隐藏层,在每个隐藏层中定义所需的节点数量,每个节点都可以连接到下一个层的节点,从而定义越来越复杂的网络,能够建模越来越复杂类型的函数。
因此这种类型的网络可以称为深度神经网络,属于深度学习算法的大家族。所有深度学习关注的就是利用多个层来预测并建模输入中的高级特征,以确定输出应该是什么。
一个深度神经网络就是一个具有多个隐藏层的神经网络,从输入开始计算对于这一层的值,然后是这一层,然后是这一层,最终得到输出。这使我们能够建模越来越复杂的函数,每一层可以计算一些略有不同的内容,我们可以结合这些信息来确定输出应该是什么。
⚠️ 过拟合与 Dropout 技术
当然,在任何机器学习的情况下,随着我们开始使模型变得越来越复杂,以建模越来越复杂的函数,我们面临的风险是过拟合。上次我们在过拟合的上下文中讨论了这一点:我们在训练模型时,试图学习某种决策边界,过拟合发生在我们对训练数据拟合得过于紧密,因此我们对其他情况的泛化效果较差。
而我们在一个复杂的神经网络中面临的风险是:不同的节点可能会因为输入数据而导致过拟合,我们可能过于依赖某些节点,仅仅基于输入数据进行计算,这不允许我们很好地泛化到输出。
有很多策略可以应对过拟合。在神经网络的背景下,最流行的技术被称为 Dropout。
Dropout 的作用是在训练神经网络时,暂时移除某些单元,随机选择这些人工神经元,从而防止对某些单元的过度依赖。过拟合通常发生在我们开始过于依赖神经网络内部的某些单元,以告诉我们如何解读输入数据。

以下是 Dropout 的工作方式:
- 我们有一个网络,在训练时,第一次我们将随机选择一定百分比(例如50%)的节点从网络中丢弃(就好像那些节点的权重根本不存在一样),然后以这种方式进行训练。
- 接下来当我们更新权重时,我们将选择另一组随机节点并继续训练。
- 这个过程在训练中不断重复。
目标是,在训练过程中,如果通过随机丢弃网络内部的节点进行训练,希望最终得到一个更健壮的网络,不会过于依赖任何特定节点,而是更普遍地学习如何近似一个函数。
🛠️ 使用 TensorFlow 构建神经网络
现在,我们想将这些想法付诸于代码。为此,有许多不同的机器学习库和神经网络库可以使用,这些库允许我们访问某些人的反向传播实现和所有这些隐藏层。最受欢迎的由谷歌开发的库被称为 TensorFlow。
这是一个我们可以用来快速创建神经网络并对其进行建模和在一些样本数据上运行的库,以查看输出将是什么。
在我们实际上开始编写代码之前,我们将先查看 TensorFlow 的 游乐场,这将为我们提供一个机会,让我们玩一玩神经网络和不同层次的概念,以便更好地理解我们可以利用神经网络做什么。
TensorFlow 游乐场演示
在游乐场中,我们可以尝试学习对于特定输出的决策边界。例如,我们想学习如何将蓝点与橙点分离。我们可以访问的输入数据特征是 x 值和 y 值。
- 简单线性可分案例:仅使用两个输入特征(x 和 y),没有隐藏层,神经网络很快学会用一条直线作为决策边界完美分开两个点,训练损失为零。
- 复杂非线性案例(如异或问题):没有一条直线能够将两类点分开。如果仅用输入层,网络无法得出清晰结论。添加一个包含两个神经元的隐藏层后,网络能做得稍好,每个隐藏神经元学习自己的决策边界(如一条线),但可能仍无法完美分类。继续添加神经元(如三个或四个),网络通过学习多个不同的决策边界并将它们组合,能够更好地对复杂数据进行分类。

通过调整隐藏层和神经元的数量,我们可以观察网络如何学习数据中的结构,找出重要的决策边界。反向传播算法则负责确定这些权重的值,以训练网络区分不同类别的点。
代码示例:钞票真伪分类
让我们看一个实际代码例子。我们将使用上次提到的钞票数据集,根据四种面值特征判断钞票是真钞还是伪钞。
以下是使用 TensorFlow (tf) 构建和训练神经网络的关键步骤:
import tensorflow as tf
# 1. 准备数据(划分训练集和测试集)
# ... (数据加载和预处理代码)
# 2. 定义模型结构
model = tf.keras.Sequential([
# 添加一个密集连接(全连接)的隐藏层,包含8个神经元
# 输入形状为4(因为有4个输入特征),使用ReLU激活函数
tf.keras.layers.Dense(8, input_shape=(4,), activation='relu'),
# 添加输出层,包含1个神经元(二分类),使用Sigmoid激活函数输出概率
tf.keras.layers.Dense(1, activation='sigmoid')
])
# 3. 编译模型
# 指定优化器(如adam)、损失函数(binary_crossentropy用于二分类)和评估指标(accuracy)
model.compile(optimizer='adam',
loss='binary_crossentropy',
metrics=['accuracy'])
# 4. 训练模型
# 使用训练数据和标签,训练20个周期(epoch)
model.fit(training_data, training_labels, epochs=20)
# 5. 评估模型
# 在测试集上评估模型性能
model.evaluate(test_data, test_labels)
运行此代码后,神经网络将被训练,并能在测试数据上达到很高的准确率(例如99.8%)。TensorFlow 的价值在于,我们只需定义网络结构和数据,它就会自动运行反向传播算法来学习最优的权重。
👁️ 神经网络与计算机视觉
我们可以开始想象将神经网络应用于更一般的问题,尤其是计算机视觉的问题。计算机视觉涉及对图像进行分析和理解的计算方法。
计算机视觉的应用非常广泛:
- 社交媒体:识别人脸并自动标记。
- 自动驾驶汽车:识别交通灯、周围车辆和行人。
- 手写识别:识别手写数字(如MNIST数据集中的数字)。
挑战与思路
我们如何使用神经网络处理图像呢?图像本质上是一个像素网格,每个像素有数值(例如,黑白图像是0-255的灰度值,彩色图像是红、绿、蓝三个通道的值)。我们可以将每个像素值作为神经网络的输入。
但这种方法有缺点:
- 输入维度巨大:大图像意味着极多的输入和需要计算的权重,计算成本高。
- 丢失空间结构信息:将图像扁平化为像素列表,忽略了相邻像素之间重要的空间关系(如形状、轮廓)。
为了更好处理图像,我们需要利用图像本身的结构化特性。接下来介绍两个关键概念:图像卷积和池化。
图像卷积
图像卷积是关于过滤图像,以提取有用或相关特征(如边缘、纹理)的方法。我们通过应用一个特定的卷积核(或滤波器)来实现。
工作原理:
- 定义一个卷积核(例如一个3x3的矩阵)。
- 将卷积核覆盖在图像的某个区域(如第一个3x3区域)上。
- 将覆盖区域的每个像素值与卷积核对应位置的值相乘,然后将所有乘积相加,得到一个输出值。
- 将卷积核在图像上滑动(通常每次移动一个像素),重复步骤3,最终生成一个新的图像,称为特征图。


示例:一个著名的边缘检测卷积核是:
-1 -1 -1
-1 8 -1
-1 -1 -1
这个核能突出像素值与其周围像素差异大的区域(即边缘),而均匀区域输出值接近0。通过应用此类过滤器,我们可以从图像中提取出轮廓和边界等关键特征,这些特征对于后续的图像识别任务非常有用。
池化
池化(如最大池化)的目的是通过下采样来减小图像的尺寸,从而减少计算量并增加一定程度的平移不变性。
最大池化工作原理:
- 将图像划分为不重叠的区域(例如2x2的区域)。
- 对每个区域,取其中所有像素值的最大值作为该区域的代表值。
- 用这些代表值组成一个新的、更小的图像。
优势:
- 降低维度:减少了输入到神经网络的数据量。
- 增强鲁棒性:网络不再过分关心某个特征精确出现在哪个像素,只要它出现在某个局部区域内即可,这使得算法对微小位移更加健壮。
本节课中我们一起学习了神经网络的核心训练算法——反向传播,了解了深度网络的概念以及过拟合的应对方法 Dropout。我们实践了如何使用 TensorFlow 库高效地构建和训练神经网络模型。最后,我们将视野拓展到计算机视觉,学习了图像卷积和池化这两个预处理图像、提取关键特征的基础操作,为神经网络处理图像数据奠定了基础。
哈佛 CS50-AI 19:L5- 神经网络 3 (卷积神经网络,循环神经网络) 🧠


在本节课中,我们将要学习两种功能强大的神经网络结构:卷积神经网络(CNN)和循环神经网络(RNN)。我们将了解它们如何工作,以及它们为何在处理图像和序列数据时特别有效。
🖼️ 卷积神经网络(CNN)
上一节我们介绍了神经网络的基础概念,本节中我们来看看如何将神经网络应用于图像分析。卷积神经网络是一种专门用于处理具有网格状拓扑结构数据(如图像)的神经网络。
核心概念:卷积与池化

卷积神经网络的工作原理是,从输入图像(一个像素网格)开始,首先应用一个卷积步骤。
卷积步骤涉及将一些不同的图像滤波器(或称为核)应用于原始图像,以得到我们称之为特征图的结果。每个特征图可能从图像中提取出一些不同的相关特征。
我们可以训练神经网络学习这些滤波器的值,以便从原始图像中提取出最有用的信息。其目标是找出能最小化损失函数的滤波器值设置。
卷积后得到的特征图通常尺寸较大,包含很多像素值。因此,接下来的逻辑步骤是池化。
池化步骤通过使用最大池化等方法减少这些图像的大小。最大池化从特定区域提取最大值。也可以使用平均池化,取一个区域的平均值。
池化会降低特征图的维度,最终我们得到更小的网格。这使处理更容易,意味着输入更少,并且对像素值的微小变化更具鲁棒性。
CNN的一般结构
在我们完成池化步骤后,我们拥有一堆值。然后我们可以将这些值展平,并放入一个更传统的神经网络中。
以下是卷积网络的一般结构:
- 从图像开始。
- 应用卷积。
- 应用池化。
- 展平结果。
- 将其放入一个更传统的神经网络(可能包含隐藏层)。
这种结构帮助我们利用对图像结构的先验知识,以获得更好的结果,并能更快地训练网络,以更好地捕捉图像的特定部分。
深度卷积网络
在实践中,没有理由只使用这些步骤一次。你可以在多个不同的步骤中多次使用卷积和池化。
首先从图像开始,应用卷积以获得一堆特征图。然后应用池化。接着可以再次应用卷积来尝试提取更高级的特征,然后再对这些结果应用池化以降低维度,最后将其输入到一个神经网络中。

这种模型的目标是,在每个步骤中,你可以开始学习原始图像的不同类型特征。在第一步中你学习非常低级的特征,比如边缘、曲线和形状。一旦你有了表示这些低级特征的特征图,你可以再次应用相同的过程来开始寻找更高层次的特征,比如物体的部件或更复杂的形状。
CNN的优势与应用
卷积神经网络在这类模型中非常强大且受欢迎,尤其在分析图像时。它们模拟了人类看图像的方式,不是同时查看每一个像素,而是查看图像的不同区域并提取相关信息和特征。
你可以想象将其应用于手写识别的情境。
✍️ 手写识别实例
现在我们就来看看一个手写识别的例子。我们将使用著名的MNIST数据集,它包含大量手写数字样本。

数据准备
首先需要将图像数据转换为可以输入到卷积神经网络中的格式。这包括将所有像素值(0-255之间)除以255,把它们转换为0到1的范围,这可能更容易训练。
构建CNN模型
以下是一个使用TensorFlow/Keras构建的简单CNN模型结构示例:
model = tf.keras.models.Sequential([
# 第一层:卷积层,学习32个3x3的滤波器
tf.keras.layers.Conv2D(32, (3, 3), activation='relu', input_shape=(28, 28, 1)),
# 第二层:最大池化层,使用2x2的池化大小
tf.keras.layers.MaxPooling2D((2, 2)),
# 将二维特征图展平为一维向量
tf.keras.layers.Flatten(),
# 添加一个具有128个单元的隐藏层
tf.keras.layers.Dense(128, activation='relu'),
# 添加Dropout层以防止过拟合,训练时随机丢弃一半节点
tf.keras.layers.Dropout(0.5),
# 输出层:10个单元,对应数字0-9,使用softmax激活函数
tf.keras.layers.Dense(10, activation='softmax')
])
在这个模型中:
- 输入形状是
(28, 28, 1),因为MNIST图像是28x28像素的灰度图(单通道)。 - 卷积层学习多个滤波器来提取特征。
- 池化层减少数据维度。
- Dropout层在训练期间随机“关闭”一部分神经元,有助于防止模型过拟合。
- 输出层使用softmax激活函数,将输出转换为概率分布,表示图像属于每个数字类别的概率。
训练与评估
编译模型后,可以在训练数据上进行拟合。训练过程涉及通过反向传播和梯度下降来调整网络权重和滤波器的值。
训练完成后,可以将模型保存到文件中,以便后续直接使用已学习好的模型进行预测,而无需重新训练。

🔁 循环神经网络(RNN)
上一节我们介绍了处理空间数据(如图像)的CNN,本节中我们来看看如何处理序列数据(如文本、时间序列)。循环神经网络是一种用于处理序列数据的神经网络。
前馈神经网络的限制

传统的前馈神经网络(Feedforward Neural Network)中,连接仅在一个方向上,从输入层经过隐藏层到输出层。这种结构有其限制,特别是输入和输出需要有固定的大小(固定数量的神经元)。
这对于处理可变长度的序列(如句子、视频帧)构成了挑战。
RNN的核心思想
循环神经网络的关键思想是,网络生成的输出可以反馈到自身,作为下一次计算的输入的一部分。这使得网络能够维持一种“状态”,存储一些可以在未来使用的信息。
在处理数据序列时,这特别有用。网络不仅基于当前输入,还基于它从之前步骤中“记住”的信息来产生输出。
RNN的结构类型
循环神经网络可以用于多种输入-输出关系:
- 一对一:标准的前馈神经网络,单一输入,单一输出。
- 一对多:单一输入,序列输出。例如,图像描述生成:输入一张图片,输出描述该图片的一句话。
- 策略:网络接收图像输入,生成第一个词;然后将这个词作为输入反馈给网络,生成第二个词;如此循环,直到生成完整的句子。
- 多对一:序列输入,单一输出。例如,情感分析:输入一个句子(单词序列),输出该句子的情感是正面还是负面。
- 多对多:序列输入,序列输出。例如,机器翻译:输入一种语言的句子,输出另一种语言的句子。
- 策略:网络先编码整个输入序列的信息,然后基于这个编码状态,逐步解码生成输出序列。
RNN的应用
循环神经网络在处理序列时非常强大,特别是在自然语言处理领域:
- 语音识别:将音频波形序列转换为文本。
- 机器翻译:如Google Translate使用的技术。
- 视频分析:将视频帧序列分类或生成描述。
一种特别流行且强大的RNN变体是长短期记忆网络(LSTM),它能够更好地学习长期依赖关系。
📝 总结
本节课中我们一起学习了两种高级的神经网络结构:
- 卷积神经网络(CNN):通过卷积和池化操作,高效处理图像等网格数据,自动学习层次化特征(从边缘到物体部件)。
- 循环神经网络(RNN):通过将输出反馈为输入,赋予网络“记忆”能力,非常适合处理文本、语音、时间序列等序列数据,实现了一对多、多对一和多对多的复杂映射。

这些工具是机器学习中非常强大的组成部分,能够基于输入数据学习复杂的函数映射,广泛应用于计算机视觉和自然语言处理等领域。在接下来的课程中,我们将更深入地探讨人工智能在自然语言理解方面的应用。
哈佛 CS50-AI 20:L6- 自然语言处理 1 (语言,语法与语义,nltk,n-grams) 🗣️💻





概述
在本节课中,我们将要学习人工智能的一个重要领域:自然语言处理。我们将探讨如何让AI理解人类语言的结构和含义,并介绍一些基础工具和概念,如语法、语义、NLTK库以及n-grams模型。
语言与AI的挑战

在课程中,我们已经看到与AI交互的多种方式,但大部分是我们构建的方式。我们以AI能够理解的方式解决问题,学习用AI的语言来表达,尝试将一个问题转化为搜索问题,或将其转化为约束满足问题。
今天的目标是,想出算法和理念,允许我们的AI与我们达成某种共识,能够理解、解释并获取人类语言中的某种含义。
口语中的语言类型,如英语或其他我们自然使用的语言,这对AI来说是一个非常具有挑战性的任务,确实涵盖了多种类型。

所有任务都在自然语言处理的广泛范畴下,提出算法使我们的AI能够处理和理解自然语言。这些任务在我们希望AI执行的任务类型和我们可能使用的算法类型上各不相同。


自然语言处理的任务

以下是自然语言处理领域的一些常见任务:

- 自动摘要:你给AI一个长文档,希望AI能够总结出来,形成同一思想的简短表达,但仍然使用某种自然语言,如英语。
- 信息提取:在给定大量信息的文档或互联网内容时,我们希望我们的AI能够提取一些有意义的语义信息。
- 语言识别:任务是识别某一页面所用的语言。确定文档所写的语言类型。
- 机器翻译:即将一种语言的文本翻译成另一种语言。
- 命名实体识别:给定一段文本,你能识别出命名实体吗?这些实体通常是公司名称、人名或地点名。
- 语音识别:不是处理书面文本,而是处理口语文本,能够处理音频并确定所说的实际单词。
- 文本分类:每当我们想将某种文本放入某种类别时,我们就会看到这些分类问题。
- 词义消歧:单词在意义上则有些模糊。一个词可以有多重不同的含义。自然语言本质上是模糊的,一个具有挑战性的任务是能够消歧或区分不同的含义。


语言的句法与语义

当我们考虑语言时,通常可以从几个不同的部分进行思考。
句法:语言的结构

第一部分涉及语言的句法,这更多的是与语言的结构有关。这个结构是如何工作的。如果你考虑自然语言,句法就是这样一种东西。

例如,句子“九点钟,福尔摩斯快步走进房间。”是一个结构良好的语法句子。从句法上讲,它在这个特定句子的结构方面是有意义的。

句法不仅适用于自然语言,也适用于编程语言。在任何自然语言中句子或句子可以进行相同类型的判断。我可以说这个句子在句法上是结构良好的,当所有组成这些词的部分按照这个顺序组合时,它构造了一个语法正确的句子。
但也有语法上不良构造的句子,比如“九点钟福尔摩斯快步走进房间。”从句法上讲它没有意义。
句法也可能是模棱两可的。有些句子结构良好,但你可以对一个句子进行不同结构的多种构造。比如,“我在山上用望远镜看到了那个人。”这个句子可能有两种不同的理解结构。
语义:语言的含义

除了语言有结构之外,语言还有意义,现在我们进入了语义的范畴。语义是指一个单词、一组单词、一个句子或整篇文章实际上意味着什么。
例如,“在九点钟前,福尔摩斯精神抖擞地走进了房间”与“福尔摩斯精神抖擞地走进了房间在九点钟前”有相同的意义,但单词顺序不同。

语法上正确的句子也可能根本没有任何意义。一个著名的例子是语言学家诺姆·乔姆斯基的句子“无色的绿色思想疯狂地睡觉”,这个句子在语法上正确但没有实际意义。
语义本身也可能是模棱两可的。相同结构的句子可能最终意味着不同类型的东西。例如,标题“大卡车运送水果,在210号高速公路上发生碰撞,造成堵塞”可能有多种不同的含义。
形式语法与上下文无关文法

我们将通过引入形式语法的概念来开始。形式语法是一种生成语言中句子的规则系统。它描述哪些句子结构是有效的。

形式语法的定义有许多不同类型,其中一种被称为上下文无关文法。上下文无关文法是通过重写规则生成语言中的句子的一种方法。


重写规则示例

假设一个简单的英语句子,比如“她看到了城市”。我们希望AI能够查看这个句子并弄清楚句子的结构。
我们定义一些符号:
- 终结符:语言中的实际单词,如
she,saw,the,city。 - 非终结符:代表词类或短语类别的符号,如
N(名词),V(动词),D(限定词),NP(名词短语),VP(动词短语),S(句子)。


重写规则的形式是:左侧非终结符 -> 右侧(可由终结符或非终结符组成)。

以下是一些示例规则:

词类规则:
N -> she | city | car | Harry(名词可以是“她”、“城市”、“车”、“哈利”等)D -> the | a | an(限定词可以是“the”、“a”、“an”)V -> saw | walked(动词可以是“看到”、“走”)


短语结构规则:
NP -> N | D N(名词短语可以是一个名词,或者一个限定词后跟一个名词)VP -> V | V NP(动词短语可以是一个动词,或者一个动词后跟一个名词短语)S -> NP VP(句子由一个名词短语后跟一个动词短语组成)


构建语法树
利用这些规则,我们可以为句子“她看到了城市”构建一个语法树(解析树):
S
/ \
NP VP
| / \
N V NP
| | / \
she saw D N
| |
the city
这个树状结构展示了句子各部分如何根据规则组合在一起。


使用NLTK进行语法解析
我们可以通过使用一个名为NLTK (Natural Language Toolkit) 的Python库来与这些概念交互。NLTK提供了多种功能和类来处理自然语言。
其中一个功能是解析上下文无关文法,能够取一些单词并根据某个上下文无关文法来分析它们。

代码示例

以下是一个使用NLTK定义简单文法并解析句子的示例:

import nltk


# 定义一个简单的上下文无关文法
grammar = nltk.CFG.fromstring("""
S -> NP VP
NP -> N | D N
VP -> V | V NP
D -> 'the' | 'a'
N -> 'she' | 'city' | 'car'
V -> 'saw' | 'walked'
""")


# 创建解析器
parser = nltk.ChartParser(grammar)
# 要解析的句子
sentence = "she saw the city".split()

# 解析并打印所有可能的语法树
for tree in parser.parse(sentence):
print(tree)
tree.pretty_print() # 可视化语法树

运行此代码将为句子“she saw the city”生成我们之前看到的语法树结构。如果我们输入一个不合语法的句子,解析器将无法生成任何树。

通过定义更复杂的规则(例如添加形容词、介词短语),我们可以让文法处理更丰富的句子结构,并展示自然语言中存在的结构歧义(例如“她看到了带望远镜的狗”)。




N-grams模型
上下文无关文法关注结构,但我们还关心单词序列在实际中出现的可能性。例如,“我吃了一个香蕉”在语法上正确且合理,而“我吃了一辆蓝色的车”虽然语法正确,但不太可能被说出。
为了处理这种“可能性”,我们引入 n-gram 模型。

什么是N-gram?

N-gram指的是文本中连续的n个项目(item)的序列。项目可以是字符或单词。
- unigram (1-gram): 单个项目。例如,单词
“the”,“cat”。 - bigram (2-gram): 两个连续项目的序列。例如,单词序列
“the cat”,“cat sat”。 - trigram (3-gram): 三个连续项目的序列。例如,单词序列
“the cat sat”。
通过分析大量文本语料库,我们可以统计不同n-gram出现的频率。常见的n-gram序列比不常见的序列更有可能出现在自然语言中。
标记化
为了提取n-gram,我们首先需要将文本拆分成基本的单元(如单词),这个过程称为标记化。

- 单词标记化: 将文本拆分成单词。最简单的方法是按空格分割,但需要处理标点符号(如“city.”和“city”是否算作同一个词)、连字符、缩写等复杂情况。
- 句子标记化: 将长文本拆分成句子。通常依据句号、问号、感叹号等,但也需处理“Mr.”中的句点等特殊情况。

使用NLTK提取N-grams


NLTK同样提供了方便的工具来进行标记化和n-gram提取。
import nltk
from collections import Counter
from nltk.util import ngrams
# 示例文本
text = “I have never seen such a thing. It is remarkable.”
# 单词标记化 (简单按空格分割,实际应用需更复杂处理)
tokens = text.split()
# 提取二元组 (bigrams)
bigrams = list(ngrams(tokens, 2))
print(“Bigrams:”, bigrams)
# 计算并打印最常见的二元组
bigram_freq = Counter(bigrams)
print(“Most common bigrams:”, bigram_freq.most_common(3))
在实际应用中,我们会在一个大型语料库(如多本书籍、文章集合)上计算n-gram频率,从而获得对语言使用模式的统计认识。
总结
本节课中我们一起学习了自然语言处理的基础知识。


我们首先了解了让AI理解人类语言所面临的挑战以及NLP的各种任务。接着,我们区分了语言的句法(结构)和语义(含义),并认识到自然语言中普遍存在的歧义性。

为了形式化地描述句法,我们引入了上下文无关文法的概念,它通过重写规则定义合法的句子结构,并可以使用NLTK库在Python中进行解析和可视化。
最后,为了捕捉语言中的统计规律和常见表达,我们学习了n-gram模型。n-gram是连续的词序列,通过分析它们在大型文本中的频率,AI可以学习到哪些词组合更常见、更自然。提取n-gram的前提是标记化,即将文本拆分成单词或句子等基本单元。
本节课的内容为理解和处理自然语言打下了坚实的基础。在接下来的课程中,我们将基于这些概念,探索更高级的语言模型和任务。
哈佛 CS50-AI 21:L6- 自然语言处理 2 (马尔可夫,词袋,朴素贝叶斯,信息检索,tf-idf) 📚



在本节课中,我们将要学习自然语言处理中的几个核心模型与技术,包括马尔可夫模型、词袋模型、朴素贝叶斯分类器、信息检索以及TF-IDF算法。我们将探讨如何利用这些技术让AI生成文本、分析情感并从文档中提取关键信息。

马尔可夫模型与文本生成 🤖

上一节我们介绍了n-gram模型,它用于分析单词序列的频率。本节中我们来看看如何利用这些数据来预测和生成文本。


这个特定的语料库,那么这里的潜在使用案例是什么?现在我们有一些数据,我们有关于特定单词序列出现的频率,按特定顺序排列,并利用这些数据,我们可以开始做一些预测。我们可能会说,如果你看到这些单词,它有合理的机会,后面跟着的单词应该是单词“a”。如果我看到单词“one of”,可以合理地想象下一个单词可能是单词“the”,例如,因为我们有关于三元组序列的数据,以及它们出现的频率。现在基于两个单词,你可能会能够预测第三个单词是什么。

而我们可以用来实现这一点的模型是我们之前见过的模型,它是马尔可夫模型。再次回想,马尔可夫模型实际上只指某种事件序列。发生在一个时间步之后,每个单位都有某种能力来预测下一个单位会是什么,或者可能是过去两个单位预测下一个单位会是什么,或者过去三个单位预测下一个单位会是什么。我们可以使用马尔可夫模型并将其应用于语言。
一种非常幼稚且简单的方法来尝试生成自然语言,让我们的AI能够像英语文本一样说话,它的工作方式是,我们将说一些内容,比如在给定这两个单词的情况下,得到一些概率分布,这个概率分布是什么?根据所有的数据,第三个单词可能是什么,如果你看到它,可能的第三个单词有哪些,它们出现的频率如何。利用这些信息,我们可以尝试构建我们期望的第三个单词是什么。如果你不断这样做,效果就是我们的马尔可夫模型可以有效地开始生成文本并能够生成不在原始语料库中的文本,但听起来有点像原始语料库,使用相同的规则。

那么我们也来看看一个例子。我们现在在这里,我还有另一个语料库,这是我手上的语料库:威廉·莎士比亚的所有作品,所以我有一整堆故事。来自莎士比亚,所有的故事都在这个大的文本文件中。因此,我想做的是看看,所有的语言图式,也许看看莎士比亚文本中的所有三元组,然后弄清楚给定两个单词的情况下,我能预测第三个单词可能是什么,然后继续。重复这个过程,我有两个单词,预测第三个单词,然后从第二和第三个单词预测第四个单词,从第三和第四个单词预测第五个单词,最终生成随机句子。听起来像莎士比亚的句子,使用莎士比亚所使用的相似单词模式,但实际上从未在莎士比亚中出现过。
为了做到这一点,我将展示 generator.py,这将从特定文件读取数据。我使用的一个Python库叫做 Markovify,将为我完成这个过程,所以这里有一些库,可以训练一堆文本,并基于该文本生成马尔可夫模型。我将继续并且生成五个随机生成的句子,所以我们接下来将深入探讨。马尔可夫,我将对莎士比亚的文本运行生成器,我们看到的是它会加载这些数据,然后这是我们得到的五个不同的句子,这些是句子在任何地方都没有出现在莎士比亚的戏剧中,但设计成听起来像莎士比亚,旨在仅仅取两个单词,并且预测给定这两个单词莎士比亚可能会选择的第三个单词,跟随他,你知道这些句子可能没有任何意义,不是说人工智能尝试表达任何潜在的含义,这只是试图理解基于单词的顺序,接下来可能会出现什么。作为下一个单词,例如,这些是它能够生成的句子类型。如果你多次运行这个,你最终会得到不同的结果,我可能再次运行这个,然后得到一个完全不同的一组五个不同句子也应该是听起来有点像莎士比亚的句子声音一样。

因此,这就是我们如何使用马尔可夫模型,简单地尝试生成语言,语言目前并没有太多意义。你不想在这个当前形式下使用系统来做像机器翻译这样的事情,因为它无法封装任何意义,但我们现在开始看到我们的人工智能逐渐变得更好,尝试说我们的语言或以某种方式处理自然语言,具有一定的意义。
文本分类与词袋模型 📦
上一节我们介绍了如何使用马尔可夫模型生成文本。本节中我们来看看如何让AI对文本进行分类,例如判断一段评论是正面还是负面。
因此我们现在将看一下几项其他任务,我们可能希望我们的人工智能能够执行的任务之一是文本分类,这实际上就是一种分类问题,我们已经讨论过分类问题。这些问题是,我们希望将某个对象分类到多个不同类别。这种文本的表现方式是,无论何时你有一些文本样本,并且想把它归入某个类别,比如说,给定一封邮件,它是否属于收件箱,还是属于垃圾邮件?这两个类别中它属于哪个,你是通过查看文本来实现的。能够对这些文本进行某种分析,以得出结论,比如说根据出现在邮件中的词汇,我认为这可能属于收件箱,或者我认为它可能属于垃圾邮件,你可能会想象为多种不同类型的这种分类问题。
你可能想象另一个常见的例子是情感分析,我想分析给定的文本样本,是否有正面情感,还是有负面情感,这可能出现在例如网站上的产品评论,或是你有的反馈!一堆由网站用户提供的数据样本,你想能够快速分析这些评论是正面的还是负面的,人们在说什么,以便了解他们在说什么,以便将文本分类为这两种不同的类别。
所以怎么我们可以如何处理这个问题呢?让我们看看一些示例产品评论,这里有一些可能出现的产品评论:“我孙子非常喜欢这个,有趣的产品”,“几天后坏了”,这是我很久以来玩过的“最好的”游戏,“有点廉价和脆弱,不值得买”。你可能在亚马逊或易贝或其他某些人们销售产品的网站上看到的不同产品评论,我们人类可以相对容易地将其分类为正面情感或负面情感。我们可能会说第一条和第三条是正面情感的信息,第二和第四条可能是负面情感的信息,但我们如何尝试评估这些评论呢?你知道它们是正面还是负面,这最终取决于这些特定评论中的词汇。
在这些特定句子中,现在我们将忽略结构,以及词汇之间的关系,我们只关注词汇本身,所以这里可能有一些关键词,例如“喜欢”、“有趣”和“最好”,这些词可能在更多的正面评论中出现。而像“破碎”、“廉价”和“脆弱”这样的词,可能更容易出现在负面评论中,而非正面评论。因此,一种处理这种文本分析的方法是,暂时忽略这些句子的结构,也就是说我们不关心的是单词之间的关系,我们不会尝试解析这些句子以构建它们的语法结构,就像我们刚才看到的那样,但我们可能只依赖于实际使用的单词,依赖于积极评价更有可能拥有“最好”、“喜爱”和“有趣”等单词,负面评论更可能包含我们在这种模型中突出显示的负面词汇。
这种思考语言的方法通常被称为词袋模型,我们将对其进行建模。文本样本,不关心它的结构,但仅仅关注样本中出现的无序单词集合,我们关心的只是文本中的单词,而不关心这些单词的顺序,也不关心单词的结构,我们不在意什么名词与什么形容词搭配,事物之间如何相互关联,我们只关心单词,结果证明这种方法在进行分类时,比如积极情感或消极情感,效果相当不错。
朴素贝叶斯分类器 🧮

上一节我们介绍了词袋模型,它忽略文本结构,只关注单词本身。本节中我们来看看如何利用朴素贝叶斯算法,基于词袋模型对文本进行概率分类。
你可以想象用我们讨论过的多种方式来实现分类样式的问题,但在自然语言中,最流行的方法之一是朴素贝叶斯方法,这是分析某事物是否是积极情感或消极情感的一种方法,或者只是试图将一些文本进行分类可能的类别,它不仅适用于文本,也适用于其他类型的概念,但在分析文本和自然语言的领域中相当流行。

朴素贝叶斯方法基于贝叶斯规则,你可能还记得我们讨论概率时提到的贝叶斯规则。规则看起来是这样的,给定某事件A的事件B的概率可以用这个表达式来表示,给定A的B的概率等于给定B的A的概率乘以B的概率除以A的概率,我们看到这只是因为条件独立性的定义,以及两个事件一起发生的意义,这就是我们的贝叶斯规则的公式,结果证明它非常有用,我们能够通过翻转这些事件的顺序在这个概率计算中预测一个事件。这种方法将非常有帮助,我们稍后会看到原因。
它能够进行情感分析,因为我想说,消息是积极的概率是多少,或消息是消极的概率是多少,我会简化这个使用表情符号只是为了简单,比如积极的概率、消极的概率,这就是我想计算的,但我想在给定一些信息的情况下计算,比如这里是一个文本样本,我的孙子喜欢它,我想知道的不仅仅是什么任何消息是积极的概率是什么,但在给定我的孙子喜欢它作为样本文本的情况下,消息是积极的概率是什么?那么,在给定这个信息,即样本中包含单词“我的孙子喜欢它”的情况下,这个是积极消息的概率又是多少呢?
根据词袋模型,我们将真正忽略单词的顺序,而不是将其视为有某种结构的单个句子,而是将其视为一堆不同的单词,我们将要说的是,这个是积极的概率是多少?给定单词“我的”在消息中的情况下,给定单词“孙子”在消息中的情况下,给定单词“喜欢”在消息中的情况下,以及给定单词在消息中的情况下,词袋模型在这里我们将整个样本视为一堆不同的单词。这就是我想计算的概率,给定这些单词,这个是积极消息的概率是多少。
现在我们可以应用贝叶斯定理,这实际上是某个事件给定某个事件的概率,这正是我想要的。根据贝叶斯定理,这整个表达式等于……好吧,是我交换了它们的顺序,是所有这些单词在它是积极消息的情况下的概率,乘以它是积极消息的概率,除以所有单词的概率。所以这只是贝叶斯定理的一个应用,我们已经看到我想要将给定单词的积极概率表示为与积极消息的单词概率相关,结果是你可能会记得我们讨论过的关于概率,这个分母无论我们看积极还是消极消息都是相同的,这些单词的概率并没有变化,因为我们下面没有积极或消极的东西,所以我们可以说,rather than just say that this expression up 这里等于下面这个表达式,它实际上只是与分子成比例,我们可以暂时忽略分母,使用分母会得到一个确切的概率,但实际上我们要做的就是弄清楚概率与什么成比例。最后,我们必须归一化概率分布,确保概率分布最终的总和为一。



所以现在我已经能够形成这个概率,这是我关心的,与这两件事相乘成比例,即单词的概率给定正面消息,乘以正面消息的概率,但再次如果你回想我们的概率规则,我们实际上可以将其计算为所有这些事情发生的联合概率,即正面消息的概率乘以这些概率给定正面消息的词,实际上就是这些事情的联合概率。这与它是正面消息的概率,以及 my 在句子或消息中,grandson 在样本中,love 在样本中,以及 it 在样本中是一样的。所以,利用这个规则来定义联合概率我能够说,这整个表达式现在是与这序列成比例的,这些词的联合概率以及其中的正面内容。

所以,现在有趣的问题就是如何计算这个联合概率,我如何弄清楚概率给定某个任意消息,它是正面的,并且其中包含单词 my,单词 grandson,单词 loved 和单词 it。你会记得,我们可以通过将所有这些条件概率相乘来计算联合概率。我想知道 A、B 和 C 的概率,我可以将其计算为 A 的概率乘以给定 A 的 B 的概率乘以给定 A 和 B 的 C 的概率。我可以将这些条件概率相乘,以获得我关心的总体联合概率。

我可以在这里做同样的事情,我可以说,让我们将正面的概率与单词 my 在消息中出现的概率相乘,前提是它是正面的,乘以给定单词 my 在那里且它是正面的情况下,grandson 出现在消息中的概率。乘以给定这三样东西的 loved 的概率,乘以给定这四样东西的 it 的概率,而这将是一个相当复杂的计算,我们可能没有好的方法去知道答案,比如,孙子出现的概率是多少在消息中,前提是它是正面的,且单词 my 在消息中。这并不是我们会有一个容易回答的事情,这就是朴素贝叶斯的朴素之处。我们将简化这个概念,而不是精确计算这个概率分布。
我们假设这些词在已知是积极信息的情况下彼此独立。如果这是一个积极的信息,那么“grandson”在消息中出现的概率并不会因为我知道“loved”不是消息而改变。在实际情况中,这可能并不一定成立,现实世界中这些词可能并不独立,但我们假设它们独立,以简化我们的模型。事实证明,这种简化仍然让我们获得相当不错的结果,所以我们要做的假设是所有这些词出现的概率仅仅取决于消息是积极还是消极。我仍然可以说,“loved”在积极信息中出现的可能性高于在消极信息中出现的可能性,这可能是对的,但我们也会说这不会改变“loved”出现的可能性如果我知道“my”这个词出现在消息中,那么它出现的可能性不会因为这是一个积极的信息而变得更可能或不太可能。这些是我们要做的假设,所以虽然上面的表达式与下面的表达式成正比,我们将简单地说它与这个表达式的概率成正比。积极的信息,然后对于样本中出现的每个单词,我将乘以在已知这是积极的情况下,给定的不是消息的概率,乘以在已知这是积极的情况下,“grandson”出现在消息中的概率,然后依此类推,对其他出现的单词进行同样的处理。这些数据会包含在样本中,结果是这些数字我们可以计算。

我们之所以做这些数学运算,是为了能够计算我们关心的概率分布,基于这些我们实际上可以计算的项。基于我们可用的一些数据,这就是如今许多自然语言处理的内容,它涉及分析数据。如果我给你一堆标记为积极或消极的评论数据,那么你就可以开始计算这些特定的项。我可以仅通过查看我的数据来计算一条消息是积极的概率,看看有多少个正样本,然后将其除以总样本数,这就是我认为一条消息是积极的概率,以及“loved”这个词出现在消息中的概率这肯定是积极的,我可以根据我的数据来计算。让我看看样本中有多少个包含“love”这个词的正样本,并将其除以我的正样本总数,这将给我一个关于“love”在评论中出现的概率的近似值。鉴于我们知道评论是正面的,因此这使我们能够计算这些概率。
那么我们不妨进行这项计算,计算“我孙子喜欢它”这句话,是正面还是负面的评论。我们如何能够得出这些概率呢?再次上面的数据是我们要计算的表达式,以及在这种情况下可用的数据。解读这些数据的方式是,在所有消息中,49%的消息是正面的,51%的消息是负面的,或许在线评论往往会稍微偏向负面。他们是正面的,至少基于这个特定的数据样本。这就是我所拥有的,然后我有各种不同词的分布,假设这是一个正面消息,那么有多少正面消息包含“我”这个词呢?你知道,大约是30%,而对于负面消息。消息中有多少条包含“我”的词,大约是20%。所以似乎“我”这个词在正面消息中出现得更频繁,至少在这个分析中稍微多一些。以“孙子”为例,可能在1%的所有正面消息中出现,而在2%的所有负面消息中出现。“孙子”这个词出现在32%的所有正面消息中,8%的所有负面消息中,例如,“它”这个词在30%的正面消息中出现,而在40%的负面消息中再次出现,这里是一些任意的数据,仅供参考,但现在我们有了可以开始计算的数据。


那么这个表达式,我该如何计算呢?将所有这些值相乘。这实际上是正面概率乘以我所给定的正面概率,再乘以孙子在正面消息中的概率,依此类推,针对其他每个单词,如果你这样做的话。将所有这些值相乘,你会得到这个0.00014112,单独看这个数字并没有什么特别的意义,但如果你将这个表达式与我知道它是正面的概率相乘,再乘以给定的所有词的概率。消息是正面的,并且将其与负面情感消息进行比较。我想知道它是负面消息的概率,乘以给定的所有这些词的概率,得出这是一个负面消息的概率。那么我该如何做到这一点呢?为了做到这一点,你只需将负面概率与所有这些条件概率相乘。如果我将这五个值相乘,那么我得到的值是负面0.00006528,再次强调,这个数值在孤立状态下并没有特别的意义,真正有意义的是处理这两个值。作为一种概率分布,并且,通过归一化它们,使得这两个值的总和为1,这就是概率分布应有的方式。我们通过将这两个值相加,然后将每个值除以它们的总和来实现归一化——以便能够做到这一点。


当归一化这个概率分布时,你最终得到的结果大概是这样的:正面0.6837,负面0.3163。这似乎让我们能够得出结论,我们对这个消息的正面概率大约有68%的信心。“我孙子喜欢这个”,为什么我们有68%的信心呢?似乎我们比不更有信心,因为“喜欢”这个词在32%的正面消息中出现,但在8%的负面消息中仅出现,因此这是一个相当强的指标。而对于其他词来说,确实像是这个词出现在负面消息中更常出现,无法抵消那种爱在积极消息中远远更常出现,因此这种类型的分析就是我们如何应用朴素贝叶斯,我们刚刚进行了这个计算,最终不仅得到了正面或负面的分类。但我获得了一种信心,比如我认为它是正面的概率是什么。我可以说,我认为它是正面的概率是这样,因此朴素贝叶斯在尝试实现这一点时可以非常强大,只需使用这个词袋模型。通过查看样本中出现的单词,我能够得出这些结论。



现在一个潜在的缺点是,如果你开始严格按照这个规则应用,你会很快注意到的事情是,数据中如果包含零的话会发生什么,假设例如这个情况。相同的句子“我孙子喜欢这个”,但我们假设这里的值不是0.01,而是在我们的数据集中,从未发生过在正面消息中出现“孙子”这个词,这确实是可能的,如果我有一个相对较小的数据集可能并不一定所有消息都会包含“孙子”这个词,也许在我的数据集中没有任何正面消息包含“孙子”这个词,但如果有2%的负面消息仍然包含“孙子”这个词,那我们就会遇到一个问题。有趣的挑战在于,当我将所有正数相乘并将所有负数相乘以计算这两种概率时,最终得到的是一个值为零的正值。我得到的是纯零,因为当我将所有的当我将某个数乘以零时,无论其他数字是什么,结果都会是零,负数也是如此,因此这似乎是一个问题,因为“孙子”从未出现在任何正面消息中。在我们的符号内部,我们似乎得出的结论是,信息是正面的概率为零,因此它必须是负面的,因为我们看到“孙子”这个词的唯一情况是在负面消息中。正面消息更可能包含“爱”这个词,因为我们乘以零,这意味着其他概率完全无关紧要,因此这是我们需要面对的挑战,这意味着我们可能不会每个值在我们的分布中,以便稍微平滑数据。如果我们纯粹使用这种方法,就能够获得正确的结果,正因为如此,有很多方法可以确保我们不会将某个东西乘以零,将某个东西乘以一个小数字是可以的,因为它可以被其他更大的数字抵消。但将数字乘以零似乎意味着故事结束了,你将一个数字乘以零,输出将是零,无论其他任何数字有多大,因此,在朴素贝叶斯中,一个相对常见的方法是这种加法平滑的想法,给其他概率加上一个值α。一种这样的方式称为拉普拉斯平滑。这基本上意味着对我们分布中的每个值加一,所以如果我有100个样本,且其中0个包含“孙子”这个词,我可能会说,不如假设我看到了一个额外的样本,其中出现了“孙子”这个词。“孙子”没有出现,所以我会说,现在我有102个样本中有1个样本包含“孙子”这个词,我基本上创造了两个之前不存在的样本,但通过这样做,我已经能够稍微平滑分布,以确保我从未乘以数字。通过假设每个类别中都有一个额外的值,我实际上没有的,这让我们得出了一个
哈佛 CS50-AI 22:L6- 自然语言处理 3 (信息抽取,词网,word2vec) 🧠


在本节课中,我们将要学习自然语言处理中更高级的主题。我们将探讨如何从文本中自动提取结构化信息,了解如何通过词网(WordNet)建立词语间的语义关系,并深入理解一种强大的词向量表示方法——word2vec。这些技术是让计算机真正“理解”语言含义的关键。
信息抽取 📊

上一节我们讨论了句法和语义分析,本节中我们来看看如何从非结构化的文本中自动提取有用的知识,这个过程称为信息抽取。
信息抽取就是从文档中提取知识,其目标是让AI能够自动化地查看大量文本,并找出其中有用的相关信息。
让我们看一个例子。以下是两篇新闻文章的片段,一篇关于Facebook,另一篇关于亚马逊。

Facebook was founded in 2004.
Amazon was founded in 1994.

我们可能希望AI能够从这些文本中提取出“公司”和“成立年份”这样的信息。具体来说,我们想知道Facebook成立于2004年,亚马逊成立于1994年。

那么,如何从文本中自动提取这些信息呢?一种方法是寻找文本中反复出现的固定模式或模板。



我们会注意到这两句话有一个共同的模式:“[公司名] was founded in [年份].”。这个模板为我们提供了一种提取信息的机制:当匹配到“____ was founded in ____”这种模式时,我们可以合理地推断,第一个空白处是公司名称,第二个空白处是该公司成立的年份。

以下是实现信息抽取的基本思路:
- 为AI提供一些预定义的模板。
- 让AI在文档语料库中搜索匹配这些模板的句子。
- 从匹配的句子中提取出模板中空白处对应的实体信息。


然而,这种方法需要我们手动编写模板,并且不同的网站表达同一信息的方式可能不同。如果词语顺序稍有变化,固定的模板就可能无法匹配。


我们可以采用一种更自动化的方法:不是给AI模板,而是给AI一些已知的实体关系对。
例如,我们给AI提供数据对:(Amazon, 1994) 和 (Facebook, 2004)。然后,AI会在海量文本中自行寻找同时包含这两个实体的句子,并从中发现连接它们的常见表达方式(如“was founded in”)。AI可以利用这些新发现的模式,去语料库中寻找并提取其他类似的(公司, 成立年份)关系对。


这种方法本质上是一种自动化的模板生成,在实践中非常强大。通过提供少量种子数据,AI可以学习到通用的信息抽取模式。
词网(WordNet)与语义关联 🔗
信息抽取依赖于具体的文本模式,但如果我们想更深入地理解词语本身的含义及其相互关系,就需要借助语义知识库。
一个著名的语义知识库是词网(WordNet)。词网是一个由研究人员共同编纂的大型词汇数据库,它定义了词语的各种含义(因为一个词可能有多个意思),并建立了词语之间的语义关系。
在词网中,词语根据其含义被组织成同义词集合(synsets)。例如,“城市”这个词可能关联到“市政区域”、“居住地”等不同的概念类别。通过词网,我们可以查询一个词的定义、同义词、上位词(如“房子”是一种“建筑”)和下位词等关系。

词网的优势在于它能将词语与其它相关词语系统地连接起来,为理解词义提供了结构化的知识。然而,它的构建和维护需要大量人工,难以覆盖语言的所有变化和新出现的词汇关系。



词的向量表示:从独热编码到Word2Vec 📐
为了能让计算机更好地处理词语的含义并将其用于机器学习模型(如神经网络),我们需要一种数值化的表示方法。
最初级的方法是独热编码(One-Hot Encoding)。假设我们的词典只有四个词:[他, 写, 一, 书]。那么:
- “他”可以表示为
[1, 0, 0, 0] - “写”可以表示为
[0, 1, 0, 0] - “书”可以表示为
[0, 0, 0, 1]

这种表示方法有两个主要缺点:
- 维度灾难:如果词典有5万个词,每个词向量就是5万维,其中只有一个是1,其余全是0,非常稀疏且低效。
- 无法表达语义相似性:在这种表示下,“写”和“创作”、“书”和“小说”的向量彼此正交,没有任何相似性,尽管它们的含义是相近的。

我们真正需要的是分布式表示(Distributed Representation),即用一个相对较短(如50维或100维)、每个维度都有实数值的向量来表示一个词。理想情况下,语义相近的词,其向量在空间中的位置也应当接近。

那么,如何得到这样的向量呢?一个核心思想来自语言学家J.R. Firth的名言:“你可以通过一个词的结伴词来了解它”。换言之,一个词的意义由其上下文(周围经常共同出现的词)决定。
Word2Vec 正是基于这一思想的著名算法。它通过训练一个神经网络模型来学习词向量。一种常见的Word2Vec模型架构是 Skip-gram。
Skip-gram模型的目标是:给定一个中心词(如“午餐”),预测它周围特定窗口内可能出现的上下文词(如“吃”、“早餐”、“丰盛的”)。在训练过程中,模型会调整每个词的向量表示,使得在相似上下文中出现的词(如“早餐”、“晚餐”)具有相似的向量。
训练完成后,每个词都被映射为一个固定长度的稠密向量。这些向量编码了丰富的语义信息。


Word2Vec向量的魔力 ✨



词向量的强大之处不仅在于它能找到语义相似的词(例如,“书”的相近词可能是“小说”、“回忆录”),更在于我们可以对这些向量进行数学运算,从而发现词之间的类比关系。

一个经典的例子是:国王 - 男人 + 女人 ≈ 女王
用向量运算表示就是:vector(“国王”) - vector(“男人”) + vector(“女人”) 的结果向量,最接近 vector(“女王”)。


这说明了词向量空间中的方向可以编码词语间的特定关系(如“性别”关系)。我们可以进行更多类比推理:
巴黎 - 法国 + 英格兰 ≈ 伦敦(首都与国家的关系)教师 - 学校 + 医院 ≈ 护士(职业与工作场所的关系)拉面 - 日本 + 美国 ≈ 卷饼(特色食品与国家的关系)
这些类比能力并非预先编程,而是模型从海量文本数据中自动学习到的统计规律,展现了分布式词向量在捕捉复杂语义关系方面的巨大潜力。

总结 📝
本节课中我们一起学习了自然语言处理中三个核心进阶主题。


首先,我们探讨了信息抽取,它使AI能够从非结构化文本中自动提取结构化知识,无论是通过预定义模板还是通过自动化模式学习。


接着,我们介绍了词网(WordNet),这是一个人工构建的语义知识库,帮助我们理解词语的定义和它们之间的层级关系。

最后,我们深入学习了词向量,特别是Word2Vec模型。我们了解到,将词语表示为稠密的数值向量(而非稀疏的独热编码)是自然语言处理的关键突破。这些向量不仅紧凑高效,更能通过向量空间中的距离和方向来编码丰富的语义信息,甚至支持有趣的类比推理,为机器理解语言含义奠定了坚实基础。
从搜索、优化、不确定性处理,到神经网络和如今的语义理解,我们见证了AI如何一步步学习处理越来越复杂和抽象的任务。自然语言处理仍然是AI研究中充满活力与挑战的领域,希望本次课程为你打开了探索这扇大门。
哈佛 CS50-CS 1:L0- 计算机科学基础知识 🧠















在本节课中,我们将要学习计算机科学的基础知识,包括信息如何表示、算法如何工作,以及如何通过编程来解决问题。我们将从最根本的二进制系统开始,逐步探索计算机如何理解数字、文字、图像乃至视频,并最终动手使用图形化编程工具Scratch来实践这些概念。

概述:什么是计算机科学? 🤔
计算机科学的核心是解决问题。你有一个输入(即待解决的问题),你希望得到一个输出(即问题的解决方案)。在输入和输出之间,是一个“黑盒子”,里面发生着某种“魔法”。这门课程的目标,就是揭开这个黑盒子的秘密,学习如何驾驭它,让计算机为我们解决问题。

这个黑盒子里的“魔法”,最终体现为我们共同认可的代码。为了表示输入和输出,我们必须使用一种共同的语言。那么,我们通常如何表示信息呢?

信息的表示:从手指到比特 🖐️
人类最直观的表示信息方式是使用手指计数。这是一种一元记数法,但它的效率很低。我们更常用的是十进制数字系统,即基数为10的系统,使用0到9这十个数字。
然而,计算机并不使用十进制。计算机的“语言”要简单得多,它只使用两个数字:0和1。这个系统被称为二进制。




为什么是二进制?



计算机的物理基础是电力。一个简单的开关,通电代表“开”(1),断电代表“关”(0)。计算机内部有数百万个这样的微型开关(称为晶体管),通过控制它们的开合状态来存储和处理信息。
二进制中的一个数字(0或1)被称为一个比特(bit)。比特是计算机信息的最小单位。
用比特计数

如果我们只有一个灯泡(或一个比特),我们只能表示0(关)或1(开)。那么,如何用比特表示更大的数字呢?答案是使用多个比特,并赋予它们不同的“位值”。

这与十进制系统类似。在十进制数字123中:
- 最右边的“3”代表
3 * 10^0 = 3 - 中间的“2”代表
2 * 10^1 = 20 - 左边的“1”代表
1 * 10^2 = 100 - 总和是
100 + 20 + 3 = 123

在二进制中,我们使用2的幂次方作为位值。例如,用三个比特 1 1 0 表示数字:
- 最右边的“0”代表
0 * 2^0 = 0 - 中间的“1”代表
1 * 2^1 = 2 - 左边的“1”代表
1 * 2^2 = 4 - 总和是
4 + 2 + 0 = 6


因此,三个比特可以有 2^3 = 8 种不同的组合(从 000 到 111),可以表示从0到7的八个数字。

超越数字:表示字母与符号 🔤
上一节我们介绍了如何用比特表示数字。但如果计算机只能处理数字,它如何表示字母、标点符号,甚至表情符号呢?
解决方案是建立映射。人类约定一个标准,将特定的数字模式对应到特定的字符。例如,一个早期广泛使用的标准是 ASCII(美国信息交换标准代码)。它规定:
- 十进制数字 65 对应大写字母 A
- 十进制数字 66 对应大写字母 B
- 以此类推。
当计算机在文本处理程序(如短信、邮件)的上下文中遇到比特模式 01000001(即十进制的65)时,它就知道应该在屏幕上显示字母“A”。

一个字节(byte)通常由8个比特组成。8个比特可以表示 2^8 = 256 种不同的模式。这对于基本的英文字母、数字和标点符号来说足够了,但对于中文、阿拉伯文等包含成千上万个字符的语言,以及各种各样的表情符号来说,是远远不够的。

因此,更强大的 Unicode 标准被创建出来。它使用更多的比特(如16位、24位)来表示字符,从而能够涵盖世界上几乎所有的文字系统和符号。例如,“😂”这个表情符号在Unicode中对应的十进制数字是 128514。
表示更丰富的信息:颜色、图像与视频 🎨
我们已经知道如何用数字表示字符。那么,计算机如何表示颜色,进而表示图像呢?
一种常见的方法是 RGB 模型。任何颜色都可以通过混合不同强度的红色(Red)、绿色(Green)和蓝色(Blue)光来得到。在计算机中,我们通常用一个字节(8比特) 来表示一种颜色的强度,范围从0(无强度)到255(最大强度)。



因此,一个颜色点可以用三个字节表示,例如 (72, 73, 33) 代表一种特定的黄色。屏幕上的图像,就是由成千上万个这样的彩色小点组成的,这些小点被称为像素。
理解了图像,视频就变得简单了。视频本质上是一系列快速连续显示的图像。当这些图像以足够快的速度(例如每秒24或30帧)播放时,我们就会感觉到连续的运动。这就是动画和视频的原理。
算法:解决问题的步骤 🧮

到目前为止,我们讨论了计算机如何表示各种信息(输入和输出)。现在,让我们看看黑盒子内部的核心:算法。

算法是解决特定问题的一系列清晰、精确的指令。它甚至不一定要涉及计算机。例如,按照菜谱做菜,就是在执行一个算法。
让我们以“在电话簿中找人”为例,比较几种不同的算法:
- 算法一(逐页查找):从第一页开始,一页一页地翻,直到找到名字。
- 正确性:正确。
- 效率:很低。如果电话簿有n页,在最坏情况下需要n步。
- 算法二(每次翻两页):从第一页开始,每次翻两页,如果翻过了,就回翻一页检查。
- 正确性:正确(修正了可能跳过的问题后)。
- 效率:有所提升,大约需要n/2步。但效率提升的模式与算法一相同。
- 算法三(二分查找):打开电话簿的中间一页。根据名字的字母顺序,决定是向前半部分还是后半部分继续查找。然后在剩下的一半中,重复这个过程,直到找到名字。
- 正确性:正确。
- 效率:非常高。每一步都将问题规模减半。所需步骤约为
log₂(n)。对于1000页的电话簿,最多只需要约10步。
这个例子说明了算法的两个关键属性:正确性和效率。优秀的算法不仅要求结果正确,还应尽可能高效地利用资源(如时间)。
编程基础概念:从伪代码到Scratch 💻


算法可以用日常语言描述(伪代码),但最终需要翻译成计算机能理解的语言。所有编程语言都共享一些基本概念:


- 函数:执行特定任务的指令块(如“翻到中间页”、“查看名字”)。
- 条件(分支):根据问题的答案(是/否,真/假)决定执行哪条路径(如“如果名字在这一页,就打电话”;“否则,如果名字在前面,就查找左半部分”)。
- 布尔表达式:产生真或假答案的问题(如“名字在这一页上吗?”)。
- 循环:重复执行某段指令的能力(如“回到第三步”)。
- 变量:存储信息的容器(如存储当前查找的页码)。

为了直观地理解这些概念,我们将使用 Scratch——一款由麻省理工学院开发的图形化编程语言。在Scratch中,你可以像拼积木一样,通过拖拽代码块来创建程序。
Scratch 实践
以下是一个简单的Scratch程序示例,它展示了函数、循环和变量的使用:
- 当点击绿色旗帜时,程序开始。
- 询问用户的名字并等待输入。
- 将问候语“Hello, ”和用户输入的名字连接起来。
- 让角色说出连接后的句子。


通过组合不同的代码块(如运动、外观、声音、控制等),你可以创建出交互式故事、游戏和动画。编程的关键在于从小处着手,逐步构建。先实现一个简单的功能,测试成功后再添加新的功能。
总结 🎯
本节课中,我们一起学习了计算机科学的基石:
- 信息表示:计算机使用二进制(0和1)表示一切。通过约定好的映射(如ASCII、Unicode、RGB),比特可以表示数字、文本、颜色、图像和视频。
- 算法与效率:算法是解决问题的步骤。我们不仅关注算法的正确性,还追求其效率,例如使用“二分查找”比“线性查找”快得多。
- 编程基础:我们了解了函数、条件、循环和变量等核心编程概念,并通过Scratch进行了实践,看到了如何将这些概念组合起来创建有趣的程序。


计算机科学不仅仅是关于编程,它更是一种解决问题的思维方式。无论你未来从事什么领域,这种将复杂问题分解、抽象并设计高效解决方案的能力,都是极其宝贵的。
哈佛 CS50-CS 2:C语言语法与格式入门 🖥️




在本节课中,我们将学习C语言的基础语法与格式,并将其与上周学习的Scratch图形化编程进行对比。我们将从编写第一个“Hello, World”程序开始,逐步了解C语言的核心概念、开发工具以及如何编译和运行代码。

概述



我们将从Scratch过渡到C语言,理解文本编程的基本范式。虽然语法看起来不同,但核心的编程概念(如函数、条件、循环)是相通的。本节课的重点是熟悉C语言的编写环境、基本语法结构以及编译运行流程。

从Scratch到C语言

上一节我们介绍了编程的基本概念,本节中我们来看看如何将这些概念从图形化的Scratch转换到文本化的C语言。
在Scratch中,我们通过拖拽积木块来构建程序。而在C语言中,我们需要使用键盘,严格按照语法规则来编写文本代码。尽管形式不同,但两者都包含函数、条件表达式和循环等核心元素。


例如,在Scratch中让角色“说”出“hello, world”的积木块,在C语言中对应的代码是:
printf("hello, world\n");
这里的 printf 是一个函数,其作用是向屏幕输出文本。\n 是一个特殊符号,代表换行。
编写第一个C程序
为了开始编写C程序,我们需要一个编程环境。本课程使用CS50 IDE,这是一个基于网络的集成开发环境。
以下是创建并运行第一个“Hello, World”程序的步骤:



- 创建新文件:在CS50 IDE中,创建一个新文件并保存为
hello.c。.c扩展名是C源文件的惯例。 - 编写代码:在文件中输入以下代码:
#include <stdio.h> int main(void) { printf("hello, world\n"); } - 编译程序:计算机无法直接理解C代码(源代码),需要将其转换为机器能理解的0和1(机器代码)。这个过程称为编译。在终端窗口中输入命令
make hello。make是一个工具,它会调用编译器将hello.c编译成名为hello的可执行文件。 - 运行程序:编译成功后,在终端输入
./hello来运行程序。你会看到输出hello, world。
核心概念详解

上一节我们运行了第一个程序,本节中我们来深入理解代码中的各个部分。

函数与参数
函数是执行特定任务的代码块。在C语言中,printf 是一个用于输出文本的函数。
- 参数:是传递给函数的输入值。例如,在
printf("hello, world\n")中,字符串"hello, world\n"就是传递给printf函数的参数。 - 返回值:一些函数在执行后会返回一个值。例如,用于获取用户输入的函数
get_string,它会将用户输入的文本作为字符串返回。 - 副作用:函数除了返回值,还可能产生其他效果,如在屏幕上显示文字(
printf的副作用)或播放声音。

变量与赋值
变量用于存储数据。在C语言中,使用变量前必须声明其类型。
- 声明变量:
string answer;这行代码声明了一个名为answer、类型为string(字符串)的变量。 - 变量赋值:使用单等号
=进行赋值,其含义是将右边的值“赋予”左边的变量。例如:
这行代码先执行右边的string answer = get_string("What's your name? ");get_string函数获取用户输入,然后将得到的字符串存储到左边的answer变量中。

格式化输出
printf 函数支持格式化输出,允许我们将变量的值插入到输出文本中。
- 占位符:
%s是一个占位符,表示此处将插入一个字符串。 - 提供变量:在格式字符串后,用逗号分隔列出要插入的变量。
这行代码会输出 “hello, ” 后接printf("hello, %s\n", answer);answer变量中存储的名字。


重要的语法规则
以下是编写C程序时必须遵守的一些基本语法规则:

- 分号
;:在C语言中,几乎每一句完整的代码(语句)都必须以分号结尾,就像英文句子以句号结尾一样。 - 头文件
#include:要使用像printf或get_string这样的函数,需要在程序开头包含相应的头文件。例如:#include <stdio.h>提供了printf函数。#include <cs50.h>提供了get_string等CS50库函数。
main函数:每个C程序都必须有一个main函数,它是程序执行的起点。其基本结构是:int main(void) { // 你的代码写在这里 }- 注释:使用
//可以添加单行注释,用于解释代码,编译器会忽略它们。良好的注释能提高代码的可读性。// 问候用户 printf("hello, world\n");

开发工具与技巧
学习编程时,利用好工具可以事半功倍。CS50 IDE 提供了一些教育工具来帮助你。

帮助工具:help50
当你编译代码出错,看到令人困惑的错误信息时,可以使用 help50。在终端中,在出错的命令前加上 help50,它会尝试将晦涩的错误信息翻译成更易懂的建议。
help50 make hello




风格检查工具:style50
代码不仅要正确,还应具有良好的风格(如缩进、空行),使其易于阅读。style50 可以检查你的代码风格并提供改进建议。
style50 hello.c
它会用绿色高亮显示建议修改的地方,例如在哪里添加缩进或空行。
正确性检查工具:check50
在完成作业或实验时,可以使用 check50 来检查你的代码是否符合题目要求。它会运行一系列测试来验证你代码的正确性。具体命令会在题目说明中给出。
check50 cs50/problems/hello




文件管理命令
在终端中,你不仅可以编译运行代码,还可以管理文件和文件夹(目录)。以下是一些常用命令:
ls:列出当前目录下的文件和文件夹。cd 目录名:进入指定的目录。cd ..:返回上一级目录。mkdir 目录名:创建一个新目录。mv 旧文件名 新文件名:移动或重命名文件。rm 文件名:删除文件。rmdir 目录名:删除空目录。


总结


本节课中我们一起学习了C语言编程的入门知识。我们了解了如何从Scratch过渡到C,编写并运行了第一个“Hello, World”程序。我们探讨了C语言的核心概念,包括函数、参数、变量、赋值和格式化输出。同时,我们熟悉了CS50 IDE开发环境,并掌握了 help50、style50、check50 等实用工具来帮助编写、调试和美化代码。记住,编程初期遇到语法错误是正常的,善用工具并保持耐心是关键。
哈佛 CS50-CS 3:C语言(语法与格式)2 🖥️
在本节课中,我们将深入学习C语言的核心语法与格式,包括数据类型、运算符、条件语句、循环以及函数。我们将把上一节课中Scratch的图形化概念转化为C语言的文本代码,并理解计算机在表示数字时的基本限制。
📊 数据类型与变量

上一节我们介绍了C语言的基本结构,本节中我们来看看如何存储不同类型的数据。在C语言中,变量不仅可以存储文本字符串,还可以存储多种其他类型的数据,这些被称为数据类型。


以下是C语言中一些最常见的数据类型:
int:用于存储整数,例如3或-10。float:用于存储浮点数(带小数点的实数),例如3.14159。double:类似于float,但精度更高。char:用于存储单个字符,例如'A'或'?'。long:用于存储更大的整数。string:用于存储文本字符串(来自CS50库)。

为了从用户获取不同类型的数据,CS50库提供了相应的函数:

get_string:获取字符串。get_char:获取单个字符。get_int:获取整数。get_float:获取浮点数。get_long:获取长整数。

🔢 数据类型的大小与限制

这些数据类型(如 int 和 float)在计算机内存中只占用有限数量的位(bits)。例如,一个 int 通常使用 32位,这意味着它最多能表示大约 40亿 个不同的值(包括正数和负数)。如果你需要存储更大的数字,就需要使用 long(64位)这样的数据类型。

这种限制会导致一些问题,例如著名的 Y2K问题(用两位数字表示年份)和即将到来的 2038年问题(32位时间戳溢出)。
📝 printf 的格式代码
在使用 printf 打印变量时,你必须使用正确的格式代码来告诉函数如何解释你传入的数据。
以下是常用的格式代码:
%c:打印单个字符 (char)。%f:打印浮点数 (float或double)。%i或%d:打印整数 (int)。%li:打印长整数 (long)。%s:打印字符串 (string)。

例如,要打印一个浮点数并显示10位小数,可以使用 %.10f。


➕ 运算符

C语言支持多种运算符来进行数学和逻辑运算。
以下是基本的数学运算符:

+:加法-:减法*:乘法/:除法%:取模(求余数)
🧮 实践:创建一个加法计算器
让我们运用以上知识,创建一个简单的加法计算器程序。
#include <cs50.h>
#include <stdio.h>
int main(void)
{
// 从用户获取第一个整数
int x = get_int("x: ");
// 从用户获取第二个整数
int y = get_int("y: ");
// 计算并打印结果
printf("Sum: %i\n", x + y);
}

程序说明:
- 使用
get_int获取用户输入的两个整数,并分别存储在变量x和y中。 - 使用
printf和%i格式代码打印x + y的结果。

⚠️ 整数溢出与类型转换


如果你尝试将两个很大的整数(例如20亿)相加,结果可能超出 int 的表示范围,导致整数溢出,得到一个错误的值。这时应使用 long 类型。
在进行除法时,如果操作数都是整数,C语言会进行整数除法,结果会被截断(丢弃小数部分)。为了得到浮点数结果,你需要将至少一个操作数转换为浮点类型。
错误的整数除法:
int x = 1;
int y = 2;
float z = x / y; // z 将是 0.000000,因为 1/2 的整数结果是 0
正确的浮点数除法(使用类型转换):
int x = 1;
int y = 2;
float z = (float) x / (float) y; // z 将是 0.500000
(float) 是一种类型转换,它告诉编译器将 x 和 y 当作浮点数来处理。

🔁 条件语句 (if, else if, else)
条件语句允许程序根据不同的情况做出决策。这与Scratch中的“如果...那么”积木块相对应。
语法:
if (条件)
{
// 如果条件为真,执行这里的代码
}
else if (另一个条件)
{
// 如果第一个条件为假,但此条件为真,执行这里的代码
}
else
{
// 如果以上条件都为假,执行这里的代码
}
重要区别:
- 赋值使用单个等号
=。 - 相等比较使用两个等号
==。
示例:比较两个数的大小
if (x < y)
{
printf("x is less than y\n");
}
else if (x > y)
{
printf("x is greater than y\n");
}
else
{
printf("x is equal to y\n");
}

🔂 循环 (while, for)
循环用于重复执行一段代码。


while 循环:当条件为真时,重复执行。
int i = 0;
while (i < 3)
{
printf("meow\n");
i++; // i++ 是 i = i + 1 的简写
}

for 循环:更简洁的循环写法,将初始化、条件和更新写在一行。
for (int i = 0; i < 3; i++)
{
printf("meow\n");
}
这个 for 循环等同于上面的 while 循环。

do...while 循环:先执行一次循环体,再检查条件。适用于至少需要执行一次的情况。
int n;
do
{
n = get_int("Positive integer: ");
}
while (n < 1);
🧱 实践:打印砖块图案

我们可以使用嵌套循环(一个循环 inside 另一个循环)来打印网格图案,比如超级马里奥中的砖块。

for (int i = 0; i < 3; i++) // 外层循环控制行数
{
for (int j = 0; j < 3; j++) // 内层循环控制每行的砖块数
{
printf("#");
}
printf("\n"); // 每打印完一行后换行
}
输出:
###
###
###


📦 函数与抽象


函数允许我们将代码块封装起来并命名,以便重复使用,这实现了抽象——隐藏复杂细节。
定义函数:
// 函数原型声明,告诉编译器这个函数稍后会定义
void meow(void);
int main(void)
{
meow(); // 调用函数
}
// 函数定义
void meow(void)
{
printf("meow\n");
}

带参数的函数:
void meow(int n) // 函数接受一个整数参数 n
{
for (int i = 0; i < n; i++)
{
printf("meow\n");
}
}
int main(void)
{
meow(3); // 调用函数,并传入参数 3
}

带返回值的函数:
int get_positive_int(void)
{
int n;
do
{
n = get_int("Positive Integer: ");
}
while (n < 1);
return n; // 返回获取到的正整数
}
int main(void)
{
int i = get_positive_int(); // 调用函数,并将返回值赋给 i
printf("%i\n", i);
}
⚡ 语法糖
语法糖是指一种让代码写起来更简洁、更易读的语法,它并不提供新功能,只是更方便。
counter = counter + 1;可以简写为counter += 1;- 进一步简写为
counter++;(递增1) - 同理,
counter--;用于递减1。
🧠 变量的作用域
变量的作用域指的是变量在代码中可以被访问的区域。通常,在花括号 {} 内声明的变量只在该花括号内有效。
int main(void)
{
int n = 5; // 变量 n 在此函数内有效
if (n > 0)
{
int m = 10; // 变量 m 只在这个 if 语句的花括号内有效
printf("%i\n", m);
}
// printf("%i\n", m); // 错误!m 在这里不再存在
}

🎯 总结
本节课中我们一起学习了C语言的核心语法构件。我们从Scratch的图形化编程过渡到了C语言的文本编程,掌握了如何声明和使用不同的数据类型,利用运算符进行计算,使用条件语句让程序做决策,以及通过循环重复执行任务。我们还学会了如何创建自己的函数来实现代码抽象和复用。
同时,我们也认识到了计算机的基本限制:由于使用有限数量的位来表示数据,无论是整数还是浮点数,都存在精度限制和表示范围限制。理解这些限制对于编写正确、健壮的程序至关重要。

现在,你已经具备了用C语言解决更复杂问题的基础知识,可以开始着手你的第一个问题集了!
哈佛 CS50-CS 4:字符串、数组与调试方法 🧵



在本节课中,我们将更深入地探讨C语言,并回顾上周的内容,以便更好地理解C语言的特性以及让代码运行所需的步骤。我们将逐层揭开上周的抽象概念,以便更清楚地了解计算机内部的实际运作。

🔍 编译过程详解



上一节我们介绍了如何编写和运行一个简单的“Hello, World”程序。本节中,我们来看看编译这个程序时,计算机究竟做了哪些工作。


当我们运行 make hello 命令时,它实际上自动化了一系列更具体的步骤。这些步骤是将人类可读的源代码(如 hello.c)转换为计算机可执行的机器代码(零和一)的过程。

以下是编译过程的四个主要步骤:



- 预处理
- 预处理器会处理所有以
#开头的指令(如#include)。 - 它会找到指定的头文件(如
cs50.h,stdio.h),并将其内容“复制粘贴”到你的源代码中。 - 这确保了编译器知道你所使用的函数(如
get_string,printf)的存在。
- 预处理器会处理所有以


- 编译
- 编译器将预处理后的C语言源代码转换为另一种称为汇编语言的低级语言。
- 汇编语言更接近计算机硬件能理解的语言。

- 汇编
- 汇编器将汇编代码转换为最终的机器代码,即纯粹的零和一序列。
- 此时,你的程序已经变成了计算机可以直接执行的格式。




- 链接
- 链接器将你的程序机器代码与所用库(如
cs50库、C标准库)的机器代码合并在一起。 - 例如,
get_string和printf函数的实际实现代码就在这些库中。链接确保了你的程序在运行时能找到它们。
- 链接器将你的程序机器代码与所用库(如

当我们使用 clang 编译器手动编译一个使用了 cs50 库的程序时,需要明确链接该库:
clang -o hello hello.c -lcs50
而 make 工具则为我们自动处理了这些繁琐的步骤。


🐛 代码调试技巧



在编程过程中,遇到错误(Bug)是常态。本节我们将学习几种强大的调试方法,帮助你更高效地定位和修复代码中的问题。




1. 使用 printf 进行调试
printf 是最简单直接的调试工具。通过在代码中临时插入打印语句,可以观察程序执行到某处时变量的值。


例如,在一个循环中打印计数器的值:
for (int i = 0; i <= 10; i++)
{
printf("i is now: %i\n", i); // 调试输出
printf("#\n");
}
这能帮助你确认循环是否按预期执行。


2. 使用 debug50 图形化调试器
debug50 是一个基于行业标准工具 GDB 构建的图形化调试器,功能更强大。



使用步骤如下:
- 编译你的程序:
make program_name - 在代码编辑器中,点击你想暂停执行的行号左侧,设置一个断点(红色圆点)。
- 在终端运行:
debug50 ./program_name - 右侧会打开调试面板,你可以:
- 逐步执行代码。
- 在“变量”区域实时查看所有变量的值。
- 使用 Step Into 进入函数内部调试,使用 Step Over 执行当前行而不进入函数。


3. “橡皮鸭调试法”
这是一种非常有效的心理调试技巧。当你遇到难题时,尝试向一个没有生命的物体(比如一只橡皮鸭)清晰地解释你的代码逻辑。在组织语言的过程中,你常常能自己发现逻辑上的漏洞。



💾 内存、数据类型与变量设计


了解了程序如何运行后,我们来看看数据在计算机中是如何存储的。


计算机内存(RAM)
程序运行时,其代码和数据都存储在随机存取存储器中。你可以将 RAM 想象成一个巨大的网格,每个格子可以存储一个字节(8 位)的数据。

C 语言基本数据类型及其大小
在 CS50 IDE 中,常见数据类型占用的内存大小如下:
bool: 1 字节(存储真/假值)char: 1 字节(存储单个字符)int: 4 字节(存储整数)float: 4 字节(存储单精度浮点数)double: 8 字节(存储双精度浮点数)long: 8 字节(存储更大的整数)string: 大小可变(存储文本)
代码设计:从“正确”到“良好”
除了让代码运行正确,我们还应追求良好的设计。考虑以下计算三个分数平均值的代码:

int score1 = 72;
int score2 = 73;
int score3 = 33;
printf("Average: %f\n", (score1 + score2 + score3) / 3.0);
这段代码虽然功能正确,但设计上可以改进:
- 变量命名:
score1,score2,score3的命名方式在分数增多时会显得笨拙。 - 硬编码:分数值直接写在代码里,不灵活。更好的方式是让用户输入,或从文件读取。
- 扩展性:添加第四个分数需要修改多处代码。


良好的设计会使代码更易读、易维护和易扩展。
本节课中,我们一起学习了C程序编译的完整过程(预处理、编译、汇编、链接),掌握了使用 printf 和 debug50 进行调试的技巧,并理解了数据在内存中的存储方式以及编写良好设计代码的重要性。这些基础概念和工具将为后续更复杂的编程任务打下坚实的基础。
哈佛 CS50-CS 5:L2- 字符串、数组、调试方法 2 🧩

在本节课中,我们将要学习C语言中的数组概念,理解字符串在内存中的本质,并探索如何利用数组和字符串来编写更高效、更强大的程序。我们还将学习如何通过命令行参数向程序传递数据,以及如何利用现有的库函数来简化我们的代码。
概述 📋
数组是存储在内存中的连续值序列。它允许我们将多个相关的值(例如一系列分数)组织在一起,并使用统一的名称和索引来访问它们。字符串本质上就是字符数组,但有一个特殊的终止符 \0 来标记其结束。理解这些底层概念,是编写高效、无错误程序的关键。


从独立变量到数组 🔄

上一节我们介绍了使用多个独立变量存储数据的局限性。本节中我们来看看如何使用数组来更优雅地解决这个问题。

如果你在Scratch中使用过列表,那么C语言中的数组在概念上与之非常相似。数组是存储在内存中的连续值序列。
例如,与其声明 score1, score2, score3 等多个变量,不如声明一个名为 scores 的数组来一次性存储所有分数。
以下是声明一个包含三个整数的数组的语法:
int scores[3];
这行代码声明了一个名为 scores 的数组,它可以容纳三个整数。

现在,我们可以通过索引来访问或修改数组中的元素:
scores[0] = 72; // 第一个元素
scores[1] = 73; // 第二个元素
scores[2] = 33; // 第三个元素
需要注意的是,数组是零索引的,这意味着第一个元素的下标是0,第二个是1,依此类推。从零开始计数是编程中的一种约定,可以避免浪费内存空间。

使用循环优化数组操作 🔁



虽然使用数组已经比多个独立变量更简洁,但通过循环我们可以进一步优化代码,使其更具动态性和可维护性。


以下是使用 for 循环为数组赋值和计算的示例:
const int TOTAL = 3; // 定义一个常量
int scores[TOTAL];



// 使用循环获取用户输入
for (int i = 0; i < TOTAL; i++) {
scores[i] = get_int("Score: ");
}


// 计算总分
int sum = 0;
for (int i = 0; i < TOTAL; i++) {
sum += scores[i];
}
我们使用 const int 声明了一个常量 TOTAL。常量一旦被赋值就不能再改变,这有助于防止意外修改并提高代码可读性。常量的命名惯例是使用全大写字母。
字符串的本质:字符数组 🔤
字符串是编程中处理文本的基础。在C语言中,字符串实际上就是字符数组。
一个字符串,例如 "hi!",在内存中存储为连续的字符序列:'h', 'i', '!',并在末尾自动添加一个特殊的空字符 \0 作为字符串的结束标志。因此,字符串 "hi!" 实际上占用4个字节的内存。




我们可以像访问数组一样访问字符串中的单个字符:
string s = "hi!";
printf("%c\n", s[0]); // 输出: h
printf("%c\n", s[1]); // 输出: i
printf("%c\n", s[2]); // 输出: !
printf("%i\n", s[3]); // 输出: 0 (即 \0 的整数值)

遍历字符串与 strlen 函数 📏




为了处理字符串,我们经常需要遍历其中的每一个字符。我们可以利用字符串以 \0 结尾的特性来设计循环。


以下是遍历字符串并打印每个字符的一种方法:
string s = get_string("Input: ");
for (int i = 0; s[i] != '\0'; i++) {
printf("%c", s[i]);
}
printf("\n");
循环会一直执行,直到遇到 \0 字符为止。


然而,更常见的做法是使用 string.h 库中的 strlen 函数来获取字符串长度。但需要注意一个效率问题:


低效做法(不推荐):
for (int i = 0; i < strlen(s); i++) { // strlen(s) 在每次循环时都被调用
printf("%c", s[i]);
}
strlen 函数需要遍历整个字符串来计算长度。在循环条件中反复调用它,会导致程序进行大量不必要的重复计算。


高效做法(推荐):
int n = strlen(s); // 只计算一次长度并存储
for (int i = 0; i < n; i++) {
printf("%c", s[i]);
}
或者,在 for 循环中直接初始化长度变量:
for (int i = 0, n = strlen(s); i < n; i++) {
printf("%c", s[i]);
}


利用库函数简化操作:ctype.h 📚
C语言提供了丰富的标准库,我们可以直接使用他人编写好的、经过测试的函数,而无需重复造轮子。ctype.h 库包含了许多用于字符处理的函数。

例如,要将用户输入的字符串全部转换为大写,我们可以自己实现逻辑:
string s = get_string("Input: ");
for (int i = 0, n = strlen(s); i < n; i++) {
if (s[i] >= 'a' && s[i] <= 'z') {
printf("%c", s[i] - 32); // ASCII码差值
} else {
printf("%c", s[i]);
}
}
printf("\n");
但使用 ctype.h 库中的函数,代码会更简洁、更易读:
#include <ctype.h>
...
string s = get_string("Input: ");
for (int i = 0, n = strlen(s); i < n; i++) {
printf("%c", toupper(s[i]));
}
printf("\n");
toupper 函数会自动处理字符是否已经是大写的情况,使代码更加健壮。

命令行参数 🖥️

到目前为止,我们一直使用 get_string 等函数在程序运行时获取用户输入。另一种常见的方式是通过命令行参数在启动程序时直接传递数据。


main 函数可以接受两个参数来接收命令行输入:
int main(int argc, string argv[])
argc(argument count):一个整数,表示命令行中输入参数的数量(包括程序名本身)。argv(argument vector):一个字符串数组,存储了命令行中输入的所有参数。
例如,编写一个程序,要求用户在运行程序时输入自己的名字:
#include <stdio.h>
#include <cs50.h>

int main(int argc, string argv[])
{
if (argc == 2) {
printf("hello, %s\n", argv[1]); // argv[0] 是程序名,argv[1] 是第一个参数
} else {
printf("hello, world\n");
}
}
编译后,运行方式如下:
$ ./greet
hello, world


$ ./greet David
hello, David
退出状态码 🚦

main 函数返回一个整数,这个值称为退出状态码。按照惯例,返回 0 表示程序成功执行,返回非零值(通常是 1)表示程序执行过程中出现了错误。


操作系统和其他程序可以通过这个状态码来判断你的程序运行是否正常。例如,在命令行中,可以使用 echo $? 来查看上一个执行命令的退出状态码。


#include <stdio.h>
#include <cs50.h>


int main(int argc, string argv[])
{
if (argc != 2) {
printf("Missing command-line argument\n");
return 1; // 返回错误码
}
printf("hello, %s\n", argv[1]);
return 0; // 返回成功码
}



应用展望:可读性与密码学 🔐



掌握了数组和字符串的操作后,我们可以解决许多实际问题。
- 文本可读性分析:通过分析文本中句子的平均长度、单词长度等特征,可以量化其阅读难度等级(例如,判断是7年级还是大学阅读水平)。
- 密码学:加密的核心是操作文本(字符串)。最简单的加密算法之一是凯撒密码,即将每个字母在字母表中移动固定的位置(例如,密钥为1时,
a变成b,b变成c)。利用我们对字符ASCII码和数组操作的知识,完全可以实现这样的加密解密程序。

总结 🎯
本节课中我们一起学习了C语言中至关重要的两个概念:数组和字符串。
我们了解到数组是存储连续同类型数据的高效方式,并通过索引访问元素。字符串本质上是字符数组,以空字符 \0 结尾。我们学习了如何遍历数组和字符串,并强调了使用 strlen 时避免低效循环的重要性。

我们还探索了如何利用现有的库函数(如 ctype.h 中的函数)来简化代码,如何通过命令行参数向程序传递输入,以及如何使用退出状态码来表明程序的执行结果。




这些构建块为我们处理更复杂的数据、编写更实用的程序(如接下来的密码学应用)奠定了坚实的基础。记住,好的程序设计在于选择正确的工具并高效地使用它们。
哈佛 CS50-CS 6:L3- 算法(结构体、搜索与排序)1



概述
在本节课中,我们将要学习算法的核心概念,特别是搜索算法。我们将从回顾上周的工具开始,然后深入探讨线性搜索和二分搜索的实现与效率分析。此外,我们还将学习如何在C语言中创建自定义的数据结构(结构体),以便更有效地组织和管理数据。
回顾上周的工具
上一节我们讨论了解决问题的方法和调试工具。本节中,我们来看看这些工具如何帮助我们编写更好的代码。

以下是上周介绍的一些关键工具:
- 调试器 (debug 50):一个交互式调试器,允许你设置断点、查看变量状态和跟踪代码执行流程。
- 代码风格检查工具:提供关于代码格式和风格的反馈。
- 正确性检查工具 (check 50):自动检查你的代码在给定问题上的正确性。
- 打印输出 (printf):一种基本的调试方法,用于在屏幕上显示信息。
- “橡皮鸭”调试法:通过向他人(或物体)解释你的代码来发现逻辑错误。
理解内存与数组

上一节我们回顾了内存和数组的概念。本节中,我们来看看数组如何成为实现搜索算法的基础。




计算机的内存可以看作是一系列连续的字节。数组允许我们在内存中连续存储多个相同类型的值。例如,我们可以用一个数组scores来存储三个分数,而不是使用三个独立的变量scores_one、scores_two和scores_three。




代码示例:
int scores[3] = {95, 87, 92};



然而,计算机一次只能“查看”数组中的一个元素。这种特性决定了我们搜索数组的方式必须是循序渐进的,即通过算法来实现。






算法运行时间与大O表示法





在讨论具体搜索算法之前,我们需要一种方法来描述算法的效率。这就是运行时间分析和大O表示法。


运行时间指的是一个算法解决问题所需的时间或步骤数量。
大O表示法 (Big O notation) 是计算机科学家用来描述算法运行时间上限(最坏情况)的一种方式。它关注的是随着输入规模n(例如数组中的元素数量)增长,运行时间的增长趋势,并忽略常数因子和低阶项。
以下是几种常见的大O复杂度:



- O(1):常数时间。无论输入多大,运行时间都是固定的。
- O(log n):对数时间。运行时间随输入规模呈对数增长,非常高效。
- O(n):线性时间。运行时间与输入规模成正比。
- O(n²):平方时间。运行时间随输入规模呈平方增长,对于大规模输入可能较慢。
在之前的电话簿例子中,我们见过三种搜索方法:
- 逐页搜索(线性搜索):O(n)
- 每次翻两页:O(n/2),在大O表示法中简化为 O(n)
- 不断对半分割(二分搜索):O(log n)




实现线性搜索
上一节我们介绍了运行时间的概念。本节中,我们来看看如何实现最简单的搜索算法:线性搜索。
线性搜索的思路很简单:从数组的第一个元素开始,逐个检查每个元素,直到找到目标值或遍历完整个数组。
伪代码实现:
For i from 0 to n-1
If number behind i-th door
Return true
Return false
C语言代码示例 (搜索数字):
int numbers[] = {4, 6, 8, 2, 7, 5, 0};
int n = 7;
for (int i = 0; i < n; i++)
{
if (numbers[i] == 0)
{
printf("Found\n");
return 0; // 成功,返回0
}
}
printf("Not found\n");
return 1; // 失败,返回1
C语言代码示例 (搜索字符串 - 注意字符串比较):
string names[] = {"Bill", "Charlie", "Fred", "George", "Ginny", "Percy", "Ron"};
for (int i = 0; i < 7; i++)
{
// 使用 strcmp 比较字符串,相等时返回 0
if (strcmp(names[i], "Ron") == 0)
{
printf("Found\n");
return 0;
}
}
printf("Not found\n");
return 1;
注意:在C语言中,不能使用==直接比较字符串,必须使用strcmp函数。
线性搜索的效率分析
- 最坏情况运行时间 (上界):如果目标元素在数组末尾或不存在,我们需要检查所有
n个元素。因此,上限是 O(n)。 - 最好情况运行时间 (下界):如果目标元素恰好在数组开头,我们只需一步就能找到。因此,下限是 Ω(1)。




实现二分搜索

线性搜索在数组未排序时是唯一的选择。但如果数组已经排序,我们可以使用更高效的二分搜索算法。

二分搜索利用了数组已排序的特性:
- 查看数组中间的元素。
- 如果中间元素正好是目标值,则搜索成功。
- 如果目标值小于中间元素,则在数组的左半部分重复搜索。
- 如果目标值大于中间元素,则在数组的右半部分重复搜索。
- 如果搜索范围为空,则目标值不存在。


伪代码实现:
If no doors left
Return false
If number behind middle door
Return true
Else if number < middle door
Search left half
Else if number > middle door
Search right half




二分搜索的效率分析

- 最坏情况运行时间 (上界):每次都将问题规模减半,因此运行时间为 O(log n)。
- 最好情况运行时间 (下界):如果目标值恰好位于第一次检查的中间位置,运行时间为 Ω(1)。


与线性搜索相比,当n很大时,O(log n) 比 O(n) 要快得多。例如,在64个元素中搜索,线性搜索最多需要64步,而二分搜索最多只需要6步(因为 log₂64 = 6)。
创建自定义数据结构:结构体
上一节我们实现了对简单数据的搜索。本节中,我们来看看如何将相关的数据捆绑在一起,形成更复杂的数据结构。


在实际应用中,数据项通常包含多个属性。例如,一个“联系人”可能包含“姓名”和“电话号码”。为了在代码中更好地组织这类数据,C语言允许我们创建自定义的数据类型,称为结构体 (struct)。


不使用结构体的方式 (容易出错):
string names[] = {"Brian", "David"};
string numbers[] = {"+1-617-495-1000", "+1-949-468-2750"};
// 需要手动确保 names[i] 和 numbers[i] 对应同一个人

使用结构体的方式 (更清晰、更安全):
// 定义一个新的数据类型 called ‘person‘
typedef struct
{
string name;
string number;
}
person;




int main(void)
{
// 创建一个 person 类型的数组
person people[2];
// 为第一个 person 赋值
people[0].name = "Brian";
people[0].number = "+1-617-495-1000";
// 为第二个 person 赋值
people[1].name = "David";
people[1].number = "+1-949-468-2750";
// 搜索 David
for (int i = 0; i < 2; i++)
{
// 使用点操作符 ‘.‘ 访问结构体内部的字段
if (strcmp(people[i].name, "David") == 0)
{
printf("Found %s\n", people[i].number);
return 0;
}
}
printf("Not found\n");
return 1;
}
通过使用typedef struct,我们将name和number这两个字段封装在一起,创建了一个名为person的新数据类型。这样,每个联系人的信息都被捆绑在一个变量中,代码更易于理解和维护,也避免了数据不同步的错误。








总结

本节课中我们一起学习了算法的核心基础,重点是搜索。
- 我们回顾了用于分析和调试代码的工具。
- 我们学习了如何使用大O表示法来分析算法的运行时间效率。
- 我们实现了线性搜索算法,并分析了其**O(n)**的时间复杂度。
- 我们实现了更高效的二分搜索算法,它要求数据已排序,但其**O(log n)**的时间复杂度在处理大规模数据时优势显著。
- 最后,我们引入了结构体的概念,它允许我们将多个相关的数据项组合成一个自定义的数据类型,从而使程序的数据组织更加清晰和健壮。


理解这些搜索算法和数据结构是学习更复杂计算机科学概念的重要基石。在接下来的课程中,我们将探讨如何对数据进行排序,这是高效使用二分搜索的前提。
哈佛 CS50-CS 7:L3- 算法(结构体、搜索与排序)2








在本节课中,我们将要学习排序算法。我们将探讨两种基本的排序算法——选择排序和冒泡排序,并分析它们的效率。我们还将初步了解一种更高效的算法——归并排序,并理解递归在算法设计中的应用。





排序的必要性






上一节我们介绍了搜索算法,并了解到二分查找虽然高效,但要求数据必须预先排序。本节中我们来看看如何对数据进行排序。



排序的目标是将一个未排序的输入数组,转换成一个已排序的输出数组。例如,输入 [6, 3, 8, 5, 2, 7, 4, 1],目标是得到输出 [1, 2, 3, 4, 5, 6, 7, 8]。
有人可能会问,既然线性查找简单直接,为什么还要费心去排序数据呢?以下是需要考虑的几点:
- 效率权衡:对于小型数据集,编写和运行一个简单的线性查找可能比先排序再使用二分查找更快。
- 开发时间:实现一个复杂但高效的算法(如二分查找)可能需要更多的时间和精力。
- 应用场景:对于需要反复搜索的大型数据集(如搜索引擎、社交网络),前期投入时间进行排序,后续就能持续享受高效搜索带来的收益。
选择排序

选择排序是一种直观的排序算法。其核心思想是:重复地在未排序部分中找到最小(或最大)元素,并将其放到已排序部分的末尾。

以下是选择排序的步骤描述:
- 找到数组中最小的元素。
- 将其与数组第一个位置的元素交换。
- 接着在剩下的元素中找到最小的元素,将其与第二个位置的元素交换。
- 重复此过程,直到整个数组排序完成。

伪代码表示
选择排序的伪代码如下:
对于 i 从 0 到 n-2:
找到从第 i 项到最后一项之间的最小项
将最小项与第 i 项交换
效率分析
选择排序的运行时间可以近似用公式 n² / 2 + n / 2 来描述。当 n 很大时,起主导作用的是 n² 项。因此,在大 O 表示法中,选择排序的时间复杂度是 O(n²)。


值得注意的是,即使输入数组已经是排序好的,选择排序仍然会进行全部的比较和交换操作,其最佳情况下的运行时间也是 Ω(n²)。所以我们可以更精确地说,选择排序的时间复杂度是 Θ(n²)。


冒泡排序


冒泡排序采用了一种不同的策略:它反复遍历列表,比较相邻的元素,如果它们的顺序错误就把它们交换过来。这个过程重复进行,直到列表排序完成。


其名称来源于较大的元素会像气泡一样逐渐“浮”到列表的顶端(末尾)。
伪代码表示


一个基础的冒泡排序伪代码如下:
重复 n-1 次:
对于 i 从 0 到 n-2:
如果 第 i 项 > 第 i+1 项:
交换 第 i 项 和 第 i+1 项


我们可以对其进行优化,增加一个标志来记录本轮是否发生了交换。如果某一轮遍历没有发生任何交换,说明数组已经有序,可以提前终止算法。
优化后的伪代码:
重复执行:
设置 已排序 标志为 真
对于 i 从 0 到 n-2:
如果 第 i 项 > 第 i+1 项:
交换 第 i 项 和 第 i+1 项
设置 已排序 标志为 假
如果 已排序 标志为 真:
退出循环
效率分析
在最坏情况下,冒泡排序需要进行大约 n² 次比较,所以其上界时间复杂度为 O(n²)。然而,在最佳情况下(输入已排序),优化后的冒泡排序只需进行 n-1 次比较就能提前结束,因此其下界时间复杂度为 Ω(n)。
递归与归并排序简介
我们能否做得比 O(n²) 更好呢?答案是肯定的。归并排序就是一种更高效的算法,其平均和最坏情况下的时间复杂度为 O(n log n)。
归并排序的核心思想是 分治法:
- 分:递归地将数组分成两半。
- 治:对每一半进行排序(递归调用自身)。
- 合:将两个已排序的半部分合并成一个完整的已排序数组。
这里的关键操作是 合并:给定两个已排序的数组,我们可以通过不断比较两个数组前端的最小元素,将较小的那个放入新数组,从而高效地合并它们。
递归概念
归并排序的实现依赖于 递归,即函数调用自身的能力。递归需要定义一个 基本情况(例如,当数组只有一个元素时,它本身就是有序的),以及一个 递归情况(将问题分解为更小的子问题并调用自身解决)。
虽然归并排序的代码可能更复杂,并且通常需要额外的内存空间来辅助合并,但其 O(n log n) 的效率在处理大规模数据时远胜于 O(n²) 的算法。这体现了计算机科学中常见的 权衡:用更复杂的逻辑或更多的内存空间,来换取运行时间上的巨大提升。


算法比较与总结
本节课中我们一起学习了三种排序算法:

- 选择排序:简单直观,但效率较低,时间复杂度为 Θ(n²)。它不关心输入数据的初始状态。
- 冒泡排序:通过局部交换来排序,优化后对已排序数据友好(Ω(n)),但最坏情况仍是 O(n²)。
- 归并排序:利用分治法和递归,实现了 Θ(n log n) 的高效排序,但实现相对复杂,且需要额外空间。





可视化对比清楚地表明,当数据量增长时,O(n²) 算法的耗时将急剧增加,而 O(n log n) 算法的增长则平缓得多。在实际编程中,我们通常会使用标准库中已经高度优化的排序函数,但理解这些底层原理对于成为优秀的程序员至关重要。它帮助我们做出明智的权衡,并理解算法效率对程序性能的巨大影响。
哈佛 CS50-CS 8:输入输出、存储与内存管理 1 💾


在本节课中,我们将要学习计算机内存的基础知识,包括如何表示内存地址、理解指针的概念,以及如何通过编程来操作内存。我们将从熟悉的C语言出发,逐步揭开计算机内部工作的神秘面纱。


内存地址与十六进制 🔢

上一节我们介绍了计算机内存的基本概念。本节中我们来看看计算机科学家如何表示内存地址。
计算机内存中的所有字节都可以被编号。我们可以称之为字节0、1、2、3,一直到字节15,等等。在讨论计算机内存时,计算机科学家倾向于使用一种叫做十六进制的计数系统。
十六进制是一个不同的基数系统。它不使用10个数字或2个数字,而是使用16个数字。因此,当计算机科学家谈论计算机内存时,他们会使用0、1、2、3、4、5、6、7、8、9,但在那之后,不是继续使用十进制到10,他们会开始使用字母表中的几个字母来计数。
在十六进制中,hex表示16,总共有16个独立的数字:0到9和a到f。因此我们不需要引入第二个数字,只需计数到16。我们可以使用单个数字0到f,并且我们可以通过使用多个十六进制数字继续计数。
十六进制的工作原理与我们熟悉的十进制系统非常相似。就像在十进制世界中,我们使用了基数10,或者在二进制世界中我们使用了基数2,现在我们将使用基数16。


以下是十六进制计数的示例:
- 0, 1, 2, 3, 4, 5, 6, 7, 8, 9
- 在9之后,我们数到a, b, c, d, e, f
- 在f之后,我们进位,所以下一个数是
10(十六进制),它代表十进制的16。
计算机世界中的一个约定是,每当你表示十六进制数字时,使用前缀0x。这只是一个前缀,用于明确这些是十六进制数字。


指针:内存地址的变量 🎯


上一节我们学习了如何用十六进制表示内存地址。本节中我们来看看如何在程序中获取和使用这些地址。

这里有一行简单的代码:
int n = 50;
那么实际上存储在我们电脑内存中的是什么呢?变量n需要放在你电脑的内存中。一个整数通常在CS50 IDE和现代系统上占用四个字节。这个值50就存储在这四个字节中。
在你的电脑内存中,还有这些隐含存在的地址。即使我们根据代码中给它的变量名n来引用这个变量,这个变量也肯定存在于内存中的一个特定位置。这个位置就是它的地址。
为了探索计算机内存的内部,C语言提供了两个新的运算符。
第一个是&符号(地址运算符)。在任何变量名之前加上前缀&,我们可以告诉C语言:“请告诉我这个变量存储在什么地址”。

第二个是*符号(解引用运算符)。当你使用*时,你实际上可以告诉你的程序去查看特定内存地址的内容。
因此,&告诉你什么是地址,*意味着去到那个地址。它们是一种反向操作。
我们可以使用printf和格式代码%p来打印出地址。
以下是一个示例程序:
#include <stdio.h>
int main(void)
{
int n = 50;
printf("%p\n", &n); // 打印变量n的地址
}
运行这个程序会输出一个类似0x7ffd80792f7c的十六进制数,这就是变量n在内存中的地址。
指针变量 📍
上一节我们学会了如何获取一个变量的地址。本节中我们来看看如何将地址存储起来以便后续使用。

指针是一种变量,它包含某个其他值的地址。我们之前见过整数、浮点数、字符和字符串,现在指针只是另一种变量类型。你可以有指向整数的指针、指向字符的指针、指向布尔值或任何其他数据类型的指针。
声明一个指针的语法是type *variable_name;。例如,一个指向整数的指针声明为int *p;。
我们可以将之前获取的地址存储在一个指针变量中:
#include <stdio.h>
int main(void)
{
int n = 50;
int *p = &n; // p是一个指针,存储了n的地址
printf("%p\n", p); // 打印指针p中存储的地址(即n的地址)
}
现在,变量p存储了变量n的地址。如果我们想通过指针p来获取n的值,我们可以使用解引用运算符*:
printf("%i\n", *p); // 打印p所指向地址的值(即n的值,50)
在计算机内存中,可以这样理解:变量n占用了四个字节存储值50。指针变量p占用了八个字节(在现代64位系统中),它存储的值是n的地址(例如0x123)。我们可以将指针p图形化地看作一个箭头,指向变量n。

字符串的本质 🧵
上一节我们探讨了整数和指针。本节中我们来看看字符串在内存中是如何表示的。
对于字符串,你可能有一行代码看起来像这样:
string s = "HI!";
在底层,字符串"HI!"存储在内存中连续的字节里:'H', 'I', '!', '\0'。每个字符占用一个字节。
那么,变量s究竟是什么呢?从今天开始,我们可以将字符串视为技术上仅仅是一个地址——字符串中第一个字符的地址。

为什么这就足够了?因为字符串在C语言中是以字符数组的形式存储的,并且每个字符串都以空字符\0结尾。所以,如果你知道字符串开始的地址,你就可以通过顺序读取字符直到遇到\0来获取整个字符串。
实际上,在C语言中,并没有内置的string类型。在CS50库中,string是通过typedef定义的一个别名:
typedef char *string;
这意味着string其实就是char *,即一个指向字符的指针。所以,当我们写string s = "HI!";时,s就是一个存储着字符'H'地址的指针。
我们可以通过编程来验证这一点:
#include <stdio.h>
#include <cs50.h>
int main(void)
{
string s = "HI!";
printf("%p\n", s); // 打印字符串s的地址(第一个字符'H'的地址)
printf("%p\n", &s[0]); // 打印第一个字符'H'的地址
printf("%p\n", &s[1]); // 打印第二个字符'I'的地址
printf("%p\n", &s[2]); // 打印第三个字符'!'的地址
}
运行这个程序,你会看到输出的地址是连续的,每个相差1个字节。
指针运算与字符串访问 ➕
上一节我们明白了字符串本质上是一个地址。本节中我们来看看如何利用这个特性来访问字符串。
既然字符串s是一个地址(例如0x123),指向第一个字符'H',那么我们可以使用指针运算来访问其他字符。
*(s + 0) 等价于 s[0],即第一个字符。
*(s + 1) 等价于 s[1],即第二个字符。
*(s + 2) 等价于 s[2],即第三个字符。
以下是一个示例:
#include <stdio.h>
#include <cs50.h>

int main(void)
{
string s = "HI!";
printf("%c", *(s + 0)); // 打印 'H'
printf("%c", *(s + 1)); // 打印 'I'
printf("%c\n", *(s + 2)); // 打印 '!'
}
第二周我们使用的方括号表示法[i],实际上只是一种更友好、语法糖式的方式,底层做的就是这种指针运算。s[i]在C语言中完全等价于*(s + i)。
比较与复制字符串的陷阱 ⚠️

上一节我们学会了用指针访问字符串。本节中我们来看看直接比较或复制字符串时常见的错误。



考虑以下程序,它试图比较两个用户输入的字符串:
#include <cs50.h>
#include <stdio.h>
int main(void)
{
char *s = get_string("s: ");
char *t = get_string("t: ");
if (s == t) // 这是错误的比较方式!
{
printf("Same\n");
}
else
{
printf("Different\n");
}
}
即使用户输入了相同的字符串(如两次都输入"hi"),程序也会输出"Different"。为什么?
因为s和t是指针,它们存储的是各自字符串第一个字符的地址。get_string每次都会从内存中找一块新地方来存放用户输入的字符串。所以即使内容相同,s和t的值(即地址)也是不同的。s == t比较的是地址是否相同,而不是地址所指向的内容是否相同。
正确的比较字符串内容的方法是使用strcmp函数(来自string.h):
#include <cs50.h>
#include <stdio.h>
#include <string.h>
int main(void)
{
char *s = get_string("s: ");
char *t = get_string("t: ");
if (strcmp(s, t) == 0) // 比较字符串内容
{
printf("Same\n");
}
else
{
printf("Different\n");
}
}
类似地,直接使用赋值运算符=复制字符串也会有问题:
char *s = "hi";
char *t = s; // 这只是复制了地址,没有复制内容!
t[0] = toupper(t[0]); // 这同时修改了s和t指向的字符串!
printf("%s\n", s); // 输出 "Hi"
printf("%s\n", t); // 输出 "Hi"
因为t = s只是让t指向了和s相同的内存地址,它们共享同一份数据。修改t就等于修改s。
正确复制字符串 ✅
上一节我们看到了错误复制字符串的后果。本节中我们来看看如何正确地为字符串内容分配新的内存并进行复制。
要真正复制一个字符串,我们需要做两件事:
- 为新的字符串分配足够的内存。
- 将原字符串中的每一个字符(包括结尾的空字符
\0)复制到新分配的内存中。
我们可以使用malloc函数(来自stdlib.h)来动态分配内存。它接受一个参数:你需要的字节数。


以下是手动复制字符串的示例:
#include <cs50.h>
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void)
{
char *s = get_string("s: ");
if (s == NULL) // 良好的习惯:检查输入是否有效
{
return 1;
}
// 分配内存:字符串长度 + 1 (用于 '\0')
char *t = malloc(strlen(s) + 1);
if (t == NULL) // 检查内存是否分配成功
{
return 1;
}
// 手动复制字符
for (int i = 0, n = strlen(s); i <= n; i++) // i <= n 确保复制 '\0'
{
t[i] = s[i];
}
// 现在可以安全地修改t而不影响s
if (strlen(t) > 0)
{
t[0] = toupper(t[0]);
}
printf("s: %s\n", s);
printf("t: %s\n", t);
// 重要:释放分配的内存
free(t);
return 0;
}
实际上,有一个标准库函数strcpy(来自string.h)可以帮我们完成复制循环的工作,使代码更简洁:
strcpy(t, s); // 将字符串s复制到t指向的内存中
使用malloc分配的内存,在使用完毕后,程序员有责任使用free函数将其释放,归还给操作系统,以避免内存泄漏。

本节课中我们一起学习了计算机内存的基础表示法(十六进制)、指针的概念与操作、字符串在内存中的本质(即字符指针),以及如何正确地比较和复制字符串。理解这些底层概念是写出高效、正确程序的关键,也为我们后续学习更复杂的数据结构和内存管理打下了坚实的基础。
哈佛 CS50-CS 9:输入输出、存储与内存管理 2 🖥️


在本节课中,我们将深入学习内存管理、指针的深入应用、常见内存错误及其调试工具,以及文件输入输出的基本操作。我们将从手动管理内存开始,逐步理解指针如何帮助我们更精确地控制程序,并最终探索如何读写文件以实现数据的持久化存储。
📚 概述:内存管理与指针
上一节我们介绍了指针的基本概念和 malloc 函数。本节中,我们将看看如何正确地释放内存,并深入探讨指针在函数参数传递和数据结构操作中的强大作用。
释放内存:free 函数
当你使用 malloc 分配内存后,最好的做法是在使用完毕后将其释放。与 malloc 相对应的操作是 free 函数。free 函数的输入是 malloc 返回的地址,即分配给你的内存块的第一个字节的地址。

如果你使用 malloc 请求了四个字节,你会得到这些字节的第一个地址。你需要记住你请求了多少字节。对于 free,你只需告诉它 malloc 返回给你的地址。
例如,如果你将 malloc 返回的地址存储在变量 t 中,当你完成那段内存的使用时,只需调用 free(t)。计算机会为你释放那段内存。之后你可能再次得到它,但至少你的计算机不会那么快耗尽内存,因为它现在可以将那段空间重用于其他用途。


以下是核心操作的代码描述:
char *t = malloc(4); // 分配4个字节
// ... 使用 t ...
free(t); // 释放内存


内存布局与程序状态
让我们绘制一幅图来展示程序在内存中的新状态。假设我们复制一个字符串。原始的字符串 s 指向 "hi!"。当我们使用 malloc 为新字符串 t 分配内存时,malloc 的返回值将是那段内存第一个字节的地址(例如 0x456)。后续的字节地址会依次递增(0x457, 0x458...)。当我们把 malloc 的返回值赋给 t 时,t 中存储的就是那个起始地址。
指针的抽象表示就是一个指向内存中实际位置的箭头。现在,如果我们在 for 循环中复制 s 到 t,我们会将 'h'、'i'、'!' 和终止空字符 '\0' 逐个从 s 复制到 t 指向的内存位置。
现在的图景从根本上不同了:t 不再指向和 s 相同的内容,它指向自己独立的一块内存,并且其中逐步复制了所有字符。这显然是一个合适的程序副本。

关于 strcpy 和 free

即使你使用 strcpy 函数来复制字符串,而不是手动逐个字符复制,你仍然需要使用 free 来释放内存。每次使用 malloc 后,你必须使用 free。strcpy 只是将一块内存的内容复制到另一块,它并不为你分配或管理目标内存。它本质上实现了那个复制循环。

cs50 库中的 get_string 函数在内部使用了 malloc 来确保有足够的内存存储输入的字符串,并且库在幕后为你调用了 free。然而,当你自己使用 malloc 时,你必须负责调用 free。

🔍 检测内存错误:Valgrind 工具
引入这些新技术的同时,我们也需要工具来处理可能出现的、与内存相关的复杂问题。值得庆幸的是,有一个叫做 valgrind 的程序可以帮助你。
valgrind 程序存在于 CS50 IDE、Mac、PC 和 Linux 计算机上。它可以检测你在内存方面可能犯的错误。
你可能会在内存方面犯什么错?例如,之前我们触发的分段错误,就是因为触碰了不该触碰的内存。valgrind 可以帮助你找出触碰非法内存的位置。此外,valgrind 还可以检测你是否忘记了调用 free,这种情况称为内存泄漏。
内存泄漏与我们日常使用的设备也相关。如果你长时间使用电脑或手机,打开了很多浏览器标签页和程序,设备可能会变得非常缓慢。这可能是因为某个程序存在内存泄漏,从未释放它分配的内存。每个浏览器标签页都可能在使用 malloc 请求内存来存储网页内容。如果你不断打开新标签,就像不断调用 malloc,最终可能耗尽内存。
如何使用 Valgrind

valgrind 是一个命令行工具。为了演示,我们创建一个包含多个内存错误的程序 memory.c。
以下是 memory.c 的一个有问题的示例:
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
char *s = malloc(3); // 只分配了3个字节
s[0] = 'H';
s[1] = 'i';
s[2] = '!';
s[3] = '\0'; // 错误:写入第4个字节(无效写入)
printf("%s\n", s); // 错误:读取了未分配的内存(无效读取)
// 忘记调用 free(s); (内存泄漏)
return 0;
}
编译并运行这个程序,它可能看起来正常工作。但使用 valgrind 检查时,它会报告“无效写入”、“无效读取”和“内存泄漏”错误。
valgrind 的输出可能看起来很复杂,但关键信息会指出错误发生的行号(例如“第10行无效写入”)。通过修复这些错误(分配足够内存、释放内存),再次运行 valgrind,输出会显示“所有堆块都被释放…没有泄漏是可能的”,这表明内存管理是正确的。
从本周的问题集开始,即使你的代码看起来正确,输出也正确,也请使用 valgrind 来追查潜在的错误。
⚠️ 未初始化的指针与垃圾值


到目前为止,我们几乎总是初始化变量。但如果我们不初始化,计算机会怎么做?考虑以下代码:
int *x;
int *y;
x = malloc(sizeof(int));
*x = 42;
*y = 13; // 危险:y 未初始化!
这里,我们声明了两个指针 x 和 y,但只给 x 分配了内存并赋值。y 没有被初始化,它包含一个所谓的垃圾值——一个看似是地址但无效的随机值。当执行 *y = 13 时,程序试图向这个虚假地址写入数据,这很可能导致程序崩溃(分段错误)。
你永远不应该信任计算机内存中未初始化的内容。一个编程术语叫做“垃圾值”。如果你自己没有放入一个值,你应该安全地假设内存中的那个位置是一个垃圾值。
我们可以用一个小程序来观察垃圾值:
#include <stdio.h>
int main(void)
{
int scores[3]; // 未初始化的数组
for (int i = 0; i < 3; i++)
{
printf("%i\n", scores[i]); // 打印垃圾值
}
}
运行这个程序,你会看到一些无法预测的数值,这就是之前留在内存中的“垃圾”。
🔄 指针在函数参数传递中的应用
让我们回顾一个基本操作:交换两个值。在排序算法中,交换频繁发生。考虑这个简单的交换程序:
#include <stdio.h>
void swap(int a, int b); // 原型
int main(void)
{
int x = 1;
int y = 2;
printf("x is %i, y is %i\n", x, y);
swap(x, y);
printf("x is %i, y is %i\n", x, y); // x 和 y 并未交换!
}
void swap(int a, int b)
{
int tmp = a;
a = b;
b = tmp;
printf("a is %i, b is %i\n", a, b); // 这里 a 和 b 交换了
}
为什么 x 和 y 没有交换?这是因为 C 语言是“按值传递”的。当 swap(x, y) 被调用时,x 和 y 的值(1 和 2)被复制给了函数参数 a 和 b。函数内部交换的只是副本 a 和 b,原始的 x 和 y 并未被触及。

为了真正交换 x 和 y,我们需要传递它们的地址,让函数能够修改原始内存位置的内容。这就需要使用指针。
修改后的 swap 函数:
#include <stdio.h>
void swap(int *a, int *b); // 原型:参数是指向 int 的指针
int main(void)
{
int x = 1;
int y = 2;
printf("x is %i, y is %i\n", x, y);
swap(&x, &y); // 传递 x 和 y 的地址
printf("x is %i, y is %i\n", x, y); // 成功交换!
}
void swap(int *a, int *b) // 接收地址
{
int tmp = *a; // 获取 a 地址处的值
*a = *b; // 将 b 地址处的值放到 a 的地址
*b = tmp; // 将临时值放到 b 的地址
}
现在,swap 函数接收的是指向整数的指针(即地址)。通过解引用操作符 *,我们可以访问和修改这些地址所指向的原始值,从而实现了真正的交换。

🏗️ 栈与堆的内存模型
理解函数调用和内存分配有助于我们看清上面交换例子背后的原理。计算机内存被不同区域使用:
- 机器码:程序的指令位于内存顶部。
- 全局变量:位于机器码下方。
- 堆:用于动态内存分配(如
malloc)。当调用malloc时,内存从堆中分配,地址向上增长。 - 栈:用于函数调用。当调用函数时,其参数和局部变量在栈上分配,地址向下增长。
每次调用函数,都会在栈上使用一块称为“栈帧”的内存。当函数返回,它的栈帧就被释放(但内容可能残留为垃圾值)。在之前的错误 swap 中,main 的栈帧中有 x 和 y,swap 的栈帧中有 a、b 和 tmp。swap 只修改了自己栈帧中的副本。

在正确的指针版本中,swap 的栈帧里存储的是 x 和 y 的地址(指针),通过解引用这些指针,它直接修改了 main 栈帧中 x 和 y 位置的值。

💥 递归与栈溢出
递归函数是调用自身的函数。例如,用递归打印一个金字塔:
void draw(int h)
{
if (h == 0) { return; } // 基本情况
draw(h - 1); // 递归调用
for (int i = 0; i < h; i++) { printf("#"); }
printf("\n");
}
这里存在危险。每次递归调用 draw,都会在栈上创建一个新的栈帧。如果递归深度过大(例如 draw(20000)),栈空间可能会被耗尽,导致程序崩溃,这称为栈溢出。


迭代版本通常没有这个风险,因为它只使用固定次数的循环,不会增加栈帧。选择递归还是迭代,需要权衡代码的优雅性与潜在的风险。



📄 文件输入输出




到目前为止,我们的程序几乎只使用内存,程序结束后数据就消失了。文件则允许我们永久保存数据。在 C 语言中,我们可以使用文件 I/O 操作来读写文件。






基本文件操作



以下是一个将联系人信息保存到文件的简单电话簿程序:
#include <cs50.h>
#include <stdio.h>
#include <string.h>
int main(void)
{
FILE *file = fopen("phonebook.csv", "a"); // 以追加模式打开文件
if (file == NULL) { return 1; } // 检查文件是否成功打开
char *name = get_string("Name: ");
char *number = get_string("Number: ");
fprintf(file, "%s,%s\n", name, number); // 写入文件
fclose(file); // 关闭文件
}
FILE *是一个指向文件的数据类型。fopen打开文件,第一个参数是文件名,第二个参数是模式("r"读,"w"写,"a"追加)。fprintf类似于printf,但是将格式化的字符串写入指定的文件。fclose关闭文件,确保所有数据都写入磁盘。
运行此程序后,会在当前目录生成一个 phonebook.csv 文件,内容可以用电子表格软件打开。

读取文件与文件格式识别
我们也可以读取文件。例如,判断一个文件是否为 JPEG 图片。JPEG 文件的开头有几个特定的“魔数”字节。
#include <stdint.h>
typedef uint8_t BYTE; // 定义 BYTE 类型为 8 位无符号整数
bool is_jpeg(FILE *file)
{
BYTE buffer[3];
fread(buffer, sizeof(BYTE), 3, file); // 从文件读取3个字节到缓冲区
// 检查是否是 JPEG 的魔数
if (buffer[0] == 0xff && buffer[1] == 0xd8 && buffer[2] == 0xff)
{
return true;
}
return false;
}
fread从文件读取数据到内存中的缓冲区。- 许多文件格式(如图片、文档)都有独特的起始字节序列,用于识别文件类型。



复制文件
我们甚至可以重新实现 cp 命令来复制文件:
#include <stdint.h>
#include <stdio.h>
typedef uint8_t BYTE;
int main(int argc, char *argv[])
{
if (argc != 3) { return 1; } // 检查参数数量
FILE *source = fopen(argv[1], "r");
FILE *destination = fopen(argv[2], "w");
BYTE buffer;
while (fread(&buffer, sizeof(BYTE), 1, source))
{
fwrite(&buffer, sizeof(BYTE), 1, destination);
}
fclose(source);
fclose(destination);
}
这个程序打开源文件用于读取,打开目标文件用于写入,然后逐个字节地从源文件读取并写入目标文件,最终实现文件复制。

🎯 总结

本节课中我们一起学习了:
- 内存管理:使用
malloc动态分配内存,并必须使用free释放,避免内存泄漏。 - 调试工具:使用
valgrind检测内存错误,如无效访问和内存泄漏。 - 指针深入:理解了未初始化指针的危险(垃圾值),以及如何通过传递指针(地址)让函数修改调用者的变量。
- 内存布局:了解了栈(用于函数调用)和堆(用于动态分配)的基本概念,以及递归可能导致的栈溢出问题。
- 文件 I/O:学习了如何使用
fopen、fprintf、fscanf、fread、fwrite和fclose来读写文件,实现数据的持久化存储。

这些概念是进行系统编程和深入理解计算机如何工作的基石。在接下来的问题集中,你将有机会应用这些知识,例如恢复已删除的图片文件或为图片实现滤镜效果。
哈佛 CS50-CS 10:L5- 数据结构 1(数组、链表、树、哈希表、字典树、堆、栈、队列)




概述

在本节课中,我们将要学习数据结构的基础知识。我们将从回顾数组开始,探讨其局限性,并学习如何使用指针来构建更灵活、更动态的数据结构——链表。我们将通过代码示例来理解这些概念,并分析不同操作的运行时间。
回顾数组
回想一下,在第二周我们介绍了数组的概念。数组是内存中一个连续的块,用于存储一系列相同类型的值,例如整数。

例如,一个大小为3的整数数组可以这样表示:
int array[3] = {1, 2, 3};
在内存中,这三个整数会一个接一个地连续存放。


然而,数组有一个主要的限制:一旦创建,其大小就固定了。如果你想向一个已满的数组添加新元素,你不能简单地扩展它,因为数组后面的内存可能已经被其他数据占用。

数组的插入问题

假设我们有一个大小为3的数组,包含数字1、2、3。现在,我们想添加数字4。由于数组是连续的,而数组末尾之后的内存可能已被占用(例如,存储着字符串“hello, world”),我们无法直接将4放在数组后面。
一个直观的解决方案是创建一个新的、更大的数组(例如大小为4),将旧数组的所有元素复制到新数组中,然后在新数组的末尾添加新元素。最后,释放旧数组的内存。
这个过程可以用以下伪代码表示:
// 假设已有数组 `old_array` 大小为 3
int *new_array = malloc(4 * sizeof(int));
for (int i = 0; i < 3; i++) {
new_array[i] = old_array[i];
}
new_array[3] = 4;
free(old_array);
// 现在 `new_array` 是大小为4的新数组
插入操作的运行时间分析

让我们分析一下向数组中插入一个元素的运行时间。


- 最坏情况(上限 O(n)):当数组已满时,我们需要将全部 n 个元素复制到新数组中。这需要 n 步操作,因此运行时间是 O(n)。
- 最好情况(下限 Ω(1)):如果数组尚未满,有可用空间,我们可以直接将新元素放入空位。由于数组支持随机访问(通过索引直接跳转到任何位置),这一步是常数时间操作,即 Ω(1)。
因此,数组插入的时间复杂度在 Ω(1) 和 O(n) 之间。

数组是我们遇到的第一个也是最简单的数据结构。但它的固定大小特性限制了其灵活性。现在,借助指针(可以引用内存地址),我们可以构建更复杂、更动态的数据结构。

引入链表

上一节我们介绍了数组及其在插入操作上的局限性。本节中我们来看看一种更动态的数据结构——链表。

链表由一系列“节点”组成,每个节点包含两部分:
- 实际存储的数据(例如,一个整数)。
- 一个指向下一个节点的“指针”(即内存地址)。

通过这种方式,节点可以分散在内存的任何位置,而不必连续存放。每个节点通过指针“链接”到下一个节点,从而形成一条链。
链表的表示
假设我们想存储数字1、2、3。在链表中,它可能看起来像这样(地址是示例):
- 节点1: 数据 = 1, 下一个指针 = 0x456 (节点2的地址)
- 节点2: 数据 = 2, 下一个指针 = 0x789 (节点3的地址)
- 节点3: 数据 = 3, 下一个指针 = NULL (表示链表结束)

NULL 是一个特殊值,表示指针不指向任何有效地址,在这里标识链表的末尾。


抽象地看,链表可以图示为:
[1] -> [2] -> [3] -> NULL
每个方框代表一个节点,箭头代表指针。
在C语言中定义链表节点
我们可以使用C语言中的 struct 来定义节点:
typedef struct node
{
int number;
struct node *next;
}
node;
这段代码定义了一个名为 node 的新类型。每个 node 包含一个整数 number 和一个指向另一个 node 的指针 next。typedef 允许我们之后直接使用 node 而不是 struct node。
链表的构建过程
构建链表通常从一个指向 NULL 的指针开始,表示一个空链表。
node *list = NULL;
然后,我们动态创建并连接节点:
- 使用
malloc为新节点分配内存。 - 检查分配是否成功(指针是否为
NULL)。 - 初始化节点的数据和指针。
- 将新节点链接到链表中的适当位置。
以下是逐步构建一个包含1、2、3的链表的代码概念:
// 创建第一个节点
node *n = malloc(sizeof(node));
if (n != NULL) {
(*n).number = 1; // 或 n->number = 1;
n->next = NULL;
}
list = n; // 链表现在指向第一个节点
// 创建并链接第二个节点
n = malloc(sizeof(node));
if (n != NULL) {
n->number = 2;
n->next = NULL;
list->next = n; // 第一个节点指向第二个
}
// 创建并链接第三个节点
n = malloc(sizeof(node));
if (n != NULL) {
n->number = 3;
n->next = NULL;
list->next->next = n; // 第二个节点指向第三个
}
注意:在实际程序中,我们会使用循环来避免这种重复且冗长的代码,并更优雅地遍历链表来找到插入点。
链表的优缺点


- 优点(动态大小):可以轻松地添加或移除节点,无需像数组那样复制所有现有元素。只需调整指针即可。
- 缺点(内存开销和访问速度):每个节点都需要额外的内存来存储指针。此外,你无法像数组那样通过索引直接访问任意元素(随机访问)。要找到第 i 个元素,必须从链表头开始,沿着指针逐个遍历 i 个节点。

链表操作的运行时间分析



以下是链表常见操作的时间复杂度分析:

- 搜索:在最坏情况下,可能需要遍历所有 n 个节点才能找到目标或确认其不存在。因此,搜索的运行时间是 O(n)。
- 插入(在开头):如果不在乎顺序,在链表开头插入一个新节点是常数时间操作。只需创建节点,让其指向原来的链表头,然后更新链表头指针。这只需要固定几步,运行时间是 O(1)。
- 插入(在末尾或特定位置):如果需要在链表末尾或保持排序顺序插入,则需要先遍历链表找到正确位置,这需要 O(n) 时间,然后执行常数时间的指针调整。

因此,链表在需要频繁在开头插入元素时非常高效,但牺牲了随机访问和有序插入的效率。

从数组代码到链表代码

上一节我们探讨了链表的理论概念。本节中我们通过一个具体的代码演变示例,看看如何从使用数组和 malloc 的代码过渡到使用链表的代码。

版本1:使用固定大小数组
#include <stdio.h>


int main(void)
{
int list[3];
list[0] = 1;
list[1] = 2;
list[2] = 3;
for (int i = 0; i < 3; i++)
{
printf("%i\n", list[i]);
}
}
这个程序使用栈分配的固定大小数组。大小无法改变。
版本2:使用 malloc 模拟动态数组
#include <stdio.h>
#include <stdlib.h> // 包含 malloc 和 free

int main(void)
{
// 动态分配相当于大小为3的数组
int *list = malloc(3 * sizeof(int));
if (list == NULL)
{
return 1;
}
list[0] = 1;
list[1] = 2;
list[2] = 3;
// 现在假设我们需要添加数字4
int *temp = malloc(4 * sizeof(int));
if (temp == NULL)
{
free(list);
return 1;
}
// 复制旧数组到新数组
for (int i = 0; i < 3; i++)
{
temp[i] = list[i];
}
temp[3] = 4; // 添加新值
free(list); // 释放旧内存
list = temp; // 让 list 指向新内存
// 打印新数组
for (int i = 0; i < 4; i++)
{
printf("%i\n", list[i]);
}
free(list); // 最终释放内存
return 0;
}
这个版本使用 malloc 在堆上分配内存,并手动实现“调整大小”:分配新内存、复制数据、释放旧内存。插入操作仍然是 O(n)。

版本3:使用 realloc 简化


realloc 函数可以调整已分配内存块的大小,并可能自动复制数据。
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
int *list = malloc(3 * sizeof(int));
if (list == NULL)
{
return 1;
}
list[0] = 1;
list[1] = 2;
list[2] = 3;
// 使用 realloc 调整大小
int *temp = realloc(list, 4 * sizeof(int));
if (temp == NULL)
{
free(list);
return 1;
}
list = temp; // realloc 成功,list 指向新内存块
list[3] = 4; // 现在可以安全地添加
for (int i = 0; i < 4; i++)
{
printf("%i\n", list[i]);
}
free(list);
return 0;
}
realloc 可能原地扩展内存(如果后面有空间),也可能分配新内存并复制数据。但无论如何,对于程序员来说,插入操作在逻辑上仍然是 O(n) 的复杂度,因为可能涉及复制。

向链表版本过渡

以上版本本质上还是在操作数组(连续内存块)。要获得真正的 O(1) 头部插入,我们需要放弃“连续”和“索引访问”的概念,转而使用节点和指针的链表结构。这将是我们接下来实践的重点,通过节点结构体和指针操作来动态管理数据。



总结



本节课中我们一起学习了数据结构的基础,重点比较了数组和链表:



- 数组:内存连续,支持快速随机访问(O(1)),但大小固定,插入/删除元素(特别是在需要调整大小时)可能较慢(O(n))。
- 链表:由节点通过指针链接而成,内存不必连续。它可以动态增长和收缩。在链表开头插入/删除节点很快(O(1)),但随机访问元素较慢(O(n)),因为需要从头遍历。
- 权衡:在编程中经常面临权衡。链表用额外的内存开销(存储指针)和更慢的访问速度,换来了动态插入的灵活性。选择哪种数据结构取决于程序最频繁的操作是什么。



我们还回顾了使用 malloc、free 和 realloc 进行动态内存管理,这是构建链表等动态数据结构的基础。在接下来的课程中,我们将继续探索其他更复杂的数据结构。
哈佛 CS50-CS 11:L5- 数据结构 2(数组、链表、树、哈希表、字典树、堆、栈、队列) 📚
在本节课中,我们将要学习多种核心数据结构,包括数组、链表、树、哈希表、字典树、堆、栈和队列。我们将探讨它们各自的优缺点、实现原理以及适用场景,并通过代码和公式来理解其核心概念。


回顾数组与链表的权衡 🔄
上一节我们介绍了数组和链表的基本概念。数组在搜索时表现良好,但动态修改(如插入或删除)的成本非常高,可能需要O(n)步来复制数据到新数组。为了避免这种高成本,我们引入了指针和链表。
链表通过指针将节点连接在一起,带来了动态性。插入操作可以实现常数时间O(1),但牺牲了像可排序性这样的特性,并且增加了内存消耗。

以下是链表节点的基本结构定义:
typedef struct node
{
int number;
struct node *next;
}
node;



链表的C语言实现 🧱
本节中我们来看看如何在C语言中实现一个简单的链表。我们将逐步构建链表,并演示插入和遍历操作。
首先,我们需要在main函数内部声明节点类型并初始化一个空链表。将指针初始化为NULL至关重要,以避免指向垃圾内存导致段错误。


node *list = NULL;

假设我们想依次插入数字1、2、3。以下是插入第一个节点的步骤:

- 使用
malloc分配一个新节点。 - 检查分配是否成功(是否为
NULL)。 - 初始化节点的
number字段。 - 将节点的
next指针设为NULL。 - 更新
list指针,使其指向新节点。
node *n = malloc(sizeof(node));
if (n == NULL)
{
return 1;
}
n->number = 1;
n->next = NULL;
list = n;
插入后续节点(如数字2)时,我们需要遍历到链表末尾,然后将最后一个节点的next指针指向新节点。
n = malloc(sizeof(node));
if (n == NULL)
{
free(list);
return 1;
}
n->number = 2;
n->next = NULL;
node *tmp = list;
while (tmp->next != NULL)
{
tmp = tmp->next;
}
tmp->next = n;
为了遍历并打印链表中的所有值,我们可以使用一个for循环:
for (node *tmp = list; tmp != NULL; tmp = tmp->next)
{
printf("%i\n", tmp->number);
}
循环结束后,务必释放所有已分配的内存以避免内存泄漏:
while (list != NULL)
{
node *tmp = list->next;
free(list);
list = tmp;
}

在链表中插入节点 📍
如果我们想在链表中间(例如按排序顺序)插入一个节点,操作顺序就非常重要。错误的顺序可能导致节点被孤立,从而引发内存泄漏。
例如,我们有一个包含数字2、4、5的链表,现在想插入数字1。正确的步骤是:
- 让新节点
n的next指针指向当前list指向的节点(即数字2)。 - 然后更新
list指针,使其指向新节点n。
n->next = list;
list = n;
如果先执行list = n,就会丢失对原链表的引用,导致内存泄漏。
二叉搜索树 🌳
链表是一维结构。如果我们引入第二个维度,就可以构建树形结构,例如二叉搜索树(BST)。在BST中,任何节点的左子树所有值都小于该节点,右子树所有值都大于该节点。
这种结构结合了链表的动态性和数组的有序性,允许我们进行高效的二分搜索。
一个BST节点的结构可能如下所示:
typedef struct node
{
int number;
struct node *left;
struct node *right;
}
node;
在BST中搜索一个值的递归函数实现如下:
bool search(node *tree, int number)
{
if (tree == NULL)
{
return false;
}
else if (number < tree->number)
{
return search(tree->left, number);
}
else if (number > tree->number)
{
return search(tree->right, number);
}
else
{
return true;
}
}

在平衡的BST中,搜索和插入的理想时间复杂度是O(log n)。然而,如果插入顺序不当(例如按顺序插入1,2,3),树会退化成类似链表的结构,时间复杂度恶化到O(n)。高级的树结构(如AVL树、红黑树)通过旋转操作保持平衡,但需要更复杂的代码。
哈希表 🗂️
哈希表是一种结合了数组和链表优点的数据结构,目标是实现平均接近常数时间**O(1)**的查找和插入。
一个简单的哈希表可以是一个链表数组。我们使用一个哈希函数将输入(例如名字)映射到数组的特定索引(桶)。例如,可以根据名字的首字母映射到0-25的索引。

int hash_function(char *name)
{
return toupper(name[0]) - 'A';
}
如果多个输入映射到同一个桶(哈希冲突),我们就在该索引对应的链表中追加新节点。
哈希表的性能取决于哈希函数将数据均匀分布到各个桶的能力。如果某个链表过长,性能就会下降。优化方法包括使用更复杂的哈希函数(例如考虑前两个或三个字母),但这会增加内存开销,因为数组会变得非常稀疏。

字典树 (Trie) 🔤
字典树(Trie)是一种树形结构,专门用于存储字符串集合。每个节点包含一个指针数组(例如对应26个字母),路径代表字符串的字符序列。

例如,存储“HAGRID”和“HARRY”:
- 从根节点开始,跟随
H指针。 - 然后跟随
A指针。 - 在第三个节点,
G指针指向“HAGRID”的剩余路径,R指针指向“HARRY”的剩余路径。 - 在单词结尾的节点,会有一个布尔标记(如
is_word)表示这是一个完整单词。

在Trie中查找一个单词的时间复杂度是O(k),其中k是单词的长度。如果单词长度有上限,这可以视为常数时间O(1)。然而,Trie消耗大量内存,因为即使很多指针是NULL,每个节点也需要为所有可能的字符分配指针空间。
抽象数据类型:栈、队列、字典 🧾

以上是底层数据结构。在此基础上,我们可以构建具有特定行为和接口的抽象数据类型(ADT)。

栈 (Stack) 遵循后进先出 (LIFO) 原则。主要操作是push(压入)和pop(弹出)。现实类比是自助餐厅的托盘架。
队列 (Queue) 遵循先进先出 (FIFO) 原则。主要操作是enqueue(入队)和dequeue(出队)。现实类比是排队等待的队伍。


字典 (Dictionary) 是一种将键与值关联的抽象数据类型,支持通过键插入、查找和删除值。它可以用数组、链表、哈希表或Trie等多种底层结构实现。
这些ADT的关注点在于其行为规范,而不限定底层实现,为我们提供了更高层次的编程抽象。







总结 📝





本节课中我们一起学习了多种关键数据结构。我们从数组和链表的权衡开始,探讨了如何用C语言实现链表。接着,我们引入了二维的二叉搜索树,它结合了有序性和动态性。为了追求更快的常数时间操作,我们研究了哈希表和字典树,但也理解了它们在内存使用上的代价。最后,我们了解了栈、队列和字典这些抽象数据类型,它们基于底层结构构建,提供了更清晰的编程接口。






每种数据结构都有其时间与空间的权衡,没有一种结构在所有情况下都是最优的。理解这些特性,并根据具体问题选择合适的数据结构,是计算机科学中的核心技能。
哈佛 CS50-CS 12:L6- Python从语法到应用实战 1






在本节课中,我们将要学习一种新的编程语言——Python。我们将通过对比之前学过的Scratch和C语言,来快速掌握Python的基本语法和核心概念,并体验其简洁、强大的特性。

概述:从C到Python的演变


上一节我们介绍了C语言中复杂的语法细节。本节中我们来看看如何用更简洁的Python语言实现相同的功能。

在课程开始时,我们用Scratch的“说你好,世界”积木块打印文字。在C语言中,这需要写成 printf("hello, world\n");,其中包含了反斜杠n和分号等语法。



在Python中,实现同样目标的代码变得非常简单:
print("hello, world")
Python建立在C语言等低级语言的基础上,通过识别并改进其中的痛点,提供了更高级、更易用的功能。它是一种目前非常流行的语言。


基础语法对比
以下是几个核心编程概念在C语言和Python中的对比,我们可以看到Python语法是如何简化的。

1. 获取用户输入

在Scratch中,我们使用“询问并等待”积木块。在C语言中,这需要调用get_string函数并配合printf进行格式化输出。
在Python中,我们可以使用input函数直接获取输入,并使用加号+进行字符串连接:
answer = input("What's your name? ")
print("hello, " + answer)
另一种更现代、更推荐的方式是使用格式化字符串(f-string):
answer = input("What's your name? ")
print(f"hello, {answer}")
这里的f前缀告诉Python这是一个需要格式化的字符串,花括号{}内的变量名会被其实际值替换。
2. 变量与递增
在C语言中声明和递增一个计数器变量:
int counter = 0;
counter = counter + 1; // 或 counter += 1; 或 counter++;
在Python中,代码更加简洁,无需声明类型,并且使用+=进行递增(Python没有++运算符):
counter = 0
counter = counter + 1 # 或 counter += 1


3. 条件语句
C语言中的条件语句需要括号、花括号和分号:
if (x < y) {
printf("x is less than y\n");
}
在Python中,我们使用冒号:和缩进来定义代码块,括号和分号都消失了:
if x < y:
print("x is less than y")
缩进在Python中至关重要,它取代了花括号的作用,用于界定代码块的范围。
对于if-else if-else结构,Python使用elif关键字:
if x < y:
print("x is less than y")
elif x > y:
print("x is greater than y")
else:
print("x is equal to y")
4. 循环

实现一个无限循环,在C语言中:
while (true) {
printf("hello, world\n");
}
在Python中,布尔值True和False需要大写:
while True:
print("hello, world")
实现一个执行特定次数的循环,在C语言中通常需要初始化、条件和递增三个步骤:
for (int i = 0; i < 3; i++) {
printf("hello, world\n");
}
在Python中,for循环通常与range()函数一起使用,语法更加直观:
for i in range(3):
print("hello, world")
range(3)会生成一个序列[0, 1, 2]。range()函数非常灵活,例如,要生成0到100之间的所有偶数,可以写为:
for i in range(0, 101, 2):
print(i)
这里的三个参数分别是起始值、终止值(不包含)和步长。

Python的数据类型与库

上一节我们对比了基础语法。本节中我们来看看Python内置的数据类型以及如何使用外部库。
Python的数据类型
Python是一种动态强类型语言。这意味着变量在赋值时无需显式声明类型,解释器会自动推断,但类型本身是严格存在的。
以下是Python中的一些核心数据类型:
bool: 布尔值,True或False(注意首字母大写)。float: 浮点数,即带小数的实数。int: 整数。str: 字符串,这是Python中真正的一级数据类型,功能强大。list: 列表,类似于数组,但可以动态调整大小。tuple: 元组,不可变的有序序列,常用于存储一组相关的值(如坐标)。dict: 字典,存储键值对映射的集合。set: 集合,存储不重复元素的无序集合。
使用库函数

在C语言中,我们使用#include 来引入CS50库以使用get_string等函数。



在Python中,我们使用import语句。可以导入整个模块,也可以导入特定的函数:
# 方法一:导入整个cs50模块,使用时需加前缀
import cs50
answer = cs50.get_string("What's your name? ")

# 方法二:从cs50模块中导入特定的get_string函数
from cs50 import get_string
answer = get_string("What's your name? ")

# 方法三:导入多个函数
from cs50 import get_string, get_int
为了从C语言平稳过渡,本周我们会使用CS50库。但最终,我们会使用Python原生的input()等函数来编写代码。


Python的强大之处:站在巨人的肩膀上
前面我们学习了Python的基础。本节中我们来看看Python如何通过利用他人编写好的库,让我们用极少的代码完成复杂任务。
示例一:图像模糊


在之前的课程中,模糊一张图片需要手动处理每个像素,计算周围像素的平均值,代码相当复杂。

在Python中,利用PIL(Python Imaging Library)库,只需几行代码即可实现:
from PIL import Image, ImageFilter

# 打开图片
before = Image.open("bridge.bmp")
# 应用模糊滤镜,参数代表模糊半径
after = before.filter(ImageFilter.BoxBlur(10))
# 保存图片
after.save("out.bmp")
这段代码借助库函数,抽象掉了所有底层细节,让我们可以专注于解决问题的逻辑。


示例二:拼写检查器
回顾之前用C语言实现的拼写检查器,我们需要手动管理哈希表、链表和内存。
用Python实现核心的字典功能则简洁得多:
# 初始化一个集合来存储单词(自动去重)
words = set()
def load(dictionary):
"""从字典文件加载单词到内存"""
file = open(dictionary, "r")
for line in file:
word = line.rstrip() # 去掉行尾换行符
words.add(word) # 添加到集合
file.close()
return True

def check(word):
"""检查单词是否在字典中"""
return word.lower() in words # 转换为小写后检查是否存在

def size():
"""返回字典中的单词数量"""
return len(words)
def unload():
"""‘卸载’字典(Python自动管理内存,此处只需返回True)"""
return True
Python的set数据结构自动处理了去重和高效查找,字符串方法(如.lower(), .rstrip())让处理文本变得异常简单,并且无需手动进行内存管理。
性能权衡


需要注意的是,这种便利性有时会带来性能上的代价。在上述拼写检查器的例子中,用C语言精心优化的版本可能比等价的Python版本运行得更快。




这是因为Python是一种解释型语言。运行Python程序时,有一个叫做“解释器”的程序会逐行读取你的源代码,并动态地将其翻译成计算机能理解的指令。这个过程比直接运行编译好的C语言机器码要慢。




而C语言是编译型语言,源代码会先被clang编译器一次性全部转换成高效的机器码,然后直接由CPU执行。

这体现了计算机科学中一个永恒的权衡:开发效率 vs 运行效率。Python通过牺牲一些运行速度,换来了更高的开发效率和更简洁的代码。对于许多现代应用(如Web开发、数据分析、人工智能)来说,开发效率的提升远比那一点运行时的开销重要。
实践:编写一个加法程序

让我们通过一个完整的例子来巩固所学。我们将编写一个提示用户输入两个数字并进行加法的程序,并逐步去掉CS50库的“辅助轮”。


首先,使用CS50库的版本:
from cs50 import get_int

# 提示用户输入x
x = get_int("x: ")
# 提示用户输入y
y = get_int("y: ")

# 执行加法并打印结果
print(x + y)

现在,我们改用Python原生的input()函数。但要注意,input()始终返回字符串(str),我们需要用int()函数将其转换为整数:
# 获取输入,返回的是字符串
x = input("x: ")
y = input("y: ")


# 将字符串转换为整数
x = int(x)
y = int(y)

# 现在可以执行加法
print(x + y)
如果用户输入的不是有效的整数(例如输入了“cat”),int()函数会抛出ValueError错误。CS50库中的get_int()帮我们处理了这种错误检查,而使用原生方法则需要我们自己编写额外的代码来验证输入,这再次体现了使用库的便利性。


此外,在Python中进行除法运算时,即使两个操作数都是整数,结果也会是浮点数,避免了C语言中的整数截断问题:
z = x / y # 即使x和y是int,z也会是float
总结
本节课中我们一起学习了Python编程语言的基础。我们从与C语言的对比入手,看到了Python在语法上的巨大简化:去除了分号、花括号,使用缩进定义代码块,让代码更加清晰。

我们学习了Python的核心概念,包括变量、数据类型(int, str, list, dict, set等)、条件语句(if/elif/else)、循环(while, for ... in range())以及函数的定义和使用。

更重要的是,我们体验了Python生态系统的强大。通过import使用丰富的第三方库,我们可以用极少的代码完成如图像处理、拼写检查等复杂任务,这极大地提升了开发效率。我们也了解了这种便利性背后“解释执行”所带来的性能权衡。

Python是一门注重可读性和开发效率的语言,它的设计哲学是“用一种方法,最好是只有一种方法来做一件事”。写出符合这种哲学的代码,通常被称为具有“Pythonic”风格。在接下来的学习中,我们将继续探索Python的更多功能,并运用它来解决实际问题。
哈佛 CS50-CS 13:Python从语法到应用实战 2 🐍
在本节课中,我们将学习如何将之前用C语言编写的程序翻译成Python代码。我们将从简单的条件判断开始,逐步深入到循环、函数、数据结构(如列表和字典)以及文件操作。通过对比C和Python的语法差异,你将更深入地理解Python的简洁性和强大功能。课程最后,我们还将探索一些使用Python库实现的酷炫应用,如语音合成、面部识别和二维码生成。
条件判断的翻译
上一节我们回顾了Python的基本语法,本节中我们来看看如何将C语言中的条件判断结构翻译成Python。
首先,我们打开一个来自第一周的C程序 conditions.c。这个程序的目的是获取用户输入的两个整数 x 和 y,然后比较它们的大小并打印相应的信息。
以下是将其翻译成Python代码的步骤:
- 导入必要的库。
- 获取用户输入。
- 使用
if-elif-else结构进行比较。

# 导入cs50库以使用get_int函数
from cs50 import get_int

# 获取用户输入
x = get_int("x: ")
y = get_int("y: ")


# 条件判断
if x < y:
print("x is less than y")
elif x > y:
print("x is greater than y")
else:
print("x is equal to y")
注意:在导入库时,可以选择只导入特定函数(from cs50 import get_int),也可以导入整个库并指定命名空间(import cs50)。后者在避免函数名冲突时很有用。
处理用户同意输入
接下来,我们翻译一个询问用户是否同意的程序。在C语言中,我们需要检查用户输入的是“y”、“Y”、“n”还是“N”。在Python中,我们可以更简洁地处理字符串比较,并利用列表和 in 关键字来简化大小写检查。
以下是实现该功能的Python代码:
from cs50 import get_string
s = get_string("Do you agree? ")
# 将输入转换为小写,然后检查是否在同意列表中
if s.lower() in ["y", "yes"]:
print("Agreed.")
elif s.lower() in ["n", "no"]:
print("Not agreed.")
核心概念:Python中没有单独的字符(char)类型,所有文本都是字符串。使用 字符串.lower() 方法可以忽略大小写差异。元素 in 列表 语法用于检查元素是否存在于列表中。
循环结构:打印“Meow”
现在,我们来看看循环结构。在C语言中,我们使用 for 循环来重复执行操作。在Python中,for 循环的语法更加简洁。

假设我们要打印三次“Meow”,在Python中可以这样实现:

for i in range(3):
print("meow")


如果我们想将打印“Meow”的功能抽象成一个函数,并在主函数中调用它三次,代码结构如下:
def main():
for i in range(3):
meow()
def meow():
print("meow")

# 调用主函数
if __name__ == "__main__":
main()


重要:在Python中,函数必须在其被调用之前定义。常见的做法是定义一个 main() 函数,并在脚本末尾通过 if __name__ == "__main__": 来调用它。这确保了代码的结构清晰,并且只有在直接运行该脚本时才会执行 main() 函数。


模拟 Do-While 循环
Python没有直接的 do-while 循环结构,但我们可以使用 while True 循环配合 break 语句来模拟其行为。

以下是一个获取正整数的程序,它要求用户至少输入一次,直到输入为正数为止:
from cs50 import get_int

def main():
i = get_positive_int()
print(i)
def get_positive_int():
while True:
n = get_int("Positive Integer: ")
if n > 0:
break
return n
if __name__ == "__main__":
main()
代码解释:while True: 创建一个无限循环。在循环内部,我们获取用户输入。如果输入满足条件(n > 0),则使用 break 语句跳出循环,并返回该值。
打印图案与字符串控制

在打印水平图案时,我们需要控制 print 函数是否自动换行。print 函数有一个名为 end 的参数,默认值为 \n(换行)。我们可以通过修改 end 参数来改变结束符。

例如,要在一行中打印四个问号,可以这样做:


for i in range(4):
print("?", end="")
print() # 打印一个换行,使提示符出现在下一行

更简洁的方法是使用字符串乘法:
print("?" * 4)
要打印一个3x3的网格,可以使用嵌套循环,并注意内层循环不换行,外层循环换行:
for i in range(3):
for j in range(3):
print("#", end="")
print()
列表与平均值计算
Python的列表(list)比C语言的数组更强大,它可以动态增长和缩小。我们可以轻松地使用列表来存储和计算一组数字的平均值。
以下是计算一组分数平均值的示例:
# 定义一个分数列表
scores = [72, 73, 33]
# 计算平均值
average = sum(scores) / len(scores)
print(f"Average: {average}")
我们也可以动态地从用户那里获取分数并添加到列表中:


from cs50 import get_int
scores = []
for i in range(3):
score = get_int("Score: ")
scores.append(score) # 使用append方法添加元素到列表末尾

average = sum(scores) / len(scores)
print(f"Average: {average}")

字典:电话簿示例

字典(dict)是Python中非常强大的数据结构,它存储键值对,允许我们通过键快速查找对应的值。


以下是一个简单的电话簿程序:


from cs50 import get_string
# 定义一个字典,名字是键,电话号码是值
people = {
"Brian": "+1-617-495-1000",
"David": "+1-949-468-2750"
}
name = get_string("Name: ")
# 检查名字是否在字典的键中
if name in people:
number = people[name] # 通过键获取值
print(f"Number: {number}")
else:
print("Not found")
核心概念:字典使用花括号 {} 定义,键值对用冒号 : 分隔。键 in 字典 用于检查键是否存在。使用 字典[键] 可以访问对应的值。字典在底层通常使用哈希表实现,提供了高效的查找性能。




命令行参数


在Python中,我们可以通过 sys 模块访问命令行参数,类似于C语言中的 argc 和 argv。
以下是一个处理命令行参数的程序:
import sys
# sys.argv 是一个包含命令行参数的列表
# sys.argv[0] 是脚本的名称
if len(sys.argv) == 2:
print(f"hello, {sys.argv[1]}")
else:
print("hello, world")


要遍历所有命令行参数,可以这样做:

import sys

for arg in sys.argv:
print(arg)

文件操作:读写CSV


Python的 csv 库使得读写CSV文件变得非常简单。CSV(逗号分隔值)是一种常见的电子表格文件格式。


以下是一个将姓名和电话号码写入CSV文件的示例:

import csv
from cs50 import get_string


# 以追加模式打开文件
with open("phonebook.csv", "a") as file:
writer = csv.writer(file)
name = get_string("Name: ")
number = get_string("Number: ")
writer.writerow([name, number]) # 写入一行数据
# 文件会在with块结束后自动关闭

要读取CSV文件并处理数据,例如统计不同选项的数量,可以这样做:
import csv
houses = {
"Gryffindor": 0,
"Hufflepuff": 0,
"Ravenclaw": 0,
"Slytherin": 0
}
with open("Sorting Hat Responses.csv", "r") as file:
reader = csv.reader(file)
next(reader) # 跳过标题行
for row in reader:
house = row[1] # 假设房屋信息在第二列
houses[house] += 1
for house in houses:
count = houses[house]
print(f"{house}: {count}")
使用外部库实现高级功能

Python拥有丰富的第三方库,可以轻松实现复杂的功能,而无需从头编写所有代码。


语音合成示例:



import pyttsx3



engine = pyttsx3.init()
engine.say("hello, world")
engine.runAndWait()

面部识别示例(需要安装 face_recognition 库):
import face_recognition
from PIL import Image



# 加载图片并识别人脸
image = face_recognition.load_image_file("office.jpg")
face_locations = face_recognition.face_locations(image)
# 处理每个识别到的人脸
for face_location in face_locations:
top, right, bottom, left = face_location
face_image = image[top:bottom, left:right]
pil_image = Image.fromarray(face_image)
pil_image.show()


生成二维码示例:
import qrcode
# 创建二维码
img = qrcode.make("https://www.example.com")
img.save("qr.png")


语音识别示例:
import speech_recognition as sr


recognizer = sr.Recognizer()
with sr.Microphone() as source:
print("Say something:")
audio = recognizer.listen(source)
try:
words = recognizer.recognize_google(audio)
print(f"You said: {words}")
except sr.UnknownValueError:
print("Could not understand audio")
总结


本节课中我们一起学习了如何将C语言的核心编程概念转化为Python代码。我们涵盖了条件判断、循环、函数、列表、字典、命令行参数和文件操作。通过对比,我们看到了Python语法更加简洁、接近自然语言,并且拥有强大的内置数据结构和丰富的第三方库,使得实现复杂功能变得更加容易。从基础的数据处理到高级的图像和语音识别,Python为我们提供了构建现代应用程序的强大工具集。
哈佛 CS50-CS 14:L7- 数据库与SQL知识体系 1









📚 概述

在本节课中,我们将要学习如何从简单的数据收集和处理,过渡到使用更强大的关系数据库。我们将从分析一个关于最喜爱电视剧的CSV文件开始,探讨平面文件数据库的局限性,并最终引入SQLite和SQL语言,学习如何高效地存储、查询和管理数据。


从数据收集到CSV文件
上一节我们介绍了如何使用Python处理数据。本节中我们来看看如何从零开始收集数据并进行分析。
我们通过一个谷歌表单收集了大家最喜爱的电视剧及其类型信息。表单收集的数据会自动存储在一个谷歌电子表格中。
为了能在自己的程序中使用这些数据,我们将其导出为CSV(逗号分隔值)文件。CSV文件是一种简单的文本文件,每行代表一条记录,每个值用逗号分隔。




电子表格与平面文件数据库

电子表格(如Google Sheets, Excel)是处理数据的常见工具。它们擅长快速排序、筛选和存储适量数据。然而,当数据量变得非常庞大时,电子表格可能会遇到性能瓶颈或行数限制。


平面文件数据库,如CSV文件,将数据以纯文本形式存储。它们具有很好的可移植性,可以被多种程序和语言读取。但是,它们缺乏高级功能,并且在处理包含逗号本身的数据时可能遇到解析问题。解决方案是用双引号将包含逗号的字段括起来。
以下是一个CSV文件的示例结构:
时间戳,标题,类型
2023-10-26 10:00:00,办公室,喜剧
2023-10-26 10:01:00,绝命毒师,剧情,犯罪

使用Python分析CSV数据

有了CSV文件后,我们可以使用Python来分析和处理数据。Python的csv库让读取CSV文件变得非常简单。


以下是读取CSV文件并打印所有电视剧标题的Python代码:
import csv

with open(“最喜欢的电视节目.csv”, “r”) as file:
reader = csv.DictReader(file)
for row in reader:
print(row[“标题”])



使用DictReader可以让我们通过列名(如“标题”)而不是列索引来访问数据,这样即使列的顺序发生变化,代码也不会出错。

数据清洗与规范化



在处理真实世界的数据时,我们经常会遇到不一致的情况,例如大小写不统一或存在多余空格。这会导致在统计时,本应相同的数据被当作不同的条目处理。


为了解决这个问题,我们需要在分析前对数据进行清洗和规范化。这意味着将数据转换为统一的标准格式。




以下是改进后的代码,它在添加标题到集合前,先进行去除空格和转换为大写操作:
import csv

titles = set()



with open(“最喜欢的电视节目.csv”, “r”) as file:
reader = csv.DictReader(file)
for row in reader:
title = row[“标题”].strip().upper()
titles.add(title)



for title in sorted(titles):
print(title)
strip(): 去除字符串两端的空白字符。upper(): 将字符串转换为大写。



统计节目受欢迎程度



仅仅列出唯一标题还不够,我们通常更关心每个节目的受欢迎程度,即它被提及的次数。



为了统计频率,集合不再适用,因为它会丢弃重复项。此时,字典(Dictionary)是一个理想的数据结构。我们可以将节目标题作为键(Key),将该标题出现的次数作为值(Value)。



以下是使用字典统计节目出现次数的代码:
import csv





titles = {} # 创建一个空字典


with open(“最喜欢的电视节目.csv”, “r”) as file:
reader = csv.DictReader(file)
for row in reader:
title = row[“标题”].strip().upper()
if title not in titles:
titles[title] = 0
titles[title] += 1
for title in sorted(titles, key=lambda title: titles[title], reverse=True):
print(title, titles[title])
titles = {}: 创建一个空字典。if title not in titles: 检查该标题是否已经是字典的键。titles[title] += 1: 增加该标题的计数。sorted(…, key=lambda title: titles[title], reverse=True): 这是一个排序技巧。key参数指定排序依据,这里我们使用lambda函数告诉sorted函数根据每个标题对应的值(即计数)进行排序。reverse=True表示降序排列,最受欢迎的节目排在最前面。



平面文件数据库的局限性
我们刚刚编写的查询程序有一个效率问题。无论我们想查找哪个节目的受欢迎程度,程序都需要从头到尾遍历整个CSV文件。这在计算机科学中被称为**O(n)**时间复杂度,意味着运行时间随着数据记录数(n)的增长而线性增长。




对于小型数据集,这没有问题。但对于像IMDb这样拥有海量数据的情况,这种线性搜索的效率就太低了。我们希望能以更快的速度,例如常数时间O(1),来回答查询。



引入关系数据库与SQL





为了解决上述问题,我们引入关系数据库。它也是一种以表格(行和列)形式存储数据的系统,但它是通过一个专门的程序(数据库管理系统)来管理数据文件。


SQLite是一种轻量级、广泛使用的关系数据库,尤其常见于移动应用程序中。与数据库交互的语言叫做SQL(结构化查询语言)。


关系数据库通常支持四种核心操作,可以用缩写CRUD来记忆:
- Create(创建)
- Read(读取)
- Update(更新)
- Delete(删除)


将CSV数据导入SQLite



在使用SQLite之前,我们需要将数据从CSV文件导入到SQLite数据库文件中。SQLite提供了一个命令行工具sqlite3来完成这个操作。


以下是导入CSV文件并创建名为shows的表的步骤:
- 在终端运行
sqlite3进入交互环境。 - 设置模式为CSV:
.mode csv - 导入文件并创建表:
.import “最喜欢的电视节目.csv” shows



导入后,我们可以使用 .schema 命令查看自动创建的表结构,它会显示列名(如时间戳,标题,类型)及其数据类型。


总结



本节课中我们一起学习了数据处理的全流程。我们从收集数据开始,将其存储在CSV平面文件数据库中,并使用Python进行清洗、规范和基础分析。我们认识到对于大规模数据,平面文件在查询效率上的局限性。最后,我们引入了关系数据库的概念和SQLite工具,并成功将CSV数据导入其中,为下一节课学习强大的SQL查询语言打下了基础。
哈佛 CS50-CS 15:L7- 数据库与SQL知识体系 2 🗄️




概述

在本节课中,我们将深入学习SQL数据库的核心操作,包括如何从数据库中查询数据、使用函数和子句来过滤和排序结果。我们还将探讨如何通过创建多个表来更好地组织数据,理解主键和外键的概念,并初步了解如何通过索引提升查询性能。最后,我们将讨论SQL注入攻击和竞争条件这两个重要的安全问题。






从数据库查询数据
上一节我们介绍了如何将CSV数据导入数据库并创建表。本节中,我们来看看如何使用SQL从这些表中获取数据。
SQL中的SELECT命令相当于“读取”操作,它允许你从指定的表中选择一个或多个列的数据。这个功能非常强大,许多数据科学家和统计学家都喜欢使用SQL来处理、过滤和分析数据。
以下是SELECT命令的基本语法:
SELECT column_name FROM table_name;
按照惯例,SQL关键字(如SELECT、FROM)通常使用大写,而列名和表名使用小写。


例如,要获取shows表中所有节目的标题,可以执行:
SELECT title FROM shows;
这将返回shows表中title列的所有数据。



如果你想查看表中的所有列,可以使用星号*作为通配符:
SELECT * FROM shows;
这将返回shows表中从左到右的所有列。


使用函数处理数据
SQL内置了许多有用的函数,可以在查询时改变返回数据的格式,类似于Excel中的函数。

例如,如果我们想获取所有不重复的节目标题,可以使用DISTINCT函数:
SELECT DISTINCT title FROM shows;
DISTINCT函数会过滤掉所有重复的标题,只返回唯一值。

我们还可以嵌套函数。例如,先将所有标题转换为大写,再获取唯一值:
SELECT DISTINCT UPPER(title) FROM shows;
UPPER()函数会将文本转换为大写。通过函数组合,我们可以对数据进行标准化处理。



使用子句精确查询


除了函数,SQL还提供了多种子句(Clause)来使查询更加精确和强大。

以下是几个常用的子句:
WHERE:用于指定查询条件,类似于编程中的if语句。ORDER BY:用于按指定列对结果进行排序。LIMIT:用于限制返回的行数。GROUP BY:用于将相同的值分组,常与聚合函数(如COUNT)一起使用。


让我们看几个例子。

使用WHERE子句进行条件过滤。例如,查找标题为“The Office”的节目:
SELECT title FROM shows WHERE title = ‘The Office’;
为了进行更宽松的匹配(例如包含“office”的标题),可以使用LIKE操作符和通配符%:
SELECT title FROM shows WHERE title LIKE ‘%office%’;
%表示零个或多个任意字符。LIKE操作符在默认情况下是大小写不敏感的。


使用ORDER BY对结果进行排序。例如,按大写后的标题字母顺序排序:
SELECT DISTINCT UPPER(title) FROM shows ORDER BY UPPER(title);
使用GROUP BY和COUNT进行分组计数。例如,统计每个节目被喜欢的次数:
SELECT UPPER(title), COUNT(title) FROM shows GROUP BY UPPER(title);
COUNT()是一个聚合函数,用于计算行数。GROUP BY UPPER(title)会将所有大写标题相同的行合并,并计算每组的数量。


我们可以在此基础上按计数降序排列,并只查看前10个结果:
SELECT UPPER(title), COUNT(title) FROM shows GROUP BY UPPER(title) ORDER BY COUNT(title) DESC LIMIT 10;
DESC表示降序(Descending),ASC表示升序(Ascending,默认)。




设计更好的数据库结构
目前,我们的shows表中有一个genre列,里面存放着用逗号分隔的多种类型(如“喜剧,剧情”)。这种设计在SQL中并不理想,因为它使得查询变得复杂(例如,查找所有“音乐剧”时需要处理字符串匹配)。

关系数据库的核心优势在于通过多个表以及表之间的关系来规范化数据,消除冗余。

我们建议设计两个表:
shows表:包含id(主键)和title两列。genres表:包含show_id(外键)和genre两列。



这样,一个节目(如ID为1的“The Office”)的多种类型(喜剧、剧情)就会在genres表中表示为多行独立的记录(1,喜剧、1,剧情)。这被称为“一对多”关系。
主键是表中唯一标识每一行的列。
外键是另一个表中引用了主键的列,用于建立表与表之间的关联。



使用Python操作SQL数据库
手动输入SQL命令来插入大量数据效率很低。我们可以编写Python脚本,使用库(如CS50库)来连接并操作SQLite数据库。
以下是一个示例代码框架,展示了如何创建表、读取CSV文件并将数据插入到我们新设计的两个表中:
import csv
from cs50 import SQL

# 打开(或创建)数据库连接
db = SQL(“sqlite:///shows.db”)
# 创建 shows 表
db.execute(“CREATE TABLE shows (id INTEGER, title TEXT, PRIMARY KEY(id))”)
# 创建 genres 表,并设置外键关联
db.execute(“””
CREATE TABLE genres (
show_id INTEGER,
genre TEXT,
FOREIGN KEY(show_id) REFERENCES shows(id)
)
“””)
# 打开CSV文件
with open(“favorites.csv”, “r”) as file:
reader = csv.DictReader(file)
for row in reader:
title = row[“title”].strip().upper() # 清理数据
# 插入节目,并获取自动生成的主键id
show_id = db.execute(“INSERT INTO shows (title) VALUES(?)”, title)
# 分割类型字符串,并逐个插入到genres表
for genre in row[“genre”].split(“, “):
db.execute(“INSERT INTO genres (show_id, genre) VALUES(?, ?)”, show_id, genre)
在这段代码中:
?是占位符,用于安全地插入变量值,防止SQL注入攻击。- 将
shows表的id列设为主键后,数据库会自动为其生成唯一的、递增的整数值。 - 通过
FOREIGN KEY语句,我们建立了genres.show_id对shows.id的引用关系。


连接多个表进行查询

当数据分布在多个表中时,我们需要使用JOIN操作来根据关联关系重新组合数据。


例如,我们想找出所有由“Steve Carell”主演的节目。这涉及到people、stars和shows三个表。
SELECT title FROM people
JOIN stars ON people.id = stars.person_id
JOIN shows ON stars.show_id = shows.id
WHERE name = ‘Steve Carell’;
这条查询语句的逻辑是:
- 将
people表与stars表连接,连接条件是人的ID相等。 - 将连接后的结果再与
shows表连接,连接条件是节目的ID相等。 - 最后,从整个连接结果中筛选出名字为“Steve Carell”的行,并选择标题。


使用索引提升性能

随着数据量增大,查询速度可能会变慢,因为默认情况下数据库是线性扫描(时间复杂度O(n))。为了获得对数级(O(log n))的查询效率,我们可以创建索引。



索引是数据库在后台创建的一种高效数据结构(如B树),可以大大加快基于特定列的搜索速度。


例如,为people表的name列创建索引:
CREATE INDEX person_index ON people (name);
创建索引可能需要一些时间,但这是一次性的开销。之后,所有基于name列的查询(如WHERE name = ‘…’)都会快得多。



安全与并发问题



SQL注入攻击
如果直接将用户输入拼接到SQL查询字符串中,恶意用户可能输入特殊字符来改变查询逻辑,从而未经授权访问或破坏数据。这被称为SQL注入攻击。






危险的做法(Python f-string):
db.execute(f“SELECT * FROM users WHERE username = ‘{username}’ AND password = ‘{password}’“)
如果用户输入用户名 malan@harvard.edu’--,--在SQL中是注释符,会导致密码检查被忽略,从而绕过登录。


安全的做法(使用占位符):
db.execute(“SELECT * FROM users WHERE username = ? AND password = ?”, username, password)
使用问号?作为占位符,让数据库库来处理参数的插入,可以完全防止SQL注入。

竞争条件
当多个操作(如两个用户同时给一个帖子点赞)几乎同时尝试读取和更新同一个数据时,可能会发生竞争条件。
例如,点赞的逻辑是:
SELECT likes FROM posts WHERE id = 1;(读取当前点赞数,假设是50)- 在代码中将点赞数加1,得到51。
UPDATE posts SET likes = 51 WHERE id = 1;(更新点赞数)
如果两个请求交错执行,可能最终点赞数只增加了1,而不是2。

解决方案是使用事务。事务可以确保一系列数据库操作要么全部成功,要么全部失败,并且在操作过程中可以锁定相关数据行,防止其他操作干扰。
BEGIN TRANSACTION;
-- 执行一系列SELECT和UPDATE操作
COMMIT;










总结



本节课我们一起深入学习了SQL数据库的核心知识。我们掌握了使用SELECT语句配合WHERE、ORDER BY、GROUP BY等子句以及DISTINCT、COUNT、UPPER等函数来查询和操作数据。我们理解了通过创建多个表并使用主键和外键来规范化数据库设计的重要性。我们还初步了解了如何使用Python程序连接和操作数据库,以及如何通过创建索引来优化查询性能。最后,我们探讨了SQL注入和竞争条件这两个关键的安全与并发问题及其防范措施。SQL是一种强大而表达力丰富的语言,是处理结构化数据的基石。
哈佛 CS50-CS 16:L8- 网络编程全指南(网络协议、HTML、CSS、JavaScript) 🕸️





在本节课中,我们将要学习网络编程的基础知识,包括互联网的工作原理、核心协议(如TCP/IP和HTTP),以及用于构建网页的三种核心语言:HTML、CSS和JavaScript。我们将从底层的数据传输机制开始,逐步过渡到如何创建美观且交互性强的网页。
🌐 互联网与网络协议






上一节我们介绍了命令行程序,本节中我们来看看图形化的网页世界。为了理解网页如何运作,我们首先需要了解其运行的基础设施——互联网。



互联网本质上是一个网络的网络,由全球范围内相互连接的计算机(路由器)构成。数据通过这些路由器从一点传输到另一点,路径可能动态变化。
为了让这些计算机能够通信,人类制定了标准化的协议。协议是一套约定,规定了计算机之间交换信息的格式和顺序。






核心协议:TCP/IP


在互联网中,两个最关键的协议是TCP(传输控制协议)和IP(互联网协议),它们通常被合称为 TCP/IP。




- IP协议负责寻址。它为互联网上的每台设备分配一个唯一的IP地址(例如
192.168.1.1),就像信封上的收件人地址。当数据需要发送时,发送方会在“数据包”(可以理解为数字信封)上写上目标IP地址。 - TCP协议负责可靠传输和多路复用。它确保数据包完整、有序地到达目的地,如果中途丢失会重新发送。同时,它使用端口号(如
80用于网页,443用于安全网页)来让一台服务器同时处理多种服务(如网页、邮件)。

为了将人类友好的域名(如 harvard.edu)转换为机器使用的IP地址,我们使用 DNS(域名系统)。





数据传输模拟:
想象用户A(浏览器)向服务器B请求一张猫的图片:
- A通过DNS查找B的IP地址。
- A创建一个数据包,外部写上B的IP地址和端口号
80,内部写上请求内容(如“GET /cat.jpg”)。 - 数据包经过多个路由器转发,最终到达B。
- B处理请求,将图片数据分割成多个数据包(每个都有序列号),按相同路径发回给A。
- A根据序列号重新组装数据包,得到完整图片。



🌍 万维网与HTTP协议




互联网是底层管道,而万维网(Web)是运行在其上的一个最流行的应用。它使用 HTTP(超文本传输协议)来规范浏览器和服务器之间“信封”内部的内容。





HTTP请求与响应








当你在浏览器地址栏输入一个URL并按下回车时,浏览器会向服务器发送一个HTTP请求。服务器处理后会返回一个HTTP响应。







一个典型的HTTP请求格式如下:
GET /index.html HTTP/1.1
Host: www.example.com
其中 GET 是方法(动词),表示请求获取资源。POST 是另一个常用方法,用于提交数据(如表单),其数据不会显示在URL中,相对更安全。









服务器的响应以状态码开头:
- 200 OK:请求成功。
- 301 Moved Permanently:资源已永久重定向到新URL。
- 404 Not Found:请求的资源未找到。
- 500 Internal Server Error:服务器内部错误。







一个响应示例如下:
HTTP/1.1 200 OK
Content-Type: text/html



<!DOCTYPE html><html>...</html>

查看网络请求

现代浏览器的开发者工具(如Chrome的“Network”标签)允许你实时查看浏览器发送和接收的所有HTTP请求与响应,包括头信息和状态码,是调试和理解网络通信的利器。


📄 网页结构:HTML






现在我们知道如何请求和接收数据了。当请求一个网页时,服务器返回的通常是 HTML(超文本标记语言)文档。HTML不是编程语言,而是一种标记语言,用于定义网页的结构和内容。


HTML基础:标签与属性




HTML由标签(元素)构成,标签通常成对出现(开始标签和结束标签),并可以拥有属性来提供额外信息。





以下是一个最简单的HTML页面:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Hello, Title</title>
</head>
<body>
Hello, Body
</body>
</html>
<!DOCTYPE html>声明文档类型为HTML5。<html>是根元素,lang="en"属性表示页面语言为英语。<head>包含元信息,如标题<title>(显示在浏览器标签页上)。<body>包含页面的主体内容,即用户看到的区域。




浏览器会解析HTML文档,在内存中构建一个树形结构,称为 DOM(文档对象模型)。





常用HTML标签



以下是构建网页内容的一些基本标签:



- 标题:
<h1>到<h6>,表示不同级别的标题。 - 段落:
<p>定义一个段落。 - 列表:
- 无序列表
<ul>,列表项<li>。 - 有序列表
<ol>,列表项<li>。
- 无序列表
- 图像:
<img src="image.jpg" alt="描述文字">。src指定图片来源,alt提供替代文本(对可访问性至关重要)。 - 链接:
<a href="https://example.com">可点击文本</a>。href指定链接目标。 - 表格:
<table>定义表格,<tr>定义行,<td>定义单元格。 - 表单:
<form action="/search" method="get">用于收集用户输入。包含输入框<input type="text">和提交按钮<input type="submit">。提交后,数据会以?key=value形式附加到URL(GET方法)或放在请求体中(POST方法)。


🎨 网页样式:CSS



HTML定义了网页的骨架,而 CSS(层叠样式表)则负责为其添加样式,控制布局、颜色、字体等视觉效果,实现美观的界面。






应用CSS的三种方式



- 内联样式:直接在HTML标签的
style属性中编写。<p style="color: red; text-align: center;">红色居中的段落</p> - 内部样式表:在HTML的
<head>中使用<style>标签定义。<style> p { color: blue; } </style> - 外部样式表(推荐):将CSS规则写入单独的
.css文件,然后在HTML中通过<link>标签引入。这实现了内容与样式的分离,便于维护和协作。<link rel="stylesheet" href="styles.css">





CSS选择器与属性








CSS通过选择器来定位要样式化的HTML元素,然后为其设置属性。









- 元素选择器:直接使用标签名。
p { color: green; } - 类选择器:使用点号
.开头,对应HTML标签的class属性。可重复使用。.center { text-align: center; } .large { font-size: large; }<p class="center large">既居中又大的段落</p> - ID选择器:使用井号
#开头,对应HTML标签的id属性。应确保唯一。#header { background-color: gray; } - 伪类选择器:用于定义元素的特殊状态,如鼠标悬停。
a:hover { text-decoration: underline; }






CSS属性示例:color(文字颜色),background-color(背景色),font-size(字体大小),text-align(对齐方式)。






⚡ 网页交互:JavaScript



HTML和CSS创造了静态的页面,而 JavaScript 是一种真正的编程语言,它运行在用户的浏览器(客户端)中,使得网页能够动态响应用户操作,变得交互式。




JavaScript基础语法



JavaScript的语法与C和Python有相似之处。
// 变量声明
let counter = 0;
counter++; // 自增



// 条件判断
if (x < y) {
// 执行代码
} else {
// 执行其他代码
}










// 循环
for (let i = 0; i < 3; i++) {
console.log(i);
}

// 函数定义
function greet(name) {
alert('Hello, ' + name);
}







操作DOM与处理事件



JavaScript的强大之处在于它能通过 DOM API 与HTML文档交互。






- 选择元素:使用
document.querySelector()或document.getElementById()。let nameInput = document.querySelector('#name'); // 选择id为‘name’的元素 - 读取/修改内容:通过元素的属性,如
.value(表单值)、.innerHTML(HTML内容)、.style(CSS样式)。let userName = nameInput.value; // 获取输入框的值 nameInput.style.color = 'red'; // 将输入框文字改为红色 - 事件处理:让代码响应用户行为,如点击、输入、提交表单。
代码监听了表单的let form = document.querySelector('form'); form.addEventListener('submit', function(event) { event.preventDefault(); // 阻止表单默认提交行为 let name = document.querySelector('#name').value; alert('Hello, ' + name); });submit事件,当用户提交时,会执行我们定义的函数(这里是一个匿名函数)。





更多可能性






通过JavaScript,你可以实现:
- 动态更新页面内容,无需重新加载(如自动完成搜索建议)。
- 制作动画和视觉效果。
- 与服务器进行异步通信(AJAX),获取新数据。
- 访问浏览器提供的API,如获取地理位置。




🎯 总结





本节课中我们一起学习了网络编程的完整基础链条:




- 基础设施:理解了互联网作为网络之网,以及 TCP/IP 协议如何实现数据的可靠寻址和传输。
- 应用协议:掌握了 HTTP 协议如何规范浏览器与服务器之间的通信,包括请求/响应模型和状态码。
- 网页结构:学习了使用 HTML 标签和属性来构建网页的语义化结构。
- 网页样式:探索了如何使用 CSS 选择器和属性来美化HTML元素,实现视觉设计。
- 网页交互:引入了 JavaScript,了解了其如何操作DOM、处理事件,从而为网页添加动态行为和复杂功能。






这三种语言(HTML、CSS、JS)各司其职,共同构成了现代网页开发的基石。从下周开始,我们将学习如何将这些前端技术与后端的Python服务器结合起来,构建功能完整的Web应用程序。
哈佛 CS50-CS 17:🔒【CS50网络安全讲座】如何保证电脑和手机的安全







在本节课中,我们将要学习网络安全的基础知识,特别是如何保护你的个人设备,如笔记本电脑、台式机和手机。我们将从理解“安全”的真正含义开始,探讨密码的强度、常见的攻击方式以及实用的防御策略。



概述:什么是安全?





上一节我们介绍了课程背景,本节中我们来看看“安全”在数字世界中的核心含义。安全并非一个绝对的概念,它更像是一种权衡。在现实世界中,你家门上的锁和窗户上的栅栏提供了不同层次的安全防护。数字世界也是如此,你的设备在某种程度上是安全的,这取决于攻击者需要投入的时间、金钱和技术能力。



我们的目标不是追求绝对的安全,而是提高攻击者的门槛,使破解你的系统变得不划算,从而在概率上保护自己。

密码:第一道防线

保护设备最常用的机制之一是密码。密码应该是一个只有你知道的短语或数字组合。然而,人类在选择密码时往往并不擅长。

以下是2019年底全球最常见的密码列表,这些密码极不安全:


- 123456
- 123456789
- qwerty
- password
- 1234567
- 12345678
- 12345
- iloveyou
- 111111
- 123123




如果你的密码在这个列表上,特别是用于银行、邮箱等重要账户,你应该立即更改它。


暴力破解攻击


密码不安全的核心原因之一是暴力破解攻击。攻击者会编写软件,系统地尝试所有可能的密码组合,直到猜中为止。


为了理解密码的强度,我们需要计算可能的密码组合数量。对于一个4位数字密码(每位可以是0-9),其可能性为:
10 * 10 * 10 * 10 = 10,000 种可能。

使用简单的Python代码,可以在瞬间尝试所有1万种组合:
from string import digits
from itertools import product

for passcode in product(digits, repeat=4):
print(‘’.join(passcode))


如何提升密码强度?

上一节我们看到了短数字密码的脆弱性,本节中我们来看看如何通过增加复杂性来提升密码强度。



使用字母可以大大增加可能性。如果使用4位字母密码(区分大小写),每位有52种可能(26个大写字母+26个小写字母),总可能性为:
52 * 52 * 52 * 52 ≈ 7,000,000 种可能。

这比纯数字密码安全得多。如果进一步结合数字和标点符号(共约94个可打印ASCII字符),4位密码的可能性将跃升至约 7800万 种。





然而,真正的安全性提升来自于增加密码长度。一个8位字符(字母、数字、符号)的密码,其可能性是:
94^8 ≈ 6,000,000,000,000,000(6千万亿)种可能。




这为攻击者设置了极高的时间成本门槛。密码越长、越随机,就越安全。


密码的权衡



增加密码长度和复杂性的主要权衡在于可用性。人类很难记住大量长而复杂的密码。这导致了一些不安全的行为,例如:
- 在多个网站重复使用同一密码。
- 将密码写在便利贴上或存储在未加密的文件中。

为了解决这个问题,我们引入了密码管理器和双因素认证等工具。



增强防御:超越密码



仅仅依靠密码是不够的。设备和软件提供了额外的机制来减缓攻击者。


登录尝试限制


一个常见的防御措施是登录尝试限制。例如,在手机或电脑上连续输入错误密码多次后,设备会被锁定一段时间(如1分钟、5分钟或更久)。

这个功能通过极大地增加攻击者的时间成本来阻止暴力破解。即使一个4位密码只有1万种可能,如果每次错误尝试后需要等待1分钟,那么尝试所有组合将需要近1万分钟。



当然,这也有缺点:如果你自己忘记了密码,也会被暂时锁在设备外,影响了可用性。


双因素认证 (2FA)




双因素认证要求你在登录时提供两种不同类型的凭证:
- 你知道的东西:如密码。
- 你拥有的东西:如手机上通过短信或认证应用(如Google Authenticator)收到的临时验证码。







即使攻击者获得了你的密码,他们也无法登录,除非他们同时能访问你的手机。你应该为重要的账户(如邮箱、银行、医疗账户)启用双因素认证。





密码管理器







密码管理器是一种软件,用于安全地存储和管理你所有账户的复杂密码。你只需要记住一个主密码来解锁管理器,管理器会为你生成、保存并自动填充其他网站的超长随机密码。




流行的密码管理器包括 1Password 和 LastPass。使用密码管理器的权衡在于:如果主密码被泄露,你所有的账户都将面临风险。因此,主密码必须极其强大且唯一,并妥善保管。

加密:保护数据传输
加密是将数据打乱(加密)成看似随机的形式,只有拥有正确密钥的人才能将其还原(解密)。它是网络安全的基石。
- HTTPS:当你访问网站时,应确保地址以
https://开头,这表示你与该网站之间的通信是加密的。 - 端到端加密:用于即时通讯(如 Signal、WhatsApp、iMessage)。这意味着只有对话的双方能解密信息,即使是服务提供商也无法读取。在涉及敏感对话时,应优先选择支持端到端加密的工具。





案例分析:Zoom安全吗?





让我们应用今天学到的概念,分析一个具体案例:Zoom视频会议安全吗?答案并非简单的“是”或“否”,而需要结合具体情境和威胁模型来评估。




- 会议ID与密码:早期Zoom会议仅通过一个数字ID即可加入,这容易被“遍历猜测”攻击。现在,Zoom默认要求会议密码,并提供了“等候室”功能,这大大提高了安全门槛。
- 端到端加密:Zoom曾因加密宣传问题受到批评。现在它已开始为所有用户提供真正的端到端加密选项,但这可能会牺牲一些会议功能(如云端录制)。
- 评估:Zoom的安全性取决于你的使用方式。对于公开讲座,使用密码和等候室已足够;对于高度机密的商业会谈,则应启用端到端加密。安全总是在安全性和可用性之间寻求平衡。




总结与进阶话题





本节课中我们一起学习了网络安全的核心原则。我们了解到安全是相对的,需要在安全性、可用性和成本之间做出权衡。我们探讨了如何通过使用长而复杂的密码、启用双因素认证、借助密码管理器以及利用加密技术来显著提升个人设备与账户的安全水平。







在最后的问答环节,我们还简要探讨了其他相关主题:
- 生物识别(如指纹、面部识别)提供了便利性,但并非绝对安全(例如双胞胎可能互相解锁)。
- Cookie 是网站用于追踪用户的小文件,定期清理或使用浏览器的隐私模式有助于保护隐私。
- VPN(虚拟专用网络) 可以加密你的网络流量,帮助在公共网络上保护数据,或访问地域限制内容。
- 社会工程学攻击 和 字典攻击 是除了暴力破解之外,攻击者常用的手段,提醒我们不要使用容易猜到的个人信息作为密码。





记住,关键不是寻找一个“绝对安全”的解决方案,而是培养一种安全思维:理解你所面临的威胁,并采取相应的、适度的措施来管理风险。不断提问、保持警惕,是保护自己在数字世界中安全的最佳方式。
哈佛 CS50-CS 18:L9- Flask网络请求与爬虫数据编程 1 🚀




在本节课中,我们将要学习如何将之前学过的Python、SQL等知识结合起来,构建动态的Web应用程序。我们将重点介绍Flask框架,它可以帮助我们轻松处理网络请求、路由和动态生成网页内容。
📚 概述:从静态网站到动态Web应用
上一周我们介绍了JavaScript,它允许我们在浏览器(客户端)进行编程。本周我们将重新引入服务器端组件,将浏览器(客户端)与Web服务器(后端)连接起来,构建完整的Web应用程序。

为了达到这个目标,我们先回顾一下上周的内容。上周我们使用了一个简单的http-server程序来提供静态网页,这些网页不会改变,也不接受用户输入。服务器监听80或443端口,当浏览器连接时,它会根据URL和参数提供相应的HTML、CSS或JavaScript文件。
然而,像Google搜索这样的功能,需要服务器动态生成内容。例如,URL http://www.google.com/search?q=cats 中,服务器需要解析路径 /search 和参数 q=cats,然后动态生成搜索结果页面。
🛠️ 引入Flask框架
今天我们将介绍一个名为Flask的库,它也被称为一个框架。框架不仅提供函数库,还规定了组织代码和文件的方式,使Web开发更加高效。

Flask存在的意义在于,它帮助我们解析复杂的HTTP请求,提取URL中的路由和参数,让我们可以专注于业务逻辑,而不是底层的文本解析工作。
Flask应用程序的基本结构

一个典型的Flask应用程序会包含以下文件和文件夹:
app.py: 这是我们的主程序文件,我们将在这里编写大部分Python代码(控制器)。requirements.txt: 一个文本文件,列出了应用程序所需的其他库。static/文件夹: 存放静态文件,如GIF、JPEG、CSS、JavaScript文件。templates/文件夹: 存放HTML模板文件(视图)。




Flask实现了**MVC(模型-视图-控制器)**设计模式:
- 控制器 (Controller): 在
app.py中编写的逻辑代码,控制应用程序流程。 - 视图 (View): 在
templates/文件夹中的HTML模板,负责用户界面。 - 模型 (Model): 指应用程序使用的数据,例如SQL数据库或CSV文件。
🧪 编写第一个Flask应用
让我们通过一个简单的“Hello World”示例来开始。首先,我们创建一个名为hello的目录,并在其中创建application.py文件。

以下是application.py的初始代码:

from flask import Flask
app = Flask(__name__)


@app.route("/")
def index():
return "Hello, world!"


这段代码做了以下几件事:
- 从
flask库导入Flask类。 - 创建一个Flask应用实例,
__name__是一个特殊变量,表示当前文件的名称。 - 使用
@app.route(“/”)装饰器定义了一个路由。当用户访问网站的根路径(/)时,将调用下面的index函数。 index函数返回字符串“Hello, world!”。

在终端中,进入hello目录,运行命令flask run启动服务器。访问输出的URL,你将在浏览器中看到“Hello, world!”。


🌐 处理用户输入:从URL获取参数


仅仅显示静态文本并不有趣。Web应用的核心之一是处理用户输入。最常见的方式之一是通过URL参数获取输入。


让我们修改应用,使其能够通过URL参数接收一个名字并问候用户。

首先,更新application.py:

from flask import Flask, render_template, request
app = Flask(__name__)


@app.route("/")
def index():
# 从URL参数中获取‘name’的值,如果没有则使用默认值‘world’
name = request.args.get("name", "world")
# 将name变量传递给模板
return render_template("index.html", name=name)

在这段代码中:
- 我们导入了
request对象,用于访问HTTP请求数据。 request.args.get(“name”, “world”)从URL的查询字符串(如?name=David)中获取名为name的参数。如果参数不存在,则使用默认值”world”。- 我们使用
render_template函数来渲染一个HTML模板,并将name变量传递给它。
接下来,我们需要创建模板。在hello目录下创建一个templates文件夹,并在其中创建index.html文件:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Hello</title>
</head>
<body>
<p>Hello, {{ name }}!</p>
</body>
</html>

注意模板中的 {{ name }}。这是Jinja2模板引擎的语法,Flask使用它来动态插入变量值。这里的name就是我们通过render_template传递的变量。


现在,重启Flask服务器并访问你的URL。尝试访问 http://你的URL/?name=David,页面将显示“Hello, David!”。如果不提供name参数,则显示“Hello, world!”。
📝 使用表单提交数据


要求用户在URL中手动输入参数并不友好。通常,我们使用HTML表单来收集用户输入。


我们将创建两个页面:一个显示表单,另一个显示问候结果。

首先,创建index.html作为表单页:


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Greet Form</title>
</head>
<body>
<h1>Greet Form</h1>
<form action="/greet" method="get">
<input type="text" name="name" placeholder="Enter your name" autocomplete="off" autofocus>
<input type="submit" value="Greet">
</form>
</body>
</html>

这个表单使用GET方法,将数据提交到/greet路由。name输入框用于接收用户的名字。


然后,创建greet.html作为结果页:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Greeting</title>
</head>
<body>
<p>Hello, {{ name }}!</p>
</body>
</html>

现在,更新application.py来处理这两个路由:


from flask import Flask, render_template, request
app = Flask(__name__)
@app.route("/")
def index():
# 渲染包含表单的页面
return render_template("index.html")
@app.route("/greet")
def greet():
# 从表单提交的GET参数中获取名字
name = request.args.get("name", "world")
# 渲染问候页面
return render_template("greet.html", name=name)


访问根路径/,你会看到一个表单。输入名字并提交,你将被带到/greet页面并看到个性化的问候。



🔒 使用POST方法提升隐私与安全

使用GET方法提交表单有一个问题:所有输入的数据都会显示在URL中(例如/greet?name=David)。这可能会泄露隐私,也不适合提交敏感信息(如密码)。


更好的方法是使用POST方法,它将数据放在HTTP请求体中,而不是URL里。

修改index.html中的表单,将方法改为POST:
<form action="/greet" method="post">

然后,更新application.py中的/greet路由,使其支持POST方法,并从request.form中获取数据:

@app.route("/greet", methods=["POST"])
def greet():
# 从表单提交的POST数据中获取名字
name = request.form.get("name", "world")
return render_template("greet.html", name=name)

现在,当你提交表单时,名字不会再出现在URL中,提高了隐私性。


🧩 使用模板继承避免代码重复


观察index.html和greet.html,你会发现它们有大量重复的HTML样板代码(如<html>, <head>, <body>标签)。在多个文件中维护这些重复内容非常繁琐。



Flask的模板继承功能可以解决这个问题。我们可以创建一个基础布局模板,让其他模板继承它。

创建一个layout.html作为基础模板:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{% block title %}{% endblock %}</title>
</head>
<body>
{% block body %}
{% endblock %}
</body>
</html>

{% block body %} 定义了一个可替换的块。子模板可以在这个位置插入自己的内容。

然后,简化index.html和greet.html:

index.html:
{% extends “layout.html” %}

{% block title %}Greet Form{% endblock %}
{% block body %}
<h1>Greet Form</h1>
<form action=“/greet” method=“post”>
<input type=“text” name=“name” placeholder=“Enter your name” autocomplete=“off” autofocus>
<input type=“submit” value=“Greet”>
</form>
{% endblock %}
greet.html:
{% extends “layout.html” %}
{% block title %}Greeting{% endblock %}
{% block body %}
<p>Hello, {{ name }}!</p>
{% endblock %}

{% extends “layout.html” %} 告诉Flask这个模板继承自layout.html。{% block body %} 中的内容将被插入到基础模板的相应块中。


这样,公共的HTML结构只需在layout.html中维护一次,极大地提高了代码的可维护性。

🏀 实战项目:新生运动注册系统
作为综合练习,我们将构建一个简单的新生运动注册系统。这个系统将收集学生的姓名和他们选择的运动。
项目结构设置

- 创建一个新目录
frosh_ims。 - 在其中创建
application.py、templates文件夹以及layout.html。

layout.html (添加了移动设备友好的标签):
<!DOCTYPE html>
<html lang=“en”>
<head>
<meta charset=“UTF-8”>
<meta name=“viewport” content=“width=device-width, initial-scale=1.0”>
<title>{% block title %}{% endblock %}</title>
</head>
<body>
{% block body %}
{% endblock %}
</body>
</html>



创建注册表单

创建index.html,包含姓名输入和运动选择菜单:

{% extends “layout.html” %}

{% block title %}Frosh IMs{% endblock %}

{% block body %}
<h1>Register for Frosh IMs</h1>
<form action=“/register” method=“post”>
<input type=“text” name=“name” placeholder=“Name” autocomplete=“off” autofocus>
<br>
<select name=“sport”>
<option value=“” disabled selected>Sport</option>
<option value=“Dodgeball”>Dodgeball</option>
<option value=“Flag Football”>Flag Football</option>
<option value=“Soccer”>Soccer</option>
<option value=“Volleyball”>Volleyball</option>
<option value=“Ultimate Frisbee”>Ultimate Frisbee</option>
</select>
<br>
<input type=“submit” value=“Register”>
</form>
{% endblock %}


实现注册逻辑


在application.py中,我们定义路由并处理表单提交:
from flask import Flask, render_template, request
app = Flask(__name__)

# 定义支持的运动列表
SPORTS = [
“Dodgeball”,
“Flag Football”,
“Soccer”,
“Volleyball”,
“Ultimate Frisbee”
]
@app.route(“/”)
def index():
# 将运动列表传递给模板
return render_template(“index.html”, sports=SPORTS)
@app.route(“/register”, methods=[“POST”])
def register():
# 验证表单提交
name = request.form.get(“name”)
sport = request.form.get(“sport”)
if not name or sport not in SPORTS:
# 如果验证失败,返回错误页面
return render_template(“failure.html”)
# 验证成功,返回成功页面
return render_template(“success.html”)
注意,我们在服务器端(控制器)定义了运动列表SPORTS,并在两个地方使用它:
- 传递给
index.html模板,用于动态生成下拉选项。 - 在
register函数中,验证用户提交的sport是否在合法列表中。永远不要信任用户输入,必须在服务器端进行验证。
创建success.html和failure.html模板来显示结果。
动态生成表单选项与防御性编程
为了更优雅地生成运动选项并避免在HTML中硬编码,我们可以更新index.html,使用Jinja2的循环:



<select name=“sport”>
<option value=“” disabled selected>Sport</option>
{% for sport in sports %}
<option value=“{{ sport }}”>{{ sport }}</option>
{% endfor %}
</select>


这样,运动列表完全由后端的SPORTS变量控制,前端自动同步。如果有人试图通过浏览器开发者工具修改HTML并提交一个不在列表中的运动(如“Tennis”),我们的服务器端验证 sport not in SPORTS 会将其拦截,返回失败页面,从而保证了系统的安全性。

📖 总结


本节课中我们一起学习了Web编程的核心概念,并使用Flask框架进行了实践。


我们了解到:
- Flask框架 简化了Web服务器开发,帮助我们处理路由、请求和响应。
- MVC模式 为组织Web应用代码提供了清晰的架构(模型、视图、控制器)。
- 处理用户输入 可以通过URL参数(
GET)或表单数据(POST)实现,其中POST方法更安全。 - Jinja2模板引擎 允许我们动态生成HTML,并使用模板继承来消除重复代码,提升可维护性。
- 服务器端验证 至关重要。永远不要信任客户端提交的数据,必须在后端对输入进行严格的检查和验证。

通过构建“新生运动注册系统”,我们将这些概念综合运用,创建了一个具备基本交互和数据验证功能的动态Web应用程序。这为我们后续学习更复杂的数据库集成和网络爬虫等内容打下了坚实的基础。
哈佛 CS50-CS 19:L9 - Flask网络请求与爬虫数据编程 2 🕸️


在本节课中,我们将学习如何构建一个功能完整的Web应用。我们将使用Flask框架处理用户注册、数据存储、会话管理以及前后端交互。课程将涵盖从简单的表单验证到使用数据库持久化数据,再到利用Cookies和会话实现用户状态跟踪,最后通过Ajax技术实现动态的前端交互。



📝 构建动态注册系统


上一节我们创建了一个动态的用户界面,但用户点击后信息就丢失了。为了建立一个真正有效的注册系统,我们需要记住哪些用户已经注册。


我们可以使用Python的数据结构(如列表、字典、集合)在内存中存储数据。让我们从一个简单的字典开始,存储注册者的信息。




在 application.py 中,我们添加一个全局变量来存储注册者信息:

registrants = {}

这个字典将存储键值对,其中键是用户的名字,值是他们选择的运动。



🔍 改进表单验证与错误处理
目前,我们只是简单地检查用户是否提供了姓名和运动。我们需要更健壮的错误处理,以便向用户提供清晰的反馈。
以下是改进后的验证逻辑:

name = request.form.get("name")
sport = request.form.get("sport")
if not name:
return render_template("error.html", message="缺少姓名")
if not sport:
return render_template("error.html", message="缺少运动")
if sport not in SPORTS:
return render_template("error.html", message="无效运动")
我们创建了一个新的模板 error.html 来显示特定的错误信息,这比通用的“未注册”提示更有帮助。


💾 在内存中存储注册数据
验证通过后,我们将用户信息存储到全局的 registrants 字典中:


registrants[name] = sport
然后,我们可以渲染一个成功页面,或者更好的是,将用户重定向到一个展示所有注册者的页面。
📊 创建注册者列表页面
为了展示所有已注册的用户,我们创建一个新的路由 /registrants 和一个对应的模板 registrants.html。

在 registrants.html 中,我们使用HTML表格来动态展示数据:



<table>
<thead>
<tr>
<th>姓名</th>
<th>运动</th>
</tr>
</thead>
<tbody>
{% for name in registrants %}
<tr>
<td>{{ name }}</td>
<td>{{ registrants[name] }}</td>
</tr>
{% endfor %}
</tbody>
</table>
在 application.py 的 /registrants 路由中,我们将 registrants 字典传递给模板进行渲染。
🚨 内存存储的局限性
使用全局变量存储数据有一个明显的缺点:一旦服务器重启或停止,所有数据都会丢失。这不是一个持久的解决方案。
为了解决这个问题,我们需要将数据存储到更持久的地方,比如文件或数据库。


🗃️ 使用SQLite数据库持久化数据
我们将使用SQLite数据库来替代内存中的字典,以实现数据的持久化存储。


首先,我们创建一个数据库表:


CREATE TABLE registrants (id INTEGER, name TEXT NOT NULL, sport TEXT NOT NULL, PRIMARY KEY(id));
然后,在 application.py 中,我们使用SQL语句来插入和查询数据:




# 插入数据
db.execute("INSERT INTO registrants (name, sport) VALUES(?, ?)", name, sport)




# 查询数据
registrants = db.execute("SELECT * FROM registrants")


在模板中,我们遍历从数据库查询返回的行(每行是一个字典)来展示数据。


📧 发送确认电子邮件



作为功能的补充,我们可以实现注册后自动发送确认邮件的功能。这需要配置Flask-Mail扩展。


首先,进行必要的配置(注意:敏感信息如密码应从环境变量读取):
app.config["MAIL_DEFAULT_SENDER"] = os.environ["MAIL_DEFAULT_SENDER"]
app.config["MAIL_PASSWORD"] = os.environ["MAIL_PASSWORD"]
# ... 其他配置



然后,在用户注册成功后,创建并发送邮件:
message = Message("你已注册", recipients=[email])
mail.send(message)

🍪 理解Cookies与用户会话



HTTP协议本身是无状态的。为了让服务器记住用户(例如,保持登录状态),我们使用Cookies和会话(Session)。


Cookie 是服务器发送到用户浏览器并保存在本地的一小块数据。浏览器会在后续的请求中携带这个Cookie,从而让服务器识别用户。

会话 是在服务器端存储用户状态的一种机制,通常依赖于Cookie中的一个唯一会话ID。
🔐 使用Flask实现登录/注销
我们创建一个简单的登录系统来演示会话的使用。

首先,配置Flask以使用服务器端会话存储:

app.config["SESSION_PERMANENT"] = False
app.config["SESSION_TYPE"] = "filesystem"
Session(app)



在登录路由中,我们将用户名存入会话:

session["name"] = request.form.get("name")

在首页,我们检查会话中是否有用户名,以决定显示“已登录”还是“未登录”状态。
注销功能通过清除会话中的用户名来实现:
session["name"] = None



🛒 实现购物车功能
利用会话,我们可以轻松实现像购物车这样的用户特定功能。
我们可以在会话中存储一个列表来表示用户的购物车:
if "cart" not in session:
session["cart"] = []
session["cart"].append(item_id)
每个用户的会话是独立的,因此他们的购物车内容互不影响。


🔄 前后端交互与Ajax
现代Web应用追求动态的用户体验。我们可以使用Ajax技术,让前端JavaScript与后端API异步通信,无需刷新整个页面。



我们创建一个搜索功能作为示例。后端提供一个返回JSON格式数据的API端点:

@app.route("/search")
def search():
q = request.args.get("q")
shows = db.execute("SELECT * FROM shows WHERE title LIKE ?", "%" + q + "%")
return jsonify(shows)
前端使用JavaScript(借助jQuery库)来获取数据并动态更新页面:


$.get(`/search?q=${input.value}`, function(shows) {
let html = '';
for (let show of shows) {
html += `<li>${show.title}</li>`;
}
document.querySelector('ul').innerHTML = html;
});
用户在输入框中每输入一个字符,都会触发一次Ajax请求,并立即更新搜索结果列表,实现了类似自动补全的效果。
🎯 课程总结



本节课中,我们一起学习了如何构建一个功能完整的Web应用。

我们从处理表单和内存存储开始,逐步过渡到使用SQLite数据库进行数据持久化。我们探讨了如何通过Cookies和会话来管理用户状态,并以此实现了登录系统和购物车功能。最后,我们结合前端JavaScript与后端Python API,使用Ajax技术创建了动态、无需刷新页面的用户体验。


这些核心概念——请求处理、数据存储、状态管理和前后端异步通信——是构建现代交互式Web应用的基石。
哈佛 CS50-CS 20:L10- 计算机与道德话题 📚





在本节课中,我们将回顾CS50课程的核心思想,并展望未来。我们将探讨计算机科学不仅仅是关于如何编写代码,更重要的是关于是否应该编写某些代码,以及如何负责任地使用技术。课程将以一场有趣的社区知识竞赛结束。

课程回顾与展望 🔄

上一节我们介绍了课程的基本框架,本节中我们来看看CS50课程的总结与未来方向。
CS50课程即将结束,剩下的就是你们的最终项目。我们期待看到你们的创作成果。我们想借此机会回顾过去,展望未来。


我们非常感谢洛贝戏剧中心和美国剧团在整个学期中作为出色的东道主。他们为CS50注入了新的活力、灯光、动画和声音。我们也非常感谢CS50的整个团队,包括视频、技术和视觉元素团队,他们为课程提供了宝贵的支持。



这一切都是可能的,尽管这是一个特殊且充满挑战的时期。我们希望无论你是现在观看直播,还是稍后观看录像,都能保持健康和快乐。我们希望能帮助你在学习新事物的道路上找到方向。

CS50的整个教学助理团队,无论是在哈佛还是耶鲁,都是课程不可或缺的支柱。没有他们,这一切都不可能实现。他们为每个人完成问题集和实验提供了支持。
值得注意的是,我们都会犯错。即使你在学期结束时感觉并非所有内容都能顺利理解,有时仍在挣扎,这也是正常的。当你调试代码或在网上搜索技术问题的答案时,这种体验永远不会真正消失。这些感受和挫折是你工具箱中的基础工具。
现在,你在学习新语言时可能会感到不适,但最终会吸收新的想法和技能。请记住,对于CS50而言,课程中最终重要的不是你相对于同学的最终位置,而是你相对于课程开始时的进步。
考虑到时间并不久远,也许你在CS50中最大的困难之一就是尝试弄清楚如何开始。从弄清楚如何打印空格、移动金字塔,到弄清楚如何嵌套循环,再到一两周前建立自己的网页应用程序、使用第三方API并几乎实时拉取数据,这种进步是巨大的。

课程的核心收获 💡
我们在本学期花了很多时间谈论和进行编程,但我们希望你能记住的是,真正能持久的收获,比起特定语言的细节更为重要。
无论是Scratch、C还是其他我们查看的实用工具,所有这些最终都会以某种形式过时,或者作为旧语言存在,而更新更好的事物会接踵而至。我们希望的是,在过去的几个月里,你能够掌握基础知识,并建立一个可以自我提升的基础。

我们开始时,首先是原则。你可以推断出新系统、新硬件、新技术或新语言如何工作。因为在背后,最终,一切都只是零和一。
我们引入了计算思维,鼓励你更有条理地从算法角度思考。计算思维本质上是计算机科学家批判性思维的体现。这个过程是将信息作为输入,并产生输出,即某种解决方案。在这之间,是我们的算法,那个黑箱正在做一些有趣且可能困难的事情。但归根结底,这就是解决问题,这无论如何不会消失。
正确性、设计和风格是评估解决问题方法质量的三个重要方面。
以下是这三个核心评估维度:
- 正确性:如果代码不工作,那么一切都没有意义。从输入到输出的算法过程必须是正确的。
- 设计:当你想要构建更复杂的系统或解决更复杂的问题时,你需要更清晰地解决问题。你不希望代码变得缓慢、混乱或难以理解,因为这会妨碍你长期使用相同的工具和库来解决更多有趣和复杂的问题,也会使与他人合作变得困难。
- 风格:这是你代码的美感。当你用编程语言与他人交流时,以清晰的方式展现你的最佳状态,让他人理解你的想法和解决方案,这与正确性和设计同样重要。
抽象与精确的平衡 ⚖️
那么,关于其他跨越特定语言和任务的基本构建模块呢?抽象是一个关键概念。
抽象让你不必担心底层实现的细节,只需专注于高级别的构建模块。例如,你不必记得 getstring 或 printf 的具体实现,但你知道它们有效,知道它们接受输入并产生输出。因此,你可以在这个构建模块的基础上构建自己的想法和软件。
在现实世界中,我们也会抽象事物。例如,我们假设时间会由其他人完成输出,没有专家了解所有底层实现细节。
然而,另一个关键概念是精确。这在书写人类语言和编程时都至关重要。你需要明确表达你的意思,考虑边缘情况和可能不会发生的输入,以避免错误和意外行为。
抽象和精确在某种程度上是相互矛盾的。抽象让你在相对高的层次上思考和交流,而精确则需要你深入细节。找到合适的平衡点是解决问题过程的一部分。
为了说明这一点,我们进行了一个互动练习。一位志愿者(丹尼尔)口头描述了一张图片(一个立方体的线框图),让其他人根据描述绘制。丹尼尔选择用精确的几何术语(六边形、中点、顶点)来描述,而不是简单地抽象地说“画一个立方体”。结果,大家的绘图各不相同,这说明了在沟通中,过于抽象或过于精确都可能带来问题。
随后,我们进行了另一个集体绘图练习,由多位志愿者逐步给出指令。这个练习再次表明,提供合适的细节水平至关重要。有时,一个更高层次的概述(例如“画一个雪人”)可能比一系列精确但令人困惑的指令更有帮助。
因此,当你试图向某人解释某个过程时,请记住这些细节。精准很重要,但你提供的越精确,越容易让人陷入细节之中。有时,一个更高层次的概述可能是某人所需要的。
技术伦理:能力与责任 🤔
超越今天的技术细节,我们希望大家考虑的一个想法是:学习计算机科学课程不仅仅是关于你能用代码做什么,还有你是否应该用代码做某事,如果应该,你该如何做。
事实上,我们认为在这次最后的交流中,讨论伦理与技术是很有必要的。现在你们都知道如何用代码做很多事情,即使没有在讲座、问题集或实验中见过,你们也有能力弄明白。
例如,考虑到你们现在已经有能力用代码发送电子邮件,没有什么可以阻止你以他人的名义发送电子邮件,这就产生了垃圾邮件。你有能力在数据库中存储密码,如果你以明文形式存储,即使是你自己也能看到它们。如果用户在你的网站和其他账户上使用相同密码,这就带来了隐私和安全风险。
你有能力使用代码记录每一次击键,监控用户在网站或应用程序上的每一个操作。这在科技界已成为一种常态,用于个性化推荐等。但这需要存储大量个人信息。
近年来,尤其是在欧洲,在保护人们隐私的利益下,发生了一些变化。因此,建议你在构建任何东西时,无论是命令行应用程序、Chrome扩展、网页应用程序还是移动应用程序,都要记住:仅仅因为你可以做某事,并不一定意味着你应该这样做。
“面部混合”网站就是一个例子。多年前,一位哈佛学生编写代码抓取了所有哈佛学生的在线照片簿,创建了一个让学生评价彼此外貌的网站。这位学生后来创办了Facebook。这个例子引发了关于隐私、同意和技术责任的持续讨论。
因此,我们邀请了哈佛哲学系的同事,米卡·米尼亚尼和苏珊·肯尼迪,来和我们一起讨论技术伦理,为我们提供更正式的思考框架。
数字公共领域与民主 🗳️
技术彻底改变了信息和新闻的传播方式。过去,新闻由少数大众媒体机构控制。现在,我们生活在一个数字化的网络公共领域,任何人都可以在社交媒体上分享新闻和信息。
这带来了优势:内容多样性增加,信息获取更便利,社会运动得以动员,专家声音可以直接传播。
但也带来了挑战:事实核查更困难,个性化算法可能导致“信息茧房”,假新闻和极端内容更容易病毒式传播。
社交媒体平台现在面临着如何规范内容的难题。一方面,需要打击假新闻和仇恨言论;另一方面,过度干预可能被视为侵犯言论自由。

健康的民主需要一些条件,社交媒体平台的设计可以促进或阻碍这些条件:
以下是民主社会所需的五个关键条件:

- 表达自由:公民有表达和结社的基本自由。
- 表达机会:公民应有公平参与公共讨论的机会。
- 获取可靠信息:每个人都应有良好和平等的机会获取关于公共事务的可靠信息。
- 观点多样性:每个人都应有良好和平等的机会接触广泛的观点。
- 沟通权力:公民应有良好和平等的机会与他人交流,发展可能挑战主流观点的新关注。


假新闻的流行部分源于社交媒体扰乱了传统的“证言”规范。当人们分享信息时,他们应该对所分享内容的真实性负责。有提议为社交媒体用户引入“可信度评分”,根据他们分享误导信息的频率进行标记,以激励负责任的信息分享。
作为未来的技术创造者或使用者,我们需要批判性地思考技术设计选择如何影响这些民主条件。


超越课堂:终身学习与实践 🚀
对于许多人来说,CS50可能是唯一的计算机科学或编程课程。这完全没问题。我们希望你现在能够将编程技能应用于艺术、人文学科、社会科学或自然科学,在你喜欢的领域解决问题。


如果你对计算机科学本身感兴趣,我们希望这门课为你奠定了坚实的理论基础。


从实用角度来说,我们希望你不仅能编程,还能更好地提问。无论是在CS50的论坛上,还是在现实世界中,清晰地描述问题(例如,你遇到了什么错误,尝试过哪些步骤)都能帮助你更有效地获得帮助。


CS50的许多内容可能令人沮丧,因为讲座覆盖的内容可能无法解决你遇到的所有具体问题。但这正是设计意图的一部分。课程的目标是让你在安全网的支持下,学会如何独立克服障碍,利用互联网(如谷歌、Stack Overflow)寻找答案。


阅读文档是另一个关键技能。文档质量参差不齐,但适应阅读官方文档(如Python文档、API文档)将赋予你更大的能力。
最重要的是,CS50教会你如何自学新的语言或技术。我们花时间在C、Python、JavaScript、SQL上,不是为了说明哪种语言更重要,而是为了展示编程思想(条件、循环、事件等)是相通的。我们希望你现在更擅长成为一名程序员,能够自己填补知识缺口。


提到辅助工具,你可以继续使用CS50 IDE进行最终项目。但最终,你应该尝试使用行业标准工具:


以下是推荐自学的行业标准工具和资源:


- 命令行与环境:在你自己电脑的终端上工作,安装常用的命令行工具。
- 版本控制 (Git):使用Git来管理代码版本、协作和备份,而不是手动复制文件。
- 文本编辑器:尝试使用像 VS Code 这样的现代、流行的代码编辑器。
- 网站托管:对于静态网站,可以使用GitHub Pages或Netlify。对于动态网页应用,可以考虑Heroku等平台服务。
- 云服务:了解亚马逊AWS、微软Azure、谷歌云等大型云服务商。
- 社区与资讯:关注 Reddit 的编程板块、Stack Overflow、Hacker News、TechCrunch 等,以了解技术趋势和最新工具。


CS50也有自己的在线社区,欢迎你保持联系,或将来回馈社区,帮助他人。




课程总结 🎉
本节课中我们一起回顾了CS50的核心思想:计算思维、问题解决、代码的正确性、设计与风格,以及抽象与精确的平衡。我们深入探讨了技术伦理的重要性,思考了在数字时代我们作为技术使用者和创造者的责任。最后,我们展望了未来,鼓励大家持续学习、勇于实践,并利用好丰富的工具和社区资源。
现在,是时候将所学知识应用于你们的最终项目了。我们期待看到你们创造出令人惊叹的作品!





这就是CS50。
哈佛 CS50-CS 21:人工智能(决策树、广度深度优先搜索、A*搜索、强化学习、遗传算法、神经网络) 🧠




在本节课中,我们将要学习人工智能(AI)的核心概念。人工智能旨在让计算机以智能和理性的方式行动,其应用范围从简单的游戏到复杂的手写识别和内容推荐。我们将探讨多种实现人工智能的方法,包括决策制定、搜索算法以及让计算机从数据中学习的机器学习技术。

🤖 什么是人工智能?
人工智能是计算机科学的一个分支,致力于创建能够执行通常需要人类智能的任务的系统。这包括玩游戏、识别手写、过滤垃圾邮件以及生成逼真的图像。与使用大量if和else语句的简单程序不同,真正的人工智能系统能够更灵活地推理和学习。



🌳 决策制定与决策树
上一节我们介绍了人工智能的目标,本节中我们来看看计算机如何做出决策。一种直观的方法是使用决策树。决策树通过一系列是/否问题来引导决策过程,最终得出一个行动方案。

例如,在一个简单的球拍游戏中,AI需要决定如何移动球拍来接球。其决策逻辑可以表示如下:


如果 球在球拍左侧:
将球拍向左移动
否则 如果 球在球拍右侧:
将球拍向右移动
否则:
不移动球拍
以下是构建决策树的关键步骤:
- 识别关键问题:确定影响决策的核心因素(例如,“球在左侧吗?”)。
- 定义行动:为每个问题的答案指定相应的行动。
- 转化为代码:将树状结构转化为
if-else语句。

然而,对于更复杂的游戏(如井字棋),手动编写所有决策规则是不现实的。我们需要更强大的算法。




⚔️ 对抗性搜索与Minimax算法

在双方对抗的游戏中,AI需要预测对手的行动并做出最优反应。Minimax算法就是为此设计的。它将游戏结果量化为分数:己方获胜为1,平局为0,对手获胜为-1。
算法的核心思想是:
- 最大化玩家(Max):选择能使最终分数最大化的走法。
- 最小化玩家(Min):选择能使最终分数最小化的走法。
AI通过递归地模拟未来所有可能的走法序列,并为当前棋盘状态计算一个分数。伪代码如下:
function minimax(board, player):
if 游戏结束(board):
return 该局面的分数(1, 0, 或 -1)
if player == 最大化玩家:
最佳分数 = -∞
for each 可能的走法 in board:
新棋盘 = 执行走法(board, 走法)
分数 = minimax(新棋盘, 最小化玩家)
最佳分数 = max(最佳分数, 分数)
return 最佳分数
else:
最佳分数 = +∞
for each 可能的走法 in board:
新棋盘 = 执行走法(board, 走法)
分数 = minimax(新棋盘, 最大化玩家)
最佳分数 = min(最佳分数, 分数)
return 最佳分数

对于井字棋这类简单游戏,Minimax可以穷举所有可能并实现完美对战。但对于国际象棋等复杂游戏,可能走法数量巨大(例如前四步就有约2880亿种),无法穷举。
为了解决这个问题,我们引入了深度限制Minimax。算法不会搜索到游戏结束,而是在一定深度后停止,并使用一个评估函数来估算当前局面的优劣(例如,通过计算棋子价值和位置)。




🧭 搜索算法:寻找路径
除了游戏,搜索算法也用于解决路径查找问题,如地图导航。我们从最简单的算法开始。


深度优先搜索(DFS)

深度优先搜索的策略是沿着一条路径一直走到底,如果遇到死胡同就回溯到上一个岔路口尝试另一条路。


从起点A开始。
循环:
如果到达终点B:成功。
如果当前是死胡同:回溯到上一个选择点。
否则:随机选择一个方向前进。



DFS能保证找到路径(如果存在),但找到的路径很可能不是最短的,且可能探索大量无关区域。


广度优先搜索(BFS)


广度优先搜索的策略是同时探索所有可能的方向,一层一层地向外扩展。

从起点A开始,将其放入队列。
循环:
从队列中取出一个位置。
如果该位置是终点B:成功。
否则:将该位置所有未访问的相邻位置加入队列。


BFS总能找到从起点到终点的最短路径。然而,它需要探索大量节点,效率可能不高。


启发式搜索:利用额外知识


以上两种都是无信息搜索。我们可以通过引入启发式(一种估算某个状态好坏的方法)来改进搜索效率。例如,在迷宫中,可以使用曼哈顿距离(忽略障碍,只计算网格上的横向和纵向移动步数)来估算某个格子到目标的距离。


贪婪最佳优先搜索


该算法总是选择启发式值最小(即看起来离目标最近)的节点进行扩展。

从起点A开始。
循环:
如果到达终点B:成功。
否则:从所有可选项中,选择启发式值最小的节点前进。

它通常比DFS和BFS更快,但启发式估计可能误导搜索,导致找不到最短路径甚至陷入死循环。

A*搜索

A*搜索结合了BFS的最优性保证和启发式搜索的效率。它评估一个节点的优先级时,同时考虑:
g(n):从起点到该节点的实际代价。h(n):从该节点到终点的启发式估计代价。
优先级计算公式为:f(n) = g(n) + h(n)。算法总是优先扩展f(n)值最小的节点。
从起点A开始,计算其f值并入队。
循环:
从队列中取出f值最小的节点。
如果该节点是终点B:成功。
否则:计算其所有邻居的f值,并入队。




只要启发式函数h(n)满足一定条件(如“可采纳性”,即不高估实际代价),A*搜索就能保证找到最短路径,并且通常比BFS搜索更少的节点。




🎯 机器学习:从经验中学习
上一节我们介绍了让AI“思考”的算法,本节中我们来看看让AI“学习”的技术。机器学习使计算机能够从数据或经验中改进性能。
强化学习

在强化学习中,AI代理通过与环境互动来学习。它执行行动,然后获得奖励(正面)或惩罚(负面),从而学会哪些行为能带来好结果。

例如,训练一个AI玩简单游戏:
- AI随机移动。
- 如果碰到障碍(失败),获得负面反馈。
- 如果到达目标(成功),获得正面反馈。
- 经过多次尝试,AI学会避免导致失败的移动,重复导致成功的移动。

一个关键挑战是探索与利用的权衡:是应该利用已知的最佳行动,还是探索可能更好的新行动?Epsilon贪婪策略是一个常见解决方案:以概率ε随机探索,以概率1-ε利用已知最佳行动。
遗传算法
遗传算法模仿自然进化过程。我们不是直接编程一个智能体,而是创建一群随机生成的“候选解”,然后让它们“进化”。
以下是其基本流程:
- 初始化:随机生成第一代候选解(例如,不同的驾驶策略)。
- 评估:用适应度函数评估每个候选解的表现(例如,汽车行驶的距离)。
- 选择:保留适应度最高的候选解。
- 繁殖:通过复制、交叉(组合)和变异(随机改动)选中的候选解,产生新一代。
- 迭代:重复步骤2-4,直到得到令人满意的解。
经过多代进化,种群的整体适应度会不断提高。
神经网络与深度学习
神经网络的灵感来源于人脑。它由大量互连的人工神经元(或单元)组成。每个连接都有一个权重,网络通过调整这些权重来学习输入到输出的映射关系。


一个简单的神经网络前向传播公式可以表示为:
输出 = 激活函数( ∑ (输入_i * 权重_i) + 偏置 )



深度学习指的是具有许多隐藏层的神经网络。通过向网络提供大量带标签的数据(例如,成千上万张标有数字的手写图片),并使用反向传播等算法调整权重,网络可以学会完成复杂任务,如图像识别、机器翻译和图像生成。



生成逼真人脸图像的AI,正是通过训练神经网络学习真实人脸图片的分布特征,然后从随机噪声开始,一步步“细化”出完整图像。


📚 总结

本节课中我们一起学习了人工智能的多个核心领域:
- 决策制定:使用决策树将复杂决策分解为简单问题。
- 对抗性搜索:使用Minimax算法在游戏中做出最优决策,并通过深度限制和评估函数处理复杂游戏。
- 路径搜索:了解了DFS、BFS等无信息搜索,以及利用启发式的贪婪最佳优先搜索和A*搜索。
- 机器学习:探讨了让计算机从经验中学习的三种范式:
- 强化学习:通过奖励和惩罚信号学习行为策略。
- 遗传算法:通过模拟自然选择的过程进化出解决方案。
- 神经网络:通过模拟人脑结构,从大量数据中学习复杂的输入-输出关系。


人工智能是一个工具包,包含多种不同的方法,用于解决让机器变得智能这一共同目标。理解这些基础概念,是探索更高级AI应用的起点。
哈佛 CS50-WEB 1:介绍与入门 🚀



在本节课中,我们将要学习《哈佛 CS50-WEB:基于Python/JavaScript的Web编程》课程的第一部分。我们将了解这门课程的整体结构、将要学习的关键技术栈,以及如何从零开始构建现代、动态且安全的网络应用。
这门课程将从CS50的基础知识出发,深入探讨使用Python和JavaScript设计和实现网络应用。我们将使用Django、React和Bootstrap等流行框架,并学习行业最佳实践。
📚 课程内容概览
以下是本课程将涵盖的核心模块与技术。
- HTML与CSS:首先,我们将仔细学习HTML和CSS。这两种语言用于描述网页的结构与样式,是构建任何网页的基石。
- Git版本控制:之后,我们将介绍Git版本控制工具。它帮助我们跟踪代码更改,并支持多人在同一项目中高效协作。
- 深入Python与Django:接着,我们将更深入地探索Python编程语言的高级特性。重点是学习如何使用Django这一网络框架来创建动态网络应用。我们将充分利用Django处理数据的能力,结合SQL模型与迁移,构建使用数据库的交互式应用。
- JavaScript与交互界面:然后,我们将深入探索JavaScript。我们将学习如何使用它来创建动态、交互式的用户界面,编写响应事件的代码,并根据用户交互来操作网页。
- 行业最佳实践:之后,我们将探讨测试、持续集成与持续交付等最佳实践。这能确保我们的代码按预期工作,并能快速、安全地部署更新。
- 可扩展性与安全性:最后,当我们将应用从本地部署到网络供所有人访问时,将讨论如何确保应用的可扩展性(以应对大量用户)和安全性(以防范各种网络威胁)。
🎯 实践与总结
沿着这条学习路径,你将有机会通过构建自己的网络应用来实践所有学到的概念和技术。

本节课中,我们一起学习了《哈佛 CS50-WEB》课程的整体介绍与入门知识。我们了解了课程将引导我们从网页基础(HTML/CSS)开始,逐步掌握后端开发(Python/Django)、前端交互(JavaScript)以及部署运维(Git、测试、CI/CD、安全与扩展)的完整技能栈,为成为一名全栈Web开发者打下坚实基础。
哈佛 CS50-WEB 2:L0- HTML与CSS语法 1 (web编程与HTML) 📚



概述
在本节课中,我们将要学习网页编程的基础,特别是构成网页骨架的两种核心语言:HTML和CSS。我们将从HTML开始,了解如何使用它来描述网页的结构和内容。

课程介绍与目标 🎯
大家好,欢迎来到使用Python和JavaScript的网页编程课程。我叫Brian U。在这个课程中,我们将深入研究网页应用的设计和实现。
在讲座中,我们将有机会讨论和探索许多核心的想法、工具和语言。通过动手项目,你将有机会将这些想法付诸实践,设计多个你自己的网页应用,最终以一个你自己选择的项目作为结尾。
在整个学期中,我们将涵盖网页编程领域的多个主题。

网页编程的核心:HTML与CSS 🌐

我们将从HTML和CSS开始我们的讨论,这两种语言是理解网页以及网页浏览器如何显示这些网页的基础。
- HTML(超文本标记语言)是一种我们用来描述网页结构的语言。
- CSS(层叠样式表)是一种我们用来描述网页样式的语言,包括颜色、字体、布局和间距。
完成这些之后,我们将关注版本控制工具Git,它可以帮助我们跟踪对网页程序所做的更改,并使我们能够在多个不同项目上协作。
课程路线图 🗺️
之后,我们将关注Python,这是我们将要探索的第一种主要语言。我们将使用一个叫Django的框架来构建网页应用。Django是用Python编写的网页编程框架,它简化了网页应用的设计和开发,特别是使得设计与数据交互的网页应用变得简单。
接着,我们将关注SQL,这是一种我们可以用来与数据库互动的语言。我们将研究Django如何允许我们使用模型和迁移来与数据交互。
接下来,我们将探索第二种主要编程语言:JavaScript。我们将学习如何使用JavaScript在用户的网页浏览器中运行,使网页变得更加互动。
之后,我们将关注测试、持续集成和持续交付,这些是我们可以使用的软件最佳实践工具,以确保我们能够更有效地编写代码,并确保我们的网络应用程序始终如预期那样运行。
最后,我们将关注可扩展性和互联网安全性,探讨当我们的网络应用程序用户量增长时,如何进行负载均衡、优化数据库,以及如何主动设计我们的网络应用程序以确保其安全。
第一个HTML页面 📄
我们的第一个HTML页面将类似于以下代码。它是我们编写的基于文本的代码,然后像Safari、Chrome或Firefox这样的网页浏览器能够查看、解析、理解并显示给用户。

让我们来看一下这个页面,一次一行,逐步理解其工作原理。
<!DOCTYPE html>
<html lang="en">
<head>
<title>Hello</title>
</head>
<body>
Hello, world!
</body>
</html>
这个网页实际上会是什么样子呢?我们来看一下。
打开一个文本编辑器(我将使用微软的Visual Studio Code),创建一个新文件,命名为 hello.html,并写入上面的代码。
如果你在网页浏览器中打开这个 hello.html 文件,你会看到页面主体中显示“Hello, world!”,并且浏览器标签页的标题是“Hello”。

解析HTML结构 🔍

现在让我们更详细地探索这个程序是如何工作的。
<!DOCTYPE html>:这是文档类型声明,告诉网页浏览器我们在这个特定网页上使用哪个版本的HTML。<!DOCTYPE html>表示该页面是用 HTML5 编写的。<html lang="en">:这是HTML页面的根元素。lang="en"是一个属性,它告诉浏览器或搜索引擎这个页面是用英语编写的。<head>:页面的头部,包含不在网页主体中显示、但对浏览器有用的信息,例如标题。<title>:标题标签,定义浏览器标签页中显示的标题。在这个例子中是“Hello”。<body>:页面的主体部分,包含用户可以看到的所有可见内容。在这个例子中是文本“Hello, world!”。


文档对象模型 (DOM) 🌳
从结构上思考HTML页面有时很有帮助,这就是我们称之为文档对象模型 (DOM) 的树状结构。
对于上面的例子,DOM结构如下:
html元素是根节点。- 它有两个子元素:
head和body。head元素有一个子元素title,其中包含文本“Hello”。body元素包含文本“Hello, world!”。
- 它有两个子元素:
这种树状结构有助于我们理解HTML元素之间的嵌套关系,这在后续使用JavaScript修改页面时尤为重要。
常见的HTML元素 🧱
上一节我们介绍了HTML的基本结构,本节中我们来看看一些构建网页时常用的HTML元素。

标题 (Headings)
标题用于定义页面或章节的标题。HTML提供了六个级别的标题标签,从 <h1>(最重要/最大)到 <h6>(最不重要/最小)。
<h1>这是一个大标题</h1>
<h2>这是一个较小的标题</h2>
<h3>这是一个更小的标题</h3>
以下是不同级别标题的显示效果:
<h1>是最大的标题。<h2>是第二大的标题。- 还有
<h3>,<h4>,<h5>,<h6>,<h6>是最小的标题。

列表 (Lists)
列表用于展示一系列项目。HTML有两种基本类型的列表:


- 有序列表 (
<ol>): 项目以特定顺序(如1, 2, 3)编号显示。 - 无序列表 (
<ul>): 项目以项目符号(如圆点)显示,没有特定顺序。

列表中的每个项目都用 <li>(列表项)标签表示。
<!-- 有序列表 -->
<ol>
<li>第一个项目</li>
<li>第二个项目</li>
<li>第三个项目</li>
</ol>
<!-- 无序列表 -->
<ul>
<li>一个项目</li>
<li>另一个项目</li>
<li>还有一个项目</li>
</ul>
图像 (Images)
使用 <img> 标签在网页中嵌入图像。它是一个自闭合标签(没有结束标签)。有两个重要属性:
src: 指定图像文件的路径或URL。alt: 提供图像的替代文本,在图像无法加载时显示,也对屏幕阅读器友好。
<img src="cat.jpg" alt="一只猫的照片" width="300">
你还可以使用 width 或 height 属性来控制图像的显示尺寸。


链接 (Links)

使用 <a>(锚点)标签创建超链接。最重要的属性是 href(超链接引用),它指定链接的目标地址。
<!-- 链接到外部网站 -->
<a href="http://google.com">点击这里去Google</a>
<!-- 链接到同一网站内的其他页面 -->
<a href="image.html">查看猫咪图片</a>
表格 (Tables)
表格用于以行和列的形式展示数据。创建表格涉及多个标签:
<table>: 定义整个表格。<thead>: 定义表格的标题部分。<tbody>: 定义表格的主体部分。<tr>: 定义表格中的一行。<th>: 定义标题单元格(通常位于<thead>内,会加粗显示)。<td>: 定义标准数据单元格。
<table>
<thead>
<tr>
<th>海洋</th>
<th>平均深度 (米)</th>
<th>最大深度 (米)</th>
</tr>
</thead>
<tbody>
<tr>
<td>太平洋</td>
<td>4280</td>
<td>19110</td>
</tr>
<tr>
<td>大西洋</td>
<td>3646</td>
<td>8486</td>
</tr>
</tbody>
</table>
表单 (Forms)
表单允许用户向网页提供输入。使用 <form> 标签定义表单,内部包含各种输入元素 (<input>)。
<input>的type属性决定了输入框的类型(如文本、密码、提交按钮)。placeholder属性提供输入框内的提示文本。name属性用于在提交表单后识别不同的输入字段。
<form>
<input type="text" placeholder="全名" name="name">
<input type="submit" value="提交">
</form>
HTML5 提供了更丰富的输入类型和控件:
<!-- 密码框(隐藏输入字符) -->
<input type="password" placeholder="密码" name="password">
<!-- 单选按钮 -->
<input type="radio" name="color" value="red"> 红色
<input type="radio" name="color" value="blue"> 蓝色
<!-- 带数据列表的输入框(提供选项供选择或自动完成) -->
<input type="text" list="countries" placeholder="选择国家" name="country">
<datalist id="countries">
<option value="中国">
<option value="美国">
<option value="英国">
<!-- 更多选项... -->
</datalist>

总结
在本节课中,我们一起学习了网页编程的起点——HTML。

我们了解了HTML是一种用于描述网页结构的标记语言,并探索了其基本语法和常见元素,包括标题、列表、图像、链接、表格和表单。我们还认识了文档对象模型(DOM)的概念,它帮助我们将HTML页面理解为一个树状结构。
记住,构建网页就是将这些HTML元素像搭积木一样组合和嵌套起来。虽然我们现在创建的表单还不会“做事情”(比如保存数据),但在后续课程中,当我们学习Python和JavaScript后,就能让网页真正动起来,与用户进行交互。
下一节,我们将开始学习CSS,看看如何为这些HTML骨架添加色彩、布局和样式,让我们的网页变得美观。
哈佛 CS50-WEB 3:L0- HTML 与 CSS 语法 2 (CSS 语法) 🎨

在本节课中,我们将要学习 CSS(层叠样式表)的基础语法。CSS 是一种用于描述网页外观和格式的语言,它允许我们为 HTML 元素添加颜色、间距、布局等样式,从而让网页更加美观和用户友好。
概述:为什么需要 CSS? 🤔
目前为止,我们创建的网页都相对简单。我们只是描述了页面的结构,并说明了希望在哪里列出内容。然而,我们通常希望以特定的方式对网页进行样式设置,例如添加颜色、间距和其他布局元素。为此,我们将使用 CSS 语言,特别是其最新版本 CSS3。
CSS3 使我们能够告诉网页浏览器,我们希望 HTML 页面以何种方式进行样式处理,而不仅仅是在白色背景上显示黑色文本。我们可以指定特定的 CSS 属性,以确保页面的外观符合我们的期望。



添加 CSS 的基本方法 🛠️

上一节我们介绍了 CSS 的作用,本节中我们来看看如何将 CSS 代码添加到我们的页面中。

我将创建一个新文件,命名为 style.html,以演示一些为页面添加样式的基本想法,并将之前的 “hello” 代码复制到其中。

除了 “hello world” 之外,我还希望在 h1 中显示一个大标题,例如 “欢迎来到我的网页”。所以现在如果我打开 style.html,这就是我看到的:顶部有一个大标题,上面写着 “欢迎来到我的网页”,下面是 “hello world” 文本。

现在想象一下,我想为页面顶部的标题添加一些样式。也许我希望它不再是左对齐,而是居中,并且颜色从黑色变为蓝色。
内联样式

我们可以使用 style 属性为单个 HTML 元素添加样式,这被称为内联样式。
以下是具体的操作步骤:
- 在
h1元素中添加style属性。 - 在引号内,设置 CSS 属性。例如,将颜色改为蓝色:
color: blue;。 - 可以添加多个属性,用分号分隔。例如,同时设置居中对齐:
text-align: center;。
代码示例:
<h1 style="color: blue; text-align: center;">欢迎来到我的网页</h1>
现在,当我刷新页面时,会看到 “欢迎来到我的网页” 这个标题变为蓝色并且居中。

样式的继承与分离 🧩
上一节我们学习了如何为单个元素添加样式,本节中我们来看看样式如何从父元素继承,以及如何将样式代码与 HTML 结构分离。
样式的继承
HTML 元素不仅可以直接样式化,它们还可以从父元素继承样式信息。例如,在 DOM 结构中,body 元素是 h1 元素的父元素。

如果我将 style 属性从 h1 移动到 body 标签,那么 body 内部的所有内容(包括标题和段落)都会应用这些 CSS 样式。
代码示例:
<body style="color: blue; text-align: center;">
<h1>欢迎来到我的网页</h1>
hello world
</body>
刷新页面后,正文的两个部分(标题和文本)都变为蓝色并居中。

将样式移至 <head> 区域
虽然内联样式有效,但当页面有多个元素需要相同样式时,代码会变得冗余且难以维护。更好的方法是将样式代码移动到网页的 <head> 部分。

以下是具体的操作步骤:
- 在
<head>标签内,添加一个<style>标签。 - 在
<style>标签内,编写 CSS 规则。例如,选择所有h1元素并为其设置样式。
代码示例:
<head>
<style>
h1 {
color: blue;
text-align: center;
}
</style>
</head>
<body>
<h1>欢迎来到我的网页</h1>
<h1>第二个标题</h1>
hello world
</body>
这样,所有 h1 元素都会应用相同的样式,代码只需编写一次,更易于管理和修改。
使用外部样式表 🌐

上一节我们将样式移到了 <head> 中,但如果多个网页需要共享相同的样式,重复复制代码仍然不够理想。本节中我们来看看如何将 CSS 代码放入一个完全独立的文件中。
我们可以创建一个单独的 .css 文件来存放所有样式规则,然后在 HTML 文件中链接它。

以下是具体的操作步骤:

- 创建一个新文件,例如
styles.css。 - 将 CSS 代码(如
h1 { color: blue; text-align: center; })写入这个文件。 - 在 HTML 文件的
<head>部分,使用<link>标签链接到这个 CSS 文件。
代码示例 (styles.css):
h1 {
color: blue;
text-align: center;
}

代码示例 (index.html 的 <head> 部分):
<head>
<link rel="stylesheet" href="styles.css">
</head>

现在,所有链接到 styles.css 的 HTML 页面都会使用相同的样式。如果需要更改样式(例如将蓝色改为红色),只需在 styles.css 文件中修改一次即可。



常用的 CSS 属性 📐
我们已经学会了如何应用 CSS,现在让我们看看几种最常用、最流行的 CSS 属性,它们可以让我们的网页看起来更符合期望。
控制元素尺寸
默认情况下,HTML 对页面上的所有内容使用默认大小。但我们可以使用 CSS 更精确地控制任何特定元素的大小。

以下是控制元素尺寸的属性:

width: 设置元素的宽度,例如width: 100px;。height: 设置元素的高度,例如height: 400px;。background-color: 设置元素的背景颜色,例如background-color: orange;。
控制元素间距

为了让页面布局更友好,我们需要控制元素内部和元素之间的空间。


以下是控制元素间距的属性:

padding: 在元素边框内部添加空间,使内容与边框产生距离,例如padding: 20px;。margin: 在元素边框外部添加空间,使元素与其他元素或屏幕边缘产生距离,例如margin: 20px;。

综合示例:
<div style="width: 200px; height: 200px; background-color: orange; padding: 20px; margin: 20px;">
hello world
</div>


字体与边框样式 ✏️

除了尺寸和间距,我们还可以使用 CSS 来更改元素的实际外观,例如字体和边框。

字体样式


我们可以控制文本的字体、大小和粗细。

以下是控制字体样式的属性:
font-family: 指定字体,例如font-family: Arial, sans-serif;(sans-serif是备用字体)。font-size: 指定字体大小,例如font-size: 28px;。font-weight: 指定字体粗细,例如font-weight: bold;(粗体)。

边框样式


我们可以为元素添加边框,以分隔页面中的不同部分。
以下是控制边框样式的属性:

border: 这是一个简写属性,可以同时设置边框的宽度、样式和颜色,例如border: 3px solid black;。


综合示例:
<div style="font-family: Arial; font-size: 28px; font-weight: bold; border: 3px solid black; padding: 10px;">
hello world
</div>
使用 CSS 美化表格 📊

让我们将学到的 CSS 知识应用到一个实际例子中:美化一个 HTML 表格。

假设我们有一个简单的表格,显示海洋信息。没有样式时,它看起来并不美观。
以下是美化表格的步骤:
- 为整个表格添加边框:
table { border: 1px solid black; } - 为每个单元格添加边框:
td, th { border: 1px solid black; } - 合并单元格边框:
table { border-collapse: collapse; }(这会让相邻边框合并为一条线)。 - 为单元格内容添加内边距:
td, th { padding: 5px; }
最终 CSS 代码示例:
table {
border: 1px solid black;
border-collapse: collapse;
}
td, th {
border: 1px solid black;
padding: 5px;
}
通过添加这些简单的 CSS 规则,表格的外观得到了显著改善。
CSS 选择器:ID 与类 🎯


上一节我们样式化了所有同类元素,但有时我们只想样式化特定的一个或一组元素。本节中我们来看看如何使用 ID 和类选择器来实现更精确的样式控制。
ID 选择器
ID 是为 HTML 元素指定的唯一名称。在 CSS 中,使用 # 符号后跟 ID 名来选择该元素。

操作步骤:
- 在 HTML 元素中添加
id属性,例如<h1 id="foo">标题1</h1>。 - 在 CSS 中使用
#foo来选择并样式化这个特定元素。

代码示例:
#foo {
color: blue;
}
类选择器
类是为 HTML 元素指定的非唯一名称,多个元素可以共享同一个类名。在 CSS 中,使用 . 符号后跟类名来选择所有属于该类的元素。

操作步骤:
- 在 HTML 元素中添加
class属性,例如<h1 class="baz">标题1</h1>,<h1 class="baz">标题2</h1>。 - 在 CSS 中使用
.baz来选择并样式化所有具有该类名的元素。
代码示例:
.baz {
color: blue;
}
使用类和 ID 可以帮助我们更清晰、更有组织地管理样式,特别是在设计复杂的网页时。

CSS 选择器的特异性 ⚖️
当多个 CSS 规则可以应用于同一个 HTML 元素时,浏览器需要决定最终应用哪个样式。这个决策过程基于“特异性”(Specificity)。

特异性规则按照以下优先级顺序(从高到低)决定样式的应用:


- 内联样式:直接写在 HTML 元素
style属性中的样式,优先级最高。 - ID 选择器:例如
#foo。 - 类选择器、属性选择器、伪类:例如
.baz,[type="text"],:hover。 - 元素类型选择器:例如
div,h1。 - 通用选择器:例如
*。


示例分析:
div { color: blue; } /* 特异性较低 */
#foo { color: red; } /* 特异性较高 */
如果一个 div 元素的 ID 是 foo,那么它的文字颜色将是红色,因为 ID 选择器的特异性高于元素类型选择器。

了解特异性规则对于调试复杂的样式冲突非常重要。
更多 CSS 选择器 🔍
除了基本的选择器,CSS 还提供了许多其他强大的选择器,用于更精确地定位元素。


以下是几种常见的高级选择器:

- 后代选择器:选择某个元素内部的所有特定后代元素,用空格表示。
- 示例:
ul li { color: blue; }会选择ul内的所有li元素(包括嵌套的)。
- 示例:
- 子元素选择器:选择某个元素的直接子元素,用
>表示。- 示例:
ul > li { color: blue; }只会选择作为ul直接子元素的li。
- 示例:
- 属性选择器:根据元素的属性及属性值来选择元素。
- 示例:
a[href="https://facebook.com"] { color: red; }会选择href属性等于指定值的所有链接。
- 示例:
- 伪类:用于定义元素的特殊状态。
- 示例:
button:hover { background-color: orange; }会在用户将鼠标悬停在按钮上时,将按钮背景色改为橙色。
- 示例:

悬停效果示例代码:
<style>
button {
width: 200px;
height: 50px;
font-size: 24px;
background-color: green;
}
button:hover {
background-color: orange;
}
</style>
<body>
<button>点击我</button>
</body>

总结 📝


本节课中我们一起学习了 CSS 的核心语法和应用。我们从为什么需要 CSS 开始,逐步学习了添加样式的多种方法:内联样式、嵌入 <style> 标签以及使用外部样式表。我们探索了控制尺寸、间距、字体和边框的常用属性,并实践了如何使用 CSS 美化一个表格。

更重要的是,我们深入了解了 CSS 选择器,包括基本的元素选择器、ID 选择器、类选择器,以及更高级的后代选择器、属性选择器和伪类。最后,我们理解了 CSS 特异性的概念,它决定了当多个样式规则冲突时,哪一个会最终生效。
掌握这些基础知识,你将能够为网页添加丰富的视觉样式,并开始构建更具吸引力和用户友好性的 Web 应用程序。
哈佛 CS50-WEB 4:L0- HTML与CSS语法 3 (响应设计,Bootstrap) 📱

在本节课中,我们将要学习如何让网页在不同尺寸的设备上都能良好显示,即响应式设计。我们将探讨实现响应式设计的多种方法,包括视口设置、媒体查询、Flexbox、Grid布局,并介绍如何使用Bootstrap库来快速构建美观且响应式的界面。最后,我们将学习Sass,一个强大的CSS扩展语言,它能帮助我们更高效、更结构化地编写样式代码。
响应式设计与视口 🌐
上一节我们介绍了如何使用CSS精确控制网页样式。本节中,我们来看看如何确保网页在各种设备上都能良好显示,这就是响应式设计。
响应式设计的关键在于确保我们的网页,无论用户如何查看,看起来都很好。如今,人们不仅用电脑,也常用手机或平板电脑查看网页。因此,以响应式的方式设计网页非常重要。
我们将探讨多种在网页中实现响应式设计的方法,首先从讨论视口开始。
视口是用户实际可以看到的屏幕的视觉部分,也就是整个网页区域中向用户显示内容的部分。



当你将网页转换到移动屏幕时会发生什么呢?许多移动设备默认情况下会将其视口视为与电脑屏幕相同的宽度,因为并非所有网页都针对移动设备优化。为了确保在移动设备上可以看到所有内容,许多手机会将网页缩小以适应移动屏幕,但这通常不是我们理想的效果。


我们希望页面能够适应不同尺寸的屏幕,让标题、图像和文本等元素能适当调整以填满整个屏幕。
我们可以做的一件简单事情是在HTML的<head>部分添加一行代码来控制视口:
<meta name="viewport" content="width=device-width, initial-scale=1.0">

这行代码提供了元数据,表示将视口宽度设置为设备的宽度。默认情况下,许多手机会使用比设备实际宽度更宽的视口。通过指定视口仅为设备宽度,页面在移动设备上看起来会好得多。
使用媒体查询 📏
除了添加视口代码,我们还可以对页面进行其他更改以使其在不同屏幕上看起来更好,其中一项重要技术是媒体查询。
媒体查询主要用于根据渲染页面的设备或屏幕大小来控制页面的显示方式。

以下是使用媒体查询的一个简单例子,它根据屏幕宽度改变页面背景色:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Responsive Example</title>
<style>
/* 如果屏幕宽度大于等于600像素 */
@media (min-width: 600px) {
body {
background-color: red;
}
}
/* 如果屏幕宽度小于等于599像素 */
@media (max-width: 599px) {
body {
background-color: blue;
}
}
</style>
</head>
<body>
<h1>Welcome to my webpage</h1>
</body>
</html>


在这个例子中,当屏幕宽度大于等于600像素时,背景为红色;当屏幕宽度小于等于599像素时,背景变为蓝色。
我们能够使用媒体查询来微调页面在各种设备上的显示。你不仅可以控制背景颜色,还可以控制任何CSS属性,例如间距、填充,甚至可以使用display属性在小屏幕上隐藏某些元素。
媒体查询还可以检查设备是处于竖屏还是横屏模式,或者用户是否在尝试打印页面内容。
使用Flexbox进行布局 🔄

上一节我们介绍了如何使用媒体查询响应屏幕尺寸。本节中,我们来看看Flexbox,这是一个强大的CSS布局工具,特别适用于在响应式设计中排列多个元素。


如果我们有多个元素需要同时显示在同一页面上,并且不希望它们溢出,Flexbox非常有用。


想象在电脑显示器上显示六个元素,当转到移动屏幕时,如果它们都缩小到几乎不可见,效果可能不好。另一种方法是保持元素大小不变,但这会导致需要滚动。更好的方案是让元素能够自动换行。

Flexbox可以轻松实现这种换行效果。以下是一个使用Flexbox的例子:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Flexbox Example</title>
<style>
#container {
display: flex;
flex-wrap: wrap;
}
#container div {
background-color: green;
font-size: 20px;
margin: 20px;
padding: 20px;
width: 200px;
}
</style>
</head>
<body>
<div id="container">
<div>Sample text one</div>
<div>Sample text two</div>
<div>Sample text three</div>
<div>Sample text four</div>
<div>Sample text five</div>
<div>Sample text six</div>
<!-- 可以添加更多div -->
</div>
</body>
</html>
在这个例子中,#container被设置为display: flex,并且flex-wrap: wrap允许子元素在容器宽度不足时自动换行。当页面缩小时,元素会移动到新行,从而适应不同尺寸的屏幕。



使用Grid进行布局 🗂️

除了Flexbox,CSS还提供了Grid布局,适用于需要在特定网格中排列元素的情况。


Grid布局允许你定义列和行的具体大小。以下是一个使用CSS Grid的例子:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Grid Example</title>
<style>
#grid {
background-color: green;
display: grid;
grid-gap: 20px 10px; /* 行间距 10px,列间距 20px */
grid-template-columns: 200px 200px auto;
padding: 20px;
}
.grid-item {
background-color: white;
font-size: 20px;
padding: 20px;
text-align: center;
}
</style>
</head>
<body>
<div id="grid">
<div class="grid-item">1</div>
<div class="grid-item">2</div>
<div class="grid-item">3</div>
<div class="grid-item">4</div>
<div class="grid-item">5</div>
<div class="grid-item">6</div>
<!-- 可以添加更多项目 -->
</div>
</body>
</html>


这里,#grid被设置为display: grid。grid-template-columns: 200px 200px auto; 定义了三列:前两列固定为200像素宽,第三列自动调整以填充剩余空间。当屏幕尺寸变化时,第三列的宽度会动态变化。
使用Bootstrap库 🚀


上一节我们学习了如何使用原生CSS工具实现响应式布局。本节中,我们来看看Bootstrap,这是一个非常流行的CSS库,它提供了大量预定义的样式和组件,能帮助我们快速构建美观且响应式的网页,而无需从头编写所有样式。


要使用Bootstrap,只需在HTML文件的<head>部分添加其CSS链接:


<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">


添加后,Bootstrap会为页面应用一套默认样式,例如自定义字体。然后,你可以使用Bootstrap提供的类来快速添加样式化组件。
以下是使用Bootstrap警报组件的例子:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<title>Bootstrap Alert</title>
</head>
<body>
<div class="alert alert-primary" role="alert">
A simple primary alert!
</div>
<div class="alert alert-success" role="alert">
A simple success alert!
</div>
<div class="alert alert-danger" role="alert">
A simple danger alert!
</div>
</body>
</html>


通过更改alert-*类(如primary、success、danger),可以快速改变警报的颜色和样式。
Bootstrap还包含一个强大的网格系统,用于创建响应式布局。它将每行分为12个等宽的单位列。
以下是Bootstrap网格列的一个基础示例:


<div class="container">
<div class="row">
<div class="col-3">Column 1</div>
<div class="col-3">Column 2</div>
<div class="col-3">Column 3</div>
<div class="col-3">Column 4</div>
</div>
</div>

每个.col-3的div占用3个单位,一行4个正好占满12个单位。当屏幕缩小时,这些列会自动调整宽度。
你还可以指定不同屏幕尺寸下的列宽。例如,.col-lg-3表示在大屏幕上占3个单位,.col-sm-6表示在小屏幕上占6个单位。这样,元素可以根据屏幕大小自动换行排列。
使用Sass增强CSS ✨
上一节我们介绍了如何使用库来简化开发。本节中,我们来看看Sass,它是CSS的一个强大扩展,能帮助我们更高效、更结构化地编写样式代码,减少重复。
随着CSS代码量增长,可能会出现大量重复。Sass通过引入变量、嵌套和继承等功能来解决这个问题。

首先,Sass允许我们使用变量。在纯CSS中,如果一种颜色在多个地方使用,更改时需要修改多处。Sass的变量功能可以消除这种重复。

Sass文件使用.scss扩展名。以下是一个使用Sass变量的例子:


variables.scss
$color: red; // 定义变量
ul {
font-size: 14px;
color: $color; // 使用变量
}


ol {
font-size: 18px;
color: $color; // 使用变量
}
浏览器无法直接理解Sass,需要先将Sass编译成标准的CSS。可以使用命令行工具sass进行编译:

sass variables.scss variables.css

编译后生成的variables.css文件就可以像普通CSS一样链接到HTML中。如果需要修改变量值,只需在.scss文件中更改一处,然后重新编译即可。

为了方便,可以使用--watch参数让Sass自动监控文件变化并重新编译:

sass --watch variables.scss variables.css

其次,Sass允许选择器嵌套,这能让结构更清晰。例如,你想样式化所有在div内部的段落:


nested.scss
div {
font-size: 18px;
p {
color: blue;
}
ul {
color: green;
}
}
这会被编译为:
div { font-size: 18px; }
div p { color: blue; }
div ul { color: green; }

最后,Sass支持继承,这有助于减少通用样式的重复。例如,多种类型的消息框有共同的样式,但背景色不同:
inheritance.scss
%message {
font-family: sans-serif;
font-size: 18px;
border: 1px solid black;
padding: 20px;
margin: 20px;
}
.success {
@extend %message;
background-color: green;
}

.warning {
@extend %message;
background-color: orange;
}


.error {
@extend %message;
background-color: red;
}

通过@extend,.success、.warning和.error类都继承了%message占位符的所有样式,并各自添加了独特的背景色。

总结 📝

本节课中我们一起学习了网页响应式设计与CSS高级工具。
我们首先了解了响应式设计的重要性,并学习了如何通过设置视口来为移动设备优化网页。
接着,我们探讨了使用媒体查询来根据屏幕尺寸应用不同的CSS样式。
然后,我们介绍了两种强大的CSS布局模型:Flexbox和Grid,它们能帮助我们轻松创建适应不同屏幕的复杂布局。

之后,我们认识了Bootstrap库,它提供了大量预构建的响应式组件和网格系统,能极大提高前端开发效率。
最后,我们学习了Sass,这是一个CSS预处理器,它通过变量、嵌套和继承等功能,让我们能够编写更简洁、更易维护的样式代码。

通过这些工具和技术,我们可以确保自己构建的网页在任何设备上都能提供良好的用户体验。接下来,我们将学习如何将这些静态网页技术与Python、JavaScript等动态语言结合,构建功能丰富的Web应用。
哈佛 CS50-WEB 5:L1- GitHub操作 1(Git与GitHub基本操作) 🚀



在本节课中,我们将要学习一个对现代软件开发至关重要的工具——Git。我们将了解Git如何作为版本控制系统,帮助我们跟踪代码更改、与他人协作以及管理项目历史。同时,我们将学习如何与GitHub平台结合使用,将我们的代码托管在云端。
上一节我们介绍了用于构建网页结构的HTML和用于定义样式的CSS。本节中我们来看看如何利用Git来管理我们编写的这些代码文件,并开始为开发更复杂的Web应用做准备。

什么是Git? 🤔



Git是一个命令行工具,用于跟踪代码的更改。在开发初期,开发者可能通过复制文件来保存旧版本,但这会迅速导致混乱。Git通过保存代码在不同时间点的快照(称为提交),使我们能够清晰地跟踪所有修改。



此外,Git在团队协作开发中至关重要。它允许多人同时在同一个项目上工作,并轻松地同步彼此的更改。核心的工作流程涉及一个在线的代码仓库,团队成员可以从中拉取最新代码,进行修改后再推送回去。

Git还提供了其他强大功能,例如:
- 分支:允许在不影响主代码的情况下测试新功能。
- 回退:可以轻松地将代码恢复到之前的某个版本。

开始使用GitHub 🌐
Git项目需要存储在一个中心位置以便协作,GitHub就是这样一个流行的托管平台。一个GitHub仓库就像一个包含项目所有文件和修订历史的文件夹。

首先,你需要在 github.com 注册一个免费账户。


以下是创建一个新仓库的步骤:
- 访问
github.com/new。 - 为仓库命名(例如:
hello)。 - (可选)添加描述。
- 选择仓库可见性(公开或私有)。
- 点击“Create repository”按钮。



创建后,你会看到一个空的仓库页面,接下来我们需要将其下载到本地电脑进行操作。

核心Git命令 💻
克隆仓库

要将在线仓库下载到本地计算机,我们使用 git clone 命令。这会在你的电脑上创建该仓库的一个完整副本。
命令格式:
git clone <仓库的URL>

例如,在终端中执行:
git clone https://github.com/你的用户名/hello.git
这会在当前目录下创建一个名为 hello 的文件夹,里面包含了仓库的所有内容(初始时为空)。

跟踪与保存更改
Git不会自动保存每一个更改。你需要明确地告诉Git哪些文件的变化需要被记录,然后创建一个提交来保存当前状态的快照。


这个过程通常分为两步:
git add:将文件添加到“暂存区”,标记哪些更改将被包含在下次提交中。git commit:创建一个提交,永久记录暂存区中文件的当前状态,并附上一条描述信息。
命令示例:
# 将特定文件添加到暂存区
git add hello.html

# 提交更改,并添加描述信息
git commit -m “添加了hello.html文件”
你也可以使用快捷命令一次性添加所有已修改的文件并提交:
git commit -am “添加了标题”


与远程仓库同步
到目前为止,所有操作都只影响你本地的仓库。为了与GitHub上的远程仓库同步,需要使用另外两个命令。

git push:将你本地的提交上传到远程仓库(如GitHub)。git pull:从远程仓库下载最新的提交,更新你的本地副本。
这是团队协作的核心:你pull获取同事的最新工作,进行自己的修改并commit,然后push分享你的工作成果。


命令示例:
# 将本地提交推送到GitHub
git push
# 从GitHub拉取最新更改到本地
git pull



查看状态
在操作过程中,你可以随时使用 git status 命令来查看工作目录和暂存区的状态,例如哪些文件已被修改但未暂存,或者本地分支比远程分支领先多少个提交。

命令示例:
git status



工作流程示例 🔄



让我们串联起上述命令,看一个完整的本地修改并同步到GitHub的流程:
- 克隆仓库:
git clone <url> - 进入目录:
cd hello - 创建文件:
touch hello.html - 编辑文件:用编辑器编写HTML代码。
- 添加文件:
git add hello.html - 提交更改:
git commit -m “初始HTML页面” - 推送更改:
git push - (之后修改文件)
- 提交所有修改:
git commit -am “添加了h1标题” - 再次推送:
git push - 获取他人更新:
git pull



总结 📝


本节课中我们一起学习了版本控制工具Git及其托管平台GitHub的基本操作。我们了解了使用Git跟踪代码历史、创建提交快照的重要性,并掌握了clone、add、commit、push、pull和status这几个最核心的命令。通过本地与远程仓库的配合,我们可以高效地进行个人项目管理和团队协作开发,这是构建更复杂Web应用的基石。在接下来的课程中,我们将继续利用Git来管理我们的项目代码。
哈佛 CS50-WEB 6:L1 - GitHub操作 2 (GitHub冲突处理与分支) 🧩


在本节课中,我们将要学习Git中两个核心且强大的概念:合并冲突的处理与分支管理。当多人协作或自己在不同功能上并行工作时,理解如何解决代码冲突以及如何利用分支隔离工作流至关重要。我们将通过简单的例子,一步步掌握这些技能。

概述 📋


Git是一个强大的版本控制系统,它允许多人协作开发项目。然而,当多人修改同一文件的同一部分时,就会产生合并冲突。同时,为了高效地并行开发多个功能或修复不同的问题,我们需要使用分支功能。本节课将详细讲解如何识别、解决合并冲突,以及如何创建、切换和合并分支。


合并冲突:当更改发生碰撞时 ⚔️

上一节我们介绍了基本的Git操作,如提交、推送和拉取。本节中我们来看看当多个开发者对同一代码进行不同修改时会发生什么。


如果我们都对代码的同一部分进行了更改,然后尝试同步我们的工作,就会遇到合并冲突。这是因为我对同一行进行了更改,而我的同事也对其进行了更改。

这种类型的冲突称为合并冲突,发生在试图将我的更改与其他人的更改合并时。

合并冲突是如何产生的

通常,当我运行 git pull 命令试图从远程仓库(如GitHub)拉取更新时,如果存在冲突的提交,就会触发合并冲突。

git pull

如果有一些冲突的提交,我会收到一条类似这样的消息:
自动合并失败,修复冲突然后提交结果。
这表示某些文件的合并冲突失败,需要我手动解决这些冲突,然后提交结果。


理解冲突标记

Git会自动在冲突的文件中添加特殊的元数据标记,以帮助我们理解冲突的位置。虽然这些标记初看起来有些复杂,但我们可以将其分解为几个关键部分。


冲突标记的格式通常如下:
<<<<<<< HEAD
我的本地更改
=======
来自远程仓库的更改
>>>>>>> 冲突提交的哈希值


<<<<<<< HEAD和=======之间的内容,是我本地仓库当前版本所做的更改。=======和>>>>>>>之间的内容,是我试图拉取的远程仓库(例如GitHub)中的更改。>>>>>>>后面的一串字符是导致冲突的那个提交的哈希值,用于标识特定的提交。
解决合并冲突的步骤
为了解决合并冲突,我们需要执行以下操作:
- 打开包含冲突标记的文件。
- 仔细查看“我的更改”和“远程更改”。
- 决定最终的解决方案:保留我的版本、保留远程版本,或者以某种方式智能地结合两者。
- 删除所有冲突标记(
<<<<<<<,=======,>>>>>>>以及后面的哈希值)。 - 将文件修改为我们期望的最终状态。
- 使用
git add <文件名>将解决冲突后的文件标记为已解决。 - 使用
git commit -m “解决合并冲突”提交结果。
核心概念示例:
假设冲突发生在对 hello.html 中同一行代码的修改。我的版本添加了一个感叹号,而远程版本添加了内联样式。
冲突文件内容可能如下:
<h1 style="color: blue;">Hello, world!
<<<<<<< HEAD
!!!
=======
>>>>>>> abc123def456

解决方法是结合两者的优点:
<h1 style="color: blue;">Hello, world!!!



分支管理:并行工作的艺术 🌿


仅仅线性地记录更改有时并不够强大。在实际项目中,我们经常需要同时处理新功能开发和旧版本Bug修复。分支功能允许我们在同一个代码库中创建独立的开发线,从而高效地并行处理多项任务。


什么是分支?


想象一下,你的项目提交历史像一棵树的主干。分支就是从主干上分叉出去的一条新枝。你可以在新分支上自由地进行实验和开发,而不会影响主干(通常是 master 或 main 分支)的稳定性。

默认情况下,Git仓库有一个主分支,通常命名为 master 或 main。当你开始一项新功能或修复一个Bug时,可以基于当前提交创建一个新的功能分支。


分支的常用命令


以下是管理分支的核心命令:

- 查看分支:
git branch命令可以列出本地所有分支,当前所在分支前会有一个星号*标记。 - 创建并切换分支:
git checkout -b <新分支名>可以创建一个新分支并立即切换到该分支。 - 切换分支:
git checkout <已有分支名>可以切换到指定的已有分支。 - 合并分支:当功能开发完成并测试通过后,我们可以将其合并回主分支。首先切换到主分支
git checkout master,然后执行git merge <功能分支名>。
代码示例:
# 查看当前分支
git branch

# 创建并切换到名为 “feature-x” 的新分支
git checkout -b feature-x

# 在新分支上进行一些修改并提交...
git add .
git commit -m “开发功能X”


# 切换回主分支
git checkout master

# 将 feature-x 分支的更改合并到主分支
git merge feature-x



分支合并与冲突


在合并分支时,如果两个分支对同一文件的同一部分进行了不同的修改,同样可能产生合并冲突。其解决流程与处理拉取冲突完全相同:手动编辑文件解决冲突,然后提交合并结果。


分支的强大之处在于,它允许我们在不干扰主代码线的情况下开展工作。只有当我们对某个功能的更改感到满意时,才将其合并回主分支。
GitHub 高级协作功能 🤝

除了基本的Git操作,GitHub还提供了一些强大的协作功能,特别适合开源项目和团队开发。

1. 复刻 (Fork)

复刻 是指在GitHub上复制别人的仓库到自己的账户下。这让你拥有一个原仓库的独立副本,可以在其中自由地进行修改和实验,而不会影响原仓库。
这对于参与开源项目至关重要。你可以复刻一个项目,在自己的副本上修复Bug或添加功能,然后通过 拉取请求 提议将你的更改合并回原项目。

2. 拉取请求 (Pull Request, PR)
拉取请求 是GitHub上协作的核心机制。当你复刻了一个仓库并完成了一些改进后,可以创建一个拉取请求,请求原项目的维护者审核并将你的更改合并到他们的代码库中。

维护者可以审查代码、提出修改意见,经过讨论和修改后,如果更改被接受,就可以合并到原项目中。

3. GitHub Pages
GitHub Pages 是一项免费服务,可以让你直接从GitHub仓库托管静态网站(HTML, CSS, JavaScript)。只需创建一个名为 <你的用户名>.github.io 的特殊仓库,并将网站文件推送到该仓库,你的网站就会自动发布在 https://<你的用户名>.github.io。
代码示例:
# 克隆你的GitHub Pages仓库
git clone https://github.com/yourusername/yourusername.github.io

# 添加一个简单的首页
echo “<h1>我的个人网站</h1>” > index.html

# 提交并推送
git add index.html
git commit -m “首次提交”
git push origin master
完成推送后,访问 https://yourusername.github.io 即可看到你的网站。


总结 🎯

本节课中我们一起学习了Git中处理协作与并行开发的核心工具。

首先,我们深入探讨了合并冲突的产生原因与解决方法。当多人修改同一代码时,Git无法自动决定采用谁的版本,此时需要开发者手动介入,查看冲突标记,并决定最终的代码形态。
接着,我们学习了分支管理。分支允许我们创建代码的独立副本,从而可以安全地开发新功能或修复Bug,而不会影响稳定的主分支。我们掌握了创建分支 (git checkout -b)、切换分支 (git checkout)、查看分支 (git branch) 和合并分支 (git merge) 等关键命令。
最后,我们了解了GitHub上促进协作的高级功能:复刻(Fork) 让我们可以参与他人的项目;拉取请求(Pull Request) 是贡献代码和进行代码审查的标准流程;GitHub Pages 则为我们提供了快速、免费部署静态网站的能力。


掌握这些技能,你将能够更加自信和高效地进行团队协作与复杂的项目开发。在接下来的课程中,我们将开始学习Python,这是构建动态Web应用程序的重要基石。
哈佛 CS50-WEB 7:L2- Python编程语言全解 1 (变量,字符串格式化,条件与循环) 🐍


在本节课中,我们将要学习Python编程语言的基础知识。Python是一门功能强大且易于上手的语言,非常适合用于构建应用程序。我们将从最简单的程序开始,逐步介绍变量、字符串格式化、条件判断、循环以及几种核心的数据结构。通过本节课的学习,你将能够编写并运行基本的Python程序。
第一个Python程序 👋
我们将从一个经典的“Hello World”程序开始。这个程序虽然简单,但它展示了Python程序的基本结构和运行方式。
在Python中,我们可以使用内置的 print 函数来输出信息。print 函数接收一个参数,这个参数就是要打印到屏幕上的内容。
以下是我们的第一个程序:
print("hello world!")
为了运行这个程序,我们需要将其保存为一个以 .py 为扩展名的文件,例如 hello.py。然后,在终端中使用Python解释器来执行它。
python hello.py
运行后,你将在终端中看到输出:hello world!。这就是我们使用Python编写的第一个程序。

变量与数据类型 🔢

像许多编程语言一样,Python也支持变量。变量用于存储数据,以便在程序后续使用。


为变量赋值的语法非常简单。例如,a = 28 这行代码将整数值 28 存储在名为 a 的变量中。
与C或Java等语言不同,Python是一种动态类型语言。这意味着你不需要在声明变量时指定其类型,Python解释器会自动推断出值的类型。

以下是Python中一些常见的数据类型:

- 整数 (int):例如
28。 - 浮点数 (float):例如
1.5。 - 字符串 (string):用单引号或双引号括起来的文本,例如
‘hello’或“world”。 - 布尔值 (bool):表示真或假,写作
True或False。 - None类型:表示没有值,写作
None。
获取用户输入与字符串操作 ⌨️
上一节我们介绍了变量,本节中我们来看看如何与用户交互。Python内置的 input 函数可以提示用户输入信息。
让我们编写一个程序,询问用户的名字并向他们问好。
name = input("What‘s your name? ")
print("hello, " + name)

在这个程序中,input 函数显示提示信息并等待用户输入。用户输入的内容(一个字符串)被赋值给变量 name。然后,我们使用 + 运算符将字符串 “hello, “ 和变量 name 的值连接起来,形成最终的问候语。

除了使用 + 连接字符串,Python还提供了一种更现代、更便捷的字符串格式化方法:f-string(格式化字符串)。
使用f-string的方法是在字符串前加上字母 f,然后在字符串内部用花括号 {} 包裹变量名或表达式。
name = input("What‘s your name? ")
print(f"hello, {name}")


这段代码与上一段功能完全相同,但使用f-string让代码更清晰易读。花括号 {name} 会被变量 name 的实际值替换。
条件判断 🔀
程序经常需要根据不同的情况执行不同的代码。这时就需要用到条件判断。

让我们编写一个程序,判断用户输入的数字是正数、负数还是零。

n = int(input("Number: "))
if n > 0:
print("n is positive")
elif n < 0:
print("n is negative")
else:
print("n is zero")
以下是这段代码的解析:

int(input(...)):input函数返回的是字符串。为了进行数值比较,我们使用int()函数将用户输入的字符串转换为整数。if n > 0::这是条件判断的开始。如果n > 0这个表达式为True,则执行下面缩进的代码块。elif n < 0::elif是else if的缩写。如果第一个条件不满足(n不大于0),则检查这个条件。如果n < 0为True,则执行对应的代码块。else::如果以上所有条件都不满足,则执行else下面的代码块。- 缩进:在Python中,缩进(通常是4个空格)至关重要。它定义了代码块的归属。属于
if、elif或else的代码都必须统一缩进。


序列:字符串、列表与集合 📚

Python提供了多种用于存储数据集合的数据结构,我们统称为序列。它们各有特点,适用于不同的场景。


字符串 我们已经见过,它本质上是一个字符序列。我们可以通过索引访问其中的单个字符。


name = "Harry"
print(name[0]) # 输出 ‘H‘
print(name[1]) # 输出 ‘a‘
列表 (List) 是一种可变的、有序的元素集合。列表用方括号 [] 表示。
names = ["Harry", "Ron", "Hermione"]
print(names) # 输出整个列表
print(names[0]) # 输出 ‘Harry‘
列表是“可变”的,意味着我们可以修改它。
names.append("Draco") # 在列表末尾添加新元素
names.sort() # 对列表元素进行排序
print(names) # 输出排序后的列表
集合 (Set) 是一个无序的、元素唯一的集合。集合用 set() 或花括号 {}(注意:空集合必须用 set() 创建)表示。
s = set()
s.add(1)
s.add(2)
s.add(3)
s.add(3) # 重复添加,集合中仍只有一个3
print(s) # 输出可能是 {1, 2, 3},顺序不确定
s.remove(2)
print(s) # 输出可能是 {1, 3}

对于任何序列(字符串、列表、集合等),都可以使用内置函数 len() 来获取其长度(元素个数)。

print(f"The set has {len(s)} elements.")
循环 🔁

当我们需要重复执行某段代码时,循环就派上用场了。Python中最常用的循环是 for 循环。

for 循环可以遍历任何序列。
# 遍历一个数字范围
for i in range(6):
print(i) # 依次打印 0, 1, 2, 3, 4, 5

# 遍历一个列表
names = ["Harry", "Ron", "Hermione"]
for name in names:
print(name) # 依次打印每个名字
# 遍历一个字符串(字符序列)
name = "Harry"
for char in name:
print(char) # 依次打印 H, a, r, r, y
range(6) 生成一个从0到5的整数序列。for 循环会依次将序列中的每个元素赋值给变量(如 i、name、char),并执行循环体内的代码。

字典:键值对映射 🗺️
最后,我们来看一个非常强大的数据结构:字典 (Dictionary)。字典存储的是键值对映射。你可以通过“键”来快速查找其对应的“值”。

字典用花括号 {} 表示,键和值之间用冒号 : 分隔。

# 创建一个字典,将人物映射到学院
houses = {
"Harry": "Gryffindor",
"Draco": "Slytherin"
}


# 通过键查找值
print(houses["Harry"]) # 输出 ‘Gryffindor‘

# 添加新的键值对
houses["Hermione"] = "Gryffindor"
print(houses["Hermione"]) # 输出 ‘Gryffindor‘

字典在Web开发中极其有用,例如可以用来存储用户信息(用户名作为键,用户资料作为值)。


总结 📝
本节课中我们一起学习了Python编程语言的基础核心概念。
我们首先编写了第一个Python程序,学会了如何使用 print 函数。接着,我们了解了变量和Python中常见的数据类型,如整数、字符串和布尔值。


然后,我们探索了如何通过 input 函数与用户交互,并使用字符串连接和f-string来格式化输出。通过条件判断(if/elif/else),我们让程序能够根据不同情况做出决策。
我们还学习了多种数据结构:作为字符序列的字符串,可变有序的列表,元素唯一且无序的集合,以及通过键来映射值的字典。利用 for 循环,我们可以方便地遍历这些序列中的每一个元素。


掌握这些基础知识是使用Python进行Web编程的坚实第一步。在接下来的课程中,我们将运用这些概念来构建更复杂的应用程序。
哈佛 CS50-WEB 8:L2- Python编程语言全解 2 (函数,面向对象,异常处理) 🐍
在本节课中,我们将要学习Python编程语言的核心进阶概念,包括如何定义和使用函数、面向对象编程的基本思想,以及如何处理程序运行中可能出现的异常。掌握这些知识是构建复杂Web应用的基础。
函数定义与使用 🔧

我们已经见过Python内置的许多函数,例如接收用户输入的input函数和打印信息的print函数。但更重要的是,我们可以定义自己的函数来封装特定逻辑。

以下是定义一个简单函数的方法:
def square(x):
return x * x


这个名为square的函数接收一个参数x,并返回它的平方值。我们可以这样使用它:
for i in range(10):
print(square(i))


这段代码会循环10次,分别打印0到9的平方值。
模块与函数导入 📦
为了代码的组织和复用,我们通常将函数定义放在独立的文件中,然后在其他文件中导入使用。
假设我们在functions.py文件中定义了square函数。要在另一个文件squares.py中使用它,我们需要进行导入。
以下是导入函数的几种方式:
# 方式一:导入整个模块
import functions
result = functions.square(5)
# 方式二:导入特定函数
from functions import square
result = square(5)
如果尝试使用未导入的函数,Python会抛出NameError异常,提示名称未定义。
Python本身也提供了许多内置模块(如csv、math)和第三方模块(如Django),我们可以通过相同的方式导入并使用它们的功能。
面向对象编程 🧱
面向对象编程是一种以“对象”为中心的编程范式。对象可以存储数据(称为属性),并定义操作这些数据的方法(即函数)。

上一节我们介绍了函数和模块,本节中我们来看看如何创建和使用类。

创建类
我们可以创建一个类来表示一种新的数据类型。例如,创建一个表示二维坐标点的类。
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
class Point:定义了一个名为Point的类。__init__是一个特殊方法(构造方法),在创建类的新实例时自动调用。self参数代表实例对象本身,用于访问该实例的属性和方法。self.x = x将传入的参数x的值,存储为实例的一个属性。
创建和使用Point对象的示例:
p = Point(2, 8)
print(p.x) # 输出:2
print(p.y) # 输出:8
一个更复杂的类示例
让我们创建一个管理航班乘客的类,它包含容量限制和添加乘客的逻辑。
class Flight:
def __init__(self, capacity):
self.capacity = capacity
self.passengers = []
def open_seats(self):
return self.capacity - len(self.passengers)
def add_passenger(self, name):
if self.open_seats() <= 0:
return False
self.passengers.append(name)
return True
以下是这个类的使用方法:
flight = Flight(3)
people = ["Harry", "Ron", "Hermione", "Ginny"]
for person in people:
success = flight.add_passenger(person)
if success:
print(f"成功添加 {person} 至航班。")
else:
print(f"航班已满,无法为 {person} 提供座位。")

运行上述代码,前三位乘客会被成功添加,而第四位乘客会因航班容量已满而被拒绝。

装饰器 ✨
装饰器是Python中一个强大的特性,它允许我们在不修改原函数代码的情况下,为函数添加额外的功能。装饰器本质上是一个接收函数作为参数并返回一个新函数的函数。
以下是一个简单的装饰器示例,它在函数执行前后打印信息:
def announce(f):
def wrapper():
print("即将运行函数...")
f()
print("函数执行完毕。")
return wrapper
@announce
def hello():
print("Hello, world!")
hello()
运行hello()函数时,输出将是:
即将运行函数...
Hello, world!
函数执行完毕。


在Web开发中,装饰器常用于实现权限检查、日志记录等功能。
Lambda表达式 λ
Lambda表达式用于创建匿名函数(即没有名字的函数),通常用于需要一个简单函数作为参数的场合。
假设我们有一个字典列表,需要按特定键进行排序:
people = [
{"name": "Harry", "house": "Gryffindor"},
{"name": "Cho", "house": "Ravenclaw"},
{"name": "Draco", "house": "Slytherin"}
]

如果直接调用people.sort(),Python不知道如何比较这些字典。我们需要提供一个key函数来指定排序依据。

使用普通函数:
def get_name(person):
return person["name"]
people.sort(key=get_name)

使用更简洁的Lambda表达式:


people.sort(key=lambda person: person["name"])

Lambda表达式lambda person: person[“name”]定义了一个接收person参数并返回person[“name”]的函数。
异常处理 🛡️
程序运行时难免会遇到错误(异常)。优雅地处理异常可以防止程序崩溃,并向用户提供清晰的反馈。
我们来看一个除法程序的例子,它可能遇到两种异常:用户输入非数字(ValueError)和除数为零(ZeroDivisionError)。
import sys
try:
x = int(input("请输入x: "))
y = int(input("请输入y: "))
except ValueError:
print("错误:输入无效,请输入数字。")
sys.exit(1)
try:
result = x / y
except ZeroDivisionError:
print("错误:无法除以零。")
sys.exit(1)

print(f"{x} 除以 {y} 等于 {result}")
try:块包含可能引发异常的代码。except:块用于捕获并处理特定的异常。sys.exit(1)用于终止程序,状态码1通常表示程序因错误而退出。

通过异常处理,我们可以引导用户提供正确的输入,并在出现数学错误时给出友好提示,而不是显示复杂的错误回溯信息。


总结 📝


本节课中我们一起学习了Python的几个核心进阶概念。
我们掌握了如何定义和调用函数来封装代码逻辑,以及如何通过模块导入来组织和复用代码。我们深入探讨了面向对象编程,学会了如何创建类、定义属性和方法,从而用对象来模拟现实世界的实体。我们还了解了装饰器这一强大工具,它可以动态地修改函数的行为。对于简单的函数场景,我们学习了使用Lambda表达式来编写简洁的匿名函数。最后,我们学习了异常处理机制,使用try...except结构来优雅地捕获和处理程序运行中可能出现的错误,提升程序的健壮性。
这些概念是使用Python进行有效编程,尤其是构建复杂Web应用程序的基石。在接下来的课程中,我们将运用这些知识来开发动态的Web应用。
哈佛 CS50-WEB 9:L3- Django网络编程 1(web应用,http,路由) 🚀

📖 概述
在本节课中,我们将要学习如何使用Django框架构建动态Web应用。我们将从理解Web应用的基本工作原理(HTTP协议)开始,然后学习如何设置Django项目、创建应用、定义视图(View)和配置URL路由,最终实现一个能够根据用户请求动态生成内容的简单Web应用。
🌐 什么是Web应用与HTTP协议
我们已经学习了HTML和CSS,它们用于描述网页的结构和样式。我们还学习了Python,它是一种具备循环、条件、变量和函数等特性的编程语言。
今天我们要介绍Django,它将结合这两者。Django是一个Python网络框架,可以让我们编写能够动态生成HTML和CSS的Python代码,最终构建动态网络应用。

使用HTML和CSS时,我们创建的网页是静态的。每当你访问网页时,页面是相同的。但日常使用的网站(如新闻网站)内容会动态变化,这通常是由服务器上的程序(如用Python编写)动态生成HTML和CSS实现的。
Web应用运行在网络服务器上。客户端(如浏览器)向服务器发出请求,服务器处理请求并返回响应。这个过程基于HTTP协议(超文本传输协议)。
你可以将这个过程理解为客户端与服务器之间的请求-响应关系。
一个HTTP请求可能如下所示:
GET / HTTP/1.1
Host: www.example.com
GET是请求方法,表示希望获取资源。/表示请求网站的根目录。HTTP/1.1是协议版本。Host指定了要访问的网站地址。
服务器处理该请求后,会返回一个HTTP响应,通常如下:
HTTP/1.1 200 OK
Content-Type: text/html

<!DOCTYPE html><html>...</html>
HTTP/1.1 200 OK中,200是状态码,表示请求成功。Content-Type: text/html表示返回的内容是HTML格式,浏览器应将其渲染为网页。
常见的HTTP状态码包括:
- 200:成功。
- 404:未找到(请求的资源不存在)。
- 301:永久移动(重定向)。
- 403:禁止访问(没有权限)。
- 500:内部服务器错误(服务器端程序出错)。
🛠️ 开始使用Django
Django是一个Web框架,它预先构建了一系列工具,使我们能够专注于应用逻辑,而无需处理通用功能。
安装Django
使用Python的包管理器pip进行安装:
pip3 install Django
创建Django项目
安装完成后,可以运行以下命令创建一个新的Django项目:
django-admin startproject lecture3
此命令会创建一个名为 lecture3 的目录,其中包含Django项目的初始文件。
重要的初始文件有:
manage.py:一个命令行工具,用于管理Django项目(如运行服务器)。settings.py:项目的配置文件。urls.py:项目的URL声明文件,就像是网站的“目录”。
运行开发服务器

进入项目目录,运行以下命令启动开发服务器:
python manage.py runserver
服务器启动后,通常会提示在 http://127.0.0.1:8000 运行。在浏览器中访问此地址,可以看到Django的默认欢迎页面,这证明安装成功。
📁 Django项目与应用
一个Django项目(Project)可以包含多个应用(App)。这种划分有助于组织大型网站的各个功能模块(例如,一个电商项目可能包含用户、商品、订单等多个应用)。
创建Django应用

在项目目录下,运行以下命令创建一个新的应用:
python manage.py startapp hello
这将在项目中创建一个名为 hello 的目录,其中包含该应用的文件。
安装应用
创建应用后,需要将其添加到项目的配置中。编辑 lecture3/settings.py 文件,找到 INSTALLED_APPS 列表,添加应用名称:
INSTALLED_APPS = [
...
'hello',
]
🎯 创建视图(View)与配置URL
视图是用户访问特定URL时,服务器返回内容的处理函数。
编写第一个视图
打开 hello/views.py 文件。我们将创建一个简单的视图函数,返回“Hello World”响应。
首先,需要从Django导入 HttpResponse 类:
from django.http import HttpResponse
然后,定义一个视图函数。按惯例,它接受一个 request 参数(代表用户的HTTP请求):
def index(request):
return HttpResponse("Hello, world!")
配置应用级URL
接下来,需要告诉Django:当用户访问某个URL时,应该调用哪个视图函数。为此,我们在 hello 应用目录下创建一个 urls.py 文件。
在 hello/urls.py 中,我们定义URL模式:
from django.urls import path
from . import views
urlpatterns = [
path('', views.index, name='index'),
]
path('', ...)中的空字符串''表示这是该应用的默认(根)URL。views.index指定当访问此URL时,应调用views.py中的index函数。name='index'给这个URL模式起个名字,便于在其他地方引用。
配置项目级URL
最后,需要将应用的URL配置包含到项目的总URL配置中。编辑 lecture3/urls.py 文件:
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('hello/', include('hello.urls')),
]
path('hello/', include('hello.urls'))表示:所有以hello/开头的URL,都交给hello应用下的urls.py文件去处理。
查看结果
- 确保开发服务器正在运行 (
python manage.py runserver)。 - 在浏览器中访问
http://127.0.0.1:8000/hello/。 - 你将看到页面上显示 “Hello, world!”。
🔗 创建更多视图与URL

我们可以轻松地添加更多视图和URL。

在 views.py 中添加新函数:
def brian(request):
return HttpResponse("Hello, Brian!")

def david(request):
return HttpResponse("Hello, David!")
在 hello/urls.py 中添加对应的URL模式:
urlpatterns = [
path('', views.index, name='index'),
path('brian/', views.brian, name='brian'),
path('david/', views.david, name='david'),
]
现在,访问 http://127.0.0.1:8000/hello/brian/ 和 http://127.0.0.1:8000/hello/david/ 将看到不同的问候语。


🧩 使用URL参数实现动态路由
为每个名字都创建一个视图和URL是不现实的。我们可以使用URL参数来创建动态路由。

创建带参数的视图

在 views.py 中创建一个新视图,它接受一个额外的 name 参数:
def greet(request, name):
return HttpResponse(f"Hello, {name.capitalize()}!")
配置带参数的URL模式

在 hello/urls.py 中,使用尖括号 < > 定义参数:
urlpatterns = [
... # 其他路径
path('<str:name>/', views.greet, name='greet'),
]
<str:name>表示:匹配此位置的任意字符串,并将其作为名为name的参数传递给视图函数。str是路径转换器,确保参数是字符串类型。

现在,访问 http://127.0.0.1:8000/hello/alice/、http://127.0.0.1:8000/hello/bob/ 等任意路径,视图函数都会接收对应的 name 值,并动态生成问候语。
📝 使用模板(Template)分离HTML与Python代码
将大量HTML代码直接写在Python字符串中会很混乱,且不利于协作。Django使用模板来分离表现层(HTML)和业务逻辑(Python)。
修改视图以渲染模板

首先,修改 views.py 中的 index 视图,使用 render 函数:
from django.shortcuts import render
def index(request):
return render(request, "hello/index.html")
render 函数接收请求对象和模板名称,它会找到并渲染指定的HTML模板文件。
创建模板文件
- 在
hello应用目录下,创建一个名为templates的文件夹。 - 在
templates文件夹内,再创建一个与应用同名的文件夹hello(这是Django的推荐做法,避免模板名称冲突)。 - 在
hello/templates/hello/目录下,创建文件index.html。
在 index.html 中编写简单的HTML:
<!DOCTYPE html>
<html>
<head>
<title>Greetings</title>
</head>
<body>
<h1>Welcome to the Hello App!</h1>
</body>
</html>

现在,当访问 http://127.0.0.1:8000/hello/ 时,Django将渲染并返回这个 index.html 文件的内容。

我们将在后续课程中深入学习如何在模板中动态插入数据。

🎓 总结
本节课中我们一起学习了Web应用的基础和Django框架的入门知识。
我们了解了客户端与服务器通过HTTP协议进行通信的请求-响应模型。我们学会了如何安装Django、创建项目和应用程序。我们掌握了视图(View)的概念,并学会了如何编写视图函数来生成HTTP响应。我们深入学习了URL配置,包括如何将URL映射到特定的视图,以及如何使用参数创建动态路由。最后,我们接触了模板的概念,了解了如何将HTML代码与Python逻辑分离,以构建更清晰、更易维护的Web应用。

这些是构建任何Django Web应用的基石。在接下来的课程中,我们将在此基础上,学习如何处理表单、使用数据库以及构建更复杂的应用逻辑。
哈佛 CS50-WEB 10:L3- Django网络编程 2 (模板) 🧩
概述
在本节课中,我们将学习Django模板系统。我们将了解如何创建和使用HTML模板,如何在模板中插入变量,以及如何使用条件语句和循环来动态生成页面内容。通过构建一个简单的待办事项列表应用,我们将实践这些核心概念。
创建与应用关联的模板目录
上一节我们介绍了Django的基本视图和URL配置,本节中我们来看看如何创建和使用模板。
首先,需要在应用目录内创建一个名为 templates 的文件夹。为了给模板命名空间,避免不同应用间的模板文件冲突,最佳实践是在 templates 文件夹内再创建一个与应用同名的子文件夹。

以下是创建模板目录的步骤:
- 在
hello应用目录内创建templates文件夹。 - 在
templates文件夹内创建hello文件夹。 - 在
hello文件夹内创建index.html文件。
在 index.html 文件中,可以编写标准的HTML代码。例如,可以创建一个显示“你好,世界”的页面。

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Hello</title>
</head>
<body>
<h1>你好,世界</h1>
</body>
</html>

配置好视图函数返回此模板后,访问 /hello 路由即可看到渲染后的HTML页面。

在模板中使用变量
静态HTML页面无法动态显示内容。Django模板语言允许我们将变量插入到HTML中,实现页面内容的动态化。
假设我们想实现一个路由 /hello/<name>,根据URL中的名字显示个性化的问候。这需要修改视图函数和创建新的模板。
首先,修改 views.py 中的 greet 函数,使用 render 函数并传递上下文(context)。

# hello/views.py
from django.shortcuts import render
def greet(request, name):
# 将名字的首字母大写后传递给模板
return render(request, "hello/greet.html", {
"name": name.capitalize()
})
render 函数的第三个参数 context 是一个字典,它定义了模板可以访问的变量。这里,键 "name" 对应的值是经过 capitalize() 处理后的名字。

接下来,创建对应的模板文件 templates/hello/greet.html。在模板中,使用双大括号 {{ }} 来插入变量的值。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Greet</title>
</head>
<body>
<h1>你好,{{ name }}</h1>
</body>
</html>
当访问 /hello/Harry 时,Django会调用 greet 视图,将 "Harry" 处理为 "Harry" 并传递给 greet.html 模板。模板中的 {{ name }} 会被替换为 "Harry",最终页面上显示“你好,Harry”。
这种设计实现了关注点分离:urls.py 负责路由,views.py 负责业务逻辑和选择模板,HTML模板文件负责页面展示。
在模板中使用条件逻辑
Django模板语言不仅支持变量,还支持条件判断等逻辑控制,这让我们能创建更智能的页面。
我们将创建一个新的应用 newyear,用于判断当天是否是1月1日(新年)。这个应用将演示如何在模板中使用 if 条件语句。
首先,创建新应用并完成基本配置(添加到 INSTALLED_APPS,配置项目级 urls.py,创建应用级 urls.py)。
在 newyear/views.py 中,编写 index 视图函数。我们需要获取当前日期,并判断是否为1月1日。

# newyear/views.py
from django.shortcuts import render
import datetime

def index(request):
now = datetime.datetime.now()
# 判断月份和日期是否都为1
is_new_year = (now.month == 1 and now.day == 1)
return render(request, "newyear/index.html", {
"newyear": is_new_year
})
视图函数将布尔值 is_new_year 以 "newyear" 为键传递给模板。

然后,创建模板文件 templates/newyear/index.html。在Django模板中,使用 {% %} 标签来包裹逻辑语句,如 if 条件判断。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Is it New Year?</title>
</head>
<body>
{% if newyear %}
<h1>是</h1>
{% else %}
<h1>没有</h1>
{% endif %}
</body>
</html>


模板会根据 newyear 变量的值(True 或 False)来决定渲染“是”还是“没有”。用户最终在浏览器中看到的只是渲染结果,而看不到背后的模板逻辑。
加载静态文件(CSS)
为了使页面更美观,我们需要添加CSS样式。在Django中,不经常变化的文件(如CSS、JavaScript、图片)被称为“静态文件”。
Django提供了专门的方式来管理和加载静态文件。首先,在应用目录下创建 static 文件夹,并遵循类似的命名空间约定,在里面创建与应用同名的子文件夹。
以下是添加CSS的步骤:
- 在
newyear应用内创建static/newyear文件夹。 - 在
static/newyear文件夹内创建styles.css文件。 - 在
styles.css中编写样式。
/* static/newyear/styles.css */
h1 {
font-family: sans-serif;
font-size: 90px;
text-align: center;
}

接下来,需要在模板中加载这个CSS文件。在模板文件顶部,首先使用 {% load static %} 标签加载静态文件模块。然后,使用 {% static 'path' %} 模板标签来生成静态文件的正确URL。

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Is it New Year?</title>
{% load static %}
<link rel="stylesheet" href="{% static 'newyear/styles.css' %}">
</head>
<body>
{% if newyear %}
<h1>是</h1>
{% else %}
<h1>没有</h1>
{% endif %}
</body>
</html>

{% static 'newyear/styles.css' %} 会被Django替换为实际的静态文件URL(如 /static/newyear/styles.css)。这种方式比硬编码URL更灵活,便于后续维护和部署优化。
在模板中使用循环

我们将创建一个更复杂的任务管理应用 tasks,来演示如何在模板中使用 for 循环动态生成列表内容。
首先,创建 tasks 应用并完成基本配置。在 tasks/views.py 中,我们暂时用一个全局列表变量来存储任务,并在视图中将其传递给模板。
# tasks/views.py
from django.shortcuts import render
# 模拟的任务列表
tasks = ["foo", "bar", "baz"]
def index(request):
return render(request, "tasks/index.html", {
"tasks": tasks
})
然后,创建模板文件 templates/tasks/index.html。在模板中,使用 {% for ... in ... %} 标签来遍历任务列表。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Tasks</title>
</head>
<body>
<ul>
{% for task in tasks %}
<li>{{ task }}</li>
{% endfor %}
</ul>
</body>
</html>
{% for task in tasks %} 会遍历上下文中的 tasks 列表。对于列表中的每个元素,将其值插入到 <li> 标签中。{% endfor %} 标记了循环的结束。
访问 /tasks 路由,页面上会动态生成一个包含“foo”、“bar”、“baz”三个项目的无序列表。通过查看页面源代码,可以看到Django已经将循环逻辑转换成了具体的HTML列表项。

总结
本节课中我们一起学习了Django模板系统的核心功能。我们掌握了如何创建和组织模板文件,如何使用 {{ variable }} 在模板中插入变量,以及如何使用 {% if %} 和 {% for %} 标签进行条件判断和循环迭代来动态生成内容。此外,我们还学习了如何使用 {% static %} 标签来正确加载CSS等静态文件。通过这些工具,我们可以将业务逻辑(Python视图)与页面展示(HTML模板)清晰地分离开,构建出动态且结构清晰的Web应用程序。
哈佛 CS50-WEB 11:L3-Django网络编程3(表单与session) 📝
在本节课中,我们将学习如何使用Django处理表单、实现模板继承、管理URL命名空间,以及利用会话(session)为不同用户存储独立的数据。我们将构建一个简单的任务管理应用,通过实践来掌握这些核心概念。

概述

我们将创建一个包含两个页面的任务管理应用:一个页面用于显示任务列表,另一个页面用于添加新任务。我们将学习如何避免代码重复、安全地处理表单提交,并为每个用户维护独立的任务列表。
创建添加任务页面
上一节我们介绍了如何显示任务列表,本节中我们来看看如何创建一个新页面来添加任务。
我们首先需要创建一个新的视图函数 add 来渲染添加任务的页面。这个函数将负责显示一个表单。
# views.py
def add(request):
return render(request, "tasks/add.html")
为了让这个视图能够被访问,我们需要在 urls.py 文件中为其配置一个URL路径。
# urls.py
from django.urls import path
from . import views
urlpatterns = [
path("", views.index, name="index"),
path("add/", views.add, name="add"), # 新增的添加任务路径
]

现在,当用户访问 /tasks/add 时,将会调用 add 视图并渲染 add.html 模板。
接下来,我们创建 templates/tasks/add.html 文件。这个页面的主体将包含一个表单,而不是任务列表。
<!-- add.html -->
<!DOCTYPE html>
<html>
<head>
<title>Tasks</title>
</head>
<body>
<h1>Add Task</h1>
<form>
<input type="text" name="task">
<input type="submit" value="Add">
</form>
</body>
</html>

这个表单目前还没有任何功能,提交后不会产生任何效果。

使用模板继承避免重复

我们注意到 add.html 和 index.html 的页面结构(如 <head>、<title>)非常相似。复制粘贴代码不是最佳实践,Django的模板继承功能可以解决这个问题。

我们创建一个基础模板 layout.html,它包含页面的通用结构,并定义一个可以被子模板填充的“块”。
<!-- layout.html -->
<!DOCTYPE html>
<html>
<head>
<title>Tasks</title>
</head>
<body>
{% block body %}
{% endblock %}
</body>
</html>
然后,我们修改 index.html 和 add.html,让它们继承自 layout.html,并只填充 body 块中的内容。
<!-- index.html -->
{% extends "tasks/layout.html" %}

{% block body %}
<h1>Tasks</h1>
<ul>
{% for task in tasks %}
<li>{{ task }}</li>
{% endfor %}
</ul>
{% endblock %}
<!-- add.html -->
{% extends "tasks/layout.html" %}
{% block body %}
<h1>Add Task</h1>
<form>
<input type="text" name="task">
<input type="submit" value="Add">
</form>
{% endblock %}

这样,公共的HTML结构只定义一次,易于维护和修改。
在页面间添加链接并解决命名空间冲突
为了在页面间导航,我们可以在模板中添加链接。但是,直接硬编码URL(如 href="/tasks/add")不是好方法,因为一旦URL改变,就需要修改多处代码。
Django允许我们通过URL的名称(name)来动态生成链接。我们在 urls.py 中已经为路径定义了名称(name="index", name="add")。
在模板中,我们使用 {% url %} 标签来生成链接。
<!-- 在 index.html 中添加链接 -->
<a href="{% url 'add' %}">Add a New Task</a>
<!-- 在 add.html 中添加链接 -->
<a href="{% url 'index' %}">View Tasks</a>
然而,如果多个应用中有同名的URL(例如,新年应用也有一个叫 index 的URL),就会产生命名空间冲突。Django可能无法确定我们想链接到哪个 index。

为了解决这个问题,我们在应用的 urls.py 中为 urlpatterns 添加一个 app_name。
# tasks/urls.py
app_name = "tasks"
urlpatterns = [...]
然后,在模板中引用URL时,需要包含应用命名空间。
<!-- 修改后的链接 -->
<a href="{% url 'tasks:index' %}">View Tasks</a>
<a href="{% url 'tasks:add' %}">Add a New Task</a>


处理表单提交(POST请求与CSRF保护)
现在,我们需要让添加任务的表单真正起作用。我们将表单的 action 指向添加任务的URL,并将方法设置为 POST。POST 方法通常用于提交会改变服务器状态的数据。

<!-- add.html 中的表单 -->
<form action="{% url 'tasks:add' %}" method="post">
<input type="text" name="task">
<input type="submit" value="Add">
</form>

如果我们此时提交表单,会得到一个 403 Forbidden 错误,提示“CSRF verification failed”。这是Django的一项安全特性,用于防止跨站请求伪造攻击。
为了让表单通过验证,我们需要在表单内部添加一个CSRF令牌。
<form action="{% url 'tasks:add' %}" method="post">
{% csrf_token %}
<input type="text" name="task">
<input type="submit" value="Add">
</form>
{% csrf_token %} 标签会被Django替换为一个隐藏的输入字段,包含一个唯一的令牌。服务器会验证这个令牌,确保请求来自我们自己的网站。
使用Django表单类简化开发
手动编写HTML表单字段是可行的,但当表单复杂时,使用Django的 forms 模块会更高效。它可以帮助我们自动生成HTML、进行数据验证和清理。

首先,在 views.py 中创建一个表单类。
# views.py
from django import forms

class NewTaskForm(forms.Form):
task = forms.CharField(label="New Task")
# 可以轻松添加更多字段,例如:
# priority = forms.IntegerField(label="Priority", min_value=1, max_value=10)
然后,在 add 视图中,将这个表单的实例传递给模板。
def add(request):
return render(request, "tasks/add.html", {
"form": NewTaskForm()
})

最后,在 add.html 模板中,使用 {{ form }} 来渲染整个表单。

<form action="{% url 'tasks:add' %}" method="post">
{% csrf_token %}
{{ form }}
<input type="submit" value="Add">
</form>


Django会自动生成带有标签和输入框的HTML,并可以添加客户端验证(如检查数字范围)。
在视图中处理GET和POST请求
一个表单页面通常需要处理两种请求:
- GET请求:用户首次访问页面,获取空表单。
- POST请求:用户提交表单数据。

我们需要修改 add 视图来区分这两种情况并处理提交的数据。
# views.py
from django.shortcuts import render, redirect
from django.urls import reverse


def add(request):
# 检查请求方法是否为 POST(表单提交)
if request.method == "POST":
# 用提交的数据填充表单实例
form = NewTaskForm(request.POST)
# 检查表单数据是否有效
if form.is_valid():
# 获取清理后的数据
task = form.cleaned_data["task"]
# TODO: 将任务保存到列表
# 成功后重定向到任务列表页
return redirect(reverse("tasks:index"))
else:
# 如果表单无效,将带有错误信息的表单返回给用户
return render(request, "tasks/add.html", {"form": form})
# 如果是 GET 请求,显示一个空表单
return render(request, "tasks/add.html", {"form": NewTaskForm()})

使用会话(Session)存储用户特定数据
目前,我们将任务存储在一个全局变量 tasks = [] 中。这意味着所有访问网站的用户都共享同一个任务列表,这显然不是我们想要的。
我们需要为每个用户维护独立的任务列表。Django的会话(Session) 功能可以实现这一点。会话就像一个字典,可以为每个访问者存储特定的数据。

首先,我们需要运行迁移命令来创建Django用于存储会话数据的数据库表。

python manage.py migrate
然后,我们修改视图,将任务列表存储在 request.session 中,而不是全局变量里。

# views.py
def index(request):
# 检查会话中是否已有“tasks”键,如果没有,则初始化一个空列表
if "tasks" not in request.session:
request.session["tasks"] = []
return render(request, "tasks/index.html", {
"tasks": request.session["tasks"]
})

def add(request):
if request.method == "POST":
form = NewTaskForm(request.POST)
if form.is_valid():
task = form.cleaned_data["task"]
# 将新任务添加到当前会话的任务列表中
request.session["tasks"] += [task]
# 重定向前,确保保存会话(Django 默认会保存)
return redirect(reverse("tasks:index"))
else:
return render(request, "tasks/add.html", {"form": form})
return render(request, "tasks/add.html", {"form": NewTaskForm()})

现在,不同浏览器或隐身窗口访问网站,会拥有各自独立的会话和任务列表。


我们还可以改进 index.html,当任务列表为空时显示友好的提示。

<!-- index.html -->
{% block body %}
<h1>Tasks</h1>
<ul>
{% for task in tasks %}
<li>{{ task }}</li>
{% empty %}
<li>No tasks yet.</li>
{% endfor %}
</ul>
<a href="{% url 'tasks:add' %}">Add a New Task</a>
{% endblock %}
总结

本节课中我们一起学习了Django Web编程的几个核心概念:
- 创建多页面应用:通过定义新的视图和URL配置来添加功能页面。
- 模板继承:使用
{% extends %}和{% block %}来消除HTML代码重复,提升可维护性。 - URL命名与命名空间:使用
name参数为URL命名,并通过app_name和{% url 'app:name' %}解决命名冲突,实现动态、解耦的链接生成。 - 表单处理:
- 使用
POST方法提交表单。 - 添加
{% csrf_token %}以防止跨站请求伪造攻击。 - 利用Django的
forms.Form类简化表单创建、渲染和验证。 - 在视图中区分处理
GET和POST请求,使用form.is_valid()和form.cleaned_data处理提交的数据。
- 使用
- 会话(Session):使用
request.session(一个类字典对象)为每个用户存储独立的数据,实现了多用户环境下数据的隔离,这是构建交互式Web应用的基础。
通过这些技术,我们构建了一个具有基本功能的多用户任务管理应用,它能够安全地接收用户输入并持久化用户特定的数据。这为学习更复杂的数据库操作和Django模型打下了坚实的基础。
哈佛 CS50-WEB 12:L4- 数据库、SQL 与集成 1 (数据表与 SQL) 📊



在本节课中,我们将要学习数据库的基础知识,特别是关系型数据库和 SQL 语言。我们将了解如何创建数据表、插入数据、查询数据以及更新和删除数据。这些是构建动态 Web 应用程序,尤其是使用 Django 框架时,存储和管理数据的核心技能。
上一节我们介绍了 Django 框架,它使我们能够构建动态生成 HTML 的 Web 应用程序。本节中我们来看看如何让这些应用程序与数据库交互,以持久化存储数据。
数据库与 SQL 简介 🗃️
数据库是存储和组织数据的系统。关系型数据库将数据存储在由行和列组成的表中。SQL 是一种用于与关系数据库管理系统交互的标准化语言。
我们将以一个航空公司的航班管理系统为例,来理解这些概念。这个系统需要跟踪航班及其乘客信息。
数据表的结构 📋
在关系数据库中,数据存储在表中。每个表有行和列。列定义了数据的字段和类型,而行则代表一条条具体的记录。
例如,一个 flights 表可能包含以下列:
id: 唯一标识每个航班的数字。origin: 航班起飞机场的城市名。destination: 航班降落机场的城市名。duration: 航班的飞行时长(分钟)。
SQL 数据类型 🔢
就像 Python 有整数、字符串等类型一样,SQL 也为数据定义了类型。不同的数据库管理系统支持的类型略有不同。
以下是 SQLite 支持的一些基本类型:
TEXT: 用于存储文本字符串。INTEGER: 用于存储整数。REAL: 用于存储浮点数。NUMERIC: 一种更通用的数字类型。BLOB: 用于存储二进制大对象(如图片、文件)。
其他数据库如 MySQL 则有更丰富的类型,如 VARCHAR(n)(可变长度字符串)和不同精度的数字类型(如 INT, BIGINT, FLOAT, DOUBLE)。
创建数据表 (CREATE TABLE) 🛠️
创建表需要使用 CREATE TABLE 命令,它定义了表的结构。
以下是创建 flights 表的 SQL 语句:
CREATE TABLE flights (
id INTEGER PRIMARY KEY AUTOINCREMENT,
origin TEXT NOT NULL,
destination TEXT NOT NULL,
duration INTEGER NOT NULL
);
让我们分解这个命令:
CREATE TABLE flights: 创建一个名为flights的新表。- 括号内是列的列表,每列用逗号分隔。
id INTEGER PRIMARY KEY AUTOINCREMENT: 创建一个名为id的整数列。PRIMARY KEY表示它是唯一标识每行的主键。AUTOINCREMENT表示每次插入新行时,数据库会自动为其分配一个递增值。origin TEXT NOT NULL: 创建一个名为origin的文本列。NOT NULL是一个约束,确保该列不能为空。destination和duration列的定义方式类似。
可以为列添加其他约束,例如 UNIQUE(值必须唯一)、DEFAULT(设置默认值)或 CHECK(确保值满足特定条件)。
插入数据 (INSERT) ➕
创建表后,我们需要使用 INSERT 命令向表中添加数据。
以下是如何向 flights 表插入一行数据:
INSERT INTO flights (origin, destination, duration)
VALUES ('New York', 'London', 415);
这个命令的含义是:
INSERT INTO flights: 指定要向flights表插入数据。(origin, destination, duration): 列出我们要提供值的列名。注意,我们没有包含id,因为它会自动生成。VALUES ('New York', 'London', 415): 提供与列名顺序对应的具体值。
查询数据 (SELECT) 🔍
SELECT 命令用于从数据库中检索数据,而不会修改它。
最简单的查询是获取表中的所有数据:
SELECT * FROM flights;
* 是一个通配符,表示“所有列”。
我们也可以只选择特定的列:
SELECT origin, destination FROM flights;
通常,我们不想获取所有行,而是根据条件进行过滤。这需要使用 WHERE 子句:
SELECT * FROM flights WHERE id = 3;
SELECT * FROM flights WHERE origin = ‘New York’;
SELECT * FROM flights WHERE duration > 500 AND destination = ‘Paris’;
SELECT * FROM flights WHERE origin IN (‘New York’, ‘Lima’);
SELECT * FROM flights WHERE origin LIKE ‘%a%’;
WHERE 子句支持各种比较运算符(=, >, <, >=, <=, !=)和逻辑运算符(AND, OR, NOT)。IN 用于检查值是否在列表中,LIKE 用于模式匹配(% 代表任意数量的字符)。
SELECT 查询还可以使用函数和子句进行更复杂的操作:
LIMIT: 限制返回的行数。ORDER BY: 按指定列对结果排序。GROUP BY: 将行分组,通常与聚合函数(如COUNT,SUM,AVG,MIN,MAX)一起使用。SELECT origin, COUNT(*) FROM flights GROUP BY origin; SELECT origin, COUNT(*) FROM flights GROUP BY origin HAVING COUNT(*) > 1;
更新数据 (UPDATE) ✏️
要修改表中已有的数据,我们使用 UPDATE 命令。
以下是将所有从纽约飞往伦敦的航班时长更新为 430 分钟的语句:
UPDATE flights
SET duration = 430
WHERE origin = ‘New York’
AND destination = ‘London’;
SET 子句指定要修改的列和新值。WHERE 子句精确指定要更新哪些行,这非常重要,否则会更新表中的所有行。
删除数据 (DELETE) 🗑️
要从表中移除数据,使用 DELETE 命令。
以下是删除所有目的地为东京的航班的语句:
DELETE FROM flights WHERE destination = ‘Tokyo’;
同样,WHERE 子句至关重要,它指定了要删除哪些行。没有 WHERE 子句的 DELETE 命令将清空整个表。
在 SQLite 中实践 🖥️
SQLite 是一个轻量级的数据库,它将整个数据库存储在一个文件中,非常适合学习和开发。
以下是在命令行中使用 SQLite 的简单示例:
- 创建一个数据库文件:
sqlite3 flights.db - 在 SQLite 提示符下,运行
CREATE TABLE语句来创建表。 - 运行
.tables命令查看所有表。 - 运行
INSERT语句添加数据。 - 运行
SELECT语句查询数据。可以使用.mode column和.headers on让输出更美观。
总结 📝
本节课中我们一起学习了数据库和 SQL 的基础知识。我们了解了关系型数据库如何通过表来组织数据,并学习了使用 SQL 执行核心操作:
- 使用
CREATE TABLE定义表结构。 - 使用
INSERT向表中添加新数据。 - 使用
SELECT和WHERE子句查询和过滤数据。 - 使用
UPDATE修改现有数据。 - 使用
DELETE移除数据。
这些是直接与数据库交互的基础。在接下来的课程中,我们将看到 Django 如何提供一个抽象层(称为模型),让我们用 Python 代码来执行这些操作,而无需编写大量的原始 SQL,从而使 Web 开发更加高效和安全。
哈佛 CS50-WEB 13:L4- 数据库、SQL与集成 2 (表关联,django模型,集成) 🗄️➡️🔗
在本节课中,我们将要学习数据库的核心概念——表关联,并了解如何在Django框架中使用模型来优雅地管理这些关系,最终将它们集成到我们的Web应用中。
表关联与外键 🔗
上一节我们介绍了数据库和SQL的基本操作。本节中我们来看看当数据分布在多个表中时,它们如何相互关联。
数据中我们有多个数据表,这些表可能以某种方式相互关联。让我们看一个示例,看看这可能是如何产生的。我们将介绍一个概念,称之为外键。
这里再次是我们的航班表,它包含四列:ID、起点、目的地和持续时间。但是在纽约,当然有多个机场。因此,仅仅通过城市名称标记每个起点或目的地可能没有意义。可能我还想提供三个字母的机场代码。
那么我该如何在这个表中编码,不仅是起点,还有那个城市的机场代码,以及目的地城市的名称,还有那个机场的代码呢?
我可以添加更多的列。现在我们有这个表,包含一个ID、一个起点、起点代码、一个目的地、目的地代码和一个持续时间。但这个表开始变得相当宽,有很多列,尤其是我重复了一些冗余数据。这个数据的结构中存在一些混乱。
因此,当我们开始处理数据和越来越大的数据集,拥有越来越多的列时,我们通常会想要规范化这些数据,将它们分隔到多个不同的表中,这些表以某种方式相互引用。
所以与其仅仅有一个航班表,我们可能考虑的是,航班是一种对象,但还有另一种我关心的对象——机场。所以我可能只会有一个单独的机场表。
这个表有三列:一列是机场的ID(一个唯一数字可以识别特定机场),一列是那个机场的三个字母代码,还有一列是城市名称。
现在这是一个更加直接、简单的所有机场的表示。问题变成了我的航班表会发生什么?我的航班表在这里有一个ID、起点、目的地和持续时间,而起点和目的地的类型仅仅是文本数据。
现在我有了这个单独的机场表,其中每一行都有其独特的ID。那么在这种情况下,我可以做的是,避免存储冗余数据。我可以存储我们称之为外键的内容,即对另一个表中键的引用。
我将这些列重命名为出发地ID和目的地ID。而不是存储文本,而是存储一个数字。其中出发地ID 1意味着航班1的出发地是机场1。我可以查找机场表,找出哪个机场的ID是1,这将告诉我该航班的出发地。
因此,通过结合两个不同的表——用于表示机场的一个表和用于表示航班的一个表——我能够通过外键将这两个不同的表连接起来。我的航班表中的某些列,即出发地ID列和目的地ID列,使我能够引用存储在其他表中的信息。
你可以想象这个航空公司数据库的增长和存储更多不同种类的数据,将表之间的关系变得非常强大。
表关系的类型:一对多与多对多 ↔️
因此,你可能想象的是,除了存储机场和航班,航空公司可能还需要存储有关乘客的信息,比如谁在哪个航班上。
所以你可以想象,构建一个乘客表,其中有一个ID列来唯一标识每位乘客,一个名字列来存储每位乘客的名字,以及一个姓氏列来存储他们的姓氏,和航班ID列,以存储该乘客正在乘坐的航班。
在这种情况下,我可以说,哈利·波特在航班号1上。我可以在航班表中查找,以找出航班的出发地和目的地以及它的持续时间。
现在当我们开始设计这些表时,我们必须考虑这种设计的影响。在乘客表的情况下,确实似乎存在我创建的表设计的局限性。换句话说,如果你仔细考虑一下,你会发现这个表设计的局限性在于,任何特定行只能关联一个航班ID。哈利·波特只有一个航班ID列,并且只能存储一个值,这似乎使我们无法表示一个人可以有多个航班的情况。
这开始涉及到表中行之间不同类型关系的想法。关系的一种类型是多对一关系或一对多关系。在这种情况下,我可以表达一个航班可以关联许多不同的乘客。
但我们可能还想要一个多对多关系,其中许多不同的乘客可以与许多不同的航班关联。一个乘客可能有多个航班,一个航班可能有多个乘客。为此,我们需要另一个表,对于这种特定类型的表格有稍微不同的结构。
一种方法是创建一个单独的表来存储人员。我可以有一个人员表,每个人都有一个ID、一个名字和一个姓。但我不再在表中存储航班信息。然后我会有一个单独的表来处理航班上的乘客,并将人员与他们的航班关联起来。
这个表格可以看起来像这样。现在这是简化后的乘客表。这个表只有两列:一个是人员ID列,另一个是航班ID列。这个表的想法现在是,它被称为关联表或连接表,旨在将一个表中的一个值与另一个表中的另一个值关联起来。
这里的这一行 (1, 1) 意味着ID为1的人在航班1上。我可以在人员表中查找那个人,在航班表中查找该航班,弄清楚那个人是谁以及他们乘坐的航班。
因此,现在这使我们能够表示我们想要的关系类型。我们有一个机场表和一个航班表,任何航班都将映射到两个不同的机场。机场可能出现在多个不同的航班上,这是一种一对多的关系。
然后在这里,当涉及到乘客时,我们将人们存储在一个单独的表中,并且在人员和航班之间有多对多的映射,任何人都可以乘坐多个不同的航班,同样一个航班可以有多个乘客。
使用SQL连接查询合并数据 🧩
这种情况有些混乱,因为当我查看这个表时,不明显我在看什么数据。我看到这些数字,但不知道它们的含义。我已经将所有这些表分开,现在更难判断谁在乘坐哪个航班。
要查看人员表中的人员,在航班表中查找航班,并以某种方式将所有信息关联起来,以得出任何结论。但幸运的是,SQL使我们能够轻松地从多个不同的表中提取数据并进行连接。
我们可以使用一个连接查询,将多个表结合在一起。因此连接查询的语法可能看起来像这样:
SELECT passengers.name, flights.origin, flights.destination
FROM flights
JOIN passengers ON passengers.flight_id = flights.id;
这里我想选择每个人的名字、出发地和目的地。我将从航班表中提取信息,名字将从乘客表中提取。但通过使用连接查询,我能够从两个不同的表中提取数据,并将它们结合在一起。

我看到的是默认的连接,也称为内连接。我们有效地进行内连接,将两个表交叉比较,基于我指定的条件,仅返回在两侧都有匹配的结果。
有各种不同类型的外连接。如果我希望允许左侧表的某些内容与右侧表的任何内容不匹配,或者右侧表的某些内容与左侧表的不匹配。
数据库优化与安全考量 ⚡️🛡️
在处理序列表时,我们可以进行优化,使查询更高效。因此,我们可以在特定表上创建一个索引。你可以将其视为书籍最后的索引。索引是一个附加的数据结构,可以构建,确实需要时间和内存来构建并维护它。但一旦它存在,它就会使在特定列上的查询变得更高效。
以下是创建索引的SQL命令示例:
CREATE INDEX name_index ON passengers (last_name);
与这些技术相关的风险和潜在威胁,而在SQL中,关键是要意识到所谓的SQL注入攻击。这是一种安全漏洞,如果你不注意实际操作方式执行你的SQL命令,可能会出现这种情况。
例如,如果数据库中有一些用户信息,你可能会在数据库中存储这些用户。我们有一个看起来像这样的登录表单,你可以在其中输入你的用户名和密码。如果有人输入了他们的用户名,可能发生的情况是,网络应用程序可能会执行类似于 SELECT * FROM users WHERE username = '这个特定的用户名' 的查询。
但如果输入用户名的用户是一个黑客,可能会发生什么。似乎这个用户名有点奇怪,输入的密码无论是什么,结果可能就是他们输入的内容。用户名是 WHERE username = 'hacker',然后 -- 后面会出现的情况是,-- 在SQL中表示注释,这意味着忽略其后的一切。这样就有效地绕过了密码检查。
那么,我们该如何解决这个问题呢?一种策略是转义这些字符。另一种策略是使用一个抽象层在SQL之上,这样我们根本不需要编写SQL查询,这正是我们接下来要做的。
另一点需要注意的是关于SQL的潜在竞争条件。竞争条件是指在你有多个事件在并行线程中同时发生时,可能会发生的事情。例如,如果两个人同时试图点赞同一条帖子,当我们尝试更新时就会发生冲突。
一个策略是对数据库进行锁定,表示我在处理这个数据库,其他人无法触碰这些数据,让我完成这个事务,只有在我完成后,我才能释放锁,让其他人去修改数据库。
Django模型:用Python管理数据库 🐍🗃️
所以现在我们已经看过SQL的语法,理解这些表如何工作、如何结构化以及我们可以在这些表中添加什么,接下来我们就来特别关注Django模型。这是在Django应用程序中表示数据的一种方式。
Django在设计我们的网络应用程序时,真正强大的地方就是能够通过这些模型表示数据。因此我们将继续尝试创建一个网络应用程序,以代表航空公司可能希望在其自己的网络应用程序中存储的内容。
首先,创建一个Django项目:
django-admin startproject airline
cd airline
python manage.py startapp flights
然后,在 settings.py 中将 flights 应用添加到 INSTALLED_APPS 列表中。
接下来,在 flights 应用的 models.py 文件中定义我们的模型。每个模型将是一个Python类,代表我们关心存储信息的主表。
from django.db import models
class Airport(models.Model):
code = models.CharField(max_length=3)
city = models.CharField(max_length=64)
def __str__(self):
return f"{self.city} ({self.code})"
class Flight(models.Model):
origin = models.ForeignKey(Airport, on_delete=models.CASCADE, related_name="departures")
destination = models.ForeignKey(Airport, on_delete=models.CASCADE, related_name="arrivals")
duration = models.IntegerField()
def __str__(self):
return f"{self.id}: {self.origin} to {self.destination}"
Flight 模型中的 origin 和 destination 字段是外键,它们指向 Airport 模型。on_delete=models.CASCADE 参数表示如果引用的机场被删除,相关的航班也将被删除。related_name 参数允许我们从机场对象反向查询航班(例如,airport.departures.all())。
为了在数据库中创建这些表,我们需要创建并应用迁移:
python manage.py makemigrations
python manage.py migrate
现在,我们可以使用Django的shell与这些模型进行交互,而无需编写原始SQL:
python manage.py shell
# 导入模型
from flights.models import Airport, Flight
# 创建机场
jfk = Airport(code="JFK", city="New York")
jfk.save()
lhr = Airport(code="LHR", city="London")
lhr.save()
# 创建航班
f = Flight(origin=jfk, destination=lhr, duration=415)
f.save()
# 查询所有航班
flights = Flight.objects.all()
for flight in flights:
print(flight)
将模型集成到Web视图 🌐

我们可以围绕这个想法设计一个网络应用程序。首先,在 flights/urls.py 中设置URL路由:
from django.urls import path
from . import views
urlpatterns = [
path("", views.index, name="index"),
]
然后,在 flights/views.py 中创建视图函数:
from django.shortcuts import render
from .models import Flight

def index(request):
# 获取所有航班
flights = Flight.objects.all()
# 将航班列表传递给模板
return render(request, "flights/index.html", {
"flights": flights
})
最后,创建模板文件 flights/templates/flights/index.html 来显示航班列表:

{% extends "flights/layout.html" %}

{% block body %}
<h1>Flights</h1>
<ul>
{% for flight in flights %}
<li>Flight {{ flight.id }}: {{ flight.origin }} to {{ flight.destination }}</li>
{% endfor %}
</ul>
{% endblock %}
运行开发服务器:
python manage.py runserver
访问 http://127.0.0.1:8000/flights/,你将看到一个显示所有航班的网页。数据是从Django管理的SQLite数据库中动态获取并显示的。
总结 📚

本节课中我们一起学习了数据库表关联的核心概念,包括外键、一对多和多对多关系。我们探讨了如何使用SQL的JOIN查询来合并多个表中的数据,并简要了解了数据库优化(索引)和安全(SQL注入、竞争条件)的考量。

随后,我们深入Django框架,学习了如何定义模型(Model)来映射数据库表,如何使用外键建立模型间的关系,以及如何通过Django的ORM(对象关系映射)来操作数据,而无需编写复杂的SQL语句。
最后,我们将Django模型集成到一个简单的Web视图中,创建了一个能动态显示航班列表的网页应用。这展示了Django如何将数据库逻辑、业务逻辑和展示逻辑清晰地分离开来,极大地简化了Web开发中数据驱动的部分。
哈佛 CS50-WEB 14:L4- 数据库、SQL与集成 3 (用户管理) 🧑💻

在本节课中,我们将要学习如何利用Django框架内置的强大功能来管理用户和模型数据。我们将重点探索Django的管理应用程序、如何扩展我们的航班应用以包含乘客信息,以及如何实现用户认证系统。通过这些内容,你将学会如何高效地操作数据库,并为你的Web应用添加用户登录和登出功能。

概述:Django管理应用程序 🛠️

上一节我们介绍了如何创建模型和视图。本节中我们来看看Django如何通过其内置的管理应用程序,让我们无需编写大量代码就能操作数据库模型。

Django基于“不重复造轮子”的理念构建。它不希望程序员重复他人已完成的工作。定义模型并快速创建、编辑和操作模型的过程非常普遍,因此Django已经构建了一个完整的、专门用于操作模型的应用程序,称为Django管理应用程序。
我们已经看到过这个应用程序的踪迹。在我们的应用程序内部的urls.py配置文件中,除了为我们自己的应用添加路径外,Django还默认提供了一个路径:
path('admin/', admin.site.urls)
访问 /admin 路径会将我们带到管理应用程序。
为了使用这个管理应用程序,我们需要在Django网络应用程序内部创建一个管理员账户。这可以通过命令行完成:
python manage.py createsuperuser
此命令会提示输入用户名、电子邮件地址和密码。Django会为这个网络应用程序创建一个超级用户账户,使我们能够使用这些凭证访问管理网页界面,并操作底层模型。
注册模型到管理界面 📝

为了在管理界面中操作我们的模型,我们需要做的第一件事是将模型注册到管理应用程序。
在模型的models.py文件中,我们定义了Airport和Flight类。在我们的应用目录中,还有一个名为admin.py的文件。在这个文件中,我们首先从模型中导入Flight和Airport,然后使用admin.site.register()方法进行注册。

以下是admin.py文件的内容示例:
from django.contrib import admin
from .models import Flight, Airport
admin.site.register(Airport)
admin.site.register(Flight)
这段代码告诉Django的管理应用程序,我们希望使用它来操作Airport和Flight模型。

使用管理界面操作数据 🖥️
现在让我们启动服务器并查看管理应用程序的实际工作方式。

运行以下命令启动网络服务器:
python manage.py runserver
访问 http://localhost:8000/admin(而不是 /flights),这将打开Django管理应用程序的登录界面。使用之前创建的超级用户凭证登录。



登录后,你将看到Django的站点管理界面。这个界面完全由Django构建,我们无需设计。重要的是,我们现在有能力通过这个网页接口来“添加”和“管理”机场与航班。

例如,点击“Airports”,可以看到已添加到数据库中的机场(如东京、巴黎、伦敦、纽约)。我们可以点击“ADD AIRPORT”来添加新机场,例如上海(PVG)、伊斯坦布尔、莫斯科和利马。添加后点击保存。
同样,我们可以点击“Flights”来添加新航班,选择出发地、目的地和时长。通过这个界面,我们能够快速地向网站添加新数据。Django最初就是为新闻机构设计的,这种界面使得快速发布新文章变得非常容易。

如果我们回到自己编写的航班应用页面(/flights),现在可以看到通过Django管理界面添加的所有新航班。

创建航班详情页面 🔗

现在,我们希望为Web应用添加更多页面,使其功能更复杂。例如,我们希望能够点击特定航班以查看其详细信息,而不仅仅是/flights页面显示的所有航班列表。

我们希望每个航班都有自己的页面,例如 /flights/1 显示航班ID为1的详细信息。
为此,我们需要返回到urls.py,创建一个新的路径。这个路径将包含一个航班ID作为参数。
path('flights/<int:flight_id>', views.flight, name='flight')
然后,在views.py中添加一个名为flight的函数,它接受flight_id作为参数。
def flight(request, flight_id):
flight = Flight.objects.get(pk=flight_id)
return render(request, "flights/flight.html", {
"flight": flight
})
这个函数首先获取具有指定flight_id的Flight对象(pk是主键Primary Key的通用引用方式)。然后,它渲染一个名为flight.html的模板,并将航班对象传递给它。
接下来,我们创建flight.html模板文件。这个文件将扩展基础布局,并在主体部分显示航班的详细信息。
{% extends "flights/layout.html" %}
{% block body %}
<h1>Flight {{ flight.id }}</h1>
<ul>
<li>Origin: {{ flight.origin }}</li>
<li>Destination: {{ flight.destination }}</li>
<li>Duration: {{ flight.duration }} minutes</li>
</ul>
{% endblock %}
现在,访问 /flights/1 将显示关于航班1的信息,访问 /flights/2 将显示航班2的信息。
需要注意的是,目前如果尝试访问一个不存在的航班(例如 /flights/28),会引发错误。在实际应用中,应该添加错误检查来处理这种情况,但为了简单起见,我们暂时跳过。
扩展模型:添加乘客 👥
上一节我们为航班添加了详情页面。本节中,我们来扩展数据模型,为航班添加乘客信息,以便表示实际搭乘航班的乘客。
我们回到models.py,在Airport和Flight类之外,创建一个新的Passenger模型类。
class Passenger(models.Model):
first = models.CharField(max_length=64)
last = models.CharField(max_length=64)
flights = models.ManyToManyField(Flight, blank=True, related_name="passengers")
def __str__(self):
return f"{self.first} {self.last}"
Passenger模型具有以下属性:
first: 名字,字符字段,最大长度64。last: 姓氏,字符字段,最大长度64。flights: 一个与Flight模型的多对多关系字段。这意味着一个航班可以有多个乘客,一个乘客也可以搭乘多个航班。blank=True允许乘客没有关联航班。related_name="passengers"意味着,如果我们有一个航班对象,可以通过flight.passengers来访问该航班上的所有乘客。
创建模型后,我们需要运行迁移命令来将这些更改应用到数据库。
python manage.py makemigrations
python manage.py migrate
接下来,我们需要在admin.py中注册Passenger模型,以便在管理界面中操作它。
from .models import Passenger
admin.site.register(Passenger)
现在,运行服务器并访问管理界面(/admin)。在“Passengers”部分,我们可以添加乘客,例如哈利·波特,并选择他搭乘的航班(可以多选)。我们还可以添加罗恩·韦斯利、赫敏·格兰杰、金妮·韦斯利等乘客。
在航班页面显示乘客信息 📋
现在,我们希望在航班详情页面上显示搭乘该航班的乘客信息。
为此,我们需要修改views.py中的flight视图函数,将乘客信息也传递给模板。
def flight(request, flight_id):
flight = Flight.objects.get(pk=flight_id)
passengers = flight.passengers.all()
return render(request, "flights/flight.html", {
"flight": flight,
"passengers": passengers
})
我们通过flight.passengers.all()获取该航班上的所有乘客(passengers是我们在模型中定义的related_name)。
然后,在flight.html模板中,我们可以添加一个部分来循环显示乘客。
<h2>Passengers</h2>
<ul>
{% for passenger in passengers %}
<li>{{ passenger }}</li>
{% empty %}
<li>No passengers on this flight.</li>
{% endfor %}
</ul>
现在,访问 /flights/1,你会看到“哈利·波特”被列为该航班的乘客。而访问 /flights/2,则会显示“No passengers on this flight.”。


添加页面间导航与预订功能 ➕

目前,我们必须在浏览器地址栏手动输入URL才能在页面间导航,这很不方便。我们可以在航班列表和详情页面之间添加链接。

在flight.html中,添加一个返回航班列表的链接。
<a href="{% url 'index' %}">Back to Full List</a>
在index.html(显示所有航班的页面)中,将每个航班列表项变成一个指向该航班详情页面的链接。
{% for flight in flights %}
<li>
<a href="{% url 'flight' flight.id %}">
Flight {{ flight.id }}: {{ flight.origin }} to {{ flight.destination }}
</a>
</li>
{% endfor %}
现在,在航班列表页面点击任何一个航班,都会跳转到对应的详情页面,详情页面也有链接可以返回列表。
接下来,我们可能还想添加一个功能:为特定航班预订(添加)乘客。
这需要一个新的路由来处理预订。我们在urls.py中添加:
path('flights/<int:flight_id>/book', views.book, name='book')
然后,在views.py中实现book视图函数。
from django.http import HttpResponseRedirect
from django.urls import reverse
def book(request, flight_id):
if request.method == "POST":
flight = Flight.objects.get(pk=flight_id)
passenger_id = int(request.POST["passenger"])
passenger = Passenger.objects.get(pk=passenger_id)
passenger.flights.add(flight)
return HttpResponseRedirect(reverse("flight", args=(flight_id,)))
这个函数的工作原理:
- 检查请求方法是否为
POST(表示提交表单数据)。 - 获取指定的航班对象。
- 从表单的
POST数据中获取乘客ID(表单中会有一个名为passenger的字段)。 - 获取对应的乘客对象。
- 使用
passenger.flights.add(flight)将该乘客添加到航班的乘客集合中。Django会在底层处理多对多关系表的更新。 - 操作完成后,将用户重定向回该航班的详情页面。

现在,我们需要在航班详情页面上创建这个预订表单。但表单中应该列出所有未搭乘该航班的乘客供选择。因此,我们需要修改flight视图,将“非乘客”列表也传递给模板。
def flight(request, flight_id):
flight = Flight.objects.get(pk=flight_id)
passengers = flight.passengers.all()
non_passengers = Passenger.objects.exclude(flights=flight).all()
return render(request, "flights/flight.html", {
"flight": flight,
"passengers": passengers,
"non_passengers": non_passengers
})
Passenger.objects.exclude(flights=flight).all()会获取所有flights字段中不包含当前航班的乘客。

最后,在flight.html模板中添加表单。
<h2>Add Passenger</h2>
<form action="{% url 'book' flight.id %}" method="post">
{% csrf_token %}
<select name="passenger">
{% for passenger in non_passengers %}
<option value="{{ passenger.id }}">{{ passenger }}</option>
{% endfor %}
</select>
<input type="submit" value="Book Flight">
</form>
这个表单将提交到book视图。它包含一个下拉选择框(<select>),其选项是所有非乘客。每个选项的值是乘客的ID,显示的文字是乘客的名字。用户选择一个乘客并提交后,该乘客就会被添加到当前航班。
运行应用,现在在航班详情页面底部可以看到“Add Passenger”部分。选择一个未搭乘该航班的乘客(如金妮·韦斯利)并提交,页面刷新后,该乘客就会出现在乘客列表中。

自定义管理界面 ⚙️
Django的管理界面虽然是现成的,但高度可定制。例如,默认的航班列表只显示对象的字符串表示(通常是ID)。我们可以自定义显示更多字段。
在admin.py中,我们可以为每个模型创建一个管理配置类。
class FlightAdmin(admin.ModelAdmin):
list_display = ("id", "origin", "destination", "duration")
class PassengerAdmin(admin.ModelAdmin):
filter_horizontal = ("flights",)
FlightAdmin中的list_display指定了在航班列表页面显示哪些字段。
PassengerAdmin中的filter_horizontal为多对多字段flights提供了一个更友好的水平选择器界面,方便添加或移除航班。
然后,在注册模型时使用这些配置类。
admin.site.register(Flight, FlightAdmin)
admin.site.register(Passenger, PassengerAdmin)
admin.site.register(Airport)
现在,在管理界面中查看航班列表,会看到ID、出发地、目的地和时长。编辑乘客时,操作其航班的界面也变成了更直观的水平双选框。
实现用户认证系统 🔐

在许多网站中,我们需要用户认证功能,允许用户登录和登出。Django框架内置了完整的认证系统,我们无需从头实现。


首先,我们创建一个新的Django应用来处理用户相关功能。
python manage.py startapp users
然后,在项目的settings.py中的INSTALLED_APPS列表里添加'users'。接着,在项目的urls.py中包含用户应用的URL配置。
path('users/', include('users.urls'))
在users应用目录下创建urls.py文件,定义路由。
from django.urls import path
from . import views
urlpatterns = [
path('', views.index, name='index'),
path('login', views.login_view, name='login'),
path('logout', views.logout_view, name='logout'),
]
我们定义了三个路由:主页(index)、登录页(login)和登出页(logout)。
接下来,在views.py中实现这些视图。

index视图显示当前登录用户的信息。如果用户未认证,则重定向到登录页面。
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.contrib.auth import authenticate, login, logout
def index(request):
if not request.user.is_authenticated:
return HttpResponseRedirect(reverse("login"))
return render(request, "users/user.html")
login_view视图处理登录逻辑。它通过POST请求接收用户名和密码,使用Django的authenticate函数验证,如果成功则使用login函数登录用户。
def login_view(request):
if request.method == "POST":
username = request.POST["username"]
password = request.POST["password"]
user = authenticate(request, username=username, password=password)
if user is not None:
login(request, user)
return HttpResponseRedirect(reverse("index"))
else:
return render(request, "users/login.html", {
"message": "Invalid credentials."
})
return render(request, "users/login.html")
logout_view视图非常简单,调用Django的logout函数即可。
def logout_view(request):
logout(request)
return render(request, "users/login.html", {
"message": "Logged out."
})
现在,我们需要创建对应的模板。首先创建users/templates/users/layout.html作为基础模板。
<!DOCTYPE html>
<html>
<head>
<title>Users</title>
</head>
<body>
{% block body %}
{% endblock %}
</body>
</html>
然后创建登录模板login.html。
{% extends "users/layout.html" %}
{% block body %}
<h1>Login</h1>
{% if message %}
<div>{{ message }}</div>
{% endif %}
<form action="{% url 'login' %}" method="post">
{% csrf_token %}
<input type="text" name="username" placeholder="Username">
<input type="password" name="password" placeholder="Password">
<input type="submit" value="Login">
</form>
{% endblock %}
最后创建用户主页模板user.html,显示用户信息并提供一个登出链接。
{% extends "users/layout.html" %}


{% block body %}
<h1>Welcome, {{ request.user.first_name }}</h1>
<ul>
<li>Username: {{ request.user.username }}</li>
<li>Email: {{ request.user.email }}</li>
</ul>
<a href="{% url 'logout' %}">Log Out</a>
{% endblock %}
在Django模板中,可以通过request.user访问与当前请求关联的用户对象。
在使用前,我们需要通过Django管理界面(/admin)创建一些普通用户(非超级用户)。然后访问/users,由于未登录,会被重定向到登录页面。输入正确的用户名和密码后,会跳转到用户主页,显示欢迎信息和用户详情。点击“Log Out”链接即可登出。


总结 📚
本节课中我们一起学习了Django框架中几个核心的高级功能:

- Django管理应用程序:一个开箱即用的强大界面,允许我们快速地对数据库模型进行增删改查操作,极大地提高了开发效率。
- 模型关系与页面扩展:我们扩展了航班应用,引入了
Passenger模型,并建立了与Flight的多对多关系。我们创建了航班详情页面,并实现了在页面间导航以及为航班添加乘客的功能。 - 用户认证系统:我们利用Django内置的认证模块,快速实现了一个完整的用户登录、登出和会话管理系统,而无需自己处理密码哈希和会话管理等复杂问题。

Django通过这些内置功能,为我们提供了表示模型、操作数据和管理用户的强大工具集,使得构建数据驱动的动态Web应用变得快速而高效。
哈佛 CS50-WEB 15:L5- JavaScript编程全解 1 (事件,变量) 🚀



[音乐]

概述
在本节课中,我们将要学习JavaScript编程的基础知识。我们将了解JavaScript如何作为客户端脚本语言在用户的网页浏览器中运行,并探索其核心概念,包括事件处理和变量操作。通过本节课的学习,你将能够编写简单的JavaScript代码,使网页具备交互性。
从服务器端到客户端

上一节我们介绍了基于Python的服务器端Web编程。本节中我们来看看客户端编程语言JavaScript。
通常,用户(也称为客户端)使用网页浏览器(如Chrome或Safari)向Web服务器发送HTTP请求。服务器处理请求后,返回响应给客户端。到目前为止,我们编写的所有代码(例如在Django Web应用程序中)都在服务器上运行。

JavaScript使我们能够开始编写在用户网页浏览器中运行的客户端代码。这非常有用,原因如下:
- 首先,如果有些计算不需要与服务器交互,在客户端独立运行代码可以更快。
- 其次,我们可以使网页更具互动性。HTML仅描述页面结构,而JavaScript提供了直接操作DOM(文档对象模型)的能力。DOM代表了用户所查看网页的树状层次结构。
如何在网页中添加JavaScript 🛠️
我们如何在网页中使用JavaScript来添加编程逻辑呢?HTML是一种通过嵌套标签描述网页结构的语言。为了将JavaScript添加到网页中,我们使用 <script> 标签。
以下是使用 <script> 标签的方法:
- 在HTML页面内部使用
<script>标签。 - 浏览器会将
<script>标签之间的任何内容解释为JavaScript代码并执行。


我们的第一个程序可能看起来像这样:
<script>
alert('你好,世界');
</script>
其中 alert 是一个函数,它会向用户显示一个包含指定文本的警告框。

让我们来实践一下。创建一个名为 hello.html 的新文件,并包含以下代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Hello</title>
<script>
alert('你好,世界');
</script>
</head>
<body>
<h1>你好</h1>
</body>
</html>
当你在浏览器中打开这个文件时,页面顶部会显示一个写着“你好,世界”的警告框。点击“确定”按钮关闭警告后,你将看到原始的“你好”页面。这是我们第一个JavaScript示例,它使用了浏览器内置的 alert 函数。
事件驱动编程 ⚡
上一节我们介绍了如何执行简单的JavaScript代码。本节中我们来看看JavaScript的核心编程范式:事件驱动编程。

网络上的许多交互都是以事件的形式发生的。事件的例子包括:
- 用户点击按钮。
- 用户从下拉列表中选择选项。
- 用户滚动页面。
- 用户提交表单。

我们可以为这些事件添加“事件监听器”或“事件处理程序”。这些处理程序是当特定事件发生时运行的JavaScript函数。
让我们修改之前的例子,让警告在点击按钮时显示,而不是在页面加载时显示。
首先,我们将警告功能封装到一个函数中:
function hello() {
alert('你好,世界');
}
然后,我们在HTML中添加一个按钮,并为其设置 onclick 事件处理程序:
<button onclick="hello()">点击这里</button>
现在,完整的 hello.html 文件如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Hello</title>
<script>
function hello() {
alert('你好,世界');
}
</script>
</head>
<body>
<h1>你好</h1>
<button onclick="hello()">点击这里</button>
</body>
</html>
刷新页面后,你会看到“你好”标题和一个“点击这里”按钮。只有当你点击按钮时,才会弹出“你好,世界”的警告框。每次点击按钮,都会调用 hello 函数并显示警告。
使用变量存储状态 🔢
上一节我们让代码响应了点击事件。本节中我们来看看如何使用变量来跟踪和存储数据。
我们将创建一个简单的计数器程序。创建一个新文件 counter.html。

首先,我们定义一个变量来存储当前的计数值,并初始化为0:
let counter = 0;

然后,我们创建一个 count 函数,每次调用时增加计数器的值并显示它:
function count() {
counter++;
alert(counter);
}
以下是增加变量值的几种等效写法:
counter = counter + 1;counter += 1;counter++;(这是增加1的简写)
最后,在HTML中添加一个按钮来触发这个函数:
<button onclick="count()">计数</button>
完整的 counter.html 文件如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Counter</title>
<script>
let counter = 0;
function count() {
counter++;
alert(counter);
}
</script>
</head>
<body>
<h1>计数器</h1>
<button onclick="count()">计数</button>
</body>
</html>
打开页面,点击“计数”按钮,你会看到警告框依次显示1, 2, 3... 每次点击,变量 counter 的值都会增加,并显示出来。

操作DOM以更新页面内容 🎨
上一节我们使用警告框来显示信息。本节中我们来看看如何直接操作网页内容,这是JavaScript在Web开发中更强大、更常见的用途。
我们可以使用JavaScript来操作DOM(文档对象模型),即页面上所有元素的表示。让我们回到 hello.html 的例子,这次我们不显示警告,而是直接更改页面上的文本。
我们将修改 hello 函数,让它找到页面上的 <h1> 元素并将其内容从“你好”改为“再见”。
使用 document.querySelector() 函数可以选择页面上的元素。它接收一个CSS选择器作为参数(例如 'h1' 用于选择第一个 <h1> 标签),并返回该元素的JavaScript表示。
然后,我们可以通过修改元素的 innerHTML 属性来改变其内容。
修改后的 hello 函数如下:
function hello() {
document.querySelector('h1').innerHTML = '再见!';
}
现在,完整的 hello.html 文件如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Hello</title>
<script>
function hello() {
document.querySelector('h1').innerHTML = '再见!';
}
</script>
</head>
<body>
<h1>你好</h1>
<button onclick="hello()">点击这里</button>
</body>
</html>
打开页面,点击按钮,标题会从“你好”变成“再见!”。我们成功使用JavaScript代码查找并操纵了页面元素。
添加条件逻辑和优化代码 ⚖️
上一节我们实现了直接更新页面内容。本节中我们来看看如何添加条件逻辑,并优化我们的代码。
如果我们希望点击按钮时,文本在“你好”和“再见”之间切换,而不是永远变成“再见”,就需要使用条件语句。

JavaScript的条件语句与Python类似,使用 if、else if 和 else。我们使用 === 进行严格相等比较(检查值和类型是否都相同)。也可以使用 == 进行宽松比较,但通常推荐使用 ===。
以下是实现切换功能的 hello 函数:
function hello() {
if (document.querySelector('h1').innerHTML === '你好') {
document.querySelector('h1').innerHTML = '再见!';
} else {
document.querySelector('h1').innerHTML = '你好';
}
}
这段代码有一个可以优化的地方:它三次调用了 document.querySelector('h1'),效率较低。我们可以将查找到的元素存储在一个变量中,然后重复使用这个变量。
此外,如果我们知道一个变量的值在初始化后不会改变,应该使用 const 而不是 let 来声明它,这能防止意外修改并提高代码清晰度。
优化后的代码如下:
function hello() {
const heading = document.querySelector('h1');
if (heading.innerHTML === '你好') {
heading.innerHTML = '再见!';
} else {
heading.innerHTML = '你好';
}
}

现在,页面标题会在“你好”和“再见!”之间来回切换,并且我们的代码更高效、更易读。
总结

本节课中我们一起学习了JavaScript编程的基础知识。我们了解了JavaScript作为客户端语言的作用,学会了如何在HTML中使用 <script> 标签添加JavaScript代码。我们探索了事件驱动编程,通过 onclick 属性让函数响应按钮点击事件。我们还学习了如何使用 let 和 const 声明变量来存储和跟踪数据状态。最后,我们掌握了通过 document.querySelector() 操作DOM来动态更新网页内容的方法,并引入了条件逻辑 (if...else) 来创建更复杂的交互行为。这些是构建交互式Web应用的基石。
哈佛 CS50-WEB 16:L5- JavaScript 编程全解 2 (DOM 操作) 🧩



在本节课中,我们将要学习如何使用 JavaScript 来操作网页的文档对象模型(DOM)。我们将从简单的计数器程序开始,逐步探索如何动态更新页面内容、响应用户事件、修改元素样式,并最终构建一个交互式的待办事项列表应用。通过本课,你将掌握让网页“活”起来的关键技能。
从计数器到 DOM 操作 🔢
上一节我们介绍了 JavaScript 的基本语法和函数。本节中我们来看看如何让 JavaScript 与网页内容进行交互。


我们的计数程序实际上正在改进。目前计数器的状态显示一个警报,当我计数时显示一个警报,显示“1”,再次计数时显示“2”。我可以做得更好,不是显示警报,而是以某种方式操作 DOM。
我希望将这个 h1 大标题放在顶部,初始值设为 0。现在当我增加计数器的值时,我将改为使用 document.querySelector 找到 h1 元素并更新其 innerHTML,将其等于计数器变量的值。
document.querySelector('h1').innerHTML = counter;
这样,如果我刷新此页面,h1 的初始值就是 0。每次我点击计数,我们将更新该 h1 元素的内容,设置为 1、2、3、4 等等。每次我点击计数按钮,它会增加变量的值,并操作 DOM 进行更改以产生我想在这个实际页面上看到的效果。

添加条件逻辑与模板字面量 🧠
我们可以开始添加额外的逻辑。也许我确实偶尔想要一个警报,但不想每次都出现。我可以添加一个条件,比如说如果我只想显示一个警报,每次我计数到 10 的倍数,比如 10、20、30、40、50 等等。
我可以添加一个条件,判断如果计数器对 10 取模,取模运算只会在你除以 10 时得到余数。如果将计数器除以 10,如果余数为 0,这意味着计数器是 10 的倍数。
if (counter % 10 === 0) {
// 显示警报
}
为了做到这一点,我真正想做的是在 JavaScript 中将变量插入字符串中。在 Python 中,我们会使用格式化字符串。JavaScript 也做同样的事情,只是语法略有不同。它使用反引号来创建模板字面量。
alert(`计数现在是 ${counter}`);
这是一个模板字面量,每次我们到达十的倍数时,我们将显示一个警报。这个美元符号和大括号意味着实际上插入 counter 变量的值。因此这个模板字面量可以让我们结合变量和字符串,以生成新的字符串。
分离 JavaScript 与 HTML 🧹
现在我们可以开始做哪些改进?我们可以做哪些更改来改进设计它的方式?特别是当我们的程序变得更复杂时,我们通常不想将 JavaScript 代码与 HTML 的内容交杂在一起。
这里我们有按钮 onclick 等于 count,这是函数的名称。尤其是当我们的页面变得更复杂时,如果我们不断维护一点 JavaScript 代码,比如在 HTML 中调用一个函数,这会变得有点烦人。


我们可以开始做到这一点,我可以在 JavaScript 中添加一个事件监听器。
document.querySelector('button').onclick = count;
这里所说的是 document.querySelector('button') 找到页面上的按钮,一旦我找到了那个按钮,我将访问它的 onclick 属性并将其设置为 count 函数。注意,我实际上并没有调用该函数,只是将 onclick 设置为函数本身。这样做的目的是在按钮被点击时,仅在那时才应该运行这个 count 函数。
处理代码执行时机问题 ⏰
现在我可以尝试通过刷新页面来运行这个程序。我将其刷新,初始值为零,我按下计数,似乎什么也没有发生。每当你在 JavaScript 中遇到问题而未得到想要的行为时,查看 JavaScript 控制台通常是有帮助的。
问题似乎是我正在尝试访问 null 的 onclick 属性。null 在 JavaScript 中表示“没有”,是表示不存在某个对象的方式。那么为什么这个 document.querySelector('button') 会返回 null 呢?
结果发现如果 document.querySelector 无法找到某个元素,它将返回 null。这是浏览器工作方式的一点怪癖。浏览器从上到下运行我们的代码,当它到达 document.querySelector('button') 这行时,文档的主体尚未加载完成,DOM 的内容尚未加载,结果是我们无法找到这个按钮。
那么我们如何解决这个问题呢?一种策略是将 <script> 标签移动到底部,在定义了按钮之后。另一种更常见的方式是添加另一个事件监听器到整个文档上。
document.addEventListener('DOMContentLoaded', function() {
// 在这里为按钮添加事件监听器
document.querySelector('button').onclick = count;
});
DOMContentLoaded 事件是在文档对象模型(DOM)页面结构完成加载时触发的事件。现在我们所做的是,等到 DOM 加载完成,等到所有的页面上的内容加载完成后,接着运行这个函数,这个函数将为这个按钮添加事件处理程序。

将 JavaScript 移至外部文件 📁

相较于我之前的情况,这是一个改进。但是我代码的其余部分也如此。就像在 CSS 的情况下,我们能够将原本位于页面头部的 CSS 移动到单独的文件中,你也可以对 JavaScript 做同样的事情。
通过将我们的 JavaScript 移动到一个单独的文件中,为此我可以创建一个新文件,称为 counter.js,它将包含我 counter.html 文件的所有 JavaScript。
在 counter.html 中,我使用 <script src="counter.js"></script> 来引入 JavaScript 文件。这样,我的 HTML 现在更简单,它只是主体、h1 和按钮,然后我的所有 JavaScript 现在都位于一个单独的文件中。
这让我能够将 HTML 代码和 JavaScript 代码彼此分开。如果我有在多个不同 HTML 页面中使用的常见 JavaScript 代码,多个 HTML 页面都可以包含相同的 JavaScript 源,而不需要重复自己。
响应用户输入与表单 📝
既然我们有能力获取 DOM 元素并实际操作它们的内容,我们可以开始让我们的页面变得更加互动,实际上响应用户在页面上的操作,例如填写表单。
假设用户正在填写表单,我们希望我们的代码以某种方式响应他们输入的内容。我将创建一个表单,用户可以在其中输入姓名并提交。
<form>
<input id="name" placeholder="名称" type="text">
<input type="submit" value="提交">
</form>
在我的 JavaScript 中,当 DOM 加载完成后,我会为表单添加一个提交事件监听器。
document.addEventListener('DOMContentLoaded', function() {
document.querySelector('form').onsubmit = function() {
const name = document.querySelector('#name').value;
alert(`Hello, ${name}!`);
return false; // 阻止表单默认提交行为
};
});

这里我们能够结合事件监听器、函数和查询选择器,既可以读取页面的信息以获取特定 HTML 元素,又可以访问用户输入的内容(.value 属性),从而对用户提交表单做出动态响应。
动态修改元素样式 🎨
不仅仅是改变元素内的 HTML,我们也可以改变 CSS,改变特定元素的样式属性。让我们看一个例子,创建三个按钮来改变标题的颜色。

<h1 id="hello">你好</h1>
<button id="red">红色</button>
<button id="blue">蓝色</button>
<button id="green">绿色</button>
document.addEventListener('DOMContentLoaded', function() {
document.querySelector('#red').onclick = function() {
document.querySelector('#hello').style.color = 'red';
};
document.querySelector('#blue').onclick = function() {
document.querySelector('#hello').style.color = 'blue';
};
document.querySelector('#green').onclick = function() {
document.querySelector('#hello').style.color = 'green';
};
});
这显示我们可以修改样式,而不仅仅是修改页面内容。但是,这种设计可能并不最佳。每当你发现自己反复编写相同的代码时,通常表明有更好的方法。


使用数据属性与循环优化代码 🔄

我们可以为按钮添加数据属性来存储颜色信息,然后使用循环来为所有按钮添加事件监听器。
<button data-color="red">红色</button>
<button data-color="blue">蓝色</button>
<button data-color="green">绿色</button>

document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('button').forEach(function(button) {
button.onclick = function() {
const color = this.dataset.color;
document.querySelector('#hello').style.color = color;
};
});
});
querySelectorAll 返回一个包含所有匹配元素的节点列表(类似于数组)。forEach 方法允许我们为数组中的每个元素运行一个函数。this 关键字在事件处理函数中指向接收到事件的元素(即被点击的按钮)。dataset 属性允许我们访问元素的数据属性。
使用下拉菜单与 onchange 事件 📋


我们可以使用下拉菜单代替按钮,并监听 onchange 事件。
<select>
<option value="black">黑色</option>
<option value="red">红色</option>
<option value="blue">蓝色</option>
<option value="green">绿色</option>
</select>

document.querySelector('select').onchange = function() {
document.querySelector('#hello').style.color = this.value;
};

this.value 获取用户在下拉菜单中选择的值。这样,代码比之前更简洁。
构建待办事项列表应用 ✅

让我们看看如何使用这些事件来构建一个待办事项列表应用程序。我们将创建一个表单来添加新任务,并动态更新任务列表。

以下是核心的 HTML 结构:

<h1>任务</h1>
<ul id="tasks"></ul>
<form>
<input id="task" placeholder="新任务" type="text">
<input id="submit" type="submit" value="添加">
</form>

以下是实现添加任务功能的 JavaScript 代码:

document.addEventListener('DOMContentLoaded', function() {
// 默认禁用提交按钮
document.querySelector('#submit').disabled = true;
// 监听任务输入框,启用/禁用提交按钮
document.querySelector('#task').onkeyup = function() {
if (document.querySelector('#task').value.length > 0) {
document.querySelector('#submit').disabled = false;
} else {
document.querySelector('#submit').disabled = true;
}
};
// 监听表单提交事件
document.querySelector('form').onsubmit = function() {
// 获取用户输入的任务
const task = document.querySelector('#task').value;
// 创建一个新的列表项元素
const li = document.createElement('li');
li.innerHTML = task;
// 将新任务添加到列表中
document.querySelector('#tasks').append(li);
// 清空输入框
document.querySelector('#task').value = '';
// 重新禁用提交按钮
document.querySelector('#submit').disabled = true;
// 阻止表单默认提交行为
return false;
};
});

这段代码实现了以下功能:
- 页面加载时禁用提交按钮。
- 当用户在任务输入框中键入时,检查输入内容长度,从而启用或禁用提交按钮。
- 提交表单时,获取输入值,创建一个新的
<li>元素,将其添加到任务列表中,然后清空输入框并重新禁用提交按钮。

总结 📚


本节课中我们一起学习了 JavaScript DOM 操作的核心概念。我们从更新元素内容开始,逐步学会了如何响应用户事件(如点击、提交、按键),如何动态修改元素的样式和属性,以及如何创建和添加新的 HTML 元素到页面中。

我们还探讨了优化代码的方法,例如使用数据属性、循环以及将 JavaScript 代码与 HTML 结构分离。最后,我们综合运用这些知识构建了一个交互式的待办事项列表应用。

通过掌握这些技术,你已经能够让静态网页变得动态和交互,这是开发现代 Web 应用的基础。
哈佛 CS50-WEB 17:L5- JavaScript编程全解 3 (逻辑存储,API) 📚


在本节课中,我们将要学习JavaScript中两个强大的功能:定时执行与本地存储,以及如何通过API与外部服务进行异步通信。我们将通过构建一个自动计数器和货币兑换应用来实践这些概念。


1. 定时执行:setInterval ⏱️



上一节我们介绍了如何通过事件监听器响应用户的点击操作。本节中我们来看看如何让函数自动、周期性地执行,而无需用户手动触发。

JavaScript提供了setInterval函数,它允许我们设置一个定时器,每隔指定的毫秒数就自动运行一次特定的函数。
让我们回到之前的计数器示例。原本我们需要手动点击按钮来增加计数。现在,我们可以使用setInterval让计数器每秒自动加一。
以下是实现自动计时的核心代码:
// 设置一个间隔,每1000毫秒(1秒)执行一次count函数
setInterval(count, 1000);

在这段代码中,setInterval是JavaScript的内置函数。第一个参数count是要周期性运行的函数名,第二个参数1000是间隔的毫秒数。这样,页面加载后,计数器就会每秒自动更新,无需用户干预。

2. 数据持久化:localStorage 💾

然而,上述自动计数器有一个问题:每次刷新页面,计数器都会重置为零。这是因为JavaScript变量在页面重新加载时状态会丢失。
为了解决这个问题,现代浏览器提供了本地存储(localStorage) 功能。它允许网页在用户的浏览器中存储键值对数据,并且在后续访问时能够读取这些数据。
localStorage主要提供两个方法:
localStorage.getItem(‘key’): 根据键名获取存储的值。localStorage.setItem(‘key’, value): 设置一个键值对。
以下是使用localStorage改造计数器应用的步骤:
- 初始化检查:页面加载时,检查
localStorage中是否已有counter值。如果没有,则将其初始化为0。 - 更新显示:将页面标题的初始内容设置为
localStorage中存储的计数器值。 - 保存状态:每次计数函数执行时,不仅更新页面显示,还要将新的计数值保存回
localStorage。
核心逻辑代码如下:

// 1. 初始化检查
if (!localStorage.getItem(‘counter’)) {
localStorage.setItem(‘counter’, 0);
}
// 2. 页面加载时更新显示
document.addEventListener(‘DOMContentLoaded’, function() {
document.querySelector(‘h1’).innerHTML = localStorage.getItem(‘counter’);
// ... 设置事件监听器等
});
// 3. 计数函数中保存状态
function count() {
let counter = localStorage.getItem(‘counter’);
counter++;
document.querySelector(‘h1’).innerHTML = counter;
localStorage.setItem(‘counter’, counter); // 保存新值
}

经过改造后,计数器的值会在浏览器刷新后依然保留。你可以在浏览器的开发者工具(Application → Local Storage)中查看和操作这些存储的数据。
3. 与外部世界通信:API 与 fetch 🌐
到目前为止,我们的JavaScript都在浏览器内部运行。但网络应用的强大之处在于能够与其他服务交互。这通常通过API(应用程序编程接口) 实现。
API定义了服务之间通信的规则。许多服务(如天气、地图、汇率)都提供API,允许我们以结构化的格式(通常是JSON)请求和接收数据。
JSON(JavaScript Object Notation) 是一种轻量级的数据交换格式,易于人阅读和编写,也易于机器解析和生成。它看起来很像JavaScript对象。
例如,一个表示航班信息的JSON数据可能如下所示:
{
“origin”: {“city”: “New York”, “code”: “JFK”},
“destination”: “London”,
“duration”: 415
}

实践:构建货币兑换器 💰
我们将使用一个免费的汇率API来构建一个货币兑换器。这个API会返回以某种货币为基础的最新汇率JSON数据。

实现与API通信的关键是使用fetch函数,它用于发起异步网络请求。

以下是构建货币兑换器的核心步骤:
- 发起请求:使用
fetch()向汇率API的URL发送请求。 - 处理响应:使用
.then()处理返回的“承诺(Promise)”,并将响应解析为JSON格式。 - 使用数据:从JSON数据中提取所需的汇率信息,并更新到网页上。
以下是获取汇率并显示欧元汇率的简化代码:
fetch(‘https://api.exchangerate-api.com/v4/latest/USD’)
.then(response => response.json()) // 将响应转换为JSON
.then(data => {
// data 现在是包含汇率的JavaScript对象
let rate = data.rates.EUR; // 获取美元对欧元的汇率
document.querySelector(‘body’).innerHTML = `1 USD = ${rate.toFixed(3)} EUR`;
});
为了使应用交互性更强,我们可以创建一个表单,让用户输入任意货币代码来查询汇率。
以下是增强版货币兑换器的关键逻辑:

// 监听表单提交事件
document.querySelector(‘form’).onsubmit = function() {
// 获取用户输入的货币代码,并转换为大写(因为API键名是大写的)
let currency = document.querySelector(‘#currency’).value.toUpperCase();
// 发起API请求
fetch(‘https://api.exchangerate-api.com/v4/latest/USD’)
.then(response => response.json())
.then(data => {
let rate = data.rates[currency]; // 使用变量动态访问属性
if (rate !== undefined) {
// 显示结果
document.querySelector(‘#result’).innerHTML =
`1 USD = ${rate.toFixed(3)} ${currency}`;
} else {
// 货币代码无效
document.querySelector(‘#result’).innerHTML = ‘无效货币’;
}
})
.catch(error => {
// 处理请求过程中可能发生的错误
console.log(‘Error:’, error);
});
return false; // 阻止表单默认提交行为
};

在这个应用中,用户输入货币代码(如EUR, GBP, JPY)后,页面会异步地向汇率API请求最新数据,并将结果动态地展示在页面上,而无需刷新整个页面。
总结 🎯
本节课中我们一起学习了:
setInterval:用于设置定时任务,让函数周期性自动执行。localStorage:用于在客户端浏览器中持久化存储数据,提升用户体验。- API与异步通信:通过
fetch函数发起异步HTTP请求,与外部服务API交互,获取并处理JSON格式的数据,实现动态内容更新。
这些技术结合起来,使得我们能够创建出状态持久、能与外部服务交互、并且高度动态交互的现代Web应用程序。在接下来的课程中,我们将继续探索JavaScript的更多功能,构建更复杂的用户界面。
哈佛 CS50-WEB 18:L6- Web用户接口与交互 1 (用户接口,单页面应用) 🖥️➡️🔄




概述
在本节课中,我们将要学习如何利用JavaScript创建更具互动性的Web用户界面。我们将重点关注单页面应用的设计理念,学习如何在不重新加载整个页面的情况下,动态地更新和切换页面内容,从而提升用户体验。
上一节我们关注了在用户的Web浏览器中运行的JavaScript,这让我们能够做很多事情,使我们的网页更具互动性。JavaScript让我们能够显示警报,操纵DOM,调整网页结构。
添加内容或查看已有的内容,还能让我们响应用户事件。当用户点击按钮、提交表单或在输入字段中输入内容时,我们可以运行JavaScript函数,响应这些事件,以使我们的网页更具互动性。今天我们将继续这个话题。
特别是查看用户界面设计,关注一些常见的用户界面范式,以及我们如何利用JavaScript实现这些目标,创建与用户互动时有价值的互动用户界面。
单页面应用 (SPA) 简介
更常见的范式之一,特别是在现代Web编程中,是单页面应用的理念。如果我们想创建一个有多个不同页面的Web应用,通常是通过在我们的Django Web应用中使用多个不同的路由来实现,例如你去某个地址可以获取一个页面,而去另一个地址则可以获取另一个页面。


为了获得另一个页面,常用JavaScript的方式,我们可以创建单页面应用,整个网页实际上就是一个单页面,然后我们使用JavaScript来操纵DOM,用我们想替换的内容替换页面的部分,这有很多优势。

其中一个是我们只需对实际变化的页面部分进行修改,例如如果你有五个不同的页面,但页面的一般布局和结构相似,当你在页面之间切换时,而不是加载一个全新的页面。
你可以只加载页面的一部分,这对于频繁变化的应用非常有帮助。


实现一个简单的单页面应用
那么现在我们来看看如何实现一个非常简单的单页面应用,假设我们想要一个只显示三个不同页面的单页面应用,但这些内容都是包含在一个页面中。
在同一页面上,我将创建一个新的文件,称为singlepage.html。在这个文件中,我们将包含我们的常规内容,并在这个页面的主体中,我将包括三个不同的部分,以代表我可能希望向用户展示的三个不同页面。
因此我将有一个id为page one的div,里面可能只是包含一些内容。有一个标题,写着这是页面一,你可以想象这些页面上还有更多内容。同样有一个 id 为页面二的 div,我们称之为页面二,然后是一个 id 为页面三的最终 div。

它有一个标题,写着这是页面三,例如,现在如果我打开。


single page.html,我们看到的是同时显示所有三个页面,这可能不是我们想要的。我们真正想要的是默认情况下。


将这些页面隐藏,直到我们想要查看它们,例如一次查看一个页面。所以我可以做的一件事是使用 CSS 来切换,某个内容是否可见。添加一些样式标签到我的页面,默认情况下,我的所有 div 都是不可见的。
div {
display: none;
}


现在在屏幕上显示,如果我刷新页面,我实际上看不到三个标题中的任何一个。


我之前有的,但是我真的希望现在有一些按钮,让我在这三页之间切换。所以我会给自己三个按钮,一个按钮写着页面一,一个按钮写着页面二,一个按钮写着页面三。例如,我需要某种机制,让这些按钮知道当你点击这个。
按钮应该显示哪个页面,因此我将继续使用数据属性,我们上次在 JavaScript 中看到过,给这些特定的 HTML 元素添加一些额外的信息,我会给第一个按钮一个 data-page 值为页面一,第二个按钮的值为页面二。
第三个是页面三的 data-page 值,这里同样只是提供信息。这样在我稍后写一些 JavaScript 时,可以让 JavaScript 代码查看这个 data-page 属性,来判断当你点击这个按钮时,应该让我看到 id 为页面一的 div,这就是我们要实现的。
所以现在让我们继续,写代码,做的就是能够说我想显示页面一,隐藏其他两个,或者显示页面二,隐藏其他两个,或者例如显示页面三。

编写切换页面的JavaScript函数
为此,我首先会写一个函数,让我实现这一点,我会写一个名为 showPage 的函数,它的参数就是要显示的页面。

我想显示的页面,所以这个函数应该做什么,我们将要做的是使用 document.querySelector,我想获取具有特定 id 的元素。这个页面的 id 将代表我想显示的 div 的 id,所以我会说获取这个 id 的元素。
然后使用模板字面量,我会说好的,获取页面上任意元素的id,并且我想要更改它的样式属性,具体是更改它的显示属性,而不是默认的none。我希望更改为block,这样它就会显示出来。
块在页面上实际上。
function showPage(page) {
document.querySelector(`#${page}`).style.display = 'block';
}

函数,我可以在浏览器中刷新页面进行测试。现在我看到三个按钮,这些按钮暂时没有任何作用。但是我可以在控制台中运行这个函数,像是运行显示页面函数,举个例子,显示页面一。
按下回车,现在页面一,然后页面二,那么页面二将变得可见。好的,这完成了我想要的一半,页面二现在可见,但页面一也可见。因此我可能想要这样,如果我显示一个页面,首先隐藏其他页面,像是隐藏。

所有页面,然后显示页面二,或者隐藏所有页面,然后显示页面三。那么我该如何进行呢?首先,当我显示一个页面时,我想先隐藏所有其他页面,隐藏所有页面。为了获取所有页面,我会用document.querySelectorAll获取所有的div,这是我用来封装页面的。
现在对于每一个,再次有效地创建一个循环,我在遍历每个div,对于每个div,我们就这样设置。
function showPage(page) {
// 首先隐藏所有页面
document.querySelectorAll('div').forEach(function(div) {
div.style.display = 'none';
});
// 然后显示请求的页面
document.querySelector(`#${page}`).style.display = 'block';
}

div的样式属性设置为none。所以这个显示页面的函数现在首先查询所有的div,它们模拟着我的单页面应用中的页面,对于每一个div,我们将其作为输入传递给这个函数。

每个使用这个箭头函数的语法,这只是一种简写方式,用于表达一个函数。在这里,我说对于每个div,我们将修改它的样式属性,将显示设置为none,意味着不显示任何div,然后只显示被请求的div,现在这应该解决这个问题。
同时出现多个页面,如果我回到这个页面并点击或输入显示页面一,那么页面一会出现,但如果我运行显示页面二,那么页面二会出现,但页面一。


消失,同样,当我显示页面三时,它显示页面三而不显示其他两个,所以我可以通过控制台操控哪个页面是可见的。但现在我希望这些按钮能够真正起作用,当我点击其中一个按钮时,它能实际显示请求的页面。因此,为了做到这一点,我想要。
我需要为这些按钮附加一些事件监听器,这意味着我需要等待这些按钮加载到页面上,所以我们将使用document.addEventListener。等待直到页面上所有内容加载完成,然后我才会说让我们查询选择器所有。
对于所有按钮及每一个按钮,让我们为每个按钮附加一个事件监听器,因此我正在查询所有按钮,并说对每个按钮我想这样做,我想要做的是在按钮被点击时执行这个函数。
要显示页面,我想显示哪个页面呢?我想显示按钮数据集中页面部分的内容,而要获取当前按钮,即被点击的按钮,请记住,当我们在事件处理程序内时,我们可以利用javascript关键字this。
该关键字指的是接收到点击事件的元素,因此我可以说this.dataset。
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('button').forEach(function(button) {
button.onclick = function() {
showPage(this.dataset.page);
}
});
});

点页面意味着,对于每个按钮,当按钮被点击时,我们会为每个按钮运行这个函数。当按钮被点击时,我们想要显示哪个页面,我们将取这个按钮,即接收到事件的按钮,访问它的数据属性。
页面属性,这里有页面一、页面二或页面三。然后我们就可以调用我们刚才写的showPage函数。所以现在我们已经完成了,为按钮附加了这些事件处理程序,所以现在如果我刷新页面。



页面上我可以点击这些按钮,切换到任意三个页面之间。现在有趣的是,我们现在可以在一个单一页面中模拟多个页面的概念,所有内容都封装在一个html文件中,但不需要持续向服务器发出额外请求来获取访问权限。
动态加载内容与服务器交互

然而,有时当你需要一个页面的新信息时,联系服务器可能是合理的。例如,你可能想象每个页面包含大量文本,如果我们立即将所有数据加载到html中并显示和隐藏,那将是低效的。
需要这样做,因为我们可能加载的信息超过了用户实际上关心的内容,尤其是如果他们从不查看第2页或第3页。所以我们可能想象的一个做法是动态加载这些数据,上次我们讨论的javascript中,我们看到如何使用fetch来请求一些额外的信息。
上一次是来自一个网络服务器,货币兑换率,但我们利用返回的数据填充了我们页面上的内容,同样,我们可以在这里做类似的事情,如果我们有一个单页面的总体结构,并且希望加载新内容,而不是完全加载新的HTML内容并重新加载整个页面。
我们可以询问自己的网络服务器,页面的哪个部分需要更改,然后替换页面的那部分,这就是我们现在要看的内容,现在结合Django作为我们的网络服务器,以及JavaScript用于编写客户端代码,以生成单页面应用。
我们将进入一个我提前准备的示例,叫做单页面一,里面只是一个Django应用程序,包含一个叫做单页面的应用。我们会注意到,首先要查看URLs,里面有两个URLs,一个是默认URL,仅加载索引函数,另一个是加载不同内容的URL。
就像是我可能想动态加载的页面部分,例如,我有 /section/ 加上某个特定数字,如果我们看看这些URL的视图,实际上索引函数只是返回 index.html,然后部分函数做的是,它首先确保数字在1到3之间,如果是的话。
响应的是这些字符串之一。
那么这实际上是如何工作的呢?如果我进入。
如果我访问这个URL /section/one,我得到的是这一段文本,如果我去 /section/two,我得到的是另一段文本,而 /section/three 则是完全不同的文本,所以只是不同的文本。
我想将这段文本纳入一个现有的index.html模板中,该模板在我访问默认路由时加载,而在index.html中,我们会看到我有一个显示部分的函数,其行为与我们刚才看到的显示页面函数非常相似,但不同的是,显示部分将要做的事情。
它将从我的网络服务器获取我应该在页面上显示的文本,我从 /sections/ 获取数据,填入一个数字,比如一、二或三,当我收到响应时,过去我们已经看到如何将该响应转换为JSON数据,如果它是一些结构化数据,我们也可以直接转换响应。
转换成纯文本后,我将获取该文本并在控制台中记录它,以便我们在日志输出中看到,但接着会查询选择页面内容,某个ID为content的元素,更新其innerHTML并将其设置为该文本,所以现在这个整个函数正在做的是,它将联系我的服务器,弄清楚哪些文本内容应该在。
新部分将填充页面相应的部分,并根据来自该 HTTP 请求的文本进行填充,然后在页面的下方,我们会看到我有一个“你好”标题和三个按钮,它们在不同的部分之间切换,每个按钮都有一个 data-section 属性。
用于确定应该加载哪个部分,然后是一个最初为空的 div。
function showSection(section) {
fetch(`/sections/${section}`)
.then(response => response.text())
.then(text => {
console.log(text);
document.querySelector('#content').innerHTML = text;
});
}



现在把这一切放在一起,如果我访问默认路由,我会看到“你好”,加上三个按钮,给我提供在三个不同部分之间选择的机会,如果我点击第一部分,发生的事情是 JavaScript 将请求 section/1 的文本,它会返回文本。


它将填充到页面的第一部分、第二部分和第三部分,所以与之前非常相似,但不同于之前我们所有文本一次性加载到 HTML 页面中,现在我们使用异步 JavaScript 仅在需要时动态加载信息,当我们点击某个部分时,它将发出请求。
请求需要填充的内容,它将进行填充,并生成标题。你可能想象,在一个更复杂的网站中,网页边缘的内容要多得多,所有这些内容保持不变,我们不需要重新加载任何这些信息,我们仅重新加载实际改变的页面部分。
当我们在这些不同的部分标题之间切换时,这在某些方面似乎是一种优势,也许我们可以更高效地运行这样的单页应用。
使用历史API管理URL状态
但是,我们似乎失去了在 URL 中保持状态的概念,通常 URL 会给你指示当前页面的信息。
如果你在第一部分,你的 URL 是 /1,如果你在第二部分,URL 是 /2,第三部分是 /3,但当然在所有这些示例中,我们都停留在同一页面,每当我点击一个按钮,无论是第一、第二还是第三部分,URL 永远不会改变,URL 始终保持不变,结果是 JavaScript 中有一种方法。
以更新 URL 的方式操控该 URL,利用所谓的 JavaScript 历史 API,我可以将某些内容推送到历史中,这意味着更新 URL 并实际将其保存到用户的浏览器历史中,这样用户稍后可以潜在地返回到那个位置。为此,我将在类似的单页应用中展示另一个例子。
除了在 index.html 内部,我还添加了一些额外的东西。一个是当我点击一个按钮时,也就是说当我点击第一部分、第二部分或第三部分时,我在这里添加了这一行 history.pushState,history.pushState 的作用是,基本上会向我的浏览历史中添加一个新元素。
我首先指定任何与状态相关的数据,特别是。我存储了一个表示这里所表示的部分编号的javascript对象,下一个是一个标题参数,大多数网页浏览器实际上会忽略,因此通常可以是空字符串。但这里的第三个参数是应该放在url中的内容,我希望放在这个url中。
情况是类似于部分,后跟部分编号,例如我可以输入二。或斜杠部分三,例如,当我点击不同的页面时,这些内容将出现在url栏中,然后我希望能够支持的是,当我回顾我的历史时,如果我在网页浏览器中点击后退按钮。
如果我是之前访问的页面,我想从第3部分返回到第2部分。而且确实有一个事件处理程序,window.onpopstate,这意味着当我从历史记录中弹出某个内容时,比如返回我的历史记录。我们可以将一些事件作为参数,如果你查看event.state。
我在控制台上运行的部分。日志记录,以便我们可以在稍后查看它。我们将看到存储的状态。
// 点击按钮时更新URL和历史
button.onclick = function() {
const section = this.dataset.section;
history.pushState({section: section}, '', `/section/${section}`);
showSection(section);
}
// 监听浏览器前进/后退按钮
window.onpopstate = function(event) {
console.log(event.state);
if (event.state && event.state.section) {
showSection(event.state.section);
}
};

与用户历史的那部分相关联,我可以继续显示该部分。因此,总的来说,当我运行这个网页应用时。

我看到三个部分的按钮,当我点击其中一个按钮时。我不仅看到文本,而且在url栏中也看到我现在在斜杠部分一,这已经被推入我的历史中,我也更新了url以反映这一点。我点击第二部分,更新了url,第三部分也更新了url,当我将内容推入我的部分时。
这样我就可以在需要时返回,事实上,如果我现在打开javascript控制台。如果我返回,例如返回到。



第二部分你会看到的是记录的内容是数字。二当我打印出当前与这个url相关的部分时。它保存了我应该加载第二部分的状态。因此,它确实加载了第二部分,这里当然没有任何问题。

原始范式是仅仅动态加载不同的页面。使用django进行请求并获取响应,但通常当你开始想象许多内容在同一页面上同时变化的应用程序时。你可能会想象社交网络网站,其中许多内容保持不变,你可能会。

查看同一页面的不同部分,能够动态加载信息,请求额外的信息,然后在页面上显示,这实际上可以是一种非常强大的方式,使你的网页更加互动。因此,这就是我们可能构建单页应用程序的方式,利用javascript来。
异步加载新数据,然后利用这个历史 API,让我们可以向 URL 添加内容,增加用户的浏览历史,以便我们可以稍后通过监听窗口的 onpopstate 事件回到它们。

窗口对象与滚动检测
而且,事实证明,在 JavaScript 中我们访问到的窗口对象。

功能非常强大,它表示计算机屏幕上显示所有网页内容的物理窗口,还有一些窗口的属性可以查看,这使我们能够启用一些有趣的功能。例如,窗口的高度确实由用户的。
实际上看到的是他们在 Google Chrome、Safari 或任何他们使用的网页浏览器中的窗口
哈佛 CS50-WEB 19:L6- Web用户接口与交互 2 (动画与交互) 🎬
概述
在本节课中,我们将要学习如何使用CSS和JavaScript为网页元素添加动画效果,并实现用户交互。我们将从基础的CSS动画开始,逐步深入到如何用JavaScript控制动画的播放状态,最后通过一个“隐藏帖子”的实例,展示如何结合两者创建流畅、用户友好的交互体验。
CSS动画基础

为了让网页元素以某种方式移动或改变其属性,CSS提供了对动画的原生支持。CSS不仅允许我们设置元素的静态样式(如颜色、大小),还赋予我们动态改变这些属性的能力,例如在特定时间段内改变元素的大小或位置。

现在,我们来看一个实际的例子。我将创建一个名为 animate.html 的新文件。

在 animate.html 中,我将添加一些动画效果。首先,为这个页面应用CSS样式。我将从一个标题开始,例如“欢迎”,它将显示一条欢迎消息。
现在,如果我打开 animate.html,看到的只是一个写着“欢迎”的静态消息。
接下来,让我们为它添加一些CSS样式。在 <style> 标签中,针对标题 h1,我想为其应用一个特定的动画。
首先,我需要指定动画的名称。我可以为动画选择一个名字,比如“增长”。我会将动画的持续时间设置为两秒。动画填充模式决定了动画的播放方向,通常是向前进行,以便根据我们指定的规则取得进展。
在这里,我声明将使用一个名为“增长”的动画来作用于所有标题。现在,我需要在样式上方定义这个动画的关键帧。
使用 @keyframes 规则,我可以为这个特定元素指定关键帧,即定义动画开始和结束时的样式属性。CSS会自动计算并处理中间每一帧的变化。
例如,我可以说在动画开始时(from),字体大小为20像素;在动画结束时(to),字体大小为100像素。

总体来看,这表明我想将名为“增长”的动画应用于所有标题,该动画应持续两秒并向前进行。
而“增长”动画具体会做什么呢?它意味着任何遵循此动画的元素,在开始时字体大小为20像素,在结束时将增长到100像素。
现在我已经定义了这个动画。如果我刷新 animate.html 页面,你会看到“欢迎”二字在两秒钟内从小变大,遵循了我设定的关键帧指令。

我们可以在页面上查看效果。实际上,你能操控的远不止大小,几乎任何CSS属性都可以被动画化。
操控位置与多关键帧动画
如果我将标题的定位设置为相对定位(position: relative),这意味着它的位置将相对于其父元素或其他元素。然后,我可以让它的位置从屏幕左侧的0%移动到距离左侧50%的位置。

此时,“增长”可能不是这个动画的最佳名称,我会将其改名为“移动”。
现在,这个动画的效果是:当你运行动画时,元素将从紧靠屏幕左侧开始,逐渐调整到距离屏幕左侧大约50%的位置。我们看到的就是这个移动过程。

刷新页面,它就会执行完全相同的操作。
我们不仅需要指定动画的起点和终点,还可以在动画的不同时间点设置多个关键帧,以捕捉更复杂的变化。
例如,如果我想让标题不仅向右移动,还要再移动回来,我可以设置三个关键帧:
- 在动画开始时(0%),距离左侧0%。
- 在动画进行到一半时(50%),距离左侧50%。
- 在动画结束时(100%),再次回到距离左侧0%。
现在,当我刷新页面时,标题会先向右移动,然后再移动回来。

控制动画的播放

还有其他属性可以控制动画,例如 animation-iteration-count(动画迭代次数)。我可以将其设置为2,这意味着动画不是只运行一次然后停止,而是运行两次。
因此,当我刷新时,它会向右移动,然后向左移动,然后再重复一次这个循环。

如果你愿意,甚至可以将其设置为 infinite(无限),这意味着动画永远不会停止,将始终依据指定的关键帧在右侧和左侧之间来回移动。

因此,如果你看到页面上有元素以某种方式互动地移动,有很多方法可以实现,但仅使用CSS也能很好地创建这类动画。
目前这个动画只是无限循环。我们还可以使用JavaScript来控制它。
使用JavaScript控制动画
让我们看看如何实现。我要回到页面的主体部分,除了一个写着“欢迎”的标题,我还会添加一个按钮,上面写着“点击这里”。
接下来,就是添加一些JavaScript,让按钮可以控制动画的开始和停止。
在这个 <script> 标签内部,首先使用 document.addEventListener(‘DOMContentLoaded‘, ...) 来等待DOM加载完成。
然后,我获取那个 h1 元素。最初,我会将其样式中的 animationPlayState 属性设置为 paused。animationPlayState 是样式的一个属性,用于决定动画是正在播放还是暂停,我可以使用JavaScript来控制它。
我希望的情况是:每当有人点击按钮时,就改变动画的播放状态。
所以,我使用 document.querySelector(‘button‘) 来获取那个按钮,并为它添加一个点击事件监听器。当按钮被点击时,运行一个函数。
这个函数会检查标题当前的动画播放状态。如果当前是 paused,就将其设置为 running;否则(如果已经在运行),就将其设置回 paused。

总体来看,这个函数的作用是:获取标题元素,初始状态为暂停。每当按钮被点击时,就切换动画的播放状态。
现在,如果我刷新这个页面,会看到“欢迎”信息和一个写着“点击这里”的按钮。最初动画是暂停的,没有发生任何移动。
但当我点击按钮时,动画开始无限进行。直到我决定停止它,再次点击按钮,动画就会暂停。这样,我就能够控制动画何时开始和何时停止。
这在你想创建更具互动性的页面动画时特别有帮助。这意味着你可以逐渐地改变CSS属性,而不是立即改变,从而创造出更平滑的过渡效果。
实践:为“隐藏帖子”功能添加动画
那么,让我们看一个如何将这个想法付诸实践的例子。回到我们之前的“帖子”示例,我们有一个无限滚动的帖子列表。现在,我们希望在用户操作后能够优雅地隐藏帖子。
我准备了一个名为“隐藏”的示例,它与之前的非常相似,但这次我为每个帖子添加了一个额外的“隐藏”按钮。
目前,点击“隐藏”按钮什么也不会发生,我们稍后将实现其功能。
首先,看看这是如何工作的。在 index.html 模板中,唯一的变化发生在添加新帖子时。
它从服务器加载帖子,然后在获取到那些帖子时,遍历每个帖子(目前只是一个文本字符串)。然后,它通过 addPost 函数将这个字符串文本添加到页面的一个元素中。
addPost 函数会创建一个 div 来存储那个帖子,并给它一个类名(我们将通过这个类名来动画化它)。然后将其内部HTML设置为帖子的内容(如“帖子编号一”),并添加一个只写着“隐藏”的按钮。最后,将这个 div 添加到DOM中。
这就是 addPost 现在要做的事情:我们通过这段JavaScript代码生成一些HTML,然后将这些HTML添加到页面上。我们添加的是一个包含文本和一个“隐藏”按钮的 div。
实现帖子隐藏功能
我们最终希望能够隐藏那篇帖子。那么,如何让帖子的隐藏功能正常工作呢?
我们想要做的是,检测用户何时点击这些“隐藏”按钮。有多种方法可以做到这一点,其中一种方法是监听整个文档上的点击事件。
每当有人点击文档时,我们可以通过事件对象来查询他们到底点击了什么。对于大多数事件监听器,传入的函数可以接收一个事件对象作为参数。
这个事件对象有一个属性叫 event.target,它代表了事件的实际目标,即被点击的元素。我将 event.target 保存在一个名为 element 的变量中。
现在的想法是:无论被点击的是什么,我们都将其保存在 element 中。然后,我想知道 element 是否是一个“隐藏”按钮。
我也可以为每个“隐藏”按钮单独附加事件监听器,但这里演示另一种方式。
假设当我们在任何地方点击时,如果被点击的元素具有类名 hide(因为我给每个“隐藏”按钮都赋予了这个类名),那么我们就可以认为它实际上是一个“隐藏”按钮。
所以,我可以说:如果 element.className 等于 hide,那么被点击的对象就是一个“隐藏”按钮。
接下来,我想做的是调用 element.remove() 来移除那个元素。

那么,如果我刷新页面并点击“帖子一”的“隐藏”按钮,会有什么效果呢?它移除了“隐藏”按钮本身,但并没有移除整个帖子。

这里发生的情况是:如果元素的类名是 hide,意味着我点击了一个“隐藏”按钮。element.remove() 只会移除那个按钮元素,并不会移除包含它的整个帖子 div。
从DOM结构来看,帖子是一个 div,而“隐藏”按钮是它的子元素。因此,移除按钮并不会同时移除帖子。
如果你也想去掉帖子,你需要移除的不是这个元素本身,而是它的父元素。在JavaScript中,可以通过 element.parentElement.remove() 来实现。
也就是说,获取这个元素的父级并移除它。现在,如果我点击“隐藏帖子一”,帖子一就消失了,直接看到帖子二。

这虽然有效,但效果并不明显,因为所有帖子看起来都一样,用户可能注意不到隐藏成功了。

为隐藏操作添加淡出动画
因此,这正是动画可以发挥价值的时候。我可以为每个帖子关联一个动画。
我将给帖子 div 一个动画名称,叫做“隐藏”,动画持续时间为两秒,动画填充模式为向前(forwards)。最初,我将给帖子设置动画播放状态为暂停,因为我不想立即隐藏所有帖子。
稍后,我们会通过JavaScript来运行动画,以便实际隐藏帖子。
接下来,我需要定义“隐藏”动画具体意味着什么。在 @keyframes hide 中,我会说:在0%时(动画开始),元素的不透明度为1;在100%时(动画结束),元素的不透明度为0。
不透明度是一个CSS属性,它控制HTML元素的透明度。值为1表示完全不透明(完全可见),值为0表示完全透明(不可见)。
因此,这个动画的效果是:最初元素完全可见,在2秒内逐渐变为完全透明。
现在,我需要在实际的事件监听器中触发这个动画。与其立即移除元素,不如先获取帖子的父元素(即帖子本身),并将其动画播放状态设置为 running。
这意味着当我点击“隐藏”按钮时,就开始运行动画。动画将会在几秒钟内将不透明度从1变为0。

如果我真的想要在动画结束后移除元素,可以添加另一个事件监听器。元素有一个叫做 animationend 的事件,当动画结束时就会触发。
然后我可以说:好的,当动画结束后,我们再继续移除这个元素。
因此,总体流程是:当我点击“隐藏”按钮时,不是立即移除元素,而是获取帖子元素本身,将其动画播放状态设置为运行(即触发“隐藏”动画),然后为整个帖子添加一个 animationend 事件监听器。当动画结束时,再从DOM中完全移除整个帖子。
那么现在这一切的效果是什么?进行这个动画后,如果我刷新页面,就会看到所有这些帖子。如果我试图隐藏第二个帖子,你会看到它的透明度逐渐变化,慢慢消失,直到完全透明时,帖子才会被完全移除。
这样,当用户点击“隐藏”按钮时,会触发一个优雅的淡出动画。这就是动画的价值之一:它能让我们的用户界面更友好,而不是生硬地立即删除内容。
优化动画:平滑的高度变化
即使这样,动画可能还不够完美。你可能会注意到,当帖子消失时,下面的帖子会突然跳上来填补位置。

我希望效果能更智能一些,在帖子消失后,能平滑地缩小其占用的空间,这样其他帖子就不会猛地跳入位置,而是更自然地滑入。
这里我可以进一步调整。也许我想将这个动画变成多部分动画。
所以在这里,我不只是简单地将不透明度从1变为0。也许在动画的前75%中,只处理不透明度从1降到0。但在动画的最后25%中,虽然不透明度保持为0,但我希望将所有产生垂直空间的属性减少到零。
所以,高度应该变为0像素,行高(文本的高度)也应该变为0像素,任何内边距我希望消失,实际上我已经在帖子底部添加了一些外边距,我也想去掉它。
所以我希望将所有这些设置为零。最初,高度是auto(或具体值),行高是初始值,父元素有大约20像素的内边距和10像素的底部外边距。我希望在动画的75%处这些值仍然保持不变,但只有在动画的最后25%中,才希望将这些垂直高度属性都设置为零。
这样效果就是:我会有一个动画,在动画的前75%中,唯一变化的是不透明度,从1变为0,即从完全可见到完全透明。此时,帖子已经透明了,你看不见它,但它仍然占据页面上的物理空间。
然后,在动画的最后25%,我们将减少这个帖子的高度、行高、内边距和外边距,这样它就完全不占空间了。
所以,如果我刷新这个页面,这里又出现了所有帖子。但如果我点击隐藏某一特定帖子,我们会看到它首先渐渐消失(变透明),然后它的高度缩小,以便下一个帖子能够很顺畅地滑入位置。

我可以再这样做:隐藏另一个帖子,它先变透明,然后高度缩小,其他帖子平滑地滑入位置。
这再次应用了CSS动画的理念,使用动画属性使我们的界面更好用,视觉上更清晰。一条帖子优雅地消失了,其他帖子平滑地向上滚动以占据它的位置。

总结
本节课中,我们一起学习了如何使用CSS创建基础动画(包括大小、位置变化和多关键帧动画),以及如何用JavaScript控制这些动画的播放状态(开始、暂停)。最后,我们通过一个完整的“隐藏帖子”实例,实践了如何结合CSS动画与JavaScript事件监听,创造出流畅、直观的用户交互体验,从而提升Web应用的用户友好性。我们现在能够使用JavaScript创建许多漂亮的用户界面,包括单页面应用程序、无限滚动和丰富的动画效果。
哈佛 CS50-WEB 20:L6- Web用户接口与交互 3 (React) 🧩

在本节课中,我们将要学习如何使用React框架来构建动态、交互式的Web用户界面。React是一个强大的JavaScript库,它允许我们基于应用程序的状态来声明式地描述UI,并自动处理UI的更新。
概述:从命令式到声明式编程
随着网页变得越来越复杂和动态,我们需要大量的JavaScript代码来保持所有元素的同步和更新。为了更高效地创建交互式界面,开发者转向了像React这样的JavaScript库或框架。
React使我们能够设计出能根据底层状态自动更新的用户界面。它的核心思想是声明式编程,这与传统的命令式编程风格不同。

上一节我们介绍了JavaScript如何直接操作DOM来更新界面,本节中我们来看看React如何通过声明状态来简化这一过程。
命令式编程 vs 声明式编程
在命令式编程中,你需要明确地告诉计算机每一步该做什么。例如,更新一个计数器显示:
// 命令式编程示例
let h1 = document.querySelector('h1');
let num = parseInt(h1.innerHTML);
num += 1;
h1.innerHTML = num;
这段代码明确地获取元素、解析内容、计算新值并更新DOM。
在声明式编程中,你只需描述UI应该是什么样子,而由框架(如React)来处理如何更新到那个状态。在React中,你可能会这样写:




// 声明式编程示例 (React JSX)
<h1>{num}</h1>
// 当 num 的值改变时,React会自动更新h1的内容。

React允许我们将应用程序分割成多个组件,每个组件管理自己的状态。当我们操作状态时,React会自动、高效地更新用户界面。

开始使用React
要在网页中使用React,最简单的方法是引入三个必要的JavaScript库。


以下是引入React、React DOM和Babel(用于转换JSX代码)的基本HTML结构:
<!DOCTYPE html>
<html>
<head>
<script src="https://unpkg.com/react@17/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@17/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<title>React 应用</title>
</head>
<body>
<div id="app"></div>
<script type="text/babel">
// 我们的React代码将写在这里
</script>
</body>
</html>
React: 核心库,用于定义组件和逻辑。ReactDOM: 用于将React组件渲染到网页的DOM中。Babel: 一个转换器,用于将JSX语法转换为浏览器能理解的普通JavaScript。


第一个React组件:Hello World
React应用由组件构成。组件是用户界面的一部分,可以用JavaScript函数来定义。

让我们创建一个简单的“Hello World”组件:

// 定义一个名为 App 的组件
function App() {
return (
<div>你好,世界!</div>
);
}
// 将 App 组件渲染到 id 为 “app” 的 div 中
ReactDOM.render(<App />, document.querySelector(‘#app’));



在这个例子中:
- 我们定义了一个函数
App,它是一个React组件。 - 这个函数返回一些JSX(看起来像HTML的代码),描述了组件要渲染的内容。
- 使用
ReactDOM.render()方法将<App />组件插入到页面中ID为app的<div>里。

JSX允许我们在JavaScript中直接编写类似HTML的结构,并且可以在其中嵌入JavaScript表达式,使用花括号 {}。
function App() {
let x = 1;
let y = 2;
return (
<div>{x} + {y} 的值是:{x + y}</div>
);
}
// 页面上会显示:1 + 2 的值是:3

组件的复用与Props

组件的强大之处在于可复用性。我们可以创建通用组件,并通过props(属性)来定制它们。
以下是如何创建一个可复用的问候组件:

// 定义一个接受 props 的 Hello 组件
function Hello(props) {
return <h1>你好,{props.name}!</h1>;
}

function App() {
return (
<div>
<Hello name="哈利" />
<Hello name="罗恩" />
<Hello name="赫敏" />
</div>
);
}
// 页面上会渲染三个标题,分别显示“你好,哈利!”、“你好,罗恩!”和“你好,赫敏!”。
Hello组件接受一个props参数,其中包含了传递给组件的所有属性。- 在
App组件中,我们三次使用<Hello />组件,每次传递不同的name属性值。 - 这使得同一个组件能根据不同的输入渲染出不同的内容。
状态管理:useState Hook


使界面具有交互性的关键是状态。状态是组件内部需要跟踪和响应的数据。React 提供了 useState 这个 Hook 来在函数组件中添加状态。

让我们用状态来构建一个计数器应用:

function Counter() {
// 声明一个状态变量 count,初始值为 0,以及更新它的函数 setCount
const [count, setCount] = React.useState(0);
// 定义点击按钮时调用的函数
function updateCount() {
setCount(count + 1); // 使用 setCount 更新状态
}
return (
<div>
<div>当前计数:{count}</div>
<button onClick={updateCount}>点击计数</button>
</div>
);
}
ReactDOM.render(<Counter />, document.querySelector(‘#app’));
const [count, setCount] = React.useState(0);创建了状态。count是当前值,setCount是用于更新它的函数。- 在按钮上,我们设置了
onClick={updateCount}事件处理程序。 - 当点击按钮时,
updateCount函数调用setCount(count + 1)来更新状态。 - 关键点:当状态
count改变时,React 会自动重新渲染组件,UI 中的{count}会显示新的值。我们不需要手动操作DOM。

综合实践:构建一个加法测验游戏

现在,我们将结合所学知识,构建一个更复杂的应用:一个随机生成加法题目的测验游戏。
步骤1:设置基础结构和状态
首先,我们设置游戏所需的状态:两个加数、用户的答案、当前得分以及是否回答错误。
function AdditionGame() {
// 使用一个对象来管理多个状态
const [state, setState] = React.useState({
num1: Math.ceil(Math.random() * 10), // 随机数1
num2: Math.ceil(Math.random() * 10), // 随机数2
response: “”, // 用户输入的答案
score: 0, // 得分
incorrect: false // 是否回答错误
});
// ... 后续UI和逻辑将在这里添加
}


步骤2:渲染用户界面

根据状态渲染出题目、输入框和得分。

return (
<div>
{/* 问题部分,如果回答错误则添加 ‘incorrect’ 类 */}
<div id=“problem” className={state.incorrect ? “incorrect” : “”}>
{state.num1} + {state.num2}
</div>
{/* 答案输入框 */}
<input
value={state.response}
onChange={updateResponse}
onKeyPress={inputKeyPress}
autoFocus
/>
{/* 显示当前得分 */}
<div>得分:{state.score}</div>
</div>
);
步骤3:处理用户输入

我们需要两个函数:一个用于实时更新输入框的内容,另一个用于在按下回车键时检查答案。

// 当输入框内容变化时更新 response 状态
function updateResponse(event) {
setState({
...state, // 使用扩展运算符保留其他状态
response: event.target.value
});
}

// 当在输入框中按下按键时触发
function inputKeyPress(event) {
if (event.key === “Enter”) { // 检查是否是回车键
const answer = parseInt(state.response);
if (state.num1 + state.num2 === answer) {
// 回答正确:加分,生成新题目,清空输入,标记为正确
setState({
...state,
score: state.score + 1,
num1: Math.ceil(Math.random() * 10),
num2: Math.ceil(Math.random() * 10),
response: “”,
incorrect: false
});
} else {
// 回答错误:扣分,清空输入,标记为错误
setState({
...state,
score: state.score - 1,
response: “”,
incorrect: true
});
}
}
}



步骤4:添加游戏胜利条件

我们可以通过条件渲染,在得分达到10分时显示胜利画面。


// 在 return 之前添加条件判断
if (state.score === 10) {
return <div id=“winner”>🎉 你赢得了比赛!</div>;
}

// 原来的 return 语句渲染正常的游戏界面
return ( ... );


步骤5:添加CSS样式
最后,添加一些CSS让游戏看起来更美观。


<style>
body { text-align: center; font-family: Arial; }
#problem { font-size: 48px; }
.incorrect { color: red; }
#winner { font-size: 72px; color: green; }
input { font-size: 24px; margin: 10px; }
</style>

现在,一个功能完整的加法测验游戏就完成了!用户可以通过它练习加法,得分会随之变化,达到10分即可获胜。

总结

本节课中我们一起学习了React框架的核心概念与应用。


我们首先了解了声明式编程与命令式编程的区别,React通过让我们描述“UI应该是什么样子”来简化开发。接着,我们学习了如何创建组件,这是构建React应用的基石,并通过props使组件变得可复用和可配置。
然后,我们深入探讨了状态管理,使用 useState Hook 来存储和更新组件内部的数据,这是创建交互式应用的关键。最后,我们综合运用这些知识,构建了一个包含状态、事件处理、条件渲染和基础样式的加法测验游戏。
React(以及类似的框架如Vue和Angular)的核心价值在于,它让我们能够专注于应用程序的数据(状态)和业务逻辑,而将复杂的UI同步与更新任务交给框架本身高效处理。这极大地提升了开发动态、数据驱动型Web应用的效率和体验。
哈佛 CS50-WEB 21:L7- 测试与前端CI/CD 1 (测试与断言,单测) 🧪



在本节课中,我们将要学习软件开发中的一项重要最佳实践:测试。我们将从最基础的断言开始,逐步了解如何编写自动化测试来验证代码的正确性,并初步接触单元测试的概念。这对于构建和维护日益复杂的Web应用程序至关重要。
概述:为什么需要测试? 🤔
在之前的课程中,我们已经学习了使用HTML、CSS、Python(如Django框架)和JavaScript来设计和构建交互式Web应用程序。随着应用程序变得越来越复杂,确保代码按预期工作变得至关重要。测试就是验证代码正确性的过程。它能帮助我们高效地发现错误,并在添加新功能或修改代码时,确保不会破坏已有的功能。

本节我们将从简单的Python函数测试开始,讨论断言(assert)的用法,并最终引入Python的unittest库来编写更结构化的单元测试。


从简单的断言开始


测试的核心思想是验证某个条件是否为真。在Python中,最基础的工具就是assert语句。它的作用是声明某事应为真;如果条件为假,程序会抛出一个AssertionError异常。


让我们通过一个简单的例子来理解。假设我们有一个计算数字平方的函数:

# square.py
def square(x):
return x * x

为了验证这个函数是否正确,我们可以手动计算并打印结果,但这不够自动化。更好的方法是使用assert:
assert square(10) == 100
如果square(10)确实等于100,程序会静默通过。如果不等于(例如,我们在函数中错误地写了return x + x),程序会抛出AssertionError,立即告诉我们有错误发生。
上一节我们介绍了使用断言进行基础验证。本节中我们来看看如何为一个更复杂的函数编写测试。
测试一个更复杂的函数:判断质数
让我们考虑一个更复杂的函数is_prime(n),用于判断一个数字是否为质数。这个函数有更多的逻辑分支,出错的概率也更高。
# prime.py
import math
def is_prime(n):
"""Check if a number is prime."""
if n < 2:
return False
for i in range(2, int(math.sqrt(n)) + 1):
if n % i == 0:
return False
return True
我们可以手动在Python解释器中测试几个案例,但这很繁琐。为了自动化,我们可以编写一个测试脚本。

以下是手动测试的一种方式,它定义了一个测试函数来比较实际输出和期望输出:
# test0.py
from prime import is_prime

def test_prime(n, expected):
if is_prime(n) != expected:
print(f"ERROR: is_prime({n}) returned {not expected}")
然后,我们可以创建一个Shell脚本test0.sh来批量运行这些测试:
#!/bin/bash
python3 -c "from test0 import test_prime; test_prime(1, False)"
python3 -c "from test0 import test_prime; test_prime(2, True)"
python3 -c "from test0 import test_prime; test_prime(8, False)"
python3 -c "from test0 import test_prime; test_prime(25, False)"
# ... 更多测试

运行这个脚本会立即告诉我们哪些测试失败了。然而,自己管理测试框架仍然很麻烦。幸运的是,Python提供了强大的内置库来帮助我们。
使用 unittest 库进行单元测试 🧰

Python的unittest库提供了一个测试框架,可以自动发现和运行测试,并生成清晰的报告。它比我们自制的测试脚本更强大、更规范。

让我们将之前的测试用unittest重写:
# test1.py
import unittest
from prime import is_prime

class PrimeTestCase(unittest.TestCase):
"""Tests for `prime.py`."""
def test_is_prime_one(self):
"""Is 1 correctly determined not to be prime?"""
self.assertFalse(is_prime(1))
def test_is_prime_two(self):
"""Is 2 correctly determined to be prime?"""
self.assertTrue(is_prime(2))
def test_is_prime_eight(self):
"""Is 8 correctly determined not to be prime?"""
self.assertFalse(is_prime(8))
def test_is_prime_twentyfive(self):
"""Is 25 correctly determined not to be prime?"""
self.assertFalse(is_prime(25))
# ... 可以添加更多测试方法
if __name__ == '__main__':
unittest.main()
在这个例子中:
- 我们创建了一个测试类
PrimeTestCase,它继承自unittest.TestCase。 - 每个测试都是一个以
test_开头的方法。 - 我们使用
self.assertTrue()或self.assertFalse()来断言结果。 - 方法下的文档字符串(三个引号内的内容)会被
unittest用作测试描述。
运行这个测试文件(python test1.py),unittest会自动运行所有test_方法。输出会显示每个测试的结果(点.表示通过,F表示失败),并总结通过和失败的数量。如果测试失败,它会清晰地指出是哪个测试失败了以及失败的原因,这极大地帮助我们定位问题。


例如,如果is_prime(25)错误地返回了True,测试失败信息会直接指向test_is_prime_twentyfive这个方法,让我们能快速修复prime.py中的逻辑错误(例如,循环范围需要包含平方根本身)。

测试驱动开发与持续测试
编写测试不仅是为了修复已知的错误。测试驱动开发 是一种最佳实践,提倡在编写实现代码之前先编写测试。这有助于明确需求,并设计出更易测试的代码结构。
此外,随着项目增长,手动运行所有测试会变得不现实。我们应该将测试自动化,并集成到开发流程中。每次修改代码后都运行一遍完整的测试套件,可以确保新更改没有引入回归错误(即破坏了之前正常的功能)。这就是我们后续会讨论的持续集成理念的基础。
总结 📝

本节课中我们一起学习了软件测试的基础知识。

- 测试的目的:验证代码的正确性,确保程序行为符合预期,这对于复杂项目的维护至关重要。
- 使用断言:我们学习了如何使用Python的
assert语句进行最基本的条件验证。 - 编写测试函数:我们尝试了编写自定义函数来批量测试代码,并利用Shell脚本执行它们。
- 引入单元测试:我们重点介绍了Python内置的
unittest库,它提供了结构化的方式来编写和组织测试用例,并能自动运行测试、生成报告。
通过编写全面的测试,我们可以在优化代码、添加新功能或修复错误时拥有更大的信心。测试是保障软件质量、实现可持续开发的关键步骤。在接下来的课程中,我们将探讨如何将这些测试实践与前端开发和自动化部署流程结合起来。
哈佛 CS50-WEB 22:L7- 测试与前端CI/CD 2 (selenium,CI/CD) 🧪
在本节课中,我们将要学习如何为Web应用程序编写自动化测试,特别是使用Django的测试框架和Selenium进行浏览器端测试。我们还将探讨持续集成(CI)和持续交付/部署(CD)的核心概念,了解它们如何帮助团队更高效、更可靠地开发和发布软件。
概述:为何需要自动化测试? 🤔
上一节我们介绍了单元测试的基本概念,用于验证单个函数(如is_prime)的行为。本节中,我们来看看如何将这些测试思想应用于更复杂的Web应用程序,例如使用Django框架构建的网站。我们将测试模型功能、视图响应,甚至模拟用户在浏览器中的交互行为。
第一部分:测试Django应用程序 🐍
我们现在希望使用单元测试来验证Django网络应用程序中各种不同功能是否正常工作。让我们以之前讨论过的“航空公司”程序为例,该程序涉及将航班数据存储在数据库中。
1.1 定义模型与验证逻辑
首先,我们打开models.py文件,查看Flight模型。我们为航班定义了三个属性:出发地(origin)、目的地(destination)和持续时间(duration)。出发地和目的地都引用了另一个模型Airport。
我们希望有一种方法来验证航班数据是否有效。一个有效的航班需要满足两个条件:
- 出发地和目的地不应该是同一个机场。
- 航班的持续时间需要大于零。
我们在Flight类中编写了一个名为is_valid_flight的函数来实现这个验证逻辑。其核心检查代码如下:
def is_valid_flight(self):
return (self.origin != self.destination) and (self.duration > 0)
1.2 使用Django的测试框架
Django应用程序提供了一个tests.py文件,专门用于编写测试。我们可以定义一个继承自TestCase的类来组织我们的测试。

在运行测试时,Django会为我们创建一个完全独立的测试数据库,不会影响生产环境的数据。我们可以在测试类的setUp方法中初始化一些测试数据。

以下是创建测试数据和测试is_valid_flight函数的示例:

from django.test import TestCase
from .models import Airport, Flight
class FlightTestCase(TestCase):
def setUp(self):
# 创建测试用的机场和航班
a1 = Airport.objects.create(code="AAA", city="City A")
a2 = Airport.objects.create(code="BBB", city="City B")
Flight.objects.create(origin=a1, destination=a2, duration=100)
Flight.objects.create(origin=a1, destination=a1, duration=200)
Flight.objects.create(origin=a1, destination=a2, duration=-100)
def test_valid_flight(self):
a1 = Airport.objects.get(code="AAA")
a2 = Airport.objects.get(code="BBB")
f = Flight.objects.get(origin=a1, destination=a2, duration=100)
self.assertTrue(f.is_valid_flight())
def test_invalid_flight_destination(self):
a1 = Airport.objects.get(code="AAA")
f = Flight.objects.get(origin=a1, destination=a1)
self.assertFalse(f.is_valid_flight())
def test_invalid_flight_duration(self):
a1 = Airport.objects.get(code="AAA")
a2 = Airport.objects.get(code="BBB")
f = Flight.objects.get(origin=a1, destination=a2, duration=-100)
self.assertFalse(f.is_valid_flight())
我们可以通过运行python manage.py test命令来执行所有测试。如果测试失败,输出会明确指出是哪个断言出了问题,帮助我们快速定位和修复代码中的逻辑错误(例如,最初错误地使用了or而不是and)。

1.3 测试视图与HTTP响应
除了测试模型,我们还需要测试特定的网页是否按预期工作。Django的测试客户端允许我们模拟向应用程序发出请求并检查响应。
以下是测试航班列表页(index)和详情页的示例:
def test_index(self):
# 创建一个测试客户端
c = Client()
# 模拟用户访问 /flights 页面
response = c.get("/flights/")
# 断言响应状态码是200(成功)
self.assertEqual(response.status_code, 200)
# 断言响应上下文中包含3个航班(与setUp中创建的数量一致)
self.assertEqual(response.context["flights"].count(), 3)

def test_valid_flight_page(self):
a1 = Airport.objects.get(code="AAA")
f = Flight.objects.get(origin=a1, destination=a1)
c = Client()
# 访问一个存在的航班页面
response = c.get(f"/flights/{f.id}")
self.assertEqual(response.status_code, 200)
def test_invalid_flight_page(self):
# 获取当前最大的航班ID
max_id = Flight.objects.all().aggregate(Max("id"))["id__max"]
c = Client()
# 访问一个不存在的航班页面,应返回404
response = c.get(f"/flights/{max_id + 1}")
self.assertEqual(response.status_code, 404)
通过这种方式,我们可以确保应用程序的各个部分,从数据库逻辑到用户界面,都按照我们的设计正常运行。
第二部分:使用Selenium进行浏览器测试 🌐
上一节我们测试了服务器端的逻辑。但现代Web应用有很多交互发生在用户的浏览器中,使用JavaScript。为了测试这部分功能,我们需要能模拟浏览器行为的工具,Selenium就是其中最流行的框架之一。
2.1 一个简单的计数器应用

假设我们有一个简单的HTML页面,包含一个显示数字的标题和“增加”、“减少”两个按钮,其功能由JavaScript实现。

<!DOCTYPE html>
<html>
<head>
<title>Counter</title>
<script>
document.addEventListener('DOMContentLoaded', function() {
let counter = 0;
document.querySelector('#increase').onclick = function() {
counter++;
document.querySelector('h1').innerHTML = counter;
};
document.querySelector('#decrease').onclick = function() {
counter--;
document.querySelector('h1').innerHTML = counter;
};
});
</script>
</head>
<body>
<h1>0</h1>
<button id="increase">+</button>
<button id="decrease">-</button>
</body>
</html>
2.2 使用Selenium WebDriver自动化交互
Selenium WebDriver允许我们用代码(如Python)控制一个真实的浏览器。我们可以命令它打开网页、查找元素、点击按钮,并检查页面的状态。
以下是使用Selenium编写测试的步骤:
- 设置WebDriver:需要下载与浏览器对应的WebDriver(如ChromeDriver)。
- 编写测试:使用
unittest框架组织测试,在测试方法中使用Selenium的API。
import unittest
from selenium import webdriver
from selenium.webdriver.common.by import By
class CounterTest(unittest.TestCase):
def setUp(self):
# 启动Chrome浏览器驱动
self.driver = webdriver.Chrome()
# 构建本地HTML文件的URL
self.uri = "file://" + os.path.abspath("counter.html")
self.driver.get(self.uri)
def tearDown(self):
# 每个测试结束后关闭浏览器
self.driver.quit()
def test_title(self):
# 测试页面标题是否正确
self.assertEqual(self.driver.title, "Counter")
def test_increase(self):
# 找到“增加”按钮并点击
increase_button = self.driver.find_element(By.ID, "increase")
increase_button.click()
# 检查h1标签中的数字是否变为1
h1 = self.driver.find_element(By.TAG_NAME, "h1")
self.assertEqual(h1.text, "1")
def test_decrease(self):
# 找到“减少”按钮并点击
decrease_button = self.driver.find_element(By.ID, "decrease")
decrease_button.click()
# 检查h1标签中的数字是否变为-1
h1 = self.driver.find_element(By.TAG_NAME, "h1")
self.assertEqual(h1.text, "-1")
def test_multiple_increases(self):
increase_button = self.driver.find_element(By.ID, "increase")
for i in range(3):
increase_button.click()
h1 = self.driver.find_element(By.TAG_NAME, "h1")
self.assertEqual(h1.text, "3")
运行这些测试时,Selenium会自动打开浏览器窗口,执行点击操作,并验证结果。如果测试失败(例如,减少按钮的JavaScript有bug,导致数字增加而非减少),断言错误信息会清晰地指出期望值和实际值,帮助我们快速定位问题。

第三部分:持续集成与持续交付/部署 (CI/CD) 🔄
在编写了全面的测试之后,如何确保它们在团队开发过程中始终有效,并安全地将代码交付给用户?这就需要CI/CD实践。
3.1 持续集成 (Continuous Integration)
持续集成的核心思想是:
- 频繁合并:开发者频繁地将代码更改合并到主分支(如Git仓库的main分支),避免长期分支导致的复杂合并冲突。
- 自动化测试:每次代码推送或合并请求时,自动运行测试套件(包括单元测试、集成测试等)。如果测试失败,则阻止合并,要求开发者立即修复。


这样做的好处是能快速发现因代码更改引入的错误,而不是等到开发周期末尾才进行集成测试,那时定位问题将非常困难。
3.2 持续交付与持续部署 (Continuous Delivery/Deployment)
这是两个紧密相关的概念:
- 持续交付 (CD):指拥有短的发布周期,能够可靠地将软件的任何新版本快速交付给用户。它强调每次通过测试的代码更改都可以随时投入生产环境。
- 持续部署 (CD):是持续交付的更进一步,指通过自动化流程,将通过测试的代码更改自动部署到生产环境,无需人工干预。
采用CI/CD的好处包括:
- 快速反馈:问题能更早被发现和修复。
- 降低风险:小批量的增量更改比一次性的大改动更容易管理和回滚。
- 加速发布:新功能可以更快地到达用户手中。
3.3 实现CI/CD的工具
市场上有许多工具可以帮助实现CI/CD流水线,例如:
- Jenkins:一个开源的自动化服务器,功能强大,插件丰富。
- GitHub Actions:直接集成在GitHub仓库中,可以轻松配置在代码推送、拉取请求等事件发生时自动运行测试和部署脚本。
- GitLab CI/CD:GitLab内置的持续集成服务。
- Travis CI, CircleCI:流行的第三方云CI/CD服务。

这些工具通常允许你编写一个配置文件(如.github/workflows/main.yml for GitHub Actions),定义在什么条件下触发、运行哪些步骤(如安装依赖、运行测试、构建应用、部署到服务器)。
总结 📝
本节课中我们一起学习了Web应用程序测试与CI/CD的完整流程。
首先,我们深入探讨了如何为Django应用程序编写测试,包括测试模型中的业务逻辑(如is_valid_flight)和使用测试客户端测试视图的HTTP响应。这确保了服务器端代码的健壮性。
接着,我们引入了Selenium,这是一个强大的浏览器自动化工具。我们学习了如何用它来模拟真实用户的交互,例如点击按钮,并验证前端JavaScript代码的行为是否符合预期。这使得我们对应用程序端到端的功能有了信心。
最后,我们探讨了持续集成(CI)和持续交付/部署(CD)的理念。CI强调通过频繁集成和自动化测试来保证代码质量;CD则关注如何将这些高质量的更改快速、安全地交付给最终用户。结合使用测试框架和CI/CD工具,可以构建一个高效、可靠且迭代迅速的现代软件开发流程。
通过本课的学习,你应该能够为自己的项目建立一套从代码验证到自动发布的防护网,显著提升开发效率和软件质量。
哈佛 CS50-WEB 23:L7- 测试与前端CI/CD 3 (GitHub Actions与Docker应用) 🚀
概述
在本节课中,我们将要学习如何利用自动化工具来提升Web开发的效率与可靠性。我们将重点介绍持续集成(CI)与持续交付(CD)的概念,并学习如何使用GitHub Actions来自动化测试流程,以及如何使用Docker来标准化开发与部署环境。
利用GitHub Actions实现持续集成
上一节我们介绍了自动化测试的重要性。本节中我们来看看如何利用GitHub Actions来实现持续集成,确保每次代码变更都能自动运行测试。
持续集成工具可以帮助我们自动执行代码检查与测试。GitHub Actions是GitHub提供的一项功能,它允许我们创建工作流。例如,我们可以配置一个工作流,每当有人向GitHub仓库推送代码时,就自动运行我们为该代码库设定的测试。

GitHub Actions工作流示例
以下是一个GitHub Actions工作流的核心结构示例,它定义了一个在代码推送时运行的测试任务:

name: testing
on: [push]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Run Django Unit Tests
run: |
pip install django
python3 manage.py test
这个工作流被命名为testing,它会在push事件(即代码推送)时触发。它包含一个名为test的作业,该作业将在最新的Ubuntu系统上运行。作业的步骤包括:使用actions/checkout检出代码,然后安装Django并运行所有单元测试。
工作流执行与反馈
当工作流执行后,我们可以在GitHub仓库的“Actions”选项卡中查看结果。如果测试失败,对应提交旁会显示红色的“X”标记,并且我们会收到通知。这能帮助开发者快速发现问题并及时修复。

使用Docker进行环境标准化与持续交付
上一节我们介绍了如何自动化测试。本节中我们来看看如何利用Docker来解决开发与部署环境不一致的问题,并实现持续交付。
在团队协作或部署到服务器时,环境差异(如操作系统、软件版本)常导致程序运行异常。Docker通过容器化技术,将应用程序及其依赖打包在一个独立的、可移植的容器中,确保了环境的一致性。
编写Dockerfile
我们通过编写一个Dockerfile来定义如何构建应用程序的Docker镜像。镜像是一个包含运行应用所需一切(代码、运行时、库)的模板。
以下是一个用于运行Django应用的Dockerfile示例:
FROM python:3
COPY . /usr/src/app
WORKDIR /usr/src/app
RUN pip install -r requirements.txt
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
FROM python:3:指定基础镜像为包含Python 3的官方镜像。COPY . /usr/src/app:将当前目录所有文件复制到容器的/usr/src/app目录。WORKDIR /usr/src/app:设置容器内的工作目录。RUN pip install -r requirements.txt:安装requirements.txt中列出的所有Python依赖包。CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]:指定容器启动时运行的命令,即启动Django开发服务器。
使用Docker Compose组合多服务
在实际项目中,应用可能依赖其他服务,如数据库。Docker Compose允许我们通过一个YAML文件定义和运行多个相互关联的Docker容器。
以下是一个docker-compose.yml文件示例,它定义了一个Web应用服务和一个PostgreSQL数据库服务:
version: '3'
services:
db:
image: postgres
web:
build: .
ports:
- "8000:8000"
version: ‘3’:指定Docker Compose文件的版本。services:定义要运行的服务。db:基于postgres官方镜像的数据库服务。web:基于当前目录Dockerfile构建的Web应用服务,并将容器的8000端口映射到主机的8000端口。
运行docker-compose up命令即可同时启动这两个服务,它们将运行在独立的容器中并能相互通信。
在容器内执行命令
有时我们需要在正在运行的容器内执行命令(例如创建超级用户)。可以使用docker exec命令:
# 1. 查看运行中的容器
docker ps

# 2. 进入指定容器的交互式bash shell
docker exec -it <容器ID> bash

# 3. 在容器内执行命令,例如创建Django超级用户
python manage.py createsuperuser

总结
本节课中我们一起学习了现代Web开发中两项至关重要的实践:持续集成(CI)与持续交付(CD)。

我们首先了解了如何使用GitHub Actions来自动化测试流程。通过编写YAML格式的工作流文件,我们可以在每次代码推送时自动运行测试,快速获得反馈,确保代码质量。
接着,我们探讨了如何使用Docker来解决环境配置问题。通过编写Dockerfile定义应用环境,并使用Docker Compose管理多服务应用,我们能够确保开发、测试和生产环境的一致性。这大大简化了部署流程,并使得团队协作更加顺畅。
结合CI/CD与容器化技术,我们可以实现快速、可靠且可重复的软件交付周期,这是构建和维护高质量、可扩展Web应用程序的关键。
哈佛 CS50-WEB 24:L8- 拓展性与安全 1 (可扩展性,负载均衡,自动伸缩) 🚀



在本节课中,我们将要学习如何将我们构建的Web应用程序从本地计算机部署到互联网上,并探讨随之而来的可扩展性与安全性问题。我们将首先聚焦于可扩展性,了解当用户数量增长时,如何确保应用程序能够稳定、高效地运行。

概述:从本地到全球 🌍
到目前为止,我们一直在自己的计算机上构建和运行Web应用程序。但如果想让全世界的人都能使用我们的应用,就需要将其部署到Web服务器上。Web服务器是一种专用硬件,能够监听网络请求并返回响应。

将应用部署到服务器上,会引入一系列关于可扩展性和安全性的新挑战。本节课,我们将首先探讨可扩展性相关的问题及其解决方案。
服务器部署:本地与云端 ☁️
服务器主要有两种部署方式:本地服务器和云端服务器。
- 本地服务器:指公司内部拥有并维护的物理服务器。这种方式能提供对硬件的直接控制,但需要自行承担维护和扩展的责任。
- 云端服务器:指由亚马逊、谷歌、微软等云服务商提供的远程服务器。这种方式无需管理物理硬件,由服务商负责基础设施的维护,并且通常能提供更便捷的扩展能力。
随着云计算的发展,越来越多的应用选择部署在云端,以便利用其强大的可扩展性。
可扩展性的挑战:单台服务器的极限 ⚖️
一台服务器在任何给定时间内能服务的用户数量是有限的。这个上限取决于服务器的计算能力、内存、网络带宽等资源,以及处理单个请求的复杂度。
为了评估服务器的处理能力,我们需要进行基准测试。例如,可以使用 Apache Bench (ab) 这样的工具来测试服务器在单位时间内能处理多少请求。
# 示例:使用 Apache Bench 对本地服务器进行压力测试
ab -n 1000 -c 100 http://localhost:8000/
-n 1000表示总请求数。-c 100表示并发用户数。
当并发用户数超过服务器的处理上限时,应用就会变慢甚至崩溃。为了解决这个问题,我们需要对系统进行扩展。
扩展策略:垂直扩展与水平扩展 📈
主要有两种扩展策略:
- 垂直扩展 (Scale Up):通过升级单台服务器的硬件(如更快的CPU、更大的内存)来提升其处理能力。这种方法简单直接,但受物理极限和成本的制约。
- 水平扩展 (Scale Out):通过增加服务器的数量来分担负载。例如,从一台服务器扩展到两台,理论上处理能力就能翻倍。这是应对大规模用户访问的更常用方法。
上一节我们介绍了扩展的必要性,本节中我们来看看水平扩展的核心组件——负载均衡器。
负载均衡器:流量调度员 🚦
当有多台服务器时,需要一个机制来决定将每个用户请求分发到哪台服务器。这个角色由负载均衡器承担。
负载均衡器位于所有服务器之前。用户的请求首先到达负载均衡器,然后由它决定将请求转发给后端的哪台服务器进行处理。
负载均衡器可以采用不同的算法来决定请求的分发:
以下是几种常见的负载均衡算法:
- 随机选择:简单地将请求随机分配给任意一台服务器。实现简单,但可能导致负载分配不均。
- 轮询:按顺序将请求依次分配给每台服务器(如 Server1, Server2, Server3, 再回到 Server1...)。比随机更公平,但未考虑服务器当前的实际负载。
- 最少连接数:将新请求分配给当前活动连接数最少的服务器。这种方法能更好地反映服务器的实时负载情况,但计算成本稍高。
会话感知:记住用户是谁 🧠
水平扩展带来了一个新问题:会话保持。Web应用通常使用会话来跟踪用户状态(例如登录信息)。如果用户第一次请求被发到 Server A 并建立了会话,但第二次请求被负载均衡器发到了 Server B,那么 Server B 无法识别该用户,导致用户需要重新登录。
为了解决这个问题,我们需要会话感知的负载均衡。主要有以下方法:
以下是几种实现会话感知的策略:
- 粘性会话:负载均衡器记录用户首次访问时被分配到的服务器,并在后续请求中始终将同一用户指向那台服务器。缺点是可能导致服务器间负载不均衡。
- 集中式会话存储:不将会话数据存储在单台服务器上,而是存储在一个所有服务器都能访问的中央数据库(如 Redis)中。这样无论请求被发到哪台服务器,都能获取到会话信息。
- 客户端会话存储:将会话数据加密后存储在客户端的 Cookie 中。这种方式无需服务器存储状态,但增加了网络传输开销,并需注意安全性(防篡改)。
自动伸缩:弹性应对流量波动 📊
我们很难精确预测在任何时刻会有多少用户访问应用。流量可能随时波动(例如新闻网站遇到突发新闻时)。如果始终按最大流量配置服务器,在低峰期会造成资源浪费。

自动伸缩 是云平台提供的一种解决方案。它可以监控应用的负载指标(如CPU使用率、请求数量),并自动增加或减少服务器实例的数量。
例如,可以配置规则:当平均CPU使用率超过70%时,自动增加一台服务器;当低于30%时,减少一台服务器。这样既能应对流量高峰,又能在平时节约成本。
避免单点故障:高可用性设计 🛡️
在分布式系统中,需要避免单点故障——即某个关键组件失效导致整个系统瘫痪。
- 服务器层:使用多台服务器和负载均衡器,当一台服务器宕机时,负载均衡器可以通过心跳检测(定期向服务器发送健康检查请求)发现故障,并不再向其分发流量。
- 负载均衡器层:负载均衡器本身也可能成为单点故障。解决方案是部署多个负载均衡器,形成主备或集群模式,当一个失效时,另一个能立即接管。
- 数据库层:数据库也是关键的单点。我们可以通过数据库复制来提升可用性。
数据库的可扩展性 🗃️
随着应用规模增长,数据库也可能成为瓶颈。除了升级数据库服务器硬件(垂直扩展),我们还可以采用以下策略:
数据库分区:
- 垂直分区:将一个包含多列的大表拆分成多个关联的小表(即数据库规范化)。例如,将用户信息和登录凭证分表存储。
- 水平分区/分片:将同一个表的数据按某种规则(如用户ID范围、地域)拆分到多个结构相同的表中。例如,将用户表按注册年份分表。
数据库复制:
- 主从复制:设置一个主数据库负责处理所有写入操作,多个从数据库同步主库的数据并负责读取操作。这提升了读性能,但写入仍集中在主库,且主库是单点故障。
- 多主复制:多个数据库都可以处理读写操作,并相互同步数据。这提升了写能力和可用性,但带来了数据同步冲突的复杂性(如更新冲突、唯一键冲突),需要额外的冲突解决机制。
缓存:对于不经常变化但访问频繁的数据(如新闻网站首页),可以将其查询结果存储在 Redis 或 Memcached 等内存缓存中。后续请求可以直接从缓存读取,极大减轻数据库压力。
# 伪代码示例:使用缓存获取首页文章
def get_homepage_articles():
cache_key = "homepage_articles"
articles = cache.get(cache_key) # 首先尝试从缓存获取
if articles is None:
articles = db.query("SELECT * FROM articles ORDER BY publish_time DESC LIMIT 10") # 缓存未命中,查询数据库
cache.set(cache_key, articles, timeout=300) # 将结果存入缓存,有效期300秒
return articles
总结 🎯
本节课中我们一起学习了Web应用可扩展性的核心概念。我们了解到,当用户量增长时,可以通过水平扩展增加服务器数量,并使用负载均衡器分发流量。为了保持良好的用户体验,需要实现会话感知。利用云平台的自动伸缩功能,可以弹性应对流量变化。设计系统时,要时刻注意避免单点故障,并通过数据库分区、复制和缓存等策略来提升数据层的性能和可靠性。这些策略各有权衡,需要根据应用的具体需求进行选择和设计。
哈佛 CS50-WEB 25:L8- 拓展性与安全 2 (缓存,安全,https) 🛡️
在本节课中,我们将要学习如何通过缓存技术提升Web应用的可扩展性,并深入探讨Web开发中至关重要的安全议题,包括网络钓鱼、密码学以及HTTPS协议的工作原理。
概述 📋
文章内容可能不会频繁变动。如果一个人发出请求,一秒后另一个人又发出同样的请求,那么从数据库重新请求所有信息以再次生成模板可能效率低下,因为这是一个开销较大的过程。
从数据库请求数据或生成模板时,我们理想上希望有某种方法来避免重复工作。我们可以通过某种形式的缓存来解决这个问题。缓存指的是一系列可以在不同场景下使用的想法和工具。
缓存的概念与类型 💾
在我们的系统中,一般来说,当我们谈论缓存时,我们指的是以某种方式存储某些信息的已保存版本,以便我们能够更快速地访问,从而避免持续向数据库发出请求。例如,有许多方式可以进行缓存。
客户端缓存
我们可以在客户端进行缓存。通过客户端缓存,其核心思想是,无论是Safari、Chrome还是其他浏览器,都能够缓存数据、存储信息,这样浏览器下次就不需要重新请求相同的信息了。
假设该页面加载了一张图片,并且你重新加载该页面,那么你的网页浏览器可能会尝试再次请求同样的图片。但另一种可能是,你的网页浏览器可以在缓存中保存该图片的副本以供本地存储。
浏览器存储该图片的一个版本,这样下次用户向网站发出请求时,用户就不需要重新加载整个图片。这也适用于整个网页和网络资源。如果某些页面不常改变,那么如果网页浏览器缓存了已保存的版本,当用户下一次访问时,浏览器可以直接显示缓存的已保存页面,而无需与服务器交互。
这有助于减少特定服务器的负担,并且用户体验会更快,因为他们可以立即看到信息,而不需要发出请求并等待响应。服务器也无需处理那么多请求。
因此,尝试解决这个问题的一种方法是在HTTP响应的头部中添加一些内容。当你的web服务器响应某些请求时,可以在响应中包含类似这样的行:Cache-Control: max-age=86400。这实际上是指定你应该缓存此资源的秒数。
如果我在10秒后尝试访问这个页面,那就少于86,400秒,因此不必重新请求整个页面,我们只是使用网页浏览器中缓存的版本。这有几个优势:减少了查看页面内容所需的时间,并且减轻了对服务器的负载。
但也有缺点。例如,如果在这段时间内资源发生了变化,假设在60秒内页面发生了变化,如果我再次加载页面,加载的是缓存的版本,我可能会看到过时的内容。我看到的网页版本是旧版本,因为我的网页浏览器恰好缓存了该特定资源。
这种情况可能适用于网页,尤其适用于其他静态资源,例如CSS文件或JavaScript文件。页面的CSS可能并没有经常变化,因此很自然的是,网页浏览器不会一次又一次请求完全相同的CSS文件,而是可能只会保存这些CSS文件的副本,将其缓存,以便能够重复使用缓存版本。
如果网站更新了他们的CSS,你可能看不到最新的更改。如果你在自己开发网页应用时更改CSS并刷新页面,你可能不会总是看到这些更改被反映出来,如果你的网页浏览器正在缓存这些内容。
在大多数网页浏览器中,你可以进行硬刷新,忽略缓存中的内容,实际上发出新请求并获取一些新数据。但最终,如果你不这样做,你就会受到缓存控制的限制。网页浏览器将会说,除非经过的秒数达到了这个数字,否则我们将重新使用现有版本的页面。
使用ETag进行缓存验证
这种方法的替代方案(这确实有效且相当流行)是,我们可以通过添加称为ETag的内容来扩展这一方法。资源(如CSS文件、图像或JavaScript文件)的ETag就是一个独特的字符序列,用于标识特定版本的资源。
这允许程序(如网页浏览器)在请求资源时,例如请求一个CSS文件或JavaScript文件,它们会收到返回和相关的ETag值。因此我知道这是与这个版本的CSS文件相关的值。如果网络服务器的CSS文件被更新,对应的ETag也会改变。
那么这有什么好处呢?这意味着如果我在考虑是否应该加载资源的新版本,或者是否应该尝试另一个请求以获取最新的CSS,首先我可以问一下ETag值。这个短小的序列可以在浏览器中快速回答。
如果ETag值与我上次记住的相同,那么我就不需要获取整个新版本的资源。因此这很常见——我们的网页浏览器会说:“嘿,让我请求这个资源,但我已经有一个版本,资源上指定这个特定ETag。”如果该ETag仍然是某个特定资源(如CSS或JavaScript文件)最新版本的ETag,那么网络服务器就不需要发送该文件的新版本,只需回应并说你拥有的版本完全可以使用。
但如果有一个新版本,那么网络服务器可以用新资产进行响应,例如新的CSS文件,还有新的ETag值。因此这两种方法可以相辅相成。你可以说继续缓存这些,持续一段时间,因此在这段时间内你不会决定请求该资源的新版本。但即使你在这段时间过后请求新的版本,如果ETag值没有更新,那么就不需要重新下载特定文件的整个新版本,你可以直接重用缓存的版本。
所以缓存和浏览器可以是一个极其强大的工具,来加速这些请求,减轻特定服务器的负担。
服务器端缓存 🖥️
上一节我们介绍了客户端缓存,但客户端并不是我们进行缓存的唯一地方。我们也有能力进行服务器端缓存。
在服务器端缓存中,我们将向我们的概念介绍一个缓存。拥有多个服务器与数据库进行通信,而这些服务器也可以与某个地方的缓存进行通信,在那里我们存储可能想要重用的信息,而不是做所有的重新计算。
结果发现Django有一个完整的缓存框架和一整套功能,允许我们利用这个能力使用缓存来加速请求。
以下是Django中几种主要的缓存方式:
- 按视图缓存:可以在特定的视图上应用缓存。与其每次有人请求这个特定视图时都运行所有的Python代码,不如缓存该视图,这样在接下来的30秒或30分钟内,当下一个人尝试访问相同视图时,可以直接重用上次加载该视图时的结果。
- 模板片段缓存:模板可能包含多个部分。在网页上,你可能会根据当天的信息渲染导航栏、侧边栏和页脚。这些部分可能第二天会改变,但如果你预期页面的侧边栏在同一分钟或同一小时内不会频繁更改,那么你可以考虑缓存模板的这一部分。这样当Django尝试加载整个模板时,就不需要重新计算如何为你的网站生成侧边栏,它只需知道我们可以使用上次加载该网站时保存的侧边栏版本。
- 低级缓存API:Django还提供了访问较低级缓存API的能力,方便你缓存和存储任何信息以供后用。你可以将信息保存在API中。例如,进行一次耗时几毫秒或几秒钟的数据库查询,你可以将这些结果保存在缓存中,以便下次访问同样的数据时更容易获取。
这使我们能够通过减少服务器和数据库的负载来处理这些规模问题。而不是每次对特定网页应用程序发出新请求时都与数据库进行交互,我们可以重用缓存中已有的信息,从而提升我们的网页应用程序的可扩展性。
Web安全基础 🔐
在讨论了可扩展性相关的问题后,现在我们将注意力转向安全。确保在构建和部署我们的网络应用程序时,随着更多用户的使用,我们要确保安全。还有一大堆安全考虑因素需要在我们所研究的所有主题中加以考虑。
我们已经看过多个不同的主题,每个主题都有安全漏洞,需要注意相关的想法,以确保我们的应用程序是安全的。

Git与凭证安全
我们的故事实际上可以从Git和版本控制开始。Git的核心在于帮助我们跟踪代码的不同版本。而与Git密切相关的是开源软件的概念。
在像GitHub和其他托管Git存储库的服务的网站上,越来越多的软件正变成开源,任何人都可以查看并贡献应用程序的源代码。这在某种意义上是很棒的,因为它允许很多人能够合作共同工作,以尝试发现可能存在于网络应用中的错误。
但这也带来了缺点。如果应用程序中有一个错误,现在查看我们程序源代码的人可能会发现这个错误。或者你可能会想象,因为Git跟踪我们代码的不同版本,每次我们向存储库提交时,你必须非常小心关于凭证或可能泄漏到源代码中的东西。
通常情况下,你绝对不想把密码或任何安全信息放入Git存储库中,因为Git存储库可能会与其他人共享,可能向任何人开放以供查看。因此这些都是需要注意的安全考虑因素。
如果你进行提交并意外地提交了你的代码,暴露了这些凭证,你可以删除这些凭证并再次提交。因此你程序的最新版本不包含这些凭证。但拥有访问Git存储库的人可以访问的不仅是你代码的最新版本,还有你代码的每个版本。而那个人理论上可以回顾存储库的历史记录,找到凭证暴露的提交,并查看那些凭证。
因此,虽然Git是一个非常强大的工具,但这也是需要注意的。任何你所做的更改可能会被保存在一个提交中,因此可能会在稍后被访问。所以如果存储库中暴露了凭证,你要确保删除所有之前的提交,而不仅仅是进行一些新的提交来试图隐藏之前可能暴露的凭证,因为它们仍然可以在任何特定存储库的历史记录中检索到。
HTML与网络钓鱼攻击
那时我们查看了一些可能出现的问题。我们还在课程开始时讨论了HTML,以及我们可以用HTML做什么,以及我们如何使用这种语言来设计网页的结构,以决定所有段落将放在哪里,页面上将包含哪些表格。我们讨论了链接以及如何使用锚标签将一个页面链接到另一个页面。
现在一个关注点是这种攻击类型,称为网络钓鱼攻击,与HTML有关。网络钓鱼攻击实际上只涉及一小段HTML,像这样非常简单的代码:
<a href="https://evil-site.com">https://legitimate-bank.com</a>
我有一个锚标签,它将用户引导到URL 1,但看起来是引导用户到URL 2。这种情况的例子是什么呢?例如,我写了一个网站,看起来有一个指向谷歌的链接,但如果我点击那个链接,我突然被引导到了这个课程网站。那是怎么发生的,为什么会这样?看起来它在链接谷歌,但如果你查看代码,你会看到这里有一个锚标签,它实际上链接到课程网站,但看起来是链接到谷歌。
这是一个非常常见的攻击向量,特别是在电子邮件中。例如,你可能会看到一封电子邮件告诉你点击某个链接,但那个链接实际上会把你带到完全不同的地方。因此有人可能会不小心分享他们的银行账户凭证或其他敏感信息。
这里也要引起注意。在互动网络时,可能并不一定在你自己的网站上,但在其他网站上你可能会互动,务必注意链接实际上将你带到哪里。大多数网页浏览器在你悬停在链接上时会显示该链接可能实际指向的地方,因为它可能与文本中该特定锚标签所显示的内容不同。

因此,HTML存在各种不同的漏洞,因为你可以决定页面的结构,这就留下了有人试图欺骗你正在访问一个实际上并不在的页面的可能性。这个问题更为普遍,因为任何人都可以查看任何页面的HTML。HTML来自服务器,因此网页浏览器可以访问所有这些HTML,并可以使用这些HTML来渲染页面,这留下了开放其他漏洞的可能性。

例如,如果我想创建一个假冒的美国银行网站,以欺骗他人认为他们正在访问美国银行,实际上他们将访问我的网站。那么我可以查看这个网页的源代码,复制所有这些内容,放入HTML文件中,创建一个新文件。现在我的页面上是一个看起来像美国银行的网页,它使用了美国银行的所有HTML,但实际上是我的HTML页面,而不是美国银行。
因此,你可能想象将这些结合在一起,创建一个更加令人担忧的攻击。例如,创建一个链接,看起来指向 bankofamerica.com,但实际上链接到我的假冒银行HTML页面。现在如果我点击那个链接,我会进入一个看起来像美国银行的页面,但它并不是美国银行的网站,而是我写的HTML文件,正好看起来像美国银行的网站,因为我复制了所有的底层HTML。

因此,HTML能够描述我们网页的结构,但每当你写这HTML时,记得这一点是很重要的:任何人都可以复制你的HTML,从理论上讲,可以假装成你的网站。这些安全漏洞值得我们在开始开发网络应用程序和与网络应用程序互动时注意。

密码学与HTTPS 🔒
最终我们在设计使用Django的网络应用程序时使用了框架。这些网络框架如何创建监听请求并响应这些请求的网络服务器?最终,互联网的许多内容都是围绕客户端与服务器之间的通信这个理念构建的,或者更一般来说,是任一台计算机正在使用HTTP与另一台计算机通信,尤其是HTTP的更安全版本。
因此,你可能想象这些协议实际上是关于如何将信息从一个人传递到另一个人,以及我们与这些信息一起存储的内容。试图与另一台计算机通信时,为此信息通常会通过这些路由器流动。你可以想象信息在一台计算机和另一台计算机之间来回传递,通过这些中间路由器沿途。
因此需要谨慎的一件事是,你如何知道这信息在来回传递时是安全的?理想情况下,当我向另一台计算机发送消息时,例如发送电子邮件或发出请求,我不希望任何拦截请求的路由器能查看我的银行账户等敏感信息。我希望确保这些路由器无法看到我的请求及其内容。我希望在网上发送的密码信息能够被加密。
因此我们将讨论密码学,即确保能够与他人沟通而不被中间窃听者监听的过程。显然,如果我直接发送未加密的消息文本,那么任何看到该消息的人都会知道内容,因此我需要某种加密方式对消息进行加密,以防有人在传输过程中能够解密。
对称密钥加密
如果中间的路由器或其他人能够拦截消息,那么我们首先要考虑的是所谓的对称密钥密码学。这里涉及的不是明文,而是某个密钥,一个用于加密或解密信息的秘密信息。
我将使用密钥和明文生成称为密文的加密版本。我可能想要通过互联网发送密文,而不是明文,以防发送明文导致内容泄露。因此,密文被传送,另一方也需要密钥。
如果其他人拥有密文和密钥,那么他们就可以利用这些信息使用密钥解密密文,得到原始明文。这个密钥可以称为对称密钥加密和解密密钥,用来加密和解密消息。
为了完成解密过程,我和交流对象都需要同样的密钥。只要我们都能访问这个密钥,就可以加密和解密消息,而仅拥有密文但没有密钥的人则不太可能解读出内容。
但在互联网环境中存在一个问题,即我和另一方都需要访问这个密钥。密钥用于加密和解密,但我不能直接将密钥通过互联网发送给他人。如果我做得很好,那么中间的某个人拦截了我的请求,他们就可以同时获取密文和密钥,因此他们能够解密消息,因为他们同时拥有密文和密钥。
如果我能够与另一个人当面交换密钥,只要我们都有密钥,而且我没有将密钥公开分享给可能拦截消息的任何人,这个方案就能奏效。只有我和另一个人拥有密钥。但通常在互联网上交流时,你并不是在与你已经通信的服务器沟通。在我尝试向一个新网站发起请求时,我们需要达成一个协议,让我可以加密消息,而只有对方能够解密这些消息,因此这种加密方式可能并不适合初步创建互联网的安全连接。
非对称密钥加密(公钥密码学)
对于密码学的一项重大进展是公共密钥加密的概念。而在秘密密钥加密中,密钥必须是秘密的,因为如果每个人都知道密钥,那么任何人都能够解密。
在公钥密码学中,我们能够创建一个安全的加密系统,其中密钥是公开的,或者至少是其中一个密钥。如你所见,这里的想法是我们使用两个密钥而不是一个,我们同时拥有一个公钥和一个称为私钥的密钥。
私钥是你应该绝对不与他人分享的东西,以保持加密方案的安全,而公钥则可以与他人分享。二者的区别在于:公钥用于加密信息,私钥用于解密由公钥加密的信息。公钥与私钥在数学上是相关的。
如果我想与另一个人交流,那个人会把他们的公钥发送给我。公钥可以在互联网中传输。任何人都可以看到公钥,因为公钥仅用于加密数据。因此我可以使用明文和公钥生成密文。
我将密文发送给我想要沟通的另一方。而另一方现在使用密文,再用他们没有分享的私钥,私钥能够解密使用公钥加密的信息。因此使用这种方式,密文与私钥的组合,使得我所沟通的那个人能够解密信息,恢复出原始的明文内容。
这就是我们如何通过使用公钥和私钥对在互联网上进行大量通信的方式。可以使用公钥进行加密,使用私钥进行解密。而现在两个之前从未互动过的计算机,无需见面以交换某些秘密信息,可以使用这种技术安全地互相沟通。
我们可以来回发送消息,而中间没有人能够拦截消息并识别消息的内容。一旦你具备这种能力,能够与他人秘密交流,那么你可以想象达成某种秘密密钥的共识,然后使用对称密钥加密以便能够加密和解密消息。
因此这是你在尝试与他人通过互联网沟通时可以采取的一种方法。加密的理念使得HTTPS得以实现,这是HTTP协议的安全版本,以确保你在沟通时,例如你银行的网站上,途中不会有人能够拦截信息并识别你在沟通的内容,而只能得到加密版本的信息,以及一个公钥,用于加密信息,但没有可以解密信息的私钥。
最终,这种方法使我们能够在互联网上实现这种安全通信,并使我们的网络应用程序变得安全。
总结 📝
本节课中我们一起学习了提升Web应用性能与安全性的核心知识。

- 我们首先探讨了缓存技术,包括客户端缓存(通过HTTP头如
Cache-Control和ETag控制)和服务器端缓存(如Django的视图缓存、模板片段缓存和低级API缓存),它们能显著减少服务器负载并提升响应速度。 - 接着,我们转向了Web安全。我们回顾了Git使用中暴露凭证的历史风险,以及HTML可能被用于网络钓鱼攻击的漏洞,强调了在开发和使用Web应用时保持警惕的重要性。
- 最后,我们深入了解了保障网络通信安全的基石——密码学。从需要共享密钥的对称加密,到引入公钥/私钥对、无需预先交换秘密就能建立安全连接的非对称加密(公钥密码学)。正是这套机制支撑着HTTPS协议,确保了我们与网站(如银行)之间的数据传输不被窃听和篡改。

理解这些缓存策略和安全原理,对于构建高效、可靠且安全的现代Web应用程序至关重要。
哈佛 CS50-WEB 26:L8- 拓展性与安全 3 (数据库,JS) 🛡️
在本节课中,我们将要学习Web应用开发中与数据库和JavaScript相关的核心安全概念。我们将探讨如何安全地存储用户数据,以及如何防范常见的网络攻击,如SQL注入和跨站脚本攻击。
概述
我们将从数据库安全开始,讨论密码存储的最佳实践。接着,我们会分析JavaScript运行环境带来的安全挑战,并学习如何保护我们的应用免受恶意代码的侵害。理解这些概念对于构建健壮、可信赖的Web应用至关重要。
数据库安全:密码存储 🔐

上一节我们介绍了服务器端的安全考量,本节中我们来看看如何安全地在数据库中处理用户凭证。
将密码以明文形式存储在数据库中是非常不安全的做法。明文存储意味着密码以其原始文本形式直接保存在数据库中。
不安全示例(伪代码):
CREATE TABLE users (
id INT,
username VARCHAR(255),
password VARCHAR(255) -- 明文存储密码
);
这种做法存在严重的安全漏洞。如果数据库因某种原因泄露,攻击者就能直接获取所有用户的密码。
因此,推荐的方法是存储密码的哈希版本,而不是实际的密码。哈希函数是一种单向加密函数,它将密码作为输入,并输出一个固定长度的字符串(哈希值)。
核心概念:
- 哈希函数:一个数学函数,满足
哈希值 = H(密码)。 - 单向性:从哈希值反向推导出原始密码在计算上是极其困难的。
这意味着应用本身并不知道用户的真实密码。当用户尝试登录时,系统会对用户输入的密码进行哈希计算,然后将得到的哈希值与数据库中存储的哈希值进行比较。
登录验证流程:
- 用户输入密码。
- 系统计算
输入哈希 = H(用户输入)。 - 系统从数据库读取
存储哈希。 - 比较
输入哈希 == 存储哈希。若相等,则登录成功。

正因为如此,遵循最佳实践的公司无法告知用户其密码是什么。如果用户忘记密码,公司只能提供“重置密码”的功能,而无法“找回密码”。
信息泄露与用户隐私 🕵️
在设计用户交互功能时,需要注意避免无意中泄露敏感信息。
例如,在“忘记密码”页面,如果用户输入一个不存在的邮箱,系统返回“该邮箱未注册”的错误信息,这就会泄露信息。攻击者可以通过尝试不同的邮箱地址,来探测哪些用户在网站上拥有账户。
以下是需要注意的潜在信息泄露点:
- 错误信息:过于详细的错误信息(如“用户名不存在”与“密码错误”)可能被用来枚举有效用户。
- 响应时间:数据库查询的响应时间差异可能间接泄露信息(例如,查询一个拥有大量数据的用户可能比查询新用户更慢)。
开发者需要根据应用的安全需求,决定是否隐藏这类信息,以保护用户隐私。
SQL注入攻击 💉
处理SQL时,另一个重大安全威胁是SQL注入。这种攻击发生在攻击者能够将恶意的SQL代码“注入”到应用程序的数据库查询中。


假设一个登录查询如下:
SELECT * FROM users WHERE username = ‘[用户输入]’ AND password = ‘[用户输入]’;
如果用户输入普通的用户名和密码,例如 harry 和 12345,查询会正常工作。
SELECT * FROM users WHERE username = ‘harry’ AND password = ‘12345’;

然而,如果攻击者输入 admin’-- 作为用户名,并将密码字段留空,查询就会变成:
SELECT * FROM users WHERE username = ‘admin’--’ AND password = ‘’;
在SQL中,-- 是注释符号,这意味着其后的所有内容(包括密码检查)都会被数据库忽略。这可能导致攻击者无需密码就能以管理员身份登录。
解决方案:使用参数化查询或ORM(对象关系映射)。这些技术能确保用户输入被当作数据处理,而非可执行的SQL代码。
在Django框架中,使用其内置的ORM(例如 User.objects.filter(username=username))可以自动防止SQL注入攻击。
API 安全与最佳实践 🔑
当我们讨论与其他服务器交互(如调用API)时,也需要考虑安全性和可扩展性。
以下是保护API的两种常见技术:
- 速率限制:限制单个用户或IP地址在特定时间窗口内可发出的请求数量。这有助于防止拒绝服务攻击,即攻击者通过海量请求使服务器瘫痪。
- 身份验证:使用API密钥等凭证来验证请求者的身份。这确保只有授权用户才能访问特定数据。
重要警告:绝对不要将API密钥等敏感信息硬编码在源代码中(尤其是提交到Git等版本控制系统)。否则,任何能访问代码的人都能窃取并使用该密钥。
安全实践:使用环境变量来存储敏感信息。这样,密钥被保存在运行应用程序的服务器环境中,而不是在代码文件里。
示例(Python中获取环境变量):
import os
api_key = os.environ.get(“MY_API_KEY”)
JavaScript 安全:跨站脚本攻击 🚨
JavaScript在浏览器中运行,拥有强大的能力来操控网页内容。这也带来了独特的安全挑战,最主要的是跨站脚本攻击。
XSS攻击允许攻击者将恶意JavaScript代码注入到其他用户浏览的网页中。这些代码不是开发者编写的,却能在受害者的浏览器中执行。
一个简单示例:
假设一个网页显示URL中的路径:https://example.com/page?msg=<script>alert(‘Hacked!’)</script>
如果应用没有正确处理输入,直接将其插入到HTML中,那么 <script>alert(‘Hacked!’)</script> 就会被浏览器当作JavaScript代码执行,弹出一个警告框。
这不仅仅是恶作剧。注入的脚本可以窃取用户的Cookie、会话令牌,篡改页面内容,或将用户重定向到恶意网站。
防御方法:对用户输入进行严格的过滤和转义。确保所有用户提供的数据在插入到HTML页面之前,都被当作纯文本处理,而不是可执行的代码。现代前端框架(如React, Vue)和模板引擎通常内置了XSS防护机制。
跨站请求伪造攻击 🔄
CSRF攻击诱使已登录的用户在不知情的情况下,向一个他们已认证的Web应用提交恶意请求。
攻击场景:
- 用户登录了银行网站
bank.com。 - 用户访问了恶意网站
evil.com。 evil.com的页面中包含一个隐藏的表单或图片,其目标指向bank.com/transfer?to=attacker&amount=1000。- 用户的浏览器会自动携带
bank.com的Cookie(登录凭证)发起这个请求。 - 银行服务器看到合法的凭证,便执行了转账操作。
防御方法:使用CSRF令牌。
- 服务器在返回表单时,生成一个随机、唯一的令牌,并将其嵌入表单中(通常是一个隐藏字段)。
- 当用户提交表单时,必须将这个令牌一并提交。
- 服务器验证提交的令牌是否与之前生成的匹配。
- 由于恶意网站无法获取或预测这个令牌,因此无法伪造有效的请求。
Django等Web框架默认提供了CSRF防护中间件,极大地简化了防护工作。
总结
本节课中我们一起学习了Web应用后端与前端的关键安全议题。
我们首先探讨了数据库安全,明白了永远不要明文存储密码,而应存储其哈希值。接着,我们分析了因设计不当导致的信息泄露问题。
然后,我们深入研究了两种主要的代码注入攻击:SQL注入和跨站脚本攻击,并了解了通过参数化查询、输入转义等方式进行防御。
最后,我们讨论了API的速率限制与认证机制,以及如何利用CSRF令牌来防范跨站请求伪造攻击。
安全性是Web开发的基石。作为开发者,我们必须时刻保持警惕,在设计之初就将安全考虑融入其中,这样才能构建出既强大又值得用户信赖的应用程序。


浙公网安备 33010602011771号