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

85 阅读9分钟

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

新的一天,新的 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 letfor 循环吗?我们不能只是使用迭代器方法吧?

好吧,那就这样吧:

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 很少。

下次见,保重!