【译】用 Rust 实现 Advent of Code 2020 第13天

49 阅读7分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第13天,点击查看活动详情

第 13 天的问题中,我们将尝试坐公共汽车。

我们的输入如下:

939
7,13,x,x,59,x,31,19

第一行表示距离末班车出发的分钟数,第二行表示活动线路的班车的“标识”。

每辆公共汽车根据“公共汽车 ID”的倍数形成的分钟值离开(出发) —— 如:7 号公共汽车在第 0 分钟、第 7 分钟、第 14分钟、第 21 分钟等时间离开。问题是: 我们可以先乘哪辆公共汽车(显然他们要么都去同一个目的地,要么我们根本不在乎要去哪里),还有我们要等多久?

给出的答案是通过将公交车 ID 乘以我们需要等待的分钟数来计算的。

你现在知道该怎么做了,我们定义一些类型!

#[derive(Debug)]
struct ProblemStatement {
    departure_time: usize,
    buses: Vec<usize>,
}

#[derive(Debug)]
struct WaitTime {
    bus_id: usize,
    /// in minutes
    wait: usize,
}

然后是一个简洁的解析器:

impl ProblemStatement {
    fn parse(input: &str) -> Self {
        let mut lines = input.lines();
        ProblemStatement {
            departure_time: lines.next().unwrap().parse().unwrap(),
            buses: lines
                .next()
                .unwrap()
                .split(',')
                .filter(|&s| s != "x")
                .map(|x| x.parse().unwrap())
                .collect(),
        }
    }
}

我们检查一下我们是否真的可以解析示例输入:

939
7,13,x,x,59,x,31,19
$ cargo run --quiet
[src/main.rs:31] ProblemStatement::parse(include_str!("input.txt")) = ProblemStatement {
    departure_time: 939,
    buses: [
        7,
        13,
        59,
        31,
        19,
    ],
}

至此,我们要计算我们需要等多少分钟才能等到下一班车。好的,我们在这个系列中已经使用了好几次取模运算符(%),它确实让我们省事了很多。如果我们复制示例中的表格:

departure_time % bus_id 可以计算从最早到最晚一班公交车 bus_id 出发之间的距离

所以我们真正要做的是计算 bus_id - (departure_time % bus_id),它可计算出我们到下一个班车出发时的距离:

bus_id - departure_time % bus_id 计算我们从最早出发到后续 bus_id 公交车出发的距离

酷熊:这看起来很容易实现!

Amos:是的! 我们已经有一个适合该场景的类型!

fn main() {
    let stat = ProblemStatement::parse(include_str!("input.txt"));

    let times: Vec<_> = stat
        .buses
        .iter()
        .map(|&bus_id| WaitTime {
            bus_id,
            wait: bus_id - stat.departure_time % bus_id,
        })
        .collect();
    dbg!(times);
}
$ cargo run --quiet
[src/main.rs:41] times = [
    WaitTime {
        bus_id: 7,
        wait: 6,
    },
    WaitTime {
        bus_id: 13,
        wait: 10,
    },
    WaitTime {
        bus_id: 59,
        wait: 5,
    },
    WaitTime {
        bus_id: 31,
        wait: 22,
    },
    WaitTime {
        bus_id: 19,
        wait: 11,
    },
]

现在我们需要找到在我们最早出发时间之后,紧跟着出发的公共汽车的出发时间。即最小值 WaitTime::wait

酷熊:Mhhh 我们可以使用 Iterator::min... 但是只有当我们首次调用 map(|wt| wt.wait) 或者其他情况 —— 我们会丢失 bus_id

Amos:是的,这不行 —— 我们需要一个能相关联的方法: Iterator::min_by_key

fn main() {
    let stat = ProblemStatement::parse(include_str!("input.txt"));

    let answer = stat
        .buses
        .iter()
        .map(|&bus_id| WaitTime {
            bus_id,
            wait: bus_id - stat.departure_time % bus_id,
        })
        .min_by_key(|wt| wt.wait);

    match answer {
        Some(wt) => {
            dbg!(&wt, wt.bus_id * wt.wait);
        }
        None => {
            panic!("No answer found!");
        }
    }
}

酷熊:哇!

$ cargo run --quiet
[src/main.rs:44] &wt = WaitTime {
    bus_id: 59,
    wait: 5,
}
[src/main.rs:44] wt.bus_id * wt.wait = 295

酷熊:酷! 使用样本输入一样可以...但它能基于问题提供的输入执行后同样有效吗?

Amos:只有一个办法能知道!

$ cargo run --quiet
[src/main.rs:44] &wt = WaitTime {
    bus_id: 383,
    wait: 5,
}
[src/main.rs:44] wt.bus_id * wt.wait = 1915

