【数据结构与算法】2 - 6 图

§2-6 图

下文内容部分参考、引用自

《数据结构教程(第 6 版)》李春葆 著

2-6.1 图的概念与术语

定义:图(graph)由顶点集 \(V\)(Vertex)和边集 \(E\)(Edge)组成,记为 \(G = (V,E)\)。点集 \(V\) 是顶点的有限集合,记为 \(V(G)\),边集 \(E\) 是连接 \(V\) 中两个不同顶点的边的有限集合,记为 \(E(G)\)

简而言之,图是一种由点集和边集构成的典型非线性结构。图必须至少有一个顶点,可以没有边。树是图的一个特例。而图又分为两种:

  • 在图 \(G\) 中,若表示边的顶点(序偶)是有序的,则称 \(G\)有向图(digraph),该序偶使用一对尖括号包围起来,用于表示一条有向边,且序偶顺序不能改变。\(<i,j>\) 表示从顶点 \(i\) 指向顶点 \(j\) 的一条有向边,与 \(<j,i>\) 是两条不同的边。
  • 在图 \(G\) 中,若表示边的顶点(序偶)是无序的,则称 \(G\)无向图(undigraph),该序偶使用一对圆括号包围起来,用于表示一条无向边,序偶顺序可以改变。\((i,j)\)\((j,i)\) 都表示同一条由 \(i\)\(j\) (反之亦然)的无向边。

介绍图的算法前,有必要先了解有关图的基本术语。

  • 端点和邻接点:在一张无向图中,若存在一条边 \((i,j)\),则称顶点 \(i,j\) 为该边的两个端点(endpoint),且互为邻接点(adjacent)。在一张有向图中,若存在一条有向边 \(<i,j>\)(弧),则称此边为顶点 \(i\) 的一条出边、顶点 \(j\) 的一条入边,\(i\) 为该边的起始端点(起点),\(j\) 为该边的终止端点(终点),

  • 顶点的度、入度和出度:在一张无向图中,一个顶点所关联的边的数目称为该顶点的(degree)。而在有向图中,一个顶点的度又分为入度和出度,以顶点 \(i\) 为终点的边的数目称为 \(i\)入度(indegree),以该顶点为起点的边的数目称为该顶点的出度(outdegree)。

    一张图中有 \(n\) 个顶点和 \(e\) 条边,每个顶点的度为 \(d_i(0 \leq d_i \leq n-1)\),则有关系

    \[e = \frac{1}{2} \sum_{i=0}^{n-1} d_i \]

    该关系可用于计算顶点数量和边的数量。

  • 完全图(complete graph):若无向图中每两个顶点间都存在一条边,有向图中的每两个顶点之间都存在两条方向相反的边,在称这样的图为完全图。一张无向完全图具有 \(\text{C}_n^2 = \frac{n(n-1)}{2}\) 条边,一张有向完全图具有 \(\text{A}_n^2 = n(n-1)\) 条边。

    该关系可用于计算非连通图中的最少顶点问题,这样的问题意味着边要尽可能地多,而完全图的边数是最多的。

  • 稠密图和稀疏图:当一张图接近完全图时,称为稠密图(dense graph)。当一张图含有较少边数(\(e < n\log_2 n\))时,称为稀疏图(sparse graph)。

    稠密图适合邻接矩阵存储,而稀疏图适用邻接表存储。

  • 子图(subgraph):当一张图 \(G'\) 的顶点集和边集都是另一张图 \(G\) 的顶点集和边集的子集时,即 \(V' \subseteq V, E' \subseteq E\) 时,\(G'\)\(G\) 的子图。

    子图一定是个图。但是,一张图的顶点集的任一子集与其边集的任一子集不一定能够构成一张图,因为这样的子集不一定能够将边和顶点一一对应。

  • 路径和路径长度:在一张图 \(G=(V,E)\) 中,从顶点 \(i\)\(j\) 的一条路径(path)是一个顶点序列。路径长度(path length)则为这条路径上经过的边的数目

    若一条路径上除起点和终点可以相同外,其余顶点均不相同,则称此路径为简单路径(simple path)。

  • 回路或环:若一条路径上的起点与终点相同,则称此路径为回路(cycle)。起点与终点相同的简单路径称为简单回路或简单环(simple cycle)。

    一个简单环至少要有 3 个顶点。

    当一张图的顶点数为 \(n\),边数为 \(n-1\) 时,这张图就退化成一棵树。在一座有 \(N\) 个顶点、\(K\) 条边(\(N > K\))的森林 \(F\) 中,必有 \(N-K\) 棵树。

  • 连通、连通图与连通分量:在无向图 \(G\) 中,若顶点 \(i\)\(j\) 有路径,称顶点 \(i\)\(j\)连通的(connected)。若 \(G\) 中任意两个顶点都是连通的,则称 \(G\)连通图(connected graph),否则为非连通图。\(G\) 的极大连通子图(不能够在不破坏连通性的情况下添加邻接结点的无向图)称为 \(G\)连通分量(connected component)。

    连通图的连通分量只有一个连通分量,即它本身,而非连通图有多个连通分量。

  • 强连通图和强连通分量:在有向图 \(G\) 中,若顶点 \(i\)\(j\) 有路径,称顶点 \(i\)\(j\)连通的(connected)。若 \(G\) 中任意两个顶点都是连通的,即任意两个顶点之间存在双向的通路,则称 \(G\)强连通图(strongly connected graph),否则为非强连通图。\(G\) 的极大强连通子图(不能够在不破坏连通性的情况下添加邻接结点的有向图)称为 \(G\)强连通分量(strongly connected component)。

    显然,强连通图只有一个强连通分量,即它本身,而非强连通图有多个强连通分量。

    强连通分量的每一个顶点必须有 1 个入度和 1 个出度。

    在一张非强连通图中找到强连通分量的方法为:

    1. 在图中找到有向环;
    2. 扩展该环:若某个顶点到该环中的任一顶点有路径,且该环中的任一顶点到该顶点也存在路径,将该顶点加入环中。
  • 权和网:边上带有权值的图称为带权图(weighted graph),也称为(net)。

