五子棋 – 人机对战 – 第二代

前言

终于把C语言课程大作业-五子棋写完了。虽然高中时曾写过一遍,本想着直接拿去用了。但一方面,学校课程要求纯C完成、不能有图形界面;另一方面,规则相比原来复杂许多,增添了禁手限制——看来还是得重新写一遍。>﹏<

在同学的帮助下,第二代五子棋相比第一代改进了算法,并增加了一些功能。在此表示感谢。

具体代码计划于课程结束后扔到EricJin2002/wuziqi: UCAS大二C语言课程大作业 (github.com)

编译

linux/gcc

编译参数记得加上

“-lm”, //调用了math.h中的pow函数

“-finput-charset=GBK” //文件编码格式为GBK

windows/msvc

需使用旧版控制台,可在命令提示符-属性-选项处设置

思路

规则

先手禁手,禁双三、双四、长连。

禁手与连五同时达成时,获胜为先。

黑方第一子必下天元。

基础功能

棋手

先手为黑,后手为白。为了方便逻辑判断,将两种颜色宏定义到1和0。

#define black 1
#define white 0

棋盘

棋盘的存储

棋盘通过一个全局数组(board)存储,默认设空,值为0。每下一子,在数组的对应位置记录当前落子步数(step)。并生成黑方禁手点,记录值为-2。

因此,通过判断数组值的正负性可以确定对应点是否为空,通过判断奇偶性可以确定落子方的颜色。

int step;
int board[16][16];

棋盘的打印

制表字符可以参考方框绘制字符 – 维基百科。需要额外注意的是,不同终端下输出的字符宽度不同,需要根据特定的编译环境指定对应的字符。

void print_char(int i,int j){
    if(board[i][j]==0){
        if(i==15){
            printf(j==1?"┌":j==15?"┐":"┬");
        }else if(i==1){
            printf(j==1?"└":j==15?"┘":"┴");
        }else{
            printf(j==1?"├":j==15?"┤":"┼");
        }
    }else if(board[i][j]==-2){
        printf("×");
    }else if(board[i][j]%2){//black
        printf(board[i][j]==step-1?"▲":"●");
    }else{//white
        printf(board[i][j]==step-1?"△":"○");
    }
#ifdef __linux__
    if(j!=15) printf("─");
#endif
    return;
}
void print_board(){
    for(int i=15;i>=1;i--){
        printf("%2d",i);
        for(int j=1;j<=15;j++){
            print_char(i,j);
        }
        printf("\n");
    }
    printf(" ");
    for(int j=1;j<=15;j++){
        printf(" %c",'A'-1+j);
    }
    printf("\n");
}

输入

使用函数(get_input)输入到全局变量(x,y),具体实现略。

int x,y;
bool get_input();

需要注意的是,输入有可能不合法,此时需要引入错误判断(judge_input)。如果没有错误,则存储落子时间到对应的落子点(store_input),并更新时间线(如果需要悔棋功能的话)。如果有错误,更新错误变量(err)为当前错误代码,并输出(print_err)。

int err;
bool judege_input();
bool print_err(){
    switch (err){
    case 1:printf("横坐标违法输入\n");break;
    case 2:printf("纵坐标违法输入\n");break;
    case 3:printf("该位置已经有子\n");break;
    case 4:printf("第一子当落天元\n");break;
    case 10:printf("该点为黑方禁手\n");break;
    case 8:
        printf(step%2?"白方":"黑方");
        printf("获胜\n\n");
        return false;
    case 9:printf("平局\n\n");return false;
    while(true){
        case 5:printf("悔棋两步,");break;
        case 6:printf("悔棋一步,");break;
        case 7:printf("无法悔棋,");break;
    }
    default:
        if(step!=1) printf("上一步位置为: %c%d\n",'A'+last_y-1,last_x);
        else printf("第一步请落天元\n");
        break;
    }
    err=0;
    return true;
}

胜负判断

每次落点,遍历该点的四个方向。对每个方向,从落子点向两端延伸,直至遇到非同色点或成五。需要额外注意的是,延伸范围不能超过棋盘边缘。

bool win_or_not(int x0,int y0,bool whom){
    int i=1,j=1;
    while(i+j!=6&&(x0+i<=15&&board[x0+i][y0]>0&&board[x0+i][y0]%2==whom&&++i||x0-j>=1&&board[x0-j][y0]>0&&board[x0-j][y0]%2==whom&&++j));
    if(i+j==6) return true;
    i=1;j=1;
    while(i+j!=6&&(y0+i<=15&&board[x0][y0+i]>0&&board[x0][y0+i]%2==whom&&++i||y0-j>=1&&board[x0][y0-j]>0&&board[x0][y0-j]%2==whom&&++j));
    if(i+j==6) return true;
    i=1;j=1;
    while(i+j!=6&&(x0+i<=15&&y0+i<=15&&board[x0+i][y0+i]>0&&board[x0+i][y0+i]%2==whom&&++i||x0-j>=1&&y0-j>=1&&board[x0-j][y0-j]>0&&board[x0-j][y0-j]%2==whom&&++j));
    if(i+j==6) return true;
    i=1;j=1;
    while(i+j!=6&&(x0+i<=15&&y0-i>=1&&board[x0+i][y0-i]>0&&board[x0+i][y0-i]%2==whom&&++i||x0-j>=1&&y0+j<=15&&board[x0-j][y0+j]>0&&board[x0-j][y0+j]%2==whom&&++j));
    if(i+j==6) return true;
    return false;
}

