🔱

JPCB レシピ 2.1 配列中の最小要素のインデックスを取得する

2021/01/27に公開2

オライリー・ジャパンから刊行されている「Juliaプログラミングクックブック」のレシピを写経してみました.実行したJuliaのバージョンは以下です.

Julia Version 1.5.3

このレシピでは,配列中の最小要素のインデックス返す関数,特に最小要素が複数ある場合に一様ランダムに選択したインデックスを1つ返す関数について議論しています.

まず,配列中のすべての最小要素のインデックスを返す関数は次のように実装されます.

# 最小要素のインデックスをすべて(配列として)返す
function allargmin(a)
    m = minimum(a)
    filter(i -> a[i] == m, eachindex(a))
end

#%% 実行
ar = [1,2,3,1,2,3]
allargmin(ar) # [1, 4]

eachindex()は配列のインデックスをコレクションとして返す関数のようです.

# eachindexは配列のインデックスを返す
for i = eachindex(ar)
    println(i)
end
#= 実行結果
1
2
3
4
5
6
=#

本題にもどって,allargmin()を使って最小要素を一様ランダムに選択して返す関数を実装します.

#%% 最小要素を一様ランダムに選択して返す関数
randargmin1(a) = rand(allargmin(a))

そして,同様の関数を別実装した以下の関数も用意します.

function randargmin2(a)
    indices = eachindex(a)
    y = iterate(indices)
    y === nothing && throw(ArgumentError("collection must be non-empty"))
    (idx, state) = y
    minval = a[idx]
    bestidx = idx
    bestcount = 1
    y = iterate(indices, state)
    while y !== nothing
        (idx, state) = y
        curval = a[idx]
        if isless(curval, minval)
            minval = curval
            bestidx = idx
            bestcount = 1
        elseif isequal(curval, minval)
            bestcount += 1
            rand() * bestcount < 1 && (bestidx = idx)
        end
        y = iterate(indices, state)
    end
    bestidx
end

早速実行してみましょう.

using StatsBase
x = [1, 2, 3, 1, 2, 3, 1, 1]
countmap([randargmin1(x) for i in 1:10^6])
# Dict{Int64,Int64} with 4 entries:
#   7 => 250606
#   4 => 249997
#   8 => 249730
#   1 => 249667
countmap([randargmin2(x) for i in 1:10^6])
# Dict{Int64,Int64} with 4 entries:
#   7 => 249765
#   4 => 249842
#   8 => 250474
#   1 => 249919

乱数を使用しているので結果は一致しませんが,同じ機能が実現できていることが確認できました.
countmap()は要素のヒストグラムをとってくれる関数のようです.便利ですね.

実行時間についても比較してみましょう.

x = rand(1:10, 1000)
using BenchmarkTools
@btime randargmin1($x)
#   1.233 μs (2 allocations: 8.81 KiB)
# 724
@btime randargmin2($x)
#   1.670 μs (0 allocations: 0 bytes)
# 272

興味深いことに,書籍ではrandargmin2($x)の方が17倍も速いのに対し,手元の環境ではrandargmin1($x)の方が速くなっています.書籍はJulia 1.2.0での実行結果のようなので,バージョンが上がって最適化が進んだようですね.実装方法としてrandargmin1($x)の方があきらかにコード量が少ないので,こちらの実行速度が速くなったことは素晴らしいと思います.
またメモリ量に関して,randargmin1($x)は書籍では18.05KB使用しており,手元の環境よりもメモリ使用量が多いです.

その他:isless(), isequal()について.
Juliaでは大小比較や等号比較をする際にはこの関数を使うべきとのことです.演算子による比較をした場合,以下のようにいくつかの値で振る舞いが異なるようです.

0.0 == -0.0, isequal(0.0, -0.0)
# (true, false)
-0.0 < 0.0, isless(-0.0, 0.0)
# (false, true)
NaN == NaN, isequal(NaN, NaN)
# (false, true)
NaN < NaN, isless(NaN, NaN)
# (false, false)

「Juliaプログラミングクックブック」のレシピ2.1を実装してみました.知らない文法やバージョンの違いによる結果の差異など,学ぶことが多かったので,今後も写経を続けていこうと思います.

https://www.oreilly.co.jp/books/9784873118895/

Discussion

antimon2antimon2

マクロでは引数を $x にするのが一般的なのでしょうか?

マクロではと言うか、@btime 等の BenchmarkTools で提供されているマクロでは、です。

$x とすると、x を参照する時間がベンチマーク時間に含まれなくなります。
単純な変数なら x のままでも実行時間に大きな影響はないかもしれませんが、例えば

@btime randargmin1($(rand(1:10, 1000)))

とすると、毎回ランダムな配列を生成しますがその時間がベンチマーク時間に含まれなくなります。なので純粋に randargmin1 の実行時間のベンチマークが取れます。
なおそれだと可読性が落ちることを気にするなら、↓のような書き方もあります。

@btime randargmin1(x) setup=(x=rand(1:10, 1000))
uchkwuchkw

コメントありがとうございます!たしかにrand(1:10, 1000)で実行したら測定時間に有意な差が出ました.モヤモヤしていたのがとれて非常に嬉しいです.setupを使った記述法についてもご教授感謝です.勉強になります.