2-6.2 图的存储结构

图的两种常用的存储结构为邻接矩阵和邻接表。

2-6.2.1 邻接矩阵(adjacency matrix)

邻接矩阵是一种采用邻接矩阵数组表示顶点之间相邻关系的存储结构。图 \(G=(V,E)\) 是一张含有 \(n(n>0)\) 个顶点,顶点编号为 \(0 \sim n-1\) 的图,则 \(G\) 的邻接矩阵数组是一个 \(n\) 阶方阵,定义为:

  1. \(G\) 为不带权无向图,则:

    \[A[i][j] = \begin{cases} 1, & \text{if } (i,j) \in E(G) \\ 0, & \text{else} \end{cases} \]

  2. \(G\) 为不带权有向图,则:

    \[A[i][j] = \begin{cases} 1, & \text{if } <i,j> \in E(G) \\ 0, & \text{else} \end{cases} \]

  3. \(G\) 为带权无向图,则:

    \[A[i][j] = \begin{cases} \omega_{ij}, & \text{if } i \neq j \text{ and } (i,j) \in E(G) \text{ where } \omega_{ij} \text{ is the weight of the edge} \\ 0, & i = j \\ \infin, & \text{else} \end{cases} \]

  4. \(G\) 为带权有向图,则:

    \[A[i][j] = \begin{cases} \omega_{ij}, & \text{if } i \neq j \text{ and } <i,j> \in E(G) \text{ where } \omega_{ij} \text{ is the weight of the edge} \\ 0, & i = j \\ \infin, & \text{else} \end{cases} \]

邻接矩阵具有以下特点:

  1. 邻接矩阵表示唯一;
  2. 空间复杂度为 \(O(n^2)\),占用空间与边数无关;
  3. 无向图的邻接矩阵是对称矩阵,可以压缩存储;
  4. 无向图中第 \(i\) 行(或第 \(i\) 列)非零元素、非 \(\infin\) 元素个数为顶点 \(i\) 的度;
  5. 有向图中第 \(i\) 行(或第 \(i\) 列)非零元素、非 \(\infin\) 元素个数为顶点 \(i\) 的出度(或入度);
  6. 使用邻接矩阵判断图中两顶点是否邻接或求边权的时间复杂度为 \(O(1)\),适用于提取边权值的算法。
// 一个邻接矩阵示例
typedef struct AdjacentMatrix
{
    int** matrix;			// 邻接矩阵
    InfoType* vertices;		// 顶点信息
    int edgeCount;			// 边数量
    int vertixCount;		// 顶点数量
} MatrixGraph;

// 适用于邻接矩阵的图的算法

2-6.2.2 邻接表(adjacency list)

邻接表是一种结合顺序与链式存储的存储方法。

对于一张含有 \(n\) 个顶点的图,每个结点都需要建立一张单链表,每张单链表中的结点记录关联该顶点(以该顶点为起点)的边,称为边结点。同时还需要有一个长度为 \(n\) 的头结点数组,记录顶点的信息和该顶点的单链表。

对于无向图而言,边结点个数恰好是顶点数的两倍。而对于有向图,边数等于边结点个数。

// 一个邻接表存储结构示例
// 边结点
struct Edge
{
    int adjacentVertex;			// 该边的终点
    int weight;					// 边权
    struct Edge* next;			// 下一条邻接边
};

// 顶点结点
struct Vertex
{
    int no;						// 顶点编号
    InfoType info;				// 顶点信息
    struct Edge* edges;			// 关联边单链表
};

// 邻接表
typedef struct AdjacentList
{
    struct Vertex* vertices;	// 顶点数组
    int vertexCount;			// 顶点数量
    int edgeCount;				// 边数量
} ListGraph;

2-6.2.3 其他存储方式

除了上述的两种常用的存储方式之外,还可以使用十字链表(orthogonal list)、邻接多重表(adjacency multi-list)存储图。

2-6.3 图的基本运算

图的基本运算包括创建图、销毁图、输出图。这里假设顶点信息数据类型为整型。

2-6.3.1 使用邻接矩阵实现

创建图

