定义

Dijkstra(读音:/'daɪkstrə/)算法,是用来求解一个边带权图中从某个顶点出发到达其余各个顶点的最短距离的算法。(为表达简便,下文中“起点(源点)到某个顶点的距离”简称为“某个顶点的距离”)

限制条件:各个边的权不能为负。

原理

假设s,v1,v2,...,vn(以下简称P1)为从源点s到vn的最短路,则s,v1,v2,...,vi-1(以下简称P2)也为从源点s到vi-1的最短路。

这点可以用反证法证明,假如P2不是从源点s到vi-1的最短路,那必然存在某两个m、n(1 <= m < n <= i-1),使得在vm和vn之间,存在着某条更短的路径。由于P1只不过是在P2的基础上加上了vi-1到vi的距离,那么P1显然就不是最短路了。如图:

Dijkstra算法利用了这一点,从源点出发,不断地利用已知的最短距离,依次求得剩余顶点的最短距离。因此,这是一个贪心算法。

算法步骤

引入两个集合S和U,S为已求出最短距离的点的集合,U为尚未求出最短距离的点的集合。显然S和U的交集为空,且S与U的并为整个图的所有节点。初始时,S为空,而U包含图的所有节点。初始时,除了起点的距离初始化为0之外,其余所有节点的距离设为无穷大。(这样才能保证能够从起点开始)

不断执行以下步骤,直到S已包含所有的顶点:

  1. 从U中找出距离最小的点,令其为v。
  2. 将v从U移到S。
  3. 对于v的每一个邻接节点v',如果v'属于U,且v的距离加上边v-v'的长度之和小于v'的距离则更新v'的距离。(如果v'的距离为无穷大,那么因为v的距离加上边v-v'的长度之和一定是有限的,所以v'的距离一定会得到更新)

举个例子:

给定下面这张图,以0为起点求出剩余顶点的最短距离。

(PS:黑色空心节点表示U中的节点,绿色空心节点表示S中的节点,绿色实心节点表示当前选中的节点)

首先,S为空,而U包含了所有的节点。在U中的所有节点中,节点0的距离最短,为0。将0移到S,并更新其所有邻接节点的距离。

此时,U包含的节点为1、2、3、4、5、6,其中距离最短的节点为1。1的邻接节点为0、2、3,其中:

  • 节点0已属于S,不做任何处理。
  • 节点1的距离4加上1到2这条边的距离2为6,小于节点2的距离7,因此更新2的距离为6。
  • 节点1的距离4加上1到3这条边的距离10为14,小于节点3的距离无穷大,因此更新3的距离为14。

此时,U中距离最小的顶点为2,同样的方法,更新其邻接节点的距离。

按照上面的步骤一直进行下去,就可以得到所有节点的最小距离:

代码实现

传统实现

类定义

因为每条边携带了权值信息,所以这里使用邻接矩阵来表示图。非邻接节点之间的权值规定为无穷大。出于效率考虑,这里使用一维数组存储邻接矩阵,假设整个图的节点数为n,则节点i和 节点j的边的权值为数组中下标为i*n+j的值。

由于每条边的权值规定不能为负,因此这里用std::size_t(计算机能够表示的最大的无符号整数类型)存储权值。

另外,用16进制的0x3f3f3f3f表示无穷大。这个数在10进制下是109数量级的,这样既可以保证路径长度在一般情况下远远不会达到这么大,也可以确保在进行加法运算的时候不会溢出。

#define INF 0x3f3f3f3f

为了使语义更加明确,使用类型别名表示节点下标,以和其他的整数信息(如权值等)区分开。

using node_t = std::size_t;

因此,整个图类定义如下:

class Graph {
std::size_t n; // 节点个数
std::vector<std::size_t> adjMatrix; // 邻接矩阵 public:
using node_t = std::size_t; // 类型别名,表示节点下标 Graph(std::size_t n, std::initializer_list<std::size_t> il) : n(n), adjMatrix(il) {} // 构造函数 std::vector<std::size_t> dijkstra(node_t start); // Dijkstra算法,起点通过入参start指定
};

算法代码

由于一个节点要么属于S,要么属于V,因此我们使用一个bool数组,true表示该节点属于S,false表示该节点属于V。初始时,数组元素全为false。

std::vector<bool> shortest(n, false);

因此,整个算法代码的实现如下:

