写这篇的时候,我一直在想一个问题:很多学Python的朋友,前几篇跟着教程把语法、函数、类、文件操作都过了一遍,到第十篇反而不知道下一步该干什么了。语法都认识,但离"能做东西"总觉得隔着一层。这一篇我就把进阶路上最容易卡住的三类问题一次讲透:环境怎么搭才能长期舒坦、数据结构和并发到底怎么用在真实场景里、画图这种小事怎么不翻车。
与其继续按语法手册往下念,我更愿意直接带你看几个我实际项目中反复用到的组合拳。它们串起来,正好是一套"能干活"的Python基本功。
1. 进阶者真正要过的三道坎:环境、思维与性能
1.1 为什么大多数人的Python水平卡在了"会写"这一步
我带过不少新人,也看过很多自学者的代码,发现一个共同规律:从"会写Python"到"写好Python",中间不是缺语法知识,而是缺一套稳定的环境和一种工程化思维。
先说环境。热搜词里常年霸榜的"python安装""python环境变量配置""python安装numpy库的方法",看着是入门问题,其实很多人到了进阶阶段还在被它们反复折磨。我最早学Python的时候,图省事在系统里装了一个全局解释器,今天pip install一个numpy,明天又装一个pandas,半年后各种包版本互相打架,想卸载又怕把别的东西带崩,最后只能重装系统。那段经历让我明白一件事:环境问题不解决,后面学再多都是建在沙子上。
再说思维。新手写代码的目标是"跑通",进阶的目标是"扛得住"。比如处理一份一万行的数据,新手用for循环遍历没问题,但放到生产环境处理百万级数据,同样的写法可能跑到第二天早上还出不来结果。所谓进阶,其实就是把"能跑"升级成"跑得对、跑得快、跑不挂"。
最后是性能。Python的性能话题很玄,说它慢吧,它又是AI和数据科学领域的事实标准。这里面的关键不是Python本身慢,而是你知不知道在恰当的场合用合适的武器。线程、进程、协程,三件事我都踩过坑,后面专门用一章讲清楚。
1.2 一套能陪我走到"精通"的开发环境,我是这样搭的
说到环境,我不推荐新手一上来就折腾Docker或者云开发环境,性价比太低。我推荐的方案是"本地解释器+虚拟环境+VSCode"三件套,完全够用到你真正接触到团队协作项目。
第一步:装Python本体。
Windows用户去官网或Microsoft Store安装,安装时务必勾选最下面那个"Add Python to PATH"选项。80%的"cmd里输入python没反应"都是因为这个没勾上。如果已经装完才发现没勾,也可以用安装包重新运行一次,选Modify补上,或者手动把Python安装目录加到系统环境变量里。
Linux用户相对省心,Ubuntu/Debian系执行sudo apt install python3就行,但要注意系统自带的Python版本往往偏旧。要是你需要安装较新版本,我建议用源码编译,别直接覆盖系统的/usr/bin/python3,那会影响系统工具的运行。网上搜"linux系统安装python"一大半都是教这个事情的,重点就是:装到/usr/local,不要动系统自带版本。
macOS用户建议用Homebrew,brew install python,然后让Homebrew的Python排在系统自带Python前面,能少踩很多PATH的坑。
第二步:给每个项目建一个独立的虚拟环境。
这一步是"从会写到会干活"的分水岭。命令很简单:
python -m venv venvWindows下激活:
venv\Scripts\activateLinux/macOS下激活:
source venv/bin/activate激活后你在命令行里输入python,走的就是这个项目自己的解释器了,pip安装的所有包都只影响当前项目。我最开始不建虚拟环境,后来被依赖冲突教育过一次,就再也不敢偷懒了。项目哪怕只写一个20行的脚本,我也会先建环境再动手——因为你永远不知道这个脚本三个月后会变成什么样子。
第三步:VSCode配置Python开发环境。
VSCode装个官方Python扩展,然后Ctrl+Shift+P,执行"Python: Select Interpreter"(也就是热搜里那个"add python interpreter"),选择你刚才建好的虚拟环境。再顺便装上Ruff(代码检查和格式化)插件,保存时自动帮我修掉低级语法问题,写起来会清爽很多。
依赖管理我习惯用最朴素的requirements.txt:
pip freeze > requirements.txt别人拿到项目后执行pip install -r requirements.txt就能复现环境。后面等你进入正经团队了,再升级到poetry或者uv也不迟,但核心思路不会变:依赖是项目的一部分,必须跟着项目走。
2. 邻接矩阵、数组切片与队列:AI数据处理的三块地基
2.1 邻接矩阵:用二维数组把"关系"建模
很多做AI或数据分析的朋友第一次看到"邻接矩阵"这个词,是在图神经网络或者路径规划相关的资料里。它本身不复杂:假设有N个节点,我们就建一个N×N的矩阵,matrix[i][j]表示从第i个节点到第j个节点有没有边(有向图不一定是1或0,也可以填权重)。
举个最直白的例子。假设你手上有4个城市的数据,1号和2号之间有直飞航班,2号和3号有,3号和4号有,1号和4号也有。用邻接矩阵表示就是:
import numpy as np cities = ["北京", "上海", "广州", "成都"] n = len(cities) # 方法一:手工初始化全0矩阵再填边 adj_matrix = np.zeros((n, n), dtype=int) edges = [(0, 1), (1, 2), (2, 3), (0, 3)] for i, j in edges: adj_matrix[i][j] = 1 adj_matrix[j][i] = 1 # 无向图要对称 # 方法二:直接用numpy构建 adj_matrix = np.array([ [0, 1, 0, 1], [1, 0, 1, 0], [0, 1, 0, 1], [1, 0, 1, 0] ])新手最容易犯的错是以为邻接矩阵只能装0和1。实际上在AI场景里,它更多用来存权重或者特征值。比如你在做一个路线推荐系统,矩阵里存的可以是距离、时间或者拥堵系数,第i行第j列的值越大,说明两个节点间的"代价"越大。后面做最短路径算法的时候,直接在这个矩阵上跑就行。
还有一个很重要的常识:真实世界里的图几乎都是稀疏的。一万个节点的图,如果只有五万条边,用普通二维数组存会浪费四千多万个格子。这种时候就该换成稀疏矩阵表示,用scipy.sparse或者干脆用networkx库来管理图结构。但无论用哪个库,手工构建邻接矩阵的过程一定要亲自写几遍,因为它是N维数组操作和图论算法的交界点,写一遍就会对"矩阵的形状、索引、方向"有肌肉记忆。
2.2 数组切片与矩阵操作:Numpy高性能操作的入门套路
说实话,我见过太多人学过Numpy之后就把它忘在脑后,遇到矩阵操作还是用list套list手动写循环。这种习惯放到AI项目里是灾难,因为Python原生list做矩阵乘法,跟Numpy比慢几十倍不止。
Numpy的核心操作里,第一个必须玩熟的是切片(slicing)。热搜词里有"python数组切片命令",说明这是高频疑问。语法就一句话:arr[start:stop:step],三个位置都可以省略,start缺省从头开始,stop缺省到最后,step缺省为1。
import numpy as np data = np.arange(12).reshape(3, 4) print(data) # [[ 0 1 2 3] # [ 4 5 6 7] # [ 8 9 10 11]] # 取第2行(索引从0开始) row = data[1, :] # [4 5 6 7] # 取第2列到第3列的所有行 cols = data[:, 1:3] # [[1 2], [5 6], [9 10]] # 反向切片,把第3列倒序拿出来 reversed_col = data[::-1, 2]这里有个所有教程都会强调但我偏要再强调一遍的坑:切片的默认行为是视图,不是副本。也就是说,你改切片出来的子数组,原来的数组也会跟着变。拿到别人的数据、想先截一段做分析临时改一改的时候,很多人就是在这里被坑的。要真正复制一份数据,必须显式调用.copy():
sub = data[1:, :2].copy() sub[0, 0] = 999 print(data) # 原数组不受影响矩阵操作里还有几个高频命令:reshape改变形状、transpose或.T做转置、dot或@做矩阵乘法。比如在AI里做批量数据处理时,常需要把一行数据从行向量变成列向量,很多人会写data[:, np.newaxis],其实.reshape(-1, 1)更直观。记住一句话:凡是涉及二维以上数组形状变换的操作,先停下来想一下你想得到的是"形状、方向还是值",再决定用什么函数,能少改很多bug。
2.3 Queue队列的"不堵塞"真相:从同步阻塞到超时处理
热搜词里有个"python队列queue不堵塞",这个关键词背后肯定藏着一个懵圈的朋友。我直接说结论:queue.Queue本身是线程安全的FIFO队列,但它的get()方法默认是阻塞的。所谓"不堵塞",是你主动用非阻塞方法或者超时机制实现的,不是它天生就非阻塞。
典型的生产者-消费者模型代码长得是这样:
import queue import threading import time q = queue.Queue(maxsize=10) def producer(): for i in range(5): q.put(i) print(f"生产: {i}") time.sleep(0.1) def consumer(): while True: try: item = q.get(timeout=2) print(f"消费: {item}") q.task_done() except queue.Empty: # 超时2秒还没拿到东西,退出循环 break t1 = threading.Thread(target=producer) t2 = threading.Thread(target=consumer) t1.start() t2.start() t1.join() t2.join()这里get(timeout=2)就是"不堵塞"的标准做法:队列为空时线程不会无限卡死,等两秒就抛一个queue.Empty异常让你处理退出逻辑。get_nowait()则是立刻返回,队列空就直接抛异常,适用于你确定当前不该等待的场景,比如UI线程里定时去轮询队列。
我踩过的坑是task_done()和join()搭配错位。q.join()会一直阻塞直到队列里所有任务都被task_done()标记为完成。如果你只get()却不调task_done(),那么join()会永远等下去,程序就"假死"了。当时我以为队列的get已经把任务拿走、队列空了,join就该返回,结果发现完全不是这样。get和task_done是一对,缺一个就出事。
3. 协程与嵌套线程:把并发写清楚的三种姿势
3.1 线程嵌套线程的踩坑记录:异常、爆炸与退不出的循环
先说一个我在真实项目里遇到的问题:批量下载几百个文件,我为每个大文件开了一个线程,然后每个线程内部又为了并发加速给文件切片再开了几个小线程。结果程序运行到一半,突然沉默了几分钟,然后炸出一堆报错。
排查了半天,根因有两个:
第一,内部线程的异常不会自动传给外层线程。外层线程看到内层线程异常退出,不会知道原因,主线程更不知道,整个流程在某个子任务失败后陷入僵局。你很难定位到底哪个切片失败了。
第二,线程数随任务量指数级膨胀。十个大任务、每个开五个小任务就是五十个线程,再叠加每批次波动,线程调度开销比干活还大,系统负载直线上升。
线程嵌套本身不是不能用,但必须有一个明确的管理层。现在我的建议是:能用单层就只用单层。外层用ThreadPoolExecutor管理任务,每个任务内部不要再开线程,而是把细粒度任务投递回到同一个线程池,或者最多用一层受限的嵌套。这是典型的需要控制"扇出"的场景。
给你看一个正面写法:
from concurrent.futures import ThreadPoolExecutor, as_completed def download(url): # 单个下载任务,不再开线程 return url def batch_download(urls): with ThreadPoolExecutor(max_workers=8) as executor: futures = [executor.submit(download, url) for url in urls] for future in as_completed(futures): try: print(future.result()) except Exception as e: print(f"任务失败: {e}")这一版的好处是:所有异常都汇流到as_completed循环里处理,哪些任务失败一目了然,线程池也会在with块退出时自动回收。
3.2 async/await协程:用单线程搞定高并发IO
协程是个好东西,但很多文章把它讲玄了。我尽量用最直白的话说:协程就是单线程里的"分时复用"。你的程序碰到await关键字的时候,知道自己要等网络请求(或者读写文件)返回,这段时间CPU闲着也是闲着,干脆跳去执行另一个任务。等网络响应回来了,再回过头继续往下走。
写异步代码只需要记住三件事:用async def定义协程函数,用await调用另一个协程,用asyncio.run()或事件循环驱动整个程序。
import asyncio import httpx async def fetch_one(url): async with httpx.AsyncClient(timeout=10) as client: resp = await client.get(url) return url, resp.status_code async def main(): urls = [ "https://api.example.com/data/1", "https://api.example.com/data/2", "https://api.example.com/data/3", ] tasks = [fetch_one(url) for url in urls] results = await asyncio.gather(*tasks) for url, code in results: print(f"{url} -> {code}") asyncio.run(main())注意,requests库是同步的,阻塞线程的那套逻辑放进async代码里不会自动变成异步。要在协程里发HTTP请求,请使用httpx或aiohttp这两个异步版本客户端。这个坑我见人踩过无数次:写了个async函数,里面用的还是requests.get(),结果并发请求把所有协程卡成了串行,性能一点没提升。
3.3 选型表:什么场景用线程、进程还是协程
这个表是我自己实践后整理的,不一定适合所有场景,但作为初始选型参考90%的情况够用:
| 场景类型 | 首选方案 | 备选方案 | 原因 |
|---|---|---|---|
| 网络请求、文件读写等IO密集型 | 协程(asyncio + httpx/aiohttp) | ThreadPoolExecutor | 协程单线程开销最小,并发量高 |
| 简单并发脚本、任务数量几十个 | ThreadPoolExecutor | 协程 | 线程池API简单,好理解好维护 |
| CPU密集计算(图像处理、数值计算) | ProcessPoolExecutor | multiprocessing直接开进程 | 绕开GIL限制,多核并行 |
| 混合型负载 | 协程并发IO + run_in_executor丢计算任务给进程池 | 拆成两个独立服务 | 让合适的东西去合适的执行器 |
有一句忠告想写给刚进阶的朋友:别一上来就追求异步。如果你到现在还没吃透同步代码,建议先把"阻塞任务丢进线程池"这条路走通,再碰async/await。异步代码调试起来比同步难得多,堆栈信息会绕好几个弯,报错原因藏在事件循环深处,对新手很不友好。
4. "李白打酒"这类递归题,练的是AI算法思维
4.1 原题、数学建模与暴力枚举思路
"李白打酒"是经典的算法练习题。原题大意:李白提着酒壶出门买酒,遇到酒店就把酒量翻一倍,遇到花就喝掉一斗。已知他出门时壶中有一定量的酒,总共遇到酒店和花若干次(经典说法是"三遇店和花"),最后恰好喝光。问壶中原有多少酒?
这道题在网络上的变体非常多,热搜词"李白打酒python"大多是问怎么用代码枚举所有可能过程的。常见的版本是:壶中初始不知道有多少酒,一共遇到店5次、花10次,最后壶中酒喝光,问初始酒量有多少种可能。
先做数学建模。定义wine为当前壶中酒量,shop为已经遇到的店数,flower为已经遇到的花数。初始状态是(wine, 0, 0),目标是(0, 5, 10)且最后一个动作必须是遇花。每走一步有两种选择:
def solve(wine, shop, flower, path): if shop == 5 and flower == 10: if wine == 0: solutions.append(path.copy()) return # 遇店:酒量翻倍 if shop < 5: solve(wine * 2, shop + 1, flower, path + ["店"]) # 见花:喝掉一斗 if flower < 10 and wine > 0: solve(wine - 1, shop, flower + 1, path + ["花"])直接暴力枚举的话,把wine从0到某个上限都试一遍,每个分支都递归下去,也能搜出答案。但效率不高,而且初始值范围不好定。更好的思路是逆向倒推:从最终状态"酒为0、店5花10"开始往回推,遇花的逆操作是加1斗,遇店的逆操作是减半。倒推的好处是状态空间大幅缩小,因为路径已经固定为那条唯一的过程序列,我们只需要验证每种可能的初始值能否走到终点。
实际代码里用DFS枚举过程更通用,因为它可以处理"店和花顺序有多种可能"的版本——这种版本问的是"有多少种行走顺序能让最后喝光",答案往往是一个几千上万的大数,正是搜索算法的用武之地。
4.2 递归+剪枝:把状态空间一步步压小
讲到DFS枚举,就一定要讲剪枝。不剪枝的机器人会把所有可能路径都走一遍,状态空间是指数级的。以"店5次、花10次、最后喝光"为例,总共15步,每一步只有两个选项,全部枚举是2的15次方,也就是32768种可能,这数量其实还好。但如果你把规模扩大到店和花各几十次,指数增长就变成天文数字了。
剪枝的本质是提前判断某条路不可能到达终点,直接放弃。以这道题为例,我常用的剪枝条件有三个:
- 酒量变成负数,直接不可能。
- 酒量不是整数,直接不可能(遇店翻倍后酒量一定是整数,减半后出现小数说明这条路径的倒推状态不合法)。
- 剩下的店和花数量已经少于还能做的步数,或者剩余酒量太大,花全喝完也清不掉,直接不可能。
# 完整可运行版本:统计方案数 def count_solutions(wine, shop, flower): if wine < 0 or wine % 1 != 0: return 0 if shop == 5 and flower == 10: return 1 if wine == 0 else 0 if shop == 5 and wine != flower: # 已无店可遇,酒量必须恰好等于剩余花数 return 1 if wine == flower else 0 if wine > 10 - flower: # 酒喝不完了,剪枝 return 0 total = 0 if shop < 5: total += count_solutions(wine * 2, shop + 1, flower) if flower < 10: total += count_solutions(wine - 1, shop, flower + 1) return total # 尝试初始酒量 1~20 for w in range(1, 21): cnt = count_solutions(w, 0, 0) if cnt > 0: print(f"初始酒量 {w} 斗,共有 {cnt} 种过程满足条件")这个版本里最关键的几行是wine > 10 - flower这个剪枝:剩余的见花次数最多能消耗等于次数的酒量,如果当前酒量超过这个数,后面无论如何都喝不完。剪枝条件写得好,指数级搜索可以被压成多项式级的遍历,这在人工智能的很多搜索算法里是同样的思路。
4.3 回溯思想在AI路径规划与贝叶斯搜索中的影子
学递归题的价值不在于背答案,而在于练"状态设计、剪枝、回溯"这套方法。这套方法在AI领域到处都是。
路径规划算法(比如机器人室内导航)本质上就是在网格或拓扑图上做深度优先或广度优先搜索,每走一步就是一种状态转移,撞墙就回溯换路。数独求解是典型的回溯:填一个空位、检查约束、不合法就回退。在国际象棋和围棋AI里,蒙特卡洛树搜索(MCTS)更是把"探索-回溯-再探索"这个循环放大到了极致,AlphaGo就是靠这个思路赢下棋局的。包括贝叶斯优化的搜索策略,也脱离不了"采样-评估-根据结果决定下一步采样范围"的循环。
所以我的建议是:刷题的时候不要只刷数量,每做一道递归/回溯题,问自己三个问题——状态是什么?转移动作有哪些?约束条件能不能帮我剪枝?想清楚这三个问题,比默写十道题都有用。
5. 让图表不再"翻车":matplotlib的横坐标与出图细节
5.1 横坐标太密集?先看数据量再看刻度策略
画个图本身不难,难的是画一张"能看、能交差、能进报告"的图。多数人第一次用matplotlib画时间序列时都会碰到一个问题:横坐标挤成一团黑压压的竖线,打出来根本没法看。"python画图横坐标太密集"这个热搜词背后,至少站着几千个深夜改图的同学。
为什么会密集?原因无非三类:
- 数据点太多,刻度自动生成时取了太多个位置。
- 横轴标签是长字符串(比如带时分秒的时间戳),每根柱子都标上就叠字了。
- 日期型坐标没有格式化,matplotlib直接把整数时间戳打印出来。
解决方案我都试过,最有效的三个手段:
import matplotlib.pyplot as plt import numpy as np x = np.arange(100) y = np.sin(x / 5) plt.figure(figsize=(10, 4)) # 手段一:限制刻度的最大数量 plt.locator_params(axis='x', nbins=10) # 手段二:手动指定刻度间隔 plt.xticks(ticks=np.arange(0, 100, 10)) # 手段三:旋转标签并右对齐,缓解长标签叠字 plt.xticks(rotation=45, ha='right') plt.plot(x, y) plt.tight_layout() plt.show()实际项目中我习惯先nbins=10把刻度数量压下来,再根据需求手动补关键节点,比如月度数据我就只标每年一月和七月。别指望matplotlib自动选刻度选得好,审美这回事它真的学不会。
如果横轴是日期时间,还需要额外用matplotlib.dates格式化。这里有一个绕不开的坑:pandas的DatetimeIndex直接传给matplotlib时,默认刻度标签是一长串数字。要显示成"2024-01"这种格式,得先用mdates.DateFormatter设置格式,再用mdates.MonthLocator指定间隔:
import matplotlib.dates as mdates fig, ax = plt.subplots() ax.plot(df.index, df["value"]) ax.xaxis.set_major_locator(mdates.MonthLocator(interval=1)) ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m")) plt.xticks(rotation=30, ha="right") plt.show()5.2 保存图片:格式、dpi、bbox_inches和中文乱码
画图只是第一步,把图保存成文件往往是最后一步,也是翻车重灾区。plt.savefig()和plt.show()的坑在于:很多人点完show()手动关掉窗口再去savefig(),存下来的经常是一张空白图。正确做法是先savefig,再show,或者给savefig传figure引用。
保存参数各有各的用处:
| 参数 | 常用值 | 应用场景 |
|---|---|---|
dpi | 150(网页)、300(论文/打印) | 控制清晰度 |
bbox_inches | "tight" | 自动裁剪空白,避免坐标标签被砍掉 |
transparent | True | 在背景色非白色的页面上嵌入图片 |
format | png/pdf/svg | 位图和矢量图之间的取舍 |
plt.savefig("output.png", dpi=300, bbox_inches="tight")格式选择也有讲究。网页和PPT里用PNG最省心,论文投稿建议用PDF矢量图,这样放大到任意尺寸都不会糊,编辑也挑不出毛病。SVG适合Web项目,因为它是浏览器直接支持的矢量格式。
中文乱码是另一大痛点。matplotlib默认字体不含中文字符,任何中文标签都会变成方框。最常见的解决方法是显式指定一个系统自带中文字体:
plt.rcParams["font.sans-serif"] = ["SimHei", "Microsoft YaHei", "WenQuanYi Zen Hei"] plt.rcParams["axes.unicode_minus"] = False # 解决负号显示成方框的问题在Windows上SimHei和Microsoft YaHei基本都有;Linux服务器上的matplotlib出中文乱码,通常是系统没装中文字体,先apt install fonts-wqy-zenhei再指定字体就能解决。macOS可以用PingFang SC或Arial Unicode MS。
5.3 一张"能直接进报告"的图,需要做的五件事
被导师、甲方、读者反复教育之后,我现在出图前固定检查五件事:
- 全局设置字体和字号。标题不小于14,坐标轴标签不小于12,不然图插到PPT里一缩放就什么都看不清。
- 坐标轴要有单位和含义。很多人只写"时间"和"数值",不如写"时间(月)"和"收入(万元)",一目了然。
- 网格线能帮读图,但别喧宾夺主。
plt.grid(True, alpha=0.3)比默认网格线淡很多,既能对齐数据点又不抢主线。 - 保存参数统一。我用固定模板:
dpi=300加bbox_inches="tight",一张图只出了一个尺寸,不会因为窗口大小变化导致图幅忽大忽小。 - 核心结论直接在图上标注。在峰值点加一个
annotate,写一句"Max: 12.5",比让读者自己去找峰值友好得多。
import matplotlib.pyplot as plt import numpy as np def build_chart(x, y, title, xlabel, ylabel, output_path): plt.rcParams["font.sans-serif"] = ["SimHei", "Microsoft YaHei"] plt.rcParams["axes.unicode_minus"] = False fig, ax = plt.subplots(figsize=(8, 5)) ax.plot(x, y, marker="o", linewidth=2) ax.set_title(title, fontsize=14) ax.set_xlabel(xlabel, fontsize=12) ax.set_ylabel(ylabel, fontsize=12) ax.grid(True, alpha=0.3) ax.locator_params(axis="x", nbins=8) max_idx = int(np.argmax(y)) ax.annotate(f"Max: {y[max_idx]:.2f}", xy=(x[max_idx], y[max_idx]), xytext=(x[max_idx] - 2, y[max_idx] + 1), arrowprops=dict(arrowstyle="->")) fig.tight_layout() fig.savefig(output_path, dpi=300, bbox_inches="tight") plt.close(fig)把画图封装成一个纯函数,传参数出图,是我强烈推荐的习惯。好处很明显:不用每次重画都复制粘贴一大段配置,改参数只要改入口。
6. 综合实战:构建"距离矩阵热力图"一条龙小项目
6.1 需求设计与方案取舍:怎么把散装知识串成项目
最后用一个小项目把这篇文章的知识点串起来。我做的是"城市两两距离矩阵可视化":手头有若干城市的经纬度,计算每两个城市之间的球面距离,构建距离矩阵,然后画一张热力图存成PNG文件。
这个项目虽然小,但五脏俱全:
- 搭建邻接/距离矩阵,用到第2章的矩阵思维。
- 用
ThreadPoolExecutor并发计算多对城市距离,用到第3章的并发选型(虽然计算量小,主要是演示结构)。 - 用
matplotlib画热力图,用到第5章的出图细节。
数据来源我直接用内置经纬度,不涉及网络请求,这样人人都能跑通。你想换成真实数据,把城市列表换成从公开地图API拉回来的坐标就行。
它的价值不在于难,而在于你能在20分钟内从零跑通一个"输入城市列表,输出一张能看的矩阵图"的完整流程。
6.2 核心代码与关键点注释
import numpy as np import matplotlib.pyplot as plt from concurrent.futures import ThreadPoolExecutor, as_completed # 城市:名称 -> (纬度, 经度) cities = { "北京": (39.9042, 116.4074), "上海": (31.2304, 121.4737), "广州": (23.1291, 113.2644), "成都": (30.5728, 104.0668), "武汉": (30.5928, 114.3055), "西安": (34.3416, 108.9398), } # 半正矢公式(Haversine)计算球面距离(单位:公里) def haversine(lat1, lon1, lat2, lon2): R = 6371.0 p1 = np.radians(lat1) p2 = np.radians(lat2) dp = np.radians(lat2 - lat1) dl = np.radians(lon2 - lon1) a = np.sin(dp / 2) ** 2 + np.cos(p1) * np.cos(p2) * np.sin(dl / 2) ** 2 return 2 * R * np.arcsin(np.sqrt(a)) names = list(cities.keys()) n = len(names) # 任务列表:计算所有城市对的距离 tasks = [(i, j) for i in range(n) for j in range(i + 1, n)] dist_matrix = np.zeros((n, n)) # 用线程池并发计算距离(对称矩阵,只需算上三角) def calc(task): i, j = task lat1, lon1 = cities[names[i]] lat2, lon2 = cities[names[j]] return i, j, haversine(lat1, lon1, lat2, lon2) with ThreadPoolExecutor(max_workers=4) as executor: futures = [executor.submit(calc, t) for t in tasks] for future in as_completed(futures): i, j, dist = future.result() dist_matrix[i][j] = dist dist_matrix[j][i] = dist # 画热力图 fig, ax = plt.subplots(figsize=(8, 6)) im = ax.imshow(dist_matrix, cmap="YlOrRd", interpolation="nearest") ax.set_xticks(range(n)) ax.set_yticks(range(n)) ax.set_xticklabels(names, rotation=45, ha="right") ax.set_yticklabels(names) # 在格子中心标注数值 for i in range(n): for j in range(n): ax.text(j, i, f"{dist_matrix[i][j]:.0f}", ha="center", va="center", color="black", fontsize=9) fig.colorbar(im, ax=ax, shrink=0.8) fig.suptitle("城市间球面距离矩阵(公里)", fontsize=14) fig.tight_layout() fig.savefig("distance_matrix.png", dpi=200, bbox_inches="tight") plt.close(fig) print(dist_matrix)代码里几个细节值得讲一下:
- 我用
interpolation="nearest"保留了格子的清晰边界,热力图边缘不会糊成渐变,更适合看矩阵格子。 - 因为矩阵是对称的,
tasks只生成了i < j的上三角任务,计算量几乎省了一半。 plt.close(fig)是主动关图释放内存。如果你在循环里连续画几十张图又不关,内存会明显上涨。
6.3 跑通之后,还可以往三个方向进阶
这个demo跑通后,想继续往"精通"方向走,我建议按以下顺序扩展:
第一,把距离矩阵喂给图算法库networkx,用nx.from_numpy_array把矩阵变成图对象,然后直接跑最短路径(Dijkstra)、最小生成树(MST)这些经典算法。这是从"数据结构"走向"算法应用"最自然的一步。
第二,把并发部分替换成协程。如果你的项目从"城市坐标内置"换成"从公开API批量获取坐标",网络IO就变成核心瓶颈了,此时用httpx.AsyncClient配合asyncio.gather来并发请求,性能会比线程池再上一个台阶。注意把while True+sleep这套同步逻辑换掉,异步代码不能按同步思维写。
第三,把可视化从静态热力图升级成交互式地图。用folium把城市经纬度直接画到真实地图上,点与点之间的连线粗细表示距离远近,这样输出比一张PNG热力图直观得多,做汇报展示的时候效果拔群。
我自己写这个例子最大的体会是:知识不串起来就是死的。当你把数组切片、矩阵、并发、画图一个个知识点放进一个真实能跑的小项目里,它们之间会自然产生化学反应。那些"热搜词"里的问题,比如"python构建邻接矩阵""python画图横坐标太密集""python队列queue不堵塞",本质上都是某个项目链条上断掉的一环。把一环一环扣回去,进阶的路就走顺了。你手头如果有想练的手上项目,建议也按这个思路拆一拆:明确输入输出,挑一把合适的并发工具,把结果可视化出来,然后存好图、写好注释,让它成为你作品集里的一张牌。