// 创建图
MatrixGraph* createGraph(int** matrix, int* vertices, int edgeCount, int vertixCount) {
    // 检查参数合法性
    if (!matrix || !vertices || vertixCount <= 0 || edgeCount < 0)
        return NULL;

    MatrixGraph* graph = (MatrixGraph*)malloc(sizeof(MatrixGraph));
    if (graph) {
        graph->edgeCount = edgeCount;
        graph->vertixCount = vertixCount;

        graph->vertices = (int*)malloc(vertixCount * sizeof(int));
        for (int i = 0; i < vertixCount; i++)
            graph->vertices[i] = vertices[i];
        
        graph->matrix = (int**)malloc(vertixCount * sizeof(int*));
        for (int i = 0; i < vertixCount; i++) {
            graph->matrix[i] = (int*)malloc(vertixCount * sizeof(int));

            for (int j = 0; j < vertixCount; j++) 
                graph->matrix[i][j] = matrix[i][j];
        }
    }

    return graph;
}

输出图

// 输出图
void printGraph(MatrixGraph* graph) {
    if (!graph)
        return;

    int vertixCount = graph->vertixCount;

    printf("邻接矩阵:\n");
    for (int i = 0; i < vertixCount; i++) {
        printf("[");

        for (int j = 0; j < vertixCount; j++) {
            if (graph->matrix[i][j] == INF)
                printf("INF");
            else
                printf("%d", graph->matrix[i][j]);
            
            if (j != vertixCount - 1)
                printf(", ");
        }

        printf("]\n");
    }

    printf("顶点信息:\n");
    for (int i = 0; i < vertixCount; i++) 
        printf("%d:%d\n", i, graph->vertices[i]);

    return;
}

销毁图

// 销毁图
void destroyGraph(MatrixGraph*& graph) {
    if (!graph)
        return;

    free(graph->vertices);

    for (int i = 0; i < graph->vertixCount; i++) {
        free(graph->matrix[i]);
        graph->matrix[i] = NULL;
    }
    
    free(graph->matrix);
    free(graph);
    graph = NULL;
}

2-6.3.2 使用邻接表实现

创建图

// 创建图
ListGraph* createGraph(int** matrix, int* vertices, int edgeCount, int vertexCount) {
    // 检查参数合法性
    if (!matrix || !vertices || vertexCount <= 0 || edgeCount < 0)
        return NULL;

    // 初始化
    ListGraph* graph = (ListGraph*)malloc(sizeof(ListGraph));
    if (graph) {
        // 边和顶点数目
        graph->edgeCount = edgeCount;
        graph->vertexCount = vertexCount;

        // 初始化顶点数组
        graph->vertices = (struct Vertex*)malloc(vertexCount * sizeof(struct Vertex));
        for (int i = 0; i < vertexCount; i++) {
            graph->vertices[i].data = vertices[i];
            graph->vertices[i].edges = NULL;
            struct Edge* edges = graph->vertices[i].edges;

            // 初始化关联边
            for (int j = 0; j < vertexCount; j++) {
                if (matrix[i][j] != 0 && matrix[i][j] < INF) {
                    struct Edge* edge = (struct Edge*)malloc(sizeof(struct Edge));
                    edge->endpoint = j;
                    edge->weight = matrix[i][j];
                    
                    if (edges) {
                        edge->next = edges;
                        edges = edge;
                    } else {
                        edge->next = NULL;
                        edges = edge;
                    }
                }
            }
        }
    }

    return graph;
}

输出图

// 输出图
void printGraph(ListGraph* graph) {
    if (!graph)
        return;
    
    for (int i = 0; i < graph->vertexCount; i++) {
        printf("%d: ", graph->vertices[i].data);

        struct Edge* edge = graph->vertices[i].edges;
        while (edge) {
            printf("%d[%d] - > ", graph->vertices[edge->endpoint].data, edge->weight);
            edge = edge->next;
        }
        
        printf("^\n");
    }
}

销毁图

// 销毁图
void destroyGraph(ListGraph*& graph) {
    if (!graph)
        return;

    // 销毁单链表
    for (int i = 0; i < graph->vertexCount; i++) {
        struct Edge* edges = graph->vertices[i].edges;

        while (edges) {
            struct Edge* temp = edges;
            edges = temp->next;

            free(temp);
        }
    }

    free(graph->vertices);
    free(graph);
    graph = NULL;
}

2-6.4 图的遍历方式

图的遍历方式有两种:广度优先遍历和深度优先遍历。图的遍历概念同树的遍历,但遍历实现要更复杂些。考虑到图中每个顶点之间存在多条路径,为防止重复访问,应当使用一个数组记录已访问顶点。

使用邻接矩阵的实现比较简单,这里提供使用邻接表的实现。

2-6.4.1 深度优先遍历

深度优先遍历可使用栈这一数据结构实现,考虑到函数调用本身也使用了栈这一数据结构,深度优先遍历可以使用递归方式实现。

邻接矩阵实现

// 深度优先遍历
void DFS(MatrixGraph* graph, int* visited, int start) {
    // 检查参数合法性
    if (!graph || !visited || start < 0 || start >= graph->vertexCount)
        return;

    // 访问自己
    if (visited[start] == 0) {
        printf("%d ", graph->vertices[start]);
        visited[start] = 1;

        // 递归访问邻接点
        for (int i = 0; i < graph->vertexCount; i++) {
            if (graph->matrix[start][i] != 0 && graph->matrix[start][i] < INF) {
                DFS(graph, visited, i);
            }
        }
    }
}

邻接表实现