std::vector<std::size_t> Graph::dijkstra(Graph::node_t start) {

    // shortest数组定义
std::vector<bool> shortest(n, false); // 存储各个顶点距离的数组
std::vector<node_t> distance(n, INF); // 其余元素初始化为无穷大
distance[start] = 0; // 起点距离初始化为0 // 集合U中还剩余多少个节点
std::size_t left = n; // 循环执行以下步骤,直到U中没有节点了
while (left) { // 从U中选出距离最小的节点
node_t cur = 0;
std::size_t minDistance = INF;
for (node_t v = 0; v < n; ++v) {
if (shortest[v]) continue; // 排除掉已经在S中的节点
if (distance[v] < minDistance) {
cur = v;
minDistance = distance[cur];
}
} // 将该节点从U移到S
shortest[cur] = true;
left--; // 剩余节点数相应减一 // 更新该节点所有在U中的邻接节点的距离
for (node_t neighbor = 0; neighbor < n; neighbor++) {
if (shortest[neighbor]) continue; // 排除S中的节点
auto dis = adjMatrix[cur * n + neighbor]; // 该节点与邻接节点之间的权值
if (dis == INF) continue; // 排除不与该节点邻接的节点
distance[neighbor] = std::min(distance[cur] + dis, distance[neighbor]); // 更新邻接节点的距离
}
}
return distance;
}

测试:

int main() {
Graph::node_t n = 6;
Graph::node_t start = 0;
Graph graph(n, {0, 4, 7, INF, INF, INF,
4, 0, 2, 10, INF, INF,
7, 2, 0, 2, 3, INF,
INF, 10, 2, 0, 5, 8,
INF, INF, 3, 5, 0, 4,
INF, INF, INF, 8, 4, 0});
auto distance = graph.dijkstra(start);
for (Graph::node_t node = 0; node < n; ++node) {
std::cout << start << "->" << node << ": " << distance[node] << std::endl;
}
return 0;
}

输出:

0->0: 0
0->1: 4
0->2: 6
0->3: 8
0->4: 9
0->5: 13

时间复杂度分析

时间复杂度:可以看出,算法代码里面有while循环嵌套着for循环,其中while循环的执行次数为n(left初始化为n,每次循环减一,直到减到0退出循环),for循环的执行次数也为n,因此时间复杂度为O(n2)。

这样的时间复杂度好像不太能令人满意,那么是否可以减少时间成本呢?

答案是可以的。

堆优化

我们发现,每次选出距离最小的点,都需要遍历图中所有的顶点,时间成本为O(n),显然过大。利用优先队列这个数据结构,我们可以将时间成本减少为O(1)。(C++的STL中的优先队列的底层实现默认为完全二叉堆)

要实现这一点,我们首先需要定义一个struct,包含节点下标和距离。同时,我们也需要定义一个operator>重载运算符用来定义比较大小的规则(按照距离值排序)。

struct Node {
std::size_t index;
std::size_t distance; Node(std::size_t index, std::size_t distance) : index(index), distance(distance) {} inline bool operator>(Node another) const {
return distance > another.distance;
}
};

类定义

为节省空间和时间,我们改用邻接列表的方式存储图。

class Graph2 {

    struct Node {
std::size_t index;
std::size_t distance; Node(std::size_t index, std::size_t distance) : index(index), distance(distance) {} inline bool operator>(Node another) const {
return distance > another.distance;
}
}; std::size_t n;
std::vector<std::list<Node>> adj; public:
Graph2(std::initializer_list<std::initializer_list<Node>> ll); std::vector<std::size_t> dijkstra(std::size_t start);
};

算法代码

定义一个存储节点的小根堆:

std::priority_queue<Node, std::vector<Node>, std::greater<>> heap;

每次遍历某个节点的邻接节点的时候,更新完其距离就将其扔到堆里,以供下次取距离最小的节点的时候取出。你可能会问,万一同一个节点多次入队怎么办呢?这点不用担心,即便队列里面有多个相同下标的节点,取出的也一定是其中距离最小的那个。而取出之后就在S里面了,因此相同下标的其他节点取出后就可以直接丢弃。所以取出一个节点的时候先要判断其是否处于S中,如果是的话就丢弃。否则就是V中距离最小的节点。

auto cur = heap.top();
heap.pop();
if (shortest[cur.index]) continue;

