低级别系统设计实战:Tic Tac Toe(井字棋)游戏的设计与多语言实现

原创2026-09-30 15:59:201,954 阅读
文章标签:示例工程教程后端

低级别系统设计实战:Tic Tac Toe(井字棋)游戏的设计与多语言实现

导读

本篇技术指南以 awesome-low-level-design 仓库中 problems/tic-tac-toe.md 为问题定义主体,系统讲解如何为经典的 3x3 井字棋游戏做面向面试的低级别系统设计(LLD)。文中完整继承需求文档中的 7 条功能需求与 4 个核心类(Player、Board、Game、TicTacToe),并深入仓库中 C++、Java 等多语言实现源码,剖析回合流转、落子校验、胜负判定、平局检测等关键逻辑,以及用 Strategy / State / Observer 模式、Minimax 算法扩展单机 AI 的工程化写法。读完本文,你将掌握从需求分析到类设计再到多语言落地的一整套 LLD 答题范式,可直接迁移到停车场、电梯、象棋等其他常见面试题。

一、需求分析:把一句话需求拆成可验收的规格

原文档 problems/tic-tac-toe.md 给出了 7 条需求,它们是整个设计的验收标准,任何实现都不能偏离:

  1. 游戏在 3x3 网格上进行;
  2. 两名玩家轮流在网格上放置自己的符号(X 或 O);
  3. 率先在横向、纵向或对角线上连成三个己方符号的玩家获胜;
  4. 网格全部填满且无人获胜时,游戏以平局结束;
  5. 游戏应提供用户界面来展示网格并允许玩家落子;
  6. 游戏应管理玩家回合并校验落子是否合法;
  7. 游戏应在结束时检测并宣布胜者或平局。

其中值得在面试中强调的两点:

  • 需求 3 与需求 4 是互斥的终止条件:要么出现胜者,要么棋盘填满,二者必居其一,这是游戏主循环跳出(game over)的判定依据;
  • 需求 6 里的“合法”包含两层校验:坐标必须落在 3x3 网格范围内,且该格子必须为空。仓库中 Board.cpp 的 makeMove 正是同时执行这两层校验后才落子。

Java 版的问题陈述 solutions/java/src/tictactoe/README.md 将棋盘抽象为 NxN、并补充了“可扩展性:易于修改棋盘尺寸或新增功能”这条需求,这提示我们:面试答题时应把需求归纳为「双人轮流落子」「胜负/平局检测」「输入校验」「可扩展」四个维度去组织类设计。

二、UML 类图:从需求到类的映射

原文档给出的 UML 类图位于 class-diagrams/tictactoe-class-diagram.png,其核心实体可以归纳为 4 个类,职责划分如下:

类 职责 关键方法
Player 表示一名玩家,持有名字与符号(X/O) getName()、getSymbol()
Board 表示 3x3 棋盘,提供落子、胜负检测、满盘检测 makeMove()、checkWin()、isFull()、display()
Game 管理游戏流程与玩家交互:回合流转、校验、判定结果 play()、makeMove()、switchPlayer()、displayResult()
TicTacToe 应用入口,创建玩家与游戏实例并启动 main()

这个划分遵循典型的“数据(Board)+ 状态(Game)+ 实体(Player)+ 入口(TicTacToe)”分层:Board 只管网格数据与棋盘规则,不关心谁在下棋;Game 负责编排流程;入口类负责组装。面试时按“谁持有数据、谁驱动流程、谁组织启动”三步即可快速确定类边界。

三、核心类的职责与接口设计

3.1 Player:最小化的玩家实体

Player 只需三个字段:name、symbol('X' 或 'O')、以及标识是否人类玩家的 isHuman(为 AI 扩展预留)。仓库 C++ 实现 Player.hpp 与 Player.cpp 通过构造函数默认参数 bool isHuman = true 提供了单机模式所需的扩展点——把 isHuman 设为 false 即得到一个由电脑控制的玩家。

3.2 Board:网格数据与棋盘规则

