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

图算法

无向图

术语

  • 相邻:⓪-① 1与0相邻
  • 依附:⓪-① 该连接依附于0和1
  • 度数:⓪-①-② 1的度数为2(依附于它的边的总数)
  • 子图:一幅图所有边的子集
  • 路径:由边顺序连接的一系列节点
  • (简单)路径:一条没有重复节点的路径
  • :至少包含一条起点终点相投的路径
  • (简单)环:起点和终点相同其他节点不同,环的长度等于边的个数
  • 连通:⓪-① 1与0连通
  • 连通图:可以从任一顶点到达任一其他点
  • 极大连通子图:非连通图有多个连通图构成
  • 无环图:没有环的图
  • :一种无环图(连通图,去掉任意一边便不全连,加任一边便产生环,任一两点只有一种路径)
  • 森林:互不相连的数组成的集合
  • 生成树:连通图的生成树是它的一副子图,含有所有的顶点
  • 生成树森林:同理
  • 密度:已经连通的点占所有点对的比例(分为稠密图和稀疏图)
  • 二分图:将所有的节点分为两部分,每条边的两个节点都能属于不同部分

无向图基本API

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

数据类型

对于无向图的数据类型,我们选用邻接表数组。邻接表数组即一个数组内的每个元素维护一个bag对象,由于c++没有Bag对象,我们将进行定义。Bag对象的特点类似于链表,可以不断添加,但Bag不能删除元素,因为基本api并不要求删除,若要求,可以换个类型例如链表。

Undigraph头文件

// 代码中Undigraph显示为Graph
class Graph
{
public:
    Graph(int v);
    Graph(const Graph &G);
    ~Graph() { 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];} ;
 
    Graph &operator=(const Graph &G);
    friend std::ostream &operator<<(std::ostream &os, const Graph &G);

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

分析:

而对于无向图的实现,我们还可以使用邻接矩阵,使用V*V的布尔值二维数组,但是将要花费V平方级别的内存空间,百万个点的图也是很常见的,这样的花销十分不值得。

我们还可以使用边的数组,使用一个Edge类,有两个int成员变量来代表连接的两个点,但是对于adj()的实现,便要遍历整个边的数组,时间花费成本高达O(E)。

对于邻接表数组,添加边的操作,时间复杂度为O(1)。adj()函数实现也只需要查看对应数组内的Bag内容即可。优点:性能好 缺点:内存占用大(但是相对于邻接矩阵,可以省下相当的空间大小)。

图连接查找 API

接口 操作 返回类型
Search(Graph G, int s) 找到和起点s连通的所有顶点 构造函数
marked(int v) v和s是否连通 bool
count() 与s连通的顶点总数 int

寻找路径算法 API

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

Search 头文件

class Search
{
public:
    Search(Graph G, int s);
    bool marked(int v) { return _marked[v]; };
    int count() const { return _count; };
    ~Search() { delete[] _marked; }

private:
    void dfs(Graph G, int s);
    void bfs(Graph G, int s);

    bool *_marked = nullptr;
    int _count = 0;
    int root_node;
};

Path头文件

class Path
{
public:
    Path(Graph G, int s);
    ~Path() { delete[] _marked; }

    Bag<int> pathTo(int v);
    bool hasPathTo(int v) { return _marked[v]; };
    bool marked(int v) { return _marked[v]; };
    int count() const { return _count; };

private:
    void dfs(Graph G, int s);
    void bfs(Graph G, int s);

    bool *_marked = nullptr;
    int *edgeTo = nullptr;
    int _count = 0;
    int root_node;
};

这里我们将会介绍图算法中最基本的两种算法,以用来解决上面两种问题,由于上面的两种问题具有共通性,于是放在一起介绍。

关于路径搜索

Bag<int> Path::pathTo(int v)
{
    if (!hasPathTo(v))
        return Bag<int>();
    Bag<int> path;
    for (int x = v; x != root_node; x = edgeTo[x])
    {
        path.add(x);
    }
    path.add(root_node);
    Bag<int> reserve(path);
    return reserve;
}

我们在Search的基础上进行修改即可,Search需要遍历起点连通的所有点。Path也需要遍历所有点。对于路径搜索,只需要在遍历到点的时候,将该点连接到上一个点即上面的edgeTo[v] = s;这一句。路径搜索需要额外维护一个数组edgeTo,以用来保存每个点指向根节点的路径,以便在pathTo函数中,输出点的路径。

当查找点v到s的路径时,若v在以s为根节点的树内,则_marked[v]应当为true(v点在遍历中被标记),此时,寻找关于点v到s的路径,只需要按照树的连接,从子节点不断输出到根节点。

graph TB
A((0))
B((1))
C((3))
D((2))
E((4))
F((5))

