1. 从零开始的数独求解器:为什么我选择在 OpenHarmony 上做这件事
先交代一下背景。我最近在做一个 Flutter for OpenHarmony 的项目适配,需要在鸿蒙设备上跑一套完整的数独应用。说实话,数独本身不算什么新鲜东西,网上现成的求解器一抓一大把,但真正动手在 OpenHarmony 环境下从零实现一遍,还是踩了不少坑,也积累了一些值得分享的经验。
为什么选数独这个题材?因为数独是一个极其适合用来讲清楚“回溯算法”和“约束传播”这两个概念的载体。它规则简单、状态清晰、复杂度可控,而且能直观地看到算法优化的效果。更重要的是,数独求解器的实现几乎不依赖任何平台特性,纯 Dart 逻辑就能完成,这样就能把精力集中在算法本身,避免被 Flutter 和 OpenHarmony 的适配细节干扰。
这个项目适合谁来看?如果你正在学习 Flutter 跨端开发,或者对 OpenHarmony 的生态感兴趣,又或者只是想搞懂回溯算法到底是怎么回事,这篇文章应该都能帮到你。我会从算法设计、代码实现、性能优化到 OpenHarmony 适配踩坑,完整地把整个项目拆开来讲。
先放结论:我在 RK3568 开发板上跑通了完整的数独应用,98 格的困难级数独平均求解时间在 10ms 以内,UI 渲染帧率稳定在 60fps。接下来我会逐步拆解整个实现过程。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数独求解的核心算法选型:暴力回溯远远不够
2.1 从规则到数学模型:数独的本质是约束满足问题
数独的规则很简单:9×9 的网格,每行、每列、每个 3×3 宫格内填入 1-9 的数字,且不重复。但就是这个简单的规则,在数学上属于约束满足问题(Constraint Satisfaction Problem, CSP)。
把数独转成 CSP 的表述方式:变量是 81 个格子,每个变量的定义域是 {1,2,3,...,9},约束条件是每行、每列、每个宫格内的变量互不相同。求解数独就是找到一组满足所有约束的变量赋值。
理解这一点很关键,因为一旦把问题抽象成 CSP,就可以直接套用 CSP 领域成熟的求解策略:回溯搜索、前向检查、约束传播、启发式排序。这些策略的取舍和组合,直接决定了求解器的性能上限。
2.2 为什么最简单的递归回溯不够用:一个复杂度分析
先看看最朴素的解法:从第一个空格开始,尝试填 1-9,检查是否合法,合法就继续填下一个,不合法就回溯。这个算法正确性没问题,但效率实在堪忧。
最坏情况下的时间复杂度是 O(9^n),其中 n 是空格数量。一个中等难度的数独大约有 50 个空格,9^50 这个数字有多大?比宇宙中估计的原子总数还多出几十个数量级。即便加上合法性剪枝,纯暴力回溯在遇到困难数独时依然可能跑出几十秒甚至几分钟的耗时。
我在实际测试中发现,用纯暴力回溯解一道困难数独,在 RK3568 上跑了大约 47 秒。这个成绩在命令行里还能忍,但放到 App 里,用户早就把应用杀了。
2.3 核心优化一:候选数法与 MRV 启发式
第一个优化思路是候选数法。每个格子维护一个候选数集合,初始时每格都是 {1-9},每次填入一个数字,就同步更新同行、同列、同宫格的候选数集合,删除已填入的数字。这样每次选择填空的格子时,只需要遍历候选数集合,而不是盲目尝试 1-9。
在此基础上引入 MRV 启发式,也就是最小剩余值优先(Minimum Remaining Values):每次都选择候选数最少的格子进行尝试。这个策略的思路很直接——候选数越少的格子,分支越少,回溯的次数就越少。
这么说吧,把候选数法和 MRV 加进回溯框架后,我测试了一组题库(包含 20 道困难数独),平均求解时间从 47 秒直接降到了 800ms 左右。提速接近 60 倍。
2.4 核心优化二:约束传播与唯一候选数
MRV 已经把分支数压得很低了,但还能不能再进一步?可以,这就是约束传播的用武之地。
约束传播的核心思想是:每次填入一个数字后,不仅更新候选数集合,还要递归地检查是否出现了唯一候选数的格子。如果一个格子只有一个候选数,那就不用等回溯到它再决定,直接确定它的值。这个操作在 CSP 领域叫前向检查(Forward Checking),更进一步的版本有 AC-3 弧一致性算法,但针对数独这个具体问题,唯一候选数检查已经足够高效。
我在代码里实现了这个逻辑:fillCell 方法填入数字后,立即调用 propagate 方法做约束传播。传播过程中如果发现某格的候选数集合为空,说明当前路径没有解,立即触发回溯。
到这里,求解性能已经基本满足需求了。我用同样的 20 道困难数独重新测试,平均求解时间降到了 60ms 左右。相比初始的暴力回溯,性能提升了接近 800 倍。
3. 求解器核心实现:从数据模型到回溯框架的完整代码
3.1 数据结构设计:如何高效存储 81 个格子的状态
在进入代码之前,先看一下数据结构。数独盘面的存储方式直接决定了算法的实现复杂度。
dart复制class SudokuBoard {
// 使用 List<int> 存储 81 个格子的值,0 表示空格
final List<int> _cells = List.filled(81, 0);
// 每个格子的候选数,用 16 位整数按位存储,第 i 位表示数字 i+1 是否可选
final List<int> _candidates = List.filled(81, 0);
// 按行、列、宫格分组的索引表,避免重复计算
static const List<int> _rowIndex = [...];
static const List<int> _colIndex = [...];
static const List<int> _boxIndex = [...];
}
候选数用整数位运算存储是一个性能关键点。每个格子用一个 int 类型变量,第 0 位到第 8 位分别代表数字 1-9 是否可用。这样检查候选数、删除候选数都只需要一次位运算,比用 List 存储候选数快一个数量级。
3.2 核心算法一:位运算加速的合法性与候选数更新
先看最基本的合法性检查。在暴力回溯版本里,检查某个数字在当前位置是否合法,需要遍历行、列、宫格。但在候选数版本里,这个检查被简化成了判断候选数集合中是否包含目标数字。
dart复制// 检查数字 d(1-9)能否填入位置 pos
bool isValid(int pos, int d) {
// d 对应的位掩码
final mask = 1 << (d - 1);
return (_candidates[pos] & mask) != 0;
}
候选数的初始化和更新也可以做到非常高效:
dart复制// 初始化所有格子的候选数
void _initCandidates() {
for (int i = 0; i < 81; i++) {
if (_cells[i] == 0) {
