Algorithm Design · Lecture 1-2

如何度量时间复杂度?How to Measure Time Complexity?

上一课我们确认了 LinearSearch 是对的。现在轮到第二问:它有多快? 你会发现"拿秒表跑一遍"这个直觉,竟然处处是坑——而绕开这些坑,正好逼出 T(n) 这个概念。

Ke Chen · May 19, 2026 承接 Lecture 1-1 可交互
向下滚动开始 · scroll to begin
01

天真的想法:"跑一遍不就知道了?" / Let's run it!

最自然的念头:用某种语言实现它,在机器上跑,拿秒表 (stopwatch) 掐时间。听起来无懈可击——直到你认真想想。

这个想法有三个致命问题。整节课其实就是逐个拆掉它们,每拆一个,就往"时间复杂度"这个概念上补一块拼图。

ISSUE 01

时间取决于输入规模

在规模 10 的数组里查找 vs 在规模一百万的数组里查找,耗时天差地别。

修法 → 用 T(n) 把时间写成规模 n 的函数
ISSUE 02

同规模,不同输入也不同

同样一百万个元素,key 恰好在第一个被检查的位置 vs key 根本不存在——差距巨大。

修法 → 分情况讨论(最坏 / 平均 / …)
ISSUE 03

随实现/硬件波动

换语言、换机器、甚至同机器跑两次,秒表读数都不一样。根本问题。

修法 → 放弃真实时间,改测 可扩展性
02

问题一:时间取决于输入规模 / Input size

这个最好办。既然时间随规模变,那就别指望用一个数字来描述它。

解法 · The fix 时间复杂度不是一个数,而是一个关于输入规模 n 的函数,记作 T(n)。规模翻倍,你去问 T(2n) 是多少,而不是"它到底几毫秒"。

输入规模 n 指什么,取决于问题:数组的长度、图的顶点数、字符串的字符数…… 对 LinearSearch 来说,就是数组的元素个数 n。

03

问题二:同样规模,时间也能差很远 / Case analysis

同一个规模为 n 的输入,喂给同一个算法,耗时可以从"1 次比较"到"n 次比较"。先亲眼看看这个落差。

互动实验室 · 同规模,不同命运

下面是一个规模固定 = 12 的数组,LinearSearch 从末尾往前扫。选一种输入,看看要比较几次:

本次比较次数 COMPARISONS
选一个输入
返回值 RESULT
最好 best
1 次
最坏 worst
n = 12 次

同一个 n = 12,最好只要 1 次、最坏要 12 次。规模一样,命运不同——所以只报"一个时间"是不负责任的。

解法:分情况讨论 (case analysis)。 常见有四种,各有各的用途:

最常用 · usually

最坏情况 Worst-case

T(n) = 规模 n 的所有输入里,耗时最长的那个

给出一个上界保证 (upper bound):"无论你喂什么,我保证 1 分钟内出结果。" 这种承诺最有价值,所以本课默认用它。

▸ 本课主力
偶尔 · sometimes

平均情况 Average-case

T(n) = 所有规模 n 输入上的期望耗时

更贴近实际,但要假设输入的分布 (distribution)——哪种输入更常出现。分布一变,答案就变,难算。

▸ 本课偶尔一见
偶尔 · sometimes

摊还分析 Amortized

T(m) = m 次调用的耗时 ÷ m

某个操作大多数时候很便宜、偶尔很贵,那把总账摊到每次头上。经典例子是动态数组——下面就有得玩。

▸ 本课偶尔一见
几乎不用 · almost never

最好情况 Best-case

T(n) = 规模 n 输入里,耗时最短的那个

"我最好情况 1 秒完成!"——但最坏可能要一年。信息量极低,基本等于作弊 (cheating),现实里几乎不用。

▸ 本课几乎不用
互动实验室 · 摊还分析:向动态数组里插入

动态数组 (dynamic array) 是一块预分配的连续内存。空间够时,插入只要放进去 = 1 次开销;一旦装满,就得申请两倍大的新数组、把旧元素全搬过去——很贵。不停点"插入",盯着底部的"摊还开销":

↑ 每根柱 = 一次插入的开销。红色高柱 = 触发了扩容+搬移的"贵操作"。
插入次数 INSERTS0
本次开销 THIS
累计开销 TOTAL0
摊还开销 AMORTIZED = TOTAL/INSERTS

