开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第13天,点击查看活动详情
- Advent of Code 2020 Day13 译文(用 Rust 实现 Advent of Code 2020 第13天)
- 原文链接:fasterthanli.me/series/adve…
- 原文作者:Amos
- 译文来自:github.com/suhanyujie/…
- 译者:suhanyujie
- ps:水平有限,如有不当之处,欢迎指正
- 标签:Rust,advent of code, algo
在第 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(|×tamp| 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:这次我们可能要用数学方法了。
酷熊:是的
好吧。好吧。我们一定会碰到一个问题,在很多时候,我们不能只是使用暴力破解方式。
好吧,接下来我们来解释一下数学方法。
待续