☰
用Qt开发数独游戏:从界面绘制到回溯算法的完整工程实践
2026/10/13 4:46:05 网站建设 项目流程

简介:这是一份基于Qt框架开发的数独游戏完整工程源码,面向C++/Qt初学者和需要GUI项目练手的开发者,覆盖了从界面搭建、事件响应到数独算法实现的全部环节。资源包共141个文件,压缩后约25.6MB,其中以21个cpp源文件、15个头文件、8个ui界面文件、png图片和table数据文件为主,同时提供pro工程、qrc资源、makefile及debug/release配置,便于直接构建和二次开发。已有2558人学习下载。源码中可重点学习Qt信号与槽机制、QWidgets与QLayout的界面组织、基于MVC的盘面逻辑处理以及QFile存档读写;还包含QPixmap背景切换、规则讲解对话框等细节,可延伸思考数独生成与求解算法的实现。整体结构清晰,注释完整,适合作为课程设计、毕业设计或Qt实战入门参考。

1. 为什么用 Qt 写数独:从「能玩」到「值得做」的分水岭

数独大概是桌面开发里最被低估的练手项目。它表面上只是一个 9×9 的格子,但当你真用 Qt 动手写一遍就会发现:界面渲染、鼠标交互、状态管理、算法生成、难度控制、撤销重做,几乎把桌面应用开发的骨架全过了一遍。很多从业者拿它当 Qt 入门的第一个完整项目,不是因为简单,而是因为它「麻雀虽小、五脏俱全」——你既要处理视图层的绘制与响应,又要在模型层维护一套合法的数独状态机,还得在算法层解决「生成一个有唯一解、难度可控的题面」这个不那么 trivial 的问题。

我最初写这个项目的时候,想的只是做一个能填数字的小工具,结果做到一半才发现,真正的工程量全在「生成一道好题」和「判断用户填错了没有」这两件事上。就算你用 Qt 自带的 QTableWidget 硬刚,也能在两小时内拼出一个能玩的壳子,但那个壳子会在三个地方翻车:卡死、误判、题目无解。这篇笔记就沿着「界面 → 算法 → 交互 → 排错」的顺序,把一套完整可复现的做法讲清楚,适合已经会写基本 Qt 信号槽、想完整走一遍项目的人,也适合想给自己的作品集加一个「有算法深度、有 UI 细节」的桌面应用的人。


2. 界面选型与工程骨架:QGraphicsView 还是 QTableWidget

2.1 为什么最终选择 QGraphicsView 而不是 QTableWidget

第一版我图省事,直接用 QTableWidget。填数字、读数字都简单,信号槽一套就完事。但做到第三步「标记候选数」(就是用户在格子里临时记几个可能的数字)的时候,QTableWidget 的 cellWidget 方案就变得非常拧巴——你得把每个格子塞进一个自定义 QWidget,还要在数字和候选标记之间来回切换,性能和代码可读性都会快速劣化。

所以我建议直接用 QGraphicsView + QGraphicsScene 来自绘棋盘。这不是炫技,而是因为数独的视觉表达本质上是「一个表格 + 多种图层状态」:不可编辑的题面数字、用户填的实数字、候选小数字、错误高亮、选中高亮。用 QGraphicsItem 体系,最自然的做法是让每个格子成为一个 QGraphicsItem,通过重写 paint() 在同一个格子里画出数字和候选标记;选中、冲突、不可编辑三种状态也能在 paint() 里统一处理,而不是靠堆控件。

工程结构上,我一般拆成四个文件:

sudoku/ ├── main.cpp ├── boardwidget.h / boardwidget.cpp // 棋盘视图:绘制、鼠标事件 ├── sudokuboard.h / sudokuboard.cpp // 数据模型:合法性校验、状态 ├── generator.h / generator.cpp // 生成题面与求解器 └── mainwindow.h / mainwindow.cpp // 主窗口:菜单、按键、组装

