最近我在系统啃 PostgreSQL 的递归查询翻到官方 Wiki 上有个用纯 SQL 实现的数独求解器第一反应是“这玩意儿靠一条 SQL 真能跑通”后来我借助 DeepSeek 把它一步步拆开又把逻辑整理成了自己的笔记。说句实话这个例子对理解递归 CTE 和约束排除非常有帮助但源码写得很紧凑直接读很容易劝退。这篇文章就是记录我怎么用 DeepSeek 辅助解读这段代码、复现求解过程以及踩过的一些坑适合对 SQL 递归、约束求解感兴趣的读者。1. 先搞懂这个求解器到底在做什么1.1 数独问题在 SQL 里的建模数独本质是一个 9×9 的约束满足问题每行、每列、每个 3×3 宫格内数字 1 到 9 各出现一次。一般用程序写回溯很容易理解但 SQL 里没有“循环”和“数组”怎么表示棋盘状态PostgreSQL Wiki 上的解法很巧妙直接用 81 个字符的字符串表示棋盘其中.表示空格数字就是已知数字。比如这个盘面53..7.... 6..195.... 98....6.8 ...6...34 ..8.3..17 ...2...6. 6....28.. ...419..5 ....8..79在 SQL 里就是一个长度为 81 的字符串53..7....6..195....98....6.8...6...34..8.3..17...2...6.6....28....419..5....8..79这个建模方式非常关键。字符串的好处是位置pos从 1 到 81天然是行优先索引。行号、列号、宫号可以通过pos的取整和取模算出来。字符串拼接函数substr和||可以直接生成新状态方便递归迭代。坐标换算关系我整理成了一张表后面所有 SQL 逻辑都依赖它目标公式pos 从 1 开始说明行号0 基(pos - 1) / 9每行 9 格列号0 基(pos - 1) % 9行内偏移行内第 k 个格子((pos - 1) / 9) * 9 k 1k 从 0 到 8列内第 k 个格子((pos - 1) % 9) k * 9 1从上到下宫起始行((pos - 1) / 27) * 27每 3 行是一组宫宫起始列((pos - 1) / 3 % 3) * 3每 3 列是一组宫宫内第 k 个格子宫起始行 宫起始列 (k / 3) * 9 (k % 3) 1k 从 0 到 8这些公式看起来枯燥但如果你理解了它们后面看递归条件就会非常顺畅。1.2 递归 CTE 是怎么“试错”的PostgreSQL 的递归 CTE 语法大致是WITH RECURSIVE cte AS ( -- 非递归部分初始状态 SELECT ... UNION ALL -- 递归部分从当前状态推导下一步状态 SELECT ... FROM cte, ... WHERE ... ) SELECT ...对于数独来说初始状态就是原始盘面递归部分每次从当前状态挑选一个空格尝试填入一个候选数字生成一个新状态直到某个状态没有任何空格它就是解。这个过程和普通程序里的回溯算法非常像只不过 SQL 把“递归”和“集合操作”交给了数据库引擎。每一轮递归都可能产生多个候选状态数据库会把他们统统塞进下一轮继续处理。也就是说SQL 求解器本质上是在并行地探索多条路径而不是像写程序那样用显式栈一条条试。刚开始我觉得这个思路很难理解后来 DeepSeek 给了一个很好的类比想象你在走迷宫程序回溯是一个人走到死路再退回来换一条路走而这个 SQL 版本相当于你雇佣了一群分身每个岔路口都有分身去试谁先走到终点谁就喊一声。数据库的递归 CTE 就是这群分身的管理者。这个视角很有用。它解释了为什么这个求解器不需要“撤销”操作——因为每次填格子的新状态都是不可变字符串旧状态还在集合里躺着新状态只是从旧状态派生出来的副本。2. DeepSeek 辅助拆解 Wiki 源码的全过程2.1 源码难读在哪Wiki 上那个求解器的原始代码写得很紧凑大量使用generate_series、translate和position这类 SQL 函数加上字符串处理可读性并不好。你第一眼看到的时候很难把“一长串字符替换函数”和“数独回溯”联系起来。我一开始试着直接读结果发现有两个主要难点字符从 1 到 9 的生成方式藏在generate_series和类型转换里看得人眼花。判断“某个数字是否已经出现在行、列、宫”的逻辑用一串NOT EXISTS包裹里面全是substr的嵌套运算很难在脑子里模拟出递归过程。如果用传统方式我需要把代码一行行手动翻译成伪代码再画递归树工作量不小。所以我决定把这段代码丢给 DeepSeek让它先帮我打开一个“翻译”的突破口。2.2 我给 AI 的提问模板与追问策略这里把我和 DeepSeek 的交互过程梳理一下也许对你以后拆类似源码有帮助。我的方法分三轮提问第一轮让 AI 做“逐行注解”。我给的 prompt 大概长这样你是 PostgreSQL 专家。下面是一段来自 PostgreSQL Wiki 的 SQL 数独求解器代码。 请逐行解释代码中每一部分的作用尤其是递归 CTE 的迭代逻辑。 如果某个函数比如 translate或某些表达式比较特殊也请单独说明。 // 把代码贴在这里这一轮的主要目的是“快速建立整体映射”。AI 给出的解释会把每个函数名称拆开说明它是做什么的、为什么出现在这里。虽然有些细节需要验证但确实让我看到了原本没注意到的重点。第二轮让 AI 把 SQL 翻译成自然语言算法。我用的 prompt请把这段 SQL 翻译成 5 步以内的伪代码并用一个只有 3 个空格的棋盘例子演示前两次递归会做什么。这个要求很有效。为了让 AI 解释清楚它必须把复杂的substr条件转换为更接近人类思维的逻辑比如“找到当前位置可用的数字”“把数字填进去”“继续下一层”。通过这个回答我基本确认了求解器的算法骨架每次选择最靠前的空格逐个尝试可填数字生成后代状态。第三轮让 AI 主动找坑。我用的 prompt请列出这段 SQL 可能出错或性能较差的场景。这一轮的价值最大。AI 会提到递归状态爆炸、无解盘面会跑很久、字符串函数依赖位置约定、如果输入不是 81 字符会怎样等等。这些问题在直接读源码时不容易想到但实际跑起来时都会遇到。2.3 验证 AI 解释的两种方法我必须强调AI 的 SQL 解释能力再强也可能一本正经地胡说八道。你得有一套验证方法。我用的第一种方法是“小盘面验证”。自己构造一个 4×4 的数独如果你愿意也可以直接用一个 9×9 的极简盘面手动模拟 AI 说的逻辑看看每一步是不是符合预期。比如用只有 4 个空格的盘面算一下第一轮递归应该产生多少个候选状态再对比 SQL 实际跑出来的结果。只要数字不一致说明 AI 的解释或者我自己的理解有问题。第二种方法是“对照官方文档”。PostgreSQL 官方文档对WITH RECURSIVE的语义、递归项的限制、UNION ALL的行为写得很清楚。AI 说“递归 CTE 会自动去重”这类话时一定要翻一下文档确认。实际上UNION ALL不会去重UNION才会如果用的是UNION ALL搜索空间不考虑剪枝的话会指数膨胀。这里给大家一个额外提醒AI 会把“可行方案”讲得很完美但不会主动告诉你数据库版本差异带来的坑。我试出来一个典型错误——某些函数在旧版本 PostgreSQL 里的行为不同AI 却默认按新版语法解释。所以凡是 AI 给出的结论最终都要在你的数据库版本上跑一遍。3. 实操把 AI 总结转成一份可直接运行的 SQL3.1 复刻版求解器的 SQL 结构与代码为了让这份总结更接地气我根据 Wiki 思路整理了一个可运行且更容易读懂的“教学版”求解器。它不像 Wiki 原版那样追求行数最短而是把每一步都摊开WITH RECURSIVE solve(x) AS ( -- 初始盘面81 个字符. 表示空格 SELECT 53..7....6..195....98....6.8...6...34..8.3..17...2...6.6....28....419..5....8..79::text UNION ALL -- 递归生成新状态 SELECT substr(x, 1, p - 1) || d::text || substr(x, p 1) AS x FROM solve CROSS JOIN LATERAL generate_series(1, 81) AS ps(p) CROSS JOIN LATERAL generate_series(1, 9) AS ds(d) WHERE substr(x, p, 1) . -- 只处理最靠前的空格避免同一轮填多个格 AND NOT EXISTS ( SELECT 1 FROM generate_series(1, p - 1) AS q WHERE substr(x, q, 1) . ) -- 检查数字 d 是否与当前空格冲突 AND NOT EXISTS ( SELECT 1 FROM generate_series(0, 8) AS k WHERE -- 同行 substr(x, ((p - 1) / 9) * 9 k 1, 1) d::text -- 同列 OR substr(x, ((p - 1) % 9) k * 9 1, 1) d::text -- 同宫 OR substr(x, ((p - 1) / 3 % 3) * 3 ((p - 1) / 27) * 27 (k / 3) * 9 (k % 3) 1, 1) d::text ) ) -- 最终选出没有空格的状态 SELECT x FROM solve WHERE position(. IN x) 0;代码中有几个关键点需要展开说明CROSS JOIN LATERAL generate_series(1, 81) AS ps(p)表示生成 1 到 81 的位置序号p代表当前要尝试填空的位置。CROSS JOIN LATERAL generate_series(1, 9) AS ds(d)表示生成 1 到 9 的数字候选d。两个NOT EXISTS是核心。第一个保证“只填最靠前的空格”避免一轮递归同时填多个格导致搜索分支爆炸。第二个检查数字d在当前行、列、宫中是否已经出现过已经出现过就排除。对照前面那套坐标公式你会发现第二个NOT EXISTS里的三个substr分别扫了整行、整列、整个宫。这三个条件缺一不可否则解出来的盘面必然违反数独规则。3.2 运行时的参数、版本和资源建议先说版本。这个 SQL 用到的generate_series、substr、position都是 PostgreSQL 老牌函数8.4 以上基本都能跑。真正影响体验的是性能搜索空间大时递归 CTE 会产生很多中间行内存压力会明显上升。我在本地实测时给几个参考参数参数建议值说明work_mem64MB 以上递归排序和中间结果会占据内存statement_timeout10s 或 30s避免无解盘面无限跑下去max_stack_depth默认即可不需要动除非深递归报错无解盘面是最容易让人怀疑人生的场景。比如你把一个已知盘面改错一个数字让它变成无解SQL 会一直递归试探直到所有状态都死掉。某些盘面可能要跑好几分钟甚至更久。这时候用statement_timeout是保命手段SET statement_timeout 30s;另外提醒一句PostgreSQL 的递归 CTE 不像 SQL Server 有MAXRECURSION选项也没有直接的“递归上限”参数所以控制超时只能靠外层statement_timeout或者自己限制递归深度。3.3 网格化输出把求解结果拼成 9×9直接输出一长串 81 个字符虽然不是不能看但很难核对结果。我通常会再写一段辅助 SQL把它拆成 9 行。最简单的方式是这样WITH result AS ( SELECT x AS ans FROM solve WHERE position(. IN x) 0 LIMIT 1 ) SELECT string_agg(substr(ans, (rn - 1) * 9 col, 1), ) AS sudoku_row FROM result, generate_series(1, 9) AS rn, generate_series(1, 9) AS col GROUP BY rn ORDER BY rn;它做的事情很简单外层把字符数组按 9 列拆开string_agg把每行的 9 个字符用空格连接起来GROUP BY rn让每行独立成一条记录。跑出来的结果大致是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 8 5 9 7 6 1 4 2 3 4 2 6 8 5 3 7 9 1 7 1 3 9 2 4 8 5 6 9 6 1 5 3 7 2 8 4 2 8 7 4 1 9 6 3 5 3 4 5 2 8 6 1 7 9建议你无论如何都加上这层转换核对答案时肉眼扫一遍比看 81 个连续字符可靠得多。4. 踩坑记录与排查速查表4.1 我遇到过的三类典型报错在实际运行过程中我遇到过三个比较常见的报错这里整理一下。第一个是递归引用相关报错提示信息类似ERROR: recursive reference to query solve must not appear more than once这个报错一般是在递归项里使用了JOIN solve两次或者把递归表同时用在FROM和子查询里而且子查询还以集合形式引用了递归表。解决思路是让递归引用只出现在FROM处其他需要在子查询中引用的字段尽量用相关列引用而不是再次FROM solve。第二个是 set-returning function 使用位置问题。PostgreSQL 有些版本不允许直接在WHERE子句里调用generate_series()比如WHERE generate_series(1, 9) 1这种写法在部分版本会报错或者行为跟预期不一致。我的处理方式是把generate_series放到FROM里用CROSS JOIN LATERAL这样更符合数据库的集合处理习惯。第三个是类型转换问题。generate_series生成的是整数而字符比较时要用文本类型。如果你写substr(x, p, 1) d整数d和文本substr(...)比较时可能会触发隐式转换逻辑上没问题但一旦比较对象变成其他类型就容易报could not identify an equality operator。我的习惯是直接写d::text明确告诉数据库把整数转成文本再比。4.2 性能爆炸与无解数独的处理数独求解的搜索空间理论上是指数级的无解盘面尤其明显。有一次我把一个盘面的已知数字删掉两个导致多个空格的可选数字变多SQL 一跑就是几十秒最后我用statement_timeout强制掐掉。排查性能问题时我的思路是分两步先看中间结果数量是否异常再看是不是缺少“只填最前空格”的约束。中间结果数量可以在原 SQL 上临时加一个计数版本WITH RECURSIVE solve(x, depth) AS ( SELECT 初始盘面::text, 0 UNION ALL SELECT ..., depth 1 FROM solve, ... ) SELECT depth, count(*) FROM solve GROUP BY depth ORDER BY depth;这样你能看到每一层递归生成了多少状态。如果某层状态数已经上千说明剪枝条件太弱或者盘面本身无解。一般来说标准 9×9 数独在正确剪枝下中间状态数量不应该爆炸到无法接受。4.3 AI 辅助学习时容易被带偏的点用 DeepSeek 这类工具辅助解读源码“真香”是真的但要注意三个容易被带偏的点。第一AI 容易“补齐式解释”。比如你贴一段代码过去它哪怕没见过某个函数的细节也会根据上下文编一段看似合理的解释。我遇到过一次它把translate解释成“删除字符”的语义但实际 PostgreSQL 里translate是字符映射替换两者行为不同。还好我跑实验验证不然就被带偏了。第二AI 默认你使用的是“现代写法”。它给出的优化建议可能是基于 PostgreSQL 15 或更高版本的行为甚至混合了其他数据库的语法。所以你需要自己确认版本差异。第三AI 往往不会主动告诉你“这段代码为什么这么设计”。比如“只填最前空格”这个限制从结果正确性来说就算同时填多格只要约束条件对最终也可能得到解但搜索空间会大很多。这种经验性的取舍AI 不一定会主动强调你得自己追问。我的方法是让 AI 给结论时必须附上“如果不这样做会怎样”的推演。它一旦开始推演就会暴露出很多隐含假设你再去验证这些假设学到的比单纯看代码多得多。4.4 三个实战避坑建议最后分享三个我在实际操作中总结出来的建议这些在普通文档里一般不会写。第一个建议先用“最短空格优先”还是“最前空格优先”Wiki 版和大多数教学版会用“最前空格优先”但它不是最优策略。更高效的策略是选可选数字最少的空格先填这样剪枝更快。如果你想在 PostgresSQL 里实现最短空格优先需要额外计算每个空格的候选数量复杂度更高教学版为了可读性选择了最前空格。这一点在你做性能优化时可以改。第二个建议把递归 CTE 拆开调试。别指望一次性写对。我会先把“初始状态”单独跑一遍确认字符串长度是 81再单独验证坐标公式最后再放到递归里完整跑。每一步都验证过最后出错时才能快速定位。第三个建议出于安全考虑求解器的最终 SQL 不要在生产环境数据库上直接跑。尤其是在共享实例上一个几十秒的递归查询可能拖垮同库的其他应用。我通常会在本地或者专用开发环境里跑用完就关。5. 后续还能怎么玩如果你已经跑通了上面的 SQL别急着关掉页面。这个求解器还有不少值得扩展的方向。第一把输入盘面改成参数而不是硬编码字符串。比如用WITH input(sud) AS (VALUES ($1))接收外部盘面这样就能给这个求解器做个“通用接口”。第二结合表格显示函数把求解结果转成 9×9 的可视化网格。除了前面那种string_agg输出你还可以用array_agg把它拼成二维数组配合前端工具展示。第三尝试用它解更复杂的变形数独比如对角数独、锯齿数独。只要改约束条件里的NOT EXISTS部分把“行、列、宫”的检查换成“行、列、对角线”就能适配新规则。我自己的体会是不要把这个例子当成“炫技 SQL”而应该把它当成理解递归 CTE 的活教材。它比普通的“递归累加”例子有意思得多因为每一步递归都有明确的业务含义填一个数字、检查三条约束、生成新状态。理解了它以后再写递归查询处理树形结构、层级关系、状态搜索脚本思路会打开很多。最后一个实用小技巧如果你想让 DeepSeek 帮你总结类似的源码记得把数据库版本、输入格式、预期输出这三个信息一起写进 prompt。AI 的回答质量会明显提升因为它不用猜你的环境。我在总结这个数独求解器时加了“PostgreSQL 15、输入是 81 字符文本、目标是输出解盘文本”这几个约束后面它给出的解释几乎都能直接用。