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

169 阅读5分钟

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

第 12 天了。

在这个问题中,我们有一艘船,并且有导航说明:

  • 动作 N 意思是向 north 移动指定值的距离
  • 动作 S 意思是向 south 移动指定值的距离
  • 动作 E 意思是向 east 移动指定值的距离
  • 动作 W 意思是向 west 移动指定值的距离
  • 动作 L 意思是向 left 移动指定值的角度
  • 动作 R 意思是向 right 移动指定值的角度
  • 动作 F 意思是根据船的朝向,向 forward 方向移动指定值的距离

看到这些,我一开始有点困惑 —— 如果船面朝东,向北移动会改变方向吗?答案是否定的 —— 这是一艘来自未来的飞船,可以横向移动。

下面是一个导航说明的例子:

F10
N3
F7
R90
F11

这里意思是: 前进 10,向北移动 3,前进 7,向右转 90 度,前进 11。

值得注意的是,L 和 R 指令后面总是跟着 90 的倍数(四分之一角度)。

第一部分我们需要回答的问题是 —— 飞船的起始位置(黄色部分)和最终位置(蓝色部分)之间的曼哈顿距离是多少?

上方的例子中,结果是 25:

那么,我们来建模吧,先定义一些类型!

我们需要一个二维的 vector,来表示位置和运动:

#[derive(Clone, Copy, PartialEq, Eq, Debug)]
struct Vec2 {
    x: isize,
    y: isize,
}

我们希望至少能把该类型的值相加。

酷熊:哦,我们要再次实现 Add 吗?

Amos:当然,我们可以这么做:

impl std::ops::Add for Vec2 {
    type Output = Self;

    fn add(self, rhs: Self) -> Self::Output {
        Self {
            x: self.x + rhs.x,
            y: self.y + rhs.y,
        }
    }
}

Amos:但是这种代码看起来非常机械化 —— 就像自动生成的... 只要我们能找到一个板条箱呢?

酷熊:看我找到了什么

我们尝试 derive_more

$ cargo add derive_more --no-default-features --features add
      Adding derive_more v0.99.11 to dependencies with features: ["add"]

酷熊:等等,cargo-edit 可以做到吗?

Amos:是的,它可以! 我们看一下 Cargo.toml 生成的 [dependencies] 部分:

[dependencies]
derive_more = { version = "0.99.11", features = ["add"], default-features = false }

现在我们可以派生 Add

use derive_more::*;

//                                          👇 new!
#[derive(Clone, Copy, PartialEq, Eq, Debug, Add)]
struct Vec2 {
    x: isize,
    y: isize,
}

#[test]
fn vec2_add() {
    let a = Vec2 { x: 3, y: 8 };
    let b = Vec2 { x: 2, y: 10 };
    assert_eq!(a + b, Vec2 { x: 5, y: 18 });
}

现在,通常我会在这里展示 shell 会话中的 cargo test,但事实是,我只想运行一个测试,怎么办?我使用 vscodeRA 提供的“Run Test”命令:

结果如下:

> Executing task: cargo test --package day12 --bin day12 -- vec2_add --exact --nocapture <

   Compiling day12 v0.1.0 (/home/amos/ftl/aoc2020/day12)
    Finished test [unoptimized + debuginfo] target(s) in 0.47s
     Running target/debug/deps/day12-35a7c5988354f23f

running 1 test
test vec2_add ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out


Terminal will be reused by tasks, press any key to close it.

从现在开始,在这个系列中,我将只在测试不通过时显示测试结果。可以吗?

酷熊:很合理!

既然这样,我们也可以派生 Sub,然后加上一个 manhattan 方法,计算两个坐标的绝对值之和:

//                                               👇 new!
#[derive(Clone, Copy, PartialEq, Eq, Debug, Add, Sub)]
struct Vec2 {
    x: isize,
    y: isize,
}

impl Vec2 {
    // Vec2 is copy, so it's fine to take `self`
    fn manhattan(self) -> usize {
        (self.x.abs() + self.y.abs()) as _
    }
}

