您的位置:首页 > 其它

五子棋(二)极大极小搜索 + 剪枝

2016-05-15 20:44 295 查看
五子棋(一)

如果在落子的时候往后推算几步,可以大概估算一下复杂度。如果往后推算 4 步走法,即给自己和对方各推算两步,每步计算 50 个可落子点;那总共需要计算 50^4 = 6,250,000 个结点;这是一颗非常巨大的博弈树;程序的实际情况是每秒大约算 10000 个结点(有点慢~),那将需要 600 多秒才可确定下一步落子,这样的结果是不可接受的。还有一点就是,推算的层数最好是偶数,因为如果是奇数的话,最后一步并没有考虑到对方落子,防御力会降低。



在这颗博弈树中用极大极小的方法去计算计算机的下一步落子,可以使计算机为 MAX 层,玩家为 MIN 层。我们可以使用 alpha-beta 剪枝来实现搜索的优化。(博弈基础)

在源代码的 computer.c -> alpha_beta()

// 初始值 player = 0, depth = 0, alpha = -inf, beta = inf
alpha_beta(player, depth, alpha, beta)
begin
if 成五 then
返回结果(inf or -inf)
endif
if depth >= depth_limit then
返回估值
endif

if(!player) then // 计算机
if !depth then // 在搜索入口层
// 取落子坐标
y = p[0].y
x = p[0].x
endif
n = cal_points(p[]) // 计算所有可落子点的得分并记录在数组 p[]
for i = 0; i < n; i++
set_in(p[i].y, p[i].x) // 落子
val = alpha_beta(player^1, depth+1, alpha, beta)
if val > alpha then
alpha = val
if !depth then // 更新结果
y = sp[i].y
x = sp[i].x
endif
endif
if alpha >= beta then // 剪枝,等号不可去掉
return alpha
endif
set_out(p[i].y, p[i].x) // 回退
end
return alpha
else if(player) // 计算机
n = cal_points(p[]) // 计算所有可落子点的得分并记录在数组 p[]
for i = 0; i < n; i++
set_in(p[i].y, p[i].x) // 落子
val = alpha_beta(player^1, depth+1, alpha, beta)
if val < beta then
beta = val
endif
if alpha >= beta then // 剪枝,等号不可去掉
return beta
endif
set_out(p[i].y, p[i].x) // 回退
end
return beta
endif
end


具体的实现细节:
之前说统计棋形的时候把二维转成一维再进行计算,这种做法在以上的搜索中表现得并不是很好;每预测落一子都要把该点横竖撇捺 4 个方向上的棋子转成一维,而且每次达到搜索深度都要将整个局面 70 多路棋子转换为一维;在效率上造成很大影响。因此,需要一种更优秀的方案: 

可以用一个全局数组 points[MAPSIZE][MAPSIZE][2] 表示整个搜索过程中可落子点的得分情况;可令 points[y][x][0] 表示在点 (y, x) 处下黑棋时,黑棋可得到的分数;points[y][x][1] 表示在点 (y, x) 处下白棋时,白棋可得到的分数。拿前面的例子来说就是:

攻击力:冲四 + 2 * 连活二 = 300 + 2 * 100 = 500

防御力:连活三 = 450

该点价值:500 + 450 = 950

设该点为 (y, x) (第 y 行,第 x 列)

point[y][x][0] = 500

point[y][x][1] =
450



然后,不把二维转换成一维,那怎样统计棋形?如下图所选点为 (y, x),并假设在该点落黑棋



可以用 4 个数组 lchess[2],rchess[2],lempty[2],rempty[2] 来记录在一个方向上的情况,初始值为 0;

无论哪个方向,碰到对手棋都要立刻停止

比如横向:

lchess[0] 表示 (y, x) 左边的且与 (y, x) 相连的连续同类棋数目,这里 lchess[0] = 0;

lempty[0] 表示从 (y, x) 左边第一个空点数起的连续空点数目,这里 lempty[0] = 1;

lchess[1] 表示 (y, x) 左边的且与 (y, x) 至少隔一个空点的连续同类棋数目,这里 lchess[1] = 1;

lempty[1] 表示在 lchess[1] 个同类棋左边的连续空点数目,这里 lempty[1] = 4;

右边做类似处理得:rchess[0] = 1, rempty[0] = 2, rchess[1] = 0(到墙了), rempty[1] = 0(到墙了)

