这两份笔记以「逐课还原」为目标——不是大纲摘录,是跟着 Malan 的讲台走一遍。每一节都附带与 Python 的对照。Week 0 是 Scratch(建立计算思维),Week 1 是 C 语言入门。
下一篇:Week 2 & 3
| 关键词 | 一句话说明 |
|---|---|
| 计算思维 | 把一个大问题拆成更小的问题,直到每块都能被计算机直接执行——这是整门 CS50 的元主题 |
| 二进制 / 比特 | 计算机只认 0 和 1。一个比特就是一位(0 或 1)。8 个比特 = 1 字节,可以表示 256 种状态(0–255) |
| ASCII | 美国信息交换标准码——把字母映射到数字的一套约定。'A' = 65,'a' = 97 |
| 抽象(Abstraction) | 把复杂细节「装进一个黑盒子」,你只需要知道它做什么,不需要知道里面怎么做。这是 CS 最核心的思维方式 |
| 编译器 | 把人类可读的源代码翻译成机器可执行的 0/1 的程序。C 需要编译,Python 不需要 |
| 源代码 vs 机器码 | 你写的叫源代码(.c 文件),编译器输出的叫机器码(可执行文件)。前者人类能读,后者只有 CPU 能读 |
| make 自动化编译 | CS50 教学环境提供的命令,替代手打 gcc hello.c -o hello。输入 make hello 自动找到 hello.c 并编译 |
| CS50 IDE / Codespace | 哈佛为学生提供的云端编程环境(VS Code 网页版),预装了所有课程工具,免去本地配置的麻烦 |
| ⭐ 整型溢出 | Malan 用「里程表翻回 0」和「俄罗斯方块溢出画面」来演示——当数字超过变量能存的最大值,它会绕回最小值 |
| 浮点数精度 | 计算机存储小数有精度限制——0.1 在二进制中是一个无限循环小数,所以 0.1 + 0.2 ≠ 0.3 |
| CS50 Manual Pages | 课程提供的简化版手册页,用大白话解释每个函数怎么用,比标准的 man 手册友好得多 |
| style50 | CS50 的代码风格检查工具,运行 style50 xxx.c 自动指出缩进、空格问题 |
| check50 | CS50 的自动评分工具,运行 check50 cs50/problems/2025/x/xxx 自动测试你的作业是否通过所有用例 |
| ⭐ 二分查找 | Week 0 里 Malan 撕电话簿的最经典演示——每次砍掉一半搜索范围,从 N 步降到 log₂N 步 |
| 伪代码(Pseudocode) | 用人类语言写算法步骤,不关心语法。是「先想清楚再写代码」的中间产物 |
Week 0 不讲任何「真正的编程语言」。Malan 选择 Scratch(MIT 开发的图形化编程工具,像拼积木一样编程)作为入口,用意很深:在语法成为障碍之前,先建立计算思维。 你看完这节课会发现——循环、条件、变量、函数、事件——这些概念用积木理解起来只需要 2 小时。然后 Week 1 切换到 C 语言时,你只需要学习「怎么写」,不需要重新学「是什么」。
课程第 10 分钟左右,Malan 拿起一本巨大的黄色电话簿走上讲台——一个助教被叫上台扮演「计算机」。他让这个助教一页一页翻找「Mike Smith」。翻了几页后,Malan 说:「等一下,你比这聪明。」
然后他拿起另一本电话簿——一撕两半。把不包含 Mike Smith 的那半本丢进垃圾桶。「再撕一次。」又丢掉一半。反复几次后,手里只剩一页——目标找到了。
这个演示的核心不是算法本身,而是一句直抵本质的话:「计算机很快,但快不是偷懒的理由。你给它的指令决定了它有多聪明。」一本 1000 页的电话簿,逐页翻需要最多 1000 步。对半撕只需要最多 10 步(log₂1000 ≈ 10)。速度差 100 倍。
Malan 用灯泡做开场:一个灯泡可以亮(1)或灭(0)。如果你有一排灯泡——8 个灯泡就有 256 种组合。你可以用这些组合来表示任何东西——字母、颜色、声音。
| 比特数 | 能表示几种状态 | 实际用途 |
|---|---|---|
| 1 bit | 2(0, 1) | 真/假、是/否 |
| 8 bits = 1 byte | 256(0–255) | ASCII 编码一个英文字母 |
| 24 bits = 3 bytes | 约 1677 万 | RGB 一个像素的颜色(R=8bit, G=8bit, B=8bit) |
| 32 bits = 4 bytes | 约 42 亿 | IPv4 地址、int 型整数的范围 |
关键顿悟:信息是什么不重要,如何表示才重要。 同一串 0 和 1,可以被解释为数字、字母、颜色、音乐——取决于「上下文」。ASCII 就是一个上下文约定:全世界同意 01000001 = 'A'。
Malan 在这里抛出一个自然的问题:ASCII 只有 128 个字符(7 bits),够英语用。但中文怎么办?阿拉伯文?日语假名?更不用说 😂🔥💻 这些 emoji 了。答案是一个更庞大的编码系统——Unicode。
Unicode 不是 8 比特,也不止 16 比特。它现在有超过 14 万个字符,还在增长。但 Unicode 只是「给每个字符发一个编号」——并不规定怎么存到内存里。实际存储使用 UTF-8(可变长度编码):英文 1 字节、常见符号 2 字节、中文 3 字节、emoji 4 字节。
最经典的例子:😂(Face with Tears of Joy)在 2015 年被牛津词典评为「年度词汇」。但计算机里它只是一个数字——U+1F602。准确地说,在 UTF-8 编码下是 4 个字节:
11110000 10011111 10011000 10000010。这就是「信息的表示」——连表情都不是表情,是数字。
算法 = 一组有顺序、有终点的步骤。它不一定是代码——菜谱是算法,乐谱是算法,宜家家具组装说明书也是算法。
Malan 在黑板上的经典板书:
1. 拿起电话簿 2. 翻到中间 3. 看这一页有没有要找的名字 4. 如果有 → 打电话! 5. 如果名字在字母顺序上靠前 → 撕掉后半本,回到第 2 步 6. 如果名字在字母顺序上靠后 → 撕掉前半本,回到第 2 步 7. 如果电话簿撕完了还没找到 → 名字不在里面
这就是「伪代码」——先用人话把逻辑讲清楚,然后才翻译成编程语言。 这是一种职业习惯:任何编程问题,在你打开编辑器之前,应该能在白纸上用伪代码写出来。
这是 Week 0 里另一个让人印象深刻的场景。Malan 拿了两个杯子——一个装红色液体,一个装蓝色液体。他问学生:「怎么把两个杯子里的液体互换?」
答案是:你需要第三个空杯子。红液体倒进空杯 → 蓝液体倒进红杯 → 空杯里的红液体倒进蓝杯。
这就是编程里两个变量交换值的原理——需要一个「临时变量」。Malan 要把这个最简单的道理念叨一整门课,因为到了 Week 4 和 Week 5,你会发现链表反转、二叉树旋转全都靠这个基本操作。
// 交换两个变量的值 int x = 1; int y = 2; int temp = x; // 先把 x 倒进"空杯子" x = y; // y 倒进 x y = temp; // 空杯子里的值倒进 y // 结果:x=2, y=1
Malan 在讲台上打开 Scratch 编程环境,逐个拖拽积木,现场做了一个「让猫说 hello」然后跟鼠标跑的程序。他的教学节奏很快,但每一块积木都对应一个你之后在 C 里会看到的语法结构:
| Scratch 积木 | 它在教你什么 | C 语言对应 | Python 对应 |
|---|---|---|---|
| 「当绿旗被点击」 | 程序入口——一切从这里开始 | int main() | 脚本第一行 |
| 「说 ___ 持续 2 秒」 | 输出——让程序告诉你一些东西 | printf("...") | print("...") |
| 「询问 ___ 并等待」→「回答」 | 输入——让程序从用户那里获得信息 | scanf("%d", &x) | x = input("...") |
| 「将 x 设为 0」 | 变量——在内存里存一个值,给它起个名字 | int x = 0; | x = 0 |
| 「重复执行 10 次」 | 循环——一件事件重复做 | for (int i=0; i<10; i++) | for i in range(10): |
| 「重复执行直到 ___」 | 条件循环——满足某个条件之前一直循环 | while (x != 0) | while x != 0: |
| 「如果 ___ 那么...否则...」 | 条件分支 | if (...) {...} else {...} | if ...: else: |
| 「碰到鼠标指针?」 | 布尔表达式——判断一个条件是真还是假 | x == 5 返回 1(真)或 0(假) | x == 5 返回 True/False |
| 「定义 ___」积木 | 函数——把一段逻辑打包,起个名字,随时调用 | void greet(void) { ... } | def greet(): |
Scratch 不是「儿童玩具」——它是去掉了所有语法噪音的程序设计训练场。你看不到
;、{}、类型声明,但你学到的是真正的编程:顺序、选择、循环、抽象。这些才是编程的骨架。语法只是皮肤。
Week 0 的 Problem Set:用 Scratch 做一个原创作品——可以是游戏、动画、互动故事。要求使用至少:两个精灵(sprite)、三个脚本(script)、一个条件、一个循环、一个变量、一个自定义积木(函数)。
学生在第一周交出的是什么?Malan 会在 Week 1 开场展示几个(这是他的传统):从《塞尔达传说》同人游戏到自动生成俳句的程序。他想说的是:「你看,这就是你第一周能做的事。接下来我们把它翻译成 C。」
从 Scratch 的彩色积木,跳到一个纯黑色的终端窗口,敲下 #include <stdio.h>——Malan 知道这个跳跃很大。他的策略是:不讲任何你还没见过对应积木的东西。 你会惊喜地发现,C 里的每一行代码,都能在 Scratch 中找到对应的积木。变的只是外观,思维模型完全一样。
Malan 在终端里敲下这段代码,编译,运行——屏幕上出现 hello, world:
#include <stdio.h>
int main(void)
{
printf("hello, world\n");
}
然后他把每一行拆开,讲得极细。以下是还原他的讲解逻辑:
| 代码 | Malan 的讲解 | Scratch 等价 |
|---|---|---|
#include <stdio.h> | 我要用打印功能。C 自己不会打印——这个功能在「标准输入输出库」里。这行代码的意思是:把那个库拿过来,装到我的程序里。就像你要用 Scratch 的「外观」类积木,你得先找到那个分类。stdio = Standard Input Output。 | 打开「外观」积木分类 |
int main(void) | 程序从这里开始。就像 Scratch 的「当绿旗被点击」——它告诉计算机:别乱跑,从这里开始读。 int 是说这个程序结束时会给操作系统返回一个整数(0 表示一切正常)。void 是说 main 不需要任何输入。 | 「当绿旗被点击」 |
{ 和 } | 花括号 = 积木的边框。Scratch 里你看到积木的凸起和凹陷就知道哪块该嵌在哪块里。C 里没有凸起凹槽,所以用花括号表示「这一段代码属于谁」。缩进是为了人好看——计算机只看花括号,不看缩进。 | 积木的外形凹凸 |
printf("hello, world\n"); | 打印。往屏幕上输出文字。\n 是换行符(newline)——不写的话,光标会停在单词后面,下一行输出会黏在右边。分号 ; 是每个语句的句号。 | 「说 hello, world」 |
return 0; | 给操作系统报个平安。返回 0 = 一切正常。如果程序出错了,可以返回其他数字。 | (没有对应——Scratch 不需要管操作系统) |
Malan 最强调的一点:「你不需要理解每一行的每一个词。Week 1 你只需要'魔术词'——
int main(void)和printf()的样子。后面的课会一层层剥开。」这种「先用起来,原理后面补」的教学哲学贯穿整个 CS50。
Malan 讲编译的方式非常直观。他在终端敲 make hello(CS50 的简化编译命令,实际背后调用 clang),系统输出了一行看起来很吓人的命令:
clang -fsanitize=signed-integer-overflow -fsanitize=undefined \ -ggdb3 -O0 -std=c11 -Wall -Werror -Wextra -Wno-sign-compare \ -Wno-unused-parameter -Wno-unused-variable -Wshadow \ hello.c -lcrypt -lcs50 -lm -o hello
然后他说:「你现在不需要懂这一行。你只需要知道 make hello 是做什么的——它把 hello.c 变成 hello。」
这背后是四步流水线,Malan 在第 1 周只提概要,后面会展开:
#include 行替换成真正的库文件内容对你而言,现在记住一件事就够了:C 程序每次改了代码必须重新编译。忘了编译就跑,跑的还是旧版本——这是 C 初学者排名第一的「灵异事件」。
Malan 在这里有一段非常经典的讲述。他问学生:「你有没有想过——为什么叫 'int'?为什么不是 'integer'?」然后自己回答:「因为 1970 年代的程序员觉得打完 integer 太浪费时间。」
然后他列出了 C 语言中最常用的数据类型,每个都带上一句「这样记」:
| 类型 | 存什么 | 占几个字节 | 能存的范围 | Malan 的口诀 |
|---|---|---|---|---|
int | 整数 | 4 bytes | 约 ±21 亿(-2³¹ 到 2³¹−1) | 「用这个,除非你数的人比地球人口还多」 |
char | 单个字符 | 1 byte | −128 到 127(或 0–255) | 「记住:一个字母 = 一个数字」 |
float | 小数(约 7 位精度) | 4 bytes | ±3.4×10³⁸ | 「浮点 32 位——够用,但不精确」 |
double | 小数(约 15 位精度) | 8 bytes | ±1.7×10³⁰⁸ | 「double 就是两倍精度的 float」 |
long | 更大的整数 | 8 bytes | ±9×10¹⁸ | 「当你觉得 21 亿不够用的时候」 |
bool | 真/假 | 1 byte | true 或 false | 「需要 #include <stdbool.h>」 |
Malan 花了几分钟专门讲 printf 里的 % 符号。你的变量有不同类型,printf 需要知道你塞给它的是什么,才能正确地把它转成文字。这套「占位符语言」不长,但必须背下来:
| 占位符 | 对应类型 | 含义 | 示例 |
|---|---|---|---|
%i 或 %d | int | 有符号十进制整数 | printf("年龄:%i\n", 25); → 年龄:25 |
%c | char | 单个字符 | printf("等级:%c\n", 'A'); → 等级:A |
%f | float / double | 小数(默认 6 位) | printf("圆周率:%f\n", 3.14159); → 3.141590 |
%.2f | float / double | 小数,保留 2 位 | printf("价格:%.2f\n", 19.9); → 19.90 |
%s | string / char * | 字符串 | printf("你好,%s\n", "世界"); → 你好,世界 |
%li | long | 长整数 | printf("人口:%li\n", 8000000000L); |
%p | 指针 | 内存地址(16进制) | Week 4 会大量使用 |
%% | — | 字面量的百分号 | printf("折扣 20%%\n"); → 折扣 20% |
\n | — | 换行(不是占位符,是转义字符) | 每次 printf 结尾几乎必有 |
C 的算术和数学课本一样:
| 运算符 | 含义 | 示例 | 注意 |
|---|---|---|---|
+ | 加 | x + y | |
- | 减 | x - y | |
* | 乘 | x * y | |
/ | 除 | x / y | 整数除法会截断小数! 5 / 2 = 2,不是 2.5 |
% | 取余(模) | x % y | 只能用于整数。判断奇偶的经典手段 |
然后是一组 Malan 特别强调的「简写」——它们在 C 里出现频率极高,不看懂会懵:
| 写法 | 等价于 | 意思 |
|---|---|---|
i++ | i = i + 1 | 自增 1 |
i-- | i = i - 1 | 自减 1 |
i += 5 | i = i + 5 | 加 N |
i -= 3 | i = i - 3 | 减 N |
i *= 2 | i = i * 2 | 乘 N |
i /= 2 | i = i / 2 | 除 N(整数除法截断!) |
整数除法是 C 初学者最容易踩的坑之一。
1 / 2在数学上是 0.5,在 C 里是0。因为两个操作数都是int,结果也是int。想要 0.5?把其中一个变成浮点数:1.0 / 2或(float) 1 / 2。
紧接在数据类型之后,Malan 会抛出一个让全场沉默的实验。他在终端里写:
#include <stdio.h>
int main(void)
{
printf("%.20f\n", 0.1 + 0.2);
}
你期待的结果:0.3 或 0.30000000000000000000。实际输出:0.30000000000000004441。
这不是 bug。这是浮点数(float / double)的数学本质——计算机用二进制存小数,而 0.1 在二进制里是一个无限循环小数(就像十进制的 1/3 = 0.33333...),永远无法精确存储。你存进去的 0.1 是一个近似值,它和真正的 0.1 之间存在一个微小的误差。两个近似值相加,误差也相加。所以 0.1 + 0.2 ≠ 0.3,不是 C 的错,是所有编程语言(包括 Python)都这样的。
实用口诀:永远不要用
==比较两个浮点数。 判断两个小数是否「相等」,应该检查它们差值的绝对值是否小于一个极小的阈值(如0.000001)。这就是为什么金融系统从来不用 float 计算金额——它们用整数存「分」而不是「元」。
他在黑板上画了一个里程表——就是老式汽车仪表盘上的数字滚轮。然后问学生:「999999 + 1 会怎样?」——答案是 000000。里程表没有第 7 位数。
然后他切到终端,写下这段代码:
#include <stdio.h>
#include <unistd.h>
int main(void)
{
for (int i = 1; ; i *= 2)
{
printf("%i\n", i);
sleep(1);
}
}
程序打印 1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024……数字疯狂翻倍。大概 30 秒后——突然变成负数。 然后变成 0。
这就是整型溢出(Integer Overflow)。 当一个
int超过了它能存的最大值(约 21 亿),它会绕回到最小值(约 −21 亿)——就像里程表从 99999 翻回 00000。这不是「bug」——这就是计算机硬件的工作方式。如果你写的程序不检查边界,后果可能是灾难性的:1996 年阿丽亚娜 5 型火箭升空 40 秒后自毁——原因就是整型溢出。一个 64 位浮点数被塞进了 16 位的整数变量里。4 亿美元在空中炸了。
Malan 说条件判断是「让程序变聪明」的第一步。他用一个非常直观的流程图(在黑板上面竖线和分叉)来解释。然后对照 Scratch:
| Scratch | C | Python |
|---|---|---|
| 如果 ___ 那么 | if (x < y) { ... } | if x < y: |
| 如果 ___ 那么...否则 | if (x < y) { ... } else { ... } | if x < y: ... else: ... |
注意:C 的条件必须用小括号包起来。等于判断用两个等号 ==——一个等号是赋值,这是 C 初学者最容易犯的错。
if (x = 5) // 危险!这是赋值,不是判断。条件永远为真! if (x == 5) // 正确✅ —— 判断 x 是否等于 5
除了单个比较,Malan 还介绍了逻辑运算符——把多个条件组合起来:
| 运算符 | 含义 | 示例 | 解读 |
|---|---|---|---|
&& | 并且(AND) | if (x > 0 && x < 10) | x 在 0 到 10 之间 |
|| | 或者(OR) | if (c == 'y' || c == 'Y') | 用户输入了 y 或 Y |
! | 取反(NOT) | if (!found) | 如果 found 是 false / 0 |
注意:C 里没有 Python 的
and/or/not关键字。这里是&&/||/!。而且 C 会「短路」求值——对&&,只要左边是 false,右边根本不会执行;对||,只要左边是 true,右边就跳过了。这和 Python 一样。
Malan 把 for 循环拆成三个问题:
| 问题 | C 的答案 | 例子 |
|---|---|---|
| 从哪开始? | 第一个分号前 | int i = 0 |
| 做到什么时候? | 两个分号之间 | i < 10 |
| 每轮完了做什么? | 第二个分号后 | i++(等同于 i = i + 1) |
他演示了一个猫叫 3 次的程序(Scratch→C 对照):
// C 版——猫叫 3 声
for (int i = 0; i < 3; i++)
{
printf("meow\n");
}
然后他故意把 i < 3 写成 i <= 3,输出变成 4 声——off-by-one(差一错误)是 CS 里最常见的 bug,没有之一。
然后 Malan 在黑板上画出 while 的结构——它比 for 更简单,只保留了一个部分:
// while 版——猫叫,计数器写法
int i = 0;
while (i < 3)
{
printf("meow\n");
i++;
}
// while 版——无限循环 + 手动跳出(常见于猜数字、菜单等场景)
while (true)
{
printf("meow\n");
break; // 这里只是个演示——实际会在某个条件满足时 break
}
| for 更适合 | while 更适合 |
|---|---|
| 你知道要循环多少次的场景(「打印 10 个数字」) | 你不知道要循环多少次、在等某个条件发生的场景(「猜对了就停」) |
Malan 在第 1 周介绍 get_string()、get_int() 等 CS50 专属函数。这些函数来自 cs50.h 这个课程定制库——它不是标准 C,是教学辅助工具,用来让你在第 1 周就能做交互式程序,而不需要先学指针。
| CS50 函数(教学用) | 标准 C 等价(你之后会学) |
|---|---|
string name = get_string("What's your name? "); | char name[50]; scanf("%49s", name);(涉及数组和指针) |
int age = get_int("How old are you? "); | int age; scanf("%d", &age); |
这就是 CS50 的教学智慧:不让学生在第一周就撞上指针这堵墙。 用一层薄薄的「教学壳」包裹复杂概念,让你先建立「输入→处理→输出」的完整感觉。壳会在后面几周一层层剥开。你已经在咱们实操里直接用
scanf了——所以你比 CS50 标准路线超前了一些。
Malan 在第 1 周末尾花了 10 分钟讲代码风格。他的理由:
「代码被阅读的次数远远多于被编写的次数。你写的代码,三个星期后的你自己就是另一个读者。」
CS50 提供了两个工具:
style50 hello.c — 自动检查你的缩进、空格、花括号位置。绿色 = OK,红色 = 需要改。check50 cs50/problems/2025/x/hello — 自动跑测试用例,告诉你作业过没过。CS50 的评分系统也是教学的一部分。学生不需要等着助教反馈——写完之后立刻跑 check50,马上知道哪错了、怎么改。即时反馈是学习效果的最大杠杆。
Malan 在课上说了一句话,很多学生当场没意识到它的分量:
「C 语言没有 string 类型。我们用的
string是我帮你发明的。在 Week 4 你会看到它的真面目——一个指向字符数组头部的指针。」
这是 CS50 最巧妙的伏笔之一。学生在前三周正常使用 string,写字符串操作——然后到 Week 4 学指针时,Malan 问:「还记得你一直在用的 string 吗?」——然后掏出 char *。那一刻整个教室都是倒吸凉气的声音。
Malan 在 Week 1 末尾简单地提了类型转换。场景:你要算平均分——总分是 int,人数是 int,但平均分应该是小数。如果直接做 total / count,两个 int 相除 → 截断,小数部分被吃了。解决方法是「强制类型转换」:
int total = 85; int count = 3; float average = (float) total / (float) count; // average = 28.333334 ✅ 不是 28.0
语法:(目标类型) 变量名——注意括号贴紧类型名,不要跟函数调用搞混。常见的:
| 写法 | 效果 |
|---|---|
(float) x | 把整数 x 临时当成小数用 |
(int) 3.14 | 砍掉小数部分 → 3(注意不是四舍五入!) |
(char) 65 | 把数字 65 转成字符 → 'A'(对照 ASCII 表) |
Malan 的原话:「类型转换不会修改变量本身——它只是临时换了个马甲。就像你穿着睡衣去拿快递,快递员不在乎你是谁。但你自始至终还是你自己。」
argc/argv)、调试技巧malloc/free、内存泄漏、valgrindWeek 2–5 是 C 语言核心区,也是 CS50 最难但最有收获的部分。Malan 会在这一段使出浑身解数——每一个数据结构他都有真人演示。