Python 编程辅导课 · 逐字稿

课题:问题分解实战 · 异常与错误处理 · 算法复杂度 时长:60 分钟 配套课件:Python辅导课_问题分解_异常处理_算法复杂度.ipynb

符号说明 - 🗣 = 老师直接念的话 - 🖥 = 操作提示(运行哪个单元格、看哪里) - 💬 = 向学生提问(后面附参考答案和追问) - ⏩ = 时间紧张时可以砍掉的部分


总时间表

时间 内容 要点
0:00–0:02 开场 地图、图例
0:02–0:17 模块 1 马尔可夫 分解 → 手算 → 测试先行 → 缺陷 → 练习 1(约 0:14–0:17)
0:17–0:37 模块 2 异常 三类错误 → 异常类型 → raise/assert → try → 练习 2(约 0:30–0:33)→ 准则
0:37–0:57 模块 3 复杂度 数步数 → Big-O → 练习 3(约 0:45)→ 搜索对比 → 实战 → 练习 4(约 0:55)
0:57–1:00 小结与作业 三个模块的一条线

如果落后了怎么砍(按优先级从先砍到后砍): 1. ⏩ 3.5 的「list vs set」小实验(省 1 分钟) 2. ⏩ 2.5 的 Pandas coerce 对比(省 1 分钟) 3. ⏩ 1.5 缺陷表只念前两行(省 1 分钟) 4. ⏩ 3.3 的画图单元格不运行,只念表格(省 1 分钟)


开场(0:00–0:02)

🖥 展示 notebook 顶部,运行「课前准备」单元格。

🗣 大家好。今天一小时,我们讲三件事,但你会发现它们其实是一条线上的三个点。

🗣 第一件事:拿到一个大任务,怎么拆成小函数,怎么先想测试再写代码。我们用一个很有意思的例子——让电脑模仿一段文字的风格,这是大语言模型思想上的”祖先”。

🗣 第二件事:你拆出来的函数,遇到坏输入怎么办?空数据、错的列名、除以零。这就是异常处理。

🗣 第三件事:函数是对的,数据变成一百万行以后,它还跑得动吗?这就是算法复杂度。

🗣 看一下图例:💬 是思考题,我会先停三十秒让你想;📝 是练习,请你们现场动手写,写完运行下面的检查格,出现绿色对勾就算过。好,开始。


模块 1:问题分解实战(0:02–0:17,15 分钟)

1.1 计算思维(0:02–0:04)

🖥 滚动到 1.1 的五步表格。

🗣 先不写代码。拿到大问题,我们按五步走:分解、找重复模式、抽象、设计算法、评估。我对着今天的任务过一遍。

🗣 分解:我们把”仿写文章”拆成两件事——“训练”,也就是读文本建一张表;“生成”,也就是查表随机选词。模式识别:读到每个词,我们都在做同一件事,记下”我后面跟了谁”。抽象:把这个模式变成一个字典,词对应后面出现过的词的列表。算法设计:扫一遍文本建表,生成时反复查表加随机。最后是评估:怎么知道对不对?这是今天的重点,一会儿细讲。

🗣 数据科学里你每天做的”读取、清洗、特征、建模”,其实也是同样的套路,每一步都应该是个可以单独测试的函数。

💬 思考 1:如果我一上来写一个大函数,读文件直接输出仿写文章,结果很奇怪,我怎么知道是哪一步错了?

🗣 停 20 秒。 参考答案:没法知道。读文件、建表、采样三件事缠在一起,任何一步错都表现为”输出奇怪”。拆开以后,每一步都有自己的输入输出,可以单独验证。 追问:那”拆”的标准是什么?→ 每个函数能用一句话说清它做什么、能单独写测试。

1.2 马尔可夫思想 + 手算(0:04–0:07)

🖥 滚动到 1.2 的概率表。

🗣 马尔可夫性质一句话:下一步只取决于当前状态。放到文本里就是:下一个词只看当前这个词。

