来源:互联网 发布:手机上编程 编辑:程序博客网 时间:2024/04/29 12:11

一个图 G=(V,E) 由顶点集 V 和边集 E 组成。每条边是一个点对 (v,w) ,其中 v,wV 。如果点对是有序的,则图称为有向图。顶点 v  w 邻接当且仅当 (v,w)E 。有时边还有权或值属性。

图中的路径是一个顶点序列 w1,w2,...,wN ,满足 (wi,wi+1)E  1i<N 。路径的长为该路径的边数 N1 。顶点到自身可以看作有不包含边的路径,路径长为0,也称为环。所有顶点都是互异的,但第一个和最后一个顶点可能相同的路径,称为简单路径。有向图中的圈是满足 w1=wN 且长度大于0的路径,如果该路径是简单路径,则圈为简单圈。无向图的圈还要求边是互异的。有向无圈图也称为DAG。

如果无向图中每个顶点到其他顶点都存在一条路径,则称为连通的。对应地有向图称为强连通的,如果有向图不是强连通的,但去掉边的方向形成的基础图是连通的,则有向图是弱连通的。完全图是每一对顶点间都存在一条边的图。

图可以用二维数组表示,称为邻接矩阵表示法。对每条边 (u,v) ,置 A[u][v]=1 ,否则为0,带权的边可以令 A[u][v] 等于权。这种表示很简单,但空间需求为 Θ(|V|2) ,大部分图都不是稠密的,因此数组的大部分元素都没有用处。

对于稀疏图,更好的方法是用邻接表表示,它也是图的标准表示方法。对每个顶点,使用一个表保存所有邻接的顶点,如果边有权,可以保存在表示邻接顶点的元素中。这种表示空间需求为 O(|E|+|V|) 。对无向图,每条边出现两次,空间需求大约为两倍。

/media/note/2012/05/30/ds-graph/fig1.png

图的邻接表表示

下面是使用邻接表方式实现的图的类型。

/* graph.h */struct vertex {    int v;    int c;    struct vertex *next;};struct edge {    int v;    int w;    int c;};struct graph {    int vn;    int en;    struct vertex **varray;};typedef struct vertex vertex_t;typedef struct edge edge_t;typedef struct graph graph_t;/* graph.c */static vertex_t *vertex_create(int v, int c){    vertex_t *u;    u = malloc(sizeof(vertex_t));    if (u == NULL)        err_quit("malloc error");    u->v = v;    u->c = c;    u->next = NULL;    return u;}static void vertex_destroy(vertex_t *u){    free(u);}static void vertex_add(vertex_t *u, vertex_t *l){    u->next = l->next;    l->next = u;}static edge_t *edge_create(int v, int w, int c){    edge_t *e;    e = malloc(sizeof(edge_t));    if (e == NULL)        err_quit("malloc error");    e->v = v;    e->w = w;    e->c = c;    return e;}static void edge_destroy(edge_t *e){    free(e);}graph_t *graph_create(int earray[][2], int vn, int en){    graph_t *g;    vertex_t *u;    int v, w;    int i, j;    g = malloc(sizeof(graph_t));    if (g == NULL)        err_quit("malloc error");    g->vn = vn;    g->en = en;    g->varray = malloc(sizeof(vertex_t *)*(vn+1));    if (g->varray == NULL)        err_quit("malloc error");    for (i = 1; i <= vn; i++)        g->varray[i] = vertex_create(i, 0);    for (j = 0; j < en; j++) {        v = earray[j][0];        w = earray[j][1];        u = vertex_create(w, 1);        vertex_add(u, g->varray[v]);        g->varray[w]->c++;    }    return g;}graph_t *graph_create_weighted(int earray[][3], int vn, int en){    graph_t *g;    vertex_t *u;    int v, w;    int i, j;    g = malloc(sizeof(graph_t));    if (g == NULL)        err_quit("malloc error");    g->vn = vn;    g->en = en;    g->varray = malloc(sizeof(vertex_t *)*(vn+1));    if (g->varray == NULL)        err_quit("malloc error");    for (i = 1; i <= vn; i++)        g->varray[i] = vertex_create(i, 0);    for (j = 0; j < en; j++) {        v = earray[j][0];        w = earray[j][1];        u = vertex_create(w, earray[j][2]);        vertex_add(u, g->varray[v]);        g->varray[w]->c++;    }    return g;}void graph_destroy(graph_t *g){    vertex_t *u, *l;    int i;    for (i = 1; i <= g->vn; i++) {        l = g->varray[i];        while (l) {            u = l;            l = l->next;            vertex_destroy(u);        }    }    free(g->varray);    free(g);}void graph_print(graph_t *g){    vertex_t *u;    int i;    for (i = 1; i <= g->vn; i++) {        printf("(%d,%d):", g->varray[i]->v, g->varray[i]->c);        u = g->varray[i]->next;        while (u) {            printf(" (%d,%d)", u->v, u->c);            u = u->next;        }        printf("\n");    }}

