紙上談兵: 拓撲排序

Vamei發表於2013-08-03

作者:Vamei 出處:http://www.cnblogs.com/vamei 歡迎轉載,也請保留這段宣告。謝謝!

 

《文明》是一款風靡20多年的回合制策略遊戲,由Sid Meier開發。《文明》結構巨集大,內容豐富,玩法多樣,遊戲性強,稱得上是歷史上最偉大的遊戲。在文明中,你可以選擇某個文明的,從部落開始發展,在接下來的幾千年的歷史中,發展科技、開荒拓野、發動戰爭等等。遊戲在保持自由度的前提下,對各個社會文明的發展順序有很好的模擬性,讓玩家彷彿置身於歷史長河,坐觀文明的起落。美國的一些大學的歷史系甚至於使用該遊戲作為教學工具。

(作為《文明》的忠實愛好者,多少個晝夜耗費在一張張地圖上啊。好吧,是為了學習歷史。)

 

“科技樹”

《文明》中的科技樹

遊戲有一個“科技樹”系統,即你可以按照該圖所示的順序來發展科技。“科技樹”是這樣一個概念: 為了研發某個科技,我們必須已經掌握了該科技的所有前提科技(prerequisite)。科技樹中有一些箭頭,從A科技指向B科技,那麼A就是B的前提科技。比如,根據上面的科技樹,為了研發物理(Physics),我們必須已經掌握了化學(Chemistry)和天文學(Astronomy)。而為了研發化學(Chemistry),我們又必須掌握了火藥(Gunpowder)。如果一個科技沒有其它科技指向,那麼就不需要任何前提科技就可以研發,比如圖中的封建主義(Feudalism)。如果同一時間只能研發一項科技,那麼玩家的科技研發就是一個序列,比如封建主義,工程(Engineering),發明(Invention),火藥,一神教(monotheism),騎士制度(Chivalry)…… 有些序列是不合法的,比如工程,發明,火藥……,在研發的“發明”時,並沒有滿足“封建主義”的前提條件。

 

一個有趣的問題是,如何找到合法的序列呢?當我們在設計計算機玩家時,很可能需要解決這樣一個問題。

圖(graph)中的拓撲排序演算法(Topological Sort)可以給出一個合法序列。雖然在遊戲中被稱為“科技樹”,但“科技樹”並不符合資料結構中的樹結構。在資料結構中,樹的每個節點只能由一個父節點,整個樹只有一個根節點。因此,“科技樹”是一個不折不扣的圖結構。此外,該樹是一個有向的無環(acyclic)圖。圖中不存在環 (cycle, 環是一個長度大於0的路徑,其兩端為同一節點)。

 

拓撲排序

拓撲排序利用了一個事實,即在一個無環網路中,有一些節點是沒有箭頭指入的,比如科技樹中的一神教、封建主義、工程。它們可以作為序列的起點。

  • 選擇沒有箭頭指入的節點中的任一個,可以作為合法序列的起點,放入序列。
  • 當我們將某個節點放入序列後,去掉該節點以及從該節點指出的所有箭頭。在新的圖中,選擇沒有箭頭指向的節點,作為序列中的下一個點。
  • 重複上面的過程,直到所有的節點都被放入序列。

 

對於每個節點,我們可以使用一個量入度(indegree),用來表示有多少個箭頭指入該節點。當某個節點被刪除時,圖發生變化,我們需要更新圖中節點的入度。

為了方便,我將“科技樹”中的節點編號,為了符合C語言中的傳統,編號從0開始。下面是對應關係:

0 1 2 3 4 5 6 7 8 9
一神教 神學 印刷術 民主 自由藝術 騎士制度 音樂理論 銀行  經濟學 封建主義
10 11 12 13 14 15 16 17 18 19
教育 天文學 航海術 物理  重力理論 發明 火藥 化學 磁學 工程
20 21                
冶金 軍事傳統                

 

我們根據編號,繪製上述圖(graph)。我同時用三種顏色,來表示不同點的入度(indegree):

我們使用鄰接表的資料結構(參考紙上談兵: 圖),來實現圖。構建程式碼如下:

/* By Vamei */
#include <stdio.h>
#include <stdlib.h>

#define NUM_V 22

typedef struct node *position;

/* node */
struct node {
    int element;
    position next;
};

/* 
 * operations (stereotype)
 */
void insert_edge(position, int, int);
void print_graph(position graph, int nv);

/* for testing purpose */
void main()
{
    struct node graph[NUM_V];
    int i;

    // initialize the vertices
    for(i=0; i<NUM_V; i++) {
        (graph+i)->element = i;
        (graph+i)->next    = NULL;
    }

    // insert edges
    insert_edge(graph,0,1);
    insert_edge(graph,0,5);
    insert_edge(graph,1,2);
    insert_edge(graph,1,10);
    insert_edge(graph,2,3);
    insert_edge(graph,3,4);
    insert_edge(graph,7,8);
    insert_edge(graph,9,5);
    insert_edge(graph,9,15);
    insert_edge(graph,10,6);
    insert_edge(graph,10,7);
    insert_edge(graph,10,11);
    insert_edge(graph,11,12);
    insert_edge(graph,11,13);
    insert_edge(graph,13,14);
    insert_edge(graph,13,18);
    insert_edge(graph,15,16);
    insert_edge(graph,16,17);
    insert_edge(graph,17,13);
    insert_edge(graph,17,20);
    insert_edge(graph,19,15);
    insert_edge(graph,20,21);

    print_graph(graph,NUM_V);
}

/* print the graph */
void print_graph(position graph, int nv) {
    int i;
    position p;
    for(i=0; i<nv; i++) {
        p = (graph + i)->next;
        printf("From %3d: ", i);
        while(p != NULL) {
            printf("%d->%d; ", i, p->element);
            p = p->next;
        }
        printf("\n");
    }
}

/*
 * insert an edge
 */
void insert_edge(position graph,int from, int to)
{
    position np;
    position nodeAddr;

    np = graph + from;

    nodeAddr = (position) malloc(sizeof(struct node));
    nodeAddr->element = to;
    nodeAddr->next    = np->next;
    np->next = nodeAddr;
}

 

我們可以根據上面建立的圖,來獲得各個節點的入度。使用一個陣列indeg來儲存各個節點的入度,即indeg[i] = indgree of ith node。下面的init_indeg()用於初始化各節點的入度:

// according to the graph, initialize the indegree at each vertice
void init_indeg(position graph, int nv, int indeg[]) {
    int i;
    position p;

    // initialize
    for(i=0; i<nv; i++) {
        indeg[i] = 0;
    }

    // assimilate the graph
    for(i=0; i<nv; i++) {
        p = (graph + i)->next;
        while(p != NULL) {
            (indeg[p->element])++;
            p = p->next;
        }
    }   
}

 

正如我們之前敘述的,我們需要找到入度為0的節點,並將這些節點放入序列。find_next()就是用於尋找下一個入度為0的節點。找到對應節點後,返回該節點,並將該節點的入度更新為-1:

/* find the vertice with 0 indegree*/
int find_next(int indeg[], int nv) {
    int next;
    int i;
    for(i=0; i<nv; i++) {
        if(indeg[i] == 0) break;
    }
    indeg[i] = -1;

    return i;
}

 

當我們取出一個節點放入序列時,從該節點出發,指向的節點的入度會減1,我們用update_indeg()表示該操作:

// update indeg when ver is removed
void update_indeg(position graph, int nv, int indeg[], int ver) {
    position p;
    p = (graph + ver)->next;
    while(p != NULL) {
        (indeg[p->element])--;
        p = p->next;
    }
}

 

有了上面的準備,我們可以尋找序列。使用陣列seq來記錄序列中的節點。下面的get_seq()用於獲得拓撲排序序列:

// return the sequence
void get_seq(position graph, int nv, int indeg[], int seq[]){
    int i;
    int ver;
    for(i = 0; i<nv; i++) {
        ver = find_next(indeg, nv);
        seq[i] = ver;
        update_indeg(graph, nv, indeg, ver);
    }
}

 


綜合上面的敘述,我們可以使用下面程式碼,來實現拓撲排序:

/* By Vamei */
#include <stdio.h>
#include <stdlib.h>

#define NUM_V 22

typedef struct node *position;

/* node */
struct node {
    int element;
    position next;
};

/* 
 * operations (stereotype)
 */
void insert_edge(position, int, int);
void print_graph(position, int);
void init_indeg(position, int , int *);
void update_indeg(position, int, int *, int);
void get_seq(position, int, int *, int *);
int find_next(int *, int);

/* for testing purpose */
void main()
{
    struct node graph[NUM_V];
    int indeg[NUM_V];
    int seq[NUM_V];
    int i;
    
    // initialize the graph
    for(i=0; i<NUM_V; i++) {
        (graph+i)->element = i; 
        (graph+i)->next    = NULL;
    }

    
    insert_edge(graph,0,1);
    insert_edge(graph,0,5);
    insert_edge(graph,1,2);
    insert_edge(graph,1,10);
    insert_edge(graph,2,3);
    insert_edge(graph,3,4);
    insert_edge(graph,7,8);
    insert_edge(graph,9,5);
    insert_edge(graph,9,15);
    insert_edge(graph,10,6);
    insert_edge(graph,10,7);
    insert_edge(graph,10,11);
    insert_edge(graph,11,12);
    insert_edge(graph,11,13);
    insert_edge(graph,13,14);
    insert_edge(graph,13,18);
    insert_edge(graph,15,16);
    insert_edge(graph,16,17);
    insert_edge(graph,17,13);
    insert_edge(graph,17,20);
    insert_edge(graph,19,15);
    insert_edge(graph,20,21);

    print_graph(graph,NUM_V);
    
    init_indeg(graph,NUM_V,indeg);
    get_seq(graph, NUM_V, indeg, seq);
    for (i=0; i<NUM_V; i++) {
        printf("%d,", seq[i]);
    }
}

void print_graph(position graph, int nv) {
    int i;
    position p;
    for(i=0; i<nv; i++) {
        p = (graph + i)->next;
    printf("From %3d: ", i);
    while(p != NULL) {
        printf("%d->%d; ", i, p->element);
        p = p->next;
    }
    printf("\n");
    }
}
/*
 * insert an edge
 */
void insert_edge(position graph,int from, int to) 
{
    position np;
    position nodeAddr;
    
    np = graph + from;

    nodeAddr = (position) malloc(sizeof(struct node));
    nodeAddr->element = to;
    nodeAddr->next    = np->next;
    np->next = nodeAddr;    
}

void init_indeg(position graph, int nv, int indeg[]) {
    int i;
    position p;

    // initialize
    for(i=0; i<nv; i++) {
        indeg[i] = 0;
    }

    // update
    for(i=0; i<nv; i++) {
        p = (graph + i)->next;
        while(p != NULL) {
            (indeg[p->element])++;
            p = p->next;
        }
    }  
}

// update indeg when ver is removed
void update_indeg(position graph, int nv, int indeg[], int ver) {
    position p;
    p = (graph + ver)->next;
    while(p != NULL) {
        (indeg[p->element])--;
        p = p->next;
    }
}

/* find the vertice with 0 indegree*/
int find_next(int indeg[], int nv) {
    int next;
    int i;
    for(i=0; i<nv; i++) {
        if(indeg[i] == 0) break;
    }
    indeg[i] = -1;

    return i;
}

// return the sequence
void get_seq(position graph, int nv, int indeg[], int seq[]){
    int i;
    int ver;
    for(i = 0; i<nv; i++) {
        ver = find_next(indeg, nv);
        seq[i] = ver;
        update_indeg(graph, nv, indeg, ver);
    }
}
View Code

上面演算法中有一個遍歷所有節點的for迴圈,而迴圈中的find_next()函式也會遍歷所有的節點。因此,演算法複雜度為[$O(|V|^2)$]。

在find_next()中,我們將放入序列的節點入度記為-1。find_next()每次會遍歷所有的節點,沒有必要遍歷入度為-1的節點。為了改善演算法複雜度,我們可以使用一個佇列(或者棧)來記錄入度為0的元素。我們每次從佇列中取出一個元素放入拓撲序列,並更新相鄰元素的入度。如果該元素的相鄰元素的入度變為0,那麼將它們放入佇列中。可以證明,這樣的改造後,演算法複雜度為[$O(|V|+|E|)$]

 

問題擴充

通過上面的演算法,我們可以獲得一個合法的科技發展序列。我隨後想到一個問題,如何輸出所有的科技發展序列呢?

這個問題的關鍵在於,某個時刻,可能同時有多個節點的入度同時為0。它們中的任何一個都可以成為下一個元素。為此,我們需要記錄所有的可能性。

(我想到的,是複製入度陣列和序列陣列,以儲存不同的可能性。不知道大家有什麼更好的想法呢?)

 

總結

圖,拓撲排序

 

歡迎繼續閱讀“紙上談兵: 演算法與資料結構”系列。

 

相關文章