Python:排序、查找、树、堆、图这些基础,怎样从题解走向代码能力

学完双指针、滑动窗口、回溯、动态规划之后,下一步常会卡在一个地方:

  • 题会做
  • 名词也认识
  • 一到项目里却认不出排序、查找、树、堆、图到底对应什么问题

这类内容真正难的地方,不是模板本身,而是怎么把问题形状映射回合适的数据结构和算法写法

这一篇还是不按题型背,而是围绕一个实际场景展开:做一个夜间任务中心的调度和分析脚本

这个脚本要完成下面这些事:

  1. 把待执行任务按优先级和截止时间排顺
  2. 快速判断某个阈值是否已经越界
  3. 递归遍历树形任务目录
  4. 从大量任务里持续拿到最紧急的一批
  5. 处理有依赖关系的任务执行顺序

只要这条链能真正串起来,排序、查找、树、堆、图就不会再停留在刷题层。

一、这篇文章要解决什么问题

读完这一篇,应该能独立完成这些动作:

  • 知道排序和查找在真实脚本里最常落到哪些问题
  • 知道树结构不只是“二叉树题”,它经常就是目录、菜单、配置结构
  • 知道堆为什么特别适合优先级任务和 TopK
  • 知道图为什么经常对应依赖关系,而不是抽象数学对象
  • 能从一个实际需求里判断应该先想到哪类结构
  • 能识别几类高频错误,例如依赖环没处理、堆顺序理解错、排序字段不稳定

二、先看这个脚本到底要做什么

假设现在有一个最简单的任务中心,任务数据像这样:

1
2
3
4
5
tasks = [
{"task_id": 1001, "priority": 3, "deadline": 12, "duration": 5},
{"task_id": 1002, "priority": 1, "deadline": 4, "duration": 2},
{"task_id": 1003, "priority": 2, "deadline": 6, "duration": 3},
]

同时还有依赖关系:

1
2
3
4
5
6
dependencies = {
"prepare_env": [],
"sync_data": ["prepare_env"],
"run_case": ["sync_data"],
"collect_report": ["run_case"],
}

目录里还有一批分层配置:

1
2
3
4
5
6
jobs/
├── nightly/
│ ├── order_sync.json
│ └── user_sync.json
└── smoke/
└── login_check.json

这个脚本要解决的,不只是“把任务跑掉”,而是:

  • 谁先跑
  • 谁可以跳过
  • 谁依赖谁
  • 谁最紧急
  • 哪些配置文件属于同一棵树

三、先从最直观的一层开始:排序

排序在工程里最常见的落点通常不是考试题,而是:

  • 报告按时间排序
  • 任务按优先级排序
  • 缺陷按风险排序
  • 指标按耗时或错误率排序

对当前场景来说,最直接的问题就是:

任务到底该按什么顺序排。

如果业务规则是:

  • 优先级越小越先执行
  • 同优先级下,截止时间越近越先执行

写法通常会是:

1
2
3
ordered_tasks = sorted(tasks, key=lambda item: (item["priority"], item["deadline"]))
for item in ordered_tasks:
print(item["task_id"], item["priority"], item["deadline"])

输出:

1
2
3
1002 1 4
1003 2 6
1001 3 12

排序真正要抓住的不是语法,而是:

  • 排序字段是什么
  • 主次排序关系是什么
  • 这个顺序是否稳定且能解释

四、一个实际错误:排序键只写了一半

下面这种写法最容易埋坑:

1
ordered_tasks = sorted(tasks, key=lambda item: item["priority"])

它在数据少时看起来没问题,但一旦多个任务优先级相同,就会暴露出两个问题:

  • 同优先级下顺序不一定符合业务意图
  • 后续排查时很难解释为什么这条任务排在前面

更稳的写法通常是显式把二级键也写出来:

1
ordered_tasks = sorted(tasks, key=lambda item: (item["priority"], item["deadline"]))

如果顺序承载调度含义,就不要让“刚好原始顺序如此”替你做决定。

五、查找真正高频的样子,往往不是线性扫,而是阈值定位

查找在项目里经常会以这些问题出现:

  • 某个指标第一次超过阈值的位置在哪
  • 某个时间点应该落在哪个区间
  • 一个延迟值属于哪个告警等级

最小例子:

1
2
thresholds = [50, 100, 200, 500]
latency = 180

这个问题不是“列表里有没有 180”,而是:

180 应该落在哪个阈值区间。

这时二分查找就比线性扫更自然:

1
2
3
4
5
6
from bisect import bisect_right

thresholds = [50, 100, 200, 500]
latency = 180
index = bisect_right(thresholds, latency)
print(index)

输出:

1
2

这表示:

  • 180 右侧应插在下标 2
  • 它落在 100 ~ 200 这个区间里

六、一个实际错误:数据没排序就直接二分

二分查找的前提非常简单,也非常容易被忽略:

  • 数据必须已经有序

错误写法:

1
2
thresholds = [200, 50, 500, 100]
index = bisect_right(thresholds, 180)

这类代码通常不会直接报错,但结果没有可信度。
所以工程里真正要记住的是:

  1. 先确认查找容器是否已排序
  2. 再决定要不要上二分
  3. 如果容器持续变动,维护有序性本身也要算成本

七、树结构最常见的样子,其实就是目录和层级配置

一看到“树”,最容易先想到二叉树题。
放到真实脚本里,树通常长成这些样子:

  • 文件目录
  • 菜单结构
  • 权限层级
  • 任务编排树
  • 树形 JSON 配置

对当前脚本来说,最直接的树就是任务目录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from pathlib import Path


def collect_job_files(base_dir):
result = []

def walk(current):
for item in current.iterdir():
if item.is_dir():
walk(item)
elif item.suffix == ".json":
result.append(item)

walk(Path(base_dir))
return sorted(result)

这段写法的本质不是“递归题”,而是:

  • 当前节点下面还有子节点
  • 子节点的处理方式和当前层一致

只要看到这种一层套一层的层级结构,就该先想到树。

八、堆最适合的,不是背定义,而是持续拿到最紧急的一批

堆在工程里特别适合这类问题:

  • 持续取出最小值或最大值
  • 维护 TopK
  • 优先级任务调度
  • 延迟任务队列

放到当前场景里,可以把堆理解成:

任务很多,但每次只想拿当前最紧急的一条。

最小示例:

1
2
3
4
5
6
7
8
9
import heapq

heap = []

for item in tasks:
heapq.heappush(heap, (item["priority"], item["deadline"], item["task_id"]))

while heap:
print(heapq.heappop(heap))

输出:

1
2
3
(1, 4, 1002)
(2, 6, 1003)
(3, 12, 1001)

堆的价值不在于“一次性排完”,而在于:

  • 插入一个新任务后不用整体重排
  • 随时都能拿到当前最小项

九、一个实际错误:把堆当成完整有序列表

下面这种误判非常常见:

1
2
3
4
5
heap = []
for item in tasks:
heapq.heappush(heap, item["priority"])

print(heap)

这里很容易把打印出来的 heap 直接理解成完整有序序列。
其实不是。

堆只保证:

  • heap[0] 是当前最小项
  • 父节点满足堆序关系

它不保证整个列表从头到尾完全排好。
如果要逐个拿出完整顺序,应该持续 heappop()

十、图最常落到依赖关系,不是抽象建模题

图在工程里最常见的落点通常是:

  • 任务依赖
  • 服务调用关系
  • 权限继承关系
  • 流程节点跳转

当前场景里最直接的图就是:

1
2
3
4
5
6
dependencies = {
"prepare_env": [],
"sync_data": ["prepare_env"],
"run_case": ["sync_data"],
"collect_report": ["run_case"],
}

这不是树,因为一个节点理论上可以依赖多个前置。
这种结构最常见的问题是:

  • 谁先执行
  • 有没有环
  • 哪一层没准备好

最小可运行版本通常会做一次拓扑排序。

十一、用拓扑排序把依赖执行顺序真正算出来

下面给一个最小版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
from collections import deque


def topo_sort(dependencies):
indegree = {node: 0 for node in dependencies}
graph = {node: [] for node in dependencies}

for node, parents in dependencies.items():
indegree[node] = len(parents)
for parent in parents:
graph.setdefault(parent, []).append(node)

queue = deque([node for node, degree in indegree.items() if degree == 0])
result = []

while queue:
node = queue.popleft()
result.append(node)

for child in graph.get(node, []):
indegree[child] -= 1
if indegree[child] == 0:
queue.append(child)

if len(result) != len(indegree):
raise ValueError("dependency cycle detected")

return result

执行:

1
print(topo_sort(dependencies))

输出:

1
['prepare_env', 'sync_data', 'run_case', 'collect_report']

十二、一个实际错误:依赖环没处理,脚本却还在假装能排顺序

如果数据变成这样:

1
2
3
4
5
6
bad_dependencies = {
"prepare_env": ["collect_report"],
"sync_data": ["prepare_env"],
"run_case": ["sync_data"],
"collect_report": ["run_case"],
}

这时依赖已经成环。
如果代码没有显式检测,就会出现两种后果:

  • 队列从一开始就是空的
  • 结果列表长度异常,却没有任何报错信号

所以图相关问题真正该优先补的是:

  1. 顺序怎么算
  2. 环怎么发现
  3. 发现环后怎么中止,而不是继续往后跑

十三、把排序、查找、树、堆、图放回同一条实际处理链

把这一篇重新串起来,会更容易理解迁移关系:

1. 树负责把配置文件和目录结构收上来

先把散落在不同目录里的任务配置找齐。

2. 排序负责把任务列表按业务规则排顺

不是所有任务都先进先出,有些必须按优先级和截止时间排序。

3. 查找负责做阈值定位和等级判断

例如某个耗时应该落在哪个告警等级。

4. 堆负责持续拿出最紧急的任务

任务流不停变化时,堆比反复全排序更顺手。

5. 图负责解决依赖顺序和环检测

只要任务之间存在“先做谁再做谁”,图就会反复出现。

这才是算法真正该建立的感觉:
不是背模板,而是看到问题形状时知道先去哪个工具箱里找答案。

十四、怎么测试这一层是不是真的掌握了

先给一个最小测试方向:

1
2
3
4
5
6
7
def test_topo_sort_should_return_dependency_order():
deps = {
"prepare_env": [],
"sync_data": ["prepare_env"],
"run_case": ["sync_data"],
}
assert topo_sort(deps) == ["prepare_env", "sync_data", "run_case"]

除了 happy path,至少还要补这些:

  • 排序:主键相同、二级键冲突、空列表
  • 查找:阈值边界、空区间、无序输入
  • 树:空目录、深层嵌套、非 JSON 文件混入
  • 堆:新任务持续插入、重复优先级、空堆弹出
  • 图:单节点、并行分支、依赖环

这类内容只要不补边界测试,后面一复杂就很容易误判。

十五、一个实际排错场景

值班里特别常见的一类问题是:

脚本明明说任务能执行,真正跑时却总是“环境未准备好”。

后来看代码,问题往往不是执行器坏了,而是依赖顺序没排对。

典型现象:

  • run_case 先于 sync_data 被执行
  • 报告里到处都是“前置资源缺失”

排查顺序通常应该这样走:

  1. 先看任务列表是怎么排序的
  2. 再看是否把“优先级排序”误当成“依赖顺序”
  3. 最后发现依赖关系根本没进入调度逻辑

修复方向不是继续加更多 if,而是:

  • 先把依赖抽成图
  • 再做拓扑排序
  • 再把排序结果作为调度输入

