chapter_7 图

图的概念

图(Graph)由顶点集合(Vertex)和边集合(Edge)构成,记作:G=(V, E)。
对于 n个顶点的图,对每个顶点连续编号,即顶点的编号为 0~n-1,通过编号唯一确定一个顶点。

无向图:如果代表边的顶点对是无序的,则称G为无向图,用圆括号序偶表示无向边,即 (vi,vj)。
有向图:如果表示边的顶点对是有序的,则称G为有向图,用尖括号序偶表示有向边,即 <vi,vj>。

邻接:若存在一条边 (i,j) 顶点i和顶点j为端点,它们互为邻接点。
若存在一条边 <i,j> 顶点i为起始端点(简称为起点),顶点j为终止端点(简称终点),它们互为邻接点,称vi邻接到vj, vj邻接于vi。
关联(依附):边/弧与顶点之间的关系,如:存在 (vi, vj) / <vi, vj>,则称该边/弧关联于vi和vj。

顶点的度:与该顶点相关联的边的数目,记为 TD(v)。
无向图中,顶点的度等于该顶点相关联的边数。
有向图中, 顶点的度等于该顶点的入度与出度之和。
顶点 v 的入度是以 v 为终点的有向边的条数, 记作 ID(v)。
顶点 v 的出度是以 v 为始点的有向边的条数, 记作 OD(v)。

当有向图中仅1个顶点的入度为0,其余顶点的入度均为1,此时是何形状?
答案:一棵有向树

完全图:任意两个点都有一条边相连。
完全无向图:每两个顶点之间都存在着一条边,包含有 C(n,2) = n(n-1)/2条边。
完全有向图:每两个顶点之间都存在着方向相反的两条边,包含有 2C(n, 2) = n(n-1)条边。

稀疏图:一个图中含有较少的边数时(如 e<nlogn),称为稀疏图(Sparse graph)。
稠密图:当一个图接近完全图时,称为稠密图(Dense graph)。
也有这样的定义:有很少条边或弧(边的条数|E|远小于|V|²)的图称为稀疏图,反之边的条数|E|接近|V|²,称为稠密图。

权和网
图中每一条边都可以附带有一个对应的数值,这种与边相关的数值称为权(Weight)。
权可以表示从一个顶点到另一个顶点的距离或花费的代价。
边上带有权的图称为带权图,也称作网(Network)。

路径和路径长度
在一个图G=(V,E)中,从顶点i到顶点j的一条路径(i,i1,i2,…,im,j)。
所有的(ix,iy)∈E(G),或者<ix,iy>∈E(G)。
路径长度是指一条路径上经过的边的数目。
若一条路径上除开始点和结束点可以相同外,其余顶点均不相同,则称此路径为简单路径。

设有两个图G=(V, E)和G'=(V', E'),若V'是V的子集,E'是E的子集,则称G'是G的子图。
思考:设有一个图G=(V, E),取V的子集V',E的子集E'。那么 (V', E')一定是G的子图吗?
答案:如果点集不包含边集的所有顶点,子图就不成立。

回路或环
若一条路径上的开始点与结束点为同一个顶点,则此路径被称为回路或环。
开始点与结束点相同的简单路径被称为简单回路或简单环。

连通、连通图和连通分量
无向图:若从顶点i到顶点j有路径,则称顶点i和j是连通的。
若图中任意两个顶点都连通,则称为连通图,否则称为非连通图。

极大连通子图:该子图是G的连通子图,将G的任何不在该子图中的顶点加入,子图不再连通。
极小连通子图:该子图是G 的连通子图,在该子图中删除任何一条边,子图不再连通。

无向图G中的极大连通子图称为G的连通分量。
显然,任何连通图的连通分量只有一个,即本身,而非连通图有多个连通分量。

强连通图和强连通分量
有向图:若从顶点i到顶点j有路径,则称从顶点i到j是连通的。
若图G中的任意两个顶点i和j都连通,即从顶点i到j和从顶点j到i都存在路径,则称图G是强连通图。

有向图G中的极大强连通子图称为G的强连通分量。
显然,强连通图只有一个强连通分量,即本身,非强连通图有多个强连通分量。