其余部分相应的更改就是了。

std::vector<std::size_t> Graph2::dijkstra(std::size_t start) {
std::vector<bool> shortest(n, false);
std::vector<std::size_t> distance(n, INF);
distance[start] = 0;
std::priority_queue<Node, std::vector<Node>, std::greater<>> heap;
heap.emplace(start, 0); // 初始化 while (!heap.empty()) { // 取出距离最小的顶点
auto cur = heap.top();
heap.pop();
if (shortest[cur.index]) continue; // 如果当前顶点处于S中,就丢弃 // 将其移入S中
shortest[cur.index] = true; // 更新其邻接节点的距离
for (auto &neighbor: adj[cur.index]) {
if (shortest[neighbor.index]) continue;
if (distance[cur.index] + neighbor.distance < distance[neighbor.index]) {
distance[neighbor.index] = distance[cur.index] + neighbor.distance;
heap.emplace(neighbor.index, distance[neighbor.index]);
}
}
}
return distance;
}

测试:

int main() {
std::size_t n = 6;
std::size_t start = 0;
Graph2 graph({
{{1, 4}, {2, 7}},
{{0, 4}, {2, 2}, {3, 10}},
{{0, 7}, {1, 2}, {3, 2}, {4, 3}},
{{1, 10}, {2, 2}, {4, 5}, {5, 8}},
{{2, 3}, {3, 5}, {5, 4}},
{{3, 8}, {4, 4}}
});
auto distance = graph.dijkstra(start);
for (std::size_t node = 0; node < n; node++) {
std::cout << start << "->" << node << ": " << distance[node] << std::endl;
}
return 0;
}

输出:

0->0: 0
0->1: 4
0->2: 6
0->3: 8
0->4: 9
0->5: 13

时间复杂度分析

时间复杂度:O((n + m) * log(n))。(n为点数,m为边数)

证明:

设每个节点平均拥有的边数为k,即k=m/n。

每一次while循环,执行上述3个步骤,其中:

  • 取出距离最小节点的时间复杂度为O(log(n)),这是因为取出后还要花O(log(n))的时间恢复二叉堆的堆序性。
  • 移动到S中的时间复杂度为O(1)。
  • 更新其邻接节点的距离的时间复杂度为k * O(log(n)) = O(k * log(n)),这是因为放入堆中后还要花O(log(n))的时间恢复二叉堆的堆序性。

而while循环的次数有多少次呢,因为初始时S中有一个节点,每次while循环往S中增加一个节点,当S中有n个节点时停止循环。因此while循环的次数为n-1次,为O(n)量级。

所以,堆优化的Dijkstra算法的时间复杂度为:

O(n) * (O(log(n)) + O(1) + O(k * log(n)))

= O(n) * (k + 1) * O(log(n))

= O(n * k + n) * O(log(n))

= O(m + n) * O(log(n))

= O((n + m) * log(n))

