作者: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); } }
上面演算法中有一個遍歷所有節點的for迴圈,而迴圈中的find_next()函式也會遍歷所有的節點。因此,演算法複雜度為[$O(|V|^2)$]。
在find_next()中,我們將放入序列的節點入度記為-1。find_next()每次會遍歷所有的節點,沒有必要遍歷入度為-1的節點。為了改善演算法複雜度,我們可以使用一個佇列(或者棧)來記錄入度為0的元素。我們每次從佇列中取出一個元素放入拓撲序列,並更新相鄰元素的入度。如果該元素的相鄰元素的入度變為0,那麼將它們放入佇列中。可以證明,這樣的改造後,演算法複雜度為[$O(|V|+|E|)$]
問題擴充
通過上面的演算法,我們可以獲得一個合法的科技發展序列。我隨後想到一個問題,如何輸出所有的科技發展序列呢?
這個問題的關鍵在於,某個時刻,可能同時有多個節點的入度同時為0。它們中的任何一個都可以成為下一個元素。為此,我們需要記錄所有的可能性。
(我想到的,是複製入度陣列和序列陣列,以儲存不同的可能性。不知道大家有什麼更好的想法呢?)
總結
圖,拓撲排序
歡迎繼續閱讀“紙上談兵: 演算法與資料結構”系列。