🗣 比如在某首歌里,“hello.” 后面依次出现了 I、I、Why、I、You、You。I 出现 3 次,Why 1 次,You 2 次,概率就是六分之三、六分之一、六分之二。

🗣 注意一个关键设计:我们不存概率。我们存一个带重复的列表,I、I、Why、I、You、You。随机从列表里挑一个,I 被选中的概率自然就是六分之三——重复本身就是权重。这样代码特别简单。

🗣 还有一个约定:文本开头之前的”上下文”是空字符串。所以空字符串这个 key 对应的是文章的第一个词。

💬 思考 2(请同学拿出纸笔,1 分钟):下面这段话,like、see、my 对应的列表是什么?My 和 my 是同一个 key 吗?me. 有没有 key?

🖥 停下来,等 1 分钟,然后运行 expected_demo 单元格对答案。

参考答案:like → ['my','to'];see → ['my','me.'];my → ['cat.','dog.']。 My 和 my 不是同一个 key(大小写敏感)。me. 没有 key,因为它是最后一个词,后面没有词。 顺带引出:标点也粘在词上了(cat.),这是我们这个版本的简化,后面讲缺陷时会提。

🗣 这一步为什么重要?因为如果你自己都不能手算出正确答案,你就不可能写出代码,更不可能测试它。

1.3 测试先行:make_dictionary(0:07–0:10)

🖥 滚动到 1.3 的测试表。

🗣 现在我要问一个问题:怎么测这个函数?有人会说,拿一个大文件跑一下,看着像不像。这不行,因为你没有标准答案,看不出对错。正确的做法是造小而全的测试:重复词、手算的综合样例、空文本、单个词,还有一个很容易漏的——跨行,上下文要在换行之后延续。

🖥 运行红灯单元格(假实现 lambda f: {})。

🗣 看,这叫红灯。测试先写好,拿一个假实现跑,大部分都是红的——但注意 T3 空文本是绿的,因为假实现恰好返回空字典。这提醒我们:一个测试通过,不代表代码对,只代表它没被这个测试抓住。

🖥 运行 associate_pair + make_dictionary 单元格。

🗣 现在写真实现。我先拆出一个小函数 associate_pair:往字典的列表里追加,key 不存在就先建一个。再写 make_dictionary:context 从空字符串开始,一行一行读,一个词一个词处理,每处理一个词,它就变成下一轮的上下文。

🗣 注意 for line in f:文件对象可以直接遍历,比 while 加 readline 简洁。我们用 io.StringIO 模拟一个打开的文件,这样不需要外部 txt。

🖥 绿灯,全部 ✅。

💬 思考 3:为什么 context = '' 必须写在 for line 外面,而不是每行重置?

参考答案:因为上一行最后一个词,应该连接到下一行第一个词。T5 测的就是这个。如果每行重置,就会多出很多错误的 '' 起点。

1.4 mimic_text(0:10–0:14)

🗣 生成的算法:上下文从空字符串开始,查表,随机选一个后继,追加到故事里,它成为新的上下文,重复。我先写最直接的”教科书版”。

🖥 运行 mimic_text_v1 单元格。

🗣 看到了吗,崩了。NoneType has no len。

💬 思考 4:为什么崩?走到哪个词出的事?

参考答案:走到 me.——文章最后一个词,字典里没有它的 key,dict.get 返回 None,len(None) 报错。 追问:为什么 get 不报 KeyError?→ 因为 get 的设计就是找不到返回 None(或默认值)。这正是”悄悄的问题”:它没有立刻崩,而是把 None 传到了后面才崩。这就是模块 2 要解决的问题。

🗣 先用最简单的办法修:走到死胡同,就从头再来。同时我顺手做两件事。第一,加一个 rng 参数,可以传固定种子的随机数生成器,这样随机程序可以复现、可以测试。第二,字典为空时,主动报错——raise ValueError,这个关键字我们下个模块详细讲。