实际应用中,顶点的名字可能不是数字,可以再使用一个散列表来完成名字到数字的映射。

拓扑排序

拓扑排序是对有向无圈图的顶点的排序,使如果存在 vi  vj 的路径,则排序中 vi  vj 前面。如果图含有圈,则拓扑排序是不可能的。排序也不必是唯一的。拓扑排序的一个简单的方法是找到任意一个没有入边的顶点,加入序列,然后将该顶点和它的边从图中删除,对图重复该处理即得拓扑排序的结果。

定义顶点 v 的入度为边 (u,v) 的条数,计算图中所有顶点的入度。维护一个入度数组,每次遍历该数组来查找未分配拓扑编号的入度为0的顶点,找到后分配拓扑编号并更新入度数组,未找到说明存在圈。因为每次遍历都查找入度数组,所以该算法运行时间为 O(|V|2) 

void TopSort(Graph G){    int Counter;    Vertex V, W;    for (Counter = 0; Counter < VertexNum; Counter++) {        V = FindNewVertexOfIndegreeZero();        if (V == NotAVertex) {            Error("Graph has a cycle");            break;        }        TopNum[V] = Counter;        for each W adjacent to V            Indegree[W]--;    }}

注意到并不需要每次查看所有顶点的入度,可以改进上述算法,将入度为0的顶点放入一个栈或队列中,每次从中取出顶点时,降低邻接顶点的入度,如果降为0则加入栈或队列。如果使用邻接表,算法的运行时间为 O(|E|+|V|)

void TopSort(Graph G){    Queue Q;    int Counter = 0;    Vertex V, W;    Q = Create(VertexNum);    for each vertex V        if (Indegree[V] == 0)            Enqueue(V, Q);    while (!Empty(Q)) {        V = Dequeue(Q);        TopNum[V] = ++Counter;        for each W adjacent to V            if (--Indegree[W] == 0)                Enqueue(W, Q);    }    if (Counter != VertexNum)        Error("Graph has a cycle");    Destroy(Q);}

下面是使用了队列的拓扑算法的一个实现。

void graph_top_sort(graph_t *g, int top_a[]){    int indegree_array[g->vn];    int v, w;    queue_t *q;    vertex_t *u;    int i = 0;    for (v = 1; v <= g->vn; v++)        indegree_array[v] = g->varray[v]->c;    q = queue_create(g->vn);    for (v = 1; v <= g->vn; v++)        if (indegree_array[v] == 0)            queue_enqueue(v, q);    while (!queue_empty(q)) {        v = queue_dequeue(q);        top_a[v] = ++i;        u = g->varray[v]->next;        while (u) {            w = u->v;            if (--indegree_array[w] == 0)                queue_enqueue(w, q);            u = u->next;        }    }    if (i != g->vn)        err_quit("graph is not a DAG");    queue_destroy(q);}