酷熊:正确!

第二部分

酷熊:哦,不,第 1 部分看起来很简单 —— 第 2 部分会难很多吧?

Amos:为什么是这样!

现在我们根本不在乎第一行,我们只在乎下面这一行:

7,13,x,x,59,x,31,19

现在 x 值很重要(惊喜吧!)因为问题考虑到了“公交车 ID”在列表中的位置,所以我们应该记住:

  • 7 => 0, 7 = > 0,
  • 13 => 1, 13 = > 1,
  • 59 => 4, 59 = > 4,
  • 31 => 6, 31 = > 6,
  • 19 => 7, 19 = > 7,

我们应该找到一个时间戳 t:

  • 公交车 7 在 t 时离开
  • 公交车 13 在 t + 1 时离开
  • 公交车 59 在 t + 4 时离开
  • 公交车 31 在 t + 6 时离开
  • 公交车 19 在 t + 7 时离开

这部分让我非常困惑,我不得不反复读了很多遍才弄明白。

但后来我用了一个秘密武器 —— 电子表格!

“时间偏移量(Time offset)”列表示公交车在初始列表中的位置。

“偏移间隔(Offset gap)”是公交车和下一辆的出发时间偏移量之间的差 —— 最后一个公交车,即 19 路车,没有“偏移间隔”,因为它之后没有公交车了。

后续的所有单元格都是简单的 timestamp % bus_id 计算值。由于 GoogleSheets 的条件格式(conditional formatting)特性,零值(对应于离开)以蓝色高亮显示。

有趣的是: 如果我们查看 19 路公交车列中的蓝色行,并立即查看它左边的单元格,我们会发现 31 路公交车的“偏移间隔”。如果我们将 31 路公交车的列的蓝色单元格并向左移动,我们会找到 59 路公交车的“偏移间隔”,以此类推。

如果我们一直向后,直到列表的开始(即 7 路车),并且左边的单元格总是匹配“偏移间隔”,那么我们就找到了答案: 第一列中的蓝色单元格。

也许有一个更好的解释,但这是圣诞节,并且我的大脑有点累,所以达到这个效果不错了。

酷熊:我们还有下一步计划吗?

Amos:有!

开始吧!

首先让我们使用解析 —— 现在我们必须记住我们在提供的列表中找到公交车位置:

#[derive(Debug)]
struct ProblemStatement {
    departure_time: usize,
    buses: Vec<Bus>,
}

#[derive(Debug)]
struct Bus {
    id: usize,
    time_offset: usize,
}

impl ProblemStatement {
    fn parse(input: &str) -> Self {
        let mut lines = input.lines();
        ProblemStatement {
            departure_time: lines.next().unwrap().parse().unwrap(),
            buses: lines
                .next()
                .unwrap()
                .split(',')
                .enumerate()
                .filter_map(|(index, input)| {
                    input.parse().ok().map(|id| Bus {
                        id,
                        time_offset: index,
                    })
                })
                .collect(),
        }
    }
}

fn main() {
    let stat = ProblemStatement::parse(include_str!("input.txt"));
    dbg!(stat);
}
$ cargo run --quiet
[src/main.rs:36] stat = ProblemStatement {
    departure_time: 939,
    buses: [
        Bus {
            id: 7,
            time_offset: 0,
        },
        Bus {
            id: 13,
            time_offset: 1,
        },
        Bus {
            id: 59,
            time_offset: 4,
        },
        Bus {
            id: 31,
            time_offset: 6,
        },
        Bus {
            id: 19,
            time_offset: 7,
        },
    ],
}

好了,这个结果符合我们神奇的电子表格!

对于下一部分,我想... 我们可以迭代所有的公交车,并使用 tuple_windows 来考虑它们的配对问题!

$ cargo add itertools
      Adding itertools v0.9.0 to dependencies
fn main() {
    let stat = ProblemStatement::parse(include_str!("input.txt"));

    use itertools::Itertools;

    stat.buses
        .iter()
        .tuple_windows()
        .for_each(|(earlier, later)| {
            let offset_gap = later.time_offset - earlier.time_offset;
            dbg!("-----------", earlier.id, later.id, offset_gap);
        });
}
$ cargo run --quiet
[src/main.rs:44] "-----------" = "-----------"
[src/main.rs:44] earlier.id = 7
[src/main.rs:44] later.id = 13
[src/main.rs:44] offset_gap = 1
[src/main.rs:44] "-----------" = "-----------"
[src/main.rs:44] earlier.id = 13
[src/main.rs:44] later.id = 59
[src/main.rs:44] offset_gap = 3
[src/main.rs:44] "-----------" = "-----------"
[src/main.rs:44] earlier.id = 59
[src/main.rs:44] later.id = 31
[src/main.rs:44] offset_gap = 2
[src/main.rs:44] "-----------" = "-----------"
[src/main.rs:44] earlier.id = 31
[src/main.rs:44] later.id = 19
[src/main.rs:44] offset_gap = 1

