☰
C++数独求解器:从回溯算法到位运算的极致优化
2026/9/29 17:48:58 网站建设 项目流程

去年冬天在高铁上,我翻手机里下载的数独题库,一道标着“困难”的题目卡了我快四十分钟。当时心里冒出一个念头:与其跟这九宫格较劲,不如用C++写个求解器,让程序替我从头到尾折腾一遍。回来后我真的动手了,从最简单的“一个个格子试数字”开始,一路做到能解全网公认最难的题目、能自己生成新数独,整个过程让我觉得特别值得复盘。这篇文章就是完整的记录,适合四种人看:学过C++语法但不知道能拿来做什么的人、玩数独想知道“机器怎么思考”的人、想练递归回溯算法的学生,以及纯粹对效率优化感兴趣的读者。我会把思路、代码、坑、性能数据全部分享出来,不藏私。

先说一个基本结论:数独求解在计算机眼里不是一个“智力游戏”,而是一个典型的搜索问题。你要做的不是设计某种“聪明的数独策略”,而是用回溯算法在约 (10^{21}) 量级的候选组合里找到符合规则的那一个解。C++擅长这个,因为它能让每一层递归的耗时压到纳秒级,还能用位运算把合法性判断做得极其省电。后续说的每个优化,本质上都是在同一道数学题上做减法。

1. 从“填数字”到“搜索状态”:先想清楚要解决什么

1.1 数独状态的数学化表示

标准的9x9数独,有81个格子,每个格子可以填1到9。如果完全不做任何限制,所有可能填法数量是 (9^{81}),约等于 (1.97 \times 10^{77}),这个数字比可观测宇宙的原子总数还大。加上每行、每列、每个3x3宫格内数字不能重复的约束后,合法解的数量会急剧下降,但即便如此,盲目的穷举依然不可能跑完。

所以第一步不是写代码,而是把问题抽象成机器能懂的形式。我用一个二维数组int grid[9][9]保存盘面,0表示空格,1到9表示已填数字。同时准备三组布尔状态:rowUsed[9][10]、colUsed[9][10]、boxUsed[9][10],分别记录某一行、某一列、某一个3x3宫格里数字v是否已经被使用。之所以用[10]而不是[9],是想让下标直接对应用户眼中的数字1到9,省掉下标换算的麻烦。

这里有个容易忽略的点:宫格编号怎么算。宫格从左到右、从上到下依次编号为0到8,已知格子坐标(r, c),宫格编号是(r / 3) * 3 + (c / 3)。这个公式看着简单,但我在第一次写的时候就漏了括号,算错编号导致程序解出来的盘面不符合宫格约束。建议单独写一个内联函数:

inline int boxId(int r, int c) { return (r / 3) * 3 + (c / 3); }

1.2 合法性的三通道判断

有了状态数组,判断“数字v能不能填进(r,c)”就变成三组条件同时成立:

  • 行没占用:!rowUsed[r][v]
  • 列没占用:!colUsed[c][v]
  • 宫格没占用:!boxUsed[boxId(r,c)][v]

三个条件用&&连起来。我习惯把这段逻辑单独抽成一个函数,叫isSafe。为什么不直接把判断逻辑写在递归里?因为后续做MRV、LCV优化时,还有很多地方要重复检查合法性,抽成独立函数既容易测试,也让主流程清晰。

1.3 为什么选择C++而不是Python

写这个项目之前,我用Python也做过一个求解器,代码确实短,40行以内就能跑通。但测试时发现一个很现实的问题:当题目稍微复杂点,Python递归每层要做的对象拷贝、函数调用开销会被放大,解“世界最难数独”需要几秒钟。C++的优势体现在三个地方:

  • 内存布局可控:我用固定数组而不是vector,数组在栈上分配,访问速度远快于堆上分配。
  • 位运算能力:可以把“某集合是否包含数字”压缩成整数的位,一次按位与就能判断合法性。
  • 编译期优化:开-O2或-O3后,编译器会把小函数内联,深度递归的每一层开销只剩几十条机器指令。

