前两天有人问我Python基础语法学了一遍感觉都会了就是不知道自己能做什么。我给的答案可能和很多人想的不一样——不是爬虫不是Web框架而是一份包含四个练习的清单排序、随机生成矩阵、求逆矩阵并验证、猜数字游戏。这四个题目在初学阶段被翻来覆去地练看起来不起眼实际上把Python日常开发里最核心的几块能力全串了起来容器和算法、第三方库、数值计算、输入与流程控制。这篇文章我就把这四件事从头到尾拆开讲一遍包括完整代码、运行效果、常见坑位和排查思路新手可以直接照抄已经练过的朋友也可以回头看看有没有遗漏的细节。1. 排序从内置函数到算法本质1.1 先用好 sort 和 sorted摸清它们的脾气很多人写Python半年了排序还在自己实现冒泡这是没有理解内置函数的定位。Python的排序核心就两个list.sort()和sorted()它们用的是Timsort算法一个混合归并排序和插入排序的稳定排序算法平均复杂度和最坏复杂度都是O(n log n)而且特别擅长利用数据中已有的有序片段。实际项目中绝大多数排序都用它们而不是手写。先看两行代码的区别nums [3, 1, 4, 1, 5, 9, 2, 6] result nums.sort() print(result) # 这里会输出 None这是最常见的坑 print(nums) # 数据已经被原地改成了有序列表 # 需要保留原列表时用 sorted original [3, 1, 4, 1, 5] sorted_copy sorted(original) print(original) # [3, 1, 4, 1, 5]原数据没变 print(sorted_copy) # [1, 1, 3, 4, 5]list.sort()是列表的方法直接修改原列表返回值为None。sorted()是Python内建函数接收任何可迭代对象返回一个新的列表不碰原数据。新手最容易犯的错误就是把nums.sort()的返回值当排序结果去用结果拿到的是一整片None。我见过好几个刚转行的人卡在这个问题上半小时其实只要记住一句话sort是就地处理sorted是返回新结果。再往深一步实际开发里“排序”这个需求往往不是简单升序而是按某个字段或规则排。这时候必须用key参数。比如一个字典要按值从高到低排data {张伟: 88, 李娜: 74, 王强: 93} names sorted(data, keylambda name: data[name], reverseTrue) print(names) # [王强, 张伟, 李娜]key接收一个函数每次拿一个元素进去返回一个可比较的“排序键”Python按这个键的大小来决定元素的顺序。这样做的好处是原列表本身不用改造哪怕列表里存的是复杂对象也能按任意规则排。比如一堆学生对象按age维度排就直接keylambda stu: stu.age清晰又高效。还有一个容易被忽略的点稳定排序。Python的排序算法是稳定的意思是当两个元素排序键相等时它们原本的相对顺序不会改变。这个特性在处理“先按班级排再按分数排”这类二级排序时特别有用。你只要先按第二个字段排一次再按第一个字段排一次结果就是组内有序。后台管理系统里常见的“分组内排序”需求用稳定排序一条链做下来就行完全不用手动分组。1.2 字符串排序、中文排序别被默认规则坑到字符串排序看起来很简单实际上有个很容易踩的坑默认按Unicode码点排。大写字母的码点全部在小写字母之前所以Cherry会排在apple前面因为大写C的码点是67小写a的码点是97。如果你的数据是用户输入的名字、地址、文件名直接排序很容易出现“Z开头的词跑到a开头前面”的诡异现象。解决办法是用keystr.lower统一转成小写再排fruits [banana, apple, Cherry, date] print(sorted(fruits)) # [Cherry, apple, banana, date] print(sorted(fruits, keystr.lower)) # [apple, banana, Cherry, date]这里str.lower是字符串方法本身可以直接作为函数传递不需要再包一层lambda。同样的思路也可以用在文件名排序、用户名排序上。中文排序问题就更麻烦一点。默认情况下Python按中文的Unicode码点排绝大多数场景下既不是拼音序也不是笔画序而是看起来完全没规律的顺序。如果你做的是通讯录、城市列表、商品分类这类真实项目需要按拼音排序常规做法是引入pypinyin库from pypinyin import lazy_pinyin names [张伟, 李娜, 王强] print(sorted(names, keylazy_pinyin)) # [李娜, 王强, 张伟]lazy_pinyin会把每个汉字转成拼音再交给排序函数。这个方案在小数据量下很稳但数据量大了以后性能一般因为每个元素都要做一次汉字转拼音。真到了百万级数据建议在数据库层面或搜索服务里处理不要放Python里硬排序。这个认知是实际工作中踩过坑才有的一开始我以为Python万能后来才发现跨语言、跨系统的排序规则必须放源头解决好。1.3 手写排序算法理解原理但别在生产里用它学习阶段我强烈建议你手写一次冒泡排序、选择排序和快速排序。不是为了替代内置函数而是为了理解排序的本质。比如冒泡排序理解它以后你才会懂“相邻元素交换”“内层循环逐渐缩短”这些概念def bubble_sort(arr): n len(arr) for i in range(n - 1): swapped False for j in range(n - 1 - i): if arr[j] arr[j 1]: arr[j], arr[j 1] arr[j 1], arr[j] swapped True if not swapped: break return arr这个版本里有一个优化swapped标志。如果某一轮内层循环完全没有交换说明整个列表已经有序直接退出外层循环。这个细节说起来简单但新手经常漏掉导致一个已经排好序的数组也要白白扫一遍白白浪费O(n²)时间。学习排序算法时常规路径是冒泡、选择、插入、希尔、归并、快速、堆排序。理解它们的区别时重点盯三个维度平均复杂度、最坏复杂度、稳定性。比如选择排序是不稳定排序因为交换可能改变相同元素的相对位置快速排序平均很快但最坏是O(n²)而且递归过深会有栈溢出风险归并排序稳定但需要额外空间。面试时能把这个对比说清楚比背十个实现都管用。但进入真实项目后无论什么场景默认都用内置sort或sorted。Timsort之所以是默认方案是因为它在各种真实数据分布下都表现稳定还能利用数据中已有的局部有序性。你自己手写快速排序在数据含大量重复元素时容易退化在极大数据量下可能递归爆栈这些都是精确踩得到的坑。我的建议是手写算法留在学习笔记里业务代码一律内置函数这是最优解。2. 随机生成矩阵从random到numpy2.1 为什么生成矩阵要直接上 numpy.random标准库random也提供随机数但它主要生成单个标量。要生成一个二维矩阵你只能用嵌套列表推导式import random # 标准库生成 3x3 整数矩阵只能靠循环 M1 [[random.randint(1, 10) for _ in range(3)] for _ in range(3)]如果只用来展示数据这个写法也不是不行。但你很快会发现当你下一步想做矩阵加法、乘法、求逆、转置时标准库生成的嵌套列表处处难受要么自己写双重循环要么还得转成numpy。与其绕一圈不如从一开始就习惯numpy。import numpy as np # numpy 一行搞定同尺寸矩阵 M2 np.random.randint(1, 10, size(3, 3))一行代码生成一个3行3列的整数矩阵。同样重要的地方在于M2的数据类型天然是ndarray加法、乘法、求逆、切片、统计全部都是一句话的事。后面练求逆矩阵、做数据分析、写机器学习代码全都离不开numpy早点养成用numpy的习惯后面会很顺。2.2 随机种子让你的“随机”可以被复现这里要提醒一个概念计算机生成的随机数是伪随机数。它本质上是一套确定性算法通过一个初始值不断递推产生数列。默认情况下每次程序启动时种子不同结果看起来不可预测。但在调试、写实验、写教学代码时你常常希望每次运行都得到同一组随机数以便复现问题或对比算法。此时要设置随机种子np.random.seed(42) A np.random.rand(3, 3) print(A) np.random.seed(42) B np.random.rand(3, 3) print(B) # A 和 B 完全一致因为种子相同且调用顺序相同你会看到很多开源项目里写seed(42)这个42来自《银河系漫游指南》的梗其实填多少都行只要固定就行。真正重要的是理解它的原理种子相同、调用顺序相同随机序列就完全相同但只要你中间多调用一次随机函数后面所有值都会整体偏移。我在复现别人实验时踩过几次坑对方只给了种子没给之前的随机调用过程我怎么都复现不出他的矩阵后来才发现是这个原因。如果你做需要随机性的实验比如数据增强、参数初始化、Monte Carlo模拟建议在脚本入口固定一次种子保证整个程序可复现。这也是一种工程素养提交给别人的代码不能“每次跑结果都不一样”否则别人没法验证。2.3 不同矩阵需求选不同的随机分布生成矩阵之前先想清楚我需要什么类型的数据numpy里面最常见的几种我列一下以后直接用函数分布类型取值特点常见用途np.random.rand(3, 3)均匀分布[0, 1)之间浮点数测试数据、权重初始化np.random.randn(3, 3)标准正态分布均值为0方差为1可为负值模拟噪声、机器学习实验np.random.randint(1, 10, size(3, 3))均匀整数1到9之间的整数左闭右开逆矩阵练习、游戏数据np.random.normal(0, 1, size(3, 3))正态分布自定义均值和标准差更真实的噪声模拟np.random.uniform(1, 5, size(3, 3))均匀分布自定义范围浮点数手动控制数据范围举个例子如果你要模拟一个降噪算法的输入往往用randn生成正态噪声如果你要给信号加一个零到一之间的随机扰动用rand如果你要练习线性代数用randint生成整数矩阵不容易出现极端病态的情况。生成矩阵之后还有三个检查动作是专业习惯A np.random.randint(1, 10, size(3, 3)).astype(float) print(A.shape) # (3, 3)确认尺寸 print(A.dtype) # float64确认数据类型第一看形状确保size填对了第二看dtype确认是浮点还是整数。为什么我强调提前astype(float)因为np.linalg.inv需要浮点运算虽然numpy会自动做隐式转换但如果矩阵本身是整数一些边界情况下结果容易被误判。提前转float后面的数值计算更可控也更容易看出数据增删时的问题。3. 求逆矩阵并验证线性代数的落地点3.1 先分清三种求逆方式写Python求逆矩阵大多数人第一反应是np.linalg.inv这当然是最直接的方式import numpy as np np.random.seed(7) A np.random.randint(1, 10, size(3, 3)).astype(float) print(原矩阵) print(A) A_inv np.linalg.inv(A) print(逆矩阵) print(A_inv)inv函数对方阵求逆会做完整的高斯消元或LU分解结果非常稳定。这是工程首选。除了inv还有两个概念值得了解。第一个是np.linalg.pinv它求的是伪逆。当矩阵不可逆、甚至是非方阵时inv会直接报错但pinv依然能给出一个在最小二乘意义下“最接近逆”的结果。第二个是2x2矩阵的手动公式对于一个矩阵 [[a, b], [c, d]] 如果ad - bc ≠ 0它的逆矩阵是 1/(ad - bc) * [[d, -b], [-c, a]]这个公式适合在面试或学习阶段快速验证概念但实战中千万不要手算大矩阵逆矩阵时间成本和错误率都不可接受。写代码的人能理解原理、知道调哪个函数就够了计算部分交给库。3.2 验证为什么打印出来的单位矩阵不是单位矩阵求逆之后必须验证这个习惯比求逆本身更重要。验证方法是用A A_inv结果应该接近单位矩阵。但如果你直接跑一遍打印出来的结果往往长得像这样I_check A A_inv print(I_check)你大概率会看到类似下面这种输出对角线位置是1.00000000e00但旁边还飘着一些-4.44089210e-16、1.11022302e-16之类的数字。这些极小的非零值就是浮点误差不是程序出错了。为什么会这样因为浮点数在二进制里无法精确表示大多数十进制小数矩阵求逆又涉及大量的乘法和除法每一步都会累积极微小的舍入误差。线性代数教材里写A乘以A⁻¹精确等于I但在计算机里精确等于几乎不可能尤其是矩阵规模大或条件较差的时候。所以验证时切勿用和np.eye(n)比较要用np.allclosen A.shape[0] identity np.eye(n) print(np.allclose(I_check, identity)) # Truenp.allclose默认允许绝对误差和相对误差在1e-08级别内。这个习惯是我以前练线性回归时踩坑换来的。当时我求完(X^T X)^(-1) X^T y直接拿去和理想结果比较得到一堆False还以为是代码写错了排查了很久才意识到是浮点精度在作怪。从那以后凡是涉及浮点结果比较我第一反应就是allclose或np.isclose。3.3 求逆失败怎么办奇异矩阵和病态矩阵求逆失败最常见的情况是矩阵不可逆也就是行列式det等于0。此时np.linalg.inv会抛出LinAlgErrortry: A_inv np.linalg.inv(A) except np.linalg.LinAlgError: print(矩阵不可逆考虑改用伪逆) A_inv np.linalg.pinv(A)更微妙的情况是“理论可逆但实际上因为数值问题不可靠”。比如行列式不为0但非常接近0这种矩阵叫病态矩阵计算出的逆矩阵会极其敏感哪怕输入数据改变一个小数点结果都可能天翻地覆。判断矩阵病态程度的一个常用指标是条件数numpy里可以用np.linalg.cond(A)计算。条件数越大说明矩阵越接近奇异计算结果越不可靠。工程上遇到这种问题常规解法不是硬着头皮用inv而是加正则化。机器学习里的岭回归就是把(X^T X)换成(X^T X λI)这样即使原矩阵奇异加上一个单位矩阵的对角项后也能可逆。面试里经常问“如果特征矩阵的转置乘自身奇异怎么办”你只要能说出“加一个小的正则项让矩阵可逆”这一条面试官基本就认了。3.4 求逆矩阵的真实用途解线性方程组求逆矩阵不是考试专用技术它在真实项目里有非常典型的落地场景——解线性方程组。比如有方程组3x 2y 11 1x 2y 7写成矩阵形式就是Ax b其中A是系数矩阵b是右侧常数向量。理论上x A⁻¹bA np.array([[3.0, 2.0], [1.0, 2.0]]) b np.array([11.0, 7.0]) A_inv np.linalg.inv(A) x A_inv b print(x) # [2. 3.] 也就是 x2, y3当然实际项目中解方程组更多用np.linalg.solve因为它的计算效率更高、数值稳定性更好。但通过“求逆”来理解解方程组是建立线性代数直觉的好方法。你把前面的矩阵练习、求逆练习、方程组求解串成一条线就明白numpy的数值计算组件是怎么配合使用的了。3.5 分块矩阵求逆大矩阵场景的储备知识再补充一个“分块矩阵求逆”的思路。热词搜索里有很多人查这个概念因为矩阵一大直接inv往往会遇到内存或效率瓶颈。分块求逆的基本想法是把一个大矩阵切成四块[[P, Q], [R, S]]在P和S可逆的前提下可以用Schur补一步步算出逆矩阵的四个分块。numpy里很少需要手写这个除非你在写并行计算库或者处理超大规模稀疏矩阵。真实工作中如果矩阵大到需要分块我更建议先考虑scipy.sparse里的稀疏矩阵或者改用迭代求解器比如共轭梯度法而不是自己分块。这个知识点当储备即可。知道它说明你理解大矩阵计算的边界知道什么时候不该用它说明你真的写过大矩阵代码。这两层加在一起面试官会相信你不是只会调inv的小白。4. 猜数字游戏从零写一个完整小游戏4.1 核心循环怎么写while True 是正道猜数字游戏几乎是所有Python初学路线的必备项目但它绝不是“只是玩一玩”那么简单。这个游戏把循环、条件、输入输出、类型转换全揉在了一起是理解程序流程控制的极佳载体。最直观的核心逻辑是这样import random target random.randint(1, 100) while True: guess int(input(请输入你猜的数字1-100)) if guess target: print(小了再大点。) elif guess target: print(大了再小点。) else: print(恭喜猜对了数字就是, target) break我推荐用while True加break而不是while guess ! target。为什么因为后者在进入循环前必须先给guess赋一个初始值比如guess 0这个初始值没有实际含义只是为了满足循环条件而存在。一旦逻辑变复杂这种“前置假值”很容易带来莫名其妙的边界bug。while True则是明确表达“我主动控制循环何时退出”阅读起来更接近人的思维。4.2 输入校验别让一个回车搞崩你的游戏上面的代码有一个非常现实的问题如果用户输入的不是数字比如“abc”或者干脆直接按回车int(input(...))这一行会抛出ValueError程序直接闪退。不要再让用户去猜你的代码在干什么把输入校验写好是专业和业余的分水岭。我通常会这样改import random target random.randint(1, 100) while True: raw input(请输入你猜的数字1-100输入 q 退出) if raw.lower() q: print(游戏结束正确数字是, target) break try: guess int(raw) except ValueError: print(无效输入请输入 1-100 之间的整数。) continue if guess 1 or guess 100: print(数字必须在 1-100 之间。) continue if guess target: print(小了) elif guess target: print(大了) else: print(f恭喜答案就是 {target}) break这里用try/except包住类型转换比用isdigit()判断更可靠。isdigit()的问题在于它会把一些看起来是数字的Unicode特殊字符也判断成True比如某些表格里的数字字符int()未必能正确处理。直接try/except让int()自己判断什么能转、什么不能转逻辑上最干净。还有一个小细节退出指令q应该在类型转换之前处理。如果先转int用户输入q就直接崩溃了。这个顺序看起来简单但很多人一开始都搞反导致“我明明做了退出功能怎么一按q还是报错”。把字符串处理放在数字逻辑之前是最稳妥的做法。4.3 加一个计数器让游戏变成算法课统计猜的次数几乎不增加成本整个游戏的层次感却完全不一样了。只需要在比较之前加一行count 1最后输出时带上count即可。count 0 while True: raw input(请输入 1-100 之间的整数q 退出) ... count 1 ... else: print(f恭喜答案就是 {target}你用了 {count} 次。) break有了次数你就能讲一个很重要的数学概念二分查找。在1到100的范围内猜一个数理论最优策略是7次以内猜中因为每次把范围缩小一半最多log2(100)7次就能收束到目标数。所以游戏里可以加反馈超过7次就提示“再想想怎么用更少次数锁定答案”少于等于7次就提示“你的策略接近最优解”。这不是让游戏变得复杂它实际上是把二分算法的直觉种到玩家脑子里。很多人背“二分查找模板”容易忘但玩几局猜数字后自然就能理解“为什么每次要和中间值比较而不是从头到尾扫一遍”的原理。算法不是死记硬背的东西是用一个又一个场景喂出来的。还可以增加难度选择。简单模式范围1-50最优次数约6次普通模式1-100困难模式1-1000最优次数约10次。代码上只需要把play_game(low1, high100)的默认参数改掉其他逻辑全部复用。这一步就是说代码结构写好了扩展一个功能真的只需要改一行参数。4.4 把代码拆成函数从小游戏养成工程习惯猜数字游戏的代码量很小几十行就能写完因此也是练习“函数拆分”最好的时机。如果你一上来就把所有逻辑塞在同一个while循环里以后加难度、加排行榜、加重玩功能时代码会越来越难看。我建议从第一步就写出带函数结构的样子import random def generate_target(low, high): return random.randint(low, high) def get_user_guess(low, high): while True: raw input(f请输入 {low}-{high} 之间的整数q 退出) if raw.lower() q: return None try: guess int(raw) except ValueError: print(无效输入请重新输入。) continue if guess low or guess high: print(f数字必须在 {low}-{high} 之间。) continue return guess def play_game(low1, high100): target generate_target(low, high) times 0 while True: guess get_user_guess(low, high) if guess is None: print(已退出正确数字是, target) break times 1 if guess target: print(小了) elif guess target: print(大了) else: print(f猜对了答案就是 {target}用了 {times} 次。) break if __name__ __main__: play_game()拆成函数后有几个肉眼可见的好处。第一get_user_guess把“拿到一个合法数字”的职责完全收拢起来主循环只需要关心比较逻辑不会再被乱七八糟的input卡住。第二返回None表示用户主动退出主逻辑不需要额外定义一个state标志位流程干净。第三带默认参数low1, high100以后想做1-500的版本直接调用play_game(1, 500)不需要改动内部任何一行代码。第四ifname main保证只有直接运行这个脚本时才启动游戏以后把play_game导入别的模块不会弹出一个游戏界面打扰你。这个习惯在面试手写代码时特别加分。面试官读你代码不用猜每一段都是独立可理解的单元。逻辑清晰不是靠形容词是靠代码结构体现的。4.5 再进阶一点支持多轮重玩和排行榜雏形如果还想继续加深可以做一个支持多轮重玩的版本。最朴素的做法是用一个外层while包住play_game内部用一个value判断是否继续while True: play_game(low1, high100) again input(再来一局(y/n)) if again.lower() ! y: break更专业一点的做法是让play_game返回一个结构化结果比如玩家本轮使用的次数方便后续做成排行榜def play_game(low1, high100): ... return times这样主程序就能收集每次游戏的次数存进一个列表算平均值、最佳成绩、最近几局趋势都是顺手的事。你别小看这个小游戏的扩展过程它其实就是一个小型项目的演化路径“先做出可用版本再逐步优化结构和扩展功能”这个流程和真实产品开发完全一致。我个人实际练的时候顺序是这么安排的先做猜数字游戏因为它是第一个让我体会到“程序是在按我的流程走的”的项目然后做排序把列表玩熟接着做随机矩阵开始接触numpy最后做求逆矩阵并验证把前面学的numpy和浮点数思想用起来。四个练习走完Python循环、类型、函数、常用库基本都摸了一遍。最后再分享一个小技巧每完成一个练习都把它存进git仓库每次提交记录写下当时踩的坑。别小看这个习惯回头翻提交记录时你会清楚地看到自己从“能跑就行”到“考虑边界、验证结果、优化结构”的成长过程这就是最好的复习资料。四个项目做完你对自己代码能力的判断会比任何教程都准确。