虽然偶尔冒出一根红色高柱,但因为每次都翻倍,贵操作越来越稀疏。把总开销摊平,每次插入趋近一个常数(≈3)——所以我们说动态数组插入的摊还复杂度是 O(1)

04

问题三:随硬件/实现波动 / The real problem

这才是秒表大法的根本死穴。同一段逻辑,换 C 还是 Python、换你的笔记本还是超算、甚至同机器跑两次——数字都不一样,永远得不到一个"准确值"

解法 · The fix —— 干脆放弃 我们不再追求真实时间的具体数字,转而衡量算法的可扩展性 (scalability):输入越来越大时,它撑得住吗?为此引入一个抽象计算模型 (abstract computation model)——
  • 单处理器、顺序执行 (single processor, sequential)
  • 所有基本操作 (elementary operations) 都算作常数时间:加减乘除、赋值、分支判断 (if/while)、子程序调用……

在这个模型里,我们不问"一次加法几纳秒",只问"输入翻倍,操作次数怎么变"。

互动 · 同一算法,三台机器

下面三条线是同一个线性算法在三台机器上的耗时。切换机器,绝对数字变了,但都是直线——我们关心的正是这个"形状",而非某台机器上的具体值。

机器 A(快) 机器 B(中) 机器 C(慢) 全部叠加
横轴:输入规模 n 纵轴:耗时。三条斜率不同,但同为线性 (linear) → 同一个"可扩展性档次"。
⚠ 前提 · The fine print "基本操作 = 常数时间"有个隐含假设:操作数要能塞进一个寄存器 (register),大小与 n 无关。比如 64-bit 整数,一次算术就是常数时间。但如果整数大到超过寄存器(要用好几个寄存器拼),就得按位数分块计算了——那是教材第一章的内容,本课统一假设"操作数都足够小,一个 64-bit 寄存器装得下"。
05

小结:T(n) 到底是什么 / In summary

三个坑填完,"时间复杂度"这个概念也就成形了。

  • 是一个函数:关于输入规模 n 的函数 T(n)。(填了坑一)
  • 取最坏情况:通常作为上界保证 (upper bound)。(填了坑二)
  • 量的是可扩展性,不是速度:不是"几毫秒",而是"n 变大时怎么长"。(填了坑三)
一句话 时间复杂度 T(n) = 最坏情况下,操作次数如何随输入规模 n 增长。它不告诉你"多快",而告诉你"多能扛"。
06

实战推导:LinearSearch 的 T(n) / Cost × Rounds

这套"逐行算账"的方法,我们全课只认真做这一次——目的是让你彻底看清 T(n) 是怎么被"数"出来的。方法:给每行代码配两列——Cost(执行一次的开销)和 Rounds(整个过程里跑几次)。两者相乘再全部相加,就是总开销。

伪代码 PseudocodeCostRounds
(最坏)
1LinearSearch(A[1..n], k)
2 i n
3 while i ≥ 1 do
4 if A[i] == k then
5 return i
6 i i − 1
7 return1

为什么 Cost 用 c1、c2… 而不是具体数字? 讲义里第一行本来写的是"3 微秒"。可 3µs 是瞎编的——我们根本不知道一次赋值真正要多久(还记得坑三吗?它取决于机器)。既然拿不到普适的准确值,不如直接抽象成一个未知常数 c1:我们只知道它是常数,不知道也不关心它具体几何。

推导的骨架 只执行一次的行(赋值 i←n、return)贡献常数项;在最坏情况下要跑 n 次的行(while 判断、if 判断、i←i−1)贡献含 n 的项。加起来再把一堆不认识的常数打包,就得到最终形态。
07

随堂小测 / Quick Check

无限次尝试,只为检验理解。

Q1. 为什么时间复杂度写成 T(n) 而不是一个具体数字?
Q2. 本课默认采用哪种情况分析?为什么?
Q3. 抽象计算模型里,下面哪一项成立?
Q4. LinearSearch 的最坏情况时间复杂度 T(n) 化简后是?
Q5. 动态数组的插入,为什么摊还 (amortized) 是常数 O(1)?