[理论储备]博弈论

主要内容来自《挑战程序设计竞赛》以及《ACM国际大学生程序设计竞赛算法与实现》, 这里只是我的理解以及总结。也许会有很多不足,欢迎提出不同意见,谢谢!

一、游戏与必胜策略
1.硬币游戏1

Alice和Bob在玩这样一个游戏。给定k个数字a1, a2, …, ak。一开始,有x枚硬币,Alice和Bob轮流取硬币。每次所取硬币的枚数一定要在a1, a2, …, ak当中。Alice先取,取走最后一枚硬币的一方获胜。当双方都采取最优策略时,谁会获胜?题目假定a1, a2, …, ak中一定有1。

这是一个递推的过程:

  1. 当硬币为0时,此时取硬币的人必输
  2. 当j-ai为必输的,那么此时必赢;当j-ai为必赢的,那么此时必输
    代码:
#include <cstdio>

using namespace std;

const int MAXK = 105;
const int MAXN = 10005;

int k, x, A[MAXK]; 
bool win[MAXN]; // 当win[i]为true时表示有i个硬币时Alice赢, 为false表示Bob赢 

void read () {
	scanf("%d%d", &x, &k);
	for (int i = 0; i < k; i++) {
		scanf("%d", &A[i]);
	}
}

// 解决这个问题的核心代码
void solve () {
	win[0] = false; // 先手时Alice,此时Alice输了
	
	// 使用动态规划解决该问题 
	for (int i = 1; i <= x; i++) {
		win[i] = false; // 先设置为false是为了不影响或运算 
		for (int j = 0; j < k; j++) {
			// 因为都采用最优策略,所以只要有一种情况Bob必输,此时Alice就必赢, 否则Bob必赢 
			win[i] = win[i] | (A[j] <= i && !win[i - A[j]]);
		}
	} 
}

int main () {
	read();
	solve(); 
	if (win[x]) printf("Alice");
	else printf("Bob");
	return 0;
} 

2.A Funny Game

当第一个人取走第一枚或者两枚硬币,第二个只要同样取走一枚或两枚硬币,将硬币均匀的分为两个部分,之后只要第二个人永远模仿第一个人在另外一堆硬币里取硬币。那么第二个人一定会胜。唯一例外的情况,第一个一次就能将硬币取完。
代码:

#include <cstdio> 

using namespace std;

int main () {
	int n;
	while (scanf("%d", &n) && n != 0) {
		if (n <= 2) printf("Alice\n");
		else printf("Bob\n");
	}
	return 0;
}

在类似A Funny Game的游戏中,模仿对方往往是非常有效的
3.Euclid’s Game
这道题也是一个递推的过程:
先假设 b>a,

  1. b - a > a。那么如果下一态为必胜态,此态则为必败态。
  2. b - a >= a。此态永远为必胜态。因为如果1这种情况的下一态是必胜态,那么可以让对方先进入1这种情况,自己就会进入必胜态;如果1这种情况的下一态是必败态,那么就可以一次性把所有能减的都减完,对方就会进入必败态。

所以哪一个人先进入第二种状态,那个人就一定会胜。

#include <cstdio>
#include <algorithm>

using namespace std;

bool solve (long long a, long long b) {
	if (a > b) swap(a, b);
	if (a == b|| b >= 2 * a) return true;
	else return !solve(b-a, a);
}

int main () {
	long long a, b;
	while (scanf("%lld%lld", &a, &b) && a != 0) {
		if (solve(a, b)) printf("Stan wins\n");
		else printf("Ollie wins\n");
	}
	return 0;
} 

必胜态总能转移到某个必败态。
二、Nim

1.Nim游戏
对于Nim游戏先手赢的唯一条件就是A1 xor A2 xor A3 xor … xor An != 0.
原因如下:

  1. 必胜态时(XOR不为零时) , 总能从一堆石头中选出若干个石头使其XOR值变为0,进入必败态。
  2. 当必败态时(XOR为零时), 取走任意颗石头XOR一定不再为零,所以必败态一定会转移到必胜态
#include <cstdio>

using namespace std;

int N;
int main () {
	int t;
	scanf("%d", &t);
	while (t--) {
		scanf("%d", &N);
		int a = 0, b;
		for (int i = 1; i <= N; i++) {
			scanf("%d", &b);
			a = a ^ b;
		}
		if (a != 0) printf("Yes\n");
		else printf("No\n");
	} 
	return 0;
}

2.Georgia and Bob
当只有两颗棋子时,这两颗棋子靠在一起了,此时先手的人一定就是必败态,因为无论前面的一颗棋子怎么移动,后面的棋子都可以紧随其后。

那么我们可以将棋子从左到右,两两匹配分为一组。

这类似与nim游戏。每两枚棋子之间的间隔数就相当于石头数,到了最后没有间隔,就相当于取走了所有的石子。
代码:

#include <cstdio>
#include <algorithm>

using namespace std;

const int MAXN = 1005;

int p[MAXN], n;

void read () {
	scanf("%d", &n);
	for (int i = 1; i <= n; i++) {
		scanf("%d", &p[i]);
	}
	sort(p+1, p+n+1);
}

int main () {
	int t;
	scanf("%d", &t);
	while (t--) {
		read();
		int ans = 0;
		// 当棋子的个数为奇数时,第一颗棋子与最右边的边界配对 
		for (int i = n % 2 == 0 ? 2 : 1; i <= n; i += 2) {
			ans = ans ^ (p[i] - p[i-1] - 1);
		}
		if (ans != 0) printf("Georgia will win\n");
		else printf("Bob will win\n");
	}
	return 0;
}

三、SG函数和SG定理

1.基础概念

在组合游戏中把所有可能出现的状态看作是图的节点,如果从一个状态可以转移到达另一个状态,则在两点之间连一条有向边,这样就得到了一个状态图。如果游戏不会出现平局,那么该状态图会是一个有向无环图。所有的状态又可以分为两种,必胜态和必败态。任意一个必败态,要么它是一个终止状态,要么它可以转移到必胜态。任意一个必胜态,它至少有一个后继状态时必败态。

SG函数定义如下:对于任意状态x,它的SG函数值g(x) = mex{g(y) | y是x的后继状态},mex(S)为集合S中没有出现的最小非负整数。对于一个终止状态因为没有后继状态,所以SG函数值为0。

2.硬币游戏2

Alice和Bob在玩这样一个游戏。给定k个数字a1, a2, …, ak。一开始,有n堆数据,每堆有xi枚硬币,Alice和Bob轮流取硬币。每次所取硬币的枚数一定要在a1, a2, …, ak当中。Alice先取,取走最后一枚硬币的一方获胜。当双方都采取最优策略时,谁会获胜?题目假定a1, a2, …, ak中一定有1。

硬币游戏2与硬币游戏1类似,但由于有n堆硬币,使用动态规划就不太现实了。为了高效的解决该问题,就可以使用SG函数了。
当只有一堆硬币时,SG函数的使用方式如下:

void getSG (int x) {
	集合S = {}; 
	for (int j = 0; j <= k; j++) {
		if (a[j] <= i) {
			将getSG(x - a[j])添加入S中 
		}
	}
	return 最小的不属于S的非负整数 
} 

当前状态的sg值就是除任意一步能转移到的状态的sg值以外的最小非负整数。这样的sg值与Nim中的一个石头堆类似,具有如下性质:

  1. Nim中x颗石子的石子堆,能转换为0,1,…,x-1颗石子的石子堆;而从sg值为x的状态出发,可以转移到Grundy值为0,1,…,x-1的状态
  2. 虽然转移后的sg值可能增加,但是对方也总能将增加的值追赶回来,故对胜负没有影响

所以,我们可以将一个sg值看做一个Nim游戏中的一个石头堆。
因此利用sg值,很多游戏都可以转换成Nim游戏。
硬币游戏2的代码:

#include <cstdio>
#include <algorithm>
#include <cstring>

using namespace std;

const int MAXN = 1000005;
const int MAXK = 105;
const int MAXX = 10005;

int N, K, X[MAXN], A[MAXK], grundy[MAXX], SG[MAXX]; // SG函数值
bool S[MAXX]; // 相当于集合的作用 

void read () {
	scanf("%d%d", &N, &K);
	for (int i = 0; i < N; i++) {
		scanf("%d", &X[i]);
	}
	for (int i = 0; i < K; i++) {
		scanf("%d", &A[i]);
	}
} 

void getSG (int n) {
	for (int i = 1; i <= n; i++) {
		memset(S, 0, sizeof(S)); // 相当于重置集合
		for (int j = 0; j < K; j++) {
			if (A[j] <= i) S[SG[i-A[j]]] = true;
		} 
		for (int j = 0; ; j++) {
			if (!S[j]) {
				SG[i] = j;
				break;
			}
		}
	} 
}

int main () {
	read();
	getSG(*max_element(X, X+N));
	int x = 0;
	for (int i = 0; i < N; i++) {
		x ^= SG[X[i]];
	}
	if (x != 0) printf("Alice");
	else printf("Bob");
	return 0;
}

四、一些习题 以下的习题几乎时按照难度排序的

1.pb的游戏

分析:
这是一个递推的过程。

  1. m = 1时,先手无法再分,先手必输
  2. m = 2时,先手可以分为1和1,先手必赢
  3. m = 3时,先手只能分为1和2,先手必输
  4. m = 4时,先手分为1和3,后手只能选择3继续分,后手必输,先手必赢。
  5. m = 5时,无论是2+3还是1+4,都有一个数字令先手必输。
    ……
  6. m % 2 = 0时, 将其分为1和m-1,pb必赢
  7. m % 2 = 1时,将其分为i和j,其中一定有一个数字令先手必输

AC代码:

#include <cstdio>

using namespace std;

int main () {
	int N, M;
	scanf("%d", &N);
	for (int i = 0; i < N; i++) {
		scanf("%d", &M);
		if (M % 2 == 0) printf("pb wins\n");
		else printf("zs wins\n");
	}
	return 0;
} 

2.谁能赢呢?

分析:这道题只需要仔细想想就会发现,无论如何都有办法继续走,除非整个棋盘都已经被放满。

AC代码:

#include <cstdio> 

using namespace std;

int main () {
	int n;
	while (scanf("%d", &n) && n != 0) {
		if (n % 2 == 0) printf("Alice\n");
		else printf("Bob\n");
	} 
	return 0;
}

3.肥猫的游戏

分析:
这个应该按照黑色三角形有几条边需要切,和总共有几条边需要切来分情况。

  1. 当只有黑色三角只有一条边需要切,毫无疑问先手必胜
  2. 当不止一条边需要切,为了避免对方获胜,此时切边的人会尽力不切与黑色三角形的边,以避免对方切最后一次,对方获胜。故永远是切最后一条边的获胜。所有当n为奇数时有偶数条边需要切,后手胜。当n为偶数时,有奇数条边,先手胜。

AC代码:

#include <cstdio>
#include <algorithm>

using namespace std;

int p[3];

int main () {
	int n;
	scanf("%d", &n);
	
	// 输入黑色三角形的顶点 
	for (int i = 0; i < 3; i++) scanf("%d", &p[i]);
	sort(p, p+3);
	
	// 这些数据都是没有用的 
	for (int i = 3, x, y, z; i < n; i++) {
		scanf("%d%d%d", &x, &y, &z);
	}
	// p[0]+1 == p[1] && (p[1]+1 == p[2] || (p[0] - p[2] + n) % n == 1) 为真时黑色三角形只有一条边需要切
	if ((p[0]+1 == p[1] && (p[1]+1 == p[2] || (p[0] - p[2] + n) % n == 1)) || n % 2 == 0) {
		printf("JMcat Win");
	} else {
		printf("PZ Win");
	}
	 
	return 0;
}

4.三国游戏

分析:
首先,因为自己是先手,如果是选择最优先的情况下,自己一定是比对方的默契值要大的。但是,我们又不可能是最大的默契值。因为我们挑选拥有最大默契值的一对武将中的一个时,另外一个永远都会被电脑挑走。所以,我们只能努力挑选所有武将拥有第二大的默契值的一对。

若此时,我们先挑选5,对方一定会挑选4避免我们获得拥有默契值33的一对武将,此时我们挑选3就能拥有一对拥有默契值为32的武将。我们之后的任务,就是避免电脑的默契值不要超过我们。很显然,这是做得到的,我们只需要是尽量破坏对手下一步将形成的最强组合就可以了。

AC代码:

#include <cstdio>
#include <algorithm>

using namespace std;

const int MAXN = 505;
int val[MAXN][MAXN], N;

int main () {
	scanf("%d", &N);
	for (int i = 1; i < N; i++) {
		for (int j = i+1; j <= N; j++) {
			scanf("%d", &val[i][j]);
			val[j][i] = val[i][j];
		}
	}
	for (int i = 1; i <= N; i++) {
		sort(val[i]+1, val[i]+1+N);
	}
	int ans = 0;
	for (int i = 1; i <= N; i++) {
		ans = max(ans, val[i][N-1]);
	}
	printf("1\n%d\n", ans);
	return 0;
}

5.取数游戏II

分析:

  1. 当棋子左右边值都为零时,先手必败。
  2. 当棋子左边或右边有一个各一条边有边为零时,先手必胜,如下图所示。
  3. 每个人都想努力的到第二种情况,同时自己为了避免对方成为第二种情况。因此会将自己走过的边的值全部减少至0,当然即使你在自己会输的情况下不减少为0,对方为了赢也会减少。

所以我们应该从被选中的硬币,左右延长,分计算出离零那条边的距离。当存在奇数时,先手必胜,当都是偶数时,后手必胜。

AC代码:

#include <cstdio>

using namespace std;

const int MAXN = 25;

int p[MAXN], n;

int main () {
	scanf("%d", &n);
	for (int i = 0; i < n; i++) {
		scanf("%d", &p[i]);
	}
	int a = 0, b = 0;
	for (int i = n-1; p[i] != 0; i--) {
		a++;
	}
	for (int i = 0; p[i] != 0; i++) {
		b++;
	}
	if (a % 2 == 1 || b % 2 == 1) printf("YES");
	else printf("NO");
	return 0;
} 

6.取火柴游戏

分析:
这很显然是一个Nim游戏,判断会输会赢只要判断异或值是否为0就好了。当会赢时,我们需要改变一堆火柴的数量使其异或值变为0,进入必败态。

AC代码:

#include <cstdio>

using namespace std;

const int MAXN = 500005;

int p[MAXN], n;

int main () {
	int a = 0;
	scanf("%d", &n);
	for (int i = 1; i <= n; i++) {
		scanf("%d", &p[i]);
		a ^= p[i];
	}
	if (a == 0) {
		printf("lose");
	} else {
		int b;
		for (int i = 1; i <= n; i++) {
			b = a ^ p[i]; // 当p[i]变为b根火柴后,就会变为必败态
			// 判断是否有这么多根火柴,使其进入必败态 
			if (p[i] - b >= 0) {
				printf("%d %d\n", p[i] - b, i);
				p[i] = b;
				break;
			}
		}
		printf("%d", p[1]);
		for (int i = 2; i <= n; i++) {
			printf(" %d", p[i]);
		}
	}
	return 0;
} 

8.高手过招

分析:
首先,每一行都可以单独考虑,最后异或将sg值异或起来就可以了。
现在,我们开始观察每一行的处理方式。我们可以使用二进制来存储棋子的排放形式,有棋子时该位置的二进制为1,没有棋子的时候该棋子的二进制为0。而2^20大小的数组,是我们可以接受的。我们只需要先来打sg值的表,然后在运算就可以了。

AC代码:

#include <cstdio>
#include <cstring>

using namespace std;

const int MAXN = 1 << 20;

int sg[MAXN+100];
bool vis[MAXN+100], S[25];

// 干脆直接把所有的可能的sg值都写出来。 
void SG() {
	for (int x = 1; x <= MAXN; x++) {
		memset(S, 0, sizeof(S));
		int pos = 0; // 记录空格的位置 
		for (int i = 1; i <= 20; i++) {
			if (x & (1 << (i-1))) { // 当第i位有棋子
				if (pos) { // 如果右边有空格,跳到第一个空格位置。
					S[sg[x^(1 << (i-1))^(1 << (pos-1))]] = true;
				}
			} else {
				pos = i;
			}
		}
		for (int i = 0; ;i++) {
			if (!S[i]) {
				sg[x] = i;
				break;
			}
		}
	} 
}

int main () {
	SG();
	int T, n, m;
	scanf("%d", &T);
	while (T--) {
		scanf("%d", &n);
		int a = 0;
		for (int i = 0; i < n; i++) {
			int b = 0;
			scanf("%d", &m);
			for (int j = 0, c; j < m; j++) {
				scanf("%d", &c);
				b = b + (1 << (20-c));
			}
			a ^= sg[b];
		}
		if (a == 0) {
			printf("NO\n");
		} else {
			printf("YES\n");
		}
	} 
	
	return 0;
} 

8.[SDOI2009]E&D

