エンジニアのスキルを試すコードパズル ─この問題、あなたは解けますか?

第14回(最終回) 辻真吾からの問題(第8回)解説編

Battery IncludedなPythonの魅力

宝くじで何億円も当たったら、アパート経営でも始めて、気が向いたらコードを書く生活をしたいと思っていますが、なかなかそうもいきません。

今回は「ひと山当てよう」と銘打ってみましたが、もちろんお金儲けができるわけではなく、数字選択式宝くじのシミュレーションをするプログラムを皆さんに作っていただきました。

全部で18人の方々からご応募いただき、少し惜しかったお2人を除いて、16人の方々は正解でした。基本的な問題だったとは言え、CodeIQ挑戦者のレベルの高さがわかります。

Pythonでの出題だったのは、もちろん私がPythonしか採点できないからですが、これを機会にPythonには便利なライブラリが同梱されている、いわゆる"battery included"なところを体験していただきたかったという気持ちもありました。

架空の売り上げ枚数を生成するためのポイント

売り上げの数については、設問で次のようにしました。

1 何口売れるか、20万~30万口という幅の中から、それらしい数字が選ばれるような仕組みを考えてみてください。

なんともあいまいな書き方ですが、ここから正規分布を連想してもらえるかを期待していました。結果は、4人の方々に期待どおりのコードを書いていただきました。

正規分布についてはWebにもくわしい情報がたくさんあるで、そちらを参照していただくとして、Pythonには正規分布に従う乱数を生成する関数が標準ライブラリに含まれています。

たとえば以下のように記述することで、平均が250,000、標準偏差が50,000/3.0の正規分布に従う乱数を1つ取得できます。

>>> random.gauss(250000,50000/3.0)
260870.83563410284

確率変数なので、このコードを何度も実行すれば違った値になりますが、99.7%は200,000から300,000の間に入ります。

これは、標準偏差をσとすると、±3σの範囲になります。それをわかりやすくするために、50,000/3.0と書いています。

中には、⁠そもそも売り上げが正規分布するのか?」という鋭い指摘をコメント行に入れていただいた解答者もいらっしゃいました。ごもっともかと思います。もちろん、200,000から300,000の範囲で等確率に生成される、一様な乱数でもかまいません。

randomモジュールには、正規分布のほかにも、いろいろな分布に従う乱数を取得できる関数が用意されています。ぜひ一度試してみてください。

当たり判定と結果をまとめるコードはどう書くか

今回の問題のために用意した架空の宝くじ「LOTO-G」は、1から40までの中から5つの数字を選び、全部当たると1等、4つ当たると2等、3つ当たると3等になる、というものでした。

架空の販売くじは、約25万あることになります。そのすべてについて、当たりを判定する必要があります。

これは、以下のようにするといいでしょう。

・5つの数字の組をset型にする ・当たりの番号5つと、当籤を確かめる5つの番号で、interactionメソッドを使い、いくつの数字が重なるかを調べる

18人の応募者のうち最多の6人がこの方法を採用していました。

当たり判定の後、当籤順位ごとに結果をまとめるコードが必要ですが、次のような働きをしてくれるCounterを使うと便利です。

>>> import collections
>>> collections.Counter([0,0,0,1,1,2])
Counter({0: 3, 1: 2, 2: 1})

これを使えば、以下のように短く書けます。

def loto_check(mine,hit):
    return len(set(hit).intersection(set(mine)))
result = collections.Counter([loto_check(v,jackpot) for v in lotog_sales])
# jackpotは当たりくじ、lotog_salesは5個の数字のリストを要素にもつリスト型

これで

result[5]

とすると、1等の当籤が何本あるか、すぐにわかります。resultの中に5がなくても、KeyErrorにならず、0が返ってくるところが便利です。

collections.Counterについては、英語ですが以下の記事がわかりやすく参考になります。

大きな数字はわかりやすい表示に

今回は、売り上げや主催者の取り分など、大きな数字がたくさん出てくるので、表示のときに3桁ごとに区切ると、よりわかりやすくなります。18人中7人の方々が、この処理をしてくださいました。

>>> val = 200000000
>>> locale.setlocale(locale.LC_MONETARY,'ja_JP')
>>> print(locale.currency(val,grouping=True))
¥200,000,000

数字のカンマ区切りは、適切なlocaleを設定して、locale.format関数を使うことで実現できます。その時、grouping引数にTrueを指定するのがポイントです。

