
简介这是一份面向C学习者的数独游戏GUI完整项目适合希望将算法、数据结构与界面编程融会贯通的读者也可作为高校C课程设计或GUI入门项目的参考资料。项目实现了标准9x9数独盘面支持用户填入数字、即时合法性检查及一键求解盘面数据以二维数组组织求解核心采用回溯法并辅以约束剪枝能有效排除无用分支。压缩包共9个文件体积仅24KB包含核心C源文件(cpp)和Visual C工程文件(dsw/dsp)以及pch预编译头、pdb/idb调试信息、opt/dsp工程配置等辅助文件结构十分紧凑。目前已有360人学习下载。通过研读源码可重点学习数独模块划分、GUI事件驱动编程、异常输入处理、游戏进度写入文件等实战技巧对建立图形化程序完整开发流程的认识很有帮助。1. 数独游戏编程C 与 GUI 的组合为什么值得照着做一遍数独这个游戏本身不复杂9x9 的格子、1 到 9 的数字、三条约束规则但把它做成一个 C GUI 程序涉及的却是完整的一套桌面应用开发路径算法设计生成盘面与求解、控件交互输入与校验、界面刷新冲突高亮与状态反馈。很多初学者写数独停在黑底白字的控制台版本用二维数组打印棋盘能跑但谈不上“产品”。而一个带 GUI 的数独游戏哪怕界面再朴素也要解决「用户填错怎么提示」「题目从哪来」「新游戏难度怎么分」这些真实问题——这些问题在控制台版本里几乎不存在。这篇笔记就沿着「生成 → 求解 → 界面 → 联动 → 避坑」的顺序把一条能跑通的最小路径讲清楚。适合手里有 C 语法基础、想用一个小项目串起 Qt 或 Win32 GUI 开发的读者照做一遍也适合准备课程设计、想找一个能讲明白的项目的人参考。2. 先让算法站住生成唯一解的盘面是数独 GUI 的地基GUI 只是壳真正的灵魂在「生成一局有且仅有一个解的题」。这一章先把算法逻辑讲透因为后面界面的每一个交互全部建立在「当前盘面必然可解」这个前提上。2.1 为什么必须先求解后生成别用随机填充碰运气我见过不少初学方案是这么生成题目的随机往 9x9 里填数字填完检查行、列、宫是否有冲突有冲突就重填。这个做法在盘面稀疏时比如只填 20 个数字勉强能跑但生成出来的根本不算数独题目——它大概率有多个解用户填到后面会发现「我这里填 3 和填 7 都能过」整个游戏的逻辑就崩了。正确顺序一定是「先生成一个完整终盘再根据难度挖掉若干数字并且每挖一个数字都验证唯一性」。终盘保证了解的存在性唯一性靠挖洞时的逐次校验来保证。反向思考一下就知道为什么如果先随机挖洞再判断是否有解判断成本极高因为要跑完整棵搜索树而先有终盘再挖洞每挖一个只要跑一次求解器算出一个解即可随后数一下解的数量是否大于 1大于 1 就说明挖多了。所以第一步是「生成终盘」。常见做法是回溯填充从左到右、从上到下扫描格子尝试填入 1 到 9每填一个检查行、列、宫是否冲突。这个算法看起来简单但有个性能陷阱我在 2.2 里具体说。2.2 回溯求解器的四个关键设计搜索顺序、剪枝、恢复、计数求解器是整个项目的核心组件生成盘面、验证唯一性、用户求助「提示」都要调它。我一般用一个函数一次搞定「求一个解」和「求所有解的数量」两个需求通过传引用计数器实现。代码不长但每一行都有讲究#include vector class SudokuSolver { public: // board: 9x90 表示空格。传入引用直接修改为解。 // count_limit: 超过该数量即停止搜索用于唯一性校验。 bool solve(std::vectorstd::vectorint board, int count_limit 2, int* solution_count nullptr) { int count 0; bool result backtrack(board, count, count_limit); if (solution_count) *solution_count count; return result; } private: // 判断在 (row, col) 填入 val 是否合法 bool isValid(const std::vectorstd::vectorint board, int row, int col, int val) { for (int i 0; i 9; i) { if (board[row][i] val) return false; // 行检查 if (board[i][col] val) return false; // 列检查 } int box_row row / 3 * 3; int box_col col / 3 * 3; for (int i 0; i 3; i) for (int j 0; j 3; j) if (board[box_row i][box_col j] val) return false; return true; } bool backtrack(std::vectorstd::vectorint board, int count, int count_limit) { if (count count_limit) return true; // 超出限制提前终止 // 找到第一个空格 int row -1, col -1; for (int i 0; i 9; i) { for (int j 0; j 9; j) { if (board[i][j] 0) { row i; col j; break; } } if (row ! -1) break; } if (row -1) { // 没有空格了找到一个解 count; return count count_limit; } for (int val 1; val 9; val) { if (isValid(board, row, col, val)) { board[row][col] val; if (backtrack(board, count, count_limit)) return true; board[row][col] 0; // 回溯恢复 } } return false; } };先说count_limit的作用校验唯一性时传 2求解器找到一个解后继续找第二个一旦找到第二个就返回true不再往下搜——这就是「发现多解立即止损」。如果全程只找到 1 个解说明当前盘面唯一。这个机制比「先求一个解再把这个解禁掉求另一个」干净得多后者要在代码里维护「禁掉哪个数字」的状态容易出错。然后是「找第一个空格」这个逻辑。我按行优先扫描这在稀疏盘面上效率尚可但密集盘面下有个优化空间每次递归都从头扫描 81 个格子白白浪费 CPU。更好的做法是维护一个空格坐标列表按候选数数量排序后优先填候选数少的格子——也就是 MRVMinimum Remaining Values启发式。但博弈论上有个平衡对 9x9 标准数独MRV 优化的收益不如 16x16 那么明显而且代码复杂度上升不少。我建议第一版用「顺序扫描」跑通了再考虑优化别一步到位。还有一个细节board[row][col] 0这行是回溯恢复。很多人漏掉它导致搜索到后面盘面被填得乱七八糟解不出来。这个「填了没恢复」的问题比任何性能问题都隐蔽。2.3 挖洞生成题目唯一解校验不是每次都要跑全量有了完整终盘挖洞的思路是把 81 个格子随机打乱顺序按顺序尝试挖掉每个数字每挖一次调用求解器校验唯一性如果仍唯一就保留否则把数字填回去。这个策略能保证挖出的洞数足够多且每一步都合法。有个实用的优化唯一性校验时不必每次都从头求两个解。可以在挖洞前先维护一份「空格列表」每次挖洞后求解器只会在当前这些空格上搜索当解找到两个时立刻停止。配合count_limit2绝大部分盘面在搜索树的较早层级就能判定多解速度非常快。我实测过普通 PC 上生成一局困难题约 50 个洞耗时在 10 毫秒级完全不需要引入 Dancing Links 那种复杂结构。难度分级也在这层做。简单粗暴的方案是按挖洞数量分简单挖 30~35 个中等挖 40~45 个困难挖 50~55 个。但这个方案有缺陷——挖洞数相同的两个盘面难度可能差异巨大。更准确的难度指标是「解出这道题需要用到几层逻辑」比如唯一候选数、隐性唯一候选数、数对摒除、X-Wing 等。但实现这些逻辑推理函数的工作量远大于回溯求解器所以很多商业数独 App 也只是「近似分级」靠挖洞数 少量校验比如从空盘开始用回溯求解统计回溯次数来估算难度。我自己的做法是挖洞后记录求解器的回溯次数回溯次数低于某个阈值判为简单高于阈值判为困难中间为中等。这个方法虽然不是严格的人类逻辑难度但实现成本极低且能保证「同一难度档次的题目耗时相近」。3. 画一个能填数的 9x9 棋盘Qt Widgets 的选型与布局算法层跑通后进入 GUI 层。这里的核心决策不是「用什么控件」而是「每个格子是独立控件还是整体绘制」。选错了后面的交互代码会越写越别扭。3.1 为什么用 QLineEdit 而不是 QTableWidget我见过很多初学者用QTableWidgetQStyledItemDelegate来画数独棋盘理由是「表格天然是格子」。这个思路看似合理但用起来坑很多第一表格的单元格选中状态、焦点框、编辑触发器和数独的输入逻辑是冲突的——你要拦截键盘事件去判断「按 1 到 9 才接受其他键忽略」在 TableView 里要写事件过滤器还要处理编辑状态的进入和退出非常绕第二数独棋盘在视觉上有「粗线分宫」的需求表格默认只有细线要自定义绘制边框。我的选择是QGridLayout放 81 个QLineEdit每个格子就是一个独立输入框。这样每个格子的输入校验直接写在QLineEdit的子类里粗线分宫用setStyleSheet给不同位置的格子设置不同的边框即可逻辑和视图的边界非常清晰。#include QLineEdit #include QRegularExpressionValidator class SudokuCell : public QLineEdit { Q_OBJECT public: SudokuCell(int row, int col, QWidget* parent nullptr) : QLineEdit(parent), row_(row), col_(col) { setMaxLength(1); setAlignment(Qt::AlignCenter); // 只允许输入 1-9 的数字 QRegularExpressionValidator* validator new QRegularExpressionValidator(QRegularExpression([1-9]), this); setValidator(validator); setFixedSize(48, 48); } int row() const { return row_; } int col() const { return col_; } private: int row_, col_; };关键点在setValidator它接受QRegularExpression校验后的输入[1-9]意味着用户按 0 或者字母时输入直接被拒绝不需要写额外的事件拦截代码。setMaxLength(1)保证一个格子只存一个字符。setFixedSize固定格子尺寸这样QGridLayout的尺寸就是确定的 9x9 网格不会因为窗口拉伸而变形。整体装配在一个自定义的BoardWidget里完成#include QGridLayout class BoardWidget : public QWidget { Q_OBJECT public: BoardWidget(QWidget* parent nullptr) : QWidget(parent) { auto* layout new QGridLayout(this); layout-setSpacing(0); // 格子间不留缝隙 for (int i 0; i 9; i) { for (int j 0; j 9; j) { auto* cell new SudokuCell(i, j, this); // 粗线分宫在宫边界处设置 2px 边框 QString style border: 1px solid #999;; if (j % 3 0) style border-left: 2px solid #333;; if (i % 3 0) style border-top: 2px solid #333;; if (j 8) style border-right: 2px solid #333;; if (i 8) style border-bottom: 2px solid #333;; cell-setStyleSheet(style); layout-addWidget(cell, i, j); } } // 外层再包一层边框让棋盘有完整的外轮廓 setStyleSheet(BoardWidget { border: 2px solid #333; }); } };layout-setSpacing(0)很关键——如果间距非 0格子中间的缝隙会透出底色的白线视觉上棋盘会「碎掉」。边框用样式表按宫边界设置2px其他位置1px视觉上自然形成 3x3 的粗线分区。这里没有用setContentsMargins的额外处理是因为QGridLayout默认 margin 为 0正好满足需求。3.2 输入冲突的实时高亮信号槽方案与重绘策略棋盘能填数字只是第一步真正的体验差别在于「填错时有没有提示」。我推荐的做法是用户每输入一个数字检查行、列、宫三个维度是否有重复如果有把冲突的格子背景标红。这里有个性能上的讲究每次输入只影响「当前格、当前行、当前列、当前宫」这 4 个集合不需要全盘遍历。但实现时如果偷懒全盘遍历也就 81 个格子性能完全无感。所以不必过度优化重点在于「把冲突信息存起来」——我用一个std::setstd::pairint,int存冲突位置每次校验后更新它然后驱动界面刷新。界面刷新有两条路一是QLineEdit的setStyleSheet直接改背景色二是重写paintEvent统一绘制。前者简单直接但有一个副作用——修改样式表会触发整个控件的重新布局和绘制81 个格子还好如果是更大的网格会有可感知的闪烁。后者代码量大先不推荐。我用前者配一个「只在冲突状态变化时更新样式」的小优化void BoardWidget::onCellTextChanged(int row, int col, const QString text) { // 清除旧的冲突标记 conflicts_.clear(); // 重新计算整盘冲突 for (int r 0; r 9; r) { for (int c 0; c 9; c) { SudokuCell* cell cellAt(r, c); if (cell-text().isEmpty()) continue; int val cell-text().toInt(); if (hasConflict(r, c, val)) { conflicts_.insert({r, c}); } } } // 更新所有格子的样式 for (int r 0; r 9; r) { for (int c 0; c 9; c) { SudokuCell* cell cellAt(r, c); bool has_conflict conflicts_.count({r, c}) 0; cell-setProperty(conflict, has_conflict); // 刷新样式触发重绘 cell-style()-unpolish(cell); cell-style()-polish(cell); } } }unpolishpolish是 Qt 样式系统里的「强制刷新样式」组合拳。因为setProperty改变了动态属性但样式表里[conflicttrue]选择器的重新匹配不会自动发生必须手动触发。这个细节很容易踩坑写完setProperty发现背景色没变其实就是因为缺了这两行。如果想把代码收得更干净可以包一个updateCellStyle(SudokuCell*)函数把 unpolish/polish 封装进去。注意style()返回的是当前控件的样式对象unpolish通知它丢弃旧样式polish让它按新属性重新计算顺序不能反。4. 把算法和界面接起来从“能填空”到“能玩”的完整交互这一章做的是「连接」工作生成题目、填入棋盘、用户填数、冲突提示、胜利判定、新游戏、提示按钮。代码量不大但这是整个项目里最容易写乱的部分。4.1 游戏状态机生成题目的入口与三色格子状态游戏运行中每个格子有三种状态题目格预置数字、用户格用户填了数字且无冲突、错误格用户填了数字但有冲突。三种状态下格子的行为完全不同——题目格不能编辑且背景色固定错误格背景标红正常用户格可以反复修改。状态机的数据模型我建议用一个枚举存着enum class CellState { Given, // 题目预置数字 User, // 用户填入 Error // 用户填入但有冲突 };不把状态存到SudokuCell里而是存到一个std::vectorstd::vectorCellState理由是界面可能要「重绘整个棋盘」比如新游戏时如果状态分散在每个控件里重建棋盘时要一个个读回来麻烦且容易漏。数据与界面分离后面的逻辑会清爽很多。新游戏按钮的完整动作是void MainWindow::startNewGame(Difficulty level) { // 1. 生成终盘 std::vectorstd::vectorint solution; generateFullBoard(solution); solution_ solution; // 2. 挖洞得到题目 std::vectorstd::vectorint puzzle solution; digHoles(puzzle, level); // 按难度挖洞内部保证唯一解 // 3. 重置棋盘控件 for (int r 0; r 9; r) { for (int c 0; c 9; c) { SudokuCell* cell cellAt(r, c); if (puzzle[r][c] ! 0) { cell-setText(QString::number(puzzle[r][c])); cell-setReadOnly(true); // 题目格不可编辑 states_[r][c] CellState::Given; } else { cell-clear(); cell-setReadOnly(false); // 空白的由用户填 states_[r][c] CellState::User; } } } // 4. 清空旧冲突标记 conflicts_.clear(); }setReadOnly是区分「题目格」和「用户格」的关键接口。题目格设为只读用户输入时QLineEdit根本不允许修改。这也顺带解决了一个隐蔽问题如果题目格可编辑用户把预置数字改了后面所有的算法逻辑全部错乱。generateFullBoard的实现就是 2.2 的求解器跑一个空盘——把空盘交给solve返回的就是一个完整终盘。这里有个取巧不需要单独写生成终盘的算法复用求解器即可。唯一要注意的是随机性如果求解器按1..9顺序尝试填入每次生成的终盘都是同一张。要洗牌把尝试顺序变成随机排列我放在第 6 章讲。4.2 信号连接与输入拦截为什么空格之外的键必须拦掉用户交互主要通过两个信号textChanged和returnPressed。textChanged在每次输入时触发用于冲突校验returnPressed在回车时触发用于跳到下一格。连接方式如下// 在搭建棋盘时把每个格子的 textChanged 连到同一个槽 for (int i 0; i 9; i) { for (int j 0; j 9; j) { SudokuCell* cell cellAt(i, j); connect(cell, QLineEdit::textChanged, this, [this, i, j](const QString text) { onCellTextChanged(i, j, text); }); } }这里用 Lambda 捕获i, j避免写 81 个独立连接。onCellTextChanged内部先判断当前格是不是题目格——是题目格说明textChanged是程序自己setText触发的比如新游戏时填充题目不需要做冲突校验不是的话才执行校验和状态更新。还有一个细节textChanged在setText时也会触发所以「新游戏填充题目」时题目格的textChanged会被连发 81 次。如果不做题目格判断这个环节会白白执行几十次校验。上面的代码已经规避了这个问题但很多初次实现的同学想不到。另一个更好但更重的方案是先blockSignals(true)再批量填充填充完后blockSignals(false)——这适合题目格很多的情形。IDE 环境方面如果还在纠结用什么写 Qtvscode配置c/c环境之后装 Qt 扩展如 Qt for VS Code就行配好CMake构建链。要注意 Qt 的 CMake 包路径要写对不然find_package(Qt6 REQUIRED COMPONENTS Widgets)这行会直接报错这类问题在 Windows 上用qt-cmake预设脚本能少花不少时间。4.3 胜利判定三种判法我推荐「空格数归零 冲突为 0」最简单的胜利判定是「所有格子都填了数字」但这不够——用户可能填了但填错了。我见过有项目只判满格导致用户把错题「填完」后弹恭喜体验非常糟糕。正确做法是把「空格为 0」和「冲突为 0」同时满足才算赢。实现时我在onCellTextChanged的校验逻辑末尾加上统计auto checkWin [this]() { int empty_count 0; for (const auto row : board_) { for (int val : row) { if (val 0) empty_count; } } if (empty_count 0 conflicts_.empty()) { QMessageBox::information(this, 完成, 恭喜你解出了这道题); // 启动计时器停止、弹出统计信息等 } };这个 lambda 放在onCellTextChanged末尾执行省掉额外的事件。还有第三种判法每填一个格就调一次求解器看当前盘面是否等于终盘。这当然最精确但没必要——冲突校验已经保证「每个时刻的盘面合法」只要空格填满且无冲突必定等于终盘。5. 数独 GUI 开发的 5 个典型坑现象、原因、解决这个项目我在不同环境里做过几遍也帮别人改过课程设计踩过的坑集中在下面几个位置。每一条都是「现象 → 原因 → 解决」的结构对照着自己代码排查即可。5.1 生成的题目解不唯一用户填到后面发现两条路都能走现象盘面看起来正常但用户填到某一步时同一个空格填 3 或填 7 都不触发冲突最后两个数字来回换都能「完成」。原因挖洞时只判断了「是否有解」没有判断「解是否唯一」。有的实现用solve找到一个解就认为成功完全没意识到多解问题的存在。解决所有挖洞逻辑里唯一性校验必须传count_limit2并且solve返回后检查解的数量是否大于 1。如果大于 1把当前挖掉的洞填回去。这个校验要放在「每次挖洞之后」而不是「一批洞挖完之后」——后者一旦发现多解前面已经挖掉的一堆洞都不知道是谁导致的只能整盘重来。5.2 Qt 中文输入法下输入一个字母会触发两次 textChanged现象用拼音输入法输入数字时候选框弹出textChanged被触发两次有时候输入的 5 变成了一串「5 5」或者完全没输入成功。原因QLineEdit的textChanged在输入法组合字符串变化时也会触发而这个信号变化的内容不是最终的提交文本。如果槽函数里直接拿text().toInt()做校验就会吃到中间态。解决不监听textChanged改监听editingFinished回车或失焦时触发或者监听textEdited只由用户编辑触发程序setText不触发。textEdited的信号参数是「当前可见文本」输入法组合期间的中间态不会进入这个信号。如果仍用textChanged务必加一个防抖判断——记录上一次接受的值相同则跳过。5.3 新游戏时棋盘上有残留数字现象点「新游戏」后上一局的数字还在棋盘上或者上一局的错误红色标记残留了一部分。原因startNewGame里只处理了「需要填数字的格子」忘了把上一局用户填过的格子清空。或者清空了格子但conflicts_集合还留着旧数据。解决startNewGame开头就把conflicts_.clear()和格子全清空然后重新填题目。更稳妥的做法是把「重置棋盘」抽成一个独立函数新游戏、游戏结束、加载存档时都调用同一个重置入口避免散落多处的清理逻辑。5.4 高分屏 / HiDPI 下棋盘模糊格子看起来像隔了一层纱现象界面在 2K/4K 显示器上文字发虚边框粗细不一。原因没有设置高分屏缩放策略。Qt 6 默认启用缩放但如果你在main()里手动设置了Qt::AA_EnableHighDpiScalingQt 5 的做法反而可能和系统缩放叠加出问题。解决Qt 6 下不要手动启用高 DPI 相关属性去掉AA_EnableHighDpiScaling让 Qt 自己处理。如果仍有问题检查main.cpp里的QApplication::setHighDpiScaleFactorRoundingPolicy默认PassThrough在 Windows 上可能导致非整数缩放比例改成Round能减轻模糊。边框模糊的另一个原因是2px在 1.25 倍缩放下变成 2.5 物理像素Qt 会做近似处理可以改用1px 粗宫线用3px让缩放后的像素对齐更自然。5.5 CMake 构建时找不到 Qt6Widgets报错提示 framework not found现象find_package(Qt6 REQUIRED COMPONENTS Widgets)报错或者链接时提示cannot find -lQt6Widgets。原因环境变量CMAKE_PREFIX_PATH没指到 Qt 的安装目录CMake 找不到 Qt6Config.cmake。解决用cmake -DCMAKE_PREFIX_PATHC:/Qt/6.7.2/msvc2019_64 ..显式指定路径或者在 CMakeLists 开头写set(CMAKE_PREFIX_PATH C:/Qt/6.7.2/msvc2019_64)。注意别把路径写到PATH环境变量里——CMake 只认CMAKE_PREFIX_PATH不认系统 PATH。如果你用的是 Visual Studio 的开发者命令行建议直接运行 Qt 自带的Qt 6.7.2 (MSVC 64-bit)快捷方式启动它会自动配好编译器路径和 Qt 路径省掉这些环境问题。6. 让题目真的“随机”洗牌算法与批量出题验证最后一个技巧章因为这是几乎每个做完前面功能的人都会遇到的下一个需求为什么每次点「新游戏」出来的题目都差不多答案在生成终盘的随机性上而不是挖洞的随机性上——挖洞随机只是决定挖哪几个位置终盘本身不变的话题目结构永远相似。6.1 给回溯求解器加随机洗牌把 1-9 的尝试顺序打乱在 2.2 的代码里for (int val 1; val 9; val)是固定的这导致每次生成的终盘都是同一个「字典序」解。要打乱只需把尝试顺序改成随机排列#include algorithm #include random void shuffleValOrder(int order[9]) { for (int i 0; i 9; i) order[i] i 1; std::mt19937 rng(std::random_device{}()); std::shuffle(order, order 9, rng); }然后在backtrack里把for (int val 1; val 9; val)换成int order[9]; shuffleValOrder(order); for (int i 0; i 9; i) { int val order[i]; if (isValid(..., val)) { // 填充与回溯逻辑不变 } }std::shuffle是 C11 的标准库算法配std::mt19937梅森旋转引擎提供高质量随机数。std::random_device{}()用于播种每次程序启动后种子不同生成的题目序列就不同。这里有个性能认知在backtrack里每个空格都重新 shuffle 一次开销很小就 9 个元素的排列常数时间完全不需要担心性能。6.2 批量验证10 秒内生成 100 题检查无重复写完随机逻辑后最值得做的一件事是「批量出题验证」。这不光是检验算法正确性也是打磨难度分级参数的过程。我在开发时写了一个validateBatch(size)测试函数生成size道题把每道题的题目盘面序列化后存入std::set统计重复数量再对每道题调用求解器确认唯一解。一段可用的验证逻辑void validateBatch(int count, Difficulty level) { std::setstd::string seen; int duplicate 0, invalid 0; for (int i 0; i count; i) { std::vectorstd::vectorint solution; // 这里复用 generateFullBoard digHoles generateFullBoard(solution); auto puzzle solution; digHoles(puzzle, level); // 序列化为字符串比较是否重复 std::string key; for (auto row : puzzle) for (int v : row) key (v 0 ? . : char(0 v)); if (!seen.insert(key).second) duplicate; // 校验唯一解 int solution_count 0; SudokuSolver solver; solver.solve(puzzle, 2, solution_count); if (solution_count ! 1) invalid; } // 打印统计结果 }批量验证有一些实际心得100 道题的生成时间应该在 10 秒以内如果明显超时优先检查count_limit2是否生效——有的实现里调solve没传count_limit导致每道题都求了全量解耗时自然爆炸另外「重复」通常是「终盘 变换」的结果如果只洗数字不洗行列两道题的差异只是在宫块内部换行这种相似在用户体验上非常出戏但因为盘面数据结构不同字符串比较不会判重。所以如果想做「真正的随机」还要对终盘做全排列变换交换整个行组3 行一组内部换序、交换列组、数字置换、90 度旋转、水平/垂直镜像。这些变换数学上可以组合出超过 6 万亿种不同题目体验上「每次都是全新」的观感非常强。感兴趣的可以在验证函数里加上「只做终盘变换、不重新生成」的批量流程出题速度会快一个数量级。收个尾。我自己的习惯是每次改完算法或交互都先跑一遍 200 题批量验证确认没有多解也没有重复再打开界面玩一局困难题。这个「先验证后体验」的顺序帮我省了太多在界面上发现问题再回头翻算法的冤枉路。人的直觉在视觉上找问题还行但「生成的题稳不稳」这种事机器统计永远比眼睛可靠。希望这篇笔记里的代码和思路能帮你把数独这个经典项目做得扎实又顺手。本文还有配套的精品资源点击获取