代码随想录 刷题记录-28 图论 (5)最短路径

一、dijkstra(朴素版)精讲

47. 参加科学大会

思路

本题就是求最短路,最短路是图论中的经典问题即:给出一个有向图,一个起点,一个终点,问起点到终点的最短路径。

接下来讲解最短路算法中的 dijkstra 算法。

dijkstra算法:在有权图(权值非负数)中求从起点到其他节点的最短路径算法。

需要注意两点:

  • dijkstra 算法可以同时求 起点到所有节点的最短路径
  • 权值不能为负数

dijkstra 算法 同样是贪心的思路,不断寻找距离 源点最近的没有访问过的节点。

这里给出 dijkstra三部曲

  1. 第一步,选源点到哪个节点近且该节点未被访问过
  2. 第二步,该最近节点被标记访问过
  3. 第三步,更新非访问节点到源点的距离(即更新minDist数组)

大家此时已经会发现,这和prim算法 怎么这么像呢。

我在prim算法讲解中也给出了三部曲。 prim 和 dijkstra 确实很像,思路也是类似的,这一点我在后面还会详细来讲。

在dijkstra算法中,同样有一个数组很重要,起名为:minDist。

minDist数组 用来记录 每一个节点距离源点的最小距离

理解这一点很重要,也是理解 dijkstra 算法的核心所在。

先来画图看一下 dijkstra 的工作过程,以本题示例为例: (以下为朴素版dijkstra的思路)

示例中节点编号是从1开始,所以为了让大家看的不晕,minDist数组下标我也从 1 开始计数,下标0 就不使用了,这样 下标和节点标号就可以对应上了,避免大家搞混

朴素版dijkstra

模拟过程

0、初始化

minDist数组数值初始化为int最大值。

这里在强点一下 minDist数组的含义:记录所有节点到源点的最短路径,那么初始化的时候就应该初始为最大值,这样才能在后续出现最短路径的时候及时更新。

(图中,max 表示默认值,节点0 不做处理,统一从下标1 开始计算,这样下标和节点数值统一, 方便大家理解,避免搞混)

源点(节点1) 到自己的距离为0,所以 minDist[1] = 0

此时所有节点都没有被访问过,所以 visited数组都为0


以下为dijkstra 三部曲

1、选源点到哪个节点近且该节点未被访问过

源点距离源点最近,距离为0,且未被访问。

2、该最近节点被标记访问过

标记源点访问过

3、更新非访问节点到源点的距离(即更新minDist数组) ,如图:

更新 minDist数组,即:源点(节点1) 到 节点2 和 节点3的距离。

  • 源点到节点2的最短距离为1,小于原minDist[2]的数值max,更新minDist[2] = 1
  • 源点到节点3的最短距离为4,小于原minDist[3]的数值max,更新minDist[3] = 4

遍历更新minDist的点是当前访问节点的邻接点。

1、选源点到哪个节点近且该节点未被访问过

未访问过的节点中,源点到节点2距离最近,选节点2

2、该最近节点被标记访问过

节点2被标记访问过

3、更新非访问节点到源点的距离(即更新minDist数组) ,如图:

更新 minDist数组,即:源点(节点1) 到 节点6 、 节点3 和 节点4的距离。

为什么更新这些节点呢? 怎么不更新其他节点呢

因为 源点(节点1)通过 已经计算过的节点(节点2) 可以链接到的节点 有 节点3,节点4和节点6.

更新 minDist数组:

  • 源点到节点6的最短距离为5,小于原minDist[6]的数值max,更新minDist[6] = 5
  • 源点到节点3的最短距离为3,小于原minDist[3]的数值4,更新minDist[3] = 3
  • 源点到节点4的最短距离为6,小于原minDist[4]的数值max,更新minDist[4] = 6

1、选源点到哪个节点近且该节点未被访问过

未访问过的节点中,源点距离哪些节点最近,怎么算的?

其实就是看 minDist数组里的数值,minDist 记录了 源点到所有节点的最近距离,结合visited数组筛选出未访问的节点就好。

从 上面的图,或者 从minDist数组中,我们都能看出 未访问过的节点中,源点(节点1)到节点3距离最近。

2、该最近节点被标记访问过

节点3被标记访问过

3、更新非访问节点到源点的距离(即更新minDist数组) ,如图:

由于节点3的加入,那么源点可以有新的路径链接到节点4 所以更新minDist数组:

更新 minDist数组:

  • 源点到节点4的最短距离为5,小于原minDist[4]的数值6,更新minDist[4] = 5

1、选源点到哪个节点近且该节点未被访问过

距离源点最近且没有被访问过的节点,有节点4 和 节点6,距离源点距离都是 5 (minDist[4] = 5,minDist[6] = 5) ,选哪个节点都可以。

2、该最近节点被标记访问过

节点4被标记访问过

3、更新非访问节点到源点的距离(即更新minDist数组) ,如图:

由于节点4的加入,那么源点可以链接到节点5 所以更新minDist数组:

  • 源点到节点5的最短距离为8,小于原minDist[5]的数值max,更新minDist[5] = 8

1、选源点到哪个节点近且该节点未被访问过

距离源点最近且没有被访问过的节点,是节点6,距离源点距离是 5 (minDist[6] = 5)

2、该最近节点被标记访问过

节点6 被标记访问过

3、更新非访问节点到源点的距离(即更新minDist数组) ,如图:

由于节点6的加入,那么源点可以链接到节点7 所以 更新minDist数组:

  • 源点到节点7的最短距离为14,小于原minDist[7]的数值max,更新minDist[7] = 14

1、选源点到哪个节点近且该节点未被访问过

距离源点最近且没有被访问过的节点,是节点5,距离源点距离是 8 (minDist[5] = 8)

2、该最近节点被标记访问过

节点5 被标记访问过

3、更新非访问节点到源点的距离(即更新minDist数组) ,如图:

由于节点5的加入,那么源点有新的路径可以链接到节点7 所以 更新minDist数组:

  • 源点到节点7的最短距离为12,小于原minDist[7]的数值14,更新minDist[7] = 12

1、选源点到哪个节点近且该节点未被访问过

距离源点最近且没有被访问过的节点,是节点7(终点),距离源点距离是 12 (minDist[7] = 12)

2、该最近节点被标记访问过

节点7 被标记访问过

3、更新非访问节点到源点的距离(即更新minDist数组) ,如图:

节点7加入,但节点7到节点7的距离为0,所以 不用更新minDist数组


最后我们要求起点(节点1) 到终点 (节点7)的距离。

再来回顾一下minDist数组的含义:记录 每一个节点距离源点的最小距离。

那么起到(节点1)到终点(节点7)的最短距离就是 minDist[7] ,按上面举例讲解来说,minDist[7] = 12,节点1 到节点7的最短路径为 12。

路径如图:

在上面的讲解中,每一步 我都是按照 dijkstra 三部曲来讲解的,理解了这三部曲,代码也就好懂的。

代码如下:

java 复制代码
import java.util.*;
import java.util.List;


public class Main {

    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        int n = scanner.nextInt();
        int m = scanner.nextInt();
        int[][] grid = new int[n+1][n+1];
        for(int[] array : grid){
            Arrays.fill(array,Integer.MAX_VALUE);
        }
        //读入图
        for(int i = 0 ; i < m ; i++){
            int from = scanner.nextInt();
            int to = scanner.nextInt();
            int value = scanner.nextInt();
            grid[from][to] = value;
        }
        int[] minDist = new int[n+1];
        boolean[] visited = new boolean[n+1];
        int source = 1;
        int destination = n;
        visited[source] = true;
        Arrays.fill(minDist,Integer.MAX_VALUE);
        minDist[source] = 0;
        //dijkstra算法大循环,遍历所有节点
        for(int i = 0 ; i < n ; i++){
            int cur = 1;
            int minValue = Integer.MAX_VALUE;
            //找到当前非访问过的节点中,最近的节点。
            for(int j = 1 ; j <= n  ; j++){
                if(visited[j]) continue;
                if(minDist[j] < minValue){
                    minValue = minDist[j];
                    cur = j;

                }
            }
            visited[cur] = true;
            //针对该节点的邻接点 中的 非访问过的 节点 到 source的距离进行更新
            for(int j = 1 ; j <= n  ; j++){
                if(!visited[j] && grid[cur][j] != Integer.MAX_VALUE && minDist[cur] + grid[cur][j] < minDist[j]){
                    minDist[j] = minDist[cur] +grid[cur][j];
                }
            }
        }

        if(minDist[destination] != Integer.MAX_VALUE){
            System.out.println(minDist[destination]);
        }else{
            System.out.println("-1");
        }


    }

}

如何求路径

如果要记录路径的话,也是用一维parent数组来记录,类似于prim算法,在更新minDist的时候记录即可。

java 复制代码
import java.util.*;
import java.util.List;


public class Main {

    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        int n = scanner.nextInt();
        int m = scanner.nextInt();
        int[][] grid = new int[n+1][n+1];
        int[] parent = new int[n+1]; //用以记录路径
        Arrays.fill(parent,-1);
        for(int[] array : grid){
            Arrays.fill(array,Integer.MAX_VALUE);
        }
        //读入图
        for(int i = 0 ; i < m ; i++){
            int from = scanner.nextInt();
            int to = scanner.nextInt();
            int value = scanner.nextInt();
            grid[from][to] = value;
        }
        int[] minDist = new int[n+1];
        boolean[] visited = new boolean[n+1];
        int source = 1;
        int destination = n;
        visited[source] = true;
        Arrays.fill(minDist,Integer.MAX_VALUE);
        minDist[source] = 0;
        //dijkstra算法大循环,遍历所有节点
        for(int i = 0 ; i < n ; i++){
            int cur = 1;
            int minValue = Integer.MAX_VALUE;
            //找到当前非访问过的节点中,最近的节点。
            for(int j = 1 ; j <= n  ; j++){
                if(visited[j]) continue;
                if(minDist[j] < minValue){
                    minValue = minDist[j];
                    cur = j;

                }
            }
            visited[cur] = true;
            //针对该节点的邻接点 中的 非访问过的 节点 到 source的距离进行更新
            for(int j = 1 ; j <= n  ; j++){
                if(!visited[j] && grid[cur][j] != Integer.MAX_VALUE && minDist[cur] + grid[cur][j] < minDist[j]){
                    minDist[j] = minDist[cur] +grid[cur][j];
                    parent[j] = cur;//记录路径
                }
            }
        }

        if(minDist[destination] != Integer.MAX_VALUE){
            System.out.println(minDist[destination]);
        }else{
            System.out.println("-1");
        }


    }

}

debug方法

在每一次选择后输出日志,输出当前选择的节点和minDist数组,看和自己的预期是否相同。

出现负数

如果图中边的权值为负数,dijkstra 还合适吗?

看一下这个图: (有负权值)

节点1 到 节点5 的最短路径 应该是 节点1 -> 节点2 -> 节点3 -> 节点4 -> 节点5

