这篇文章上次修改于 1071 天前,可能其部分内容已经发生变化,如有疑问可询问作者。

加权图

数据类型

因为继承体系,我们将图分为有权图和无权图,又各自分为有向和无向。

有权图

依旧使用邻接表来实现。

基类头文件

class BaseGraph
{
public:
    BaseGraph(int v) : vertex_num(v){};
    int V() const { return vertex_num; };
    int E() const { return edge_num; };

protected:
    int vertex_num;
    int edge_num = 0;
};

class BaseWeightedGraph : public BaseGraph
{
public:
    BaseWeightedGraph(int v) : BaseGraph(v) {}
    virtual void addEdge(int v, int w, double weight) = 0;
};

有权边

将原来的边变为一个新的类型。

基类头文件

class BaseEdge
{
public:
    BaseEdge() : v(-1), w(-1), weight_(0){};
    BaseEdge(int v, int w, double wei) : v(v), w(w), weight_(wei){};
    double weight() const { return weight_; };
    bool operator>(const BaseEdge &e) const { return e.weight() < this->weight_; }
    bool operator<(const BaseEdge &e) const { return e.weight() > this->weight_; }
    bool operator>=(const BaseEdge &e) const { return !(e > *this); }
    bool operator<=(const BaseEdge &e) const { return !(e < *this); }

protected:
    int v;
    int w;
    double weight_;
};

最小生成树(MST)

接口 操作 返回值
MST(WeightedGraph G) 找到图G的最小生成树 构造函数
edges() 返回最小生成树的所有边 Bag
weight() 返回最小生成树的边的权值和 double

输入:一幅加权无向图

输出:一颗最小生成树

前提:

  • 只考虑连通图(如果一幅图非连通,则只能有最小生成森林)

  • 边的权重不一定是距离

  • 边的权重可能是0或者负数

  • 所有边的权重各不相同(若相同,最小生成树的结果将不唯一,还要外加以证明)

树的特性:

  • 删去任一一边都会使树变为两颗
  • 加一边必出环

预备理论

切分定理

术语:

切分:将图的所有顶点分为两非空不重复集合

横切边:一条连接两个属于不同集合的两个点的边

内容:对一图的任一切分,横切边的最小权重必被最小生成树包含。

证明:反证法(略)

数据类型

这里用到的是有权无向图和有权无向边

有权无向图

头文件

class WeightedGraph : public BaseWeightedGraph
{
public:
    WeightedGraph(int v) : BaseWeightedGraph(v), _adj(new Bag<Edge>[v]){};
    WeightedGraph(const WeightedGraph &G);
    ~WeightedGraph() { delete[] _adj; }

    void addEdge(int v, int w, double weight);
    void addEdge(const Edge &e);
    Bag<Edge> adj(int v) const { return _adj[v]; };
    Bag<Edge> edges() const;

    WeightedGraph &operator=(const WeightedGraph &G);
    friend std::ostream &operator<<(std::ostream &os, const WeightedGraph &G);

private:
    Bag<Edge> *_adj;
};
有权边

将原来的边变为一个新的类型。

基类头文件

class Edge : public BaseEdge
{
public:
    Edge() : BaseEdge(){};
    Edge(int v, int w, double wei) : BaseEdge(v, w, wei){};
    int either() const { return v; }
    int other(int ver) const;

    bool operator==(const Edge &edge) const { return edge.weight() == this->weight(); }

    friend std::ostream &operator<<(std::ostream &os, const Edge &edge);
};

Prim算法

思路:

1.从树的第一个节点开始,将起点和其他点作为两类利用切分定理找到第一处连接

2.再将新连接的点加入原来起点的那一类。对新的两个类的横切边进行切分定理判断

3.以此类推

分析:由此我们可以发现算法的实现体现了对横切边容器的处理上。对此Prim算法就出现了两种版本,Prim延时算法Prim即时算法。两者都利用优先队列

Prim延时算法