分析:仔细观察就会发现,这道题和Nim游戏相似,每组石头都相当于Nim游戏中的一堆石头,我们现在需要计算一组石头的sg值。转移方式为(a, b) => (c, d) (c + d == a || c + d == b)。接着我就不懂应该怎么处理了,因为数据量实在是太大了。此时,我们可以选择打表,然后观察规律推出结论。
打表使用的代码如下:

#include <cstdio>
#include <cstring>

using namespace std;

const int MAXN = 25;
int sg[MAXN][MAXN];	
bool S[MAXN];

int SG (int a, int b) {
	for (int i = 1; i <= a; i++) {
		for (int j = 1; j <= i; j++) {
			memset(S, 0, sizeof(S));
			for (int k = 1; k < i; k++) {
				S[sg[k][i-k]] = true;
			}
			for (int k = 1; k < j; k++) {
				S[sg[k][j-k]] = true;
			}
			for (int k = 0; ;k++) {
				if (!S[k]) {
					sg[i][j] = sg[j][i] = k;
					break;
				}
			}
		}
	}
}

int main () {
	SG(20, 20);
	for (int i = 1; i < 20; i++) {
		for (int j = 1; j < 20; j++) {
			printf("%2d ", sg[i][j]);
		}
		printf("\n");
	}
	return 0;
}

打完表的结果:
0 1 0 2 0 1 0 3 0 1 0 2 0 1 0 4 0 1 0
1 1 2 2 1 1 3 3 1 1 2 2 1 1 4 4 1 1 2
0 2 0 2 0 3 0 3 0 2 0 2 0 4 0 4 0 2 0
2 2 2 2 3 3 3 3 2 2 2 2 4 4 4 4 2 2 2
0 1 0 3 0 1 0 3 0 1 0 4 0 1 0 4 0 1 0
1 1 3 3 1 1 3 3 1 1 4 4 1 1 4 4 1 1 3
0 3 0 3 0 3 0 3 0 4 0 4 0 4 0 4 0 3 0
3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 3 3 3
0 1 0 2 0 1 0 4 0 1 0 2 0 1 0 4 0 1 0
1 1 2 2 1 1 4 4 1 1 2 2 1 1 4 4 1 1 2
0 2 0 2 0 4 0 4 0 2 0 2 0 4 0 4 0 2 0
2 2 2 2 4 4 4 4 2 2 2 2 4 4 4 4 2 2 2
0 1 0 4 0 1 0 4 0 1 0 4 0 1 0 4 0 1 0
1 1 4 4 1 1 4 4 1 1 4 4 1 1 4 4 1 1 5
0 4 0 4 0 4 0 4 0 4 0 4 0 4 0 4 0 5 0
4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5
0 1 0 2 0 1 0 3 0 1 0 2 0 1 0 5 0 1 0
1 1 2 2 1 1 3 3 1 1 2 2 1 1 5 5 1 1 2
0 2 0 2 0 3 0 3 0 2 0 2 0 5 0 5 0 2 0

我们可以发现当开始时只要石头两堆石头不同时为奇数,那么先手就必胜。可这也没有太多的用处。因为这里不仅仅需要一对而是n对。接着我们继续观察,其实这是有规律的,请看:

每一框框里的值都分布类似。
再把每个框的0省略,只留下非零的数字,又会有这样一张图

当然, 还可以继续分下去。
其实,现在已经可以看出主要规律了。

  1. 当a和b同为奇数是 sg值为0
  2. 当a和b一为奇数一为偶数时,假设a为奇数,b为偶数。sg[(a,b)] = sg[(a+1, b)]
  3. 当a和b同为偶数时,sg[(a, b)] = sg[(a/2, b/2)] + 1;

AC代码:

#include <cstdio>

using namespace std;

int solve (int a, int b) {
	int ans = 0;
	while (a%2 == 0 || b%2 == 0) {
		a = (a+1)/2; b = (b+1)/2;
		ans++;
	}
	return ans;
}

int main () {
	int T, N;
	scanf("%d", &T);
	while (T--) {
		scanf("%d", &N);
		int ans = 0, a, b;
		for (int i = 0; i < N; i = i+2) {
			scanf("%d%d", &a, &b);
			ans ^= solve(a, b);
		}
		if (ans == 0) printf("NO\n");
		else printf("YES\n");
	}
	
	return 0;
}
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值