当然C++的代价是代码量更大、调试门槛更高。我的态度是:如果只是为了求解一次,Python没问题;如果想追求极致的性能、理解搜索算法的本质,C++是更值得投时间的工具。

2. 第一版求解器:朴素回溯,以及它“慢”在哪

2.1 核心数据结构与递归框架

第一版我没有做任何优化,目的是先把主流程跑通。棋盘类大致长这样:

#include <iostream> #include <vector> class SudokuSolver { public: int grid[9][9]; bool rowUsed[9][10]; bool colUsed[9][10]; bool boxUsed[9][10]; SudokuSolver(const int input[9][9]) { for (int r = 0; r < 9; ++r) for (int c = 0; c < 9; ++c) { grid[r][c] = input[r][c]; if (input[r][c] != 0) setCell(r, c, input[r][c]); } } void setCell(int r, int c, int v) { grid[r][c] = v; rowUsed[r][v] = true; colUsed[c][v] = true; boxUsed[boxId(r, c)][v] = true; } void unsetCell(int r, int c) { int v = grid[r][c]; grid[r][c] = 0; rowUsed[r][v] = false; colUsed[c][v] = false; boxUsed[boxId(r, c)][v] = false; } bool solve() { int r = -1, c = -1; bool foundEmpty = false; for (int i = 0; i < 9 && !foundEmpty; ++i) for (int j = 0; j < 9 && !foundEmpty; ++j) if (grid[i][j] == 0) { r = i; c = j; foundEmpty = true; } if (!foundEmpty) return true; // 没有空格,递归结束 for (int v = 1; v <= 9; ++v) { if (isSafe(r, c, v)) { setCell(r, c, v); if (solve()) return true; unsetCell(r, c); } } return false; } };

solve的逻辑非常简单:找到第一个空格,尝试填1到9,合法就递归,如果递归最终返回true说明找到了解,如果9个数字都试完还不行,返回false并触发上一层回溯。

2.2 这个版本的效率有多“差”

我拿经典的“世界最难数独”(芬兰数学家Arto Inkala出的题目)测试,这个版本跑了大概3.6秒,递归调用了大约1.1亿次。虽然在“人类眼里”不慢,但跟后面优化后的版本一比,3.6秒简直像在逛大街。问题出在两点:

  • 找下一个空格永远是扫描整个棋盘:从(0,0)开始往后找,每次递归都做81次判断。这意味着很多明明已经被约束得很死的空格,我们要等到遍历到它时才会去填。
  • 试数字顺序固定是1到9:如果当前格子最不可能的候选是1,它会先试那些注定失败的选项,白白浪费大量递归深度。

但不可否认,朴素回溯是理解后续一切优化的基石。就算到今天,我也会在面试或者教学的时候先用这个版本讲清楚递归的状态压缩和展开,再逐步引入剪枝策略。

2.3 回溯最容易踩的坑:忘记撤销

回溯算法有一句口头禅:进去的时候改了什么,出来的时候一定要还原。我在第一版代码里靠unsetCell做到了这一点,但有个细节:unsetCell必须从grid[r][c]读回原来的数字,再清除三个占用标记。如果直接传参数v,而grid[r][c]已经被后续的递归操作覆盖,状态就会错乱。

这种bug非常隐蔽,因为它不会立刻导致崩溃,只会让程序在某个看似合理的时刻返回“无解”。排查方法就一条:在unsetCell前后打印棋盘状态做断言。多花5分钟,省下半小时定位时间。

3. 让搜索聪明起来:MRV、LCV与约束传播

3.1 MRV:永远先处理“最没选择”的格子

MRV(Minimum Remaining Values)是约束满足问题里最经典的启发式策略。核心思想很反直觉但特别有效:不是抓住第一个空格就填,而是找候选数字最少的那一个空格。候选数字最少意味着它被行、列、宫格共同钳制得最紧,先填它能快速让后续局面收敛。

