简介:压缩包内含一个基于C++的数独游戏GUI完整工程,面向初学C++、希望结合算法与界面编程的开发者,也适合作为课程设计或毕业设计的参考源码。该rar包共9个文件,包括cpp源文件、dsw/dsp工程文件以及ncb/opt/pch等编译辅助文件,整体仅24KB,代码量不大,适合逐行阅读。已有360人学习浏览。源码使用9x9二维数组表示盘面,将空格设为0,借助回溯法逐格尝试数字,并通过行、列、宫格的约束传播提前排除非法选项,核心逻辑清晰。图形界面采用事件驱动模式,可处理鼠标点击和数字输入,涵盖新游戏生成、填数合法性检查、完成判断等交互。此外还涉及棋盘数据的文件读写和异常输入提示,能够帮助读者把C++语法、经典搜索算法与GUI程序设计综合起来,是一份紧凑而完整的练习项目。
1. 从 shudu.rar 到能玩的小游戏:C++ 数独游戏 GUI 值得自己写一遍
如果你在网盘里下载过标题类似 shudu.rar 的压缩包,解压后多半是几个 .cpp、.h 文件和一张截图,赶上手气差时连运行都跑不起来。数独游戏 GUI 写起来并不神秘,核心就两件事:让程序自己能生成唯一解的题目、能校验玩家填的数,再用图形界面把九宫格画出来、把交互接起来。它能把你学过的 C++ 数组、递归、信号槽、线程串成一条完整主线,也是课设和简历项目里最容易讲清楚的小东西。这篇文章按我实际开发的顺序来写:先用终端把数独算法跑通,再选 GUI 框架,最后接界面、排坑。适合 C++ 刚入门、第一次碰 GUI 编程的读者,也适合已经写过终端版、正卡在界面层的开发者。
2. 数独核心逻辑是地基:先让程序学会出题和解题
2.1 为什么先写终端版:算法和界面不该同时调试
很多教程上来就铺界面,结果回溯几十毫秒还是几秒都看不出来,因为所有耗时都被 GUI 的黑匣子遮住了。我一般会把数独核心单独写成一个不依赖任何图形框架的类,在终端里用 main 函数直接验证。这样既能确认算法正确,后面接 Qt 时也只需要关注界面这一层。
写终端验证代码不需要任何额外依赖,先建一个sudoku_core.h,然后在 main 里跑一遍完整流程:
// main.cpp 终端验证:生成终盘 -> 挖洞 -> 求解 #include "sudoku_core.h" #include <cstdio> int main() { SudokuCore core; std::vector<std::vector<int>> full, puzzle; if (!core.generateFinished(full)) { printf("generate failed\n"); return 1; } core.digPuzzle(full, puzzle, 40); printf("puzzle:\n"); for (int i = 0; i < 9; ++i) { for (int j = 0; j < 9; ++j) printf("%2d", puzzle[i][j]); printf("\n"); } return 0; }这里的generateFinished、digPuzzle在下面两节实现。先跑通这个文件,确认题面能打印出来,再继续。很多人跳过这步直接写界面,最后报错时根本分不清是回溯逻辑错了,还是信号槽连接写错了。
2.2 用回溯生成完整终盘:让随机性来自候选数字乱序
生成终盘常见两种思路:一种是先写一行模板再通过行列交换生成基盘,速度快但随机性偏弱;另一种是从空盘开始,按随机候选数做深度优先回溯。我推荐后者,因为它和后面求解器的结构几乎一样,写一次能复用两处。
核心代码放在SudokuCore类里:
// sudoku_core.h #include <vector> #include <random> #include <algorithm> class SudokuCore { public: SudokuCore() : rng(std::random_device{}()) {} bool generateFinished(std::vector<std::vector<int>>& board) { board.assign(9, std::vector<int>(9, 0)); return fill(board); } private: std::mt19937 rng; bool isValidMove(const std::vector<std::vector<int>>& board, int row, int col, int num) { for (int i = 0; i < 9; ++i) { if (i != col && board[row][i] == num) return false; if (i != row && board[i][col] == num) return false; } int br = (row / 3) * 3; int bc = (col / 3) * 3; for (int i = br; i < br + 3; ++i) for (int j = bc; j < bc + 3; ++j) if ((i != row || j != col) && board[i][j] == num) return false; return true; } bool fill(std::vector<std::vector<int>>& board) { int row = -1, col = -1; for (int i = 0; i < 9 && row == -1; ++i) for (int j = 0; j < 9 && row == -1; ++j) if (board[i][j] == 0) { row = i; col = j; } if (row == -1) return true; std::vector<int> digits = {1,2,3,4,5,6,7,8,9}; std::shuffle(digits.begin(), digits.end(), rng); for (int d : digits) { if (isValidMove(board, row, col, d)) { board[row][col] = d; if (fill(board)) return true; board[row][col] = 0; } } return false; } };逻辑很简单:fill每次找第一个空格,没空格说明整盘填满,返回 true;候选数字先随机打乱,再逐个试。isValidMove同时检查行、列、宫格,其中br = (row / 3) * 3算的是所在宫的左上角行号,bc同理。注意循环里用i != col和i != row跳过当前位置,这样即使board[row][col]暂存着旧值,也不会把自己误判成冲突。
参数说明:generateFinished的入参是二维 vector 引用,函数内部会先assign清空,所以调用方不需要预填 0。rng用std::random_device初始化,正常情况下每次运行会拿到不同种子,生成不同终盘;random_device 失效的坑放到避坑章节讲。
2.3 挖洞生成题面:难度不只看空格数量
终盘有了,下一步就是挖洞:摘掉部分数字,让盘面变成玩家看到的题面。挖洞最关键的前提是题目必须唯一解,否则玩家填到最后可能发现两种填法都合法,整个游戏失去意义。
挖洞的常规做法是:把 81 个格子随机排序,逐个尝试置为 0,每挖一个洞就调用求解器检查解的数量,解不唯一就填回去。这样做能保证唯一解,但耗时跟后面的求解器性能强相关。
// 声明:统计解数量,limit 是为了提前退出 int countSolutions(std::vector<std::vector<int>>& board, int limit); // 成员函数,需要放在 SudokuCore 类里以便访问 rng void digPuzzle(const std::vector<std::vector<int>>& fullBoard, std::vector<std::vector<int>>& puzzle, int cellsToRemove) { puzzle = fullBoard; std::vector<std::pair<int,int>> pos; for (int i = 0; i < 9; ++i) for (int j = 0; j < 9; ++j) pos.push_back({i, j}); std::shuffle(pos.begin(), pos.end(), rng); int removed = 0; for (auto [r, c] : pos) { if (removed >= cellsToRemove) break; int backup = puzzle[r][c]; puzzle[r][c] = 0; if (countSolutions(puzzle, 2) != 1) { puzzle[r][c] = backup; } else { ++removed; } } }countSolutions的返回值约定:0 表示无解,1 表示唯一解,2 表示至少两个解。这里传limit=2,意思是只要发现第二个解就立刻返回 2,不用把棋盘翻个底朝天,挖洞性能能快不少。
关于难度,新手最容易犯的错是把难度等同于空格数。去掉 40 个洞的题目不一定比去掉 35 个更难,因为有些题面留给玩家的推理路径很直白,有些则需要高级技巧。入门阶段可以按挖洞数量粗分:简单约 35 个,中等约 45 个,困难约 55 个。更严谨的分级需要统计解题过程中用到的技巧类型,这个放到最后一章展开。
2.4 求解器与局面校验:一个裁判,三种用法
挖洞阶段和后面 GUI 的“检查答案”“提示一格”功能,都需要一个相同的裁判:求解器。我写求解器时用的是和生成终盘几乎一致的回溯结构,只是fill变成solveOne,一旦找到一组解就返回。
bool solveOne(std::vector<std::vector<int>>& board) { int row = -1, col = -1; for (int i = 0; i < 9 && row == -1; ++i) for (int j = 0; j < 9 && row == -1; ++j) if (board[i][j] == 0) { row = i; col = j; } if (row == -1) return true; for (int num = 1; num <= 9; ++num) { if (isValidMove(board, row, col, num)) { board[row][col] = num; if (solveOne(board)) return true; board[row][col] = 0; } } return false; } int countSolutions(std::vector<std::vector<int>>& board, int limit) { int row = -1, col = -1; for (int i = 0; i < 9 && row == -1; ++i) for (int j = 0; j < 9 && row == -1; ++j) if (board[i][j] == 0) { row = i; col = j; } if (row == -1) return 1; int total = 0; for (int num = 1; num <= 9; ++num) { if (isValidMove(board, row, col, num)) { board[row][col] = num; total += countSolutions(board, limit); board[row][col] = 0; if (total >= limit) return total; } } return total; }两个函数长得像,但用途不同:solveOne修改传入的 board,并把第一个找到的解留在里面,适合“提示”功能;countSolutions不改动最终局面(试填后立刻恢复 0),只返回解得数量,适合唯一解检查。
在 GUI 里校验玩家当前填的数,其实不需要完整求解,只需要检查该数字在当前行、列、宫格里有没有重复。直接用前面isValidMove即可,传入玩家刚填的数字。注意挖洞和生成时调用顺序不同,别把这几个函数搞混。
3. 选对 GUI 框架:为什么我推荐 Qt 而不是 Win32 或 Web
3.1 三个主流选择对比:配置成本、跨平台、学习曲线
数独的界面很简单,但选错框架会让整个项目变成一场环境噩梦。我先做过 Win32 原生窗口,消息循环、控件句柄、重绘逻辑一套下来,九宫格还没画完就写了三百行,而且一换电脑就编译不过。后来换 Qt,界面代码量少了三分之二,遇到问题也能在社区找到大量案例。
我用过一个对比表,直接决定选型:
| 框架 | 学习曲线 | 跨平台 | 打包体积 | 适合场景 |
|---|---|---|---|---|
| Qt (Widgets) | 中等 | Windows / Linux / macOS | 较大,需带运行库 | 桌面小游戏、工具软件 |
| wxWidgets | 较陡 | 原生控件跨平台 | 较大 | 追求原生外观的桌面应用 |
| Win32 API | 陡,控件全手写 | 仅 Windows | 小 | 练习底层机制或极简工具 |
| Web 前端套壳 | 取决于前端功底 | 跨平台 | 可大可小 | 想顺便练 HTML/CSS 的情况 |
我的看法是:如果目标是快速做出一个能玩、能展示的数独小游戏,Qt Widgets 是最稳妥的路径。它自带布局系统、信号槽和成熟的调试工具,不用手算控件坐标。Qt 5 和 Qt 6 在 Widgets 上差异不大,本项目的代码两个版本都能跑。
3.2 搭建 Qt 环境:新建项目、选编译器、跑通第一个窗口
环境搭建是整个项目里翻车率最高的环节。常见问题是装了 Qt 但没有选对编译器套件,或者下载了不支持当前系统的组件。我的建议是走官方工具链,少碰第三方 “xx 汉化版” 的打包环境。
操作步骤大致是:先从 Qt 官网下载在线安装器,安装时勾选 Qt Creator 和你需要的一个套件;套件要么是 MinGW 64-bit,要么是 MSVC 64-bit,选一个就行。如果你本机装了 Visual Studio,MSVC 套件会和它匹配;如果你希望免装 VS,就选 MinGW。接着新建 Qt Widgets Application,项目名随便取,类名默认MainWindow即可,直接运行。
默认生成的项目里有一个空的main.cpp:
#include <QApplication> #include "mainwindow.h" int main(int argc, char *argv[]) { QApplication app(argc, argv); MainWindow w; w.show(); return app.exec(); }QApplication管理界面资源,MainWindow是我们后面放棋盘的地方。跑通空窗口后,再回到第 2 章把sudoku_core.h加进工程,这样核心算法和界面分开编译,排查问题时才不用两头猜。
3.3 棋盘界面拆解:9x9 单元格、候选数与状态栏
一个数独界面拆开就三块:81 个格子构成的棋盘、几个操作按钮,加一条显示当前状态的状态栏。格子用什么控件?我推荐QLineEdit,而不是QSpinBox,因为 QSpinBox 不允许空置,玩家想清空一个格子时会很难受。QLineEdit 用 validator 限制输入 1-9,又允许空白,是最合适的单元格选择。
先不看完整代码,单看初始化棋盘的骨架:
// MainWindow 构造函数片段 SudokuWindow::SudokuWindow(QWidget *parent) : QMainWindow(parent) { QWidget *central = new QWidget(this); QGridLayout *outer = new QGridLayout(central); cells.resize(9, std::vector<QLineEdit*>(9, nullptr)); for (int br = 0; br < 3; ++br) { for (int bc = 0; bc < 3; ++bc) { QFrame *box = new QFrame(central); box->setFrameShape(QFrame::StyledPanel); QGridLayout *inner = new QGridLayout(box); inner->setSpacing(2); for (int r = 0; r < 3; ++r) for (int c = 0; c < 3; ++c) { int row = br * 3 + r; int col = bc * 3 + c; QLineEdit *cell = new QLineEdit(box); cell->setFixedSize(48, 48); cell->setAlignment(Qt::AlignCenter); cell->setMaxLength(1); cells[row][col] = cell; inner->addWidget(cell, r, c); } outer->addWidget(box, br, bc); } } setCentralWidget(central); }这段代码用两层网格实现宫格分组:外层 3x3 放QFrame,每个 QFrame 内再用 3x3 网格放 9 个 QLineEdit。好处是宫格线天然清晰,样式表的边框也更好控制。cells[row][col]这个二维数组后续访问任意格子都非常方便。
提示:这里先不接信号和输入限制,只把骨架立起来。确认棋盘能显示再继续,否则一次性写太多代码,界面一黑根本不知道是哪行的问题。
4. 把算法接进界面:数独游戏 GUI 的最小可玩版本
4.1 用 QGridLayout 渲染棋盘:固定大小、输入限制与宫格分组
3.3 的骨架已经能画出 81 个格子,但玩家现在还能输入字母。要做的就是给每个格子加输入校验和键盘监听。Qt 里的 validator 是首选,它能在输入层面直接挡住非法字符。
Qt5 下用QRegExpValidator,Qt6 则改成QRegularExpressionValidator,代码差别不大:
#include <QRegularExpressionValidator> // 初始化单个格子时调用 QRegularExpressionValidator *validator = new QRegularExpressionValidator(QRegularExpression("[1-9]"), cell); cell->setValidator(validator);正则[1-9]表示只允许单个 1 到 9 的字符,空字符串也放行,因为校验器逻辑是“输入后仍能匹配整个正则才接受”,空串符合起始状态,玩家可以用退格删除数字。注意不要把正则写成[0-9],那样 0 会被接受,而数独里根本没有 0。
宫格分组已经在 3.3 用 QFrame 实现了。现在补上样式,让每个格子边框清晰,已固定的数字和玩家输入的数字用不同颜色区分:
// 固定数字用深色粗体,玩家输入用蓝色 if (isFixed[row][col]) { cell->setStyleSheet("font-weight: bold; color: #333; background: #f0f0f0;"); } else { cell->setStyleSheet("color: #0078d4; background: white;"); }这些细节能让界面一眼看出哪些格子是题目自带、哪些是玩家填的,对后续按钮逻辑也有帮助。
4.2 信号与槽:输入校验、错误高亮、按钮联动
棋盘只是展示层,真正的交互是输入。我给每个 QLineEdit 接textChanged信号,一旦内容变化,就读取这个格子当前的数字,再拿着它去检查行、列、宫格是否有重复。冲突的格子立刻标红,这是新手最有成就感的一步。
// 在初始化循环里为每个格子连接信号 #include <functional> for (int i = 0; i < 9; ++i) { for (int j = 0; j < 9; ++j) { int fixed = isFixed[i][j]; connect(cells[i][j], &QLineEdit::textChanged, this, [this, i, j, fixed](const QString &text) { if (m_updating) return; onCellChanged(i, j, text, fixed); }); } }m_updating是一个 bool 成员变量,它的作用在避坑章节细讲,简单说就是防止程序自己 setText 时再次触发校验造成死循环。onCellChanged内部会取出当前棋盘值,调用isValidMove检查冲突:
void SudokuWindow::onCellChanged(int row, int col, const QString &text, bool fixed) { if (fixed) return; // 题目自带数字不参与校验 if (text.isEmpty()) { cells[row][col]->setStyleSheet("color: #0078d4; background: white;"); return; } int num = text.toInt(); std::vector<std::vector<int>> board = currentBoard(); if (!isValidMove(board, row, col, num)) { cells[row][col]->setStyleSheet("color: red; background: #ffe0e0;"); } else { cells[row][col]->setStyleSheet("color: #0078d4; background: white;"); } }注意这里每次都复制整块棋盘再校验,九宫格才 81 个数,开销完全可以忽略。高手当然可以用计数器做增量冲突检测,但那要把行、列、宫的三个计数器都维护起来,对初学者来说过于绕。
按钮区域通常放三个:新游戏、检查、提示。检查按钮可以直接复用solveOne:把当前盘面复制一份,求解成功就把结果和当前输入逐格比较,不一样说明填错了,弹 QMessageBox 提示。
4.3 别让界面卡死:把生成和求解放进后台线程
挖洞和唯一解检测虽然通常只有几十毫秒,但在较弱的 CPU 上、或者挖洞数量接近 55 时,耗时可能冲到几百毫秒甚至一闪而过的卡顿。更关键的是,如果玩家点“提示”时求解器回溯了很深,界面就会冻结,看起来像程序崩溃。
常见的做法是用 QtConcurrent 把耗时任务丢到线程池,再用QFutureWatcher接收结果。这样做不需要自己管理线程生命周期,回调会回到主线程,界面可以放心更新。
#include <QtConcurrent> #include <QFutureWatcher> struct PuzzleResult { std::vector<std::vector<int>> full; std::vector<std::vector<int>> puzzle; }; void SudokuWindow::newGame() { int removeCount = ui->difficultyCombo->currentIndex() == 0 ? 35 : ui->difficultyCombo->currentIndex() == 1 ? 45 : 55; QFutureWatcher<PuzzleResult> *watcher = new QFutureWatcher<PuzzleResult>(this); QFuture<PuzzleResult> future = QtConcurrent::run([removeCount]() { SudokuCore core; PuzzleResult res; core.generateFinished(res.full); core.digPuzzle(res.full, res.puzzle, removeCount); return res; }); watcher->setFuture(future); connect(watcher, &QFutureWatcher<PuzzleResult>::finished, this, [this, watcher]() { applyPuzzle(watcher->result()); watcher->deleteLater(); }); }提示:QtConcurrent 属于 Qt Concurrent 模块,需要在.pro文件里加一行QT += concurrent。这里removeCount由难度下拉框决定,实现了难度分级的基础版。
后台线程会带来一个坑:如果玩家在生成过程中直接关窗口,回调可能访问已经销毁的控件。稳妥做法是在窗口关闭时把 watcher 取消,或者让 watcher 以 this 为父对象,这样窗口销毁时 watcher 也会被销毁;但后台任务仍在跑,它返回后 lambda 里applyPuzzle不会再执行,因为 watcher 已删,finished 信号不会发出去,所以这个写法是安全的。
5. 避坑与排查:写数独 GUI 最容易翻车的五个细节
5.1 现象:每次启动游戏,题目几乎一模一样
原因:std::random_device不是在所有平台都能给出真正随机的种子,某些老版本 MinGW 环境下它会退化成固定值。第一次跑没事,第二次跑还是同一套题,浪费了大半天。
解决:给 rng 加一个混合种子,用当前时间兜底。构造函数改成这样:
SudokuCore() { std::random_device rd; auto now = std::chrono::high_resolution_clock::now(); auto nanos = std::chrono::duration_cast<std::chrono::nanoseconds>( now.time_since_epoch()).count(); rng.seed(rd() ^ static_cast<unsigned>(nanos)); }原理是拿随机设备和纳秒时间戳做异或,即使 random_device 退化,时间戳也能让每次运行的种子不同。这个改动放第 2.2 节可能不明显,但到 GUI 阶段你会特别感谢它。
5.2 现象:点“新游戏”或“提示”后窗口直接灰掉,标题栏写着“无响应”
原因:耗时任务跑在了主线程。挖洞需要反复调用countSolutions,一次回溯到底可能要遍历上百万个节点,几十毫秒的卡顿在用户眼里就是“程序死了”。这不是死循环,是界面线程被占住了。
解决:回到 4.3 的做法,把生成、求解逻辑全部放进QtConcurrent::run,界面线程只负责接收结果。判断标准很简单:任何调用了solveOne或digPuzzle的按钮事件,都不该直接在主线程里执行。
5.3 现象:明明是对的题面,程序却提示“该格子有冲突”,或者求解器返回无解
原因:宫格坐标算错。最常见的是把br = (row / 3) * 3写成br = row % 3 * 3,或者宫格循环里没有跳过(row, col)自身。这类错误在终端版里可能被巧合掩盖,一到 GUI 的多盘面切换就暴露。
解决:先把isValidMove独立出来,针对行冲突、列冲突、宫格冲突各写一个最小测试。比如把(0,0)填 5,又把(0,3)填 5,isValidMove必须返回 false。用调试器看一次宫格坐标:
(br,bc) = ( (row/3)*3, (col/3)*3 )这是 3x3 宫格左上角的起始坐标,后续循环里i < br + 3才能固定走 3 行。
5.4 现象:程序自动填入题目时,所有格子都被标红,或者文本被吃掉一部分
原因:程序内setText也会触发textChanged信号,然后onCellChanged又反过来修改样式表,甚至再次setText,形成循环。更隐蔽的是,你加载题面时逐格填数字,填到一半时下一格判断的是“上一格的临时状态”,校验就会误报。
解决:用一个m_updating布尔标志包裹所有程序内部的批量更新:
void SudokuWindow::applyPuzzle(const std::vector<std::vector<int>>& puzzle) { m_updating = true; for (int i = 0; i < 9; ++i) for (int j = 0; j < 9; ++j) { cells[i][j]->setText(puzzle[i][j] ? QString::number(puzzle[i][j]) : QString()); } m_updating = false; }textChanged回调里看到m_updating为 true 就立刻 return,中断这次连锁反应。这个标志是解决 Qt 信号循环的通用套路,不只在数独里用。
5.5 现象:程序在自己电脑能跑,拷给朋友就报“缺少 VCRUNTIME140.dll”或“找不到 Qt5Core.dll”
原因:Qt 程序动态链接了一堆运行库,直接把 exe 拷走当然不行。Qt 官方的解决思路是用部署工具把依赖的 DLL 收集到同一个目录,但很多人不知道 MSVC 套件编译出的程序还需要目标机器安装 Microsoft Visual C++ Redistributable。MinGW 套件相对少这个依赖,但也得带头文件和动态库。
解决:Release 模式编译后,打开 Qt 自带的命令行工具,进入 exe 所在目录,执行windeployqt 你的程序名.exe。它会自动复制 Qt 相关 DLL、插件和样式表。如果程序在中文路径下跑,部署时可能会漏掉目录,这也是经常被忽视的细节。完成后整个文件夹一起打包,再交给别人就很少出问题。
6. 从能玩到好用:难度分级、存档和提示的进阶改造
前面 4.3 已经用removeCount按 35 / 45 / 55 分了三个难度。更真实的分级会复杂些:两个都是 45 个空格的题面,可能一个只用唯一候选就能解出,另一个却要用区块排除;玩家体感差距很大。如果你想做得更像商业产品,可以给digPuzzle加一个“最大回溯深度”统计:挖洞时每尝试一个数字,如果求解时第三层以内就出现候选唯一路径,算简单;如果回溯到七层以上,才算困难。这个统计不需要新算法,在countSolutions里加个递归深度参数就能顺带记录。
存档功能我用的是QSettings,把棋盘和计时器状态写成一组键值对:
| 配置键 | 存储内容 |
|---|---|
sudoku/board | 当前完整的 9x9 数字数组 |
sudoku/timer | 剩余时间或已用秒数 |
sudoku/difficulty | 当前难度等级 |
读取时QSettings::value换成QVariant::toInt,再逐格填回界面。要注意存盘前把isFixed也存下来,否则重开后分不清哪些是题目数字,哪些是玩家填的。
提示功能是个很容易做顺手的小技巧:复制当前盘面,调用solveOne得到完整解,然后取玩家光标所在格子的正确数字填进去。这看起来完全自动,但别忘了把m_updating置为 true,否则又触发一轮校验标红。
我自己做这类小游戏时,习惯把SudokuCore保持成纯 C++,不包含任何 Qt 头文件。这样做的好处是可以在不启动界面的情况下快速跑算法回归测试,后期想换界面框架也不用动核心代码。这个习惯帮我少走了不少弯路。如果你也在整理自己的 C++ 课设,建议从第一行开始就把算法和界面分开,等到接 GUI 那天,你会发现一切都顺理成章。希望这些细节能帮到你。
本文还有配套的精品资源,点击获取