LoginSignup
65
44

More than 3 years have passed since last update.

Rubyのsortとsort_byを理解してマルチソートをしよう

Posted at

Rubyの配列を並び替えるときはArray#sortArray#sort_byを使うことになるかと思います。

data = (1..10).to_a.shuffle
p data.sort # [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

こんなシンプルなsortをする場面なんてほとんど無いでしょうから、なんの参考にもなりませんね笑

よくあるものですと、DBやcsvなどのデータを取得したあとに並び替えたいときですね。

サンプルデータ
require 'date'
data = [
  {id: 1, name: '吉田(A)', ruby: 'yoshida', join_date: Date.new(2009, 4, 1)},
  {id: 2, name: '鈴木', ruby: 'suzuki', join_date: Date.new(2015, 4, 1)},
  {id: 3, name: '吉田(B)', ruby: 'yoshida', join_date: Date.new(2009, 4, 1)},
  {id: 4, name: '佐藤', ruby: 'sato', join_date: Date.new(2006, 10, 1)},
  {id: 5, name: '田中', ruby: 'tanaka', join_date: Date.new(2009, 4, 1)},
]

例えばこんな社員マスタみたいなものがあったとします。

数字の降順で並び替える

idを降順に並び替えたい場合はシンプルにsortにブロックを渡してbを先にしてあげればできます。

idの降順
pp (data.sort do |a, b|
  b[:id] <=> a[:id]
end)

#[{:id=>5,
#  :name=>"田中",
#  :ruby=>"tanaka",
#  :join_date=>#<Date: 2009-04-01 ((2454923j,0s,0n),+0s,2299161j)>},
# {:id=>4,
#  :name=>"佐藤",
#  :ruby=>"sato",
#  :join_date=>#<Date: 2006-10-01 ((2454010j,0s,0n),+0s,2299161j)>},
# {:id=>3,
#  :name=>"吉田(B)",
#  :ruby=>"yoshida",
#  :join_date=>#<Date: 2009-04-01 ((2454923j,0s,0n),+0s,2299161j)>},
# {:id=>2,
#  :name=>"鈴木",
#  :ruby=>"suzuki",
#  :join_date=>#<Date: 2015-04-01 ((2457114j,0s,0n),+0s,2299161j)>},
# {:id=>1,
#  :name=>"吉田(A)",
#  :ruby=>"yoshida",
#  :join_date=>#<Date: 2009-04-01 ((2454923j,0s,0n),+0s,2299161j)>}]

文字列の昇順

では、名前のローマ字の昇順にしたい場合はどうでしょうか?
sato, suzuki, tanaka, yoshida, yoshidaの順番にしたい場合ですね。

ローマ字順
pp (data.sort do |a, b|
  a[:ruby] <=> b[:ruby]
end)

#[{:id=>4,
#  :name=>"佐藤",
#  :ruby=>"sato",
#  :join_date=>#<Date: 2006-10-01 ((2454010j,0s,0n),+0s,2299161j)>},
# {:id=>2,
#  :name=>"鈴木",
#  :ruby=>"suzuki",
#  :join_date=>#<Date: 2015-04-01 ((2457114j,0s,0n),+0s,2299161j)>},
# {:id=>5,
#  :name=>"田中",
#  :ruby=>"tanaka",
#  :join_date=>#<Date: 2009-04-01 ((2454923j,0s,0n),+0s,2299161j)>},
# {:id=>1,
#  :name=>"吉田(A)",
#  :ruby=>"yoshida",
#  :join_date=>#<Date: 2009-04-01 ((2454923j,0s,0n),+0s,2299161j)>},
# {:id=>3,
#  :name=>"吉田(B)",
#  :ruby=>"yoshida",
#  :join_date=>#<Date: 2009-04-01 ((2454923j,0s,0n),+0s,2299161j)>}]

ブロック内の比較対象を:rubyにするだけでできました。
これはString#<=>が実装されているために簡単に実現することができています。

sortってなにしてくれてるんだっけ

っていうか、そもそもなんで<=>を指定すると並び替えてくれるんだっけ?
というのを思い返すときにArray#sortのドキュメントを読んでみます。

ブロックとともに呼び出された時には、要素同士の比較をブロックを用いて行います。ブロックに2つの要素を引数として与えて評価し、その結果で比較します。ブロックは <=> 演算子と同様に整数を返すことが期待されています。つまり、ブロックは第1引数が大きいなら正の整数、両者が等しいなら0、そして第1引数の方が小さいなら負の整数を返さなければいけません。両者を比較できない時は nil を返します。

つまり最終的にブロックの最後に評価される値が整数を返してあげれば並び替えてくれるわけです。
<=>メソッドは何らかの比較をして1, -1, 0, nilのいずれかを返すように実装されているので、それを使えば簡単に並び替えできる、というわけです。

Arrayに<=>がおるやん

それを理解した上でArrayのドキュメントを見ると気付くわけです。
Array#<=>が実装されていることに。

