从零构建五子棋AI:C++实现中的博弈树与α-β剪枝实战

张开发
2026/4/4 10:46:05 15 分钟阅读
从零构建五子棋AI:C++实现中的博弈树与α-β剪枝实战
1. 五子棋AI的基本原理五子棋作为一款经典的策略型棋类游戏其AI实现的核心在于模拟人类棋手的思考过程。想象一下当你下棋时会考虑如果我下这里对方可能会下那里然后我可以这样应对...这正是博弈树算法的基本思路。博弈树就像是一棵倒立的树树根代表当前棋局每个分支代表一种可能的走法。对于五子棋这样的双人博弈我们通常使用极大极小算法Minimax来评估这些走法。简单来说AI会假设自己MAX方总是选择最优走法而对手MIN方则会选择对AI最不利的走法。在实际编码中我们会遇到一个棘手的问题随着思考步数的增加博弈树的节点数量会呈指数级增长。比如假设每步有10种合理走法思考5步就需要评估10^5100,000种局面。这就是为什么需要α-β剪枝这个优化技巧——它可以帮助我们剪掉那些明显不利的分支大幅减少需要评估的局面数量。2. C项目搭建与基础结构我们先从创建一个干净的C项目开始。我习惯使用CMake来管理项目这里给出一个简单的CMakeLists.txt示例cmake_minimum_required(VERSION 3.10) project(GomokuAI) set(CMAKE_CXX_STANDARD 17) add_executable(gomoku_ai src/main.cpp src/game.cpp src/node.cpp src/evaluation.cpp)项目主要包含以下几个核心类Node类表示博弈树中的一个节点存储棋盘状态、估值等信息GameTree类管理整个博弈树包含搜索算法实现Evaluation类负责棋局评估的核心逻辑棋盘表示我推荐使用15x15的二维数组可以用简单的字符来表示状态enum class Piece { EMPTY 0, BLACK B, WHITE W }; Piece board[15][15];3. 博弈树的实现细节3.1 Node类的设计Node类是整个AI的核心数据结构我通常会这样设计它的成员变量class Node { public: int value; // 节点估值 uint8_t depth; // 当前深度 Position lastMove; // 最后落子位置 Node* parent; std::vectorNode* children; Piece board[15][15]; bool isMaxNode() const { return depth % 2 0; // 偶数层是MAX节点 } };这里有个小技巧使用depth的奇偶性来判断当前是MAX还是MIN节点避免了额外的状态存储。在实际项目中我发现这种方法既简洁又高效。3.2 极大极小搜索实现让我们看看极大极小算法的核心代码int GameTree::minimax(Node* node, int depth, bool isMaximizing) { if (depth 0 || node-isTerminal()) { return evaluator.evaluate(node); } if (isMaximizing) { int maxEval INT_MIN; for (auto child : node-children) { int eval minimax(child, depth-1, false); maxEval std::max(maxEval, eval); } return maxEval; } else { int minEval INT_MAX; for (auto child : node-children) { int eval minimax(child, depth-1, true); minEval std::min(minEval, eval); } return minEval; } }这个递归实现虽然清晰但在实际运行中可能会遇到栈溢出的风险。在我的项目中当搜索深度超过6层时就会改用迭代加深搜索Iterative Deepening来避免这个问题。4. α-β剪枝优化实战α-β剪枝是提升AI性能的关键。想象你在下棋时如果发现某条路线明显不利就会停止继续思考这个变化——这正是α-β剪枝的核心思想。4.1 算法原理α代表MAX方至少能保证的分数β代表MIN方至多允许的分数。在搜索过程中如果MIN节点的值≤α可以剪枝因为这个路线对MAX不利如果MAX节点的值≥β可以剪枝因为这个路线对MIN不利4.2 代码实现在原有极大极小算法基础上加入剪枝int GameTree::alphaBeta(Node* node, int depth, int alpha, int beta, bool isMaximizing) { if (depth 0 || node-isTerminal()) { return evaluator.evaluate(node); } if (isMaximizing) { int maxEval INT_MIN; for (auto child : node-children) { int eval alphaBeta(child, depth-1, alpha, beta, false); maxEval std::max(maxEval, eval); alpha std::max(alpha, eval); if (beta alpha) break; // β剪枝 } return maxEval; } else { int minEval INT_MAX; for (auto child : node-children) { int eval alphaBeta(child, depth-1, alpha, beta, true); minEval std::min(minEval, eval); beta std::min(beta, eval); if (beta alpha) break; // α剪枝 } return minEval; } }在实际测试中加入α-β剪枝后搜索效率提升了约60-70%。有个小技巧对子节点按照估值排序好的走法先搜索可以进一步提高剪枝效率。5. 估价函数的设计艺术估价函数是AI的棋感所在。一个简单的估价策略是扫描棋盘上的所有五元组连续的五个点并为每种棋型分配分数。5.1 基本棋型评分这是我的评分表黑方视角棋型模式得分说明B B B B B1,000,000五连直接获胜B B B B 010,000活四0 B B B 01,000活三B B B 0 B500冲四0 B B 0 0100活二W W W W W-1,000,000对手五连W W W W 0-50,000对手活四5.2 实现细节估价函数实现示例int Evaluator::evaluate(Node* node) { int score 0; // 横向扫描 for (int i 0; i 15; i) { for (int j 0; j 11; j) { std::string pattern; for (int k 0; k 5; k) { pattern getSymbol(node-board[i][jk]); } score getPatternScore(pattern); } } // 其他方向类似... return score; }在实际开发中我发现估价函数需要不断调整。比如最初版本忽略了双三这种特殊棋型导致AI容易被人类玩家设置陷阱。后来增加了对特殊棋型的检测AI的防守能力明显提升。6. 性能优化技巧6.1 搜索范围限制五子棋的棋盘较大但实际有效的落子点通常集中在已有棋子周围。我们可以设置一个搜索半径std::vectorPosition getCandidateMoves(Node* node) { std::vectorPosition moves; int radius 2; // 搜索半径 bool visited[15][15] {false}; // 只在已有棋子周围搜索 for (int i 0; i 15; i) { for (int j 0; j 15; j) { if (node-board[i][j] ! Piece::EMPTY) { // 标记周围位置 for (int di -radius; di radius; di) { for (int dj -radius; dj radius; dj) { int ni i di, nj j dj; if (ni 0 ni 15 nj 0 nj 15 node-board[ni][nj] Piece::EMPTY !visited[ni][nj]) { moves.emplace_back(ni, nj); visited[ni][nj] true; } } } } } } return moves.empty() ? std::vectorPosition{Position(7,7)} : moves; }6.2 并行化搜索现代CPU多核心的优势可以利用起来。我们可以将第一层的子节点评估分配到不同线程std::futureint futures[node-children.size()]; for (size_t i 0; i node-children.size(); i) { futures[i] std::async(std::launch::async, GameTree::alphaBeta, this, node-children[i], depth-1, alpha, beta, false); } int maxEval INT_MIN; for (auto f : futures) { int eval f.get(); if (eval maxEval) { maxEval eval; bestMove ...; } }在我的8核机器上这种并行化处理能将搜索速度提升3-4倍。7. 完整对弈流程实现7.1 主循环结构void Game::run() { Node* current new Node(); // 初始空棋盘 while (!current-isTerminal()) { if (current-depth % 2 0) { // AI回合 GameTree tree(current); Position move tree.getBestMove(5); // 搜索5层 makeMove(current, move, Piece::BLACK); } else { // 玩家回合 printBoard(current); Position move getHumanMove(); makeMove(current, move, Piece::WHITE); } } printResult(current); }7.2 可视化界面虽然核心算法是重点但简单的控制台可视化也很重要void printBoard(Node* node) { std::cout 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4\n; for (int i 0; i 15; i) { std::cout (i 10 ? : ) i; for (int j 0; j 15; j) { char c .; if (node-board[i][j] Piece::BLACK) c O; else if (node-board[i][j] Piece::WHITE) c X; std::cout c; } std::cout \n; } }8. 进阶优化方向8.1 开局库应用专业五子棋AI通常会使用开局库。我们可以预先存储一些常见开局模式std::unordered_mapstd::string, Position openingBook { {, Position(7,7)}, // 空棋盘开局天元 {H8, Position(7,6)}, // 对天元第一手的常见应对 // 更多开局模式... }; Position getOpeningMove(Node* node) { std::string key getBoardKey(node); return openingBook.count(key) ? openingBook[key] : Position(-1,-1); }8.2 迭代加深搜索结合时间控制的迭代加深搜索Position GameTree::getBestMoveWithTime(int maxDepth, int maxTime) { Position bestMove; auto start std::chrono::steady_clock::now(); for (int depth 1; depth maxDepth; depth) { int alpha INT_MIN, beta INT_MAX; for (auto child : root-children) { int eval alphaBeta(child, depth-1, alpha, beta, false); if (eval alpha) { alpha eval; bestMove child-lastMove; } } auto now std::chrono::steady_clock::now(); if (std::chrono::duration_caststd::chrono::milliseconds(now-start).count() maxTime) { break; } } return bestMove; }8.3 Zobrist哈希为了优化重复局面检测可以实现Zobrist哈希class ZobristHash { uint64_t table[15][15][2]; // [row][col][piece] public: ZobristHash() { std::mt19937_64 rng; for (int i 0; i 15; i) { for (int j 0; j 15; j) { for (int k 0; k 2; k) { table[i][j][k] rng(); } } } } uint64_t compute(Node* node) { uint64_t hash 0; for (int i 0; i 15; i) { for (int j 0; j 15; j) { if (node-board[i][j] Piece::BLACK) { hash ^ table[i][j][0]; } else if (node-board[i][j] Piece::WHITE) { hash ^ table[i][j][1]; } } } return hash; } };9. 测试与调试经验开发过程中我遇到了几个典型问题估值函数偏差最初AI过于注重进攻而忽略防守。通过调整防守棋型的权重解决了这个问题。搜索深度不足在4层搜索时AI容易被设置陷阱。将基础搜索深度提升到6层后明显改善。性能瓶颈使用性能分析工具发现估价函数是热点通过预计算五元组模式匹配优化了30%性能。一个实用的调试技巧是记录搜索路径void logSearchPath(Node* node) { std::vectorNode* path; while (node) { path.push_back(node); node node-parent; } std::reverse(path.begin(), path.end()); for (auto n : path) { std::cout - ( n-lastMove.row , n-lastMove.col ); } std::cout \n; }10. 项目扩展思路完成基础版本后可以考虑以下扩展网络对战功能使用socket实现网络对战GUI界面用Qt等框架开发图形界面机器学习用强化学习优化估价函数比赛模式实现AI自对弈和棋谱分析一个简单的自对弈实现void selfPlay(int games) { int blackWins 0, whiteWins 0; for (int i 0; i games; i) { Game game; Piece winner game.runSelf(); if (winner Piece::BLACK) blackWins; else if (winner Piece::WHITE) whiteWins; } std::cout Black wins: blackWins \nWhite wins: whiteWins \n; }在实现这个五子棋AI的过程中最让我惊喜的是看到它从最初的随机落子逐步成长为能够进行基本攻防的棋手。虽然与职业水平还有差距但核心算法已经展现出强大的潜力。特别是在实现α-β剪枝后搜索效率的提升令人印象深刻。这也让我深刻体会到好的算法设计往往比单纯的硬件性能提升更有效。

更多文章