A --- B
A --- C
B --- D
B --- E
C --- F

以上图为例此时edgeTo数组为(第一行为数组下标,第二行为其值)

graph TB
A[0]
B[1]
C[2]
D[3]
E[4]
F[5]

a[0]
b[0]
c[1]
d[0]
e[1]
f[3]

A -->a
B -->b
C -->c
D -->d
E -->e
F -->f

Search&Path实现一:DFS深度优先搜索(Deep First Search)

思路:

1.访问一个顶点时,将其标记为已访问。

2.递归地访问没有被标记的邻接顶点。

实现:

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

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

Path

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

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

分析:DFS的核心思路是广度优先,也因此主要实现方法就是递归

graph TB
A((0))
B((1))
C((4))
D((2))
E((3))
F((5))
G((6))
A --- B
A --- C
B --- D
B --- E
C --- F
C --- G

DFS的搜索顺序如上图的序号,从0-6。主要操作的函数是dfs,在对点进行dfs操作时,标记这个点并遍历它邻接的每一个点,若接下来的点没有被标记(没有dfs过)就对其进行dfs。并且在寻找路径算法的实现中也多添加了edgeTo[v] = s;以用来连接点而记录路径。然而,当我们需要找到最短路径的时候,DFS并不能达到其效果

graph TB
A((0))
B((1))
C((2))
D((3))

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

对于以上图,当使用DFS的广度优先搜索时,输出3-0的路径,可能为3 -> 2 -> 1 -> 0 而并不是最短路径 3 -> 0

此时,我们提出BFS广度优先搜索。

Search&Path实现二:BFS广度优先搜索(Breadth First Search)

目的:为了实现最短路径搜索,我们希望图按照以下0-6的顺序搜索,这样便可达到目的,若存在短的路径,将会先被记录进edgeTo数组。实现BFS广度优先搜索就不能使用递归的方法。

graph TB
A((0))
B((1))
C((2))
D((3))
E((4))
F((5))
G((6))
A --- B
A --- C
B --- D
B --- E
C --- F
C --- G

思路:

1.维护一个队列queue,用来储存即将被查找的点。

2.从队列中取出一个点,对其进行操作,并将其邻接的没有被记录过的点全部加入队列。

3.重复2的操作直至队列为空。

实现:

Search

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

void Search::bfs(Graph G, int s)
{
    std::queue<int> que_marked;
    _marked[s] = true;
    que_marked.push(s);
    while (!que_marked.empty())
    {
        int v = que_marked.front();
        que_marked.pop();
        for (int w : G.adj(v))
        {
            if (!_marked[w])
            {
                _marked[w] = true;
                que_marked.push(w);
                _count++;
            }
        }
    }
}

Path

void Search::bfs(Graph G, int s)
{
    std::queue<int> que_marked;
    _marked[s] = true;
    que_marked.push(s);
    while (!que_marked.empty())
    {
        int v = que_marked.front();
        que_marked.pop();
        for (int w : G.adj(v))
        {
            if (!_marked[w])
            {
                _marked[w] = true;
                que_marked.push(w);
                _count++;
            }
        }
    }
}

分析:BFS也即是广度优先,以广度(和起点最近)为优先。借此我们也能解决最短路径的问题。

对比DFS和BFS

我们将对以下这幅图分别进行DFS和BFS的搜索,并记录结果

graph TB
A((0))
B((1))
C((2))
D((3))
E((4))
F((5))
G((6))
A --- B
A --- C
B --- D
D --- E
D --- F
C --- F
C --- G

DFS

graph BT
A((0))
B((1))
C((2))
D((3))
E((4))
F((5))
G((6))

E --> D
F --> D
D --> B
B --> A
G --> C
C --> A

BFS

graph BT
A((0))
B((1))
C((2))
D((3))
E((4))
F((5))
G((6))

E --> D
F --> C
D --> B
B --> A
G --> C
C --> A

分析:由上图的对比可见 DFS忽略边5-2 而BFS忽略 5-3, 此时对5进行路径查找

DFS的路径将会是5 -> 3 -> 1 -> 0, BFS的路径将会是5 -> 2 -> 0

由上可得,BFS生成的树深度会最小,而DFS不能保证,但为了实现BFS,我们需要额外维护一个队列。

拓展

两者的用途:

BFS:找到最小路径

DFS:找到目标,判断是否有环,判断是否为二分图

判断是否有环

思路:利用无环图在DFS中不会出现重复遍历的特点,若出现了重复遍历,需要判断其是否为根节点,若不是,则包含环

判断是否为二分图

思路:用color来标识两种集合,已知无环图必为二分图,所以只需要查看有环部分是否成立即可。