数据结构和算法学习笔记——图论

一个图  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-完全问题。

 http://www.yeolar.com/note/2012/05/30/ds-graph/

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值