一 题目描述
有三堆石子,分别为7,5,3个每堆,两个人轮流进行如下操作:
选择一堆(石子数不为0);
从这堆石子中取走至少一个,至多全部的石子;
直到有人拿走最后一颗石子,该人算输。
二 思路
当时直觉上这样的题目先手必胜概率很大,但今天才有机会用代码写了一下。
1. 这种棋子数量不多的情况下,完全可以采用暴力求解的手段。
2. 如果基于目前局势,能够导致对手必输的局势,那么目前选手必胜;否则,目前选手必败。这个非黑即白的逻辑解释如下:
我们可以从最终的情况逐步往上添加石子构建到初始情况。比如1-0-0的情况是先手必败情况,那所有一次操作能够形成1-0-0的局势都认为是先手必胜;如果某一个局势不能通过减少石子变为任何一个先手必败的情况,那它只能是通过先手必胜的局势添加石子构建而来,换句话说,无论这个局势下怎么动,都是留给对手一个先手必胜的局势,这种情况下,先手必败。
3. 通过局势生成局势码,避免重复计算,进行剪枝。(实际效果:将递归调用次数从98708次降低到5592次)
三 代码
# -*- coding:UTF-8 -*- cnt = 0 # 计数器,看调用函数的次数 deadSet = set() # 因为涉及一些重复判断,所以使用一个set记录必输的情况,减少迭代次数 def genDeadNum(curList): # 局势转化为局势码 return "-".join([str(x) for x in sorted(curList, reverse = True)]) def decide(a): global cnt aOld = a[:] # 保存我们的局势 以及 我们操作之后的局势 for pileIdx in xrange(0, len(a)): # 选取一堆石子 if a[pileIdx] == 0: # 如果本身没有石子,略过 continue else: temp = a[pileIdx] # 备份原始数量 for remainNums in xrange(0, temp): # 选择拿走之后剩余的数量 print pileIdx, remainNums a[pileIdx] = remainNums if sum(a) == 0: # 如果都拿没了,说明我输了,我们先略过 continue else: # 还有石子,看对手的处境了 testNum = genDeadNum(a) # 我们先生成对应局势的码 if testNum in deadSet or decide(a) == 0: # 如果对应局势是必输,或者我们迭代后发现结果为0,对手必输,我们必胜,返回 1 print "From", aOld, "to ", a a[pileIdx] = temp cnt += 1 return 1 # 返回必胜的代码 1 a[pileIdx] = temp # 遍历完取这堆石子的可能性,记得还原这堆石子的初始状况 print "When the piles like ", a, "I cannot win!" # 如果上面的所有遍历都无法找到必胜手段,那就说明我们必然输了。 deadNum = genDeadNum(a) # 生成必将失败的局势码加到死亡集合中 deadSet.add(deadNum) cnt += 1 return 0 # 必输返回 0 if __name__ == "__main__": a= [7, 5, 3] res = decide(a) print res, cnt print list(deadSet)
可以看到,最终先手必败的情形只有如下几种
'3-2-1', '6-4-2', '1-0-0', '1-1-1', '3-3-0', '5-5-0', '2-2-0', '6-5-3', '4-4-0', '5-4-1'
比如:我们第一步是将7-5-3转化为6-5-3,给对手一个必败局势,然后大家可以自行想象未来的情形。
四 延伸, 谁捡到最后一个谁赢的游戏
修改规则变为谁最后一个拿谁赢
那么就变为异或的问题了。假设两种情形:
1. 三堆石子的数量异或之后为0
2. 三堆石子的数量异或之后为非0
对情形1进行任意取石子,都导致剩余的石子转换为情形2
对情形2的情况,必然可以用一种取石子方法将剩余石子转换为情形1
最后胜利的人是将情形转换为情形1的那个(0-0-0)
这样,如果初始为情形2,那么先手的人,只要负责每次操作将情形2转换为情形1即可。
如果初始为情形1,那么先手的人被迫给后手一个情形2,后手通过上句策略可胜。
代码稍作修改即可,略。
代码给出的必输情形如下,也就是所有可能的情形1的罗列。
['3-2-1', '6-4-2', '1-1-0', '5-4-1', '5-5-0', '2-2-0', '6-5-3', '4-4-0', '3-3-0']