现在我们可以编写一个测试来检查我们是否已经可以计算出这个例子的正确答案!

#[test]
fn manhattan_example() {
    let start = Vec2 { x: 0, y: 0 };
    let end = Vec2 { x: 17, y: -8 };
    assert_eq!((end - start).manhattan(), 25);
}

接下来 —— 仅仅一个 Vec2 不足以描述我们飞船的状态。我们还要一个方向!

酷熊:好吧,四个可能的方向,所以似乎枚举是可行的?

Amos:很对!

#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Direction {
    East,
    South,
    West,
    North,
}

我们还应该决定,根据 2D 坐标 —— 哪个向量与对应方向相关联?

我们有几个选择: 我很确定我们希望 East(1,0)—— 但是我们可以选择 North(0,1)(0,-1),分别取决于 North 方向上是 +Y 还是 -Y

在这里,我们将选择 North(0,1)(这意味着在概念上我们的原点是“左下”)并坚持使用它。这个问题是经过精心设计的,因此这一点并不重要 —— 不管我们使用什么坐标系,曼哈顿的距离都是一样的。干得好,Advent of Code 开始了!

impl Direction {
    fn vec(self) -> Vec2 {
        match self {
            Direction::East => Vec2 { x: 1, y: 0 },
            Direction::South => Vec2 { x: 0, y: -1 },
            Direction::West => Vec2 { x: -1, y: 0 },
            Direction::North => Vec2 { x: 0, y: 1 },
        }
    }
}

现在我们有一个小问题 —— 船如何转身?同样,我们有两个选择,关于什么是“角增量(angle delta)”的意思。问题陈述说“R”意味着转向“右”,但是取决于你所面对的地方,可以想象这样: 一艘朝北转向右的船当然会面对右方。但是朝南的船呢?现在应该快速左转吗?还是右转?

给出的例子消除了我们的歧义。当我们的船面向东向右转时,结果是面向南,所以: “右转”意味着“顺时针”转向。

还没有结束 —— 角度有多个单位,包括弧度和度(这里的例子使用的是度) ,我们还要决定“角度 0”是什么意思。作为一个孩子,对我来说,“角度 0”(angle 0)比“北”这种描述更有意义,但在三角学( Trigonometry)中,“角度 0”是“东”。

酷熊:这就是你为什么把枚举变体按照特定顺序排列的原因吗?

Amos:是的!

现在,我们怎么转弯?如果我们可以将“angle delta”(delta 意为“差异”)应用于一个方向并得到一个新的方向,那将是非常方便的。

让我们定义一个新的类型:

/// Represents an angle, in multiples of 90°
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
struct AngleDelta(isize);

现在我们可以实现 Direction + AngleDelta 的操作了:

impl std::ops::Add<AngleDelta> for Direction {
    type Output = Self;

    fn add(self, rhs: AngleDelta) -> Self::Output {
        todo!("???")
    }
}
  • 酷熊热辣小贴士 在本系列中,这是我们第一次在两种不同类型之间实现 Add。以前,它总是在同一类型之间实现,因为 Rhs 类型参数默认是 Self(在 Add 上):
pub trait Add<Rhs = Self> {
    /// The resulting type after applying the `+` operator.
    #[stable(feature = "rust1", since = "1.0.0")]
    type Output;

    /// Performs the `+` operation.
    ///
    /// # Example
    ///
    /// ```
    /// assert_eq!(12 + 1, 13);
    /// ```
    #[must_use]
    #[stable(feature = "rust1", since = "1.0.0")]
    fn add(self, rhs: Rhs) -> Self::Output;
}

但是我们应该如何实现这个方法呢? 如果角度是 90 度,那么:

  • 如果我们面向东方,转向后我们面向南方
  • 如果我们面对的是南方,转向后我们现在面对的是西方
  • 如果我们面朝西,转向后现在我们面朝北
  • 如果我们面朝北,转向后我们现在面朝东

但是角度也可以是 180,或者 270,或者 360,或者 -90!有很多情况要处理。

