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

193 阅读6分钟

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

Advent of Code 2020 结束,我们还有很长一段路要走。第六天的时间到了。

这里的问题描述有点做“作”,就像之前那些题目一样,但这并不能阻止我们。

题目的输入如下:

abc

a
b
c

ab
ac

a
a
a
a

b

每一行代表一个人,“人群”用空白行分隔。

有 26 个可能的字母,即 az

酷熊:我们要声明一个有 26 种变体的枚举吗?

Amos:emmm,用一个字节就够了

因此,第一部分是收集组(group)中的每个人填写的答案 —— 我们甚至不需要解析器!(译注:如果不明白题意,可以看下原题的详细介绍。)

use std::collections::HashSet;

fn main() {
    let answers: Vec<_> = include_str!("input.txt")
        .split("\n\n")
        .map(|group| {
            group
                .lines()
                .map(|line| {
                    let mut set = HashSet::new();
                    for &b in line.as_bytes() {
                        set.insert(b);
                    }
                    set
                })
                .collect::<Vec<_>>()
        })
        .collect();

    dbg!(&answers[0..3]);
}
$ cargo run --quiet
[src/main.rs:20] &answers[0..2] = [
    [
        {
            98,
        },
        {
            98,
        },
        {
            98,
        },
        {
            98,
        },
    ],
    [
        {
            120,
        },
        {
            120,
            102,
            106,
            107,
        },
        {
            120,
            98,
        },
    ],
]

酷熊:输出感觉不太可读 - 难道我们不能使用一个新类型,这样我们就可以输出自己的调试信息?

use std::{collections::HashSet, fmt};

pub struct Answers(HashSet<u8>);

impl fmt::Debug for Answers {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        for &answer in &self.0 {
            write!(f, "{}", answer as char)?;
        }
        Ok(())
    }
}

fn main() {
    let answers: Vec<_> = include_str!("input.txt")
        .split("\n\n")
        .map(|group| {
            group
                .lines()
                .map(|line| {
                    let mut set = HashSet::new();
                    for &b in line.as_bytes() {
                        set.insert(b);
                    }
                    Answers(set)
                })
                .collect::<Vec<_>>()
        })
        .collect();

    dbg!(&answers[0..2]);
}
$ cargo run --quiet
[src/main.rs:31] &answers[0..2] = [
    [
        b,
        b,
        b,
        b,
    ],
    [
        x,
        xkfj,
        bx,
    ],
]

酷熊:好! 我们可以比较题目输入来确定吗?

b
b
b
b

x
xfkj
xb

酷熊:太棒了!

现在我们要做的就是,为每组计算答案的集合的联合体(译注:多个集合的合并后的集合)。

std::collection::HashSet 上有一个 union 方法:

pub fn union<'a>(&'a self, other: &'a HashSet<T, S>) -> Union<'a, T, S>

但不幸的是,它计算的是两个 HashSet 的联合体,并返回一个迭代器。

酷熊:说到迭代器... 我们为什么要这样构造 HashSet

let mut set = HashSet::new();
for &b in line.as_bytes() {
    set.insert(b);
}
Answers(set)

酷熊:我们也许可以将其收集到一个 Vec 中... 而非收集到一个 HashSet

Amos:当然可以!

let answers: Vec<_> = include_str!("input.txt")
    .split("\n\n")
    .map(|group| {
        group
            .lines()
            .map(|line| Answers(line.as_bytes().iter().copied().collect()))
            .collect::<Vec<_>>()
    })
    .collect();

酷熊:很好!对了,你刚说什么来着?

标准库提供了计算HashSet 的并集并返回一个迭代器的方法。说实话,这不是什么大问题 —— 我们可以将所有迭代器链接起来,然后将得到的迭代器再合并到一个单独的 HashSet 中。

不过我们换个方式,试试 im crate

它提供了一组不可变的数据结构,包括 HashSet

$ cargo add im
      Adding im v15.0.0 to dependencies

现在我们只要把“引入”改成如下形式:

use im::HashSet;
use std::fmt;

你看,一样可以执行:

$ cargo run --quiet
[src/main.rs:26] &answers[0..2] = [
    [
        b,
        b,
        b,
        b,
    ],
    [
        x,
        kxjf,
        bx,
    ],
]

酷熊:呃,这里的不可变是指什么?

Amos:啥?

酷熊:好了好了,我们还是基于 HashSet 来实现吧:

.map(|line| Answers(line.as_bytes().iter().copied().collect()))

酷熊:这意味着它将创建了一个空的 im::HashSet,然后将元素逐个添加到其中,对吗?

Amos:是的。

酷熊:那不是可变吗?

Amos:我们看下 im 的文档。

所有这些数据结构都支持写时复制的可变,这意味着当一个数据结构只有一个地方在使用,你可以就地更新它,而无需在修改数据结构之前复制一个数据结构(这比不可变操作快了一个数量级,几乎和 std::Collection 的可变数据结构一样快)。

酷熊:聪明!

Amos:是的,很聪明! 这个库的作者和我们最近研究smartstring 是同一个作者。

酷熊:但是,如果一个对象不是 HashSet 的唯一持有者,会发生什么情况呢?比如,把一个对象 clone 一下,给其他地方使用。

Amos:那这样就会发生写时复制(Cow)了!

由于 Rc 的引用计数,我们能够确定一个数据结构中的一个节点是否与其他数据结构共享,或者在适当的位置对其进行安全地修改。当它被共享时,我们将在修改它之前自动创建一个副本。