初始化

在游戏开始前,调用函数(choose_player)来选定双方玩家是否为机器(is_robot),以及选择是否开启禁手功能(ban_black)。然后,调用函数(initialize)对数组等变量初始化。

bool is_robot[2];
bool ban_black;
void choose_player();
void initialize();

流程

一个完整的实现可以表示如下:

int main(){
#ifndef __linux__
    system("color F0");
#endif
    //暂时忽略这一段
    black_robot.fg=fg5;
    black_robot.re=re5;
    black_robot.nt=nt5;
    white_robot.fg=fg5;
    white_robot.re=re5;
    white_robot.nt=nt5;
    
    choose_player();
    initialize();
    while(++step){
        if(ban_black) lianzhu_refresh_ban();
        clear();
        print_board();
        if(!print_err()) break;
        if(!(get_input()&&judge_input())) continue;
        store_input();
        //getchar();
        sleep(1);
    }
    getchar();
}

为了兼容不同平台,宏定义了sleep()与clear()函数。

#if defined(WIN32) || defined(_WIN32) || defined(__WIN32__) || defined(__NT__)
#include <conio.h>
#include <windows.h>
#define sleep(a) Sleep(1000*(a))
#define clear() system("cls")
#elif __linux__
#include <unistd.h>
#define clear() system("clear")
#endif

悔棋

触发

在玩家输入函数(get_input)处完成判断。当输入”re”,”rE”,”Re”,”RE”之一时触发悔棋。

bool get_input(){
    ...
        if(!(strcmp(input,"re")&&strcmp(input,"Re")&&strcmp(input,"RE")&&strcmp(input,"rE"))){
			...
            return false;
        }
    ...
}

实现

悔棋主要通过维护一个落子的时间线数组(timeline)实现。

typedef struct point{
    int x,y;
}point;
point timeline[15*15+2];

每次悔棋(retract)时,从时间线中读取之前一到两个值,并悔棋一到两步(两步是因为人机对战时,单悔棋一步会立刻被机器落子覆盖,导致无法成功悔棋)。

void retract(){
    if(--step){
        int prev_x=timeline[step].x;
        int prev_y=timeline[step].y;
        board[prev_x][prev_y]=0;
        if(--step){
            prev_x=timeline[step].x;
            prev_y=timeline[step].y;
            board[prev_x][prev_y]=0;
            err=5;
        }else{
            err=6;
            step++;
        }
    }else{
            err=7;
            step++;
    }
    if(--step){
        last_x=timeline[step].x;
        last_y=timeline[step].y;
    }
}

为便利后续落子机器悔棋,引入函数(announce_retract)通知机器玩家触发了悔棋。

void announce_retract(){
    if(is_robot[black]) black_robot.re(black);
    if(is_robot[white]) white_robot.re(white);
}

连珠

定义

使用lianzhu.h来实现对棋盘上特定位置特定方向上同色子列的判断。

包括长连、成五、活四、冲四、死四、双四、活三、跳活三、眠三、活二、眠二等,利用宏定义。

#define CHANG_LIAN 6
#define CHENG_5 5
#define HUO_4 4
#define CHONG_4 104
#define SI_4 40
//#define _3_4 34
#define _4_4 44
#define HUO_3 3
#define TIAO_HUO_3 103
#define MIAN_3 30
#define HUO_2 2
#define MIAN_2 20

寻找特定方向上的连续同色子列

通过函数(lianzhu_find)实现。从点(x0,y0)开始,逐次偏移(dx,dy),直至遇到非同色的点,记录该点是否为空(blank)并返回。

判断特定方向上的同色子列

通过函数(lianzhu_calc)实现。重复调用(lianzhu_find),完成对两个对立方向的查找。当遇到空点时,会继续调用依次查找函数。返回对该方向上长连、成五、活四、冲四、死四、双四、活三、跳活三、眠三、活二、眠二等状态的判断。

禁手

禁手判断

对于给定的点,遍历四个方向调用函数(lianzhu_calc),统计所有方向上连三、连四、连五的数目,返回该点是否为禁手点。