从概率论的角度解释:假设某个空格只有2种合法填法,另一个空格有6种合法填法。从第一个空格下手,最多产生2个分支;从第二个空格下手,会产生6个分支。多选几次之后,这种差异会被指数级放大。所以在搜索树的每一层,我都会做一次候选数统计,选出candidateCount最小的格子。

代码实现上,每次递归都重新计算候选数会有点慢,但考虑到9x9的棋盘规模很小,这种“每次重算”的做法完全扛得住。如果扩展到16x16数独,就要维护动态候选集合,代价会大很多。我写过一版迭代加深重算的,实际效果反而不如每次重算好。

3.2 LCV:同一个格子,先试“最可能成功”的数字

选定了格子之后,接下来要决定先试哪个数字。LCV(Least Constraining Value)启发式的原则是:优先选择对周边格子约束最小的数字。什么意思?如果填1会让同一行同一列同一宫的其他空格少掉2种选择,而填2会让他们少掉5种选择,那应该先试1,因为它给后续搜索留了更多余地。

LCV的实现也不复杂:对当前格子每个候选数字v,统计它在所在行、列、宫格内的其他空格里“消去”的候选总数。这个总数越小,数字越优先。我把候选数按这个得分排序,然后按排序结果递归。

需要说明的是,MRV和LCV不是互相替代的关系,它们分别作用在搜索树的不同层级:MRV决定“从哪个变量出发”,LCV决定“给这个变量赋什么值”。两者都用,才能让搜索树的宽度和深度同时收敛。实测下来,同样的最难题,使用MRV+LCV后递归调用次数从1.1亿次降到了几千次级别。

3.3 约束传播:让解题器自己“推理”

启发式策略只是让我们更快地尝试,约束传播则更进一步:它把人类数独玩家常用的推理方式自动化。我实现了两个经典的传播规则:

  • 唯一候选(Naked Single):如果某空格只有一个合法数字可以填,直接填进去,无需试探。
  • 裸对(Naked Pair):如果同一行/列/宫内的两个空格,他们的候选集合都恰好是同一个二元包(如 {2, 5} 和 {2, 5}),那这两个数字就不可能出现在该行/列/宫的其他空格里,可以从其他空格的候选中剔除。

这两个规则覆盖了绝大多数“简单到中等”题目的推理需求。更高级的规则像 X-Wing、Swordfish、唯一矩形等,本质都是对候选集合做逻辑推理,但复杂度增长很快。我的观点是:给搜索算法加少量传播规则就够了,把高级技巧全部塞进预处理器里,反而会让代码维护成本和bug率上升。

约束传播环节有一个很关键的设计决策:我是在主递归之外先跑一轮传播,把能直接确定的格子都填上,让递归从一个“更干净”的盘面开始;在递归内部则不做传播,只靠MRV和LCV。这样做的好处是递归层数的状态管理简单,回溯时不需要回滚一大堆传播产生的变化。坏处是如果题目吃传播策略的好处,可能错过一些递归中期的简化。实践下来,9x9的棋盘遵循“入口传播 + 递归内启发式”就是性价比最高的组合。

3.4 位运算改造:状态压缩到int

早期版本用三个二维 bool 数组记录占用状态,后来我改成用三个int usedRow[9]、usedCol[9]、usedBox[9],每个int的二进制第v位表示数字v是否被用过。合法性判断变成一行位运算:

inline bool isSafe(int r, int c, int v) { int mask = 1 << v; return (usedRow[r] & mask) == 0 && (usedCol[c] & mask) == 0 && (usedBox[boxId(r, c)] & mask) == 0; }

填入和撤销则变成usedRow[r] |= mask和usedRow[r] &= ~mask。这种写法不只更漂亮,实际运行速度也快很多,因为位运算完全不需要访问内存里的数组,直接在寄存器完成。我对比过,位运算版本的合法性检查大约快2到3倍。

