简介:这是一份基于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 到 50 | 31 到 36 | 第一次接触数独的小朋友 |
| 中等 | 36 到 40 | 41 到 45 | 日常练手、演示项目 |
| 困难 | 28 到 32 | 49 到 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需要 C++11 的<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抽成独立模块,之后所有新功能都先过一遍上面的自测清单。这种教训用一次就长记性:数独游戏的判断标准不是"能点会动",而是"生成的每一盘题只有一个答案"。希望这篇文章能帮你少走这段弯路,也希望你自己动手改一遍后,能对生成、求解、界面联动这套链路有更踏实的体感。
本文还有配套的精品资源,点击获取