那我们来看dijkstra 求解的路径是什么样的,继续dijkstra 三部曲来模拟 :(dijkstra模拟过程上面已经详细讲过,以下只模拟重要过程,例如如何初始化就省略讲解了)


初始化:


1、选源点到哪个节点近且该节点未被访问过

源点距离源点最近,距离为0,且未被访问。

2、该最近节点被标记访问过

标记源点访问过

3、更新非访问节点到源点的距离(即更新minDist数组) ,如图:

更新 minDist数组,即:源点(节点1) 到 节点2 和 节点3的距离。

  • 源点到节点2的最短距离为100,小于原minDist[2]的数值max,更新minDist[2] = 100
  • 源点到节点3的最短距离为1,小于原minDist[3]的数值max,更新minDist[4] = 1

1、选源点到哪个节点近且该节点未被访问过

源点距离节点3最近,距离为1,且未被访问。

2、该最近节点被标记访问过

标记节点3访问过

3、更新非访问节点到源点的距离(即更新minDist数组) ,如图:

由于节点3的加入,那么源点可以有新的路径链接到节点4 所以更新minDist数组:

  • 源点到节点4的最短距离为2,小于原minDist[4]的数值max,更新minDist[4] = 2

1、选源点到哪个节点近且该节点未被访问过

源点距离节点4最近,距离为2,且未被访问。

2、该最近节点被标记访问过

标记节点4访问过

3、更新非访问节点到源点的距离(即更新minDist数组) ,如图:

由于节点4的加入,那么源点可以有新的路径链接到节点5 所以更新minDist数组:

  • 源点到节点5的最短距离为3,小于原minDist[5]的数值max,更新minDist[5] = 5

1、选源点到哪个节点近且该节点未被访问过

源点距离节点5最近,距离为3,且未被访问。

2、该最近节点被标记访问过

标记节点5访问过

3、更新非访问节点到源点的距离(即更新minDist数组) ,如图:

节点5的加入,而节点5 没有链接其他节点, 所以不用更新minDist数组,仅标记节点5被访问过了


1、选源点到哪个节点近且该节点未被访问过

源点距离节点2最近,距离为100,且未被访问。

2、该最近节点被标记访问过

标记节点2访问过

3、更新非访问节点到源点的距离(即更新minDist数组) ,如图:


至此dijkstra的模拟过程就结束了,根据最后的minDist数组,我们求 节点1 到 节点5 的最短路径的权值总和为 3,路径: 节点1 -> 节点3 -> 节点4 -> 节点5

通过以上的过程模拟,我们可以发现 之所以 没有走有负权值的最短路径 是因为 在 访问 节点 2 的时候,节点 3 已经访问过了,就不会再更新了。

那有录友可能会想: 我可以改代码逻辑啊,访问过的节点,也让它继续访问不就好了?

那么访问过的节点还能继续访问会不会有死循环的出现呢?控制逻辑不让其死循环?那特殊情况自己能都想清楚吗?(可以试试,实践出真知)

对于负权值的出现,大家可以针对某一个场景 不断去修改 dijkstra 的代码,但最终会发现只是 拆了东墙补西墙,对dijkstra的补充逻辑只能满足某特定场景最短路求解。

对于求解带有负权值的最短路问题,可以使用 Bellman-Ford 算法 ,后序会详细讲解。

dijkstra与prim算法的区别

大家可以发现 dijkstra的代码看上去 怎么和 prim算法这么像呢。

其实代码大体不差,唯一区别在 三部曲中的 第三步: 更新minDist数组

因为prim是求 非访问节点到最小生成树的最小距离,而 dijkstra是求 非访问节点到源点的最小距离

prim 更新 minDist数组的写法:

cpp 复制代码
for (int j = 1; j <= v; j++) {
    if (!isInTree[j] && grid[cur][j] < minDist[j]) {
        minDist[j] = grid[cur][j];
    }
}

因为 minDist表示 节点到最小生成树的最小距离,所以 新节点cur的加入,只需要 使用 grid[cur][j] ,grid[cur][j] 就表示 cur 加入生成树后,生成树到 节点j 的距离。

dijkstra 更新 minDist数组的写法:

cpp 复制代码
for (int v = 1; v <= n; v++) {
    if (!visited[v] && grid[cur][v] != INT_MAX && minDist[cur] + grid[cur][v] < minDist[v]) {
        minDist[v] = minDist[cur] + grid[cur][v];
    }
}

因为 minDist表示 节点到源点的最小距离,所以 新节点 cur 的加入,需要使用 源点到cur的距离 (minDist[cur]) + cur 到 节点 v 的距离 (grid[cur][v]),才是 源点到节点v的距离。

此时大家可能不禁要想 prim算法 可以有负权值吗?

当然可以,prim算法只需要将节点以最小权值和链接在一起,不涉及到单一路径。

二、dijkstra(堆优化版)精讲

在节点数很大的情况下(稀疏图),考虑维护边集合来实现dijkstra算法。

使用邻接表来存储图。

同时,因为每一步要选择未访问节点中minDist最小的节点,考虑使用堆来进行优化。

堆优化细节

其实思路依然是 dijkstra 三部曲:

  1. 第一步,选源点到哪个节点近且该节点未被访问过
  2. 第二步,该最近节点被标记访问过
  3. 第三步,更新非访问节点到源点的距离(即更新minDist数组)

只不过之前是 通过遍历节点来遍历边,通过两层for循环来寻找距离源点最近节点。 这次我们直接遍历边,且通过堆来对边进行排序,达到直接选择距离源点最近节点。

先来看一下针对这三部曲,如果用 堆来优化。

那么三部曲中的第一步(选源点到哪个节点近且该节点未被访问过),我们如何选?

我们要选择距离源点近的节点(即:该边的权值最小),所以 我们需要一个 小顶堆 来帮我们对边的权值排序,每次从小顶堆堆顶 取边就是权值最小的边。

C++定义小顶堆,可以用优先级队列实现,代码如下:

cpp 复制代码
// 小顶堆
class mycomparison {
public:
    bool operator()(const pair<int, int>& lhs, const pair<int, int>& rhs) {
        return lhs.second > rhs.second;
    }
};
// 优先队列中存放 pair<节点编号,源点到该节点的权值> 
priority_queue<pair<int, int>, vector<pair<int, int>>, mycomparison> pq;

pair<int, int>中 第二个int 为什么要存 源点到该节点的权值,因为 这个小顶堆需要按照权值来排序)

有了小顶堆自动对边的权值排序,那我们只需要直接从 堆里取堆顶元素(小顶堆中,最小的权值在上面),就可以取到离源点最近的节点了 (未访问过的节点,不会加到堆里进行排序)

所以三部曲中的第一步,我们不用 for循环去遍历,直接取堆顶元素:

cpp 复制代码
// pair<节点编号,源点到该节点的权值>
pair<int, int> cur = pq.top(); pq.pop();

第二步(该最近节点被标记访问过) 这个就是将 节点做访问标记,和 朴素dijkstra 一样 ,代码如下:

cpp 复制代码
// 2. 第二步,该最近节点被标记访问过
visited[cur.first] = true;

cur.first 是指取 pair<int, int> 里的第一个int,即节点编号 )

第三步(更新非访问节点到源点的距离),这里的思路 也是 和朴素dijkstra一样的。

但很多录友对这里是最懵的,主要是因为两点:

  • 没有理解透彻 dijkstra 的思路
  • 没有理解 邻接表的表达方式

我们来回顾一下 朴素dijkstra 在这一步的代码和思路(如果没看过我讲解的朴素版dijkstra,这里会看不懂)

cpp 复制代码
// 3、第三步,更新非访问节点到源点的距离(即更新minDist数组)
for (int v = 1; v <= n; v++) {
    if (!visited[v] && grid[cur][v] != INT_MAX && minDist[cur] + grid[cur][v] < minDist[v]) {
        minDist[v] = minDist[cur] + grid[cur][v];
    }
}

其中 for循环是用来做什么的? 是为了 找到 节点cur 链接指向了哪些节点,因为使用邻接矩阵的表达方式 所以把所有节点遍历一遍。

而在邻接表中,我们可以以相对高效的方式知道一个节点链接指向哪些节点。

再回顾一下邻接表的构造(数组 + 链表):

假如 加入的cur 是节点 2, 那么 grid[2] 表示的就是图中第二行链表。 (grid数组的构造我们在 上面 「图的存储」中讲过)

所以在邻接表中,我们要获取 节点cur 链接指向哪些节点,就是遍历 grid[cur节点编号] 这个链表。

这个遍历方式,C++代码如下:

cpp 复制代码
for (Edge edge : grid[cur.first]) 

cur.first 就是cur节点编号, 参考上面pair的定义: pair<节点编号,源点到该节点的权值>

接下来就是更新 非访问节点到源点的距离,代码实现和 朴素dijkstra 是一样的,代码如下:

cpp 复制代码
// 3. 第三步,更新非访问节点到源点的距离(即更新minDist数组)
for (Edge edge : grid[cur.first]) { // 遍历 cur指向的节点,cur指向的节点为 edge
    // cur指向的节点edge.to,这条边的权值为 edge.val
    if (!visited[edge.to] && minDist[cur.first] + edge.val < minDist[edge.to]) { // 更新minDist
        minDist[edge.to] = minDist[cur.first] + edge.val;
        pq.push(pair<int, int>(edge.to, minDist[edge.to]));
    }
}

确定该节点没有被访问过,!visited[edge.to] , 目前 源点到cur.first的最短距离(minDist) + cur.first 到 edge.to 的距离 (edge.val) 是否 小于 minDist已经记录的 源点到 edge.to 的距离 (minDist[edge.to])

如果是的话,就开始更新操作。

即:

cpp 复制代码
if (!visited[edge.to] && minDist[cur.first] + edge.val < minDist[edge.to]) { // 更新minDist
    minDist[edge.to] = minDist[cur.first] + edge.val;
    pq.push(pair<int, int>(edge.to, minDist[edge.to])); // 由于cur节点的加入,而新链接的边,加入到优先级队里中
}

同时,由于cur节点的加入,源点又有可以新链接到的边,将这些边加入到优先级队里中。

以上代码思路 和 朴素版dijkstra 是一样一样的,主要区别是两点:

  • 邻接表的表示方式不同
  • 使用优先级队列(小顶堆)来对新链接的边排序

代码实现

堆优化dijkstra完整代码如下:

cpp 复制代码
#include <iostream>
#include <vector>
#include <list>
#include <queue>
#include <climits>
using namespace std; 
// 小顶堆
class mycomparison {
public:
    bool operator()(const pair<int, int>& lhs, const pair<int, int>& rhs) {
        return lhs.second > rhs.second;
    }
};
// 定义一个结构体来表示带权重的边
struct Edge {
    int to;  // 邻接顶点
    int val; // 边的权重

    Edge(int t, int w): to(t), val(w) {}  // 构造函数
};

