CodeIQ @riverplus さんの「マイナー・ゲーム」問題をrubyで解いた

いつものようにRubyで解きました。


回答まとめはhttp://togetter.com/li/982368から。
無限級数の形にして足している例はない? スマートなコードが多い印象。

出題者による解説はhttps://codeiq.jp/magazine/2016/06/41947/から。

問題は以下のような感じ。

n 人のプレイヤーで「少数決」というゲームを行います。

ゲームは次の手順で進みます。

  1. 各プレイヤーは、投票用紙にAまたはBの文字を記し投票します。投票は記名式です。他のプレイヤーの投票内容は分かりません。
  2. 全員が投票し終わったら開票します。その結果、少数派になる回答をした者が勝者となります。例えばAが 3 票、Bが 4 票の場合は、Aを投票した 3 名が勝者です。AとBが同数の場合や、全員がAの場合、全員がBの場合は、全員が勝者です。
  3. 勝者はそのまま勝ち残り次の投票に進めますが、敗者はそこで脱落します。
  4. 以上が 1 回の「ターン」です。この要領でターンを繰り返し、勝ち残りが 1 人か 2 人になるまでゲームを続けます。

各プレイヤーはAまたはBをランダムに選んで投票します。
このとき、n 人のプレイヤーから始めてゲームが終了するまでのターンの回数の期待値を F(n) と定義しましょう。

例えば F(3) = 4/3 です。
F(4) = 2,F(5) = 16/15,F(7) = 1.7566137…,F(8) = 2.2028985… となることが確かめられます。

標準入力から、自然数 n(3 ≦ n ≦ 100)が与えられます。
標準出力に、F(n) を 10^6 倍した値の整数部分を出力するプログラムを書いてください。

l 回後のゲームの後にm人残っている確率を p(l, m) とすると、1人または2人になったらゲーム終了なので、求める期待値は
\displaystyle F(n)=\sum_{l=1}^\infty l ( p (l, 1) +p (l, 2) )
で計算される。

 p(l, m) を考える。
まず、l+1回目のターンでk人からm人に遷移するのは、kがmの場合は可能だが、少数決ゲームの都合上、kは  2m+1 以上の数でなければ、m人に遷移できない。
特に \frac{n-1}{2} < m < n の場合は、n人より多い状態からしか遷移できないので、確率は常に0。

また、l+1回目のターンでm人からm人に遷移するときは、全員Aを選ぶ、または、全員B を選ぶ、またはA,B同数の場合になる。mが偶数のときは、同数になることができるのでその分を足す必要がある。