Board 在 Board.hpp 中声明的接口完整覆盖了需求中“落子、校验、判胜、满盘”四大能力:

  • isValidPosition(row, col):判断坐标是否在 [0, size) 范围内;
  • isEmpty(row, col):判断格子是否为空(空位用 '-' 字符表示);
  • makeMove(row, col, symbol):先校验再落子,失败返回 false;
  • isFull():遍历全盘,存在空格即返回 false;
  • checkWin(symbol):委托给 checkRows / checkColumns / checkDiagonals 三个私有方法;
  • display():以 3 列对齐方式打印当前棋盘,即需求 5 中的“用户界面”(控制台版);
  • reset():重置网格,便于复用棋盘实例。

3.3 Game:流程编排者

Game 在 Game.hpp 中维护 board、两个玩家指针、currentPlayer 与 gameOver 标志。核心循环 play() 的逻辑为:展示棋盘 → 若是人类玩家则读入 row/col 并调用 makeMove,若是电脑玩家则调用 computerMove → 循环直到 gameOver → 调用 displayResult() 宣布胜者或平局。

四、胜负判定与平局检测:checkWin 的实现细节

Board 的胜负判定拆成三个方向分别检查,见 Board.cpp:

  • 行检查(checkRows):对每一行遍历所有列,若整行都是某符号则胜;
  • 列检查(checkColumns):对每一列遍历所有行,逻辑同构于行检查;
  • 对角线检查(checkDiagonals):主对角线 grid[i][i],副对角线 grid[i][size-1-i]。

checkWin 用 checkRows(symbol) || checkColumns(symbol) || checkDiagonals(symbol) 组合三者。值得注意的工程细节是:仓库实现把循环写成通用 size 维度而非硬编码 3,这直接呼应了 Java 版 README 中“支持任意 NxN 棋盘”的可扩展性需求。

平局检测在 Game.cpp:落子合法后先查 board.checkWin(currentPlayer->getSymbol()),若胜则 gameOver = true;否则再查 board.isFull(),若满盘则将 currentPlayer 置空——这一技巧让 displayResult() 只需判断 currentPlayer 是否为 nullptr 即可区分“某人获胜”与“平局”两种终局:

void Game::displayResult() const {
    board.display();
    if (currentPlayer) {
        std::cout << currentPlayer->getName() << " wins!" << std::endl;
    } else {
        std::cout << "It's a draw!" << std::endl;
    }
}

这种用「状态语义复用」代替「额外布尔标志」的做法是 LLD 面试中的加分点。

五、回合流转与落子校验:Game 的 makeMove 主链路

回合管理依赖 currentPlayer 指针的交替:

void Game::switchPlayer() {
    currentPlayer = (currentPlayer == player1) ? player2 : player1;
}

makeMove 的主链路(Game.cpp)是理解全流程的关键:

  1. board.isValidPosition(row, col) 不通过 → 打印 "Invalid position!" 并直接返回(不切换回合);
  2. board.isEmpty(row, col) 不通过 → 打印 "Position already taken!" 并返回(不切换回合);
  3. board.makeMove 落子;
  4. checkWin 成立 → 结束;
  5. isFull 成立 → 结束(平局);
  6. 否则 switchPlayer() 轮到下一位。

注意第 1、2 步失败时都不切换玩家,这保证了“非法落子不消耗回合”的规则正确性,与需求 6 “校验落子确保合法”严格一致。

六、从 3x3 到 NxN:尺寸无关的通用化设计

原需求只要求 3x3,但仓库两种实现都做了通用化处理:

  • C++ 版:Board 构造函数带默认参数 Board(int size = 3)(见 Board.hpp),所有判定循环都以 size 为界;
  • Java 版:Game.java 在构造时 new Board(3) 显式指定尺寸,而 solutions/java/src/tictactoe/README.md 将“支持不同棋盘尺寸”列为扩展方向。

这意味着若面试官追问“棋盘改为 4x4、5x5 怎么办”,上述实现只需把落子判定从“三连”推广为“size 连”即可,类结构无需任何改动——这正是“把维度参数化”设计带来的扩展收益。

七、工程化扩展:设计模式与 AI 对弈

原文档只给出 4 个基础类,仓库实现则在基础骨架之上展示了更强的工程化扩展,可作为面试时的进阶谈资。

7.1 Java 版:Strategy + State + Observer 三模式组合

