版權申明:本文為博主窗戶(Colin Cai)原創,歡迎轉帖。如要轉貼,必須注明原文網址 http://www.cnblogs.com/Colin-Cai/p/8457744.html 作者:窗戶 QQ:6679072 E-mail:6679072@qq.com
前幾天,看到博客園里有人給了一道博弈:
事先給定一個正整數N,兩個人輪流給出一個2~9的整數。若之前兩人所有的數和當前自己報的數,其乘積大於等於N,則贏。
比如給定數為8,A先報數8,則A贏;給定數為100,A先報9,B報2,A再報9,從而9*2*9>=100,A贏。
首先想到的是minmax算法,這個是完全信息動態博弈的萬能算法。雖然很多時候minmax算法是不實用的,但是這里我們還是試一試,雖然在這個例子里,它依然不實用,但我們要看看為何不實用。
方法很簡單,首先構造完全博弈樹。我這里采用C語言寫,本想采用lisp(scheme)表達起來最方便,但因為lisp對於很多人可能不是那么友好。
對於完全博弈樹,每一個選擇就是一個節點。
typedef struct _node_t {
int score;/*分數,這里分數只有兩檔,WIN/LOSE*/
struct _node_t* next[8];/*以下代表着8種選擇,分別是2~9*/
} node_t;
接用指針數組中的偏移來代表所選擇的數字的好處是,看上去相對節省一點空間(實際上可能是一樣的)。
我的程序全部使用遞歸的方法來寫,應該相對容易理解。
首先,建立博弈樹是一個前序的過程,先建立樹根,然后依次建立各個子樹。
然后,再用minmax來依次標記所有博弈樹上節點。
代碼如下:
#include <stdio.h> #include <stdlib.h> #define WIN 1 #define LOSE 2 int node_cnt = 0;/*這個值的加入是為了觀察博弈樹的節點個數*/ typedef struct _node_t { int score;/*分數,這里分數只有兩檔,WIN/LOSE*/ struct _node_t* next[8];/*以下代表着8種選擇,分別是2~9*/ } node_t; node_t* create_tree(unsigned N);/*建立目標是N的博弈樹*/ void clear_tree(node_t* p);/*銷毀博弈樹*/ void play(node_t *p, unsigned N);/*計算機先手,與人博弈*/ int main(int argc, char **argv) { unsigned N; node_t *p; /*手動輸入目標N*/ printf("N = "); fflush(stdout); scanf("%u", &N); p = create_tree(N);/*建立目標是N的博弈樹*/ play(p, N);/*計算機先手,與人博弈*/ clear_tree(p);/*銷毀博弈樹*/ return 0; } /*遞歸建立博弈樹,current表示當前所有報數的乘積*/ node_t* _create_tree(unsigned N, unsigned current) { node_t *ret; unsigned i; ret = malloc(sizeof(node_t)); node_cnt++; if(current >= N) {/*如果達到終局條件,則節點為葉子*/ ret->next[0] = NULL;/*以next[0]為不為NULL來判斷次節點是不是葉子節點*/ return ret; } /*2~9這8種選擇遞歸構造博弈樹,傳入第二個參數應為current*(i+2),而不是current*i*/ for(i=0U;i<8U;i++) { ret->next[i] = _create_tree(N, current*(i+2U)); } return ret; } /*minmax算法,layer是節點層數,整個博弈樹的根的層數為1*/ void mark_tree(node_t* p, int layer) { if(p->next[0] == NULL) { if(layer%2 == 0) {/*如果是計算機的節點*/ p->score = WIN; } else { p->score = LOSE; } } else { int i; /*8種選擇遞歸標記*/ for(i=0;i<8;i++) { mark_tree(p->next[i], layer+1); } /*標記完了,則依然是minmax決定本節點的score*/ if(layer%2 == 1) {/*如果是計算機選擇的層*/ for(i=0;i<8;i++) { if(p->next[i]->score == WIN) break; } if(i<8) { p->score = WIN;/*子節點上只要有一個是WIN,這個節點就是WIN*/ } else { p->score = LOSE; } } else {/*如果是人選擇的層*/ for(i=0;i<8;i++) { if(p->next[i]->score == LOSE) break; } if(i<8) { p->score = LOSE;/*子節點上只要有一個是LOSE,這個節點就是LOSE*/ } else { p->score = WIN; } } } } node_t* create_tree(unsigned N) { node_t* ret; ret = _create_tree(N, 1U);/*遞歸構建博弈樹,未標記score*/ printf("node_cnt = %d\n", node_cnt); mark_tree(ret, 1);/*標記score*/ return ret; } /*遞歸銷毀*/ void clear_tree(node_t* p) { if(p->next[0] != NULL) { int i; for(i=0;i<8;i++) clear_tree(p->next[i]); } free(p); } /*既然博弈樹已出,計算機只要每次都把節點往WIN節點上趕就必勝了*/ /*如果計算機不是WIN節點,那么就出2,盡量拖長博,期待人犯錯*/ void play(node_t *p, unsigned N) { int layer = 1; int i; unsigned now = 1U; while(1) { if(layer%2) { if(p->score == WIN) { for(i=0;i<8;i++) { if(p->next[i]->score == WIN) { break; } } i += 2; } else { i = 2; } printf("computer: %d\n", i); now *= (unsigned)i; i -= 2; } else { printf("I: "); fflush(stdout); scanf("%u", &i); now *= (unsigned)i; i = i-2; } p = p->next[i]; printf("now: %u\n", now); if(now >= N) { printf("GAME OVER\n%s win\n", layer%2?"computer":"I"); return; } layer++; } }
程序中沒有判斷malloc是否成功也沒判斷人的選擇是否是2~9,因為這只是一個示例。
運行一下
# ./a.out N = 10000 node_cnt = 3271801 computer: 2 now: 2 I: 9 now: 18 computer: 2 now: 36 I: 5 now: 180 computer: 4 now: 720 I: 7 now: 5040 computer: 2 now: 10080 GAME OVER computer win
其中,node_cnt是觀察博弈樹的節點個數,如上,當N到10000的時候,節點個數3271801,非常龐大。
很顯然,這種方法不靠譜。我們必須要想辦法壓縮博弈樹,只壓縮到博弈樹的節點級,無論是存儲還是龐大的計算量,都足以壓垮我們的系統。當然,如果有個辦法大尺度的壓縮博弈樹,那么則是可行的。比如經典的博弈游戲搶30,每次報往后1-3,最終誰搶到目標數誰贏,其策略就是讓對方面對的剩余數為4的倍數,則己方立於不敗之地,這個本質上其實就是一個博弈樹的壓縮。只是可惜的是,博弈樹並沒有通用的如此簡化的壓縮手段,也是為什么像圍棋這樣復雜的博弈我們到了今天才算是個可能的解決。當然,本題也有大尺度的壓縮。
我們的博弈變形一下:初始的時候目標為N;雙方輪流選擇;每次假如目標為x,而自己選擇的是y(y為2~9的整數),如果y>=x,則勝利,否則,目標變為x/y。很顯然,這個博弈和之前提到的博弈完全等價,區別只在於,目標數在不斷變化,而不需要去記錄之前雙方的計數。而且,如果初始目標大於等於2,那么過程中的目標都大於1。帶來的方便就是,狀態變少,只有一個目標數和一個選擇人,只是目標數之前為正整數,這里的目標數為一個正實數(其實是有理數)。如此,為我們的處理帶來了方便。
我們去回憶minmax算法的思想,每次當博弈樹某節點面臨自己選擇的時候,都去選擇分數最大的節點。此博弈里,只有輸贏兩檔分數,那么如果輪到己方選擇,本節點被標為WIN,那么只需要在子節點中隨便找一個WIN的節點即可;而本節點被標為LOSE,就選擇一個2,拉長戰線,期望對方犯錯。那么,我們只需要一個方法推出所有先手必輸的正實數N,或者所有先手必勝的N,兩邊都有無窮多個,可幸運的是,本博弈完全可以把這些正實數歸納進一個個的區間。
我們試圖歸納一下:
首先,(1,9]是先手必勝點,因為只要選擇9就贏了。
(9,18]是先手必輸點,因為這個區間里無論選擇2~9里的哪個整數,都會讓對方落入(1,9]的先手必勝點。
(18,36]是先手必勝點,因為這個區間里只要選擇2,會讓對方落入(2,18]的先手必輸點;
(36,54]是先手必勝點,因為這個區間里只要選擇3,會讓對方落入(2,18]的先手必輸點;
....
(144,162]是先手必勝點,因為這個區間里只要選擇9,會讓對方落入(2,18]的先手必輸點;
從而(18,162]是先手必勝點。
(162,324]是先手必輸點,無論怎么選擇,都會落入(18.162]。
(324,2916]是先手必勝點。
...
很容易用數學歸納法證明出:
(18n,9 * 18n]為先手必勝點,(9 * 18n,18n+1]為先手必輸點,其中n為非負整數。
於是整個博弈就規約為對數的運算。於是我們就可以重新寫程序如下:
#include <stdio.h> #include <stdlib.h> #define WIN 1 #define LOSE 2 void play(unsigned N); int main(int argc, char **argv) { unsigned N; printf("N = "); fflush(stdout); scanf("%u", &N); play(N); return 0; } typedef struct { unsigned p; unsigned q; } num_t; void div_num(num_t *num, unsigned num2, unsigned *res) { unsigned i; i = num->p/(num->q * num2); if( num->p > num->q * num2 * i) i++;/*區間是左開右閉,不整除則加1*/ *res = i; } void play(unsigned N) { unsigned now = 1U; unsigned i; unsigned log_num; unsigned tmp; unsigned power_num[10]; int layer = 1; num_t num, num2; unsigned div_res; num.p = N; num.q = now; tmp = 1U; for(log_num=0;log_num<10;log_num++) { power_num[log_num] = tmp; if(N <= tmp) { log_num--; break; } tmp *= 18U; } while(1) { if(layer%2) { do { div_num(&num, power_num[log_num], &div_res); log_num--; } while(div_res < 2U); if(div_res > 9U) {/*落入先手必輸區間,那么給個2拉長等待對手犯錯*/ i = 2U; } else {/*必勝區間,就這么來吧*/ i = div_res; } printf("computer: %u\n", i); } else { printf("I : "); fflush(stdout); scanf("%u", &i); } now *= i; num.q = now; printf("now: %u\n", now); if(now >= N) { printf("GAME OVER\n%s win\n", layer%2?"computer":"I"); return; } layer++; } }
如此,本質上是用目標數對數級數量的區間去壓縮本來是目標數的乘冪級(指數可能1.618,沒有證明)數量的博弈樹,其運算量的差距可想而知。