最短路径算法

每条边 (vi,vj) 有代价 ci,j 的图为赋权图,路径 v1v2...vN 的值为 N1i=1ci,i+1 ,称为赋权路径长,对应地无权路径长为边数 N1 

单源最短路径问题:给定一个赋权图 G=(V,E) 和一个顶点 s 作为输入,找出从 s  G 中每一个其他顶点的最短赋权路径。

图中也可能出现负边,这时可能出现称为负值圈的循环,如果出现了负值圈,最短路径问题就是不确定的。

无权最短路径

可以用广度优先搜索解决无权最短路径问题。广度优先搜索按层处理顶点,距开始点最近的顶点首先被赋值,最远的顶点最后被赋值,类似于树的层序遍历。

一种简单的算法是每次特定距离的顶点时遍历所有的顶点,这样运行时间为 O(|V|2) 

void Unweighted(Table T){    int CurrDist;    Vertex V, W;    for (CurrDist = 0; CurrDist < VertexNum; CurrDist++)        for each vertex V            if (!T[V].Known && T[V].Dist == CurrDist) {                T[V].Known = True;                for each W adjacent to V                    if (T[W].Dist == Infinity) {                        T[W].Dist = CurrDist + 1;                        T[W].Path = V;                    }            }}

和拓扑排序类似,可以简化该算法,考虑到正查看的顶点只有相距特定距离和特定距离加1两种情况,可以用队列来临时保存这些顶点,并且使用队列会在处理完特定距离的顶点后再处理特定距离加1的顶点,正好满足需要。如果使用邻接表,算法的运行时间为 O(|E|+|V|) 

void Unweighted(Table T){    Queue Q;    Vertex V, W;    Q = Create(VertexNum);    Enqueue(S, Q);    while (!Empty(Q)) {        V = Dequeue(Q);        T[V].Known = True;        for each W adjacent to V            if (T[W].Dist == Infinity) {                T[W].Dist = T[V].Dist + 1;                T[W].Path = V;                Enqueue(W, Q);            }    }    Destroy(Q);}

下面是使用了队列的拓扑算法的一个实现。

void graph_unweighted_path_length(graph_t *g, int s, pathlen_t plen_a[]){    int v, w;    queue_t *q;    vertex_t *u;    for (v = 1; v <= g->vn; v++) {        plen_a[v].dist = INT_MAX;        plen_a[v].path = -1;    }    plen_a[s].dist = 0;    q = queue_create(g->vn);    queue_enqueue(s, q);    while (!queue_empty(q)) {        v = queue_dequeue(q);        u = g->varray[v]->next;        while (u) {            w = u->v;            if (plen_a[w].dist == INT_MAX) {                plen_a[w].dist = plen_a[v].dist + 1;                plen_a[w].path = v;                queue_enqueue(w, q);            }            u = u->next;        }    }    queue_destroy(q);}

Dijkstra算法

解决单源最短路径的一般算法称为Dijkstra算法,它是个典型的贪婪算法。贪婪算法分阶段地求解一个问题,在每个阶段把当前出现的当作最好的去处理。贪婪算法的问题是不能总是成功。

Dijkstra算法分阶段进行,每个阶段从所有未知顶点中选择 dv 最小的顶点 v ,同时声明从 s  v 的最短路径是已知的,更新邻接顶点的 dw 值。对无权情形,若 dw=inf 则置 dw=dv+1 ;对赋权情形,若 dv+cv,w 小于 dw ,则置 dw=dv+cv,w ,即在通向 w 的路径上使用顶点 v 

使用反证法可以证明,只要没有负边,算法总能顺利完成。