我用的是 QWidget + QGraphicsView 而非 QML。原因有两个:其一,数独的交互逻辑以键盘和鼠标为主,QWidget 的信号槽机制写起来更直接;其二,这一项目如果你是想练 C++/Qt Widgets 技能,QML 的语法重心会让你的注意力从算法和状态管理上移开。如果你后续想做触屏或者动画效果,再考虑换 QML 也不迟。

2.2 棋盘自绘的最小代码:一个能画格子与数字的 Item

这里给出一个可以直接跑起来的核心类骨架。它不依赖任何外部资源,画出 9×9 格子、粗线分隔、以及数字:

// cellitem.h #ifndef CELLITEM_H #define CELLITEM_H #include <QGraphicsItem> #include <QFont> class CellItem : public QGraphicsItem { public: CellItem(int row, int col, int cellSize, QGraphicsItem *parent = nullptr); void setValue(int v) { m_value = v; update(); } // v: 1~9; 0 表示空白 void setEditable(bool e) { m_editable = e; update(); } void setSelected(bool s) { m_selected = s; update(); } void setConflict(bool c) { m_conflict = c; update(); } QRectF boundingRect() const override; void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; private: int m_row, m_col; int m_cellSize; int m_value = 0; bool m_editable = true; bool m_selected = false; bool m_conflict = false; }; #endif // CELLITEM_H
// cellitem.cpp #include "cellitem.h" #include <QPainter> #include <QPen> CellItem::CellItem(int row, int col, int cellSize, QGraphicsItem *parent) : QGraphicsItem(parent), m_row(row), m_col(col), m_cellSize(cellSize) { setPos(col * cellSize, row * cellSize); } QRectF CellItem::boundingRect() const { return QRectF(0, 0, m_cellSize, m_cellSize); } void CellItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) { Q_UNUSED(option); Q_UNUSED(widget); // 1. 背景 painter->fillRect(boundingRect(), m_selected ? QColor("#e8f0fe") : (m_conflict ? QColor("#fdecea") : QColor("#ffffff"))); // 2. 边框:每 3x3 块边界用粗线,其余用细线 QPen pen; pen.setWidth((m_row % 3 == 0 && m_col == 0) || (m_col % 3 == 0) ? 2 : 1); pen.setColor(QColor("#333333")); painter->setPen(pen); painter->drawRect(boundingRect().adjusted(0, 0, -pen.width(), -pen.width())); // 3. 数字 if (m_value != 0) { QFont font = painter->font(); font.setPixelSize(m_cellSize * 0.6); font.setBold(!m_editable); // 题面数字加粗 painter->setFont(font); painter->setPen(m_editable ? QColor("#1a73e8") : QColor("#202124")); painter->drawText(boundingRect(), Qt::AlignCenter, QString::number(m_value)); } }

这里的 paint() 逻辑是核心:先用QColor区分选中和冲突背景,再根据行列位置决定粗线(每 3 行/列起点)或细线;数字绘制时用setBold区分题面(不可编辑)和用户输入(可编辑蓝色)。setPos(col * cellSize, row * cellSize)让每个 item 自己定位,简单且高效。

2.3 什么时候用 QTableWidget 反而更合适

如果你的需求是「做个 Excel 风格的表格式输入」,或者说你只想快速做一个内部用的填数字工具、不打算把代码演化成完整产品,那 QTableWidget 完全够用,而且开发速度快得多。QTableWidget 最大的优势是自带了表格编辑、焦点切换和默认的键盘导航;最大的劣势是候选数展示、自定义绘制、冲突高亮这三件事都会越改越别扭。

一句话建议:二维规则表格 + 自定义视觉状态,选 QGraphicsView;纯数据录入而不关心视觉定制,选 QTableWidget。这个项目我坚定选 QGraphicsView,因为后面所有的进阶功能——高亮行/列/宫、快速填候选、颜色标记——都在自绘的体系里更容易实现。


3. 数独生成与求解:回溯算法和挖洞策略

