
视频元信息
- 视频标题:哈佛大学【中英⚡计算机科学导论(2026)|CS50x Introduction to Computer Science】 p04 P5 Lecture 3 - Algorithms
- 时长:约 1 小时 50 分(本片段为 Lecture 3 完整内容)
内容摘要(TL;DR)
本讲围绕算法设计与效率分析展开:通过课堂人数统计、储物柜搜索等实例引入线性搜索与二分搜索,正式讲解大O、Ω、Θ渐近符号;随后介绍选择排序、冒泡排序与归并排序,并用递归实现马里奥金字塔;最后通过可视化对比各算法运行时间,强调算法设计对效率的决定性影响。
一、开场:课堂人数统计算法
[时间戳 134s-512s]
1.1 三种计数算法的实际演示
-
教授在课堂上让学生们用不同算法统计教室人数:
- 算法一:逐个计数(Kelly 手动一个个数)→ 线性时间
- 算法二:两人一组配对相加 → 仍然线性,但速度约为两倍
- 算法三:分治配对——每人先持有数字1,然后一半人把自己的数字交给另一半人,如此反复,最终汇聚到一个人手中 → 对数时间
-
实际执行结果:分治算法得到 141 人,Kelly 手工数得约 160 人。
- 理解:分治算法理论上更快,但实际执行中因为“人肉算术”出错(mental math bugs)导致结果偏差,这正好说明算法的正确性(correctness)与执行正确性是两回事。
- 易错点:算法理论上正确,但执行过程中可能因人为失误产生错误结果,不能因此否定算法本身。
1.2 算法效率的直观分析
- 逐个计数:n 步,画成直线(斜率1)
- 两个两个数:约 n/2 步,仍是直线,但严格更快
- 分治配对:约 log₂n 步,人数翻倍只需多一步,人数变为四倍只需多两步
- 推导:如果教室人数翻倍,分治算法只需额外1轮配对;人数变为4倍,只需额外2轮。这就是对数增长的威力。
- 核心结论:算法设计越巧妙,成本(时间/空间/金钱)增长越慢。
二、数组与计算机的“单门访问”模型
[时间戳 674s-763s]
2.1 数组回顾
- 数组是计算机中最简单的数据结构:内存中一块连续的区域,值一个接一个存放(back to back)。
- 上一周已经介绍了数组的基本概念,本周将在此基础上讨论算法。
2.2 计算机不能“鸟瞰”数组
- 人眼可以一眼看到整个屏幕,立刻找到数字50在哪里;但计算机不能这样。
- 计算机每次只能访问一个内存位置——相当于走到一扇关着的门前,打开门看里面的值,然后关门,再去下一扇门。
- 思考:这是理解算法复杂度的关键模型。即使人觉得“一眼就看到”,算法必须一步一步访问,因此步骤数才成为衡量标准。
2.3 零索引(Zero-indexing)
- 数组位置从 0 开始编号:locker 0, locker 1, ..., locker 6。
- 易错点:听到“location 6”意味着至少有7个元素,因为从0开始计数。 这是C语言等编程语言的通用约定。
三、线性搜索与二分搜索的实物演示
[时间戳 724s-1120s 左右]
3.1 储物柜实验
- 舞台上放置7个储物柜,里面是不同面值的 monopoly 钞票,目标是找到50美元钞票。
- 志愿者 Jose:从左到右依次打开每个柜子,逐个检查。
- 他的算法:走到第一个门→打开→检查是否是目标→关上门→去下一个。
- 结果:找了很久才找到50(50在最后一个柜子)。
- 他自己也承认:如果从右端开始可能运气好一步找到,但一般情况不知道数据分布,线性扫描是最好的选择。
- 随机跳跃查找:可能运气好,但需要额外记忆哪些门已经打开,浪费空间。
- 志愿者 Caitlin:Kelly 事先把钞票按面值从小到大排序。
- 她的策略:从中间开始。
- 第一次打开中间柜子:20(小于50)→ 50一定在右边。
- 再打开右侧三个柜子的中间:100(大于50)→ 50一定在左边。
- 再打开左侧中间:50,找到。
- 理解:虽然演示中 Caitlin 因“已知面值范围”有直觉,但通用二分搜索确实只需 log₂n 次比较。
3.2 形式化伪代码
线性搜索(Linear Search):
for each door from left to right:
if 50 is behind door:
return true
return false
- 易错点:不能写成 if-else 结构——如果在第一个门没找到就 return false,会漏掉后面所有门。正确做法是循环结束后再 return false。
- 计算机科学记号(伪代码风格):
for i from 0 to n-1: if 50 is behind doors[i]: return true return false- 注意 n-1 而不是 n:因为从0开始计数,n个元素的下标是0到n-1。
二分搜索(Binary Search):
if no doors left:
return false
if 50 is behind middle door:
return true
else if 50 < middle door value:
search left half
else if 50 > middle door value:
search right half
- 关键细节:
- 搜索左半部分时,范围是
doors[0]到doors[middle-1],而不是doors[middle]——因为中间门已经检查过。 - 搜索右半部分时,范围是
doors[middle+1]到doors[n-1]。 - 需要先检查“没有门可搜”的边界情况(base case),否则会无限递归。
- 搜索左半部分时,范围是
- 中间下标计算:n=7 时,7/2=3.5,向下取整为3,即 doors[3] 是中间元素。
3.3 为什么二分搜索要求数据有序
- 易错点:在未排序数组上使用二分搜索是错误用法——因为比较大小后决定向左/向右,但数据无序时,左右两边没有大小规律,会做出错误决策,很可能直接错过目标。
- 思考:排序是二分搜索的前提,但排序本身也有成本,是否值得排序取决于搜索次数。
四、渐近符号:Big O, Ω, Θ
[时间戳 1120s-1400s 左右]
4.1 运行时间的图形分析
- 三种计数算法画成图:
- 线性:n 步 → 直线
- 每次两个:n/2 步 → 直线但更陡(更快)
- 分治:log₂n 步 → 曲线增长极慢
- 计算机科学家不关心精确步骤数,只关心“量级”(order of),因此使用 Big O 符号。
4.2 Big O(大O):上界
- Big O 描述算法在最坏情况下(或作为上界)步骤数随 n 增长的量级。
- 常见 Big O 运行时间(由慢到快):
- O(n²) —— 平方级
- O(n log n) —— n 乘以 log n
- O(n) —— 线性
- O(log n) —— 对数
- O(1) —— 常数
- 规则:忽略低阶项,只关注主导项(dominant term)。
- 例如 O(n/2) 写作 O(n),因为常数因子不影响增长趋势。
- O(log₂n) 写作 O(log n),底数不重要。
- 线性搜索:O(n)(最坏情况目标在最后)
- 二分搜索:O(log n)(每次排除一半)
4.3 Omega(Ω):下界
- Omega 描述算法在最好情况下(或作为下界)至少需要多少步。
- 线性搜索:Ω(1)——运气好第一个门就是目标。
- 二分搜索:Ω(1)——运气好目标正好在中间。
- 理解:上下界共同给出了算法性能的可能范围。
4.4 Theta(Θ):紧界
- Theta 当 Big O 和 Omega 相同时使用,表示算法运行时间被精确界定。
- 目前讨论的线性搜索、二分搜索都不能用 Θ,因为它们的上下界不同。
4.5 渐近符号的意义
- 推导:当 n 很大时,O(n²) 与 O(n log n) 的差距巨大。例如 n=10⁹,n²=10¹⁸,n log n≈3×10¹⁰,相差数亿倍。
- 设计决策:如果只搜索一次数据,排序再二分可能不如直接线性搜索;如果反复搜索(如 Google),排序成本可以摊还(amortize)。
五、C语言实现:线性搜索与字符串比较
[时间戳 1400s-1900s 左右]
5.1 整数数组的线性搜索
- 在 VS Code 中编写
search.c:- 初始化数组
int numbers[] = {20, 5, 100, 10, 5, 1, 50};(用花括号静态初始化,编译器自动计数长度) - 用
get_int获取用户要查找的数字 - 用 for 循环遍历数组,
if (numbers[i] == n)则打印 found 并return 0(成功退出码) - 循环结束后打印 not found 并
return 1(失败退出码)
- 初始化数组
- 易错点:main 函数返回 int 作为退出状态,0 表示成功,非0表示出错。
5.2 字符串比较的陷阱
- 将代码改为搜索字符串数组(battleship, boot, cannon, iron, thimble, top hat)时,直接用
==比较字符串会失败,即使字符串明明在数组中。 - 原因:字符串不是单个值,而是多个字符的序列。
==比较的是字符串的地址(指针),而不是内容。 - 解决方法:使用
strcmp函数(来自string.h):if (strcmp(strings[i], s) == 0) { ... } - 易错点:
strcmp返回 0 表示两个字符串相等,而不是返回 true/false。它返回负数/正数表示字典序(实际上是 ASCII 序)的前后关系,这为将来排序提供了便利。 - 易错点:使用
strcmp必须#include <string.h>,否则编译器报错“call to undeclared library function”。
5.3 电话簿示例:从平行数组到结构体
- 最初用两个平行数组:
names[]和numbers[],通过相同下标关联。- 缺点:依赖“君子协定”保证两个数组长度一致、顺序对应,容易出错。
- 思考:如果数组有30个人,手动维护两个数组的对应关系很容易出错,这是“信任自己/同事”的坏味道。
- 改进:使用
typedef struct自定义数据类型:typedef struct { string name; string number; } person;- 然后创建
person people[3];,用点号访问字段:people[0].name = "Kelly"; people[0].number = "+1-617-495-1000"; - 这样把相关数据“封装”在一起,避免平行数组的对齐问题。
- 然后创建
- 为什么电话号码用字符串而不用整数:
- 电话号码包含
+、-、括号等非数字字符。 - 如果号码有前导零(如某些国家的区号),整数会丢失前导零。
- 理解:凡是可能包含非数字字符或需要保留前导零的“编号”,都应该用字符串存储。
- 电话号码包含
六、排序算法:选择排序与冒泡排序
[时间戳 1900s-2600s 左右]
6.1 排序问题的定义
- 输入:未排序的数据(如 7 2 5 4 1 6 0 3)
- 输出:排序后的数据(如 0 1 2 3 4 5 6 7)
- 课堂上用8名志愿者手持数字牌模拟排序。
6.2 选择排序(Selection Sort)
- 算法思想:每次从剩余元素中选出最小元素,放到正确位置。
- 伪代码:
for i from 0 to n-1: find smallest number between numbers[i] and numbers[n-1] swap it with numbers[i] - 演示过程:
- 第一轮:扫描全部8个数字,记住最小值0,与位置0的7交换。
- 第二轮:扫描剩下7个数字,记住最小值1,与位置1的2交换。
- 继续直到全部排好。
- 分析运行时间:
- 第一轮比较 n-1 次,第二轮 n-2 次,...,最后一轮1次。
- 总比较次数 = (n-1) + (n-2) + ... + 1 = n(n-1)/2
- 展开 = n²/2 - n/2 → 忽略低阶项 → O(n²)
- 即使数据已经有序,选择排序仍然要执行相同的比较次数,所以 Ω(n²),因此 Θ(n²)。
- 结论:选择排序无论数据是否有序,运行时间都是 Θ(n²),非常慢。
6.3 冒泡排序(Bubble Sort)
- 算法思想:反复比较相邻元素,如果顺序错误就交换,每一轮把当前最大元素“冒泡”到末尾。
- 伪代码:
repeat n-1 times: for i from 0 to n-2: if numbers[i] and numbers[i+1] are out of order: swap them - 易错点:内层循环到 n-2 而不是 n-1,因为要访问 i+1,防止数组越界。
- 演示过程:7与2交换,7与5交换,7与4交换... 7一路“冒泡”到最右端;然后6冒泡到倒数第二,依此类推。
- 分析运行时间:
- 外层循环 n-1 次,内层循环 n-1 次 → 总步骤约 (n-1)² = n² - 2n + 1 → O(n²)
- 优化:如果在某一轮遍历中没有发生任何交换,说明列表已经有序,可以提前终止。
- 加入优化后,最好情况(数据已有序)只需遍历一次,做 n-1 次比较 → Ω(n)
- 由于 O(n²) 和 Ω(n) 不同,不能使用 Θ。
- 理解:冒泡排序的优化只在“基本有序”时有效,平均/最坏仍是 O(n²)。
6.4 可视化对比
- 使用动画演示选择排序和冒泡排序:紫色竖条高度代表数值大小。
- 选择排序:每轮找最小,逐步固定左侧,但右侧元素要等很久。
- 冒泡排序:相邻比较交换,最大元素逐步“冒泡”到右侧。
- 两者在几十个元素时已经明显耗时,n 很大时更是不可接受。
- 结论:需要更快的排序算法。
七、递归(Recursion)
[时间戳 2600s-3200s 左右]
7.1 递归的定义
- 递归函数:一个函数在定义中调用自身(数学上:f(x) 的定义依赖于 f(某表达式))。
- 递归最容易引发的问题是无限循环——函数不断调用自己,永不停止。
- 解决方案:必须有一个基准情形(base case),直接给出答案,不再递归。
7.2 二分搜索中的递归
- 回顾二分搜索伪代码中的“search left half”和“search right half”——这就是递归调用。
- 每次递归调用处理更小的问题(一半的数据),因此最终会到达基准情形(没有门可搜)。
- 理解:递归不是简单地重复同样的问题,而是把问题规模不断缩小,直到可以直接解决。
7.3 递归定义马里奥金字塔
- 高度为4的金字塔 = 高度为3的金字塔 + 一行4个砖块。
- 高度为3的金字塔 = 高度为2的金字塔 + 一行3个砖块。
- 高度为2的金字塔 = 高度为1的金字塔 + 一行2个砖块。
- 高度为1的金字塔 = 一个砖块(基准情形)。
7.4 迭代实现 vs 递归实现
-
迭代版本(
iteration.c):void draw(int n) { for (int i = 0; i < n; i++) { for (int j = 0; j < i + 1; j++) { printf("#"); } printf("\n"); } }- 外层循环控制行,内层循环控制每行的砖块数(i+1 是因为第0行要1个砖块)。
-
递归版本(
recursion.c):void draw(int n) { if (n <= 0) return; draw(n - 1); for (int i = 0; i < n; i++) printf("#"); printf("\n"); }- 先画高度 n-1 的金字塔,再画最后一行 n 个砖块。
- 必须加上
if (n <= 0) return;基准情形,否则 clang 会报错“all paths through this function will call itself”,拒绝编译。 - 使用
<= 0而不是== 0是为了处理负数输入(防御性编程)。
7.5 递归的代价:栈溢出
- 演示中输入非常大的高度(如 100000),程序崩溃,出现
segmentation fault或类似错误。 - 原因:每次调用
draw都会占用一定的内存(栈帧),递归调用太深会耗尽计算机内存。 - 迭代版本不会出现此问题,因为它不额外占用调用栈。
- 思考:递归代码更优雅、更简洁,但牺牲了内存;这是“时间/空间/优雅”之间的权衡。
八、归并排序(Merge Sort)
[时间戳 3200s-3800s 左右]
8.1 归并排序的伪代码
sort left half of numbers
sort right half of numbers
merge sorted halves
- 基准情形:如果只剩一个元素,它已经有序,直接返回。
- 关键在于“合并”(merge)两个已排序的数组。
8.2 合并过程演示
- 假设左半 [1,3,4,6] 和右半 [0,2,5,7] 都已排序。
- 用两个指针分别指向左半开头和右半开头,比较两者,把较小的放到结果数组顶部。
- 每一步只移动一个指针,每个元素只被比较一次。
- 合并 n 个元素需要 n 步。
8.3 归并排序的完整过程
- 对 [6,3,4,1,5,2,7,0] 排序:
- 排序左半 [6,3,4,1]:
- 排序左半 [6,3]:排序左半 [6](基准),排序右半 [3],合并 → [3,6]
- 排序右半 [4,1]:排序左半 [4],排序右半 [1],合并 → [1,4]
- 合并 [3,6] 和 [1,4] → [1,3,4,6]
- 排序右半 [5,2,7,0]:类似 → [0,2,5,7]
- 合并 [1,3,4,6] 和 [0,2,5,7] → [0,1,2,3,4,5,6,7]
- 排序左半 [6,3,4,1]:
- 整个过程只进行了约 log₂n 层“分治”,每层合并总共 n 步。
8.4 归并排序的运行时间
- 把大小为8的列表排序:分成3层(log₂8=3),每层合并8个元素 → 总步骤约 n log n。
- Big O:O(n log n)
- Omega:Ω(n log n)(没有类似冒泡排序的提前终止优化)
- 因此 Theta:Θ(n log n)
- 推导:n=8 时 log₂8=3;n=1024 时 log₂1024=10;n 越大,n log n 比 n² 的优势越明显。
- 注意:归并排序需要额外的内存空间来存放合并结果(演示中用了第二个架子),这是它的代价。
8.5 可视化对比
- 在动画中点击 merge sort,可以看到明显的速度提升:它不像选择/冒泡排序那样反复比较同一批元素,而是分而治之,每层只做少量工作。
- 最终演示:同时运行选择排序、冒泡排序、归并排序,归并排序最先完成,凸显 O(n log n) 与 O(n²) 的实际差距。
九、总结与关键结论
- 算法的正确性和设计同样重要。
- 渐近符号(Big O / Ω / Θ)是描述算法效率的标准语言。
- 线性搜索 O(n)、二分搜索 O(log n)(需有序数据)。
- 选择排序 Θ(n²)、冒泡排序 O(n²)/Ω(n)、归并排序 Θ(n log n)。
- 递归是一种强大的问题解决技术,但必须注意基准情形和内存开销。
- 设计决策:根据数据规模、搜索/排序频率、可用内存等权衡不同算法。
关键标签/关键词
算法 Algorithms 线性搜索 二分搜索 Big O Omega Theta 渐近符号 选择排序 冒泡排序 归并排序 递归 数组 字符串比较 strcmp 结构体 typedef struct 运行时间 分治 CS50 Lecture 3
生成时间:2026-09-02 17:14:31 生成平台:VideoNote