此时的优先队列维护边的权值,以边的权值作为优先标准。

思路:

1.以一个起点开始,在优先队列中加入其相邻的边。

2.生长出其中权值最小的边,在优先队列中继续添加新增点的相邻边。

3.同上,以此类推。

实现:

class LazyPrimMST
{
public:
    LazyPrimMST(WeightedGraph G);
    ~LazyPrimMST() { delete[] marked; }
    Bag<Edge> edges() const { return mst; }
    double weight() const;

private:
    edge_pq pq;
    bool *marked;
    Bag<Edge> mst;

    void visit(WeightedGraph G, int v);
};

LazyPrimMST::LazyPrimMST(WeightedGraph G) : marked(new bool[G.V()])
{
    visit(G, 0);
    while (!pq.empty())
    {
        Edge e = pq.top();
        pq.pop();
        int v = e.either(), w = e.other(v);
        if (marked[v] && marked[w])
            continue;
        mst.add(e);
        if (!marked[v])
            visit(G, v);
        if (!marked[w])
            visit(G, w);
    }
}

void LazyPrimMST::visit(WeightedGraph G, int v)
{
    marked[v] = true;
    for (Edge e : G.adj(v))
        if (!marked[e.other(v)])
            pq.push(e);
}

分析:其中,按照思路进行时,我们会遇到一些问题。例如以下

graph TB
1((1));2((2));3((3));4((4));5((5));6((6));7((7));8((8));

5 --- 4;5 --- 1;5 --- 6;
6 --- 1;6 --- 2;6 --- 8;
8 --- 2;8 --- 3;8 --- 7;
7 --- 4;7 --- 3;
1 --- 3;1 --- 2 --- 3; 

问题:对如上图,其中重复边的问题,当2-1-3为当前树的时候,由于加入2节点的时候,优先队列中已经有2-3的边直到依次加入1,3,2-3边依旧存在于优先队列,若此时2-3边出列,然后在树中2,3已经连接了,故会成环出错。

解决:仅需要维护一个布尔值数组marked即可,marked用于标记已经在树内的点,当我们要添加无效边2-3的时候,若23已在树内则不会添加。

分析:也因此特性,在部分边无效的时候,它还存在于队列中,只有在出队的时候才会被删除,故成为Prim延时算法。

Prim即时算法

此时的优先队列维护边的权值,以边的权值作为优先标准。

思路:

1.以一个起点开始,将这个点周边的点和它们到起点的边的权值加入队列

2.在增长新的节点后,将新点周边的点和它们到新点的边的权值“设置”入队列

“设置”是指若队列中已存在该点,比较新权值和旧权值,小的留下。若不存在则直接加入

3.以此类推

实现:

#include "MST.hpp"

class PrimMST
{
public:
    PrimMST(WeightedGraph G);
    ~PrimMST() { delete[] marked, distTo; }
    Bag<Edge> edges() const { return mst; }
    double weight() const;

private:
    point_pq pq;
    bool *marked;
    double *distTo;
    Edge *edgeTo;
    Bag<Edge> mst;

    void visit(WeightedGraph G, int v);
};

PrimMST::PrimMST(WeightedGraph G) : marked(new bool[G.V()]), distTo(new double[G.V()])
{
}

void PrimMST::visit(WeightedGraph G, int v)
{
    marked[v] = true;
    for (Edge e : G.adj(v))
    {
        int w = e.other(v);
        if (marked[w])
            continue;
        if (e.weight() < distTo[w])
        {
            edgeTo[w] = e;

            distTo[w] = e.weight();
            ele new_ele(w, distTo[w]);
            pq_update(pq,  new_ele);
        }
    }
}

void pq_update(point_pq &pq, const ele &new_data)
{
    point_pq temp;
    temp.swap(pq);
    while (!temp.empty())
    {
        ele top_edge = temp.top();
        temp.pop();
        if (new_data.first == top_edge.first)
        {
            pq.push(new_data);
            break;
        }
        else
            pq.push(top_edge);
    }
    while (!temp.empty())
    {
        ele top_edge = temp.top();
        temp.pop();
        pq.push(top_edge);
    }
}