在一个非强连通中找强连通分量的方法:

  1. 在图中找有向环。
  2. 扩展该有向环:如果某个顶点到该环中任一顶点有路径,并且该环中任一顶点到这个顶点也有路径,则加入这个顶点。

生成树:包含图中全部顶点的极小连通子图。
生成森林:对非连通图,由各个连通分量的生成树的集合。

欧拉路:从一个点S出发,不重不漏的经过每条边(允许重复经过一个点),最终去到另一个点T,的一条路径。
欧拉回路:从一个点S出发,不重不漏的经过每条边(允许重复经过一个点),最终回到这个点S,的一条路径。
欧拉图:存在欧拉回路的无向图。
欧拉路判定条件:一个无向图存在欧拉路当且仅当该图是连通的且有且只有2个点的度数是奇数,此时这两个点只能作为欧拉路径的起点和终点。

图的存储结构

邻接矩阵是表示顶点之间相邻关系的矩阵。
设G=(V,E)是具有n(n>0)个顶点的图,顶点的编号依次为0~n-1。

G的邻接矩阵A是n阶方阵,其定义如下:
(1)如果G是无向图,则:A[i][j]=1:若(i,j)∈E(G),0:其他;
(2)如果G是有向图,则:A[i][j]=1:若<i,j>∈E(G),0:其他;
(3)如果G是带权无向图,则:A[i][j]= wij :若i≠j且(i,j)∈E(G),0:i=j,∞:其他;
(4)如果G是带权有向图,则:A[i][j]= wij :若i≠j且<i,j>∈E(G),0:i=j,∞:其他。

一个图的邻接矩阵表示是唯一的,存储空间为O(n^2),适合于稠密图的存储。

问题描述:给定5个节点,编号 0~4,以及之间的关系,构建一张无向图。
输入数据:(0,1) (0,3) (0,4) (1,2) (1,3) (2,3) (2,4) (3,4)

#include<stdio.h>
#define N 10001
int G[N][N], u,v,i,j;
int main(){
    while(~scanf("(%d,%d) ", &u, &v)){
        G[u][v] = G[v][u] = 1;
    }
    for(i=0; i<=4; i++){
        for(j=0; j<=4; j++) printf("%d ", G[i][j]);
        printf("\n");
    }
    return 0;
}

图的邻接表存储方法是一种顺序分配与链式分配相结合的存储方法。
对图中每个顶点i建立一个单链表,将顶点i的所有邻接点链起来。
每个单链表上添加一个表头结点(表示顶点信息),并将所有表头结点构成一个数组,下标为i的元素表示顶点i的表头结点。

邻接表表示不唯一,存储空间为O(n+e),适合于稀疏图存储。

问题描述:给定5个节点,编号 0~4,以及之间的关系,构建一张无向图。
输入数据:(0,1) (0,3) (0,4) (1,2) (1,3) (2,3) (2,4) (3,4)

#include<stdio.h>
#include<stdlib.h>
#define N 10001
typedef struct Node{
    int adjvex;
    struct Node* next;
}Node; // 构建单链表节点
struct T{
    Node *next;
}G[N]; // 构建邻接表

void pr(){ //按照领接表输出
    int i=0;
    for(i=0; i<=4; i++){
        Node* pre = G[i].next;
        printf("%d: ", i);
        while(pre!=NULL) {
            printf("%d ", pre->adjvex);
            pre = pre->next;
        }
        printf("\n");
    }
}
int main(){
    int u,v,i,j;
    for(i=0; i<=4; i++) G[i].next=NULL;
    while(~scanf("(%d,%d) ", &u, &v)){
       Node* node = (Node*)malloc(sizeof(Node));
       node->adjvex = v;
       node->next = G[u].next; //头插法
       G[u].next = node;

       Node* node2 = (Node*)malloc(sizeof(Node));
       node2->adjvex = u;
       node2->next = G[v].next;
       G[v].next = node2;
    }
    pr(); printf("\n");
    return 0;
}

思考:图的邻接矩阵和邻接表两种存储结构各有什么优缺点?

