ランレングス符号化をpythonで

タイトルに反して、実はやりたかったのは符号化ではなく、下の本の最初の「帽子の問題」。

www.oreilly.co.jp

問題は、前向き/後向きバラバラに帽子を被った人たちが一列に並んでいる状況で、整理員が指示を出して全員の帽子を同じ方向になる様にする、というもの。指示は「○番から×番の人」のように範囲で指定できます。なるべく少ない回数でそれを達成するにはどうしたらよいか、というわけです。
f:id:tkuro:20220130114849p:plain
パッとみただけで「あ、これランレングスみたいなのして偶奇で判定だ」とは閃いたので、本は読まずに見切り発車始めてしまったのですが、ここで、はて、ランレングスってどう書くのがエレガントかなー、と脱線し始め、そちらの迷宮にハマってしまった、というパターンですかっこわるー(実際この本でも迂回しながらランレングス圧縮にランディングしてます。合わせてLZのような話も出ていました)。一般のランレングスの場合、処理対象はあらゆる記号、かつ、出るタイミングはランダムですが、この問題では前向き(F), 後向き(B)が必ず交互に出流ので、もっと簡単にできそうです。そこまでやらんけどなー。

まずはナイーブに…

ベースラインとして、まずは単純極まりない、Cで書いたらこうなる、みたいなのを書く、がセオリーだと思うのですが、そこは脊髄反射プログラマの私なので、最初に考えついた方法で猪突実装してしまいました。内容は 1 runを一気に消したい→lstripで最初の文字を剥がしちゃえ、でした。

def rle_strip(s):
    while s:
        c = s[0]
        striped = s.lstrip(c)
        yield((c, len(s) - len(striped)))
        s = striped

lstrip()で剥がして、元の長さから剥いだ後の長さを引いて差を出していく、という単純ながらも、あれ、もうこれで良くね? なアルゴリズムに見えます。ジェネレータイテレータとして実装したので、一個剥がすたびにyieldで次を返しています。

ついでなので単純極まりないほうも作っておきましょう(これが後からガビーンを産むことになるのですが…)

def rle_naive(s):
    prev = s[0]
    cnt = 1
    for c in s:
        if prev != c:
            yield((prev, cnt))
            cnt = 1
        else:  cnt += 1
        prev = c
    yield((prev,cnt))

同じようにジェネレータイテレータとして実装しておきます。

pythonicな、あるいは numpy的な解法

python的、というより関数言語的というかリスト処理的というか、並列にできるもんはリストで並べてしまう戦略*1
numpy使うなら結構簡単でずらしたndarray同士で比較した ndarray作って、nonzero()またはwhere()で場所を拾う、というよくある戦略で良いです。

def rle_numpy(s):
    o = _succ(s[-1])   # sentinel
    s = np.array(list(s+o))
    idxs = np.append(-1, np.nonzero(s[1:] != s[:-1])[0])
    return zip(s[idxs+1], idxs[1:] - idxs[:-1])

pythonicな、あるいはリスト内包な解法

上記numpy方式をリスト内包でできないかなーとやってみたのが次。僕のリスト内包の作り方って基本的に後ろから、「まずこれ作ってー、作ったやつでこうしてー」と思考の順番に前につなげて作ってしまう、、、

>>> list((lambda t: zip(t[1:],t))("good speed"))
[('o', 'g'), ('o', 'o'), ('d', 'o'), (' ', 'd'), ('s', ' '), ('p', 's'), ('e', 'p'), ('e', 'e'), ('d', 'e')]
>>> list((lambda t: (a!=b for a,b in zip(t[1:],t)))("good speed"))
[True, False, True, True, True, True, True, False, True]
>>> list((lambda t: compress(range(len(t)), (a!=b for a,b in zip(t[1:],t))))("good speed"))
[0, 2, 3, 4, 5, 6, 8]

という癖があるので、いつも最終的に凶悪なのが生まれてしまいます…


はい産まれましたーーーー(ムスカ大佐の目が見えなくなってしまうレベル)。

まあ清書して、

from itertools import compress
def rle_wierd(s):
    o = _succ(s[-1])   # sentinel
    return [(s[q],p-q) for p,q in
              _pairwise(compress(range(len(s)+1),
                        (a!=b for a,b in _pairwise(s+o,s[0])))
              )]

とはしたものの、numpy使わない代わりにitertools.compressは使うは、pairwiseは実は無くなっていたことに気が付かず、自前で作ることになるはでもうダメダメwww

あ、この辺で使っていた_pairwise, とか_succは以下です。

## utility routine
## (A, B, C, ..., Y, Z) -> ((0, A), (A,B), (B,C), ..., (Y,Z))
def _pairwise(seq, last = 0):
    for c in seq:
        yield(c, last)
        last = c

# Next character in ASCII
def _succ(c):
    return chr((ord(c)+1) % 255)

いやー盛大に自爆!

しかもー

ベンチとってみて氏ぬ。M1 Macbook Airでやってみました。