Java 实现 solutions/java/src/tictactoe/ 采用了三层设计模式:

  • Strategy 模式:把“行胜/列胜/对角胜”拆成三个独立的 WinningStrategy 实现(strategy/RowWinningStrategy.java、strategy/ColumnWinningStrategy.java、strategy/DiagonalWinningStrategy.java),Game 持有策略列表,checkWinner 遍历调用(见 Game.java)。新增“四连”“斜向更长连法”只需加策略类,符合开闭原则;
  • State 模式:把游戏生命周期建模为 InProgressState、WinnerState、DrawState 三个状态(state/),Game.makeMove 委托给当前状态的 handleMove,终局判定被状态机化;
  • Observer 模式:Game 继承 GameSubject,当状态变为非 IN_PROGRESS 时通知 Scoreboard 等观察者(Game.java),为后续接 UI、记分板等外部系统提供了松耦合出口。

7.2 C++ 版:Minimax 算法驱动的电脑玩家

C++ 实现把需求“双人”扩展为“人机对战”:Game::initializePlayers("Human", "Computer") 中第二个玩家以 isHuman=false 创建(TicTacToeDemo.cpp)。其 AI 核心是经典的 Minimax 极大极小搜索(Game.cpp):

  • 评分规则:电脑胜得 10 - depth,人类胜得 depth - 10,满盘平局为 0(depth 越小越优先,追求速胜、避免拖局);
  • isMax 层取最大值(电脑视角),isMin 层取最小值(人类视角);
  • 搜索通过拷贝棋盘 Board tempBoard = board 进行假设性落子,不污染真实棋盘;
  • findBestMove() 遍历每个空位,模拟落子后用 minimax 求分,取最高分位置落子(Game.cpp)。

对 3x3 棋盘,Minimax 全搜索即可保证电脑不输(最佳应对),是面试中“如何让机器不犯错”的标准答案。可以顺便指出:更大的棋盘(如 5x5)需要 Alpha-Beta 剪枝或蒙特卡洛方法,这能体现你对算法复杂度的意识。

八、多语言实现导航与运行方式

该题在仓库中提供了 5 种语言的完整实现,可直接对照阅读同一份设计的差异化落地:

语言 位置 入口/说明
Java solutions/java/src/tictactoe/ TicTacToeDemo.java 演示,Game/Board/Player/Cell 分包组织,README 含类设计与示例代码
C++ solutions/cpp/tictactoe/ TicTacToeDemo.cpp 为入口,含人机对战与 Minimax AI
C# solutions/csharp/tictactoe/ 与 C++ 同套类设计的 .NET 版本
Go solutions/golang/tictactoe/ 以 Go 结构体与接口组织的版本
Python solutions/python/tictactoe/ Python 面向对象版本

C++ 版可直接编译运行体验完整人机对战流程:

# 在解决方案目录下编译并运行(需 C++17 以支持结构化绑定)
g++ -std=c++17 TicTacToeDemo.cpp Board.cpp Game.cpp Player.cpp -o tictactoe
./tictactoe

运行后程序会打印棋盘(空位显示 -),提示人类玩家输入 row 与 column(取值 0~2),电脑玩家则由 Minimax 自动落子,最终输出胜者或 It's a draw!。Java 版则参考其 README 中的示例用法:

Player p1 = new Player("Alice", Symbol.X);
Player p2 = new Player("Bob", Symbol.O);
Game game = new Game(p1, p2);
game.play();

九、面试答题要点总结

结合需求文档与仓库实现,回答本题时的关键得分点归纳如下:

  1. 先定验收标准再画类图:7 条需求逐条映射到类的哪个方法,做到“需求可追踪”;
  2. 明确职责边界:Board 管数据与规则,Game 管流程,Player 是纯实体,入口类只做组装;
  3. 讲清两种终局:checkWin 三方向判定 + isFull 满盘平局,注意“非法落子不换回合”的细节;
  4. 主动展示扩展性:棋盘尺寸参数化(NxN)、Java 的 Strategy/State/Observer 组合、C++ 的 Minimax 人机对战,都是把“能跑”升级为“好扩展”的证明;
  5. 多语言对照:能指出 5 种语言的实现位置并对比其组织方式,说明你不仅会设计,还能工程化落地。

这套“需求 → 类职责划分 → 核心算法 → 设计模式扩展 → 多语言落地”的流程,可以直接套用到仓库中 problems/ 下的停车场、电梯、象棋、餐厅管理等同类 LLD 题目,是面试准备的通用方法论。

登录后查看全文
awesome-low-level-design