Dijkstra优化与A*:最短路径算法实战
发布日期: 2026/08/09 阅读总量: 0

先说我踩过的坑

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。我司当时用的是两千万级订单的物流网络,但单城路径计算规模就是这个量级。

方案对比:三条路,一个比一个快

方案时间复杂度核心思路单次耗时(实测)
朴素 DijkstraO(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探索节点数
朴素 Dijkstra1862.40 ms1x4978
SplPriorityQueue458.77 ms4.06x 快4978
手写 MinHeap391.20 ms4.76x 快4978
A*96.84 ms19.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 只能减小