资讯动态

Qt Widgets数独实战:从盘面绘制到回溯出题与位运算求解

发布时间:2026/10/14 16:33:40 来源:尧图企业网站定制
简介这是一份基于Qt框架开发的数独游戏完整工程源码面向Qt初学者、计算机专业学生及游戏开发入门者提供从零搭建一个跨平台数独应用的完整参考。资源共141个文件压缩包约25.6MB包括21个C源文件、15个头文件、8个界面设计文件、47张图片素材以及工程配置和构建产物并区分debug与release目录方便直接查看运行效果与学习编译配置。已有2558人下载学习。项目围绕数独核心玩法展开涉及盘面随机生成、合法性校验、输入响应、进度保存等环节能够帮助读者深入理解Qt的信号与槽机制、QWidget与布局管理以及模型-视图-控制器结构在复杂界面中的实际应用。同时代码实现了自定义背景切换、规则说明与关于对话框并预留了多语言接口。在细节上工程使用QFile与QTextStream完成游戏存档的读写利用Q_ASSERT进行关键逻辑断言并通过QTranslator支持国际化这些都可作为实际项目开发的范本。整体目录清晰、模块划分明确适合一步步拆解学习也可在此基础上继续扩展难度选择、计时挑战或联机对战功能。1. 为什么用 Qt 写数独从画格子到出题判题的一整套练习很多人以为用 Qt 写数独游戏难点在界面得先把 9×9 的格子画漂亮。真正动手之后才发现出题、判题、唯一解校验才是绕不开的坎随机挖洞可能挖出多解盘面求解器如果写得慢点一下提示卡三秒。这篇文章把我用 Qt Widgets 实现完整数独游戏的一套做法写清楚覆盖盘面绘制、坐标映射、回溯出题、位运算求解、交互校验和性能验证。适合刚学完 Qt 基础、想拿一个小型但完整的项目巩固能力的开发者文中的代码按可编译、可扩展的方式组织照着敲完之后你会对数据层和界面层怎么分离有一个很直观的体感。2. 盘面绘制与交互坐标Widgets 选型、paintEvent 与鼠标映射2.1 技术选型Widgets 和 QML 怎么选数独游戏在界面上的需求其实很克制一个 9×9 的网格、数字文本、几个按钮。QML 的动画和粒子效果在这里基本派不上用场反而要花额外精力去维护 QObject 接口和模型映射。所以我一般直接选 Qt WidgetsQPainter 画网格非常直接鼠标事件和键盘事件的回调模型也清晰最重要的是算法逻辑可以纯 C 编写不依赖 QML 的 JavaScript 环境方便做单元测试。如果你把数独当作练手项目Widgets 还有一个隐性好处你能更多地把时间花在“如何组织数据结构和界面刷新”上而不是折腾 QML 的绑定语法。这里不评价 QML 好不好只说做一个桌面小游戏Widgets 是投入产出比更高的选择。界面层我通常拆成两个类一个叫SudokuBoard继承QWidget只负责画盘面、响应鼠标和按键另一个叫GameState负责维护 81 个格子的数据、撤销栈和校验逻辑。这样做的好处后面会反复体现界面上无论怎么改数据层不需要跟着动。2.2 用 QPainter 画出 9×9 盘面最小可运行代码先看SudokuBoard的绘制部分。核心思路是重写paintEvent用QPainter画网格线再用drawText在每个格子中央绘制数字。为了让窗口拉伸时盘面不变形我取width()和height()的较小值作为边长并居中绘制。// SudokuBoard.cpp void SudokuBoard::paintEvent(QPaintEvent *) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); int side qMin(width(), height()); int offsetX (width() - side) / 2; int offsetY (height() - side) / 2; double cell side / 9.0; // 背景 painter.fillRect(offsetX, offsetY, side, side, QColor(#F7F8FA)); // 9x9 细线 QPen thin(QColor(#B0B7C3), 1); painter.setPen(thin); for (int i 0; i 9; i) { painter.drawLine(offsetX i * cell, offsetY, offsetX i * cell, offsetY side); painter.drawLine(offsetX, offsetY i * cell, offsetX side, offsetY i * cell); } // 3x3 粗线 QPen bold(QColor(#4A5568), 2); painter.setPen(bold); for (int i 0; i 3; i) { int pos i * 3 * cell; painter.drawLine(offsetX pos, offsetY, offsetX pos, offsetY side); painter.drawLine(offsetX, offsetY pos, offsetX side, offsetY pos); } }逻辑说明cell side / 9.0是关键如果写成side / 9整数除法最后一格的宽度会和前面不一致窗口较小时尤其明显。细线循环i 9画出 10 条线形成 9 个格子粗线循环i 3时pos取 0、3、6、9 格的边界正好构成 3×3 宫。数字绘制和格子高亮可以追加在同一个paintEvent里for (int row 0; row 9; row) { for (int col 0; col 9; col) { int idx row * 9 col; if (m_board[idx] 0) continue; QRectF rect(offsetX col * cell, offsetY row * cell, cell, cell); painter.drawText(rect, Qt::AlignCenter, QString::number(m_board[idx])); } }这里m_board是QVectorint长度 810 表示空格。为什么用一维数组而不是二维数组因为一维数组在递归回溯时可以少一层下标换算也方便用idx / 9和idx % 9在行列之间切换。窗口拉伸时盘面不会变形的保证来自两点一是边长的qMin二是居中偏移量offsetX/offsetY。如果直接把格子画在(0,0)到(width,height)窗口变成非正方形时格子会变扁这是新手最容易翻车的地方。2.3 鼠标点击坐标到行列的换算绘制时用的偏移量在鼠标事件里必须重新算一遍否则点击位置和格子对不上。我见过不少实现是直接event-pos()除以 9但窗口带了工具栏或边距时就开始串行。正确做法是保持和绘制完全一致的算法。void SudokuBoard::mousePressEvent(QMouseEvent *event) { int side qMin(width(), height()); int offsetX (width() - side) / 2; int offsetY (height() - side) / 2; QPoint pos event-pos(); // 点在盘面外直接忽略 if (pos.x() offsetX || pos.x() offsetX side || pos.y() offsetY || pos.y() offsetY side) { return; } int col (pos.x() - offsetX) / (side / 9.0); int row (pos.y() - offsetY) / (side / 9.0); if (row 0 row 9 col 0 col 9) { emit cellClicked(row, col); } }逻辑说明(side / 9.0)必须用浮点和绘制时的cell保持一致。如果pos.x()正好落在格子的边界线上整数除法会向下取整这通常是可接受的但如果你的需求要求边界精确可以加一个容差判断不过实际体验里几乎没人会刻意点一条线。鼠标事件里只发信号不做任何逻辑处理。逻辑层收到cellClicked(row, col)之后去更新高亮下标、显示候选数字、或者准备接收键盘输入。这样界面层和逻辑层的边界就非常干净。3. 数独生成算法从随机终盘到挖洞出题3.1 用回溯法生成完整终盘随机洗牌带来的唯一性差异出题的第一步是生成一个合法的完整终盘。最简单可靠的方式是回溯填充从第一个空格开始依次尝试数字 1 到 9用行、列、宫冲突检查做剪枝填满 81 格即完成。直接按 1 到 9 的顺序回溯每次运行都会生成完全一样的终盘所以必须对候选数字做随机洗牌。这里我用std::shuffle配合std::mt19937随机种子来自std::random_device保证每次启动的题目都不一样。// SudokuGenerator.cpp bool SudokuGenerator::fillBoard(QVectorint board, int idx) { if (idx 81) return true; int row idx / 9, col idx % 9; if (board[idx] ! 0) return fillBoard(board, idx 1); // 随机打乱 1~9避免生成固定终盘 QVectorint candidates {1, 2, 3, 4, 5, 6, 7, 8, 9}; std::shuffle(candidates.begin(), candidates.end(), m_rng); for (int num : candidates) { if (isValidAt(board, row, col, num)) { board[idx] num; if (fillBoard(board, idx 1)) return true; board[idx] 0; // 回溯恢复 } } return false; }isValidAt的实现是标准的三重检查同一行是否有相同数字、同一列是否有、所在 3×3 宫是否有。算法上可以只查已填入的位置不必扫描整个棋盘因为回溯过程中只会遇到 0 和已固定的数字。参数说明idx范围 0 到 80board用引用传递避免每次递归拷贝 81 个 int。这里有个优化点可以先预处理每一行的空白位置列表减少无效递归但对 9×9 数独来说回溯深度最多 81 层这个优化收益不大反而增加理解成本。如果你观察fillBoard的递归顺序会发现它总是从 0 开始逐个尝试随机性完全来自候选数字的顺序。这样既保证终盘合法又让不同次的生成结果差异足够大。关于随机种子记住一个原则桌面应用绝不要用qsrand(time(NULL))这种固定种子拼接因为同一秒内多次启动会拿到同一套题std::random_device在主流桌面平台上足够用。3.2 挖洞与难度控制怎么确认挖掉之后仍有唯一解拿到终盘之后出题就是“挖洞”把若干格子的数字清零同时保证剩余盘面只有唯一解。如果挖掉一个格子后出现多个解用户填到后面会发现同一个空位可以放两个数字都不冲突那题目就是废的。所以挖洞必须依赖求解器做校验。基本流程是随机打乱 81 个格子的访问顺序逐个尝试挖掉每次挖完调用“求解数量”函数只要解的数量大于 1 就回滚。这里我把“只找两个解”作为性能红线。bool SudokuGenerator::digHoles(QVectorint board, int targetHoles) { QVectorint cells(81); std::iota(cells.begin(), cells.end(), 0); // 随机顺序访问格子保证挖洞位置不固定 std::shuffle(cells.begin(), cells.end(), m_rng); int holes 0; for (int idx : cells) { if (holes targetHoles) break; if (board[idx] 0) continue; int saved board[idx]; board[idx] 0; int solCount 0; solveWithLimit(board, solCount, 2); if (solCount ! 1) { board[idx] saved; // 挖完变成多解撤销 } else { holes; } } // 不一定能挖到目标数量以实际结果为准 return holes targetHoles; }targetHoles的计算和难度绑定用“保留数字个数”来描述更直观。我给一个经常用的分档简单保留 40 到 45 个数字中等保留 36 到 39 个困难保留 30 到 34 个。保留越少空格越多挖洞过程中被回滚的概率越大生成耗时也越长。这里有个细节容易被忽视挖洞时随机访问的顺序会影响最终题面的形状。同样保留 32 个数字有的题目看起来“很散”有的题目某个宫全空。从可玩性角度散一点反而更好因为聚焦在一个宫里的空位会让用户感到无趣。如果你希望题目更均匀可以在随机洗牌后增加一个约束每个宫至少保留 2 个数字否则跳过该位置。生成耗时是这个环节的核心指标。若你的求解器是朴素回溯不剪枝地枚举所有解生成一道困难题可能耗时几百毫秒以上用后面第 4 章的位运算求解器通常可以压缩到 10 毫秒以内。4. 数独求解器回溯法和位运算的工程取舍4.1 求解器为什么是出题模块的“质检员”如果把出题流程比作生产线求解器就是质检员挖洞时每删一个数字都要问它“现在还有几个解”只有答案是“正好一个”才放行。所以在写生成器之前应该先把求解器写好而且要写成“传一个最多解数量限制”的版本而不是只返回“有没有解”。求解器的第二个用途是提示功能。用户点“提示”时取当前盘面让求解器算一个合法解把对应格子的数字显示出来。第三个用途是完成判定用户把所有空格填满时可以用求解器验算正确答案防止“填完但和标准答案不同”的情况。4.2 用位运算写一个 81 格的回溯求解器朴素回溯法的瓶颈在于每次尝试数字都要扫描行、列、宫导致递归里嵌套了三层循环。位运算的思路是用三个数组rowMask[9]、colMask[9]、boxMask[9]来记录已经占用的数字每个数字对应一个比特位。候选数字的集合就是这三个掩码的并集取反。// 递归前先初始化三个掩码 void initMasks(const QVectorint board, int rowMask[], int colMask[], int boxMask[]) { for (int i 0; i 9; i) { rowMask[i] 0; colMask[i] 0; boxMask[i] 0; } for (int i 0; i 81; i) { if (board[i] 0) continue; int r i / 9, c i % 9, b (r / 3) * 3 (c / 3); int bit 1 (board[i] - 1); rowMask[r] | bit; colMask[c] | bit; boxMask[b] | bit; } }主递归函数用 MRV 策略也就是优先处理候选数字最少的空格。这个优化对难题尤其重要能减少回溯分支数。bool solveWithLimit(QVectorint board, int count, int limit) { if (count limit) return true; // 已经找到足够多的解及时剪枝 int idx -1, minCnt 10; for (int i 0; i 81; i) { if (board[i] ! 0) continue; int r i / 9, c i % 9, b (r / 3) * 3 (c / 3); int used rowMask[r] | colMask[c] | boxMask[b]; int avail (~used) 0x1FF; // 只取低 9 位 int cnt countBits(avail); if (cnt minCnt) { minCnt cnt; idx i; } if (cnt 1) break; // 只有一个候选没必要再找 } if (idx -1) { count; return false; } // 没有空格找到一个解 int r idx / 9, c idx % 9, b (r / 3) * 3 (c / 3); int mask (~(rowMask[r] | colMask[c] | boxMask[b])) 0x1FF; while (mask) { int bit mask -mask; // 取最低位的 1 int num bitToNum(bit); board[idx] num; rowMask[r] | bit; colMask[c] | bit; boxMask[b] | bit; solveWithLimit(board, count, limit); // 回溯恢复 rowMask[r] ~bit; colMask[c] ~bit; boxMask[b] ~bit; board[idx] 0; if (count limit) return true; mask mask - 1; // 清除最低位的 1 } return false; }参数说明count是引用计数limit是求解数量上限。把limit设为 2就能判断唯一解设为 1就只验证“是否有解”。countBits可以用内置函数也可以手写 9 次循环因为只统计 9 位性能差异可以忽略。bitToNum把 1(num-1) 还原成数字 num用一个简单的 while 循环即可。这里要特别说明~used 0x1FF~used会把高位也置 1所以必须用0x1FF只保留低 9 位。很多人第一次写位运算求解器漏了掩码导致候选数字里出现大于 9 的位结果数字错乱排查很久。这是我的血泪经验。4.3 多解判断影响难度分级的关键细节digHoles里调用solveWithLimit(board, solCount, 2)solCount ! 1表示两种情况没有解或者有多个解。挖洞过程中不会出现没解的情况因为是从合法终盘挖下去的所以这里实际上只需要区分“1 个解”和“超过 1 个解”。为什么不直接数出所有解再判断因为多解盘面可能解的数量非常大全数完会浪费时间。solveWithLimit在count limit时提前返回所以绝大多数多解情况在找到第二个解时就停住了。这是出题性能的关键。limit参数的另一个用途是生成“提示数字”。当用户请求提示时可以用solveWithLimit(board, count, 1)快速算出任意一个解取当前空格的对应值写入。注意这里不要忽略状态恢复两条分支共享同一个board数组求解结束后必须恢复初始状态否则用户界面上的数字会错乱。5. 交互细节与常见问题排查输入校验、悔棋和高亮5.1 数据结构和界面分离别再在 paintEvent 里算数独我见过一个反面案例某位同事把棋盘状态直接存在SudokuBoard的成员变量里然后在paintEvent中调用算法函数判断当前玩家是否获胜。结果每次鼠标移动触发局部重绘时整个盘面要被扫描好几遍鼠标稍微快一点就掉帧。正确做法是单独建一个GameState界面类只保存一个数据指针绘制时只读取不计算。class GameState { public: QVectorint puzzle; // 当前盘面0 表示空格 QVectorint initial; // 出题时的初始盘面 QStackint undoStack; // 悔棋栈记录每次填入前的旧值 bool canEdit(int idx) const { return initial[idx] 0; // 初始数字不允许修改 } void setCell(int idx, int value) { if (!canEdit(idx) || puzzle[idx] value) return; undoStack.push(puzzle[idx]); // 保存旧值后悔药在这里 puzzle[idx] value; } void undo() { if (undoStack.isEmpty()) return; int idx undoStack.size() - 1; // 简化示意 // 实际应记录 idx 和旧值这里用一个栈存旧值 } };这里我把canEdit作为硬约束初始数字不可修改否则用户误触会把题目改掉。undoStack存的是“填入前的旧值”撤销时直接写回。如果同时记录格子下标两个栈同步操作功能更完整。逻辑层和界面层的接口就三四个信号cellClicked(row, col)、numberEntered(int num)、undoRequested()。界面只负责把用户动作翻译成信号数据层只负责改状态改完调用boardWidget-update()触发重绘。这样即使以后把 GUI 换成 QML算法层一行都不用动。5.2 输入校验、冲突高亮和“一步后悔药”输入校验常见的需求是用户填入数字后如果行、列或宫里有相同数字就把冲突格子和当前格子标红。最简单可靠的做法是填完之后做一次局部冲突检查而不是每次都调用求解器。求解器适合判胜负或提示不适合做实时反馈因为它在最坏情况下会走完整棵回溯树。// 检查 board 中 idx 位置的 num 是否与同行/同列/同宫冲突 bool isConflictAt(const QVectorint board, int idx, int num) { int r idx / 9, c idx % 9; int br (r / 3) * 3, bc (c / 3) * 3; for (int i 0; i 9; i) { // 同行 if (i ! c board[r * 9 i] num) return true; // 同列 if (i ! r board[i * 9 c] num) return true; // 同宫 int rr br i / 3, cc bc i % 3; if (rr ! r cc ! c board[rr * 9 cc] num) return true; } return false; }这里的board[idx]要先填入新数字再检查检查时要跳过自身下标否则必然会和自己冲突。如果你先检查再填值就少了跳过的逻辑但代码会更绕。我的习惯是填完检查然后通过update(rectOfCell)只重绘冲突的格子区域。冲突标红的绘制逻辑可以放在paintEvent里对每个格子如果它属于冲突集合就用浅红背景填充。注意这里要维护一个“当前冲突格子列表”成员变量由逻辑层在每次填入后计算并更新而不是在绘制函数里现算。悔棋功能是用户最依赖的“后悔药”。它不需要很复杂一个栈就够了。每次setCell时把旧值入栈undo()出栈并恢复。如果保存旧值的同时保存下标还能支持“撤销填错数字”但不撤销“擦除”的操作。很多小游戏把撤销栈设计成只存旧值结果撤销后光标位置丢失体验很差。我的建议是栈里存QPairint, int第一个是下标第二个是旧值。5.3 三个翻车现场坐标错位、窗口缩放变形和重绘卡顿先说坐标错位。现象鼠标点击一个格子高亮的却是它右边或下边的格子。原因大多是mousePressEvent里用了event-globalPos()然后忘记做窗口到控件的坐标映射。解决方法是直接用event-pos()它已经是控件本地坐标如果确实需要全局坐标用mapFromGlobal(event-globalPos())。另外窗口有边框或工具栏时全局坐标映射很容易出错所以优先用pos()。再说窗口缩放变形。现象把窗口拉宽后格子变成长方形数字不再居中。原因基本是绘制时没有用qMin(width(), height())做正方形约束。修法是回到第 2 章的绘制代码所有格子坐标都基于side和offset换算而不是基于width()或height()。记住一条绘制和鼠标事件里的坐标换算必须从同一个side导出。最后说重绘卡顿。现象鼠标在盘面上移动时CPU 占用飙升拖动窗口时盘面闪烁。原因有两种一是paintEvent里做了逻辑计算比如实时调用求解器二是每次mouseMoveEvent都触发update()导致整块盘面反复重绘。修法把计算逻辑全部移出绘制函数鼠标移动时只在“格子发生变化”时才update()。比如维护hoverRow和hoverCol内容变了才重绘。6. 性能验证与优化用 QElapsedTimer 确认生成耗时生成一道困难题到底要多久别用“感觉很快”来回答直接上QElapsedTimer量一下。我习惯写一个小的性能测试函数连续生成 10 盘记录最大耗时和平均耗时。QElapsedTimer timer; qint64 maxMs 0, sumMs 0; const int TESTS 10; SudokuGenerator gen; for (int i 0; i TESTS; i) { QVectorint board(81, 0); timer.restart(); gen.generate(board, 32); // 保留 32 个数字困难级别 qint64 ms timer.elapsed(); maxMs qMax(maxMs, ms); sumMs ms; } qDebug() avg: (sumMs / TESTS) ms max: maxMs ms;如果 max 超过 50 毫秒你该看一下是不是求解器没有用位运算或者digHoles的循环没有提前终止。事实上唯一解的校验在绝大多数情况下只递归几十层10 毫秒是一个很保守的目标。性能没问题之后还有一个体验细节值得做预生成题库。桌面小游戏启动时现场生成题目用户会看到几百毫秒的“转圈”。更好的做法是首次启动时生成 100 盘题存到本地 JSON 文件里之后每次启动随机抽一题。这样把生成耗时转移到后台用户无感知。最后一件事是教训。我在模拟项目X里做这个功能时忘记区分initial盘面和puzzle盘面结果“完成判定”把未填的空格也当成用户填过的格子直接判负。后来统一用GameState管理所有状态initial[idx] 0才允许修改这个问题才彻底消失。边界条件这种东西写的时候总觉得不会撞上但用户总会用一种你没想到的方式点到它。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