438 阅读14分钟

图(Graph)的定义与术语

一个图是由顶点的集和边的集组成的集合。 G = (V,E)。 如果点对有序,那么就是有向图;否则是无向图。 边上带权,就是带权图。

路径

路径是一个顶点序列w1,w2...wn,他满足(wi,wi+1)属E,1≤i≤n。这条路径的长等于该路径上的边数,也就是n-1. 简单路径指的是路径上的除了起始点和终点可能相同外,其他的顶点都是互异的。

顶点的度 (degree)

与该顶点相关联的边的数目

  • 入度(indegree)
  • 出度 ( out degree )

环cycle

有向图的环指的是w1=wn且长度至少为1的一条路径。 无向图中如果两个结点之间有平行边不是环,因为他们实际上被看作是一条边。 如果一个有向图无环,那么就是有向无环图(directed acylic graph),简写为DAG。

连通图

对无向图 G= (V,E) 而言,如果从 V1 到 V2 有一 条路径 (从 V2 到 V1 也一定有一条路径) ,则称 V1 和V2是连通的(connected)。具备这个性质的有向图,称两个顶点是强连通。 非强连通图有向图的极大强连通子图,称为 强连通分量。

###图的ADT

class Graph{ 
public:
   int VerticesNum();
   int EdgesNum();
   Edge FirstEdge(int oneVertex);
   Edge NextEdge(Edge preEdge);
   bool setEdge(int fromVertex,int toVertex,
   int weight); // 添一条边
   bool delEdge(int fromVertex,int toVertex); // 删边 bool IsEdge(Edge oneEdge); // 判断oneEdge是否 int FromVertex(Edge oneEdge); // 返回边的始点
   int ToVertex(Edge oneEdge); // 返回边的终点
   int Weight(Edge oneEdge); // 返回边的权
};

图的表示法(存储结构)

邻接矩阵(具体表示)

  • 设 G = <V,E> 是一个有 n 个顶点的图,则图 的相邻矩阵是一个二维数组 A[n,n],定义如下: A[i,j]= { 1,若(Vi,Vj)属于E或<Vi,Vj> 属于E 0,若(Vi,Vj)属于E或<Vi,Vj> 属于E }

  • 对于 n 个顶点的图,相邻矩阵的空间代价都为 O(n2),与边数无关

typedef struct
{
    VertexType vexs[MAXVEX];/* 顶点表 */
    EdgeType arc[MAXVEX][MAXVEX];/* 邻接矩阵,可看作边表 */
    int numNodes, numEdges;/* 图中当前的顶点数和边数  */
} MGraph;

邻接表(具体表示)

邻接表是表示图的标准方法。

  • 对于稀疏图,可以采用邻接表存储法
    • 边较少,相邻矩阵就会出现大量的零元素
    • 相邻矩阵的零元素将耗费大量的存储 空间 和 时间
  • 邻接表 ( adjacency list ) 链式存储结构
    • 顶点表目有两个域:顶点数据域和指向此顶点边表指针域
    • 边表把依附于同一个顶点 vi 的边(即相邻矩阵中同一行 的非0元素)组织成一个单链表。由两个主要的域组成:
      • 与顶点 vi 邻接的另一顶点的序号
      • 指向边表中下一个边表目的指针

struct ArcNode
{
    int adjvex;               //该弧所指向的顶点的位置
    ArcNode * next;           //指向下一条弧的指针
};
 
typedef struct VNode
{
    char vertex;              //顶点信息
    ArcNode * firstarc;       //指向第一条依附该顶点的弧的指针
} AdjList[MAX_SUM];
 
struct ALGraph
{
    AdjList adjList;
    int vexNum;               //图的顶点数
    int arcNum;               //图的弧数
}

图的邻接表空间代价

  • n 个顶点 e 条边的无向图
    • 需用(n+2e)个存储单元
  • n 个顶点 e 条边的有向图
    • 需用(n+e)个存储单元
  • 当边数 e 很小时,可以节省大量的存储空间
  • 边表中表目顺序往往按照顶点编号从小到大排列

十字链表

十字链表(Orthogonal List)是有向图的另一种链式存储结构。可以看成是将有向图的邻接表和逆邻接表结合起来得到的一种链表。

在十字链表中,对应于有向图中每一条弧都有一个结点,对应于每个定顶点也有一个结点。

十字链表之于有向图,类似于邻接表之于无向图。 也可以理解为将行的单链表和列的单链表结合起来存储稀疏矩阵称为十字链表, 每个节点表示一个非零元素。