int main() {
    int n, m, p1, p2, val;
    cin >> n >> m;

    vector<list<Edge>> grid(n + 1);

    for(int i = 0; i < m; i++){
        cin >> p1 >> p2 >> val; 
        // p1 指向 p2,权值为 val
        grid[p1].push_back(Edge(p2, val));

    }

    int start = 1;  // 起点
    int end = n;    // 终点

    // 存储从源点到每个节点的最短距离
    std::vector<int> minDist(n + 1, INT_MAX);

    // 记录顶点是否被访问过
    std::vector<bool> visited(n + 1, false); 
    
    // 优先队列中存放 pair<节点,源点到该节点的权值>
    priority_queue<pair<int, int>, vector<pair<int, int>>, mycomparison> pq;


    // 初始化队列,源点到源点的距离为0,所以初始为0
    pq.push(pair<int, int>(start, 0)); 
    
    minDist[start] = 0;  // 起始点到自身的距离为0

    while (!pq.empty()) {
        // 1. 第一步,选源点到哪个节点近且该节点未被访问过 (通过优先级队列来实现)
        // <节点, 源点到该节点的距离>
        pair<int, int> cur = pq.top(); pq.pop();

        if (visited[cur.first]) continue;

        // 2. 第二步,该最近节点被标记访问过
        visited[cur.first] = true;

        // 3. 第三步,更新非访问节点到源点的距离(即更新minDist数组)
        for (Edge edge : grid[cur.first]) { // 遍历 cur指向的节点,cur指向的节点为 edge
            // cur指向的节点edge.to,这条边的权值为 edge.val
            if (!visited[edge.to] && minDist[cur.first] + edge.val < minDist[edge.to]) { // 更新minDist
                minDist[edge.to] = minDist[cur.first] + edge.val;
                pq.push(pair<int, int>(edge.to, minDist[edge.to]));
            }
        }

    }

    if (minDist[end] == INT_MAX) cout << -1 << endl; // 不能到达终点
    else cout << minDist[end] << endl; // 到达终点最短路径
}
  • 时间复杂度:O(ElogE) E 为边的数量
  • 空间复杂度:O(N + E) N 为节点的数量

堆优化的时间复杂度 只和边的数量有关 和节点数无关,在 优先级队列中 放的也是边。

以上代码中,while (!pq.empty()) 里套了 for (Edge edge : grid[cur.first])

for 里 遍历的是 当前节点 cur 所连接边。

那 当前节点cur 所连接的边 也是不固定的, 这就让大家分不清,这时间复杂度究竟是多少?

其实 for (Edge edge : grid[cur.first]) 里最终的数据走向 是 给队列里添加边。

那么跳出局部代码,整个队列 一定是 所有边添加了一次,同时也弹出了一次。

所以边添加一次时间复杂度是 O(E), while (!pq.empty()) 里每次都要弹出一个边来进行操作,在优先级队列(小顶堆)中 弹出一个元素的时间复杂度是 O(logE) ,这是堆排序的时间复杂度。

(当然小顶堆里 是 添加元素的时候 排序,还是 取数元素的时候排序,这个无所谓,时间复杂度都是O(E),总之是一定要排序的,而小顶堆里也不会滞留元素,有多少元素添加 一定就有多少元素弹出)

所以 该算法整体时间复杂度为 O(ElogE)

网上的不少分析 会把 n (节点的数量)算进来,这个分析是有问题的,举一个极端例子,在n 为 10000,且是有一条边的 图里,以上代码,大家感觉执行了多少次?

while (!pq.empty()) 中的 pq 存的是边,其实只执行了一次。

所以该算法时间复杂度 和 节点没有关系。

至于空间复杂度,邻接表是 数组 + 链表 数组的空间 是 N ,有E条边 就申请对应多少个链表节点,所以是 复杂度是 N + E

Java实现如下:

java 复制代码
import java.util.*;
import java.util.List;


public class Main {


    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        int n = scanner.nextInt();
        if(n == 1){
            System.out.println(0);
            return ;
        }
        int m = scanner.nextInt();
        //邻接表存储表
        List<List<Edge>> grid = new ArrayList<>(n+1);
        for(int i = 0 ; i <= n ; i++){
            grid.add(new ArrayList<>());
        }
        //读入表
        for(int i = 0 ; i < m ; i++){
            int from = scanner.nextInt();
            int to = scanner.nextInt();
            int value = scanner.nextInt();
            grid.get(from).add(new Edge(to,value));
        }
        //构建优先队列维护边集合
        PriorityQueue<Edge> pq = new PriorityQueue<>(new MyComparision());
        boolean[] visited = new boolean[n+1];
        int[] minDist = new int[n+1];
        Arrays.fill(minDist,Integer.MAX_VALUE);
        //初始化
        int start = 1;
        int destination = n;
        minDist[start] = 0;
        pq.add(new Edge(start,0));
        //dijkstra算法
        while(!pq.isEmpty()){
            //访问距离源点最小的未访问过的节点
            Edge edge = pq.poll();
            if(visited[edge.to]) continue;
            visited[edge.to] = true;
            //更新当前访问节点的 未访问过的邻接点的 minDist
            for(Edge e : grid.get(edge.to)){
                if(visited[e.to] == false && minDist[edge.to] + e.value < minDist[e.to]){
                    minDist[e.to] = minDist[edge.to] + e.value;
                    pq.add(new Edge(e.to,minDist[e.to]));//pq中加入更新过的节点及其对应的与源点的距离。
                    //这里不用作删除操作,因为取的一定是权重最小的那一个,并且访问过之后由于visited数组存在,不会再次访问
                }
            }
        }
        if(minDist[destination] == Integer.MAX_VALUE){
            System.out.println("-1");
        }else{
            System.out.println(minDist[destination]);
        }
    }

}

class Edge{
    int to;
    int value;
    Edge(int to , int value){
        this.to = to;
        this.value = value;
    }

}

class MyComparision implements Comparator<Edge>{
    @Override
    public int compare(Edge o1, Edge o2) {
        return Integer.compare(o1.value,o2.value);
    }
}

也可以像C++里一样,定义一个Pair类使用泛型,PriorityQueue里维护Pair:

java 复制代码
import java.util.*;

class Edge {
    int to;  // 邻接顶点
    int val; // 边的权重

    Edge(int to, int val) {
        this.to = to;
        this.val = val;
    }
}

class MyComparison implements Comparator<Pair<Integer, Integer>> {
    @Override
    public int compare(Pair<Integer, Integer> lhs, Pair<Integer, Integer> rhs) {
        return Integer.compare(lhs.second, rhs.second);
    }
}

class Pair<U, V> {
    public final U first;
    public final V second;

    public Pair(U first, V second) {
        this.first = first;
        this.second = second;
    }
}

public class Main {
    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        int n = scanner.nextInt();
        int m = scanner.nextInt();

        List<List<Edge>> grid = new ArrayList<>(n + 1);
        for (int i = 0; i <= n; i++) {
            grid.add(new ArrayList<>());
        }

        for (int i = 0; i < m; i++) {
            int p1 = scanner.nextInt();
            int p2 = scanner.nextInt();
            int val = scanner.nextInt();
            grid.get(p1).add(new Edge(p2, val));
        }

        int start = 1;  // 起点
        int end = n;    // 终点

        // 存储从源点到每个节点的最短距离
        int[] minDist = new int[n + 1];
        Arrays.fill(minDist, Integer.MAX_VALUE);

        // 记录顶点是否被访问过
        boolean[] visited = new boolean[n + 1];

        // 优先队列中存放 Pair<节点,源点到该节点的权值>
        PriorityQueue<Pair<Integer, Integer>> pq = new PriorityQueue<>(new MyComparison());

        // 初始化队列,源点到源点的距离为0,所以初始为0
        pq.add(new Pair<>(start, 0));

        minDist[start] = 0;  // 起始点到自身的距离为0

        while (!pq.isEmpty()) {
            // 1. 第一步,选源点到哪个节点近且该节点未被访问过(通过优先级队列来实现)
            // <节点, 源点到该节点的距离>
            Pair<Integer, Integer> cur = pq.poll();

            if (visited[cur.first]) continue;

            // 2. 第二步,该最近节点被标记访问过
            visited[cur.first] = true;

            // 3. 第三步,更新非访问节点到源点的距离(即更新minDist数组)
            for (Edge edge : grid.get(cur.first)) { // 遍历 cur指向的节点,cur指向的节点为 edge
                // cur指向的节点edge.to,这条边的权值为 edge.val
                if (!visited[edge.to] && minDist[cur.first] + edge.val < minDist[edge.to]) { // 更新minDist
                    minDist[edge.to] = minDist[cur.first] + edge.val;
                    pq.add(new Pair<>(edge.to, minDist[edge.to]));
                }
            }
        }

        if (minDist[end] == Integer.MAX_VALUE) {
            System.out.println(-1); // 不能到达终点
        } else {
            System.out.println(minDist[end]); // 到达终点最短路径
        }
    }
}

拓展

当然也有录友可能想 堆优化dijkstra 中 我为什么一定要用邻接表呢,我就用邻接矩阵 行不行 ?

也行的。

但 正是因为稀疏图,所以我们使用堆优化的思路, 如果我们还用 邻接矩阵 去表达这个图的话,就是 一个高效的算法 使用了低效的数据结构,那么 整体算法效率 依然是低的

总结

在学习一种优化思路的时候,首先就要知道为什么要优化,遇到了什么问题。

正如我在开篇就给大家交代清楚 堆优化方式的背景。

堆优化的整体思路和 朴素版是大体一样的,区别是 堆优化从边的角度出发且利用堆来排序。

三、Bellman_ford 算法精讲

本题依然是单源最短路问题,求 从 节点1 到节点n 的最小费用。 但本题不同之处在于 边的权值是有负数了

Bellman_ford算法可以解决图中有负权值的求单源最短路问题(不能解决有负环的问题)。

Bellman_ford算法的核心思想是 对所有边进行松弛n-1次操作(n为节点数量),从而求得目标最短路

什么叫做松弛

《算法四》里面把这个操作叫做 "放松", 英文版里叫做 "relax the edge"

所以大家翻译过来,就是 "放松" 或者 "松弛" 。

但《算法四》没有具体去讲这个 "放松" 究竟是个啥? 网上很多题解也没有讲题解里的 "松弛这条边,松弛所有边"等等 里面的 "松弛" 究竟是什么意思?

这里我给大家举一个例子,每条边有起点、终点和边的权值。例如一条边,节点A 到 节点B 权值为value,如图:

minDist[B] 表示 到达B节点 最小权值,minDist[B] 有哪些状态可以推出来?

状态一: minDist[A] + value 可以推出 minDist[B] 状态二: minDist[B]本身就有权值 (可能是其他边链接的节点B 例如节点C,以至于 minDist[B]记录了其他边到minDist[B]的权值)

minDist[B] 应为如何取舍。