今回のようにお金を扱っている場合、通貨に関するlocaleを使う方法もあります(使うと、数字の先頭に円マークがつきます⁠⁠。もちろん国際化も簡単で、日本円の場合、JPYを付けることもできます。

端数の問題をどう処理するか

大きな数字に限りませんが、お金を数字として扱うときにもっと問題になることがあります。それは端数の問題です。

当籤準備金は、売り上げの60%に設定していました。通常、複数口出る2等の当籤者は、この1/3を山分けすることになっています。しかし、これは割り切れない可能性があり、その場合は端数が出ます。

18人の解答者の中でお1人だけ、このことについてコメント行で言及されている方がいらっしゃいましたが、基本的には端数を回避するコードを書かれた方はありませんでした。

端数をどうするのか、問題文には書いてありませんでしたが、末尾に掲載した解答例では主催者の懐にこっそり入れることにしました。

ここで便利なのが、組み込み関数のdivmodです。この関数は、1つ目の引数を2つ目の引数で割り、商と余りをこの順のタプルで返してくれます。

>>> divmod(10,3)
(3, 1)

Pythonには便利な道具がたくさん

プログラミングには、ちょっとした想像力が必要です。日本語など、普通の言語で書かれたことを、forとかifとか、まったくあいまいさのない、厳密なロジックに落とし込む必要があるのがプログラミングです。

次々にアルゴリズムが浮かんでくる天才ハッカーは別として、我々普通のプログラマは便利な道具がたくさんある方が仕事が早く終わります。そんな普通のプログラマのために、Pythonはとても便利なプログラミング言語です。

ところで、皆さんのコードを実行するついでに、入力として私の好きな番号を買い続けましたが、残念ながらまったく当たりませんでした。でもきっと来週のロト6も買ってしまうんだろうな……。

解答例

#!/usr/bin/env python
# coding: utf-8
 
'''
Python2.7での実行を想定しています。
'''
 
import random
import argparse
import collections
import locale
locale.setlocale(locale.LC_MONETARY,'ja_JP')
 
def money_fmt(val):
    '''
    金額を少し見やすくします。
    '''
    return locale.currency(val,grouping=True)
 
def loto():
    '''
    一つ分のくじを返します
    '''
    return random.sample(range(1,41),5)
 
def loto_check(mine,hit):
    '''
    当たり判定用の関数(一致している数字の個数を返す関数)
    '''
    return len(set(hit).intersection(set(mine)))
 
if __name__ == '__main__':
    parser = argparse.ArgumentParser('LOTO-Gで一山当てるプログラム')
    parser.add_argument('-m','--myloto', nargs=5, type=int, help='1から40までの数字から5つを選んで、スペース区切り入力してください。',required=True)
    parser.add_argument('-n','--num', type=int, help='購入する口数を指定してください。',default=1)
    args = parser.parse_args()
 
    # 今回の売り上げ口数を決定(これで99.7%が200,000から300,000にはいります)
    amount = int(random.gauss(250000,50000/3.0))
    # 架空の宝くじ販売を作る
    lotog_sales = []
    for i in range(amount):
        lotog_sales.append(loto())
    # 自分のくじを追加
    for i in range(args.num): 
        lotog_sales.append(args.myloto)
    # 今回の当たりを生成
    jackpot = loto()
    # 各当籤口数の計算
    result = collections.Counter([loto_check(v,jackpot) for v in lotog_sales])
    # 総売上
    total = 500 * len(lotog_sales)
    # 胴元の取り分(このあと端数を追加)
    bookmaker = int(total * 0.4)
    # 当籤準備金
    refund = total - bookmaker
    # 分配率を保持する辞書型
    rate = {1:2, 2:3, 3:6}
    # 結果の出力
    jackpot.sort()
    print('今回の当選番号は「{}」です。'.format(' '.join([str(v) for v in jackpot])))
    # 当選金の分配
    for i in range(1,4):
        _reserve = divmod(refund,rate[i])
        bookmaker += _reserve[1] #端数は胴元が持って行くことにします。
        n = result[5-i+1] #当籤口数
        if n:
            prize = divmod(_reserve[0],n)
            print('{}等({}口) 当籤金額は{}です。'.format(i,n,money_fmt(prize[0])))
            bookmaker += prize[1]
        else:
            print('{}等の当籤はありません。'.format(i))
    print('総売上は{}でした。'.format(money_fmt(total)))
    print('主催者の取り分は{}でした。'.format(money_fmt(bookmaker)))
    my_hit = loto_check(args.myloto,jackpot)
    if my_hit in (0,1,2):
        print('あなたは、はずれー')
    else:
        print('あなたは、{}等に当籤!'.format(6-my_hit))

おすすめ記事

記事・ニュース一覧