はじめに
この記事は以下の記事を見て書いた。
Rubyのパターンマッチを使って簡単なプログラミング問題を解いてみた - Qiita
上記の記事は,
"1, 5, 10-12, 15, 18-20"
という文字列から
[1, 5, 10, 11, 12, 15, 18, 19, 20]
という配列を Ruby で得る方法について書かれている。
一方,こっちの記事はその逆。つまり,正の整数の配列が与えられたとき,それをソートしたうえで,さらに続き番号になっている箇所をハイフンで繋いだ表記にしてカンマ区切りの文字列を作るということ。
こういった処理は,例えば書籍の索引のページ番号の表記を作るのに使われる。
なお,このような表記には,
- 続き番号が 4, 5 のように二つしかなくても
4-5
とまとめる - 二つしか続かない場合は
4, 5
とバラバラにしておく
という二つの流儀が考えられるが,この記事では前者を取る(後者の場合についても書く)。
コード
test-unit を使ったテストコードも書いておいた。
require "test/unit"
def gather(array)
array.uniq.sort
.chunk_while{ |a, b| a.succ == b }
.map{ |c| (c.size == 1) ? c : "#{c[0]}-#{c[-1]}" }
.join(", ")
end
# ここから下がテストコード
# スクリプトを実行すれば以下に記述したテストは自動的に行われる
class TestGather < Test::Unit::TestCase
test "バラバラ" do
assert_equal "1, 3, 5", gather([3, 5, 1])
end
test "重複あり" do
assert_equal "2, 4", gather([4, 2, 2, 4, 4])
end
test "まとめ" do
assert_equal "1-2", gather([2, 1])
assert_equal "1-3", gather([2, 3, 1])
assert_equal "2-4, 7, 9, 11-12",
gather([12, 11, 7, 9, 2, 4, 3])
end
end
解説
重複を省きソートする
最初に
array.uniq.sort
として,重複を省き,ソートしている。あまり説明は要らないと思う。
ただ,効率を考えると,このように先に uniq
してあとで sort
したほうがいい,ということに注意したい1。
続き番号をカタマリにする
次に,このコードの肝といえるのが
chunk_while{ |a, b| a.succ == b }
の部分。
実は公式リファレンスの Enumerable#chunk_while に,この記事のコードとそっくりなサンプルが書かれているのだが。
chunk_while
は実に Ruby 的な面白いメソッドだ。chunk_while
とは何者なのか?
これは,配列などの Enumerable なオブジェクトについて,要素を端から舐めていって,隣り合う二つの要素の間をつなげるか切断するかをある判断基準に従って決定し,そうやってカタマリ(chunk)を作っていくメソッドだ。
ただ,返り値はカタマリの配列ではなく Enumerator である。Enumerator とは何か,については本記事では解説しないが,Enumerable なオブジェクトなので to_a
すればカタマリの配列になる。
たとえば,
[2, 6, 0, 3, 5, 8]
という配列があって,これを偶数同士,奇数同士のカタマリに分けたいとしよう。
つまり
[[2, 6, 0], [3, 5], [8]]
という配列が欲しいわけ。
重要なのは隣り合う偶数同士,奇数同士しかカタマリにしないということ。
だから,最後の 8
は 2, 6, 0
とは一緒にならず,飛び地になっている。
ところで,二つの整数 a
と b
が「共に偶数であるか,あるいは共に奇数である」という条件式はどう書けばいいだろう?
一つのやり方は
(a.even? && b.even?) || (a.odd? && b.odd?)
なのだが,こんな面倒なことをする必要はない。
「偶数と偶数の差は偶数」「奇数と奇数の差は偶数」「偶数と奇数の差は奇数」ということに気づけば
(a - b).even?
でよいと分かる。
さて,隣り合う偶数同士,奇数同士のカタマリの配列を得るには,chunk_while
を使って以下のように書く。
numbers = [2, 6, 0, 3, 5, 8]
p numbers.chunk_while{ |a, b| (a - b).even? }.to_a
# => [[2, 6, 0], [3, 5], [8]]
この chunk_while
はどのように働くのだろうか。
chunk_while
は each
を使ってレシーバーから一つ一つ要素を取り出す。
まず最初に,そうやって二つの要素を取り出す。今の場合,2
と 6
が取り出される。この二つをブロックに渡し,ブロックを評価する。すると (2 - 6).even?
は真。このとき chunk_while
は「ふむふむ,では 2
と 6
の間は切断しないぞ」と決めるのだ。
次に,既に取り出している 6
と,その次に取り出した 0
をブロックに渡す。ここでもブロックは真を返すので,切断しない。
同様にして,0
と 3
をブロックに渡すが,これは偽を返す。そうして chunk_while
は「よっしゃ! ここで断ち切ったるで!」と決めるのだ。
以下同様。
chunk_while
が一つずつ位置をずらしながら隣り合う 2 要素を取り上げてゆくさまは,each_cons(2)
に似ている。
each_cons(2)
はただ 2 要素を拾っていくだけだが,chunk_while
は結合・切断の箇所を判定するために 2 要素を拾っていくのだ。
chunk_while
の働きが分かったところで,
chunk_while{ |a, b| a.succ == b }
を検討しよう。
Integer#succ
は自身に 1 を足した整数を返すメソッド。
a.succ == b
でもって「a
の次が b
になっている」という条件を表している。
続き番号のところはハイフンでまとめる
その次の
map{ |c| (c.size == 1) ? c : "#{c[0]}-#{c[-1]}" }
は簡単。
おっとその前に一言。chunk_while
の返り値は Enumerator オブジェクトなので,to_a
で配列にする必要はなく,そのまま map
を続けることができる。
さて,この map
のブロックだが,個々のカタマリが長さ 1 ならそのまま,2 以上なら先頭と末尾をハイフンで繋ぐ,ということを行っている。
例えば [7]
なら [7]
のままにし,[2, 3, 4]
なら "2-4"
に変えている。
うーん,変換後の配列は要素が配列だったり文字列だったりしてちょっと気持ち悪いな。いやこれで何の問題もない(後述)のだが,ちょっとムズムズするのは確か。
カンマで繋げる
最後に
join(", ")
で仕上げ。
これはまあ要素を単に ", "
で繋ぐだけなのだが,前段で「要素が配列だったり文字列だったりするが問題ない」と述べたことに確信を持つためには,Array#join の正確な理解が必要だ。
join
は,要素がすべて文字列であれば,間に引数を挟んで繋げた文字列を返す。
要素に文字列でないものがあった場合,繋げる前にまずそいつを文字列化するのだが,そいつがもし配列だった場合,to_s
ではなく join
を使って文字化するのだ。その際の join
には元の join
と同じ引数が使われる。
だから,
p [1, [2, 3]].join("-") # => "1-2-3"
のようになる。
以上で動作が完全に理解できた。
バリエーション
「はじめに」の節で予告したが,続き番号が 4, 5
のように二つしか続いていない場合にハイフンでまとめないようにするにはどう改変すればよいか。
つまり,
p gather([1, 2, 4, 5, 6]) # => "1, 2, 4-6"
となるようにするには?
実は,Enumerable#chunk_while のサンプルコードはこの流儀にしたがって書かれている。
そのようにするには,
.map{ |c| (c.size == 1) ? c : "#{c[0]}-#{c[-1]}" }
を
.map{ |c| (c.size < 3) ? c : "#{c[0]}-#{c[-1]}" }
に変えればよい。
これだけの改変で済むのは,既に述べた join
の仕様から明らか。
余談
Ruby の同系統のユニットテストライブラリーが test-unit と minitest に分裂したままなのは不幸というほか無い。
私の目には test-unit のほうが優秀な気がするのだが2,Rails が minitest(を魔改造したもの?)を採用しちゃってるので,世間的には minitest のほうが優勢なのかもしれない。
え? RSpec? いやー,ヘタレの私には意味不明すぎて書ける気がしない。it
って何よ?