Dijkstra算法详解(朴素算法+堆优化)的更多相关文章

  1. kmp算法详解

    转自:http://blog.csdn.net/ddupd/article/details/19899263 KMP算法详解 KMP算法简介: KMP算法是一种高效的字符串匹配算法,关于字符串匹配最简 ...

  2. Java虚拟机详解04----GC算法和种类【重要】

    [声明] 欢迎转载,但请保留文章原始出处→_→ 生命壹号:http://www.cnblogs.com/smyhvae/ 文章来源:http://www.cnblogs.com/smyhvae/p/4 ...

  3. 【最短路径Floyd算法详解推导过程】看完这篇,你还能不懂Floyd算法?还不会?

    简介 Floyd-Warshall算法(Floyd-Warshall algorithm),是一种利用动态规划的思想寻找给定的加权图中多源点之间最短路径的算法,与Dijkstra算法类似.该算法名称以 ...

  4. Java虚拟机详解04----GC算法和种类

    [声明] 欢迎转载,但请保留文章原始出处→_→ 生命壹号:http://www.cnblogs.com/smyhvae/ 文章来源:http://www.cnblogs.com/smyhvae/p/4 ...

  5. 机器学习经典算法详解及Python实现--基于SMO的SVM分类器

    原文:http://blog.csdn.net/suipingsp/article/details/41645779 支持向量机基本上是最好的有监督学习算法,因其英文名为support vector  ...

  6. 字符串匹配KMP算法详解

    1. 引言 以前看过很多次KMP算法,一直觉得很有用,但都没有搞明白,一方面是网上很少有比较详细的通俗易懂的讲解,另一方面也怪自己没有沉下心来研究.最近在leetcode上又遇见字符串匹配的题目,以此 ...

  7. 八大排序算法详解(动图演示 思路分析 实例代码java 复杂度分析 适用场景)

    一.分类 1.内部排序和外部排序 内部排序:待排序记录存放在计算机随机存储器中(说简单点,就是内存)进行的排序过程. 外部排序:待排序记录的数量很大,以致于内存不能一次容纳全部记录,所以在排序过程中需 ...

  8. 第三十一节,目标检测算法之 Faster R-CNN算法详解

    Ren, Shaoqing, et al. “Faster R-CNN: Towards real-time object detection with region proposal network ...

  9. [Network Architecture]DPN(Dual Path Network)算法详解(转)

    https://blog.csdn.net/u014380165/article/details/75676216 论文:Dual Path Networks 论文链接:https://arxiv.o ...

  10. KMP算法详解&&P3375 【模板】KMP字符串匹配题解

    KMP算法详解: KMP算法是一种改进的字符串匹配算法,由D.E.Knuth,J.H.Morris和V.R.Pratt(雾)提出的. 对于字符串匹配问题(such as 问你在abababb中有多少个 ...

随机推荐

  1. yum 更新yum源

    yum 更新yum源 # 1.做好备份,防止更新失败时切换回去 $ mv /etc/yum.repos.d/CentOS-Base.repo /etc/yum.repos.d/CentOS-Base. ...

  2. JavaScrip基础学习笔记(一)

    一.三元表达式 1.1 什么是三元表达式 由三元运算符组成的式子我们称为三元表达式 1.2 语法结构 条件表达式 ? 表达式1 : 表达式2 1.3 执行思路 如果表达式为结果真 则返回表达式1的值, ...

  3. shell编写循环检查脚本

    背景:如下脚本实现当微服务重启后,检查微服务的启动端口正常,可通过轮询的方式来实现所需要用到配置文件config.properties信息如下: onlineService:8001 algorthS ...

  4. windows安装grunt时提示不是内部或外部命令解决方案

    参考:https://www.cnblogs.com/hts-technology/p/8477258.html 安装windows安装elasticsearch-head时 不需要输入grunt s ...

  5. 为什么Git远程仓库中要配置公钥?

    最近在使用阿里云效平台代码管理,首次使用新建仓库,使用SSH时需要配置公钥.之前也在GitHub.Gitee上配置过,每次都能正常使用,也没有思考过为什么要配置公钥.这次记录一下其中的原理. 本地和远 ...

  6. Velero系列文章(四):使用Velero进行生产迁移实战

    概述 目的 通过 velero 工具, 实现以下整体目标: 特定 namespace 在B A两个集群间做迁移; 具体目标为: 在B A集群上创建 velero (包括 restic ) 备份 B集群 ...

  7. 关于Window中右键新建菜单的设置

    在日常工作中,有时候需要根据自己的需要对Windows桌面右键新建菜单进行自定义,自定义的方法如下: 1.在Window中输入Win+R调出运行命令框 2.输入regedit,打开注册表 3.如果想要 ...

  8. 互斥锁 线程理论 GIL全局解释器锁 死锁现象 信号量 event事件 进程池与线程池 协程实现并发

    目录 互斥锁 multiprocessing Lock类 锁的种类 线程理论 进程和线程对比 开线程的两种方式(类似进程) 方式1 使用Thread()创建线程对象 方式2 重写Thread类run方 ...

  9. JAVA中的注解可以继承吗?

    前言 注解想必大家都用过,也叫元数据,是一种代码级别的注释,可以对类或者方法等元素做标记说明,比如Spring框架中的@Service,@Component等.那么今天我想问大家的是类被继承了,注解能 ...

  10. windows上用vs2017静态编译onnxruntime-gpu CUDA cuDNN TensorRT的坎坷之路

    因为工作业务需求的关系,需编译onnxruntime引入项目中使用,主项目exe是使用的vs2017+qt5.12. onnxruntime就不用介绍是啥了撒,在优化和加速AI机器学习推理和训练这块赫 ...