如果通过遍历表来找到最小的 dv ,则每一步花费 O(|V|) 时间,整个算法花费 O(|V|2) 查找最小值,每条边最多更新一次 dw ,花费 O(|E|) ,因此总运行时间为 O(|E|+|V|2) 。如果图是稠密的,这种方法简单而且基本上最优。如果图是稀疏的,算法就不理想了,需要把距离保存在优先队列中,运行时间为 O(|E|log|V|+|V|log|V|)=O(|E|log|V|) ,如果使用Fibonacci堆,运行时间为 O(|E|+|V|log|V|) 

void Dijkstra(Table T){    Vertex V, W;    for (;;) {        V = smallest unknown distance vertex;        if (V == NotAVertex)            break;        T[V].Known = True;        for each W adjacent to V            if (!T[W].Known)                if (T[V].Dist + Cvw < T[W].Dist) {                    T[W].Dist = T[V].Dist + Cvw;                    T[W].Path = V;                }    }}

下面用优先队列实现Dijkstra算法。

void graph_dijkstra(graph_t *g, int s, pathlen_t plen_a[]){    int v, w;    heap_t *h;    vertex_t *u;    vdist_t *vd;    int c;    for (v = 1; v <= g->vn; v++) {        plen_a[v].known = 0;        plen_a[v].dist = INT_MAX;        plen_a[v].path = -1;    }    plen_a[s].dist = 0;    h = heap_create(g->en, dist_lt);    for (v = 1; v <= g->vn; v++) {        vd = vdist_create(v, plen_a[v].dist);        heap_insert(h, vd);    }    while (1) {        do {            vd = heap_delete(h);            if (vd == NULL) {                heap_destroy(h);                return;            }            v = vd->v;            vdist_destroy(vd);        } while (plen_a[v].known);        plen_a[v].known = 1;        u = g->varray[v]->next;        while (u) {            w = u->v;            c = u->c;            if (!plen_a[w].known && plen_a[v].dist + c < plen_a[w].dist) {                vd = vdist_create(w, plen_a[v].dist+c);                heap_insert(h, vd);                plen_a[w].path = v;            }            u = u->next;        }    }    heap_destroy(h);}

有负边值的图

如果图有负边值,就不能用Dijkstra算法了,可以将无权和赋权的算法结合起来解决。

void WeightedNegative(Table T){    Queue Q;    Vertex V, W;    Q = Create(VertexNum);    Enqueue(S, Q);    while (!Empty(Q)) {        V = Dequeue(Q);        for each W adjacent to V            if (T[V].Dist + Cvw < T[W].Dist) {                T[W].Dist = T[V].Dist + Cvw;                T[W].Path = V;                if (W is not already in Q)                    Enqueue(W, Q);            }    }    Destroy(Q);}

下面是该算法的一个实现。

void graph_weighted_negative_path_length(graph_t *g, int s, pathlen_t plen_a[]){    int v, w;    queue_t *q;    vertex_t *u;    int c;    for (v = 1; v <= g->vn; v++) {        plen_a[v].dist = INT_MAX;        plen_a[v].path = -1;    }    plen_a[s].dist = 0;    q = queue_create(g->vn);    queue_enqueue(s, q);    while (!queue_empty(q)) {        v = queue_dequeue(q);        u = g->varray[v]->next;        while (u) {            w = u->v;            c = u->c;            if (plen_a[v].dist + c < plen_a[w].dist) {                plen_a[w].dist = plen_a[v].dist + c;                plen_a[w].path = v;                if (!queue_inqueue(w, q))                    queue_enqueue(w, q);            }            u = u->next;        }    }    queue_destroy(q);}

这个算法对于赋权图每条边可能不只执行一次,每个顶点最多出队 |V| 次,运行时间为 O(|E||V|) ,这明显大于Dijkstra算法的。如果存在负值圈,会出现死循环,可以通过任一顶点已出队 |V|+1 次来判断终止。

无圈图

如果图是无圈的,可以以拓扑顺序来选择顶点,改进Dijkstra算法。当顶点 v 被选取后,根据拓扑排序的法则,它没有从未知顶点发出的入边,因此 dv 可以不再降低。使用这种选择法则不需要优先队列,选择花费常数时间,所以运行时间为 O(|E|+|V|) 