本题我们要求最小权值,那么 这两个状态我们就取最小的

cpp 复制代码
if (minDist[B] > minDist[A] + value) minDist[B] = minDist[A] + value

也就是说,如果 通过 A 到 B 这条边可以获得更短的到达B节点的路径,即如果 minDist[B] > minDist[A] + value,那么我们就更新 minDist[B] = minDist[A] + value这个过程就叫做 "松弛" 。

以上讲了这么多,其实都是围绕以下这句代码展开:

cpp 复制代码
if (minDist[B] > minDist[A] + value) minDist[B] = minDist[A] + value

这句代码就是 Bellman_ford算法的核心操作

以上代码也可以这么写:minDist[B] = min(minDist[A] + value, minDist[B])

如果大家看过代码随想录的动态规划章节,会发现 无论是背包问题还是子序列问题,这段代码(递推公式)出现频率非常高的。

其实 Bellman_ford算法 也是采用了动态规划的思想,即:将一个问题分解成多个决策阶段,通过状态之间的递归关系最后计算出全局最优解。

即 松弛操作就是Bellman_ford算法里进行动态规划中的一个单步操作,这个单步操作取当前遍历到的元素 在 当前状态下 最优的minDist.

那么为什么是 n - 1次 松弛呢

这里要模拟一遍 Bellman_ford 的算法才行,接下来看对所有边松弛 n - 1 次的操作是什么样的。

依然使用minDist数组来表达 起点到各个节点的最短距离,例如minDist[3] = 5 表示起点到达节点3 的最小距离为5

模拟过程

初始化过程。

起点为节点1, 起点到起点的距离为0,所以 minDist[1] 初始化为0

如图:

其他节点对应的minDist初始化为max,因为我们要求最小距离,那么还没有计算过的节点 默认是一个最大数,这样才能更新最小距离。

对所有边 进行第一次松弛: (什么是松弛,在上面我已经详细讲过)

以示例给出的所有边为例:

5 6 -2
1 2 1
5 3 1
2 5 2
2 4 -3
4 6 4
1 3 5

接下来我们来松弛一遍所有的边。

边:节点5 -> 节点6,权值为-2 ,minDist[5] 还是默认数值max,所以不能基于 节点5 去更新节点6,如图:

(在复习一下,minDist[5] 表示起点到节点5的最短距离)

边:节点1 -> 节点2,权值为1 ,minDist[2] > minDist[1] + 1 ,更新 minDist[2] = minDist[1] + 1 = 0 + 1 = 1 ,如图:

边:节点5 -> 节点3,权值为1 ,minDist[5] 还是默认数值max,所以不能基于节点5去更新节点3 如图:

边:节点2 -> 节点5,权值为2 ,minDist[5] > minDist[2] + 2 (经过上面的计算minDist[2]已经不是默认值,而是 1),更新 minDist[5] = minDist[2] + 2 = 1 + 2 = 3 ,如图:

边:节点2 -> 节点4,权值为-3 ,minDist[4] > minDist[2] + (-3),更新 minDist[4] = minDist[2] + (-3) = 1 + (-3) = -2 ,如图:

边:节点4 -> 节点6,权值为4 ,minDist[6] > minDist[4] + 4,更新 minDist[6] = minDist[4] + 4 = -2 + 4 = 2

边:节点1 -> 节点3,权值为5 ,minDist[3] > minDist[1] + 5,更新 minDist[3] = minDist[1] + 5 = 0 + 5 = 5 ,如图:


以上是对所有边进行一次松弛之后的结果。

那么需要对所有边松弛几次才能得到 起点(节点1) 到终点(节点6)的最短距离呢?

对所有边松弛一次,相当于计算 起点到达 与起点一条边相连的节点 的最短距离

上面的距离中,我们得到里 起点达到 与起点一条边相邻的节点2 和 节点3 的最短距离,分别是 minDist[2] 和 minDist[3]

这里有录友疑惑了 minDist[3] = 5,分明不是 起点到达 节点3 的最短距离,节点1 -> 节点2 -> 节点5 -> 节点3 这条路线 距离才是4。

注意我上面讲的是 对所有边松弛一次,相当于计算 起点到达 与起点一条边相连的节点 的最短距离,这里 说的是 一条边相连的节点。

与起点(节点1)一条边相邻的节点,到达节点2 最短距离是 1,到达节点3 最短距离是5。

而 节点1 -> 节点2 -> 节点5 -> 节点3 这条路线 是 与起点 三条边相连的路线了。

所以对所有边松弛一次 能得到 与起点 一条边相连的节点最短距离。

那对所有边松弛两次 可以得到与起点 两条边相连的节点的最短距离。

那对所有边松弛三次 可以得到与起点 三条边相连的节点的最短距离,这个时候,我们就能得到到达节点3真正的最短距离,也就是 节点1 -> 节点2 -> 节点5 -> 节点3 这条路线。

那么再回归刚刚的问题,需要对所有边松弛几次才能得到 起点(节点1) 到终点(节点6)的最短距离呢

节点数量为n,那么起点到终点,最多是 n-1 条边相连。

那么无论图是什么样的,边是什么样的顺序,我们对所有边松弛 n-1 次 就一定能得到 起点到达 终点的最短距离。

其实也同时计算出了,起点 到达 所有节点的最短距离,因为所有节点与起点连接的边数最多也就是 n-1 条边。

截止到这里,Bellman_ford 的核心算法思路,大家就了解的差不多了。

共有两个关键点。

  • "松弛"究竟是个啥?
  • 为什么要对所有边松弛 n - 1 次 (n为节点个数) ?

那么Bellman_ford的解题解题过程其实就是对所有边松弛 n-1 次,然后得出得到终点的最短路径。

Java实现如下:

java 复制代码
import java.util.*;
import java.util.List;


public class Main {


    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        int n = scanner.nextInt();
        int m = scanner.nextInt();
        List<Edge> edges = new ArrayList<>();
        for(int i = 0 ; i < m ; i++){
            edges.add(new Edge(scanner.nextInt(),scanner.nextInt(),scanner.nextInt()));
        }
        //初始化
        int start = 1;
        int end = n;
        int[] minDist = new int[n+1];
        Arrays.fill(minDist,Integer.MAX_VALUE);
        minDist[start] = 0;
        for(int i = 0 ; i < n-1 ; i++){
            //对所有的边进行n-1次松弛操作
            for(Edge edge : edges){
                if(minDist[edge.from] != Integer.MAX_VALUE && minDist[edge.from] + edge.value < minDist[edge.to]){
                    minDist[edge.to] = minDist[edge.from] + edge.value;
                }
            }
        }
        if(minDist[end] == Integer.MAX_VALUE){
            System.out.println("unconnected");
        }else{
            System.out.println(minDist[end]);
        }
    }

}

class Edge{
    int from;
    int to;
    int value;
    Edge(int from, int to , int value){
        this.from = from;
        this.to = to;
        this.value = value;
    }

}

总结

Bellman_ford 是可以计算 负权值的单源最短路算法。

其算法核心思路是对 所有边进行 n-1 次 松弛。

弄清楚 什么是 松弛? 为什么要 n-1 次? 对理解Bellman_ford 非常重要。

四、Bellman_ford 队列优化算法(又名SPFA)

94. 城市间货物运输 I

可以发现 Bellman_ford 算法每次松弛 都是对所有边进行松弛。

但真正有效的松弛,是基于已经计算过的节点在做的松弛。

给大家举一个例子:

本图中,对所有边进行松弛,真正有效的松弛,只有松弛 边(节点1->节点2) 和 边(节点1->节点3) 。

而松弛 边(节点4->节点6) ,边(节点5->节点3)等等 都是无效的操作,因为 节点4 和 节点 5 都是没有被计算过的节点。

所以 Bellman_ford 算法 每次都是对所有边进行松弛,其实是多做了一些无用功。

只需要对 上一次松弛的时候更新过的节点作为出发节点所连接的边 进行松弛就够了(对上一轮更新结点的出发边做松弛)

基于以上思路,如何记录 上次松弛的时候更新过的节点呢?

用队列来记录。(其实用栈也行,对元素顺序没有要求)

模拟过程

接下来来举例这个队列是如何工作的。

以示例给出的所有边为例:

5 6 -2
1 2 1
5 3 1
2 5 2
2 4 -3
4 6 4
1 3 5

我们依然使用minDist数组来表达 起点到各个节点的最短距离,例如minDist[3] = 5 表示起点到达节点3 的最小距离为5

初始化,起点为节点1, 起点到起点的最短距离为0,所以minDist[1] 为 0。 将节点1 加入队列 (下次松弛从节点1开始)


从队列里取出节点1,松弛节点1 作为出发点连接的边(节点1 -> 节点2)和边(节点1 -> 节点3)

边:节点1 -> 节点2,权值为1 ,minDist[2] > minDist[1] + 1 ,更新 minDist[2] = minDist[1] + 1 = 0 + 1 = 1 。

边:节点1 -> 节点3,权值为5 ,minDist[3] > minDist[1] + 5,更新 minDist[3] = minDist[1] + 5 = 0 + 5 = 5。

将节点2、节点3 加入队列,如图:


从队列里取出节点2,松弛节点2 作为出发点连接的边(节点2 -> 节点4)和边(节点2 -> 节点5)

边:节点2 -> 节点4,权值为1 ,minDist[4] > minDist[2] + (-3) ,更新 minDist[4] = minDist[2] + (-3) = 1 + (-3) = -2 。

边:节点2 -> 节点5,权值为2 ,minDist[5] > minDist[2] + 2 ,更新 minDist[5] = minDist[2] + 2 = 1 + 2 = 3 。

将节点4,节点5 加入队列,如图:


从队列里出去节点3,松弛节点3 作为出发点连接的边。

因为没有从节点3作为出发点的边,所以这里就从队列里取出节点3就好,不用做其他操作,如图:


从队列中取出节点4,松弛节点4作为出发点连接的边(节点4 -> 节点6)

边:节点4 -> 节点6,权值为4 ,minDist[6] > minDist[4] + 4,更新 minDist[6] = minDist[4] + 4 = -2 + 4 = 2 。

将节点6加入队列

如图:


从队列中取出节点5,松弛节点5作为出发点连接的边(节点5 -> 节点3),边(节点5 -> 节点6)

边:节点5 -> 节点3,权值为1 ,minDist[3] > minDist[5] + 1 ,更新 minDist[3] = minDist[5] + 1 = 3 + 1 = 4

边:节点5 -> 节点6,权值为-2 ,minDist[6] > minDist[5] + (-2) ,更新 minDist[6] = minDist[5] + (-2) = 3 - 2 = 1

如图,将节点3加入队列,因为节点6已经在队列里,所以不用重复添加

所以我们在加入队列的过程可以有一个优化,用visited数组记录已经在队列里的元素,已经在队列的元素不用重复加入


从队列中取出节点6,松弛节点6 作为出发点连接的边。

节点6作为终点,没有可以出发的边。