/*十字链表的结构类型定义如下:*/
typedef struct OLNode
{
    int row,col; /*非零元素的行和列下标*/
    int value;
    struct OLNode *right; /*非零元素所在行表、列表的后继链域*/
    struct OLNode *down;
} OLNode, *OLink;
typedef struct
{
    OLink *row_head; /*行、列链表的头指针向量*/
    OLink *col_head;
    int m,n,len; /*稀疏矩阵的行数、列数、非零元素的个数*/
} CrossList;

思考:

扩展的复杂图结构(含有自环和重边)存储结构应做怎样的改变?

在邻接矩阵中可以存储含有自环的图(对角线上元素非0即可),对于含有重边的图的邻接矩阵,则需在每个元素拉出一个链表来存储不同的边。而在图的邻接表中,则不许做任何改变,直接将自环和重边的终结点插入对应始节点对应的链表中即可。

图的遍历 (graph traversal)

  • 给出一个图G和其中任意一个顶点V0,从V0出发系统地访问G中所有的顶点,每个顶点访问而且只访问一次
  • 深度优先遍历
  • 广度优先遍历
  • 拓扑排序

图的遍历算法框架

void graph_traverse(Graph& G) {
    for(int i=0;i<G.G.VerticesNum();i++)
        G.Mark[i] = UNVISITED;
    for(int i=0;i<G.VerticesNum();i++){//因为图,可能是非连通的,需要遍历完
        if(G.Mark[i] == UNVISITED)
            do_traverse(G,i);//DFS,BFS,TOPO
    }
}

深度优先DFS

void DFS(Graph &G, int v){
    G.Mark[i] = VISITED;
    Visit(G,v);
    for(Edge e= G.FirstEdge(v);G.IsEdge(e);e = G.NextEdge(e))
        if(G.Mark[i] == UNVISITED)
            DFS(G,G.ToVertex(e));
    //后处理
    //PostVisit(G,v);
}

广度优先DFS

void BFS(Graph &G,int v){
    using namespace std;
    Queue<int> aQueue;
    Visit(G,v);
    G.Mark[v] = VISITED;
    aQueue.push(v);
    while(!aQueue.empty()){
        int u = aQueue.front();
        aQueue.pop();
        for(Edge e = G.FirstEdge(u);G.IsEdge(e);e=G.NextEdge(e)){
            if(G.Mark[G.toVertex(e)] == UNVISITED){
                    Visit(G,G.toVertex(G.toVertex(e)));
                    G.Mark[i] = VISITED;
                    aQueue.push(G.toVertex(e));        
                }
        }
    }
}

图搜索的时间复杂度

  • DFS 和 BFS 每个顶点访问一次,对每一条边 处理一次 (无向图的每条边从两个方向处理)
    • 采用邻接表表示时,有向图总代价为 Θ(n + e), 无向图为 Θ(n + 2e)
    • 采用相邻矩阵表示时,处理所有的边需要 Θ(n2) 的时间 ,所以总代价为 Θ(n + n2) = Θ(n2)

拓扑排序

对于有向无环图 G= (V,E) ,V 里顶点的线性 序列称作一个 拓扑序列,该顶点序列满足:

  • 若在有向无环图 G 中从顶点 vi 到vj有一条路径, 则在序列中顶点 vi 必在顶点 vj 之前
  • 拓扑排序 (topological sort)
    • 将一个有向无环图中所有顶点在不违反 先决条件关系的前提下排成线性序列的过程称为 拓扑排序 显然,如果图有环是不行的,因为对于u,v两个顶点,u既可能出现在v之前,v也可能出现在u之前。 此外,拓扑排序是不唯一的。

拓扑排序方法

任何有向无环图(DAG),其顶点都可以排在一个拓扑序列里,其拓扑排序的 方法是:

  1. 从图中选择任意一个入度为0的顶点且输出之
  2. 从图中删掉此顶点及其所有的出边,将其入度减少1
  3. 回到第 (1) 步继续执行

用队列实现图拓扑排序

关键点在于把入度为0的放到队列中,而不是每次去判断,提高了算法效率(要求初始点入度为0)

void TopsortbyQueue(Graph& G) {
for (int i = 0; i < G.VerticesNum(); i++) G.Mark[i] = UNVISITED; // 初始化
using std::queue; queue<int> Q; 
for (i = 0; i < G.VerticesNum(); i++)
    if (G.Indegree[i] == 0) Q.push(i); 
while (!Q.empty()) {
 int v = Q.front(); 
 Q.pop();
Visit(G,v); 
G.Mark[v] = VISITED;
for (Edge e = G.FirstEdge(v); G.IsEdge(e); e = G.NextEdge(e)) {
    G.Indegree[G.ToVertex(e)]--;
    if (G.Indegree[G.ToVertex(e)] == 0)
        Q.push(G.ToVertex(e));
    }}
for (i = 0; i < G.VerticesNum(); i++)
    if (G.Mark[i] == UNVISITED) { 
    cout<<“ 此图有环!”; break;
}}