为了简化我们的工作,我们将显式地为 enum 定义一个特殊表示,并简单地表示为 0..=3 范围内的数值:

#[derive(Clone, Copy, PartialEq, Eq, Debug)]
#[repr(u8)]
enum Direction {
    East = 0,
    South = 1,
    West = 2,
    North = 3,
}

现在 Direction 是一样的大小,都是一个 u8 —— 它具有超级枚举的能力,它允许我们写 match 表达式时,处理 4 种 case,因为有一个 Direction 的值不在 0..=3 中,是未定义行为。

我们可以很容易地转换 Directionisize,因为任何 Direction 始终是一个有效的 isize

impl Into<isize> for Direction {
    fn into(self) -> isize {
        self as _
    }
}

反过来也不是那么简单 —— 如果我们有一个值为 6 的 isize —— 那就不是合法的 Direction!换句话说,这是一个容易出错的转换,对此我们可以使用 TryFrom trait:

impl std::convert::TryFrom<isize> for Direction {
    type Error = &'static str;

    fn try_from(value: isize) -> Result<Self, Self::Error> {
        if (0..=3).contains(&value) {
            Ok(unsafe { std::mem::transmute(value as u8) })
        } else {
            Err("direction out of bounds!")
        }
    }
}

酷熊:哦,unsafe 的代码,那不是很危险吗?

Amos:是啊! 如果我们用 0..=4 代替,我们可以创造出非法的 Direction 值,且造成未定义行为。

酷熊:那就试试吧!

#[test]
fn direction_try_from() {
    use std::convert::TryFrom;

    assert_eq!(
        <Direction as TryFrom<isize>>::try_from(0).unwrap(),
        Direction::East
    );
    assert_eq!(
        <Direction as TryFrom<isize>>::try_from(2).unwrap(),
        Direction::West
    );
    assert!(<Direction as TryFrom<isize>>::try_from(-1).is_err(),);
    assert!(<Direction as TryFrom<isize>>::try_from(4).is_err(),);
}

通过了!现在,实现 Add 稍微容易一些。在第 3 部分中,我们与 modulos 进行了斗争,但是谢天谢地,有人指出我们可以使用 rem_euclide

所以我们要试试!

impl std::ops::Add<AngleDelta> for Direction {
    type Output = Self;

    fn add(self, rhs: AngleDelta) -> Self::Output {
        use std::convert::TryInto;

        let angle: isize = self.into();
        (angle + rhs.0).rem_euclid(4).try_into().unwrap()
    }
}

测试一下:

#[test]
fn test_direction_add() {
    // From example
    assert_eq!(Direction::East + AngleDelta(1), Direction::South);
    // Turning "left" (counter-clockwise)
    assert_eq!(Direction::East + AngleDelta(-1), Direction::North);
    // Doing a 360°
    assert_eq!(Direction::East + AngleDelta(4), Direction::East);
}

最后,这艘船的状态将由它的位置和方向来描述:

#[derive(Clone, Copy, PartialEq, Eq, Debug)]
struct ShipState {
    pos: Vec2,
    dir: Direction,
}

就这样,我们可以开始了!

酷熊:Amos,你是不是忘了什么?

Amos:什么?

酷熊:我们的解析器呢?

哦,对了! 我们的解析器。问题描述中列出了 7 个指令,但实际上我们只有 3 种不同的指令:

  • 朝一个固定的方向移动
  • 按给定的角度旋转
  • 向前移动(这取决于船的朝向)
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Instruction {
    /// Moves in given direction
    Move(Direction, isize),
    /// Turns
    Rotate(AngleDelta),
    /// Moves forward
    Advance(isize),
}

我们解析一下:

fn parse_instructions(input: &str) -> impl Iterator<Item = Instruction> + '_ {
    input.lines().map(|line| {
        let command = line.as_bytes()[0];
        // Safety: this will panic if `line` starts with multibyte character
        let number: isize = (&line[1..]).parse().unwrap();

        match command {
            b'N' => Instruction::Move(Direction::North, number),
            b'S' => Instruction::Move(Direction::South, number),
            b'E' => Instruction::Move(Direction::East, number),
            b'W' => Instruction::Move(Direction::West, number),
            b'L' => Instruction::Rotate(AngleDelta(-number / 90)),
            b'R' => Instruction::Rotate(AngleDelta(number / 90)),
            b'F' => Instruction::Advance(number),
            c => panic!("unknown instruction {}", c as char),
        }
    })
}

fn main() {
    for ins in parse_instructions(include_str!("input.txt")) {
        println!("{:?}", ins);
    }
}
$ cargo run --quiet
Advance(10)
Move(North, 3)
Advance(7)
Rotate(AngleDelta(1))
Advance(11)

我们比较一下我们最初的说明:

F10
N3
F7
R90
F11

看起来不错! 现在我们需要一种方法来将我们的指令应用到我们船的当前状态上。

酷熊:写一个方法?

Amos:或者我们可以滥用运算符重载?

但是在此之前,我们有一种方法可以将 Direction 转换成 Vec2,但是我们经常将几个单元转换成一个方向,而不仅仅是一个单元 —— 所以如果我们可以用一个 isize 来乘以一个 Vec2,那将会非常简洁:

impl std::ops::Mul<isize> for Vec2 {
    type Output = Self;

    fn mul(self, rhs: isize) -> Self::Output {
        Self {
            x: self.x * rhs,
            y: self.y * rhs,
        }
    }
}

现在我们可以执行一个复杂的操作... ShipState + Instruction

impl std::ops::Add<Instruction> for ShipState {
    type Output = Self;

    fn add(self, rhs: Instruction) -> Self::Output {
        match rhs {
            Instruction::Move(dir, units) => Self {
                pos: self.pos + dir.vec() * units,
                ..self
            },
            Instruction::Rotate(delta) => Self {
                dir: self.dir + delta,
                ..self
            },
            Instruction::Advance(units) => Self {
                pos: self.pos + self.dir.vec() * units,
                ..self
            },
        }
    }
}

酷熊:我觉得我们为第 1 部分编写的代码有点太多了,但是最后一个 impl 是点睛之笔!

然后,我们就可以执行 fold。太合适了!我们有一个初始状态,并且我们一直对它进行修改,从迭代器生成的每条指令中进行修改。

fn main() {
    let start = ShipState {
        dir: Direction::East,
        pos: Vec2 { x: 0, y: 0 },
    };
    let end = parse_instructions(include_str!("input.txt")).fold(start, |state, ins| state + ins);

    dbg!(start, end, (end.pos - start.pos).manhattan());
}

酷熊:噢,太美了

我们尝试使用示例值:

$ cargo run --quiet
[src/main.rs:185] start = ShipState {
    pos: Vec2 {
        x: 0,
        y: 0,
    },
    dir: East,
}
[src/main.rs:185] end = ShipState {
    pos: Vec2 {
        x: 17,
        y: -8,
    },
    dir: South,
}
[src/main.rs:185] (end.pos - start.pos).manhattan() = 25

现在使用题目提供的输入:

$ cargo run --quiet
[src/main.rs:185] start = ShipState {
    pos: Vec2 {
        x: 0,
        y: 0,
    },
    dir: East,
}
[src/main.rs:185] end = ShipState {
    pos: Vec2 {
        x: 1615,
        y: -655,
    },
    dir: East,
}
[src/main.rs:185] (end.pos - start.pos).manhattan() = 2270

酷熊:正确! 这是一个全新连胜的开始

第二部分

第二部分,像往常一样,颠覆了一切。啊,旧的 Advent of Code 降临。现在有一个“航路点”(waypoint),相对于船的位置,它围绕着船旋转。现在向前移动意味着向航路点移动,但航路点也随着船移动..

所以基本上,我们现在的状态是这样的:

#[derive(Clone, Copy, PartialEq, Eq, Debug)]
struct ShipState {
    pos: Vec2,
    dir: Direction,
    waypoint: Vec2,
}