这样就可以根据这些值判断棋型了;这个例子横向棋型为 xxoxooxx ,是活三

1)lchess[0]+1+rchess[0] = 2,加 1 表示 (y, x) 自身;

2)往左一个空点,即 lempty[0] = 1;

3)再往左一个同类棋,即 lchess[1] = 1;

4)整个 oxoo 两边至少各有一个空点(xoxoox ),即 lempty[1] >= 1 && rempty[0] >= 1;

竖向类似,可设下为左,上为右,得:

lchess[0] = 0, lempty[0] = 1, lchess[1] = 0, lempty[1] = 0

rchess[0] = 3, rempty[0] = 0(碰到对手棋,后面类似), rchess[1] = 0, rempty[1] = 0

类似分析可得冲四棋型

判断完横竖撇捺 4 个方向上的棋型就可以打分了。

points[][][2] 的计算在源代码的 computer.c -> cal_point() -> get_points() -> cal_chess() 

这样,在搜索过程中在某点落子时,发生改变的 points[][][] 里的值就在该点横竖撇捺 4 个方向上的值,仅改变这 4 个方向上的可落子点的值就可以了,回退时再改回来。

还有一点重要的优化是:在选择落子的时候,不必所有的可行点都去尝试一下,比如大部分落子是在棋盘中心,你没理由在棋盘的某个角落里下子;这些点就是可以排除的。这样,在计算所有可落子点的得分后,对得分由高往低排序,取前面少许点来试探就可以了,得分太低的不需要理会,因为就算考虑它,在搜索的时候仍然会被排除掉;吃力不讨好,还影响了搜索速度。当然也不能只取几个~,取四五十个就差不多了,具体还是需要根据测试确定。

另一个重要的优化是:有些棋型的出现是可以直接判断胜负的,不需要再往下搜索;前面也讲过。

如果落一子

可以成五,则必胜;

可以成活四,双冲四,冲四活三这样的的绝杀棋,且对手不能一步成五,则必胜;

可以成双活三,且对手不能一步成五,也不能一步成绝杀棋,则必胜

以上优化都加入后

伪代码为:

cal_points() // 计算所有可落子点的得分
// 初始值 player = 0, depth = 0, alpha = -inf, beta = inf
alpha_beta(player, depth, alpha, beta)
begin
if 成五 then
返回结果(inf or -inf)
endif
if depth >= depth_limit then
返回估值
endif

if(!player) then // 计算机
if !depth then // 在搜索入口层
// 取落子坐标
y = p[0].y
x = p[0].x
endif
n = set_order(p[]) //对所有可落子点排序并记录在数组 p[]
for i = 0; i < n && i < num_limit; i++ // 最多计算 num_limit 个点
if kill() then // 绝杀棋判断
if !depth then // 更新结果
y = sp[i].y
x = sp[i].x
endif
alpha = inf;
break;
endif
set_in(p[i].y, p[i].x) // 落子
val = alpha_beta(player^1, depth+1, alpha, beta)
if val > alpha then
alpha = val
if !depth then // 更新结果
y = sp[i].y
x = sp[i].x
endif
endif
if alpha >= beta then // 剪枝,等号不可去掉
return alpha
endif
set_out(p[i].y, p[i].x) // 回退
end
return alpha
else if(player) // 计算机
n = set_order(p[]) //对所有可落子点排序并记录在数组 p[]
for i = 0; i < n && i < num_limit; i++ // 最多计算 num_limit 个点
if kill() then // 绝杀棋判断
beta = -inf;
break;
endif
set_in(p[i].y, p[i].x) // 落子
val = alpha_beta(player^1, depth+1, alpha, beta)
if val < beta then
beta = val
endif
if alpha >= beta then // 剪枝,等号不可去掉
return beta
endif
set_out(p[i].y, p[i].x) // 回退
end
return beta
endif
end

做到以上所说,进行 6 层搜索,每层搜索 20 个结点,可以把 20^6 = 64,000,000 个结点缩到 100000 以内,可以在几秒内落子;而且随着局势的明朗,会更快找到解答(其实就是绝杀棋的作用)

源代码的 fun.c -> go_chose 中的 computer_go() 与 test() 分别表示多层搜索与单层贪心搜索。

地址:https://github.com/QYPan/gobang2

文件:gobang22

把界面改成纯 ASCII 字符了~

下一篇将加入置换表,进一步提高性能。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: