前言
最近,我写了一个五子棋人机对战的AI,但速度一直很慢。其实,我感觉性能的瓶颈并不是极小化极大算法和α-β剪枝有问题,而是估价函数太差。其实,估价函数比极小化极大算法和α-β剪枝这两个算法的难度大多了,具体表现是,我开始跟着这篇博客(讲解的非常详细,虽然α-β剪枝有点错误,评论区有人指出来了,但是其他地方包括估价函数值得借鉴。在此也向作者表示感谢)学习博弈树的时候,极小化极大算法和α-β剪枝,我就只看了一个介绍和节点的数据结构模型,然后就摸索着写出了代码(虽然不知道正确不正确)。而估价函数,我是一点头绪都没有,后来只好把教程上的代码复制上了。后来我仔细研究那些代码,终于明白了。并且昨天考试写完试卷闲着无聊的时候,又想出来了一种能略微提升速度的方法。
我也把这个AI和之前做的双人五子棋结合了,形成一个拥有MFC图形界面的五子棋人机对战程序,点击此处下载。
旧版本估价函数
原来的版本思路基本如下:
遍历棋盘,寻找所有的五个点组成的棋链,对每一个棋链执行以下逻辑:
- 如果该棋链中既有白子又有黑子,该棋链估价为0;
- 如果该棋链中只有n个一种颜色的白棋(默认计算机为白),若 n ≤ 4 n≤4 n≤4,得 k n k^n kn分;若 n = 5 n=5 n=5,得INT_MAX分并且立即结束整个算法(并非对一个棋链的估价,而是对整个棋盘的估价)。k可以自由调整,我这里是10。
- 如果该棋链中只有n个一种颜色的黑棋(默认计算机为白),若 n ≤ 4 n≤4 n≤4,得 − p k n -pk^n −pkn分;若 n = 5 n=5 n=5,得INT_MIN分并且立即结束整个算法(并非对一个棋链的估价,而是对整个棋盘的估价)。p可以自由调整。如果想让计算机优先防守,将p设为一个较大的值,如1.5。
最后,把所有棋链的分数加起来即可。
C++代码如下:
/*
一个节点类的成员函数,State是枚举,有BLACK,WHITE,SPACE;Board是棋盘,State[15][15]类型的。
*/
int Evaluate()const//估价函数
{
int result = 0;
static auto EvaluateSome = [](const std::array<State, 5>& v)//假定自己是白方
{
//判断颜色并记录棋子个数
State lastColor = SPACE;
uint8_t count = 0;
for (State i : v)
{
if (i != SPACE)
{
++count;
if (i != lastColor)
{
if (lastColor == SPACE)//遇到的第一个棋子
{
lastColor = i;
}
else//有不同颜色的棋子
{
return 0;
}
}
}
}
if (!count)//没有棋子
return 0;
if (count == 5)
{
return lastColor == WHITE ? INT_MAX : INT_MIN;//一定不要认为-INT_MAX就是INT_MIN!
}
const int result = static_cast<int>(std::pow(10, count - 1));
return lastColor == WHITE ? result : static_cast<int>(-1.1 * result);//对手返回负值,我方返回正值,乘以1.1后优先防守
};
for (uint8_t i = 0; i < 15; i++)//分别从四个方向判断
{
for (uint8_t j = 0; j < 15; j++)
{
if (j + 4 < 15)
{
std::array<State, 5>v;
for (uint8_t k = 0; k < 5; k++)
v[k] = board[i][j + k];
const int t = EvaluateSome(v);
if (t == INT_MAX || t == INT_MIN)//决出胜负直接返回
return t;
result += t;
}
if (i + 4 < 15)
{
std::array<State, 5>v;
for (uint8_t k = 0; k < 5; k++)
v[k] = board[i + k][j];
const int t = EvaluateSome(v);
if (t == INT_MAX || t == INT_MIN)//决出胜负直接返回
return t;
result += t;
}
if (i + 4 < 15 && j + 4 < 15)
{
std::array<State, 5>v;
for (uint8_t k = 0; k < 5; k++)
v[k] = board[i + k][j + k];
const int t = EvaluateSome(v);
if (t == INT_MAX || t == INT_MIN)//决出胜负直接返回
return t;
result += t;
}
if (i + 4 < 15 && j - 4 >= 0)
{
std::array<State, 5>v;
for (uint8_t k = 0; k < 5; k++)
v[k] = board[i + k][j - k];
const int t = EvaluateSome(v);
if (t == INT_MAX || t == INT_MIN)//决出胜负直接返回
return t;
result += t;
}
}
}
return result;
}
这个代码需要把整个棋盘遍历一遍,粗略估计一下,遍历的范围是15×15=225。
新版本估价函数
我突然想到,你下到一个地方,只能影响到周围9×9的范围,我们可以把每一个节点的估价保存下来,然后,创建一个新的节点时,先按照原来的方法估价这9×9的范围,然后估价父节点9×9的范围,用父节点原来的估价分数减去父节点9×9的范围得分,再加上子节点9×9范围的得分,就是子节点的得分。这种算法在最坏情况下(落子地方靠近棋盘中间),需要遍历的范围是9×9×2=162,计算量大大减小。另外,如果落子的地方靠近棋盘边角,使得9×9的范围缩小,那就可以更节省时间,最好情况下(下到棋盘角上)遍历范围只有6×6×2=72。现实情况中,有许多次都是遍历了边角处,所以速度加快了许多。这样还有一个好处,就是最坏情况始终是162,不会因为棋盘的变大而变大,如果棋盘是100×100,这个算法还是遍历162个,而第一种算法遍历100×100=10000个。经过测试,这种方法比原来的速度提高了许多。下面是C++代码:
int Evaluate()const//估价函数
{
static auto EvaluateSome = [](State board[BOARDSIZE][BOARDSIZE], uint8_t beginX, uint8_t endX, uint8_t beginY, uint8_t endY) {
static auto EvaluateList = [](const std::array<State, 5>& v)//假定自己是白方
{
//判断颜色并记录棋子个数
State lastColor = SPACE;
uint8_t count = 0;
for (State i : v)
{
if (i != SPACE)
{
++count;
if (i != lastColor)
{
if (lastColor == SPACE)//遇到的第一个棋子
{
lastColor = i;
}
else//有不同颜色的棋子
{
return 0;
}
}
}
}
if (!count)//没有棋子
return 0;
if (count == 5)
{
return lastColor == WHITE ? INT_MAX : INT_MIN;//一定不要认为-INT_MAX就是INT_MIN!
}
const int result = static_cast<int>(std::pow(10, count - 1));
return lastColor == WHITE ? result : -result;//对手返回负值,我方返回正值
};
int result = 0;
for (uint8_t i = beginX; i < endX; i++)//分别从四个方向判断
{
for (uint8_t j = beginY; j < endY; j++)
{
if (j + 4 < endY)
{
std::array<State, 5>v;
for (uint8_t k = 0; k < 5; k++)
v[k] = board[i][j + k];
const int t = EvaluateList(v);
if (t == INT_MAX || t == INT_MIN)//决出胜负直接返回
return t;
result += t;
}
if (i + 4 < endX)
{
std::array<State, 5>v;
for (uint8_t k = 0; k < 5; k++)
v[k] = board[i + k][j];
const int t = EvaluateList(v);
if (t == INT_MAX || t == INT_MIN)//决出胜负直接返回
return t;
result += t;
}
if (i + 4 < endX && j + 4 < endY)
{
std::array<State, 5>v;
for (uint8_t k = 0; k < 5; k++)
v[k] = board[i + k][j + k];
const int t = EvaluateList(v);
if (t == INT_MAX || t == INT_MIN)//决出胜负直接返回
return t;
result += t;
}
if (i + 4 < endX && j >= 4)
{
std::array<State, 5>v;
for (uint8_t k = 0; k < 5; k++)
v[k] = board[i + k][j - k];
const int t = EvaluateList(v);
if (t == INT_MAX || t == INT_MIN)//决出胜负直接返回
return t;
result += t;
}
}
}
return result;
};
uint8_t beginX, endX, beginY, endY;
if (lastX <= 5)
beginX = 0;
else
beginX = lastX - 5;
endX = lastX + 5;
if (endX > BOARDSIZE)
endX = BOARDSIZE;
if (lastY <= 5)
beginY = 0;
else
beginY = lastY - 5;
endY = lastY + 5;
if (endY > BOARDSIZE)
endY = BOARDSIZE;
const int t = EvaluateSome((State(*)[15])board, beginX, endX, beginY, endY);
if (t == INT_MAX || t == INT_MIN)//决出胜负直接返回
return t;
return t - EvaluateSome((State(*)[15])father->board, beginX, endX, beginY, endY) + father->evaluateValue;
}
第三版估价函数
过了一段时间,我又想出了一种更优的方案,,具体来说,在判断一个棋链的得分(即EvaluateList 函数)时,不再简单地根据棋子的数量确定分数,而是考虑棋子的具体分布,对每一种分布给出一个特定的分数。不难看出,这样没有改变时间复杂度,但使结果相对来说准确了一些。下面是一个棋链中不同分布对应的得分,这里的分数是我估摸着打的,可以进行调整优化。
另外,评论区有大佬指出,并不需要重新计算9*9的区域,只需要计算包含上一步落子点的最多20个棋链就可以,这样也可以大大加快速度。
代码:
int Evaluate()const//估价函数
{
static const auto EvaluateList = [](const std::array<State, 5>& v)//假定自己是白方
{
//判断颜色并记录棋子个数
State lastColor = SPACE;
uint8_t bitList = 0;//将棋链以二进制形式表示,如01101
for (State i : v)
{
bitList <<= 1;
if (i != SPACE)
{
if (i != lastColor)
{
if (lastColor == SPACE)//遇到的第一个棋子
lastColor = i;
else//有不同颜色的棋子
return 0;
}
bitList |= 1;
}
}
static constexpr int results[]
= { 0,5,5,80,5,60,100,500,5,20,80,500,100,500,8000,100000,5,10,//0-17
20,600,50,600,500,8000,80,600,500,6000,500,8000,100000,maxEvaluteValue };//18-31
/*
* 十进制-二进制-得分
* 0-00000-0
* 1-00001-5
* 2-00010-5
* 3-00011-80
* 4-00100-5
* 5-00101-60
* 6-00110-100
* 7-00111-500
* 8-01000-5
* 9-01001-20
* 10-01010-80
* 11-01011-500
* 12-01100-100
* 13-01101-500
* 14-01110-8000
* 15-01111-100000
* 16-10000-5
* 17-10001-10
* 18-10010-20
* 19-10011-600
* 20-10100-50
* 21-10101-600
* 22-10110-500
* 23-10111-5000
* 24-11000-80
* 25-11001-600
* 26-11010-500
* 27-11011-6000
* 28-11100-500
* 29-11101-5000
* 30-11110-100000
* 31-11111-MAX
*/
//对手返回负值,我方返回正值
//乘以1.1是为了偏重防守
if (bitList == 31)
{
return lastColor == WHITE ? maxEvaluteValue : -maxEvaluteValue;
}
return lastColor == WHITE ? results[bitList] : -results[bitList] * 11 / 10;
};
static const auto EvaluteSome = [](State** board, uint8_t lastX, uint8_t lastY)
{
int result = 0;
for (uint8_t i = 0; i < 5; ++i)
{
//横向
if (lastX + i < boardSize && lastX + i >= 4)
{
std::array<State, 5>v;
for (uint8_t k = 0; k < 5; k++)
v[k] = board[lastX + i - k][lastY];
int t = EvaluateList(v);
if (t == maxEvaluteValue || t == -maxEvaluteValue)//决出胜负直接返回
return t;
result += t;
}
//纵向
if (lastY + i < boardSize && lastY + i >= 4)
{
std::array<State, 5>v;
for (uint8_t k = 0; k < 5; k++)
v[k] = board[lastX][lastY + i - k];
int t = EvaluateList(v);
if (t == maxEvaluteValue || t == -maxEvaluteValue)//决出胜负直接返回
return t;
result += t;
}
//左上-右下
if (lastX + i < boardSize && lastX + i >= 4 && lastY + i < boardSize && lastY + i >= 4)
{
std::array<State, 5>v;
for (uint8_t k = 0; k < 5; k++)
v[k] = board[lastX + i - k][lastY + i - k];
int t = EvaluateList(v);
if (t == maxEvaluteValue || t == -maxEvaluteValue)//决出胜负直接返回
return t;
result += t;
}
//左下-右上
if (lastX + i < boardSize && lastX + i >= 4 && lastY - i + 4 < boardSize && lastY - i >= 0)
{
std::array<State, 5>v;
for (uint8_t k = 0; k < 5; k++)
v[k] = board[lastX + i - k][lastY - i + k];
int t = EvaluateList(v);
if (t == maxEvaluteValue || t == -maxEvaluteValue)//决出胜负直接返回
return t;
result += t;
}
}
return result;
};
int t = EvaluteSome(board, lastX, lastY);
if (t == maxEvaluteValue || t == -maxEvaluteValue)//决出胜负直接返回
return t;
return t + father->GetEvaluateValue() - EvaluteSome(father->board, lastX, lastY);
}