C++五子棋项目实战:从面向对象设计到AI算法实现
1. 项目概述:从棋盘到代码的思维转换
五子棋,这个规则简单却变化无穷的棋盘游戏,几乎是每个程序员在初学图形界面或算法时都会尝试实现的项目。用C++来实现它,远不止是画个棋盘、摆几个棋子那么简单。这背后涉及到面向对象思想的落地、游戏逻辑的严密构建、人机交互的设计,甚至是对简单AI算法的初步探索。我见过很多新手写的五子棋,棋盘用二维数组,逻辑里塞满了if-else,代码臃肿且难以维护。今天,我想分享的是一套经过实战检验的、结构清晰、易于扩展的C++五子棋实现方案。它不仅能让游戏跑起来,更能让你理解如何将现实世界的游戏规则,优雅地映射到C++的类、对象和算法之中。无论你是刚学完C++基础语法想找个项目练手,还是已经有一定经验想重构自己的代码,这篇文章都能给你带来直接的参考价值。
2. 整体架构与核心类设计
一个健壮的五子棋程序,其核心在于清晰的分层架构。我们不能把所有代码都堆在main函数里。我的设计通常分为三个核心层:数据模型层、逻辑控制层和表示层(虽然我们这次主要讨论控制台版本,但设计要为图形界面留出接口)。
2.1 核心类的职责划分
我设计了三个核心类:Board(棋盘)、Game(游戏)和Player(玩家)。Board类纯粹负责棋盘状态的存储和查询,它不关心游戏规则。Game类是大脑,它持有Board和Player的实例,驱动游戏流程,并调用Board的方法来校验落子是否合法、判断胜负。Player类则抽象了落子行为,可以是人类玩家通过输入坐标,也可以是AI玩家通过算法计算。
这种设计的最大好处是“高内聚、低耦合”。比如,如果你想从控制台切换到图形界面(例如用Qt或SFML),你几乎不需要修改Board和Game类,只需要重写Player的输入输出部分和棋盘的显示方式。再比如,你想增加悔棋功能,只需要在Game类中维护一个落子历史栈,对Board的状态进行压栈和出栈操作,其他模块感知不到这个变化。
2.2 棋盘(Board)类的数据结构选择
棋盘的本质是一个二维状态网格。最直观的是使用二维数组,比如int board[15][15]。但静态数组大小固定,且作为类成员传递时需要注意。我更倾向于使用std::vector<std::vector<int>>,因为它更灵活,也更能体现C++现代特性。不过,对于性能要求极致且棋盘大小固定的场景,二维数组在内存连续性和访问速度上确实有优势。
在我的实现中,我选择了一个折中方案:使用一维std::vector<int>来模拟二维棋盘。为什么?因为内存连续,缓存友好,计算索引也很快。棋盘大小(如15x15)在构造函数中确定。每个位置用三种状态表示:空(0)、黑子(1)、白子(2)。这个Board类会提供几个关键方法:bool placePiece(int x, int y, int player)(放置棋子)、int getPiece(int x, int y) const(获取棋子状态)、bool isFull() const(判断棋盘是否已满),以及最重要的bool checkWin(int x, int y, int player) const(以刚落子的位置为中心,检查该玩家是否获胜)。
注意:
checkWin函数的实现是性能关键。新手容易遍历整个棋盘,那是O(N²)的复杂度。正确做法是,以落子点(x,y)为中心,向四个方向(水平、垂直、两个对角线)进行扫描,看是否有连续五个同色棋子。这样复杂度是O(1),与棋盘大小无关。
2.3 游戏(Game)类的流程控制
Game类是导演,它控制着游戏的整个生命周期。其核心是一个循环:
- 显示当前棋盘。
- 获取当前玩家(轮流)的落子位置。
- 验证位置合法性(是否在棋盘内、该位置是否为空)。
- 调用
Board::placePiece落子。 - 调用
Board::checkWin判断是否获胜。 - 若未获胜,切换玩家,回到步骤1;若获胜或平局,则结束循环,宣布结果。
这个循环逻辑清晰,Game类自身不处理具体的输入输出细节,它通过Player类的抽象接口来获取落子坐标。这使得单元测试变得容易,你可以模拟一个Player来测试游戏逻辑。
3. 核心算法深度解析:胜负判定与AI雏形
五子棋的核心算法有两个:高效的胜负判定和初步的AI落子算法。前者是必须实现的,后者则能极大提升项目的挑战性和趣味性。
3.1 高效胜负判定算法实现
胜负判定的逻辑是:在玩家落子后,从该点出发,向四个方向(东-西、南-北、东南-西北、东北-西南)分别探测,统计连续的同色棋子数量。如果任何一个方向上的连续数量达到5,则判定获胜。
这里有一个极易出错的细节:双向探测。例如水平方向,不能只向右数,还要向左数,然后把两个方向的连续数相加,最后再加1(代表刚落下的这颗子本身)。许多初学者写的代码只单向计数,导致某些情况下的“五连珠”无法被正确识别。
下面是一个经过优化的checkWin函数核心代码片段:
bool Board::checkWin(int x, int y, int player) const { // 四个方向向量:水平(dx=1, dy=0),垂直(dx=0, dy=1),主对角线(dx=1, dy=1),副对角线(dx=1, dy=-1) const int dirs[4][2] = {{1, 0}, {0, 1}, {1, 1}, {1, -1}}; for (const auto& dir : dirs) { int dx = dir[0], dy = dir[1]; int count = 1; // 刚落下的子 // 向正方向探测 for (int step = 1; step < 5; ++step) { int nx = x + dx * step; int ny = y + dy * step; if (!isInBoard(nx, ny) || getPiece(nx, ny) != player) break; count++; } // 向反方向探测 for (int step = 1; step < 5; ++step) { int nx = x - dx * step; int ny = y - dy * step; if (!isInBoard(nx, ny) || getPiece(nx, ny) != player) break; count++; } // 如果任意方向连续子数达到5,则获胜 if (count >= 5) return true; } return false; }提示:
isInBoard是一个辅助函数,用于判断坐标是否在棋盘范围内。将边界检查独立成函数,能让核心逻辑更清晰。另外,这里判断条件是count >= 5而不是==5,是一种防御性编程,防止计数逻辑错误导致无法识别长连(虽然五子棋规则是连五即胜,但代码鲁棒性更强)。
3.2 基于评分表的简单AI实现
实现一个“聪明”的AI需要复杂的博弈树搜索(如Minimax算法)和估值函数,但对于入门项目,我们可以实现一个基于“评分表”的贪婪AI,它已经能提供不错的游戏体验。
其核心思想是:遍历棋盘所有空位,计算如果在该位置落子(对我方)和如果对方在该位置落子(对敌方)的“价值”,选择一个综合价值最高的点落子。如何计算一个点的价值?我们可以定义几种棋型并赋予分数,例如:
- 连五:100000分(获胜)
- 活四:10000分(下一步就能成五)
- 冲四:5000分(只有一个点能成五)
- 活三:1000分
- 眠三:200分
- 活二:100分
- 眠二:10分
AI在每个空位上,模拟放置己方棋子,扫描四个方向形成的最优棋型是什么,得到进攻分;再模拟放置对方棋子,得到防守分。最后,该点的总分可能是进攻分和防守分的加权和(例如进攻分1.2 + 防守分1.0),以鼓励AI在势均力敌时积极进攻。
// 简化版的评分函数示例 int evaluatePoint(const Board& board, int x, int y, int player) { int score = 0; // 这里应实现复杂的棋型识别逻辑,遍历四个方向,匹配预定义的棋型模式 // 例如,判断是否形成“活三”、“冲四”等 // 这是一个简化的示意,实际代码较长 score += evaluateDirection(board, x, y, player, 1, 0); // 水平 score += evaluateDirection(board, x, y, player, 0, 1); // 垂直 score += evaluateDirection(board, x, y, player, 1, 1); // 主对角 score += evaluateDirection(board, x, y, player, 1, -1); // 副对角 return score; } // AI决策函数 std::pair<int, int> AIPlayer::makeMove(const Board& board) { int bestScore = -1; std::pair<int, int> bestMove = {-1, -1}; for (int i = 0; i < board.getSize(); ++i) { for (int j = 0; j < board.getSize(); ++j) { if (board.getPiece(i, j) == EMPTY) { // 计算如果我方下这里的得分(进攻) int attackScore = evaluatePoint(board, i, j, myColor); // 计算如果对方下这里的得分(防守),需要防止对方成杀 int defendScore = evaluatePoint(board, i, j, opponentColor); int totalScore = attackScore * 12 + defendScore * 10; // 加权 if (totalScore > bestScore) { bestScore = totalScore; bestMove = {i, j}; } } } } return bestMove; // 返回最佳落子坐标 }这个AI虽然不会深谋远虑,但它能识别即时威胁和机会,对于新手玩家来说已经颇具挑战。实现这个评分函数的过程,本身就是对五子棋棋形理解的深度训练。
4. 开发环境搭建与项目构建
工欲善其事,必先利其器。一个顺手的开发环境能极大提升编码效率和调试体验。对于C++项目,我强烈推荐使用“VSCode + CMake + GCC/Clang”的组合,它轻量、跨平台且高度可配置。
4.1 使用CMake管理项目
直接在VSCode里写单个cpp文件然后g++编译,对于小练习可以,但对于我们这种有多个类文件(Board.cpp, Game.cpp, Player.cpp, main.cpp)的项目,管理起来就很混乱。CMake是C++事实上的标准构建工具,它帮你管理编译依赖、生成构建系统(如Makefile)。
一个最基础的CMakeLists.txt文件可以这样写:
cmake_minimum_required(VERSION 3.10) project(Gomoku CXX) # 项目名和语言 set(CMAKE_CXX_STANDARD 11) # 使用C++11标准 set(CMAKE_CXX_STANDARD_REQUIRED ON) # 将所有的源文件添加到一个变量中 set(SOURCES src/main.cpp src/Board.cpp src/Game.cpp src/Player.cpp src/AIPlayer.cpp # 如果有AI类的话 ) # 将所有的头文件目录包含进来 include_directories(include) # 生成可执行文件 add_executable(gomoku ${SOURCES})把你的.h文件放在include/目录下,.cpp文件放在src/目录下。在项目根目录打开终端,执行:
mkdir build && cd build cmake .. make就会在build目录下生成名为gomoku的可执行文件。这种结构清晰、规范,是工程化的第一步。
4.2 VSCode配置要点
在VSCode中安装C/C++扩展(ms-vscode.cpptools)是必须的。为了让智能提示(IntelliSense)和跳转定义正常工作,你需要配置c_cpp_properties.json文件(通过命令面板C/C++: Edit Configurations (UI)更直观)。
关键配置在于includePath和compilerPath。includePath要添加你的项目头文件路径(如"${workspaceFolder}/include")以及可能需要的标准库路径。compilerPath指向你的GCC或Clang编译器位置(如/usr/bin/g++或C:/mingw64/bin/g++.exe)。
另外,配置tasks.json用于一键构建,配置launch.json用于调试。在launch.json中,program项要指向CMake生成的可执行文件(如"${workspaceFolder}/build/gomoku"),preLaunchTask可以设置为你的构建任务名,实现调试前自动编译。
踩坑记录:最常见的问题是“找不到头文件”。请务必检查:1.
c_cpp_properties.json中的includePath是否正确包含了你的include目录。2. 头文件引用时,在.cpp文件中使用#include "Board.h",并且在Board.h中使用#pragma once或#ifndef守卫防止重复包含。3. 确保CMake的include_directories指令已添加。
5. 从零开始的详细实现步骤
让我们抛开理论,一步步把代码敲出来。我会按照模块化的顺序,并穿插解释每个设计决策背后的原因。
5.1 第一步:定义常量与棋盘类(Board)
首先,在include/Board.h中定义一些全局常量和类接口。
// Board.h #pragma once #include <vector> const int BOARD_SIZE = 15; const int EMPTY = 0; const int BLACK = 1; const int WHITE = 2; class Board { public: Board(int size = BOARD_SIZE); // 获取棋盘大小 int getSize() const; // 获取指定位置棋子状态 int getPiece(int x, int y) const; // 在指定位置落子,成功返回true bool placePiece(int x, int y, int player); // 检查是否获胜 bool checkWin(int x, int y, int player) const; // 检查棋盘是否已满 bool isFull() const; // 重置棋盘 void clear(); // 打印棋盘到控制台(辅助函数,后期可被图形界面替代) void display() const; private: int size_; std::vector<int> board_; // 一维数组存储棋盘状态 // 内部辅助函数:检查坐标是否在棋盘内 bool isInBoard(int x, int y) const; };在src/Board.cpp中实现这些方法。重点实现placePiece和checkWin。
// Board.cpp #include "Board.h" #include <iostream> #include <iomanip> Board::Board(int size) : size_(size), board_(size * size, EMPTY) {} bool Board::placePiece(int x, int y, int player) { if (!isInBoard(x, y) || getPiece(x, y) != EMPTY) { return false; } board_[x * size_ + y] = player; // 一维索引计算 return true; } bool Board::checkWin(int x, int y, int player) const { // 如前文所述的四个方向检查逻辑 const int dirs[4][2] = {{1,0},{0,1},{1,1},{1,-1}}; for (auto& dir : dirs) { int count = 1; for (int d = 1; d < 5; ++d) { // 正向 int nx = x + dir[0] * d; int ny = y + dir[1] * d; if (!isInBoard(nx, ny) || getPiece(nx, ny) != player) break; count++; } for (int d = 1; d < 5; ++d) { // 反向 int nx = x - dir[0] * d; int ny = y - dir[1] * d; if (!isInBoard(nx, ny) || getPiece(nx, ny) != player) break; count++; } if (count >= 5) return true; } return false; } // 其他函数实现... void Board::display() const { std::cout << " "; for (int j = 0; j < size_; ++j) std::cout << std::setw(2) << j; std::cout << std::endl; for (int i = 0; i < size_; ++i) { std::cout << std::setw(2) << i; for (int j = 0; j < size_; ++j) { int piece = getPiece(i, j); char c = (piece == EMPTY) ? '.' : (piece == BLACK) ? 'B' : 'W'; std::cout << std::setw(2) << c; } std::cout << std::endl; } }5.2 第二步:实现玩家基类与人类玩家
在include/Player.h中,我们定义一个抽象的玩家基类,这样游戏逻辑就不需要关心对手是人还是AI。
// Player.h #pragma once #include "Board.h" #include <string> class Player { public: Player(const std::string& name, int color) : name_(name), color_(color) {} virtual ~Player() = default; // 纯虚函数,由子类实现如何落子 virtual std::pair<int, int> getMove(const Board& board) = 0; std::string getName() const { return name_; } int getColor() const { return color_; } protected: std::string name_; int color_; // BLACK or WHITE };然后实现一个HumanPlayer类,它从控制台读取用户的输入。
// HumanPlayer.h #pragma once #include "Player.h" class HumanPlayer : public Player { public: using Player::Player; // 继承构造函数 std::pair<int, int> getMove(const Board& board) override; }; // HumanPlayer.cpp #include "HumanPlayer.h" #include <iostream> std::pair<int, int> HumanPlayer::getMove(const Board& board) { int x, y; while (true) { std::cout << name_ << " (" << (color_ == BLACK ? "Black" : "White") << "), enter your move (row col): "; std::cin >> x >> y; if (std::cin.fail()) { std::cin.clear(); // 清除错误状态 std::cin.ignore(10000, '\n'); // 忽略错误输入 std::cout << "Invalid input. Please enter two numbers." << std::endl; } else if (board.isInBoard(x, y) && board.getPiece(x, y) == EMPTY) { return {x, y}; } else { std::cout << "Invalid move. Position out of range or already occupied." << std::endl; } } }实操心得:输入验证至关重要。必须处理非数字输入(
std::cin.fail())和棋盘越界、位置被占的情况。使用while(true)循环直到获得合法输入,这是控制台程序的常见模式。std::cin.ignore用于清空输入缓冲区,防止之前的错误输入影响后续读取。
5.3 第三步:编织游戏核心逻辑(Game类)
Game类负责将棋盘和玩家组合起来,驱动游戏进程。
// Game.h #pragma once #include "Board.h" #include "Player.h" #include <memory> class Game { public: Game(std::unique_ptr<Player> player1, std::unique_ptr<Player> player2, int boardSize = BOARD_SIZE); void start(); // 开始游戏 private: Board board_; std::unique_ptr<Player> player1_; std::unique_ptr<Player> player2_; int currentPlayer_; // 0 for player1, 1 for player2 Player* getCurrentPlayer() const; void switchPlayer(); void announceResult(Player* winner) const; };Game.cpp中的start函数是游戏的主循环。
// Game.cpp #include "Game.h" #include <iostream> void Game::start() { board_.clear(); currentPlayer_ = 0; // 黑方先手 std::cout << "Game Start! " << player1_->getName() << " (Black) vs " << player2_->getName() << " (White)" << std::endl; while (true) { board_.display(); Player* curPlayer = getCurrentPlayer(); std::cout << "Current turn: " << curPlayer->getName() << std::endl; // 获取玩家落子 auto [x, y] = curPlayer->getMove(board_); // 落子(这里理论上应该总是成功,因为Player的getMove应该返回合法位置,但做二次检查更安全) if (!board_.placePiece(x, y, curPlayer->getColor())) { std::cerr << "Internal error: Invalid move placed." << std::endl; continue; } // 检查胜负 if (board_.checkWin(x, y, curPlayer->getColor())) { board_.display(); announceResult(curPlayer); break; } // 检查平局 if (board_.isFull()) { board_.display(); std::cout << "The board is full. It's a draw!" << std::endl; break; } // 切换玩家 switchPlayer(); } }5.4 第四步:组装与运行(main函数)
最后,在main.cpp中,我们创建玩家和游戏对象,并启动游戏。
// main.cpp #include "Game.h" #include "HumanPlayer.h" // 未来可以在这里 #include "AIPlayer.h" int main() { std::cout << "Welcome to Gomoku!" << std::endl; // 创建两个人类玩家 auto player1 = std::make_unique<HumanPlayer>("Player1", BLACK); auto player2 = std::make_unique<HumanPlayer>("Player2", WHITE); // 或者创建一个人机对战 // auto player2 = std::make_unique<AIPlayer>("AI", WHITE); Game game(std::move(player1), std::move(player2)); game.start(); return 0; }至此,一个支持两人对战的、控制台版本的五子棋核心框架就完成了。运行程序,你将能看到棋盘,并可以轮流输入坐标进行对战。
6. 功能扩展与进阶优化思路
基础版本跑通后,我们可以从多个维度丰富这个项目,让它更完整、更专业。
6.1 实现悔棋与存档/读档功能
悔棋功能:需要在Game类中维护一个std::stack<std::pair<int, int>> moveHistory_。每次成功落子后,将坐标压栈。当玩家输入悔棋命令时,从栈中弹出上一步坐标,并将棋盘上对应位置重置为EMPTY。注意,悔棋通常只能悔一步,或者约定次数。同时,需要切换当前玩家。
存档/读档:本质上是序列化与反序列化。我们需要将游戏状态(棋盘状态、当前玩家)保存到文件。一个简单的方法是将棋盘数据(一维vector)和当前玩家索引写入一个二进制文件或格式清晰的文本文件。例如,文本文件第一行存棋盘大小和当前玩家,后面每行存一行棋盘数据。读档时,再根据文件内容重建Board和设置currentPlayer_。
// 简化的存档函数示例 bool Game::saveGame(const std::string& filename) const { std::ofstream ofs(filename); if (!ofs) return false; ofs << board_.getSize() << " " << currentPlayer_ << std::endl; for (int i = 0; i < board_.getSize(); ++i) { for (int j = 0; j < board_.getSize(); ++j) { ofs << board_.getPiece(i, j) << " "; } ofs << std::endl; } return ofs.good(); }6.2 从控制台到图形界面
控制台交互毕竟简陋。将核心逻辑迁移到图形界面是质的飞跃。你可以选择以下任一框架:
- Qt:功能强大,跨平台,信号槽机制非常适合事件驱动的游戏。你需要将
Board::display()的逻辑改为在QPainter上绘制网格和棋子,将HumanPlayer::getMove()改为响应鼠标点击事件。 - SFML:更轻量,专注于多媒体和游戏,适合2D图形渲染。用它来绘制棋盘和棋子同样直观。
- EasyX(Windows):国内初学者友好的图形库,但仅限于Windows平台。
迁移的关键在于分离模型与视图。我们的Board、Game、Player类作为“模型”几乎不需要改动。我们需要新建“视图”类来处理图形绘制,新建“控制器”类(或直接在Qt的窗口类中)来接收用户事件(鼠标点击),并将其转化为对Game模型的调用(如game.makeMove(x, y))。这种MVC(模型-视图-控制器)模式让代码结构保持清晰。
6.3 打造更强的AI:极小化极大算法入门
前面实现的评分表AI是“贪婪”的,只看一步。一个更强的AI应该能思考多层,这就是博弈树搜索。极小化极大算法是基础。其核心思想是:AI假设自己(最大化玩家)会选择对自己最有利的走法,而对手(最小化玩家)会选择对AI最不利的走法。通过递归地模拟未来几步,最终选择一个在当前看来最优的走法。
实现一个带Alpha-Beta剪枝的极小化极大算法可以大幅提升搜索效率。你需要:
- 估值函数:比之前的评分表更全面,能给任何棋盘状态打分。正分对AI有利,负分对对手有利。
- 递归搜索函数:模拟双方轮流走棋,在搜索深度达到限制或游戏结束时,调用估值函数返回分数。
- 走法生成:不是遍历所有空位(15x15=225),而是只搜索有棋子的周围位置(“星位”),这能极大减少分支因子。
这是一个进阶话题,实现起来代码量会显著增加,但它能让你对递归、搜索、剪枝等算法概念有深刻理解。你的AI将从“有点聪明”变成“难以战胜”。
7. 调试技巧与常见问题排雷
在开发过程中,你一定会遇到各种bug。这里分享几个我踩过的坑和调试方法。
7.1 边界检查与数组越界
这是最经典的错误。在checkWin函数中,向四个方向扫描时,nx和ny可能变成-1或大于等于BOARD_SIZE。务必在访问board_之前用isInBoard函数检查。同样,在placePiece和getPiece中也要检查。一维数组索引计算x * size_ + y也要确保x, y在合法范围内。
调试方法:在疑似越界的地方添加断言assert(isInBoard(x, y)),或者打印出错的坐标。使用Valgrind(Linux/macOS)或Visual Studio的调试器(Windows)来检测内存访问错误。
7.2 输入流状态混乱
在HumanPlayer::getMove中,如果用户输入了非数字字符,std::cin会进入错误状态,后续所有输入操作都会失败。这就是为什么我们必须用std::cin.clear()清除错误状态,并用std::cin.ignore丢弃错误输入。一个更健壮的做法是将整行读入字符串,再用std::istringstream解析。
7.3 胜负判断逻辑错误
除了前文提到的单向扫描错误,另一个常见错误是“长连”判断。五子棋规则是连五即胜,连六也算赢。我们的count >= 5判断是正确的。但如果你错误地写成了count == 5,当某种原因导致count为6时(比如双向计数逻辑有重叠),就会判负。写完后,一定要用多种棋型测试:水平五连、垂直五连、两种对角线五连、长连、四连但被堵住等情况。
测试方法:可以写一个简单的测试函数,预设一个棋盘状态,然后调用checkWin看结果是否符合预期。这是单元测试的思想。
7.4 内存管理与智能指针
在这个项目中,我们使用了std::unique_ptr来管理Player对象。这确保了当Game对象销毁时,Player对象也会被自动销毁,无需手动delete,避免了内存泄漏。这是现代C++提倡的RAII(资源获取即初始化)理念。如果你用new创建了对象,务必在析构函数中delete。
7.5 跨平台编译问题
如果你在Windows上使用MinGW-w64的GCC,在Linux/macOS上使用Clang或GCC,代码本身应该是可移植的。但要注意:
- 路径分隔符:Windows是
\,类Unix是/。在代码中写文件路径时,尽量使用/,C++标准库都支持。 - 控制台编码:如果中文字符显示为乱码,可能是控制台编码问题。在Windows下,可以在程序开头执行
system("chcp 65001");来设置为UTF-8编码(但system调用有安全风险,仅用于测试)。更推荐的做法是避免在核心逻辑中使用中文,或者使用宽字符。 - 编译器标准:在
CMakeLists.txt中明确指定set(CMAKE_CXX_STANDARD 11),确保所有平台使用相同的语言标准。
这个项目就像一块很好的磨刀石,涵盖了C++从基础语法(类、函数、数组/向量)、到面向对象设计(继承、多态)、再到标准库使用(智能指针、文件流)、算法设计(胜负判断、搜索)乃至工程化管理(CMake)的多个方面。当你按照这个思路完整实现一遍,并尝试进行扩展时,你对C++的理解一定会深入一个层次。编程的乐趣,就在于将想法一步步变为现实,并在过程中不断解决涌现出来的问题。
