从这一篇文章开始,笔者开始了对《算法的乐趣》一书的学习。与以往笔者看的面向竞赛的算法数和经典教材不同,这本书接介绍的算法多为在现实生活中或者已经应用在生产实践当中的算法,比如说这篇文章所介绍的博弈树,就是前段时间非常火的人与AI的围棋大战的基础。
需要提前说明的一件事情是,由于本书当中的算法有非常好的应用与实践性,但是受笔者能力和经历所限,可能无法非常给出并分析算法的源代码,因此笔者在文章中介绍这些算法的时候,也主要以算法思想和伪代码为主,如果读者对某个算法的源代码感兴趣,可以留言笔者会将原作者所写的源码发送给你。
那么我们开始正文。
首先,我们来抛出一个问题,既IBM的深蓝打败国际象棋大师卡斯帕罗之后,也就在最近,Google的阿尔法狗又攻陷了人类智力的最后堡垒——围棋。那么我们一定会好奇,没有生命的计算机何以强大到如此地步能够在这些以智力称道的游戏中打败人类呢?那么接下来我们就要一一揭开棋类AI的神秘面纱。
博弈:
首先我们应该对我们要探讨的问题有充分的了解,不管是围棋还是国际象棋,他们本质上都能叫做“博弈”,那么什么是博弈呢?博弈可以理解为有限参与者进行有限策略选择的竞争性活动,比如下棋、打牌、竞技、战争等。而在这里我们将博弈更进一步简化,我们探讨一些简单的“二人零和、全信息、非偶然”的博弈。所谓零和,就是有输必有赢,最多出现平局,不会出现双赢的博弈;而“全信息”则指博弈过程双方对战局信息的了解是公开和透明的,即一种信息对称;而所谓“非偶然”,即规定博弈的参与者都是理性而聪明的。
初步了解了博弈的定义,我们从一个最简单的博弈开始探讨。
以井字棋游戏为例的极大极小值搜索算法:
首先来介绍井字棋游戏,非常类似五子棋,在一个3x3的方格上,双方轮流填充“x”和“o”,先使得3个“x”或“o”相连的一方获胜。
我们设玩家Max填充“x”,玩家Min填充“o”,Max先手。那么这里我们应该意识到的一个很重要的思想:计算机能够做出的一切看似“聪明”的策略,其实都是都是在较短时间内模拟出了所有棋局状态然后筛选出对AI最有利的状态。
我们来看这样一个图。
在这个图中,Player1其实就是上文中我们定义的Max,容易看到,这里是列出了前两步的所有状态的树结构,这种记录棋局状态的树状结构变叫做博弈树。
那么容易看到,我们可以顺着这个博弈树,得到所有的棋局状态,那么现在的问题是,假设我们已经得到了一个完全的博弈树,我们如何优化AI的决策呢?
这便是我们要介绍的极大极小值搜索算法,我们从Max即Player2的角度,设置一个权值w给博弈树中的每个节点,用来表征这个节点(某一种状态)对Max的有利程度,这个权值越大,表示这种状态对Max更有利。
那么我们在模拟对弈的过程中,对于第一层树也就是Max填充“x”,由上面的定义,显然Max倾向选择节点权值较大的节点,即w[1] = max(w[2],w[2],w[3]),因此我们需要找到博弈树第二层三个节点的权值,这便需要继续拓展博弈树的第三层。
而对于玩家Min来说,它显然倾向于选择对自己更有利的棋局状态,即w最小的节点,由此我们可知w[2] = min(w[5],w[6],w[7],w[8],w[9]),同时w[3]、w[4]也是类似的方法求解。
是否找到了规律化的模式呢?容易看到,这是一个递归算法,对于第i层落子的策略,不论对于Max还是Min,它都要基于第i+1层的所有状态,只不过不同的是,对于玩家Max,他要选择该节点所有子节点的权值最大值,而Min则要选择最小值。
最终,我们需要将博弈树拓展到叶节点,然后回溯回去得到应对各种情况的最有利策略。
基于对这种思想理解,我们不难写出下面极大极小值算法的伪代码。
int MiniMax(node,depth,isMaxPlayer) { if(depth == 0) return Evaluate(node); int score = isMaxPlayer ? -INF : INF; for_each(node的子节点child_node) { int value = MiniMax(child_node,depth-1,!isMaxPlayer); if(isMaxPlayer) score=max(score,value); else score=min(score,value); } }
其实我们能够看到,这种博弈树是基于搜索或者穷举或者dp的思想都可以,这不难理解,这是很基本的编程思维,虽然可以说这是设计棋类AI很核心的部分,但是这样就能打败人类了么?当然不能,这种算法设计模式面临的一个最大的问题是,博弈树的规模太过庞大,当时计算能力超强的计算机也无从招架,因此在真正的算法设计中,我们需要需要限制搜索深度和各种各样的剪枝如阿尔法贝塔剪枝、A*剪枝等算法来削减博弈树的规模,这便是我们下面要介绍的内容。