LoginSignup
4
2

More than 3 years have passed since last update.

[4, 7, 9, 2, 3] から "2-4, 7, 9" を得る

Posted at

はじめに

この記事は以下の記事を見て書いた。
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]]

という配列が欲しいわけ。
重要なのは隣り合う偶数同士,奇数同士しかカタマリにしないということ。
だから,最後の 82, 6, 0 とは一緒にならず,飛び地になっている。

ところで,二つの整数 ab が「共に偶数であるか,あるいは共に奇数である」という条件式はどう書けばいいだろう?
一つのやり方は

(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 を使ってレシーバーから一つ一つ要素を取り出す。
まず最初に,そうやって二つの要素を取り出す。今の場合,26 が取り出される。この二つをブロックに渡し,ブロックを評価する。すると (2 - 6).even? は真。このとき chunk_while は「ふむふむ,では 26 の間は切断しないぞ」と決めるのだ。
次に,既に取り出している 6 と,その次に取り出した 0 をブロックに渡す。ここでもブロックは真を返すので,切断しない。
同様にして,03 をブロックに渡すが,これは偽を返す。そうして 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 って何よ?


  1. 重複が少ない場合は大して変わらない。とはいえ,教条的に .uniq.sort と覚えておいてもよいかもしれない。 

  2. test-unit だとこの記事のようにテスト名を文字列で与えることができるし,データ駆動テストもできる。 

4
2
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
2