同理从队列中取出节点3,也没有可以出发的边

所以直接从队列中取出,如图:


这样我们就完成了基于队列优化的bellman_ford的算法模拟过程。

大家可以发现 基于队列优化的算法,要比bellman_ford 算法 减少很多无用的松弛情况,特别是对于边数众多的大图 优化效果明显。

Java代码如下:

java 复制代码
import java.util.*;
import java.util.List;


public class Main {


    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        int n = scanner.nextInt();
        int m = scanner.nextInt();
        List<List<Edge>> edges = new ArrayList<>(n+1);
        for(int i = 0 ; i <= n; i++){
            edges.add(new ArrayList<>());
        }
        for(int i = 0 ; i < m ; i++){
            int from = scanner.nextInt();
            int to = scanner.nextInt();
            int value = scanner.nextInt();
            edges.get(from).add(new Edge(from,to,value));
        }
        boolean[] visited = new boolean[n+1];
        int[] minDist = new int[n+1];
        Arrays.fill(minDist,Integer.MAX_VALUE);
        int start = 1;
        int end = n;
        minDist[start] = 0;
        Queue<Integer> queue = new ArrayDeque<>();
        queue.add(start);
        visited[start] = true;
        //进行松弛操作
        while(!queue.isEmpty()){
            int node = queue.remove();
            visited[node] = false;
            for(Edge edge : edges.get(node)){
                if(!visited[node] && minDist[node] + edge.value < minDist[edge.to]){
                    minDist[edge.to] = minDist[node] + edge.value;
                    queue.add(edge.to);
                    visited[edge.to] = true;
                }
            }
        }
        if(minDist[end] == Integer.MAX_VALUE){
            System.out.println("unconnected");
        }else{
            System.out.println(minDist[end]);
        }
    }

}

class Edge{
    int from;
    int to;
    int value;
    Edge(int from, int to , int value){
        this.from = from;
        this.to = to;
        this.value = value;
    }

}

效率分析

队列优化版Bellman_ford 的时间复杂度 并不稳定,效率高低依赖于图的结构。

例如 如果是一个双向图,且每一个节点和所有其他节点都相连的话,那么该算法的时间复杂度就接近于 Bellman_ford 的 O(N * E) N 为节点数量,E为边的数量。

在这种图中,每一个节点都会重复加入队列 n - 1次,因为 这种图中 每个节点 都有 n-1 条指向该节点的边,每条边指向该节点,就需要加入一次队列。

如果图越稠密,则 SPFA的效率越接近与 Bellman_ford。

反之,图越稀疏,SPFA的效率就越高。

一般来说,SPFA 的时间复杂度为 O(K * N) K 为不定值,因为 节点需要计入几次队列取决于 图的稠密度。

如果图是一条线形图且单向的话,每个节点的入度为1,那么只需要加入一次队列,这样时间复杂度就是 O(N)。

所以 SPFA 在最坏的情况下是 O(N * E),但 一般情况下 时间复杂度为 O(K * N)。

尽管如此,以上分析都是 理论上的时间复杂度分析

并没有计算 出队列 和 入队列的时间消耗。 因为这个在不同语言上 时间消耗也是不一定的。

拓展

这里可能有录友疑惑,while (!que.empty()) 队里里 会不会造成死循环? 例如 图中有环,这样一直有元素加入到队列里?

其实有环的情况,要看它是 正权回路 还是 负权回路。

题目描述中,已经说了,本题没有 负权回路 。

如图:

正权回路 就是有环,但环的总权值为正数。

在有环且只有正权回路的情况下,即使元素重复加入队列,最后,也会因为 所有边都松弛后,节点数值(minDist数组)不在发生变化了 而终止。

(而且有重复元素加入队列是正常的,多条路径到达同一个节点,节点必要要选择一个最短的路径,而这个节点就会重复加入队列进行判断,选一个最短的)

0094.城市间货物运输I 中我们讲过对所有边 最多松弛 n -1 次,就一定可以求出所有起点到所有节点的最小距离即 minDist数组。

即使再松弛n次以上, 所有起点到所有节点的最小距离(minDist数组) 不会再变了。 (这里如果不理解,建议认真看0094.城市间货物运输I讲解)

所以本题我们使用队列优化,有元素重复加入队列,也会因为最后 minDist数组 不会在发生变化而终止。

节点再加入队列,需要有松弛的行为, 而 每个节点已经都计算出来 起点到该节点的最短路径,那么就不会有 执行这个判断条件if (minDist[to] > minDist[from] + value),从而不会有新的节点加入到队列。

但如果本题有 负权回路,那情况就不一样了。

五、bellman_ford之判断负权回路

95. 城市间货物运输 II

本题中,在评估从城市 1 到城市 n 的所有可能路径中综合政府补贴后的最低运输成本时,存在一种情况:图中可能出现负权回路

负权回路是指一系列道路的总权值为负,这样的回路使得通过反复经过回路中的道路,理论上可以无限地减少总成本或无限地增加总收益。

为了避免货物运输商采用负权回路这种情况无限的赚取政府补贴,算法还需检测这种特殊情况。

思路

使用 bellman_ford 算法来判断 负权回路。

kama94.城市间货物运输I 中 我们讲了 bellman_ford 算法的核心就是一句话:对 所有边 进行 n-1 次松弛。 同时文中的 【拓展】部分, 我们也讲了 松弛n次以上 会怎么样?

在没有负权回路的图中,松弛 n 次以上 ,结果不会有变化。

但本题有 负权回路,如果松弛 n 次,结果就会有变化了,因为 有负权回路 就是可以无限最短路径(一直绕圈,就可以一直得到无限小的最短距离)。

那么每松弛一次,都会更新最短路径,所以结果会一直有变化。

在代码中,进行n次松弛,前n-1次计算最短路,第n次判断是否有负环。如果第n次仍然有minDist需要更新的情况,那么说明有负环:

java 复制代码
import java.util.*;
import java.util.List;


public class Main {


    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        int n = scanner.nextInt();
        int m = scanner.nextInt();
        List<Edge> edges = new ArrayList<>();
        for(int i = 0 ; i < m ; i++){
            edges.add(new Edge(scanner.nextInt(),scanner.nextInt(),scanner.nextInt()));
        }
        //初始化
        int start = 1;
        int end = n;
        int[] minDist = new int[n+1];
        Arrays.fill(minDist,Integer.MAX_VALUE);
        minDist[start] = 0;
        boolean flag = false;
        for(int i = 0 ; i < n ; i++){
            //对所有的边进行n次松弛操作,其中第n次用于判断是否有负环
            if(i < n-1){
                for(Edge edge : edges){
                    if(minDist[edge.from] != Integer.MAX_VALUE && minDist[edge.from] + edge.value < minDist[edge.to]){
                        minDist[edge.to] = minDist[edge.from] + edge.value;
                    }
                }
            }else{
                for(Edge edge : edges){
                    if(minDist[edge.from] != Integer.MAX_VALUE && minDist[edge.from] + edge.value < minDist[edge.to]){
                        flag = true;
                        break;
                    }
                }
            }
            
        }
        if(flag){
            System.out.println("circle");
        }else if(minDist[end] == Integer.MAX_VALUE){
            System.out.println("unconnected");
        }else{
            System.out.println(minDist[end]);
        }
    }

}

class Edge{
    int from;
    int to;
    int value;
    Edge(int from, int to , int value){
        this.from = from;
        this.to = to;
        this.value = value;
    }

}

拓展

本题可不可 使用 队列优化版的bellman_ford(SPFA)呢?

上面的解法中,我们对所有边松弛了n-1次后,在松弛一次,如果出现minDist出现变化就判断有负权回路。

如果使用 SPFA 那么节点都是进队列的,那么节点进入队列几次后 足够判断该图是否有负权回路呢?

0094.城市间货物运输I-SPFA 中,我们讲过 在极端情况下,即:所有节点都与其他节点相连,每个节点的入度为 n-1 (n为节点数量),所以每个节点最多加入 n-1 次队列。

那么如果节点加入队列的次数 超过了 n-1次 ,那么该图就一定有负权回路。

所以本题也是可以使用 SPFA 来做的。

六、bellman_ford之单源有限最短路

96. 城市间货物运输 III

本题要求计算在最多经过 k 个城市的条件下,从城市 src 到城市 dst 的最低运输成本。

根据bellman_ford算法的思路,容易想到可以进行k+1次松弛。

问题在于在每一次松弛的时候,受到松弛边的处理顺序的影响,当前在处理的边的结果可能在本次松弛过程中影响到其他边,实际起到了一条边被多次松弛的效果,这就导致了k实际上不起作用,这不是我们期望的。

我们期望的是第一次松弛只更新和start直接相连的边,第二次松弛在第一次松弛的基础上更新和start间隔1个城市的边。参考讲解:代码随想录:bellman_ford之单源有限最短路

那么只需要将上一次的minDist记录下来,本次更新使用上一次的minDist,避免本次更新minDist过程中使用到本次的结果。

Java实现如下:

java 复制代码
import java.util.*;
import java.util.List;


public class Main {


    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        int n = scanner.nextInt();
        int m = scanner.nextInt();
        List<Edge> edges = new ArrayList<>();
        for(int i = 0; i < m; i++){
            int from = scanner.nextInt();
            int to = scanner.nextInt();
            int value = scanner.nextInt();
            edges.add(new Edge(from,to,value));
        }
        int[] minDist = new int[n+1];
        int[] minDist_pre ;
        Arrays.fill(minDist,Integer.MAX_VALUE);
        int start = scanner.nextInt();
        int end = scanner.nextInt();
        minDist[start] = 0;
        int k = scanner.nextInt();
        for(int i = 0 ; i < k+1 ; i++){
            minDist_pre = Arrays.copyOf(minDist,n+1);
            for(Edge edge : edges){
                if(minDist_pre[edge.from] != Integer.MAX_VALUE && minDist_pre[edge.from] + edge.value < minDist[edge.to]){
                    //要更新的是 minDist[edge.to] 使用 minDist_pre进行更新
                    minDist[edge.to] = minDist_pre[edge.from] + edge.value;
                }
            }
        }
        if(minDist[end] == Integer.MAX_VALUE){
            System.out.println("unreachable");
        }else{
            System.out.println(minDist[end]);
        }
    }

}

class Edge{
    int from;
    int to;
    int value;
    Edge(int from, int to , int value){
        this.from = from;
        this.to = to;
        this.value = value;
    }

}

拓展一(边的顺序的影响)

其实边的顺序会影响我们每一次拓展的结果。

我来给大家举个例子。

我上面讲解中,给出的示例是这样的:

4 4
1 2 -1
2 3 1
3 1 -1
3 4 1
1 4 3

我将示例中边的顺序改一下,给成:

4 4
3 1 -1
3 4 1
2 3 1
1 2 -1
1 4 3

所构成是图是一样的,都是如下的这个图,但给出的边的顺序是不一样的。