// 深度优先遍历
void DFS(ListGraph* graph, int* visited, int start) {
    // 检查参数合法性
    if (!graph || !visited || start < 0 || start >= graph->vertexCount)
        return;

    // 访问自己
    if (visited[start] == 0) {
        printf("%d ", graph->vertices[start].data);
        visited[start] = 1;

        // 递归访问邻接点
        struct Edge* edge = graph->vertices[start].edges;
        while (edge) {
            DFS(graph, visited, edge->endpoint);
            edge = edge->next;
        }
        
    }
}

2-6.4.2 广度优先遍历

邻接表的广度优先遍历更加简单。广度优先遍历不需要栈这一结构,而是需要使用队列实现。示例采用一个十分简单的顺序队列实现。

邻接矩阵实现

// 广度优先遍历
void BFS(MatrixGraph* graph, int start) {
    // 检查参数合法性
    if (!graph || start < 0 || start >= graph->vertexCount)
        return;

    // 访问记录:0 - 为入列;1 - 已访问;-1 入列但未访问
    int* visited = (int*)malloc(graph->vertexCount * sizeof(int));
    for (int i = 0; i < graph->vertexCount; i++) {
        visited[i] = 0;
    }

    // 初始化顺序队列
    int* queue = (int*)malloc(graph->vertexCount * sizeof(int));
    for (int i = 0; i < graph->vertexCount; i++) {
        queue[i] = -1;
    }
    int front = -1;
    int rear = 0;

    // 起点入列
    queue[rear++] = start;
    front = 0;
    visited[start] == -1;

    // 队列不为空时继续
    while (rear != front) {
        // 出列
        int out = queue[front++];

        // 访问自己
        if (visited[out] == -1) {
            printf("%d ", graph->vertices[out]);
            visited[out] = 1;

            for (int i = 0; i < graph->vertexCount; i++) {
                // 入列未访问顶点
                if (graph->matrix[out][i] != 0 && graph->matrix[out][i] < INF && visited[i] == 0) {
                    queue[rear++] = i;
                    visited[i] = -1;
                }
            }
        }
    }

    // 释放分配的空间
    free(visited);
    free(queue);
}

邻接表实现

// 广度优先遍历
void BFS(ListGraph* graph, int start) {
    // 检查参数合法性
    if (!graph || start < 0 || start >= graph->vertexCount) 
        return;

    // 访问记录:0 - 为入列;1 - 已访问;-1 入列但未访问
    int* visited = (int*)malloc(graph->vertexCount * sizeof(int));
    for (int i = 0; i < graph->vertexCount; i++) {
        visited[i] = 0;
    }

    // 初始化队列
    int* queue = (int*)malloc(graph->vertexCount * sizeof(int));
    for (int i = 0; i < graph->vertexCount; i++) {
        queue[i] = -1;
    }
    int front = -1;
    int rear = 0;

    // 起点入列
    queue[rear++] = start;
    front = 0;
    visited[start] = -1;

    // 队列不为空时继续
    while (front != rear) {
        // 出列
        int out = queue[front++];

        // 访问自己
        if (visited[out] == -1) {
            printf("%d ", graph->vertices[out].data);
            visited[out] = 1;

            // 入列未访问顶点
            struct Edge* edge = graph->vertices[out].edges;
            while (edge)
            {
                if (visited[edge->endpoint] == 0) {
                    visited[edge->endpoint] = -1;
                    queue[rear++] = edge->endpoint;
                }

                edge = edge->next;
            }
            
        }
    }

    // 释放分配空间
    free(visited);
    free(queue);
}

2-6.4.3 遍历非连通图

非连通图中具有多个连通分量,每一次遍历只能够将图中的其中一个连通分量的所有顶点遍历完,而无法遍历到处于该连通分量之外的顶点。因此,遍历非连通图时,需要在每个连通分量中选择一个顶点开始遍历。

实现时,只需要从每个顶点开始都尝试调用上述遍历方法遍历一次,直到所有的顶点都遍历完毕即可。

2-6.4.5 遍历算法的应用

深度优先遍历:判断两顶点间是否存在一条简单路径(可扩展至求所有简单路径)、通过某顶点的所有简单回路(带有回溯功能的深度优先搜索)。

广度优先遍历:不带权连通图的两点最短路径。

2-6.5 生成树和最小生成树

生成树(spanning tree):一张连通图生成树是一个极小连通子图,其中包含图中的全部顶点和构成一棵树的 \(n-1\) 条边。

生成树由无向图生成。一棵生成树必须包含所有顶点,其路径不含有环。往生成树上添加任何一条边都会形成一个环。同一张图可以形成多棵不同生成树,但树的结点个数 \(n\) 和边数 \(n-1\) 始终保持不变。

最小生成树(minimal spanning tree, MST):图的所有生成树中,边上权值最小的树称为最小生成树

构造最小生成树的规则:

  1. 必须只使用图中的边构造最小生成树;
  2. 必须使用且仅使用 \(n-1\) 条边连接图中的 \(n\) 个顶点;
  3. 不可使用产生环的边。

应用:城市之间的交通工程造价问题。求解图的最小生成树算法有 Prim 算法和 Kruskal 算法。

使用深度优先遍历得到的生成树称为深度优先生成树(DFS tree),使用广度优先遍历得到的生成树称为广度优先生成树(BFS tree)。一般地,深度优先生成树高度较高(层次较深),广度优先生成树高度较低(层次较浅)。

