1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Pythonでサイコロを出力するコードを139バイト(ないし125バイト)にするまで

Last updated at Posted at 2024-09-29

今回はコード自体ではなく、各部分を作るまでの発想や思考を細かく記述してみました。参考になれば何よりです。

最終的に作ったコード

自分で納得できる出力のもののうち最小サイズの、139バイトのコードです。
改行文字は1バイト文字にしています。念のため。

dice.py
import random
n=random.randint(1,6);d=65,468,3951,400,3505,3532;s='\n';c=' ',' O',s
for i in range(8):s=s+c[d[n-1]//3**i%3]
print(str(n)+s)

出力例

>python dice.py
6
 O O
 O O
 O O

>python dice.py
2
   O

 O
 

ツッコミ対策

このコードには、ツッコミどころが以下のようにいくつかあると思っているので、ツッコまれる前に解説しておきます。

① 数字の出力は削れる
サイコロの目だけを出力するのなら、print(str(n)+s)print(s)にするだけで7バイト減らせます。(ただしこのとき出力の1行目は空白行になります。)
しかし、こんな可読性の低いコードを書いていると、出力内容と実際の乱数値が一致しているのか不安になります。
つまりこれは、開発中のチェック用の数字です。
ひとまず開発完了した現在、省略してもいいかもしれませんが、数字とサイコロの目のダブルチェックができて個人的に捗っているので残しました。

② サイコロの図は左端に寄せるべきではないか
c[1]' O'から'O 'にすることで、出力が次のように1文字分左にずれます。

>python dice.py
6
O O
O O
O O

>python dice.py
2
  O

O
 

これを実行画面上で見てみると、サイコロの図、特に6の目が画面端に寄り過ぎていて不格好だと感じたので、あえてこうしませんでした。
残した方が空白文字で疑似的に枠ができているように見える、というのもあります。

③ 2と3の目を右下がりにした方が、配列dの数値は小さくなる
実際に計算してみると、現状の2と3の目が右上がりの場合が

d=65,468,3951,400,3505,3532

2と3の目を右下がりにすると

d=65,268,2383,400,3505,3532

で、確かに数値は小さくなりますが、桁数は減らないので軽量化の役には立ちません。
さらに出力も

>python dice.py
3
 O
  O
   O

のように、サイコロの目と数字の出力が並んでしまって見辛くなってしまうので、同サイズであることも考慮し、右上がりにしました。

以上の解説を無視して、ツッコミどころを全部変更した場合のコードが、これです。

dice_101.py
import random
n=random.randint(1,6);d=65,268,2383,400,3505,3532;s='\n';c=' ','O ',s
for i in range(8):s=s+c[d[n-1]//3**i%3]
print(s)

printの必要がなくなった変数nを削除する
変数nprintの必要がなくなったので、定義して使用・参照可能にしておく必要もありません。
今までd[n-1]として扱ってきた対象を新たにdとおき、その他の点を多少調整することで、次の通り軽量化できます。

dice_102.py
import random
d=(65,268,2383,400,3505,3532)[random.randrange(6)];s='\n';c=' ','O ',s
for i in range(8):s=s+c[d//3**i%3]
print(s)

なおdの定義式において、タプルとリストのどちらを使っても、あるいはrandom.randrange(6)random.randint(0,5)のどちらを使っても、ファイルサイズは等しく、動作も問題ありません。

⑤ 変数sも削除できる
for文の内部で、改行させないようにしたprintを実行させることで、変数sも削除できます。結果として125バイトになりました。

dice_103.py
import random
d=(65,268,2383,400,3505,3532)[random.randrange(6)];c=' ','O ','\n'
for i in range(8):print(c[d//3**i%3],end='')

ただし、出力は

>python dice_103.py
O O
O O
O O
>

のように相当余裕のないものになってしまいます。

当初のイメージ

最初は次のように、サイコロの輪郭を出力するコードにするつもりでした。

5
 _______
|       |
| O   O |
|       |
|   O   |
|       |
| O   O |
|       |
 -------

しかし、AA(アスキーアート)のように、環境によって表示が崩れてしまうことが気になりました。

出力の崩れ対策

表示が崩れるのを防ぐために、図に登場する文字の種類を減らすことを考えました。
出力されるすべての文字の幅が同じなら表示は崩れません。
また、コードゴルフをするに当たっても、参照する文字の種類が減った方が何かと得です。
そんなわけで、次のように半角スペース' 'と大文字のオー'O'の2種類のみでサイコロの図を書くことにしました。

5
OOOOOOOOO
O       O
O O   O O
O       O
O   O   O
O       O
O O   O O
O       O
OOOOOOOOO

なお、大文字のオー'O'は「表示が崩れない」「見栄えがいい」の2つの基準から私が独断と偏見で選んだ文字なので、小文字のオー'o', ゼロ'0', 大文字/小文字のエックス'X', 'x'などに、お好みで代替可能です。

最初に作ったコード

実は最初に作ったのは2進数と対応させた後のコードであり、次のコードは記事を書くにあたって初めて書いたのですが、こんな感じのイメージを持っていた、という説明として表示します。
なお、タブ文字を記事に反映できないので、以下のコードはすべて、タブ文字の代わりに半角スペース1文字をインデントに使っています。

dice_01.py
import random
n=random.randint(1,6)
print(n)
d=[
'OOOOOOOOO',
'O       O',
'O f   t O',
'O       O',
'O s o s O',
'O       O',
'O t   f O',
'O       O',
'OOOOOOOOO'
]
for l in d:
 f=t=o=s=' '
 if n%2:o='O'
 if n>1:t='O'
 if n>3:f='O'
 if n>5:s='O'
 print(l.replace('o',o).replace('t',t).replace('f',f).replace('s',s))

文字列d内の'o'nが奇数のときに、't'nが2以上のときに、'f'nが4以上のときに、's'nが6のときに、それぞれ'O'に置換され、それ以外の場合は' 'に置換されます。

これ以降のコードも、3進数を使い始めるまでは、これを別の形で実装しているだけです。

なお、これは完全に余談ですが、最終行は次のようにした方が軽量化できます。可読性が下がるので説明としては上の方がより適切ですが。

 print(l.translate(str.maketrans('otfs',o+t+f+s)))

文字列と2進数を対応させる

まずはこちらをご覧ください。

dice_02.py
import random
n=random.randint(1,6)
print(n)
d=[0]*9
d[0]=d[8]=127
if n%2:d[4]=8
if n>1:d[2]=2;d[6]=32
if n>3:d[2]=d[6]=34
if n>5:d[4]=34
for i in d:
 s='O'
 for j in range(7):
  if((i//(2**(6-j)))%2):s=s+'O'
  else:s=s+' '
 print(s+'O')

出力が9行なので、整数を9個並べたリストを作ります。
リストの各要素は7ケタの2進数と対応しており、for jループで0' 'に、1'O'に変換されます。
つまり、0=0b0000000'_______'に(7文字の' 'が記事にうまく反映されなかったので'_'で代用しています)、127=0b1111111'OOOOOOO'に変換されます。
これが、「文字列と2進数を対応させる」ということです。
変換後の文字列は、for iループの最初と最後でそれぞれ最初と最後に'O'が追加されて出力されます。

文字列と2進数を対応させる過程を詳しく見てみます。
ここでは、文字列のj文字目を7ケタの2進数のj文字目と対応させることが目標です。
7ケタの2進数のj文字目とは、7ケタの2進数の(7-j)ケタ目のことです。

2進数nのmケタ目の数字は、①nを2の(m-1)乗で割り、②余り(≒小数部分)を無視した後、③2で割った余りを取得する、という手順をたどれば取得できます。
この最初の割り算の余りを無視するときに(m-1)ケタ目以下の情報が削除され、その後に2で割った余りを取得するときに(m+1)ケタ目以上の情報が削除される仕組みです。
これを割り算の整数部分を取得する演算子'//'と剰余演算子'%'を使って表すと、
(n//(2**(m-1)))%2
となります。
目的に沿って、このniに、m7-jに変えることで、上記のコードの表記となります。

式の文字数を減らす

(i//(2**(6-j)))%2に出てくる演算子の優先順位は、**>//=%>-の順です。
これを考慮すると、次のようにカッコの大半を省略できます。
i//2**(6-j)%2

しかしそれでも、カッコが1個残ってしまうのが気になりました。
ここであえて、「文字列と『一番左のケタを1ケタ目とする』2進数を対応させる」という手段を取ることにしました。(通常の2進数は一番右のケタが1ケタ目)
こうすることで、文字列のj文字目を2進数のjケタ目と対応させることができ、式も次のようにスッキリします。
i//2**j%2
ただし、文字列と数値の対応付け方が変わったので、if文で設定する数値も変更する必要があります。
もっとも、サイコロの図は左右対称な場合が多いので、次のようなわずかな変更で済みます。

dice_03.py
import random
n=random.randint(1,6)
print(n)
d=[0]*9
d[0]=d[8]=127
if n%2:d[4]=8
if n>1:d[2]=32;d[6]=2
if n>3:d[2]=d[6]=34
if n>5:d[4]=34
for i in d:
 s='O'
 for j in range(7):
  if(i//2**j%2):s=s+'O'
  else:s=s+' '
 print(s+'O')

条件分岐を初期化に組み込む

d=[0]*9という表記があまりにかっこよかったので相当期間見逃していたのですが、if文がコードの結構なスペースを占領しています。
このため、d=[0]*9とif文の組み合わせの代わりに、多少不格好でもすべての条件分岐をdの初期化時に組み込んだ方が、軽量化する可能性は高いと推測できます。
そうして実装したのが次のコードです。実際に軽量化されています。

dice_04.py
import random
n=random.randint(1,6)
print(n)
t=n>1;f=n>3
for i in [127,0,f*2+t*32,0,n%2*8+(n>5)*34,0,t*2+f*32,0,127]:
 s='O'
 for j in range(7):
  if(i//2**j%2):s=s+'O'
  else:s=s+' '
 print(s+'O')

リストをタプルに置き換える

リストの書き換え処理が不要になったので、不変なオブジェクトであるタプルを使えます。
リストは全体を[]で囲むのに対し、タプルは通常()で囲むのですが、この()は省略できます。つまり2バイト軽量化できます。
ちなみに、次のコードが実際に動くことからもわかるように、コード上ではタプルの要素を数式で表し、実行時は数式の計算結果を初期値としてタプルに与える、という操作はタプルの不変性と矛盾せず、エラーは発生しません。

dice_05.py
import random
n=random.randint(1,6)
print(n)
t=n>1;f=n>3
for i in 127,0,f*2+t*32,0,n%2*8+(n>5)*34,0,t*2+f*32,0,127:
 s='O'
 for j in range(7):
  if(i//2**j%2):s=s+'O'
  else:s=s+' '
 print(s+'O')

chr関数を使う

2重のループを使っているとき、1行当たり2文字のインデントを使う(行頭に' 'が2文字も使われている)行があるのは、コードゴルフで見過ごせるものではありません。
できればfor jループはfor jの行ですべてを終わらせるべきです。

ここで目を付けたのが、Unicodeコードポイントを文字に変換する関数、chrです。
chr(32)=' ', chr(79)='O'なので、次のようなコードが書けます。
今までは対象のケタが0なら0、1なら1を出力していた式を、対象のケタが0なら32、1なら79を出力するように変更するというわけです。

dice_06.py
import random
n=random.randint(1,6)
print(n)
t=n>1;f=n>3
for i in 127,0,f*2+t*32,0,n%2*8+(n>5)*34,0,t*2+f*32,0,127:
 s='O'
 for j in range(7):s=s+chr(i//2**j%2*47+32)
 print(s+'O')

(失敗例)三項演算子を使う

for jループを1行で済ます別の手段として、三項演算子を使う方法があります。
この場合、上のdice_06.pyの7行目が次のようになります。

 for j in range(7):s=s+('O' if(i//2**j%2) else ' ')

見ての通り、こちらの方が文字数が多いので軽量化失敗です。
当然ながら、試行錯誤の過程では、こんなことも起こります。

コードゴルフのために輪郭を犠牲にする

「最初に作ったコード」の出力を維持するなら、dice_06.pyあたりが限界だと思います。
しかし、そんなに異常なコードになったとは思えませんでした。
正直、コンテンツとして弱い。
輪郭を省略すれば、まだ文字数を削れるのではないか。
こうして、以下のように段階的に出力を削っていきました。

上下の輪郭を飛び飛びにする

5
O O O O O
O       O
O O   O O
O       O
O   O   O
O       O
O O   O O
O       O
O O O O O

こんな感じに、上下の輪郭をOで埋めるのではなく1個飛ばしにすれば、今まで2進法で7ケタ(10進法で最大3ケタ)の数字が必要だったところ、2進法で3ケタ(10進法で1ケタ)の数字だけで済むようになります。
コードは次の通り。

dice_07.py
import random
n=random.randint(1,6);print(n);t=n>1;f=n>3
for i in 7,0,f*1+t*4,0,n%2*2+(n>5)*5,0,t*1+f*4,0,7:
 s='O '
 for j in range(3):s=s+chr(i//2**j%2*47+32)+' '
 print(s+'O')

出力範囲を小さくする

一度出力の省略を始めると、行きつくところまで行きたくなります。
次はサイコロの目の間の(実質的な)空白行・空白列を省略することにしました。
つまりこんな感じです。

>python dice_08.py
6
O OOO O
O     O
O O O O
O O O O
O O O O
O     O
O OOO O

>python dice_08.py
5
O OOO O
O     O
O O O O
O  O  O
O O O O
O     O
O OOO O

だいぶ不自然に見えますが、サイコロの目と輪郭の間の空白をなくしてしまうと、読み取りが非常に困難になってしまうので、一旦この出力を考えることにしました。
と言っても、出力がわずかに変わるだけなので、かなりマイナーチェンジです。

dice_08.py
import random
n=random.randint(1,6);print(n);t=n>1;f=n>3
for i in 7,0,f*1+t*4,n%2*2+(n>5)*5,t*1+f*4,0,7:
 s='O '
 for j in range(3):s=s+chr(i//2**j%2*47+32)
 print(s+' O')

輪郭を完全に削除する

とうとう、この境地に到達してしまいました。
とはいえ、この状態の出力は最初の出力例と同じで、こんな感じです。

5
 O O
  O
 O O

このときのコードは次の通り。

dice_09.py
import random
n=random.randint(1,6);print(n);t=n>1;f=n>3
for i in f*1+t*4,n%2*2+(n>5)*5,t*1+f*4:
 s=' '
 for j in range(3):s=s+chr(i//2**j%2*47+32)
 print(s)

これでもかなり複雑なコードに見えるでしょうが、精読してみるとchr関数内以外は割と簡単に読み解けるコードです。
少し寝かせて、より変な上にファイルサイズの小さくなる変更点はないか、考えてみることにしました。

改行文字を考慮した3進数が使えることに気付く

上記のdice_09.pyよりファイルサイズを小さくしようとすると、ネックになるのが2重のforループです。
出力予定の文字列を1行ずつに分割したするのが外側のループで、各行の文字列を出力するのが内側のループです。
これら2つのループを改めて見直して共通点を考えることで、1行ずつに分割せずに、改行文字を含めた' ', 'O', '\n'の3種の文字による3進法を使うことにより、forループを1重にできることに思い至りました。

多くの利点=同時に変更すべき点

  1. より多くの情報を持ったタプルを用意できる
    今までは「出力の行数分の情報を持ったタプルを考える」→「サイコロの目に応じてタプルの数値を初期化する」→「タプルから1行分ずつ情報を取り出す」という方式でしたが、
    改行を含む全出力をひとまとめにすることで「サイコロの目がnのときの情報をn番目の要素とするタプル」を作ることができます。
    これにより、タプルの初期化処理と情報取得の2重ループが省略できます。
    ただし、このタプルは各要素のケタ数が多くなるという点が軽量化の足かせとなります。
    結果として、全部一度に変更した後のコードと変更後のコードのサイズを比べるまで、どちらが軽いかわかりませんでした。
  2. chr関数の代わりに、3進法の0, 1, 2に対応するタプルを使うことになる
    より柔軟に文字列と数値を対応付けることができます。
  3. print関数の使用箇所を1ヶ所にできる
    これまではprint(n)print(s)の2ヶ所でしたが、改行込みの出力に出目を表す数値nを組み込むことで出力を1ヶ所に減らすことができます。
  4. ' 'と0を対応付けることで出力調整とタプルの数値削減ができる
    例えば6の目はOを6個出力しなければいけませんが、1の目は1個でいいので、for i in range(?)の形式で表記しようとすると、出力回数が単純計算で5回余ります。
    ここで' 'と0を対応付けると、' 'を空打ちさせることで出力回数を消費できます。
    同時に空打ちの分は(3進数で)0が5ケタ並ぶだけなので数値としては無視してよくなり、ケタ数の削減に繋がります。

変更時に考えたこと

まず3進法の0, 1, 2に対応するタプルを何にするのか、という点です。

0に対応するのは' 'でなければならないのは前述の通り。
改行と'O'は互いにあまり関連性なく動いているので、残りの2個のうち1個は改行、もう1個は'O'が関わるものにする必要がありました。
今回の計算方法は後で出力される文字の方が上のケタに対応しています。
ここで一番出力回数が多くなりそうな6の目などでは'O'が後で出力されるため、数値を小さくするために'O'と1を対応させることにしました。

ここで、改行と'O'の前後のどこに' 'を入れるかを考えます。
'O'が続けて出力されることがない点と、4~6の目は2つの'O'の間に' 'がある点から、'O'の前か後ろのどちらかに' 'を付けて0, 1, 2に対応するタプルへ記述した方が、forループ数とサイコロの目のタプル内の数値の削減に繋がります。
前後どちらに入れるか、改行の前後には入れないのかなどを比較検討した結果、最終形の出力が最良と結論付けました。

次に、サイコロの目のタプル内の数値を計算します。
これはただの作業です。以下のように出力したい文字列を分解した後、左から1の位、3の位、9の位……と見なして0, 1, 2に対応付けて、合計を計算します。

d[1] <- ['\n',' ',' O','\n']
d[2] <- [' ',' ',' O','\n','\n',' O']
d[3] <- [' ',' ',' O','\n',' ',' O','\n',' O']
d[4] <- [' O',' O','\n','\n',' O',' O']
d[5] <- [' O',' O','\n',' ',' O','\n',' O',' O']
d[6] <- [' O',' O','\n',' O',' O','\n',' O',' O']

ここで文字列を分解してできた上記リストの要素数がケタ数に対応し、それが最大で8なので、forループはfor i in range(8)とすれば十分であるとわかります。
これで最終形のタプルdの数値が作れました。

最終形のタプルcと出力用文字列sの初期化は、色々試した結果、次の表記が一番軽量そうだと判断しました。

s='\n';c=' ',' O',s

forループの中身は、3進法を採用する前後で次のように変化しました。(beforeの方は内側のforループのみ)

# before:
 for j in range(3):s=s+chr(i//2**j%2*47+32)
# after:
for i in range(8):s=s+c[d[n-1]//3**i%3]

かなり変化しているように見えて、「数値を取得」→「対応する進法の(ループ変数)ケタ目を計算」→「(ループ変数)ケタ目の数を文字列に変換」という手順は変わっていません。
ちなみにbeforeの2進法表記の方でタプルc=' ','O'との対応付けをしようとすると、この行は8バイト減るものの、タプル定義で10バイト増えるので、軽量化できません。

最終形

というわけで次の通り、最終形ができました。といってもこれは、最初に書いたコードの再掲です。

dice.py
import random
n=random.randint(1,6);d=65,468,3951,400,3505,3532;s='\n';c=' ',' O',s
for i in range(8):s=s+c[d[n-1]//3**i%3]
print(str(n)+s)

追記:コメント @shiracamus 様より、122バイトのワンライナー

dice_comment.py
print(n:=__import__('random').randint(1,6),'\n',*[('',*'o\n')[(65,468,3951,400,3505,3532)[n-1]//3**i%3]for i in range(8)])

感想としては、「こういうのが書きたかった……!」「いろいろな方面で理解が足りてなかった……!」の2点です。
文字列には+=が使えないものと思い込んでいましたが、そういう点も含めて理解が足りていませんでした。

改良に感謝するとともに、 before → after 形式で変更点を列挙することで解説に代えさせていただきます。
(以下の各段階で動作すること、データ量が非増加であることを確認済み)

# before01:
print(str(n)+s)
# after01:
print(n,s)

# before02:
s='\n';c=' ',' O',s
for i in range(8):s=s+c[d[n-1]//3**i%3]
print(n,s)
# after02:
c='','o','\n' # 小文字と大文字の違いは本質ではない
print(n,'\n',*[c[d[n-1]//3**i%3]for i in range(8)])

# before03:
c='','o','\n'
# after03:
c='',*'o\n'

# before04:
d=65,468,3951,400,3505,3532;c='',*'o\n'
print(n,'\n',*[c[d[n-1]//3**i%3]for i in range(8)])
# after04:
 # 行末の';'も削除
print(n,'\n',*[('',*'o\n')[(65,468,3951,400,3505,3532)[n-1]//3**i%3]for i in range(8)])

# before05:
import random
n=random.randint(1,6)
# after05:
n=__import__('random').randint(1,6)

# before06:
n=__import__('random').randint(1,6)
print(n,
# after06:
print(n:=__import__('random').randint(1,6),
1
0
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
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?