🍲

オフラインリアルタイムどう書く P.2 の実装例

に公開

これは何?

オフラインリアルタイムどう書く P.2
https://dokaku.connpass.com/event/359350/
の、問題
二進数を回す
の実装例。

実装例集積所は下記。

https://zenn.dev/nabetani/scraps/486b92465f2214

実装

素朴な実装

まずは素朴に。

ruby
# "1[3]3" などを 0b100011 などに変換する
def parse(s)
  r=0
  s.scan(/((?:\[?)\d+(?:)\]?)/) do |m,|
    if m[0]=="["
      r<<= m[1..-2].to_i
    else
      v = m.to_i
      r <<= v.to_s(2).size
      r |= v
    end
  end
  r
end

# n を ro÷b 回転 回す
def rot(b,n,ro)
  r=0
  lo=0
  (1...).each do |edge|
    len = edge*b
    mask = (2**len-1)
    org = (n>>lo) & mask
    shift = ro*edge
    rotated = ((org<<shift) | org>>(len-shift)) & mask
    r |= rotated << lo
    lo += len
    return r if n<2**lo
  end
end

# 解く
def slow(src)
  bs, ns  = src.split(" ")
  b = bs.to_i
  n = parse(ns)
  h=Hash.new{ |h,k| h[k]=[] }
  b.times do |r|
    h[rot(b,n,r)].push r
  end
  mm = h.keys.minmax
  # pp h
  mm.map{ |m| h[m].sort.join(",") }.join("/")
end

ruby なので、多倍長整数が使える。
素直に二進数にし、b角形なら b通りの回転を全部試す。
回転した結果得られる値をキー、回転量の配列を値にする Hash を作り、全部突っ込む。
minmax → sort → join(",") → join("/") で完成。

手元の MacBook Pro (Apple M1 Pro 非Max with ruby 3.4.5) で、10分ぐらい。
上記を 40〜50分ぐらいで書ければ勝利という目算だった。

速い実装

スパース性

テストデータを見ると、1000万桁の二進数がある。比較演算一回で数十万回のループが回る。
四則演算するたびに数メガバイトのコピーが発生する。

ということで問題文や図の後半をよく見ると、何万ビットあろうともその大半はゼロで、1のビットは多くても 100ぐらいしか無いということがわかる。

いわゆるスパースなビット列。

ということで、通常の2進数ではなく「1のビットがある桁を覚える」という作戦で行くと扱うデータ量が少なくなる。

探索範囲

テストデータを見ると、9万角形とかがある。
素朴な実装では 9万回のループを回していたわけだけど、そんなに調べる必要はない。

最大値となるのは「回転量を増やしても減らしても値が小さくなる」という場所に 1 があるときだけ。
最小値となるのは「回転量を増やしても減らしても値が大きくなる」という場所に 1 があるときだけ。

ということで、最小値になるのは下図左の範囲に 1 があるときだけ。最大値になるのは下図右の範囲に 1 があるときだけ。

いずれにも 1 が無いケースについては調査不要となる。

実装例

前述の 二点を入れると、手元の環境で 1秒以下でテストが終わる。

ruby
# 正の整数 n が 2進数で何桁かを計算する
# 例: 0b1 => 1, 0b10 => 2, 0b1111 => 4
def binLength(n)
  n.digits(2).size
end

# n を 2進数で表したときに 1 になっている桁の位置を配列で返す
# 例: 0b101011 => [5,3,1,0]
def ones(n)
  binLength(n).downto(0).select{ n[_1] !=0 }
end

# "1[80]1" などを [81,0] などに変換する
def parse(s)
  s.split(/\D/).each_slice(2).each_with_object([]) do |(n,gs),o|
    g = gs ? gs.to_i : 0
    b=ones(n.to_i).map{ _1+g }
    offset = b.first + 1
    o.map!{ _1 + offset }
    o.push(*b)
  end
end

# b 角形で 番号 n の場所を返す。
# @return [p0,p1,e] p0 はその b角形の番号の最小値、p1 は一つ外側の多角形の最小値。e は1辺の長さ。
def poly_ix(b,n)
  p0=0
  for e in (1...) do
    p1 = p0+ e*b
    return [p0,p1,e] if n<p1
    p0 = p1
  end
end

# nums を r÷b 回転 回す
def rotate(b, nums, r)
  nums.map{ |n|
    p0, p1, e = poly_ix(b,n)
    ((n-p0) + r*e) % (p1-p0) + p0
  }.sort.reverse
end

# 最大・最小となる回転量の候補
def candidates(b,nums)
  c=nums.flat_map do |n|
    p0, _, e = poly_ix(b,n)
    x = (n-p0)/e
    (-1..0).map{ |o| (o-x).modulo(b) }
  end
  c.uniq.sort
end

def solve(src)
  bs, ns  = src.split(" ")
  b = bs.to_i
  nums = parse(ns)
  rotateds = candidates(b,nums).map{ |r| [r,rotate(b, nums, r)] }
  min, max = rotateds.minmax_by{ _1[1] }
  [min,max].map{ |m|
    rotateds.select{ _1[1]==m[1] }.map(&:first).sort.join(",")
  }.join("/")
end

candidates で行った計算をもう一回 rotate でやるのは無駄だし、そもそも poly_ix でループを回すのはダサイと思うけれど、ビット長を n として、 O(\sqrt{n}) ぐらいのループになるので(あってる?)大したことはない。

それと。
せっかくなので、 parse の戦略も素朴な実装とは違う作戦のものにしてみた。

素朴な実装は、正規表現で scan する作戦。
今回の実装は「数字以外」で split して、二個ずつ処理する作戦。ちょっと姑息な感じもするけど、正規表現を書かなくていいので短時間で書けそうだと思う。

split だと、1[2][3]4 というような入力に負けるけど、そういう入力はないことになっている(出題当初、そういうテストケースがあるというミスをしていた。すいません)ので大丈夫。

出題してみて

この問題は

  • 素朴な実装なら割とすぐに書けるだろう
  • スパースであることを利用すると速くできる
  • 調査対象を減らせることに来づければもっと速くできる

という三段階になっているところはよいなと思っているけど、問題文が長く、問題を理解するのに時間がかかるのが良くないなと思った。

総合的には悪くないかなと思っている。
次回はもう少し簡単で短い問題を作りたい。

Discussion