自身と other の各要素をそれぞれ順に <=> で比較していき、結果が 0 でなかった場合にその値を返します。各要素が等しく、配列の長さも等しい場合には 0 を返します。各要素が等しいまま一方だけ配列の末尾に達した時、自身の方が短ければ -1 をそうでなければ 1 を返します。 other に配列以外のオブジェクトを指定した場合は nil を返します。

つまり配列同士の比較もできるわけです。
配列の先頭から比較し、同値の場合には次の要素を検証していき、同値じゃなかった場合には1, -1を返してくれます。
ここまで来ればもうマルチソートのやり方もなんとなく理解できましたね。

マルチソートしてみる

ではサンプルのデータで、先程ローマ字順に並び替えましたが、yoshidaがかぶっています。
sortでは元の順番が保持されているためidが低い1のyoshidaが先に来ています。
3のyoshidaを先に持っていきたい(第2ソートキーとしてidの降順にしたい)場合はどうするか?

こうです。

第二ソートキーとしてidを指定
pp (data.sort do |a, b|
  [a[:ruby], -a[:id]] <=> [b[:ruby], -b[:id]]
end)

最初に:rubyで比較をし、そこで優劣がつくようなら:idでの評価はしません。
:idでの比較の際に-をつけ、値を逆転させることで降順を実現しています。

日付(Dateクラス)の降順の並び替えは?

じゃあマルチソートは一旦置いといて、今度は入社年月日の降順(新しい順)にしたい場合はどうでしょうか?
Dateクラスにも<=>が実装されているので並び替えは容易ですね。
しかし降順となるとIntegerのように-をすることはできません。(Date#-は実装されていますが、算術演算子としての-なので引数が必要です)

そうなった場合、最初の:idでの降順の並び替えで提示したようにaとbを入れ替えてあげることで実現できます。

join_dateの降順で並び替え
pp (data.sort do |a, b|
  b[:join_date] <=> a[:join_date]
end)

#[{:id=>2,
#  :name=>"鈴木",
#  :ruby=>"suzuki",
#  :join_date=>#<Date: 2015-04-01 ((2457114j,0s,0n),+0s,2299161j)>},
# {:id=>1,
#  :name=>"吉田(A)",
#  :ruby=>"yoshida",
#  :join_date=>#<Date: 2009-04-01 ((2454923j,0s,0n),+0s,2299161j)>},
# {:id=>3,
#  :name=>"吉田(B)",
#  :ruby=>"yoshida",
#  :join_date=>#<Date: 2009-04-01 ((2454923j,0s,0n),+0s,2299161j)>},
# {:id=>5,
#  :name=>"田中",
#  :ruby=>"tanaka",
#  :join_date=>#<Date: 2009-04-01 ((2454923j,0s,0n),+0s,2299161j)>},
# {:id=>4,
#  :name=>"佐藤",
#  :ruby=>"sato",
#  :join_date=>#<Date: 2006-10-01 ((2454010j,0s,0n),+0s,2299161j)>}]

マルチソートで日付の降順の方法、その2

しかしこれをマルチソートのキーとして採用するとちょっとわかりづらくなる気がします。しませんか?私だけですか?

join_dateの降順で並び替え
pp (data.sort do |a, b|
  [a[:ruby], b[:join_date]] <=> [b[:ruby], a[:join_date]]
  # aとbのキーを間違えてない???って思ってしまう
end)
# サンプルデータだとあんまり意味がないのでppの結果は省略します笑

まぁ-で値を逆転させるのもあまり直感的ではないかも知れないので好みの問題だとは思いますが
個人的にはunixtime化して計算させるほうがわかりやすく思います。

pp (data.sort do |a, b|
  a_time = a[:join_date].to_time.to_i
  b_time = b[:join_date].to_time.to_i
  [a[:ruby], -a_time] <=> [b[:ruby], -b_time]
end)

と、こんな感じで配列同士を比較させればマルチソートでの昇順/降順も自由にコントロールできます。

処理が肥大化しそうならsort_byを使えばいいじゃない

ただ上記のto_time.to_iとかもそうですし、nullrableな値がある場合の処理とかを考えると、a,bそれぞれの変数に対して同じことをやってブロックの中が肥大化していく未来が見えます。

そこでEnumerable#sort_byを使うのが良いのではないかと考えます。

ブロックの評価結果を <=> メソッドで比較することで、self を昇順にソートします

とあるので、ブロックの最後の評価されるものが<=>で比較できればよいので配列を渡せばArray#<=>でマルチソートが簡単にできるやん、という感じです。

つまりこう
pp (data.sort_by do |v|
  [v[:ruby], -v[:join_date].to_time.to_i, v[:id]]
end)

またドキュメントにある通り、複雑な並び替えの場合にはsortよりも処理数が少なくパフォーマンス的にも期待できます。

まとめ

  • sortのブロックには整数を返せばよい
  • 配列を渡せばPHPのarray_multisortみたいなことができる
  • 複雑な条件にはsort_byの方がおすすめ

ブロック内でififするのは複雑度が上がって良くないので配列を渡しちゃいましょう!

65
44
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
65
44