Nim 游戏
n堆物品,每堆有 m 个,两个玩家轮流取走任意一堆的任意个物品,但不能不取。
取走最后一个物品的人获胜。
我们先来看第一道题目
题目一:
给定n堆石子,两个玩家轮流操作,每次可以从任意一堆石子中拿走任意数量的石子,可以拿完但不能不拿。无法操作者视为失败。问先手是否存在必胜策略。
输入格式:
第一行包含整数n。第二行包含n个数字,其中第i个数字表示第i堆石子的数量。
输出格式:
如果先手方必胜,则输出“Yes”。
Nim游戏模型,有个结论,设每堆石子个数分别是。
NIM和为:
当且仅当 Nim 和为 0 时,该状态为必败状态;否则该状态为必胜状态。
为什么呢?
为了详细介绍NIM游戏以及这个结论,我先介绍一下博弈图和有向图游戏:
博弈图:
如果将每个状态视为一个节点,再从每个状态向它的后继状态连边,我们就可以得到一个博弈状态图。
有向图游戏:
有向图游戏是一个经典的博弈游戏——实际上,大部分的公平组合游戏都可以转换为有向图游戏。
在一个有向无环图中,只有一个起点,上面有一个棋子,两个玩家轮流沿着有向边推动棋子,不能走的玩家判负。
我们能发现,如果子节点都是必胜状态,那么当前一定必败。 如果子节点有一个必败状态,其他节点不用管,当前节点就是必胜状态,因为我可以让拿走若干石子,达到这个状态。
简单总结如下:
1:没有后继状态的状态是必败态
2:一个状态是必胜态当且仅当存在至少一个必败态为它的后继状态
3:一个状态是必败状态当且仅当它的所有后继状态均为必胜状态
显然,我们定义必胜状态值大于0,必败状态值为0,那么我们可以得到有向图里面的mex函数就是这样定义的,子节点中没出现的最小非负整数值;
有了mex函数,那么我们就能定义sg函数了,按照我的理解,sg函数主要是处理后继节点的状态转移。先直接看定义:
我们再给两个结论(注,这些结论我都不会证明,想看证明的读者请查阅其他资料)
一个局面的SG函数可以用起点的SG函数代替。
故而对于第一题,我们本来的结论应该是
但是对于第一道题,因为我们对于每次拿的石子数不限制,所以我们能得出SG(a1) = a1,故而有了
。
说到这里,我先给出SG函数的代码模板:
int n, k;
int s[N], f[M]; // s存取石子的方法,f存取所有取法对应的sg值
// dfs求取sg结果
int sg(int x) {
if (f[x] != -1) return f[x];
unordered_set<int> S; // 哈希表存所有可以到的局面
for (int i = 0; i < k; ++i)
if (x >= s[i])
S.insert(sg(x - s[i]));
for (int i = 0 ; ; ++i)
if (!S.count(i))
return f[x] = i;
}
代码中的第一个if是用来做记忆化搜索的。
第一重循环是dfs的遍历,这里面的s[i]是每次允许拿的石子个数,对于第一题,我们可以直接初始化为s[i] = i-1。其他s[i]初始化的情况我稍后会介绍。
第二重循环是计算mex函数。
下面我们来介绍第二种题型,加上对石子个数的限定,也就是限制了状态转移,后面我们会知道,实际上与第一种题目一模一样,甚至代码都是一样的,只需要更改s[i]。
题目二:
注意看,除了增加了对石子的限制,其他都没变,也就是说,我们只需要改变s[i]即可。
其实做到这里我们发现,关键改变点就是状态转换的边数。
再来一题:
题目三:
这里的转换,不是减少石子的个数,而是把一个石子变成两堆规模更小的石子,也就是状态转移有O(n^2)个,代码如下。
int sg(int x)
{
if (f[x] != -1) return f[x];
unordered_set<int> S;
for (int i = 0; i < x; ++ i)
for (int j = 0; j <= i; ++ j)
S.insert(sg(i) ^ sg(j));
for (int i = 0; ; ++ i)
if (!S.count(i))
return f[x] = i;
}