对于一张非连通图,需要通过多次遍历才能遍历完所有顶点。每个连通分量中的顶点集和遍历时走过的边构成一棵生成树,每个连通分量的生成树构成非连通图的一座生成森林(spanning forest)。

2-6.5.1 Prim 算法

Prim 算法得名于其提出者 Robert Clay Prim (1921~2021)。

Prim 算法的思想是将连通图 \(G\) 的顶点集 \(V\) 划分为两个集合 \(U\)\(V-U\),其中 \(U\) 表示生成树所含有的顶点。算法每一次都从 \(U\)\(V- U\) 之间的边中选择一条权值最小的边,将该边中位于 \(V-U\) 集合的顶点加入到生成树的顶点集 \(U\) 中,此时,这条边就并入到了生成树的边集中。不断重复,直至图中所有的顶点都加入到生成树为止。

设生成树的边集为 \(TE\),具体步骤如下:

  1. 初始令 \(U = \{ u_0\}(u_0 \in V)\)\(TE = \{\}\)
  2. 在所有满足 \(u \in V, v \in V- U\) 的边中,找一条权值最小的边 \((u_0, v_0)\)
  3. 将边 \((u_0, v_0)\) 并入 \(TE\) 中,同时将 \(v_0\) 并入 \(U\) 中;
  4. 重复上述操作直至 \(U = V\) 为止,此时,\(T = (V, TE)\) 即为 \(G\) 的最小生成树。

最小生成树实际上就是在考虑权值关系,为方便起见,这里使用邻接矩阵实现。

// Prim 算法
void primMST(AdjacentMatrix* graph, int start) {
    // 检查参数合法性
    if (!graph || start < 0 || start >= graph->vertexCount) 
        return;

    // 生成树数据
    int* mst = (int*)malloc(graph->vertexCount * sizeof(int));      // 存储生成树结点对应父结点
    int* lowCost = (int*)malloc(graph->vertexCount * sizeof(int));  // 生成树点集 U 与 V-U 关联边,树中结点权视为 0
    // 初始化:根结点的父结点为 -1
    for (int i = 0; i < graph->vertexCount; i++) {
        mst[i] = -1;
        lowCost[i] = graph->matrix[start][i];
    }
    int current = start;                                            // 当前树结点

    // 往生成树中添加剩余 n 个顶点
    for (int i = 1; i < graph->vertexCount; i++) {
        int minWeight = INF;        // 最小关联边
        int minIndex = -1;          // 关联边终点
        // 找到最小关联边及其终点
        for (int j = 0; j < graph->vertexCount; j++) {
            if (minWeight > lowCost[j] && lowCost[j] != 0) {
                minWeight = lowCost[j];
                minIndex = j;
            }
        }

        // 将新的顶点存入树中
        mst[minIndex] = current;
        current = minIndex;
        lowCost[minIndex] = 0;

        // 调整关联边
        for (int j = 0; j < graph->vertexCount; j++) {
            if (lowCost[j] != 0 && graph->matrix[minIndex][j] < lowCost[j]) {
                lowCost[j] = graph->matrix[minIndex][j];
            }
        }

        // 输出一条边
        printf("边(%d, %d)权为:%d\n", mst[minIndex], minIndex, graph->matrix[minIndex][mst[minIndex]]);
    }

    // 释放分配空间
    free(mst);
    free(lowCost);
}

Prim 算法的时间复杂度为 \(O(n^2)\)\(n\) 为图中顶点个数。Prim 算法的执行时间与图的边数 \(e\) 无关,因此 Prim 算法适用于稠密图求最小生成树。

2-6.5.2 Kruskal 算法

Kruskal 算法也得名于其提出者 Joseph Bernard Kruskal (1928~2010)。

Kruskal 算法的思想是按权值的递增顺序选择合适的边构造最小生成树。对于一张具有\(n\) 个顶点的无向带权图 \(G = (V, E)\),设 \(T = (V, TE)\)\(G\) 的最小生成树。算法先将所有顶点纳入 \(T\) 中,且置 \(TE\) 为空集。然后,算法将 \(G\) 中的边按权值大小的升序顺序依次选取,若选取的边不能使得树构成回路,则将该边加入 \(TE\),否则将其舍弃,直到 \(TE\) 中包含 \(n-1\) 条边为止。

Kruskal 算法的步骤如下:

  1. 将图中所有的边按升序排序,得到边集;
  2. 构建一个生成树并查集,集合包含图中所有的顶点,每个顶点互不相连;
  3. 每次从边集选取一条最小边,在不构成环的情况下将边加入生成树(并查集合并起点和终点);
  4. 重复 3 直至树中存在 \(n-1\) 条边为止。

同样地,为方便起见,这里采用邻接矩阵实现。考虑到需要按照权值大小选择边,这里需要设计用于存储边的数据结构以及对应的排序算法,这里采用堆排序(实现略)。此外,这里使用并查集存储生成树结点,对应实现略。

// 边数据
struct Edge
{
    int start;		// 起点
    int end;		// 终点
    int weight;		// 权
};

