- Advent of Code 2020 Day7 译文(用 Rust 实现 Advent of Code 2020 第7天)
- 原文链接:https://fasterthanli.me/series/advent-of-code-2020/part-7
- 原文作者:Amos
- 译文来自:https://github.com/suhanyujie/article-transfer-rs/
- 译者:suhanyujie
- ps:水平有限,如有不当之处,欢迎指正
- 标签:Rust,advent of code, algo
新的一天,新的 Advent of Code 2020 问题。
这题看起来有点意思!对那些类似于书呆子的做题者,尤其如此。
输入是一些规则:
light red bags contain 1 bright white bag, 2 muted yellow bags.
dark orange bags contain 3 bright white bags, 4 muted yellow bags.
bright white bags contain 1 shiny gold bag.
muted yellow bags contain 2 shiny gold bags, 9 faded blue bags.
shiny gold bags contain 1 dark olive bag, 2 vibrant plum bags.
dark olive bags contain 3 faded blue bags, 4 dotted black bags.
vibrant plum bags contain 5 faded blue bags, 6 dotted black bags.
faded blue bags contain no other bags.
dotted black bags contain no other bags.
我们要解答的问题是:有多少种袋子最终会至少包含一个“闪亮金”色(shiny gold)袋子?
例如,从上面输入的例子来看,“亮白色(bright white)袋子”包含一个闪亮金(shiny gold)袋子,而浅红色袋子包含一个亮白色(bright white)袋子,亮白色袋子又可以包含一个闪亮金色(shiny gold)袋子。
第一部分题目不是“ X 色袋子中可以包含多少 Y 色袋子”,所以我怀疑它会出现在题目第二部分(只是一个猜测,我还没看到,除非我已经解决了第一部分) —— 我想尝试解析并选好输入类型,以便解答第一个问题。
描述袋子有两个明确的属性: 形容词:光(light),暗(dark),明亮(bright),柔和(muted),闪亮(shiny),充满活力(vibrant),褪色(faded),点状(dotted),还有颜色:红色(red),橙色(orange),白色(white),黄色(yellow),金色(gold),橄榄色(olive),李子色(plum)等。
这里还不知道会有多少不同的形容词和颜色,所以我想把它们表示为借用字符串:
/// (adjective, color), i.e. ("dark", "orange")
type BagSpec<'a> = (&'a str, &'a str);
- 酷熊的热辣小贴士 “Spec”在这里是“specification”的简写。
从这里开始,我们的规则其实就是从 BagSpec
到另一个 BagSpec
和数量的映射:
use std::collections::HashMap;
/// K can contain V.0 of V.1
type Rules<'a> = HashMap<BagSpec<'a>, (usize, BagSpec<'a>)>;
规则表示,有些袋子可以包含几种其他类型的袋子,例如这个规则:
light red bags contain 1 bright white bag, 2 muted yellow bags.
我们不能用当前的类型集合来表达这个规则:
fn main() {
let mut rules: Rules = Default::default();
rules.insert(("light", "red"), (1, ("bright", "white")));
rules.insert(("light", "red"), (2, ("muted", "yellow")));
dbg!(&rules);
}
$ cargo run --quiet
[src/main.rs:13] &rules = {
(
"light",
"red",
): (
2,
(
"muted",
"yellow",
),
第二条规则覆盖了第一条!
好消息是: 有一个板条箱(和类型)适用于该场景。
$ cargo add multimap
Adding multimap v0.8.2 to dependencies
use multimap::MultiMap;
/// K can contain V.0 of V.1
type Rules<'a> = MultiMap<BagSpec<'a>, (usize, BagSpec<'a>)>;
$ cargo run --quiet
[src/main.rs:13] &rules = {
(
"light",
"red",
): [
(
1,
(
"bright",
"white",
),
),
(
2,
(
"muted",
"yellow",
),
),
],
}
酷熊:就像 hyper 中的 HeaderMap
一样吗?
Amos:是的,我们最近讨论过。
现在我们尝试将示例规则解析到这个数据结构中。
酷熊:要用 peg 吗?
Amos:是的,peg。
$ cargo add peg
Adding peg v0.6.3 to dependencies
fn parse_rules(input: &str) -> Rules<'_> {
let mut rules: Rules = Default::default();
peg::parser! {
pub(crate) grammar parser() for str {
pub(crate) rule root(r: &mut Rules<'input>)
= (line(r) "." whitespace()*)* ![_]
rule line(r: &mut Rules<'input>)
= spec:bag_spec() " contain " rules:rules() {
if let Some(rules) = rules {
for rule in rules {
r.insert(spec, rule)
}
}
}
rule bag_spec() -> BagSpec<'input>
= adjective:name() " " color:name() " bag" "s"? { (adjective, color) }
rule rules() -> Option<Vec<(usize, BagSpec<'input>)>>
= rules:rule1()+ { Some(rules) }
/ "no other bags" { None }
/// Rule followed by an optional comma and space
rule rule1() -> (usize, BagSpec<'input>)
= r:rule0() ", "? { r }
/// A single rule
rule rule0() -> (usize, BagSpec<'input>)
= quantity:number() " " spec:bag_spec() { (quantity, spec) }
rule number() -> usize
= e:$(['0'..='9']+) { e.parse().unwrap() }
/// A sequence of non-whitespace characters
rule name() -> &'input str
= $((!whitespace()[_])*)
/// Spaces, tabs, CR and LF
rule whitespace()
= [' ' | '\t' | '\r' | '\n']
}
}
parser::root(input, &mut rules).unwrap();
rules
}
现在,dbg!()
会有相当详细的输出 —— 也许我们可以模仿并复制输入的格式,以便查看?
use std::fmt;
struct FormattedRules<'a>(Rules<'a>);
impl fmt::Display for FormattedRules<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for (k, vv) in &self.0 {
write!(f, "{} {} bags can contain ", k.0, k.1)?;
if vv.is_empty() {
write!(f, "no other bags")?;
} else {
for (i, v) in vv.iter().enumerate() {
if i > 0 {
write!(f, ", ")?;
}
write!(
f,
"{} {} {} {}",
v.0,
v.1 .0,
v.1 .1,
if v.0 == 1 { "bag" } else { "bags" }
)?;
}
}
writeln!(f, ".")?;
}
Ok(())
}
}
fn main() {
let rules = parse_rules(include_str!("input.txt"));
print!("{}", FormattedRules(rules));
}
现在,为了将我们程序的输出与示例输入进行比较,我们必须对它进行排序 —— 传统的排序方法可以很好地实现:
$ cargo run --quiet | sort -n
bright white bags can contain 1 shiny gold bag.
dark olive bags can contain 3 faded blue bags, 4 dotted black bags.
dark orange bags can contain 3 bright white bags, 4 muted yellow bags.
light red bags can contain 1 bright white bag, 2 muted yellow bags.
muted yellow bags can contain 2 shiny gold bags, 9 faded blue bags.
shiny gold bags can contain 1 dark olive bag, 2 vibrant plum bags.
vibrant plum bags can contain 5 faded blue bags, 6 dotted black bags.
$ cat src/input.txt | sort -n
bright white bags contain 1 shiny gold bag.
dark olive bags contain 3 faded blue bags, 4 dotted black bags.
dark orange bags contain 3 bright white bags, 4 muted yellow bags.
dotted black bags contain no other bags.
faded blue bags contain no other bags.
light red bags contain 1 bright white bag, 2 muted yellow bags.
muted yellow bags contain 2 shiny gold bags, 9 faded blue bags.
shiny gold bags contain 1 dark olive bag, 2 vibrant plum bags.
vibrant plum bags contain 5 faded blue bags, 6 dotted black bags.
看起来不错!唯一的区别是我们不记得那些没有装其他袋子的袋子。现在这些都无关紧要,我们可以以后再调整方法。
回到问题上来,尽管这个数据结构是平的(展开的),但我们手头上有一张图:
这不是普通的图表,它是个有向无环图。
酷熊:哦!Dag,我挺喜欢 Dag 的
因此,如果我们想知道“什么颜色的袋子最终可以装一个闪亮金色袋子?”我们可以按图表走。对于图中的每个节点,我们只需按照箭头指示操作,直到:
- 找到“闪亮金色” —— 在这种情况下,表示它可以包含闪亮金色袋子
- 走出边缘,在这种情况下,不,它不能装闪亮金色袋子
我们以“浅红色”为例。如果我们跟随整个图表,我们会经过所有的边和节点:
并且这个子集确实包含“闪亮金色”,所以“浅红色”袋子最终包含“闪亮金色”袋子。
然而,如果我们从深橄榄色节点开始,我们只会遇到这些:
这些都不是“闪亮金色”。
现在我们只剩下最简单的部分 —— 把想法变成现实:
fn subgraph_contains(graph: &Rules<'_>, root: &(&str, &str), needle: &(&str, &str)) -> bool {
if let Some(neighbors) = graph.get_vec(root) {
for (_, neighbor) in neighbors {
if neighbor == needle || subgraph_contains(graph, neighbor, needle) {
return true;
}
}
}
false
}
酷熊:我们需要 if let
和 for
循环吗?我们不能只是使用迭代器方法吧?
好吧,那就这样吧:
fn subgraph_contains(graph: &Rules<'_>, root: &(&str, &str), needle: &(&str, &str)) -> bool {
graph
.get_vec(root)
.map(|v| {
v.iter().any(|(_, neighbor)| {
neighbor == needle || subgraph_contains(graph, neighbor, needle)
})
})
.unwrap_or_default()
}
酷熊:我们能不能把这个展开一些?
当然,展开后差不多是这样:
fn subgraph_contains(graph: &Rules<'_>, root: &(&str, &str), needle: &(&str, &str)) -> bool {
graph
.get_vec(root)
.unwrap_or(&Default::default())
.iter()
.any(|(_, neighbor)| neighbor == needle || subgraph_contains(graph, neighbor, needle))
}
酷熊:啊,这是有点笨 —— 难道担心它不分配,以防没有值关联到 root
节点?
Amos:呃,太迟了。
酷熊:好吧,但你能保证我们待会可以学到一个优化方法吗?
Amos:好,我保证!
无论如何,我们可以使用这样的函数:
fn main() {
let rules = parse_rules(include_str!("input.txt"));
let needle = &("shiny", "gold");
let colors_that_contain_shiny_gold: Vec<_> = rules
.keys()
// shiny gold bags are already shiny god, we're not interested
// in what they can contain (as per the example)
.filter(|&k| k != needle)
.filter(|&k| subgraph_contains(&rules, k, needle))
.collect();
println!("{:?}", colors_that_contain_shiny_gold);
}
我们会得到这样的结果:
$ cargo run --quiet
[("dark", "orange"), ("light", "red"), ("bright", "white"), ("muted", "yellow")]
它与问题描述中的示例相匹配:
根据规则,有以下情况:
- 明亮的白色袋子,它可以直接容纳闪亮的金色袋子
- 柔和的黄色袋子,它可以直接容纳闪亮的金色袋子,再加上一些其他袋子
- 深橙色的袋子,它可以装明亮的白色和柔和的黄色袋子,其中任何一个都可以装闪亮的金色袋子
- 浅红色的袋子,它可以装明亮的白色和柔和的黄色袋子,其中任何一个都可以装闪亮的金色袋子
酷熊:就这样结束了?
Amos:好吧。
我还想试试别的。当我们从所有节点开始遍历图时,我们会多次遍历相同的子图。
例如,从“浅红色”和从“深橙色”移动时,意味着访问“明亮的白色”、“柔和的黄色”、“褪色的蓝色”和“闪亮的金色”两次:
我们可以做出小小的改变,就能摆脱所有这些重复的工作
就像 gittup 一样,我们要让箭头上升,让事情变得更快。
假定我们的图表看起来是这样的:
现在我们的箭头或边,意味着“袋子 N 可以存储在那个袋子”。
如果我们思考“哪些袋子可以储存闪亮金的袋子?”我们只需要从“闪亮金”(shiny gold)开始,遍历整个子图。
酷熊:但是怎样才能让箭头往上呢?
其实很简单:
fn reverse_graph<'a>(graph: &Rules<'a>) -> Rules<'a> {
let mut reverse: Rules = Default::default();
for (&node, neighbors) in graph.iter_all() {
for &(quantity, neighbor) in neighbors {
reverse.insert(neighbor, (quantity, node));
}
}
reverse
}
酷熊:等等,你可以将它收集到一个 MultiMap
中吗?
Amos:噢,可以。
fn reverse_graph<'a>(graph: &Rules<'a>) -> Rules<'a> {
graph
.iter_all()
.map(|(&node, neighbors)| {
neighbors
.iter()
.map(move |&(quantity, neighbor)| (neighbor, (quantity, node)))
})
.flatten()
.collect()
}
酷熊:噢,数据展开了,这是最新的吗?
Amos:和这个世界一样历史悠久。我们需要它是因为 .iter_all()
返回 Iterator<Item = (tuple, Vec<tuple>)>
—— 因为它是一个 multimap,记得吗?
酷熊:太棒了!随后,我们构造一个遍历“子图”的函数,对吗?
Amos:是的,但是它应该返回什么呢?
酷熊:迭代器?
我们先从 Vec
开始:
fn walk_subgraph<'a>(graph: &Rules<'a>, root: &(&str, &str)) -> Vec<(&'a str, &'a str)> {
let mut res: Vec<_> = Default::default();
if let Some(neighbors) = graph.get_vec(root) {
for &(_quantity, neighbor) in neighbors {
res.push(neighbor);
res.extend(walk_subgraph(graph, &neighbor));
}
}
res
}
看看我们能得到什么执行结果:
fn main() {
let rules = parse_rules(include_str!("input.txt"));
let rev_rules = reverse_graph(&rules);
let colors_that_contain_shiny_gold = walk_subgraph(&rev_rules, &("shiny", "gold"));
println!("{:?}", colors_that_contain_shiny_gold);
}
$ cargo run --quiet
[("bright", "white"), ("light", "red"), ("dark", "orange"), ("muted", "yellow"), ("light", "red"), ("dark", "orange")]
酷熊:嘿! 有重复的!
事实上,如果我们天真地从“闪亮金”开始遍历图表,我们将不止一次地访问某些节点。
我们可以收集到一个 HashSet
中,或者用一些其他方式去重 —— 现在让我们考虑一下,不要在每次遍历一个子图时都分配一个 Vec。
可以选择 &mut Vec
,这是完全合理的:
fn walk_subgraph1<'a>(graph: &Rules<'a>, root: &(&str, &str), res: &mut Vec<(&'a str, &'a str)>) {
if let Some(neighbors) = graph.get_vec(root) {
for &(_quantity, neighbor) in neighbors {
res.push(neighbor);
walk_subgraph1(graph, &neighbor, res);
}
}
}
fn main() {
let rules = parse_rules(include_str!("input.txt"));
let rev_rules = reverse_graph(&rules);
let mut colors_that_contain_shiny_gold = Default::default();
walk_subgraph1(
&rev_rules,
&("shiny", "gold"),
&mut colors_that_contain_shiny_gold,
);
println!("{:?}", colors_that_contain_shiny_gold);
}
这给了我们完全相同的结果,但分配更少。同样,这是一个合法的技术,我们不用太担心借用检查器。
当然,现在我们不能收集到一个 HashSet
来去重。我们一开始根本不打算这么做,我们只需要通过计数就能实现!
因此,让我们尝试创建一个返回迭代器的版本。我们将要遇到的问题是,我们的图可以是无限大的,所以我们的迭代器类型,如果不加以调整,也将是无限大的 —— 我在 2019 年 5 月谈到过这一点。
酷熊:是啊,那时候你的文章都是在喝咖啡休息时间里写的。
这篇文章的 tl; dr 是: Box
是你的朋友(Box
is your friend),所以,废话不多说:
fn walk_subgraph2<'iter, 'elems: 'iter>(
graph: &'iter Rules<'elems>,
root: &(&'iter str, &'iter str),
) -> Box<dyn Iterator<Item = (&'elems str, &'elems str)> + 'iter> {
Box::new(
graph
.get_vec(root)
.into_iter()
.flatten()
.map(move |&(_, neighbor)| {
std::iter::once(neighbor).chain(walk_subgraph2(graph, &neighbor))
})
.flatten(),
)
}
酷熊:快速问答!我理解 into_iter()
—— 我们有 &Vec<V>
,把它转换成 Iterator<Item = V>
。但是 flatten()
是怎么处理?
Amos:实际上... 我们没有 &Vec<T>
。
酷熊:没有吗?
pub fn get_vec<Q: ?Sized>(&self, k: &Q) -> Option<&Vec<V>>
where K: Borrow<Q>,
Q: Eq + Hash
酷熊:哦,我们有一个 Option<&Vec<V>>
,它是可迭代的?
Amos:当然!它是一个迭代器,如果它是 Some
的,它会得到一个元素; 如果它是 None
,则没有元素。
酷熊:太棒了,而 flatten()
给我们返回一个迭代器,它要么生成元素的 Vec
,要么什么都不生成?OHHHhhhh 这就是你提到的可以用更好的东西替换 .unwrap_or(&Default::default())
的技巧吗?
Amos:没错,😎!
现在我们有了一个迭代器,我们可以使用 itertools
中的另一个好东西,而且我已经三次检查它是否在标准库中(标准库中没有):
$ cargo add itertools
Adding itertools v0.9.0 to dependencies
use itertools::Itertools;
fn main() {
let rules = parse_rules(include_str!("input.txt"));
let rev_rules = reverse_graph(&rules);
let needle = ("shiny", "gold");
let answer = walk_subgraph2(&rev_rules, &needle).unique().count();
println!("{} colors can contain {:?} bags", answer, needle);
}
$ cargo run --quiet
4 colors can contain ("shiny", "gold") bags
酷熊:哦,我看到你在那里做什么 —— &str
实现 Debug
trait,所以 (&str,&str)
也这样做。整洁!
但是 .unique()
是如何起作用呢?
Amos:它... 它收集到一个 HashSet
。但这是隐藏的!所以我们的代码很不错并且是功能有效的。
让我们用题目提供的输入,来运行它:
$ cargo run --quiet
103 colors can contain ("shiny", "gold") bags
酷熊:正确!
现在,我们要求计算你必须购买多少袋才能装进一个“闪亮金”袋子。我们终于可以利用这些数字了!
是时候使用另一个 walk_subgraph
方法了 —— 这个方法实际上包含了数量。
只需要做几项改变:
fn walk_subgraph3<'iter, 'elems: 'iter>(
graph: &'iter Rules<'elems>,
root: &(&'iter str, &'iter str),
// 👇 we're now returning the quantity as well
) -> Box<dyn Iterator<Item = (usize, (&'elems str, &'elems str))> + 'iter> {
Box::new(
graph
.get_vec(root)
.into_iter()
.flatten()
// 👇 this is even simpler, since we're not destructing the tuple anymore
.map(move |&n| std::iter::once(n).chain(walk_subgraph3(graph, &n.1)))
.flatten(),
)
}
我们将使用带有向下箭头的图形,所以我们不再需要逆转规则。而且,我们不用再处理 .unique()
。
如果“闪亮金”袋子同时包含“暗红色”和“浅红色”袋子,并且这两种袋子都包含“暗青色”袋子 —— 我们必须对“暗青色”袋子计数两次。
因此,我们的 main
函数更简单:
fn main() {
let rules = parse_rules(include_str!("input.txt"));
let root = ("shiny", "gold");
let answer = walk_subgraph3(&rules, &root).count();
println!("you must buy {} bags to fill a {:?} bag", answer, root);
}
$ cargo run --quiet
you must buy 63 bags to fill a ("shiny", "gold") bag
酷熊:这不是正确答案!别担心,我们中最优秀的人都会遇到错误情况,不过无法“连胜”了。
哦,对了,我太兴奋了,忘了我们需要把东西放在一起!
如果每个“闪亮金”袋子含有两个“深红色”袋子,而那些袋子有三个“浅品红色”(light magenta)袋子,那么我们就有 2 * 3 = 6
个“浅品红色”袋子。
我想我们不可能有一个像 walk_subgraph3
这样通用的方法 —— 创建一个自定义的方法。
fn bag_quantities<'iter, 'elems: 'iter>(
graph: &'iter Rules<'elems>,
root: &(&'iter str, &'iter str),
) -> Box<dyn Iterator<Item = usize> + 'iter> {
Box::new(
graph
.get_vec(root)
.into_iter()
.flatten()
.map(move |&(qt, n)| {
std::iter::once(qt).chain(bag_quantities(graph, &n).map(move |x| x * qt))
})
.flatten(),
)
}
fn main() {
let rules = parse_rules(include_str!("input.txt"));
let root = ("shiny", "gold");
let answer: usize = bag_quantities(&rules, &root).sum();
println!("you must buy {} bags to fill a {:?} bag", answer, root);
}
$ cargo run --quiet
you must buy 1469 bags to fill a ("shiny", "gold") bag
酷熊:这才像话!
Amos:我知道 63 很少。
下次见,保重!