
网络计划技术这套东西我在做研发交付和产线导入的项目排期时用得最多。它的核心其实就三件事把任务之间的先后依赖画成一张有向图用关键路径法找出决定总工期的那条链再用 PERT 三点估算给工期加上概率区间。手工算过一次十几个活动的网络之后我彻底放弃了 Excel 手搓改成用 Python 自动计算——拓扑排序、正推逆推、时差计算、关键路径提取、工期达标概率一次性全出来。这篇就把我实际在用的这套方案完整拆开讲它是什么、能解决什么问题、关键路径法和 PERT 的边界在哪里、代码怎么写、踩过哪些坑。适合两类人看一是手上有排期任务但只会拉甘特图的研发和项目岗二是想搞明白 CPM/PERT 数学原理再落地的工程同学。看完至少能自己算出一条关键路径而不是对着横道图猜哪条最紧。1. 从一次拍脑袋排期翻车说起1.1 甘特图回答不了的那个问题我遇到过一个挺典型的场景。某个硬件小组要做一款控制器模块的样机交付八个人十来个任务排期会上大家按经验各自报了工期然后项目经理在表格里拉了一张横道图把任务一行行往下堆看起来条理分明。结果第二周就出问题了PCB 打样卡在原理图评审之后而结构件加工又独立于原理图——但结构件和 PCB 最后要在整机装配那一步汇合谁先谁后、哪条线有空余、哪条线一点回旋余地都没有横道图上看不出来。横道图的本质是时间轴上的一排条形它能告诉你每个任务什么时候开始什么时候结束但它不能告诉你任务之间的依赖传导关系。当原理图延期三天是整体延期三天还是被后面的采购缓冲吃掉了当固件开发计划提前一周完成总工期能提前吗答案藏在图的结构里不在条形图里。网络计划技术就是为回答这类问题而生的它把项目抽象成一张有向无环图用节点表示活动、用边表示依赖然后通过两次遍历算出每个活动的时间窗口最终锁定那条没有任何松动余地的链条——关键路径。我后来跟那个小组复盘把他们的任务关系画出来一算发现真正决定交付日期的是需求梳理 → 原理图设计 → 器件采购 → 贴片焊接 → 固件联调 → 系统测试这一条链而结构件加工那条线居然有五天多的总时差。他们前两周一直在催结构件纯属用力用错了地方。1.2 关键路径法和 PERT 各自管什么很多人把 CPM 和 PERT 当成同义词其实分工很清晰。关键路径法CPMCritical Path Method解决的问题是结构给定每个活动的工期算出项目最短总工期、每个活动的最早/最迟开始时间、总时差和自由时差以及哪条链决定了总工期。CPM 默认工期是确定的、已知的它关心的是逻辑关系与时间窗口。PERTProgram Evaluation and Review Technique解决的问题是不确定性现实中没人能准确说出一个软件模块到底要几天只能说顺利的话五天正常八天最差两周。PERT 用乐观、最可能、悲观三个估计值合成一个期望工期和方差然后把整条关键路径上的方差累加起来最后回答我在某天之前完工的概率有多大。我通常的用法是CPM 负责算账PERT 负责算险。先用 CPM 把网络结构、时差、关键路径算清楚这是排期的骨架再叠加 PERT把关键路径上的不确定性量化成一个百分比用于对外承诺和对内备料。只做 CPM你对上级只能说预计 46 天加上 PERT你可以说46 天是五成把握想有九成把握得按 50 天准备——后者的说服力和实操价值完全不是一个量级。这里补一句CPM 中的工期既可以是确定值也可以是 PERT 算出来的期望值 TE。这两种做法在实际中都有人用我会在 3.3 节讲清楚各自适用场景。1.3 这套方案最终要交付什么我把目标定义得很具体输入一张活动表活动编号、名称、紧前活动、三点工期输出四样东西。第一是完整的时间参数表包含每个活动的 ES最早开始、EF最早完成、LS最迟开始、LF最迟完成、TF总时差、FF自由时差。第二是关键路径也就是总时差为零的活动序列并且要能处理平行关键路径的情况。第三是PERT 概率曲线给定任意目标日期输出按期完工概率。第四是结构校验包括环检测、孤立活动检查、紧前活动引用是否存在的检查。这四样东西用 Python 实现下来大概两百多行纯标准库不依赖任何第三方包。我坚持不用现成的项目管理软件原因是自建脚本可以随时改逻辑比如加资源约束、加赶工成本可以把结果直接喂给其他工具做后续分析而且算法透明——每一步算什么、为什么这么算自己心里有数。用黑盒软件最怕的就是结果对不上时不知道错在哪。2. 整体设计与技术选型上的几个取舍2.1 单代号网络还是双代号网络网络图有两种经典画法。双代号网络AOAActivity-on-Arrow用箭线表示活动、节点表示事件优点是历史教材讲得多缺点是要处理虚活动——当两个活动有相同的起止节点时必须插入一条工期为零的虚箭线来区分画图和写代码都别扭。单代号网络AONActivity-on-Node用节点表示活动、箭线表示依赖一个活动就是一个节点依赖就是一条从紧前指向紧后的有向边没有任何歧义。我毫不犹豫选 AON。理由很实在AON 的数据结构和代码里的邻接表是一一对应的一个 dict 就能表达整张图不需要为虚活动单独开辟一条处理分支。而且 AON 天然适合拓扑排序——拓扑排序本来就是定义在有向图节点上的。只有在必须交纸质图纸、对方明确要求按双代号格式出图的场合我才会额外写一个转换函数把 AON 转成 AOA那是另一个话题了。顺带说一句AON 的边权重通常置为 0或者干脆不存权重所有工期信息都挂在节点上。这一点跟最短路径算法习惯把权重放边上不太一样写代码时别搞混。2.2 数据结构一张表装下所有信息我的输入就是一张活动表用一个列表存元组或者小对象每条包含五个字段活动编号、活动名称、紧前活动集合、乐观工期、最可能工期、悲观工期。活动编号用大写字母或者简短的字符串方便在结果里对照紧前活动集合用列表或者集合都行用集合可以自动去重防止有人手滑把同一个紧前写两遍。我特意把名称和编号分开是因为实际排期里编号往往是 P1、P2 这种流水号而名称是原理图设计PCB 打样这类人话报告给非技术同事看的时候只认名称。如果只有一个字段后期维护会很痛苦。在内存里我把它组织成三个字典一个info字典存每个活动的基本信息名称、三点工期、紧前集合一个succ字典存每个活动的后继列表由紧前关系反向推导出来不是手工输入一个dur字典存每个活动参与计算的工期。后继列表必须自动推导因为手工维护正向和反向两份关系百分之百会不一致这是我在早期版本里犯过的错。提示紧前关系只写一次后继关系永远从紧前关系反推。任何一个允许你同时手工维护正反两套关系的数据结构最终都会不一致。2.3 算法骨架三次遍历搞定全部参数整个算法只有三次遍历逻辑非常干净我拆开说。第一次是拓扑排序。用 Kahn 算法统计每个节点的入度也就是紧前活动个数把入度为 0 的节点没有紧前活动的起始活动放进队列逐个出队并删除它指向后继的边后继入度减到 0 时入队。如果最终输出的节点数小于总节点数说明图里有环——这在项目排期里是很常见的输入错误比如 A 依赖 B 而 B 又依赖 A写代码的人自己都没发现。拓扑排序顺便还给出了一个合法的计算顺序后面的遍历就按这个顺序来。第二次是正向遍历。按拓扑序依次计算每个活动的 ES 和 EF。规则很简单一个活动的 ES 等于它所有紧前活动 EF 的最大值没有紧前活动则取 0EF 等于 ES 加自己的工期。取最大值这一步是整个算法的灵魂——它表达的是我必须等所有前置工作都完成才能开始而项目里真正制约你的永远是那条最慢的前置链。第三次是反向遍历。按拓扑序的逆序计算 LF 和 LS。规则对称一个活动的 LF 等于它所有紧后活动 LS 的最小值没有紧后活动则取项目总工期LS 等于 LF 减去自己工期。取最小值表达的是我不能晚到拖累任何一个后继。三次遍历之后剩下的都是四则运算总时差 TF 等于 LS 减 ES也等于 LF 减 EF两者应该相等可以互相校验自由时差 FF 等于所有紧后活动 ES 的最小值减去自己的 EF没有紧后活动则等于项目总工期减 EF。关键路径就是所有 TF 约等于 0 的活动按 ES 排序连起来。2.4 为什么坚持纯标准库有人会问为什么不直接用 networkx 或者现成的项目管理库。我的考虑有三层。第一层是部署成本。这套脚本我在很多环境里跑过包括内网离线机器、只有基础 Python 的容器、同事的笔记本。纯标准库意味着把文件拷过去就能跑不需要 pip不需要管版本兼容不需要担心某个包升级后 API 变了。第二层是可审计性。排期结果有时候要拿去跟人争论为什么这条不是关键路径这时候你必须能指着代码说清楚每一步是怎么算的。third-party 库的算法实现封在内部出了问题只能猜。第三层是可扩展性。我后面要加资源约束、加赶工成本、加多项目共享资源这些都需要改核心逻辑用别人的库改起来反而束手束脚。唯一用到科学计算的地方是 PERT 的正态分布累积概率。很多人第一反应是 scipy.stats.norm.cdf但标准库里的math.erf完全够用一行就能写出来标准正态分布的累积概率等于0.5 * (1 erf(x / sqrt(2)))。这一行省掉了一个几十兆的依赖我觉得非常值。3. 核心细节拆解教科书一句话实操一堆坑3.1 ES/EF/LS/LF 的边界约定必须先统一这四个参数本身不难难的是边界怎么定——到底用第 0 天开始还是第 1 天开始EF 是 ES 加工期还是 ES 加工期减一。这个约定如果不统一算出来的结果会出现整整一天的偏差而且这种偏差在十几个活动串联之后会被放大最后对不上交付日期。我的做法是统一采用连续时间轴、以零为起点的约定项目从时刻 0 开始工期为 5 的活动从 0 干到 5占用的时间长度就是 5。这样所有计算都是纯加减法没有减一的修正代码最干净。如果要对人展示第几天到第几天只在最终输出的格式化环节做一次加一转换绝不把这个转换混进计算逻辑里。另一条边界约定是起点和终点。理论上一个网络可以有多个起点多个没有紧前活动的活动和多个终点多个没有紧后活动的活动。我的处理是所有起点活动的 ES 都取 0这等价于给项目加了一个虚拟的开始节点所有终点活动的 LF 都取项目总工期等价于加了一个虚拟的结束节点。这样不需要真的把虚拟节点建出来只在这两处取值时特判一下就行代码简洁很多。注意不要真的去构造虚拟起止节点。构造了之后它们会混进拓扑排序结果、混进关键路径、混进统计你后面得写一堆过滤逻辑把它们排除掉得不偿失。3.2 总时差和自由时差一字之差用途天差地别这是我发现最多人搞混的地方值得单独讲。总时差 TFTotal Float是在不影响项目总工期的前提下这个活动可以拖延多久。它等于 LS 减 ES。只要一个活动的拖延量不超过总时差项目最终交付日期不变。总时差为零的活动就在关键路径上。自由时差 FFFree Float是在不影响任何紧后活动最早开始的前提下这个活动可以拖延多久。它等于紧后活动 ES 的最小值减去自己的 EF。自由时差衡量的是对下游邻居的冲击范围比总时差小。举个具体的例子就清楚了。假设活动 D 的总时差是 10.7 天自由时差是 0 天。这说明什么说明 D 只要晚一天紧接着的 H 就得跟着晚一天开始但 H 本身有足够的缓冲所以整体交付日期不受影响。这种情况下 D 的负责人可以放心拖但 H 的负责人会被迫调整自己的计划——协调成本是真实存在的只是不体现在交付日期上。实际排期时我的用法是总时差用来判断风险自由时差用来判断协调成本。总时差大的活动是资源调配的池子可以从里面抽人支援关键路径自由时差为零但总时差不为零的活动抽人之前得先跟下游打个招呼否则下游的计划表要重排。还有一点必须说清楚一个活动消耗了总时差会连带消耗它所在路径上所有共享这段时差的活动。比如 B 有 8.5 天总时差它一旦拖了 5 天剩下的 3.5 天就不属于它一个人了——同一条非关键链上的其他活动可能也在用这段时差。这就是时差共享问题也是为什么多项目并行时时差会越算越少。3.3 PERT 三点估算那个 4 倍权重是怎么来的PERT 的期望工期公式是三值加权$$T_E \frac{O 4M P}{6}$$其中 O 是乐观值一切顺利、没有任何意外M 是最可能值正常情况下最常出现的工期P 是悲观值各种不顺叠加但还不至于推倒重来。方差用$$\sigma^2 \left(\frac{P - O}{6}\right)^2$$很多人背了这个公式但不知道为什么是 4 和 6。我用人话解释一下这个公式隐含了一个假设——工期服从 Beta 分布且最可能值 M 出现的概率密度最高而 O 和 P 是分布的两个端点。在这个假设下算出来的均值和方差恰好可以用 1:4:1 的加权平均来近似。换句话说4 倍权重表达的是最可能值对期望值的影响远大于两个极端值而分母 6 是权重之和141本质是个加权平均。那为什么方差里除以 6 再平方因为正态分布里 99.7% 的值落在均值正负三个标准差之内也就是说整个区间长度大约等于 6 个标准差。既然 P 减 O 是区间的全长那么一个标准差就约等于 (P−O)/6。这个近似虽然粗糙但在项目管理这个精度层级上完全够用。实操上我有几条经验。第一三个估计值必须由同一个人、在同一套假设下给出。如果乐观值是开发自己报的、悲观值是主管拍的那这组数据毫无意义方差会严重失真。第二悲观值不要设成世界末日应该是大约 10% 概率会超过的水平否则方差被拉大最后算出来的概率区间宽得没法用。第三如果某个活动实在估不出三点可以退化成单点估计把它当成 OMP 处理这样它对期望工期没有偏差只是方差贡献为零——但要注意这会低估整体风险。3.4 工期分布怎么合成方差相加标准差开根号单个活动的期望工期和方差算出来之后怎么得到整个项目的这里有个容易出错的地方。期望工期是直接沿着关键路径相加的这个没问题。方差也是相加的但标准差不是。为什么方差可以相加因为方差的可加性依赖于活动之间相互独立这个假设——PERT 的经典假设就是各活动工期互不影响。在这个假设下关键路径上各活动工期之和的方差等于各活动方差之和。这里必须强调只有方差能加标准差不能加。项目总标准差等于总方差的平方根。我见过有人把每个活动的标准差直接加起来那算出来的数值会偏大很多概率估计会严重偏保守。然后用这个总均值和总标准差套正态分布算概率。假设项目总工期均值是 46.2 天总标准差是 2.9 天那么50 天内完工的概率就是标准正态分布里 z 等于 (50 − 46.2) / 2.9 约等于 1.31 处的累积概率大约 90%。反过来44 天内完工的 z 约等于 −0.74概率大约 23%。这两个数字放在一起对决策的价值就出来了44 天是几乎不可能完成的任务50 天是比较稳妥的承诺。提示PERT 的方差可加假设有一个隐含前提——关键路径上的活动在概率意义上真的独立。如果两个活动是同一个人做、或者共享同一个供应商它们大概率是正相关的这时候方差相加会低估整体风险。这种情况下我通常会把总标准差手动上调 20% 到 30% 做保守修正。还有一点严格来说 PERT 的概率计算应该用项目工期分布而不是关键路径分布。因为当非关键路径的时差被吃掉时它也可能变成关键路径这会改变整体分布。工程实践里通常忽略这个效应直接用关键路径的分布近似代价是略微低估工期。我在做对外承诺时会把这个偏差算进去一般多留 3% 到 5% 的余量。3.5 PERT 代入 CPM 时工期该取哪个值这是一个实操中必须做决定的问题把 PERT 三点估算接进 CPM 计算时dur字典里到底存最可能值 M还是期望值 TE两种做法我都用过各有适用场景。用期望值 TE算出来的关键路径是长期平均意义下的关键路径总工期是期望工期可以无缝衔接后面的概率计算因为概率计算需要的均值和方差都来自同一套假设。缺点是期望工期往往是个小数比如 5.167 天跟人沟通时不直观而且有时候会选出一条在现实中很少真正成为瓶颈的路径。用最可能值 M算出来的关键路径是大多数情况下实际走的那条更符合直觉跟人讲解时更容易被接受。缺点是它和 PERT 概率计算的口径不一致——你用 M 算出的总工期是 42 天但 PERT 算出来的均值是 46 天两个数字放在一张表里会让人困惑。我的做法是两个都算。主报告用期望值口径保证内部逻辑自洽同时输出一份最可能值口径的关键路径用于跟人沟通和做如果一切按最可能发展的情景分析。两份结果如果有差异那本身就是重要信息——说明这个项目在不同情景下的瓶颈不一样需要提前准备两套应对方案。4. 动手实现一份能直接跑的网络计划计算器4.1 输入数据与前置换算我把活动表定义成一个列表每条是六元组编号、名称、紧前活动列表、乐观、最可能、悲观。写完之后立刻做三件事把紧前列表转成集合去重、反推出后继列表、建立编号到下标的索引。这三步做完后面所有逻辑都可以用编号访问不用关心原始列表顺序。如果某个活动的紧前活动编号不存在必须立刻报错而不是静默跳过否则会算出一个看起来合理但根本错误的结果这种错误最难排查。我早期版本就吃过亏——有个活动的紧前写成了小写字母被当成没有这个前置直接忽略结果那条路径的时差全算错了。4.2 拓扑排序与环检测Kahn 算法的实现很直接建入度表、起始节点入队、循环出队删边。判环只需要最后比较出队节点数和总节点数。这里有个细节容易忽略必须在环检测失败时输出具体的环上节点而不是只报存在循环依赖。因为实际排期里环往往涉及三四个活动人能接受的最大信息量就是这几个活动互相依赖了让他自己去定位会浪费大量时间。我在实现里加了一个小功能环检测失败时从每个入度不为零的节点出发做一次深度优先搜索找出真正成环的那几个节点打印出来。这个功能加了不到二十行代码但省掉的沟通成本是几十倍。4.3 正向与逆向遍历的代码要点正向遍历按拓扑序走每个节点先取所有紧前 EF 的最大值作为 ES再加自己的工期得到 EF。这里用max(..., default0)可以优雅处理没有紧前活动的情况省掉一个 if 分支。逆向遍历按拓扑序的逆序遍历直接用reversed(topo)每个节点先取所有紧后 LS 的最小值作为 LF再减自己的工期得到 LS。没有紧后活动时用min(..., defaulttotal)取项目总工期。两处都用浮点数所以后面比较时一律带容差。我定义了一个EPS 1e-9所有等于零的判断都写成abs(x) EPS。这一条能省掉后面一大半的诡异 bug。4.4 时差、关键路径与概率计算总时差直接 LS 减 ES自由时差取所有紧后 ES 的最小值减自己的 EF。关键路径的提取用筛选加排序把所有 TF 约等于零的活动按 ES 排序就是一条关键路径。但要注意可能存在多条平行关键路径所以严格来说输出的应该是一个关键活动集合加若干条路径。工程上我一般直接输出关键活动集合再额外提示关键活动数是否超过了单条链的长度如果超过说明存在平行关键路径需要特别提醒。概率计算需要一个正态累积分布函数用math.erf实现一行搞定。要注意 sigma 为零的退化情况所有活动都是单点估计这时候概率是阶跃函数要么 100% 要么 0%。4.5 完整代码清单 网络计划计算器CPM PERT 纯标准库实现直接运行即可输出完整时间参数表 from math import erf, sqrt EPS 1e-9 # 活动表(编号, 名称, 紧前活动, 乐观, 最可能, 悲观) TASKS [ (A, 需求梳理与范围确认, [], 3, 5, 8), (B, 关键器件比价与订货, [], 4, 5, 7), (C, 原理图设计, [A], 6, 8, 13), (D, 结构方案设计, [A], 4, 6, 9), (E, 固件架构设计, [A], 5, 7, 10), (F, 长周期器件到货, [B, C], 7, 10, 16), (G, PCB 打样, [C], 6, 9, 14), (H, 结构件加工, [D], 8, 11, 17), (I, 固件核心模块开发, [E], 10, 14, 21), (J, 贴片焊接, [F, G], 3, 4, 6), (K, 整机装配, [H, J], 2, 3, 5), (L, 固件联调, [I, J], 5, 8, 13), (M, 系统测试与验收, [K, L], 6, 9, 15), ] class NetworkPlan: def __init__(self, tasks): self.ids [t[0] for t in tasks] self.name {} self.pre {} self.succ {i: [] for i in self.ids} self.o, self.m, self.p {}, {}, {} self.te, self.var {}, {} for tid, name, pre, o, m, p in tasks: self.name[tid] name self.pre[tid] list(set(pre)) self.o[tid], self.m[tid], self.p[tid] o, m, p # PERT 期望工期与方差 self.te[tid] (o 4 * m p) / 6.0 self.var[tid] ((p - o) / 6.0) ** 2 # 校验紧前引用 反推后继 for tid in self.ids: for q in self.pre[tid]: if q not in self.succ: raise ValueError(f活动 {tid} 的紧前 {q} 不存在) self.succ[q].append(tid) def topo(self): indeg {i: len(self.pre[i]) for i in self.ids} queue [i for i in self.ids if indeg[i] 0] order [] while queue: cur queue.pop(0) order.append(cur) for nxt in self.succ[cur]: indeg[nxt] - 1 if indeg[nxt] 0: queue.append(nxt) if len(order) ! len(self.ids): bad [i for i in self.ids if indeg[i] 0] raise ValueError(f存在循环依赖涉及活动{bad}) return order def compute(self, dur_keyte): dur self.te if dur_key te else self.m order self.topo() es, ef, ls, lf {}, {}, {}, {} # 正向 for cur in order: es[cur] max([ef[q] for q in self.pre[cur]], default0.0) ef[cur] es[cur] dur[cur] self.total max(ef.values()) # 逆向 for cur in reversed(order): lf[cur] min([ls[s] for s in self.succ[cur]], defaultself.total) ls[cur] lf[cur] - dur[cur] # 时差 tf, ff {}, {} for cur in self.ids: tf[cur] ls[cur] - es[cur] ff[cur] min([es[s] for s in self.succ[cur]], defaultself.total) - ef[cur] self.res {i: dict(eses[i], efef[i], lsls[i], lflf[i], tftf[i], ffff[i]) for i in self.ids} return self.res def critical(self): crit [i for i in self.ids if abs(self.res[i][tf]) EPS] return sorted(crit, keylambda x: self.res[x][es]) def prob(self, target): crit self.critical() mean sum(self.te[i] for i in crit) var sum(self.var[i] for i in crit) sigma sqrt(var) if sigma EPS: return 1.0 if target mean - EPS else 0.0 z (target - mean) / sigma return 0.5 * (1 erf(z / sqrt(2))) def report(self): print(f{ID:3}{活动名称:18}{工期:7}{ES:7}{EF:7} f{LS:7}{LF:7}{TF:7}{FF:7} 关键) for i in sorted(self.ids, keylambda x: self.res[x][es]): r self.res[i] flag * if abs(r[tf]) EPS else print(f{i:3}{self.name[i]:18}{self.te[i]:7.2f}{r[es]:7.2f} f{r[ef]:7.2f}{r[ls]:7.2f}{r[lf]:7.2f} f{r[tf]:7.2f}{r[ff]:7.2f} {flag}) print(f\n项目期望工期{self.total:.2f}) print(f关键路径{ - .join(self.critical())}) crit self.critical() sd sqrt(sum(self.var[i] for i in crit)) print(f关键路径总标准差{sd:.2f}) for t in (40, 44, 46, 48, 50, 52): print(f {t} 天内完工概率{self.prob(t)*100:5.1f}%) if __name__ __main__: plan NetworkPlan(TASKS) plan.compute(dur_keyte) plan.report()这段代码可以直接粘过去跑不需要装任何东西。几个设计点值得说明一下。compute方法接受一个dur_key参数就是 3.5 节讲的那个选择——传te走 PERT 期望工期口径传m走最可能值口径。两种口径共用同一套遍历逻辑只是取出工期的那一行不同这样保证了两份结果的结构完全一致可以并排对比。succ字典在构造时就被初始化为每个活动都有空列表这样反向推后继的时候不会出现 KeyError同时保证了没有后继的终点活动也能正常被遍历到。这是一个很小的细节但能省掉一堆if key in dict的判断。report里的排序用es作为 key出来的表格正好是按时间推进的顺序读起来很自然。关键活动打个星号一眼就能看出链条。4.6 输出结果逐行解读上面那份示例数据跑出来的核心数字是这样的活动名称期望工期ESEFLSLFTFFFA需求梳理与范围确认5.170.005.170.005.170.000.00B关键器件比价与订货5.170.005.178.5013.678.508.50C原理图设计8.505.1713.675.1713.670.000.00D结构方案设计6.175.1711.3315.8322.0010.670.00E固件架构设计7.175.1712.336.6713.831.500.00F长周期器件到货10.5013.6724.1713.6724.170.000.00GPCB 打样9.3313.6723.0014.8324.171.171.17H结构件加工11.5011.3322.8322.0033.5010.675.50I固件核心模块开发14.5012.3326.8313.8328.331.501.50J贴片焊接4.1724.1728.3324.1728.330.000.00K整机装配3.1728.3331.5033.5036.675.175.17L固件联调8.3328.3336.6728.3336.670.000.00M系统测试与验收9.5036.6746.1736.6746.170.000.00项目期望工期 46.17关键路径 A → C → F → J → L → M总标准差约 2.93。这张表里有几个现象特别值得看也是我每次都会重点讲给团队听的部分。B 的总时差是 8.5 天自由时差也是 8.5 天。意思是这个活动即使拖到第 8.5 天才开始也不会影响 F 的最早开始。但注意B 虽然自己完全自由它拖满之后 F 的 ES 会被推到 13.67 之后——等等这里 F 的 ES 是 13.67来自 C 的 EF所以 B 拖 8.5 天正好卡到 13.67不影响。这就是自由时差的精确含义拖到刚好不影响任何下游的最早开始。D 的总时差 10.67 天但自由时差是 0。这个组合很有迷惑性。D 只要延后一天H 的最早开始就得推后一天所以自由时差为零。但 H 自己有 5.5 天的自由时差、10.67 天的总时差整条结构件链条到最后汇入整机装配时还有很多余量。所以 D 延后对最终交付没影响但对 H 的加工排产有影响。这就是我在 3.2 节说的总时差看风险、自由时差看协调成本。I 的总时差只有 1.5 天是全表最紧的非关键活动。这个数字非常危险——只要固件核心模块开发延后一天半整条路径就会顶到关键路径项目立刻延期。我在实际排期时看到这种数字会立刻把它标红因为它比关键路径上的活动更需要关注关键路径上的活动大家都知道要盯而 I 这种差一点点就关键的活动最容易被忽视。K 的 ES 是 28.33但 LS 是 33.50中间 5.17 天的空档来自 L。整机装配要等结构件和贴片都完成才能开始但系统测试要等装配和固件联调都完成。装配完成后有 5.17 天的时间窗口因为固件联调还没结束所以测试开不了工。这 5.17 天就是装配环节可以拖延的安全区间。概率部分46 天完工概率大约 47%48 天大约 73%50 天大约 90%44 天大约 23%。这组数字直接给了一个决策依据——如果对外承诺 46 天只有一半把握如果要把把握提到九成得按 50 天走。5. 常见问题与排查实录5.1 问题速查表现象最可能的原因排查动作报存在循环依赖紧前关系写反了或确实存在互锁打印环上节点逐个核对依赖方向关键路径只有一两个活动大量活动没有设置紧前成了并列起点检查输入表确认起始活动应该有几个总工期比肉眼估计短很多有活动的紧前被误删路径被抄近道用后继列表反查每个活动的下游是否完整总时差出现负值同一活动的 EF 与 LF 口径不一致或工期录入为负检查工期字段确认 compute 用的是同一套 dur关键活动数量远超合理值存在平行关键路径或多条链恰好等长输出关键活动集合人工判断是否并联概率算出来接近 100% 或 0%方差极小三点估得太接近或目标日期离均值太远检查三点估计是否真的体现了不确定性两份口径的关键路径不一样期望值和最可能值差距大的活动构成了不同瓶颈这是正常现象需要分别准备应对方案浮点数比较导致关键活动多出几个TF 是 1e-16 而不是 0所有比较改用abs(x) EPS5.2 工期压缩时关键路径会跳变这是我在实战中遇到过最坑的问题没有之一。直觉上压缩关键路径上的活动就能缩短总工期。但当你压到一定程度原本的非关键路径可能变成新的关键路径继续压原来那条链就没有任何效果了。比如上面那个例子L 在关键路径上工期 8.33 天。如果把它压缩 3 天L 的 EF 变成 33.67此时 M 的 ES 取决于 K 的 EF 和 L 的 EF——K 的 EF 是 31.5所以 M 的 ES 变成 33.67总工期缩短 3 天。看起来有效。但如果继续压情况就变了。因为 I 的 EF 是 26.83I 到 L 的路径中L 的 ES 受 J 的 EF 制约是 28.33。当 L 的工期压到 5.33 时L 的 EF 变成 33.67……继续压到 3 天L 的 EF 变成 31.33此时 M 的 ES 由 max(31.5, 31.33) 决定等于 31.5来自 K这时候 L 已经不是瓶颈了整机装配那条线成了新的关键路径。再压 L 完全无效。所以赶工之前必须先算清楚压到什么时候会切换瓶颈。我的做法是写一个小循环每次把当前关键路径上某个活动的工期减 1重算总工期看什么时候总工期不再变化。这个敏感度分析跑一次十几行代码但能避免无数次无效的加班安排。实操心得赶工的正确顺序是——先找关键路径上每压缩一天成本最低的活动压缩到瓶颈切换点重算再找新的关键活动。盲目压关键路径上最长的那个活动很可能压到一半就没效果了。5.3 浮点数比较一个让结果全错的隐形杀手这个坑我踩过两次第二次才彻底记住。PERT 期望工期除以 6 之后基本都是无限循环小数比如 31/6 等于 5.1666666...浮点存储必然有误差。当你用ls - es 0判断关键活动时有些本该是零的结果会变成1.7763568394002505e-15判不出来。结果就是关键路径少了一两个活动链条断了你看着结果怎么也想不通。解决办法很简单但必须执行定义 EPS所有等于零改成绝对值小于 EPS所有大于等于改成大于负 EPS。EPS 取 1e-9 在项目管理这个精度层级上绝对安全因为工期的最小单位是天1e-9 天的误差毫无意义。同样的道理适用于 ES 取最大值、LF 取最小值的场景。如果有两个紧前的 EF 恰好相等浮点误差导致差 1e-15取最大值时取到哪个不影响结果但如果一个活动被判定为不是关键而另一个被判为是排序出来的关键路径就会出现逻辑断裂。5.4 PERT 概率被当成承诺值这是认知层面的坑比代码 bug 危害更大。PERT 算出来50 天内完工概率 90%很多人的第一反应是那就报 50 天。这是错的。90% 的概率意味着十次里有一次会超期如果这个项目对你至关重要一次超期的代价可能承受不起。我的一般原则是对内的资源准备按 90% 分位做对外的交付承诺按 50% 到 70% 分位报同时把风险点讲清楚。这么做的逻辑是承诺日期需要留出应对空间如果一开始就把 90% 分位报出去团队会认为这个日期很宽松从而放缓节奏最终反而把缓冲消耗掉——这就是排期里的缓冲自噬现象。另外一个常见误解是把 PERT 算出的概率当成这个日期一定能完成。实际上 PERT 的整个模型建立在活动之间相互独立的假设上现实里这个假设经常被打破。供应商延期会同时影响采购和打样两条线人员请假会同时影响多个并行任务。这些相关性会让实际分布的尾部比正态分布更厚也就是说极端情况比模型预测的更常见。所以我的习惯是模型算 90%实际沟通时按 85% 讲留 5 个点给相关性带来的额外风险。5.5 多起点多终点的网络怎么处理前面说过我把多起点全部 ES 设为 0多终点全部 LF 设为总工期。这在逻辑上是对的但有一个副作用需要注意如果存在多个终点且它们的完成时间相差很远那么项目总工期由最晚的那个决定而早完成的终点活动会获得很大的总时差。这个时差是真实的——那些活动确实可以拖很久而不影响项目整体——但它会误导人。我见过一个项目某个收尾工作算出来总时差 30 天负责人就真的拖了 30 天结果拖到最后一天发现它需要跟另一个早已结束的活动做联合验证而那个活动的资料已经归档了重新调取花了额外一周。所以看到大时差不要高兴太早要问一句这个活动的下游是不是真的只影响交付日期会不会有交付日期之外的隐性依赖。这方面机器算不出来得靠人对业务的理解。6. 把这套东西真正用起来的一些体会用了几年下来我最大的感受是网络计划技术的价值不在于算出一个精确的天数而在于强迫你把依赖关系想清楚。很多时候排期出问题不是因为估错了工期而是因为根本没意识到两个任务之间有依赖。你让一个团队把谁必须在谁之后写下来光是这个过程就能暴露出大量平时被忽略的耦合。代码层面我最后悔的一点是早期没有把数据结构和计算逻辑彻底分开。第一版里我在遍历过程中顺手修改了原始数据导致同一份数据跑两次结果不一样排查了大半天。后来改成输入数据只读、所有中间结果放新字典问题立刻消失。这个习惯我保持到现在——任何计算函数都不应该修改它的输入。另外一个实用技巧是给每个活动加一个责任人字段。算法本身不需要这个字段但输出报告时会非常有用。因为关键路径算出来只是纸面结论真正要落地得有人去盯。我在报告里会按责任人分组统计他们各自的活动的总时差把关心的重点从哪条路径关键转成谁的活最紧沟通效率高很多。最后一个建议如果你刚开始用先拿一个已经结束的项目做回溯验证。把历史数据输进去看算出来的关键路径跟当时实际发生的瓶颈是不是同一条。如果对不上说明要么依赖关系没写全要么工期估计有系统性偏差——这两种情况都是极有价值的发现。我做过三次回溯每一次都能找出几个当时没意识到的隐性依赖这些经验直接让后面几个项目的排期质量上了一个台阶。