PAT归纳总结——关于图的一些总结

  在刷题的过程中经常会碰到一些关于图论的问题,下面我将根据自己刷题的经验来对这些问题做一个总结。

  • 图的表示方法

  解决图论中的问题首先要解决的问题就是图的表示方法这一问题,图的表示方法主要有两种,一种使用链接表的方法来表示,另一种是用链接矩阵的方法来表示。在解题的过程中我用的几乎都是用邻接矩阵的方法来表示。用邻接矩阵来表示的话既可以用二维数组也可以用二维向量来表示。用二维向量来表示的好处是,如果题目中没有给出边的权重,只是让我们求解图的连通性问题的话,我们可以不指定vector数组的大小,例如:vector[i].push_back(x),以此来表示i与x相连。这样来表示的话又有一些链接表的感觉。

  • 图论中涉及到的一些算法

  就PAT考察的范围,以及刷题的经验来看,这里面涉及到的算法主要包括DFS、BFS、并查集、Dijkstra(最短路径)、Kruskal(最小生成树)以及求连通分量的问题。

  今天复习数据结构刚好复习了 图论这一章,所以把图论中的一些算法总结一下,毕竟有些算法在PAT中还是经常考到的。

  两种搜索算法BFS + DFS:

  这两种算法应该是图论中最基本的两种算法了,其他的算法在实现的时候都或多或少的用到了这两种算法。BFS实现的时候可以用一个队列来维护,以当前的结点为中心将于之相连的结点依次放入队列中,如果存在回路的话可以使用一个visited[]来标记当前结点是否已经被访问过。

  BFS的代码实现:

const int MAX_VERTEX_NUM = 0x7fffffff;
bool visited[MAX_VERTEX_NUM];
void BFSTraverse(Graph G) {
    for (int i = 0; i < G.vexnum; ++i) visited[i] = false;
    InitQueue(Q);
    for (int i = 0; i < G.vexnum; ++i)
        if (!visited[i]) BFS(G, i);
}

void BFS(Graph G, int v) {
    visit(v);
    visited[v] = true;
    Enqueue(Q, v);
    while (!isEmpty(Q)) {
        DeQueue(Q, v);
        for (w = FirstNeighbor(G, v); w >= 0; w = NextNeighbor(G, v, w))
            if (!visited[w]) {
                visit(w);
                visited[w] = true;
                EnQueue(Q, w);
            }
    }
}

 

 

  DFS就是从一个结点开始一直往下走,直到走到尽头,然后跳出递归返回上一层继续寻找没有遍历过的结点,直至将所有的结点都遍历完毕为止。实现的时候可以用一个栈来实现。

  DFS的代码实现如下所示:

 1 bool visited[MAX_VERTEX_NUM];
 2 void DFSTraverse(Graph G) {
 3     for (int v = 0; v < G.vexnum; ++v) 
 4         visited[v] = false;
 5     for (int v = 0; v < G.vexnum; ++v)
 6         if (!visited[v])
 7             DFS(G, v);
 8 }
 9 void DFS(Graph G, int v) {
10     visit(v);
11     visited[v] = true;
12     for (int w = FirstNeighbor(G, v); w >= 0; w = NextNeighbor(G, v, w))
13         if (!visited[w])
14             DFS(G, w);
15 }

 

  使用BFS可以解决无权图的单源最短路问题,具体代码如下所示:

 1 void BFS_MIN_Distance(Graph G, int u) {
 2     for (int i = 0; i < G.vexnum; ++i)
 3         d[i] = inf;
 4     visited[u] = true;
 5     d[u] = 0;
 6     EnQueue(Q, u);
 7     while (!isEmpty(Q)) {
 8         DeQueue(Q, u);
 9         for (w = FirstNeighbor(G, v); w >= 0; w = NextNeighbor(G, u, w))
10             if (!visited[w]) {
11                 visited[w] = true;
12                 d[w] = d[u] + 1;
13                 EnQueue(Q, w);
14             }
15     }
16 }

 

  除了这些东西以外,还有两个重要的概念就是广度优先生成树和深度优先生成树。如果要用代码实现的话,我会这样来实现:通过在多开一个数组,来记录那条边是在生成树中存在的。通过这些边的信息应该就能将这棵树输出。

 

  区别:

  BFS:这是一种基于队列这种数据结构的搜索方式,它的特点是由每一个状态可以扩展出许多状态,然后再以此扩展,直到找到目标状态或者队列中头尾指针相遇,即队列中所有状态都已处理完毕。

  DFS:基于递归的搜索方式,它的特点是由一个状态拓展一个状态,然后不停拓展,直到找到目标或者无法继续拓展结束一个状态的递归。

  优缺点:

  BFS:对于解决最短或最少问题特别有效,而且寻找深度小,但缺点是内存耗费量大(需要开大量的数组单元用来存储状态)。

  DFS:对于解决遍历和求所有问题有效,对于问题搜索深度小的时候处理速度迅速,然而在深度很大的情况下效率不高 总结:不管是BFS还是DFS,它们虽然好用,但由于时间和空间的局限性,以至于它们只能解决数据量小的问题。

 

  最小生成树:

  最小生成树可以使用两种算法来实现,一种叫Prime算法,另一种叫Kruskal算法。

  1. Prim算法

  Prim算法的核心思想就是“找点”,根据已经遍历过的点,来找出与这些点相连的边中最短的那一条,从而将该边另一个端点上的结点收入已经遍历过的结点之中。这个算法在刷题的过程中用到的次数不多。

  伪代码实现如下所示:

1 void Prim(G, T) {
2     T != {};        // 初始化空树
3     U = {w};        // 将一个结点放入以遍历结点的集合
4     while ((V - U) != {}) {
5         设(u, v)是使u∈U与v∈(V-U),且权值最小的边;
6          T = T ∪ {(u, v)};
7          U = U ∪ {v};
8     }
9 }

   Prim算法的思想:归并顶点,与边数无关,适用于稠密图。

 

  2. Kruskal算法

  Kruskal算法的核心思想就是不断地去找图中边长最小的边,然后判断该边所连接的两个端点是否在同一棵生成子树中(这一部分可通过使用并查集来判断),如果不在同一棵生成子树中则将该边加入到连通分量中,如此重复直到将所有的结点都加入到最小生成树中。

  伪代码实现如下所示:

 1 void Kruskal(V, T) {
 2     T = V;      // 初始化树
 3     numS = n;   // 连通分量数
 4     while (numS > 1) {
 5         if (v和u属于T中不同的连通分量) {
 6             T = T ∪ {(v, u)};       //将此边加入到生成树中
 7             numS--;
 8         }
 9     }
10 }

  Kruskal算法:归并边,适用于稀疏图。

 

  最小生成树Prim与Kruskal算法的比较

  Prim算法是依赖于点的算法。它的基本原理是从当前点寻找一个离自己(集合)最近的点然后把这个点拉到自己家来(距离设为0),同时输出一条边,并且刷新到其他点的路径长度。俗称,刷表。

  根据Prim算法的特性可以得知,它很适合于点密集的图。通常在教材中,对Prim算法进行介绍的标程都采用了邻接矩阵的储存结构。这种储存方法空间复杂度N^2,时间复杂度N^2。对于稍微稀疏一点的图,其实我们更适合采用邻接表的储存方式,可以节省空间,并在一定条件下节省时间。

  Kruskal算法是依赖边的算法。基本原理是将边集数组排序,然后通过维护一个并查集来分清楚并进来的点和没并进来的点,依次按从小到大的顺序遍历边集数组,如果这条边对应的两个顶点不在一个集合内,就输出这条边,并合并这两个点。

  根据Kruskal算法的特性可得,在边越少的情况下,Kruskal算法相对Prim算法的优势就越大。同时,因为边集数组便于维护,所以Kruskal在数据维护方面也较为简单,不像邻接表那样复杂。从一定意义上来说,Kruskal算法的速度与点数无关,因此对于稀疏图可以采用Kruskal算法。

 

  最短路径:

  上面提到用BFS来求无权图的最短路径问题,但是当我们遇到有权图的时候这种方法将不再适用。对于有权图我们有两种方法来求最短路问题,一种是使用Dijkstra算法来求单源最短路,另一种,是使用Floyd算法来求任意两点之间的最短路问题。Dijkstra算法在PAT中出现的次数还是比较多的,所以我们有必要弄清楚该算法的实现原理。

  1. Dijkstra算法

  基本思想:维护一个已经访问过的结点的集合通过向这个集合中不断地添加结点,并且更新与已添加结点相邻的结点到起始点的距离,以便下次找到一个到起始点距离最小的结点。最后将所有的结点都加入到这个集合中,算法执行完毕。

  辅助数组:

  visited[]:标记已经计算完成的顶点。

  dist[]:记录从源点v0到其他各顶点当前的最短路径长度。

  path[]:记录从最短路径中顶点的前驱顶点,即path[i]为v到vi最短路径上vi的前驱顶点。

  基本步骤:

  • 初始化数组,令集合S初始为{Ø};
  • 从集合V-S中选出vj,满足dist[j] = Min{dist[i] + vj | vj∈V-S},vj就是当前求得的最短路径的终点,并令S ∪ {j};
  • 修改此时从v0出发到集合V-S上任一顶点vk最短路径的长度:
    if dist[j] + arcs[j][k] < dist[k]
        dist[k] = dist[j] + arcs[j][k];
        path[k] = j;
  • 重复2)、3)操作n-1次,直到S中包含全部顶点。

 

 

  代码实现:

 1 // Dijkstra
 2 bool visited[1005];
 3 int path[1005];
 4 int dist[1005];
 5 int grap[1005][1005];
 6 void Dijkstra(int start) {
 7     for (int i = 0; i < 1005; ++i) {  // 初始化
 8         visited[i] = false;
 9         path[i] = 0;
10         dist[i] = inf;
11     }
12     visited[start] = true;
13     dist[start] = 0;
14     for (int i = 0; i < 1005; ++i) {
15         int u = -1, minn = inf;
16         for (int j = 0; j < 1005; ++j) {
17             if (visited[j] == false && dist[i] < minn) {
18                 u = i;
19                 minn = dist[i];
20             }
21         }
22         if (u == -1) break; visited[u] = true;
23         for (int j = 0; j < 1005; ++j) {
24             if (visited[j] == false && grap[u][j] != inf) {
25                 if (dist[j] > dist[u] + grap[u][j]) {
26                     dist[j] = dist[u] + grap[u][j];
27                     path[j] = u;
28                 }
29             }
30         }
31     }
32 }

  时间复杂度O(|V|2

  Dijkstra算法不能够求解权重为负的图。

  2. Floyd算法

  Floyd算法能够求解任意两点之间的最短路问题,如果有N个结点那么就需要N个二维数组来存储绕行结点k时任意两顶点间的最短路径,虽然比较繁琐,但是代码实现起来还是比较简单的。

  代码实现:

 1 // Floyd
 2 for (int k = 0; k < n; ++k) {       // 考虑以Vk作为中转点
 3     for (int i = 0; i < n; ++i) {   // 遍历整个矩阵,i为行号,j为列号
 4         for (int j = 0; j < n; ++j) {
 5             if (A[i][j] > A[i][k] + A[k][j]) {      // 以Vk为中转点的路径更短
 6                 A[i][j] = A[i][k] + A[k][j];        // 更新最短路径
 7                 path[i][j] = k;                     // 中转点
 8             }
 9         }
10     }
11 }

  Floyd算法可以解决带有负权重的图,其时间复杂度为O(|V|3

 

  并查集:

  并查集问题也是图论中常见的内容,因为刷题的过程中有涉及到,所以有必要对这类问题进行总结。并查集中有一个重要的辅助数组fa[i]用来表示i的祖先是谁,如果是在同一个连通分量中,那么该连通分量中的每一个结点的祖先结点一定是相同的。初始化的时候我们将每一个结点的祖先结点指向其自身。

  并查集中有两个重要的函数,这两个函数也是并查集的核心,一个是寻找某个结点的祖先结点(另外,也会在这个过程中实现路径压缩),另一个函数,是将两个结点进行合并。他们的实现代码分别如下:

  寻找某个结点的祖先结点:

 1 int findFather(int x) {
 2     int a = x;
 3     while (x != fa[x]) {
 4         x = fa[x];
 5     }
 6     while (a != fa[a]) {
 7         int z = a;
 8         a = fa[a];
 9         fa[z] = x;
10     }
11     return x;
12 }

 

  将两个结点进行合并: 

1 void Union(int x, int y) {
2     int faX = findFather(x);
3     int faY = findFather(y);
4     if (faX != faY) fa[faY] = faX;
5 }

 

  拓扑排序:

  在刷题的过程中也遇到过这种类型的题目,题目要求我们输出拓扑排序的结果。如果明白输出的结果都是入度为0的结点的话,用代码实现起来也不是太难。

  代码实现:

 1 bool TopologicalSort(Grap G) {
 2     InitStack(S);
 3     for (int i = 0; i < G.vexnum; ++i) {
 4         if (indegree[i] == 0)
 5             Push(S, i);
 6     }
 7     int count = 0;
 8     while (!IsEmpty(S)) {
 9         Pop(S, i);
10         print[count++] = i;
11         for (p = G.vertices[i].firstarc; p; p = p->nextarc) {
12             v = p->adjvex;
13             if (!(--indegree[v]))
14                 Push(S, v);
15         }
16     }
17     if (count < G.vexnum) 
18         return false;
19     else 
20         return true;
21 }

2020-07-06 21:27:25

 

posted @ 2020-06-27 16:47  Veritas_des_Liberty  阅读(375)  评论(0编辑  收藏  举报