ClickHouseInc

用纯 SQL 硬刚 Advent of Code?ClickHouse 把「不可能」变成了 12 天的现实

图片

本文字数:39754;估计阅读时间:100 分钟

作者:Zach Naimon

Image

Image

介绍

每年十二月,编程社区都会迎来一场集体仪式:Advent of Code(https://adventofcode.com/)。这个活动由 Eric Wastl 创建,是一个由小型编程谜题组成的降临节日历,从 12 月 1 日开始到 12 日结束,每天都会发布一个全新的挑战。

这些任务并不是常见的“修复一个 bug”或“构建一个 API”。它们是偏算法方向的挑战,涉及复杂的数据结构、图遍历、三维几何、元胞自动机模拟以及路径查找算法。因此,开发者通常会选择 Python、Rust、Go 或 C++ 等通用编程语言来完成这些题目。

为什么不是 SQL?

让数据库来解决这类问题,通常被认为是一个错误的选择。标准 SQL 是一种声明式语言,主要用于关系型数据的检索与聚合,而并非为命令式的游戏循环或复杂的状态管理而设计。它缺乏这些谜题常用的数据结构支持(例如栈、队列或树),而试图通过标准的 JOIN 操作来实现,往往会引发严重的性能问题,甚至直接导致语法错误。简而言之,用 SQL 来完成 Advent of Code 被广泛视为“不可行”的——至少也是极其痛苦的。

ClickHouse 的方法

在 ClickHouse,我们并不喜欢“不可行”这个词。我们坚信,只要工具合适,一切问题本质上都是数据问题。ClickHouse 不只是一个高速的 OLAP 数据库,它还是一个向量化查询引擎,内置了大量分析函数库,这些能力经过巧妙组合后,甚至可以用来解决通用计算问题。

为了验证这一点,我们决定用一种非常规的方式来完成 2025 年的 Advent of Code:完全使用 ClickHouse SQL。

规则 

为了确保整个过程不存在任何取巧行为,我们为解决方案制定了三条严格的规则:

  1. 仅使用纯 ClickHouse SQL:我们完全不允许使用任何用户自定义函数(User Defined Functions,UDFs),尤其是可执行的 UDF,因为那样可以通过调用 Python 或 Bash 来“作弊”。如果查询引擎本身无法原生完成某件事,我们就不能使用任何变通手段。

  2. 只接受原始输入:在 Advent of Code 中,输入通常是杂乱的文本文件,有时是数字列表,有时是 ASCII 艺术地图,或者是一段晦涩的指令文本。我们不允许对输入进行任何预处理。解决方案查询必须直接接收 AoC 提供的原始输入字符串,并在查询内部完成解析。

  3. “单一查询”约束:这是最具挑战性的一条规则。我们不能创建表、物化视图或临时表来保存中间状态。整个谜题流程——从输入解析,到解决第 1 部分,再到解决通常复杂得多的第 2 部分——都必须在一个单一、原子的查询中完成。这迫使我们大量依赖 CTE,在一次连续的执行过程中将所有逻辑串联起来。

下面展示的是 Advent of Code 2025 全部 12 天的解决方案,展示了我们如何将被认为“不可行”的算法挑战,转化为纯粹的 ClickHouse SQL 查询。

注意:为了遵守 Advent of Code 的分发政策,下面的查询通过一个封装的 URL() 表来获取原始谜题输入,而不会直接暴露输入内容。支持直接字符串输入的原始查询版本,可以在我们的 ClickHouse/TreeHouse 仓库中找到。

Image

第 1 天:秘密入口

谜题:精灵们用一个带有旋转拨盘的保险装置锁住了他们的秘密入口。这个谜题需要根据一系列指令来模拟一个标记为 0–99 的拨盘的移动,例如 L68(向左转动 68 个刻度)或 R48(向右转动 48 个刻度)。

  • 第 1 部分要求在初始位置为 50 的情况下,计算所有旋转指令执行完毕后拨盘的最终位置。

  • 第 2 部分则需要进行更复杂的模拟:精确统计在整个过程中拨盘指向 0 的次数,包括在旋转过程中多次经过 0 的所有中间刻度。

我们是如何用 ClickHouse SQL 解决这个问题的:我们没有将其实现为过程式的循环,而是把整个过程视为一个流处理问题。由于拨盘的状态完全由历史移动决定,我们可以一次性计算出每一条指令对应的累计位置。具体做法是将方向解析为正数(向右)和负数(向左)的整数,然后使用窗口函数计算步数的累计和。在第 2 部分中,为了检测是否“经过 0”,我们比较当前累计值与上一行的累计值,从而判断拨盘是否跨过了 0。

实现细节:

1. sum() OVER (...): 我们使用标准的 SQL 窗口函数来维护拨盘位置的“累计值”。通过将左右方向统一归一化为正值和负值,我们可以在一次扫描中得到每一行对应的累计位置。

sum(normalized_steps) OVER (ORDER BY instruction_id) AS raw_position

2. lagInFrame:为了统计经过 0 的次数,我们需要知道当前旋转开始前拨盘的位置。我们使用 lagInFrame 查看上一行的累计位置,从而比较一次旋转的起点和终点,并通过数学方式判断 0 是否位于两者之间。

完整解决方案:

WITH--Fetch puzzle inputinput_wrapper AS (SELECT raw_blob AS input FROM aoc.input1),
-- Parse the input string into individual instructionsparsed_instructions AS (    -- Initial placeholder row    SELECT        0 AS instruction_id,        'R50' AS raw_instruction,        'R' AS direction,        50::Int16 AS steps
    UNION ALL
    -- Parse each line from input    SELECT        rowNumberInAllBlocks() + 1 AS instruction_id,        raw AS raw_instruction,        substring(raw, 1, 1) AS direction,        substring(raw, 2)::Int16 AS steps    FROM format(TSV, 'raw String', (SELECT input FROM input_wrapper))), 
-- Part 1: Calculate positions with simple modulo wrappingpart1_positions AS (    SELECT        instruction_id,        raw_instruction,        direction,        steps,
        -- Normalize direction: positive for R, negative for L        if(direction = 'R', steps % 100, -1 * (steps % 100)) AS normalized_steps,
        -- Calculate cumulative position        sum(normalized_steps) OVER (            ORDER BY instruction_id        ) AS raw_position,
        -- Wrap position to 0-99 range        ((raw_position % 100) + 100) % 100 AS position    FROM parsed_instructions),
-- Part 2: Calculate positions with full movement trackingposition_calculations AS (    SELECT        instruction_id,        raw_instruction,        direction,        steps,
        -- Normalize direction (no modulo yet)        if(direction = 'R', steps, -1 * steps) AS normalized_steps,
        -- Calculate cumulative raw position        sum(normalized_steps) OVER (            ORDER BY instruction_id ASC        ) AS raw_position,
        -- Wrap to 0-99 range        ((raw_position % 100) + 100) % 100 AS position    FROM parsed_instructions),
-- Track turn counts based on position changesturn_tracking AS (    SELECT        *,
        -- Get previous position for comparison        lagInFrame(position) OVER (            ORDER BY instruction_id ASC             ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW        ) AS previous_position,
        -- Calculate turns for this instruction        if(            instruction_id = 0,            0,
            -- Base turns from full rotations            floor(steps / 100) + 
            -- Additional turn if we wrapped around            if(                direction = 'R',                (position != 0 AND previous_position != 0 AND position < previous_position) ? 1 : 0,                (position != 0 AND previous_position != 0 AND position > previous_position) ? 1 : 0            )        ) + 
        -- Extra turn if we land exactly on position 0        if(instruction_id != 0 AND position = 0, 1, 0) AS turn_count    FROM position_calculations),
-- Calculate cumulative turn countspart2_turn_counts AS (    SELECT        instruction_id,        raw_instruction,        direction,        steps,        position,        turn_count,
        -- Running sum of turns        sum(turn_count) OVER (            ORDER BY instruction_id ASC             ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW        ) AS cumulative_turns    FROM turn_tracking)
-- Final results for both partsSELECT     'Part 1' AS part,    countIf(position = 0) AS solution -- Should be 1100 with my inputFROM part1_positions
UNION ALL
SELECT     'Part 2' AS part,    max(cumulative_turns)::UInt64 AS solution -- Should be 6358 with my inputFROM part2_turn_counts;

查看完整谜题描述:https://adventofcode.com/2025/day/1

Image

第 2 天:礼品店

谜题:你正在帮助清理一个礼品店数据库,其中充满了无效的产品 ID。输入数据是一系列 ID 范围(例如 11-22、95-115)。

  • 第 1 部分将无效 ID 定义为由某个序列恰好重复两次组成的数字(例如 1212 或 55)。

  • 第 2 部分将这一规则扩展为任意序列至少重复两次(例如 123123123 或 11111)。目标是在给定的范围内找出所有无效 ID,并对它们求和。

我们是如何用 ClickHouse SQL 解决这个问题的:我们没有编写循环逐个遍历数字,而是利用了 ClickHouse 将数据“展开”为多行的能力。我们把紧凑的输入范围(例如 11-22)直接扩展成大量独立的行——范围内的每一个整数都会生成一行数据。在为每一个潜在 ID 创建行之后,我们将其转换为字符串,并并行地使用数组函数来检测是否存在重复模式。

实现细节:

1. arrayJoin:这是我们生成行时最常用的函数。我们使用 range(start, end) 为每一行输入生成一个整数数组,再通过 arrayJoin 将数组展开为独立的多行数据。这样一来,筛选无效 ID 就只需要一个简单的 WHERE 条件。

SELECT arrayJoin(range(bounds[1], bounds[2] + 1)) AS number

2. arrayExists:在第 2 部分中,我们需要检查任意子串长度(从 1 到字符串长度)是否构成了重复模式。为此,我们使用带有 lambda 函数的 arrayExists 来遍历所有可能的子串长度。如果对任意长度返回 1,该 ID 就会被判定为无效。

arrayExists(    x -> (string_length % x = 0) AND (repeat(substring(..., x), ...) = number_string),    range(1, string_length))

完整解决方案:

-- Define puzzle inputWITH input_wrapper AS (SELECT trimRight(raw_blob,'\n') AS input FROM aoc.input2),
-- Parse range bounds from input stringrange_bounds AS (    SELECT arrayMap(        x -> x::UInt64,        splitByChar('-', arrayJoin(splitByChar(',', (SELECT input FROM input_wrapper)::String)))    ) AS bounds),
-- Expand ranges into individual numbersexpanded_numbers AS (    SELECT        arrayJoin(range(bounds[1], bounds[2] + 1)) AS number,        toString(number) AS number_string,        length(number_string) AS string_length    FROM range_bounds),
-- Analyze each number for repeating patternsrepeating_analysis AS (    SELECT        number_string,        toUInt64(number_string) AS number,
        -- Part 2: Check if string is made of any repeating pattern        -- (e.g., "123123" = "123" repeated, "1111" = "1" repeated)        arrayExists(            x -> (string_length % x = 0)                AND (                    repeat(                        substring(number_string, 1, x),                        (string_length / x)::UInt32                    ) = number_string                ),            range(1, string_length)        ) AS has_pattern_repeat,
        -- Part 1: Check if second half equals first half        -- (e.g., "1212" -> "12" = "12", "123123" -> "123" = "123")        if(            string_length % 2 = 0             AND substring(number_string, (string_length / 2) + 1, string_length / 2)                 = substring(number_string, 1, string_length / 2),            1,            0        ) AS has_half_repeat    FROM expanded_numbers    WHERE        has_pattern_repeat != 0         OR has_half_repeat != 0    ORDER BY number ASC)
-- Calculate final solutionsSELECT     sumIf(number, has_half_repeat = 1) AS part_1_solution, -- Should be 24043483400 with my input    sumIf(number, has_pattern_repeat = 1) AS part_2_solution -- Should be 38262920235 with my inputFROM repeating_analysis

查看完整谜题描述:https://adventofcode.com/2025/day/2

Image

第 3 天:大厅

谜题:你需要使用多组电池来应急启动一部自动扶梯,每一组电池都表示为一个数字字符串(例如 987654321)。

  • 第 1 部分要求你恰好选取两块电池(数字),在保持它们原始相对顺序不变的前提下,组成能够得到的最大两位数。

  • 第 2 部分将这一问题扩展到更大的规模:需要恰好选取 12 块电池,组成最大的 12 位数。这就变成了一个典型的贪心优化问题——在每一步中,你都希望选择当前能选到的最大数字,同时又必须保证在它之后还剩下足够的数字,用来完成整个序列。

我们是如何用 ClickHouse SQL 解决这个问题的:第 1 部分只涉及简单的字符串操作,而第 2 部分则要求在遍历数字时持续维护状态。我们需要同时跟踪还剩多少个数字需要选择,以及当前在字符串中的位置,从而避免打乱原有顺序。为此,我们直接在 SQL 中使用 arrayFold 实现了这一贪心算法,它允许我们在遍历数组的同时,携带一个包含约束条件的累加器元组。

实现细节:

1. arrayFold:我们使用这个高阶函数来实现类似 reduce() 的逻辑。累加器中保存了一个元组:(digits_remaining, current_position, accumulated_value)。在 fold 的每一步中,我们都会计算当前可以选择的最优合法数字,并据此更新整个状态元组。

arrayFold(    (accumulator, current_element) -> ( ... ), -- Update logic    digits,    (num_digits_needed, 0, 0) -- Initial state)

2. ngrams:为了把数字字符串当作数组来处理,我们使用了 ngrams(string, 1)。这个函数通常用于文本分析,但在这里,它恰好可以把字符串拆分成由单个字符组成的数

完整解决方案:

-- Define puzzle inputWITH input_wrapper AS (SELECT trimBoth(raw_blob,'\n') AS input FROM aoc.input3),
-- Convert input to array of digit values for Part 2digit_array AS (    SELECT        arrayMap(            x -> toUInt8(x),            ngrams(arrayJoin(splitByChar('\n', (SELECT input FROM input_wrapper)::String)), 1)        ) AS digits,        length(digits) AS total_digits),
-- Constants12 AS num_digits_needed,
-- Part 1: Find largest two-digit number from each linepart1_largest_pairs AS (    SELECT        ngrams(arrayJoin(splitByChar('\n', (SELECT input FROM input_wrapper)::String)), 1) AS chars,        arraySlice(chars, 1, length(chars) - 1) AS chars_without_last,
        -- Find first max digit, then find max digit after it        concat(            arrayMax(chars_without_last),            arrayMax(                arraySlice(                    chars,                    arrayFirstIndex(                        x -> x = arrayMax(chars_without_last),                        chars                    ) + 1                )            )        )::Int16 AS largest_two_digit),
-- Part 2: Build largest N-digit number by greedily selecting max digitspart2_greedy_selection AS (    SELECT        digits,
        -- Iteratively build number by selecting maximum available digit        arrayFold(            (accumulator, current_element) -> (                -- Decrement remaining digits counter                greatest(accumulator.1 - 1, 0)::Int64,
                -- Update position: find where max digit is in remaining slice                accumulator.2 + (                    arrayFirstIndex(                        x -> x = arrayMax(                            arraySlice(                                digits,                                accumulator.2 + 1,                                total_digits - accumulator.1 - accumulator.2 + 1                            )                        ),                        arraySlice(                            digits,                            accumulator.2 + 1,                            total_digits - accumulator.1 - accumulator.2 + 1                        )                    )                )::UInt64,
                -- Accumulate joltage: add max digit * 10^(remaining-1)                accumulator.3 + if(                    accumulator.1 = 0,                    0::UInt64,                    arrayMax(                        arraySlice(                            digits,                            accumulator.2 + 1,                            total_digits - accumulator.1 - accumulator.2 + 1                        )                    ) * intExp10(greatest(0, accumulator.1 - 1))                )            ),            digits,
            -- Initial accumulator state:            -- (digits_remaining, current_position, accumulated_value)            (num_digits_needed::Int64, 0::UInt64, 0::UInt64)        ).3 AS joltage  -- Extract the accumulated value (3rd element)
    FROM digit_array)
-- Combine results from both partsSELECT    'Part 1' AS part,    sum(largest_two_digit)::UInt64 AS solution -- Should be 17263 with my inputFROM part1_largest_pairs
UNION ALL
SELECT    'Part 2' AS part,    sum(joltage) AS solution -- Should be 170731717900423 with my inputFROM part2_greedy_selection

查看完整谜题描述:https://adventofcode.com/2025/day/3

Image

第 4 天:打印部门

谜题:精灵们需要突破一面由纸卷堆叠而成的墙。这个谜题是 Conway 的生命游戏的一种变体。你会得到一个网格,其中 @ 表示一个纸卷的位置。

  • 第 1 部分定义了一条规则:如果一个纸卷周围的邻居数量少于 4 个,它就可以被“移除”。你需要统计在初始状态下满足这一条件的纸卷数量。

  • 第 2 部分要求递归地模拟这一过程。移除一个纸卷,可能会让原本无法移除的其他纸卷变得可移除。你需要不断执行这一过程,直到整个系统稳定下来,最后统计被移除的纸卷总数。

我们是如何用 ClickHouse SQL 解决这个问题的:由于这是一个每一步都依赖前一步结果的迭代模拟问题,我们采用了递归 CTE。我们将网格表示为一组 (x, y) 坐标点。在每一次递归中,通过对这些点进行自连接来计算每个点的邻居数量。随后,只保留那些仍然“存活”的点(邻居数量 >= 4),其余点则被隐式地移除。我们持续执行递归,直到点的数量不再发生变化。

实现细节:

1. WITH RECURSIVE:我们使用标准 SQL 的递归 CTE 来完成这一基于邻接关系的遍历过程。初始步骤选出所有纸卷的位置,递归步骤则根据邻居数量对结果集进行不断收缩。

WITH RECURSIVE recursive_convergence AS (    -- Base case: all points    UNION ALL    -- Recursive step: keep points with >= 4 neighbors    SELECT ... HAVING countIf(...) >= 4)

2. argMin:为了准确判断模拟过程在哪一刻达到稳定状态,我们在每一层递归深度上记录点的数量,并使用 argMin(point_count, depth)

完整解决方案:

WITH RECURSIVE -- Define puzzle input (grid with '@' symbols)input_wrapper AS (SELECT raw_blob AS input FROM aoc.input4),
-- Split input into linesinput_lines AS (    SELECT splitByChar('\n', (SELECT input FROM input_wrapper)::String) AS lines),
-- Find all '@' symbol positions in the gridgrid_points AS (    SELECT arrayJoin(        arrayFlatten(            arrayMap(                line_tuple ->                     arrayMap(                        x_pos -> (x_pos, line_tuple.2),                        arrayFilter(                            (pos, val) -> val = '@',                            arrayEnumerate(line_tuple.1),                            line_tuple.1                        )                    ),                arrayMap(                    (line, line_num) -> (ngrams(line, 1), line_num),                    lines,                    range(1, length(lines) + 1)                )            )        )::Array(Tuple(UInt8, UInt8))    ) AS point    FROM input_lines),
-- Expand points into separate columnsinitial_points AS (    SELECT         point.1 AS x,        point.2 AS y    FROM grid_points),
-- Recursive CTE: Keep only points with 4+ neighbors at each iterationrecursive_convergence AS (    -- Base case: all initial points at depth 1    SELECT        x,        y,        1 AS depth    FROM initial_points
    UNION ALL
    -- Recursive case: keep points with at least 4 neighbors    SELECT        p.x,        p.y,        depth + 1 AS depth    FROM recursive_convergence AS p    CROSS JOIN recursive_convergence AS q    WHERE depth < 256  -- Maximum recursion depth    GROUP BY p.x, p.y, depth    HAVING countIf(        q.x BETWEEN p.x - 1 AND p.x + 1        AND q.y BETWEEN p.y - 1 AND p.y + 1        AND NOT (p.x = q.x AND p.y = q.y)    ) >= 4),
-- Track point counts at each depth leveldepth_statistics AS (    SELECT         depth,        count() AS point_count,        lagInFrame(point_count, 1) OVER (ORDER BY depth) AS previous_count    FROM recursive_convergence    GROUP BY depth    ORDER BY depth),
-- Find the depth where the count stabilizes (no more points removed)stabilization_analysis AS (    SELECT         min(depth) AS stabilization_depth,        argMin(point_count, depth) AS stabilized_count    FROM depth_statistics    WHERE point_count = previous_count         AND point_count > 0),
-- Part 1: Points removed after first iteration (depth 2)part1_solution AS (    SELECT        (SELECT count() FROM initial_points) -         (SELECT point_count FROM depth_statistics WHERE depth = 2 LIMIT 1) AS solution),
-- Part 2: Points removed when convergence stabilizespart2_solution AS (    SELECT        (SELECT count() FROM initial_points) - stabilized_count AS solution    FROM stabilization_analysis),
-- Combine results from both parts (necessary to prevent a bug with recursive CTE/external UNIONs)combined_solutions AS (SELECT    'Part 1' AS part,    solution -- Should be 1604 with my inputFROM part1_solution
UNION ALL
SELECT    'Part 2' AS part,    solution -- Should be 9397 with my inputFROM part2_solution)
select * from combined_solutions settings use_query_cache=true, query_cache_share_between_users = 1, query_cache_nondeterministic_function_handling = 'save', query_cache_ttl = 80000000, result_overflow_mode = 'throw', read_overflow_mode = 'throw'

查看完整谜题描述:https://adventofcode.com/2025/day/4

Image

第 5 天:自助餐厅

谜题:精灵们在管理库存时遇到了麻烦,问题涉及一组被标记为“新鲜”的 ID 范围(例如 3-5、10-14)。

  • 第 1 部分要求统计有多少个指定的物品 ID 落在任意一个新鲜范围之内。

  • 第 2 部分则要求计算这些新鲜范围一共覆盖了多少个唯一的整数,也就是所有区间的并集。例如,当范围为 1-5 和 3-7 时,它们的并集是 1-7(大小为 7),而不是简单相加得到的 10。

我们是如何用 ClickHouse SQL 解决这个问题的:这是一个典型的区间运算问题。第 1 部分只需要做简单的范围判断过滤,而第 2 部分则需要将存在重叠的区间合并起来。手动实现区间合并逻辑往往相当复杂,但我们直接使用了 ClickHouse 提供的一个专用聚合函数,将原本复杂的算法问题压缩成了一行查询。

实现细节:

1. intervalLengthSum:我们使用这个专用聚合函数来计算区间并集的总长度。它能够自动处理区间之间的重叠和嵌套关系,使我们无需自行编写复杂的合并逻辑。

SELECT intervalLengthSum(range_tuple.1, range_tuple.2) AS solution

2. arrayExists:在第 1 部分中,我们使用 arrayExists 来判断某个指定的 ID 是否落在数组中的任意一个有效区间内。这样就可以在不将区间展开为海量行数据的情况下,高效完成判断。

完整解决方案:

-- Define puzzle inputWITH input_wrapper AS (SELECT trimRight(raw_blob,'\n') AS input FROM aoc.input5),
-- Split input into two sectionsinput_sections AS (    SELECT        splitByString('\n\n', (SELECT input FROM input_wrapper)::String)[1] AS ranges_section,        splitByString('\n\n', (SELECT input FROM input_wrapper)::String)[2] AS ids_section),
-- Parse ranges from first section (format: "min-max" per line)parsed_ranges AS (    SELECT arrayMap(        x -> (            toUInt64(splitByChar('-', x)[1]),            toUInt64(splitByChar('-', x)[2]) + 1 -- Make max half-open        ),        splitByChar('\n', ranges_section)    ) AS ranges    FROM input_sections),
-- Parse IDs from second section (one ID per line)parsed_ids AS (    SELECT arrayMap(        x -> toUInt64(x),        splitByChar('\n', ids_section)    ) AS ids    FROM input_sections),
-- Explode ranges into individual rows for Part 2 interval calculationexploded_ranges AS (    SELECT arrayJoin(ranges) AS range_tuple    FROM parsed_ranges),
-- Part 1: Count how many IDs fall within any rangepart1_solution AS (    SELECT        length(            arrayFilter(                id -> arrayExists(                    range -> id BETWEEN range.1 AND range.2,                    ranges                ),                ids            )        ) AS solution    FROM parsed_ranges, parsed_ids),
-- Part 2: Calculate total interval length (union of all ranges)part2_solution AS (    SELECT        intervalLengthSum(range_tuple.1, range_tuple.2) AS solution    FROM exploded_ranges)
-- Combine results from both partsSELECT    'Part 1' AS part,    solution -- Should be 707 with my inputFROM part1_solution
UNION ALL
SELECT    'Part 2' AS part,    solution -- Should be 361615643045059 with my inputFROM part2_solution;

查看完整谜题描述:https://adventofcode.com/2025/day/5

Image

第 6 天:垃圾压缩机

谜题:你发现了一张数学练习表,上面的计算题按列排列。

  • 第 1 部分将输入理解为由空格分隔的数字列。你需要根据底部给出的运算符,对每一列的数字执行求和或相乘操作。

  • 第 2 部分则揭示,这些输入是以“从右向左”的方式按列书写的,一个数字的各个数位被垂直堆叠在一起。你需要重新解析整个网格,将这些数位重新组合成数字,再按空白列进行分组,并应用对应的运算符。

我们是如何用 ClickHouse SQL 解决这个问题的:这一谜题的关键在于文本解析和数组操作。我们将输入文本当作一个二维字符矩阵来处理。为了从按行书写的文本形式切换到按列组织的数学题,我们本质上执行了一次“矩阵转置”。具体来说,我们先将每一行文本转换为字符数组,再对其进行“旋转”以按列处理,最后使用数组函数重建数字并完成运算。

实现细节:

1. splitByWhitespace:在第 1 部分中,我们使用该函数解析“横向”的表示方式。它能够自动处理列之间不固定的空格数量,这是简单字符串拆分难以正确处理的。

2. arrayProduct:由于 ClickHouse 并没有提供标准的 product() 聚合函数,我们将每一列映射为整数数组,并通过 arrayProduct 来完成乘法计算。

toInt64(arrayProduct(    arrayMap(x -> toInt64(x), arraySlice(column, 1, length(column) - 1))))

3. arraySplit:在第 2 部分中,在提取出所有原始数字之后,我们需要将它们拆分成独立的数学表达式。我们使用 arraySplit 在遇到运算符列时对大数组进行切分,从而有效地区分不同的计算问题。

完整解决方案:

-- Define puzzle inputWITH input_wrapper AS (SELECT trimRight(raw_blob,'\n') AS input FROM aoc.input6),
-- Part 1: Parse input into columns and apply operationspart1_parsed_rows AS (    SELECT arrayMap(        x -> splitByWhitespace(x),        splitByChar('\n', (SELECT input FROM input_wrapper)::String)    ) AS rows),
part1_columns AS (    SELECT arrayMap(        column_index -> arrayMap(            row -> row[column_index],            rows        ),        range(1, length(rows[1]) + 1)    ) AS columns    FROM part1_parsed_rows),
part1_solution AS (    SELECT arraySum(        arrayMap(            column -> if(                -- Check if last element is multiplication operator                arrayLast(x -> 1, column) = '*',
                -- Multiply all numbers in column                toInt64(arrayProduct(                    arrayMap(                        x -> toInt64(x),                        arraySlice(column, 1, length(column) - 1)                    )                )),
                -- Otherwise, add all numbers in column                toInt64(arraySum(                    arrayMap(                        x -> toInt64(x),                        arraySlice(column, 1, length(column) - 1)                    )                ))            ),            columns        )    ) AS solution    FROM part1_columns),
-- Part 2: Parse with character-level precision to handle multi-digit numberspart2_parsed_chars AS (    SELECT arrayMap(        x -> ngrams(x, 1),        splitByChar('\n', (SELECT input FROM input_wrapper)::String)    ) AS char_rows),
part2_columns_raw AS (    SELECT arrayMap(        column_index -> arrayMap(            row -> row[column_index],            char_rows        ),        range(1, length(char_rows[1]) + 1)    ) AS columns    FROM part2_parsed_chars),
part2_columns_filtered AS (    SELECT arrayFilter(        x -> NOT arrayAll(y -> y = ' ', x),        columns    ) AS non_empty_columns    FROM part2_columns_raw),
part2_numbers_extracted AS (    SELECT arrayMap(        column -> replaceAll(            arrayStringConcat(                arraySlice(column, 1, length(column) - 1)            ),            ' ',            ''        ),        non_empty_columns    ) AS number_strings    FROM part2_columns_filtered),
part2_numbers_grouped AS (    SELECT        number_strings,        non_empty_columns,
        -- Split numbers by operator positions        arraySplit(            (number_str, has_operator) -> has_operator,            number_strings,            arrayMap(                column -> hasAny(column, ['+', '*']),                non_empty_columns            )        ) AS number_groups    FROM part2_numbers_extracted, part2_columns_filtered),
part2_operations AS (    SELECT arrayZip(        -- Extract operators from columns        arrayFilter(            x -> has(['+', '*'], x),            arrayFlatten(non_empty_columns)        ),        -- Pair with corresponding number groups        number_groups    ) AS operations_with_numbers    FROM part2_numbers_grouped),
part2_solution AS (    SELECT arraySum(        arrayMap(            operation -> if(                -- Check operator type                operation.1 = '*',
                -- Multiply all numbers in group                toInt64(arrayProduct(                    arrayMap(                        x -> toInt64(x),                        operation.2                    )                )),
                -- Otherwise, add all numbers in group                toInt64(arraySum(                    arrayMap(                        x -> toInt64(x),                        operation.2                    )                ))            ),            operations_with_numbers        )    ) AS solution    FROM part2_operations)
-- Combine results from both partsSELECT    'Part 1' AS part,    solution -- 5782351442566 with my inputFROM part1_solution
UNION ALL
SELECT    'Part 2' AS part,    solution -- 10194584711842 with my inputFROM part2_solution;

查看完整谜题描述:https://adventofcode.com/2025/day/6

Image

第 7 天:传送实验室

谜题:你正在分析一个网格中的快子束传播过程。

  • 第 1 部分模拟一束光线向下传播。当它遇到分裂器 ^ 时,会分裂成两束,分别向左和向右延伸。你需要统计整个过程中发生的分裂次数。

  • 第 2 部分引入了“量子多世界”的设定:这一次,不是光束在分裂,而是宇宙本身发生分裂。你需要计算在网格底部最终存在的所有活跃“时间线”(路径)的数量。

我们是如何用 ClickHouse SQL 解决这个问题的:如果对每一条路径进行显式模拟,计算量会呈指数级增长。相反,我们将问题建模为一种波传播过程,类似于帕斯卡三角形的计算方式。我们使用 arrayFold 按行处理整个网格,在每一行中维护一个映射,记录每一列位置上的“世界”数量,并根据分裂器的规则,计算这些数量如何传递到下一行。

实现细节:

1. arrayFold:我们使用 arrayFold 来实现按行推进的模拟状态机。在这个过程中,我们携带一个复杂的状态对象——(left_boundary, right_boundary, worlds_map, part1_counter),并在处理网格的每一行时持续更新它。

2. sumMap:为了处理多条光束在同一位置汇合的情况(例如左分支和右分支最终落在同一坐标),我们使用 sumMap 对 world 映射中相同键对应的值进行聚合,从而自然地合并汇聚到同一位置的“时间线”数量。

arrayReduce('sumMap', arrayMap(position -> map(...), ...))

完整解决方案:

-- Define puzzle input WITH input_wrapper AS (SELECT raw_blob AS input FROM aoc.input7),
-- Parse input into character gridparsed_grid AS (    SELECT arrayMap(        x -> ngrams(x, 1),        splitByChar('\n', (SELECT input FROM input_wrapper)::String)    ) AS rows),
-- Find starting position in first rowinitial_state AS (    SELECT        arrayFirstIndex(x -> x = 'S', rows[1])::UInt8 AS start_position,        map(            arrayFirstIndex(x -> x = 'S', rows[1])::UInt8,            1::UInt64        )::Map(UInt8, UInt64) AS initial_worlds    FROM parsed_grid),
-- Filter to only rows with '^' markers (active rows)active_rows AS (    SELECT arrayFilter(        x -> has(x, '^'),        rows    ) AS filtered_rows    FROM parsed_grid),
-- Main iteration: propagate world counts through rowsworld_propagation AS (    SELECT        start_position,        initial_worlds,        filtered_rows,
        -- Fold through each row, updating state        arrayFold(            (accumulator, current_row) -> (                -- Update left boundary (shrink inward)                (accumulator.1 - 1)::UInt8,
                -- Update right boundary (shrink inward)                (accumulator.2 + 1)::UInt8,
                -- Update world map: propagate counts based on '^' positions                mapSort(                    (key, value) -> key,                    mapUpdate(                        accumulator.3,                        arrayReduce(                            'sumMap',                            arrayMap(                                position -> if(                                    -- Check if position has '^' and exists in current worlds                                    current_row[position] = '^'                                    AND mapContains(accumulator.3, position),
                                    -- Propagate world count to adjacent positions                                    map(                                        -- Left neighbor gets count (unless blocked by another '^')                                        (position - 1)::UInt8,                                        (                                            accumulator.3[position] + if(                                                current_row[greatest(0, position - 2)] = '^',                                                0,                                                accumulator.3[position - 1]                                            )                                        )::UInt64,
                                        -- Current position resets to 0                                        (position)::UInt8,                                        0::UInt64,
                                        -- Right neighbor gets count                                        (position + 1)::UInt8,                                        (accumulator.3[position + 1] + accumulator.3[position])::UInt64                                    ),
                                    -- No propagation if conditions not met                                    map()::Map(UInt8, UInt64)                                ),                                -- Only process positions within current boundaries                                arraySlice(                                    arrayEnumerate(current_row),                                    accumulator.1,                                    (accumulator.2 - accumulator.1) + 1                                )                            )                        )                    )                ),
                -- Part 1 counter: count '^' positions with non-zero worlds                accumulator.4 + arrayCount(                    position ->                         current_row[position] = '^'                        AND mapContains(accumulator.3, position)                        AND accumulator.3[position] > 0,                    arraySlice(                        arrayEnumerate(current_row),                        accumulator.1,                        (accumulator.2 - accumulator.1) + 1                    )                )            ),            filtered_rows,
            -- Initial accumulator state:            -- (left_boundary, right_boundary, worlds_map, part1_counter)            (                start_position,                start_position,                initial_worlds,                0::UInt64            )        ) AS final_state    FROM initial_state, active_rows),
-- Part 1: Count of '^' positions encountered with non-zero worldspart1_solution AS (    SELECT final_state.4 AS solution    FROM world_propagation),
-- Part 2: Sum of all world counts across all positionspart2_solution AS (    SELECT arraySum(mapValues(final_state.3)) AS solution    FROM world_propagation)
-- Combine results from both partsSELECT    'Part 1' AS part,    solution -- 1633 with my inputFROM part1_solution
UNION ALL
SELECT    'Part 2' AS part,    solution -- 34339203133559 with my inputFROM part2_solution;

查看完整谜题描述:https://adventofcode.com/2025/day/7

Image

第 8 天:游乐场

谜题:精灵们正在连接三维空间中的电气接线盒。

  • 第 1 部分要求找出距离最近的 1000 对点并将它们连接起来,然后分析由这些连接形成的电路规模,也就是各个连通分量的大小。

  • 第 2 部分则要求不断连接当前距离最近的点,直到所有接线盒最终连成一个整体的大电路。这在算法上对应的是一个最小生成树问题。

我们是如何用 ClickHouse SQL 解决这个问题的:这是一个典型的图论问题,需要使用并查集(disjoint-set / union-find)的思路。我们首先生成点之间所有可能的边,并按距离从小到大排序。接着,使用 arrayFold 依次遍历这些边,只要一条边连接了两个原本不在同一集合中的点,就将这两个集合合并,从而逐步构建连通分量。

实现细节:

1. L2Distance:我们使用 ClickHouse 原生提供的 L2Distance 函数,高效地计算三维坐标 [x, y, z] 之间的欧几里得距离,从而可以按连接长度对所有候选边进行排序。

2. runningAccumulate:在第 2 部分中,我们需要判断何时已经包含了足够多的不同点,从而形成一个单一的电路。与其在每一行上反复执行代价高昂的 DISTINCT 统计,我们使用 uniqCombinedState 构建唯一元素的紧凑状态表示,再通过 runningAccumulate 按行合并这些状态,高效地得到唯一点数量的累计结果。

runningAccumulate(points_state) AS unique_points_seen

完整解决方案:

-- Define puzzle input WITH input_wrapper AS (SELECT raw_blob AS input FROM aoc.input8),
-- Parse 3D coordinate pointsparsed_points AS (    SELECT (x, y, z) AS point    FROM format('CSV', 'x UInt32, y UInt32, z UInt32', (SELECT input FROM input_wrapper)::String)),
-- Generate all point pairs with L2 distances, sorted by distancepoint_pairs_by_distance AS (    SELECT        t1.point AS point1,        t2.point AS point2,        L2Distance(            [point1.1, point1.2, point1.3],            [point2.1, point2.2, point2.3]        ) AS distance    FROM parsed_points AS t1    CROSS JOIN parsed_points AS t2    WHERE point1 < point2    ORDER BY distance ASC),
-- Take the 1000 closest pairsclosest_pairs AS (    SELECT groupArray([point1, point2]) AS pairs    FROM (        SELECT point1, point2        FROM point_pairs_by_distance        ORDER BY distance ASC        LIMIT 1000    )),
-- Part 1: Build connected components from closest pairsconnected_components AS (    SELECT        pairs,
        -- Fold through pairs to merge into connected components        arrayFold(            (accumulator, pair) -> if(                -- Check if any existing components contain points from current pair                length(                    arrayFilter(                        component -> hasAny(component, pair),                        accumulator                    )                ) > 0,
                -- Merge matching components with current pair                arrayConcat(                    -- Keep non-matching components unchanged                    arrayFilter(                        component -> NOT hasAny(component, pair),                        accumulator                    ),                    -- Add merged component                    [                        arrayDistinct(                            arrayFlatten(                                arrayConcat(                                    arrayFilter(                                        component -> hasAny(component, pair),                                        accumulator                                    ),                                    [pair]                                )                            )                        )                    ]                ),
                -- No matches found, add pair as new component                arrayConcat(accumulator, [pair])            ),            pairs,            []::Array(Array(Tuple(UInt32, UInt32, UInt32)))        ) AS components    FROM closest_pairs),
component_analysis AS (    SELECT        components,        arrayMap(x -> length(x), components) AS component_sizes    FROM connected_components),
part1_solution AS (    SELECT arrayProduct(        arraySlice(            arrayReverseSort(component_sizes),            1,            3        )    ) AS solution    FROM component_analysis),
-- Part 2: Find first pair where 1000 unique points have been seenpoint_pair_states AS (    SELECT        point1,        point2,        distance,        arrayReduce('uniqCombinedState', [point1, point2]) AS points_state    FROM point_pairs_by_distance),
part2_solution AS (    SELECT        point1,        point2,        distance,        runningAccumulate(points_state) AS unique_points_seen,        point1.1 * point2.1 AS solution    FROM point_pair_states    WHERE unique_points_seen >= 1000    ORDER BY distance ASC    LIMIT 1)
-- Combine results from both partsSELECT    'Part 1' AS part,    solution::UInt64 AS solution -- 135169 with my inputFROM part1_solution
UNION ALL
SELECT    'Part 2' AS part,    solution::UInt64 AS solution -- 302133440 with my inputFROM part2_solution
SETTINGS allow_deprecated_error_prone_window_functions = 1;

查看完整谜题描述:https://adventofcode.com/2025/day/8

Image

第 9 天:电影院

谜题:电影院的地板可以表示为一个网格,其中分布着一些红色瓷砖。

  • 第 1 部分要求找出以任意两块红色瓷砖作为对角点时,所能形成的最大面积矩形。

  • 第 2 部分在此基础上增加了约束:这个矩形必须完全位于由所有红色和绿色瓷砖围成的闭合环路内部。

我们是如何用 ClickHouse SQL 解决这个问题的:我们没有把它当作一个逐格搜索的问题,而是建模为几何计算问题。我们构造了表示候选矩形以及边界环路的多边形对象。通过将矩形边界转换为“环”结构,就可以直接使用 ClickHouse 原生的几何函数来计算面积并判断包含关系。

实现细节:

1. polygonAreaCartesian:我们通过为候选矩形构造对应的多边形对象,并直接使用 polygonAreaCartesian 计算面积,从而避免了手动计算宽度和高度。

2. polygonsWithinCartesian:为了判断矩形是否完全位于环路内部,我们使用了这个几何包含函数。这里采用了一个技巧:由于几何计算在处理恰好落在边界上的点时容易出现问题,我们将候选矩形略微向内缩小(缩小 0.01 个单位),从而确保包含性检查严格成立,避免因边界重合带来的误判。

-- Create slightly inset test bounds (0.01 units inside)(least(x1, x2) + 0.01, least(y1, y2) + 0.01) AS bottom_left, ...polygonsWithinCartesian(test_bounds, all_points_ring)

完整解决方案:

-- Define puzzle input WITH input_wrapper AS (SELECT raw_blob AS input FROM aoc.input9),
-- Parse 2D coordinate pointsparsed_points AS (    SELECT *    FROM format('CSV', 'x Float64, y Float64', (SELECT input FROM input_wrapper)::String)),
-- Generate all unique pairs of pointspoint_pairs AS (    SELECT        c1.x AS x1,        c1.y AS y1,        c2.x AS x2,        c2.y AS y2    FROM parsed_points AS c1    CROSS JOIN parsed_points AS c2    WHERE (c1.x, c1.y) < (c2.x, c2.y)),
-- Create bounding box polygons for each pairbounding_boxes AS (    SELECT        x1,        y1,        x2,        y2,
        -- Exact bounding box (corners at point coordinates)        [            (least(x1, x2), least(y1, y2)),        -- bottom-left            (least(x1, x2), greatest(y1, y2)),     -- top-left            (greatest(x1, x2), greatest(y1, y2)),  -- top-right            (greatest(x1, x2), least(y1, y2)),     -- bottom-right            (least(x1, x2), least(y1, y2))         -- close the ring        ]::Ring AS exact_bounds,
        -- Expanded bounding box (extends 0.5 units beyond points)        [            (least(x1, x2) - 0.5, least(y1, y2) - 0.5),        -- bottom-left            (least(x1, x2) - 0.5, greatest(y1, y2) + 0.5),     -- top-left            (greatest(x1, x2) + 0.5, greatest(y1, y2) + 0.5),  -- top-right            (greatest(x1, x2) + 0.5, least(y1, y2) - 0.5),     -- bottom-right            (least(x1, x2) - 0.5, least(y1, y2) - 0.5)         -- close the ring        ]::Ring AS expanded_bounds    FROM point_pairs),
-- Create polygon containing all points (for Part 2 containment test)all_points_array AS (    SELECT groupArray((x, y)) AS points_array    FROM parsed_points),
all_points_polygon AS (    SELECT arrayPushBack(points_array, points_array[1])::Ring AS ring    FROM all_points_array),
-- Part 1: Find largest bounding box by areapart1_candidates AS (    SELECT        x1,        y1,        x2,        y2,        exact_bounds,        expanded_bounds,        polygonAreaCartesian(expanded_bounds) AS area    FROM bounding_boxes    ORDER BY area DESC    LIMIT 1),
part1_solution AS (    SELECT area AS solution    FROM part1_candidates),
-- Part 2: Find largest bounding box that contains all pointspart2_candidates AS (    SELECT        bb.x1,        bb.y1,        bb.x2,        bb.y2,
        -- Create slightly inset test bounds (0.01 units inside)        (least(x1, x2) + 0.01, least(y1, y2) + 0.01) AS bottom_left,        (least(x1, x2) + 0.01, greatest(y1, y2) - 0.01) AS top_left,        (greatest(x1, x2) - 0.01, greatest(y1, y2) - 0.01) AS top_right,        (greatest(x1, x2) - 0.01, least(y1, y2) + 0.01) AS bottom_right,
        -- Create test bounds polygon        [            bottom_left,            top_left,            top_right,            bottom_right,            bottom_left        ]::Ring AS test_bounds,
        -- Check if all points are within test bounds        polygonsWithinCartesian(test_bounds, app.ring) AS all_points_contained,
        polygonAreaCartesian(bb.expanded_bounds) AS area    FROM bounding_boxes AS bb    CROSS JOIN all_points_polygon AS app    WHERE all_points_contained != 0    ORDER BY area DESC    LIMIT 1),
part2_solution AS (    SELECT area AS solution    FROM part2_candidates)
-- Combine results from both partsSELECT    'Part 1' AS part,    solution AS area -- 4739623064 with my inputFROM part1_solution
UNION ALL
SELECT    'Part 2' AS part,    solution AS area -- 1654141440 with my inputFROM part2_solution;

查看完整谜题描述:https://adventofcode.com/2025/day/9

Image

第 10 天:工厂 

谜题:你需要通过按下按钮来配置工厂中的机器。

  • 第 1 部分涉及通过切换灯光状态(XOR 逻辑)来匹配指定的目标模式。

  • 第 2 部分则需要通过递增“电压值”计数器,用尽可能少的按键次数达到一个非常大的目标整数。

我们是如何用 ClickHouse SQL 解决这个问题的:在第 1 部分中,由于搜索空间相对较小,我们可以直接使用暴力枚举的方法,生成所有可能的按钮组合,并通过位掩码进行验证。第 2 部分则需要更高效的策略。我们在 SQL 中实现了一个自定义的递归折半算法,通过反复减去按钮的影响,并对剩余的目标值进行“折半”处理,逐步将巨大的目标数缩减到零。

实现细节:

1. bitTest 和 bitCount:我们将按钮组合表示为二进制整数。bitTest 用于判断某个组合中是否按下了指定按钮,而 bitCount 则用于统计该组合对应的总按键次数,也就是整体代价。

2. ARRAY JOIN:为了构造第 1 部分的完整搜索空间,我们生成了一个整数范围(从 0 到 2^N),并使用 ARRAY JOIN 将其展开为多行数据,从而为每一种可能的按钮按压组合生成一行记录。

ARRAY JOIN range(0, toUInt32(pow(2, num_buttons)))

完整解决方案:

WITH RECURSIVE-- Define puzzle input     input_wrapper AS (SELECT raw_blob AS input FROM aoc.input10),
-- Parse raw input into structured formatraw_split AS (    SELECT        row_number() OVER () AS puzzle_id,        splitByChar(' ', raw) AS components    FROM format('TSVRaw', 'raw String', (SELECT input FROM input_wrapper)::String)),
parsed_puzzles AS (    SELECT        puzzle_id,
        -- Parse diagram: '#' becomes 1, '.' becomes 0        arrayMap(            x -> if(x = '#', 1, 0),            ngrams(replaceRegexpAll(components[1], '[\\[\\]]', ''), 1)        ) AS target_diagram,
        -- Parse buttons: each button affects specific positions        arrayMap(            button_str -> arrayMap(                pos_str -> (toUInt16(pos_str) + 1),                splitByChar(',', replaceRegexpAll(button_str, '[\\(\\)]', ''))            ),            arraySlice(components, 2, length(components) - 2)        ) AS button_effects,
        -- Parse joltages: target values for Part 2        arrayMap(            x -> toUInt32(x),            splitByChar(',', replaceRegexpAll(components[-1], '[\\{\\}]', ''))        ) AS target_joltages    FROM raw_split),
puzzle_metadata AS (    SELECT        puzzle_id,        target_diagram,        button_effects,        target_joltages,        length(button_effects) AS num_buttons,        length(target_joltages) AS num_positions    FROM parsed_puzzles),
-- PART 1: Brute force - enumerate all button combinationspart1_button_combinations AS (    SELECT        p.puzzle_id,        p.target_diagram,        p.button_effects,        p.num_buttons,        p.num_positions,        combination_id,        toUInt32(bitCount(combination_id)) AS button_presses,
        -- Calculate resulting diagram from this combination        arrayMap(            position -> toUInt8(                modulo(                    arrayReduce(                        'sum',                        arrayMap(                            button_index -> if(                                bitTest(combination_id, button_index)                                AND has(button_effects[button_index + 1], position),                                1,                                0                            ),                            range(0, num_buttons)                        )                    ),                    2                )            ),            range(1, num_positions + 1)        ) AS resulting_diagram    FROM puzzle_metadata p    ARRAY JOIN range(0, toUInt32(pow(2, num_buttons))) AS combination_id),
part1_minimum_solutions AS (    SELECT        puzzle_id,        min(button_presses) AS minimum_presses    FROM part1_button_combinations    WHERE target_diagram = resulting_diagram    GROUP BY puzzle_id),
-- PART 2: Pre-compute button combination patterns for recursive algorithmbutton_combination_patterns AS (    SELECT        p.puzzle_id,        p.button_effects,        p.num_buttons,        p.num_positions,        combination_id,        toUInt32(bitCount(combination_id)) AS pattern_cost,
        -- Pattern: numeric effect on each position        arrayMap(            position -> toUInt32(                arrayReduce(                    'sum',                    arrayMap(                        button_index -> if(                            bitTest(combination_id, button_index)                            AND has(button_effects[button_index + 1], position),                            1,                            0                        ),                        range(0, num_buttons)                    )                )            ),            range(1, num_positions + 1)        ) AS effect_pattern,
        -- Parity pattern: XOR constraint (mod 2)        arrayMap(            position -> toUInt8(                modulo(                    arrayReduce(                        'sum',                        arrayMap(                            button_index -> if(                                bitTest(combination_id, button_index)                                AND has(button_effects[button_index + 1], position),                                1,                                0                            ),                            range(0, num_buttons)                        )                    ),                    2                )            ),            range(1, num_positions + 1)        ) AS parity_pattern    FROM puzzle_metadata p    ARRAY JOIN range(0, toUInt32(pow(2, num_buttons))) AS combination_id),
-- Group patterns by parity for efficient lookuppatterns_grouped_by_parity AS (    SELECT        puzzle_id,        button_effects,        num_buttons,        num_positions,        parity_pattern,        groupArray(tuple(effect_pattern, pattern_cost)) AS available_patterns    FROM button_combination_patterns    GROUP BY puzzle_id, button_effects, num_buttons, num_positions, parity_pattern),
-- Recursive halving algorithm: iteratively reduce joltages to zerorecursive_halving_solver AS (    -- Base case: start with target joltages    SELECT        puzzle_id,        button_effects,        num_buttons,        num_positions,        target_joltages AS current_goal,        toUInt64(0) AS accumulated_cost,        0 AS recursion_depth    FROM puzzle_metadata
    UNION ALL
    -- Recursive case: apply pattern, subtract, halve, and continue    SELECT        puzzle_id,        button_effects,        num_buttons,        num_positions,        current_goal,        min(accumulated_cost) AS accumulated_cost,        min(recursion_depth) AS recursion_depth    FROM (        SELECT            solver.puzzle_id,            solver.button_effects,            solver.num_buttons,            solver.num_positions,
            -- New goal: (current - pattern) / 2            arrayMap(                i -> intDiv(                    solver.current_goal[i] - pattern_tuple.1[i],                    2                ),                range(1, solver.num_positions + 1)            ) AS current_goal,
            -- Accumulate cost: pattern_cost * 2^depth            solver.accumulated_cost +                 toUInt64(pattern_tuple.2) * toUInt64(pow(2, solver.recursion_depth)) AS accumulated_cost,
            solver.recursion_depth + 1 AS recursion_depth        FROM recursive_halving_solver solver        INNER JOIN patterns_grouped_by_parity patterns            ON patterns.puzzle_id = solver.puzzle_id            AND patterns.parity_pattern = arrayMap(                x -> if(x % 2 = 0, toUInt8(0), toUInt8(1)),                solver.current_goal            )        ARRAY JOIN patterns.available_patterns AS pattern_tuple        WHERE            solver.recursion_depth < 100            AND NOT arrayAll(x -> x = 0, solver.current_goal)            -- Ensure pattern doesn't overshoot (feasibility constraint)            AND arrayAll(                i -> pattern_tuple.1[i] <= solver.current_goal[i],                range(1, solver.num_positions + 1)            )    )    GROUP BY puzzle_id, button_effects, num_buttons, num_positions, current_goal),
part2_minimum_solutions AS (    SELECT        puzzle_id,        min(accumulated_cost) AS minimum_cost    FROM recursive_halving_solver    WHERE arrayAll(x -> x = 0, current_goal)    GROUP BY puzzle_id),
-- Aggregate final solutionscombined_solutions AS (    SELECT 'Part 1' AS part, sum(minimum_presses) AS solution -- 527 with my input    FROM part1_minimum_solutions
    UNION ALL
    SELECT 'Part 2' AS part, sum(minimum_cost) AS solution -- 19810 with my input    FROM part2_minimum_solutions)
-- Combine results from both partsSELECT * FROM combined_solutions settings use_query_cache=true, query_cache_share_between_users = 1, query_cache_nondeterministic_function_handling = 'save', query_cache_ttl = 80000000, result_overflow_mode = 'throw', read_overflow_mode = 'throw';

查看完整谜题描述:https://adventofcode.com/2025/day/10

Image

第 11 天:反应堆 

谜题:你正在调试一个反应堆的控制图结构。

  • 第 1 部分要求统计从你当前所在的节点到 out 节点之间所有不同路径的数量。

  • 第 2 部分则要求统计从 svr 到 out 的路径数量,并且必须满足一个额外条件:路径需要同时经过中间节点 dac 和 fft。

我们是如何用 ClickHouse SQL 解决这个问题的:我们使用递归 CTE 对这张图进行遍历。为了在第 2 部分中满足路径约束条件,我们在递归过程中携带了“是否访问过”的状态标志。在遍历过程中,只要遇到对应的关键节点,就更新相应的布尔标志。最终,只需筛选出两个标志都为 true 的路径即可。

实现细节:

1. cityHash64:在大规模递归 JOIN 中,字符串比较往往代价较高。为此,我们使用 cityHash64 将节点名称(例如 svr、dac)转换为确定性的 64 位整数,从而显著提升 JOIN 的执行速度,并降低整体内存占用。

cityHash64('svr') AS svr_node

2. 状态跟踪:我们在递归结果表中额外添加了布尔字段,用于跟踪路径状态。这使我们能够在一次遍历中完成“必须同时访问 X 和 Y”这样的约束判断,而无需额外的复杂后处理逻辑。

paths.visited_dac OR (edges.to_node = kn.dac_node) AS visited_dac

完整解决方案:

WITH RECURSIVE-- Define puzzle input input_wrapper AS (SELECT raw_blob AS input FROM aoc.input11),
-- Define key node identifierskey_nodes AS (    SELECT        cityHash64('svr') AS svr_node,        cityHash64('you') AS you_node,        cityHash64('dac') AS dac_node,        cityHash64('fft') AS fft_node,        cityHash64('out') AS out_node),
-- Parse input connectionsraw_connections AS (    SELECT splitByString(': ', raw) AS parsed_parts    FROM format('TSV', 'raw String', (SELECT input FROM input_wrapper)::String)),
parsed_connections AS (    SELECT        parsed_parts[1] AS input_node,        splitByWhitespace(parsed_parts[2]) AS output_nodes    FROM raw_connections),
-- Create graph edges with hashed node IDsgraph_edges AS (    SELECT        cityHash64(input_node) AS from_node,        cityHash64(arrayJoin(output_nodes)) AS to_node    FROM parsed_connections),
-- Part 2: Count paths from 'svr' to 'out' that visit both 'dac' and 'fft'paths_from_svr AS (    -- Base case: start at 'svr' node    SELECT        0 AS generation,        svr_node AS current_node,        0::UInt8 AS visited_dac,        0::UInt8 AS visited_fft,        1::UInt64 AS paths_count    FROM key_nodes
    UNION ALL
    -- Recursive case: traverse edges and track checkpoint visits    SELECT        generation,        current_node,        visited_dac,        visited_fft,        sum(paths_count) AS paths_count    FROM (        SELECT            paths.generation + 1 AS generation,            edges.to_node AS current_node,            paths.visited_dac OR (edges.to_node = kn.dac_node) AS visited_dac,            paths.visited_fft OR (edges.to_node = kn.fft_node) AS visited_fft,            paths.paths_count AS paths_count        FROM paths_from_svr paths        JOIN graph_edges edges ON edges.from_node = paths.current_node        CROSS JOIN key_nodes kn        WHERE            edges.to_node != kn.out_node            AND paths.generation < 628    )    GROUP BY generation, current_node, visited_dac, visited_fft),
-- Part 1: Count all paths from 'you' to 'out'paths_from_you AS (    -- Base case: start at 'you' node    SELECT        0 AS generation,        you_node AS current_node,        1::UInt64 AS paths_count    FROM key_nodes
    UNION ALL
    -- Recursive case: traverse edges    SELECT        generation,        current_node,        sum(paths_count) AS paths_count    FROM (        SELECT            paths.generation + 1 AS generation,            edges.to_node AS current_node,            paths.paths_count AS paths_count        FROM paths_from_you paths        JOIN graph_edges edges ON edges.from_node = paths.current_node        CROSS JOIN key_nodes kn        WHERE            edges.to_node != kn.out_node            AND paths.generation < 628    )    GROUP BY generation, current_node),
-- Part 1 solution: paths from 'you' to 'out'part1_solution AS (    SELECT sum(paths.paths_count) AS solution    FROM paths_from_you paths    JOIN graph_edges edges ON edges.from_node = paths.current_node    CROSS JOIN key_nodes kn    WHERE edges.to_node = kn.out_node),
-- Part 2 solution: paths from 'svr' to 'out' visiting both checkpointspart2_solution AS (    SELECT sum(paths.paths_count) AS solution    FROM paths_from_svr paths    JOIN graph_edges edges ON edges.from_node = paths.current_node    CROSS JOIN key_nodes kn    WHERE        edges.to_node = kn.out_node        AND paths.visited_dac = 1        AND paths.visited_fft = 1),
solutions_combined as (SELECT     'Part 1' AS part,    (SELECT solution FROM part1_solution) AS solution -- 724 with my input
UNION ALL
SELECT     'Part 2' AS part,    (SELECT solution FROM part2_solution) AS solution -- 473930047491888 with my input)
SELECT * FROM solutions_combined;

查看完整谜题描述:https://adventofcode.com/2025/day/11

Image

第 12 天:圣诞树农场

谜题:精灵们需要将形状不规则的礼物(由 # 组成的网格表示)打包进矩形区域中。乍一看,这像是一个复杂的二维装箱问题。不过,谜题的输入条件允许使用一种启发式的简化方法:只要礼物的总面积不超过区域的总面积,就可以认为是可行的。

我们是如何用 ClickHouse SQL 解决这个问题的:由于只需要进行面积层面的判断,解决方案的重点放在了解析上。我们将 ASCII 图案表示的礼物形状转换为二进制网格(由 1 和 0 组成的数组),并计算每种形状的面积(也就是 1 的数量)。接着,将每种礼物的需求数量与其面积相乘并求和,再与目标区域的总面积进行比较。

实现细节:

1. replaceRegexpAll:我们通过正则表达式替换,将可视化的 # 字符替换为 1,将 . 替换为 0。这样一来,原本用于展示的“图案”就变成了可以直接计算的二进制字符串,并进一步解析为数组。

2. arraySum:我们使用了带 lambda 的 arraySum 来完成类似“点积”的计算。通过将每种礼物的数量与其面积相乘,并在同一个表达式中求和,使实现保持简洁清晰。

arraySum(    (volume, area) -> volume * area,    requested_shape_volumes,    areas_per_shape)

完整解决方案:

-- Define puzzle input WITH input_wrapper AS (SELECT trimRight(raw_blob,'\n') AS input FROM aoc.input12),
-- Split input into sectionsinput_sections AS (    SELECT arrayMap(        section -> splitByChar('\n', section),        splitByString('\n\n', (SELECT input FROM input_wrapper)::String)    ) AS sections),
-- Extract regions section (last section)regions_section AS (    SELECT sections[-1] AS region_lines    FROM input_sections),
-- Extract shape sections (all except last)shapes_sections AS (    SELECT arrayJoin(        arraySlice(sections, 1, length(sections) - 1)    ) AS shape_lines    FROM input_sections),
-- Parse shape dataparsed_shapes AS (    SELECT        shape_lines,
        -- Transform shape lines: first line is name, rest is pattern        arrayMap(            line_index -> if(                line_index = 1,                -- First line: remove ':' from name                replaceAll(shape_lines[line_index], ':', ''),                -- Other lines: convert '#' to 1, '.' to 0                replaceRegexpAll(                    replaceRegexpAll(shape_lines[line_index], '#', '1'),                    '\\.',                    '0'                )            ),            arrayEnumerate(shape_lines)        ) AS transformed_lines    FROM shapes_sections),
-- Convert shape patterns to binary arraysshape_patterns AS (    SELECT        transformed_lines,        arrayMap(            line -> arrayMap(                char -> toUInt8(char),                ngrams(line, 1)            ),            arraySlice(transformed_lines, 2)        ) AS shape_grid    FROM parsed_shapes),
-- Calculate area needed for each shapeshape_areas AS (    SELECT groupArray(        arrayCount(            cell -> cell = 1,            arrayFlatten(shape_grid)        )    ) AS areas_per_shape    FROM shape_patterns),
-- Parse region specificationsparsed_regions AS (    SELECT        arrayJoin(            arrayMap(                line -> splitByString(': ', line),                region_lines            )        ) AS region_parts    FROM regions_section),
-- Calculate region dimensions and requested volumesregion_specifications AS (    SELECT        region_parts,
        -- Calculate total region area (product of dimensions)        arrayProduct(            arrayMap(                dim -> toUInt32(dim),                splitByChar('x', region_parts[1])            )        ) AS total_region_area,
        -- Extract requested volumes for each shape        arrayMap(            vol -> toUInt8(vol),            splitByChar(' ', region_parts[2])        ) AS requested_shape_volumes    FROM parsed_regions),
-- Check if each region can fit the requested shapesregion_fit_analysis AS (    SELECT        total_region_area,        requested_shape_volumes,        areas_per_shape,
        -- Calculate total area needed: sum of (volume * area) for each shape        arraySum(            (volume, area) -> volume * area,            requested_shape_volumes,            areas_per_shape        ) AS total_area_needed,
        -- Check if shapes fit in region        total_area_needed <= total_region_area AS shapes_fit    FROM region_specifications    CROSS JOIN shape_areas),
-- Count regions where shapes fitsolution AS (    SELECT countIf(shapes_fit) AS solution    FROM region_fit_analysis)
-- Return final answerSELECT solution -- 463 with my inputFROM solution

查看完整谜题描述:https://adventofcode.com/2025/day/12

Image

结论

我们成功地仅使用纯 ClickHouse SQL 解决了全部 12 个谜题,这充分展示了 ClickHouse 查询引擎的灵活性与表达能力。这一切得益于 ClickHouse 内置的庞大函数库,它在很大程度上弥合了 SQL 与通用编程语言之间的差距。在整个挑战过程中,我们使用了十多种字符串函数,并配合 format 表函数,将杂乱无章的输入整理成可处理的数据集。我们大量依赖了 arrayMap、arrayProduct 等数组函数,而真正发挥关键作用的是 arrayReduce 和 arrayFold,它们让我们能够实现复杂的函数式逻辑,并在多次迭代中持续维护状态。再结合用于路径查找的原生递归 CTE、用于三维几何计算的距离函数、用于空间分析的多边形函数,以及用于逻辑运算的位运算,ClickHouse 的表现更像是一个高性能的向量计算引擎,而不仅仅是传统意义上的数据库。

由于我们人为设定的限制条件,这些 SQL 方案在计算效率上可能不及使用 Rust 或 Python 等通用编程语言实现的版本,但它们依然能够产生完全一致的结果。这个实验表明,只要工具足够强大,那些在 SQL 中看似“不可能”的问题,对 ClickHouse 来说不过是另一类有趣的数据挑战。完整的解决方案查询可以在我们的 ClickHouse/TreeHouse 仓库中查看。

注意:Thomas Neumann 在 2024 年使用 Umbra DB 完成了全部 Advent of Code 谜题,他的相关成果可以在对应的仓库中找到。

/END/

试用阿里云 ClickHouse企业版

轻松节省30%云资源成本?阿里云数据库ClickHouse 云原生架构全新升级,首次购买ClickHouse企业版计算和存储资源组合,首月消费不超过99.58元(包含最大16CCU+450G OSS用量)了解详情:https://t.aliyun.com/Kz5Z0q9G

图片
图片

征稿启示

面向社区长期正文,文章内容包括但不限于关于 ClickHouse 的技术研究、项目实践和创新做法等。建议行文风格干货输出&图文并茂。质量合格的文章将会发布在本公众号,优秀者也有机会推荐到 ClickHouse 官网。请将文章稿件的 WORD 版本发邮件至:[email protected]

图片图片