事情进展得很顺利! 下面是电子表格,以供参考:

31 路车和 19 路公交车之间的偏移差应该是 1 (根据电子表格) ,59 路车和 31 路公交车之间的偏移差应该是 2,13 路车和 59 路公交车之间的偏移差应该是 3,7 路车和 13 路公交车之间的偏移差应该是 1。

我们更进一步 - 假设我们已经知道一个潜在的解决方案(即:最后一班公共汽车,19 路公共汽车的发车时间),我们能查一下吗?

让我们把这里变得好看一些,并使用 fold!实际上,方法中,使用 Result<T,E> 累加器,其中 T 是一个时间戳,E 是一个自定义错误类型:

struct WrongGap<'a> {
    earlier: &'a Bus,
    later: &'a Bus,
    offset_gap: usize,
    actual_gap: usize,
}

impl fmt::Debug for WrongGap<'_> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "expected Bus {} to leave {} minutes after Bus {}, but it left {} minutes after",
            self.later.id, self.earlier.id, self.offset_gap, self.actual_gap
        )
    }
}

impl fmt::Display for WrongGap<'_> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        fmt::Debug::fmt(self, f)
    }
}

impl std::error::Error for WrongGap<'_> {}

现在我们使用它:

fn main() {
    let stat = ProblemStatement::parse(include_str!("input.txt"));

    use itertools::Itertools;

    let solution = 1068781_usize;

    let ok = stat.buses.iter().tuple_windows().fold(
        Ok::<_, WrongGap>(solution),
        |acc, (earlier, later)| {
            let earlier_timestamp = acc?;
            let later_timestamp = earlier_timestamp + later.id - (earlier_timestamp % later.id);

            let offset_gap = later.time_offset - earlier.time_offset;
            let actual_gap = later_timestamp - earlier_timestamp;

            if offset_gap == actual_gap {
                Ok(later_timestamp)
            } else {
                Err(WrongGap {
                    earlier,
                    later,
                    earlier_timestamp,
                    offset_gap,
                    actual_gap,
                })
            }
        },
    );
    dbg!(&ok);
}
$ cargo run --quiet
[src/main.rs:89] &ok = Ok(
    1068788,
)

我们已经知道这个方案会起作用 —— 让我们看看这个方法在一个非法值的情况下运行结果,比如 256:

$ cargo run --quiet
[src/main.rs:91] &ok = Err(
    expected Bus 13 to leave 7 minutes after Bus 1 (which left at 256), but it left 4 minutes after,
)

酷熊:真是见多识广!

Amos:谢谢,很高兴你这么说

在我们开始编写测试之前,有一件事我想说明一下,额,有两件事。

首先,让我们将所有这些都移动到一个方法中,并添加一些调试打印:

impl ProblemStatement {
    fn check_solution(&self, solution: usize) -> Result<(), WrongGap<'_>> {
        use itertools::Itertools;

        self.buses
            .iter()
            .tuple_windows()
            .fold(Ok::<_, WrongGap>(solution), |acc, (earlier, later)| {
                // 👇 here's the only debug print we need
                dbg!(&acc);
                let earlier_timestamp = acc?;
                let later_timestamp = earlier_timestamp + later.id - (earlier_timestamp % later.id);

                let offset_gap = later.time_offset - earlier.time_offset;
                let actual_gap = later_timestamp - earlier_timestamp;

                if offset_gap == actual_gap {
                    Ok(later_timestamp)
                } else {
                    Err(WrongGap {
                        earlier,
                        later,
                        earlier_timestamp,
                        offset_gap,
                        actual_gap,
                    })
                }
            })
            .map(|_| ())
    }
}

在 main 中使用它:

fn main() {
    let stat = ProblemStatement::parse(include_str!("input.txt"));
    dbg!(&stat.check_solution(256));
}
$ cargo run --quiet
[src/main.rs:71] &acc = Ok(
    256,
)
[src/main.rs:71] &acc = Err(
    expected Bus 13 to leave 7 minutes after Bus 1 (which left at 256), but it left 4 minutes after,
)
[src/main.rs:71] &acc = Err(
    expected Bus 13 to leave 7 minutes after Bus 1 (which left at 256), but it left 4 minutes after,
)
[src/main.rs:71] &acc = Err(
    expected Bus 13 to leave 7 minutes after Bus 1 (which left at 256), but it left 4 minutes after,
)
[src/main.rs:96] &stat.check_solution(256) = Err(
    expected Bus 13 to leave 7 minutes after Bus 1 (which left at 256), but it left 4 minutes after,
)

