こんにちは、Progateのキリル(@virtualkirill)です。
本記事は Progate Advent Calendar 16日目の記事になります。
ビンゴというゲームをご存知ですか?知っている人が多いと思いますが、5x5 のカード(ボード)に並べたマスに書かれた番号と、順番に発表される番号がマッチしたら、そのマスをマークして、一列(縦あるいは横)がマークできたら勝ちというシンプルなルールです。
今回は、発表用の番号と、複数のカードを与えるとどのカードが先に勝つかを計算するプログラムを作っていきます。これは毎年行われるAdvent Of Codeのパズルの一つで、このパズルの実装に特に向いている Rust を選びました。
インプット
インプットはこんな感じです。
7,4,9,5,11,17,23,2,0,14,21,24,10,16,13,6,15,25,12,22,18,20,8,19,3,26,1 22 13 17 11 0 8 2 23 4 24 21 9 14 16 7 6 10 3 18 5 1 12 20 15 19 3 15 0 2 22 9 18 13 17 5 19 8 7 25 23 20 11 10 24 4 14 21 16 12 6 14 21 17 24 4 10 16 15 9 19 18 8 23 26 20 22 11 13 6 5 2 0 12 3 7
1 行目は発表される番号で、その下にはスペースで区切られたボードがたくさんあります。
アプローチ
まずは、インプットを発表番号とそれ以外にわけて、各ボードをu8
の配列としてパースします。u8
にしたのは、どの番号も 100
を超えることなく 8
ビットに収まりそうだからです。
fn main() { let input = include_str!("input.txt"); let mut lines = input.split("\n\n"); let drawn: Vec<u8> = lines .next() .unwrap() .split(",") .map(|ch| ch.parse().unwrap()) .collect(); }
split
メソッドが返すのはIterator
です。タイトルでは Iterator
をたくさん使うことを約束しましたが、ここが出番です。Iterator
トレイトには next
メソッドがあり、そのトレイトを実装するなんらかのコレクションのアイテムをステップ・バイ・ステップに取得することができます。Iterator
自体はインターフェースだったり、プロトコルだったりさまざま名前でさまざまな言語に存在していて馴染んでいる人もいるかと思いますが、Rust ほど Iterator
を実装したスタンダードライブラリの要素を持つ言語はなかなかないです。
Rust の Iterator
は lazy です。というと、実際に処理のチェーンの結果を求めるまで、なにも計算されることがない。新しいコレクションに結果を保存するためには collect
する必要があります。collect
してはじめて計算がはしり十分なメモリがアロケートされます。そのためたくさんの処理をチェーンして、途中の状態を気にしなくてよくなります。 collect
はかなり抽象的で、たまにはコレクションの型を指定する必要があります。上記では、発表番号の字をu8
として parse していますが、何型として parse
するか指定しないと collect
が混乱します。そのためVec<u8>
というふうに指定しました(一方で、そこで指定すると parse
に型を教えなくても推論が効きます)。
次はボードです。Primitive obsession (基本データ型への執着)というフレーズがありますが、なんでもかんでも基本データ型で表そうとすることをさしています。ビンゴのボードの番号はマークされているかされていないかの二つの状態があるので、ボードと同じサイズの、0 と 1 しか入れない配列を作って状態をトラッキングしてもいいですが、それだと「基本データ型への執着」が強いです。なので Board
という概念の定義からはじめます:
use std::collections::HashMap; type State = HashMap<u8, bool>; #[derive(Debug, Clone)] struct Board { state: State, numbers: Vec<u8>, width: usize, } impl Board { fn new(state: State, numbers: Vec<u8>, width: usize) -> Self { Self { state, numbers, width, } } }
十分わかりやすいでしょう。要件を見てみると、特定の番号をボードで探す処理が多くなりそうなので、O(n)
の find を避けるために、番号の値で状態がわかるように HashMap
に初期化します。HashMap
だと与えられた番号でO(1)
でその状態を取得することができます。
let mut boards = lines .map(|board| { let numbers: Vec<u8> = board .split_ascii_whitespace() .map(|num| num.parse().unwrap()) .collect(); let state = numbers.iter().map(|num| (*num, false)).collect(); Board::new(state, numbers, 5) }) .collect();
collect
がたくさんありますね。まずはボードの番号を普通の Vec
として parse
し collect
します。次は初期状態としてすべての番号はマークされていないので、その番号の Vec
を(番号、false)
タプルにマップするのですが、なんと collect
で HashMap
が作れるので非常に便利です。そして最後に結果をボードの Vec
に collect
します。
パズルの要件では、一番最初に勝つボードを見つけるだけではなく、その勝ったボードのマークされていないすべての番号の和と勝ったときの番号の積を答えないといけないです。それを達成するには、以下のような処理が必要になりそうです。
if let Some((board, num)) = find_winning_board(&drawn, &mut boards) { let sum = board.sum_unmarked(); println!("{}", num as usize * sum) }
find_winning_board
がメインです。その中身をどうすれば答えにたどり着くかイメージしてみましょう。
fn find_winning_board(drawn: &Vec<u8>, boards: &mut Vec<Board>) -> Option<(Board, u8)> { for n in drawn { for board in boards.iter_mut() { if let Ok(_) = board.try_complete(*n) { return Some((board.clone(), *n)); } } } return None; }
イメージといいながら答えを半分出しています。もちろん投稿する前に解いてしまっているのでそのままコピペしています。やることは非常にシンプルです。発表番号をひとつずつ取り、全部のボードでその番号を探し、見つかればマークします。ついでにそれでボードの一列(縦か横)の番号がマークされれば勝ちなので早期 return
をします。iter_mut
は、コレクションをIterator
にするメソッドiter()
のミュータブル版です。それぞれのボードの番号の状態を変更しないといけないため、ボードへのミュータブルな参照を取得します。
try_complete
では、番号をマークした後に、ボードの全ての例の中で、全てマークされている列がないかを確認します。どういうふうに実装するのがいいかな?
僕がたどり着いた答えはこんな感じです。
fn try_complete(&mut self, num: u8) -> Result<(), ()> { if let Some(checked) = self.state.get_mut(&num) { *checked = true; for i in 0..self.width { let mut row = self.numbers.iter().skip(self.width * i).take(self.width); let mut col = self.numbers.iter().skip(i).step_by(self.width); if row.all(|n| self.state[n]) { return Ok(()); } if col.all(|n| self.state[n]) { return Ok(()); } } } return Err(()); }
まずは発表番号がそもそもボードに存在しているかどうかを見ます。もし存在していたらついでにそれをマークします。次はまたIterator
の出番です。マーク状態を確認するとき、番号単位ではなく、列単位でみたい。これはいくつかのやり方があると思いますが、一つのやり方としては以下のような順番で番号の列を交互に全て確認します(5x5の場合は全部で10回)。
x x x x x . . . . . . . . . . . . . . . . . . . . x . . . . x . . . . x . . . . x . . . . x . . . . . . . . . x x x x x . . . . . . . . . . . . . . . . x . . . . x . . . . x . . . . x . . . . x . . . . . . . . . . . . . x x x x x . . . . . . . . . .
このイメージをコードに直接訳す前に、人間の言葉でざっと命令にするとどうなるでしょうか?
- 最初の横の列(5つの番号)を確認する
- 縦の列は、ボードの幅と同じ個数の番号をステップ幅として全て確認する
- 次の横の列は、ボードの幅と同じ個数の番号をスキップして、そこから5つの番号を確認する
- 次の縦の列は、前回の起点の次の番号を起点として、またボードの幅と同じ個数の番号をステップ幅として全て確認する
- ...
アルゴリズムができそうです。これをそのままコードに訳すと、ネストした複雑なループになってしまうけど、もう一度重要な部分をコピペします:
for i in 0..self.width { let mut row = self.numbers.iter().skip(self.width * i).take(self.width); let mut col = self.numbers.iter().skip(i).step_by(self.width); }
iter()
の結果には、さまざま便利なメソッドが実装されています。人間の言葉でアルゴリズムを書いた時は「スキップ」という単語を使ったが、やろうとしていることをまさに表すメソッドskip
があります。横の列の場合は、0..ボードの幅
の範囲で、スキップする番号の数を
Iterator
に教えます(0, 5, 10, 15...)。skip
だけだとスキップはするけど、最後まで取得してしまうので、take
で確認したい番号だけの数を指定できます。
同じく縦の列の場合は、0..ボードの幅
の範囲で、起点を1ずつずらして、ステップ幅をstep_by
という便利なメソッドで指定します。そして最後にall
というメソッドと、ボードの番号→状態のマップを使って列の状態を確認します。
if row.all(|n| self.state[n]) { return Ok(()); } if col.all(|n| self.state[n]) { return Ok(()); }
これでほぼ終わりです。パズルの答えに必要な和、上記で出てきた board.sum_unmarked()
はどう実装するか?もちろん Iterator
を使います。
fn sum_unmarked(&self) -> usize { self.numbers .iter() .filter(|n| !self.state[n]) .map(|n| *n as usize) .sum() }
番号のベクタであるVec<u8>
をIterator
にすると、sum
という自明で便利なメソッドが使えます。(sum
は u8
にはできなくて一回usize
に変換する必要がありました)。
パフォーマンス
パズルなのでそこまでパフォーマンスは気にしなくてもいいと思いますが、一点注目したい点があります。try_complete
で使うIterator
はどれもcollect
していない。なぜかというと、最終的にall
で状態をしたいだけなので、列を別のところで再利用するつもりがないからです。これだと、毎回一つの独立したコレクションとしてそれぞれの列を扱っているように見えても本当はnumbers
の中身しかアクセスしていないです。新しいVec
はヒープにアロケートされますが、ヒープのアロケーションがプログラムが遅くなる原因の一つです。上記のコードで collect
などでアロケートをしてみると実行が2倍ぐらい遅くなってしまいます!しかし上記でメンションしたようにRustのIterator
はlazyなので不要なアロケーションを回避できて素晴らしいと思います。
フルコード
use std::collections::HashMap; type State = HashMap<u8, bool>; #[derive(Debug, Clone)] struct Board { state: State, numbers: Vec<u8>, width: usize, } impl Board { fn new(state: State, numbers: Vec<u8>, width: usize) -> Self { Self { state, numbers, width, } } fn sum_unmarked(&self) -> usize { self.numbers .iter() .filter(|n| !self.state[n]) .map(|n| *n as usize) .sum() } fn try_complete(&mut self, num: u8) -> Result<(), ()> { if let Some(checked) = self.state.get_mut(&num) { *checked = true; for i in 0..self.width { let mut row = self.numbers.iter().skip(self.width * i).take(self.width); let mut col = self.numbers.iter().skip(i).step_by(self.width); if row.all(|n| self.state[n]) { return Ok(()); } if col.all(|n| self.state[n]) { return Ok(()); } } } return Err(()); } } fn find_winning_board(drawn: &Vec<u8>, boards: &mut Vec<Board>) -> Option<(Board, u8)> { for n in drawn { for board in boards.iter_mut() { if let Ok(_) = board.try_complete(*n) { return Some((board.clone(), *n)); } } } return None; } fn main() { let input = include_str!("input.txt"); let mut lines = input.split("\n\n"); let drawn: Vec<u8> = lines .next() .unwrap() .split(",") .map(|ch| ch.parse().unwrap()) .collect(); let mut boards: Vec<Board> = lines .map(|board| { let numbers: Vec<u8> = board .split_ascii_whitespace() .map(|num| num.parse().unwrap()) .collect(); let state = numbers.iter().map(|num| (*num, false)).collect(); Board::new(state, numbers, 5) }) .collect(); if let Some((board, num)) = find_winning_board(&drawn, &mut boards) { let sum = board.sum_unmarked(); println!("{}", num as usize * sum) } }