博弈論詳解

Suzt_ilymtics發表於2021-01-31


寫在前面

因為圖論專題考試考到了博弈論,然後就跑過來通了一遍
至於圖論考試為什麼會扯到博弈論?我不知道,就很奇怪

正文

何為博弈論?

博弈論 ,是經濟學的一個分支,主要研究具有競爭或對抗性質的物件,在一定規則下產生的各種行為。博弈論考慮遊戲中的個體的預測行為和實際行為,並研究它們的優化策略。

詳細解釋可以請自行百度百科

先看一個簡單的例題

先來看一道小學就接觸過的思維題

你和好基友在玩一個取石子游戲。面前有30顆石子,每次只能取一顆或兩顆,你先取,取完的人為勝,問你是否有必勝策略

Q:什麼?有必勝策略?能否勝利不應該隨著我們選擇而改變嗎?
A:確實。但如果我們足夠聰明呢?每次都做最優的選擇,把取勝之路留給自己
Q:我一點也不聰明,那該如何做呢?

先從簡單入手,
假如只有一個或兩個石子,無疑先手必勝
只有三個石子,無疑先手必輸

(我們約定先手必敗狀態必敗狀態先手必勝狀態必勝狀態)
這就是我們的終止狀態,即無論怎麼拿,都會回到這幾個狀態
因為我們想贏,所以我們要讓自己處於必勝狀態,即剩下一個或兩個石子的時候,我們是先手。不難發現,我們也許不能使自己處於必勝態,但我們可以讓對方處於必敗態。即剩下三個石子的時候,我們是後手。

不難發現,只要是三的倍數就一定是必敗狀態,否則就是必勝狀態。
證明:
假設不是三的倍數,我們使它成為三的倍數,此時我們是後手。對方如果拿一個,我們就拿兩個;如果拿兩個,我們就拿一個。所以我們那完後剩下的一定永遠是三的倍數,所以只剩下三個石子的時候我們一定是後手,此時對手必輸,也就是我們必勝。
假設是三的倍數,因為兩個人都足夠聰明,所以對方一定會使我們永遠處於三的倍數中。所以我們必敗。
所以只要判斷是不是三的倍數,就可以確定我們是否必勝了

至此,小學時代遺留的問題已經解決了可以拿去欺負同學,(這也是博弈論最基礎的問題,Nim遊戲)
可以說,你已經學會博弈論了

現在,讓我們對自己的思考做一下規範

博弈圖和狀態

把每個可到達的狀態都看做結點,每次做出決策都是從舊的狀態轉移到新的狀態,也就是在兩個狀態結點間連一條有向邊。如果把所有狀態轉移都畫出來,我們就得到了一張博弈圖

就像這樣
就像這樣

大多數博弈圖會是一個DAG,否則遊戲不可能結束

三個基本定理

通過推理不難得到這幾個定理

  • 定理一:沒有後繼狀態的狀態是必敗狀態
  • 定理二:一個狀態是必勝狀態 當且僅當 存在至少一個必敗狀態為它的後繼狀態。
  • 定理三:一個狀態是必敗狀態 當且僅當 它的所有後繼狀態均為必勝狀態。

對於定理一,遊戲進行不下去了,即這個玩家沒有可操作的了,那麼這個玩家就輸掉了遊戲

對於定理二,如果該狀態至少有一個後繼狀態為必敗狀態,那麼玩家可以通過操作到該必敗狀態;此時對手的狀態為必敗狀態,即對手必定是失敗的,而相反地,自己就獲得了勝利。

對於定理三,如果不存在一個後繼狀態為必敗狀態,那麼無論如何,玩家只能操作到必勝狀態;此時對手的狀態為必勝狀態——對手必定是勝利的,自己就輸掉了遊戲。

如果博弈圖是一個有向無環圖,則通過這三個定理,我們可以在繪出博弈圖的情況下用 \(O(n + m)\) 的時間(其中 \(n\) 為狀態種數, \(m\) 為邊數)得出每個狀態是必勝狀態還是必敗狀態。(利用拓撲排序

Nim 和

讓我們回顧Nim遊戲,顯然我們可以通過構建博弈圖獲得是否必勝
但這樣的複雜度是 \(O(\begin{matrix} \prod_{i=1}^n a_i \end{matrix})\),顯然不能接受。

有沒有什麼快速簡單的方法呢?
定義 Nim 和 = \(a_1 \oplus a_2 \oplus a_3 \oplus ... \oplus a_n\)
當且僅當 Nim 和 為 \(0\) 時,該狀態為必敗狀態;否則該狀態為必勝狀態。

證明過程詳見Oi-wiki
其實是我沒看懂
後面內容也一定程度上會證明這個定理

有向圖遊戲和SG函式

有向圖遊戲是一個經典的博弈遊戲——實際上,大部分的公平組合遊戲都可以轉換為有向圖遊戲。

在一個有向無環圖中,只有一個起點,上面有一個棋子,兩個玩家輪流沿著有向邊推動棋子,不能走的玩家判負。

定義 \(mex\) 函式的值為不屬於集合 \(S\) 中的最小非負整數,即:

\[mex(S) = min\{x\} \ \ (x \notin S,x\in \mathbb{N}) \]

例如 \(mex(\{ 0, 1, 3, 4\}) = 1\)\(mex(\{1,2 \}) = 0,mex(\{\}) = 0\)

對於狀態 \(x\) 和它的所有 \(k\) 個後繼狀態 \(y_1,y_2,...,y_k\),定義 \(SG\) 函式:

\[SG(x) = mex\{SG(y_1),SG(y_2),...,SG(y_k)\} \]

SG定理:

而對於由 \(n\) 個有向圖遊戲組成的組合遊戲,設它們的起點分別為 \(s_1,s_2,...,s_n\) ,則有定理: 當且僅當 \(SG(s_1) \oplus SG(s_2) \oplus ... \oplus SG(s_n) \ne 0\) 時,這個遊戲是先手必勝的。

還是拿原來那個圖開刀

就像這樣

\(SG[]\) 陣列來存所有結點的 \(SG\) 函式值
因為 \(9,3,8,10,4\) 這幾個點都沒有後繼狀態,所以它們 \(SG\) 值均為 \(0\),同理推出 2,7,5這個點的 \(SG\) 值為 \(1\),而

\[SG[6] = mex(SG[7],SG[8]) = 2 \]

\[SG[1] = mex(SG[2],SG[5],SG[6]) = 0 \]

把 Nim遊戲 轉化為有向圖遊戲

我們可以將一個有 \(x\) 個物品的堆視為節點 \(x\) ,拿掉若干個石子後剩下 \(y\)個,則當且僅當 \(0 < y < x\) 時,節點 \(x\) 可以到達 \(y\)

那麼,由 \(n\) 個堆組成的 Nim 遊戲,就可以視為 \(n\) 個有向圖遊戲了。

根據上面的推論,可以得出 \(SG(x) = x\) 。再根據 SG 定理,就可以得出 Nim 和的結論了。

博弈論DP

不得不說,博弈論DP就是個神仙做法,能有博弈論DP做的都是神仙題!

並沒有什麼固定的做法,但基本原理還是照著那三個定理來。能用DP的一般是因為想不出來如何用 \(SG\) 定理。狀態的設計都比較神仙,主要是根據題目要求來設計。

可以參考一下下面兩個博弈論DP習題找找感覺,我也不是很會,主要是學會如何去設計狀態。

博弈論習題

1、取石子游戲 1
2、取石子游戲 2
3、移棋子游戲

其實這三道題大體思路上面都講過了,比較基礎

4、取石子游戲

Describe

同樣是n堆石子,只不過可取的石子數只有m個數,求先手必勝還是先手必敗,並輸出第一次取的方案

Solution

現根據 \(m\) 個數預處理出 \(1000\) 以內的數的 \(SG\) 值,再將 \(n\) 堆石子的數量異或,如果是 \(0\) 先手必敗,反之先手必勝。
尋找一個方案:在從第一堆石子開始,一次拿取所有能拿取的情況,並判斷能否達成必勝條件。必勝條件是拿去列舉的拿取石子數量後,剩下的石子數異或起來為 \(0\) ,因為你拿了一次石子後你就變成後手了

Code

/*
Work by: Suzt_ilymics
Knowledge: ??
Time: O(??)
*/
#include<iostream>
#include<cstdio>
#include<cstring>
#include<algorithm>
#define LL long long
#define orz cout<<"lkp AK IOI!"<<endl

using namespace std;
const int MAXN = 1e5+5;
const int INF = 1e9+7;
const int mod = 1e9+7;

int n, m;
int a[15], SG[MAXN], val[15];
int pos[15];
bool vis[MAXN];

int read(){
	int s = 0, f = 0;
	char ch = getchar();
	while(!isdigit(ch))  f |= (ch == '-'), ch = getchar();
	while(isdigit(ch)) s = (s << 1) + (s << 3) + ch - '0' , ch = getchar();
	return f ? -s : s;
}

void init_SG(){
	SG[0] = 0;
	for(int i = 1; i <= 1000; ++i){
//		int maxm = -1;
		memset(vis, false, sizeof vis);
		for(int j = 1; j <= m && (i - val[j]) >= 0; ++j){
			vis[SG[i - val[j]]] = true;
//			maxm = max(maxm, SG[i - val[j]]);
		}
		int j = 0;
		while(vis[j]) j++;
		SG[i] = j;
	}
}

bool check(int x, int y){
	for(int i = 1; i <= n; ++i){
		pos[i] = a[i];
	}
	pos[x] -= y;
	int ans = 0;
	for(int i = 1; i <= n; ++i)	ans ^= SG[pos[i]];
	if(ans) return false;
	return true;
}

int main()
{
	n = read();
	for(int i = 1; i <= n; ++i) a[i] = read();
	m = read();
	for(int i = 1; i <= m; ++i) val[i] = read();
	init_SG();
	int ans = 0;
	for(int i = 1; i <= n; ++i) ans ^= SG[a[i]];
	if(ans) {
		printf("YES\n");
		for(int i = 1; i <= n; ++i){
			for(int j = 1; j <= m && (a[i] - val[j]) >= 0; ++j){
				if(check(i, val[j])) {
					printf("%d %d", i, val[j]);
					return 0;
				}
			}
		}
	}
	else printf("NO");
	return 0;
}

5、S-Nim

Describe

和第二題一樣,就是多了 \(T\) 組資料,每組資料又有多輪遊戲,每輪遊戲如果存在先手必勝輸出 \(W\) 否則輸出 \(L\)

Solution

直接根據Nim和來做就好了,需要注意的是每次都要預處理一遍 \(SG\) 函式,每次預處理之前都要拍一遍序

Code

/*
Work by: Suzt_ilymics
Knowledge: ??
Time: O(??)
*/
#include<iostream>
#include<cstdio>
#include<cstring>
#include<algorithm>
#define LL long long
#define orz cout<<"lkp AK IOI!"<<endl

using namespace std;
const int MAXN = 1e5+5;
const int INF = 1e9+7;
const int mod = 1e9+7;

int k, m, n;
int SG[10010], val[110];
int vis[10010];

int read(){
	int s = 0, f = 0;
	char ch = getchar();
	while(!isdigit(ch))  f |= (ch == '-'), ch = getchar();
	while(isdigit(ch)) s = (s << 1) + (s << 3) + ch - '0' , ch = getchar();
	return f ? -s : s;
}

void init_SG(){
	SG[0] = 0;
	for(int i = 1; i <= 10000; ++i){
		memset(vis, false, sizeof vis);
		for(int j = 1; j <= k && (i - val[j]) >= 0; ++j){
			vis[SG[i - val[j]]] = true;
		}
		int j = 0;
		while(vis[j]) ++j;
		SG[i] = j;
	}
}

int main()
{
//	freopen("test1.in","r",stdin);
//	freopen("test.out","w",stdout);
	while(true){
		memset(SG, 0, sizeof SG);
		k = read();
		if(!k) break;
		for(int i = 1; i <= k; ++i) val[i] = read();
		sort(val + 1, val + k + 1);
		init_SG();
		m = read();
		for(int j = 1; j <= m; ++j){
			n = read();
			int ans = 0;
			for(int i = 1; i <= n; ++i) ans ^= SG[read()];
			if(ans) printf("W");
			else printf("L");
		}
		printf("\n");
	}
	return 0;
}

6、巧克力棒

Describe

一共10輪,每次一人可以從盒子裡取出若干條巧克力棒,或是將一根取出的巧克力棒吃掉正整數長度。TBL 先手兩人輪流,無法操作的人輸。如果勝輸出 \(NO\) ,負輸出 \(YES\)

Solution

需要對Nim博弈有深入的瞭解,這題如果不用取巧克力,就是典型的Nim博弈。
我們知道,Nim博弈,如果異或和為0則是必敗狀態,所以,如果先手拿出幾根巧克力異或和不為0,後手就可以使異或和變為0,此時先手再拿,後手又可以通過操作使異或和變為0。
所以,先手要想取勝,必須先拿出最大的異或和為0的集合,此時後手無論怎麼操作,都會使異或和變為不等於0。所以,如果有異或和為0的集合,先手必勝。如果沒有,先手必輸。因為n很小,所以直接暴搜判斷即可。

Code

/*
Work by: Suzt_ilymics
Knowledge: ??
Time: O(??)
*/
#include<iostream>
#include<cstdio>
#include<cstring>
#include<algorithm>
#define LL long long
#define orz cout<<"lkp AK IOI!"<<endl

using namespace std;
const int MAXN = 1e5+5;
const int INF = 1e9+7;
const int mod = 1e9+7;

int n; 
int a[22];

int read(){
	int s = 0, f = 0;
	char ch = getchar();
	while(!isdigit(ch))  f |= (ch == '-'), ch = getchar();
	while(isdigit(ch)) s = (s << 1) + (s << 3) + ch - '0' , ch = getchar();
	return f ? -s : s;
}

bool dfs(int pos, int val, int cnt){
	if(cnt && !val) return true;
	if(pos > n) return false;
	if(dfs(pos + 1, val, cnt)) return true;
	if(dfs(pos + 1, val ^ a[pos], cnt + 1)) return true;
	return false;
}

int main()
{
	for(int i = 1; i <= 10; ++i){
		n = read();
		for(int j = 1; j <= n; ++j) a[j] = read();
		dfs(1, 0, 0) ? printf("NO\n") : printf("YES\n");
	}
	return 0;
}

博弈論DP習題

7、取石子

Describe

同樣n堆石子,兩種操作,拿一個或者合併其中兩堆,不能操作的人輸

Solution

參考的這篇部落格

把一個石子的堆的數量作為一個狀態,將多個石子的堆的數量作為一個狀態跑搜尋,同時用f陣列來記錄答案減少搜尋量

把自己的理解放到了註釋裡,看程式碼吧

Code

/*
Work by: Suzt_ilymics
Knowledge: ??
Time: O(??)
*/
#include<iostream>
#include<cstdio>
#include<cstring>
#include<algorithm>
#define LL long long
#define orz cout<<"lkp AK IOI!"<<endl

using namespace std;
const int MAXN = 1010;
const int INF = 1e9+7;
const int mod = 1e9+7;

int T, n;
int f[55][55 * MAXN];

int read(){
	int s = 0, f = 0;
	char ch = getchar();
	while(!isdigit(ch))  f |= (ch == '-'), ch = getchar();
	while(isdigit(ch)) s = (s << 1) + (s << 3) + ch - '0' , ch = getchar();
	return f ? -s : s;
}

int dfs(int cnt, int stp){
	if(cnt <= 0 && stp <= 0) return 0;//如果沒有石子了,遊戲結束
	if(f[cnt][stp] != -1) return f[cnt][stp];//如果以前搜到過,直接返回儲存的值,減少搜尋複雜度
	if(cnt <= 0) return f[cnt][stp] = (stp & 1);//只剩下熱鬧堆值,只需要判斷熱鬧堆裡石子的奇偶性就好了
	if(stp == 1) return f[cnt][stp] = dfs(cnt + 1, 0);//如果熱鬧堆還剩下一個石子,就變成了一個寂寞堆
	
	f[cnt][stp] = 0;//先賦為0,後面再看看是否有使這個狀態必勝的後續狀態
	
	if(cnt && !dfs(cnt - 1, stp)) return f[cnt][stp] = 1;//從寂寞堆裡拿一顆石子
	if(stp && !dfs(cnt, stp - 1)) return f[cnt][stp] = 1;//從熱鬧堆裡拿一顆石子
	if(cnt && stp && !dfs(cnt - 1, stp + 1)) return f[cnt][stp] = 1;//將寂寞堆合併到熱鬧堆裡
	if(cnt > 1 && !dfs(cnt - 2, stp + 2 + (stp ? 1 : 0))) return f[cnt][stp] = 1;//將兩個寂寞堆合併,至於後面為啥多加個1?還不是很懂
	return f[cnt][stp]; 
}

int main()
{
	T = read();
	memset(f, -1, sizeof f);
	while(T--){
		int cnt = 0, stp = 0;
		n = read();
		for(int i = 1, x; i <= n; ++i) x = read(), (x == 1) ? ++cnt : stp = (stp + x + 1);
		//通過題解的論證,發現熱鬧堆的合併並不影響結果,所以直接合並起來
		if(stp) stp--;//少合併一次要減一(感覺這裡有問題?)
		dfs(cnt, stp) ? puts("YES") : puts("NO");
	}
	return 0;
}

8、取石子游戲

Describe

n堆石子,一次取任意個,但是隻能從第一堆或者最後一堆取,求是否先手必勝

Solution

yyb神仙%%%!

洛谷題解

設的神仙狀態,建議親自觀摩;還有一個很神奇的做法也能過,可惜正確性不能保證

Code

DP:

/*
Work by: Suzt_ilymics
Knowledge: ??
Time: O(??)
*/
#include<iostream>
#include<cstdio>
#include<cstring>
#include<algorithm>
#define LL long long
#define orz cout<<"lkp AK IOI!"<<endl

using namespace std;
const int MAXN = 1e3+5;
const int INF = 1e9+7;
const int mod = 1e9+7;

int T, n;
int a[MAXN], L[MAXN][MAXN], R[MAXN][MAXN];

int read(){
	int s = 0, f = 0;
	char ch = getchar();
	while(!isdigit(ch))  f |= (ch == '-'), ch = getchar();
	while(isdigit(ch)) s = (s << 1) + (s << 3) + ch - '0' , ch = getchar();
	return f ? -s : s;
}

int main()
{
	T = read();
	while(T--){
		n = read();
		for(int i = 1; i <= n; ++i) L[i][i] = R[i][i] = a[i] = read();
		for(int len = 2; len <= n; ++len){
			for(int i = 1, j = i + len - 1; j <= n; ++i, ++j){
				int x = a[j], l = L[i][j - 1], r = R[i][j - 1];
				if(x == r) L[i][j] = 0;
				else if((x < l && x < r) || (x > l && x > r)) L[i][j] = x;
				else if(r < x && x < l) L[i][j] = x - 1;
				else L[i][j] = x + 1;
				
				x = a[i], l = L[i + 1][j], r = R[i + 1][j];
				if(x == l)  R[i][j] = 0;
				else if((x < l && x < r) || (x > l && x > r)) R[i][j] = x;
				else if(r < x && x < l) R[i][j] = x + 1;
				else R[i][j] = x - 1; 
 			}
		}
		printf("%d\n", (L[2][n] == a[1]) ? 0 : 1);
	}
	return 0;
}

奇技淫巧:(雖然過了但已被Hack)
主要是判斷最外邊兩個堆的關係,看能不能讓對手先拿裡面的堆

/*
Work by: Suzt_ilymics
Knowledge: ??
Time: O(??)
*/
#include<iostream>
#include<cstdio>
#include<cstring>
#include<algorithm>
#define LL long long
#define orz cout<<"lkp AK IOI!"<<endl

using namespace std;
const int MAXN = 1e5+5;
const int INF = 1e9+7;
const int mod = 1e9+7;

int T, n;
int a[MAXN];

int read(){
	int s = 0, f = 0;
	char ch = getchar();
	while(!isdigit(ch))  f |= (ch == '-'), ch = getchar();
	while(isdigit(ch)) s = (s << 1) + (s << 3) + ch - '0' , ch = getchar();
	return f ? -s : s;
}

int main()
{
	T = read();
	while(T--){
		n = read();
		int ans = 0;
		for(int i = 1; i <= n; ++i)	a[i] = read();
		if(abs(a[1] - a[n]) <= 1){
			if(a[1] != 1 && a[n] != 1) printf("0\n");
			else printf("1\n");
		}
		else printf("1\n"); 
	}
	return 0;
}

如果本文有什麼錯誤,或者您有什麼問題,請在評論區提出。

相關文章