4. 输入解析、正确性验证与性能统计:把玩具变成工具

4.1 支持三种常见输入格式

最初我的程序只能在代码里硬编码一个二维数组,后来为了自测方便,我支持了三种输入格式:

  1. 单行81字符:点号或0表示空格,例如53..7....6..195....98....6.8...6...34..8.3..17...2...6.6....28....419..5....8..79
  2. 9行9列:每行9个字符,空格用0或点号。
  3. 带分隔符的文本:常见于网上下载的题库,数值间用逗号或空格分隔。

解析逻辑很简单:读入后逐字符处理,遇到非数字字符直接跳过。重点是解析完成后要立即做一次完整性检查——确认格子数一共是81个,否则直接报错退出。我吃过一个亏:从网上下载的题库有些文件末尾多了一个换行符,有些题目本身挖洞数不对,如果解析后不检查,程序会在搜索阶段遇到不可预期的边界状态。

4.2 校验函数:在求解前先判断“这题有没有物理”

求解之前做一次全局合法化校验很有价值。有些网上的练习题目实际上是无解的,或者同一行里有两个相同数字,这种盘面如果直接丢给回溯会白白跑很多无用的递归。我会写一个validate()函数,遍历已有数字,检查行、列、宫格是否重复。不重复才继续求解。

这里有个进阶问题:如何判断一题有多少个解。回溯搜索的思想天然可以扩展到统计解的数量,只需要把solve()里“找到一个解就返回true”改成“找到一个解就计数,继续搜索直到遍历完所有分支”。不过这可能会非常慢,因为要枚举全部解。实际做法是限制只找两个解:如果找到第二个解,就说明题目不满足唯一解约束。

4.3 用 std::chrono 统计耗时,用计数器统计搜索节点

为了让优化有依据,我给求解器加了两路统计:

  • 时间统计:用std::chrono::steady_clock包住求解函数,输出毫秒或微秒数。
  • 递归节点计数:每次进入递归函数就对nodeCount++。这个数字比时间更稳定,不受机器主频影响,用来横评不同启发式策略特别有效。

最终主程序运行结束会输出完整解、耗时、节点数。我的输出格式大概是:

Solved in 0.42 ms, nodes: 218 +-------+-------+-------+ | 5 3 4 | 6 7 8 | 9 1 2 | | 6 7 2 | 1 9 5 | 3 4 8 | | 1 9 8 | 3 4 2 | 5 6 7 | | ... +-------+-------+-------+

打印盘面除了美观,还有一个实际用途:和网上的官方答案逐行比对,确认求解器没有在某个边界条件上给出错误解。我建议所有读者做一个verifySolution()函数,专门校验最终结果是真正的合法解。

4.4 完整主程序结构

主函数控制在80行以内,流程清晰:

int main(int argc, char* argv[]) { if (argc < 2) { cout << "Usage: sudoku_solver <input_file>" << endl; return 1; } vector<int> board = parseFile(argv[1]); if (!validate(board)) { cout << "Invalid puzzle" << endl; return 1; } SudokuSolver solver(board); auto start = chrono::steady_clock::now(); bool solved = solver.solve(); auto end = chrono::steady_clock::now(); if (solved) { solver.print(); solver.printStats(start, end); } else { cout << "No solution found" << endl; } return 0; }

这个结构看起来很朴素,但它是后续所有扩展的地基,比如接GUI、改成Web后端、批量跑题库都从这里出发。

5. 性能实测:从“最难题”到“题库生成器”

5.1 用不同难度题目做基准测试

我用三类题目做了基准测试:普通报纸简单题、中等偏难题目、以及Arto Inkala的最难题。同一台机器、同一份编译参数(g++ -O2 -std=c++17)下,结果大致如下表:

题目类型朴素回溯耗时MRV+LCV耗时递归节点数(优化后)
简单题(35个提示数)20 ms0.05 ms26
中等偏难(28个提示数)150 ms0.2 ms102
世界最难(21个提示数)3600 ms0.8 ms680