tkuro@carnalite ~/00PlayGround/5265da25fd296305141db600c27095d4
 % sysctl machdep.cpu.brand_string
machdep.cpu.brand_string: Apple M1
tkuro@carnalite ~/00PlayGround/5265da25fd296305141db600c27095d4
 % ./runlength_test.py
*****  --- run-length 1 (totally random)  ---  *****
 # rle_naive 0.724449584
 # rle_strip 2.3304234999999998
 # rle_numpy 2.630215708
 # rle_wierd 1.5978349170000001
-----------
*****  --- run-length 10      aaaa bbbb   ---  *****
 # rle_naive 0.33949912500000057
 # rle_strip 0.2601713329999997
 # rle_numpy 1.0721955419999993
 # rle_wierd 0.8074890840000002
-----------
*****  --- run-length 100     aaaa bbbb   ---  *****
 # rle_naive 0.2919787500000002
 # rle_strip 0.05756099999999975
 # rle_numpy 0.930163125
 # rle_wierd 0.7280477919999999
*****  --- run-length natural language    ---  *****
 # rle_naive 0.4841780419999999
 # rle_strip 2.0906731670000003
 # rle_numpy 1.9265417080000002
 # rle_wierd 1.1903997910000008

なななななななななんと、numpyが一番遅い!!!!
しかもnaiveが一番まともじゃないですか。stripはrunが長くなってくるとゲキ強いですが、それにしても何の工夫もしてない奴が一番早くって、一番苦労したwierdがあんまり報われないとか、これはひどいなー。絶倫にアウトじゃないですか。

まあでもやっていることを考えるとそれはそうか、という気もしますね。2リストを全部見ていくよりも1リスト1イテレーションの方が軽いですね。

encoder & decoder

とりあえず目的はこれじゃないんだけど、一応遊んでみるために書いておきました。-nで最小ラン長を指定できる様にしておいた。
encoder

from runlength_test import rle_naive
import argparse

parser = argparse.ArgumentParser()
parser.add_argument('-n', '--num',
        action="store", dest="num", default=1, type=int)
parser.add_argument('filename', type=str, nargs="?")
args = parser.parse_args()
n = args.num
if args.filename:
    f = open(args.filename)
else:
    import sys
    f = sys.stdin

code = rle_naive(f.read())
ret = ""
for c, l in code:
    if l >= n:
        ret += str(c) * n
        ret += chr(l-n)
    else:
        ret += c * l
print(ret)

decoder

from runlength_test import rle_naive
import argparse

parser = argparse.ArgumentParser()
parser.add_argument('-n', '--num',
        action="store", dest="num", default=1, type=int)
parser.add_argument('filename', type=str, nargs="?")
args = parser.parse_args()
if args.filename:
    f = open(args.filename)
else:
    import sys
    f = sys.stdin

text = f.read()
cnt = 0
last = ret = ""
for c in text:
    if cnt >= args.num:
        ret += last * ord(c)
        cnt = 0
    else:
        ret += c
        if last != c:
            cnt = 0
        cnt += 1
        last = c
print(ret)

Alice in worderlandのrabitholeのところを拾ってきて圧縮?してみた

tkuro@carnalite ~/00PlayGround/5265da25fd296305141db600c27095d4
 % ./encoder.py alice_down_the_rabithole.txt > alice_n1.comp
tkuro@carnalite ~/00PlayGround/5265da25fd296305141db600c27095d4
 % ./encoder.py alice_down_the_rabithole.txt -n2 > alice_n2.comp
tkuro@carnalite ~/00PlayGround/5265da25fd296305141db600c27095d4
 % ./encoder.py alice_down_the_rabithole.txt -n3 > alice_n3.comp
tkuro@carnalite ~/00PlayGround/5265da25fd296305141db600c27095d4
 % ls -l alice_*
-rw-r--r--  1 tkuro  staff  11725  2  3 00:12 alice_down_the_rabithole.txt
-rw-r--r--  1 tkuro  staff  22383  2  3 01:00 alice_n1.comp
-rw-r--r--  1 tkuro  staff  11867  2  3 01:00 alice_n2.comp
-rw-r--r--  1 tkuro  staff  11658  2  3 01:01 alice_n3.comp

あーやっぱりランをある程度伸ばさないと普通のテキストは難しいですねー。3でやっと元ファイルより小さくなった。
SRLEとかLZとか、ちょっと面倒なので今回はパス。

教訓

  • numpy的な書き方は楽しいけど、いらんことをいらんふうにやってしまうと、素朴なアルゴリズムの方が素朴なだけに速いことが多い
  • リスト内包表現は楽しいけど、(ry
  • 案外最初のひらめきが良かったりする
  • ランレングスは普通の文章には難しいややっぱ

我ながらしょうもないことばかりやっているなあ、と思いました。

今回のはここに置いておきました。
runlength_test.py · GitHub

*1:こういうのなんていうんだろう