🖥 运行 mimic_text 单元格,再运行测试单元格。

🗣 怎么测一个带随机的函数?有两招。第一招:构造只有唯一路径的输入,“I like cats.”,输出就可预测。第二招:检查性质——不管怎么随机,每个词都必须是上一个词的合法后继。我们跑 200 个不同的种子,全部合法。

🗣 T4 我特别说一下:测试检查器本身。我拿一段不可能生成的文本,开头是 My,检查器必须说”非法”。因为字典里空字符串只对应 I。这是一个小技巧:别让检查器自己也出 bug。

1.5 缺陷与改进 + 练习 1(0:14–0:17)

🗣 快速过一下缺陷:第一,开头永远是第一个词,可以随机选 key;第二,cat. 和 cat、My 和 my 被当成不同的词,需要清洗,数据科学里叫分词和归一化;第三,只看一个词,句子不通顺,可以把上下文扩成多个词,用 tuple 做 key,但太长会变成照抄原文;第四,没有句子结尾。

🗣 最后说一句和大模型的关系:它们的核心思想都是”根据前文预测下一个词”。大模型用神经网络,看的上下文长得多,但这个小程序是它思想上的前身。

📝 练习 1:词频统计(约 2 分钟)

🗣 现在请你们动手。写 count_words:读入文件对象,返回每个词出现了几次。思路和 make_dictionary 几乎一样,只是把”追加”换成”加一”。2 分钟。

🖥 巡视;2 分钟后让一位同学念答案。

参考答案:

counts = {}
for line in f:
    for word in line.split():
        counts[word] = counts.get(word, 0) + 1
return counts

常见错误:忘了 get 的默认值 0 → 第一次出现时 KeyError。(正好预告了模块 2。)


模块 2:异常与错误处理(0:17–0:37,20 分钟)

2.1 三类错误(0:17–0:20)

🗣 刚才 mimic_text_v1 崩了。崩溃是坏事吗?今天我要说服你:崩溃是好事。

🖥 展示 2.1 表格。

🗣 错误有三类。语法错误:程序还没跑就被 Python 拒绝,比如少了冒号,一眼看出。运行时错误:跑到一半崩,比如除以零,会打印出清楚的信息。逻辑错误:程序跑完了,没报任何错,但答案是错的。

🖥 运行 2.1 第一个代码单元格。

🗣 看第三个例子。50 分及格,我写成了大于 50,结果 50 分被判不及格。没有任何报错,没有任何提醒。

🖥 运行 np.mean([]) 那个单元格。

🗣 再看一个数据科学里的典型:空数据求平均,NumPy 不崩,给你一个 nan。而 nan 会传染:任何数加 nan 还是 nan,一路传到你的报告里。

💬 思考 5:三类错误里哪个最危险?为什么崩溃反而比算出 nan 好?

参考答案:逻辑错误最危险,因为没有人告诉你有问题。崩溃好在:它让问题暴露在离现场最近的地方;nan 会让错误传到很远的下游,你可能在画图时才发现,那时已经不知道是哪一步来的。 板书一句话:Fail fast——尽早失败。

2.2 什么是异常、常见类型(0:20–0:23)

🗣 异常是 Python 处理运行时错误的机制。出错时,当前函数立刻停止,异常沿着函数调用栈往上传,要么被某处接住,要么让程序崩溃并打印回溯。

🖥 运行 calculate_average / main 的 traceback 单元格。

🗣 读回溯有个口诀:从下往上读。最后一行是异常类型和信息,倒数第二段是出错的那行代码,再往上是谁调用了它。

🖥 运行”常见异常类型一览”单元格。

🗣 九种异常一次看完。大家看最后一个:Pandas 的 KeyError: 'Price'——列名是小写的 price,我写成了大写。这是数据科学里最常见的错误,没有之一。

💬 思考 6:看到 KeyError: 'Price',你第一反应检查什么?