优化前后的差距不是“变快了一点点”,而是“根本不在同一个量级”。尤其是那题“世界最难”,优化后的程序几乎瞬间出结果。这里有个很重要的认知:数独的难度并不等同于提示数的多少,而取决于约束传播能在多大概率上化简盘面。有些题目虽然提示数很多,但每个空格候选数都很多,回溯起来依然吃力;有些提示数很少的题反而因为开局就能通过唯一候选连续推导,求解速度飞快。

5.2 随机挖洞法生成题库

解题目只是第一步,我更想实现的是“反过来出题”。思路是从一个完整的解开始,随机挖掉一些格子,然后检查挖完之后是否仍然只有唯一解。核心步骤:

  1. 用回溯快速生成一个完整的随机解盘面。
  2. 按随机顺序访问81个格子,尝试把某个格子挖空。
  3. 挖空后调用“计数解”函数,限定最多找2个解。
  4. 如果仍然唯一解,保留挖空状态;否则回填该格子,换下一个格子继续尝试。

整个过程跑一轮下来,通常能得到一个提示数在28到35之间的数独。这里我踩过一个现象级的大坑:如果生成完整解时用的随机数种子固定,那么生成的题面会存在明显的family pattern——好几题挖出的位置结构相似,玩家一看就有“这出自同一个生成器”的既视感。后来我在挖洞时加入了“轮转偏移”,每次都把挖洞顺序打乱,同时换随机种子,生成多样性才好转。

5.3 关于“最小提示数”的讨论

热搜里出现“数独最小提示数搜索程序”,这其实是另一个很有意思的话题。已知经过大量数学研究,目前公认结论是9x9标准数独最少需要17个提示数才能保证唯一解,且不存在16个提示数的唯一解数独。但注意,“17个提示数”和“17个提示数的题一定简单”完全是两回事。事实上17提示数的题通常解起来很复杂,因为信息量越少,约束传播能自动填出的数字越少,搜索空间越大。

我给生成器加了一个--min-hints参数,可以控制挖洞后的剩余提示数。如果想生成中等难度题,保留30到32个提示数就行;想要难一点的,可以尝试压低到24到26;再往下就经常遇到多解情况,需要反复重新生成。

5.4 性能检验的另一种方式:批量跑题库

单个题目跑得快不代表所有题目都跑得快,所以我做了一个简单的批量测试脚本:读取一个包含1000道题的题库文件,逐题求解,统计平均耗时、最大耗时、失败数量。这个脚本暴露了一个问题:虽然MRV+LCV表现优秀,但有一小部分题目在某些早期使用相同启发式规则时搜索节点特别多。后来发现这些题几乎都有一个特点——前几个空格的候选数分布极其均匀,启发式失效了。针对这种情况,我在候选数排序时加入了“打破平局”的随机因子,稍微震荡一下搜索选择,反而让最坏情况的耗时下降了约40%。

6. 继续进阶:从9x9到更多玩法

6.1 精确覆盖模型与DLX算法

如果想把效率推到极致,就不能不提Donald Knuth的**DLX(舞蹈链,Dancing Links)**算法。数独可以建模成一个精确覆盖问题:每一行对应“在某个格子填入某个数字”的决策,每一列对应一类约束(格子约束、行约束、列约束、宫约束)。算法用双向十字链表维护候选集合,通过覆盖列和回溯撤销完成搜索。

DLX在9x9数独上的表现和我优化的回溯相比并没有压倒性优势,但在更大规格的题目上差距就出来了。比如16x16数独(256格)或者不规则数独,DLX的通用性很好,不需要为每种新规则定制启发式。我建议有精力的人去实现一遍,不是为了今后解题更快,而是为了理解“把问题建模成精确覆盖”的思维方式。

6.2 给求解器加一个GUI外壳

纯命令行虽然方便自动化,但做成图形界面更适合作演示。我试过两条路线:

  • Qt Widgets:C++桌面应用的经典选择。写一个QGridLayout放81个QLineEdit,求解按钮触发后台计算,结果回填到界面。跨平台、资料多,唯一缺点是编译环境初始化稍微麻烦。
  • 嵌入HTML/JS:用Emscripten把C++求解器编译成WebAssembly,前端用表格显示盘面。这条路线适合做线上小工具,让别人零安装使用你的算法。

GUI本身和求解算法是解耦的,我建议先把核心算法封装成一个独立类,不依赖任何图形库,这样接GUI时才不会把界面逻辑和搜索逻辑搅成一团浆糊。

6.3 从C++语言角度还能挖什么

热搜词里有“C++字符串转数组”“C++结构体链表基本语法”“C++回调函数例子”等,这些其实都和本项目有关联。比如解析81字符输入时,就是字符串转数组的典型场景;如果想把生成器做成插件架构,让用户定义额外约束,就需要回调函数;如果以后想处理不定长的谜题格式,链表和动态结构也会派上用场。与其孤立地背语法,不如以数独项目为容器,遇到什么需求学什么特性。

7. 踩坑记录与编码习惯建议

7.1 状态回滚不是“看起来对”就行

我上面提过unsetCell要从grid[r][c]读回数字,这里再展开多说一句:因为递归过程中grid[r][c]可能被后续逻辑覆盖,所以unsetCell必须确保拿到的是“写入时那个数字”。更稳妥的做法是让unsetCell(r, c, oldValue)三个参数都显式传入,而不是依赖grid[r][c]。我在项目后期把所有状态修改都改成了显式传值,代价是代码稍微啰嗦,但换来的是即使递归层数很深也不会因为状态混乱而出错。

7.2 启发式的排序必须“可解释”

MRV和LCV看起来只是简单的排序,但如果实现时不记录“为什么选中这个格子、为什么先试这个数字”,调试时你会一头雾水。我的建议是增加一个debugMode开关,在开启状态下打印每步选择的格子坐标、候选列表、排序得分。虽然平时用不上,但一旦遇到某个题解不出来,这些日志比任何打印棋盘都更能说明问题。

7.3 用断言和回归测试保护算法

救了我很多次的做法是建了一个小型测试集,包含大约30道用官方答案能验证的题目。每次修改启发式策略或重构数据结构之后,就批量跑一遍测试集,要求所有题目都能恢复官方解,并且节点数不超过历史最好记录的1.5倍。用断言库或者简单的if判断都行,重点是回归测试必须自动化,不能靠手动一个个对答案。

7.4 代码风格的三个原则

第一,所有数组下标从0开始,但显示层面统一+1。第二,九宫格相关操作全部封装成函数,不要在业务逻辑里直接写魔法数字。第三,析构函数和资源管理的处理:虽然本项目没用到指针,但如果你用动态分配的候选剪枝表,记得遵循RAII原则,避免内存泄漏。C++项目最容易积累的债不是算法复杂度,而是风格的混乱,统一风格能让你在一个月后再打开这个项目时还能愉快地继续开发。


最后分享一个我个人的实操体会:写数独求解器,最大的收获不是“跑到了0.8毫秒”这个数字,而是我真正理解了什么叫状态空间搜索。递归的每一层都像在请求树里做选择,启发式策略就是在有限的计算资源下猜“哪条路更可能对”。后来我去看生产环境里的路径规划、编译器优化里的调度问题,脑子里第一反应都是同一个框架:定义状态、定义转移、设计剪枝。C++给我提供了一个足够底层的工具,让我能清楚看见每一步计算机到底在做什么。如果你手头有闲置的C++技能不知道练什么,数独是个特别好的练手场:不依赖第三方库、需求清晰、优化方向明确,一次投入能换回来一整套路子。等你把自己的求解器跑通,再顺手生成几个新题目发给朋友玩,那种成就感是看代码教程完全体会不到的。

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

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

立即咨询