还有其他存图方式(了解即可,算法中一般使用链式前向星):
逆邻接表:就是在有向图的邻接表中,对每个顶点,链接的是指向该顶点的边。
十字链表:是有向图的另外一种链式存储结构,它是邻接表和逆邻接表的结合。
邻接多重表:是无向图的另外一种存储结构,与十字链表类似。

图的遍历

从给定图中任意指定的顶点(称为初始点)出发,按照某种搜索方法沿着图的边访问图中的所有顶点,使每个顶点仅被访问一次,这个过程称为图的遍历。
图的遍历得到的顶点序列称为图遍历序列。

  • 深度优先遍历(DFS)

(1)从图中某个初始顶点v出发,首先访问初始顶点v。
(2)选择一个与顶点v相邻且没被访问过的顶点w,再从w出发进行深度优先搜索,直到图中与当前顶点v邻接的所有顶点都被访问过为止。

深度优先遍历的过程体现出后进先出的特点:用栈或递归方式实现。

采用邻接表的DFS算法:该算法的时间复杂度为O(n+e)。

bool vis[N];
void dfs(int u){
    printf("%d ", u);
    vis[u] = 1;
    Node* pre = G[u].next;
    while(pre!=NULL){
        if(!vis[pre->adjvex]) dfs(pre->adjvex);
        pre = pre->next;
    }
}
  • 广度优先遍历(BFS)

(1)访问初始点v,接着访问v的所有未被访问过的邻接点v1,v2,…,vt。
(2)按照v1,v2,…,vt的次序,访问每一个顶点的所有未被访问过的邻接点。
(3)依次类推,直到图中所有和初始点v有路径相通的顶点都被访问过为止。

广度优先搜索遍历体现先进先出的特点,用队列实现。

采用邻接表的BFS算法:该算法的时间复杂度为O(n+e)。

int que[N];
void bfs(int u){
    int head=0, rear=-1;
    que[++rear] = u, vis[u] = 1; //入队标记
    while(head <= rear){
        Node* temp = G[que[head]].next;
        printf("%d ", que[head++]);
        while(temp!=NULL){
            if(!vis[temp->adjvex]) {
                que[++rear] = temp->adjvex;
                vis[temp->adjvex] = 1;
            }
            temp = temp->next;
        }
    }
}
  • 完整程序
#include<stdio.h>
#include<stdlib.h>
#define N 10001
typedef struct Node{
    int adjvex;
    struct Node* next;
}Node; // 构建单链表节点
struct T{
    Node *next;
}G[N]; // 构建邻接表

void pr(){
    int i=0;
    for(i=0; i<=4; i++){
        Node* pre = G[i].next;
        printf("%d: ", i);
        while(pre!=NULL) {
            printf("%d ", pre->adjvex);
            pre = pre->next;
        }
        printf("\n");
    }
}

bool vis[N];
void dfs(int u){
    printf("%d ", u);
    vis[u] = 1;
    Node* pre = G[u].next;
    while(pre!=NULL){
        if(!vis[pre->adjvex]) dfs(pre->adjvex);
        pre = pre->next;
    }
}

int que[N];
void bfs(int u){
    int head=0, rear=-1;
    que[++rear] = u, vis[u] = 1; //入队标记
    while(head <= rear){
        Node* temp = G[que[head]].next;
        printf("%d ", que[head++]);
        while(temp!=NULL){
            if(!vis[temp->adjvex]) {
                que[++rear] = temp->adjvex;
                vis[temp->adjvex] = 1;
            }
            temp = temp->next;
        }
    }
}
int main(){
    freopen("data.cpp", "r", stdin);
    int u,v,i,j;
    for(i=0; i<=4; i++) G[i].next=NULL;
    while(~scanf("(%d,%d) ", &u, &v)){
       Node* node = (Node*)malloc(sizeof(Node));
       node->adjvex = v;
       node->next = G[u].next; //头插法
       G[u].next = node;

       Node* node2 = (Node*)malloc(sizeof(Node));
       node2->adjvex = u;
       node2->next = G[v].next;
       G[v].next = node2;
    }
    pr(); printf("\n");
//    dfs(0); printf("\n");
    bfs(0); printf("\n");
    return 0;
}

