十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

用Python实现关键路径法:分析网约车订单履约链路瓶颈

用Python实现关键路径法:分析网约车订单履约链路瓶颈 在网约车订单履约链路里跑完一单并不只是“从A点到B点”那么简单。从用户发起订单、系统校验、平台派单、司机接单、前往上车点到乘客上车、途中计费、到达结算这些环节存在明确的前置依赖也有一部分可以并行。要判断哪条链路真正决定整单耗时CPMCritical Path Method关键路径法是一个很直接的工程分析工具。需要先说明这里的CPM不是广告行业按展示付费的Cost Per Mille而是项目管理里用来排工期、找瓶颈的关键路径法。这篇文章以一份网约车订单任务清单作为案例从任务拆解、依赖建模开始逐步实现一个基于Python的CPM计算脚本并解释如何找到关键路径、计算浮动时间以及结果在项目排期和流程优化中怎么用。对于刚接触项目管理算法的开发者这个案例能帮你把CPM从抽象概念变成可运行代码对于已经做过任务调度的工程师重点可以放在依赖建模和生产环境注意事项上。1. 为什么跑一单网约车也要算关键路径1.1 从订单流程看任务依赖与并行关系在真实系统中订单履约不是单线程。乘客发起订单后系统可能同时做身份校验、费用预估、附近司机筛选用户也可能同时走向上车点。每个环节的前置条件不同耗时也不同。如果把所有环节画成一张有向无环图节点表示活动箭头表示“必须先完成”那么整张图会呈现多条从开始到结束的路径。路径长度不同决定整单完成时间的不是所有环节耗时之和而是最长的那条路径这条路径就是关键路径。举一个直观的例子司机前往上车点需要12分钟乘客走向上车点只需要6分钟。哪怕乘客早就到了司机没到之前仍然无法上车。所以真正决定上车节点开始时间的是司机到达而不是乘客准备。对应到排程上司机前往上车点所在路径更关键乘客准备活动即使晚一点开始也不会影响整体完成时间。订单履约和传统项目排期在结构上非常相似。一个项目里有多个任务任务之间有先后关系也有并行关系。项目管理者最关心的问题通常是整个项目最短需要多久哪些任务一旦延期就会拖慢整体进度。CPM就是用来回答这两个问题的。1.2 CPM的核心概念CPM通过四个时间字段描述每个活动ESEarliest Start最早开始时间所有前置活动都完成的情况下该活动最早可以开始的时间。EFEarliest Finish最早完成时间EF等于ES加活动工期。LSLatest Start最晚开始时间在不影响项目总工期的前提下该活动最晚必须开始的时间。LFLatest Finish最晚完成时间LF等于LS加活动工期。由ES和LS可以算出总浮动时间Total FloatTFTF等于LS减ES也等于LF减EF。TF为0的活动不能延迟否则总工期会变长。把这些TF为0且存在串联依赖的活动串起来就得到关键路径。关键路径的总时长就是项目最短可能工期。学习CPM时最容易混淆的是“最早”和“最晚”。最早时间是从项目起点正向推出来的最晚时间是从项目终点反向推出来的。正向计算回答“最早何时能开始”反向计算回答“最晚何时必须开始”两者相减才得到活动可以自由浮动的时间。正向计算依赖前置活动反向计算依赖后继活动所以代码里必须先建立“后继活动”关系。1.3 CPM适合哪些场景CPM适合活动之间有明确依赖关系、工期可以估计、目标是找最短总工期或优化排期的场景。典型的项目管理、产品发布排期、订单履约流程梳理都适用。它不适合处理实时性极强、资源动态变化的场景。比如实时网约车调度中司机位置、道路拥堵、订单供需都在分钟级变化平台通常会使用数学规划或启发式算法而不是先画一张静态CPM图。但CPM依然可以用来分析“订单履约链路中哪个环节是瓶颈”或者评估某个新功能上线后是否会影响订单整体完成时长。理解这个边界很重要否则容易把项目管理工具用在错误的问题上。2. 环境准备与网约车订单任务建模2.1 开发环境依赖本文使用Python脚本完成CPM计算核心算法只用标准库不需要额外安装第三方包。开发环境大致要求如下。项目要求Python3.8及以上操作系统Windows / macOS / Linux均可依赖包无需安装第三方包文件建议新建cpm_ride_demo.py输入方式硬编码活动字典便于教学生产环境可改为读JSON或数据库使用纯标准库的好处是在任意Python环境下复制运行即可不需要处理虚拟环境依赖冲突。如果不确定本机Python版本可以运行下面的命令确认。python --version如果命令报错通常说明Python没有加入PATH或者环境变量未生效和脚本逻辑无关。2.2 活动清单与依赖关系设计为了让示例尽量接近真实订单链路同时又能体现并行关系这里把“跑一单网约车”拆成11个活动。活动内容工期分钟前置活动A用户发起订单2无B系统校验用户与支付方式1AC计算预估里程与费用2AD平台派单3B, CE司机接单确认4DF司机前往上车点12EG乘客准备并走向上车点6AH乘客上车确认1F, GI行程导航与实时计费15HJ到达目的地并结算3IK乘客评价并完成订单1J这个模型有两条并行分支一条是系统侧的B、C、D、E、F链路另一条是用户侧的G活动。H节点要求F和G都完成所以司机到达路线和乘客准备路线在这里汇合。通过CPM可以证明G比F多出9分钟浮动时间因此在日常运营中乘客准备通常不会成为订单完成瓶颈。2.3 工期单位与边界约定示例中所有工期统一使用“分钟”这是CPM计算成立的重要前提。如果B用分钟F用秒正向计算得到的ES会错得非常隐蔽。在实际项目中还需要提前约定清楚活动是否包含排队等待时间。一个活动开始时刻是“上一活动结束的时刻”还是“上一活动结束并完成状态上报的时刻”。是否考虑跨天、休息时间、资源不足导致的等待。如果存在不确定性工期应该使用三点估算或PERT而不是直接塞一个固定值。示例模型假设每个活动一旦可以开始就立即开始没有资源争抢也没有中途失败重试。这些假设在真实生产环境里几乎不会完全成立但作为学习CPM的最小模型是合适的。3. 用Python实现CPM核心计算3.1 数据结构与输入约定活动字典是核心输入。每个活动包含两个字段duration表示工期predecessors表示它开始前必须完成的活动名称列表。为了让算法更通用所有活动名称使用字符串这样后续可以从JSON或数据库表加载。from collections import defaultdict, deque from typing import Dict, List, Set # 活动数据duration 单位为分钟predecessors 为该活动开始前必须完成的活动 ACTIVITIES: Dict[str, dict] { A: {duration: 2, predecessors: []}, B: {duration: 1, predecessors: [A]}, C: {duration: 2, predecessors: [A]}, D: {duration: 3, predecessors: [B, C]}, E: {duration: 4, predecessors: [D]}, F: {duration: 12, predecessors: [E]}, G: {duration: 6, predecessors: [A]}, H: {duration: 1, predecessors: [F, G]}, I: {duration: 15, predecessors: [H]}, J: {duration: 3, predecessors: [I]}, K: {duration: 1, predecessors: [J]}, }这种写法的优点是每个活动的前置依赖一目了然适合教学。缺点是活动一多手写字典容易漏掉前置关系。生产环境建议从外部配置读取并用脚本统一校验活动名称是否存在。3.2 拓扑排序先保证任务图无环CPM要求活动依赖图是有向无环图。如果存在循环依赖正向计算会进入死循环结果也没有意义。因此第一步是进行拓扑排序。Kahn算法思路很简单不断找“当前没有未完成前置”的活动并把它们从图中移除如果移除后仍有活动剩余说明图中存在环。def compute_cpm(activities: Dict[str, dict]): preds: Dict[str, Set[str]] { name: set(act[predecessors]) for name, act in activities.items() } succs: Dict[str, Set[str]] defaultdict(set) for name, act in activities.items(): for p in act[predecessors]: succs[p].add(name) indegree {name: len(preds[name]) for name in activities} queue deque([name for name, degree in indegree.items() if degree 0]) topo: List[str] [] while queue: node queue.popleft() topo.append(node) for nxt in sorted(succs[node]): indegree[nxt] - 1 if indegree[nxt] 0: queue.append(nxt) if len(topo) ! len(activities): raise ValueError(活动依赖图存在循环依赖请检查前置关系) return topo, preds, succs这里用sorted保证同一层的活动按字母顺序出队便于复现输出。如果不在乎顺序可以去掉sorted但调试时稳定顺序能减少困惑。3.3 正向计算得到最早开始和最早完成时间用拓扑序从左到右计算ES和EF。一个活动的ES是它所有前置活动中EF的最大值如果没有前置ES就是0。def compute_time_fields(activities, topo, preds, succs): es {name: 0 for name in activities} ef {name: 0 for name in activities} for node in topo: if preds[node]: es[node] max(ef[p] for p in preds[node]) ef[node] es[node] activities[node][duration] project_duration max(ef.values()) lf {name: project_duration for name in activities} ls {name: project_duration for name in activities} for node in reversed(topo): if succs[node]: lf[node] min(ls[nxt] for nxt in succs[node]) else: lf[node] project_duration ls[node] lf[node] - activities[node][duration] tf {name: ls[name] - es[name] for name in activities} return es, ef, ls, lf, tf, project_duration为什么取最大值而不是求和因为D活动要等B和C都完成而B在3分钟完成C在4分钟完成所以D最早只能在4分钟开始。取最大值表达的就是“所有前置都完成”的约束。反向计算时LF取的是后继活动LS的最小值。同样是因为当前活动一旦延后会影响所有后继活动必须选择最紧的那个后继时间作为约束才能保证总工期不超。3.4 识别关键路径与浮动时间浮动时间等于LS减ES。TF为0的活动就是关键活动。关键路径并不是简单把所有TF为0的活动拼起来。还要确保活动之间存在前后置关系并且路径从起点一直延伸到终点。可以用DFS从起点开始只沿着TF为0的后继活动走。def find_critical_paths(activities, preds, succs, tf): starts [name for name in activities if not preds[name]] paths: List[List[str]] [] def dfs(node: str, path: List[str]): cur path [node] if not succs[node]: if all(tf[x] 0 for x in cur): paths.append(cur) return for nxt in sorted(succs[node]): if tf[nxt] 0: dfs(nxt, cur) for start in starts: if tf[start] 0: dfs(start, []) return paths如果项目存在多个起点或多个终点这段逻辑也能正确处理。只要起点TF为0并且后续有TF为0的完整链路就会输出一条完整关键路径。3.5 组装一个可运行脚本把函数组合后可以得到下面这份完整脚本。读者可以直接保存为cpm_ride_demo.py运行。from collections import defaultdict, deque from typing import Dict, List, Set ACTIVITIES: Dict[str, dict] { A: {duration: 2, predecessors: []}, B: {duration: 1, predecessors: [A]}, C: {duration: 2, predecessors: [A]}, D: {duration: 3, predecessors: [B, C]}, E: {duration: 4, predecessors: [D]}, F: {duration: 12, predecessors: [E]}, G: {duration: 6, predecessors: [A]}, H: {duration: 1, predecessors: [F, G]}, I: {duration: 15, predecessors: [H]}, J: {duration: 3, predecessors: [I]}, K: {duration: 1, predecessors: [J]}, } def compute_cpm(activities: Dict[str, dict]): preds: Dict[str, Set[str]] { name: set(act[predecessors]) for name, act in activities.items() } succs: Dict[str, Set[str]] defaultdict(set) for name, act in activities.items(): for p in act[predecessors]: succs[p].add(name) indegree {name: len(preds[name]) for name in activities} queue deque([name for name, degree in indegree.items() if degree 0]) topo: List[str] [] while queue: node queue.popleft() topo.append(node) for nxt in sorted(succs[node]): indegree[nxt] - 1 if indegree[nxt] 0: queue.append(nxt) if len(topo) ! len(activities): raise ValueError(活动依赖图存在循环依赖请检查前置关系) return topo, preds, succs def compute_time_fields(activities, topo, preds, succs): es {name: 0 for name in activities} ef {name: 0 for name in activities} for node in topo: if preds[node]: es[node] max(ef[p] for p in preds[node]) ef[node] es[node] activities[node][duration] project_duration max(ef.values()) lf {name: project_duration for name in activities} ls {name: project_duration for name in activities} for node in reversed(topo): if succs[node]: lf[node] min(ls[nxt] for nxt in succs[node]) else: lf[node] project_duration ls[node] lf[node] - activities[node][duration] tf {name: ls[name] - es[name] for name in activities} return es, ef, ls, lf, tf, project_duration def find_critical_paths(activities, preds, succs, tf): starts [name for name in activities if not preds[name]] paths: List[List[str]] [] def dfs(node: str, path: List[str]): cur path [node] if not succs[node]: if all(tf[x] 0 for x in cur): paths.append(cur) return for nxt in sorted(succs[node]): if tf[nxt] 0: dfs(nxt, cur) for start in starts: if tf[start] 0: dfs(start, []) return paths def main(): topo, preds, succs compute_cpm(ACTIVITIES) es, ef, ls, lf, tf, project_duration compute_time_fields( ACTIVITIES, topo,
返回列表