深度优先搜索实现的拓扑排序(处理不了有环的)

 int *TopsortbyDFS(Graph& G) { 
 for(int i=0; i<G.VerticesNum(); i++)
    G.Mark[i] = UNVISITED;
int *result=new int[G.VerticesNum()]; 
int index=0;  //对初始点无要求
for(i=0; i<G.VerticesNum(); i++)
    if(G.Mark[i] == UNVISITED) 
    Do_topsort(G, i, result, index);
for(i=G.VerticesNum()-1; i>=0; i--) 
    Visit(G, result[i]);
return result; 
 }
 
 //拓扑递归函数
void Do_topsort(Graph& G, int V, int *result, int& index) {
G.Mark[V] = VISITED;
for (Edge e = G.FirstEdge(V);
    G.IsEdge(e); e=G.NextEdge(e)) {
    if (G.Mark[G.ToVertex(e)] == UNVISITED)
        Do_topsort(G, G.ToVertex(e), result, index);
}
result[index++]=V; // 相当于后处理 
}

拓扑排序的时间复杂度

  • 与图的深度优先搜索方式遍历相同
    • 图的每条边处理一次
    • 图的每个顶点访问一次
  • 采用邻接表表示时,为 Θ(n + e)
  • 采用相邻矩阵表示时,为 Θ(n2)

再次强调拓扑排序算法只能用于DAG,支持非连通图

单源最短路径算法

单源最短路径(single-source shortest paths)

  • 给定带权图 G = <V,E>,其中每条边 (vi,vj) 上的权 W[vi,vj] 是一个 非负实数 。计算从任给的一个源点s到所有其他各结点的最短路径

Dijkstra单源最短路径算法基本思想及实现

它实际上是一种贪心算法。

  • 把所有结点分成两组
    • 第一组 U 包括已确定最短路径的结点
    • 第二组 V–U 包括尚未确定最短路径的结点
  • 按最短路径长度递增的顺序逐个把第二组的结 点加到第一组中(把U对应的最短路径达到的点加入到U中)
  • 直至从 s 出发可达结点 都包括进第一组中
class Dist { // Dist类,用于保存最短路径信息
public:
    int index; //结点的索引值,仅Dijkstra算法用d到
    int length; //当前最短路径长度
    int pre;// 路径最后经过的结点
};

void Dijkstra(Graph& G, int s, Dist* &D) {// s是源点
    D = new Dist[G. VerticesNum()];
    for (int i = 0; i < G.VerticesNum(); i++) {
        G.Mark[i] = UNVISITED;
        D[i].index = i; 
        D[i].length = INFINITE; 
        D[i].pre = s; 
    }
    D[s].length = 0; //源点到自身的路径长度置为0 MinHeap<Dist> H(G.EdgesNum()); //最小值堆用于找出最短路径 H.Insert(D[s]);
    
    for (i = 0; i < G.VerticesNum(); i++) {     bool FOUND = false;
        Dist d;
        while (!H.isEmpty()) {
            d = H.RemoveMin();
            if (G.Mark[d.index] == UNVISITED) { 
                FOUND = true;   
                break; 
            
        }}
        if (!FOUND) break;//如果未访问过则跳出循环
        int v = d.index;
        G.Mark[v] = VISITED;
        for (Edge e = G.FirstEdge(v); G.IsEdge(e); e = G.NextEdge(e)) 
            if (D[G.ToVertex(e)].length > (D[v].length+G.Weight(e))) {
                D[G.ToVertex(e)].length = D[v].length + G.Weight(e); 
                D[G.ToVertex(e)].pre = v;
                H.Insert(D[G.ToVertex(e)]);
    }} 
    
}

Dijkstra算法时间代价分析

  • 每次改变D[i].length
  • 不删除,添加一个新值(更小的),作为堆中 新元素。旧值被找到时,该结点一定被标记 为VISITED,从而被忽略
  • 在最差情况下,它将使堆中元素数目由Θ(|V|)增加到Θ(|E|),总的时间代价 Θ((|V|+|E|) log|E|)

算法支持

  • 支持有向图,无向图,非连通图,有环图
  • 不支持负权值

求所有节点对之间的最短路径

算法:

  1. 以每个节点为前点,调用V次Dijkstra算法,O(V3)
  2. Floyd算法

