贪心算法大多靠不住,为什么Dijkstra的贪心选择偏偏能保证全局最优

0 阅读8分钟

「算法与数据结构」系列 Day08

写在前面

贪心算法名声不算太好——每一步只顾眼前最优,不考虑未来,大部分场景下拼凑出来的结果都只是局部最优,离全局最优差着一截(找零钱问题就是经典的反例)。但求单源最短路径的Dijkstra算法,恰恰就是一个贪心算法,而且它的贪心选择被严格证明在特定条件下等于全局最优。这篇就讲清楚Dijkstra的贪心策略到底是什么、为什么在这里贪心是安全的,以及它的成立条件——非负权重——一旦被打破会发生什么。


一、是什么:每次锁定当前已知最近的一个节点

Dijkstra解决的问题是单源最短路径:给定一个带权重的图(权重非负)和一个起点,求起点到图中所有其他节点的最短距离。

核心动作围绕两个结构展开:一个dist数组,记录"目前已知的、从起点到每个节点的最短距离"(初始时除起点是0,其余都是无穷大);一个优先队列(小根堆),按距离从小到大排序,每次弹出的都是当前候选里距离最小的节点。

算法反复做同一件事:从优先队列弹出当前距离最小的节点u,认定u的最短距离已经锁定,不会再变,然后遍历u的所有出边,尝试用"经过u再走到邻居v"这条路径去更新v的dist值——如果这条路径比v已知的最短距离更短,就更新它(这个更新动作叫"松弛"),并把v重新塞回优先队列。重复这个过程直到队列为空,dist数组里存的就是起点到每个节点的最终最短距离。

flowchart LR
    A[从优先队列取出<br/>距离最小的节点u] --> B[锁定u的最短距离]
    B --> C[遍历u的所有<br/>出边尝试松弛邻居]
    C --> D{松弛后距离<br/>变小了吗}
    D -- 是 --> E[更新距离并<br/>把邻居重新入队]
    D -- 否 --> F[不做任何改动]
    E --> A
    F --> A

二、为什么:非负权重让贪心选择恰好等于全局最优

先说贪心算法为什么名声不好:大多数贪心策略只能保证"这一步看起来最优",但没法保证这些局部最优选择拼在一起就是全局最优——著名的反例是用面值{1, 5, 11}凑15,贪心会先拿一个11再拿四个1(一共5枚),但最优解是三个5(只要3枚),贪心的"眼前最优"反而误导了全局结果。

Dijkstra的贪心选择是:每次从优先队列弹出距离最小的节点,就直接认定它的最短距离已经定死,后面不会再被任何路径刷新。这个假设能不能成立,是可以严格证明的:假设当前弹出的节点是u,它的dist[u]是所有还未被锁定的节点里最小的一个。如果存在另一条路径,能让u的距离变得比dist[u]更小,那这条路径必然要先经过某个还没被锁定的节点w,而根据优先队列弹出顺序,dist[w]一定>= dist[u](因为u是当前最小的,w还排在它后面)。既然图里所有边权都是非负的,从w再往后走一段到u,走过的距离只会让总距离变得不小于dist[w]——也就不可能小于dist[u]。矛盾由此产生:既然任何经过w的路径都不可能比dist[u]更短,那u的最短距离在被弹出的这一刻就已经锁定,贪心选择在这里是安全的。

这个证明完全依赖"边权非负"这一个前提。一旦图里允许出现负权边,"从w再往后走只会让距离变大或不变"这个推论就站不住脚了——一条负权边完全可能让后续路径反而更短,一个已经被弹出、被判定"锁定"的节点,理论上还能被后来发现的更短路径反超。这也是Dijkstra算法唯一的硬伤:它不会报错,只会悄悄给出错误的结果,如果图里存在负权边,需要换成Bellman-Ford这类专门处理负权的算法。


三、怎么用:代码实现 + 常见坑

用真实的图走一遍:4个节点(0~3),边是0→1(权1)、0→2(权4)、1→2(权2)、1→3(权5)、2→3(权3),从节点0出发。

public static int[] dijkstra(List<List<Edge>> graph, int start) {
    int n = graph.size();
    int[] dist = new int[n];
    Arrays.fill(dist, Integer.MAX_VALUE);
    dist[start] = 0;

    PriorityQueue<int[]> pq = new PriorityQueue<>(Comparator.comparingInt(a -> a[1]));
    pq.offer(new int[]{start, 0});

    while (!pq.isEmpty()) {
        int[] cur = pq.poll();
        int u = cur[0], d = cur[1];

        if (d > dist[u]) continue;   // 过时记录,跳过

        for (Edge edge : graph.get(u)) {
            int v = edge.to;
            int newDist = dist[u] + edge.weight;
            if (newDist < dist[v]) {
                dist[v] = newDist;
                pq.offer(new int[]{v, newDist});
            }
        }
    }
    return dist;
}

走一遍这张图:

弹出的(节点,距离)处理动作dist数组变化
(0, 0)松弛邻居1、2dist[1]=1,dist[2]=4,两者入队
(1, 1)松弛邻居2(1+2=3比已知的4更优)、邻居3dist[2]更新为3,dist[3]=6,两者入队
(2, 3)尝试松弛邻居3:3+3=6,不比已知的6更小,不更新不变
(2, 4)弹出时发现4 > dist[2]=3,直接跳过不变
(3, 6)没有出边结束

最终dist = [0, 1, 3, 6]——验证一下节点3:直接走0→2→3是4+3=7,走0→1→3是1+5=6,走0→1→2→3是1+2+3=6,最短确实是6,跟结果对上。

第一个坑、也是最容易漏掉的一行:if (d > dist[u]) continue;。同一个节点可能因为被多次松弛,在优先队列里同时存在好几条距离不同的记录(比如上面例子里节点2就有(2,4)和(2,3)两条)。只有最小的那条是有效的,其余的是"过时记录",弹出时必须先判断一下再决定要不要处理——这种不直接从堆里删除过时数据、而是留到弹出时再判断丢弃的做法叫懒删除。如果漏掉这一行,程序会拿着过时的、偏大的距离去错误地松弛邻居,得到错误结果。

第二个坑是用错数据结构:必须用优先队列(堆)保证每次拿到的是当前候选里最小的,不能像BFS那样用普通队列先进先出——一旦偷懒换成普通队列,"贪心选择当前最小值"这个前提就不成立了,整个正确性证明也就跟着塌了。

第三个坑是负权边不会报错,只会算错:如果这张图里有一条负权边,程序照样能跑完、照样输出一个dist数组,但这个结果可能是错的,不会有任何异常提示,排查起来格外隐蔽。

BFSDijkstra
适用图无权图(或边权都相等)边权非负的带权图
数据结构普通队列(FIFO)优先队列(小根堆)
正确性保证第一次到达即最短弹出时锁定最短
时间复杂度O(V+E)O((V+E)logV)

四、面试追问

Q1:为什么说Dijkstra是贪心算法?它的贪心选择具体是什么?

它每一步都做出"当前看起来最优"的选择:从优先队列里弹出当前距离最小的节点,立刻认定这个距离就是最终答案,不再考虑它未来是否可能被更新——这种"只看眼前最优、不回头重新考虑"的策略就是贪心算法的典型特征。

Q2:为什么Dijkstra的贪心选择在非负权重图上能保证得到全局最优解?

因为可以证明:如果弹出的节点u的距离是当前所有未锁定节点里最小的,那么任何还能让u变得更短的路径,都必须经过某个距离不小于dist[u]的未锁定节点w。由于所有边权非负,从w再往后走只会让总距离变得不小于dist[w],也就不可能小于dist[u]。所以u的最短距离在被弹出的那一刻就已经无法再被刷新,贪心选择等于全局最优。

Q3:为什么Dijkstra不能处理带负权边的图?

因为上面那条证明完全依赖"边权非负"——非负权重保证了"路径越走越长(至少不变短)",一旦出现负权边,这个前提被打破,一条负权边可能让后续路径反而更短,导致一个已经被判定"锁定"的节点,其实还存在更短的路径没被发现。Dijkstra不会对此报错,只会悄悄给出错误结果,需要换成能处理负权边的Bellman-Ford算法。

Q4:代码里if (d > dist[u]) continue;这一行的作用是什么,删掉会怎样?

这一行用来识别并跳过优先队列里的"过时记录"。因为同一个节点可能因为被多次松弛而在队列里存在多条距离不同的记录,只有最小的那条有效。如果删掉这个判断,程序会拿着过时的、偏大的距离去错误地松弛邻居节点,导致算出的最短距离结果不正确。


下一篇预告

Day09 最小生成树:Kruskal算法与并查集的配合。