以上から、p(l, m) については下記の漸化式が成り立つので、これを計算しておいてから期待値を計算すればよい。
 \displaystyle \large p(l+1, m) = \left\{ \begin{array}{ll} \frac{p(l,n)}{2^{n-1}} + q(l,n) & (m=n)\\ 0 & (\frac{n-1}{2} < m < n) \\ \sum_{k \in \boldmath{A}(n,m)} \binom{k}{m} \frac{p(l,k)}{2^{k-1}} + q(l,m) & (otherwise) \end{array} \right. \\ p(0, m) = \left\{ \begin{array}{ll} 1 & (m=n) \\ 0 & (m \neq n) \end{array} \right.
ここで、
\large q(l,m) = \left\{\begin{array}{ll} \binom{m}{m/2}\frac{p(l,m)}{2^m} & (m: even) \\ 0 & (m: odd)\end{array}\right.
\boldmath{A}(n,m)n, m 及び閉区間[2m+1, \frac{n-1}{2}] に含まれる整数の集合(閉区間に含まれる整数が存在しない場合はmとnのみを要素に持つ)を表し、
\binom{n}{m} で2項係数を表す。

というわけで、このややこしい漸化式をコードにして提出したのが下記。

71 行目のeps が1e-20とやたら小さいのは、nが大きくなると1回目のゲームで終わる確率が極端に小さくなり、実行初回のi=2でwhileループをいきなり抜けてしまうのを防ぐため。

また、求める期待値は1より大きく大体1桁くらいのオーダーなので、1e-16より小さくすればFloatの範囲ではそれ以上誤差が大きくならないだろうと適当に見繕った。

nが100くらいまでなら、1e-16でも十分だったかも。いずれにせよ、nが500とかになると1e-20でも大きいくらいなので、75行目のwhile の条件の後に、 「 or i < 30 」 とかを付けてiが小さいうちは必ず回るようにしないとあまり意味がなかった。ただ、2^{1000} が大体 10^{300} くらいなので、nが1000を超えるとFloatで扱える数を超えてしまい、このコードでは計算ができなくなってしまう。(Floatの代わりにRationalを使えばできるだろうが、遅すぎる。。。。)

提出コードではiが大きい方から小さい方の順に足しているのであって、小さい値から足しているわけではないので、誤差がある場合があったかも。今は先頭7ケタまでもとめられればよいので、期待値がちょうど2になるn=4の場合以外はあまり気にする必要がないような気もする。

$combi=[[1], [1,1], ]
# 2項係数(n,m)を求める
def combination(n, m)
  m = n-m if n-m > m
  return $combi[n][m] if $combi[n] and $combi[n][m]
  $combi[n] ||= []
  $combi[n][m] = if m == 0 or m == n then 1
                 else combination(n-1, m-1)*n/m
                 end
end
$power=[1]
# 2の累乗を求める
def power2(n)
  return $power[n] if $power[n]
  $power[n] = if n.even?
                power2(n/2) * power2(n/2).to_f
              else
                2*power2(n-1).to_f
              end
end

$n=gets.to_i
# l 回目のゲームの後でちょうどm人残っている確率p(l,m)を考える。
# 1人または2人になった時点でゲームが終了するので、
# E(n) は、 (l*p(l,1) + l*p(l,2))を全てのlについて足し上げればよい。
# この数は、lが大きくなると0に収束するので、十分小さいところまで
# 計算してから、誤差に注意して足し上げればよい。
$memo = [[0,0]]
# 1回目のゲームの後の確率を計算しておく。
p1 = []
p1[$n] = $n.odd? ? 2/power2($n) : (2+combination($n, $n/2))/power2($n)
(1..(($n-1)/2)).each{|m|
  p1[m] = 2*combination($n, m)/power2($n)
}
$memo[1] = p1
# l 回目のゲームの後でちょうどm人残っている確率を返す再帰関数
def p(l, m)
  return $memo[l][m] if $memo[l] and $memo[l][m]
  ret = 0
  if $memo[1][m].nil?
    ret = nil
  elsif m == $n # l回後に$n人残る
    if $n.odd?
      ret = 2*p(l-1, m)/power2($n)
    else
      ret = (2+combination($n, ($n/2)))*p(l-1, m) / power2($n)
    end
  else
    # l回後にm人残るときは、(l-1)回後に㎜人(m ≤ mm ≤ $n)残っている
    # 状態からm人勝ち残ったことになる。

    # $n人から変化したときは別に計算。
    ret = 2*combination($n, m)*p(l-1, $n)/power2($n)
    # 人数が変化するときは必ず半分以下になる点に注意する。
    ((2*m+1)..(($n-1)/2)).each{|mm|
      ret += 2*combination(mm, m)*p(l-1, mm)/power2(mm)
    }
    # m人からm人に遷移する場合を分けて計算。
    if m.even? and m > 2
      ret += (2+combination(m,m/2))*p(l-1,m)/power2(m)
    elsif m > 2
      ret += 2*p(l-1,m)/power2(m)
    end

  end
  $memo[l] ||= []
  $memo[l][m] = ret
end
p0 = p(1, 1) + ( p(1, 2) || 0 )
eps = 1e-20
i = 2
# 情報落ち誤差による積み残しを避けるため、
# 十分小さいところまで計算してから、、、
while i*( p(i, 1) + ( p(i, 2)||0 )) > eps
  i+=1
end
# 逆順で小さい方から足し上げていく。
result = 0
while i > 0
  result += i*(p(i, 1) + ( p(i, 2)||0 ))
  i-=1
end
puts (result*10**6).to_i

コメントを残す