参考答案:列名拼写、大小写、前后空格;print(df.columns.tolist()) 看真实列名。异常类型本身就是线索——KeyError 说明”按名字取东西,取不到”。

2.3 raise 与 assert(0:23–0:26)

🗣 不只是 Python 能抛异常,你自己的函数发现输入不对时,也应该主动抛。

🖥 运行 validate_age 单元格。

🗣 看细节:类型不对用 TypeError,值不合理用 ValueError,选对类型,调用者才知道怎么处理。信息里把出错的值带上。顺便提醒——有一个常见低级错误,字符串里要用到变量,一定要加 f,不然输出的就是字面的花括号。

🖥 运行 to_probabilities 单元格。

🗣 这个函数里同时用了两种写法。外部输入有问题,比如空列表,用 raise;内部”不可能发生”的事,比如概率之和不等于 1,用 assert——它检查的是我们自己有没有写错。

🖥 看 DataFrame 年龄体检那段。

🗣 数据管道里,assert 常用来做体检,比如检查年龄都在 0 到 120 之间。

⚠️ 🗣 重点:assert 可以被 python -O 关掉,raise 不会。所以凡是检查外部输入,必须用 raise。

💬 思考 7:读取用户上传 CSV,要求必须有 price 列,用 assert 还是 raise?

参考答案:raise(比如 raise ValueError("缺少 price 列"))。这是外部输入,不能因为 -O 就被跳过。

2.4 try / except / else / finally(0:26–0:30)

🖥 运行 flow_demo 单元格。

🗣 一个函数里四个部分。try 放可能出错的代码;except 放具体的异常类型,匹配了才走;else 是没出错才走;finally 是不管怎样都走,用来关文件、释放资源。大家看三组输入的输出,注意 finally 每次都打印了。

🗣 我这里没有用”返回 None”,而是返回一个 ("error", 原因) 的元组——让调用者清楚地知道发生了什么。这个思路一会儿还会讲。

🖥 运行 f / g 单元格。

🗣 异常往上传的规则:当前函数停止,一层层往上找,第一个匹配的 except 处理,找不到就崩。

💬 思考 8:先预测 f(2,-2)、f("ab","cd")、f(2,"x") 各返回什么?

参考答案:f(2,-2) → g(2,0) 抛 ZeroDivisionError,g 只处理 TypeError,所以传回 f,f 返回 0。 f("ab","cd") → g("ab","abcd") 中 "ab"/"abcd" 抛 TypeError,g 自己接住,返回 None。 f(2,"x") → 陷阱:x + y 在 f 里、调用 g 之前计算,2 + "x" 抛 TypeError,由 f 接住,返回 1,g 根本没被调用(所以输出里没有 g 的打印)。

📝 练习 2:safe_divide(0:30–0:33,约 3 分钟)

🗣 现在你们写。要求三条:b 为零,返回 default;a 或 b 不是数字,抛 TypeError,信息里要有 safe_divide;其它返回商。注意最后一条:不要把 TypeError 吞掉。3 分钟。

参考答案:

try:
    return a / b
except ZeroDivisionError:
    return default
except TypeError as e:
    raise TypeError(f"safe_divide 需要数字,收到 {type(a).__name__} 和 {type(b).__name__}") from e

要点:① from e 保留原始异常链;② 课内幻灯片里的版本是”TypeError 时打印并返回 None”,我们这里更严格,原因在 2.5 讲。 常见错误:把 try 范围写得太大;except: 不写类型。

2.5 数据科学家准则(0:33–0:37,约 4 分钟)

🖥 运行 average_bad / average_good 单元格。

🗣 这是经典反面教材。average_bad 遇到空列表,只打印一句”空列表”,然后悄悄返回 None。调用者拿到 None,不知道发生了什么,直到拿去比大小,在很远的地方才崩。而且崩的信息是”float 和 None 不能比较”,和”空列表”完全没关系,你得倒推。

