您的位置:首页 > 其它

Dijkstra算法的深入理解以及基于堆的优化改进

2019-06-21 12:16 344 查看
版权声明:本文为博主原创文章,遵循 CC 4.0 by-sa 版权协议,转载请附上原文出处链接和本声明。 本文链接:https://blog.csdn.net/wskywskywsky/article/details/92839207

Dijkstra算法的深入理解与优化改进

  • 采用邻接表,利用堆的结构对算法进行优化
  • 补充1,Bellman-Ford算法
  • 补充2,在使用智能指针与数组的结合时的一些所得
  • 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]);
    内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
    标签: