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

有向图

特点:每条边都是有方向的,从一个顶点到另一个顶点

术语:

  • 指出:⓪→① 顶点0指出顶点1
  • 出度:对于⓪→①→② 0和1 都各有一条出度
  • 入度:1 和 2 都各有一条入度
  • :一条有向边的第一个顶点
  • :与头相反
  • 有向路径:由一系列顶点和有向边组成如⓪→①→②
  • 简单有向环:除起点和终点其它各不相同
  • 长度:包含的边数
  • 达到:当有⓪→① 则 1能由0达到
  • 强连通:对于⓪,①若存在⓪→① 的路径也存在①→⓪的路径,则称0,1强连通
  • 强连通分量:对于同一强连通分量的顶点,任一两点都强连通

有向图基本API

接口 操作 返回类型
Digraph(int v) 创建一个含有V个点无边的无向图 构造函数
V() 查看顶点数 int
E() 查看边数 int
addEdge(int v, int w) 向图中添加一条v-w边 int
adj(int v) 查看v的出度 Bag
reverse() 该图的反向图 Digraph

概要

有向图的基本api和无向图的区别在于有向图有reverse()函数用于生成一张反向图,其实现也十分简单。还有一点就是adj函数变成用于查询出度。这都是由于有向图的实现方式,有向图我们依旧使用邻接表数组,但是原来的边会记录两次,分别为该边连接的两点,但有向图为了体现边的有向性,将一条有向边只记录一次,记录在指出这条边的点。既然如此,reverse函数的实现也变得简单,只需要创建一个新有向图,遍历邻接表中的每一个点的出度(也就是遍历所有有向边)并将原本的有向边的尾记录为新的头。

Digraph头文件

class Digraph
{
public:
    Digraph(int v);
    Digraph(const Digraph &G);
    ~Digraph() { delete[] _adj; }

    int V() { return vertex_num; };
    int E() { return edge_num; };

    void addEdge(int v, int w);
    Bag<int> adj(int v) { return _adj[v]; };

    Digraph reverse();

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

private:
    int vertex_num;
    int edge_num;
    Bag<int> *_adj;
};

addEdge的改变

inline void Digraph::addEdge(int v, int w)
{
    for (int temp : adj(v))
        if (temp == w)
            return;
    if (v == w)
        return;
    _adj[v].add(w); // 有向图只加一边
    edge_num++;
}

reverse的实现

Digraph Digraph::reverse()
{
    Digraph reverse_digraph(vertex_num);
    for (int i = 0; i < vertex_num; i++)
        for (int v : _adj[i])
            reverse_digraph.addEdge(v, i);
    return reverse_digraph;
}

有向图算法

有向图的可达性API

接口 操作 返回类型
DirectedDFS(Digraph G, int s) 找到和起点s连通的所有顶点 构造函数
DirectedDFS(Digraph G, Bag b) 找到和一组起点连通的所有顶点 构造函数
marked(int v) v和s(一组起点)是否连通 bool

有向图的可达性也就是主要利用DFS和BFS的算法,会如同一下的有向图的寻路。

有向图的寻路API

接口 操作 返回类型
Path(Digraph G, int s) 创建一个含有V个点无边的无向图 构造函数
hasPathTo(int v) 是否存在从s到v 的路径 bool
pathTo(int v) s到v的路径,如果不存在,返回空序列 int

DFS

Path::Path(Digraph G, int s) : _marked(new bool[G.V()]{false}), edgeTo(new int[G.V()]), root_node(s)
{
    dfs(G, s);
}

void Path::dfs(Digraph G, int s)
{
    _marked[s] = true;
    for (int v : G.adj(s))
    {
        if (!_marked[v]){
            edgeTo[v] = s;
            _count++;
            dfs(G, v);
        }
    }
}

BFS

Path::Path(Digraph G, int s) : _marked(new bool[G.V()]{false}), edgeTo(new int[G.V()]), root_node(s)
{
    bfs(G, s);
}

void Path::bfs(Digraph G, int s)
{
    using namespace std;
    queue<int> b_que;
    _marked[s] = true;
    b_que.push(s);
    while (!b_que.empty())
    {
        int v = b_que.front();
        b_que.pop();
        for (int w : G.adj(v))
        {
            if (!_marked[w])
            {
                _marked[w] = true;
                edgeTo[w] = v;
                b_que.push(w);
                _count++;
            }
        }
    }
}

分析:BFS和DFS在有向图中依旧可以使用,甚至代码几乎没有什么变化,此处作为复习。同理只有BFS的实现才是最短路径。

拓扑排序

情景:此时你需要为一些任务进行排序,而且,这些任务中的部分包含先后顺序的要求,例如存在任务ABCD其中D需要在A之后完成,C要在A之前完成,B要在A之后完成。此时如何排序就成为一种实际问题。

对这种问题进行抽象化,将任务抽象化为一个顶点,之间的先后要求抽象成有向边此时我们可以得到

graph LR
A((A))
B((B))
C((C))
D((D))

A --> D
C --> A
A --> B

对于以上的描述,我们得到一张十分简单的有向图,虽然此时我们能一下分辨先后,但是如果有多个顶点,任务就复杂起来。我们需要一种算法,来解决这种问题。

思考:为了确保任务的顺利完成,我们先要排除不可处理的情况。此时,对于可进行这种排序的有向图,它一定是无环的,若有环,例如对于任务ABC,如下图所示,根本无法进行排序。所以在解决问题之前,我们应该先排除有向环的存在。

graph LR
A((A))
B((B))
C((C))
A --> B
B --> C
C --> A

寻找有向环API

有向图的寻路API

接口 操作 返回类型
DirectedCycle(Digraph G) 寻找有向环构造函数 构造函数
hasCycle() G是否存在有向环 bool
cycle() 有向环中的所有顶点(如果存在) Bag

头文件

class DirectedCycle
{
public:
    DirectedCycle(Digraph G);
    ~DirectedCycle(){ delete [] onStack, edgeTo;}
    bool hasCycle() { return !_cycle.isEmpty(); };
    Bag<int> cycle() { return _cycle; };

private:
    void dfs(Digraph G, int s);

    Bag<int> _cycle;
    bool *marked;
    bool *onStack;
    int *edgeTo;
};

思路:和无向图的搜索环类似,利用深度优先搜索,记录当前的路径,若出现重复顶点又不是根节点,即为环。此时要获取环,只需要利用edgeTo数组的链接即可。

实现:

DirectedCycle::DirectedCycle(Digraph G) : marked(new bool[G.V()]), onStack(new bool[G.V()]), edgeTo(new int[G.V()])
{
    for (int i = 0; i < G.V(); i++)
    {
        if (!marked[i])
        {
            dfs(G, i);
        }
    }
}

void DirectedCycle::dfs(Digraph G, int s)
{
    onStack[s] = true;
    marked[s] = true;
    for (int v : G.adj(s))
    {
        if (hasCycle())
            return;
        else if (!marked[v])
        {
            edgeTo[v] = s;
            dfs(G, v);
        }
        else if (onStack[v])
        {
            Bag<int> cc;
            for (int x = s; x != v; x = edgeTo[x])
                cc.add(x);
            cc.add(v);
            cc.add(s);
            _cycle = cc;
        }
    }
    onStack[s] = false;
}

拓扑排序

解决了环的判定,此时我们就可以进入正题了,这种排序的名称为拓扑排序

要求:给定一有向图,将所有的顶点排序,使所有顶点只能指向排在后面的点。

示例:

graph TB
0((0))
1((1));2((2));3((3));4((4));5((5));6((6));7((7));8((8));9((9));10((10))
11((11));12((12))

0 --> 1; 0-->6; 0-->5;
2 --> 0; 2-->3;
3 --> 5;
5 --> 4; 6-->4;
7 --> 6; 6-->9;
9 --> 10; 9-->12; 11-->12;
9 --> 11;
8 -->7 ; 7-->6;

以上图的排序结果(理想):

graph LR
0((8))
1((7));2((2));3((3));4((0));5((6));6((9));7((10));8((11));9((12));10((1))
11((5));12((4))
0 --> 1;1 --> 2;2 --> 3;3 --> 4;4 --> 5;5 --> 6;6 --> 7;7 --> 8;8 --> 9;9 --> 10;10 --> 11;11 --> 12;

拓扑排序API

接口 操作 返回类型
Topological(WeightedDigraph G) 对有向图G的节点进行拓扑排序 构造函数
isDAG() 图是否有环 bool
order() 返回拓扑排序的结果 Bag

头文件

class Topological
{
public:
    Topological(WeightedDigraph G);
    ~Topological() { delete[] marked, onStack; }

    bool isDAG() const { return !resOrder.isEmpty(); };
    Bag<int> order() const { return resOrder; };

private:
    bool *marked;
    bool *onStack;
    Bag<int> resOrder;

    void dfs(WeightedDigraph G, int v);
};

思路:

1.根据拓扑排序遍历顶点

2.每次dfs操作后将点加入栈

3.遍历结束后栈的结果即为拓扑排序顺序

实现:

Topological::Topological(WeightedDigraph G) : marked(new bool[G.V()]), onStack(new bool[G.V()])
{
    DirectedCycle dc(G);
    if (!dc.hasCycle())
    {
        dfs(G, 0);
    }
}
void Topological::dfs(WeightedDigraph G, int v)
{
    marked[v] = true;
    for (int w : G.adj(v))
    {
        if(!marked[w])
            dfs(G, w);
    }
    resOrder.add(v);
}

分析:主要的实现方法为DFS。首先,DFS会遍历所有的点,其次拓扑排序不一定只有一种结果。以下图为例。

graph LR

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

当从0开始进行DFS(先0->4再1->2->3)被忽略的关系2 -> 4 ; 3 -> 0 ; 1 -> 0;

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

当从1开始,被忽略的关系3 -> 0; 2->4

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

当从2开始,被忽略的关系1 -> 0; 1 -> 2; 2 -> 4

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

当从3开始,被忽略的关系1 -> 0; 2 -> 3; 2 -> 4

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

分析:以上可发现被无视的有向边均为新节点指向旧树。也就是说旧节点不可能再指向新节点,它们应当被放在排序的最后端。

原因:DFS保证生成的树已为最深,确保遍历了每个点的指向,以保证不会再有旧节点指向新的节点,不会被指向的节点作为排序尾理所应当,故不断排入尾即可。最后再利用stack逆序。

有向图强连通分量API

接口 操作 返回类型
SCC(WeightedDigraph G) 查找G图中的 构造函数
stronglyConnected(int v, int w) 查看w,v点是否强连通 bool
count() 查看强连通分量的数量 int
id(int v) 查看v点所在连通分量 int

头文件

class SCC
{
public:
    SCC(WeightedDigraph G);
    ~SCC() { delete[] _id, marked; }

    bool stronglyConnected(int v, int w) const { return _id[w] == _id[v]; };
    int count() const { return _count; };
    int id(int v) const { return _id[v]; };

private:
    void dfs(WeightedDigraph G, int s);

    int *_id;
    bool *marked;
    int _count = 0;
};

有向图也存在连通性问题,但在有向图中,被称为强连通性。在一条有向环中,各个顶点都互相强连通。

Kosaraju算法

思路:

1.利用DFS的拓扑排序(DFO)对G的反向图进行排序

2.按照1得到的顺序对G进行DFS

3.其中同一个DFS中遇到的点都在同一个强连通分量中

实现:

SCC::SCC(WeightedDigraph G) : _id(new int[G.V()]), marked(new bool[G.V()])
{
    Topological ts(G.reverse());
    for (int s : ts.order())
    {
        if (!marked[s])
        {
            dfs(G, s);
            _count++;
        }
    }
}

void SCC::dfs(WeightedDigraph G, int v)
{
    marked[v] = true;
    _id[v] = _count;
    for (int w : G.adj(v))
        if (!marked[w])
            dfs(G, w);
}

分析:Kosaraju算法简单但难以理解,首先要理解DFO在有环图中的排序行为。

深入拓扑排序DFO

1.DFO一棵树内关系

DFO对第一个起点进行DFS,即从起点生成一棵树。其中生成的第一颗树,我们可以发现起点为这个树的,并且对于树的任一节点,都存在起点到节点的有向路径

2.DFO多颗树先后关系

若生成多颗树,即为存在起点无法连接到的点,其中,由于第一颗树保证了深度最大,不会指向后面的节点,换句话说,后面生成的树中的任一节点不会被前面的旧节点指向。

第一步:对G的反向图同样分析

若逆后序为⑥|①|⓪⑤③②④|⑦⑧

其中⑥①不会指向后面的节点⑤③②④有指向⓪的路径。则如果⓪有指向⑤③②④中的任一节点,那必成为一有向环,即为同一连通分量。且⓪⑤③②④中的任一节点不可能指向之后的节点。又⓪⑤③②④是拓扑排序保证了⑤③不可能指向②④,除非在同一树枝上。

graph BT
0((0));5((5));2((2));3((3));4((4));
4-->2-->0;3-->5-->0;

例如②可以指向④,⑤可以指向③,这一点是DFO的特性,也保证了Kosaraju算法的正确性。

第二步:

当分析⑥的时候因⑥不可能被后面的任一节点指向。⑥自己成为一分量,分析①同理。

当分析⓪的时候⓪ -> ②则②⑥成为一分量,若就此结束,到⑤的时候,⑤不可能指向④(理由见上文)也不会导致问题。