图
一个图 G=(V,E) 由顶点集 V 和边集 E 组成。每条边是一个点对 (v,w) ,其中 v,w∈V 。如果点对是有序的,则图称为有向图。顶点 v 和 w 邻接当且仅当 (v,w)∈E 。有时边还有权或值属性。
图中的路径是一个顶点序列 w1,w2,...,wN ,满足 (wi,wi+1)∈E , 1≤i<N 。路径的长为该路径的边数 N−1 。顶点到自身可以看作有不包含边的路径,路径长为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](http://www.yeolar.com/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 的值为 ∑N−1i=1ci,i+1 ,称为赋权路径长,对应地无权路径长为边数 N−1 。
单源最短路径问题:给定一个赋权图 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-完全问题。