前两天有人问我: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, key=lambda name: data[name], reverse=True) print(names) # ['王强', '张伟', '李娜']key接收一个函数,每次拿一个元素进去,返回一个可比较的“排序键”,Python按这个键的大小来决定元素的顺序。这样做的好处是原列表本身不用改造,哪怕列表里存的是复杂对象,也能按任意规则排。比如一堆学生对象,按age维度排,就直接key=lambda stu: stu.age,清晰又高效。
还有一个容易被忽略的点:稳定排序。Python的排序算法是稳定的,意思是当两个元素排序键相等时,它们原本的相对顺序不会改变。这个特性在处理“先按班级排,再按分数排”这类二级排序时特别有用。你只要先按第二个字段排一次,再按第一个字段排一次,结果就是组内有序。后台管理系统里常见的“分组内排序”需求,用稳定排序一条链做下来就行,完全不用手动分组。
1.2 字符串排序、中文排序:别被默认规则坑到
字符串排序看起来很简单,实际上有个很容易踩的坑:默认按Unicode码点排。大写字母的码点全部在小写字母之前,所以"Cherry"会排在"apple"前面,因为大写C的码点是67,小写a的码点是97。如果你的数据是用户输入的名字、地址、文件名,直接排序很容易出现“Z开头的词跑到a开头前面”的诡异现象。
解决办法是用key=str.lower统一转成小写再排:
fruits = ["banana", "apple", "Cherry", "date"] print(sorted(fruits)) # ['Cherry', 'apple', 'banana', 'date'] print(sorted(fruits, key=str.lower)) # ['apple', 'banana', 'Cherry', 'date']这里str.lower是字符串方法本身,可以直接作为函数传递,不需要再包一层lambda。同样的思路也可以用在文件名排序、用户名排序上。
中文排序问题就更麻烦一点。默认情况下Python按中文的Unicode码点排,绝大多数场景下既不是拼音序也不是笔画序,而是看起来完全没规律的顺序。如果你做的是通讯录、城市列表、商品分类这类真实项目,需要按拼音排序,常规做法是引入pypinyin库:
from pypinyin import lazy_pinyin names = ["张伟", "李娜", "王强"] print(sorted(names, key=lazy_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到numpy
2.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.00000000e+00,但旁边还飘着一些-4.44089210e-16、1.11022302e-16之类的数字。这些极小的非零值就是浮点误差,不是程序出错了。
为什么会这样?因为浮点数在二进制里无法精确表示大多数十进制小数,矩阵求逆又涉及大量的乘法和除法,每一步都会累积极微小的舍入误差。线性代数教材里写A乘以A⁻¹精确等于I,但在计算机里,精确等于几乎不可能,尤其是矩阵规模大或条件较差的时候。
所以验证时切勿用==和np.eye(n)比较,要用np.allclose:
n = 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会抛出LinAlgError:
try: 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⁻¹b:
A = 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.] 也就是 x=2, y=3当然,实际项目中解方程组更多用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(low=1, high=100)的默认参数改掉,其他逻辑全部复用。这一步就是说,代码结构写好了,扩展一个功能真的只需要改一行参数。
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(low=1, high=100): 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标志位,流程干净。第三,带默认参数low=1, high=100,以后想做1-500的版本,直接调用play_game(1, 500),不需要改动内部任何一行代码。第四,ifname== "main"保证只有直接运行这个脚本时才启动游戏,以后把play_game导入别的模块,不会弹出一个游戏界面打扰你。
这个习惯在面试手写代码时特别加分。面试官读你代码,不用猜,每一段都是独立可理解的单元。逻辑清晰不是靠形容词,是靠代码结构体现的。
4.5 再进阶一点:支持多轮重玩和排行榜雏形
如果还想继续加深,可以做一个支持多轮重玩的版本。最朴素的做法是用一个外层while包住play_game,内部用一个value判断是否继续:
while True: play_game(low=1, high=100) again = input("再来一局?(y/n):") if again.lower() != "y": break更专业一点的做法,是让play_game返回一个结构化结果,比如玩家本轮使用的次数,方便后续做成排行榜:
def play_game(low=1, high=100): ... return times这样主程序就能收集每次游戏的次数,存进一个列表,算平均值、最佳成绩、最近几局趋势,都是顺手的事。你别小看这个小游戏的扩展过程,它其实就是一个小型项目的演化路径:“先做出可用版本,再逐步优化结构和扩展功能”,这个流程和真实产品开发完全一致。
我个人实际练的时候,顺序是这么安排的:先做猜数字游戏,因为它是第一个让我体会到“程序是在按我的流程走的”的项目;然后做排序,把列表玩熟;接着做随机矩阵,开始接触numpy;最后做求逆矩阵并验证,把前面学的numpy和浮点数思想用起来。四个练习走完,Python循环、类型、函数、常用库基本都摸了一遍。
最后再分享一个小技巧:每完成一个练习,都把它存进git仓库,每次提交记录写下当时踩的坑。别小看这个习惯,回头翻提交记录时,你会清楚地看到自己从“能跑就行”到“考虑边界、验证结果、优化结构”的成长过程,这就是最好的复习资料。四个项目做完,你对自己代码能力的判断,会比任何教程都准确。