Dijkstra算法的深入理解以及基于堆的优化改进
Dijkstra算法的深入理解与优化改进
Dijkstra算法的深入理解
1. 问题提出
dijkstra算法主要为了解决单源最短路径问题:给定带权有向图(带权代表着我们可以针对不同的情况对路径上的权值进行调整,以得到最符合当前需求的路径,有向则表明它要求解的路径有源点和目的点,还有它是一个图)G和源点V,求从 V 到 G 中其余各顶点的最短路径。
2. 求解过程
1.将图中的节点分为两组,一组为已求出最短路径的节点集合,另一组为还未求出最短路径的节点集合。(这有点类似与动态规划的思想,利用已知最优解,求解后续最优解)
2.按照各节点与V的最短路径长度递增的顺序,逐个将还未求出最短路径的节点加入到已求出最短路径的集合中(还未求出最短路径的节点到V的路径是不断变化的,但是未求出最短路径集合中当前距V最近的节点的路径已经可以确定是其最短路径,因为未求出最短路径的集合中其他节点到V的路径之所以会变,是因为把当前最短加入到已求出集合后对其造成的影响)
3.代码分析与实现
根据上面的求解过程,假设我们已经有了用邻接表或邻接矩阵保存的节点间路径信息,我们需要一个数组来保存bool值,即节点是否已确定最短路径,一个数组来保存还未确定最短路径的节点,若我们需要得到中间路径,则还需要一个数组来保存path路径(即其前驱节点),上述三个数组的大小均为结点的个数。
typedef struct ArcNode //代表一条边 { int adjvex; //边指向的节点 struct ArcNode * nextarc;//下一条边 int weight;//权重 }ArcNode; typedef struct VNode { char *name; //节点的名字,标识 ArcNode * firstarc; //指向可达的第一条边的指针 }VNode; typedef struct { VNode vertices[num]; //图中的节点数组 int vexnum, arcnum; //图中节点的数目,边的数目 }MyGraph; void DIJ(MyGraph &a, int v, int *lens, int *paths) { int n = a.vexnum; int *temp = new int[n];//保存临时最短路径 const int max = ~(1<<31); //初始化 for(int i=0; i<n; i++) { //lens表示节点是否已确定最短路径,本来可以是一个bool的数组, //这里用-1表示未确定,确认后用实际的最短路径进行填充。 lens[i] = -1; temp[i] = max;//初始所有节点不可达 paths[i] = -1;//保存源节点到目的节点的路径上,目的节点的前一个节点 } ArcNode *p = a.vertices[v].firstarc; //使用源节点的邻接边初始化一部分路径长度以及paths while(p) { temp[p->adjvex] = p->weight; paths[p->adjvex] = v; p = p->nextarc; } lens[v] = 0; paths[v] = 0; //开始主循环,每次求得源点到某个顶点的最短路径,并将此节点加入到已确定最短路径的集合中 for(int i=1; i<n; i++) { int index = -1; int min = max; //选择一条当前最短路径 for(int j=0; j<n; j++) { if(lens[j] < 0 && temp[j] < min) { min = temp[j]; index = j; } } if(index == -1)//若没有可达路径,则退出 break; lens[index] = min;//设置节点最短路径为已确定,并将最短路径保存起来 //根据新确定最短路径的节点的邻接边,更新临时最短路径 ArcNode *q = a.vertices[index].firstarc; while(q!=nullptr) { //若更小,则更新 if(lens[q->adjvex] < 0 && min+q->weight < temp[q->adjvex]) { temp[q->adjvex] = min + q->weight; paths[q->adjvex] = index; } q = q->nextarc; } } }
上面的代码采用了邻接表的方式来表示图,与邻接矩阵的方式相比,最主要的区别在与主循环内的第二个循环,即更新节点临时最短路径的循环。若采用邻接矩阵,则相当于总共做了n*(n-1)次更新判断,因为在遍历邻接矩阵时,不管有没有边,都会进行一次判断。而采用邻接表,则只会根据存在的边对临时最短路径进行更新判断,总共做了m(m代表图中边的个数)次判断,在实际问题中,大多数情况下,不会达到完全图的状态,这样 m 要小于n*(n-1);在后面用堆对算法进行优化时还会再讨论邻接表和邻接矩阵的区别。
4. 其他注意点
Dijkstra算法图中节点间的权重不能存在负值,负值会破坏算法中的更新条件。
采用邻接表,利用堆的结构对算法进行优化
1. 为什么要用堆,在哪个步骤上使用堆
常见的堆分为大根堆和小根堆,使用堆的结构(假设已经完成建初堆的过程)可以以O(1)的时间取得集合中的最大值,然后以O(lgn)的时间完成堆的调整。在dijkstra算法中,主循环内的第一个循环,寻找当前可达的最短路径节点时,若采用堆的结构,可以在以O(1)的时间找出最短路径,然后以O(lgn)时间进行调整,这相比较于O(n)的线形查找有了很大的提高。但需要注意一点,这会加重第二个循环对临时最短路径进行更新时的复杂度,从O(m)变为O(m*lgn);至于如何取舍和具体的分析,我们在最后在说,先看一下代码的实现。
2. 代码实现
堆的实现
typedef struct { int index;//边的目的节点 int weight;//边的权重 int prev;//边的前驱 }Ele;//这个结构体是针对当前这个问题定义的 template<typename T> class Heap { using com = bool (*)(T&, T&); public: //maxlen:堆结构最大容量;curlen:堆的尾部,从一开始方便后面的上浮,下沉操作 //compare:比较函数,如何比较容器内的元素; //container:盛放元素的容器,使用数组实现,因为动态申请内存,用到了智能指针 //使用unique_ptr而不是shared_ptr是因为shared_ptr不支持下标操作,并且在使用 //时还需要传入特定用于数组的删除器。 explicit Heap(size_t n = 100, com _compare = fn) :maxlen(n),curlen(1), compare(_compare),container(unique_ptr<T[]>(new T[n+1])){} //取得第一个值,对于本问题来说,取的是当前路径最小值 T getFirst() { //若容器中没有元素,则返回-1;针对不同问题应该做相应的更改,或者也可以定义一个 //特定的T类型进行返回 if(curlen == 1) return {-1, ~(1<<31)}; T temp = container[1];//不能使用引用 sink(); return temp; } void insert(const T &ele) { container[curlen++] = ele; up(); } void up(); void sink(); //默认的比较函数 static bool fn(T& first, T& second) { return first > second; } private: size_t maxlen; size_t curlen; com compare; unique_ptr<T[]> container; }; template<typename T> void Heap<T>::sink() { container[1] = container[curlen - 1]; curlen--; int index = 1; T temp = container[1]; for(int j=index*2; j<curlen; j=j*2) { if(compare(container[j],container[j+1])) j++; if(compare(container[index],container[j])) { container[index] = container[j]; index = j; } else break; } container[index] = temp; } template<typename T> void Heap<T>::up() { int index = curlen - 1; T temp = container[index]; for(int j = index/2; j>=1; j = j/2) { if(compare(container[j], container[index])) { container[index] = container[j]; index = j; } else break; } container[index] = temp; }
Dijkstra算法基于堆的实现
typedef struct ArcNode //代表一条边 { int adjvex; //边指向的节点 struct ArcNode * nextarc;//下一条边 int weight;//权重 }ArcNode; typedef struct VNode { char *name; //节点的名字,标识 ArcNode * firstarc; //指向可达的第一条边的指针 }VNode; typedef struct { VNode vertices[num]; //图中的节点数组 int vexnum, arcnum; //图中节点的数目,边的数目 }MyGraph; //针对本问题的比较函数 bool compare(Ele& first, Ele& second) { return first.weight > second.weight; } void DIJ(MyGraph &a, int v, int *lens, int *paths) { int n = a.vexnum; //初始化 for(int i=0; i<n; i++) { //lens表示节点是否已确定最短路径,本来可以是一个bool的数组, //这里用-1表示未确定,确认后用实际的最短路径进行填充。 lens[i] = -1; paths[i] = -1;//保存源节点到目的节点的路径上,目的节点的前一个节点 } Heap<Ele> temp(10, compare); ArcNode *p = a.vertices[v].firstarc; //使用源节点的邻接边初始化一部分路径长度以及paths while(p) { temp.insert({p->adjvex, p->weight, v}); paths[p->adjvex] = v; p = p->nextarc; } lens[v] = 0; paths[v] = 0; //开始主循环,每次求得源点到某个顶点的最短路径,并将此节点加入到 //已确定最短路径的集合中 for(int i=1; i<n; i++) { //选择一条当前最短路径 Ele now = temp.getFirst(); //因为insert时不是覆盖,而是添加一个新项,所以这里还要判断是否已经访问过, //还有返回index= -1的情况 while(now.index != -1 && lens[now.index] != -1) now = temp.getFirst(); int index = now.index; int min = now.weight; if(index == -1)//若没有可达路径,则退出 break; lens[index] = min;//设置节点最短路径已确定,并将最短路径保存起来 paths[index] = now.prev;//设置前驱节点 //根据新确定最短路径的节点的邻接边,更新临时最短路径 ArcNode *q = a.vertices[index].firstarc; while(q!=nullptr) { //插入新项,因为堆的内部结构在这里是不可见的,所以无法进行覆盖 //这里直接插入新项,因为每次堆中找出的是最短路径,所以只要在上面 //获得最短路径时判断节点是否已经在已确定最短路径的集合中,就可以保证 //可以取得源点到所有可达节点的最短路径。 temp.insert({q->adjvex, q->weight + min, index}); q = q->nextarc;//不要忘记指针后移 } } }
3使用邻接矩阵和邻接表的区别
最开始实现Dijkstra算法时,在朴素算法中使用的是邻接矩阵,然后在使用堆的结构做优化时,就感到很奇怪,内层的两个循环,第一个由O(n)优化为O(lgn),但第二个却是由O(n)变成了O(n*lgn),那么整体的时间复杂度不是更高了码?被这个问题困扰很久,直到后来有一次和同学讨论时,猛然发现好像在使用堆做优化时大多说的是使用邻接表,这样时间复杂度由O(n^2+m)变为O( (n+m)*lgn );但是再仔细考虑,邻接矩阵和邻接表其实都只是保存节点间是否有边,邻接矩阵的复杂度之所以会高些,是因为它相当于是把图当成完全图来计算,而通常情况下图中的边不会那么多,但是若图中的边真的有那么多,那么使用堆反而还不如朴素算法,这点需要注意。
补充1,Bellman-Ford算法
上文提到过一点,Dijkstra算法无法使用于图中有环的情况,而Bellman-Ford算法则可以判断图中是否有环。朴素的Bellman-Ford算法是O(n*m)的复杂度,相比较于Dijkstra算法会慢不少。另外Bellman-Ford算法也有一个利用队列的进行优化的方法。在学习Bellman-Ford算法时,有四个点困扰我很久,先上代码(这里只放上核心语句)。
//朴素算法 for(int i=1; i<n; i++)//外层循环仅控制循化次数,与内层循环无关 { int change = 0; for(int j=0; j<m; j++) { //数组v中存储的是每条边的起点,w存的是每条边的权重,u存的是每条边的终点 //dis存的是当前源点到目的点的距离 if(dis[v[i]] + w[i] < dis[u[i]]) { dis[u[i]] = dis[v[i]] + w[i]; change = 1; } } if(change == 0) break; }
//检查回路,经过n-1趟后还存在可以调整的距离则存在回路 for(int j=0; j<m; j++) { if(dis[v[i]] + w[i] < dis[u[i]]) return false; }
//使用队列优化 int v;//源点 queue<int> q;//队列 bool isInQueue[n];//节点是否在队列中 q.push(v);//初始源点入队 isInQueue[v] = true; while(!q.empty()) { int now = q.front();//获取队头 //循环代表着遍历以队头节点为起点的所有边,这里的方式只是为了简单这样写, //正常情况下还是应该使用指针,邻接表的方式 for(int i = fir[now]; i!=-1; i=next[i]) { if(dis[v[i]] + w[i] < dis[u[i]])//是否更新 { dis[u[i]] = dis[v[i]] + w[i]; if(!isInQueue[u[i]])//没有在队列中的放入队列 { q.push(u[i]); isInQueue[u[i]] = true; } } } q.pop(); isInQueue[now] = false;//出队列置false,这样下次更新的时候才会重新放入队列 }
1,为什么朴素算法外层要进行(n-1)次循环?
网上搜到的普遍回答为:假设图中无环,那么从源点到各个节点最长经过n-1条路径,而每轮对这n-1条路径最少可以确定一条,那么最多n-1次,最长经过n-1条路径的节点也可以确定最短路径(可以想象一条直线线上分布着所有节点,源点在其中一头,而每次遍历边的时候都从另外一头开始,那么从源点到另一头,即最后一个节点之间的路径每次确定一条,n-1次确定完成)。
2,判断是否有环的写法?
3,队列优化代码中什么情况下把节点(重新)放入队列?
4,队列优化代码的时间复杂度?
补充2,在使用智能指针与数组的结合时的一些所得
template<typename T> struct Array_delete { void operator()(T const *p) { delete[] p; } }; shar 8000 ed_ptr<int> sp(new int[10], Array_delete<int>()); shared_ptr<int> sp(new int[10], default_delete<int[]>()); shared_ptr<int> sp(new int[10], [](int *p){delete[] p}); //实际上,除非需要共享目标,使用unique_ptr更适合与数组,unique_ptr还支持下标操作。 unique_ptr<int[]> up(new int[10]);
- 基于大端法、小端法以及网络字节序的深入理解
- 深入理解dp,px,以及density
- 深入理解ANGULAR中的$APPLY()以及$DIGEST()
- Adapter深入理解与优化
- 深入理解DIP、IoC、DI以及IoC容器
- 深入理解C++对象模型-成员函数的本质以及虚函数的实现(非虚继承)
- Adapter深入理解与优化
- 基于memset()函数的深入理解
- 【MySQL】基于MySQL的SQL优化(四)——对group by以及limit的优化
- 基于php-fpm 参数的深入理解
- 基于C语言sprintf函数的深入理解
- 深入理解计算机系统学习笔记(二)之程序优化
- 高可用服务架构设计(14) - 深入理解hystrix的断路器执行原理以及模拟接口异常时的短路实验
- 深入理解计算机系统(2.2)---布尔代数以及C语言上的位运算
- 深入理解java注解(Annotation)以及 自定义注解入门
- mybatis深入理解之 # 与 $ 区别以及 sql 预编译
- 深入理解View知识系列二- View底层工作原理以及View的绘制流程
- 深入理解计算机系统:优化程序性能
- 深入理解mybatis原理(七) MyBatis的架构设计以及实例分析
- 深入理解图优化与g2o:图优化篇