void kruskalMST(AdjacentMatrix* graph, int start) {
    // 检查参数合法性
    if (!graph || start < 0 || start >= graph->vertexCount)
        return;

    // 获取边集
    struct Edge* edges = (struct Edge*)malloc(graph->edgeCount * sizeof(struct Edge));
    int currentTreeEdge = 0;                // 当前正在获取的边
    for (int i = 0; i < graph->vertexCount; i++) {
        for (int j = 0; j < i; j++) {
            if (graph->matrix[i][j] != 0 && graph->matrix[i][j] < INF) {
                edges[currentTreeEdge].start = i;
                edges[currentTreeEdge].end = j;
                edges[currentTreeEdge].weight = graph->matrix[i][j];

                currentTreeEdge++;
            }
        }
    }
    // 堆排序边集
    heapSort(edges, graph->edgeCount);

    // 初始化生成树并查集
    DisjointSet* mst = initSet(graph->vertexCount);

    currentTreeEdge = 0;                    // 当前正在构造的生成树边
    int edge = 0;                           // 当前遍历的边集的边
    while (currentTreeEdge < graph->vertexCount - 1)
    {
        int start = edges[edge].start;      // 当前侯选边的起点
        int end = edges[edge].end;          // 当前侯选边的终点
        
        int startSet = find(mst, start);    // 查找起点所属集合
        int endSet = find(mst, end);        // 查找终点所属集合

        if (startSet != endSet) {           // 二者不同属,则候选边不会构成环
            // 输出这条边
            printf("(%d, %d): %d\n", start, end, edges[edge].weight);

            // 准备构造下一条边
            currentTreeEdge++;

            // 合并终点与起点到 MST 集合中
            merge(mst, start, end);
        }

        edge++;                             // 遍历下一条边
    }
    

    // 释放分配空间
    free(edges);
    destroySet(mst);
}

不考虑生成边集数组及排序过程,Kruskal 算法的时间复杂度为 \(O(n\log e)\),其中 \(e\) 为图的边数,算法的执行时间仅与边数有关,而与顶点数无关,因此适用于稀疏图求解最小生成树。

2-6.6 最短路径

最短路径(shortest path)是从一个顶点到另一个顶点的长度最短的路径,其长度称为最短路径长度或最短距离。

对于带权图,考虑路径上每一条边的权值,将一条路径上所经过边的权值之和定义为带权图的路径长度。使得该路径长度最短的路径称为最短路径,长度为最短路径长度或最短距离。

求解最短路径的算法有两种,Dijkstra 算法和 Floyd 算法,分别用于求解两种不同的最短路径问题:单源最短路径问题(从一个顶点到其余所有顶点的最短路径)和多源最短路径问题(每个顶点到其余各顶点的最短路径)。

2-6.6.1 Dijkstra 算法:单源最短路径

Dijkstra 算法得名于其提出者 Edsger Wybe Dijkstra (1930~2002)。

Dijkstra 算法的思想是将一张有向带权图 \(G=(V,E)\) 中顶点的集合 \(V\) 分为两组:已经求解出最短带权路径的顶点集 \(S\) 和尚未确定最短路径的顶点集 \(U\)。算法按照最短长度的递增次序依次把 \(U\) 中顶点加入到 \(S\) 中,直至 \(S = V\) 为止。

Dijkstra 算法的步骤如下:

  1. 初始时 \(S\) 只包含起始点 \(S = \{ v\}\),起始点距自己为 0。\(U\) 包含处起始点之外的所有点。\(v\) 到达 \(U\) 任一顶点 \(i\) 的最短路径长度为边上权值;
  2. \(U\) 中选取一个顶点 \(u\),使得 \(v \Rightarrow u\) 的最短路径长度最小,然后把顶点 \(u\) 添加到 \(S\) 中;
  3. 调整路径:以 \(u\) 作为中间点,考虑起始点 \(v\)\(U\) 中所有顶点的最短路径长度,将最短路径调整为从起始点经过和不经过 \(u\) 的路径长度的最小值;
  4. 重复 2, 3,直至 \(S\) 包含所有顶点,即 \(S = V\)

为方便起见,仍然采用邻接矩阵实现。

/ Dijkstra 算法
void dijkstra(AdjacentMatrix* graph, int start) {
    // 检查参数合法性
    if (!graph || start < 0 || start >= graph->vertexCount)
        return;

    int* distance = (int*)malloc(graph->vertexCount * sizeof(int));      // 集合 S 和 U 之间的距离
    int* path = (int*)malloc(graph->vertexCount * sizeof(int));          // 最短路径记录,记录其路径前驱
    int* dijkstraSet = (int*)malloc(graph->vertexCount * sizeof(int));   // 集合 S

    // 初始化上述内容
    for (int i = 0; i < graph->vertexCount; i++) {
        distance[i] = graph->matrix[start][i];      // 距离初始化
        dijkstraSet[i] = 0;                         // 置空集合 S

        if (graph->matrix[start][i] < INF) {        // 路径初始化
            path[i] = start;                        // 置邻接边终点的最短路径前驱
        } else {
            path[i] = -1;                           // 不存在邻接边,路径不存在
        }
    }

    // 将起始点(源点)添加到集合 S 中
    dijkstraSet[start] = 1;
    path[start] = start;

    // 循环求剩余顶点路径
    for (int i = 0; i < graph->vertexCount - 1; i++) {
        int minDistance = INF;                          // 初始化最短距离为最大值
        int end = -1;                                   // 最短路径边终点
        for (int j = 0; j < graph->vertexCount; j++) {  // 选取不在集合 S 中顶点(选择最短距离)
            if (dijkstraSet[j] == 0 && minDistance > distance[j]) {
                minDistance = distance[j];
                end = j;
            }
        }

        // 添加所找到的终点到集合 S 中
        dijkstraSet[end] = 1;
        
        // 调整路径:考虑中转点到集合 U 的距离
        for (int j = 0; j < graph->vertexCount; j++) {
            // 保证具有邻接关系
            if (dijkstraSet[j] == 0) {
                if (graph->matrix[end][j] < INF && distance[end] + graph->matrix[end][j] < distance[j]) {
                distance[j] = distance[end] + graph->matrix[end][j];
                path[j] = end;
            }
            }
        }
    }

    // 输出路径
    printPath(path, distance, graph->vertexCount, start);

    // 释放分配空间
    free(distance);
    free(path);
    free(dijkstraSet);
}

// 输出单源最短路径
void printPath(int* path, int* distance, int vertexCount, int start) {
    // 检查参数合法性
    if (!path || !distance || vertexCount <= 0) 
        return;

    // 用于存储路径的数组
    int* reversedPath = (int*)malloc(vertexCount * sizeof(int));
    for (int i = 0; i < vertexCount; i++) {
        reversedPath[i] = 0;
    }
    int length = 0;         // 路径经过的边的个数
    int scanner = 0;        // 源路径扫描器(扫描位置)

    // 遍历每个顶点
    for (int i = 0; i < vertexCount; i++) {
        printf("从 %d 到 %d 的路径长度为 %d, 路径为: ", start, i, distance[i]);

        length = 0;                 // 重置路径数据
        reversedPath[length] = i;   // 添加路径终点

        scanner = path[i];
        if (scanner == -1) 
            printf("无路径\n");
        else {
            // 反向写入路径
            while (scanner != start) {
                length++;
                reversedPath[length] = scanner;
                scanner = path[scanner];
            }

            // 添加路径起点
            length++;
            reversedPath[length] = start;

            // 先输出起点
            printf("%d", reversedPath[length]);

            // 打印路径
            for (int j = length - 1; j >= 0; j--) 
                printf(", %d", reversedPath[j]);
            printf("\n");
        }
    }

    // 释放空间
    free(reversedPath);
}

不考虑路径输出,Dijkstra 算法的时间复杂度为 \(O(n^2)\),得到的结果是单源全局最优解。

2-6.6.2 Floyd 算法:多源最短路径

Floyd 算法得名于其提出者 Robert W. Floyd (1936~2001)。

Floyd 算法是一种贪心 + 动态规划的算法。算法轮流将每个顶点视作中转点,通过不断地迭代权值矩阵最终找到所有顶点到其余各顶点的最短路径和距离。其思想为:

\[A_{-1} [i][j] = \text{graph.matrix} [i][j] \\ A_k [i][j] = \min\{ A_{k-1} [i][j], A_{k-1} [i][k] + A_{k-1} [k][j] \} \]

上述迭代表达式,每迭代一次,\(i \Rightarrow j\) 的最短路径上就多考虑了一个顶点。迭代完成后,就能够得到最终解。

这里仍然使用邻接矩阵实现。

// 输出多源最短路径
void printPath(AdjacentMatrix* graph, int** distance, int** path) {
    // 检查参数合法性
    if (!graph || !distance || !path) 
        return;

    // 申请用于存储路径的数组
    int* reversedPath = (int*)malloc(graph->vertexCount * sizeof(int));
    for (int i = 0; i < graph->vertexCount; i++) 
        reversedPath[i] = 0;
    int length = 0;         // 路径结点数
    int scanner = 0;        // 扫描路径

    for (int i = 0; i < graph->vertexCount; i++) {
        for (int j = 0; j < graph->vertexCount; j++) {
            // 若 i -> j 存在路径
            if (distance[i][j] != INF && i != j) {
                printf("从 %d 到 %d 的路径为:", i, j);
                scanner = path[i][j];   // 扫描路径

                // 添加终点到路径
                length = 0;
                reversedPath[length] = j;

                // 无路径
                if (scanner == -1) {
                    printf("无路径\n");
                } else {
                    // 添加中间点
                    while (scanner != i) {
                        length++;
                        reversedPath[length] = scanner;

                        scanner = path[i][scanner];
                    }

                    // 添加起点
                    length++;
                    reversedPath[length] = i;

                    // 输出起点
                    printf("%d", reversedPath[length]);
                    // 输出路径
                    for (int s = length - 1; s >= 0; s--) {
                        printf(", %d", reversedPath[s]);
                    }
                    }
                    printf("\t路径长度为: %d\n", distance[i][j]);
            }
        }
    }

    // 释放空间
    free(reversedPath);
}

// Floyd 算法
void floyd(AdjacentMatrix* graph) {
    // 检查参数合法性
    if (!graph)
        return;

    // 申请空间存储距离和路径
    int** distance = (int**)malloc(graph->vertexCount * sizeof(int*));
    int** path = (int**)malloc(graph->vertexCount * sizeof(int));
    // 初始化
    for (int i = 0; i < graph->vertexCount; i++) {
        distance[i] = (int*)malloc(graph->vertexCount * sizeof(int));
        path[i] = (int*)malloc(graph->vertexCount * sizeof(int));

        for (int j = 0; j < graph->vertexCount; j++) {
            distance[i][j] = graph->matrix[i][j];           // 权值矩阵(距离矩阵)使用邻接矩阵初始化

            if (i != j && graph->matrix[i][j] < INF) {
                path[i][j] = i;                             // 顶点 i 到 j 相邻
            } else {
                path[i][j] = -1;                            // 不相邻
            }
        }
    }

    // 依次将所有顶点视作中转点考察最短路径
    for (int k = 0; k < graph->vertexCount; k++) {
        for (int i = 0; i < graph->vertexCount; i++) {
            for (int j = 0; j < graph->vertexCount; j++) {
                if (distance[i][j] > distance[i][k] + distance[k][j]) {
                    distance[i][j] = distance[i][k] + distance[k][j];   // 修改最短长度
                    path[i][j] = path[k][j];                            // 修改最短路径
                }
            }
        }
    }

    // 输出结果
    printPath(graph, distance, path);

    // 释放空间
    for (int i = 0; i < graph->vertexCount; i++) {
        free(distance[i]);
        free(path[i]);
    }
    free(distance);
    free(path);
}

Floyd 算法的时间复杂度为 \(O(n^3)\)

2-6.7 拓扑排序

一张具有 \(n\) 个顶点的有向图 \(G=(V,E)\) 中,\(V\) 的顶点序列 \(v_1, v_2, \cdots, v_n\) 称为一个拓扑序列(topological sequence)。拓扑序列中任意两个顶点间的位置不可互换,必须保证一定的相对次序。

在一张有向图中找到一个拓扑序列的过程称为拓扑排序(topological sort)。

拓扑排序的一般步骤:

  1. 从有向图中找到一个没有入度的结点,输出该结点;
  2. 从有向图中删去该结点以及从该顶点出发的所有有向边;
  3. 重复上述两个步骤,直至剩余的图中不存在无入度的顶点为止。

2-6.8 AOE 网与关键路径

使用一张有向无环图(directed acyclic graph)描述工程的预计进度。以顶点表示事件(event),有向边表示活动(activity),边 \(e\) 的权 \(c(e)\) 表示完成活动 \(e\) 所需时间或其持续时间,图中入度为 0 的顶点表示工程的开始事件(start event),出度为 0 的顶点称为工程的结束事件(end event)。这样的有向图称为边表示活动的网(Activity on edge network, AoE network)。通常每个工程有且只有一个开始事件和一个结束事件,分别称为源点(source)和汇点(converge)。若存在多个入度为 0 的顶点或出度为 0 的顶点,通常会再添加一个虚拟源点,使得这个虚拟源点到原来入度为 0 的定点有一条长度为零的边。同样地,对具有多个出度为 0 的顶点做类似处理。这样,就只需讨论单源点和单汇点的情况。

关键路径(critical path)指的是在 AoE 网中,从源点到汇点所有路径中具有最大路径长度的路径。完成整个工程的最短时间就是 AoE 网中关键路径的长度,或 AoE 网中一条关键路径上所有活动持续时间之和。若存在多条关键路径,它们的长度是相等的。

关键路径上的活动称为关键活动(key activity)。关键活动不存在富余时间,非关键活动存在富余时间。

求解关键路径,实际上就是求出活动的最早开始时间和最晚开始时间,二者作比较。

规定源点 \(x\) 的发生时间为 0。图中任一事件 \(v\)最早开始时间等于从 \(x\)\(v\) 所有路径长度的最大值:

\[\text{ve}(v) = \max_p \{c(p) \} \]

其中,\(\max\) 表示对所有从 \(x\)\(v\) 的路径取最大值,\(c(p)\) 表示路径 \(p\) 的长度,即路径上所有活动 \(a\) 的时间之和:

\[c(p) = \sum_{a \in p} \{ c(a) \} \]

完成整个工程所需最少时间即汇点 \(y\) 的最早开始时间 \(\text{ve}(y)\)。而从源点 \(x\) 到汇点 \(y\) 的最长路径就是关键路径,完成工程所需最短时间就是关键路径长度。

在不影响整个工程进度的前提下,事件 \(v\) 必须开始的时间称为最晚开始时间 \(\text{vl}(v)\),等于 \(\text{ve}(v)\)\(v\) 到汇点 \(y\) 的最长路径长度之差:

\[\text{vl}(v) = \text{ve}(y) - \max_p \{ c(p) \} \]

显然,对于汇点 \(y\),有 \(\text{ve}(y) = \text{vl}(y)\),对于源点 \(x\),有 \(\text{ve}(x) = \text{vl}(x) = 0\)

对于任一活动 \(a_i = <v, \omega>\),有

\[\begin{cases} \text{ve}(v) + c(a_i) < \text{vl}(\omega), & \text{non-key activity} \\ \text{ve}(v) + c(a_i) = \text{vl}(\omega), & \text{key activity} \end{cases} \]

求出关键路径,现需要拓扑排序得到所有拓扑序列。然后计算所有事件的最早和最晚开始时间,利用上式判断有关活动是否为关键活动,即可求出一条关键路径。

posted @ 2023-07-31 23:08  Zebt  阅读(109)  评论(0)    收藏  举报