无圈图可以模拟下坡滑雪、不可逆化学反应等。还有一个重要用途是关键路径分析法,用来分析动作节点图,松弛时间为0的动作为关键动作,由关键动作组成的路径为关键路径。

网络流问题

设有向图 G=(V,E) 的给定边的容量为 cv,w ,有发点 s 和收点 t ,求从 s  t 可以通过的最大流量。对于 s  t之外的任一顶点 v ,总的进入流必然等于总的发出流。

这类问题一般都要分阶段解决。由图 G 构造一个流图 Gf ,表示在算法任意阶段已经达到的流,开始时 Gf 的所有边都没有流。再构造一个残余图 Gr ,表示每条边还能再添加多少流,可以从容量中减去当前的流来计算残余的流。每个阶段中,寻找 Gr 中从 s  t 的路径,称为增长通路,该路径上的最小值边就是可以添加到路径每一边上的流的量。调整 Gf 并重新计算 Gr ,当发现 Gr 中没有新路径时算法终止。路径的选择是任意的。

上面的算法有个问题是,路径选择的不同可能出现不同的结果,选择有些路径会在达到最大流量前就找不到新的路径。这是贪婪算法行不通的一个例子。

解决该问题可以通过在 Gr 中选择的路径上加上相反方向的流。可以证明,如果边的容量为有理数,该算法总能以最大流终止。这个算法并不要求图是无圈的。如果容量都为整数且最大流为 f ,则有 f 个阶段,一条增长通路可以用无权最短路径算法以 O(|E|) 时间找到,因此总运行时间为 O(f|E|) 

现在还有一个问题,如果存在流特别大的通路,因为路径是任意选择的,可能出现多次运行运行时间差距特别大的情况。避免这个问题的方法是总是选择使流增长最大的路径,可以通过修改Dijkstra算法来完成。如果最大的边容量为 capmax ,则有 O(|E|logcapmax) 个阶段,对增长通路的每次计算需要 O(|E|log|V|) 时间,因此总运行时间为 O(|E|2log|V|logcapmax) ,如果容量为小整数,则为 O(|E|2log|V|) 

最小生成树

一个无向图 G 的最小生成树就是由所有边构成的树,且总价值最低。当且仅当 G 是连通的,存在最小生成树。最小生成树中边数为 |V|1 

对任一生成树 T ,如果将一条不属于 T 的边 e 添加进来,则产生一个圈,从该圈中除去任一条边,则又恢复生成树的特性。如果 e 的值比除去的边的值小,则新的生成树的值就比原生成树的值小。这样最后即可得到最小生成树。

Prim算法

Prim算法维护一个已添加到树上的顶点集,在每一阶段都从所有 u 在树上而 v 不在树上的边 (u,v) 中选择其值最小者,这样找到一个新的顶点并加到树中。Prim算法和Dijkstra算法基本一样,但更新法则不同,顶点 v 被选取后,对每一个和 v 相邻接的未知顶点 w  dw=min(dw,cw,v) ,另外Prim算法在无向图上运行,所以需要把每条边都放到两个邻接表中。不用堆时的运行时间为 O(|V|2) ,使用二叉堆的运行时间为 O(|E|log|V|) 

Kruskal算法

Kruskal算法连续地按照最小的权选择边,当所选的边不产生圈时就把它作为选定的边。开始时存在 |V| 棵单节点树,添加一个边将两棵树合并为一棵树,最后只有一棵树时即为最小生成树。

对边的选择通过不相交集来完成。需要能够按顺序选取最小边,比较好的办法是使用堆,以线性时间建立。通常只有一部分的边需要测试。该算法的最坏运行时间为 O(|E|log|E|) ,因为 |E|=O(|V|2) ,所以实际上为 O(|E|log|V|) 

