第35回シェル芸勉強会 - 解答(Python ワンライナー版)

前記事はこちら

LT 大会をリモートで見ていても暇だったので、(解ける問題だけ)最近ハマっている Python のリスト内包表記ワンライナーで解きました。
(もともと Python ワンライナー縛りでやる予定だったのですが 1 問目が Python ワンライナーだけでやるには面倒なものだったので忘れてました。)
Q1、Q4 以外を解きました。

リスト内包表記の基本

記法は [<expression> for <variable> in <iterable>] です。
<iterable> にイテレーション可能なオブジェクトを指定すると、その各要素が <variable> に格納されて <expression> の結果からリストが作られます。

$ python3 -c 'print([i**2 for i in range(5)])'
[0, 1, 4, 9, 16]
$ python3 -c '[print(i**2) for i in range(5)]'
0
1
4
9
16

[print(i**2) for i in range(5)] のように、<expression> に処理を書くと、ループのように扱えます。
Iterable なオブジェクトを順次実行できるので、実質リストである標準入力も簡単に扱えます。

リスト内包表記そのものは以下の 2 記事がわかりやすかったです。この記事ではこれ以上詳しくは説明しません。
pythonの内包表記を少し詳しく - Qiita
リスト内包表記の活用と悪用 - Qiita

Python ワンライナーで使うときの基本の基本

そもそもなぜリスト内包表記を使うかというと、Python ワンライナーで便利だからです。
ワンライナーPython を使うには python -c 'script' で script 部分を指定しますが、ループを使うためにリスト内包表記が必要です。(表現力は map と変わらないのでそちらでもいいのですが、個人的にリスト内包表記のほうが好きだし早いらしいです。)

Python ワンライナー+リスト内包表記で標準入力を扱う

Python 3 の標準入力といえば input() ですが、input() は 1 行しか扱えません。([i for i in input()]すると、Python では文字列はリストのように扱えるので、i には 1 行目の各文字が入ります。)
input() で複数行を得るには↓のようにする必要があります。

$ seq 10 | python3 -c '[print(input()) for i in range(3)]'
1
2
3

このやり方では、実行結果を見てもわかるように、読み込み行数を range() に固定で与える必要があります。
[2018/04/11 追記]
ジェネレーター式・iter()any()を使えば無限ループができることがわかったので、以下のようにすれば input() で、すべての標準入力が得られます。

$ seq 3 | python3 -c 'any(print(i) for i in (input() for null in iter(int,1)))' 2> /dev/null
1
2
3

ただし注意点が 1 つあって、標準入力がなくなって input() が例外を投げて終了するので、Python の終了コードはエラー (1) になります。
[2018/04/11 追記終わり]

標準入力からすべての行を得るには↓のように、<iterable>を sys.stdin にします。

$ seq 3 | python3 -c 'import sys; [print(int(i)) for i in sys.stdin]'
1
2
3

改行が入るので int にキャストしていますが、処理(キャストするなど)を挟めば改行は消えるのでだいたい気にしなくていいです。

余談ですが、個人的には CSV の処理が楽です。↓ は一列目の切り出しです。

$ echo $'a,b\n1,2' | python3 -c 'import sys,csv;[print(i[0]) for i in csv.reader(sys.stdin)]'
a
1

改行の入っている要素のある CSV の処理に良く使っています。

問題と解答

Q2

次のようなファイルheroheroがあります。

$ cat herohero 
1へ
7ろ
9へ
13ろ

ひらがなを左側に書いてある数字の行に持って行き、次のような出力に変換してください。

へ





ろ

へ



ろ
A2
nkf -Z herohero | sed 's#[0-9]*#& #' | python3 -c 'import sys;d={int(i.split()[0])-1:i.split()[1] for i in sys.stdin};[print(d[i]) if i in d else print() for i in range(max(d.keys())+1)]'

https://twitter.com/nogiro_iota/status/982563903669338118

解説

全角から半角の変換は Python ではめんどくさいので、前処理はシェル芸でします。