再用版本一的代码是运行一下,发现结果输出是 1, 是对的。

分明刚刚输出的结果是 -2,是错误的,怎么 一样的图,这次输出的结果就对了呢?

其实这是和示例中给出的边的顺序是有关的,

我们按照修改后的示例再来模拟 对所有边的第一次拓展情况。

初始化:

边:节点3 -> 节点1,权值为-1 ,节点3还没有被计算过,节点1 不更新。

边:节点3 -> 节点4,权值为1 ,节点3还没有被计算过,节点4 不更新。

边:节点2 -> 节点3,权值为 1 ,节点2还没有被计算过,节点3 不更新。

边:节点1 -> 节点2,权值为 -1 ,minDist[2] > minDist[1] + (-1),更新 minDist[2] = 0 + (-1) = -1 ,如图:

以上是对所有边 松弛一次的状态。

可以发现 同样的图,边的顺序不一样,使用版本一的代码 每次松弛更新的节点也是不一样的。

而边的顺序是随机的,是题目给我们的,所以本题我们才需要 记录上一次松弛的minDist,来保障 每一次对所有边松弛的结果。

拓展二(本题本质)

那么前面讲解过的 94.城市间货物运输I95.城市间货物运输II 也是bellman_ford经典算法,也没使用 minDist_copy,怎么就没问题呢?

94.城市间货物运输I, 是没有 负权回路的,那么 多松弛多少次,对结果都没有影响。

求 节点1 到 节点n 的最短路径,松弛n-1 次就够了,松弛 大于 n-1次,结果也不会变。

那么在对所有边进行第一次松弛的时候,如果基于 本次计算的 minDist 来计算 minDist (相当于多做松弛了),也是对最终结果没影响。

95.城市间货物运输II 是判断是否有 负权回路,一旦有负权回路, 对所有边松弛 n-1 次以后,在做松弛 minDist 数值一定会变,根据这一点来判断是否有负权回路。

所以,95.城市间货物运输II 只需要判断minDist数值变化了就行,而 minDist 的数值对不对,并不是我们关心的。

那么本题 为什么计算minDist 一定要基于上次 的 minDist 数值。

其关键在于本题的两个因素:

  • 本题可以有负权回路,说明只要多做松弛,结果是会变的。
  • 本题要求最多经过k个节点,对松弛次数是有限制的。(这就不允许有多松弛的效果)

如果本题中 没有负权回路的测试用例, 那版本一的代码就可以过了,也就不用我费这么大口舌去讲解的这个坑了。

拓展三(SPFA)

本题也可以用 SPFA来做,关于 SPFA ,已经在这里 0094.城市间货物运输I-SPFA 有详细讲解。

使用SPFA算法解决本题的时候,关键在于 如何控制松弛k次。

其实实现不难,但有点技巧,可以用一个变量 que_size 记录每一轮松弛入队列的所有节点数量。

下一轮松弛的时候,就把队列里 que_size 个节点都弹出来,就是上一轮松弛入队列的节点。

(SPFA实际上更符合对k个节点的要求,更加直观)

代码如下(详细注释)

cpp 复制代码
#include <iostream>
#include <vector>
#include <queue>
#include <list>
#include <climits>
using namespace std;

struct Edge { //邻接表
    int to;  // 链接的节点
    int val; // 边的权重

    Edge(int t, int w): to(t), val(w) {}  // 构造函数
};


int main() {
    int n, m, p1, p2, val;
    cin >> n >> m;

    vector<list<Edge>> grid(n + 1); // 邻接表

    // 将所有边保存起来
    for(int i = 0; i < m; i++){
        cin >> p1 >> p2 >> val;
        // p1 指向 p2,权值为 val
        grid[p1].push_back(Edge(p2, val));
    }
    int start, end, k;
    cin >> start >> end >> k;

    k++;

    vector<int> minDist(n + 1 , INT_MAX);
    vector<int> minDist_copy(n + 1); // 用来记录每一次遍历的结果

    minDist[start] = 0;

    queue<int> que;
    que.push(start); // 队列里放入起点

    int que_size;
    while (k-- && !que.empty()) {

        minDist_copy = minDist; // 获取上一次计算的结果
        que_size = que.size(); // 记录上次入队列的节点个数
        while (que_size--) { // 上一轮松弛入队列的节点,这次对应的边都要做松弛
            int node = que.front(); que.pop();
            for (Edge edge : grid[node]) {
                int from = node;
                int to = edge.to;
                int price = edge.val;
                if (minDist[to] > minDist_copy[from] + price) {
                    minDist[to] = minDist_copy[from] + price;
                    que.push(to);
                }
            }

        }
    }
    if (minDist[end] == INT_MAX) cout << "unreachable" << endl;
    else cout << minDist[end] << endl;

}

时间复杂度: O(K * H) H 为不确定数,取决于 图的稠密度,但H 一定是小于等于 E 的

关于 SPFA的是时间复杂度分析,我在0094.城市间货物运输I-SPFA 有详细讲解

但大家会发现,以上代码大家提交后,怎么耗时这么多?

理论上,SPFA的时间复杂度不是要比 bellman_ford 更优吗?

怎么耗时多了这么多呢?

以上代码有一个可以改进的点,每一轮松弛中,重复节点可以不用入队列。

因为重复节点入队列,下次从队列里取节点的时候,该节点要取很多次,而且都是重复计算。

所以代码可以优化成这样:

cpp 复制代码
#include <iostream>
#include <vector>
#include <queue>
#include <list>
#include <climits>
using namespace std;

struct Edge { //邻接表
    int to;  // 链接的节点
    int val; // 边的权重

    Edge(int t, int w): to(t), val(w) {}  // 构造函数
};


int main() {
    int n, m, p1, p2, val;
    cin >> n >> m;

    vector<list<Edge>> grid(n + 1); // 邻接表

    // 将所有边保存起来
    for(int i = 0; i < m; i++){
        cin >> p1 >> p2 >> val;
        // p1 指向 p2,权值为 val
        grid[p1].push_back(Edge(p2, val));
    }
    int start, end, k;
    cin >> start >> end >> k;

    k++;

    vector<int> minDist(n + 1 , INT_MAX);
    vector<int> minDist_copy(n + 1); // 用来记录每一次遍历的结果

    minDist[start] = 0;

    queue<int> que;
    que.push(start); // 队列里放入起点

    int que_size;
    while (k-- && !que.empty()) {

        vector<bool> visited(n + 1, false); // 每一轮松弛中,控制节点不用重复入队列
        minDist_copy = minDist; 
        que_size = que.size(); 
        while (que_size--) { 
            int node = que.front(); que.pop();
            for (Edge edge : grid[node]) {
                int from = node;
                int to = edge.to;
                int price = edge.val;
                if (minDist[to] > minDist_copy[from] + price) {
                    minDist[to] = minDist_copy[from] + price;
                    if(visited[to]) continue; // 不用重复放入队列,但需要重复松弛,所以放在这里位置
                    visited[to] = true;
                    que.push(to);
                }
            }

        }
    }
    if (minDist[end] == INT_MAX) cout << "unreachable" << endl;
    else cout << minDist[end] << endl;
}

以上代码提交后,耗时情况:

大家发现 依然远比 bellman_ford 的代码版本 耗时高。

这又是为什么呢?

对于后台数据,我特别制作的一个稠密大图,该图有250个节点和10000条边, 在这种情况下, SPFA 的时间复杂度 是接近与 bellman_ford的。

但因为 SPFA 节点的进出队列操作,耗时很大,所以相同的时间复杂度的情况下,SPFA 实际上更耗时了。

这一点我在 0094.城市间货物运输I-SPFA 有分析,感兴趣的录友再回头去看看。

拓展四(能否用dijkstra)

本题能否使用 dijkstra 算法呢?

dijkstra 是贪心的思路 每一次搜索都只会找距离源点最近的非访问过的节点。

如果限制最多访问k个节点,那么 dijkstra 未必能在有限次就能到达终点,即使在经过k个节点确实可以到达终点的情况下。

这么说大家会感觉有点抽象,我用 dijkstra朴素版精讲 里的示例在举例说明: (如果没看过我讲的dijkstra朴素版精讲,建议去仔细看一下,否则下面讲解容易看不懂)

在以下这个图中,求节点1 到 节点7 最多经过2个节点 的最短路是多少呢?

最短路显然是:

最多经过2个节点,也就是3条边相连的路线:节点1 -> 节点2 -> 节点6-> 节点7