酷熊:fold 最终会遍历列表中的所有项即使我们第一项失败了?

Amos:是的

酷熊:真可惜,我原以为 acc? 技巧很不错 - 这样看到错误在闭包中传播并传递到 fold

Amos:是的,但是这里效率很低。

这是低效的,因为我们将要测试很多的解决方案 —— 第一辆巴士的所有发车时间。所以对于第一辆巴士的所有出发时间,即使第二辆巴士的下一个出发时间不满足题目输入,我们依然使用 fold 迭代所有巴士。

幸运的是,有一种方法可以做到这一点!Iterator::try_fold 可以实现短路(short-circuit) fold:

impl ProblemStatement {
    fn check_solution(&self, solution: usize) -> Result<(), WrongGap<'_>> {
        use itertools::Itertools;

        self.buses
            .iter()
            .tuple_windows()
            // 👇 here's our `try_fold`
            .try_fold(solution, |acc, (earlier, later)| {
                // 👇 that debug print is still here for now
                //    (note that `acc` is now a `usize`, not a `Result<usize, WrongGap>`)
                dbg!(&acc);

                let earlier_timestamp = acc;
                let later_timestamp = earlier_timestamp + later.id - (earlier_timestamp % later.id);

                let offset_gap = later.time_offset - earlier.time_offset;
                let actual_gap = later_timestamp - earlier_timestamp;

                // 👇 we still return a `Result` though!
                if offset_gap == actual_gap {
                    Ok(later_timestamp)
                } else {
                    Err(WrongGap {
                        earlier,
                        later,
                        earlier_timestamp,
                        offset_gap,
                        actual_gap,
                    })
                }
            })
            .map(|_| ())
    }
}

我们用错误的解再运行一次 (256) :

$ cargo run --quiet
[src/main.rs:71] &acc = 256
[src/main.rs:97] &stat.check_solution(256) = Err(
    expected Bus 13 to leave 7 minutes after Bus 1 (which left at 256), but it left 4 minutes after,
)

酷熊:马上就完了!

Amos:🙌

现在让我们自己试着找出这个例子的解决方案:

impl ProblemStatement {
    fn solve(&self) -> usize {
        let first_bus = self.buses.first().unwrap();
        itertools::iterate(0, |&i| i + first_bus.id)
            .find(|&timestamp| self.check_solution(timestamp).is_ok())
            .unwrap()
    }
}

酷熊:所以我们... 生成了首发班车的所有出发时间,然后在第一个时间上停下来,这就是解决问题的有效方法?

Amos:就是这样!

fn main() {
    let stat = ProblemStatement::parse(include_str!("input.txt"));
    dbg!(&stat.solve());
}
  • 酷熊热辣小贴士 如果你跟上来了,请不要忘记删除 dbg! 除非你喜欢这些繁杂的终端输出(并且有耐心)。
$ cargo run --quiet
[src/main.rs:103] &stat.solve() = 1068781

我们甚至可以添加一些测试,因为我们在第 2 部分的陈述中提供了一些示例:

#[test]
fn test_solutions() {
    macro_rules! test {
        ($list: literal, $solution: expr) => {
            assert_eq!(
                ProblemStatement::parse(concat!("0\n", $list, "\n")).solve(),
                $solution
            )
        };
    }

    test!("17,x,13,19", 3417);
    test!("67,7,59,61", 754018);
    test!("67,x,7,59,61", 779210);
    test!("67,7,x,59,61", 1261476);
    test!("1789,37,47,1889", 1202161486);
}

酷熊:哦,我喜欢这个宏 —— 微小的宏,但非常方便。

Amos:是的,我经常这样做 —— 它不完全是 DSL,但它允许我将所有测试数据整齐地分组,而把逻辑放在其他地方。

这个测试通过了 —— 当事情第一次像这样工作时总是令人不安,所以我添加了一个失败的测试,只是为了确认一下,果然,测试不通过。

我想我们已经准备好进入关键阶段了!我们将 input.txt 替换为题目输入,并在 release 模式下运行,虽然会有警告:

但是,由于你的列表中有这么多公交车 ID,实际的最早时间戳肯定要大于 100000000000000

所以我们可以预设一些数字处理。

$ cargo build --release
    Finished release [optimized] target(s) in 0.00s
$ time ./target/release/day13

酷熊:好吧。。

Amos:已经过了几分钟了,还未执行完。

酷熊:是的

Amos:这次我们可能要用数学方法了。

酷熊:是的

好吧。好吧。我们一定会碰到一个问题,在很多时候,我们不能只是使用暴力破解方式。

好吧,接下来我们来解释一下数学方法。

待续