[关闭]
@Dmaxiya 2020-08-24T23:44:11.000000Z 字数 7429 阅读 1784

组合博弈问题:从 dfs 到 SG 函数

博文


定义

  1. 只有两个玩家轮流进行游戏;
  2. 游戏规则对游戏双方是平等的;
  3. 游戏能在有限的步骤内达到其中一方胜/败的局面,没有平局;
  4. 双方都采取最优策略,面对相同的状态,对于游戏双方的必胜/必败态是确定的。

必胜态与必败态

首先,存在一个状态,使得先手为必胜态或者必败态,再根据以下两点,就可以确定所有状态的必胜/败态

  1. 如果一个状态的后继状态中,存在一个必败态,则该状态为必胜态
  2. 如果一个状态的所有后继状态都为必胜态,则该状态为必败态

对于当前状态而言,如果它的后继状态中存在一个必败态,先手(先手后手是相对与当前状态而言,当前状态的操作者即为先手,而当前状态的后手,便是后继状态的先手)只需要将当前状态改变成这个必败态,则后手必败,先手必胜;如果它的所有后继状态都是必胜态,那么无论先手做什么操作,后手都必胜,因此先手必败。

深度优先搜索

博弈问题最基本最暴力的解法,就是深度优先搜索,其依据就是以上的必胜态与必败态的确定方法,以下给出博弈问题深度优先搜索的伪代码(函数返回值为 表示搜索当前状态的结果为必胜态):

  1. bool dfs(/*当前状态*/) {
  2. if(/*当前状态已知为必胜/败态*/) {
  3. return true; / return false;
  4. }
  5. for(/*遍历所有后继状态*/) {
  6. if(dfs(/*后继状态*/) == false) {
  7. return true;
  8. }
  9. }
  10. return false;
  11. }

其中的递归停止条件,一般题目都会给出,例如:当甲达到某一条件时,则甲/乙获胜。

记忆化搜索

一般情况下,深搜过程中会搜索到许多重复的状态,而对于相同的状态,其必胜/败态已经确定,不必重复搜索,如果把这些状态都记录下来,就可以大大减少搜索的次数,这里给出博弈问题记忆化搜索的伪代码(假设所有状态初始化为 ,必胜为 ,必败为 ):

  1. int dfs(/*当前状态*/) {
  2. if(/*当前状态已知为必胜/败态*/) {
  3. return true; / return false;
  4. }
  5. if(mp[/*当前状态*/] != -1) {
  6. return mp[/*当前状态*/];
  7. }
  8. for(/*遍历所有后继状态*/) {
  9. if(dfs(/*后继状态*/) == 0) {
  10. mp[/*当前状态*/] = 1;
  11. return 1;
  12. }
  13. }
  14. mp[/*当前状态*/] = 0;
  15. return 0;
  16. }

其实只是在上面的深度优先搜索代码中加了一些记录状态的语句,这样写或许有些抽象,下面用记忆化搜索来解决一道博弈问题。

Codeforces 69D: Dot

题意

最初有一个点在坐标 处,现在 两人轮流从 个向量中取一个向量 ,将这个点的坐标更新为 ,若某一方操作后,使得点 离原点的距离大于 ,则当前操作方失败。在游戏过程中,两个人分别有一次机会将 点关于 对称(横纵坐标互换)。现在给定 个向量以及点的初始位置 先手,问哪一方将获胜。

数据范围

题解

开始深搜,对于每个状态,除了到达的点的坐标,还需要记录一个双方使用“对称”的情况,这里用一个 分别表示双方都未使用对称、先手使用了对称、后手使用了对称、双方都使用了对称,注意在后继状态中,先手成为后手,后手成为先手,则使用“对称”的情况需要反过来。
当坐标 满足 时,操作方失败,即当前状态为“先手胜”。这里用 来存状态与必胜/败之间的映射,因此所有值初始化为 ,将必胜态记为 ,必败态记为
接着调用 判断是否返回 即可。

