简介:基于Qt与C++实现的数独游戏完整工程包,面向计算机相关专业学生、教师及C++/Qt入门开发者,既可作为课程设计、毕业设计或项目立项演示,也适合在现有代码基础上继续扩展功能。压缩包共59个文件,大小18.99MB,包含5个cpp与5个h核心源码、2个ui界面文件、qrc资源与pro工程配置;同时附带21个qm翻译文件、20个dll运行库和可直接启动的exe演示程序,解压后无需额外配置即可运行查看效果。项目实现了数独盘面生成、题目求解、自定义对话框、表格委托显示等模块,代码按工程化方式组织,边界清晰,适合对照界面与逻辑学习Qt的信号槽、事件处理、Model/View框架和对话框编程。已有193人学习下载,包内附README说明文件,可帮助快速了解目录结构、运行环境与各文件作用,便于二次开发和个人学习参考。
1. 拿到“基于Qt的C++数独游戏.zip”之后,先别急着跑界面
很多人解压“基于Qt的C++数独游戏.zip”后第一件事就是双击 .pro 文件,编译、弹窗、填几个数字、点一下“检查”,然后感慨一句“跑通了”。但这类源码包真正值钱的不是那个能跑起来的窗口,而是藏在里面的三样东西:数独题面的生成算法、唯一解的校验逻辑、以及 Qt 信号槽把算法和界面接起来的方式。这套项目解决的核心问题是“怎么把 C++ 写的数独逻辑,通过 Qt 变成一套能输入、能提示、能判断结果的桌面工具”。如果你正在做课程设计、刚入门 Qt 想找一个不太大的练手项目、或者单纯想搞明白“为什么网上下的数独生成器总有多解”,这条路线值得你花半天时间。下面按我的实操习惯,把这套代码拆成棋盘生成、界面接线、编译打包、避坑排查和进阶改造五段来讲。
2. 数独核心算法:先写一个能生成唯一解题面的回溯求解器
2.1 题面与终盘的关系:为什么“生成题面”比“判断答案”难
数独的规则不用展开:9×9 棋盘,每行、每列、每个 3×3 宫格里的数字 1 到 9 各出现一次。一个“合法题面”必须满足一个硬条件——它的解存在且唯一。很多从零开始写数独的人会踩的第一个坑,就是直接在空盘上随机撒十几个数字,然后拿去给玩家做。这种题面大概率不是多解就是无解,玩家做到一半发现有两种填法都能满足规则,整个项目的可信度就崩了。
业内做数独生成,普遍走“终盘挖洞”这条路:先构造一个完整且合法的终盘,然后按随机顺序“挖掉”格子,每挖一个就校验剩余盘面是否仍然只有唯一解,挖到目标数量就停止。这样得到的题面必然有解、天然唯一。挖得越多,玩家能参考的数字越少,难度越高。所以这个方案里有两个核心函数:一个负责求解(也负责数解),一个负责挖洞。下面先写求解器。
2.2 回溯求解器:一个函数兼顾“求解”和“数解验证”
回溯是数独最朴素的解法,适合课程设计和中小型代码包。核心思路是从左到右、从上到下找第一个空格子,依次尝试 1 到 9,放入后立刻检查行、列、宫是否冲突,不冲突就递归填下一个空格子,填满 81 格说明找到一组解。
// sudoku_solver.h class SudokuSolver { public: bool solve(int board[9][9]); // 求解:找到一组解就返回 true int countSolutions(int board[9][9]); // 数解:限制最大统计个数,用于唯一性校验 private: bool backtrack(int board[9][9], int pos); bool isValid(int board[9][9], int row, int col, int val); }; // sudoku_solver.cpp bool SudokuSolver::backtrack(int board[9][9], int pos) { if (pos == 81) return true; // 递归出口:所有格子填完即得到一组解 int row = pos / 9, col = pos % 9; // 把线性位置映射回二维坐标 if (board[row][col] != 0) // 题面已经给了数字的格子直接跳过 return backtrack(board, pos + 1); for (int val = 1; val <= 9; ++val) { // 候选值从 1 到 9 依次尝试 if (isValid(board, row, col, val)) { board[row][col] = val; // 临时填入 if (backtrack(board, pos + 1)) return true; board[row][col] = 0; // 恢复现场,这是回溯的关键 } } return false; }这段代码里有两个参数值得说明。第一个是pos的推进方向,按 0 到 81 线性推进的好处是顺序直观、不容易漏格;坏处是纯按顺序尝试并不是最优策略,碰到难盘会多绕一些路。第二个是val的枚举顺序,从 1 到 9 是自然顺序,如果希望求解放置速度更快,可以改成“先试当前空格的可选数最少的数字”,也就是 MRV 启发式,后面进阶章节会再提。
isValid的实现同样是常规套路:检查同一行、同一列是否有重复值,再检查所属 3×3 宫格是否有重复值。检查宫格时,row / 3 * 3和col / 3 * 3是定位宫格左上角的标准写法,很多刚上手的人在这里写成row % 3或者直接两层循环到 9,都会导致误判。
2.3 唯一性校验与挖洞生成:让难度变成一个可调参数
有了回溯求解器,下一步就是生成题面。这里有个容易忽略的细节:生成题面时不能用上面那个只找一组解的solve,因为它找到一组解就返回了,无法判断“是否只有一组解”。所以要让回溯器具备“数解”能力:继续递归直到统计够两个解就提前返回。
int SudokuSolver::countSolutions(int board[9][9]) { int count = 0; // 这里用 lambda 对同一个回溯器重入,限定最多数到 2 个解 std::function<bool(int)> dfs = [&](int pos) -> bool { if (count >= 2) return true; // 已经有了两个解,提前结束 if (pos == 81) { ++count; return count >= 2; } int row = pos / 9, col = pos % 9; if (board[row][col] != 0) return dfs(pos + 1); for (int val = 1; val <= 9; ++val) { if (isValid(board, row, col, val)) { board[row][col] = val; if (dfs(pos + 1)) return true; // 短路返回,不再继续 board[row][col] = 0; } } return false; }; dfs(0); return count; }唯一性校验里的“数到 2 就停”是一个实打实的优化点。对中等难题,完整数完所有解可能需要几万次递归,但找到第二个解往往比找第一个快很多,拉满上限会让生成题面的耗时成倍上升。挖洞逻辑写在下面。
void generatePuzzle(const int solution[9][9], int puzzle[9][9], int holes) { memcpy(puzzle, solution, sizeof(int) * 81); // 从完整终盘复制一份出来 std::vector<std::pair<int,int>> cells; for (int i = 0; i < 9; ++i) for (int j = 0; j < 9; ++j) cells.emplace_back(i, j); std::shuffle(cells.begin(), cells.end(), rng); // 随机打乱挖洞顺序 SudokuSolver solver; int dug = 0; for (auto [r, c] : cells) { if (dug == holes) break; int backup = puzzle[r][c]; puzzle[r][c] = 0; if (solver.countSolutions(puzzle) != 1) { // 挖掉后不再是唯一解,回填 puzzle[r][c] = backup; } else { ++dug; } } }这里的backup回填逻辑是“唯一解题面”的保底机制,千万不能省。我见过把挖洞写成“随机置零直到数量够”的做法,最后玩家反馈“这题有两个答案”,问题就出在生成阶段没有做唯一性判断。挖洞数量上,简单难度通常挖 35 到 40 洞,中等难度 45 到 50 洞,偏难 55 洞以上。但洞数只能当参考,不能当严谨指标,因为同样的洞数下,数字分布不同,实际难度差距非常大,后面第六章会讲怎么用回溯次数做真正可复现的分档。
3. Qt 界面接线:从 9×9 网格到信号槽的完整链路
3.1 界面骨架:用 QGridLayout 铺 81 个输入格
数独棋盘在界面上最直接的做法,是用QGridLayout放 9×9 个QLineEdit。这个方案代码量小、每个格子天然支持键盘输入和焦点切换,适合课程设计和工具类小软件。另一种方案是用QTableWidget,它能用setItem直接写数据,但处理输入合法性、焦点样式、以及每个格子的装饰时要写更多委托代码,对这个项目级别来说偏重。
// board_widget.cpp 里初始化棋盘网格 QGridLayout *grid = new QGridLayout(this); grid->setSpacing(0); // 去掉间距,让边框连续 cells_.resize(9); for (int row = 0; row < 9; ++row) { cells_[row].resize(9); for (int col = 0; col < 9; ++col) { QLineEdit *cell = new QLineEdit(this); cell->setFixedSize(42, 42); // 固定格子尺寸,避免窗口拉伸错位 cell->setAlignment(Qt::AlignCenter); // 数字居中 cell->setMaxLength(1); // 一个格子只允许一个字符 cell->setProperty("row", row); // 记下坐标,后续信号槽里用 cell->setProperty("col", col); grid->addWidget(cell, row, col); cells_[row][col] = cell; } }参数上,setFixedSize(42, 42)这个 42 是经验值,比 40 稍大一点,鼠标点选不容易误触相邻格。setMaxLength(1)必须设置,否则用户可以往一个格里输入多位数,后面的校验逻辑就全乱了。这里不建议让窗口尺寸变化时格子跟着放大缩小,因为内边距一参与,宫格边线的位置就会和格子中心线错位,这是后面避坑章节要展开的一个翻车点。
宫格的粗边框效果不需要额外画控件,用样式表按坐标计算即可。行和列都在 2、5 边界时,给格子加右侧和下侧的粗边框,其余位置细边框。
// 按坐标给每个格子套样式 int rightWidth = (col % 3 == 2) ? 2 : 1; // 每行第三格右边加粗 int bottomWidth = (row % 3 == 2) ? 2 : 1; QString style = QString( "QLineEdit { border: 1px solid #bbb;" "border-right-width: %1px; border-bottom-width: %2px; }") .arg(rightWidth).arg(bottomWidth); cell->setStyleSheet(style);这样处理之后,3×3 宫格的分隔线自动就出来了,不需要额外嵌套 QFrame 或者用背景色块定位,也不会有“底色块和实际格子错位”的问题。
3.2 把棋盘抽象成 BoardModel:界面不要直接操作二维数组
很多初版项目会把 9×9 数组直接放在MainWindow里,信号槽里到处传QLineEdit指针,最后界面逻辑和算法逻辑搅成一团。我一般会先写一个简单的BoardModel类,专门管理棋盘数据和输入合法性的判断。
// board_model.h class BoardModel { public: BoardModel(); bool setValue(int row, int col, int value); // 设置值,并做行列宫查重 int value(int row, int col) const; bool isComplete() const; // 是否已经全部填满 void loadFrom(const int data[9][9]); private: int data_[9][9]; }; bool BoardModel::setValue(int row, int col, int value) { if (value < 1 || value > 9) return false; for (int i = 0; i < 9; ++i) { if (data_[i][col] == value && i != row) return false; // 同列冲突 if (data_[row][i] == value && i != col) return false; // 同行冲突 } int br = row / 3 * 3, bc = col / 3 * 3; // 所在宫格左上角 for (int r = br; r < br + 3; ++r) for (int c = bc; c < bc + 3; ++c) if ((r != row || c != col) && data_[r][c] == value) return false; // 宫内冲突 data_[row][col] = value; return true; }这个类存在的意义不是炫技,而是让界面层只做一件事:把QLineEdit里的文本转成整数,丢给BoardModel,再由BoardModel决定这个数字能不能放进去。这样“提示”按钮调用求解器时,可以直接拿data_数组求解,不需要逐个遍历控件取值;存档时也是直接把这个二维数组写盘,不需要关心界面状态。
3.3 信号槽串起交互链:输入校验、提示按钮与回环控制
有了模型,界面交互就是标准的“控件 → 模型”链路。给每个格子接textChanged信号,在 lambda 里做转换和合法性判断。
connect(cell, &QLineEdit::textChanged, this, [this, row, col](const QString &text) { if (text.isEmpty()) { model_->setValue(row, col, 0); // 清空时恢复空格状态 return; } bool ok = false; int val = text.toInt(&ok); if (!ok || !model_->setValue(row, col, val)) { // 非法输入:直接清掉,并恢复成原来的值 QSignalBlocker blocker(cells_[row][col]); // 防止清空再次触发信号 cells_[row][col]->clear(); } });这里有两个实操细节。第一个,清空格子后textChanged还会再触发一次,如果不加QSignalBlocker,就会形成“清空 → 触发 → 再清空”的回环,虽然最终界面没问题,但调试时看信号日志会让人误以为逻辑有问题。第二个,不要用editingFinished替代textChanged,前者只有输入框失去焦点时才触发,玩家连续填格子时根本看不到即时的冲突反馈。
“提示”按钮的逻辑就更有意思了:它需要调用求解器拿到完整解,再往当前空格里填入一个正确数字。填进去的方式如果也是setText,又会触发上面那条textChanged链路,而且这个数字本身是合法的,不会走清空分支,所以还算好处理。但要注意,提示填入的数字不该算作“用户输入”,否则玩家可以一直按提示直到通关,难度分档和统计就失真了。常规做法是维护一个bool hintMode_;,提示填入时置为 true,textChanged里看到这个标记就直接跳过模型更新。
4. 编译与打包:MSVC 工具链、.pro 与 windeployqt 的取舍
4.1 先选工具链:MSVC2019 64 位与 MinGW 的取舍
这类 Qt 项目源码包在 Windows 上最常见的编译组合是“Qt 5.15.2 + MSVC2019 64 位”,因为 5.15 是 Qt 5 的最后一个 LTS 分支,网上大量现成源码都基于它的目录结构写的。另一个常见组合是 MinGW,它和 Qt Creator 捆绑发布,装完就能编译,不需要单独装 Visual Studio。
| 对比项 | MSVC2019 64 位 | MinGW 64 位 |
|---|---|---|
| 对应 Qt 安装包 | msvc2019_64 | mingw81_64 |
| 调试器 | 需要装 Windows SDK / Visual Studio | 自带的 GDB,开箱即用 |
| 对标准 C++ 的支持 | 完整,适合写算法 | 完整,但个别第三方库预编译包只给 MSVC 版 |
| 发布依赖 | 目标机器要装 VC++ Redistributable | 免装 VC++ 运行库,但要处理好 MinGW 运行库 |
| 适合场景 | 正式交付、接数据库/第三方 SDK | 课程设计、个人快速验证 |
我的习惯是:如果这个项目后面要接任何第三方 C++ 库,直接用 MSVC 工具链;如果只是把数独算法跑通、界面能交互,MinGW 省事得多。注意一点,不要混用:用 MSVC 编译的 .obj/.h 和 MinGW 编译的静态库之间二进制不兼容,这是新手最容易踩的无形坑。
4.2 .pro 与 CMake 两种工程写法的注意点
无论作者原包给的是 .pro 还是 CMakeLists.txt,你拿到手第一件事都应该是把依赖路径改成自己机器的实际路径,然后重建工程。经典的 .pro 写法如下:
QT += core gui widgets # widgets 模块必须显式声明 CONFIG += c++17 # 用 C++17,方便 std::shuffle 这类特性 TARGET = sudoku TEMPLATE = app SOURCES += main.cpp MainWindow.cpp BoardModel.cpp SudokuSolver.cpp HEADERS += MainWindow.h BoardModel.h SudokuSolver.h参数说明:QT += widgets在 Qt 5 里必须写,否则QLineEdit、QGridLayout这些类编译直接报“找不到头文件”;CONFIG += c++17是为了用std::function和结构化绑定auto [r, c],如果你的编译器版本老,可以把这两处改回普通循环和函数指针。
CMake 版本对应的写法更规整,也方便以后接别的库:
cmake_minimum_required(VERSION 3.16) project(sudoku LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) # Qt 信号槽必须开 AUTOMOC find_package(Qt5 COMPONENTS Widgets REQUIRED) add_executable(sudoku main.cpp MainWindow.cpp BoardModel.cpp SudokuSolver.cpp) target_link_libraries(sudoku Qt5::Widgets)CMake 里最关键的是CMAKE_AUTOMOC ON,没有这一行,Q_OBJECT 宏所在的头文件不会生成 moc 文件,链接时会报一堆“未定义 vtable”错误。这类错误乍看像代码问题,实际是构建系统配置问题,属于编译期最坑的黑匣子之一。
4.3 release 发布:windeployqt 与运行时依赖
程序在 Qt Creator 里能跑,不代表 exe 拷到别的机器能跑。Qt 是动态库分发模型,release 构建完必须把依赖的 DLL 和插件目录一起拷走。Qt 官方工具windeployqt就是干这个的。
# 先构建 release 版本,然后在 Qt 命令行环境里执行: windeployqt --release --compiler-runtime .\release\sudoku.exe参数说明:--compiler-runtime会把 MSVC 运行库也拷到目标目录,你就不用让用户手工装 VC++ Redistributable;--release指定从 release 目录采集依赖,如果你 debug 和 release 的输出在同一个目录,这个参数可以避免拷进一堆带 d 后缀的调试 DLL。执行完后,sudoku.exe旁边会多出Qt5Core.dll、Qt5Gui.dll、Qt5Widgets.dll和platforms/qwindows.dll,这些一个都不能少。平台插件目录platforms经常被漏,漏掉后程序双击没有任何反应,任务管理器里也看不到进程,这是 Qt 发布最典型的翻车现场。
如果是 MinGW 构建,windeployqt 同样适用,但--compiler-runtime参数不要加,改成把libgcc_s_seh-1.dll、libstdc++-6.dll、libwinpthread-1.dll三个运行库一并拷走,否则目标机器上缺一个就会启动失败。
5. 避坑排查:数独项目里最常踩的 5 个坑
5.1 解压层级过深导致 Qt Creator 报 dependent 路径错误
现象:打开原 zip 里的 .pro 文件后,Qt Creator 直接报错,错误信息长得像:-1: error: dependent '..\..\..\..\..\..\Qt\5.15.2\msvc2019_64\include\QtWidgets' ...这种一长串..的相对路径,有时候还伴随找不到qtwidget头文件的提示。
原因:作者把自己的 Qt 安装在D:\Qt\5.15.2\msvc2019_64这类绝对路径下,写 .pro 时用了相对路径访问 Qt 头文件,而 zip 解压的层级一旦比原目录深,相对路径就指向了错误位置。
解决:把整个包解压到纯英文短路径,比如D:\sudoku\,不要放在带中文和空格的目录下。然后在 Qt Creator 里找到“构建设置”,清空构建目录,重新执行 qmake。如果还报错,检查 .pro 里是否有硬编码的INCLUDEPATH += ../../../../Qt/...,直接把这一行改成你自己机器的 Qt 安装路径,一劳永逸。
5.2 点击格子的高亮和实际棋盘坐标错位
现象:鼠标点在第 3 行的格子上,程序里拿到的 row 却是第 2 行,或者宫格粗边框背景块出现在错误位置。
原因:多数是 QGridLayout 里混用了setRowStretch、setColumnStretch或者嵌套布局,导致控件实际排列位置和addWidget传入的行列参数不一致。另一种常见原因是拿宫格底色块(QLabel 背景色)去对格子,背景块稍微偏移一两个像素,整片底色就盖到别的宫格上。
解决:棋盘区不要做弹性拉伸,直接用addWidget(cell, row, col)并按固定尺寸放格子,宫格分隔线用样式表算,不要让背景色块参与布局。如果项目已经用了 QTableWidget,改起来更简单:确认setItem(row, col, ...)里的行列和cellClicked(row, col)的行列一致,不要在中间把row和col调换。
5.3 MSVC 下中文界面乱码
现象:按钮文字、窗口标题在 Windows 上显示成乱码,编译输出里偶尔夹杂 C4819 编码警告。
原因:源码文件保存为 UTF-8,但 MSVC 编译器默认按本机代码页(GBK)读取字符串字面量,导致中文字符被解码成错误字节。MinGW 系编译器一般没这个问题,所以网上教程说法不一,很容易困惑。
解决:最简单可靠的办法是在 .pro 里加上全局编译选项QMAKE_CXXFLAGS += /utf-8,让 MSVC 明确按 UTF-8 读取源码。如果项目以后要跨平台,字符串尽量写成QStringLiteral("开始游戏")而不是裸的"开始游戏",能避免编码在不同编译器下的差异。
5.4 点“求解”按钮界面卡死无响应
现象:点击“求解”或“填入提示数字”后,整个窗口变成“未响应”状态,过几秒甚至几十秒才恢复。
原因:求解器跑在 UI 线程里,回溯对复杂题面可能要递归几十万次,期间事件循环被阻塞,键盘和鼠标事件全部排队处理,界面看起来就是卡死。
解决:第一步,给回溯求解器加剪枝,优先尝试候选数字最少的格子(MRV 启发式),这个优化通常能把求解时间从几秒降到几十毫秒。第二步,数唯一解时限制统计上限为 2,找到第二个解立刻返回,避免无意义地数完所有解。如果题目确实极难,再把求解丢到QtConcurrent::run里,UI 线程只负责接收结果信号。对一个数独游戏来说,先做第一步基本就够用,不要一上来就上线程。
5.5 每次启动生成的题面都一样
现象:重开游戏后拿到的题面跟上次完全相同,或者生成 50 关后开始出现重复。
原因:随机数生成器没有正常播种,用了std::rand()或常量种子,导致每次 shuffle 和挖洞顺序完全一致。
解决:尽量用std::mt19937搭配随机种子,Qt 项目里直接写std::mt19937 rng(QRandomGenerator::global()->generate()),一行代码解决。不要用time(nullptr)做种子,在秒级时间戳相同的情况下,短时间连续开局会重复题面,这是随机性不够的经典表现。
6. 进阶:用回溯次数给难度分档,把计时与存档一并接上
6.1 难度分档:别再按洞数拍脑袋
洞数当难度最大的问题是不同题面在同样洞数下难度差异巨大。我后来养成的习惯是记录挖洞过程中求解器实际回溯的次数,按回溯次数分档更接近玩家的真实感受。
// 挖洞时累计回溯计数,而不是只看 holes long long totalBacktracks = 0; // 每次 countSolutions 后累加内部递归次数 // 简单:洞数 38 ~ 42,且 totalBacktracks < 500 // 中等:洞数 45 ~ 49,且 totalBacktracks < 3000 // 困难:洞数 52 以上,且 totalBacktracks >= 3000实现上,只需在回溯函数入口处++counter;,求解完成返回时把counter加到统计变量里。这一步改动很小,但可以让“简单—中等—困难”三个档位变成一个可复现的量化指标,而不是靠感觉调参。
6.2 存档与计时:一套结构体搞定
计时用QTimer每秒触发一次,记录累计秒数;暂停时stop(),恢复时start()。存档时把棋盘数据、难度档位、剩余时间合并成一个结构体,用QDataStream读写。
struct GameSave { int board[9][9]; int elapsedSec; int difficulty; // 0 简单 1 中等 2 困难 int hintMode; // 是否处于提示状态 }; QDataStream stream(&file); stream.writeRawData(reinterpret_cast<const char*>(&save), sizeof(save));读档时按相同结构回读,重新 setValue 把board刷进界面。这里值得注意:不要用QDataStream <<直接流一个裸结构体,因为结构体的内存对齐在不同编译器下可能不同,换一个 Qt 版本或者换到 MinGW 就可能读错字节。单机小项目用writeRawData存固定结构体最直观,等以后要跨平台了再改成 Qt 序列化格式。
以前我在自己写的数独项目里偷懒,只存棋盘不存计时状态,结果玩家退出重开之后发现计时从零开始了,血泪教训。后来把存档做成“棋盘 + 计时 + 难度 + 提示状态”四合一,才真正把存档这件事做干净。这个改造对任何拿到这类 zip 源码后再开发的人来说,都是最值得先做的一个功能点,希望帮到你。
本文还有配套的精品资源,点击获取