Floyd算法求每对结点之间的最短路径

  • 用相邻矩阵 adj 来表示带权有向图
  • 基本思想:
    • 初始化 adj(0) 为相邻矩阵 adj
    • 在矩阵 adj(0)上做 n 次迭代,递归地产生一个矩 阵序列adj(1),...,adj(k),...,adj(n)
    • 其中经过第k次迭代,adj(k)[i,j] 的值等于从结点 vi 到结点 vj 路径上所经过的结点序号不大于 k 的 最短路径长度
  • 这其实是动态规划法,我们如下分析:
    • 由于第 k 次迭代时已求得矩阵adj(k-1),那么结点 vi 到 vj 中间结点的序号不大于 k 的最短路径有两种情况:
      • 一种是中间不经过结点 vk,那么此时就有 adj(k) [i,j] = adj(k-1)[i,j]
      • 另中间经过结点 vk,此时 adj(k) [i,j] < adj(k-1)[i,j], 那么这条由结点 vi 经过 vk 到结点 vj 的中间结点序号不 大于k的最短路径由两段组成
      • adj(k) [i,j] = adj(k-1)[i,k] + adj(k-1)[k,j]
    • 所以说adj(k) [i,j] = Min{ adj(k-1)[i,j], adj(k-1)[i,k] + adj(k-1)[k,j] },那么adj[i,j] = Min{adj(k):0<=k<V}

Floyd算法实现

void Floyd(Graph& G, Dist** &D) {
    int i,j,v;
    D = new Dist*[G.VerticesNum()];
    for(i=0;i<G.VerticesNum();i++){
        D[i] = new Dist[VerticesNum()];
    }
    //初始化数组
    for (i = 0; i < G.VerticesNum(); i++)
        for (j = 0; j < G.VerticesNum(); j++) { 
            if (i == j) {
                D[i][j].length = 0;
                D[i][j].pre = i; 
            } 
            else {
                D[i][j].length = INFINITE;
                D[i][j].pre = -1; 
            }
    }
    //初始化距离矩阵与路径矩阵
    for(v=0;v<G.VerticesNum();v++){
        for(Edge e= G.FirstEdge(v);G.IsEdge(e);e=G.NextEdge(e)){
            D[v][G.ToVertex(e)].length = G.Weight(e);
            D[v][G.ToVertex(e)].pre = v;
        }
    }
    for(v=0;i<G.VerticesNum();v++){
    for(i=0;i<G.VerticesNum();i++){
       for(j=0;j<G.VerticesNum();j++){
        if(D[i][j].length() > D[i][v].length() + D[v][j].length()){
            D[i][j].length() = D[i][v].length() + D[v][j].length();
            D[i][j].pre = D[v][j].pre;//确保j之前最近点是正确的,保存了路径关系
        }
       }
       }
}}

恢复最短路径:

int i,j,pre;
pre = D[i][j].pre;
while(pre!=i){
    cout<<pre;
    pre = D[i][pre].pre;
}

可以看出因为有三重循环,时间复杂度是O(N3)。 但是实际应用中,因为Floyd的循环很紧凑,尤其是在稠密表中效率是比Dijkstra算法更高效。对于稀疏图效率较低。

最小生成树MST

  • 图 G 的生成树是一棵包含G的所有顶点的树,树上所有权值总和表示代价,那么在G的所有的生成树中代价最小的生成树称为图 G 的 最小生成树 (minimum-cost spanning tree,简称 MST)

Prim 算法

  • 与 Dijkstra 算法类似——也是贪心法
  • 从图中任意一个顶点开始 (例如 v0),首先把这个顶点包括在 MST,U=(V*, E*) 里 * 初始 V*= {v0} , E* = {}
  • 然后在那些其一个端点已在 MST 里,另一个端点还不是MST里的边,找权最小的一条边 (vp, vq) ,并 把此vq 包括进 MST 里......
  • 如此进行下去,每次往MST里加一个顶点和一条权最 小的边,直到把所有的顶点都包括进 MST 里
  • 算法结束时V*=V,E*包含了G中的n-1条边
//错误的做法!:
void Prim(Graph &G, int s, Edge*MST){
    int MSTtag = 0;  //最小生成树边计数
    MST  = new Edge[G.VerticesNum()];
    int v=s; //起始边
    while(MSTtag < G.VerticesNum()){
        int minLen = -1,Edge minE;
        for(e = G.FirstEdge(v);G.IsEdge(e);e= G.NextEdge(e)){
            if(G.Mark[v] == UNVISITED){
                if(minLen > G.Weight(e)){
                    v = G.ToVertex(e);;
                    G.Mark[v] = VISITED;
                    minLen = G.Weight(e);
                    minE = e;
                }
            }
        }
        if(minLen = -1) break; //非连通有不可达顶点
        MST[MSTtag++] = minE;
    }
}