3.1 求解器:秒解任意唯一解题面的回溯实现

数独生成的根基是一个可靠的求解器。不是让你在界面上做「提示」功能时才需要它,而是生成题面时离不开——你得先随机生成一整个完整终盘(填满数字的 9×9),再 从里面挖掉一些格子,每挖一个就要用求解器验证「挖掉之后是否仍然只有唯一解」。所以求解器的正确性和速度,直接决定你能不能在用户点击「新游戏」时流畅出题。

最稳妥的算法是回溯(backtracking)。对 9×9 数独来说,纯回溯的求解时间通常远小于 1 毫秒,完全没必要用舞蹈链这种高级结构。先看代码:

// solver.h #ifndef SOLVER_H #define SOLVER_H #include <QVector> class Solver { public: // 棋盘用 QVector<int> 存 81 个格子,0 表示空白。 // 返回值表示是否有解;找到第一个解后 board 即被填充为解。 static bool solve(QVector<int> &board); static int countSolutions(QVector<int> &board, int limit = 2); // 数解个数,默认数到 2 就停 private: static bool isValid(const QVector<int> &board, int index, int num); static bool solveRecursive(QVector<int> &board, int pos); static bool countRecursive(QVector<int> &board, int pos, int &count, int limit); }; #endif // SOLVER_H
// solver.cpp #include "solver.h" #include <algorithm> bool Solver::isValid(const QVector<int> &board, int index, int num) { int row = index / 9; int col = index % 9; // 同行、同列 for (int i = 0; i < 9; ++i) { if (board[row * 9 + i] == num && i != col) return false; if (board[i * 9 + col] == num && i != row) return false; } // 同 3x3 宫 int boxRow = (row / 3) * 3; int boxCol = (col / 3) * 3; for (int r = boxRow; r < boxRow + 3; ++r) { for (int c = boxCol; c < boxCol + 3; ++c) { if (board[r * 9 + c] == num && r * 9 + c != index) return false; } } return true; } bool Solver::solveRecursive(QVector<int> &board, int pos) { // 找到下一个空白格 while (pos < 81 && board[pos] != 0) ++pos; if (pos == 81) return true; // 没有空白格了,说明填满,求解成功 for (int num = 1; num <= 9; ++num) { if (isValid(board, pos, num)) { board[pos] = num; if (solveRecursive(board, pos + 1)) return true; board[pos] = 0; // 回溯 } } return false; } bool Solver::solve(QVector<int> &board) { return solveRecursive(board, 0); }

逻辑说明:solveRecursive从位置 0 开始扫描,遇到空格就尝试 1-9;每填一个数都要用isValid检查「这一个数放在这里是否合法」,不合法就跳过。如果某个空格 1-9 全试过都不行,就回退到上一层,把上一格的值换掉再试。这本质上是深度优先搜索,最坏情况理论上有 9^81 种状态,但数独的强约束让实际搜索空间非常小。参数上注意pos + 1的推进方式——不是简单pos + 1,而是下一轮递归会while跳过已填的格子,这样可以避免不必要的递归层级。

3.2 生成完整终盘:先随机填对角线再做「求解式填充」

生成终盘不能靠「随机填 81 个数再检查」,那个搜索空间太大了。常见做法是「分别随机填好三个 3×3 对角线宫,然后调用求解器把其余格子补完」。因为对角线三个宫互不冲突,剩下的空格数只有 54 个,回溯速度非常快。这样做的好处是:每生成一次终盘,都是一个新的随机排列,而不是固定模板。

// generator.cpp 关键片段 #include "generator.h" #include "solver.h" #include <QRandomGenerator> #include <algorithm> static void shuffleNine(QVector<int> &arr) { std::shuffle(arr.begin(), arr.end(), *QRandomGenerator::global()); } QVector<int> Generator::generateFinishedBoard() { QVector<int> board(81, 0); // 1. 准备 1~9 的随机排列 QVector<int> nums = {1,2,3,4,5,6,7,8,9}; shuffleNine(nums); // 2. 填三个对角线宫 (左上、正中、右下) for (int box = 0; box < 3; ++box) { int startRow = box * 3; int startCol = box * 3; for (int i = 0; i < 9; ++i) { int row = startRow + i / 3; int col = startCol + i % 3; board[row * 9 + col] = nums [(box * 3 + i) % 9]; } } // 3. 调用求解器补全 Solver::solve(board); return board; }

