简介一份基于Qt框架与C实现的数独游戏完整工程适合在校学生、教师及企业开发者作为课程设计、毕业设计或Qt入门进阶的练习项目。压缩包共59个文件包含5个C源文件、5个头文件、2个UI界面文件、Qt项目配置与资源文件以及可直接运行的exe和20个运行依赖DLL另附翻译文件与说明文档整体约18.99MB。代码经过测试运行通过具备界面交互、数独生成与判断等基础功能并向读者展示Qt信号槽、自定义委托、自定义对话框等典型用法。目前已有193人浏览学习下载后可通过工程文件二次修改扩展难度、计时或提示功能也可作为项目初期演示参考。README与图片素材便于快速了解结构适合想要通过实际项目掌握Qt桌面开发的学习者。1. 拿到这个压缩包时你在解决什么问题一个标题叫“基于Qt的C数独游戏.zip”的项目拆开看就三件事用C写算法、用Qt做界面、把成果打包分发。这三件事恰好卡在每个入门 Qt 开发的人都会撞上的位置——你已经有了一点 C 语法基础知道类、指针、容器但面对“做一个有界面的程序”依然手足无措。控制台里跑通算法不算产品贴上界面才算。这个压缩包的意义就是给你一条从裸算法到可双击运行的完整路径。这份笔记适合两类人。一类是拿它做课程设计的学生你需要的不是炫技而是能答辩、能演示、代码结构说得清楚另一类是工作后想补 Qt 桌面开发短板的人你要的是搞清楚 QWidget 体系、信号槽、事件循环这些概念到底怎么用在正经项目里。别指望这个体量的项目能教你高并发或底层渲染它的价值在于数独规则足够简单算法有标准答案界面元素足够丰富让你能把 Qt 最核心的机制——信号槽、布局管理器、绘图事件、对话框——全部实战一遍。一句话总结这个项目的本质它是个“麻雀虽小五脏俱全”的 Qt 入门落地点。算法层你可以选回溯或舞蹈链界面层你可以选 QWidget 或 QML发布层你可以选静态编译或 windeployqt。每个选择都有代价而这篇笔记就是帮你把每个选项的代价提前标好价格。2. 开局选型先定 Qt 版本、编译器与界面方案2.1 Qt 版本怎么选别追新看你的编译器和目标机器我的建议非常直接Qt 5.15.2 LTS 是当前做桌面小工具最稳的选择尤其是课程设计和内部工具。为什么不是 Qt 6Qt 6 砍掉了大量 QWidget 组件里的过时接口如果你找的示例代码是五年前写的编译过去的概率会明显低于 Qt 5.15。而且 Qt 5.15.2 是 Qt 5 系列的最后一个 LTS 版本你不需要安装在线安装器直接找国内镜像下载离线包就行装完断网也能用。另一个关键选择是编译器套件。Windows 上你会有 MinGW 和 MSVC 两套选择最常翻车的点就在这里。如果你装的是 Qt 5.15.2 的 MinGW 版本那你的编译器必须匹配 Qt 打包目录里自带的那个 MinGW 版本用 Dev-C 或 CodeBlocks 自带的 MinGW 去编译 Qt 项目大概率报一堆莫名奇妙的头文件错误。最简单的办法打开 Qt 安装目录下的Tools文件夹看里面带的 MinGW 是哪个版本然后在 Qt Creator 里手动指定同名编译器。吃透这一点你能避开后续 80% 的链接错误。2.2 界面方案QWidget 手工布局 vs Qt Designer数独游戏这个体量我不建议你一开始就扑向 Qt Designer 拖控件。原因不是 Designer 不好而是数独棋盘是固定 9×9 的格子你有两种实现思路用 QGridLayout 放 81 个 QLineEdit或者用一个 QWidget 重写 paintEvent 整体绘制。前者适合用 Designer 拖出来但每个格子都要处理输入约束、焦点跳转、高亮状态代码会膨胀得很快后者绘制灵活、代码集中但对绘图 API 的熟练度有一定要求。我的一般做法是折中整体框架用 Designer 画——放按钮、标签、下拉框棋盘区域用一个普通的 QWidget 占位然后子类化它重写paintEvent和mousePressEvent。这样既保留了 Designer 在复杂布局上的效率又把最需要动态变化的部分握在自己手里。记住一个原则静态界面交给 Designer动态绘制交给代码。2.3 项目组织剥开 zip 之后你该看到什么结构假设你解压后看到的是一个完整的 Qt 工程合理的文件结构应该长这样sudoku_game/ ├── CMakeLists.txt # 或 .pro 文件 ├── main.cpp # 程序入口 ├── mainwindow.h/.cpp # 主窗口 ├── boardwidget.h/.cpp # 棋盘绘制组件 ├── sudoku.h/.cpp # 生成、求解、校验核心逻辑 └── resources.qrc # 图标和音频资源我强烈建议你把界面和算法彻底分离。sudoku.h/.cpp里不出现任何QWidget相关代码它只负责处理整型二维数组——生成题目、求解答案、检查冲突。boardwidget负责把二维数组画到屏幕上接收键盘和鼠标输入把用户填入的数字回写到数组。mainwindow负责把这两者粘起来处理按钮点击、难度选择、计时更新。这样做的好处是你的算法可以直接用命令行程序单独测试根本不需要启动 Qt 界面。遇到逻辑 bug先在控制台里跑一遍别让界面问题干扰你排查。3. 核心算法生成、求解与校验的完整实现3.1 求解器怎么写回溯法是最稳妥的起点数独求解是典型的回溯法应用场景——逐格尝试 1~9遇到冲突就回退。别一上来就研究舞蹈链Dancing Links那是竞赛级别的优化在 9×9 的数独上性能优势根本体现不出来。回溯法配合几个剪枝技巧单题的求解时间在毫秒级用户完全感知不到。// sudoku.h #ifndef SUDOKU_H #define SUDOKU_H #include vector #include array class Sudoku { public: using Board std::arraystd::arrayint, 9, 9; bool solve(Board board); // 求解成功返回 true bool generate(Board board, int clues); // 生成有 clues 个提示数的题目 bool isValid(const Board board) const; // 校验当前盘面是否合法 private: bool isSafe(const Board board, int row, int col, int num) const; }; #endif// sudoku.cpp #include sudoku.h #include random #include algorithm bool Sudoku::isSafe(const Board board, int row, int col, int num) const { // 检查行和列 for (int i 0; i 9; i) { if (board[row][i] num || board[i][col] num) return false; } // 检查 3x3 宫 int boxRow (row / 3) * 3; int boxCol (col / 3) * 3; for (int i 0; i 3; i) { for (int j 0; j 3; j) { if (board[boxRow i][boxCol j] num) return false; } } return true; } bool Sudoku::solve(Board board) { for (int row 0; row 9; row) { for (int col 0; col 9; col) { if (board[row][col] 0) { // 随机打乱尝试顺序避免每次生成的解都相同 std::vectorint nums {1,2,3,4,5,6,7,8,9}; std::shuffle(nums.begin(), nums.end(), std::mt19937{std::random_device{}()}); for (int num : nums) { if (isSafe(board, row, col, num)) { board[row][col] num; if (solve(board)) return true; board[row][col] 0; // 回溯撤销选择 } } return false; // 所有数字都试过无解 } } } return true; // 所有格子填满求解完成 }这段代码的逻辑核心是递归中的“尝试-撤销”模式每次找到一个空格生成打乱顺序的 1~9 候选数字逐个尝试。shuffle不是必须的但加了之后能让同一种盘面每次求解的落子顺序不同调试时更容易暴露隐性逻辑错误。isSafe的三个检查——行、列、宫——必须全部通过才尝试填入这是回溯法的剪枝基础。需要注意的一个实现细节是递归深度最大只有 81 层根本不需要担心栈溢出。但如果你把它改造成“求解所有解”的计数器递归终止条件要注意找到解后应该返回 false 让上层继续尝试其他数字而不是直接返回 true 停止整个搜索。这两者的差别只在一个返回值上但语义完全不同。3.2 题目生成先填满对角线再挖洞生成数独题目公认最稳妥的思路是三步走第一步从一个空盘出发用求解器生成一个完整终盘第二步根据难度决定挖掉多少个数字第三步每挖一个数字就用求解器验证剩余盘面是否唯一解。这个过程看起来繁琐但正确率最高。bool Sudoku::generate(Board board, int clues) { // 1. 清空棋盘并生成完整终盘 for (auto row : board) row.fill(0); solve(board); // 此时 board 是一个完整终盘 // 2. 准备所有格子的位置索引随机打乱 std::vectorint cells(81); std::iota(cells.begin(), cells.end(), 0); std::shuffle(cells.begin(), cells.end(), std::mt19937{std::random_device{}()}); // 3. 逐个挖洞目标是保留 clues 个提示数 int remaining 81; for (int idx : cells) { if (remaining clues) break; int row idx / 9; int col idx % 9; int backup board[row][col]; board[row][col] 0; // 4. 验证唯一解尝试找两个解找到第二个就说明不唯一 Board copy board; int solutionCount 0; countSolutions(copy, solutionCount, 2); // 最多数到 2 就停 if (solutionCount ! 1) { board[row][col] backup; // 挖掉后不唯一回滚 } else { --remaining; } } return remaining clues; } // 辅助函数只数到 limit 个解就提前终止 void countSolutions(Sudoku::Board board, int count, int limit) { if (count limit) return; for (int row 0; row 9; row) { for (int col 0; col 9; col) { if (board[row][col] 0) { for (int num 1; num 9; num) { if (isSafe(board, row, col, num)) { board[row][col] num; countSolutions(board, count, limit); if (count limit) return; board[row][col] 0; } } return; } } } // 所有格子填满找到一个解 count; }这段生成代码里有一个关键参数limit 2的作用是性能优化。当你只需要判断“是否唯一解”时发现第二个解就可以立刻停止搜索不必把整个解空间遍历完。对于挖洞过程来说这个优化能把生成时间从几秒压缩到几十毫秒。clues参数的设定就是难度档位简单 40~45 个提示数中等 32~36 个困难 26~30 个。少于此范围比如低于 22 个提示数唯一解判断会变得非常耗时生成一题可能要等好几秒用户会以为程序卡死了。课程设计做到 30 个左右就足够支撑“三个难度”的演示需求。3.3 用户输入校验禁止冲突数字即时反馈校验不只是最后提交时做一次更友好的体验是用户每填入一个数字就立刻检查它是否与同行、同列、同宫的其他数字冲突。这个功能放在boardwidget里每次数据变更后扫描相关位置冲突的格子用红色高亮。具体实现不复杂重写单元格绘制函数时对处于冲突状态的格子调用painter.fillRect填充半透明红色背景再调用update()触发重绘。4. 界面构建棋盘绘制、键盘输入与信号槽联动4.1 重写 paintEvent把 9×9 数组变成可视化棋盘棋盘组件是整个程序里最“Qt”的部分因为它涉及绘图事件、鼠标事件和焦点机制。核心思路一个自定义 QWidget在paintEvent里根据当前的二维数组状态逐格绘制数字和边框粗线区分宫边界细线区分单元格。// boardwidget.h class BoardWidget : public QWidget { Q_OBJECT public: explicit BoardWidget(QWidget* parent nullptr); void setBoard(const Sudoku::Board board); void setReadOnly(bool readOnly); // 游戏结束后锁定棋盘 signals: void cellEntered(int row, int col, int value); // 用户输入了一个数字 protected: void paintEvent(QPaintEvent* event) override; void mousePressEvent(QMouseEvent* event) override; void keyPressEvent(QKeyEvent* event) override; private: Sudoku::Board m_board; bool m_readOnly false; int m_selectedRow -1; int m_selectedCol -1; };// boardwidget.cpp - paintEvent 关键绘制段 void BoardWidget::paintEvent(QPaintEvent* event) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, false); int side qMin(width(), height()); int cellSize side / 9; int offsetX (width() - cellSize * 9) / 2; int offsetY (height() - cellSize * 9) / 2; // 背景 painter.fillRect(rect(), Qt::white); // 绘制每个格子的数字 QFont font painter.font(); font.setPixelSize(cellSize * 0.55); font.setBold(true); painter.setFont(font); for (int row 0; row 9; row) { for (int col 0; col 9; col) { QRect cellRect(offsetX col * cellSize, offsetY row * cellSize, cellSize, cellSize); // 高亮选中的格子 if (row m_selectedRow col m_selectedCol) { painter.fillRect(cellRect, QColor(200, 220, 255)); } if (m_board[row][col] ! 0) { // 题目给定的数字用黑色用户填入的用蓝色 bool isGiven m_givenMask[row][col]; painter.setPen(isGiven ? Qt::black : QColor(0, 80, 200)); painter.drawText(cellRect, Qt::AlignCenter, QString::number(m_board[row][col])); } } } // 绘制网格线普通格子细线宫边界粗线 QPen thinPen(QColor(180, 180, 180), 1); QPen thickPen(QColor(80, 80, 80), 2); for (int i 0; i 9; i) { bool isThick (i % 3 0); painter.setPen(isThick ? thickPen : thinPen); int pos offsetX i * cellSize; painter.drawLine(pos, offsetY, pos, offsetY cellSize * 9); pos offsetY i * cellSize; painter.drawLine(offsetX, pos, offsetX cellSize * 9, pos); } }paintEvent的重点在于用qMin(width(), height())保证棋盘始终是正方形并且居中绘制这比写死尺寸更适应窗口缩放。isGiven这个掩码必须维护否则题目给的数字和用户填的数字在重绘时无法区分。m_selectedRow和m_selectedCol记录鼠标选中的格子位置点击时切换高亮。一个常见的翻车点是组件尺寸变化时paintEvent里用到了旧的cellSize计算位置结果整个棋盘画偏了。解决办法是在resizeEvent里重新计算并调用update()或者在paintEvent里每次现算版心偏移量。上面代码每次绘制都重新计算offsetX/offsetY天然规避了这个问题。4.2 键盘直接填数用事件过滤器拦截按键比弹窗快十倍最直观的交互方式是点击格子后弹出一个QInputDialog让用户输入数字。这个方案可用但不优雅每填一个数要点击三下玩几局就烦了。更好的方案是重写keyPressEvent点击选中格子后直接按键盘数字键输入。void BoardWidget::keyPressEvent(QKeyEvent* event) { if (m_readOnly || m_selectedRow 0 || m_selectedCol 0) return; int key event-key(); if (key Qt::Key_1 key Qt::Key_9) { int value key - Qt::Key_0; // 计算数字值 if (m_givenMask[m_selectedRow][m_selectedCol]) return; // 题目格不可改 m_board[m_selectedRow][m_selectedCol] value; emit cellEntered(m_selectedRow, m_selectedCol, value); update(); // 触发重绘 } else if (key Qt::Key_Delete || key Qt::Key_Backspace) { if (!m_givenMask[m_selectedRow][m_selectedCol]) { m_board[m_selectedRow][m_selectedCol] 0; emit cellEntered(m_selectedRow, m_selectedCol, 0); update(); } } // 方向键移动选中格 else if (key Qt::Key_Up m_selectedRow 0) { m_selectedRow--; update(); } else if (key Qt::Key_Down m_selectedRow 8) { m_selectedRow; update(); } // 左右方向键同理不再重复 }这套键盘交互一旦用顺手效率远超鼠标点数字。关键细节方向键移动选中格后不需要校验m_givenMask因为那是题目格的锁定状态与光标位置无关。emit cellEntered后主窗口可以监听这个信号来做实时冲突检测或者计时器暂停等联动。4.3 信号槽连接界面与逻辑的粘合剂现在把BoardWidget和主窗口MainWindow连接起来。主窗口上有两个核心操作新建游戏和提交答案。新建时要调用算法层生成题目然后调用boardWidget-setBoard()把数据灌进去提交时要反过来从界面取回二维数组交给算法层校验。// MainWindow 构造函数里的关键连接 connect(newGameButton, QPushButton::clicked, this, [this]() { int clues difficultyCombo-currentIndex() 0 ? 42 : difficultyCombo-currentIndex() 1 ? 32 : 26; Sudoku::Board board; m_sudoku.generate(board, clues); // 生成带提示数的盘面 boardWidget-setBoard(board); // 传给棋盘组件 statusLabel-setText(QString(剩余提示数: %1).arg(clues)); }); connect(checkButton, QPushButton::clicked, this, [this]() { if (m_sudoku.isValid(boardWidget-currentBoard())) { QMessageBox::information(this, 提示, 答案正确恭喜通关); } else { QMessageBox::warning(this, 提示, 存在冲突或未完成请检查红色格子); } });lambda 表达式是连接界面操作的简洁方式但要注意捕获列表里的成员变量不需要显式捕获this指针已经在作用域内。这里每个按钮的处理逻辑很薄不需要单独抽成槽函数逻辑超过十行再考虑抽函数。信号槽的调试技巧在槽函数里加qDebug()打印关键变量的值然后运行时观察终端输出。遇到“点击按钮没反应”的问题八成的可能性是 connect 里信号名或参数类型写错了qDebug能立刻定位。5. 编译与运行的避坑清单三条真实翻车记录5.1fatal: cannot mix incompatible Qt library (version ex50601) with this library现象编译好的 exe 双击运行或从终端启动时直接闪退或报这行 fatal 错误。原因很明确程序编译链接的 Qt 版本与运行时加载的 Qt DLL 版本不一致。比如你下载的 Qt 安装包是 5.15.2但系统 PATH 环境变量里残留了旧版 Qt 的 bin 目录程序运行时优先找到了旧 DLL。也可能是你直接把编译产物复制到别的机器上目标机器只装了 Qt 6没有 Qt 5 的运行库。解决打开 Qt 安装目录下的bin文件夹确认Qt5Core.dll的版本前缀和你的编译器是对应的。把 Qt 的 bin 目录从 PATH 环境变量的低优先级位置挪到合适位置或者干脆用 Qt 自带的windeployqt工具把所有依赖 DLL 打包到 exe 同目录下。windeployqt的命令很简单windeployqt --release --no-translations your_app.exe这个命令会自动扫描 exe 依赖的 Qt 模块把对应的 DLL 拷贝到当前目录。这是我的习惯无论程序多小发布时一定先用 windeployqt 铺一遍依赖再拿一个装了 360 和各类全家桶的“脏”测试机跑一遍。干净开发机上跑通不叫发布成功。5.2qt.qpa.plugin: Could not find the Qt platform plugin linuxfb现象在 Linux 开发板上运行程序启动即报错终端输出找不到linuxfb平台插件。这个报错的直接原因只有一张嘴Qt 的运行时缺少platforms插件目录。在 Windows 上这个问题相对少见但在嵌入式 Linux 环境极其常见——交叉编译时没有把$QTDIR/plugins/platforms完整拷贝到目标机。解决用ldd检查程序依赖的 Qt 库是否齐全然后确认 plugins 目录结构# 目标机上的 Qt 插件目录结构应该类似 ./plugins/ ├── platforms/ │ ├── libqlinuxfb.so │ ├── libqminimal.so │ └── libqxcb.so └── imageformats/如果只在 X11 环境下开发没装linuxfb可以用-platform xcb参数强制指定平台插件。但如果是纯 framebuffer 环境必须编译安装对应插件。回忆一下你自己的开发环境开发机上跑 xcb目标开发板上跑 linuxfb这是嵌入式 Qt 开发的基本盘。建议在项目初期就确定目标平台并提前测试 hw 环境的 Qt 运行库是否能完整打包而不是等程序写完再补。5.3MSVC2013编译报 LNK2019/LNK2001 链接错误现象打开别人给的 .pro 工程用的是 MSVC2013 编译器编译时报一堆LNK2019: unresolved external symbol而且报错位置集中在 Qt 类库调用上。原因你用 MSVC 编译器编译生成的 obj 文件和 Qt 官方发布的不同编译器版本编译的 lib 文件格式不匹配。Qt 官方通常提供 MSVC2015/MSVC2017/MSVC2019 的预编译版本但没有 MSVC2013 的。你打开工程的电脑装了 MSVC2013 工具链qmake 找不到对应的 Qt 库就报链接错误。解决换编译器或者换 Qt 版本。你的电脑装的是 MSVC2013需要下载一个 MinGW 版本的 Qt 5.15.2然后在 Qt Creator 里把构建套件切到 MinGW。如果必须用 MSVC2013你需要自己去编译 Qt 源码周期很长而且坑极多——不推荐。最佳路径是接受“编译器版本必须和 Qt 库版本配套”这个事实别在编译器选择上恋战。Qt Creator 里同时装多套套件太常见了MinGW 做开发调试MSVC 做最终发布这是行业通用做法。5.4 棋盘布局被挤变形一个 5 秒浪费你两小时的经典问题现象窗口拉大后棋盘格子变成矩形不再保持正方形或者右侧按钮栏撑开整个窗口宽度。原因没有正确设置QGridLayout的拉伸因子和QWidget的最小尺寸。解决在布局器里棋盘所在列设置拉伸因子同时限制棋盘组件的最大尺寸gridLayout-setColumnStretch(0, 1); // 棋盘所在列 gridLayout-setColumnStretch(1, 0); // 按钮列不拉伸 boardWidget-setMinimumSize(450, 450); boardWidget-setMaximumSize(600, 600);setColumnStretch(0, 1)让棋盘列占据所有额外空间按钮列收缩到内容宽度setMaximumSize防止窗口过大时棋盘也跟着无限拉伸。这个组合几乎可以应对所有小工具类项目的布局器拉伸问题。6. 写出正确率 100% 的“验证神器”双 Buffer 自检工具最后给你一个我每次做数独项目都会写的工具函数它不算功能需求但能在开发期帮节省大量调试时间——一个不依赖界面的双向校验器。它的核心逻辑是用回溯求解器生成一个终盘然后反推挖洞后的盘面是否唯一解把这两个结果交叉对比确保生成器的正确率恒定为 100%。这个工具不需要界面直接用命令行跑写成独立的verify_sudoku.cpp和 Qt 工程分开编译。// verify_sudoku.cpp - 纯 C 命令行验证工具不依赖 Qt // 编译方式: g verify_sudoku.cpp sudoku.cpp -o verify #include sudoku.h #include iostream #include chrono int main() { Sudoku s; Sudoku::Board board; // 用三个不同难度做压力测试 int difficulties[] {42, 32, 26}; int total 0, failed 0; for (int clues : difficulties) { for (int i 0; i 100; i) { s.generate(board, clues); // 正向校验求解当前盘面 Sudoku::Board solution board; bool solvable s.solve(solution); // 反向校验再挖一个格子看是否还能保持唯一解 // 如果能挖就说明生成算法保守了 Sudoku::Board copy board; int count 2; countSolutions(copy, count, 2); bool unique (count 1); if (!solvable || !unique) { failed; std::cerr FAIL: clues clues solvable solvable unique unique \n; } total; } std::cout 难度 clues : 测试 100 题完成\n; } std::cout 总计: total 题, 失败: failed \n; return failed 0 ? 0 : 1; }这个工具的妙处在于它把生成器、求解器、唯一性判断三个模块放在同一个测试环境里互相验证生成器生成的题目必然能被求解器解出这是第一层挖洞后必然保持唯一解这是第二层。如果两层都通过基本可以肯定生成器是可靠的。我在开发期跑一遍这个工具比在界面上手工点 100 局“新建游戏”效率高一个数量级。除了验证算法这个工具还承担一个重要的性能测试职责打印每道题的生成耗时。如果发现某一题的生成时间超过 200 毫秒就需要检查是不是clues设置太低导致唯一解判定过于耗时。这比用户在界面上感受卡顿再反馈给你要高效得多。回想我做第一个 Qt 数独项目时最大的教训不是算法不会写而是过早打开界面调试把大量时间浪费在 UI 观感和布局微调上真正该优先验证的算法逻辑反而滞后。现在我的习惯变了所有核心逻辑先用命令行工具验证完毕再动界面。界面只是算法的皮肤算法可靠才是游戏的灵魂。希望这个习惯也能帮到你少走这一段弯路。本文还有配套的精品资源点击获取