这样做的结果是,把拷贝一个数据结构变成了一个延迟操作: 首次拷贝是即时的,当你修改克隆的数据结构时,它只会在你修改它们的地方发生拷贝,所以如果你修改了整个结构,你最终会完成一个完整的拷贝。

  • 酷熊热辣小贴士
  • 注意,im crate 中的所有数据结构都使用 Arc,这意味着它们是线程安全的,但是,这样大约有“20-25%”的性能损失。
  • 有一个使用 Rcim-rc 变体,它具有更好的性能,但是数据结构不是线程安全的(它们既不符合 Send 也不符合 Sync 约束)。

无论如何, im 有一个我喜欢的 union 方法:

pub fn union(self, other: Self) -> Self

等等,不,我喜欢 unions 方法:

pub fn unions<I>(i: I) -> Self
where
    I: IntoIterator<Item = Self>,
    S: Default, 

酷熊:所以我们只是... 我们只是使用 im::HashSet::unionsmap 我们的 groups?

Amos:是的!

main 的内容如下:

fn main() {
    let answers: Vec<_> = include_str!("input.txt")
        .split("\n\n")
        .map(|group| {
            group
                .lines()
                .map(|line| Answers(line.as_bytes().iter().copied().collect()))
                .collect::<Vec<_>>()
        })
        .collect();

    let group_answers: Vec<_> = answers
        .into_iter()
        .map(|group| Answers(HashSet::unions(group.into_iter().map(|x| x.0))))
        .collect();

    dbg!(&group_answers[0..5]);
}

或者,如果我们跳过中间步骤:

fn main() {
    let group_answers: Vec<_> = include_str!("input.txt")
        .split("\n\n")
        .map(|group| {
            Answers(HashSet::unions(
                group
                    .lines()
                    .map(|line| line.as_bytes().iter().copied().collect()),
            ))
        })
        .collect();

    dbg!(&group_answers[0..5]);
}

酷熊:太棒了! 还有什么问题来着?

每个组(group)都有一定数量的“yes”答案(“yes”在集合中表示为一个字母)。

题目要求计算所有小组回答问题的总和。

酷熊:好! 我们可以使用 .sum()!我们先引入 itertools 和..

Amos:实际上,正如很多读者指出的那样,标准库中的 .sum() 也是可用的。

酷熊:那更好了!

我们可以跳过一些中间步骤:

fn main() {
    let answer: usize = include_str!("input.txt")
        .split("\n\n")
        .map(|group| {
            HashSet::<u8>::unions(
                group
                    .lines()
                    .map(|line| line.as_bytes().iter().copied().collect()),
            )
            .len()
        })
        .sum();

    dbg!(answer);
}

然后得到第一部分问题的答案:

$ cargo run --quiet
[src/main.rs:28] answer = 6534

第二部分

在接下来的部分中,the Advent of Code 将我们带入了旧的转换。

我们不需要计算小组中每个成员的答案的 unions,而是取交集 —— 也就是说,只保留小组中每个人都同意的答案。

酷熊:im 中是否有 intersections 相关方法?

Amos:很遗憾,没有... 但我们可以尝试 reduce

酷熊:你是说 fold 吗?

Amos:没错,就是它!

fn main() {
    let answer: usize = include_str!("input.txt")
        .split("\n\n")
        .map(|group| {
            group
                .lines()
                .map(|line| line.as_bytes().iter().copied().collect())
                .fold(HashSet::<u8>::new(), |acc, x| acc.intersection(x))
                .len()
        })
        .sum();

    dbg!(answer);
}
$ cargo run --quiet
[src/main.rs:27] answer = 0

酷熊:有点不对劲。

Amos:我不太确定有什么问题

酷熊:哦! HashSet::new() 返回一个空集!

Amos:啊,空集与其他集合的交集就是。。

酷熊:空集合!

Amos:好的,我们可以通过分离第一个元素来修复这个问题,并使用它作为 fold() 调用的初始值。

酷熊:或者只是让初始化时的 HashSet 包含所有可能的答案?

Amos:这主意不错。

fn main() {
    let init: HashSet<u8> = (b'a'..=b'z').collect();

    let answer: usize = include_str!("input.txt")
        .split("\n\n")
        .map(|group| {
            group
                .lines()
                .map(|line| line.as_bytes().iter().copied().collect())
                .fold(init.clone(), |acc, x| acc.intersection(x))
                .len()
        })
        .sum();

    dbg!(answer);
}

Amos:你知道吗? 这样做效果更好,因为 init.clone() 基本上是免费的!

酷熊:的确如此,因为它是不可变的?

Amos:没错!

酷熊:所以使用 im 是有特殊原因的?

Amos:都有一点,问题最后都会解决的。

酷熊:难道没有一种 fold 的变体,只是给第一个元素设置 init 吗?

Amos:事实证明,! 只是不在标准库中。

$ cargo add itertools
      Adding itertools v0.9.0 to dependencies

我们的解决方案是:

use im::HashSet;
use itertools::Itertools;

fn main() {
    let answer: usize = include_str!("input.txt")
        .split("\n\n")
        .map(|group| {
            group
                .lines()
                .map(|line| line.as_bytes().iter().copied().collect())
                .fold1(|acc: HashSet<u8>, x| acc.intersection(x))
                .unwrap_or_default()
                .len()
        })
        .sum();

    dbg!(answer);
}

酷熊:等等,为什么用 unwrap_or_default

Amos:嗯。。如果我们 fold1 一个 0 项的集合会怎么样呢? 那么开始就没有 init 值了!

酷熊:啊,对!

$ cargo run --quiet
[src/main.rs:29] answer = 3402

酷熊:又对了,太有趣了!

Amos:下次见,熊!

酷熊:再见!