参数说明:对角线宫填满后,整个盘面不存在任何行/列冲突,只是宫与宫之间还没有对齐。此时调用求解器,求解器会在约束下快速填完剩余格子。你可能会问:为什么只填对角线,不随机填更多格子?因为每多填一个非对角线格子,都可能引入冲突导致回溯时间变长;对角线宫是所有宫中最容易满足行/列约束的位置,这个策略在工程上最省事。

3.3 挖洞生成题面:从唯一解到难度控制

有了终盘,接下来是「挖洞」(去掉部分数字)。这里最大的坑在于:不是挖得越多越难。挖掉一个数字必须保证题面仍然有唯一解,而且挖洞的位置分布决定了实际解题难度。我用的是「逐个挖洞 + 唯一解验证」的贪心策略:

QVector<int> Generator::generatePuzzle(int cluesToRemove) // 比如想留 30 个数字 { QVector<int> puzzle = generateFinishedBoard(); // 先得到终盘 // 把所有格子的位置打乱 QVector<int> positions(81); for (int i = 0; i < 81; ++i) positions[i] = i; shuffleNine(positions); // 复用随机函数 int removed = 0; for (int idx = 0; idx < 81 && removed < cluesToRemove; ++idx) { int cell = positions[idx]; int backup = puzzle[cell]; puzzle[cell] = 0; // 关键:验证唯一解 if (Solver::countSolutions(puzzle, 2) != 1) { puzzle[cell] = backup; // 不能挖,恢复 } else { ++removed; } } return puzzle; }

Solver::countSolutions的实现思路是:还是走回溯,但每找到一个解就计数,并立即检查是否超过limit(这里传 2,即只要发现有两个解就停止)。性能上,绝大多数情况下,countSolutions数到 2 只需要极短时间,因为盘面里已经挖了不少洞,回溯会很快发散。如果countSolutions(puzzle, 2)返回 2,说明挖掉这个格会导致多解,必须恢复。这是一种保守但稳定的挖洞策略——它不会尝试「一次挖走多个再验证」,因此生成时间完全可预测。

难度控制的参数:留数字越少越难——一般留 28-32 个是「专家」,35-40 个是「中等」,45 个以上是「简单」。但更准确地说,难度不完全取决于留多少个数字,而是取决于挖洞的位置。后续做难度分级时,可以在挖洞序列上多做文章:先挖掉「一眼能看出来唯一解」的格子,保留那些需要链式推理的格,这就要引入候选数分析算法了。对这个项目,先用「留多少数字」来控制难度,足够稳定。


4. 交互与状态管理:从点击格子到撤销重做

4.1 鼠标选中、高亮行/列/宫、键盘填数

QGraphicsView 自带的鼠标事件处理在 QGraphicsItem 上并不直观——如果你需要让格子响应点击,建议在视图层重写mousePressEvent,用QGraphicsScene::itemAt找到对应的 CellItem,而不是在每个 item 内部处理鼠标事件。这样所有格子的选中逻辑就集中在一个函数里,不会出现多个 item 各自维护状态导致的冲突。

// boardwidget.cpp 中重写鼠标事件 void BoardWidget::mousePressEvent(QMouseEvent *event) { QPointF scenePos = mapToScene(event->pos()); // 视图坐标 → 场景坐标 QGraphicsItem *item = scene()->itemAt(scenePos, QTransform()); CellItem *cell = dynamic_cast<CellItem*>(item); if (!cell) return; int row = cell->row(); int col = cell->col(); setSelectedCell(row, col); // 更新选中状态,并通知场景刷新高亮 QWidget::mousePressEvent(event); }

