7
3

教科書に載っていないPython定数倍高速化Tips

Last updated at Posted at 2021-08-15

0. はじめに

みなさん、こんにちは。競技プログラミングを楽しんでいますか?
私は主にAtCoderで遊んでいて、使用言語はPythonです。

Pythonといえば、近年、機械学習関連の機運が高まったことにより注目されている言語であり、AtCoderでも競技プログラミングの主要言語であるC++に次いで人気の高い言語となっています。
しかし、一方でPythonはインタープリタ言語であり、「実行速度の遅い言語の代表格」として語られることも多いです。

競技プログラミングでは問題毎に決められた実行時間制限を満たすために、計算量がオーダーレベルで改善される効率的なアルゴリズムを考えることが主なタスクとなることが多いですが、Pythonでは良いアルゴリズムを考えても、その書き方が AC (正解)と TLE (実行時間超過) の分かれ目となる場合もあります。いわゆる定数倍高速化もPythonで競技プログラミングをやる場合には重要になります。そのため、インターネットで検索するとPythonの定数倍高速化について書かれた記事もいくつか出てきます。

本記事では、数あるPython高速化Tips記事にも登場しないであろう定数倍高速化 を紹介します。
本記事を読んで高速化テクニックを身につけ、「定数倍でTLE」とはおさらばしましょう!!


※注意

この記事はネタ記事です。本記事を読んで得られる情報が全くないということはないとは思いますが、「定数倍でTLEをしないために高速化テクニックを身につけたい」という目的を達成するためにはあまり役に立たないかもしれません。このような目的のためには上で紹介したような記事を読むことをお勧めします。


本記事での実行時間の計測にはAtCoderのコードテストPython(3.8.2)を使用しています。

1. 値のswap, rotate

1つ目は、値のswap, rotateです。
多くのプログラミング言語では、2つの変数の値を交換(swap)するためには、一時的な変数を用意しそこに片方の値を保存するという手続きを記述することになります。

a = 1
b = 2
print(a, b) # 1 2

# swap
tmp = a
a = b
b = tmp

print(a, b) # 2 1

しかし、Pythonでは一時的な変数を用意することなくswapを記述できます。

a = 1
b = 2
print(a, b) # 1 2

# swap
a, b = b, a

print(a, b) # 2 1

このような変数の値の交換は2つの変数だけではなく、3つ以上の変数についても同様に行うことができます。

a = 1
b = 2
c = 3
d = 4
e = 5
print(a, b, c, d, e) # 1 2 3 4 5

# 値の交換
a, b, c, d, e = c, e, d, a, b

print(a, b, c, d, e) # 3 5 4 1 2

便利ですね!
ですが、ここに落とし穴があります。
変数の数が 2 ~ 4 個の場合について一時変数tmpを使う場合と使わない場合で実行時間を計測しました。
例えば変数が4個の場合のコードは以下の通りです。

def main():
    a, b, c, d = 0, 1, 2, 3
    for _ in range(10**7):
        # tmpなし
        a, b, c, d = b, c, d, a

        # tmpあり
        #tmp = a
        #a, b, c = b, c, d
        #d = tmp
      
if __name__ == '__main__':
    main()

結果は次の表のようになりました。

変数の数 tmpなし tmpあり
2 $255 \;\rm{ms}$ $294 \;\rm{ms}$
3 $311 \;\rm{ms}$ $334 \;\rm{ms}$
4 $534 \;\rm{ms}$ $376 \;\rm{ms}$

変数が2個、3個の時にはtmpを使わない方が速く、4個の時にはtmpを使った方が速くなりました。

なぜこのようなことが起こるのでしょうか?これを解明するためにPython標準のdisモジュールを使ってPython内部の動きを見てみます。

まずは、2変数の場合です。以下のコードを実行し、tmpなしでswapする場合の内部の挙動を可視化します。

import dis

def f(a, b):
    a, b = b, a

dis.dis(f)

これを実行すると以下のようになると思います。

4             0 LOAD_FAST                1 (b)
              2 LOAD_FAST                0 (a)
              4 ROT_TWO
              6 STORE_FAST               0 (a)
              8 STORE_FAST               1 (b)
             10 LOAD_CONST               0 (None)
             12 RETURN_VALUE

注目するのは3行目で、ROT_TWOという操作が行われています。2変数の場合に一時変数tmpを使用しない方が高速だったのはROT_TWOという、2変数のswapに適した命令コードが存在したからです。

次に3変数の場合もみていきましょう。先ほどと同様に以下のコードを実行します。

import dis

def f(a, b, c):
    a, b, c = b, c, a

dis.dis(f)