🗣 好的版本:算不出正确结果,就 raise。

🖥 展示五条准则。

🗣 五条,请记下来:第一,只接住你知道怎么处理的;第二,写具体异常类型,except Exception: pass 是大忌;第三,算不出正确结果就 raise,不要返回垃圾值,None、0、nan 都可能是垃圾;第四,接住后要么有意义地处理,要么记录后重新抛出;第五,资源清理用 finally 或 with。

💬 思考 9:safe_divide(10, 0, default=...) 返回默认值,这算悄悄吞异常吗?和 average_bad 的区别?

参考答案:区别在谁做决定、是否显式。default 是调用者显式传入的,并写在了文档里,调用者清楚”除不了时会拿到什么”;average_bad 是函数自己偷偷决定返回 None,调用者毫不知情。

🖥 运行 parse_numbers 单元格。

🗣 最后是一个数据清洗的实战写法。清洗时有些值就是解析不了。错误的做法是 except: pass,悄悄丢掉。正确的做法:收集起来,报告出来。我把失败的位置和原值单独存起来,还有一个 strict 开关,需要严格时直接抛错。

⏩ 🗣 Pandas 的 errors="coerce" 方便,但它是把坏值悄悄变成 NaN。用完一定要数一数 NaN 有多少。

🗣 模块 2 小结一句话:Fail fast,不静默吞异常。 还记得模块 1 的 mimic_text_v1 吗?一个没处理的死胡同让它崩了——现在你知道该怎么做了。下面我们换一个问题:函数就算正确,数据变大,它还跑得动吗?


模块 3:算法复杂度与 Big-O(0:37–0:57,20 分钟)

3.1 复杂度是什么(0:37–0:41)

🗣 复杂度不是”这段代码跑了 2.3 秒”,换台电脑就变了。它问的是:数据量变成 10 倍,操作次数变成几倍?

🗣 我们不推公式,直接数。

🖥 运行线性搜索计数单元格。

🗣 这是最坏情况,目标不在列表里,所以要比较 n 次。n 从 10 到 10 万,次数就是 n,不多不少。

💬 思考 10:数据量从 1 万变成 10 万,比较次数变成几倍?Big-O 是什么?

参考答案:10 倍;线性,O(n)。

3.2 Big-O 三条规则 + 练习 3(0:41–0:46)

🗣 三条规则:只留增长最快的项;扔掉常数系数;看最坏情况、看 n 很大时。

🖥 运行 1000n vs 2n² 单元格。

🗣 为什么敢扔系数?看这个对比。n 等于 10 的时候,2n² 反而快;到 n 等于 1000,反过来了;到一万,慢了 20 倍。n 一大,增长率说了算。

📝 练习 3(约 3 分钟)

🗣 三个函数,先不要运行,先判断,把答案写进 my_answer,再运行检查。

参考答案:func_a → O(n);func_b → O(n)(内层固定 10 次,是常数);func_c → O(n^2)(内层次数是 0+1+…+(n−1) = n(n−1)/2)。

🖥 答完后,运行”验证”单元格(n 翻倍表)。

💬 思考 11:n 翻倍时 a、b 次数 ×2,c 次数 ×4,说明什么?func_b 里的 10 为什么不影响级别?

参考答案:级别看”n 翻倍后增长几倍”,不看系数;固定 10 次只是把总数乘了 10,增长曲线形状没变。

3.3 常见复杂度对比(0:46–0:48)

🗣 对照表念前五行就够:常数、对数、线性、线性对数、平方,后面是指数和阶乘。

🖥 运行对比单元格。

🗣 n 等于 1000 的时候:对数大约 10 次,线性 1000,平方 100 万,指数是 10 的 301 次方。可观测宇宙的原子也才 10 的 80 次方。所以经验是:比平方更差的,数据稍大就基本没法用。

⏩ 图只扫一眼:这条几乎竖直的线就是 2 的 n 次方。