waypoint 实际上是第二种方向,其幅度(或振幅,或长度)可以大于 1。

在这里,当向前移动(朝着路标点)时,船实际上移动了 sqrt(8^2 + 16^2) = ~17.9 个单位的距离。

此外,NSEW 指令现在向北,南,东或西移动路标点。

酷熊:所以这根本不是未来的飞船?

Amos:看来不是!

为了应用这些规则,我们需要一个新的操作: 通过给定的 AngleDelta 旋转 Vec2

它相对比较简单——首先,我们将使用 rem_euclid 将任何可能的 AngleDelta 值映射到 0..=3 的范围,然后:

  • 如果角度为 0,我们不需要修改任何东西
  • 如果角度是 1,我们将它设置为 (y,-x)
  • 如果角度是 2,我们将它设置为 (- x,-y)
  • 如果角度是 3,我们将它设置为 (- y,x)

实现它:

impl Vec2 {
    fn rotate(self, d: AngleDelta) -> Self {
        let Self { x, y } = self;
        match d.0.rem_euclid(4) {
            0 => Self { x, y },
            1 => Self { x: y, y: -x },
            2 => Self { x: -x, y: -y },
            3 => Self { x: -y, y: x },
            _ => unreachable!(),
        }
    }
}

再加上一个测试:

#[test]
fn test_rotate() {
    let v = Vec2 { x: 3, y: 1 };
    assert_eq!(v.rotate(AngleDelta(0)), v);
    assert_eq!(v.rotate(AngleDelta(4)), v);
    assert_eq!(v.rotate(AngleDelta(-4)), v);

    assert_eq!(v.rotate(AngleDelta(1)), Vec2 { x: 1, y: -3 });
    assert_eq!(v.rotate(AngleDelta(2)), Vec2 { x: -3, y: -1 });
    assert_eq!(v.rotate(AngleDelta(3)), Vec2 { x: -1, y: 3 });
}

现在,我们准备好大干一场了!指令解析还是一样,只是 <ShipState as Add>::<Instruction, Output = Self> 变了:

impl std::ops::Add<Instruction> for ShipState {
    type Output = Self;

    fn add(self, rhs: Instruction) -> Self::Output {
        match rhs {
            // moves waypoint
            Instruction::Move(dir, units) => Self {
                waypoint: self.waypoint + dir.vec() * units,
                ..self
            },
            // rotates waypoint (relative to ship)
            Instruction::Rotate(delta) => Self {
                waypoint: self.waypoint.rotate(delta),
                ..self
            },
            // advance towards waypoint
            Instruction::Advance(units) => Self {
                pos: self.pos + self.waypoint * units,
                ..self
            },
        }
    }
}

我们试试新版本的简短示例输入:

F10
N3
F7
R90
F11
$ cargo run --quiet
[src/main.rs:212] start = ShipState {
    pos: Vec2 {
        x: 0,
        y: 0,
    },
    dir: East,
    waypoint: Vec2 {
        x: 10,
        y: 1,
    },
}
[src/main.rs:212] end = ShipState {
    pos: Vec2 {
        x: 214,
        y: -72,
    },
    dir: East,
    waypoint: Vec2 {
        x: 4,
        y: -10,
    },
}
[src/main.rs:212] (end.pos - start.pos).manhattan() = 286

酷熊:没问题!

现在是题目提供的输入:

$ cargo run --quiet
[src/main.rs:212] start = ShipState {
    pos: Vec2 {
        x: 0,
        y: 0,
    },
    dir: East,
    waypoint: Vec2 {
        x: 10,
        y: 1,
    },
}
[src/main.rs:212] end = ShipState {
    pos: Vec2 {
        x: 68489,
        y: 70180,
    },
    dir: East,
    waypoint: Vec2 {
        x: 33,
        y: 50,
    },
}
[src/main.rs:212] (end.pos - start.pos).manhattan() = 138669

没错!

酷熊:连胜,连胜,连胜!

Amos:恐怕有点冷。

酷熊:🙄

下次见,保重!