点击后要做三件事:第一,把该格设为选中状态;第二,高亮它所在的整行、整列和所在 3×3 宫(这让用户能快速识别关联格子);第三,更新界面上的提示信息,比如当前格是否题面格、是否可编辑。关于高亮的实现,我会在每个 CellItem 里增加一个bool m_highlighted标记,由视图统一计算哪些格子需要高亮——而不是让每个 item 自己判断,避免重复逻辑。

键盘填数的实现更简单:在BoardWidget上重写keyPressEvent,捕获数字键 1-9、Backspace(清空格)和方向键(移动选中格)。这里有一次性别踩坑的事:BoardWidget必须调用setFocusPolicy(Qt::StrongFocus),否则键盘事件不会发给它。我一开始没有设置,结果发现按数字键毫无反应。

4.2 状态管理:使用 QVector 作为唯一数据源

数独应用最容易写乱的就是状态管理——如果每个格子各自保存自己的值,那么「清除错误标记」「高亮行/列」「判断是否完成」都会变得非常麻烦。我用的模式很简单:整个棋盘只有一个QVector<int> m_board作为唯一数据源,它保存用户当前填写的 81 个数字;同时保留一份QVector<int> m_puzzle作为题目快照,用于区分哪些格子是题面、哪些是用户填的。

// 数据更新的核心函数 void BoardWidget::setCellValue(int row, int col, int value) { int index = row * 9 + col; if (!m_editable[index]) return; // 题面格子不可修改 m_board[index] = value; // 更新数据源 updateCellDisplay(row, col); // 让对应 CellItem 刷新 checkConflict(row, col); // 检查是否与同行/列/宫冲突 checkFinished(); // 检查是否全部填满且合法 }

为什么敢用QVector<int>而不是一个二维数组int[9][9]?因为QVector在传递给Solver的时候天然就是线性内存,你不需要做二维到一维的转换;而且在Generator里生成题面的时候返回的也是QVector<int>,整条链路上数据结构完全一致,少一层转换就少一类 bug。m_editable是另一个QVector<bool>,在载入题面时根据「原题是否为 0」来初始化。

4.3 撤销和重做:不引入复杂框架的栈式实现

撤销重做是数独里用户一定会点的一个功能,而且比大多数人的直觉要麻烦。我采用最简单的策略:两个QVector<int>栈。undoStack存每一步操作前的完整棋盘状态,redoStack存撤销时弹出来的状态。用户每填入一个数字,就把当前完整棋盘快照压入撤销栈;撤销时,把当前状态压入重做栈,然后从撤销栈弹出一个快照作为当前状态。

