Advent of Code 2025 day10

day10 的输入是:

[.##.] (3) (1,3) (2) (2,3) (0,2) (0,1) {3,5,4,7}
[...#.] (0,2,3,4) (2,3) (0,4) (0,1,2) (1,2,3,4) {7,5,12,7,2}
[.###.#] (0,1,2,3,4) (0,3,4) (0,1,2,4,5) (1,2) {10,11,11,5,10,5}

看第一行, [.##.] 表示是一排灯,其中 . 表示对应下标(从 0 开始)的灯是熄灭的; # 表示灯是亮起的。默认灯全部都是熄灭的。

(1,3) 表示一个按钮,连接了下标 1 和下标 3 的灯,按一次,下标 1 和 3 的灯就会亮起,变成 # ;再按一次,下标 1 和 3 的灯就会熄灭,变回 .

part1 的问题是,你可以按任意的按钮任意次,使得灯的状态变成每一行开头 [] 里的状态,找到每一行需要的最少次数,累计求和。


针对一个按钮,按 1 次,对应下标的灯会亮起;按 2 次,对应下标的灯会熄灭,相当于没按过;按 3 次和按 1 次的效果是一样的。

也就是说,对一个按钮,按奇数次,会把对应下标的灯亮起,偶数次相当于这个灯没被按过。所以对任何一个灯,最多按 1 次就够了。

那么问题就变成了,对任意的灯按 1 次,它们的组合使得灯的状态变成 [] 中的状态。

假设有 A、B、C 三个按钮,我可以有很多组合:

对每一行,我只要枚举所有的按钮组合,都按 1 次,从组合数量少的开始遍历(这样次数就是最少的),找到一个组合,使得灯的状态和 [] 中的状态一致就好了。

得到按钮的所有组合,需要用到 回溯算法,在 python 中也可以利用 itertools.combinations

判断状态的时候,我是按照按钮的下标拼接成 .# 组合的字符串,和 [] 中的字符串做比较,效率可能比较低。

Yordi Verkroost 在 Advent of Code 2025 - Day 10 中使用二进制表示按钮,用 XOR(异或) 运算得到最终的状态,效率应该会更好。


part2 的输入不变,不过此时需要关注的是 {} 里的部分。

[.##.] (3) (1,3) (2) (2,3) (0,2) (0,1) {3,5,4,7}
[...#.] (0,2,3,4) (2,3) (0,4) (0,1,2) (1,2,3,4) {7,5,12,7,2}
[.###.#] (0,1,2,3,4) (0,3,4) (0,1,2,4,5) (1,2) {10,11,11,5,10,5}

还是看第一行, [] 可以忽略, {3,5,4,7} 表示的是电压,下标 0 的电压是 3;下标 1 的电压是 5;依次类推。{} 中的电压默认都是 0。按钮 (1,3) 可以使得下标 1、3 的电压增加 1。

part2 的问题是,你可以按任意按钮任意次,最终使得电压达到 {} 里面的要求,找到需要的最少次数,累计每一行的最小次数求和。


一开始没什么头绪,我尝试随便按一个按钮看看会如何,例如我先尝试按 (0,2) ,先满足把下标 0 的电压:

此时我就不能继续按 (0,2) 了,因为下标 0 的电压已经达到了 {3,5,4,7} 中的要求,再按就会超了。也就是说,现在我不能操作下标包含 0 的按钮了。

继续尝试操作其他按钮,再满足一个下标的电压,直到达到 {} 中的电压要求。

为什么我先开始满足下标 0 的电压?

因为在 {3,5,4,7} 中,下标 0 的电压是最小的,无论我操作哪个按钮,当下标 0 的电压达到要求的时候,其他下标的电压也不会超出。

按钮 (0,2)(0,1) 都能使得下标 0 的电压增加 1,我怎么判断用哪个呢?我不知道,干脆穷举算了。

我先尝试都用 (0,2) 满足下标 0 的电压,下标 0 电压满足了,我就排除所有包含下标 0 的按钮,从剩下的按钮里,继续满足当前电压较小的,不断循环这个过程。

最后如果找到一个组合,能够满足 {} 中的电压要求,就记录按钮的操作次数,再找到操作次数的最小值。

尝试完 (0,2) ,就继续尝试 (0,1) ,看看哪个的操作次数是最少的。

折腾了好久写出一段代码,通过了示例输入,但死活过不了实际输入,看来思路是不对了。


没什么思路,于是去 reddit 找找思路,发现很多人提到了 Z3ILP (Integer Linear Programming),都是我不了解的东西。

和 LLM 进行了一些对话,把 part2 的问题丢给了它,让它分析问题,我大概明白了应该如何做。

对于 [.##.] (3) (1,3) (2) (2,3) (0,2) (0,1) {3,5,4,7}

有这么几个按钮:

假设:

设最终需要的操作总次数是 T,那么 T = a + b + c + d + e + f。

其中:

下标 0 的电压要求是 3,(0,2) 和 (0,1) 会影响下标 0,操作 e 次 (0,2) 和 f 次 (0,1) 最终应该达到电压 3,所以有 e + f = 3。

类似的:

于是得到了几个线性方程:

目标是使得 T 最小。


此时再看看 线性规划 (Linear programming, LP) 的定义:

线性规划(LP),也称为线性优化,是一种在数学模型中实现最佳结果的方法,该模型的各项要求和目标均由线性关系表示。

[…]

更正式地说,线性规划是一种优化线性目标函数的技术, 该函数受线性等式和线性不等式约束。其可行域是一个凸多面体,该多面体定义为有限多个半空间的交集,每个半空间都由一个线性不等式定义。其目标函数是定义在该多面体上的实值仿射(线性)函数。如果存在这样的点,线性规划算法会在多面体中找到该函数具有最大(或最小)值的点。

说实话我没太看懂,但大体的意思是,有一种数学模型,可以用一些线性等式和线性不等式来表示,同时存在一些约束条件。通过这样的模型,最终可以找到最值。

而当线性规划中,变量被约束为整数,问题就会变成了 整数线性规划 (Integer Linear Programming, ILP)

此时回过头来看看上面得到的结论:

目标是使得 T 最小。

可以发现这个模型是符合整数线性规划的:

所以,这就是一个整数线性规划问题,而在 python 中,可以利用 Z3PuLP 来处理整数线性规划问题。

不过就算知道可以用 Z3,我也不会用 _​(:3 」∠)_​。

还是求助了 LLM,得到这样的一个函数,并问它每一行代码的含义:

from z3 import Int, Optimize, sat

def min_operations_z3(ops, target):
    """使用 Z3 求解最小操作次数,返回整数最优值(若无解返回 None)"""
    n = len(ops)           # 操作数量
    m = len(target)        # 索引数量

    # 创建变量,表示的是每个按钮的操作次数,x0 表示第一个按钮的操作次数
    vars = [Int(f'x{i}') for i in range(n)]

    opt = Optimize()
    # 添加约束,操作次数只能大于或等于 0
    for v in vars:
        opt.add(v >= 0)

    # 遍历所有的目标电压
    for i in range(m):
        # 遍历所有的按钮,当按钮会影响目标电压,则累计操作次数,相当于构造 e + f = 3
        coeff_sum = sum(vars[j] for j in range(n) if i in ops[j])
        opt.add(coeff_sum == target[i])

    # 最小化总操作次数,使用 minimize 求最小值
    total = sum(vars)
    opt.minimize(total)

    # 当所有约束都能满足的时候,得到这个模型对象,获取操作总次数的最小值
    if opt.check() == sat:
        model = opt.model()
        return model.evaluate(total).as_long()
    else:
        return None

使用 Z3 问题就很轻松就解决了。

如果你对 PuLP 的方法感兴趣,可以看看 Yordi Verkroost 的 Advent of Code 2025 - Day 10


day10 依然是让人折磨的一天 _​(:3 」∠)_​,希望以后我碰到类似问题能想起来可以用线性规划解决。

Webmentions (0)

如果你想回应这篇文章,你可以在你的文章中链接这篇文章,然后在下面输入你的文章的 URL 并提交。你的回应随后会显示在此页面上(如果是垃圾信息我会屏蔽)。如果要更新或删除你的回应,请更新或删除你的文章,然后再次输入该文章的 URL 并提交。(了解有关 Webmention 的更多信息。)


作 者: Spike Leung

创建于: 2025-12-15 Mon 11:03

修改于: 2025-12-15 Mon 11:03

许可证: 署名—非商业性使用—相同方式共享 4.0

支持我: 用你喜欢的方式