如果是 dijkstra 求解的话,求解过程是这样的: (下面是dijkstra的模拟过程,我精简了很多,如果看不懂,一定要先看dijkstra朴素版精讲

初始化如图所示:

找距离源点最近且没有被访问过的节点,先找节点1

距离源点最近且没有被访问过的节点,找节点2:

距离源点最近且没有被访问过的节点,找到节点3:

距离源点最近且没有被访问过的节点,找到节点4:

此时最多经过2个节点的搜索就完毕了,但结果中minDist[7] (即节点7的结果)并没有被更。

那么 dijkstra 会告诉我们 节点1 到 节点7 最多经过2个节点的情况下是不可到达的。

通过以上模拟过程,大家应该能感受到 dijkstra 贪心的过程,正是因为 贪心,所以 dijkstra 找不到 节点1 -> 节点2 -> 节点6-> 节点7 这条路径。

本质上的原因是dijkstra是贪心的。

总结

本题是单源有限最短路问题,也是 bellman_ford的一个拓展问题,如果理解bellman_ford 其实思路比较容易理解,但有很多细节。

例如 为什么要用 minDist_copy 来记录上一轮 松弛的结果。 这也是本篇我为什么花了这么大篇幅讲解的关键所在。

接下来,还给大家做了四个拓展:

  • 边的顺序的影响
  • 本题的本质
  • SPFA的解法
  • 能否用dijkstra

学透了以上四个拓展,相信大家会对bellman_ford有更深入的理解。

七、Floyd 算法精讲

97. 小明逛公园

思路

本题是经典的多源最短路问题,即 求多个起点到多个终点的多条最短路径。

通过本题来系统讲解一个新的最短路算法-Floyd 算法。

Floyd 算法对边的权值正负没有要求,都可以处理

Floyd算法核心思想是动态规划。

例如我们再求节点1 到 节点9 的最短距离,用二维数组来表示即:grid[1][9],如果最短距离是10 ,那就是 grid[1][9] = 10。

那 节点1 到 节点9 的最短距离 是不是可以由 节点1 到节点5的最短距离 + 节点5到节点9的最短距离组成呢?

即 grid[1][9] = grid[1][5] + grid[5][9]

节点1 到节点5的最短距离 是不是可以有 节点1 到 节点3的最短距离 + 节点3 到 节点5 的最短距离组成呢?

即 grid[1][5] = grid[1][3] + grid[3][5]

以此类推,节点1 到 节点3的最短距离 可以由更小的区间组成。

那么这样我们是不是就找到了,子问题推导求出整体最优方案的递归关系呢。

节点1 到 节点9 的最短距离 可以由 节点1 到节点5的最短距离 + 节点5到节点9的最短距离组成, 也可以有 节点1 到节点7的最短距离 + 节点7 到节点9的最短距离的距离组成。

那么选哪个呢?

是不是 要选一个最小的,毕竟是求最短路。

此时我们已经接近明确递归公式了。

之前在讲解动态规划的时候,给出过动规五部曲:

  • 确定dp数组(dp table)以及下标的含义
  • 确定递推公式
  • dp数组如何初始化
  • 确定遍历顺序
  • 举例推导dp数组

那么接下来我们还是用这五部来给大家讲解 Floyd。

1、dp数组及下标含义

grid[i][j][k] = m:表示 从节点 i 考虑经过(可以选择经过或者不经过)节点1~k 到节点j 的代价是m.

2、确定递推公式

想要从 grid[i][j][k-1] 得到 grid[i][j][k] ,考虑两种情况

(1)从i到j的最短路径经过节点k,那么

grid[i][j][k] = grid[i][k][k-1] + grid[k][j][k-1] 右边的两个依赖项均计算过了

(2)从i到j的最短路径经过节点k,那么

grid[i][j][k] = grid[i][j][k-1]

综上,递推公式为 grid[i][j][k] = Math.min(grid[i][k][k-1] + grid[k][j][k-1] , grid[i][j][k-1]);

3、初始化

考虑 k = 0 的情况 ,根据定义,此时grid[i][j][0] 是 从i到j不经过任何节点所要付出的代价,那么只能是i和j直接相连,所以在读入边的时候对grid[i][j][0]和 grid[j][i][0]初始化成对应的权重(无向图)

4、遍历顺序

dp数组是三维数组,初始化的都是k=0这个平面,要保证每一次计算的时候,它所以来的项都已经计算出来,并且依赖于之前计算的结果,这就要求从k=0这个平面一层一层向上计算。所以最外层的循环是k,内层的i的循环和j的循环可以交换。

5、dp模拟

日志输出的时候可以按k=0,1,2...逐层输出。

Java实现如下:

java 复制代码
import java.util.*;


public class Main {


    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        int n = scanner.nextInt();
        int m = scanner.nextInt();
        int[][][] grid = new int[n+1][n+1][n+1];
        for(int[][] grid1 : grid){
            for(int[] grid2 : grid1){
                Arrays.fill(grid2,Integer.MAX_VALUE);
            }
        }
        //读图,初始化
        for(int i = 0 ; i < m; i++){
            int start = scanner.nextInt();
            int end = scanner.nextInt();
            int value = scanner.nextInt();
            grid[start][end][0] = value;
            grid[end][start][0] = value;
        }
        //动态规划
        for(int k = 1 ; k <= n ; k++){
            for(int i = 1 ; i <= n; i++){
                for(int j = 1 ; j <= n; j++){
                    if(grid[i][k][k-1] == Integer.MAX_VALUE || grid[k][j][k-1] == Integer.MAX_VALUE) grid[i][j][k] = grid[i][j][k-1];
                    else grid[i][j][k] = Math.min(grid[i][k][k-1] + grid[k][j][k-1],grid[i][j][k-1]); //如果初始化成Integer.MAX_VALUE,这里可能溢出,可以先判断一下是不是
                }
            }
        }
        //答案输出
        int plans = scanner.nextInt();
        for(int i = 0 ; i < plans ; i++){
            int start = scanner.nextInt();
            int end = scanner.nextInt();
            if(grid[start][end][n] != Integer.MAX_VALUE){
                System.out.println(grid[start][end][n]);
            }else{
                System.out.println("-1");
            }
        }
    }

}

注意一个问题,dp数组递推的过程中,可能会出现Integer.MAX_VALUE相加导致溢出,需要预先判断一下。

也可以选择一个题目中一定不会到达的大数同时还能够避免溢出的 来初始化。

空间优化

这里 我们可以做一下 空间上的优化,从滚动数组的角度来看,我们定义一个 grid[n + 1][ n + 1][2] 这么大的数组就可以,因为k 只是依赖于 k-1的状态,并不需要记录k-2,k-3,k-4 等等这些状态。

那么我们只需要记录 grid[i][j][1] 和 grid[i][j][0] 就好,之后就是 grid[i][j][1] 和 grid[i][j][0] 交替滚动。

在进一步想,如果本层计算(本层计算即k相同,从三维角度来讲) grid[i][j] 用到了 本层中刚计算好的 grid[i][k] 会有什么问题吗?

如果 本层刚计算好的 grid[i][k] 比上一层 (即k-1层)计算的 grid[i][k] 小,说明确实有 i 到 k 的更短路径,那么基于 更小的 grid[i][k] 去计算 gird[i][j] 没有问题。

如果 本层刚计算好的 grid[i][k] 比上一层 (即k-1层)计算的 grid[i][k] 大, 这不可能,因为这样也不会做更新 grid[i][k]的操作。

所以本层计算中,使用了本层计算过的 grid[i][k] 和 grid[k][j] 是没问题的。

那么就没必要区分,grid[i][k] 和 grid[k][j] 是 属于 k - 1 层的呢,还是 k 层的。

所以递归公式可以为:

grid[i][j] = min(grid[i][j], grid[i][k] + grid[k][j]);

Java实现如下:

java 复制代码
import java.util.*;


public class Main {


    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        int n = scanner.nextInt();
        int m = scanner.nextInt();
        int[][] grid = new int[n+1][n+1];
        for(int[] grid1 : grid){
            Arrays.fill(grid1,Integer.MAX_VALUE);
        }
        //读图,初始化
        for(int i = 0 ; i < m; i++){
            int start = scanner.nextInt();
            int end = scanner.nextInt();
            int value = scanner.nextInt();
            grid[start][end] = value;
            grid[end][start] = value;
        }
        //动态规划
        for(int k = 1; k <= n ; k++){
            for(int i = 1; i <= n ; i++){
                for(int j = 1 ; j <= n ; j++){
                    if(grid[i][k] == Integer.MAX_VALUE || grid[k][j] == Integer.MAX_VALUE) continue;
                    else grid[i][j] = Math.min(grid[i][j],grid[i][k] + grid[k][j]);
                }
            }
        }
        
        //答案输出
        int plans = scanner.nextInt();
        for(int i = 0 ; i < plans ; i++){
            int start = scanner.nextInt();
            int end = scanner.nextInt();
            if(grid[start][end] != Integer.MAX_VALUE){
                System.out.println(grid[start][end]);
            }else{
                System.out.println("-1");
            }
        }
    }

}

总结

本期如果上来只用二维数组来讲的话,其实更容易,但遍历顺序那里用二维数组其实是讲不清楚的,所以我直接用三维数组来讲,目的是将遍历顺序这里讲清楚。

理解了遍历顺序才是floyd算法最精髓的地方。

floyd算法的时间复杂度相对较高,适合 稠密图且源点较多的情况

如果是稀疏图,floyd是从节点的角度去计算了,例如 图中节点数量是 1000,就一条边,那 floyd的时间复杂度依然是 O(n^3) 。

如果 源点少,其实可以 多次dijsktra 求源点到终点。

八、A * 算法精讲 (A star算法)

127. 骑士的攻击

bfs

本题要求求最短路径,容易想到bfs:

Java实现:

java 复制代码
import java.util.*;


public class Main {
    static int[][]directions = {{1,2},{2,1},{-1,2},{2,-1},{1,-2},{-2,1},{-1,-2},{-2,-1}};

    public static void main(String[] args) {
        int[][] grid = new int[1001][1001];
        Scanner scanner = new Scanner(System.in);
        int n = scanner.nextInt();
        for(int i = 0 ; i < n; i++){
            //注意每次都要把grid清0
            for(int[] l : grid){
                Arrays.fill(l,0);
            }
            int startX = scanner.nextInt();
            int startY = scanner.nextInt();
            int endX = scanner.nextInt();
            int endY= scanner.nextInt();
            if(startX == endX && startY == endY){
                System.out.println(0);
                continue;
            }
            Queue<int[]> queue = new ArrayDeque<>();
            queue.add(new int[]{startX,startY});
            while(!queue.isEmpty()){
                int[] nowLocation = queue.remove();
                int nowX = nowLocation[0];
                int nowY = nowLocation[1];
                for(int j = 0 ; j < 8 ; j++){
                    int nextX = nowX + directions[j][0];
                    int nextY = nowY + directions[j][1];
                    if(nextX < 1 || nextX > 1000 || nextY < 1 || nextY > 1000) continue;
                    if(grid[nextX][nextY] == 0){
                        grid[nextX][nextY] = grid[nowX][nowY] + 1;
                        queue.add(new int[]{nextX,nextY});
                    }
                }
                if(grid[endX][endY] != 0) break;
            }
            System.out.println(grid[endX][endY]);
        }
    }

}

C++实现:

cpp 复制代码
#include<iostream>
#include<queue>
#include<string.h>
using namespace std;
int moves[1001][1001];
int dir[8][2]={-2,-1,-2,1,-1,2,1,2,2,1,2,-1,1,-2,-1,-2};
void bfs(int a1,int a2, int b1, int b2)
{
	queue<int> q;
	q.push(a1);
	q.push(a2);
	while(!q.empty())
	{
		int m=q.front(); q.pop();
		int n=q.front(); q.pop();
		if(m == b1 && n == b2)
		break;
		for(int i=0;i<8;i++)
		{
			int mm=m + dir[i][0];
			int nn=n + dir[i][1];
			if(mm < 1 || mm > 1000 || nn < 1 || nn > 1000)
			continue;
			if(!moves[mm][nn])
			{
				moves[mm][nn]=moves[m][n]+1;
				q.push(mm);
				q.push(nn);
			}
		}
	}
}

int main()
{
    int n, a1, a2, b1, b2;
    cin >> n;
    while (n--) {
        cin >> a1 >> a2 >> b1 >> b2;
        memset(moves,0,sizeof(moves));
		bfs(a1, a2, b1, b2);
		cout << moves[b1][b2] << endl;
	}
	return 0;
}

但提交后会时间超限。

Astar

Astar 是一种 广搜的改良版。 有的是 Astar是 dijkstra 的改良版。

其实只是场景不同而已 我们在搜索最短路的时候, 如果是无权图(边的权值都是1) 那就用广搜,代码简洁,时间效率和 dijkstra 差不多 (具体要取决于图的稠密)

如果是有权图(边有不同的权值),优先考虑 dijkstra。

而 Astar 关键在于 启发式函数, 也就是 影响 广搜或者 dijkstra 从 容器(队列)里取元素的优先顺序。

A*算法通过启发函数的设计,实现有方向地搜索。

那么启发式函数落实到代码处,如果指引搜索的方向?

在本篇开篇中给出了BFS代码,指引 搜索的方向的关键代码在这里:

cpp 复制代码
int m=q.front();q.pop();
int n=q.front();q.pop();

从队列里取出什么元素,接下来就是从哪里开始搜索。

所以 启发式函数 要影响的就是队列里元素的排序

这是影响BFS搜索方向的关键。

对队列里节点进行排序,就需要给每一个节点权值,如何计算权值呢?

每个节点的权值为F,给出公式为:F = G + H

G:起点达到目前遍历节点的距离

F:目前遍历的节点到达终点的距离

起点达到目前遍历节点的距离 + 目前遍历的节点到达终点的距离 就是起点到达终点的距离。

本题的图是无权网格状,在计算两点距离通常有如下三种计算方式:

  1. 曼哈顿距离,计算方式: d = abs(x1-x2)+abs(y1-y2)
  2. 欧氏距离(欧拉距离) ,计算方式:d = sqrt( (x1-x2)^2 + (y1-y2)^2 )
  3. 切比雪夫距离,计算方式:d = max(abs(x1 - x2), abs(y1 - y2))

x1, x2 为起点坐标,y1, y2 为终点坐标 ,abs 为求绝对值,sqrt 为求开根号,

选择哪一种距离计算方式 也会导致 A * 算法的结果不同。

本题,采用欧拉距离才能最大程度体现 点与点之间的距离。

所以 使用欧拉距离计算 和 广搜搜出来的最短路的节点数是一样的。 (路径可能不同,但路径上的节点数是相同的)

可以使用 优先级队列 帮我们排好序,每次出队列,就是F最小的节点。

Java实现:

java 复制代码
import java.util.*;

class Knight{
    int x;
    int y;
    int g;
    int h;
    int weight;
    Knight(int x, int y){
        this.x = x;
        this.y = y;
    }

    void calWeight(int g,int endX,int endY){
        this.g = g;
        this.h = (x - endX) * (x - endX) + (y - endY) * (y - endY); // 统一不开根号,这样可以提高精度
        this.weight = this.g + this.h;
    }
}

class MyComparison implements  Comparator<Knight>{
    @Override
    public int compare(Knight o1, Knight o2) {
        return Integer.compare(o1.weight,o2.weight);
    }
}


public class Main {
    static int[][]directions = {{1,2},{2,1},{-1,2},{2,-1},{1,-2},{-2,1},{-1,-2},{-2,-1}};
    static int endX;
    static int endY;
    static PriorityQueue<Knight> queue = new PriorityQueue<>(new MyComparison());

    static int[][] grid =  new int[1001][1001];

    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        int n = scanner.nextInt();
        for(int i = 0 ; i < n; i++){
            for(int[] arr : grid){
                Arrays.fill(arr,0);
            }
            Knight knight = new Knight(scanner.nextInt(),scanner.nextInt());
            endX = scanner.nextInt();
            endY = scanner.nextInt();
            knight.calWeight(0,endX,endY);
            astar(knight);
            while(!queue.isEmpty()) queue.remove();
            System.out.println(grid[endX][endY]);
        }
    }

    public static void astar(Knight knight){
        Knight cur,next;
        queue.add(knight);
        while(!queue.isEmpty()){
            cur = queue.remove();
            if(cur.x == endX && cur.y == endY) break;
            for(int i = 0 ; i < 8 ; i++){
                int nextX = cur.x + directions[i][0];
                int nextY = cur.y + directions[i][1];
                if(nextX < 1 || nextX > 1000 || nextY < 1 || nextY > 1000) continue;
                if(grid[nextX][nextY] == 0){
                    grid[nextX][nextY] = grid[cur.x][cur.y] + 1;
                    next = new Knight(nextX,nextY);
                    next.calWeight(cur.g+5,endX,endY);//统一不开根号,提高精度
                    queue.add(next);
                }
            }
        }

    }

}

感觉这里有必要讲解一下代码架构。

(1)为了实现对位置的排序,定义了一个类Knight专门用来存储当前的位置、当前的g、h和weight。

(2)为了实现优先队列排序,自定义了MyComparison 取 implements Comparator<Knight> 实现对Knight的排序,使用Integer.compare维护一个最小堆。

(3)Knight类中calWeight函数需要传入当前的g,endX和endY,用来计算h,并把g和h加和得到weight。Knight的x,y是要在创建的时候传入的。

(4)将一些必要的量,诸如grid,endX,endY设为全局静态变量,方便类内函数沟通,降低传参的要求。但也要注意每一轮grid初始化,每一轮要把queue清空。

C++实现:

cpp 复制代码
#include<iostream>
#include<queue>
#include<string.h>
using namespace std;
int moves[1001][1001];
int dir[8][2]={-2,-1,-2,1,-1,2,1,2,2,1,2,-1,1,-2,-1,-2};
int b1, b2;
// F = G + H
// G = 从起点到该节点路径消耗
// H = 该节点到终点的预估消耗

struct Knight{
    int x,y;
    int g,h,f;
    bool operator < (const Knight & k) const{  // 重载运算符, 从小到大排序
     return k.f < f;
    }
};

priority_queue<Knight> que;

int Heuristic(const Knight& k) { // 欧拉距离
    return (k.x - b1) * (k.x - b1) + (k.y - b2) * (k.y - b2); // 统一不开根号,这样可以提高精度
}
void astar(const Knight& k)
{
    Knight cur, next;
	que.push(k);
	while(!que.empty())
	{
		cur=que.top(); que.pop();
		if(cur.x == b1 && cur.y == b2)
		break;
		for(int i = 0; i < 8; i++)
		{
			next.x = cur.x + dir[i][0];
			next.y = cur.y + dir[i][1];
			if(next.x < 1 || next.x > 1000 || next.y < 1 || next.y > 1000)
			continue;
			if(!moves[next.x][next.y])
			{
				moves[next.x][next.y] = moves[cur.x][cur.y] + 1;

                // 开始计算F
				next.g = cur.g + 5; // 统一不开根号,这样可以提高精度,马走日,1 * 1 + 2 * 2 = 5
                next.h = Heuristic(next);
                next.f = next.g + next.h;
                que.push(next);
			}
		}
	}
}

int main()
{
    int n, a1, a2;
    cin >> n;
    while (n--) {
        cin >> a1 >> a2 >> b1 >> b2;
        memset(moves,0,sizeof(moves));
        Knight start;
        start.x = a1;
        start.y = a2;
        start.g = 0;
        start.h = Heuristic(start);
        start.f = start.g + start.h;
		astar(start);
        while(!que.empty()) que.pop(); // 队列清空
		cout << moves[b1][b2] << endl;
	}
	return 0;
}

复杂度分析

A * 算法的时间复杂度 其实是不好去量化的,因为他取决于 启发式函数怎么写。

最坏情况下,A * 退化成广搜,算法的时间复杂度 是 O(n * 2),n 为节点数量。

最佳情况,是从起点直接到终点,时间复杂度为 O(dlogd),d 为起点到终点的深度。

因为在搜索的过程中也需要堆排序,所以是 O(dlogd)。

实际上 A * 的时间复杂度是介于 最优 和最坏 情况之间, 可以 非常粗略的认为 A * 算法的时间复杂度是 O(nlogn) ,n 为节点数量。

A * 算法的空间复杂度 O(b ^ d) ,d 为起点到终点的深度,b 是 图中节点间的连接数量,本题因为是无权网格图,所以 节点间连接数量为 4。

拓展

如果本题大家使用 曼哈顿距离 或者 切比雪夫距离 计算的话,可以提交试一试,有的最短路结果是并不是最短的。

原因也是 曼哈顿 和 切比雪夫这两种计算方式在 本题的网格地图中,都没有体现出点到点的真正距离!

可能有些录友找到类似的题目,例如 poj 2243 (opens new window),使用 曼哈顿距离 提交也过了, 那是因为题目中的地图太小了,仅仅是一张 8 * 8的地图,根本看不出来 不同启发式函数写法的区别。

A * 算法 并不是一个明确的最短路算法,A * 算法搜的路径如何,完全取决于 启发式函数怎么写

A * 算法并不能保证一定是最短路,因为在设计 启发式函数的时候,要考虑 时间效率与准确度之间的一个权衡。

虽然本题中,A * 算法得到是最短路,也是因为本题 启发式函数 和 地图结构都是最简单的。

例如在游戏中,在地图很大、不同路径权值不同、有障碍 且多个游戏单位在地图中寻路的情况,如果要计算准确最短路,耗时很大,会给玩家一种卡顿的感觉。

而真实玩家在玩游戏的时候,并不要求一定是最短路,次短路也是可以的 (玩家不一定能感受出来,即使感受出来也不是很在意),只要奔着目标走过去 大体就可以接受。

所以 在游戏开发设计中,保证运行效率的情况下,A * 算法中的启发式函数 设计往往不是最短路,而是接近最短路的 次短路设计

A * 的缺点

大家看上述 A * 代码的时候,可以看到 我们想 队列里添加了很多节点,但真正从队列里取出来的 仅仅是 靠启发式函数判断 距离终点最近的节点。

相对了 普通BFS,A * 算法只从 队列里取出 距离终点最近的节点。

那么问题来了,A * 在一次路径搜索中,大量不需要访问的节点都在队列里,会造成空间的过度消耗。

IDA * 算法 对这一空间增长问题进行了优化,关于 IDA * 算法,本篇不再做讲解,感兴趣的录友可以自行找资料学习。

另外还有一种场景 是 A * 解决不了的。

如果题目中,给出 多个可能的目标,然后在这多个目标中 选择最近的目标,这种 A * 就不擅长了, A * 只擅长给出明确的目标 然后找到最短路径。

如果是多个目标找最近目标(特别是潜在目标数量很多的时候),可以考虑 Dijkstra ,BFS 或者 Floyd。

总结参考:最短路算法总结篇

图论总结参考:图论总结篇

相关推荐
网易独家音乐人Mike Zhou7 分钟前
【卡尔曼滤波】数据预测Prediction观测器的理论推导及应用 C语言、Python实现(Kalman Filter)
c语言·python·单片机·物联网·算法·嵌入式·iot
Swift社区4 小时前
LeetCode - #139 单词拆分
算法·leetcode·职场和发展
Kent_J_Truman4 小时前
greater<>() 、less<>()及运算符 < 重载在排序和堆中的使用
算法
IT 青年5 小时前
数据结构 (1)基本概念和术语
数据结构·算法
Dong雨5 小时前
力扣hot100-->栈/单调栈
算法·leetcode·职场和发展
SoraLuna5 小时前
「Mac玩转仓颉内测版24」基础篇4 - 浮点类型详解
开发语言·算法·macos·cangjie
liujjjiyun6 小时前
小R的随机播放顺序
数据结构·c++·算法
¥ 多多¥6 小时前
c++中mystring运算符重载
开发语言·c++·算法
trueEve7 小时前
SQL,力扣题目1369,获取最近第二次的活动
算法·leetcode·职场和发展
天若有情6737 小时前
c++框架设计展示---提高开发效率!
java·c++·算法