博弈论重要算法: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 上面的这些证明,对深层次理解这些算法问题有很大益处。

posted @ 2013-01-19 14:39 呆滞的慢板 阅读(...) 评论(...) 编辑 收藏
呆滞的慢板