$ nkf -Z herohero | sed 's#[0-9]*#& #'
1 へ
7 ろ
9 へ
13 ろ

ディクショナリ内包表記(リスト内包表記の辞書版) d={int(i.split()[0])-1:i.split()[1] for i in sys.stdin} で前処理済データを変数に格納します。

その後、[print(d[i]) if i in d else print() for i in range(max(d.keys())+1)] で、数字の最大数 (max(d.keys())+1)) 回数ループして、3 項演算子 (ifTrue if expression else ifFalse) で入力と空白の処理を分けています。
(記事を書いていて気づきましたが print(d[i]) if i in d else print()print(d[i] if i in d else "") に書き直せますね。)

感想

FizzBuzz ちっくに解けました。

Q3

次のようなファイルdataがあります。

$ cat data
1 A
1 B
2 C
2 C
1 B
3 C
4 C
3 B
3 B
3 D
3 B
1 B
2 A
1 A
2 C

集計して次のような出力を得てください。

1 A:2 B:3
2 A:1 C:3
3 B:3 C:1 D:1
4 C:1
A3
cat data | python3 -c 'import collections,sys;d={};[d[i2[0]].append(i2[1]) if i2[0] in d.keys() else d.update({i2[0]:[i2[1]]}) for i2 in [tuple(i.split()) for i in sys.stdin]];[print(i,dict(collections.Counter(d[i]))) for i in d.keys()]' | sort

https://twitter.com/nogiro_iota/status/982573747440791552

解説

collections.Counter を使うと、リスト内の要素の数を数えることができます。

$ python3 -c 'import collections;a=[int(i**1.3)%5 for i in range(10)];print(a);print(collections.Counter(a))'
[0, 1, 2, 4, 1, 3, 0, 2, 4, 2]
Counter({2: 3, 0: 2, 1: 2, 4: 2, 3: 1})

この出力を整形しています(中途半端ですが)。

感想

collections.Counter 知らなかったんですが、解くためにググって新しいことが知れたのが良かった。

Q5

echo 響け!ユーフォニアム からはじめて、次のような出力を得てください。なお、出題者はこのアニメを見たことがありません。

響け!ユーフォニアム
 響け!ユォニアム
  響け!ニアム
   響けアム
    響ム
     
     
    ム響
   ムアけ響
  ムアニ!け響
 ムアニォユ!け響
ムアニォフーユ!け響
A5
echo 響け!ユーフォニアム | python3 -c '[(lambda k:[print(l[::j]) for l in k])(s[::j]) for s in [[" "*i+x[:int(len(x)/2)-i]+x[int(len(x)/2)+i:]+" "*i for x in [input()] for i in range(int(len(x)/2)+1)]] for j in [1,-1]]'

https://twitter.com/nogiro_iota/status/982577064900349954

解説

入力は for x in [input()] で行っています。これは、リスト内包表記内で変数を代入する方法で、今考えると別に外出しで x=input(); すれば良かったです。

前半の三角部分は Python のスライスで " "*i+x[:int(len(x)/2)-i]+x[int(len(x)/2)+i:]+" "*i すると作ることができます。
さらに Python のスライスは、ステップ数を指定することができ、-1 を指定すると逆順になるので、前半部分を2次元リストにしておくと後半部分は簡単に作成できます。

感想

ステップ数を [1,-1] で与えることに気づいたときは「この問題は Python ワンライナーのためにあったのでは???」という気分になりました。

ひらけ!ポンキッキ」はこんな感じになりますが、たしかに中央が謎です。

$ echo ひらけ!ポンキッキ | python3 -c '[(lambda k:[print(l[::j]) for l in k])(s[::j]) for s in [[" "*i+x[:int(len(x)/2)-i]+x[int(len(x)/2)+i:]+" "*i for x in [input()] for i in range(int(len(x)/2)+1)]] for j in [1,-1]]'
ひらけ!ポンキッキ
 ひらけンキッキ 
  ひらキッキ  
   ひッキ   
    キ    
    キ    
   キッひ   
  キッキらひ  
 キッキンけらひ 
