1. 数独生成算法:先想明白“要什么”再动手
1.1 数独谜题的三条硬性规则
做数独App,第一个绕不开的问题就是:谜题从哪里来?如果只是做Demo,硬编码几张盘面当然能跑,但一个真正能上架的产品,每天要消耗大量新关卡,谜题生成器就是核心引擎。开始写任何生成逻辑之前,我建议你先把数独的规则在代码层面翻译一遍,三句话就能说清:
- 行约束:每一行必须包含1~9,且不能重复。
- 列约束:每一列必须包含1~9,且不能重复。
- 宫约束:每个3x3小宫格内必须包含1~9,且不能重复。
一个完整的数独终盘,就是同时满足这三条约束的9x9矩阵。而数独谜题,本质上是从这样一个终盘里“挖掉”一部分数字,并保证剩下的局面只有唯一解。请注意“唯一解”这三个字,它是谜题生成算法和普通矩阵填充的最大区别。很多人第一次写生成器,只实现了“生成一个合法终盘”,却没有做唯一解校验,导致玩家玩到一半发现有两种填法都能通过,这是产品事故级别的体验缺陷。
关于难度分级,行业里通常用“剩余已知数个数”作为初步指标:简单级一般留38~42个数字,中等难度留30~34个,困难难度留24~28个。但这只是粗粒度分类,真正决定难度的是挖洞位置和求解路径的复杂度,后面我会展开讲。
1.2 生成算法的主流路线对比
数独生成算法从思路上大致可以分成三条路线,每条我在开发中都试过,直接说结论和使用感受。
路线一:纯随机填充 + 回溯求解。 从一个空格子开始,随机尝试填数字,遇到冲突就回溯,填满整个9x9后得到一个随机终盘,再随机挖洞。这种做法的优点是代码量最少,理解起来最直观。缺点是性能和随机性不可控——纯随机回溯最坏情况下可能跑上千次尝试才能填满一行,即使加了一些启发式规则,生成一个终盘的平均耗时也明显偏长。在OpenHarmony的低端开发板上,我实测过纯回溯方案生成一张中等难度盘面平均需要180~300毫秒,虽然单次看起来不多,但批量生成关卡时,体验就比较捉急。
路线二:预置终盘 + 转换打乱。 先内置一个已知合法的标准终盘,然后通过“交换数字符号”“交换行/列”“旋转镜像”等操作,派生出大量不同的合法终盘。然后用求解器反向挖洞。这条路线非常稳定,因为所有变换都保持了解存在的必然性——标准终盘是合法的,变换后的终盘也一定是合法的。性能很好,生成耗时可以降到10毫秒以内。缺点是需要额外维护一组“种子终盘”,而且如果你只用一个种子,打乱后的盘面在高手眼里会有“模式感”,玩家会隐约察觉到难度分布异常。
路线三:基于拉丁方阵的数学构造法。 用组合数学的方式,按规则生成一个3x3的宫格布局,再把每行错位排列,直接构造出合法终盘。这条路线我在论文里见过很多次,数学上很漂亮,生成速度极快。但工程上有两个麻烦:第一,构造算法虽然能保证终盘合法,但挖洞后是否唯一解仍需自行校验;第二,它的随机性分布虽然均匀,但要控制“谜题美感”需要额外写规则。所以实际项目中,用它的人反而不多。
综合对比下来,我最终选择了路线二的变体——内置多个种子终盘,用交换变换打乱,再配合一个高效的求解器做唯一性校验和挖洞。理由很简单:性能快、代码相对可控、后续扩展难度分级也方便。
1.3 预置终盘为什么是工程上的“最优解”
很多刚从算法教程走出来的开发者会纠结:预置终盘是不是不够“原汁原味”?我的看法是,产品要的是体验,不是数学论文。用预置终盘 + 变换,有三个非常现实的好处。
第一个好处是生成耗时可控。数独生成通常发生在关卡切换时,如果卡了300毫秒,玩家能明显感知到卡顿。而预置终盘的变换操作只是数组换位和数值映射,几乎不涉及搜索,耗时可以压到1毫秒级别。
第二个好处是方便做难度曲线。你可以针对每个种子终盘,提前标注好哪些位置的数字在挖洞后才能形成“高难度分支”,这样在生成不同难度关卡时,可以选择性地保留或挖掉这些关键位置,难度控制更精准。纯随机方案很难做到这一点。
第三个好处是可测试性。每次生成都可以基于同一个种子做确定性复现,测试时只要固定随机种子,就能稳定复现同一个Bug。这在纯随机方案里是做不到的,调试成本会高很多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 预置终盘 + 变换打乱:一步步写出生成器核心逻辑
2.1 从一个合法终盘出发
先定义一个标准终盘。最简单的方式是直接硬编码一个,我工程里用的种子是这样的:
dart复制const List<List<int>> seedBoard = [
[1, 2, 3, 4, 5, 6, 7, 8, 9],
[4, 5, 6, 7, 8, 9, 1, 2, 3],
[7, 8, 9, 1, 2, 3, 4, 5, 6],
[2, 3, 4, 5, 6, 7, 8, 9, 1],
[5, 6, 7, 8, 9, 1, 2, 3, 4],
[8, 9, 1, 2, 3, 4, 5, 6, 7],
[3, 4, 5, 6, 7, 8, 9, 1, 2],
[6, 7, 8, 9, 1, 2, 3, 4, 5],
[9, 1, 2, 3, 4, 5, 6, 7, 8],
];
这个盘面不是随便写的,它满足一个非常好的性质:每一行都是1~9的循环排列,行与行之间是固定的偏移量。它保证了所有行、列、宫都合法,后续做变换时不会意外破坏合法性。为了增加多样性,我在工程里准备了12个这样的种子盘,每个种子的偏移规律都不同,这能有效避免“盘面模式感”问题。
2.2 变换打乱:为什么交换行列不会破坏合法性
有了种子终盘之后,下一步是让它“看起来不像同一个盘”。这里用到四种变换,全部是合法的“同构变换”——也就是说,变换后的盘面一定仍然满足数独规则。
第一种是数字符号交换。把整个盘面中的数字按一个随机映射替换,比如把所有1变成5、所有5变成3。因为行、列、宫的都是“不能重复”约束,数字符号本身是等价的,所以这种变换永远是安全的。
第二种是行内交换。在同一行带内交换两行。这里要强调“同一行带内”:因为数独的行被3个一组分成上中下三个“带”,第1~3行之间任意交换不会破坏宫约束,第4~6行之间、第7~9行之间同理。但如果跨带交换,比如交换第1行和第4行,虽然行约束和列约束还满足,但宫约束会立刻被破坏。
第三种是列内交换。和行内交换完全同理,只能在“左中右三个列带”内部交换。
第四种是旋转和镜像。把整个盘面旋转90度、180度、270度,或做水平/垂直/对角线翻转,这些操作在几何上等价于把行列重新排列,所以也保持合法性。
用Dart实现起来比较直接:
dart复制List<List<int>> shuffleBoard(List<List<int>> board, Random rng) {
// 第一步:数字符号映射
var digits = List<int>.generate(9, (i) => i + 1)..shuffle(rng);
var mapped = List<List<int>>.generate(9, (i) =>
List<int>.generate(9, (j) => digits[board[i][j] - 1]));
// 第二步:行带内交换
for (var band = 0; band < 3; band++) {
for (var i = 0; i < 3; i++) {
var r1 = band * 3 + i;
var r2 = band * 3 + rng.nextInt(3);
var tmp = mapped[r1];
mapped[r1] = mapped[r2];
mapped[r2] = tmp;
}
}
// 第三步:列带内交换(代码同第二步,只操作列)
// 第四步:随机旋转/镜像,代码略
return mapped;
}
我在实际开发中,特意把变换次数固定为“数字映射一次 + 行内交换6次 + 列内交换6次 + 随机旋转镜像一次”。这个次数不是拍脑袋定的。变换次数太少,盘面容易看出种子之间的相似性;次数太多,耗时增加但不增加有效性——反正超过一定次数后,随机性不会更“随机”。实测中这个组合能保证相邻两次生成的盘面完全不重样。
2.3 挖洞策略:从“留几个数”到“保证唯一解”
有了合法终盘,接下来是挖洞。这是生成算法里最值得抠细节的地方,也是最容易踩坑的。
最粗暴的方案是纯随机挖掉N个格子,挖完就完事。但这么做有两个问题:第一,可能挖出多解盘面;第二,可能挖完的盘面根本没法根据已知数推断出标准答案——这对玩家来说就是一道错题。所以我采用的方案是“逐个挖洞 + 每步唯一解校验”:
- 把81个格子随机排序,作为候选挖洞顺序。
- 按顺序尝试挖掉一个格子,每挖一个就用求解器判断当前局面是否仍有唯一解。
- 如果有唯一解,保留这个洞,继续挖下一个;如果不是唯一解,撤销这次挖洞,继续尝试下一个候选格子。
- 当已挖掉的数量达到目标难度对应的数量,或者候选格子全部尝试完毕,停止。
这个方案的优点是“边挖边验证”,不会出现最终盘面多解或矛盾的情况。缺点是每挖一个洞都要跑一次求解器,耗时偏高。但因为我们用的是回溯求解器且做了剪枝优化,挖完一整张中等难度盘面,总耗时控制在50毫秒左右,完全可以接受。
关于目标挖洞数量,我用一个简单的映射表:
| 难度 | 保底已知数 | 目标挖洞数 |
|---|---|---|
| 简单 | 40 | 41 |
| 中等 | 32 | 49 |
| 困难 | 26 | 55 |
| 专家 | 22 | 59 |
注意,挖洞数只是一个初始目标。实际操作中,如果某个格子挖掉后破坏唯一解,会跳过它继续尝试,所以最终的已知数可能比目标值多几个。这不影响难度,反而因为挖洞点分布更自然,玩家体感难度和标称难度会更接近。
3. 数独唯一解求解器:生成器的“质检员”
3.1 回溯求解器:最可靠但必须做剪枝
数独求解器是生成器的“质检员”,每次挖洞后都必须用它判定当前局面是否唯一解。求解器本身不算复杂,但性能直接决定了生成器的体验。我写的是一个带剪枝的回溯求解器,核心思路是“优先从候选数最少的空格开始填”。
dart复制bool solveSudoku(List<List<int>> board, int limit) {
int bestRow = -1, bestCol = -1, minCandidates = 10;
for (var i = 0; i < 9; i++) {
for (var j = 0; j < 9; j++) {
if (board[i][j] == 0) {
var candidates = getCandidates(board, i, j);
if (candidates.length < minCandidates) {
minCandidates = candidates.length;
bestRow = i;
bestCol = j;
if (minCandidates == 1) break;
}
}
}
}
if (bestRow == -1) return true; // 没有空格,解已完成
if (limit != -1 && solutionCount > limit) return false;
for (var num = 1; num <= 9; num++) {
if (isValid(board, bestRow, bestCol, num)) {
board[bestRow][bestCol] = num;
if (solveSudoku(board, limit)) return true;
board[bestRow][bestCol] = 0;
}
}
return false;
}
这里的getCandidates不是简单地遍历1~9,而是用行、列、宫的占用表做集合运算,把候选数一次性算出来。这样做的原因是:数独本质是一个约束满足问题,每一步选择候选数最少的格子填充,能最大化减少回溯次数。实测下来,这个剪枝策略能让求解器在几乎任何难度盘面上都在几毫秒内返回结果。
要判断“唯一解”,不能只求解一个解。正常做法是:先正常求解一次得到“标准解”,然后把找到解的路径上最后一个格子“锁死”,再继续尝试搜索别的解。如果还能找到第二种解,说明当前局面多解。在工程实现上,我用一个solutionCount计数器配合limit参数,当搜索到第2个解时立即中止,返回“多解”结论。这个优化很关键——很多多解盘面其实非常容易证明多解,不需要算完整棵树。
3.2 用“最小候选优先”策略减少回溯次数
关于剪枝策略,我想多说几句。很多教程里的数独求解器会老老实实从头到尾按顺序扫描空格,每遇到一个空格就试1~9。这种做法在最简单的盘面上能跑,但遇到困难盘面,回溯次数会指数级上升。我做过一次实测:用“顺序扫描”的求解器解一道困难盘面,平均回溯5万多次,耗时约1.2秒;换成“最小候选优先”后,回溯次数降到几百次,耗时压缩到约8毫秒。两者的差距是150倍,这个数字非常直观。
为什么“最小候选优先”效果这么好?可以类比成走迷宫:在岔路口,你当然应该先选“只有一条路能走”的岔路,而不是选“四面八方都能走”的中央大厅。数独也一样,候选数最少的格子意味着约束最强,填它是最不容易出错的选择。
这里有一个注意点:每次递归都要重新扫描整个盘面找最小候选数,这本身有O(81)的开销。但因为9x9是固定的小规模问题,这个开销可以被搜索树规模的缩减完全抵消。对大数独(比如16x16)就不一定适用了,那种情况下更好的做法是维护候选数堆或使用舞蹈链算法。
4. Flutter for OpenHarmony 工程适配:从“能编译”到“跑得顺”
4.1 OpenHarmony环境下Flutter工程的搭建要点
数独游戏是一个纯UI + 逻辑项目,非常适合用Flutter来做跨平台。但Flutter for OpenHarmony不是直接用官方Flutter SDK就能跑的,你需要使用OpenHarmony生态维护的Flutter SDK分支。我当时的做法是:在项目的pubspec.yaml中声明SDK版本约束,并配置让flutter命令行工具指向OpenHarmony适配版。
这里有一个容易踩的坑:如果在同一台机器上同时开发Android版和OpenHarmony版,两个SDK的命令工具经常混。我自己就遇到过明明切了OpenHarmony的SDK,执行flutter doctor还是检测到Android目录,导致后续构建走错链路。解决办法是建议为OpenHarmony专门配置一个独立的Flutter SDK目录,或者用环境变量显式指定FLUTTER_ROOT,而不是依赖默认PATH。另外,OpenHarmony的工程入口和Android略有不同,运行到真机或模拟器前要确认hdc工具已连接,否则会报“no device”之类的问题。
4.2 数独盘面绘制:用CustomPaint还是Widget组合?
数独盘面是典型的网格状UI,有两种主流实现思路。
第一种是直接用GridView + Container组合,每个格子一个Widget。优点是实现简单,代码直观,适合快速出原型。缺点是刷新性能一般。在OpenHarmony的中低端设备上,81个格子全量重建会带来明显的掉帧感。
第二种是使用CustomPaint自己画盘面,只在shouldRepaint返回true时才重绘。我最终选的就是这个方案。核心思路是:把9x9的所有数字和格子状态打包成一个不可变的BoardRenderData对象,当玩家填入数字、做标记或切换高亮时,创建一个新对象,并触发一次重绘。这样每次刷新只画一次画布,而不是重建81个Widget,性能非常稳定。
还有一个细节:数独的格子线和宫格线粗细不一样,绘制时建议分成两层——先画3x3大宫格的粗线,再画每格细线,这样视觉上更清晰,也方便做选中态的高亮。用CustomPaint实现时,先绘制浅色背景,再绘制数字,最后绘制选中状态的红色边框线。我在适配OpenHarmony的GPU加速时,发现文本绘制有时会有轻微边缘锯齿,给TextPainter设置了抗锯齿属性并且开启了硬件加速后,显示效果就正常了。
4.3 平台通道与数据持久化:把算法和平台解耦
既然要用Flutter跨平台,就要尽量少写平台特定代码。数独游戏的逻辑部分(生成算法、求解器、难度评估)完全可以放在Dart层,做到平台无关。但某些能力——比如读取系统剪贴板、设置系统震动反馈、访问本地存储——不同平台的实现有差异,建议通过MethodChannel封装一层统一接口。
我在工程里定义了一组轻量级接口:
dart复制class PlatformBridge {
static const platform = MethodChannel('sudoku/platform');
static Future<void> vibrate(int milliseconds) async {
try {
await platform.invokeMethod('vibrate', {'duration': milliseconds});
} catch (e) {
debugPrint('vibrate not supported: $e');
}
}
}
这样在Android上可以调到原生震动,在OpenHarmony上则映射到对应的马达接口,在iOS上暂时留空。关键是,业务层只调用PlatformBridge,不需要感知具体平台,后续要支持新平台,只需要在对应原生侧实现同名Method即可。
数据持久化方面,我选了shared_preferences插件来做关卡进度、玩家记录的本地存储。需要注意:OpenHarmony上部分Flutter插件的兼容性还没有跟上,shared_preferences在新版本SDK上登录时偶尔会遇到读取空值的情况。稳妥的做法是存储时加一层JSON序列化,读取时做空值保护,避免一处异常直接崩掉整局进度。
5. 从算法到完整体验:关卡模块与难度分级的整合
5.1 生成一批关卡:预生成模式还是实时生成?
数独关卡在用户点击“新游戏”时才生成,这在技术上没问题,但会让玩家在切换关卡时等待几十毫秒。虽然已经很快了,但高频切换时仍有顿挫感。更好的做法是“预生成 + 本地缓存”:App启动后在后台线程先生成10~20个不同难度的关卡,存入内存队列。用户在界面上点下一关时,直接从队列里弹出一个,接近零延迟。
这个模式对性能有天然好处:后台生成不影响UI线程,即使某个困难盘面的生成异常耗时(比如回退次数过多),也不会造成卡顿。我实测在OpenHarmony RK3568设备上,预生成10张困难盘面的总耗时约800毫秒,分摊到每张大约80毫秒,这个量级放到后台线程完全无感。
内存队列的容量建议控制在20~30个,太少的话用户连续玩几局就“断供”了,太多则没必要,反而多占内存。另外要支持“当前关卡用完后再补充”的策略——每次弹出队列头部关卡后,立刻补一个新区块到队尾,保持水位恒定。
5.2 难度分级:不只是“挖洞多”就行
前面提到过挖洞数和难度的关系,但真正的难度分级不能只看挖洞数量。很多玩家会发现,简单关卡有时比中等关卡还难,原因在于挖洞位置对推理链条的影响完全不同。
更科学的做法是在求解器里增加“试错次数”统计:用求解器尝试解开盘面时,记录下在“最小候选优先”策略下,需要回溯的次数。回溯次数越多,说明玩家需要做的假设/试错越多,难度越高。这个指标虽然没有标准公式,但在实测中非常接近真实体验。
我总结了一套实用的难度判断逻辑:
| 难度 | 挖洞数 | 回溯次数参考 | 体验描述 |
|---|---|---|---|
| 简单 | 38~42 | 0 | 全程单一推理路径,基本不用笔记 |
| 中等 | 33~36 | 1~10 | 需要少量候选数推导 |
| 困难 | 26~30 | 10~50 | 需要较多的候选数记录和排除 |
| 专家 | 22~26 | 50+ | 需要高级技巧,甚至需要一定的猜测 |
生成时先按难度挖洞,然后用求解器统计回溯次数,如果回溯次数不在目标区间,就重新调整挖洞的位置——这个“生成-验证-校正”循环能明显提升难度标注的准确性。虽然会有额外耗时,但放在后台线程处理,完全可接受。
6. 实战中的典型报错与排查记录
6.1 Flutter环境与依赖相关的问题
在OpenHarmony的Flutter开发中,环境问题占了我调试时间的相当一部分。整理几个我遇到过的典型问题,给后来者排雷。
问题表现一:执行flutter doctor无法识别OpenHarmony SDK组件。 原因通常是没有配置OpenHarmony的SDK路径。解决方式是在local.properties里添加sdk.dir=/path/to/ohos-sdk,并确保hdc工具在PATH中。
问题表现二:插件依赖解析失败,提示找不到某个插件版本。 OpenHarmony的Flutter生态没有完全同步pub.dev上的所有插件,部分插件只有Android/iOS版本。解决办法是检查pubspec.yaml里是否引用了OpenHarmony不支持的插件,必要时改用MethodChannel自研。
问题表现三:构建时提示cmake error at cmakelists.txt:3 (project): generator visual studio。 这类问题通常是当前项目使用了旧的桌面端构建配置,而命令行参数没指定好目标平台。在OpenHarmony上构建时,建议使用flutter build hap这种针对性的命令,而不是通用构建命令,同时确认CMake版本在3.18以上。
6.2 数独生成逻辑中的隐蔽Bug
数独生成这块如果出了问题,最麻烦的Bug有两种。
第一种是“生成出来的谜题有多解”。这个问题在用纯随机挖洞时特别常见,但我采用“每步唯一解校验”后已经很少出现。如果你遇到的是历史遗留关卡还有多解,排查时先跑一遍求解器,确认标准解是否唯一,再定位是生成器还是缓存层的问题。
第二种是“同一难度下,盘面质量波动很大”。我排查过这个现象,最终发现原因是在生成器里虽然目标挖洞数一致,但挖洞的随机顺序导致某些盘面“巧合地”把多个关键提示位全部挖掉了,使得盘面难度飙升。修复办法就是前面提到的回溯次数校验,用它把这类异常盘面过滤掉。
另外,由于目前支持Random(seed)固定随机种子,测试时建议显式传入固定种子。这样每次运行都是同一套盘面序列,方便回归测试和对比优化前后的效果。
6.3 性能问题的排查思路
如果发现数独盘面生成或刷新偶发卡顿,建议优先排查以下几个方面:
- 是否在UI线程进行了大量同步运算。生成算法和求解器一定要放到
compute()或Isolate中执行。 - 是否频繁创建新的Widget对象。81个格子每次重建,即便不做复杂布局,也会带来明显GC压力。用
CustomPaint能有效减少对象数量。 - 是否在
build方法中做了不必要的遍历。比如每次build都去遍历整盘数据判断哪些格子高亮,这在棋盘状态频繁变化时会累积成卡顿。
我实测过一组对比数据:在RK3568开发板上,用Widget组合方案刷新一次盘面的耗时约35毫秒,改用CustomPaint后降到6毫秒左右。数独盘面刷新是玩家操作最频繁的交互之一,这个差距直接决定了App是“流畅”还是“有点卡”。
7. 后续扩展方向与个人心得
数独谜题生成算法这块,做完基础版本后还有很多可以扩展的方向。比如可以做“提示系统”:解析当前局面时,根据求解器的推理过程,把人类解题技巧(唯一数、排除法、唯一矩形等)映射成逐步提示。这个功能对新手玩家非常有用,但需要把求解器的搜索树和人类解题策略对齐,工程量不小。
再比如“每日挑战”功能:用当天日期作为固定随机种子,让所有用户在同一天拿到同一个谜题。实现其实很简单——只要把生成器改成支持传入种子参数,再按日期生成即可。这种方式还能带起社区讨论和比较,对产品活跃度有明显提升。
说到我个人在实际操作中的体会,有两件印象最深的事。第一件是:算法性能优化往往不在于堆复杂度优化技巧,而在于选对数据结构。数独求解器看似是在做一个搜索问题,实际上最重要的是把“候选集计算”和“合法性判断”做对——合理用位运算和预计算表,能让性能翻倍。第二件是:适配OpenHarmony时,很多坑不是Flutter层的,而是环境工具的坑。好的习惯是,每次搭建新平台环境后,先跑一个最小Demo验证全套链路,再投入业务开发。别等到项目做到一半才发现SDK配置有问题,那时候排查成本会高很多。
最后再分享一个小技巧:数独生成器的单测一定要写好。因为随机性很强,很容易出现“偶尔多解”这类概率Bug。我的做法是写一个批量测试,用不同的随机种子连续生成500张盘面,逐一用求解器验证唯一性,再校验难度分布是否合理。这个测试在CI上每次提交都会跑,帮我在开发过程中拦截了至少三次回归错误。