无向连通图:调用一次DFS或BFS,能够访问到图中的所有顶点。
无向非连通图:调用一次DFS或BFS,只能访问到初始点所在连通分量中的所有顶点,不可能访问到其他连通分量中的顶点。
可以分别遍历每个连通分量,才能够访问到图中的所有顶点。

【例】无向图 G=(V E),其中
V = {a,b,c,d,e,f}
E = {(a,b),(a,e),(a,c),(b,e),(c,f),(f,d),(e,d)}
对该图进行深度优先排序,得到的顶点序列正确的是( )。

A. a,b,e,c,d,f
B. a,c,f,e,b,d
C. a,e,b,c,f,d
D. a,e,d,f,c,b

【分析】

从 a 开始,可以选择 b/c/e
a (b/c/e):
选 b,之后确定路径:a b e d f c
选 c,之后确定路径:a c f d e b
选 e,之后可以选择 b/d:
a e (b/d):
选 b,之后确定路径:a e b d f c
选 d,之后确定路径:a e d f c b

【例】若无向图G(V,E)中含7个顶点,则保证图G在任何情况下都是连通的,则需要的边数最少是( )。

A. 6
B. 15
C. 16
D. 21

【分析】
对于具有n个顶点的无向图,当其中n-1个顶点构成一个完全图时,再加上一条边(连接该完全图和另外一个顶点)必然构成一个连通图
所以本题中,若 6个顶点构成一个完全图,再加上一条边,这样的图无论如何都是一个连通图
最少边数=(n-1)(n-2)/2+1=16

【例】下列关于无向连通图特征的叙述中,正确的是( )。
I. 所有顶点的度之和为偶数
II. 边数大于顶点个数减1
III. 至少有一个顶点的度为1

【分析】
所有顶点的度之和 = 2e,为偶数, I正确。
无向连通图中,e≥n-1, II错误。
无向连通图中,可能存在度为1 的顶点, III 错误。

【例】以下关于图的存储结构的叙述中正确的是 。
A. 一个图的邻接矩阵表示唯一,邻接表表示唯一
B. 一个图的邻接矩阵表示唯一,邻接表表示可能不唯一
C. 一个图的邻接矩阵表示可能不唯一,邻接表表示唯一
D. 一个图的邻接矩阵表示可能不唯一,邻接表表示可能不唯一

【分析】
一个图的邻接矩阵表示唯一
邻接表表示可能不唯一(一个顶点相邻的所有顶点构成一个单链表,其中相邻顶点的节点顺序可以任意)

【例】以下关于图的存储结构的叙述中正确的是( )。
A. 邻接矩阵占用的存储空间大小只与图中顶点数有关,而与边数无关
B. 邻接矩阵占用的存储空间大小只与图中边数有关,而与顶点数无关
C. 邻接表占用的存储空间大小只与图中顶点数有关,而与边数无关
D. 邻接表占用的存储空间大小只与图中边数有关,而与顶点数无关

【分析】
无向图:用邻接矩阵存储时,占用的存储空间大小为O(n2);用邻接表存储时,占用的存储空间大小为O(n+2e)。
有向图:用邻接矩阵存储时,占用的存储空间大小为O(n2);用邻接表存储时,占用的存储空间大小为O(n+e)

【例】有一个含n个字符的数组a,所有元素均不相同,设计一个算法求其所有子集(幂集)。
例如,a[]={a,b,c},所有子集是:{},{c},{b},{b,c},{a},{a,c},{a,b},{a,b,c}(输出顺序无关)。

【例】假设二叉树采用二叉链存储结构,且每个结点存储一个整数(可能有负数)。
给定一棵二叉树,求所有从根结点到叶结点路径上所有结点值之和等于sum的路径。

【例】假设图采用邻接矩阵表示,设计一个从顶点v出发的深度优先遍历算法。

假设图G采用邻接表存储,解决如下问题:

【例】判断无向图G是否连通。若连通则返回true;否则返回false。

【例】判断顶点u->v是否有简单路径。

【例】输出图G中从顶点u->v的一条简单路径(假设图G中从顶点u->v至少有一条简单路径)。

【例】输出图G中从顶点u->v的所有简单路径。

【例】输出图G中从顶点u->v的长度为l的所有简单路径。

【例】判断其中是否存在回路。

【例】求图中通过某顶点k的所有简单回路(若存在)。