キッキンポ!けらひ

Q6

素因数分解したときに23より大きい素因数を持たない自然数を1985個抽出してください。

A6
sudo pip3 install sympy
python3 -c 'import sympy;[print(y[0]) for y in [j for j in filter(lambda x:23>=max(x[1].keys()),[(i,sympy.factorint(i)) for i in range(2,21000)])][:1985]]'

https://twitter.com/nogiro_iota/status/982582064024186881

解説

まず sympy をインストールします。(シェル芸勉強会は環境構築から)

$ sudo pip3 install sympy

sympy.factorint() が factor 的をしてくれるので、そこから作ります。(戻り値が素因数がキーで値がその数になってるのがちょっと違います。)
(必要な量がわかっていたので)21000 までの素数すべてを filter しています。

感想

素数ジェネレーターないからめんどくさいかと思ったらあったりしました。
https://twitter.com/nogiro_iota/status/982577420107628545
https://twitter.com/nogiro_iota/status/982579286136709120

Q7

素数番目の文字を抽出すると意味のある語句になっているような文字列を作成してください。例を示します。(素数番目でない文字は特に凝る必要はありません。同じ文字でも大丈夫です。)

うそすんうんだいうんいんすんこうき

その後、その語句を抽出してください。

A7
echo 響け!ユーフォニアム | python3 -c 'import sympy;x=input();p=[i for i in range(2,int(5*len(x)**1.1)) if list(sympy.factorint(i).values())==[1]][:len(x)];print("".join([x[p.index(i)] if i in p else "あ" for i in range(1,1+max(p))]))'

https://twitter.com/nogiro_iota/status/982590950940655616

解説

最終的に必要になる文字数がわからなかったので、↓で調べてました。

yes '' | nl -ba -nln | factor | awk 'NF==2{print $2}' | awk '{print $0/NR}'
yes '' | nl -ba -nln | factor | awk 'NF==2{print $2}' | awk '{print $0/(NR**1.09)}'

1行目だと単調増加して、2 行目だと単調減少したので、 とりあえず「定数*入力文字数^1.1」を用意しておけば足りそうです。(定数は 5 でした。)

入力文字列以上の長さの素数のリストを用意して、「定数*入力文字数^1.1」の回数ループして 3 項演算子で A2 同様に解きます。
入力文字のインデックスと素数リストのインデックスは対応しているので、3 項演算子で入力を出力するときはそれで引っ張ってきます。

感想

解説のしにくさがすごい。

Q8

Q6の方法で作成した自然数をファイルaに保存し、この中から4つ数字を選んで掛け算したとき、その値がある自然数の4乗になっている組み合わせを1個以上探してください。

A8.1
cat a | python3 -c 'import re,math,functools,sys,itertools;[print("*".join(map(str,list(j)))) for j in itertools.combinations([int(i) for i in sys.stdin],4) if re.sub("\.0+$","",str(math.sqrt(math.sqrt(functools.reduce(lambda a,b:a*b,j,1))))).isdigit()]'

https://twitter.com/nogiro_iota/status/982556088376614913

解説

itertools.combinations() で 4 つの組み合わせをすべて列挙できるので、掛け算して 4 乗根が整数かどうかで判定します。

A8.2
cat a | python3 -c 'import math,sys,itertools;[print("*".join(map(str,list(j)))) for f in [lambda x:x==float(int(x))] for j in itertools.combinations([int(i) for i in sys.stdin],4) if f(math.sqrt(math.sqrt(j[0]*j[1]*j[2]*j[3])))]'

https://twitter.com/nogiro_iota/status/982852367249846273

解説

A8.1 から整数判定の処理を変更したり、掛け算の仕方を変えたりしています。

感想

4 乗根が整数かどうかで判定していますが sympy.factorint() も使えますね。import が増えると Twitter に載せられなくなりますが。。。