1. 先交代一下:这个“10 件事”是怎么来的
Advent of Code 对程序员来说,基本就是每年 12 月的“圣诞解题日历”,从 12 月 1 日到 25 日,每天一道算法题。大多数人都用 Python、Go、Rust 去刷,因为写起来快、调试也方便。而我当时脑子一热,想着“我天天写 PostgreSQL,那我能不能只用 SQL 解?”
结果这一热就是二十多天。
过程确实痛苦:SQL 没有数组下标遍历的天然写法,没有 while 循环,很多你觉得“一句话就能搞定”的迭代逻辑,在关系模型里要绕一大圈。但痛苦归痛苦,等我把这些题啃完之后,回过头看自己的解法,发现收获比想象中多得多。后来我把所有解法整理到一起,让 DeepSeek 从里面总结“用 PostgreSQL 解题时反复出现的方法和坑”,它列了 10 条,我和它反复讨论后,把最终的十条结论沉淀成了这篇文章。
如果你也是一个在工作里天天用 PostgreSQL、却很少拿它做“正经算法题”的人,这篇文章会告诉你:SQL 不是只能做 CRUD,它能解决图遍历、动态规划、网格搜索这些看起来“不该由数据库干”的活,只是思路要先转个弯。
下面这 10 件事,按我实际解题过程中踩坑价值的先后排序,每一件都配有代码片段和解释。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 把文本变成行之后再思考,而不是硬记“字符串”
第一件要学的事,也是最基础的一件事:Advent of Code 的输入几乎全是文本,一个空行分隔一组测试用例,每行可能是数字、可能是指令、可能是坐标。常规思路是把这些文本读进内存,然后 split、正则、再转换为结构化数据。但 PostgreSQL 里没有“读进内存”这回事,你只有表。
所以正确姿势是把输入当成“行集合”来处理。
例如某道题给了一串“move 3 from 2 to 1”这样的指令行,我一开始会想着建一张表然后逐行去解析,但更好的做法是直接一条 SQL 把文本变成字段:
sql复制SELECT
(m[1])::int AS move_count,
(m[2])::int AS from_stack,
(m[3])::int AS to_stack
FROM input,
LATERAL regexp_match(line, '^move (\d+) from (\d+) to (\d+)$') AS m
WHERE line LIKE 'move%';
这里最关键的是 regexp_match 配合 LATERAL。regexp_match 返回一个 text 数组,里面是正则里括号捕获的每一组。把它放进 LATERAL,就能在当前行的上下文中生成一个“虚拟列”,然后直接通过下标取出来。
如果你拿到的是逗号分隔或者空格分隔的数组型输入,那更不需要建复杂的解析函数:
sql复制SELECT
line_no,
unnest(string_to_array(line, ',')) AS item
FROM input;
string_to_array 先把字符串拆成数组,unnest 再把数组展开成多行。这两步组合起来,就是 SQL 界的“split + 枚举”,几乎所有以逗号分隔的输入都能用这条思路解决。
同样的道理也适用于多行二维网格。比如一个 10x10 的字符矩阵,你不需要把每一行当作一行文本存起来,再在应用层去访问 grid[3][4],应该直接拆成“每个字符一行”的扁平表,这样后续的网格操作全部可以用 SQL 集合操作完成,后面第 5 章会细讲。
2.1 我的实操心得
文本解析阶段最容易犯的错是:只做了一次 regexp_match,没考虑到某些行可能匹配不上。我建议你在解析后的查询里加一个 WHERE 过滤,或者干脆用 NULLIF 把空串转成 NULL,否则后面做聚合运算时,NULL 会无声无息地传播。
另外一点:不要总想着把所有字段一次性塞进一个表再处理。PostgreSQL 的集合操作、索引、以及执行计划优化,都是基于“行”而不是“列”的。把一个长文本串暴力拆成几百列,会让后续所有查询都变成灾难。正确做法是拆成行、拆成规范化的键值对,然后再聚合。
3. 递归 CTE 就是 SQL 世界里的 while 循环
二三十道 Advent of Code 题里,用到递归 CTE 的至少有三分之一。无论是遍历迷宫、推导状态、还是模拟“每一步所有可能的变化”,你都会发现:SQL 没有 while,但 WITH RECURSIVE 是比 while 更强大的东西。
先看一个典型场景:走迷宫。每个格子是一个节点,每个节点可以走向上下左右四个方向。传统程序里,你会用队列做 BFS。但 PostgreSQL 里,你只需要这样:
sql复制WITH RECURSIVE reachable AS (
SELECT x, y, 0 AS step
FROM grid
WHERE is_start
UNION
SELECT n.x, n.y, r.step + 1
FROM reachable r
JOIN grid n
ON (ABS(r.x - n.x) + ABS(r.y - n.y)) = 1
WHERE n.walkable
)
SELECT * FROM reachable;
WITH RECURSIVE 的语法分两部分:第一部分是“锚点”,即初始状态;第二部分是“递归项”,每一轮迭代都会以上一轮的结果作为输入继续执行。这个 UNION 默认去重,所以天然帮你避免了 BFS 里最常见的“重复入队”问题。
还有些题目不是图遍历,而是“物质演化”,比如石头每次遇到特定条件会分裂成更多石头。这种问题用递归 CTE 也能做,核心是把“当前所有状态”存成一行一行,然后在递归项里去更新这些行。只要每一步的运算量不太大,PostgreSQL 能跑得比你想的快得多。
3.1 一个必须先掌握的克制技巧
递归 CTE 最怕的是“无限递归”。虽然 PostgreSQL 默认有递归深度限制(默认是 1000),但一旦触发限制,报错信息会特别隐晦,你根本不知道是数据写错了还是业务上循环了。
所以我的做法是:任何递归 CTE 都先加一个 step 上限。哪怕题目明确说“只需要计算 25 步”,我也会在 WHERE 条件里写上 r.step < 100,调试的时候再逐步放开。这能帮你快速定位是哪一轮的数据开始变形的。
另一个提升性能的细节:递归项里用到的表,最好是有主键或者唯一约束的。如果某个状态表越来越大,而且你要反复 JOIN 它,没有索引的话,执行计划会退化成嵌套循环,慢到怀疑人生。
4. 窗口函数:很多题的答案藏在“相邻行”里
Advent of Code 里有一类经典送分题:给你一串数字,让你统计“比前一个数字大的次数”。用 Python 写只要一个循环:
python复制count = sum(1 for i in range(1, len(nums)) if nums[i] > nums[i-1])
那 SQL 怎么写?用窗口函数 LAG。
sql复制SELECT COUNT(*) AS increased_count
FROM (
SELECT value - LAG(value, 1) OVER (ORDER BY line_no) AS diff
FROM input_numbers
) t
WHERE diff > 0;
LAG(value, 1) 能拿到当前行前一行(按 ORDER BY line_no 排序)的值,两行一减,得到的就是增量。整个思路完全是“集合的视角”,不用关心下标,也不会越界。
这只是窗口函数最基础的用法。进阶一点,很多题需要你求出“滑动窗口内的最大值/和”,这时候用:
sql复制SELECT
value,
SUM(value) OVER (
ORDER BY line_no
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
) AS window_sum
FROM input_numbers;
ROWS BETWEEN 能精确指定窗口范围,比你在应用层维护一个长度为 3 的列表要清晰得多。还有 ROW_NUMBER()、RANK()、FIRST_VALUE() 这些,在解决位置排序、寻找最早/最晚值时经常能一句搞定。
4.1 窗口函数为什么值得花时间理解
很多从过程式语言转过来的开发者会本能地忽略窗口函数,觉得“我用子查询也能算出来”。但问题在于,子查询做这种跨行计算,通常需要自连接,而自连接会产生大量的中间结果,执行计划还很差。窗口函数是数据库内部直接支持的算子,性能上完全是两个量级。
我在刷题时最常用的三个窗口函数是 LAG、SUM(...) OVER、ROW_NUMBER()。刷完后我发现,工作中的很多报表需求,其实也可以用同样的思路写得更优雅。尤其是那些“计算同比、环比”的 SQL,一行 LAG 就完了,不用再去 JOIN 一张上个月的表。
5. 网格题不要写双重循环,请改用 JOIN
网格是 Advent of Code 的常客:二维数组,每个格子有值,让你找相邻关系、找路径、找区域。很多人会自然地把网格建模成“二维数组”,然后在应用层写两个嵌套 for 循环去遍历邻居。但在 PostgreSQL 里,你应该把它变成一张扁平的二维表:每行是一个格子,包含 x、y、value。
先看一下怎么用 SQL 生成一张空网格:
sql复制SELECT x, y
FROM generate_series(1, 10) AS x
CROSS JOIN generate_series(1, 10) AS y;
然后,找出每个格子上下左右四个方向的邻居,不再需要循环,只需要一个自连接:
sql复制SELECT
a.x AS x1, a.y AS y1,
b.x AS x2, b.y AS y2
FROM grid a
JOIN grid b
ON (ABS(a.x - b.x) + ABS(a.y - b.y)) = 1;
这里的核心思想:曼哈顿距离等于 1 的行对,就是相邻格子。你把二维坐标转换成关系之后,“邻居关系”本身变成了一个可查询的集合。
类似地,很多网格题里的“检查横向/纵向/斜向是否存在某个单词”的问题,也可以通过“候选起点 + 方向向量”的笛卡尔积来解决。你不需要逐个格子判断,只需要把起点集、方向集 JOIN 起来,再通过下一个格子的坐标去关联。这种写法一旦跑通,性能和维护性都比手写循环好得多。
5.1 自连接的坑
自连接看着爽,但有个性能大坑:如果表没有索引,两个几千行的表做自连接,执行计划可能生成百万级的中间结果。我建议在自连接之前,先避免“全表大爆炸”:
- 如果只需要计算上下左右,可以用
LATERAL配合generate_series直接生成四个邻居的坐标,再去 JOIN 主表。这样第一轮就能过滤掉不存在的数据。 - 在 JOIN 条件里,尽量让优化器能利用到复合索引
(x, y)。
其实还有个小技巧:如果你只是要判断当前格子周围的某种模式,可以先把网格按“行”存缩成一个“列”的比较,比如把两行合并成字符串再比。但这是针对具体题目的优化,别本末倒置,先把基础 JOIN 写对再说。
6. generate_series 是解题时的“数字生成器”
generate_series 这个函数,在常规业务开发里可能一年都用不了几次,但在 AoC 里,它简直是万能的“循环展开器”。
它的基本用法是生成一段连续的数字或时间序列:
sql复制SELECT generate_series(1, 100);
配合 CROSS JOIN,你可以快速生成所有可能的坐标组合、所有可能的排列、所有可能的状态桶。比如某道题要求对每个时间点 t 计算某个函数值,你可以:
sql复制SELECT t, calculate_value(t)
FROM generate_series(0, 99) AS t;
这是在 SQL 里做“离散时间模拟”的标准姿势。你不需要递归、不需要循环,只需把时间轴拉出来,剩下的交给查询。
除了数字,generate_series 还能生成日期序列。虽然 AoC 很少直接用日期,但在解决“某某时间间隔等于多少”这类题时,你可以用 generate_series 生成所有可能的区间端点,然后 JOIN 条件做包含判断。
6.1 另一个容易被忽略的重载
generate_series(start, stop, step) 支持步长参数。比如要生成所有奇数:generate_series(1, 99, 2)。这在处理奇偶交替的问题时非常有用,尤其是那些“每两步翻转一次方向”的模拟题,一步到位。
还有一个小技巧是结合数组:
sql复制SELECT generate_series(1, array_length(arr, 1))
AS idx, arr[idx] AS val
FROM (SELECT ARRAY['a', 'b', 'c'] AS arr) t;
这个写法有点反直觉,但它能让你在 SQL 里“模拟”数组下标遍历。如果你被迫要处理一个数组型的输入,这个方法能帮你把数组展开成带序号的行集合,后续处理就轻松多了。
7. 复杂状态别硬塞进临时表,JSONB 和数组能救命
Advent of Code 里有些题的状态不是简单的二维坐标,而是诸如“四块石头的位置 + 当前朝向 + 剩余步数”这样的复合结构。把这些状态拆成几十列显然是灾难,但全部塞进 JSONB 则非常轻松。
比如,用 JSONB 存一个动态“字典”:
sql复制SELECT key, value
FROM jsonb_each_text('{"A": 1, "B": 2, "C": 3}'::jsonb);
jsonb_each_text 把一个 JSON 对象展开成多行,适合那些状态是“键值对映射”的题目。有些题是关于“寄存器状态”的,寄存器数量固定但值会变,你可以用一个 JSONB 字段代表整个寄存器组,每轮模拟用 jsonb_set 更新。
数组也很有用。PostgreSQL 的数组操作符 @> 能判断包含关系,&& 能判断是否有交集,这些在处理集合类状态(比如“已经访问过的节点集合”)时极高效:
sql复制SELECT ARRAY[1, 2, 3] @> ARRAY[1, 2]; -- true
7.1 不要过度使用,但也不要完全回避
JSONB 是个很诱人的工具,用多了会导致 SQL 退化成一个“把处理交给服务端 JSON 函数”的过程式脚本。我自己的经验是:能拆成普通列的就拆成普通列,只有真正的复杂复合状态、或者你懒得建模时的快速原型,才用 JSONB。
另外,JSONB 的操作在走索引方面有一些限制。如果你需要频繁搜索 JSONB 里的某个键,记得建 GIN 索引。但 AoC 的数据量一般不大,大部分情况下可以不建索引直接扫。
8. 慢查询的第一步不是加索引,而是看懂执行计划
用 PostgreSQL 刷题,和用 PostgreSQL 做业务有一个巨大的不同:AoC 的输入数据量很小,通常不到几千行,但计算复杂度可以很高。这样一来,执行计划的选择往往决定了你是否能在合理时间内跑完。
我学会最实用的一招是 EXPLAIN ANALYZE:
sql复制EXPLAIN ANALYZE
SELECT ...
它会真实执行查询,并告诉你每一步耗时多少行。出现以下情况时,你就该警惕了:
- 嵌套循环(Nested Loop)出现在本不该出现的地方
- 某个节点 actual rows 和 estimated rows 偏差巨大
- 大量数据走了 Seq Scan,而你明明在等值 JOIN
比如某道题我写了一个自连接,原本以为 PostgreSQL 会自动用哈希连接,结果执行计划告诉我它选择了嵌套循环,因为我的表上恰好没有索引。加了个普通索引之后,速度提升了十倍。
8.1 用“物化 CTE”来隔离中间结果
还有一个经常踩的坑:WITH 子句(CTE)默认可能被优化器内联,导致某个中间结果被重复计算多次。如果你知道某一步的中间结果量不大,但后续会被多个分支反复使用,可以用 MATERIALIZED 强制物化:
sql复制WITH RECURSIVE base AS MATERIALIZED (
SELECT ...
)
SELECT ...
这其实是个“手动优化”手段,不要平时乱用,但在 AoC 这种“一个查询里多个子问题共享同一份数据”的场景下,效果立竿见影。
9. 查询规划器的“错觉”:为什么看起来聪明的写法反而不快
接上一章继续聊。PostgreSQL 的查询规划器基于统计信息来估算成本,但它不是万能的。有些时候,你自认为写了一个很聪明的查询,实际跑起来却奇慢无比,原因往往和规划器的“错觉”有关。
举一个具体例子:你有一个嵌套子查询,先去掉了大量行,再和另一张表 JOIN。你期望规划器先做子查询,然后只拿着结果集去 JOIN。但优化器可能认为两张表直接 JOIN 再做过滤更便宜,于是生成了一个执行计划,把中间结果扩大了上百倍。
这种时候,最直接的解决办法是分步写:
sql复制WITH filtered AS (
SELECT ...
FROM big_table
WHERE ...
),
joined AS (
SELECT *
FROM filtered
JOIN other_table ON ...
)
SELECT * FROM joined;
如果还不行,不排除用临时表,把中间结果实际落地。虽然这听起来不够“优雅”,但在 AoC 里,能跑出结果的方案就是好方案。我经常写完一个递归 CTE 后发现它极其慢,原因就是递归内部每次都重新扫描全表。如果先把初始数据过滤到只剩几条,速度能快几个量级。
9.1 关闭误导性的扫描方式
调试时有个实用的临时手段:
sql复制SET enable_seqscan = off;
这个命令能让规划器尽量不使用顺序扫描,方便你判断“如果走索引会不会更快”。但注意这只是调试手段,不要在生产环境用。它能帮你确认瓶颈是出在扫描方式还是 JOIN 策略上,跑完之后记得改回来。
刷题时,我的工作流通常是:先写一个能跑的版本;如果太慢,就 EXPLAIN ANALYZE 看执行计划;找出瓶颈后,尝试三种手段——加索引、改 JOIN 写法、拆分 CTE。三步里有九成问题能解决。
10. 数据里的坑,比算法更难绕:NULL、空数组和偏移量
前面讲的都是“怎么把 SQL 用起来”,但 AoC 还有一种让人崩溃的场景:题目数据本身藏着坑。文本输入里可能有空行、可能有隐藏空格、可能有字符编码问题,而这些问题在 PostgreSQL 里会以特殊的方式放大。
最常见的坑是 NULL 比较。SQL 里任何与 NULL 的比较都会返回 NULL,而不是 true 或 false。比如你想找“值不等于 0 的行”:
sql复制SELECT * FROM data WHERE value <> 0;
如果有一行 value 是 NULL,这一行会被过滤掉。对算法题来说,这往往不是你想要的。解决方法是把空串和 NULL 先统一:
sql复制SELECT * FROM data WHERE COALESCE(value, -1) <> 0;
另一个坑是“空数组”。PostgreSQL 的数组下标默认从 1 开始,如果你想访问 arr[0],它不是报错,而是返回 NULL。刷题时我多次栽在这个上面:直觉上以为数组是 0-based,结果所有取值都偏了一位,答案怎么都不对。
10.1 字符数据里的“隐藏杀手”
还有一类问题是输入文件里偶尔出现 \r 换行符,导致你解析出来的字符串末尾多了一个 \r,正则匹配不上、字段比较不相等,调试时又看不出问题。这种情况我建议在一开始解析时统一清理:
sql复制SELECT btrim(line) AS clean_line
FROM input;
btrim 能去掉字符串两端的空白字符。遇到奇怪的不可见字符,也可以用 replace(line, chr(13), '') 把回车符直接替换掉。这些细节虽然不涉及算法复杂度,但往往最浪费解题时间。
我后来养成了一个习惯:任何输入解析完成后,先跑一个快速检查,统计每一列的最小长度、最大长度、NULL 数量。如果你发现某个字段存在大量 NULL,那大概率不是数据有问题,而是你解析逻辑把某些行漏掉了。
11. 用 DeepSeek 复盘自己的解法,比想象中有用
最后一个收获,是关于复盘这件事本身的。
我当时做了二十几天题,每个解法都存了 SQL 文件,但我一直没时间系统性回顾。后来我想了一个办法:把几个典型的 SQL 脚本贴给 DeepSeek,让它从里面提取“你反复使用的方法”和“你反复踩的坑”。它给出的总结让我很吃惊——很多我自己都没意识到的高频模式,被它一语道破。
比如它指出:我几乎每道网格题都用了“生成坐标 + JOIN 邻居”这个模式,而我之前根本没意识到这是同一个套路。它还用非常精炼的语言解释了为什么递归 CTE 在这个场景下优于“迭代函数”——因为递归 CTE 能把状态去重交给数据库,避免在应用层手动维护 visited 集合。
从那之后,我养成了一个习惯:每隔一段时间,就把最近写的复杂 SQL 集中起来,让 AI 做一次“代码审查”。你不用要求它帮你把代码写得多完美,而是让它从宏观层面总结模式、指出重复、标记坏味道。这种“总结出来的经验”,比零散地刷题来得更系统。
我现在回头看,用 PostgreSQL 刷 Advent of Code 决定,不仅仅是锻炼了 SQL 技巧,更改变了我日常建表、写查询的思维方式。以前我遇到复杂逻辑会下意识地用临时表、游标和过程式循环,现在我会先思考:能不能用集合操作、窗口函数、递归 CTE 一次性搞定?这种思维方式一旦建立,你在工作里写出的 SQL 质量会有质的提升。
如果你也准备挑战一下,我的建议是:别怕慢,别怕写得丑,先跑通一个版本,然后花时间做两件事——看它的执行计划,再让 AI 帮你看一眼模式。这两件“课外作业”,比单纯刷题收获大得多。
