电脑落子
Ai 是电脑落子的接口:三种强度各有一个派生类,从空格子里按强度选一个位置。选出的 Move 仍交给 对局 里的 Game::TryPlace。Ai 的接口在 ai.h,实现在 ai.cpp。
落子强度
电脑落子先要定强度。强度用枚举 AiStrength 表示,三个值分别对应随机、启发式和极小极大三种选法。
ai.h
/// 电脑落子的强度。
enum class AiStrength : std::uint8_t {
kRandom = 0, ///< 随机落子。
kHeuristic = 1, ///< 能赢就赢,否则挡住对方。
kMinimax = 2, ///< 按终局结果选最优步。
};强度是枚举值,本身不含选法;三种强度在 MakeAi 里各对应一个派生类。强度一经选定,一局之内的选法不再更换。
三种强度的区别在判断依据。随机版只确认候选合法;启发式版多判断一步胜负;极小极大版把每种走法推到终局,再比较得分。
三种强度只改变选择方式;返回的落子仍写进空格子,玩家仍是当前玩家。各自的选法都有测试核对;双方都用极小极大自对弈时,对局判平局。
kRandom 适合试走一局,kHeuristic 适合有攻防的对局,kMinimax 让双方都不出错。
选择接口
调用约定
Ai 不保存棋盘,也不保存强度;调用方用 MakeAi 返回的对象选择落子,入口是 Choose。公共接口、构造与析构约定都在 ai.h 里:
ai.h
/// 电脑落子的公共接口。
///
/// 创建后由调用方持有,通过 Choose 选择一步落子。
class Ai {
public:
/// 默认构造函数:得到不携带状态的电脑落子对象。
Ai() = default;
/// 拷贝与移动构造函数、赋值运算符:删除,由 MakeAi 返回的 unique_ptr 独占持有。
Ai(const Ai&) = delete;
Ai& operator=(const Ai&) = delete;
Ai(Ai&&) = delete;
Ai& operator=(Ai&&) = delete;
/// 析构函数:保证通过接口指针正确删除派生类对象。
virtual ~Ai();
/// 为当前玩家选择一步落子。
///
/// 对局必须仍在进行。返回的坐标指向空棋盘格子,玩家是当前玩家。
///
/// @param game 只读的当前对局。
/// @return 选出的一步落子。
/// @throws std::logic_error 对局已经结束。
[[nodiscard]] virtual Move Choose(const Game& game) const = 0;
};三种强度创建的对象都作为 Ai 交给调用方,一套构造与析构约定覆盖三个派生类。virtual ~Ai() 让接口层的删除也能到达派生类析构函数;调用方把手上的对象当作 Ai 使用即可。
删除拷贝与移动,让对象只能由 unique_ptr 独占持有;调用方不必关心具体强度,也不持有第二份。
Choose 收到的是整个对局:从格子状态到落子历史都能读。三种强度的实现共享这一个入口,选法不同,返回给调用方的都是同一种 Move。返回值同时给出坐标与玩家,下一步交给 Game::TryPlace。对局仍在进行时,这一步总能写进空格子。
候选落子列表
候选落子列表是三种强度共同的起点。CollectEmptyCells 按行序收集所有空格子:
ai.h
/// 收集还没有棋子的棋盘格子。
///
/// @param game 只读的当前对局。
/// @return 空棋盘格子坐标的列表,按行序排列。
[[nodiscard]] std::vector<CellPosition> CollectEmptyCells(const Game& game);ai.cpp
std::vector<CellPosition> CollectEmptyCells(const Game& game) {
std::vector<CellPosition> cells;
// 返回顺序按行序;同一局面的候选格顺序一致,启发式与极小极大的平手规则可复现。
for (int row = 0; row < kBoardRows; ++row) {
for (int col = 0; col < kBoardCols; ++col) {
const CellPosition position = {.row = row, .col = col};
if (game.GetCellType(position) == CellType::kEmpty) {
cells.push_back(position);
}
}
}
return cells;
}CollectEmptyCells 按行优先遍历棋盘,只读棋盘,不改动对局;三种强度的 Choose 都从这份候选列表起步。
Choose 还要把“候选为空”挡在取下标之前。三种强度共用一个小函数:
ai.cpp
namespace {
/// 三种强度都要从非空候选里取一格;空列表说明对局状态与“进行中”不一致。
std::vector<CellPosition> EmptyCellsOrThrow(const Game& game) {
std::vector<CellPosition> cells = CollectEmptyCells(game);
if (cells.empty()) {
throw std::logic_error{"EmptyCellsOrThrow: no empty cell left to place"};
}
return cells;
}
} // namespace它先收集,再确认列表非空;为空说明 GetResult 与棋盘状态不一致,直接抛异常,而不是让 cells.front() 或 size() - 1 在空列表上取到非法位置。
顺序还决定平手规则:启发式与极小极大都优先取先出现的格子,同一盘面得到同一步,测试才稳定。
派生选择
随机选法
RandomAi::Choose 在空格子里等概率选一格:
ai.h
/// 随机强度的电脑落子。
///
/// 只保证合法:在空格中任选一步,用来验证对局流程与终局判定。
class RandomAi final : public Ai {
public:
/// 在空棋盘格子里等概率选一格。
///
/// 对局必须仍在进行,棋盘上至少还有一格尚未落子。
///
/// @param game 只读的当前对局。
/// @return 选出的一步落子。
/// @throws std::logic_error 对局已经结束。
[[nodiscard]] Move Choose(const Game& game) const override;
};ai.cpp
Move RandomAi::Choose(const Game& game) const {
if (game.GetResult() != GameResult::kInProgress) {
throw std::logic_error{"Ai::Choose: game is already over"};
}
const std::vector<CellPosition> cells = EmptyCellsOrThrow(game);
// thread_local:每个线程各自推进随机序列。
static thread_local std::mt19937 generator{std::random_device{}()};
std::uniform_int_distribution<std::size_t> distribution(0, cells.size() - 1);
return {.position = cells[distribution(generator)], .player = game.GetCurrentPlayer()};
}函数入口先确认对局仍在进行,不成立就抛 std::logic_error;通过检查后 EmptyCellsOrThrow 保证 cells 至少有一格。
随机引擎声明成 static thread_local,每个线程只构造一次。uniform_int_distribution 两端都包含,cells.size() - 1 能取到末尾一格。
thread_local 让每个线程拿到自己的随机序列。不用它,两个线程同时选格会争同一台引擎,要么加锁,要么互相拖慢;用了它,同一线程内多局仍共用序列,代价是每个线程各存一份状态。
选中的一格来自 CollectEmptyCells 的候选列表,坐标与玩家都由返回值给出;两次运行可能走出不同的对局,这一步能不能写进棋盘,仍由 Game::TryPlace 决定。
启发式选法
启发式按取胜、封堵、中心、四角、四边的顺序尝试。前两步都要判断同一件事:在某个空格子落下一枚棋子后,是否形成三连。判断由 Board::PreviewWin 完成,棋盘 一章讲过它如何在不落子的情况下推演三连。Game::PreviewWin 把判断转发给棋盘:
game.h
class Game {
public:
/// 推演在指定坐标放入给定棋子后是否会形成三连。
///
/// @param position 假设落子的行列坐标。
/// @param cell_type 假设放入的棋盘格子类型。
/// @return 不可落子返回 kInvalidPlace;落子后成线返回 kWouldWin;否则返回 kWouldNotWin。
[[nodiscard]] WinPreviewResult PreviewWin(CellPosition position, CellType cell_type) const;
};game.cpp
WinPreviewResult Game::PreviewWin(CellPosition position, CellType cell_type) const {
return board_.PreviewWin(position, cell_type);
}Ai 不直接访问 Board。启发式通过 Game::PreviewWin 判断这一步会不会连成三个,取得的 Move 交回 Game::TryPlace,落子仍由 Game 执行。
HeuristicAi::Choose 先用一轮 game.PreviewWin 找胜着,再用一轮找对方必须封堵的一步,其余空格子按同一顺序挑选:
ai.h
/// 启发式强度的电脑落子。
///
/// 只看一步:先赢、再挡,然后按中心、四角、四边的次序选格。
/// 只看一步,不搜索整棵树。
class HeuristicAi final : public Ai {
public:
/// 能立刻取胜就取胜,否则挡住对方马上能赢的一步,再按中心、四角、四边选格。
///
/// 对局必须仍在进行。
///
/// @param game 只读的当前对局。
/// @return 选出的一步落子。
/// @throws std::logic_error 对局已经结束。
[[nodiscard]] Move Choose(const Game& game) const override;
};ai.cpp
Move HeuristicAi::Choose(const Game& game) const {
if (game.GetResult() != GameResult::kInProgress) {
throw std::logic_error{"Ai::Choose: game is already over"};
}
const Player current = game.GetCurrentPlayer();
const CellType mine = PlayerToCellType(current);
const CellType theirs = PlayerToCellType(PlayerToOpponent(current));
const std::vector<CellPosition> cells = EmptyCellsOrThrow(game);
// 先找自己的胜手:错过必胜会让对方多走一步,后面的“挡”再准也没用。
for (const CellPosition position : cells) {
if (game.PreviewWin(position, mine) == WinPreviewResult::kWouldWin) {
return {.position = position, .player = game.GetCurrentPlayer()};
}
}
// 再看对手的胜手:自己不赢时必须挡,否则下一回合就输。
for (const CellPosition position : cells) {
if (game.PreviewWin(position, theirs) == WinPreviewResult::kWouldWin) {
return {.position = position, .player = game.GetCurrentPlayer()};
}
}
// 选格顺序:中心、四角、四边;空白格按这个次序取第一格。
const std::array<CellPosition, 9> order = {{
{.row = 1, .col = 1},
{.row = 0, .col = 0},
{.row = 0, .col = 2},
{.row = 2, .col = 0},
{.row = 2, .col = 2},
{.row = 0, .col = 1},
{.row = 1, .col = 0},
{.row = 1, .col = 2},
{.row = 2, .col = 1},
}};
for (const CellPosition position : order) {
if (game.GetCellType(position) == CellType::kEmpty) {
return {.position = position, .player = game.GetCurrentPlayer()};
}
}
// 九格全占满时对局已经结束,上面的检查会先拦下;这里保留不会取空的路径。
return {.position = cells.front(), .player = game.GetCurrentPlayer()};
}第一轮找自己的胜着,第二轮找对方的胜着;自己能赢就直接赢,不必封堵。两轮都没命中时,order 依次尝试中心、四角、四边,同一盘面得到同一个选择。中心排在最前是因为它参与四条线,角参与两条,边参与一条。对局仍在进行时 cells 至少有一格,末尾的 cells.front() 不会走到。
极小极大选法
极小极大把每个候选落子都推演到终局,再按终局结果挑一步。MinimaxAi 的公开入口仍是 Choose,另外两个私有函数完成评分和递归搜索:
ai.h
/// 极小极大强度的电脑落子。
///
/// 对每个空棋盘格子都试落一子再递归搜索,配合 alpha-beta 剪枝;
/// 井字棋状态少,能在一局内算到终局,所以不会输。
class MinimaxAi final : public Ai {
public:
/// 对每个空棋盘格子搜索一次,留下对当前玩家更好的那一格。
///
/// 对局必须仍在进行。
///
/// @param game 只读的当前对局。
/// @return 选出的一步落子。
/// @throws std::logic_error 对局已经结束。
[[nodiscard]] Move Choose(const Game& game) const override;
private:
/// 为终局结果打分:X 胜为 1,平局为 0,O 胜为 -1。
///
/// @param result 对局结果。
[[nodiscard]] static int ScoreTerminal(GameResult result);
/// 极小极大搜索:X 取更大分数,O 取更小分数,alpha、beta 剪掉不可能的分支。
///
/// @param game 只读的当前对局。
/// @param alpha 当前已知最好分数的下界。
/// @param beta 当前已知最好分数的上界。
/// @return 双方都按最优应对时当前玩家能取得的得分。
[[nodiscard]] static int Search(const Game& game, int alpha, int beta);
};Choose 逐个试走候选格。每试一步就复制一份 Game,在副本上落子,再让 Search 从副本继续推演:
ai.cpp
Move MinimaxAi::Choose(const Game& game) const {
if (game.GetResult() != GameResult::kInProgress) {
throw std::logic_error{"Ai::Choose: game is already over"};
}
// X 是最大化方,O 是最小化方;分数只从 X 的视角定义(见 ScoreTerminal)。
const bool maximize = game.GetCurrentPlayer() == Player::kCross;
int best = maximize ? -2 : 2;
const std::vector<CellPosition> cells = EmptyCellsOrThrow(game);
CellPosition chosen = cells.front();
for (const CellPosition position : cells) {
Game next = game;
const PlaceResult placed =
next.TryPlace({.position = position, .player = game.GetCurrentPlayer()});
if (placed != PlaceResult::kPlaced) {
continue;
}
const int score = Search(next, -2, 2);
const bool better = maximize ? score > best : score < best;
if (better) {
best = score;
chosen = position; ///< 严格更优才换格,同分保留先出现的格子。
}
}
return {.position = chosen, .player = game.GetCurrentPlayer()};
}Game next = game 复制整局,副本上的落子不改动原对局;kRejected 的候选直接跳过。best 初值在得分范围之外,第一步会把 chosen 换成真实候选。比较用严格不等号,同分保留先出现的格子,结果可复现。
终局得分总是从 X 的视角计算,不随当前轮到谁而改变:
ai.cpp
int MinimaxAi::ScoreTerminal(GameResult result) {
switch (result) {
case GameResult::kCrossWin:
return 1;
case GameResult::kNoughtWin:
return -1;
case GameResult::kDraw:
case GameResult::kInProgress:
return 0;
}
return 0;
}X 胜记 1,O 胜记 -1,平局记 0;kInProgress 不会传给 ScoreTerminal,实现里仍返回 0。
Search 是递归主体,先处理终局,再按“能赢、须挡、其余”排候选,最后逐个递归并用 alpha、beta 剪枝:
ai.cpp
// NOLINTNEXTLINE(misc-no-recursion)
int MinimaxAi::Search(const Game& game, int alpha, int beta) {
// 终局直接返回分数:搜索到叶子才算完成,不在中途估算局面。
if (game.GetResult() != GameResult::kInProgress) {
return ScoreTerminal(game.GetResult());
}
const Player current = game.GetCurrentPlayer();
const CellType mine = PlayerToCellType(current);
const CellType theirs = PlayerToCellType(PlayerToOpponent(current));
const std::vector<CellPosition> cells = CollectEmptyCells(game);
// 着法序:先试马上能赢的一步,再试必须挡住对方的一步,其余按行序。
// 把好棋排在前面,让 beta <= alpha 剪枝更早发生。
std::vector<CellPosition> winning;
std::vector<CellPosition> blocking;
std::vector<CellPosition> rest;
for (const CellPosition position : cells) {
if (game.PreviewWin(position, mine) == WinPreviewResult::kWouldWin) {
winning.push_back(position);
} else if (game.PreviewWin(position, theirs) == WinPreviewResult::kWouldWin) {
blocking.push_back(position);
} else {
rest.push_back(position);
}
}
std::vector<CellPosition> ordered;
ordered.reserve(cells.size());
ordered.insert(ordered.end(), winning.begin(), winning.end());
ordered.insert(ordered.end(), blocking.begin(), blocking.end());
ordered.insert(ordered.end(), rest.begin(), rest.end());
const bool maximize = current == Player::kCross;
int best = maximize ? -2 : 2;
for (const CellPosition position : ordered) {
Game next = game;
const PlaceResult placed = next.TryPlace({.position = position, .player = current});
if (placed != PlaceResult::kPlaced) {
continue;
}
const int score = Search(next, alpha, beta);
if (maximize) {
best = std::max(score, best);
alpha = std::max(best, alpha);
} else {
best = std::min(score, best);
beta = std::min(best, beta);
}
if (beta <= alpha) {
break; ///< 剪枝:这一支后续再搜也不会改变上层选择。
}
}
return best;
}current 是这一步轮到的玩家;mine 与 theirs 表示双方要写入的格子类型。着法序把能赢的排最前、必须挡的其次,好棋先搜,剪枝更早发生。轮到 X 取最大值,轮到 O 取最小值;alpha、beta 是上下界,beta <= alpha 时这一支不再展开。九格棋盘最多递归九层。
创建选择对象
MakeAi 把强度换成对应的派生类对象,所有权交给调用方:
ai.h
/// 按强度造出对应的电脑落子对象。
///
/// @param strength 电脑落子的强度。
[[nodiscard]] std::unique_ptr<Ai> MakeAi(AiStrength strength);ai.cpp
std::unique_ptr<Ai> MakeAi(AiStrength strength) {
switch (strength) {
case AiStrength::kRandom:
return std::make_unique<RandomAi>();
case AiStrength::kHeuristic:
return std::make_unique<HeuristicAi>();
case AiStrength::kMinimax:
return std::make_unique<MinimaxAi>();
}
// 枚举已覆盖全部取值;这一行让编译器看到所有路径都有返回值。
return std::make_unique<MinimaxAi>();
}终端程序在开局读入强度之后调用 MakeAi。同一局只创建一次,用哪种选法在这一步定下,对象一直用到对局结束。
MakeAi 用 switch 而不是映射表:新增强度时编译器会提醒哪个分支没处理,映射表只会静默落到默认项。末尾那一行不是可选分支,是让编译器看见 switch 之外仍有返回。
常见错误
绕过落子路径
绕过 Game::TryPlace 直接改棋盘,会跳过轮次切换、终局判定和历史记录。下一次通过 Game::TryPlace 落子时,对局状态可能与棋盘不一致,落子被拒或结果错误。
评分视角反向
ScoreTerminal 按 X 胜 1、O 胜 -1 打分。如果改成“当前玩家胜为正”,X 仍取最大、O 仍取最小,就会把“自己胜”当成最差分。评分视角与取极值方向必须成对设计,否则极小极大开始主动选坏棋。
库内打印
动态库的头文件不包含输入输出;Ai::Choose 返回 Move。
自测问题
构造一个 O 必须封堵的盘面:X 已占
(0,0)与(0,1),O 占(1,1),轮到 O。调用HeuristicAi::Choose,这次调用应返回哪一格?查看答案
应返回
(0, 2)。X 若占(0, 2)就完成第一行三连,game.PreviewWin({0,2}, CellType::kX)返回kWouldWin。启发式第二轮会选中(0, 2)封堵。可以手算核对坐标。把
ScoreTerminal中 X 胜与 O 胜的返回值对调,MinimaxAi会怎样选?查看答案
评分方向反了,
Choose仍按“X 取最大、O 取最小”比较。于是 X 会挑对 X 最不利的终局,O 也挑对 O 最不利的终局。两边都主动输棋,自对弈不再保证平局。为什么
Choose对每个候选调用Search(next, -2, 2),而不是直接调用Search(game, -2, 2)?查看答案
game还是落子前的局面,Search(game, ...)会从同一个局面重新开始,候选之间没有区别。next是已经落子的副本,Search从对手的回合继续,返回的才是这一步之后的终局得分。
本章回顾
你已经能用 Ai 为当前玩家选一步合法落子。下一章终端程序,把电脑落子接进读入与打印。