学习笔记:博弈论基础
0 一些定义与概念
公平组合游戏(Impartial combinatorial game, ICG)
公平组合游戏 满足:
- 由两名玩家交替移动;
- 在游戏进程的任意时刻,可以执行的合法行动与轮到哪名玩家无关;
- 不能行动的玩家判负。
Nim 游戏属于公平组合游戏。但常见的棋类大部分都不是公平组合游戏,因为双方执子不同,且判定胜负的规则有时也比较复杂。
游戏过程中面临的状态称为 局面。一局游戏中第一个行动的称为 先手,后一个行动的称为 后手。
如果在某一局面下,无论采取何种行动都会输掉游戏,即最后不能行动,则称该局面 必败。
在公平组合游戏中,一般以双方 采取最优策略 为前提。即如果当前局面下存在一种行动使得行动后对手必败,那么必定优先采取该行动。这时当前局面被称为 必胜。
博弈图
把一个 ICG 的所有状态抽象成一个个节点,如果一个状态能够通过游戏一方的一次行动变成另一个状态,那么由前一个状态到下一个状态连一条有向边。
这样会形成一个有向无环图,称为博弈图。
根据定义,有下面三条定理:
- 没有后继状态的状态是必败状态;
- 一个状态是必胜状态当且仅当存在 至少一个 必败状态为它的后继状态;
- 一个状态是必败状态当且仅当它的所有后继状态 均为 必胜状态。
1 巴什博弈(Bash Game)
问题
有一堆
如果 A 先取石子,请问 A 有没有一种必胜的策略?
分析
当
当
2 SG 函数
引入
定义一个集合
那么,A 还有必胜策略吗?
有没有必胜策略,我们关键是要找到哪些状态是必胜状态,哪些状态是必败状态。不过,本题没有 Bash 博弈那么容易判断,因此我们需要引入一个新东西——SG 函数。
定义
首先,设
对于博弈图中一个的状态
终止状态的 SG 值为
有向图游戏
有向图游戏是一个经典的博弈游戏——实际上,大部分的公平组合游戏都可以转换为有向图游戏,也就是在博弈图中进行有向图游戏。
有向图游戏的定义是:在一个有向无环图中,只有一个起点,上面有一个棋子,两个玩家轮流沿着有向边推动棋子,不能走的玩家判负。
SG 定理
我们定义整个 有向图游戏
对于
定义有向图游戏
其中
定理 对于一个有向图游戏,局面
这一定理被称作 Sprague-Grundy 定理(Sprague-Grundy Theorem), 简称 SG 定理。
理解:
首先,如果
是终止状态, 必败,SG 值为 。 如果存在某个后继局面的 SG 值为
,即存在某个后继局面必败,那么由于 mex 运算,当前局面的 SG 值一定大于 ,即当前局面必胜; 如果不存在某个后继局面的 SG 值为
,即所有后继局面必胜,那么由于 mex 运算,当前局面的 SG 值一定为 ,即当前局面必败。 具体可以用数学归纳法证明。
我们回到开始时的问题。
对于每一个状态,我们可以求出它的后继状态,进而求出起点的 SG 值。
3 Nim 游戏
问题
有
分析
定理 Nim 游戏先手必胜,当且仅当
证明 设
首先,所有东西都被取光是必败局面,此时满足 Nim 和为
对于任意一个局面,如果 Nim 和不等于
对于任意一个局面,如果 Nim 和等于
根据数学归纳法,Nim 和不等于
异或运算的性质:
- 交换律
; - 结合律
; - 单位元
; - 消去律
。
事实上,我们可以将一个有
模板题
代码
/*
http://poj.org/problem?id=2975
Nim 模板
问有多少种第一步使得后手必败
*/
#include <iostream>
#define f(x, y, z) for (int x = (y); (x) <= (z); ++(x))
using namespace std;
const int N = 1e3 + 10;
int n, t, a[N], ans;
signed main() {
ios::sync_with_stdio(false);
cin.tie(0), cout.tie(0);
while (cin >> n, n) {
t = ans = 0;
f(i, 1, n) {
cin >> a[i];
t ^= a[i];
}
if (!t) {
cout << 0 << '\n';
continue;
}
f(i, 1, n) if (a[i] >= (a[i] ^ t)) ++ans; //a[i]变为a[i]^t, 从而使得异或和为0
cout << ans << '\n';
}
return 0;
}
4 拓展
POJ 2960 S-Nim(集合 Nim)
题意
在 Nim 游戏中,给定正整数构成的集合
每组测试数据给定
对于每组测试数据,输出一行由
思路
类似上面引入 SG 时提出的问题,我们处理出所有状态的 SG 值,然后进行 Nim 游戏。
代码
#include <iostream>
#include <cstring>
#include <algorithm>
#define f(x, y, z) for (int x = (y); (x) <= (z); ++(x))
using namespace std;
const int N = 1e2 + 10, M = 1e4 + 10;
int k, s[N], sg[M], m, n, h, sum;
bool vis[M];
void get_sg() {
memset(sg, 0, sizeof(sg));
f(i, 1, M) {
memset(vis, 0, sizeof(vis));
f(j, 1, k) {
if (i < s[j]) break;
vis[sg[i - s[j]]] = true; //由i能到达i-s[j]
}
f(j, 0, i) if (!vis[j]) {
sg[i] = j; //第一个不能到达的值
break;
}
}
return;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0), cout.tie(0);
while (cin >> k, k) {
f(i, 1, k) cin >> s[i];
sort(s + 1, s + k + 1);
get_sg();
cin >> m;
f(i, 1, m) {
cin >> n;
sum = 0;
f(i, 1, n) {
cin >> h;
sum ^= sg[h];
}
cout << (sum ? 'W' : 'L');
}
cout << '\n';
}
return 0;
}
阶梯博弈(Staircase Nim)
问题
有
分析
其实阶梯博弈经过转化可以变为普通的 Nim。把所有奇数阶梯看成
为什么可以这样转化呢?
假设我们是先手,首先我们按 Nim 中必胜的步骤将奇数阶梯上的一些石子移动到偶数阶梯上,使奇数阶梯的石子数的异或和为
如此一直跟着后手做下去,我们一定可以最后一个移动石子使得对方再也无法行动,然后我们就赢了。
为什么是只对奇数堆做 Nim 就可以,而不是偶数堆呢?因为如果是对偶数堆做 Nim,对手移动奇数堆的石子到偶数堆,我们跟着移动这些石子到下一个奇数堆……那么最后是对手把这些石子移动到了第
例题 POJ 1704 Georgia and Bob
题意
从左到右有一排格子,其中一些格子中放有一个棋子。两个人轮流移动棋子 ,规定每个棋子只能向左移动,且不能跨过前面的棋子,并且一个格子最多可以包含一个棋子。最左边的棋子最多只能移动到第
思路
我们记录每一个棋子 最多向左移动的步数。如果把一个棋子向左移动,那么它能移动的步数变少,而它右边棋子能移动的步数变多。
如果 把步数看做是一堆石子 的话,向左移动就相当于是把石子由左边这一堆移动到右边这一堆。
我们发现这个问题就是左边高右边低的阶梯博弈。
所以我们只需要把从右往左数的所有奇数堆石子数取异或和就能判断是否必胜了。
事实上,我们也可以用上面分析经典阶梯博弈问题的思想来考虑。
我们把棋子按位置升序排列后,从后往前把他们 两两绑定成一对。如果总个数是奇数,就把最前面一个和边界(位置为
在同一对棋子中,如果对手移动前一个,你总能对后一个移动相同的步数,所以不同的两对棋子之间有多少个空位置对最终的结果是没有影响的。我们只需要考虑同一对中的两个棋子之间有多少个空位置。
我们 把同一对中的两个棋子之间的空位置数看做一堆石子。那么移动后一个棋子就相当于是拿走石子,当两个棋子相邻的时候石子数变为
(其实并没有平手的情况。。。)
代码
#include <iostream>
#include <algorithm>
#define f(x, y, z) for (int x = (y); (x) <= (z); ++(x))
using namespace std;
const int N = 1e3 + 10;
int tt, n, sum, a[N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0), cout.tie(0);
cin >> tt;
while (tt--) {
int lst = 0;
sum = 0;
cin >> n;
f(i, 1, n) cin >> a[i];
sort(a + 1, a + n + 1);
f(i, 1, n) a[i] -= lst + 1, lst += a[i] + 1;
for (int i = 1 + ~(n & 1); i <= n; i += 2) sum ^= a[i];
cout << (sum ? "Georgia will win\n" : "Bob will win\n");
}
return 0;
}
例题 [POI2004] Gra
POI官网 | 洛谷 P8382 | 黑暗爆炸 2066 - vjudge | 码创未来
题意
有一排
两个人交替移动棋子,每一次移动可以选择一个棋子,并把它移动到 它右边第一个未被占据的方格 中。第一个占据格子
如下图,一次移动可以把
问先手有多少种移动是必胜的。
数据范围:
思路
首先考虑什么情况下必败。显然如果
那么什么情况下怎么走都是必败状态呢?显然是从
问题转化为:将所有
那么如何将问题转化为阶梯博弈的模型呢?
我们 将每一个空的方格设为一级台阶,空方格左边的连续棋子数即为台阶石子数(可能为
由于空方格数是不变的,所以台阶数也是不变的。我们把台阶从右到左标号。最后一个台阶即是
对于每一次移动棋子的操作,如果把当前所在的一列连续棋子分为了
然后我们来考虑题目中提出的问题。
在上面的阶梯博弈问题中,我们算出奇数阶梯上石子数的异或和
但是……!题目中
代码
(细节,细节,还是***细节!!!)
#include <iostream>
#include <algorithm>
#define f(x, y, z) for (int x = (y); (x) <= (z); ++(x))
#define g(x, y, z) for (int x = (y); (x) >= (z); --(x))
using namespace std;
const int N = 1e6 + 10;
int n, m, sum, tot, ans, a[N], step[N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0), cout.tie(0);
cin >> m >> n;
f(i, 1, n) cin >> a[i];
// sort(a + 1, a + n + 1); //题目已经排好序了
if (a[n] == m - 1) {
int t = n + 1;
do --t, ++ans;
while (a[t] - a[t - 1] == 1);
return cout << ans << '\n', 0;
}
a[n + 1] = m - 1;
g(i, n, 1) { //从右到左构建台阶
if (a[i + 1] - a[i] == 1) ++step[tot];
else if (a[i + 1] - a[i] == 2) step[++tot] = 1;
else if ((a[i + 1] - a[i] - 1) & 1) step[tot += 3] = 1;
else step[tot += 2] = 1;
}
for (int i = 1; i <= tot; i += 2) sum ^= step[i];
if (!sum) return cout << 0 << '\n', 0;
for (int i = 1; i <= tot; i += 2)
if ((step[i] ^ sum) < step[i]) ++ans;
for (int i = 2; i <= tot; i += 2)
if ((step[i - 1] ^ sum) > step[i - 1] && (step[i - 1] ^ sum) <= step[i - 1] + step[i]) ++ans;
cout << ans << '\n';
return 0;
}
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!