这类错误很有代表性,因为它说明:

  • 排序能解决“谁更靠前”
  • 图才能解决“谁必须先发生”

十六、一个更接近值班现场的完整案例

如果把这一篇再往值班现场拉近一点,会更容易看到这些结构为什么经常一起出现。

假设现在有一个凌晨失败任务中心,值班人员早上要在 20 分钟内交付三样东西:

  1. 一份失败配置文件清单
  2. 一份优先处理顺序
  3. 一份依赖安全的补跑顺序

更顺的处理线通常是:

1. 先用树把文件和目录结构收齐

如果 jobs/ 下有:

1
2
3
4
5
6
jobs/
├── critical/
│ ├── order_sync.json
│ └── payment_sync.json
└── normal/
└── user_profile.json

第一步要先确认不是漏扫了某个目录。
这一步本质上不是“递归题”,而是处理树形目录。

2. 再用排序形成一份可以解释的处理顺序

例如:

  • critical 目录优先
  • 同目录下失败次数高的优先
  • 同失败次数下截止时间近的优先

只有顺序能解释,值班时才敢真正按这份列表处理。

3. 再用查找快速定位告警等级和阈值区间

对耗时、错误率、队列长度这类指标,最关心的通常不是“有没有这个值”,而是:

  • 这个值已经落在哪个等级里
  • 是不是已经越界

这也是查找在工程里的真实样子。

4. 再用堆维护“最先要看”的 TopN

如果现场还有新任务不断进来,反复整体排序就会越来越重。
这时堆会比每次全量重排更顺。

5. 最后用图做依赖校验

排序只能说“谁更靠前”,
图才能说“谁必须先发生”。

只要存在:

  • 环境准备
  • 数据同步
  • 用例执行
  • 报告归档

这种前后依赖,图就很难绕开。

十七、什么时候该用排序、堆、图,什么时候不要硬套

学完这些基础之后,最容易出现的误判就是:
看到顺序问题就想排序,看到依赖就全上图,看到 TopK 就先写堆。

更稳的判断通常是:

1. 排序适合一次性生成稳定顺序

如果现在就是要拿到一份完整、可解释的顺序列表,排序通常是最直接的。

2. 堆适合持续流式拿最值

如果任务会不停插入、每次只想拿最小项或 TopK,堆更顺。

3. 图只在真正有依赖语义时才值得上

如果只是简单优先级先后,不一定需要图。
只有涉及“必须先完成谁,再执行谁”的依赖关系,图才是对的工具。

4. 查找的前提永远要先看数据结构

只要准备上二分或区间查找,先问的问题应该是:
当前容器到底是不是有序的。

真正有价值的不是“会背结构定义”,而是知道:

  • 这层问题最轻的解法是什么
  • 什么时候继续优化已经不划算

十八、一个实际练习

练习目标:写一个“任务中心预处理脚本”。

要求:

  1. 递归扫描 jobs/ 目录里的 JSON 文件
  2. 用排序把任务按优先级和截止时间排顺
  3. bisect 判断耗时所属等级
  4. 用堆持续拿出当前最紧急的 3 条任务
  5. 用拓扑排序输出依赖执行顺序
  6. 故意制造一组依赖环,并把错误显式报出来

如果这个练习能独立做完,说明这几类基础结构已经开始进入真实代码能力。

十九、结语

排序、查找、树、堆、图这些内容最值得补的,不是为了刷完题单,而是为了在写脚本、做调度、做目录扫描、做依赖分析时,能更快把问题翻译成合适的结构。

回头看这一篇:

  • 排序处理顺序规则
  • 查找处理区间和阈值
  • 树处理层级结构
  • 堆处理优先级流
  • 图处理依赖关系

能把这五类结构和真实问题连起来,后面再学更复杂的调度、缓存、资源编排和工作流设计,难度会低很多。