课题:问题分解实战 · 异常与错误处理 · 算法复杂度
时长: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 分钟)
🖥 展示 notebook 顶部,运行「课前准备」单元格。
🗣 大家好。今天一小时,我们讲三件事,但你会发现它们其实是一条线上的三个点。
🗣 第一件事:拿到一个大任务,怎么拆成小函数,怎么先想测试再写代码。我们用一个很有意思的例子——让电脑模仿一段文字的风格,这是大语言模型思想上的”祖先”。
🗣 第二件事:你拆出来的函数,遇到坏输入怎么办?空数据、错的列名、除以零。这就是异常处理。
🗣 第三件事:函数是对的,数据变成一百万行以后,它还跑得动吗?这就是算法复杂度。
🗣 看一下图例:💬 是思考题,我会先停三十秒让你想;📝 是练习,请你们现场动手写,写完运行下面的检查格,出现绿色对勾就算过。好,开始。
🖥 滚动到 1.1 的五步表格。
🗣 先不写代码。拿到大问题,我们按五步走:分解、找重复模式、抽象、设计算法、评估。我对着今天的任务过一遍。
🗣 分解:我们把”仿写文章”拆成两件事——“训练”,也就是读文本建一张表;“生成”,也就是查表随机选词。模式识别:读到每个词,我们都在做同一件事,记下”我后面跟了谁”。抽象:把这个模式变成一个字典,词对应后面出现过的词的列表。算法设计:扫一遍文本建表,生成时反复查表加随机。最后是评估:怎么知道对不对?这是今天的重点,一会儿细讲。
🗣 数据科学里你每天做的”读取、清洗、特征、建模”,其实也是同样的套路,每一步都应该是个可以单独测试的函数。
💬 思考 1:如果我一上来写一个大函数,读文件直接输出仿写文章,结果很奇怪,我怎么知道是哪一步错了?
🗣 停 20 秒。 参考答案:没法知道。读文件、建表、采样三件事缠在一起,任何一步错都表现为”输出奇怪”。拆开以后,每一步都有自己的输入输出,可以单独验证。 追问:那”拆”的标准是什么?→ 每个函数能用一句话说清它做什么、能单独写测试。
🖥 滚动到 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 的测试表。
🗣 现在我要问一个问题:怎么测这个函数?有人会说,拿一个大文件跑一下,看着像不像。这不行,因为你没有标准答案,看不出对错。正确的做法是造小而全的测试:重复词、手算的综合样例、空文本、单个词,还有一个很容易漏的——跨行,上下文要在换行之后延续。
🖥 运行红灯单元格(假实现 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 测的就是这个。如果每行重置,就会多出很多错误的
''起点。
🗣 生成的算法:上下文从空字符串开始,查表,随机选一个后继,追加到故事里,它成为新的上下文,重复。我先写最直接的”教科书版”。
🖥 运行 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。
🗣 快速过一下缺陷:第一,开头永远是第一个词,可以随机选
key;第二,cat. 和 cat、My 和
my
被当成不同的词,需要清洗,数据科学里叫分词和归一化;第三,只看一个词,句子不通顺,可以把上下文扩成多个词,用
tuple 做 key,但太长会变成照抄原文;第四,没有句子结尾。
🗣 最后说一句和大模型的关系:它们的核心思想都是”根据前文预测下一个词”。大模型用神经网络,看的上下文长得多,但这个小程序是它思想上的前身。
🗣 现在请你们动手。写
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。)
🗣 刚才 mimic_text_v1
崩了。崩溃是坏事吗?今天我要说服你:崩溃是好事。
🖥 展示 2.1 表格。
🗣 错误有三类。语法错误:程序还没跑就被 Python 拒绝,比如少了冒号,一眼看出。运行时错误:跑到一半崩,比如除以零,会打印出清楚的信息。逻辑错误:程序跑完了,没报任何错,但答案是错的。
🖥 运行 2.1 第一个代码单元格。
🗣 看第三个例子。50 分及格,我写成了大于 50,结果 50 分被判不及格。没有任何报错,没有任何提醒。
🖥 运行 np.mean([]) 那个单元格。
🗣 再看一个数据科学里的典型:空数据求平均,NumPy 不崩,给你一个
nan。而 nan 会传染:任何数加 nan 还是
nan,一路传到你的报告里。
💬 思考 5:三类错误里哪个最危险?为什么崩溃反而比算出 nan 好?
参考答案:逻辑错误最危险,因为没有人告诉你有问题。崩溃好在:它让问题暴露在离现场最近的地方;nan 会让错误传到很远的下游,你可能在画图时才发现,那时已经不知道是哪一步来的。 板书一句话:Fail fast——尽早失败。
🗣 异常是 Python 处理运行时错误的机制。出错时,当前函数立刻停止,异常沿着函数调用栈往上传,要么被某处接住,要么让程序崩溃并打印回溯。
🖥 运行 calculate_average / main 的
traceback 单元格。
🗣 读回溯有个口诀:从下往上读。最后一行是异常类型和信息,倒数第二段是出错的那行代码,再往上是谁调用了它。
🖥 运行”常见异常类型一览”单元格。
🗣 九种异常一次看完。大家看最后一个:Pandas 的
KeyError: 'Price'——列名是小写的
price,我写成了大写。这是数据科学里最常见的错误,没有之一。
💬 思考 6:看到
KeyError: 'Price',你第一反应检查什么?
参考答案:列名拼写、大小写、前后空格;
print(df.columns.tolist())看真实列名。异常类型本身就是线索——KeyError 说明”按名字取东西,取不到”。
🗣 不只是 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就被跳过。
🖥 运行 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 的打印)。
🗣 现在你们写。要求三条: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:不写类型。
🖥 运行 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
吗?一个没处理的死胡同让它崩了——现在你知道该怎么做了。下面我们换一个问题:函数就算正确,数据变大,它还跑得动吗?
🗣 复杂度不是”这段代码跑了 2.3 秒”,换台电脑就变了。它问的是:数据量变成 10 倍,操作次数变成几倍?
🗣 我们不推公式,直接数。
🖥 运行线性搜索计数单元格。
🗣 这是最坏情况,目标不在列表里,所以要比较 n 次。n 从 10 到 10 万,次数就是 n,不多不少。
💬 思考 10:数据量从 1 万变成 10 万,比较次数变成几倍?Big-O 是什么?
参考答案:10 倍;线性,
O(n)。
🗣 三条规则:只留增长最快的项;扔掉常数系数;看最坏情况、看 n 很大时。
🖥 运行 1000n vs 2n² 单元格。
🗣 为什么敢扔系数?看这个对比。n 等于 10 的时候,2n² 反而快;到 n 等于 1000,反过来了;到一万,慢了 20 倍。n 一大,增长率说了算。
🗣 三个函数,先不要运行,先判断,把答案写进
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,增长曲线形状没变。
🗣 对照表念前五行就够:常数、对数、线性、线性对数、平方,后面是指数和阶乘。
🖥 运行对比单元格。
🗣 n 等于 1000 的时候:对数大约 10 次,线性 1000,平方 100 万,指数是 10 的 301 次方。可观测宇宙的原子也才 10 的 80 次方。所以经验是:比平方更差的,数据稍大就基本没法用。
⏩ 图只扫一眼:这条几乎竖直的线就是 2 的 n 次方。
🗣 任务:一批温度读数,找出不超过 target 的最大温度。线性搜索不要求排序,逐个看;二分搜索要求先排序,每次砍掉一半。
🖥 运行两个函数 + 样例。
🗣 我让两个函数都返回”结果和步数”。十个数,线性 10 步,二分 3 步。
🖥 运行随机交叉验证单元格。
🗣 注意这个测试:2000 组随机数据,拿一行”显然正确”的写法当标准答案,去检验两个函数。这是模块 1 思想的延续:用慢但显然对的写法,检验快但容易写错的写法。
🖥 看步数表。
🗣 数据从一千到一百万,线性步数是一百万,二分只有 20 步。
💬 思考
12:二分这么快,是不是永远用二分?排序的代价是
O(n log n),什么时候先排序反而亏?
参考答案:只查一次时,排序成本比线性扫一遍还高,亏;要查很多次,排序一次成本被摊薄,才赚。
🖥 运行计时单元格。
🗣 十万个数,查 100 次,线性 0.3 秒;排序加二分只要 0.017 秒。可是只查一次的话,线性只要 3 毫秒,排序要 16 毫秒。查一次用线性,查很多次先排序或者建字典。选算法要看数据规模加使用方式,不是看哪个名字更高级。
🗣 第一,能向量化就别手写循环。
🖥 运行 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 天。这不是”慢一点”,是根本跑不完。
🗣 最后一个练习:用 NumPy 布尔索引写”不超过 target 的最大值”,不要写 for,没有满足条件的元素返回 None。
参考答案:
sub = arr[arr <= target] return float(sub.max()) if sub.size > 0 else None常见错误:忘了空子数组上调用
max()会抛ValueError(连接模块 2)。
🖥 展示小结表。
🗣 一分钟收尾,一张表带走。
🗣 问题分解:先手算样例、先设计测试、一次只写一个小函数。异常处理:能处理才接住,算不出正确结果就 raise,绝不静默吞异常。复杂度:看增长率,选对数据结构和算法,先正确,再 profile,再优化。
🗣 这三个正好是一条线:拆出可测试的函数 → 让它在坏输入下大声失败 → 让它在大数据上跑得动。
🗣 作业三道:基础
1,safe_mean,练习异常,并且想想”接住异常”为什么应该放在
report_mean 而不是 safe_mean
里——这是”谁有能力处理”的问题;基础 2,两个判断重复元素的函数,分析 Big-O
并验证,思考 v2
付出的代价是内存;拓展题,把马尔可夫扩成二阶,先写测试再写代码。
🗣 附录里有今天四个练习的参考答案,做完再看。下课。