結果は次のようになります。

 4            0 LOAD_FAST                1 (b)
              2 LOAD_FAST                2 (c)
              4 LOAD_FAST                0 (a)
              6 ROT_THREE
              8 ROT_TWO
             10 STORE_FAST               0 (a)
             12 STORE_FAST               1 (b)
             14 STORE_FAST               2 (c)
             16 LOAD_CONST               0 (None)
             18 RETURN_VALUE

4、5行目でROT_THREEROT_TWOという操作が行われています。3変数の場合にも値の交換に適した命令コードが存在するので一時変数tmpを用いない方が高速に実行されたわけです。

では、4変数の場合はどうでしょうか。
以下のコードを実行します。

import dis

def f(a, b, c, d):
    a, b, c, d = b, c, d, a

dis.dis(f)

結果は次のようになります。

4             0 LOAD_FAST                1 (b)
              2 LOAD_FAST                2 (c)
              4 LOAD_FAST                3 (d)
              6 LOAD_FAST                0 (a)
              8 BUILD_TUPLE              4
             10 UNPACK_SEQUENCE          4
             12 STORE_FAST               0 (a)
             14 STORE_FAST               1 (b)
             16 STORE_FAST               2 (c)
             18 STORE_FAST               3 (d)
             20 LOAD_CONST               0 (None)
             22 RETURN_VALUE

4変数の場合には、ROT_TWOROT_THREEではなく、BUILD_TUPLEUNPACK_SEQUENCEという操作が行われます。
すなわち4変数の場合には「右辺を一度タプルにして、それをアンパックして左辺に代入する」という操作が行われています。
一方、一時変数tmpを用いた場合には

def f(a, b, c, d):
    tmp = a
    a, b, c = b, c, d
    d = tmp

で、a, b, c = b, c, dの部分は3変数の値の交換になるので先ほど見たように命令コードROT_TWOROT_THREEを用いた操作が行われます。

つまり、4変数の場合には「タプルを作成しそれをアンパックする」という操作が重いため、一時変数tmpを用いた場合より用いない場合の方が実行速度が遅くなってしまったわけです。

では、5変数以上の場合にはどうでしょうか?この場合、tmpを使っても4変数以上の値の交換になるので、3変数以下の「速い操作」が行われず、さらにtmpを用意する手間もかかるのでtmpを使わない方が速くなります。
しかし、$N$ 変数の値の交換において、tmpを使う場合には $N-1$ 変数の交換を同時に行う必要はありません。そこで $N-1$ 変数の交換を 3変数以下の交換に分割します。すると、「速い操作」が行われるので、tmpを使わない場合よりも高速に実行することができます。

実際にこの方法が上手くいくことを確認してみましょう。題材は競技プログラミングでよく現れる「10変数の交換を $10^7$ 回繰り返す」手続きです。(誰もが INF 回書いた手続きだと思います)

ベースとなるコードは以下のものです。

def main():
    a, b, c, d, e, f, g, h, i, j = list(range(10))
    for _ in range(10**7):
        # ここに交換の操作を書く


if __name__ == '__main__':
    main()

まずはtmpを使わない方法です。
交換の操作は

a, b, c, d, e, f, g, h, i, j \
    = b, c, d, e, f, g, h, i, j, a

で、実行時間は $834 \;\rm{ms}$ でした。

次に、tmpを使う方法です。
「9変数の交換」を「3変数の交換を3回」に分割します。
交換の操作は

tmp = a
a, b, c = b, c, d
d, e, f = e, f, g
g, h, i = h, i, j
j = tmp

で、実行時間は $624 \;\rm{ms}$ となり、なんと $\boldsymbol{200 \;\rm{ms}}$ もの改善に成功しました。

ちなみに、「出来るだけ2変数のswapに分割」をすると交換の操作は

tmp = a
a, b = b, c
c, d = d, e
e, f = f, g
g, h, i = h, i, j
j = tmp

で実行時間は $602 \;\rm{ms}$ となり、さらに高速化できます。

本章で分かったこと:


変数の値の同時交換は3変数までにしましょう


2. 条件式

2つ目は条件式についてです。
条件式は、一つの条件からなる単純なものから、複数の条件を複合した複雑なものまで様々なものを書きますが、本章では「3つの数値の比較」に焦点を当ててみます。

具体的には、「3つの数値a, b, cについて、bが半開区間 $[a, c)$ に含まれるかどうか」という条件式を考えます。この条件はもちろん「ba以上でかつbc未満」と等価なので多くのプログラミング言語では

a <= b and b < c

のように書くと思います。
Pythonではより直感的に

a <= b < c

と書くこともでき、非常に便利です。
ですが、これもまた落とし穴になっています。それぞれの実行時間を次のコードで計測してみます。

def main():
    a, b, c = 0, 1, 2
    for _ in range(10**7):
        a <= b and b < c
        
        #a <= b < c
        
if __name__ == '__main__':
    main()

結果は下表のようになりました。

a <= b and b < c a <= b < c
$480 \;\rm{ms}$ $501 \;\rm{ms}$