因为如果图中有环,那么就退不出来了,因为始终只能找邻接的边。所以我们还需要把之前遇到的边全记录下来。

void Prim(Graph &G, int s, Edge*MST){
    int MSTtag = 0;  //最小生成树边计数
    MST  = new Edge[G.VerticesNum()];
    Dist *D = new Dist[G.VerticesNum()];
    for(int i=0;i<G.VerticesNum();i++){
        G.Mark[i] = UNVISITED; 
        D[i].index = i; 
        D[i].length = INFINITE; 
        D[i].pre = s;  //这里为了构造边
    }
    D[s].length = 0;
    G.Mark[s] = VISITED;
    int v=s; //起始边
    while(MSTtag < G.VerticesNum()){
        for(Edge e = G.FirstEdge(v);G.IsEdge(e);e = G.NextEdge(e)){
            if(G.Mark[G.ToVertex(e)== UNVISITED]){
                if(D[G.ToVertex(e)].length > G.Weight[G.ToVertex(e)]){
                    D[G.ToVertex(e)].length =  G.Weight[G.ToVertex(e)];
                    D[G.ToVertex(e)].pre = v;
                }
            }
        }
        v = minVertex(G,D);
        if(v==-1) break; //图非连通
        G.Mark[v] = VISITED;
        Edge edge(D[v].pre,v,D[v].length);
        addToMST(Edge,MST,MSTtag++);
    }
}

int minVertex(Graph &G, Dist *D){
    int minLen = INFINITE,v=-1; //v=-1说明没找到
    for(int i=0;i<G.VerticesNum();i++){
        if(G.Mark[i] == UNVISITED && minLen > D[i]){
            minLen = D[i];
            v = i;
        }
    }
    return v;
}

Prim 算法时间复杂度

  • 本算法通过直接比较 D 数组元素,确定代价最小 的边就需要总时间O(n2);

  • 取出权最小的顶点后,修改D数组共需要时间O(e),因此共需要花费 O( n2) 的时间

  • 算法适合于稠密图

  • 对于稀疏图,可以像Dijkstra算法那样用堆来保存距 离值

Kruskal 算法

算法思想:

  • 首先将 G 中的 n 个顶点看成是独立的 n 个连通 分量,这时的状态是有n个顶点而无边的森林, 可以记为 T = <V,{}>
  • 然后在 E (边的集合)中选择代价最小的边,如果该边依附于 两个不同的连通分支,那么将这条边加入到 T 中 ,否则舍去这条边而选择下一条代价最小的边
  • 依此类推,直到T中所有顶点都在同一个连通分量中为止,此时就得到图 G 的一棵最小生成树
void Kruskal(Graph& G, Edge* &MST) { 
    ParTree<int> A(G.VerticesNum());  //等价类
    MinHeap<Edge> H(G.EdgesNum()); //最小堆
    MST = new Edge[G.VerticesNum()-1]; 
    int MSTtag = 0;
    for (int i = 0; i < G.VerticesNum(); i++) // 将所有边插入最小堆H中 
        for (Edge e = G. FirstEdge(i); G.IsEdge(e); e = G. NextEdge(e))
            if (G.FromVertex(e) < G.ToVertex(e))// 防重复边 
                H.Insert(e);

    int EquNum = G.VerticesNum();

    while(EquNum > 1){
        if(H.empty()){ //说明非连通
            cout << "不存在最小生成树." <<endl; 
            delete [] MST;
            MST = NULL;
            return;
        }
        Edge e = H.removeMin();
        int from  = G.FromVertex(e);
        int to = G.ToVertex(e);
        if(A.Different(from,to)){
            A.Union(from,to);
            AddToMST(e,MST,MSTtag++);
            EquNum--;
        }
    }
}

如图所示, 先加入DE 再加入EF,GF,AB,AC 当加入BC的时候发现ABC已经在一个等价类里面了,所以舍去 接着找到了AG 此时等价类的个数为1,所以循环结束。

Kruskal 算法代价

  • 使用了路径压缩,Different() 和 Union() 函 数几乎是常数
  • 假设可能对几乎所有边都判断过了则最坏情况下算法时间代价为 Θ (elog e),即堆排序的时间
  • 通常情况下只找了略多于 n 次,MST 就已经 生成
  • 时间代价接近于 Θ (nlog e)