C++数独GUI实战:生成算法、唯一解校验与Qt界面开发
简介这是一份基于C实现的数独游戏GUI源码包适合正在学习C编程、算法设计与图形界面开发的初学者或进阶者。压缩包内共9个文件主要包括1个cpp源程序、1个dsp工程文件、1个dsw工作区文件以及调试生成的pch、pdb、ncb等辅助文件整体大小约24KB属于轻量级可直接编译学习的小型项目。已有360人浏览学习说明具备一定的参考价值。源码完整覆盖了数独游戏的核心逻辑采用9x9二维数组作为盘面数据结构结合回溯算法自动求解或生成题目并通过MFC等GUI框架实现用户交互界面代码中还涉及事件驱动处理、输入合法性检查、局部约束剪枝优化以及基于fstream的进度存取等功能。通过阅读这份代码可以直观理解数独规则在程序中的表达方式掌握回溯搜索与约束传播的实际应用同时学习如何在C工程中组织界面与业务逻辑。对于希望快速上手C游戏编程或算法实践的开发者而言是一份结构简洁、便于剖析的入门实例。1. 拿到 shudu.rar 先别急着编译C 数独 GUI 项目的真正难点在生成与校验多数人拿到 shudu.rar 这个压缩包第一反应是解压、找 exe、点开玩两局能玩就算结束。但如果你是以 C 数独游戏 GUI 编程为目标的从业者或学生把这个压缩包当成练手项目来拆就会发现真正的仗从来不在画格子——数独盘面的生成、唯一解的保证、玩家输入后的冲突提醒这三块才是数独编程里最容易翻车、也最值得认真抄作业的地方。这个压缩包在初学者手里流通很广里面的代码质量参差不齐与其抱怨不如沿着功能把生成算法和界面拆开重写一遍。下面按我自己做这类项目的惯例把工程结构、算法实现、Qt 界面和踩坑点一条龙说清楚适合刚学完 C 基础、想从控制台走向图形界面的读者也适合做课程设计或期末项目的人。2. 把工程拆开看数独项目的模块边界与数据结构选型2.1 解压后先找 .h 文件模块划分决定项目能不能改拿到压缩包先不要急着按 F5 编译。解压后我一般先看文件列表确定它是 Qt 工程还是 Win32 工程。这类老包里最典型的文件布局是main.cpp、MainWindow 相关的 .h/.cpp、一个 SudokuGenerator 类、一个 SudokuSolver 类再加上 CMakeLists.txt 或者 .pro 工程文件。如果只有一个巨大的 main.cpp所有逻辑全塞在窗口回调里那说明作者的模块切得很糙重写是比修补更明智的选择。文件常见职责你最该关注的点main.cpp程序入口、创建主窗口高 DPI 设置、QApplication 初始化MainWindow.h/.cpp主窗体、按钮和表格是否把算法逻辑写进了 UI 类SudokuGenerator.h/.cpp终盘生成、挖洞是否验证唯一解SudokuSolver.h/.cpp回溯求解、填写候选数求解时是否深拷贝整个盘面资源文件 .qrc/.rc图标、背景图路径是否写成绝对路径看头文件的顺序是先看 SudokuGenerator 和 SudokuSolver 的头文件。如果这两个类的公开接口里只有generate()和solve()没有把盘面状态扔给 UI 层那这个项目就还有救。我见过最难受的写法是在 MainWindow 里套三层 for 循环直接操作 QTableWidget 的单元格这种代码加一个难度分级需求就得改一整片逻辑。2.2 盘面用二维数组还是位掩码数独编程的第一次选型很多人的第一版数独程序用int grid[9][9]0 表示空格1 到 9 表示已填数字。这个结构直观、好调试、打印方便对于练手项目完全够用。但如果你想在生成谜题或求解时频繁判断某个格子还能填哪些数字每次都扫行、列和九宫格循环次数会变得非常难看。我一般会在早期就把位掩码引入用 9 个 int 分别记录每行、每列、每个 3x3 宫已经使用过的数字。int 的低 9 位对应 1 到 9第 n 位是 1 表示该数字已经存在。这样判断某个位置能否放数字只需要三次与运算和一次或运算比三层循环至少快一个数量级。常见的做法是这样// maskRow[9] 记录每行已占用的数字掩码maskCol[9] 记录列maskBox[9] 记录宫 int maskRow[9] {0}; int maskCol[9] {0}; int maskBox[9] {0}; // 把数字 num 放到 (row, col)同步更新三个掩码 void placeNumber(int row, int col, int num) { int bit 1 (num - 1); // 数字 1 对应 bit0数字 9 对应 bit8 maskRow[row] | bit; // 占位后置 1 maskCol[col] | bit; int boxIndex (row / 3) * 3 (col / 3); maskBox[boxIndex] | bit; } // 判断当前格能否放 num bool canPlace(int row, int col, int num) { int bit 1 (num - 1); return !(maskRow[row] bit) !(maskCol[col] bit) !(maskBox[(row / 3) * 3 (col / 3)] bit); }这段代码里的位运算在三个地方都用了占位、判重、回溯时取消占位。取消占位就是把对应位清零也就是maskRow[row] ~bit。这套接口在生成终盘和玩家输入校验时能复用同一套逻辑。参数上要注意boxIndex一定用(row / 3) * 3 (col / 3)有人写成row / 3 * 3 col / 3由于优先级问题直接越界这是数独编程里最经典的隐蔽 bug 之一。2.3 生成、求解、校验三个类的接口怎么切模块边界我推荐这样切SudokuSolver 只做一件事给定盘面求一个解或者统计解的个数SudokuGenerator 依赖 SudokuSolver 来挖洞MainWindow 负责把盘面显示到 QTableWidget 上并把用户输入扔给一个简单的校验函数。这三层之间不要互相引用 UI 对象否则你以后想加一个命令行版或者写单元测试都会很痛苦。下面是我常用的类接口骨架你可以直接抄进自己的头文件里// SudokuSolver.h class SudokuSolver { public: static bool solve(int grid[9][9]); static int countSolutions(int grid[9][9], int limit 2); private: static bool isSafe(int grid[9][9], int row, int col, int num); }; // SudokuGenerator.h class SudokuGenerator { public: // difficulty: 0简单, 1中等, 2困难 static void generate(int grid[9][9], int difficulty); static void fillDiagonal(int grid[9][9]); static void shuffle(int arr[], int len); };接口设计成静态方法是为了让算法层不依赖窗口实例也方便你在 main 函数里直接做自动化测试。需要注意的是countSolutions的第二个参数limit是性能关键我们判断唯一解时只需要数到 2 就可以停止了不需要把整棵搜索树跑完。很多新手写的计算全解的递归在挖洞较多时会卡到毫秒级别都回不来本质上是没有这个提前终止的开关。3. 核心算法落地回溯求解、唯一解验证与挖洞参数3.1 回溯法求解递归加候选数剪枝别用全盘搜索数独求解最稳的算法就是回溯。基本思路是找到一个空格尝试填入 1 到 9如果某次填入后能走通就继续递归走不通就撤销。这个思路本身不难难在写得够快。入门写法是固定从左到右、从上到下找空格但我会改成每次找候选数最少的格子优先尝试也就是 MRV 策略这样能让递归树大幅瘦身。// 返回 1 表示找到解0 表示无解 int solveByMRV(int grid[9][9]) { // 1. 找候选数最少的空格 int bestRow -1, bestCol -1; int bestCount 10; for (int r 0; r 9; r) { for (int c 0; c 9; c) { if (grid[r][c] ! 0) continue; int cnt 0; for (int num 1; num 9; num) { if (isSafe(grid, r, c, num)) cnt; } if (cnt bestCount) { bestCount cnt; bestRow r; bestCol c; } } } // 2. 没有空格说明已经填完 if (bestRow -1) return 1; // 3. 逐个尝试候选数字 int originalRow bestRow, originalCol bestCol; for (int num 1; num 9; num) { if (!isSafe(grid, originalRow, originalCol, num)) continue; grid[originalRow][originalCol] num; if (solveByMRV(grid)) return 1; grid[originalRow][originalCol] 0; } return 0; }注意两个细节第一isSafe函数里要检查行、列和宫漏掉宫检查会让你求出的解在九宫格内重复第二递归函数传的是二维数组的引用或者指针不要按值传int grid[9][9]作为函数参数否则每次递归都会在栈上复制 81 个 int性能会有肉眼可见的下降。如果你在 Debug 模式下卡顿明显先看看是不是这里踩坑了。3.2 唯一解验证忽略了它你的游戏一半概率会翻车很多旧版数独源码的挖洞逻辑是随机挖掉一串数字谁管有没有多解。结果玩家玩到最后发现有两个数字可以互换整盘仍然成立这就是多解迷局。这种问题特别隐蔽因为开发时你可能根本不会完整地玩一局——等到别人给你提 bug 时你连哪两个数字是自由项都不好找。想保证唯一解唯一可靠的做法是在生成谜题阶段就对每个挖掉的洞做唯一性验证。我一般用一个countSolutions函数来完成验证它不返回具体解而是统计解的数量数到 2 就立即终止// 数独求解与唯一解验证统计解的数量最多数到 limit 就停止 int countSolutions(int grid[9][9], int limit) { // 找候选数最少的空格 int bestRow -1, bestCol -1, bestCount 10; for (int r 0; r 9; r) for (int c 0; c 9; c) { if (grid[r][c] ! 0) continue; int cnt 0; for (int num 1; num 9; num) if (isSafe(grid, r, c, num)) cnt; if (cnt bestCount) { bestCount cnt; bestRow r; bestCol c; if (cnt 2) break; // 候选数已经很少没有必要继续找更少的 } } if (bestRow -1) return 1; // 已填满当前路径是一个解 int total 0; for (int num 1; num 9; num) { if (!isSafe(grid, bestRow, bestCol, num)) continue; grid[bestRow][bestCol] num; total countSolutions(grid, limit); // 注意这里要传累计值 grid[bestRow][bestCol] 0; if (total limit) break; // 达到目标裁剪 } return total; }这个函数的参数limit是最关键的地方。你要判断唯一解时调用countSolutions(puzzle, 2)它返回 1 就是唯一解返回 2 就是多解。验证已经给出的区域时把 limit 设为 2 可以省掉大量无用递归。这里有个容易写错的点递归调用时不能把total也作为 limit 传进去否则上一层的累加逻辑会乱掉我见过有人写出countSolutions(grid, total 1)这种奇怪的调用结果在挖洞较深时随机出错。3.3 从终盘挖洞到谜题难度参数怎么设才合理生成谜题的常见做法是两步走先生成一个完整的终盘然后按随机顺序逐个挖洞每挖一个洞都调用countSolutions验证唯一解一旦出现多解就回填数字。下面这个表是我平时用的难度区间按盘面剩余已给数字个数来划分比按挖洞比例更好控制难度剩余已给数字挖掉个数适用场景简单45 到 5031 到 36第一次接触数独的小朋友中等36 到 4041 到 45日常练手、演示项目困难28 到 3249 到 53给玩家一点压力挖洞的时候我一般让剩余数字与难度参数匹配。注意如果挖得太多比如剩余数字少于 24 个生成的题往往要么需要非常高级的链式推理要么干脆无解容易体验崩坏。下面是挖洞函数的核心片段// 在完整终盘 base 上挖洞生成谜题 puzzle void digHoles(int puzzle[9][9], const int base[9][9], int holes) { // 生成随机打乱的 0~80 的顺序 int order[81]; for (int i 0; i 81; i) order[i] i; std::shuffle(order, order 81, std::mt19937(std::random_device{}())); int dug 0; for (int i 0; i 81 dug holes; i) { int index order[i]; int row index / 9, col index % 9; int backup base[row][col]; puzzle[row][col] 0; // 挖掉后如果不是唯一解回填 if (countSolutions(puzzle, 2) ! 1) { puzzle[row][col] backup; } else { dug; } } }参数说明std::shuffle需要 C11 的random头文件std::random_device{}()用于真正随机种子而不是老式srand(time(0))的写法。countSolutions(puzzle, 2)在这里是性能热点简单难度可能几次就挖完困难难度可能要反复回填很多次。如果发现生成时间超过一秒可以加一个尝试次数上限尝试超过 2000 次后直接重新生成一个终盘而不是死磕当前盘。4. GUI 交互层Qt Widgets 搭建九宫格与输入联动4.1 选 Qt Widgets 还是 Win32我把对比表给你压缩包里的老工程很多是基于 MFC 或者 Win32 写的因为它们年代早。但如果你现在从零写一个 C 数独 GUI我更推荐 Qt Widgets 而不是 Win32 或 MFC。原因不是 Win32 不能做而是同样的功能在 Qt 里代码量大概是 Win32 的三分之一跨平台还能顺手编译出 macOS 版本。方案跨平台控件灵活性打包体积上手成本Qt Widgets好高信号槽机制方便约 20 到 50 MB低文档多Win32 API只能在 Windows低所有控件手写小几百 KB高消息循环绕MFC只能在 Windows中有历史包袱中高类库老化控制台 数字输入无所谓无最小最低但不符合 GUI 标题如果你拿到的 shudu.rar 里是 Qt 3 或 Qt 4 的工程建议直接升级思路用现代 Qt 5 或 Qt 6 重新搭界面。类名和信号槽机制差别不大照着改最经济。有一点要提醒如果目标是发布给别人用别选 Qt Quick/QML一个数独游戏不需要动画特效QWidget 的 QTableWidget 已经够用且打包问题更少。4.2 用 QTableWidget 搭一个能跑起来的最小界面数独界面最朴素的做法是放一个 9x9 的 QTableWidget关闭行表头和列表头设置每个格子的大小和字体。下面这段代码可以直接放进主窗口的构造函数里#include QTableWidget #include QHeaderView void MainWindow::createBoard() { m_table new QTableWidget(9, 9, this); m_table-setEditTriggers(QAbstractItemView::DoubleClicked | QAbstractItemView::EditKeyPressed); m_table-setSelectionMode(QAbstractItemView::SingleSelection); // 去掉表格左侧和上方的行号列号 m_table-verticalHeader()-setVisible(false); m_table-horizontalHeader()-setVisible(false); // 每个格子固定 48 像素9 格共 432 像素 for (int i 0; i 9; i) { m_table-setRowHeight(i, 48); m_table-setColumnWidth(i, 48); } // 关闭焦点虚框否则点击后会出现难看的蓝色边框 m_table-setFocusPolicy(Qt::NoFocus); }注意setEditTriggers的参数是 QAbstractItemView::ItemEditTrigger 的或组合这里允许双击或者按 F2 进入编辑。如果你希望玩家单击后直接输入数字那就改成AllEditTriggers但是这样容易误触我建议还是双击或者按数字键直接响应策略。这里没有直接调setItem因为单元格内容由后面的updateBoard统一填充这样新增盘面时只需要改一个函数。4.3 输入校验与冲突高亮把用户操作变成有效反馈玩家输入数字后你需要立刻判断这一格是否与同行、同列或同宫的其他数字冲突。这里有一个高频 bug当你调用setItem刷新界面时QTableWidget 会再次触发itemChanged信号如果你在信号回调里又去更新界面就会形成无限循环。我见过不少新手的写法是判断到冲突就调用 setBackground结果每次重绘都会重新触发 itemChanged界面直接卡死。规避方法是加一个 bool 守卫。void MainWindow::onItemChanged(QTableWidgetItem *item) { if (m_updating) return; // 自己刷新时直接忽略 int row item-row(); int col item-column(); // 已给的初始数字格子是只读的不会触发该回调 QString text item-text().trimmed(); if (text.isEmpty()) { item-setBackground(QBrush(Qt::white)); return; } bool ok false; int value text.toInt(ok); if (!ok || value 1 || value 9) { // 输入了 0 或字母直接清空并标红 item-setText(); item-setBackground(QBrush(Qt::red)); return; } // 判定冲突忽略自身 if (isConflict(row, col, value)) { item-setBackground(QBrush(QColor(255, 180, 180))); } else { item-setBackground(QBrush(Qt::white)); } }isConflict函数的实现逻辑很简单遍历同一行 9 列同一列 9 行以及所在 3x3 宫里的其余 8 个格子检查是否有相同数字。这里最容易漏掉的是忽略当前格子这个条件不然玩家输入数字后自己也会被判成冲突。另一个容易被忽略的点是输入字母或 0 时不能直接item-setText()后就不管了因为 setText 又会触发一次 itemChanged再次进入回调后 text 为空走的是return分支问题不大但如果你在空分支里也做了setBackground配合 m_updating 的缺失就可能出现重入。4.4 新游戏、检查、提示三个按钮的联动逻辑界面上的三个核心按钮分别是新游戏、检查和提示。新游戏的逻辑是先生成终盘再挖洞最后把谜题填充到表格里。这里有一个线程问题生成困难难度时 countSolutions 可能耗时几百毫秒如果在主线程里跑界面会短暂无响应。我一般的判断标准是先直接在按钮回调里生成如果 QElapsedTimer 测出来超过 200 毫秒再考虑丢到 QThread 里对多数课程设计来说单线程足够。void MainWindow::onNewGame() { int base[9][9] {0}, puzzle[9][9] {0}; // 1. 生成完整终盘 base SudokuGenerator::generateFullSudoku(base); // 2. 按当前难度挖洞得到 puzzle int holes 40; // 由难度选项决定 SudokuGenerator::digHoles(puzzle, base, holes); // 3. 一次性刷新界面 updateBoard(puzzle); } void MainWindow::updateBoard(int puzzle[9][9]) { m_updating true; // 进入刷新模式 for (int r 0; r 9; r) { for (int c 0; c 9; c) { QTableWidgetItem *item m_table-item(r, c); if (!item) { item new QTableWidgetItem; m_table-setItem(r, c, item); } if (puzzle[r][c] 0) { item-setText(); item-setFlags(item-flags() | Qt::ItemIsEditable); } else { item-setText(QString::number(puzzle[r][c])); item-setFlags(item-flags() ~Qt::ItemIsEditable); } item-setTextAlignment(Qt::AlignCenter); item-setBackground(QBrush(Qt::white)); } } m_updating false; // 刷新完成允许用户输入触发回调 }m_updating这个布尔守卫是整套 GUI 模块里最重要的变量它防止 updateBoard 里的 setItem 触发 onItemChanged 造成递归。检查按钮的逻辑更简单把当前表格内容提取为int grid[9][9]调用countSolutions(grid, 2)返回 1 且玩家填完所有空格就弹出恭喜完成或答案正确。注意检查时不要用solveByMRV(grid)因为solve会修改 grid而 check 的语义是不改变盘面。5. 避坑手册C 数独 GUI 高频故障的现象、原因与修复5.1 中文乱码界面按钮全是问号或者乱码现象按钮文字、提示框里的中文在 Windows 上显示成乱码但同一个源码在 Linux 上正常。原因MSVC 编译器把源文件当作 GBK 读取而你的 .cpp 文件保存成了 UTF-8 无 BOM 格式反过来也成立。Qt 的 QString 默认按 UTF-8 解释字符串字面量两种编码一错位就乱码。解决把整个工程的源码统一保存为 UTF-8 with BOM或者在项目里加编译选项/utf-8如果你在维护老代码有一个兼容做法是给中文字符串套QString::fromLocal8Bit(...)但我不推荐到处都这样写治标不治本。我在 MSVC 下会优先改工程属性里的命令行附加选项写入/utf-8。5.2 挖洞死循环新游戏按键一按就卡死现象点击新游戏按钮后界面卡住不动CPU 却跑满甚至内存持续上涨。原因挖洞过程中每次尝试都调用 countSolutions但 countSolutions 在无解盘面上会遍历整棵搜索树而你写的是暴力全盘搜索再加上对多解判断用的 limit 参数没有生效导致每次都要把树跑完。解决先检查countSolutions是否在total limit时提前返回再把挖洞循环加上最大尝试次数比如超过 2000 次直接重新生成终盘。我自己的经验是一旦挖洞失败次数超过一整轮 81 次说明当前终盘的挖洞空间已经很小了与其反复试不如换个终盘重新开始花费时间反而更少。5.3 QTableWidget 信号死循环设置背景色时界面崩溃现象在 itemChanged 回调里设置背景色后程序会持续高占用甚至爆栈崩溃。原因QTableWidgetItem::setBackground 会触发数据变化进而再次发射 itemChanged 信号如果你的回调没有 m_updating 守卫信号会递归调用。解决所有由程序主动修改单元格内容或背景的地方都要先用m_updating true包围回调入口第一行判断if (m_updating) return修改完再复位。另一个类似的坑是 setText 也会触发信号所以 updateBoard 里也要加守卫不能只在回调里加。5.4 回溯求解在 Release 下崩溃在 Debug 下却好的现象Debug 版本运行正常切到 Release 后不定时崩溃或者递归一深就栈溢出。原因最常见的是函数参数里按值传递了大数组比如void solve(int grid[9][9])在调用时把整个数组复制到栈上递归 60 层后栈压力剧增。Debug 下栈空间和数组对齐方式和 Release 不同掩盖了问题。解决在所有求解函数中把参数改为int grid[9][9]的引用传递或者传入int* grid指针同时检查递归深度数独的递归深度最多 81 层正常情况下不该爆栈一旦爆栈几乎都是深拷贝或无限递归。5.5 打包之后到别的机器运行提示缺 DLL现象在自己电脑上双击 exe 正常拷到另一台 Windows 电脑上提示找不到 Qt5Core.dll或者 The application was unable to start correctly 这类错误。原因Qt 程序默认是动态链接需要 Qt 运行库和 platforms/qwindows.dll 这个平台插件直接拷贝 exe 不带上 DLL 肯定跑不起来。解决用 Qt 自带的 windeployqt 工具把依赖补全。命令大致是windeployqt --release release\shudu.exe执行后它会把你需要的 Qt DLL 和 platforms 文件夹放到 exe 旁边。这个步骤是这类 GUI 项目最容易被忽略的血泪经验如果你还要发给别人演示建议再压成一个 zip 或者用 Qt Installer Framework 做安装包。6. 让 shudu 项目真正拿得出手自测清单与追加功能先把项目跑通再做下面这个自测清单每一条都能卡住不少半成品连续生成 20 盘困难难度全部能在一秒内完成并且每一盘用countSolutions(grid, 2)验证返回 1在任意空格输入字母、0、10 和负号界面不崩溃且输入会被重置输入一个与行、列或宫重复的数字背景变红把该格改对后红色消失初始题目格无论怎么双击或按键都不能被修改填满所有格子且完全正确时点击检查弹出胜利提示点击新游戏后上一盘的冲突红色全部清空。通过这套自测之后再考虑追加能力。难度分级并不只是改挖洞数量还可以在挖洞前先按布局把终盘随机转置、交换行组增加视觉变化给初始题目格加浅灰色背景区分玩家填充格加一个 60 秒计时器用 QTimer 每秒刷新标题栏存档读档用到QSettings把 81 个格子的数字写成一个 81 字符的字符串存起来比写配置文件简单得多。如果你还想继续深入 C 数独编程可以去做候选数高亮或者唯一候选提示这两块都是前端显示与算法结合的好练习。我在最早写这个项目时只做了随机挖洞完全没做唯一解验证结果同事玩到一半指出两个数字可以互换相当于我交出去一把有后门的锁。那晚我重写了整个生成器把countSolutions抽成独立模块之后所有新功能都先过一遍上面的自测清单。这种教训用一次就长记性数独游戏的判断标准不是能点会动而是生成的每一盘题只有一个答案。希望这篇文章能帮你少走这段弯路也希望你自己动手改一遍后能对生成、求解、界面联动这套链路有更踏实的体感。本文还有配套的精品资源点击获取