博弈论重要算法:Sprague-Grundy 定理 (SRM 561 Div1 550)
源起:
TopCoder srm561,550 的题目 CirclesGame 是一个博弈的问题,判断是类似于 Nim 的游戏规则,当时不会做,后来看别人代码发现了都有一个名为 sg[] 的数组,不会然后研究了一下,最后搞懂了。然后在这里总结一下,这个算法实际上可以解决一大类的博弈算法问题。
题目简述:
A 和 B 玩游戏,在一个平面上有若干个不相交的圆圈(但可以内含),每一步的移动是选择一个点,将所有包含了这个点的圆圈删掉,谁最后不能移动谁输。给出这 N 个圆的圆心和半径 (N<=50),求 A 先移动时,假设大家都按最优策略移动,谁能赢得游戏。
题目分析:
很简单可以构造一个森林(若干个有根树),然后每次选择一个结点,删除它到对应树根的路径。一看到这种情形自然想到 Nim 游戏,而下面说的 sg 算法即是可以将这种若干情况转化为一些Nim游戏的组合的。
定理:
Nim 游戏就不说了,但其实相似的问题可以推广到一个更一般的情况:
Impartial Game:Impartial Game 简单来说,就是一个游戏,双方可以进行同样的操作,也就是说局面的移动权利是对等的(Nim 是 Impartial Game,但像国际象棋之类的就不是,因为只能动自己的棋子),唯一不同的只有移动的先后。于是,如果用一个图来表示这个游戏,那么图的顶点就对应于一个游戏状态,图的边就对应于一个合法的移动。
然后转入正题,假如在一个 Impartial Game 当中,终结状态只有一个,并且局面是不可重现的,那么整个游戏图就是一个 DAG(Directed Acyclic Graph, 有向无环图),这种情况下,可以将其等效成一个 Nim 游戏。
首先再回顾一下 Nim,有若干堆石子,A 和 B 交替移动,每次只能从某一堆中取出若干个石子,最后无法移动者必输。Nim 的结论是,将所有堆的石子数全部取异或,如果结果是 0,那么这个状态是先行者负,否则先行者胜。
然后,每堆石子其实是一个独立的 Nim,我们对于一些满足以上条件的 Impartial Game,就可以将其归约,等效于一个 Nim 游戏,他的胜负状态等效为一个 Nimber 数。至于怎么归约,就用到下面的 Sprague-Grundy 定理了,那么,下面直接上结论,要搞清楚原理,请读者自己看 Wikipedia。
Sprague-Grundy定理:对一个 Impartial Game 的状态,其等效游戏的 Nimber 数,就等于所有其后继状态 Nimber 数的 Mex 函数值。
Mex 函数:这个函数的接受一个自然数集 A,输出自然数集中减去 A 之后最小的那个整数。例如 Mex({0,1,3,4}) = 2, Mex({1,2,3}) = 0, Mex({}) = 0。下面是一个简单的 Python 代码,以更好地说明问题。
>>> def Mex(a): i = 0 while i in a: i += 1 return i >>> Mex({0, 1, 3, 4}) 2 >>> Mex({1, 2, 3}) 0 >>> Mex({}) 0
So, That's all. 最后我们如果拿到一个游戏,只需要把它拆成一个一个独立的游戏,计算它们的 Nimber,然后异或起来,就可以得到结果了。对于其中一个子游戏,我们可以 DP 所有状态的 Nimber 来得到当前状态的 Nimber。原来,我看别人写的 sg 数组就是用来装载各个状态的 Nimber 值的。
例子:
还是拿 CirclesGame 这个题来说事,直接分析样例(下面我直接将整个森林构造成一个括号结构,左括号紧跟着树节点的编号 (0-based),易于表达,我们每次删除一个路径,请读者自行对照,不解释):
明显,只有 0 这一个节点,它删除一条路径之后的后继状态为空集,而 Mex({}) = 0 所以它的 Nimber 为 Mex({0}) = 1,所以先手胜。输出 Alice。
这次是两个 Example 0 的并联,所以结果为 1 ^ 1 = 0,后手胜,输出 Bob。
这个森林有两棵树,所以分别计算出它们的 Nimber 来异或,其中第一棵树有两个后继状态,如果选择节点 0,那么整个路径删除就剩下空集了,Mex({}) = 0;如果选择节点 1,那么剩下 0 这一个节点,Example 1 算过,只有一个节点算出来的 Nimber = 1,所以第一棵树最后的结果是 Mex({0,1}) = 2,然后第二颗树就跟 Example 1 一样,最后的结果是 1 ^ 2 = 3,不为 0,所以先手胜,输出 Alice。
这个森林有三棵树,其中后面两棵跟 Example 2 的是一样的。所以我们现在算第 1 棵树。为方便表达,我们现在引入一个数组 sg[i],用于表示以节点 i 为根的子树的 Nimber 值(注意其实我们每次操作只能删除一个到树根的路径,那么剩下的肯定是若干个原样的子树构成的森林)。
然后我们来算,sg[0] = Mex({}) = 1;sg[1] = Mex({0, 1}) = 2;sg[2] = Mex({0, 1, 2}) = 3 ...
最后算出来 sg = [1, 2, 3, 1, 2, 1],我们最终的结果应该是原始的树根的 Mex 值之异或,所以结果为 sg[2] ^ sg[4] ^ sg[5] = 3 ^ 2 ^ 1 = 0,所以后手胜,输出 Bob。
这是 8 个 1 的异或,结果为 0,输出 Bob,不解释。
之后的例子就很难画图了,自己写一个例子:
x = {0, 1, 2, 6, 3, 12, 13, 14}
y = {1, 0, -1, 0, 0, 1, 0, -1}
r = {1, 3, 1, 1, 6, 1, 3, 1}
来看这个例子,首先可以看到 sg[0] = sg[2] = sg[3] = sg[5] = sg[7] = 1,单节点树的 Nimber 总是等于 1 的。
然后子树 1 的后继有三个,剩下的节点分别是 {0}, {2}, {0, 2},如果剩下节点 {0, 2},这个后继的 Nimber 是 sg[0] ^ s[2] = 1 ^ 1 = 0,所以 sg[1] = Mex({0, 1}) = 2,即 sg[1] = 2
同理 sg[6] = 2
然后剩下最后的子树 4,我们可以遍历它的所有节点,然后看一下剩下的子树的 sg 值的异或。
i. 如果干掉 0,后继为剩下的 sg[2] ^ sg[3] = 1 ^ 1 = 0
ii. 如果干掉 1,后继为剩下的 sg[0] ^ sg[2] ^ sg[3] = 1 ^ 1 ^ 1 = 1
iii. 如果干掉 2,同 i,结果为 0
iv. 如果干掉 3, 剩下 sg[1] = 2
v. 如果干掉 4,后继剩下的 sg[1] ^ sg[3] = 2 ^ 1 = 3
综上,sg[4] = Mex({0, 1, 2, 3}) = 4
最后,整个游戏的 Nimber = sg[4] ^ sg[6] = 4 ^ 2 = 6 不为 0,先手胜,输出 Alice。
题解:
最后说一下 CirclesGame 这个题的解法:
i. 构造树
按照给定的圆坐标把森林构造出来(我选择的是邻接矩阵,用一个有向图表示这个树,dist[i, j] 表示节点i到节点j的距离, i 必须是 j 的后代,dist[i, i] = 0,否则为 -1),同时构造数组 pre[i] (有了 pre 我们就知道哪些节点是树根);
ii. 树状 DP
先构造一个数组 sg[],然后我们需要来一个树状 DP,以获取所有节点代表的子树的 Nimber 值;这个树状 DP 有一点麻烦,因为对每一个子树,我们需要遍历所有子树的节点,然后获取删除一条对应的路径之后,剩下的所有子树的 Nimber 值,然后一起异或一下,然后把遍历得到的所有 Nimber 值求一下 Mex 函数。这个听起来已经够烦了。
假设我们现在求 sg[v],记节点 v 代表的子树为 T(v),然后我们遍历 v 的所有后代 w,这里构造好 dist 之后我们只要枚举 dist[w, v] >= 0 的所有 w 就好了。然后呢,路径上面的节点就是 u,我们也可以枚举 dist[w, u] >= 0 && dist[u, v] >= 0 来得到 u。
但是我们现在要求除去所有 u 之后 T(v) 剩下的所有森林的顶点,这个怎么办?幸好发现,如果 我们同时保存了每个节点的儿子的 sg 值的异或,问题可以被简化,我们构造 _sg[] 来存放每个节点的儿子节点的 sg 值的异或。参考下面的伪代码。
def _SG(v): if _sg[v] != -1: return _sg[v] _sg[v] = 0 for w in range(0, n): if pre[w] == v: _sg[v] = _sg[v] ^ SG(w) return _sg[v]
当然,我们要得到 _SG 之前,必须知道它的所有儿子的 SG 值。
我们得到 _sg 之后呢,其实只需要将所有路径上的节点的 _sg[u] 值异或起来,再把路径上的所有 sg[u] 异或起来(不过 v 必须排除在外),就可以得到删除路径之后剩下的森林的 sg 值的异或。这就是每个 w 删除路径之后的后继的 Nimber 值,然后我们拿所有枚举 w 得到的结果,最后求一下 Mex 函数。
这里,由于节点总数 n < 50,我们求 Mex 值的时候可以用位运算的掩码。我们每遍历设置一个 mask,每枚举到一个 w 删除路径后的后继 Mex 值之后,把对应的 mask 位设成 1,最后遍历完,我们输出 mask 最低位的 0 的编号,即是 v 的 sg 值。最后总结一下求 sg 的函数:
def Mex(mask): i = 0 while (1<<i) & mask > 0: i += 1 return i def SG(v): if sg[v] != -1: return sg[v] mask = 0 for w in range(0, n): if dist[w, u] >= 0: for u in range(0, n): if dist[u, v] >= 0: mask ^= _SG(u) if u != v: mask ^= SG(u)
sg[v] = Mex(mask) return sg[v]
留意到上述两个函数实际上都是使用了记忆化搜索的 DP技术。定义了以上函数以后,我们只需要枚举所有的原始森林中的树根,将他们的 sg 值异或一下,就是最终的结果,如果得到的是 0,那么输出是 Bob,否则输出 Alice。
总结:
sg 定理在博弈问题上的变种很多,应用面也比较广,如果能够掌握好 sg 定理,那么基本上涉及 Impartial Game 的题目应该不成问题了。我当时上网搜题解没有找到比较清晰的,所以自己今天搞了一份,希望对大家有帮助。日后遇到同类的题目,我也会更新在这篇文章后面,如果各位又发现题目链接,也希望能够提供,以丰富这个算法的练习渠道。
另外,建议大家还是啃一啃 Wikipedia 上面的这些证明,对深层次理解这些算法问题有很大益处。