【例】求不带权无向连通图G中从顶点u->v的一条最短路径(路径上经过的顶点数最少)。

【例】求不带权无向连通图G中距离顶点v最远的一个顶点。

【例】对于一个无向连通图G,假设不知道n和e,设计一个算法判断是否为一棵树。若是树,返回true;否则返回false。

生成树和最小生成树

一个连通图的生成树是一个极小连通子图,它含有图中全部n个顶点和构成一棵树的(n-1)条边。

命题:如果在一棵生成树上添加一条边,必定构成一个环。

由深度优先遍历得到的生成树称为深度优先生成树。
由广度优先遍历得到的生成树称为广度优先生成树。
一个连通图的生成树不一定是唯一的!

对于带权连通图G (每条边上的权均为大于零的实数),可能有多棵不同生成树。
每棵生成树的所有边的权值之和可能不同。
其中权值之和最小的生成树称为图的最小生成树。

连通图:仅需调用遍历过程(DFS或BFS)一次,从图中任一顶点出发,便可以遍历图中的各个顶点,产生相应的生成树。

非连通图:需多次调用遍历过程。每个连通分量中的顶点集和遍历时走过的边一起构成一棵生成树。
所有连通分量的生成树组成非连通图的生成森林。

普里姆(Prim)算法是一种构造性算法,用于构造最小生成树。过程如下:
(1)初始化U={v}。v到其他顶点的所有边为候选边;
(2)重复以下步骤n-1次,使得其他n-1个顶点被加入到U中:
从候选边中挑选权值最小的边输出,设该边在V-U中的顶点是k,将k加入U中;
考察当前V-U中的所有顶点j,修改候选边:若(j,k)的权值小于原来和顶点k关联的候选边,则用(k,j)取代后者作为候选边。

  • 基于邻接矩阵的普里姆算法实现

【问题描述】N个点M条边的无向连通图,每条边有一个权值,求该图的最小生成树。

【输入格式】
第1行:2个数N,M中间用空格分隔,N为点的数量,M为边的数量。
第2-M+1行:每行3个数S E W,分别表示M条边的2个顶点及权值。
(2<=N<=1000, 1<=M<=50000, 1<=S,E<=N, 1<=W<=10000)

【输出格式】输出最小生成树的所有边的权值之和。

【输入样例】

4 5
1 2 2
1 3 2
1 4 3
2 3 4
3 4 3

【输出样例】7

#include<stdio.h>
#define N 1001
const int INF = (1<<30);
int G[N][N],n,m,i,j,s,e,w;
int lowcost[N],closest[N],sum=0;
// lowcost[i]: 顶点 i到 生成树的最小边  ,
// closest[i]: (i,closest[i]) 顶点 i的最小边,权值为 lowcost[i]

int vis[N];
void prim(int u) {
    vis[u] = 1;
    for(i=1; i<=n; i++) {
        if(i!=u) {
            lowcost[i] = G[u][i];
            closest[i] = u;
        } else lowcost[i] = 0;
    }
    for(i=1; i<=n; i++) {
        int temp = INF;
        int k = u;  // k 记录最近顶点编号
        for(j=1; j<=n; j++) {
            if(!vis[j] && lowcost[j]<temp) {
                temp = lowcost[j];
                k = j;
            }
        }
        if(k==u) break;
        vis[k] = 1;     // 标记已经加入生成树
//        printf("(%d,%d) w:%d\n", closest[k], k, temp);
        for(j=1; j<=n; j++) {
            if(!vis[j] && G[k][j]<lowcost[j]) {
                lowcost[j] = G[k][j];
                closest[j] = k;
            }
        }
    }
}
int main() {
    scanf("%d%d", &n, &m);
    for(i=1; i<=n; i++) {
        for(j=1; j<=n; j++) G[i][j] = INF;
    }
    for(i=1; i<=m; i++) {
        scanf("%d%d%d", &s, &e, &w);
        G[s][e] = G[e][s] = w;
    }
    prim(1);
    for(i=1; i<=n; i++) sum+=lowcost[i];
    printf("%d\n", sum);
    return 0;
}