なんと $10^7$ 回の条件判定で $20 \;\rm{ms}$ もの差がつきました。
なぜこのような結果になったのか、やはりdisモジュールを使ってみていきましょう。

まずはa <= b and b < cの場合です。

import dis

def f(a, b, c):
    a <= b and b < c

dis.dis(f)

を実行すると

  4           0 LOAD_FAST                0 (a)
              2 LOAD_FAST                1 (b)
              4 COMPARE_OP               1 (<=)
              6 JUMP_IF_FALSE_OR_POP    14
              8 LOAD_FAST                1 (b)
             10 LOAD_FAST                2 (c)
             12 COMPARE_OP               0 (<)
        >>   14 POP_TOP
             16 LOAD_CONST               0 (None)
             18 RETURN_VALUE

となります。
この手続きを日本語で書いてみると以下のようになります。

  1. aへの参照を取得
  2. bへの参照を取得
  3. 直近2つの取得された値を <= で比較
  4. Falseなら指定位置(手続き8)までジャンプしTrueなら結果を破棄 (andの部分)
  5. bへの参照を取得
  6. cへの参照を取得
  7. 直近2つの取得された値を < で比較
  8. 結果を破棄(今回の場合は条件判定をするだけなので)

予想通りというか、実際にコーディングした通りの挙動をしていますね。

次はa <= b < cの場合です。

import dis

def f(a, b, c):
    a <= b < c

dis.dis(f)

を実行すると

  4           0 LOAD_FAST                0 (a)
              2 LOAD_FAST                1 (b)
              4 DUP_TOP
              6 ROT_THREE
              8 COMPARE_OP               1 (<=)
             10 JUMP_IF_FALSE_OR_POP    18
             12 LOAD_FAST                2 (c)
             14 COMPARE_OP               0 (<)
             16 JUMP_FORWARD             4 (to 22)
        >>   18 ROT_TWO
             20 POP_TOP
        >>   22 POP_TOP
             24 LOAD_CONST               0 (None)
             26 RETURN_VALUE

となります。
こちらも日本語で手続きを記述してみましょう。

  1. aへの参照を取得
  2. bへの参照を取得
  3. 直近の取得した参照(b)を複製
  4. 直近3つを順番をrotate(これで直近2つはabになる)
  5. 直近2つの取得された値を <= で比較
  6. Falseなら指定位置(手続き8)までジャンプしTrueなら結果を破棄 (andに相当)
  7. cへの参照を取得
  8. 直近2つの取得された値を < で比較
  9. 結果を破棄

a <= b and b < cの場合を比較すると、「bへの参照を取得」が1回減り、「bの複製」と「直近3つを順番をrotate」という操作が新たに加わったことがわかります。つまり、この違いが $20 \;\rm{ms}$ の差を生み出していたわけです。

$10^7$ 回で $20 \;\rm{ms}$ ということは1回あたり $2 \times 10^{-6}\;\rm{ms}$ です。
b を2回書き、間をandで繋げる」という手間をかけるだけで1回あたり $2 \times 10^{-6}\;\rm{ms}$ もの実行時間の短縮ができるわけですから、どちらの記法で書けば良いかは明白ですね!

本章で分かったこと:


条件式の変数は自分の手で複製しましょう


3. アンパック代入 with アスタリスク

最後はアスタリスク付きのアンパック代入についてです。
プログラミングをしていると、「リストの先頭の値と二番目以降を分けたい」と思うことが往々にしてあるかもしれません。そんな時にはアスタリスク付きのアンパック代入が使えます。

A = [0, 1, 2, 3]

a, *b = A

print(a) # 0
print(b) # [1, 2, 3]

より一般的には、先頭1つだけでなく、先頭と末尾からいくつかずつとそれ以外を分けることができます。

A = [0, 1, 2, 3, 4, 5, 6, 7]

a, b, *c, d, e, f = A

print(a) # 0
print(b) # 1
print(c) # [2, 3, 4]
print(d) # 5
print(e) # 6
print(f) # 7

これもまた便利な記法ですが、やはり便利な記法には落とし穴が存在します。

以下のコードで同じ結果をもたらす2つの方法を比較してみます。

def main():
    A = [0, 1, 2, 3, 4]
    for _ in range(10**7):
        a, b = A[0], A[1:]
        
        #a, *b = A
      
if __name__ == '__main__':
    main()

結果は下表のようになりました。

a, b = A[0], A[1:] a, *b = A
$921 \;\rm{ms}$ $1162 \;\rm{ms}$

これまでと同様に、なぜこうなったのか内部をみていきましょう。

まず、a, b = A[0], A[1:]の場合です。

import dis
A = [0, 1, 2, 3, 4]

def f(A):
    a, b = A[0], A[1:]

dis.dis(f)