3.4 线性搜索 vs 二分搜索(0:48–0:53)

🗣 任务:一批温度读数,找出不超过 target 的最大温度。线性搜索不要求排序,逐个看;二分搜索要求先排序,每次砍掉一半。

🖥 运行两个函数 + 样例。

🗣 我让两个函数都返回”结果和步数”。十个数,线性 10 步,二分 3 步。

🖥 运行随机交叉验证单元格。

🗣 注意这个测试:2000 组随机数据,拿一行”显然正确”的写法当标准答案,去检验两个函数。这是模块 1 思想的延续:用慢但显然对的写法,检验快但容易写错的写法。

🖥 看步数表。

🗣 数据从一千到一百万,线性步数是一百万,二分只有 20 步。

💬 思考 12:二分这么快,是不是永远用二分?排序的代价是 O(n log n),什么时候先排序反而亏?

参考答案:只查一次时,排序成本比线性扫一遍还高,亏;要查很多次,排序一次成本被摊薄,才赚。

🖥 运行计时单元格。

🗣 十万个数,查 100 次,线性 0.3 秒;排序加二分只要 0.017 秒。可是只查一次的话,线性只要 3 毫秒,排序要 16 毫秒。查一次用线性,查很多次先排序或者建字典。选算法要看数据规模加使用方式,不是看哪个名字更高级。

3.5 数据科学家实战建议 + 练习 4(0:53–0:57)

🗣 第一,能向量化就别手写循环。

🖥 运行 100 万温度的单元格。

🗣 统计超过 25 度的个数,Python 循环 0.03 秒,NumPy 0.002 秒,快了十几倍。

🖥 运行两两距离单元格。

⚠️ 🗣 我要诚实说一句:向量化没有改变 Big-O,两种写法都是 O(n²)。它只是把循环交给了 C,常数变小;而且广播会占 O(n²) 的内存,n 很大要分块。倍数在不同电脑上不一样,这里别纠结具体数字。

⏩ 🗣 第二,选对数据结构。x in list 是 O(n),x in set 平均 O(1)。这个例子里差了好几千倍。

🗣 第三,正确性优先,拒绝过早优化。顺序永远是:先写对,用真实规模的数据测,profile 找真正瓶颈,只优化瓶颈,保持可读。

💬 思考 13:O(n²) 算法,1000 个点耗时 2 秒,真实数据 100 万个点。要不要优化?

参考答案:要。n 变成 1000 倍,n² 变成 100 万倍,2 秒变成 200 万秒,约 23 天。这不是”慢一点”,是根本跑不完。

📝 练习 4(约 3 分钟,0:54–0:57)

🗣 最后一个练习:用 NumPy 布尔索引写”不超过 target 的最大值”,不要写 for,没有满足条件的元素返回 None。

参考答案:

sub = arr[arr <= target]
return float(sub.max()) if sub.size > 0 else None

常见错误:忘了空子数组上调用 max() 会抛 ValueError(连接模块 2)。


课堂小结与作业(0:57–1:00)

🖥 展示小结表。

🗣 一分钟收尾,一张表带走。

🗣 问题分解:先手算样例、先设计测试、一次只写一个小函数。异常处理:能处理才接住,算不出正确结果就 raise,绝不静默吞异常。复杂度:看增长率,选对数据结构和算法,先正确,再 profile,再优化。

🗣 这三个正好是一条线:拆出可测试的函数 → 让它在坏输入下大声失败 → 让它在大数据上跑得动。

🗣 作业三道:基础 1,safe_mean,练习异常,并且想想”接住异常”为什么应该放在 report_mean 而不是 safe_mean 里——这是”谁有能力处理”的问题;基础 2,两个判断重复元素的函数,分析 Big-O 并验证,思考 v2 付出的代价是内存;拓展题,把马尔可夫扩成二阶,先写测试再写代码。

🗣 附录里有今天四个练习的参考答案,做完再看。下课。


附:课前检查清单(老师用)