分析:树增加节点的方式和延时版相同,从队列中取出最小权值的点,这个时候,我们仅获取了点,不知道是哪条边,故我们应额外维护一个数组edgeTo用来记录各个点的指向。

特点:

1.相比于延时版本,即时版本的特点就在“即时性”,指无效边会被即使删除。

2.需要额外维护数组edgeTo以及数组distTo;edgeTo表示了最小的生成树的边,distTo表示了对应点的边的权值默认全为INF(无穷大)。

3.可以不用marked数组判断是否被记录,因为它和判断distTo是否为INF同理。

4.空间上限变为原来的常数因子。

KrusKal算法

思路:

1.将所有的边加入优先队列

2.移出最小(权值)的边,加入最小生成树

3.加入生成树时判断边的两点是否已被连接(已在树内)

4.直至MST的数量达到V-1

实现:

class KurskaiMST
{
public:
    KurskaiMST(WeightedGraph G);
    Bag<Edge> edges() const { return mst; }
    double weight() const;

private:
    Bag<Edge> mst;
};

KurskaiMST::KurskaiMST(WeightedGraph G)
{
    edge_pq pq;
    for (Edge e : G.edges())
        pq.push(e);

    UF uf(G.V());
    while (!pq.empty() and mst.size() < G.V() - 1)
    {
        Edge e = pq.top();
        pq.pop();
        int v = e.either(), w = e.other(v);
        if (uf.connected(w, v))
            continue;
        uf.connect(w, v);
        mst.add(e);
    }
}

double KurskaiMST::weight() const
{
    double total = 0.0;
    for (Edge e : mst)
    {
        total += e.weight();
    }
    return total;
}

特点:

1.KrusKal算法是由森林最后结合成一棵树

2.会储存所有的边一同进行排序

分析:

需要耗费Edge优先队列,union-find结构

对于使用UF是为了检测w和v是否连通,Kruskal算法在一幅图开始时是分散的,产生多颗树,这对于UF对象来说,很有使用价值。

最短路径(SP)

接口 操作 返回类型
SP 创建一个含有V个点无边的无向图 构造函数
distTo(int v) 从s点到v的距离(权值和) double
hasPathTo(int v) 查看是否存在s到v的路径 bool
pathTo(int v) 从s点到v的路径 bag

头文件

class SP
{
public:
    SP(WeightedDigraph G, int s);
    double distTo(int v) { return distTo_[v]; }
    bool hasPathTo(int v) { return distTo_[v] < INFINITY; }
    Bag<DirectedEdge> pathTo(int v);

private:
    int *distTo_;
    DirectedEdge *edgeTo_;
};

Bag<DirectedEdge> SP::pathTo(int v)
{
    Bag<DirectedEdge> res_bag;
    if (!hasPathTo(v))
        return res_bag;
    for (DirectedEdge e = edgeTo_[v]; e.from() != -1; e = edgeTo_[e.from()])
        res_bag.add(e);
    return res_bag;
}

前提:

松弛:一种基本操作

void relax(WeightedDigraph G, Edge e)
{
    int w = e.to();
    if (distTo_[w] > distTo_[v] + e.weight())
    {
        distTo_[w] = distTo_[v] + e.weight();
        edgeTo_[w] = e;
    }
}

点的松弛:和我们上面所描述的,是对于改点指出的所有边进行松弛

void relax(WeightedDigraph G, int v)
{
    for (DirectedEdge e : G.adj(v))
    {
        int w = e.to();
        if (distTo_[w] > distTo_[v] + e.weight())
        {
            distTo_[w] = distTo_[v] + e.weight();
            edgeTo_[w] = e;
        }
    }
}

理论补充:

最优性条件,对于松弛上例若为最小路径数时,则满足