bool lianzhu_judge_ban(int x0,int y0){
    int _3=0;
    int _4=0;
    bool ban=false;
    bool win=false;
    int ans[5];
    for(int dir_4=1;dir_4<=4;dir_4++){
        ans[dir_4]=lianzhu_calc(x0,y0,black,dir_4,false);
    }
    for(int dir_4=1;dir_4<=4;dir_4++){
        switch (ans[dir_4]){
        case CHANG_LIAN: ban=true;break;
        case CHENG_5: win=true;break;
        case HUO_4: _4++;break;
        case CHONG_4: _4++;break;
        //case SI_4: break;//really?
        case _4_4: ban=true;break;
        case HUO_3: _3++;break;
        case TIAO_HUO_3: _3++;break;
        //case MIAN_3: break;
        default: break;
        }
    }
    if(win) return false;
    if(ban) return true;
    if(_3>=2||_4>=2) return true;
    return false;
}

刷新

遍历棋盘上的所有点,向棋盘数组(board)标记禁手信息。

void lianzhu_refresh_ban(){
    for(int i=1;i<=15;i++){
        for(int j=1;j<=15;j++){
            if(board[i][j]<=0){
                board[i][j]=lianzhu_judge_ban(i,j)?-2:0;
            }
        }
    }
}

棋盘全刷(lianzhu_refresh_ban)比较耗时,为了提升效率,可以刷新当前落子点四个方向上周围的点(lianzhu_refresh_ban_near)。

void lianzhu_refresh_ban_near(int x0,int y0){
    for(int dir_8=1;dir_8<=8;dir_8++){
        int dx=0,dy=0,score=0;
        switch (dir_8){
        case 1:dx=dy=1;break;
        case 2:dx=1;break;
        case 3:dx=1;dy=-1;break;
        case 4:dy=-1;break;
        case 5:dx=dy=-1;break;
        case 6:dx=-1;break;
        case 7:dx=-1;dy=1;break;
        case 8:dy=1;break;
        default:break;
        }
        int x1,y1,dir_4;
        for(int k=1;k<=5;k++){
            x1=x0+k*dx;
            y1=y0+k*dy;
            if(x1>=1&&x1<=15&&y1>=1&&y1<=15){
                if(board[x1][y1]<=0) board[x1][y1]=lianzhu_judge_ban(x1,y1)?-2:0;
            }else{
                break;
            }
        }
    }
}

落子机器

第一代

高中时完成了第一代五子棋,能正常防御双三、三四、双四等进攻。

思路及其实现

参见五子棋 – 人机对战

改进

主要改进点在于综合不同方向权值所采用的方法。

原文思路是计算特定方向上白子与黑子赋值的差值,并依据其大小预处理原始数据,再将四个方向的权值相加。

其中的预处理过程是为了凸显特定方向的权值、避免重要信息在多方向权值综合时被其他方向掩盖。

原文使用了分段线性处理原始权值,来放大连三、连四、连五的权值。现改进为幂次运算,通过机器自我博弈得出理想幂次在2-3之间,采用平方运算代替原始分段线性运算,实测更优。

一些继续改进的思路

可以引入搜索算法,向后搜索几层,寻找是否存在活三、活四等必胜点。

第一点五代

起因

单纯的深度搜索难以比较不同局面的优劣,这限制了搜索的效用。一种改进方法是构造一个全局局势估值算法,使用min-max搜索来代替朴素的深搜(from lzy)。

思路

一种基于五元组的实现方法(from xzh)给我带来了巨大启发。可以从头到尾沿各方向扫描棋盘,统计其中连三、连四、连五的数目,来为局势估值。总局势值为己方估值减去敌方估值,并对各方向求和。

统计函数可以使用字符串匹配算法(KMP)(from lzy)。

实现

没有实现(至今没找到bugつ﹏⊂)

第二代

起因

一方面,如果要使用min-max算法,考虑到同时维护逐点估值逻辑和全局局势逻辑,会造成算法冗长、性能低下。要想进一步改进程序,需要对原始逐点估值逻辑下手。

另一方面,课程规则要求有禁手功能。第一代程序无法较好地应对禁手。

思路

针对逐点估值逻辑的改进,可以通过判断落子点与周围各方向上五个点是否构成三、四等结构(即前文lianzhu.h囊括的内容),来进行逐点赋值。同样地,需要维护各个方向上的估值。不同的是,还需要维护黑白双方各自的估值。

最终的各点价值来源于各方向黑白估值之和。全局局势价值来源于,各点中己方权值的极大值减去敌方权值的极大值。

引入min-max搜索

具体原理详见极小化极大算法 – 维基百科

这里,为了提高搜索层数,每层递归被设计成只搜索若干个价值较高的点。通过向后搜索、得出在程序可预知的范围内点对应的估值,来从这些个点中搜索出最优点。

采用这种设计主要是为了减少无用的搜索,让机器具有更长远的目光,而非沉迷眼前细节。

引入alpha-beta剪枝

直接用alpha-beta算法替换min-max算法即可,详见Alpha-beta剪枝 – 维基百科

剪枝算法能大大优化性能,从而在有限的时间内实现更多层数的搜索。

后记

写这些主要是为了备忘,告诉未来的lazybirds,lazybird_202110写了些什么。

如果能帮到你,就更好了。

就酱。

发表评论

%d 博主赞过: