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

news2024/12/27 13:33:02

一、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 三部曲来讲解的,理解了这三部曲,代码也就好懂的。

代码如下:

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的时候记录即可。

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数组的写法:

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数组的写法:

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++定义小顶堆,可以用优先级队列实现,代码如下:

// 小顶堆
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循环去遍历,直接取堆顶元素:

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

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

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

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

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

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

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

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


// 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++代码如下:

for (Edge edge : grid[cur.first]) 

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

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

// 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])

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

即:

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完整代码如下:

#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实现如下:


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:


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] 应为如何取舍。

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

if (minDist[B] > minDist[A] + value) minDist[B] = minDist[A] + value

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

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

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实现如下:

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代码如下:

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需要更新的情况,那么说明有负环:

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实现如下:

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.城市间货物运输I 和 95.城市间货物运输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个节点的要求,更加直观)

代码如下(详细注释)

#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 更优吗?

怎么耗时多了这么多呢?

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

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

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

#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实现如下:

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实现如下:

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实现:

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++实现:

#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代码,指引 搜索的方向的关键代码在这里:

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实现:

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++实现:

#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。

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

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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2107706.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

校园失物招领系统小程序的设计

管理员账户功能包括&#xff1a;系统首页&#xff0c;个人中心&#xff0c;用户管理&#xff0c;发布人管理&#xff0c;物品类别管理&#xff0c;失物招领管理&#xff0c;寻物启事管理&#xff0c;认领认证管理&#xff0c;系统管理 微信端账号功能包括&#xff1a;系统首页…

基于yolov8的西红柿缺陷检测系统python源码+onnx模型+评估指标曲线+精美GUI界面

【算法介绍】 基于YOLOv8的西红柿缺陷检测系统是一个利用深度学习技术的创新项目&#xff0c;旨在通过自动化和智能化的方式提高西红柿缺陷检测的准确性和效率。该系统利用YOLOv8目标检测算法&#xff0c;该算法以其高效性和准确性在目标检测领域表现出色。YOLOv8不仅继承了YO…

OpenSCAD 基础教程

OpenSCAD 基础教程 文章目录 OpenSCAD 基础教程1. 引言2. 安装与设置3. OpenSCAD 基本概念与语法3.1 基础形状3.2 变换操作3.4 布尔运算3.4 控制流3.5 特殊功能 4. 实践案例&#xff1a;创建一个简单的机械部件5. 高级技巧6. 导出与3D打印7. 常见问题与解决方案8. 结语 1. 引言…

langchain 《斗破苍穹》智谱 RAG 问题搜索

目录 代码 项目介绍 模型对比实验 分块方法对比 检索方法对比 结果 10条问题 15条问题 局限性 代码 https://github.com/5zjk5/prompt-engineering/tree/master 项目介绍 《斗破苍穹》小说 RAG 问答&#xff0c;爬虫爬取整部小说章节&#xff0c;并分别保存到不同的…

传统CV算法——图像特征算法之斑点检测算法

文章目录 3. 斑点检测3.1 斑点的理解3.1.1 斑点定义3.1.2 斑点检测 3.2斑点检测基本原理3.3LoG计算流程及原理1. 高斯函数2. 拉普拉斯算子3. 组合高斯和平滑4. 计算 LoG4.1. 一阶导数4.2. 二阶导数4.3. 组合二阶导数 5. LoG 的特性6.多尺度检测 3.4 DOG3.4.1 DoG 的基本原理3.4…

低通滤波函数实现

在做的项目中需要通过PWM驱动IGBT来控制负载功率&#xff0c;如果PWM频率很高&#xff0c;电流采样基本不受影响。但是IGBT的开关频率高会引起更多的开关损耗&#xff0c;所以降低了PWM频率&#xff0c;但此时电流会是接近于PWM信号的波形&#xff0c;无法准确采集。所以硬件上…

读取、写入、生成txt文本文档详解——C#学习笔记

一、4中写入文本的方式&#xff1a; //①表示清空 txt StreamWriter mytxt1 new StreamWriter("D:\\1清空.txt"); string t1 ""; mytxt1.Write(t1); mytxt1.Close(); //②表示向txt写入文本 StreamWriter mytxt2 new StreamWriter("D:…

用HTML写一个动态的的电子相册实战详细案例

效果展示&#xff1a;&#x1f447; 详细代码&#xff1a; 1、新建一个.html文件 2、然后将下面的内容复制到 动态相册.html里面 <!DOCTYPE html> <html> <head><title>图片轮播效果</title><style>.container {position: relative;wi…

Pyspark下操作dataframe方法(1)

文章目录 Pyspark dataframe创建DataFrame使用Row对象使用元组与scheam使用字典与scheam注意 agg 聚合操作alias 设置别名字段设置别名设置dataframe别名 cache 缓存checkpoint RDD持久化到外部存储coalesce 设置dataframe分区数量collect 拉去数据columns 获取dataframe列 Pys…

【如何用远程连接到ubuntu服务器上的redis】

文章目录 ubuntu上安装redis常用命令 远程连接测试在另一台PC上进行远程访问 ubuntu上安装redis Redis 5.0 被包含在默认的 Ubuntu 20.04 软件源中。想要安装它&#xff0c;以 root 或者其他 sudo 身份运行下面的命令&#xff1a; sudo apt update //更新apt sudo apt inst…

全视通精彩亮相宁夏养老服务业博览会,助力西北地区养老产业高质量发展

据悉&#xff0c;今年4月&#xff0c;宁夏被列入48个全国基本养老服务综合平台试点地区&#xff0c;是全域申报成功的8个省&#xff08;直辖市&#xff09;之一&#xff0c;也是西北唯一的入选省份。5月&#xff0c;中卫市成功入选2024年居家和社区基本养老服务提升行动项目地区…

多智能体强化学习:citylearn城市建筑能量优化和需求响应

今天分享一个用于能量优化的强化学习框架&#xff0c;citylearn 代码量非常庞大&#xff0c;我都不敢看&#xff0c;看也看不完&#xff0c;不花一定的时间难以搞懂它的原理。 CityLearn&#xff08;CL&#xff09;环境是一个类似 OpenAI Gym 的环境&#xff0c;它通过控制不…

网络安全服务基础Windows--第10节-FTP主动与被动模式

概述 将某台计算机中的⽂件通过⽹络传送到可能相距很远的另⼀台计算机中&#xff0c;是⼀项基本的⽹络应⽤&#xff0c;即⽂件传送。 ⽂件传送协议FTP &#xff08;File Transfer Protocol&#xff09;是因特⽹上使⽤得最⼴泛的⽂件传送协议。 FTP是⼀个⽼早的⽹络协议&…

图形几何-如何将凹多边形分解成若干个凸多边形

凹多边形的概念 凹多边形是指至少有一个内角大于180度的多边形。与之相对&#xff0c;凸多边形的所有内角均小于或等于180度&#xff0c;且任意两点之间的连线都完全位于多边形内部。将凹多边形分解成若干个凸多边形是计算几何中的一个重要问题。 分解原理 将凹多边形分解为凸…

Python【3】乌七八糟

目录 if __name__ "__main__ 模块名————__name__ 装饰器 参数的优化——可以接受任何函数 需要添加自定义参数——再套一层 语法糖——好甜&#xff01; 类init self if __name__ "__main__ 在Python中&#xff0c;if __name__ "__main__"…

再谈全排列

题目链接&#xff1a; . - 力扣&#xff08;LeetCode&#xff09; 每次做全排列的题目&#xff0c;我都要孕育好一阵子&#xff0c;到底怎么去思考这个问题呢&#xff1f; 首先&#xff0c;我觉得最好的方式就是画个树。 画了树之后&#xff0c;你就知道&#xff0c;这个问题&…

鸿蒙轻内核M核源码分析系列五 时间管理

往期知识点记录&#xff1a; 鸿蒙&#xff08;HarmonyOS&#xff09;应用层开发&#xff08;北向&#xff09;知识点汇总 持续更新中…… 在鸿蒙轻内核源码分析上一篇文章中&#xff0c;我们剖析了中断的源码&#xff0c;简单提到了Tick中断。本文会继续分析Tick和时间相关的源…

多所高校拟撤销地理、测绘、建筑等相关专业!网友:游了很久,发现没有岸!

近日&#xff0c;各大高校频频传来专业“下线”的消息。多所高校拟撤销地理、测绘等相关专业。对此很多网友破防了&#xff0c;表示&#xff1a;还没毕业&#xff0c;专业没了&#xff1f; 更有城乡规划的网友表示&#xff1a;自己已经成为怨种毕业生&#xff0c;游了很久&…

公司数字化转型的目的是什么?

不同行业公司&#xff0c;其数字化转型的目的也不一样。下面我列举几个行业&#xff0c;给大家讲讲其数字化转型的真正目的。 制造数字化转型 制造业来说&#xff0c;数字化转型的本质是通过新一代信息技术与制造技术的融合&#xff0c;实现以数据为核心的资源要素变革、以网络…

【8.28更新】Win10 22H2 正式版:19045.4842镜像下载!

今日系统之家小编给大家带来2024年最新的Windows10 22H2正式版系统&#xff0c;该版本系统基于微软官方Windows 10 22H2 19045.4842 64位 专业版进行离线制作与优化&#xff0c;系统安全无任何病毒残留&#xff0c;且兼容性出色&#xff0c;能完美兼容新老机型。安装后&#xf…