ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Qt C++数独游戏源码拆解:从回溯算法到界面装配

Qt C++数独游戏源码拆解:从回溯算法到界面装配 简介基于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::functionbool(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::vectorstd::pairint,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_;提示填入时置为 truetextChanged里看到这个标记就直接跳过模型更新。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_64mingw81_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 c17 # 用 C17方便 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 c17是为了用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_castconst char*(save), sizeof(save));读档时按相同结构回读重新 setValue 把board刷进界面。这里值得注意不要用QDataStream 直接流一个裸结构体因为结构体的内存对齐在不同编译器下可能不同换一个 Qt 版本或者换到 MinGW 就可能读错字节。单机小项目用writeRawData存固定结构体最直观等以后要跨平台了再改成 Qt 序列化格式。以前我在自己写的数独项目里偷懒只存棋盘不存计时状态结果玩家退出重开之后发现计时从零开始了血泪教训。后来把存档做成“棋盘 计时 难度 提示状态”四合一才真正把存档这件事做干净。这个改造对任何拿到这类 zip 源码后再开发的人来说都是最值得先做的一个功能点希望帮到你。本文还有配套的精品资源点击获取
返回列表