克鲁斯卡尔(Kruskal)算法也是一种求带权无向图的最小生成树的构造性算法。
按权值的递增次序选择合适的边来构造最小生成树的方法。

克鲁斯卡尔(Kruskal)算法过程:
(1)置U的初值等于V(即包含有G中的全部顶点),TE(表示最小生成树的边集)的初值为空集(即图T中每一个顶点都构成一个连通分量)。
(2)将图G中的边按权值从小到大的顺序依次选取:
若选取的边未使生成树T形成回路,则加入TE;
否则舍弃,直到TE中包含(n-1)条边为止。

  • 基于并查集的克鲁斯卡尔算法实现
#include<iostream>
#include<algorithm>
using namespace std;
const int N=1e6;
struct T{
    int x,y,z;
}node[N];
bool cmp(T a, T b){
    return a.z < b.z;
}
int f[N], ans=0;
int find(int x){
    if(f[x]==x) return x;
    return f[x]=find(f[x]);
}
int main(){
    int n,m; cin>>n>>m;
    for(int i=1; i<=n; i++) f[i]=i;
    for(int i=1; i<=m; i++) cin>>node[i].x>>node[i].y>>node[i].z;
    sort(node+1, node+1+m, cmp);
    for(int i=1; i<=m; i++){
        int a=find(node[i].x), b=find(node[i].y);
        if(a==b) continue;
        f[a]=f[b];
        ans += node[i].z;
    }
    int cnt=0;
    for(int i=1; i<=n; i++) if(f[i]==i) cnt++;
    if(cnt>=2) cout<<"orz";
    else cout<<ans;
    return 0;
}

【例】求下面带权图的最小(代价)生成树时,可能是克鲁斯卡(kruskal)算法第二次选中但不是普里姆(Prim)算法(从v4 开始)第2次选中的边是( )。
A.(v1,v3)
B.(v1,v4)
C.(v2,v3)
D.(v3,v4)

【分析】C
kruskal:1:(v1,v4),2:(v1,v3) 或(v3,v4)或(v2,v3)
Prim (从v4 开始):1:(v1,v4),2:(v1,v3) 或(v3,v4),不可能是 (v2,v3)

思考:有n(n>10)台计算机,已知它们的坐标位置信息,需要连成一个网络。
现在求所花最少网线长度,问:采用什么算法求解?采用哪个算法最好?

最短路径

考虑带权有向图,把一条路径(仅仅考虑简单路径)上所经边的权值之和定义为该路径的路径长度或称带权路径长度。
路径长度=c1 + c2 + … + cm
路径:(v,v1,v2,… ,u)
从源点到终点可能不止一条路径,把路径长度最短的那条路径称为最短路径。

问题描述:给定一个带权有向图G与源点v,求从v到G中其他顶点的最短路径,并限定各边上的权值大于或等于0。

单源最短路径问题:Dijkstra算法
设G=(V,E)是一个带权有向图, 把图中顶点集合V分成两组:
第1组为已求出最短路径的顶点集合(用S表示,初始时S中只有一个源点,以后每求得一条最短路径v,… ,u,就将u加入到集合S中,直到全部顶点都加入到S中,算法就结束了)。
第2组为其余未求出最短路径的顶点集合(用U表示)。

(1)初始化:S只包含源点即S={v},v的最短路径为0。U包含除v外的其他顶点,U中顶点i距离为边上的权值(若v与i有边<v,i>)或∞(若i不是v的出边邻接点)。
(2)从U中选取一个距离v最小的顶点u,把u加入S中(该选定的距离就是v->u的最短路径长度)。
(3)以u为新考虑的中间点,修改U中各顶点j的最短路径长度:若从源点v->j(j∈U)的最短路径长度(经过顶点u)比原来最短路径长度(不经过顶点u)短,则修改顶点j的最短路径长度。
(4)重复步骤(2)和(3)直到所有顶点都包含在S中。

拓扑排序

AOE网与关键路径

参考资料:

https://blog.csdn.net/weixin_41934068/article/details/86015865

https://blog.csdn.net/cofactor/article/details/102861749

https://zhuanlan.zhihu.com/p/112013386

posted @ 2022-03-07 10:53  HelloHeBin  阅读(244)  评论(0)    收藏  举报