先说我踩过的坑
2023年4月,我负责的外卖配送调度系统出了故障。高峰期每分钟进来3000个新订单,每个订单都要算骑手到商家的最短路径。当时用的是朴素Dijkstra,单次计算平均1.6秒。结果就是:上游队列堆积了8万条消息,骑手端订单迟迟派不出去,高峰期持续了40分钟。
事后我把整个路径计算模块用PHP 8.2重写了一遍,核心干了两件事:堆优化Dijkstra + A*启发式搜索。单次计算从1860ms压到96ms,高峰期终于能扛住了。
这篇文章不聊理论推导,直接给你能跑的代码和真实压测数据。
问题定义:配送场景下的最短路径
路网是一个无向带权图 G=(V, E),V 是路口节点,E 是路段,每条边有权重 w(u,v)(单位:米)。给定起点 s 和终点 t,求最短路径。
配送场景有三个硬性要求:
- 快:单次计算必须 <300ms,否则高峰期扛不住
- 准:必须返回最优路径,不是近似解
- 动态:路况权重实时变化,不能依赖静态缓存
图规模:一个城市城区约 5000~20000 个路口节点,边数约 15000~60000。我司当时用的是两千万级订单的物流网络,但单城路径计算规模就是这个量级。
方案对比:三条路,一个比一个快
| 方案 | 时间复杂度 | 核心思路 | 单次耗时(实测) |
|---|---|---|---|
| 朴素 Dijkstra | O(V² + E) | 每轮扫描全部未访问节点找最小 | 1862 ms |
| SplPriorityQueue 堆优化 | O((V+E)log V) | 用标准库优先队列取最小 | 458 ms |
| 手写二叉堆堆优化 | O((V+E)log V) | 支持 decrease-key 原地更新 | 391 ms |
| A*(欧氏距离启发) | O(E log V)(实际远低于此) | 用启发函数引导搜索方向 | 96 ms |
三条路的核心差异就一句话:Dijkstra 不知道终点在哪,四面八方都搜;A* 知道终点在哪,朝着那个方向搜。
方案一:朴素 Dijkstra
原理不赘述了,贪心 + 松弛。每轮从「未访问」节点中找距离最小的,然后更新所有邻居。
问题在「找最小」这一步:未访问节点有 V 个,每轮都要扫描一遍,所以复杂度是 O(V²)。V=5000 时,每轮扫 5000 个节点,最多 5000 轮,2500 万次比较。PHP 跑这个量级,1.8 秒很正常。
$w) {
if (!$visited[$v] && $dist[$u] + $w < $dist[$v]) {
$dist[$v] = $dist[$u] + $w;
$prev[$v] = $u;
}
}
}
return [$dist, $prev];
}
这段代码你直接在本地跑一下,5000 节点的图就要等将近 2 秒。生产环境绝对不能这么干。
方案二:堆优化 Dijkstra
优化点只有一个:把「每轮扫描找最小」换成「用最小堆取最小」,复杂度从 O(V²) 降到 O((V+E)logV)。
PHP 里有两套实现:标准库 SplPriorityQueue,和手写二叉堆。我都写了,先说标准库版本。
堆优化版本 A:SplPriorityQueue
一个巨大的坑:SplPriorityQueue 是最大堆,优先级最高的元素先出队。Dijkstra 要的是最小堆,所以插入的时候必须取负。2021 年我一个同事把负号漏了,线上路径全部反向,几十个骑手被派往相反方向。
setExtractFlags(SplPriorityQueue::EXTR_BOTH);
// 优先级存负距离,因为 SplPriorityQueue 是最大堆
$pq->insert([$start, 0], 0);
while (!$pq->isEmpty()) {
$item = $pq->extract();
$u = $item['data'][0];
$d = -$item['priority']; // 还原真实距离
// 惰性删除:跳过过期的堆元素
if ($d > $dist[$u]) continue;
if ($u === $end) break;
foreach ($graph[$u] as $v => $w) {
$nd = $d + $w;
if ($nd < $dist[$v]) {
$dist[$v] = $nd;
$prev[$v] = $u;
// 同样取负
$pq->insert([$v, $nd], -$nd);
}
}
}
return [$dist, $prev];
}
SplPriorityQueue 的问题是:它没有 decrease-key 方法。当我们发现一个更短的路径时,不能更新堆里已有元素,只能再 insert 一个重复节点。这就导致堆里会有很多过期元素,靠「惰性删除」来过滤。
堆优化版本 B:手写二叉堆
手写二叉堆支持 decrease-key,堆里每个节点只有一个元素,不会堆积重复节点。实测比 SplPriorityQueue 快约 17%。
在 heap 中的索引
public function push(int $node, int $value): void {
$this->heap[] = [$node, $value];
$idx = count($this->heap) - 1;
$this->pos[$node] = $idx;
$this->siftUp($idx);
}
public function pop(): array {
if (count($this->heap) === 0) return [null, null];
$top = $this->heap[0];
$last = array_pop($this->heap);
if (count($this->heap) > 0) {
$this->heap[0] = $last;
$this->pos[$last[0]] = 0;
$this->siftDown(0);
}
unset($this->pos[$top[0]]);
return $top;
}
public function decreaseKey(int $node, int $newValue): void {
if (!isset($this->pos[$node])) return;
$idx = $this->pos[$node];
// Dijkstra/A* 中 value 只会变小,如果变大直接忽略
if ($newValue >= $this->heap[$idx][1]) return;
$this->heap[$idx][1] = $newValue;
$this->siftUp($idx);
}
public function has(int $node): bool {
return isset($this->pos[$node]);
}
public function isEmpty(): bool {
return count($this->heap) === 0;
}
private function siftUp(int $i): void {
while ($i > 0) {
$parent = intdiv($i - 1, 2);
if ($this->heap[$i][1] >= $this->heap[$parent][1]) break;
$this->swap($i, $parent);
$i = $parent;
}
}
private function siftDown(int $i): void {
$n = count($this->heap);
while (true) {
$left = 2 * $i + 1;
$right = 2 * $i + 2;
$smallest = $i;
if ($left < $n && $this->heap[$left][1] < $this->heap[$smallest][1]) {
$smallest = $left;
}
if ($right < $n && $this->heap[$right][1] < $this->heap[$smallest][1]) {
$smallest = $right;
}
if ($smallest === $i) break;
$this->swap($i, $smallest);
$i = $smallest;
}
}
private function swap(int $a, int $b): void {
$tmp = $this->heap[$a];
$this->heap[$a] = $this->heap[$b];
$this->heap[$b] = $tmp;
$this->pos[$this->heap[$a][0]] = $a;
$this->pos[$this->heap[$b][0]] = $b;
}
}
配合这个堆的 Dijkstra:
push($start, 0);
while (!$heap->isEmpty()) {
[$u, $d] = $heap->pop();
if ($d > $dist[$u]) continue;
if ($u === $end) break;
foreach ($graph[$u] as $v => $w) {
$nd = $d + $w;
if ($nd < $dist[$v]) {
$dist[$v] = $nd;
$prev[$v] = $u;
if ($heap->has($v)) {
// 节点已在堆中,更新它的距离
$heap->decreaseKey($v, $nd);
} else {
$heap->push($v, $nd);
}
}
}
}
return [$dist, $prev];
}
方案三:A* 算法
A* 和 Dijkstra 的区别只有一点:Dijkstra 只考虑起点到当前点的实际代价 g(n),A* 额外考虑当前点到终点的估计代价 h(n)。
总代价 f(n) = g(n) + h(n)。每次优先扩展 f(n) 最小的节点,相当于给搜索加了个「方向感」。
h(n) 用欧氏距离(直线距离)。有一个关键性质叫「可采纳性」:只要 h(n) 不超过 n 到终点的真实最短距离,A* 一定能返回最优路径。因为在路网中,真实路径长度必然大于等于两点间直线距离,所以这里用欧氏距离是安全的。
push($start, $f[$start]);
$closed = []; // 已扩展节点集合
while (!$open->isEmpty()) {
[$u, $fu] = $open->pop();
if ($fu > $f[$u]) continue; // 过期元素
$closed[$u] = true;
if ($u === $end) break;
foreach ($graph[$u] as $v => $w) {
if (isset($closed[$v])) continue;
$ng = $g[$u] + $w;
if ($ng < $g[$v]) {
$g[$v] = $ng;
$prev[$v] = $u;
$newF = $ng + heuristic($coord, $v, $end);
$f[$v] = $newF;
if ($open->has($v)) {
$open->decreaseKey($v, $newF);
} else {
$open->push($v, $newF);
}
}
}
}
return [$g, $prev, count($closed)];
}
function heuristic(array $coord, int $a, int $b): int {
// 欧氏距离,单位:米
// 纬度方向 1 度约 111320 米
// 经度方向需要乘 cos(纬度) 修正
$dx = ($coord[$a][0] - $coord[$b][0]) * 111320.0;
$dy = ($coord[$a][1] - $coord[$b][1]) * 111320.0 * cos(deg2rad($coord[$a][0]));
return (int) round(sqrt($dx * $dx + $dy * $dy));
}
最后是路径重建函数,三个方案共用:
压测:真实数据对比
测试环境
- PHP 8.2.3,无 OPcache,CLI 模式
- 图规模:5000 节点,15000 条边
- 生成方式:先构造一条连通链保证任意两点可达,再补充随机边
- 节点坐标:模拟北京城区经纬度范围(39.8~40.0, 116.2~116.4)
- 边权:按经纬度计算的实际距离(米)
- 起点:节点 0,终点:节点 4999
- 每个算法预热 1 次,然后跑 10 次取平均耗时
压测脚本
dijkstraNaive($graph, $start, $end));
benchmark('SplPriorityQueue Dijkstra', fn() => dijkstraHeap($graph, $start, $end));
benchmark('手写MinHeap Dijkstra', fn() => dijkstraMinHeap($graph, $start, $end));
benchmark('A* (欧氏距离)', fn() => astar($graph, $start, $end, $coord));
// 额外打印 A* 探索节点数
[$g, $prev, $explored] = astar($graph, $start, $end, $coord);
printf("\nA* 探索节点数: %d / %d\n", $explored, $nodes);
一次典型跑测输出
=== 最短路径算法压测(5000节点/15000边)===
朴素Dijkstra : 1862.40 ms
SplPriorityQueue Dijkstra : 458.77 ms
手写MinHeap Dijkstra : 391.20 ms
A* (欧氏距离) : 96.84 ms
A* 探索节点数: 628 / 5000
数据解读
| 方案 | 平均耗时 | 相对朴素Dijkstra | 探索节点数 |
|---|---|---|---|
| 朴素 Dijkstra | 1862.40 ms | 1x | 4978 |
| SplPriorityQueue | 458.77 ms | 4.06x 快 | 4978 |
| 手写 MinHeap | 391.20 ms | 4.76x 快 | 4978 |
| A* | 96.84 ms | 19.2x 快 | 628 |
两个关键结论:
- 堆优化把 O(V²) 变成了 O(E logV),这是量级上的差异,跟语言无关。
- A* 比堆优化再快 4 倍,不是因为复杂度更优,而是因为它探索的节点数只有 Dijkstra 的 12.6%。Dijkstra 探索了 4978 个节点,A* 只探索 628 个。
避坑:我实际踩过的五个坑
坑 1:SplPriorityQueue 是最大堆,方向搞反就是线上事故
我那个同事漏了负号,结果每次取出来的是「距离最远」的节点。路径全反,但系统不报错,数据看起来合理。直到骑手投诉「为什么把我派到 20 公里外」才排查出来。
代码审查时一定要盯住 insert 的时候是否取负,extract 的时候是否还原。
坑 2:A* 的启发函数必须满足一致性
不是随便找个 h(n) 就能用。如果 h(n) 大于真实最短距离,A* 可能返回次优路径。
我在早期版本里用曼哈顿距离 |dx|+|dy| 做启发函数。在网格地图上没问题,但路网不是规整网格。实测路径比最优长 12%,骑手多跑了 800 米。
路网图中欧氏距离是安全的,曼哈顿距离有风险。
坑 3:手写堆的 decreaseKey 必须限制只能减小
Dijkstra 里节点距离只会越来越小,A* 里 f 值也只会越来越小。但如果堆被复用去做别的事,或者代码写得不严谨,decreaseKey 可能传入一个更大的值。
我的 MinHeap 里加了一条保护:
if ($newValue >= $this->heap[$idx][1]) return;
否则新值比旧值大时还执行 siftUp,堆序就破坏了,出队顺序错乱。
坑 4:PHP_INT_MAX 做「无穷大」的边界
如果边权是浮点数,或者累计距离超过了 PHP_INT_MAX(9.2e18),dist 数组会溢出变成了负数。一旦出现负的 dist,Dijkstra 的贪心选择直接失效。
解决方案:边权重统一转 int(单位用米),并设置合理的上限。城市路网单条路径不会超过 1000 公里,1e9 足够。
坑 5:测试图必须保证连通,否则压测数据全是假的
第一次压测时我用纯随机边生成图,结果终点不可达。Dijkstra 提前 break,时间从 1.8 秒变成 0.2 秒——看起来「性能提升 9 倍」,其实是错误结果。
后来改成先生成一条连通链,再补随机边。压测前必须校验终点可达性,否则一切数据都是废的。
生产环境还能怎么进一步优化
上面这套方案解决了 5000 节点的问题。真实生产环境还有三个优化方向:
1. 分层路网
城市路网天然有层级:高速、主干道、次干道、支路。分成两层:第一层算高速+主干道,第二层在起终点附近做局部细化。这样 20000 节点的问题可以拆成两个 5000 节点的问题。
2. 双向 A*
从起点和终点同时跑 A*,两边各扩展一部分,在中途相遇。理论计算量比单向 A* 少约一半。工程实现上要注意「相遇条件」的判断,不能只判断节点是否被对方访问。
3. 动态路况的增量更新
边权实时变化时,可以用邻接表 + LRU 缓存:只有起点终点都在受影响区域内的订单才重算,否则直接复用上一次的最短路径。
写到最后
算法选型没有银弹。5000 节点用堆优化 Dijkstra 够用;10000 节点以上建议 A*;如果对路径最优性要求极高且图超大,考虑分层 + 双向 A*。
这份代码和压测脚本可以直接拿去跑。如果你跑出的数据和我的差很多,先检查版本:
- PHP 8.2+(hrtime 函数在 7.3 以下不可用)
- 图必须连通
- SplPriorityQueue 的取负别漏
- MinHeap 的 decreaseKey 只能减小