を実行すると

  5           0 LOAD_FAST                0 (A)
              2 LOAD_CONST               1 (0)
              4 BINARY_SUBSCR
              6 LOAD_FAST                0 (A)
              8 LOAD_CONST               2 (1)
             10 LOAD_CONST               0 (None)
             12 BUILD_SLICE              2
             14 BINARY_SUBSCR
             16 ROT_TWO
             18 STORE_FAST               1 (a)
             20 STORE_FAST               2 (b)
             22 LOAD_CONST               0 (None)
             24 RETURN_VALUE

となります。
手続きを日本語にすると、

  1. Aへの参照を取得
  2. 0(aを得るためのindex)を取得
  3. 直近2つ(A, 0)からA[0]を取得
  4. Aへの参照を取得
  5. 1Noneを取得
  6. 直近2つからスライス 1:Noneを作成
  7. 直近2つ(A, 1:None)からA[1:]を取得
  8. A[0]A[1:]を適切に並び替えて abに代入

となります。

次に、a, *b = Aの場合をみていきます。

import dis
A = [0, 1, 2, 3, 4]

def f(A):
    a, *b = A

dis.dis(f)

を実行すると

  5           0 LOAD_GLOBAL              0 (A)
              2 UNPACK_EX                1
              4 STORE_FAST               0 (a)
              6 STORE_FAST               1 (b)
              8 LOAD_CONST               0 (None)
             10 RETURN_VALUE

となります。
先ほどは無かったUNPACK_EXという命令コードがあり、これが全てを行なっているようです。
つまり、このUNPACK_EXが重いため、a, *b = Aは実行速度が遅くなってしまったわけです。

では、アンパック代入を一切使用しなければ良いかというとそうでもありません。
気付いた方もいるかと思いますが、a, b = A[0], A[1:]の場合においてA[0]A[1:]を取得した後の手続きは、まさに第1章でみた「複数の値の複数の変数への代入」そのものです。つまり、4変数以上になると命令コードBUILD_TUPLEUNPACK_SEQUENCEが実行されてしまいます。
実際に試してみます。

import dis
A = [0, 1, 2, 3, 4]

def f(A):
    a, b, c, d = A[0], A[1], A[2], A[3:]

dis.dis(f)

を実行すると

  5           0 LOAD_FAST                0 (A)
              2 LOAD_CONST               1 (0)
              4 BINARY_SUBSCR
              6 LOAD_FAST                0 (A)
              8 LOAD_CONST               2 (1)
             10 BINARY_SUBSCR
             12 LOAD_FAST                0 (A)
             14 LOAD_CONST               3 (2)
             16 BINARY_SUBSCR
             18 LOAD_FAST                0 (A)
             20 LOAD_CONST               4 (3)
             22 LOAD_CONST               0 (None)
             24 BUILD_SLICE              2
             26 BINARY_SUBSCR
             28 BUILD_TUPLE              4
             30 UNPACK_SEQUENCE          4
             32 STORE_FAST               1 (a)
             34 STORE_FAST               2 (b)
             36 STORE_FAST               3 (c)
             38 STORE_FAST               4 (d)
             40 LOAD_CONST               0 (None)
             42 RETURN_VALUE

となり、2830の部分で確かにBUILD_TUPLEUNPACK_SEQUENCEが実行されていることがわかります。そのため、この方法ではA[0], A[1]...の取得 + BUILD_TUPLEUNPACK_SEQUENCEの手間がかかるので、UNPACK_EXのみで完結するa, b, c, *d = Aよりも実行速度が遅くなってしまいます。
一応、以下のコードで実行時間を計測してみます。

def main():
    A = [0, 1, 2, 3, 4]
    for _ in range(10**7):
      	a, b, c, d = A[0], A[1], A[2], A[3:]
        
        #a, b, c, *d = A
        
      
if __name__ == '__main__':
    main()

結果は下表のようになりました。

a, b, c, d = A[0], A[1], A[2], A[3:] a, b, c, *d = A
$1424 \;\rm{ms}$ $1218 \;\rm{ms}$

確かに、アンパック代入を使った方が速くなっていますね!

本章で分かったこと:


3つ以下の変数へのアスタリスク付きのアンパック代入はやめましょう


4. おわりに

いかがだったでしょうか?
最後にもう一度、ポイントをまとめておきます。

  1. 変数の値の同時交換は3変数までにしましょう
  2. 条件式の変数は自分の手で複製しましょう
  3. 3つ以下の変数へのアスタリスク付きのアンパック代入はやめましょう

どのテクニックも、「Pythonらしさ」、「快適さ」を犠牲にすることで驚くほど(わずかな)実行時間の改善ができます。

これらを駆使してぜひよい競プロライフをお過ごしください。

最後まで読んでいただきありがとうございました。おかしなところがありましたらコメント等でお教えください。

7
3
0

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
7
3