C++数独求解器:从回溯算法到位运算的极致优化
发布时间:2026/9/29 17:49:00来源:尧图网络
去年冬天在高铁上我翻手机里下载的数独题库一道标着“困难”的题目卡了我快四十分钟。当时心里冒出一个念头与其跟这九宫格较劲不如用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永远先处理“最没选择”的格子MRVMinimum Remaining Values是约束满足问题里最经典的启发式策略。核心思想很反直觉但特别有效不是抓住第一个空格就填而是找候选数字最少的那一个空格。候选数字最少意味着它被行、列、宫格共同钳制得最紧先填它能快速让后续局面收敛。从概率论的角度解释:假设某个空格只有2种合法填法另一个空格有6种合法填法。从第一个空格下手最多产生2个分支从第二个空格下手会产生6个分支。多选几次之后这种差异会被指数级放大。所以在搜索树的每一层我都会做一次候选数统计选出candidateCount最小的格子。代码实现上每次递归都重新计算候选数会有点慢但考虑到9x9的棋盘规模很小这种“每次重算”的做法完全扛得住。如果扩展到16x16数独就要维护动态候选集合代价会大很多。我写过一版迭代加深重算的实际效果反而不如每次重算好。3.2 LCV同一个格子先试“最可能成功”的数字选定了格子之后接下来要决定先试哪个数字。LCVLeast Constraining Value启发式的原则是优先选择对周边格子约束最小的数字。什么意思如果填1会让同一行同一列同一宫的其他空格少掉2种选择而填2会让他们少掉5种选择那应该先试1因为它给后续搜索留了更多余地。LCV的实现也不复杂对当前格子每个候选数字v统计它在所在行、列、宫格内的其他空格里“消去”的候选总数。这个总数越小数字越优先。我把候选数按这个得分排序然后按排序结果递归。需要说明的是MRV和LCV不是互相替代的关系它们分别作用在搜索树的不同层级MRV决定“从哪个变量出发”LCV决定“给这个变量赋什么值”。两者都用才能让搜索树的宽度和深度同时收敛。实测下来同样的最难题使用MRVLCV后递归调用次数从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 支持三种常见输入格式最初我的程序只能在代码里硬编码一个二维数组后来为了自测方便我支持了三种输入格式单行81字符点号或0表示空格例如53..7....6..195....98....6.8...6...34..8.3..17...2...6.6....28....419..5....8..799行9列每行9个字符空格用0或点号。带分隔符的文本常见于网上下载的题库数值间用逗号或空格分隔。解析逻辑很简单读入后逐字符处理遇到非数字字符直接跳过。重点是解析完成后要立即做一次完整性检查——确认格子数一共是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; } vectorint 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 -stdc17下结果大致如下表题目类型朴素回溯耗时MRVLCV耗时递归节点数优化后简单题35个提示数20 ms0.05 ms26中等偏难28个提示数150 ms0.2 ms102世界最难21个提示数3600 ms0.8 ms680优化前后的差距不是“变快了一点点”而是“根本不在同一个量级”。尤其是那题“世界最难”优化后的程序几乎瞬间出结果。这里有个很重要的认知数独的难度并不等同于提示数的多少而取决于约束传播能在多大概率上化简盘面。有些题目虽然提示数很多但每个空格候选数都很多回溯起来依然吃力有些提示数很少的题反而因为开局就能通过唯一候选连续推导求解速度飞快。5.2 随机挖洞法生成题库解题目只是第一步我更想实现的是“反过来出题”。思路是从一个完整的解开始随机挖掉一些格子然后检查挖完之后是否仍然只有唯一解。核心步骤用回溯快速生成一个完整的随机解盘面。按随机顺序访问81个格子尝试把某个格子挖空。挖空后调用“计数解”函数限定最多找2个解。如果仍然唯一解保留挖空状态否则回填该格子换下一个格子继续尝试。整个过程跑一轮下来通常能得到一个提示数在28到35之间的数独。这里我踩过一个现象级的大坑如果生成完整解时用的随机数种子固定那么生成的题面会存在明显的family pattern——好几题挖出的位置结构相似玩家一看就有“这出自同一个生成器”的既视感。后来我在挖洞时加入了“轮转偏移”每次都把挖洞顺序打乱同时换随机种子生成多样性才好转。5.3 关于“最小提示数”的讨论热搜里出现“数独最小提示数搜索程序”这其实是另一个很有意思的话题。已知经过大量数学研究目前公认结论是9x9标准数独最少需要17个提示数才能保证唯一解且不存在16个提示数的唯一解数独。但注意“17个提示数”和“17个提示数的题一定简单”完全是两回事。事实上17提示数的题通常解起来很复杂因为信息量越少约束传播能自动填出的数字越少搜索空间越大。我给生成器加了一个--min-hints参数可以控制挖洞后的剩余提示数。如果想生成中等难度题保留30到32个提示数就行想要难一点的可以尝试压低到24到26再往下就经常遇到多解情况需要反复重新生成。5.4 性能检验的另一种方式批量跑题库单个题目跑得快不代表所有题目都跑得快所以我做了一个简单的批量测试脚本读取一个包含1000道题的题库文件逐题求解统计平均耗时、最大耗时、失败数量。这个脚本暴露了一个问题虽然MRVLCV表现优秀但有一小部分题目在某些早期使用相同启发式规则时搜索节点特别多。后来发现这些题几乎都有一个特点——前几个空格的候选数分布极其均匀启发式失效了。针对这种情况我在候选数排序时加入了“打破平局”的随机因子稍微震荡一下搜索选择反而让最坏情况的耗时下降了约40%。6. 继续进阶从9x9到更多玩法6.1 精确覆盖模型与DLX算法如果想把效率推到极致就不能不提Donald Knuth的**DLX舞蹈链Dancing Links**算法。数独可以建模成一个精确覆盖问题每一行对应“在某个格子填入某个数字”的决策每一列对应一类约束格子约束、行约束、列约束、宫约束。算法用双向十字链表维护候选集合通过覆盖列和回溯撤销完成搜索。DLX在9x9数独上的表现和我优化的回溯相比并没有压倒性优势但在更大规格的题目上差距就出来了。比如16x16数独256格或者不规则数独DLX的通用性很好不需要为每种新规则定制启发式。我建议有精力的人去实现一遍不是为了今后解题更快而是为了理解“把问题建模成精确覆盖”的思维方式。6.2 给求解器加一个GUI外壳纯命令行虽然方便自动化但做成图形界面更适合作演示。我试过两条路线Qt WidgetsC桌面应用的经典选择。写一个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技能不知道练什么数独是个特别好的练手场不依赖第三方库、需求清晰、优化方向明确一次投入能换回来一整套路子。等你把自己的求解器跑通再顺手生成几个新题目发给朋友玩那种成就感是看代码教程完全体会不到的。
网站建设高端定制企业官网