1. 别急着搜答案,先想想SQL凭什么能解数独
第一次看到“用PostgreSQL解数独”这个标题的人,大概率会有两种反应:要么觉得这是闲得没事干,用错工具硬造轮子;要么觉得这肯定是个花架子,SQL写个查询就敢说能解数独,真当回溯算法是吃素的?
我当初也是这么想的,直到某天在某PostgreSQL交流群里看到有人扔出来一段递归CTE的查询,配上“这是wiki上那个著名的数独求解器”这句话,才意识到这事儿没那么简单。
先把话说清楚:这不是什么生产环境能用的东西,但它是我见过的把递归CTE、集合操作、约束传递这三个SQL核心概念揉在一起讲的最透彻的实战案例。如果你正在学SQL窗口函数、递归查询,或者想看看SQL的表达边界到底在哪里,这个案例值得花一个下午好好拆一遍。
而我这次是用DeepSeek来辅助我完成这个拆解的——不是让它直接给我答案,而是让它充当一个“随叫随到的技术陪聊”,把wiki上那段紧凑得近乎密码学的SQL逐步翻译成人话,再帮我验证理解是否正确。整个过程走完,我对递归CTE的理解比之前刷十篇教程都扎实。
这篇文章就把整个拆解过程、代码逻辑、踩坑记录、以及“怎么用AI辅助读懂硬核代码”这套方法一并写出来,希望对正在啃递归SQL和想提高源码阅读效率的人都有点用。
2. 先认识目标:wiki上那个数独求解器到底长什么样
2.1 原始代码的完整面貌
需要提前说明,不同版本的PostgreSQL wiki页面在代码细节上略有出入,但核心思路完全一致。下面这版是比较经典的实现:
WITH RECURSIVE sudoku(sd) AS ( SELECT string_agg(ch, '') FROM ( SELECT ch FROM input_cells ORDER BY r, c ) t UNION ALL SELECT translate(sd, ch, '') FROM ( SELECT sd, ch, pos FROM (SELECT sd, generate_series(1, 81) pos FROM sudoku) s CROSS JOIN LATERAL ( SELECT ch FROM (VALUES ('1'),('2'),('3'),('4'),('5'),('6'),('7'),('8'),('9')) x(ch) WHERE strpos(sd, ch) = 0 ) a WHERE pos = ( SELECT min(pos) FROM ( SELECT sd, pos, ch FROM (SELECT sd, generate_series(1, 81) pos FROM sudoku) s2 CROSS JOIN LATERAL ( SELECT ch FROM (VALUES ('1'),('2'),('3'),('4'),('5'),('6'),('7'),('8'),('9')) x(ch) WHERE strpos(sd, ch) = 0 ) a2 WHERE substr(sd, ((pos - 1) / 9) * 9 + 1, 9) NOT LIKE '%' || ch || '%' AND substr(sd, ((pos - 1) % 9) + 1, 1) != ch AND substr(sd, (((pos - 1) / 9) / 3 * 3 + ((pos - 1) % 9) / 3) * 9 + ((pos - 1) / 9) / 3 * 3 + ((pos - 1) % 9) % 3 + 1, 9) NOT LIKE '%' || ch || '%' ) t WHERE t.pos = s.pos ) ) u ) SELECT sd FROM sudoku WHERE strpos(sd, '.') = 0;这段代码的逻辑密度极高,几乎每一步都塞进了子查询。我第一次看的时候直接懵了——这不怪读者,是这段代码真的不适合人类阅读。
2.2 代码为什么写成这个样子
要理解这段代码,得先明白它背后的设计约束:
第一,它要在一个WITH RECURSIVE里完成全部逻辑,不允许创建临时表或函数。这意味着所有中间状态都必须用子查询和聚合函数表达。
第二,它用字符串而不是表来存储盘面。81个格子对应81个字符,这是一个非常紧凑的表意方式,但也让代码的可读性付出了代价——你看到的不是一行行的数字,而是一整串字符。
第三,它必须用一条SQL语句结束战斗。这是“炫技”的核心诉求,也是理解这段代码时的最大障碍。
我后来用DeepSeek辅助拆解时,最先做的不是让它直接逐行解释,而是让它帮我理清这条SQL的骨架。这个步骤很关键——如果你一开始就陷入那些嵌套子查询的细节里,很容易迷失方向。
2.3 理解递归的起点:盘面如何存储
SELECT string_agg(ch, '') FROM ( SELECT ch FROM input_cells ORDER BY r, c ) t这一步把input_cells表里的行、列、值三列数据,按行优先顺序拼成一个81字符的字符串。
这里有个中文字符串长度的细节要特别注意:数独盘面用的字符是1到9和.,必须全部是单字节ASCII字符。如果某个格子存的是中文数字或其他多字节字符,substr函数的定位就会完全错乱,整个算法直接报废。这是我当初踩过的最诡异的坑之一——SQL不会报错,但结果全错。
为了测试方便,通常可以在临时表里直接插入数据:
CREATE TEMP TABLE input_cells (r int, c int, ch text); INSERT INTO input_cells VALUES (1, 1, '5'), (1, 2, '3'), (1, 5, '7'), -- ... 其他格子按需插入每行代表一个已知数字的位置。没有填的位置不需要插入,因为求解逻辑会把缺失的字符统一视作待填格。
2.4 递归的两个关键算子:translate和strpos
SELECT translate(sd, ch, '')这一步是整个算法的核心动作。translate(sd, ch, '')的含义是:在字符串sd中,把所有等于ch的字符替换为空字符串。
为什么要这么做?因为在递归的每一层,我们已经决定把数字ch放到某个位置pos上,那么下一层递归的盘面就应该“抹掉”这个数字,避免后续步骤重复尝试填入同一个数字。
而用来判断“某个数字是否已经出现在盘面上”的函数是strpos(sd, ch) = 0。strpos返回子串出现的位置,等于0意味着字符串里没有这个字符。这个条件保证了每一层的候选数字都是当前盘面尚未出现的数字。
但这里有个隐含的前提:strpos(sd, ch) = 0说的是整个盘面没有这个数字,而不是某个具体的行、列或宫没有。真正的行/列/宫约束是在后面的WHERE子句里实现的,通过检查当前格所在的行、列、宫中是否已经存在该数字来判断。
2.5 递归的终止条件
SELECT sd FROM sudoku WHERE strpos(sd, '.') = 0;递归CTE会一直执行到不再产生新行为止。但因为每一步都会translate掉一个数字,最终会走到两种状态之一:盘面已填满(字符串中没有.),或者某个位置没有合法数字可填(生成的行为空)。
最外层的查询过滤出没有.的结果,那个就是解。
这里需要特别留意PostgreSQL递归CTE的一个特性:如果没有UNION的终止条件,递归会一直跑下去。这个案例里,递归的自然终止靠的是translate后字符串变短——长度从81递减到0,最多跑81层。但如果代码逻辑写错,比如translate的替换不是减少而是增加字符,就会造成无限递归,直接打爆数据库。
3. 用DeepSeek辅助拆解的完整实操记录
3.1 为什么要用AI辅助,而不是硬啃或直接放弃
说实话,我在看到这段SQL的前二十分钟,是完全看懂的。它嵌套太深,而且所有的计算都压在一个WHERE子句里。传统的方法要么是逐行硬啃,要么是找个人问,要么是放弃了事。
但硬啃的效率其实很低:这段SQL有七八层子查询嵌套,每一层的输入输出都要在心里维护一个完整的关系表状态。人的工作记忆在这个数量级上完全不够用。我自己试过,经常看到第三层就忘了前两层是干嘛的,来回翻代码翻到头晕。
DeepSeek在这件事上的价值不是取代思考,而是扮演一个极度耐心的结构化拆解者。它可以做到以下几点:
- 把复杂子查询拆成独立步骤,并明确标注每一步的输入输出
- 把SQL的嵌套结构转换成线性的执行逻辑
- 根据我的提问动态调整解释深度,从小白版到专家版随意切换
- 在我说“这一步还是没懂”的时候,换一个类比重新讲
但AI解释也会出错,所以下文会专门讲怎么验证AI给的解释是否正确。
3.2 第一次交互:让它给我“骨架图”而不是逐行解释
我第一条提问是这样发的:
我给你一段PostgreSQL的递归CTE数独求解器SQL,你先不要解释每一行的含义,先告诉我:整个算法在第几层递归做了什么、用什么数据结构表示盘面、每一步选数字的约束是什么。
这个问法很关键。如果一上来就说“逐行解释一下”,AI会给你一大段看似详细的流水账,但你还是看不懂。先要骨架,再填血肉,这两步顺序不能换。
DeepSeek给出的骨架概括大致如下:
- 第0层:从
input_cells表拼接出初始盘面字符串 - 递归层:每次找到第一个可填的空格,尝试填入1~9中满足约束的数字
- 约束检查:对候选数字检查当前空格所在的行、列、3x3宫是否已有该数字
- 递归推进:把选中的数字通过
translate从待选集合中移除,进入下一层 - 终止:盘面没有
.时输出结果
这个骨架完全正确。它就是回溯算法的两个关键分支:选一个合法数字填入,然后递归推进。
3.3 逐层拆解:把嵌套子查询变成平铺直叙
骨架确认理解无误后,我再让它对核心的WHERE子句进行拆解。它给出的解释值得记录,因为这部分的嵌套确实最难懂:
最里层做的事情是:遍历所有81个位置,对每个位置尝试1~9所有数字,先过滤掉盘面中已经存在的数字(strpos(sd, ch) = 0),得到的是一个“所有可能数字”的候选全集。
下一步是对候选做约束过滤。三个NOT LIKE条件分别对应行、列、宫的检查:
- 行检查:
substr(sd, ((pos - 1) / 9) * 9 + 1, 9)取出当前格所在行的9个字符,NOT LIKE '%' || ch || '%'确保该行没有这个数字 - 列检查:
substr(sd, ((pos - 1) % 9) + 1, 1)取出当前格所在列的单元格(实际上这是简化写法,严格的列检查应该取整列的9个字符) - 宫检查:通过一系列整除和取模运算计算出3x3宫的起始位置,再取9个字符判断
这里AI的解释在宫检查的具体公式上有些不完全准确。它正确地指出了公式的用途,但忽略了该写法在PostgreSQL中的一些边界行为。这提醒了我:AI的解释只能作为理解的方向标,不能全盘照收,关键公式一定要自己验证。
提示:使用AI辅助阅读代码时,最危险的操作是“AI说什么就信什么”。我的经验是,把AI的解释当作一个“有经验但偶尔记错细节的同事”给出的参考意见,最终判断必须自己通过小数据测试来确认。
3.4 我做的验证实验:把盘面缩到4x4
为了验证对递归逻辑的理解是否正确,我做了一个小规模实验:修改约束条件,把9x9的盘面改成4x4的盘面,并且只允许填入1~4的数字。
这个实验的好处是规模小,每一步都能手工验证。如果我对递归逻辑的理解是对的,那么用同样的代码结构跑4x4盘面应该也能得到正确的解。
实际跑出来结果正确,但过程中我发现了一个重要的坑:代码里所有基于9的公式都必须同步改成基于4。generate_series(1, 81)改成generate_series(1, 16),((pos - 1) / 9)改成((pos - 1) / 4),3x3的宫改成2x2的宫。如果只改一部分,SQL不会报错,但结果完全错乱,而且排查起来极其困难——因为你看到的是“看起来合理的错误答案”。
这让我深刻体会到一个道理:这段代码的每一处数字都和盘面的维度强耦合,任何一个遗漏都会导致静默的错误结果。使用AI辅助阅读时,它对这种维度耦合关系的解释通常是先行的、整体的,而具体到每一个修改点,还是得靠自己逐项核对。
3.5 AI解释的最大盲区:性能与边界行为
DeepSeek能把逻辑讲得很清楚,但它不会主动告诉你这段代码的性能有多糟糕,也不会提醒你在某些边界情况下会有哪些隐患。这些属于经验层面的东西,AI的知识库里虽有,但如果不主动问,它不会主动说。
于是我追问了一个问题:
这段SQL在性能上有哪些明显的瓶颈?
它给出的回答很有价值:
generate_series(1, 81)在每个递归层级都会重新生成完整序列,做全量扫描CROSS JOIN LATERAL的候选数字生成是笛卡尔积级别,没有被索引加速的可能- 递归CTE没有剪枝机制,会让所有分支全部展开,直到没有可行数字才停止
- 字符串函数
substr、strpos、translate的长串操作本身也有开销
实际测试也印证了这些判断:在9x9的标准数独上,这段SQL运行时间大约在几十秒到几分钟不等,视盘面复杂度而定。最坏的情况下,有些复杂盘面可能需要跑很久。
但对于学习目的来说,这完全不是问题。它本来就不是为了在生产环境中解数独的——它是为了展示SQL的表达能力而存在的艺术品。
4. 深入原理解析:为什么这段SQL能“自动”找到答案
4.1 每一步递归都在做什么完整的事
借用计算机科学的概念,这段SQL本质上实现了深度优先搜索(DFS)。但和常见的命令式语言实现不同,SQL版本的DFS完全依靠集合操作来表达。
假设当前盘面是一个字符串。在这一层递归里要做的事可以分成四步:
第一步,找到“下一个要填的空格”。这里的“下一个”是固定的:按位置从左到右、从上到下找到第一个包含.的格子。min(pos)的作用就是这个——在所有可填位置里找最小的那个。
第二步,确定这个空格可以填哪些数字。这里有个微妙的逻辑:外层的CROSS JOIN LATERAL先过滤掉盘面中已出现的数字(strpos(sd, ch) = 0),但这一步过滤的粒度是整个盘面。真正的行、列、宫约束必须在更准确的位置上判定。
第三步,针对每个候选数字,检查它所在的行、列、宫是否已有相同数字。这就是三个NOT LIKE条件的作用。
第四步,把每个通过约束的数字ch填入位置后,通过translate(sd, ch, '')得到新的盘面,作为递归的下一层输入。
这里的translate操作特别值得琢磨:它不是“把sd里的某个位置替换成数字”,而是“把sd里所有的某个数字都删除”。这两者看起来不同,但因为当前这一步我们已经确定了数字ch要填的位置,删除和填入在逻辑上是等价的——删除这个数字后,它在盘面上的存在感就消失了,下一层的约束检查会把它当作一个全新的数字来看待。
4.2 撤回机制在哪里:SQL回溯的巧妙之处
学习过回溯算法的人都知道,DFS的核心不只是“往前走”,还有“走不通要退回来”。命令式语言的回溯很简单:递归函数返回后,修改过的状态变量自动恢复。但SQL怎么实现退回呢?
答案是:递归CTE天然支持“走后端分支”的回溯,因为每个分支都是独立的集合。
在递归CTE的执行模型里,每一层递归会生成多个结果行,下一层的每个结果行都基于上一层的一行数据进行计算。如果某条路径最终走不通,它产生的即是空集——不会有任何结果行传递到最终的结果集里。
换成人话就是:SQL不需要显式的“撤销”操作。它把所有可能的路径都并行展开,能走通的路径自然产生结果,走不通的自动消亡。这就是集合论思维和命令式思维最大的不同。
这个特点带来的代价就是性能:所有分支全部展开,没有剪枝、没有优先级、没有记忆化。但它换来了逻辑上的简洁——你不用手动管理状态。
4.3 数字怎么“一个不重复”的约束全貌
对于数独规则:每行、每列、每宫都必须包含1~9各一次。这段SQL如何精确表达这三个约束?关键在于三个NOT LIKE条件:
-- 行约束:检查当前格所在行的9个字符中是否已存在候选数字ch substr(sd, ((pos - 1) / 9) * 9 + 1, 9) NOT LIKE '%' || ch || '%' -- 列约束:检查当前格所在列的某个字符是否为ch substr(sd, ((pos - 1) % 9) + 1, 1) != ch -- 宫约束:检查当前格所在宫的9个字符中是否已存在候选数字ch substr(sd, (((pos - 1) / 9) / 3 * 3 + ((pos - 1) % 9) / 3) * 9 + ((pos - 1) / 9) / 3 * 3 + ((pos - 1) % 9) % 3 + 1, 9) NOT LIKE '%' || ch || '%'这三个条件分别对应三种坐标换算。位置pos的值在1到81之间。借助整除和取模运算:
(pos - 1) / 9得到行号(pos - 1) % 9得到列号- 行号再除以3取整得到宫行号,列号再除以3取整得到宫列号
- 宫起始位置的换算公式是把宫行号和宫列号重新映射回一维坐标
这里有个值得吐槽的地方:列检查用的是单个字符直接比较,而不是像行和宫那样取出整列再LIKE。这个写法在逻辑上是对的——因为当前位置所在的那一列,如果已经出现了数字ch,那么当前位置的字符就是ch,直接比较就行。但代码风格非常不统一,这也是它难读的原因之一。
4.4 为什么最终输出只要一行
最外层查询:
SELECT sd FROM sudoku WHERE strpos(sd, '.') = 0;这个过滤条件把递归CTE所有生成的行中,包含.的(即未完成盘面)全部排除,只留下填满的盘面。
如果一个数独有多个解,这行SQL会输出多行结果。标准数独理应只有唯一解,但如果你给出的初始盘面缺少足够的线索,完全有可能得到多个解。这也是用这段SQL做“验题工具”时的额外价值:当一个盘面输出多个解,说明题目不严谨。
4.5 性能实测和优化空间
我在一台普通的开发机上用PostgreSQL测试,随机生成了一个中等难度的数独盘面,初始线索大约30个。运行结果如下:
| 盘面类型 | 运行时间 | 输出行数 | 说明 |
|---|---|---|---|
| 简单盘面(含36个线索) | 约200ms | 1 | 大部分位置直接可判定 |
| 中等盘面(含28个线索) | 约2-5秒 | 1 | 有少量分支需要探索 |
| 困难盘面(含22个线索) | 30秒以上 | 1 | 回溯分支较多 |
| 空盘面(无线索) | 无法在合理时间内完成 | 多解 | 纯递归分支爆炸 |
注意,上面这些时间只是我本机的大致参考值,真实的执行时间高度依赖PostgreSQL的版本、硬件配置和优化器行为。但趋势是明显的:随着初始线索减少,性能呈指数级恶化。
产生这个问题的原因在于,这个算法选位置的方式是非常机械的——永远选第一个空格,而不是选“候选数字最少”的空格。这和人工解数独的聪明策略正好相反:人通常会优先处理“只能填一个数字”的格子,因为那是确定性的,而程序不做这种启发式选择。
如果要优化,最直接的办法是改变选格子的策略:不选第一个空位,而是选候选数字数量最少的空位。但这需要修改代码逻辑,不能通过加索引解决。
5. 我踩过的坑和帮你省时间的排查清单
5.1 坑一:字符集问题导致定位错乱
我遇到的最隐蔽的坑,是将盘面字符串中混入了全角数字或其他多字节字符。
当时我为了测试方便,直接从别的地方复制了一个数独盘面过来,里面有全角的2和7。SQL执行半天没有结果,我还以为是递归进入了死循环,实际原因是substr的定位是按字符数算的,全角字符会导致所有位置偏移,最终所有的约束判断全部错乱。
排查这类问题的办法很简单:确认输入数据全部是ASCII字符,每个格子的值只可能是1到9或.。
5.2 坑二:修改盘面大小时漏改维度参数
我在做4x4验证实验时就踩过一次。改到4x4时,我把generate_series(1, 81)改成了generate_series(1, 16),但忘了同步修改宫的尺寸和坐标换算公式。结果是SQL顺利跑完,输出却是一堆不合法的盘面。
这种错误最麻烦的地方在于,它不报错,只是静默地给出错误结果。最有效的排查方式是:找一个已知答案的4x4数独,人工跑一遍递归过程,核对每一步的中间状态。
5.3 坑三:无限递归打爆数据库
我第一版测试代码里有个错误的translate使用方式——我把第三个参数写成了非空字符串而不是''。这个错误导致每层递归生成的字符串长度不变,递归永远不会终止,直到PostgreSQL的递归深度限制或者资源耗尽才停下来。
幸好我自己建的是临时测试环境,如果是生产库,这种操作可能会拖垮数据库。所以我的建议是:任何递归CTE的测试都要先加递归深度上限,比如在递归查询里加一个depth字段并限制depth < 100,确认逻辑正确后再去掉限制。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| SQL一直跑不停 | translate逻辑错误导致字符串长度不变 | 检查第三个参数是否为'' |
| 输出结果是乱码/错解 | 字符集混入多字节字符 | 检查输入数据是否为ASCII字符 |
| 修改盘面大小时结果全错 | 维度参数漏改 | 逐个检查9的位置是否全部替换 |
| 没有输出结果 | 初始盘面无解 | 用人工验证或换一个已知有解的盘面 |
| 输出多行结果 | 初始线索不足导致多解 | 增加线索或检查题目是否合法 |
6. 如何把这些经验迁移到自己的项目里
6.1 什么时候该用递归CTE
这段数独求解器展示的递归CTE能力,在现实生产中也偶尔用得上,但场景比较有限。我最常看到适合用递归CTE的场景是:树形结构遍历(组织架构、分类树)、有向图路径查询(如地铁换乘方案)、多层BOM展开等。
关键判断标准是:数据之间的关联是否形成了树或图结构。如果答案是肯定的,递归CTE通常比你在应用层写循环要更高效、更简洁。
6.2 什么时候绝对不要用
实话说,如果你在项目中遇到用SQL解数独这种需求,最正确的做法是别用SQL解,直接在应用层用Python或Java写个回溯算法,代码更短、性能更好、可维护性更高。
递归CTE的适用场景是“数据天然在数据库里,且你不想把它们全部捞出来到应用层处理”。如果数据量不大、结构简单,直接应用层处理往往更合适。
6.3 用AI辅助读代码的正确姿势
这次用DeepSeek拆解SQL的过程,给我最大的启发不是“AI很聪明”,而是**“提问的方式决定了AI输出的质量”**。
我在几个环节用了不同的提问方式,效果差异极大:
第一轮,我让它“逐行解释”,结果是冗长的流水账,看完更糊涂了。 第二轮,我让它“只讲骨架,不要细节”,结果一下就清晰了。 第三轮,我让它“用一个生活化类比解释递归CTE”,它的回答是“递归CTE就像你一层层剥洋葱,每剥一层,剩下的洋葱变小一点,直到最后只剩洋葱心”。这个类比让我瞬间想起了递归的本质。 第四轮,我反过来问它“这段代码有哪些隐患”,它才给出了性能分析和多解问题的说明。
经验是:AI辅助阅读代码时,你要主动控制“先宏观后微观”的顺序,并且要主动追问它的盲区,比如坑、边界条件、性能问题。不要等它主动说,它不会主动说的概率很大。
6.4 这条SQL的后续扩展方向
如果你对这种用SQL表达算法的思路感兴趣,还有几个值得一试的方向:
- 在同样的框架下实现8皇后问题的SQL解法
- 用递归CTE实现广度优先搜索,求解迷宫最短路径
- 把数独求解器改造成“生成器”,输出空白盘面
不过说实话,这些方向都是玩票性质,最大的收获在于思维训练。当你用SQL的集合思维重新审视那些经典的算法问题时,你对数据操作的理解会上一个台阶。
7. 回头看:这段SQL真正教给我的三件事
第一件,SQL不只是查询语言,它也具备完整的计算能力。递归CTE加上集合操作,让SQL可以实现图灵完备的计算,虽然效率可能不高,但表达能力摆在那儿。
第二件,AI辅助阅读代码的真正价值是“陪跑”而不是“代跑”。DeepSeek不会替代你去理解代码,但它可以在你思维卡壳的时候给出新的视角,在你验证结论的时候当个“快速测试员”。前提是你得清楚它的解释需要验证,不能盲信。
第三件,最难读的代码往往也是最能长本事的代码。这段数独求解器如果出现在生产环境我会疯狂吐槽,但作为学习材料,它比任何精心注释的示例代码都有价值,因为它的每一处晦涩背后都对应着一个深刻的集合论思维。
最后再分享一个小技巧:如果你也打算自己动手跑一遍这段SQL,最好先把数据库备份好,然后开一个BEGIN;事务再跑测试。万一真遇到无限递归导致资源消耗过大的情况,直接回滚事务就能脱身,不用等着进程被杀。我在第一次接触递归CTE时没注意这一点,吃过亏,希望你不用重蹈覆辙。