distTo[w] <= distTo[v] + e.weight()

通用路径最短算法:

起点为s将distTo[s]初始化为0,其他为inf,edgeTo[s]为空,edgeTo[v]为指向v的最小路径树上的边。

Dijkstra算法

思路:类似于Prim,需要维护一个优先队列(同Prim即时算法)

不同点:

1.distTo数组存放值为点到起点的权值和,而并不是新点到邻树点的权值

原因:Prim只存放边的权值,因为无向图的切分定理。而在有向图中,切分定理虽然有效,但是我们难以得到所有的横切边,由于边的有向,我们仅能获取每个点的指出边。Dijkstra算法利用“放松”点的方法,以确保最短的路径。

对于下例:

当树为012,仅能获得1->3, 2->5,故无法使用切分定理

graph TB
0((0));1((1));2((2));3((3));4((4));5((5));

1-->0;1-->3-->4-->0;2-->0;2-->5-->4;

2.marked数组不被使用,因为relax操作的特点。例如上图,已经有1->3->4时,2->5->4仍需要被考虑,需要得到多种指向一点的多条路径,就要多次访问4顶点,这都是因为4不知道指向它的点。

实现:

class DijkstraSP
{
public:
    DijkstraSP(WeightedDigraph G, int s);
    ~DijkstraSP() { delete[] distTo_, edgeTo_; }

    double distTo(int v) const { return distTo_[v]; }
    bool hasPathTo(int v) const { return distTo_[v] < INFINITY; }
    Bag<DirectedEdge> pathTo(int v);

private:
    void relax(WeightedDigraph G, int v);
    double *distTo_;
    DirectedEdge *edgeTo_;
    edge_pq pq;
};

void DijkstraSP::relax(WeightedDigraph G, int v)
{
    for (DirectedEdge e : G.adj(v))
    {
        int w = e.to();
        if (distTo_[w] > distTo_[v] + e.weight())
        {
            distTo_[w] = distTo_[v] + e.weight();
            edgeTo_[w] = e;
            pq_update(pq, ele(w, distTo_[w]));
        }
    }
}

DijkstraSP::DijkstraSP(WeightedDigraph G, int s) : distTo_(new double[G.V()]), edgeTo_(new DirectedEdge[G.V()])
{
    for (int i = 0; i < G.V(); i++)
        distTo_[i] = INFINITY;
    distTo_[s] = 0;
    pq.push(ele(s, 0.0));
    while (!pq.empty())
    {
        relax(G, pq.top().first);
        pq.pop();
    }
}

Bag<DirectedEdge> DijkstraSP::pathTo(int v)
{
    Bag<DirectedEdge> res;
    if (!hasPathTo(v))
        return res;
    for (DirectedEdge e = edgeTo_[v]; e.from() != -1; e = edgeTo_[e.from()])
        res.add(e);
    return res;
}

void pq_update(edge_pq &pq, const ele &new_data)
{
    edge_pq temp;
    temp.swap(pq);
    bool has_new = false;
    while (!temp.empty())
    {
        ele top_ele = temp.top();
        temp.pop();
        if (top_ele.first == new_data.first)
        {
            pq.push(new_data);
            has_new = true;
        }
        else
            pq.push(top_ele);
    }
    if (has_new)
    {
        pq.push(new_data);
    }
}

分析:若是求解两点之间的最短路径,只需要在优先队列中取到t截止即可。

对于欧拉图来说,Dijkstra有优化的解法。

Dijkstra并不高效,但是可以解决问题,同样缺点还有不能解决负权重加权有向图问题

无环加权有向图中的最短路径算法

思路:

1.distTo[s]初始化为0其他初始化为INF

2.按照拓扑排序顺序对所有点进行relax操作

特点:

1.更快,更方便

2.能够处理负权重有权图的问题

3.还能解决额外的相关问题,比如最长路径

4.是拓扑排序的拓展

实现:

class AcyclicSP
{
public:
    AcyclicSP(WeightedDigraph G, int s);
    ~AcyclicSP() { delete[] distTo_, edgeTo_; }
    double distTo(int v) const { return distTo_[v]; }
    bool hasPathTo(int v) const { return distTo_[v] < INFINITY; }
    Bag<DirectedEdge> pathTo(int v);

private:
    void relax(WeightedDigraph G, int v);

    double *distTo_;
    DirectedEdge *edgeTo_;
};
void AcyclicSP::relax(WeightedDigraph G, int v)
{
    for (DirectedEdge e : G.adj(v))
    {
        int w = e.to();
        if (distTo_[w] > distTo_[v] + e.weight())
        {
            distTo_[w] = distTo_[v] + e.weight();
            edgeTo_[w] = e;
        }
    }
}

AcyclicSP::AcyclicSP(WeightedDigraph G, int s) : distTo_(new double[G.V()]), edgeTo_(new DirectedEdge[G.V()])
{
    for (int i = 0; i < G.V(); i++)
        distTo_[i] = INFINITY;
    distTo_[s] = 0.0;
    Topological top(remove_weight(G));
    for (int v : top.order())
    {
        relax(G, v);
    }
}

Digraph remove_weight(const WeightedDigraph &G)
{
    Digraph res(G.V());
    for (DirectedEdge e:G.edges())
        res.addEdge(e.from(), e.to());
    return res;
}

分析:

对于加权有向图,对点的松弛需要有一定的顺序。无顺序的松弛,不能达到真正效果。

graph TB
0((0));1((1));2((2));3((3));4((4));
0 -->1-->2-->4;0-->3-->4;2-->3;

例如上图,如果relax的顺序是03412,那么此时,放松34后,进行12,放松2的时候对2->3边进行松弛,若此时0->3变为0->1->2->3那么dist[3]的值会被修改,但4的distTo值也应该被修改,改为distTo[4] = distTo[3] + e.weight()。但在松弛中没有这样操作,会导致在对2->4边进行松弛出现问题。

总结以上就是,已经确定了4的距离,若3的距离减小,4应该同步更新。

为了避免这样的情况,我们得出的结论就是松弛边时的指向点不能有已经被记录的点,也就是按照顺序,后面的点不能指向前面的点,也就是拓扑排序的顺序。

总结以上就是,先松弛的点只能指向后松弛的点。

最长路径实现:将distTo都初始化为0,改变relax函数中不等式的方向。

平行任务调度

添加一条关于这个算法的应用

要求:

给定一组特定任务,以及任务各有不同的耗时,并且有先后要求,再加上在若干相同处理器上运行,安排任务在最短时间内完成。

思路:

1.创建无环加权有向图,包含起点和终点,任务和任务耗时用任务点以及它的指出边的权值来表示

2.一个任务指出的边的权值,是其任务的费时

3.一个任务必须在一个任务指向的任务之前完成

4.设置起点s和终止点t,st之间的最长路径即为总耗时

解决:使用最长路径的无环加权有向图中的最短路径算法实现即可。

Bellman-Ford算法(基于队列优化)

Bellman-Ford算法

在做出队列优化之前,先简单介绍一下Bellman-Ford算法的原版

其操作描述十分简单,即对图G的所有边进行V次松弛,时间复杂度高达O(VE)。使用基于队列优化的类似于BFS广度优先搜索,可以大大减少松弛次数,从而大大减少时间。

至于为什么要反复松弛点,正如无环加权有向图中的最短路径算法的解析所示。

思路:

1.利用队列放松点,将起点加入队列

2.取出队列里的点进行放松,并且将其指向的点加入队列,

3.每放松了V次,进行负有向环检测

实现:

#include "SP.hpp"

class BellmanFordSP
{
public:
    BellmanFordSP(WeightedDigraph G, int s);
    ~BellmanFordSP() { delete[] onQ, distTo_, edgeTo_; }