void Kruskal(Graph G){    int EdgesAccepted;    DisjSet S;    PriorityQueue H;    Vertex U, V;    SetType Uset, Vset;    Edge E;    Initialize(S);    ReadGraphIntoHeapArray(G, H);    BuildHeap(H);    EdgesAccepted = 0;    while (EdgesAccepted < VertexNum - 1) {        E = DeleteMin(H);        Uset = Find(U, S);        Vset = Find(V, S);        if (Uset != Vset) {            EdgesAccepted++;            SetUnion(S, Uset, Vset);        }    }}

下面是该算法的一个实现。

void graph_kruskal(graph_t *g){    dset_t *s;    heap_t *h;    vertex_t *u;    edge_t *e;    int v;    int vs, ws;    int edge_added = 0;    s = dset_create(g->vn);    h = heap_create(g->en, cost_lt);    for (v = 1; v <= g->vn; v++) {        u = g->varray[v]->next;        while (u) {            e = edge_create(v, u->v, u->c);            heap_insert(h, e);            u = u->next;        }    }    while (edge_added < g->vn - 1) {        e = heap_delete(h);        vs = dset_find(e->v, s);        ws = dset_find(e->w, s);        if (vs != ws) {            edge_added++;            printf("(%d,%d) ", e->v, e->w);            dset_union(s, vs, ws);        }        edge_destroy(e);    }    printf("\n");    heap_destroy(h);    dset_destroy(s);}

深度优先搜索

深度优先搜索是对前序遍历的推广。为了避免圈,每访问一个顶点,需要标记该顶点为访问过的,并对未被标记的所有邻接顶点递归调用深度优先搜索。对于无向图,每条边在邻接表中出现两次。如果图是无向的且不连通,或是有向的但非强连通,则可能会访问不到某些顶点,此时搜索未标记的顶点来继续。每条边只被访问一次,所以使用邻接表时,遍历的运行时间为 O(|E|+|V|) 

当且仅当从任一顶点开始的深度优先搜索能访问到每一个顶点,无向图是连通的。如果一个连通的无向图去掉任一顶点后仍连通,则称为双连通的。删除后使图不再连通的顶点称为割点。

深度优先搜索可以在线性时间找出连通图中的所有割点。首先进行深度优先搜索给顶点编号 Num(v) ;然后对深度优先搜索生成树上的每一个顶点 v ,计算编号最低的顶点 Low(v) ,它是从 v 开始通过树的0或多条边及可能一条背向边达到的顶点的编号;最后找出割点,有多于一个儿子的根是割点,对于其他顶点 v ,有某个儿子 w 使 Low(w)Num(v) 时为割点。总共进行了三趟遍历,第一个为前序遍历,后两个为后序遍历,可以将这三个遍历合并为一个。

NP-完全性

一般来说,运行时间不能比线性更好,运行时间更小的情况往往是对数据进行了预处理。另一方面,存在不可能解出的问题,称为不可判定问题。如停机问题:是否能让C编译器能检查所有的无限循环。

NP表示非确定型多项式时间,NP类在难度上小于不可判定问题的类。确定型机器在每一时刻都在执行一条指令,再根据它执行接下来的指令,这是确定的。非确定型机器则对其后的步骤是有选择的,并总是选择能得到解的正确的步骤。NP类包括所有具有多项式时间解的问题。但不是所有可判定问题都属于NP。

NP-完全问题是NP问题的一个子集,它满足NP中的任一问题都能够多项式地归约成NP-完全问题。NP-完全问题是最难的NP问题。

证明问题是NP-完全的,需要从另一个NP-完全问题变换到它。第一个被证明是NP-完全的问题是可满足性问题,它是被直接证明的。

哈密尔顿回路问题、巡回售货员问题、最长路径问题、装箱问题、背包问题、图的着色问题和团的问题等都是NP-完全问题。

0 0