假设有一个双人零和游戏,比如井字棋、象棋或某种回合制对战。两个玩家分别叫 MAX 和 MIN。MAX 想让最终得分尽可能大,MIN 想让最终得分尽可能小。游戏从当前状态开始,每个状态可以走若干步,形成一棵游戏树。树的叶子节点表示游戏结束,或者达到预设深度后由评估函数给出一个分数。minimax 要算的,就是把这棵树从叶子往根节点回溯,给每个节点算出一个值。这个值表示:在双方都采取最优策略时,当前节点最终会得到多少分。
计算规则很直接。若当前节点是终止节点,就直接返回它的效用值。若当前轮到 MAX 走,那么这个节点的值等于所有子节点值中的最大值,因为 MAX 会选择对自己最有利的一步。若当前轮到 MIN 走,那么这个节点的值等于所有子节点值中的最小值,因为 MIN 会选择让 MAX 得分最低的一步。用公式写就是:终止节点 V(s)=utility(s);MAX 节点 V(s)=max V(后继状态);MIN 节点 V(s)=min V(后继状态)。递归地进行这个计算,直到根节点。
举个简单例子。根节点是 MAX 走,它有两个动作 A 和 B。走 A 后轮到 MIN,MIN 有两个选择,叶子分数分别是 3 和 5,因此 MIN 会选 3,所以 A 节点的值是 3。走 B 后也轮到 MIN,叶子分数分别是 2 和 9,MIN 会选 2,所以 B 节点的值是 2。回到根节点,MAX 在 A 的值 3 和 B 的值 2 之间选择,最大值是 3,所以根节点的 minimax 值是 3,MAX 的最优动作是走 A。如果根节点是 MIN 走,那么它就会在子节点值中取最小值,并选择对应动作。
写成伪代码,大致是这样:function minimax(state, depth, maximizingPlayer)。如果 state 是终止状态,或者 depth 等于 0,就返回 utility(state) 或评估函数值。如果 maximizingPlayer 为真,令 value 为负无穷;对每一个合法动作产生的子状态,计算 childValue = minimax(child, depth1, false),然后 value = max(value, childValue)。最后返回 value。如果 maximizingPlayer 为假,令 value 为正无穷;对每一个子状态,计算 childValue = minimax(child, depth1, true),然后 value = min(value, childValue)。最后返回 value。实际程序里,除了返回值,还要记录取得最大值或最小值的那个动作,这样才知道当前应该走哪一步。
如果游戏树太大,不能完全展开,就只搜索到一定深度,并用启发式评估函数代替真正的胜负值。还可以用 alphabeta 剪枝来减少计算量。alpha 表示 MAX 目前至少能保证得到的分,beta 表示 MIN 目前最多愿意让 MAX 得到的分。当 alpha 大于等于 beta 时,后面的分支不可能改变上层选择,就可以停止搜索。剪枝不会改变 minimax 的最终结果,只是少算一些分支。对于非零和游戏,不能只用一个分数表示双方收益,因为一方得分多不一定等于另一方得分少,这时需要改用向量收益或一般博弈的纳什均衡方法。对于标准零和博弈,minimax 的核心就是叶子估值、MAX 层取最大、MIN 层取最小、逐层回溯,根节点的值就是当前局面的最优保证值,根节点选中的子节点就是当前玩家的最优动作。 |
|