主要内容来自《挑战程序设计竞赛》以及《ACM国际大学生程序设计竞赛算法与实现》, 这里只是我的理解以及总结。也许会有很多不足,欢迎提出不同意见,谢谢!
一、游戏与必胜策略
1.硬币游戏1
Alice和Bob在玩这样一个游戏。给定k个数字a1, a2, …, ak。一开始,有x枚硬币,Alice和Bob轮流取硬币。每次所取硬币的枚数一定要在a1, a2, …, ak当中。Alice先取,取走最后一枚硬币的一方获胜。当双方都采取最优策略时,谁会获胜?题目假定a1, a2, …, ak中一定有1。
这是一个递推的过程:
- 当硬币为0时,此时取硬币的人必输
- 当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;
}
当第一个人取走第一枚或者两枚硬币,第二个只要同样取走一枚或两枚硬币,将硬币均匀的分为两个部分,之后只要第二个人永远模仿第一个人在另外一堆硬币里取硬币。那么第二个人一定会胜。唯一例外的情况,第一个一次就能将硬币取完。
代码:
#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,
- b - a > a。那么如果下一态为必胜态,此态则为必败态。
- 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.
原因如下:
- 必胜态时(XOR不为零时) , 总能从一堆石头中选出若干个石头使其XOR值变为0,进入必败态。
- 当必败态时(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中的一个石头堆类似,具有如下性质:
- Nim中x颗石子的石子堆,能转换为0,1,…,x-1颗石子的石子堆;而从sg值为x的状态出发,可以转移到Grundy值为0,1,…,x-1的状态
- 虽然转移后的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的游戏
分析:
这是一个递推的过程。
- m = 1时,先手无法再分,先手必输
- m = 2时,先手可以分为1和1,先手必赢
- m = 3时,先手只能分为1和2,先手必输
- m = 4时,先手分为1和3,后手只能选择3继续分,后手必输,先手必赢。
- m = 5时,无论是2+3还是1+4,都有一个数字令先手必输。
…… - m % 2 = 0时, 将其分为1和m-1,pb必赢
- 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.肥猫的游戏
分析:
这个应该按照黑色三角形有几条边需要切,和总共有几条边需要切来分情况。
- 当只有黑色三角只有一条边需要切,毫无疑问先手必胜
- 当不止一条边需要切,为了避免对方获胜,此时切边的人会尽力不切与黑色三角形的边,以避免对方切最后一次,对方获胜。故永远是切最后一条边的获胜。所有当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
分析:
- 当棋子左右边值都为零时,先手必败。
- 当棋子左边或右边有一个各一条边有边为零时,先手必胜,如下图所示。
- 每个人都想努力的到第二种情况,同时自己为了避免对方成为第二种情况。因此会将自己走过的边的值全部减少至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;
}
分析:仔细观察就会发现,这道题和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省略,只留下非零的数字,又会有这样一张图
当然, 还可以继续分下去。
其实,现在已经可以看出主要规律了。
- 当a和b同为奇数是 sg值为0
- 当a和b一为奇数一为偶数时,假设a为奇数,b为偶数。sg[(a,b)] = sg[(a+1, b)]
- 当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;
}