void BoardWidget::onUndo() { if (undoStack.isEmpty()) return; redoStack.push(m_board); // 当前状态进重做栈 m_board = undoStack.pop(); // 回到上一步 refreshAllCells(); // 让所有格子重绘 } void BoardWidget::onRedo() { if (redoStack.isEmpty()) return; undoStack.push(m_board); m_board = redoStack.pop(); refreshAllCells(); }

这个方案的内存开销是每次快照sizeof(int) * 81 ≈ 324 字节,用户可以撤销上千步也占不了几个 MB,完全不用担心。但有一个关键细节:连续在同一格填多个数字,每一步都要压栈——而不是只压「最后一次」的状态。因为用户在解题过程中很可能「先填一个数,又改成另一个数」,这中间每一步都可能需要撤销回来。如果你只记录最终值,用户会困惑为什么撤销一次就跳回了很远的旧状态。

撤销栈还有一个参数要考虑:栈最大深度。我一般限制在 500 步,超出后从底部弹掉最老的状态,避免极端情况下内存异常增长。实现上用if (undoStack.size() > 500) undoStack.removeFirst();即可。


5. 避坑指南:Qt 数独开发中最常见的 5 个实际问题

5.1 窗口大小没有适配:棋盘被切掉一块

现象:在 1080p 显示器上看起来正常,换到小屏笔记本上棋盘的下半部分或右边被裁切,或者按比例拉伸时格子变成了长方形。

原因:直接把QGraphicsView放在窗口里,没有设置场景矩形或没有适配窗口大小变化。QGraphicsView默认会显示场景内容的左上角,不会自动居中或缩放。

解决:在mainwindow的resizeEvent里手动计算fitInView。

void MainWindow::resizeEvent(QResizeEvent *event) { QMainWindow::resizeEvent(event); if (m_view && m_scene) { m_view->fitInView(m_scene->sceneRect(), Qt::KeepAspectRatio); } }

注意:fitInView必须在场景的sceneRect已经确定之后才能正确工作。我建议在搭建场景时显式设置m_scene->setSceneRect(0, 0, 9 * cellSize, 9 * cellSize);,而不是依赖 item 的隐式边界。否则sceneRect会随着 item 的位置自动计算,但可能包含额外留白,看起来虽然不会切掉,但整体偏小。

5.2 键盘事件无响应:忘记设置焦点策略

现象:棋盘能点击,但按下数字键没有反应,方向键也不动。

原因:QGraphicsView 默认的focusPolicy是Qt::NoFocus,焦点一直在别的控件上。

解决:在BoardWidget构造函数中设置setFocusPolicy(Qt::StrongFocus);。这个坑的隐蔽之处在于:如果你的窗口里没有任何其他可聚焦控件(比如只有一个 QGraphicsView),程序不会崩溃,也不会报错,只是键盘事件仿佛被吃掉一样。排查方法是打印event->key(),如果发现keyPressEvent压根没被调用,先检查焦点策略。

另一个相关的小坑:如果你在CellItem内部也重写了mousePressEvent,点击后焦点不会自动转移到视图。建议统一在视图层处理鼠标事件,并在点击时调用setFocus()确保键盘后续可以输入。

5.3 数独生成死循环或生成时间过长

现象:点击「新游戏」后界面卡住几秒甚至更久,或者生成出来的题目明显比正常题目难得多(挖洞数量异常)。

原因:最常见的原因是countSolutions写错了,导致求解器把无解和多解混在一起处理。比如countSolutions中回溯后没有重置棋盘状态,导致计数时棋盘是脏的;或者limit参数没有在递归中正确传递,导致第二个解已经被找到却还在继续搜索。另一个原因是挖洞策略过于激进——每挖一个就调用一次求解器,但求解器本身效率低(比如在isValid中重复做了行/列检查)。

解决:先用一个已知终盘做回归测试,挖洞前先验证generateFinishedBoard返回的棋盘是一个合法终盘;再验证挖洞过程中countSolutions(puzzle, 2)的结果始终不超过 2。如果你的生成时间超过 200ms,先检查isValid是否有循环冗余,再检查countSolutions是否正确地「找到两个解就快速退出」。

性能优化上还有一个简单技巧:把isValid中的行/列/宫检查改为用三个整型位掩码数组(每行/列/宫用一个 int 表示哪些数字已出现),检查时间从最坏 27 次比较降到 3 次位运算。对于 9×9 数独,这个优化在生成 100 道题的时候能明显感知到差异。

5.4 撤销重做与高亮不同步:界面数据分叉

现象:点击撤销后,棋盘上的格子内容变了,但高亮状态、冲突标记还是旧的样子;或者撤销后有些格子显示不正确。

原因:原始版本为了省事,在每个CellItem中保存了m_value和m_editable,而撤销时只更新了m_board数组,没有同步刷新所有 item。这是典型的多数据源 bug——数据有两份,一份是QVector<int>,一份是 item 内部的m_value,更新只改了其中一份。

解决:统一原则——所有格子的值都从m_board读取,item 内部的m_value只是渲染缓存。每当m_board变化(包括撤销、重做、载入新题),必须调用refreshAllCells(),它遍历所有格子,从m_board取值并调用cell->setValue(...)。这样数据只有一份权威来源,item 永远只是「显示层」。

5.5 题目唯一性验证不够严格:出现的题目有多个解

现象:生成的题目看起来正常,但用户填完后发现不是唯一解,或者某个格子填 4 或 7 都能找到完整解。

原因:挖洞验证时机不对。我最初是在「生成完整终盘」之后一次性挖掉 40 个格子,然后整体调用一次求解器,发现求解器能找到一个解就认为 OK。但完整终盘挖掉 40 个格子后,很可能存在多个解,而求解器只返回了第一个解。判断唯一解必须用到countSolutions(puzzle, 2),绝不能只用solve(puzzle)。

解决:严格按「每挖一个验证一次」的流程走,并在挖完后追加一次整体验证:

bool Generator::verifyPuzzle(const QVector<int> &puzzle) { return Solver::countSolutions(puzzle, 2) == 1; }

这道防线加上之后,基本杜绝了「多解题」问题。另外注意一个边界:如果你把cluesToRemove设置得过大(比如试图挖掉 60 个格子),挖到最后可能会发现所有格子都不能再挖了,因为一旦再挖就出现多解——此时生成器会在removed没达到目标的情况下提前返回。这是正常现象,题面最终留下的数字数比设定值多也是可接受的,不要把这种情况当 bug 处理。


6. 从「能玩」到「有点意思」:候选数提示与难度曲线的三种实战加料

前五章讲的是一个能完整运行的基础数独游戏,但如果你想让这个项目真正在作品集里发光,或者让自己的代码能力再上一个台阶,我强烈建议你给它加三个「进阶调味料」。这三个都不需要大改架构,但每一个都会显著提升项目的完成度。

第一个是候选数自动生成功能。在基础版本里,用户要靠脑子记哪些格能填哪些数;进阶版允许用户点一个「候选模式」按钮,然后空白格中用小号灰字显示所有合法候选数字。实现上并不难:对每个空白格,遍历 1-9,用isValid判断是否能填,能填就显示。这里有个关键参数:候选数字的显示字号一般是实数字的 40% 左右,且颜色要更淡,避免视觉噪音。我实测下来,当候选数超过 4 个时,小字会让界面显得非常拥挤——所以很多商业数独应用只显示「你标记过的候选数」,而不显示全部。你可以加一个开关让用户自己选择。

第二个是难度分级不再只靠留数字多少。前面讲过留 30 个数字和 40 个数字会有难度差异,但真正的高难度题往往留 24-28 个数字就够了。更实用的方案是引入「剪枝深度评估」——统计求解器回溯次数,回溯次数多的题更难。每次生成题面时让求解器计一个「尝试次数」,然后按次数区间分成简单/中等/困难三档。这样做比单纯按留数字数量更准确,而且实现成本非常低——只需要在回溯递归里给一个int &counter参数。

第三个是存档与无痕重开。你需要把当前m_board、m_puzzle、undoStack序列化保存成文件,下次启动时恢复。用QDataStream或QJsonDocument都行,数独状态极小,性能不是问题。关键设计点是:恢复存档时,要保证m_editable也同步恢复(只能根据m_puzzle是否为 0 来判断),否则用户会发现题面格子变得可编辑了。这个功能看起来简单,但它是区分「Demo」和「产品」的一个非常直观的标尺。

最后回到这个项目最值得投入的地方:它不是「Qt 能做什么」的展示,而是「面对一个带约束的状态空间,你怎么拆解数据结构、怎么设计交互、怎么保证数据一致性」的完整演练。我做完这个项目后的一个习惯性动作是:每次写完一个模块,都重新跑一遍「生成 → 挖洞 → 撤销 → 重做 → 存档恢复」这条链路,确保每一步的状态都符合预期。这个习惯帮我筛掉了大量在界面层根本看不出来的逻辑 bug。希望这篇笔记能帮你在两周内搞定一个能拿得出手的数独作品,也帮你少走我当年走过的弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询