过题代码

  1. #include <iostream>
  2. #include <cstdio>
  3. #include <cstdlib>
  4. #include <cmath>
  5. #include <climits>
  6. #include <cstring>
  7. #include <string>
  8. #include <vector>
  9. #include <list>
  10. #include <queue>
  11. #include <stack>
  12. #include <map>
  13. #include <set>
  14. #include <bitset>
  15. #include <algorithm>
  16. #include <functional>
  17. #include <iomanip>
  18. using namespace std;
  19. #define LL long long
  20. const int maxn = 100;
  21. struct Point {
  22. int x, y;
  23. };
  24. struct Node {
  25. int x, y;
  26. int flag;
  27. // 0 为双方没有使用过对称,1 为先手使用过对称,2 为后手使用过对称,3 为双方都使用过对称
  28. Node() {}
  29. Node(int xx, int yy, int f) {
  30. x = xx;
  31. y = yy;
  32. flag = f;
  33. }
  34. };
  35. bool operator<(const Node &a, const Node &b) {
  36. if(a.flag == b.flag) {
  37. if(a.x == b.x) {
  38. return a.y < b.y;
  39. }
  40. return a.x < b.x;
  41. }
  42. return a.flag < b.flag;
  43. }
  44. int n, d, x, y;
  45. Point point[maxn];
  46. map<Node, int> mp; // 0 为未定义,1 为必胜态,2 为必败态
  47. int op_flag(int flag) {
  48. // 将双方使用过对称的状态交换
  49. if(flag == 0 || flag == 3) {
  50. return flag;
  51. } else {
  52. return 3 - flag;
  53. }
  54. }
  55. int dfs(int x, int y, int flag) {
  56. int &ret = mp[Node(x, y, flag)];
  57. if(ret != 0) {
  58. return ret;
  59. }
  60. if(x * x + y * y > d * d) {
  61. return 1;
  62. }
  63. // 如果不进行对称
  64. for(int i = 0; i < n; ++i) {
  65. if(dfs(x + point[i].x, y + point[i].y, op_flag(flag)) == 2) {
  66. ret = 1;
  67. return ret;
  68. }
  69. }
  70. if((flag & 1) == 0) {
  71. // 如果可以使用对称
  72. if(dfs(y, x, op_flag(flag | 1)) == 2) {
  73. ret = 1;
  74. return ret;
  75. }
  76. }
  77. ret = 2;
  78. return ret;
  79. }
  80. int main() {
  81. #ifdef LOCAL
  82. freopen("test.txt", "r", stdin);
  83. // freopen("out.txt", "w", stdout);
  84. #endif // LOCAL
  85. ios::sync_with_stdio(false);
  86. scanf("%d%d%d%d", &x, &y, &n, &d);
  87. for(int i = 0; i < n; ++i) {
  88. scanf("%d%d", &point[i].x, &point[i].y);
  89. }
  90. if(dfs(x, y, 0) == 1) {
  91. printf("Anton\n");
  92. } else {
  93. printf("Dasha\n");
  94. }
  95. return 0;
  96. }

注意程序的第 行,都是对后继状态的遍历,只是转移到后继状态的操作不同,不能写在一个 循环当中罢了。
实际上不必记录双方关于“对称”的使用情况,因为如果先手认为“对称”操作对自己是有利的,则后手必然会在接下来的一步中使用“对称”回到原来的状态,因此对于双方而言,“对称”操作是无效的,读者可以将代码中关于 标记的部分去掉,提交一下看结论是否正确。

博弈

博弈是一个经典的博弈游戏,我将从 博弈引入到 函数。首先来介绍一下该游戏的规则:

  1. 堆石子,第 堆有 个石子;
  2. 双方轮流进行取石子操作;
  3. 每次操作,一方可以从任意一堆中任取 个石子;
  4. 取走最后一个石子的一方获胜。

这个问题将所有堆石子的个数记为一种状态,状态压缩一下用记忆化搜索,或许可以解决,但是当 很大时,记忆化搜索也将很快就爆时间复杂度。
这个问题的必胜策略直到 世纪初才被哈佛大学的一个叫做 的数学家找到,以下给出其结论:

当所有石子个数 异或值为 时,为必败态,否则为必胜态。

结论的证明如下:

  1. 当游戏结束时, 堆的石子个数 都为 ,其异或值也为 ,是必败态;
  2. 若当前状态为必败态: ,从第 堆石子中取走 个石子,等价于将第 堆石子的数量 异或一个非零值 ,使得 ,所有石子的异或值为 ,即所有后继状态的异或值都不等于 ,是必胜态;
  3. 若当前状态为必胜态: ,则总能找到一个 的最高位等于 的数字 ,将其更新为 ,使得所有石子的异或值为 ,即后继状态中存在一种异或值为 的状态,是必败态。证毕。

如果再见到 游戏,就可以直接用上述结论来解决,当然要先理解其证明过程。现在的话, 游戏这样的直接结论题不太可能出现,一般是以变体的形式出现。为了更深入地理解其证明过程,这里来一道 游戏的改编题:

HDU 1850: Being a Good Boy in Spring Festival

题意

堆扑克牌,第 堆有 张扑克牌,两人玩 游戏,问如果先手想要获胜,有几种可能的取扑克牌的方式。

数据范围

题解

算出所有 的异或值 ,遍历计算所有 的最高位为 的个数,即答案。

过题代码

  1. #include <iostream>
  2. #include <cstdio>
  3. #include <cstdlib>
  4. #include <cmath>
  5. #include <climits>
  6. #include <cstring>
  7. #include <string>
  8. #include <vector>
  9. #include <list>
  10. #include <queue>
  11. #include <stack>
  12. #include <map>
  13. #include <set>
  14. #include <bitset>
  15. #include <algorithm>
  16. #include <functional>
  17. #include <iomanip>
  18. using namespace std;
  19. #define LL long long
  20. const int maxn = 200;
  21. int n, k, dig, ans;
  22. int num[maxn];
  23. int main() {
  24. #ifdef LOCAL
  25. freopen("test.txt", "r", stdin);
  26. // freopen("out.txt", "w", stdout);
  27. #endif // LOCAL
  28. ios::sync_with_stdio(false);
  29. while(scanf("%d", &n), n != 0) {
  30. k = 0;
  31. for(int i = 0; i < n; ++i) {
  32. scanf("%d", &num[i]);
  33. k ^= num[i];
  34. }
  35. int tmp = k;
  36. dig = 0;
  37. while(tmp != 0) {
  38. tmp >>= 1;
  39. ++dig;
  40. }
  41. --dig;
  42. ans = 0;
  43. for(int i = 0; i < n; ++i) {
  44. ans += (num[i] >> dig) & 1;
  45. }
  46. printf("%d\n", ans);
  47. }
  48. return 0;
  49. }

本小节最后简单介绍一下 游戏,游戏规则除了将 游戏最后一条改为“取走最后一个石子的人失败”,其他游戏规则都相同,则可以用以下结论判定必胜态:

  1. 所有堆的石子数都为 且它们的异或值为
  2. 有些堆的石子数大于 且它们的异或值不为

该证明的方法也是从必胜态与必败态的转化入手,假设当前状态为必胜态(分石子数都为 与有些堆石子数大于 两种情况),证明其后继状态中必存在一个必败态,假设当前状态为必败态,证明其所有的后继状态都为必胜态,详细过程留给读者自行证明。

函数

以上的条件比较宽松,即对于每一堆石子,都可以取 个石子,若题目中给定一些约束条件,我们应该如何解决?
如果将每个状态都映射为一个值,这个值表示该状态为必胜态或者必败态,类比 游戏,我们将这个状态的函数定义为 函数:

于是,我们就可以将上面的 游戏每一堆的石子数 对应地等于 ,因为对于 的后继状态集合为 ,对于多个这样的游戏,我们可以将其 值异或起来,即整个游戏的 值。
相反地,我们可以将带限制的多个并行游戏,打出单个游戏的 表,就可以等价于 游戏了。
多数情况下,我们将 定义为必败态, 定义为必胜态。
有了 函数,我们做起博弈问题来,也就可以更加得心应手了,实际上, 函数对于单个游戏而言,时间复杂度与记忆化搜索相同,但是在特殊情况下,我们可以通过 函数打表找规律或者预处理,来大大降低时间复杂度。而对于多个并行游戏, 函数是再好用不过的了,但是一定要注意,使用 函数条件的严格证明。这里给出 打表的代码(来自板子-博弈论):

  1. const int maxn = 10000 + 100;
  2. int n;
  3. int Array[maxn], SG[maxn];
  4. bool visit[maxn];
  5. // maxn 为“石子”数,n 为 Array 数组大小,Array 数组需从小到大排序
  6. void get_SG() {
  7. SG[0] = 0;
  8. for(int i = 1; i <= 10000; ++i) {
  9. memset(visit, 0, sizeof(visit));
  10. for(int j = 0; j < k; ++j) {
  11. if(i >= Array[j]) {
  12. visit[SG[i - Array[j]]] = true;
  13. }
  14. }
  15. for(int j = 0; j <= 10000; ++j) {
  16. if(!visit[j]) {
  17. SG[i] = j;
  18. break;
  19. }
  20. }
  21. }
  22. }

最后来用一道 打表的裸题运用一下:

HDU 1536: S-Nim

题意

两个人玩多组变体 游戏,每组 游戏中,有 个状态:给定石子的堆数 以及每一堆石子的数量 ,以及每次可以从一堆当中取的石子的个数 个不同的值,对于每组的 个状态,判断该状态为必胜态还是必败态。

数据范围

题解

对于每组给定的可以取的石子的数量,打一个 表,然后对每个状态进行 值异或判断。

过题代码

  1. #include <iostream>
  2. #include <cstdio>
  3. #include <cstdlib>
  4. #include <cmath>
  5. #include <climits>
  6. #include <cstring>
  7. #include <string>
  8. #include <vector>
  9. #include <list>
  10. #include <queue>
  11. #include <stack>
  12. #include <map>
  13. #include <set>
  14. #include <bitset>
  15. #include <algorithm>
  16. #include <functional>
  17. #include <iomanip>
  18. using namespace std;
  19. #define LL long long
  20. const int maxk = 200;
  21. const int maxn = 10000 + 100;
  22. int k, m, l, num;
  23. int Array[maxk];
  24. int SG[maxn];
  25. bool visit[maxn];
  26. void get_SG() {
  27. SG[0] = 0;
  28. for(int i = 1; i <= 10000; ++i) {
  29. memset(visit, 0, sizeof(visit));
  30. for(int j = 0; j < k; ++j) {
  31. if(i >= Array[j]) {
  32. visit[SG[i - Array[j]]] = true;
  33. }
  34. }
  35. for(int j = 0; j <= 10000; ++j) {
  36. if(!visit[j]) {
  37. SG[i] = j;
  38. break;
  39. }
  40. }
  41. }
  42. }
  43. int main() {
  44. #ifdef LOCAL
  45. freopen("test.txt", "r", stdin);
  46. // freopen("out.txt", "w", stdout);
  47. #endif // LOCAL
  48. ios::sync_with_stdio(false);
  49. while(scanf("%d", &k), k != 0) {
  50. for(int i = 0; i < k; ++i) {
  51. scanf("%d", &Array[i]);
  52. }
  53. sort(Array, Array + k);
  54. get_SG();
  55. scanf("%d", &m);
  56. for(int i = 0; i < m; ++i) {
  57. int ans = 0;
  58. scanf("%d", &l);
  59. for(int i = 0; i < l; ++i) {
  60. scanf("%d", &num);
  61. ans ^= SG[num];
  62. }
  63. if(ans == 0) {
  64. printf("L");
  65. } else {
  66. printf("W");
  67. }
  68. }
  69. printf("\n");
  70. }
  71. return 0;
  72. }

其他博弈问题及结论

巴什博弈

题目

一堆物品有 个,两个人轮流从这堆物品中取物,规定每次至少取一个,最多取 个,取走最后一个物品者获胜。

结论

时,先手必败,否则先手必胜。

威佐夫博奕

题目

有两堆物品,分别有 个与 个,两个人轮流取物品,每次可以从任意一堆中取走任意个物品,或者从两堆物品中取走任意多个相同数量的物品,每次最少取一个物品,取走最后一个物品者获胜。

结论

,则先手必败,否则先手必胜。

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注