经典的公平组合游戏有很多,包括取数游戏,31 点,以及 Nim 游戏等。
取走最后一个物品的人获胜。
例如,如果现在有
如果现在的局面为
如果将每个状态视为一个节点,再从每个状态向它的后继状态连边,我们就可以得到一个博弈状态图。
例如,如果节点
定义 必胜状态 为 先手必胜的状态,必败状态 为 先手必败的状态。
通过推理,我们可以得出下面三条定理:
- 定理 1:没有后继状态的状态是必败状态。
- 定理 2:一个状态是必胜状态当且仅当存在至少一个必败状态为它的后继状态。
- 定理 3:一个状态是必败状态当且仅当它的所有后继状态均为必胜状态。
对于定理 1,如果游戏进行不下去了,那么这个玩家就输掉了游戏。
对于定理 2,如果该状态至少有一个后继状态为必败状态,那么玩家可以通过操作到该必败状态;此时对手的状态为必败状态——对手必定是失败的,而相反地,自己就获得了胜利。
对于定理 3,如果不存在一个后继状态为必败状态,那么无论如何,玩家只能操作到必胜状态;此时对手的状态为必胜状态——对手必定是胜利的,自己就输掉了游戏。
如果博弈图是一个有向无环图,则通过这三个定理,我们可以在绘出博弈图的情况下用
让我们再次回顾 Nim 游戏。
通过绘画博弈图,我们可以在
但是,这样的时间复杂度实在太高。有没有什么巧妙而快速的方法呢?
定义 Nim 和
当且仅当 Nim 和为
为什么异或值会和状态的胜负有关?下面给出了这个定理的证明过程。
为了证明该定理,只需要证明下面三个定理:
- 定理 1:没有后继状态的状态是必败状态。
- 定理 2:对于
$a_1 \oplus a_2 \oplus \ldots \oplus a_n \neq 0$ 的局面,一定存在某种移动使得$a_1 \oplus a_2 \oplus \ldots \oplus a_n = 0$ 。 - 定理 3:对于
$a_1 \oplus a_2 \oplus \ldots \oplus a_n = 0$ 的局面,一定不存在某种移动使得$a_1 \oplus a_2 \oplus \ldots \oplus a_n = 0$ 。
对于定理 1,没有后继状态的状态只有一个,即全
对于定理 2,不妨假设
根据异或定义,一定有奇数个
对于定理 3,如果我们要将
有向图游戏是一个经典的博弈游戏——实际上,大部分的公平组合游戏都可以转换为有向图游戏。
在一个有向无环图中,只有一个起点,上面有一个棋子,两个玩家轮流沿着有向边推动棋子,不能走的玩家判负。
定义
例如
对于状态
而对于由
这一定理被称作 Sprague-Grundy 定理(Sprague-Grundy Theorem), 简称 SG 定理。
可以使用数学归纳法来证明。
我们假设对于游戏状态
显然当
那么只需要证明对于游戏状态
事实上这一个状态可以看作一个 Nim 游戏,对于某个节点
在有向图游戏中,当一方将某一节点
当移动到 SG 值较小的节点时,情况则会和 Nim 游戏一样,能够到达任何一个游戏状态
所以状态
SG 定理适用于 任何公平的两人游戏, 它常被用于决定游戏的输赢结果。
计算给定状态的 Grundy 值的步骤一般包括:
-
获取从此状态所有可能的转换;
-
每个转换都可以导致 一系列独立的博弈(退化情况下只有一个)。计算每个独立博弈的 Grundy 值并对它们进行 异或求和。
-
在为每个转换计算了 Grundy 值之后,状态的值是这些数字的
$\operatorname{mex}$ 。 -
如果该值为零,则当前状态为输,否则为赢。
我们可以将一个有
那么,由
根据上面的推论,可以得出
(转载)Nim 游戏博弈(收集完全版)- exponent - 博客园
[组合游戏与博弈论]【学习笔记】- Candy? - 博客园
[!NOTE] AcWing 891. Nim游戏
题意: TODO
[!TIP] 思路
先手必胜状态:先手操作完,可以走到某一个必败状态
先手必败状态:先手操作完,走不到任何一个必败状态
先手必败状态:$a_1 ^{\wedge} a_2 ^{\wedge} a_3 ^{\wedge} ... ^{\wedge} a_n = 0$
先手必胜状态:$a_1 ^{\wedge} a_2 ^{\wedge} a_3 ^{\wedge} ... ^{\wedge} a_n ≠ 0$
详细代码
#include <bits/stdc++.h>
using namespace std;
int n, v;
int main() {
cin >> n;
int res = 0;
while (n--) {
cin >> v;
res ^= v;
}
if (res == 0)
cout << "No" << endl;
else
cout << "Yes" << endl;
}
[!NOTE] AcWing 892. 台阶-Nim游戏
题意: TODO
[!TIP] 思路
台阶Nim游戏是普通的Nim博弈的进阶版,最后的输赢与奇数台阶上的石子有关。
如果后手移动偶数阶的石子,那么先手将后手移动到奇数阶的石子再次移动,就可以发现偶数阶的石子对博弈是没有影响的。
如果后手移动奇数阶的石子,先手同样移动奇数阶的石子。
当奇数阶上没有石子时,由于先手走最后一步,所以此时后手只能移动偶数阶上的石子,先手将后手移动的石子再移动一次,就一定可以取胜。
详细代码
#include <bits/stdc++.h>
using namespace std;
int n, v;
int main() {
cin >> n;
int res = 0;
for (int i = 1; i <= n; ++i) {
cin >> v;
if (i & 1) res ^= v;
}
if (res == 0)
cout << "No" << endl;
else
cout << "Yes" << endl;
}
[!NOTE] AcWing 893. 集合-Nim游戏
题意: TODO
[!TIP] 思路
定理 1:
$$ 对于集合 S , mex(S) = mex({x1, x2 ... }) = S 中没有出现的最小非负整数 $$ 定理 2.1:
$$ sg(n) = mex({sg(i1), sg(i2), sg(i3) ... })
n 为结点;i1, i2, i3 ... 是 n 的后继结点 $$
定理2.2:
$$ sg(G) = sg(head). G是一个有向图, head是G的头结点。 $$ 定理3:
$$ sg(G1) ^{\wedge} sg(G2) ^{\wedge} sg(G3) ^{\wedge} ... ^{\wedge} sg(Gn) 为 n 个有向图的异或和 $$ 对于
$n$ 个有向图游戏, 这个异或和就是它的答案(和$nim$ 类似,这个定理感兴趣可以去证明)
详细代码
#include <bits/stdc++.h>
using namespace std;
const int maxn = 105;
const int maxv = 10005;
int n, m, x;
int s[maxn], f[maxv];
int sg(int x) {
if (f[x] != -1) return f[x];
unordered_set<int> S;
for (int i = 0; i < m; ++i) {
int sum = s[i];
if (x >= sum) S.insert(sg(x - sum));
}
for (int i = 0;; ++i) {
if (!S.count(i)) return f[x] = i;
}
}
int main() {
cin >> m;
for (int i = 0; i < m; ++i) cin >> s[i]; // 集合中的数
memset(f, -1, sizeof f);
int res = 0;
cin >> n;
for (int i = 0; i < n; ++i) {
cin >> x;
res ^= sg(x);
}
cout << (res ? "Yes" : "No") << endl;
}
[!NOTE] AcWing 894. 拆分-Nim游戏
题意: TODO
[!TIP] 思路
相比于
$集合-Nim$ ,这里的每一堆可以变成不大于原来那堆的任意大小的两堆即
$a[i]$ 可以拆分成$(b[i], b[j])$ , 为了避免重复规定$b[i] >= b[j]$ , 即:$a[i] >= b[i] >= b[j]$ 相当于一个局面拆分成了两个局面由
$SG$ 函数理论,多个独立局面的$SG$ 值,等于这些局面$SG$ 值的异或和。 因此需要存储的状态就是$sg(b[i]) ^{\wedge} sg(b[j])$ (与$集合-Nim$ 的唯一区别)
详细代码
#include <bits/stdc++.h>
using namespace std;
const int maxn = 105;
int n, x;
int f[maxn];
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;
}
int main() {
cin >> n;
memset(f, -1, sizeof f);
int res = 0;
while (n--) {
cin >> x;
res ^= sg(x);
}
cout << (res ? "Yes" : "No") << endl;
}
[!NOTE] LeetCode 1140. 石子游戏 II
题意: TODO
[!TIP] 思路
博弈 + 记忆化
注意状态定义与其他人略有不同,因此返回时
$(s[n] + t) / 2$
详细代码
class Solution {
public:
using PII = pair<int, int>;
const static int N = 110;
const int INF = 0x3f3f3f3f;
int n;
int s[N];
map<PII, int> hash;
int dp(int x, int m) {
if (x == n)
return 0;
PII pi = {x, m};
if (hash.count(pi))
return hash[pi];
int t = -INF, top = min(n, x + 2 * m);
for (int i = x + 1; i <= top; ++ i )
t = max(t, s[i] - s[x] - dp(i, max(m, i - x)));
return hash[pi] = t;
}
int stoneGameII(vector<int>& piles) {
this->n = piles.size();
memset(s, 0, sizeof s);
for (int i = 1; i <= n; ++ i )
s[i] = s[i - 1] + piles[i - 1];
int t = dp(0, 1);
return (s[n] + t) / 2;
}
};
[!NOTE] LeetCode 1406. 石子游戏 III
题意: TODO
[!TIP] 思路
一排石子每个人可以从左取1-3堆 求取完谁分数最大
详细代码
class Solution {
public:
string stoneGameIII(vector<int>& stoneValue) {
int n = stoneValue.size();
vector<int> dp(n + 1);
for (int i = n - 1; i >= 0; --i) {
dp[i] = INT_MIN;
int sum = 0;
for (int j = i; j < n && j < i + 3; ++j) {
sum += stoneValue[j];
dp[i] = max(dp[i], sum - dp[j + 1]);
}
}
if (dp[0] > 0)
return "Alice";
else if (dp[0] < 0)
return "Bob";
else
return "Tie";
}
};
[!NOTE] LeetCode 1510. 石子游戏 IV
题意: TODO
[!TIP] 思路
博弈dp 决策是取平方数
详细代码
class Solution {
public:
bool winnerSquareGame(int n) {
vector<bool> f(n + 1);
for (int i = 1; i <= n; ++i)
for (int j = 1; i - j * j >= 0; ++j) {
f[i] = !f[i - j * j];
if (f[i]) break;
}
return f[n];
}
};
[!NOTE] LeetCode 1686. 石子游戏 VI
题意: TODO
[!TIP] 思路
直接按和排序而 无需考虑对于 alice bob 来说和相同时是否需要选择对自己更有利的正确性证明
详细代码
class Solution {
public:
typedef pair<int, int> PII;
int stoneGameVI(vector<int>& av, vector<int>& bv) {
int n = av.size();
vector<PII> ve;
for (int i = 0; i < n; ++ i ) ve.push_back({av[i] + bv[i], i});
sort(ve.begin(), ve.end(), greater<PII>());
int v1 = 0, v2 = 0;
for (int i = 0; i < n; ++ i ) {
// cout << "i = " << i << " val = " << ve[i].first << " idx = " << ve[i].second;
int idx = ve[i].second;
if (i & 1) v2 += bv[idx];
else v1 += av[idx];
}
// cout << " v1 = " << v1 << " v2 = " << v2 << endl;
if (v1 < v2) return -1;
else if (v1 > v2) return 1;
return 0;
}
};
[!NOTE] LeetCode 1690. 石子游戏 VII
题意: TODO
[!TIP] 思路
详细代码
class Solution {
public:
int stoneGameVII(vector<int>& stones) {
int n = stones.size();
vector<int> s(n + 1);
for (int i = 1; i <= n; ++ i ) s[i] = s[i - 1] + stones[i - 1];
vector<vector<int>> f(n + 1, vector<int>(n + 1));
// len = 1 时剩余和为0 也即f[i][i]为0 故直接从2开始
for (int len = 2; len <= n; ++ len )
for (int l = 1; l + len - 1 <= n; ++ l ) {
int r = l + len - 1;
f[l][r] = max(s[r] - s[l] - f[l + 1][r], s[r - 1] - s[l - 1] - f[l][r - 1]);
}
return f[1][n];
}
};
[!NOTE] LeetCode 1872. 石子游戏 VIII [TAG]
题意: TODO
[!TIP] 思路
博弈 dp
注意 类似 LCIS 的常数优化 TODO
详细代码
class Solution {
public:
int stoneGameVIII(vector<int>& stones) {
int n = stones.size();
reverse(stones.begin(), stones.end());
vector<int> f(n + 1), s(n + 1);
for (int i = 1; i <= n; ++ i )
s[i] = s[i - 1] + stones[i - 1];
int v = s[n] - s[0] + f[1];
for (int i = 2; i <= n; ++ i ) {
// 优化
f[i] = v;
v = max(v, s[n] - s[i - 1] - f[i]);
// 本次操作之后 下次剩余至多 i - 2 + 1
// f[i] = INT_MIN;
// for (int j = 1; j <= i - 1; ++ j )
// f[i] = max(f[i], s[n] - s[j - 1] - f[j]);
}
return f[n];
}
};
[!NOTE] Codeforces A Lot of Games
题意:
给出
$n$ 个非空字符串。在游戏中,两位玩家一起建造一个单词。开始时,单词是一个空字符串。两位玩家轮流操作。
一位玩家在他的回合中,必须在单词的后面添加一个字母,使得添加后的单词是
$n$ 个字符串中至少一个的前缀。当一位玩家不能操作时,他就输掉了游戏。
Andrew 和 Alex 决定玩
$k$ 次。上一局的负者是下一局的先手。他们决定,在最后一局中获胜的人获得整场游戏的胜利。Fedor 想知道,如果他们两个足够聪明,谁会胜利。
[!TIP] 思路
较直观的,显然先建立 Trie 树再计算每个节点的必胜状态
$f[i]$ 有意思的是,每个角色可以主动选择失败,来保持先手的状态,直到最后一局
故还需计算 当前节点是否能够主动选择必败
$g[i]$ 注意
$g[i]$ 的计算细节即可,最后分情况讨论
详细代码
// Problem: B. A Lot of Games
// Contest: Codeforces - Codeforces Round #260 (Div. 1)
// URL: https://codeforces.com/problemset/problem/455/B
// Memory Limit: 256 MB
// Time Limit: 1000 ms
#include <bits/stdc++.h>
using namespace std;
// 局面一致,且上一局的负者是下一局的先手,则两者对称
// ==> 如果k是奇数直接返回先手结果,如果是偶数则反一下即可 ==> WA
// 实际上,必须考虑【一个人会不会选择主动输,进而一直掌握先手】
const static int N = 1e5 + 10;
int n, k;
string s;
int son[N][26], cnt[N], idx;
void insert() {
int p = 0, m = s.size();
for (int i = 0; i < m; ++i) {
int u = s[i] - 'a';
if (!son[p][u])
son[p][u] = ++idx;
p = son[p][u];
}
cnt[p]++;
}
int f[N], // f 表示当前局面先手是否可以必胜
g[N]; // g 表示是否可以必败 【思路正确】
void dfs(int u) {
// 必胜还是必败,默认必败(对于无法再追加字符的情况)
bool flag1 = false, flag2 = false, has_son = false;
for (int i = 0; i < 26; ++i) {
int p = son[u][i];
if (p) {
dfs(p);
if (!f[p])
flag1 = true; // 可以让对方必败,则本轮次必胜
// ATTENTION 更新细节
// IF 【如果子节点没有必败的选择,则当前点可以必败】
if (!g[p])
flag2 = true;
has_son = true;
}
}
if (!has_son)
flag2 = true;
f[u] = flag1, g[u] = flag2;
}
string res[2] = {"First", "Second"};
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cout.tie(nullptr);
cin >> n >> k;
memset(cnt, 0, sizeof cnt);
idx = 0;
for (int i = 1; i <= n; ++i) {
cin >> s;
insert();
}
memset(f, 0, sizeof f);
dfs(0);
// cout << " f0 = " << f[0] << " g0 = " << g[0] << endl;
if (!f[0])
// case 1: 先手必败,则它永远是先手,永远必败
cout << res[1] << endl;
else {
// case 2: 先手必胜,则会交替成为先手
// ATTENTION:
// Trick: 如果先手可以选择失败(如果可能)从而一直保持先手 最终直接取胜
if (g[0])
cout << res[0] << endl;
else {
// case: 必须要连续交换,因为不能主动失败
if (k & 1)
cout << res[0] << endl;
else
cout << res[1] << endl;
}
}
return 0;
}
[!NOTE] AcWing 1319. 移棋子游戏
题意: TODO
[!TIP] 思路
标准 SG 函数模版题
详细代码
#include <bits/stdc++.h>
using namespace std;
const static int N = 2010, M = 6010;
int h[N], e[M], ne[M], idx;
void init() {
memset(h, -1, sizeof h);
idx = 0;
}
void add(int a, int b) {
e[idx] = b, ne[idx] = h[a], h[a] = idx ++ ;
}
int n, m, k;
int f[N]; // sg 值, 记忆化搜索
int sg(int u) {
if (f[u] != -1)
return f[u];
set<int> S;
for (int i = h[u]; ~i; i = ne[i]) {
int j = e[i];
S.insert(sg(j));
}
for (int i = 0; ; ++ i )
if (S.count(i) == 0) {
f[u] = i;
break;
}
return f[u];
}
int main() {
init();
cin >> n >> m >> k;
for (int i = 0; i < m; ++ i ) {
int a, b;
cin >> a >> b;
add(a, b);
}
memset(f, -1, sizeof f);
int res = 0;
for (int i = 0; i < k; ++ i ) {
int u;
cin >> u;
res ^= sg(u);
}
if (res)
cout << "win" << endl;
else
cout << "lose" << endl;
return 0;
}
[!NOTE] AcWing 1321. 取石子
题意: TODO
[!TIP] 思路
复杂推导和分情况讨论
TODO
详细代码
#include <bits/stdc++.h>
using namespace std;
const static int N = 55, M = 50050;
int f[N][M];
int dp(int a, int b) {
int & v = f[a][b];
if (v != -1)
return v;
if (!a)
return v = b % 2;
if (b == 1)
return dp(a + 1, 0);
if (a && !dp(a - 1, b))
return v = 1;
if (b && !dp(a, b - 1))
return v = 1;
if (a >= 2 && !dp(a - 2, b + (b ? 3 : 2)))
return v = 1;
if (a && b && !dp(a - 1, b + 1))
return v = 1;
return v = 0;
}
int main() {
memset(f, -1, sizeof f); // 全局都只需要被计算一次
int T;
cin >> T;
while (T -- ) {
int n;
cin >> n;
int a = 0, b = 0;
for (int i = 0; i < n; ++ i ) {
int x;
cin >> x;
if (x == 1)
a ++ ;
else
b += b ? x + 1 : x;
}
if (dp(a, b))
cout << "YES" << endl;
else
cout << "NO" << endl;
}
return 0;
}
[!NOTE] AcWing 1322. 取石子游戏
题意: TODO
[!TIP] 思路
结合区间
详细代码
#include <bits/stdc++.h>
using namespace std;
const static int N = 1010;
int n;
int a[N];
int l[N][N], r[N][N];
int main() {
int T;
cin >> T;
while (T -- ) {
cin >> n;
for (int i = 1; i <= n; ++ i )
cin >> a[i];
for (int len = 1; len <= n; ++ len )
for (int i = 1; i + len - 1 <= n; ++ i ) {
int j = i + len - 1;
if (len == 1)
l[i][j] = r[i][j] = a[i];
else {
int L = l[i][j - 1], R = r[i][j - 1], X = a[j];
if (R == X)
l[i][j] = 0;
else if (X < L && X < R || X > L && X > R)
l[i][j] = X;
else if (L > R)
l[i][j] = X - 1;
else
l[i][j] = X + 1;
L = l[i + 1][j], R = r[i + 1][j], X = a[i];
if (L == X)
r[i][j] = 0;
else if (X < L && X < R || X > L && X > R)
r[i][j] = X;
else if (R > L)
r[i][j] = X - 1;
else
r[i][j] = X + 1;
}
}
if (n == 1)
cout << "1" << endl;
else
cout << (l[2][n] != a[1] ? "1" : "0") << endl;
}
return 0;
}