    double distTo(int v) const { return distTo_[v]; }
    bool hasPathTo(int v) const { return distTo_[v] < INFINITY; }
    Bag<DirectedEdge> pathTo(int v);

private:
    void relax(WeightedDigraph G, int v);
    bool hasNegativeCycle();
    void findNegativeCycle();
    Bag<DirectedEdge> negativeCycle() { return cycle;};

    int size;
    int cost = 0;
    bool *onQ;
    double *distTo_;
    DirectedEdge *edgeTo_;
    std::queue<int> que;
    Bag<DirectedEdge> cycle;
};

BellmanFordSP::BellmanFordSP(WeightedDigraph G, int s) : size(G.V()), onQ(new bool[G.V()]), distTo_(new double[G.V()]), edgeTo_(new DirectedEdge[G.V()])
{
    for (int i = 0; i < G.V(); i++)
        distTo_[i] = INFINITY;
    distTo_[s] = 0.0;
    que.push(s);
    onQ[s] = true;
    while (!que.empty() and !this->hasNegativeCycle())
    {
        int v = que.front();
        que.pop();
        onQ[v] = false;
        relax(G, v);
    }
}

void BellmanFordSP::relax(WeightedDigraph G, int v)
{
    for (DirectedEdge e : G.adj(v))
    {
        int w = e.to();
        if (distTo_[w] > distTo_[v] + e.weight())
        {
            distTo_[w] = distTo_[v] + e.weight();
            edgeTo_[w] = e;
            if (!onQ[w])
            {
                que.push(w);
                onQ[w] = true;
            }
        }
        if (cost++ % G.V() == 0)
        {
            findNegativeCycle();
        }
    }
}
void BellmanFordSP::findNegativeCycle()
{
    WeightedDigraph spt(size);
    for (int v = 0; v < size; v++)
        if (edgeTo_[v].from() != -1)
            spt.addEdge(edgeTo_[v]);
    // 寻找有向环判断是否负权重
}

bool BellmanFordSP::hasNegativeCycle()
{
    return !cycle.isEmpty();
}

关于Dijkstra的局限性:

Dijkstra无法处理负有向环,例如以下有权图所示,环1-2-3的值为-0.5。

graph TB
0((0));1((1));2((2));3((3));4((4));
0--0.1-->1--0.2-->2--0.3-->3;3--"-1"-->1;0--0.8-->4--0.1-->3;

根据Dijkstra的步骤以0为起点,当全正权值的时候,Dijkstra的一大特点就是已连接的边不会变化,又由于权值为正,故新点指向已在树中的点时,此边不可能优于已经在树中的边。对于两点,Dijkstra在处理负权值就无法解决:

1.0->1首先被连接,但0->4->3->1由于存在负权值而优先于0->1,但0->1已经被设置

2.当存在负权重环的时候,由0->1->2->3处理到3时,3指向1,此时dist[1]为0.1,dist[3] + e.weight()的值就为-0.4,此时便无法再连接到根节点也就是我们的起点。并变化了树中的0->1

Bellmen-Ford优化分析:

Bellmen-Ford队列优化类似于BFS,回到之前的问题

graph TB
0((0));1((1));2((2));3((3));4((4));0 -->1-->2-->4;0-->3-->4;2-->3;

根据BFS,先1后2再3,此时原本dist[4]的值为0->2->4的权值。在处理3后,若2变为dist[2]为0->1->3->2,那么dist[4]的值也应该被更新,故2又被加入队列,进行重复的放松。也正因为如此,将所有边放松一次并不能达成目标,最坏的情况下将时间复杂度高达O(VE)。

负权环的检测:

我们可以发现,当此算法遇到负权环的时候,将会进入无限的循环,此时,我们就需要定期检查该图是否有负权重环的存在。我们以relax的次数作为标准,每当进行了V次relax,就进行一次检测(当然,这个值也可以小于V,并且每次检测并不代表该图中有负权重环),当存在负权重环,算法应当结束。